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,1467 +0,0 @@
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 yaml = require('js-yaml');
9
- const Database = require('better-sqlite3');
10
-
11
- const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
12
- const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
13
-
14
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
15
- const SERVER = path.join(REPO_ROOT, 'mcp-server', 'server.cjs');
16
- const PACKAGE_VERSION = require(path.join(REPO_ROOT, 'package.json')).version;
17
- const { initStateDb, closeStateDb } = require('../lib/state-db.cjs');
18
- const { dispatchTool } = require('../server.cjs');
19
- const changes = require('../lib/change-workflow.cjs');
20
- const ops = require('../lib/state-ops.cjs');
21
-
22
- function tmpProject() {
23
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-mcp-'));
24
- return { dir, dbPath: path.join(dir, '.ultra', 'state.db') };
25
- }
26
-
27
- function seedReadyBaseline(project) {
28
- const { db } = initStateDb(project.dbPath);
29
- db.prepare(
30
- `INSERT OR IGNORE INTO baselines
31
- (id, project_name, mode, status, approved_by, approval_note, converged_at)
32
- VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'test fixture', ?)`,
33
- ).run(new Date().toISOString());
34
- closeStateDb(db);
35
- }
36
-
37
- function seedBaseline(project, { mode, status }) {
38
- const { db } = initStateDb(project.dbPath);
39
- db.prepare(
40
- `INSERT OR REPLACE INTO baselines
41
- (id, project_name, mode, status)
42
- VALUES ('test-baseline', 'fixture', ?, ?)`,
43
- ).run(mode, status);
44
- closeStateDb(db);
45
- }
46
-
47
- async function withClient({ dir, dbPath }, fn, extraEnv = {}) {
48
- const transport = new StdioClientTransport({
49
- command: process.execPath,
50
- args: [SERVER],
51
- env: {
52
- ...process.env,
53
- UBP_DB_PATH: dbPath,
54
- UBP_ROOT_DIR: dir,
55
- ...extraEnv,
56
- },
57
- stderr: 'pipe',
58
- });
59
- const client = new Client({ name: 'ubp-test', version: '0.0.0' }, { capabilities: {} });
60
- await client.connect(transport);
61
- try {
62
- await fn(client);
63
- } finally {
64
- await client.close();
65
- }
66
- }
67
-
68
- async function withClientNoLlmKey({ dir, dbPath }, fn) {
69
- const env = { ...process.env, UBP_DB_PATH: dbPath, UBP_ROOT_DIR: dir };
70
- delete env.ANTHROPIC_API_KEY;
71
- delete env.OPENAI_API_KEY;
72
- const transport = new StdioClientTransport({
73
- command: process.execPath,
74
- args: [SERVER],
75
- env,
76
- stderr: 'pipe',
77
- });
78
- const client = new Client({ name: 'ubp-test', version: '0.0.0' }, { capabilities: {} });
79
- await client.connect(transport);
80
- try { await fn(client); } finally { await client.close(); }
81
- }
82
-
83
- function readToolPayload(result) {
84
- if (result.structuredContent) return result.structuredContent;
85
- const text = result.content[0].text;
86
- return JSON.parse(text);
87
- }
88
-
89
- function expectError(result) {
90
- assert.equal(result.isError, true, 'expected isError result');
91
- return JSON.parse(result.content[0].text).error;
92
- }
93
-
94
- function prdTasks(taskId) {
95
- return [{
96
- id: taskId,
97
- title: `Implement ${taskId}`,
98
- type: 'feature',
99
- priority: 'P1',
100
- deps: [],
101
- files_modified: [`src/${taskId}.js`],
102
- }];
103
- }
104
-
105
- test('listTools returns workflow tools and exposes no Ultra memory API', async () => {
106
- const proj = tmpProject();
107
- try {
108
- await withClient(proj, async (client) => {
109
- const list = await client.listTools();
110
- const names = list.tools.map((t) => t.name).sort();
111
- assert.deepEqual(names, [
112
- 'baseline.converge',
113
- 'baseline.get',
114
- 'baseline.record',
115
- 'baseline.start',
116
- 'change.archive',
117
- 'change.breadcrumb',
118
- 'change.context',
119
- 'change.converge',
120
- 'change.create',
121
- 'change.get',
122
- 'change.learning_propose',
123
- 'change.learning_resolve',
124
- 'change.list',
125
- 'change.update',
126
- 'plan.export',
127
- 'plan.get',
128
- 'session.admission_check',
129
- 'session.close',
130
- 'session.get',
131
- 'session.heartbeat',
132
- 'session.list',
133
- 'session.spawn',
134
- 'session.subscribe_events',
135
- 'system.doctor',
136
- 'task.append_event',
137
- 'task.create',
138
- 'task.delete',
139
- 'task.dependency_topo',
140
- 'task.expand',
141
- 'task.get',
142
- 'task.init_project',
143
- 'task.list',
144
- 'task.parse_prd',
145
- 'task.subscribe_events',
146
- 'task.switch_tag',
147
- 'task.update',
148
- ]);
149
- assert.ok(!names.some((name) => name.startsWith('memory.')));
150
- for (const t of list.tools) {
151
- assert.equal(typeof t.inputSchema, 'object');
152
- assert.equal(t.inputSchema.type, 'object');
153
- }
154
- });
155
- } finally {
156
- fs.rmSync(proj.dir, { recursive: true, force: true });
157
- }
158
- });
159
-
160
- test('baseline MCP tools adopt and converge an existing checkout without storing provider payloads', async () => {
161
- const proj = tmpProject();
162
- try {
163
- fs.mkdirSync(path.join(proj.dir, '.ultra', 'specs'), { recursive: true });
164
- fs.mkdirSync(path.join(proj.dir, 'src'), { recursive: true });
165
- fs.writeFileSync(path.join(proj.dir, '.ultra', 'specs', 'product.md'), '# Product\n\nObserved behavior.\n');
166
- fs.writeFileSync(path.join(proj.dir, '.ultra', 'specs', 'architecture.md'), '# Architecture\n\nCurrent boundary.\n');
167
- fs.writeFileSync(path.join(proj.dir, 'src', 'index.js'), 'module.exports = true;\n');
168
- require('node:child_process').execFileSync('git', ['init', '-q'], { cwd: proj.dir });
169
- require('node:child_process').execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: proj.dir });
170
- require('node:child_process').execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: proj.dir });
171
- require('node:child_process').execFileSync('git', ['add', '.'], { cwd: proj.dir });
172
- require('node:child_process').execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: proj.dir });
173
- const revision = require('node:child_process').execFileSync(
174
- 'git', ['rev-parse', 'HEAD'], { cwd: proj.dir, encoding: 'utf8' },
175
- ).trim();
176
-
177
- await withClient(proj, async (client) => {
178
- const started = readToolPayload(await client.callTool({
179
- name: 'baseline.start',
180
- arguments: {
181
- id: 'mcp-baseline', project_name: 'fixture', mode: 'brownfield',
182
- repository_revision: revision, scope: ['.'],
183
- },
184
- }));
185
- assert.equal(started.baseline.status, 'adopting');
186
-
187
- const recorded = readToolPayload(await client.callTool({
188
- name: 'baseline.record',
189
- arguments: {
190
- id: 'mcp-baseline', repository_revision: revision,
191
- spec_refs: [
192
- { kind: 'product', path: '.ultra/specs/product.md' },
193
- { kind: 'architecture', path: '.ultra/specs/architecture.md' },
194
- ],
195
- evidence: [{ kind: 'source', ref: 'src/index.js', summary: 'Current entry point.' }],
196
- verification: [{
197
- name: 'smoke', command: 'node -e "require(\'./src\')"', status: 'pass', evidence: 'exit 0',
198
- }],
199
- unknowns: [],
200
- provider_refs: {
201
- code_graph: { provider: 'codebase-memory-mcp', project: 'fixture', status: 'fresh' },
202
- },
203
- },
204
- }));
205
- assert.match(recorded.baseline.spec_refs[0].digest, /^[0-9a-f]{64}$/);
206
-
207
- const converged = readToolPayload(await client.callTool({
208
- name: 'baseline.converge',
209
- arguments: {
210
- id: 'mcp-baseline', expected_revision: revision, approved_by: 'owner',
211
- approval_note: 'The baseline matches the current checkout.', accept_known_red: false,
212
- },
213
- }));
214
- assert.equal(converged.ready, true);
215
- assert.equal(converged.baseline.status, 'ready');
216
-
217
- const got = readToolPayload(await client.callTool({
218
- name: 'baseline.get', arguments: {},
219
- }));
220
- assert.equal(got.baseline.id, 'mcp-baseline');
221
- assert.equal(JSON.stringify(got.baseline).includes('captured prompt'), false);
222
- });
223
- } finally {
224
- fs.rmSync(proj.dir, { recursive: true, force: true });
225
- }
226
- });
227
-
228
- test('change.create + change.context expose a continuous change unit with external provider references', async () => {
229
- const proj = tmpProject();
230
- try {
231
- seedReadyBaseline(proj);
232
- fs.writeFileSync(path.join(proj.dir, 'README.md'), '# project\n');
233
- await withClient(proj, async (client) => {
234
- const created = await client.callTool({
235
- name: 'change.create',
236
- arguments: {
237
- id: 'mcp-change', title: 'Continuous maintenance', kind: 'quick',
238
- intent: 'Keep daily modifications synchronized after initial delivery.',
239
- docs_impact: { status: 'none', files: [], rationale: 'Contract-only fixture.' },
240
- provider_refs: {
241
- memory: { provider: 'cloud-mem', status: 'available', reference: 'cmem://fixture' },
242
- code_graph: { provider: 'codebase-memory-mcp', status: 'stale', project: 'fixture' },
243
- },
244
- },
245
- });
246
- const payload = readToolPayload(created);
247
- assert.equal(payload.change.id, 'mcp-change');
248
- assert.equal(payload.change.status, 'active');
249
- assert.equal(created._meta.ultra.state_commit, 'committed');
250
- assert.equal(created._meta.ultra.projection_status, 'completed');
251
-
252
- const context = readToolPayload(await client.callTool({
253
- name: 'change.context', arguments: { id: 'mcp-change' },
254
- }));
255
- assert.equal(context.manifest.change.id, 'mcp-change');
256
- assert.equal(context.manifest.schema_version, '2.0');
257
- assert.equal(context.manifest.role, 'plan');
258
- assert.equal(context.manifest.readiness.status, 'ready');
259
- assert.equal(context.manifest.providers.memory.provider, 'cloud-mem');
260
- assert.equal(context.manifest.provider_boundary.includes('content remain external'), true);
261
-
262
- const breadcrumb = readToolPayload(await client.callTool({
263
- name: 'change.breadcrumb', arguments: { id: 'mcp-change' },
264
- }));
265
- assert.equal(breadcrumb.breadcrumb.change_id, 'mcp-change');
266
- assert.equal(breadcrumb.breadcrumb.recommended_workflow, 'ultra-change');
267
- assert.match(breadcrumb.breadcrumb.context_manifest_hash, /^[0-9a-f]{64}$/);
268
-
269
- const listed = readToolPayload(await client.callTool({
270
- name: 'change.list', arguments: { status: 'active' },
271
- }));
272
- assert.equal(listed.count, 1);
273
- assert.equal(listed.changes[0].id, 'mcp-change');
274
- });
275
- } finally {
276
- fs.rmSync(proj.dir, { recursive: true, force: true });
277
- }
278
- });
279
-
280
- test('spec-learning MCP tools persist an approval-gated candidate and projection', async () => {
281
- const proj = tmpProject();
282
- try {
283
- seedReadyBaseline(proj);
284
- await withClient(proj, async (client) => {
285
- await client.callTool({
286
- name: 'change.create',
287
- arguments: {
288
- id: 'learning-change', title: 'Learn stable contract', kind: 'quick',
289
- intent: 'Persist only approved specification discoveries.',
290
- docs_impact: { status: 'none', files: [], rationale: 'Contract fixture.' },
291
- },
292
- });
293
- await client.callTool({
294
- name: 'task.create',
295
- arguments: {
296
- id: 'learning-task', title: 'Discover contract', type: 'feature', priority: 'P1',
297
- change_id: 'learning-change',
298
- },
299
- });
300
- const proposed = readToolPayload(await client.callTool({
301
- name: 'change.learning_propose',
302
- arguments: {
303
- id: 'learning-contract', change_id: 'learning-change', task_id: 'learning-task',
304
- target_ref: 'README.md#contract', summary: 'Context compilation fails closed on stale refs.',
305
- evidence: ['node --test context-spine.test.cjs'],
306
- },
307
- }));
308
- assert.equal(proposed.candidate.status, 'proposed');
309
- const withLearning = readToolPayload(await client.callTool({
310
- name: 'change.get', arguments: { id: 'learning-change' },
311
- }));
312
- assert.deepEqual(
313
- withLearning.change.learning_candidates.map((candidate) => candidate.id),
314
- ['learning-contract'],
315
- );
316
-
317
- const approved = readToolPayload(await client.callTool({
318
- name: 'change.learning_resolve',
319
- arguments: {
320
- change_id: 'learning-change', candidate_id: 'learning-contract', decision: 'approve',
321
- resolution: 'Stable behavior contract.',
322
- },
323
- }));
324
- assert.equal(approved.candidate.status, 'approved');
325
- const applied = readToolPayload(await client.callTool({
326
- name: 'change.learning_resolve',
327
- arguments: {
328
- change_id: 'learning-change', candidate_id: 'learning-contract', decision: 'apply',
329
- resolution: 'Applied to README.md#contract.',
330
- },
331
- }));
332
- assert.equal(applied.candidate.status, 'applied');
333
- assert.ok(fs.existsSync(path.join(
334
- proj.dir, '.ultra', 'changes', 'active', 'learning-change', 'spec-learning.json',
335
- )));
336
- });
337
- } finally {
338
- fs.rmSync(proj.dir, { recursive: true, force: true });
339
- }
340
- });
341
-
342
- test('system.doctor returns structured provider ownership and projection health', async () => {
343
- const proj = tmpProject();
344
- try {
345
- seedReadyBaseline(proj);
346
- await withClient(proj, async (client) => {
347
- await client.callTool({
348
- name: 'task.create',
349
- arguments: { id: 'doctor-seed', title: 'doctor seed', type: 'feature', priority: 'P1' },
350
- });
351
- const result = readToolPayload(await client.callTool({
352
- name: 'system.doctor', arguments: { repair: false },
353
- }));
354
- assert.equal(result.status, 'healthy');
355
- assert.equal(result.checks.external_providers.ownership, 'external');
356
- assert.equal(result.repair_performed, false);
357
- });
358
- } finally {
359
- fs.rmSync(proj.dir, { recursive: true, force: true });
360
- }
361
- });
362
-
363
- test('MCP metadata matches the package and tools/list does not create project state', async () => {
364
- const proj = tmpProject();
365
- try {
366
- await withClient(proj, async (client) => {
367
- assert.equal(client.getServerVersion().version, PACKAGE_VERSION);
368
- await client.listTools();
369
- assert.equal(fs.existsSync(proj.dbPath), false, 'tool discovery must not create .ultra/state.db');
370
- });
371
- } finally {
372
- fs.rmSync(proj.dir, { recursive: true, force: true });
373
- }
374
- });
375
-
376
- test('published MCP contract contains exactly the live server tools', () => {
377
- const manifest = yaml.load(fs.readFileSync(path.join(REPO_ROOT, 'spec', 'mcp-tools.yaml'), 'utf8'));
378
- const declared = manifest.tools.map((tool) => tool.name).sort();
379
- const { REGISTERED_TOOLS } = require(SERVER);
380
- assert.deepEqual(declared, [...REGISTERED_TOOLS].sort());
381
- });
382
-
383
- test('task.create + task.get round trip via MCP', async () => {
384
- const proj = tmpProject();
385
- try {
386
- seedReadyBaseline(proj);
387
- await withClient(proj, async (client) => {
388
- const created = await client.callTool({
389
- name: 'task.create',
390
- arguments: { id: 'mcp-1', title: 'first', type: 'feature', priority: 'P1', estimated_days: 2.5 },
391
- });
392
- const createdData = readToolPayload(created);
393
- assert.equal(createdData.id, 'mcp-1');
394
- assert.equal(createdData.status, 'pending');
395
-
396
- const got = await client.callTool({ name: 'task.get', arguments: { id: 'mcp-1' } });
397
- const gotData = readToolPayload(got);
398
- assert.equal(gotData.task.id, 'mcp-1');
399
- assert.equal(gotData.task.title, 'first');
400
- assert.equal(gotData.task.estimated_days, 2.5);
401
- });
402
- } finally {
403
- fs.rmSync(proj.dir, { recursive: true, force: true });
404
- }
405
- });
406
-
407
- test('task.create requires ready baseline authority or an already-authorized active change', async () => {
408
- for (const baseline of [
409
- null,
410
- { mode: 'greenfield', status: 'draft' },
411
- { mode: 'migrated', status: 'adopting' },
412
- ]) {
413
- const proj = tmpProject();
414
- try {
415
- if (baseline) seedBaseline(proj, baseline);
416
- await withClient(proj, async (client) => {
417
- const result = await client.callTool({
418
- name: 'task.create',
419
- arguments: {
420
- id: `blocked-${baseline?.mode || 'missing'}`,
421
- title: 'Must wait for baseline readiness', type: 'feature', priority: 'P1',
422
- },
423
- });
424
- assert.equal(expectError(result).code, 'BASELINE_NOT_READY');
425
- });
426
- } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
427
- }
428
-
429
- const incidentProject = tmpProject();
430
- try {
431
- seedBaseline(incidentProject, { mode: 'brownfield', status: 'adopting' });
432
- await withClient(incidentProject, async (client) => {
433
- const change = await client.callTool({
434
- name: 'change.create',
435
- arguments: {
436
- id: 'incident-task-bypass', title: 'Restore production', kind: 'incident',
437
- intent: 'Repair the urgent production path while adoption remains incomplete.',
438
- baseline_bypass: {
439
- reason: 'Production recovery cannot wait for baseline convergence.',
440
- approved_by: 'incident-commander',
441
- },
442
- },
443
- });
444
- assert.equal(readToolPayload(change).change.id, 'incident-task-bypass');
445
- const task = await client.callTool({
446
- name: 'task.create',
447
- arguments: {
448
- id: 'authorized-incident-task', title: 'Repair the production path',
449
- type: 'bugfix', priority: 'P0', change_id: 'incident-task-bypass',
450
- },
451
- });
452
- assert.equal(readToolPayload(task).id, 'authorized-incident-task');
453
- });
454
- } finally { fs.rmSync(incidentProject.dir, { recursive: true, force: true }); }
455
- });
456
-
457
- test('task.create enforces terminal-change and parent ownership invariants at the MCP boundary', async () => {
458
- const proj = tmpProject();
459
- try {
460
- seedReadyBaseline(proj);
461
- const initialized = initStateDb(proj.dbPath);
462
- for (const [id, status] of [
463
- ['parent-change', 'active'],
464
- ['other-change', 'active'],
465
- ['terminal-change', 'archived'],
466
- ]) {
467
- initialized.db.prepare(
468
- `INSERT INTO changes (id, title, kind, status, intent, artifact_root)
469
- VALUES (?, ?, 'quick', ?, ?, ?)`,
470
- ).run(id, id, status, `Intent for ${id}.`, `.ultra/changes/active/${id}`);
471
- }
472
- closeStateDb(initialized.db);
473
-
474
- await withClient(proj, async (client) => {
475
- const parent = await client.callTool({
476
- name: 'task.create',
477
- arguments: {
478
- id: 'owned-parent', title: 'Owned parent task', type: 'feature', priority: 'P1',
479
- change_id: 'parent-change',
480
- },
481
- });
482
- assert.equal(parent.isError, undefined);
483
-
484
- const child = await client.callTool({
485
- name: 'task.create',
486
- arguments: {
487
- id: 'owned-child', title: 'Owned child task', type: 'feature', priority: 'P1',
488
- parent_id: 'owned-parent',
489
- },
490
- });
491
- assert.equal(child.isError, undefined);
492
- const inherited = readToolPayload(await client.callTool({
493
- name: 'task.get', arguments: { id: 'owned-child' },
494
- }));
495
- assert.equal(inherited.task.change_id, 'parent-change');
496
-
497
- const mismatch = await client.callTool({
498
- name: 'task.create',
499
- arguments: {
500
- id: 'cross-change-child', title: 'Cross change child', type: 'feature', priority: 'P1',
501
- parent_id: 'owned-parent', change_id: 'other-change',
502
- },
503
- });
504
- assert.equal(expectError(mismatch).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
505
-
506
- const orphan = await client.callTool({
507
- name: 'task.create',
508
- arguments: {
509
- id: 'orphan-child', title: 'Orphan child task', type: 'feature', priority: 'P1',
510
- parent_id: 'missing-parent',
511
- },
512
- });
513
- assert.equal(expectError(orphan).code, 'TASK_NOT_FOUND');
514
-
515
- const terminal = await client.callTool({
516
- name: 'task.create',
517
- arguments: {
518
- id: 'terminal-task', title: 'Terminal task', type: 'feature', priority: 'P1',
519
- change_id: 'terminal-change',
520
- },
521
- });
522
- assert.equal(expectError(terminal).code, 'CHANGE_NOT_MUTABLE');
523
-
524
- const missing = await client.callTool({
525
- name: 'task.create',
526
- arguments: {
527
- id: 'missing-change-task', title: 'Missing change task', type: 'feature', priority: 'P1',
528
- change_id: 'missing-change',
529
- },
530
- });
531
- assert.equal(expectError(missing).code, 'CHANGE_NOT_FOUND');
532
- });
533
- } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
534
- });
535
-
536
- test('task.parse_prd keeps dry runs read-only and blocks persistence before baseline readiness', async () => {
537
- const dryProject = tmpProject();
538
- try {
539
- const { db } = initStateDb(dryProject.dbPath);
540
- try {
541
- const result = await dispatchTool('task.parse_prd', {
542
- tasks: prdTasks('dry-run-task'), dry_run: true,
543
- }, db, { rootDir: dryProject.dir });
544
- assert.equal(result.tasks[0].id, 'dry-run-task');
545
- assert.equal(ops.listTasks(db, {}).length, 0);
546
- } finally { closeStateDb(db); }
547
- } finally { fs.rmSync(dryProject.dir, { recursive: true, force: true }); }
548
-
549
- for (const baseline of [
550
- null,
551
- { mode: 'greenfield', status: 'draft' },
552
- { mode: 'migrated', status: 'adopting' },
553
- ]) {
554
- const proj = tmpProject();
555
- try {
556
- if (baseline) seedBaseline(proj, baseline);
557
- const { db } = initStateDb(proj.dbPath);
558
- try {
559
- await assert.rejects(
560
- dispatchTool('task.parse_prd', {
561
- tasks: prdTasks(`blocked-prd-${baseline?.mode || 'missing'}`), dry_run: false,
562
- }, db, { rootDir: proj.dir }),
563
- (error) => error.code === 'BASELINE_NOT_READY',
564
- );
565
- assert.equal(ops.listTasks(db, {}).length, 0);
566
- } finally { closeStateDb(db); }
567
- } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
568
- }
569
- });
570
-
571
- test('task.parse_prd persists only under ready or already-authorized change ownership', async () => {
572
- const readyProject = tmpProject();
573
- try {
574
- seedReadyBaseline(readyProject);
575
- const { db } = initStateDb(readyProject.dbPath);
576
- try {
577
- await dispatchTool('task.parse_prd', {
578
- tasks: prdTasks('ready-prd-task'), dry_run: false,
579
- }, db, { rootDir: readyProject.dir });
580
- assert.equal(ops.readTask(db, 'ready-prd-task').change_id, null);
581
- } finally { closeStateDb(db); }
582
- } finally { fs.rmSync(readyProject.dir, { recursive: true, force: true }); }
583
-
584
- for (const kind of ['quick', 'incident']) {
585
- const proj = tmpProject();
586
- try {
587
- seedReadyBaseline(proj);
588
- const { db } = initStateDb(proj.dbPath);
589
- try {
590
- if (kind === 'incident') {
591
- db.prepare(
592
- "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
593
- ).run();
594
- }
595
- const changeId = `parsed-${kind}`;
596
- changes.createChange(db, {
597
- id: changeId,
598
- title: kind === 'incident' ? 'Recover parsed incident work' : 'Continue parsed ordinary work',
599
- kind,
600
- intent: 'Keep parsed tasks bound to the already-authorized continuous change.',
601
- ...(kind === 'incident' ? {
602
- baseline_bypass: {
603
- approved_by: 'incident-commander',
604
- reason: 'Production recovery requires an approved task graph before adoption converges.',
605
- },
606
- } : {}),
607
- }, { rootDir: proj.dir });
608
- if (kind === 'quick') {
609
- db.prepare(
610
- "UPDATE baselines SET mode = 'brownfield', status = 'adopting' WHERE id = 'test-baseline'",
611
- ).run();
612
- }
613
- const taskId = `parsed-${kind}-task`;
614
- await dispatchTool('task.parse_prd', {
615
- tasks: prdTasks(taskId), dry_run: false, change_id: changeId,
616
- }, db, { rootDir: proj.dir });
617
- assert.equal(ops.readTask(db, taskId).change_id, changeId);
618
- } finally { closeStateDb(db); }
619
- } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
620
- }
621
- });
622
-
623
- test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets an empty state.db', async () => {
624
- const proj = tmpProject();
625
- try {
626
- const tasksDir = path.join(proj.dir, '.ultra', 'tasks');
627
- fs.mkdirSync(tasksDir, { recursive: true });
628
- fs.writeFileSync(path.join(tasksDir, 'tasks.json'), JSON.stringify({
629
- version: '4.4',
630
- tasks: [{
631
- id: 'legacy-1', title: 'legacy task', type: 'feature', priority: 'P1',
632
- status: 'pending', dependencies: [], estimated_days: 1,
633
- context_file: 'contexts/task-legacy-1.md',
634
- }],
635
- }));
636
-
637
- await withClient(proj, async (client) => {
638
- const listed = await client.callTool({ name: 'task.list', arguments: {} });
639
- const error = expectError(listed);
640
- assert.equal(error.code, 'LEGACY_STATE_MIGRATION_REQUIRED');
641
- assert.match(error.message, /ultra-tools migrate --from=4\.4 --to=4\.5/);
642
- assert.equal(error.details.legacy_task_count, 1);
643
-
644
- const created = await client.callTool({
645
- name: 'task.create',
646
- arguments: { title: 'must not overwrite legacy', type: 'feature', priority: 'P1' },
647
- });
648
- assert.equal(expectError(created).code, 'LEGACY_STATE_MIGRATION_REQUIRED');
649
- });
650
- } finally {
651
- fs.rmSync(proj.dir, { recursive: true, force: true });
652
- }
653
- });
654
-
655
- test('state-backed MCP tools return the supported v4.5 projection migration command', async () => {
656
- const proj = tmpProject();
657
- try {
658
- const tasksDir = path.join(proj.dir, '.ultra', 'tasks');
659
- fs.mkdirSync(tasksDir, { recursive: true });
660
- fs.writeFileSync(path.join(tasksDir, 'tasks.json'), JSON.stringify({
661
- schema_version: '4.5', source: '.ultra/state.db',
662
- tasks: [{
663
- id: 'projection-only', title: 'projection-only task', type: 'feature', priority: 'P1',
664
- status: 'pending', deps: [], estimated_days: 1,
665
- }],
666
- }));
667
-
668
- await withClient(proj, async (client) => {
669
- const listed = await client.callTool({ name: 'task.list', arguments: {} });
670
- const error = expectError(listed);
671
- assert.equal(error.code, 'LEGACY_STATE_MIGRATION_REQUIRED');
672
- assert.match(error.message, /ultra-tools migrate --from=4\.5 --to=12\.0/);
673
- assert.equal(error.details.projection_version, '4.5');
674
- });
675
- } finally {
676
- fs.rmSync(proj.dir, { recursive: true, force: true });
677
- }
678
- });
679
-
680
- test('task.update enforces the status state machine', async () => {
681
- const proj = tmpProject();
682
- try {
683
- seedReadyBaseline(proj);
684
- await withClient(proj, async (client) => {
685
- await client.callTool({
686
- name: 'task.create',
687
- arguments: { id: 'sm-1', title: 'state-machine task', type: 'feature', priority: 'P0' },
688
- });
689
- const updated = await client.callTool({
690
- name: 'task.update',
691
- arguments: { id: 'sm-1', patch: { status: 'in_progress' } },
692
- });
693
- assert.equal(readToolPayload(updated).task.status, 'in_progress');
694
-
695
- const completed = await client.callTool({
696
- name: 'task.update',
697
- arguments: { id: 'sm-1', patch: { status: 'completed' } },
698
- });
699
- assert.equal(readToolPayload(completed).task.status, 'completed');
700
-
701
- const illegal = await client.callTool({
702
- name: 'task.update',
703
- arguments: { id: 'sm-1', patch: { status: 'pending' } },
704
- });
705
- const err = expectError(illegal);
706
- assert.equal(err.code, 'ILLEGAL_STATUS_TRANSITION');
707
- });
708
- } finally {
709
- fs.rmSync(proj.dir, { recursive: true, force: true });
710
- }
711
- });
712
-
713
- test('task.update preserves established change ownership and freezes terminal change tasks', async () => {
714
- const proj = tmpProject();
715
- try {
716
- seedReadyBaseline(proj);
717
- const initialized = initStateDb(proj.dbPath);
718
- try {
719
- for (const id of ['update-change-a', 'update-change-b', 'update-terminal']) {
720
- initialized.db.prepare(
721
- `INSERT INTO changes (id, title, kind, status, intent, artifact_root)
722
- VALUES (?, ?, 'quick', 'active', ?, ?)`,
723
- ).run(id, id, `Intent for ${id}.`, `.ultra/changes/active/${id}`);
724
- }
725
- ops.createTask(initialized.db, {
726
- id: 'update-owned-root', title: 'Owned root task', type: 'feature', priority: 'P1',
727
- change_id: 'update-change-a',
728
- });
729
- ops.createTask(initialized.db, {
730
- id: 'update-terminal-task', title: 'Terminal change task', type: 'feature', priority: 'P1',
731
- change_id: 'update-terminal',
732
- });
733
- initialized.db.prepare("UPDATE changes SET status = 'archived' WHERE id = 'update-terminal'").run();
734
- } finally { closeStateDb(initialized.db); }
735
-
736
- await withClient(proj, async (client) => {
737
- const detach = await client.callTool({
738
- name: 'task.update', arguments: { id: 'update-owned-root', patch: { change_id: null } },
739
- });
740
- assert.equal(expectError(detach).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
741
-
742
- const cross = await client.callTool({
743
- name: 'task.update',
744
- arguments: { id: 'update-owned-root', patch: { change_id: 'update-change-b' } },
745
- });
746
- assert.equal(expectError(cross).code, 'TASK_CHANGE_OWNERSHIP_MISMATCH');
747
-
748
- const terminal = await client.callTool({
749
- name: 'task.update', arguments: { id: 'update-terminal-task', patch: { priority: 'P0' } },
750
- });
751
- assert.equal(expectError(terminal).code, 'CHANGE_NOT_MUTABLE');
752
-
753
- await client.callTool({
754
- name: 'task.create',
755
- arguments: { id: 'update-unowned', title: 'Unowned task', type: 'feature', priority: 'P1' },
756
- });
757
- const firstBinding = readToolPayload(await client.callTool({
758
- name: 'task.update',
759
- arguments: { id: 'update-unowned', patch: { change_id: 'update-change-b' } },
760
- }));
761
- assert.equal(firstBinding.task.change_id, 'update-change-b');
762
- });
763
- } finally { fs.rmSync(proj.dir, { recursive: true, force: true }); }
764
- });
765
-
766
- test('task.append_event + subscribe_events drive a monotonic cursor', async () => {
767
- const proj = tmpProject();
768
- try {
769
- seedReadyBaseline(proj);
770
- await withClient(proj, async (client) => {
771
- await client.callTool({
772
- name: 'task.create',
773
- arguments: { id: 'sub-1', title: 'subscribe target', type: 'feature', priority: 'P1' },
774
- });
775
- // task.create already emitted event_id=1 (task_created); add 4 more.
776
- for (let i = 0; i < 4; i++) {
777
- await client.callTool({
778
- name: 'task.append_event',
779
- arguments: { type: 'task_started', task_id: 'sub-1', payload: { i } },
780
- });
781
- }
782
-
783
- const first = readToolPayload(await client.callTool({
784
- name: 'task.subscribe_events',
785
- arguments: { since_id: 0, limit: 3 },
786
- }));
787
- assert.equal(first.events.length, 3);
788
- assert.equal(first.events[0].id, 1);
789
- assert.equal(first.next_since_id, 3);
790
-
791
- const tail = readToolPayload(await client.callTool({
792
- name: 'task.subscribe_events',
793
- arguments: { since_id: first.next_since_id, limit: 100 },
794
- }));
795
- assert.equal(tail.events.length, 2);
796
- assert.equal(tail.events[0].id, 4);
797
- assert.equal(tail.next_since_id, 5);
798
- });
799
- } finally {
800
- fs.rmSync(proj.dir, { recursive: true, force: true });
801
- }
802
- });
803
-
804
- test('task.list filters by status and tag', async () => {
805
- const proj = tmpProject();
806
- try {
807
- seedReadyBaseline(proj);
808
- await withClient(proj, async (client) => {
809
- await client.callTool({ name: 'task.create', arguments: { id: 'l-1', title: 'list one', type: 'feature', priority: 'P0', tag: 'main' } });
810
- await client.callTool({ name: 'task.create', arguments: { id: 'l-2', title: 'list two', type: 'feature', priority: 'P1', tag: 'main' } });
811
- await client.callTool({ name: 'task.create', arguments: { id: 'l-3', title: 'list three', type: 'feature', priority: 'P2', tag: 'feat-x' } });
812
- await client.callTool({ name: 'task.update', arguments: { id: 'l-2', patch: { status: 'in_progress' } } });
813
-
814
- const inProg = readToolPayload(await client.callTool({
815
- name: 'task.list', arguments: { status: 'in_progress' },
816
- }));
817
- assert.equal(inProg.count, 1);
818
- assert.equal(inProg.tasks[0].id, 'l-2');
819
-
820
- const onMain = readToolPayload(await client.callTool({
821
- name: 'task.list', arguments: { tag: 'main' },
822
- }));
823
- assert.equal(onMain.count, 2);
824
- });
825
- } finally {
826
- fs.rmSync(proj.dir, { recursive: true, force: true });
827
- }
828
- });
829
-
830
- test('task.create rejects bad input via the JSON Schema validator', async () => {
831
- const proj = tmpProject();
832
- try {
833
- await withClient(proj, async (client) => {
834
- const bad = await client.callTool({
835
- name: 'task.create',
836
- arguments: { title: 'no priority field', type: 'feature' },
837
- });
838
- const err = expectError(bad);
839
- assert.equal(err.code, 'VALIDATION_ERROR');
840
- });
841
- } finally {
842
- fs.rmSync(proj.dir, { recursive: true, force: true });
843
- }
844
- });
845
-
846
- test('mutating tools trigger the projector — tasks.json appears under .ultra/', async () => {
847
- const proj = tmpProject();
848
- try {
849
- seedReadyBaseline(proj);
850
- await withClient(proj, async (client) => {
851
- await client.callTool({
852
- name: 'task.create',
853
- arguments: { id: 'pj-1', title: 'projection wired', type: 'feature', priority: 'P1' },
854
- });
855
- });
856
- const tasksJson = path.join(proj.dir, '.ultra', 'tasks', 'tasks.json');
857
- assert.ok(fs.existsSync(tasksJson), 'projector should have written tasks.json');
858
- const data = JSON.parse(fs.readFileSync(tasksJson, 'utf8'));
859
- assert.equal(data.schema_version, '4.5');
860
- assert.equal(data.tasks[0].id, 'pj-1');
861
- } finally {
862
- fs.rmSync(proj.dir, { recursive: true, force: true });
863
- }
864
- });
865
-
866
- test('output schema drift cannot strand a committed mutation without a projection job', async () => {
867
- const proj = tmpProject();
868
- try {
869
- seedReadyBaseline(proj);
870
- const runtimeRoot = path.join(proj.dir, 'runtime-contract');
871
- fs.mkdirSync(path.join(runtimeRoot, 'spec', 'schemas'), { recursive: true });
872
- fs.copyFileSync(path.join(REPO_ROOT, 'package.json'), path.join(runtimeRoot, 'package.json'));
873
- fs.copyFileSync(
874
- path.join(REPO_ROOT, 'spec', 'schemas', 'state-db.sql'),
875
- path.join(runtimeRoot, 'spec', 'schemas', 'state-db.sql'),
876
- );
877
- const manifest = yaml.load(
878
- fs.readFileSync(path.join(REPO_ROOT, 'spec', 'mcp-tools.yaml'), 'utf8'),
879
- );
880
- const createTool = manifest.tools.find((tool) => tool.name === 'task.create');
881
- createTool.output_schema.required = ['impossible_output_field'];
882
- createTool.output_schema.properties.impossible_output_field = { type: 'string' };
883
- fs.writeFileSync(
884
- path.join(runtimeRoot, 'spec', 'mcp-tools.yaml'),
885
- yaml.dump(manifest, { noRefs: true, lineWidth: 120 }),
886
- );
887
-
888
- await withClient(proj, async (client) => {
889
- const result = await client.callTool({
890
- name: 'task.create',
891
- arguments: { id: 'schema-drift', title: 'schema drift', type: 'feature', priority: 'P1' },
892
- });
893
- const error = expectError(result);
894
- assert.equal(error.code, 'OUTPUT_SCHEMA_DRIFT');
895
- assert.equal(error.details._ultra.state_commit, 'committed');
896
- assert.equal(error.details._ultra.projection_status, 'completed');
897
- }, { UBP_RUNTIME_ROOT: runtimeRoot });
898
-
899
- const db = new Database(proj.dbPath, { readonly: true, fileMustExist: true });
900
- try {
901
- assert.equal(db.prepare('SELECT COUNT(*) AS count FROM tasks WHERE id = ?').get('schema-drift').count, 1);
902
- const job = db.prepare(
903
- "SELECT status FROM projection_jobs WHERE tool_name = 'task.create' ORDER BY id DESC LIMIT 1",
904
- ).get();
905
- assert.deepEqual(job, { status: 'completed' });
906
- } finally {
907
- db.close();
908
- }
909
- assert.ok(fs.existsSync(path.join(proj.dir, '.ultra', 'tasks', 'tasks.json')));
910
- } finally {
911
- fs.rmSync(proj.dir, { recursive: true, force: true });
912
- }
913
- });
914
-
915
- test('task.init_project creates .ultra/ skeleton in a fresh target directory', async () => {
916
- const proj = tmpProject();
917
- const freshTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-target-'));
918
- try {
919
- await withClient(proj, async (client) => {
920
- const res = await client.callTool({
921
- name: 'task.init_project',
922
- arguments: { target_dir: freshTarget, project_name: 'mcp-init', project_type: 'cli' },
923
- });
924
- const payload = readToolPayload(res);
925
- assert.equal(payload.status, 'created');
926
- assert.equal(payload.created_path, path.join(freshTarget, '.ultra'));
927
- assert.equal(payload.state_db_path, path.join(freshTarget, '.ultra', 'state.db'));
928
- assert.equal(payload.mode, 'greenfield');
929
- assert.equal(payload.baseline.status, 'draft');
930
- assert.ok(fs.existsSync(payload.state_db_path));
931
- assert.ok(payload.copied_files.includes('tasks/tasks.json'));
932
- const tasksJson = JSON.parse(fs.readFileSync(path.join(payload.created_path, 'tasks', 'tasks.json'), 'utf8'));
933
- assert.equal(tasksJson.source, '.ultra/state.db');
934
- assert.deepEqual(tasksJson.tasks, []);
935
- const state = new Database(payload.state_db_path, { readonly: true });
936
- try {
937
- assert.deepEqual(
938
- state.prepare('SELECT project_name, project_type, mode FROM baselines').get(),
939
- { project_name: 'mcp-init', project_type: 'cli', mode: 'greenfield' },
940
- );
941
- } finally {
942
- state.close();
943
- }
944
- });
945
- } finally {
946
- fs.rmSync(proj.dir, { recursive: true, force: true });
947
- fs.rmSync(freshTarget, { recursive: true, force: true });
948
- }
949
- });
950
-
951
- test('task.init_project returns ULTRA_DIR_EXISTS on re-init without overwrite', async () => {
952
- const proj = tmpProject();
953
- const freshTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-target-'));
954
- try {
955
- await withClient(proj, async (client) => {
956
- await client.callTool({
957
- name: 'task.init_project',
958
- arguments: { target_dir: freshTarget, project_name: 'once' },
959
- });
960
- const second = await client.callTool({
961
- name: 'task.init_project',
962
- arguments: { target_dir: freshTarget, project_name: 'twice' },
963
- });
964
- const err = expectError(second);
965
- assert.equal(err.code, 'ULTRA_DIR_EXISTS');
966
- });
967
- } finally {
968
- fs.rmSync(proj.dir, { recursive: true, force: true });
969
- fs.rmSync(freshTarget, { recursive: true, force: true });
970
- }
971
- });
972
-
973
- test('task.init_project resume preserves existing files and installs missing current assets', async () => {
974
- const proj = tmpProject();
975
- const target = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-resume-'));
976
- try {
977
- fs.mkdirSync(path.join(target, '.ultra', 'specs'), { recursive: true });
978
- fs.writeFileSync(path.join(target, '.ultra', 'specs', 'product.md'), '# Preserved contract\n');
979
- await withClient(proj, async (client) => {
980
- const result = await client.callTool({
981
- name: 'task.init_project',
982
- arguments: {
983
- target_dir: target, project_name: 'resume-project', mode: 'brownfield', resume: true,
984
- },
985
- });
986
- const payload = readToolPayload(result);
987
- assert.equal(payload.status, 'resumed');
988
- assert.equal(payload.baseline.status, 'adopting');
989
- assert.equal(
990
- fs.readFileSync(path.join(target, '.ultra', 'specs', 'product.md'), 'utf8'),
991
- '# Preserved contract\n',
992
- );
993
- assert.ok(payload.copied_files.includes('specs/architecture.md'));
994
- });
995
- } finally {
996
- fs.rmSync(proj.dir, { recursive: true, force: true });
997
- fs.rmSync(target, { recursive: true, force: true });
998
- }
999
- });
1000
-
1001
- async function seedTask(client, id = 's-1') {
1002
- await client.callTool({
1003
- name: 'task.create',
1004
- arguments: { id, title: 'session target', type: 'feature', priority: 'P1' },
1005
- });
1006
- }
1007
-
1008
- test('session.admission_check + session.spawn: happy path returns sid and paths', async () => {
1009
- const proj = tmpProject();
1010
- try {
1011
- seedReadyBaseline(proj);
1012
- await withClient(proj, async (client) => {
1013
- await seedTask(client, 's-happy');
1014
-
1015
- const admission = await client.callTool({
1016
- name: 'session.admission_check',
1017
- arguments: { task_id: 's-happy' },
1018
- });
1019
- const verdict = readToolPayload(admission);
1020
- assert.equal(verdict.can_spawn, true);
1021
- assert.equal(verdict.recommended_action, 'spawn');
1022
-
1023
- const spawn = await client.callTool({
1024
- name: 'session.spawn',
1025
- arguments: { task_id: 's-happy', runtime: 'claude' },
1026
- });
1027
- const session = readToolPayload(spawn);
1028
- assert.match(session.sid, /^sess-/);
1029
- assert.ok(session.worktree_path.includes(session.sid));
1030
- assert.ok(session.artifact_dir.endsWith(path.join('.ultra', 'sessions', session.sid)));
1031
- assert.ok(session.lease_expires_at);
1032
- });
1033
- } finally {
1034
- fs.rmSync(proj.dir, { recursive: true, force: true });
1035
- }
1036
- });
1037
-
1038
- test('session.spawn rejects a retired runtime at the MCP schema boundary', async () => {
1039
- const proj = tmpProject();
1040
- const retiredRuntime = ['gem', 'ini'].join('');
1041
- try {
1042
- await withClient(proj, async (client) => {
1043
- await seedTask(client, 's-runtime-guard');
1044
- const spawn = await client.callTool({
1045
- name: 'session.spawn',
1046
- arguments: { task_id: 's-runtime-guard', runtime: retiredRuntime },
1047
- });
1048
- assert.equal(expectError(spawn).code, 'VALIDATION_ERROR');
1049
- });
1050
- } finally {
1051
- fs.rmSync(proj.dir, { recursive: true, force: true });
1052
- }
1053
- });
1054
-
1055
- test('session.spawn refuses second session for same task without takeover (ADMISSION_DENIED)', async () => {
1056
- const proj = tmpProject();
1057
- try {
1058
- seedReadyBaseline(proj);
1059
- await withClient(proj, async (client) => {
1060
- await seedTask(client, 's-conflict');
1061
- await client.callTool({
1062
- name: 'session.spawn',
1063
- arguments: { task_id: 's-conflict', runtime: 'claude' },
1064
- });
1065
-
1066
- const admissionAgain = await client.callTool({
1067
- name: 'session.admission_check',
1068
- arguments: { task_id: 's-conflict' },
1069
- });
1070
- const verdict = readToolPayload(admissionAgain);
1071
- assert.equal(verdict.can_spawn, false);
1072
- assert.ok(verdict.conflict);
1073
-
1074
- const second = await client.callTool({
1075
- name: 'session.spawn',
1076
- arguments: { task_id: 's-conflict', runtime: 'opencode' },
1077
- });
1078
- const err = expectError(second);
1079
- assert.equal(err.code, 'ADMISSION_DENIED');
1080
- });
1081
- } finally {
1082
- fs.rmSync(proj.dir, { recursive: true, force: true });
1083
- }
1084
- });
1085
-
1086
- test('session.spawn with takeover=true crashes the old session and succeeds', async () => {
1087
- const proj = tmpProject();
1088
- try {
1089
- seedReadyBaseline(proj);
1090
- await withClient(proj, async (client) => {
1091
- await seedTask(client, 's-takeover');
1092
- const first = readToolPayload(await client.callTool({
1093
- name: 'session.spawn',
1094
- arguments: { task_id: 's-takeover', runtime: 'claude' },
1095
- }));
1096
-
1097
- const second = readToolPayload(await client.callTool({
1098
- name: 'session.spawn',
1099
- arguments: { task_id: 's-takeover', runtime: 'codex', takeover: true },
1100
- }));
1101
- assert.notEqual(second.sid, first.sid);
1102
-
1103
- const firstAfter = readToolPayload(await client.callTool({
1104
- name: 'session.get', arguments: { sid: first.sid },
1105
- }));
1106
- assert.equal(firstAfter.session.status, 'crashed');
1107
- });
1108
- } finally {
1109
- fs.rmSync(proj.dir, { recursive: true, force: true });
1110
- }
1111
- });
1112
-
1113
- test('session.subscribe_events sees task events with ≤1s latency (D31 id cursor)', async () => {
1114
- const proj = tmpProject();
1115
- try {
1116
- seedReadyBaseline(proj);
1117
- await withClient(proj, async (client) => {
1118
- await seedTask(client, 's-sub');
1119
-
1120
- // pin cursor before any writes
1121
- const before = readToolPayload(await client.callTool({
1122
- name: 'session.subscribe_events',
1123
- arguments: { since_id: 0 },
1124
- }));
1125
- const cursor = before.next_since_id;
1126
-
1127
- const t0 = Date.now();
1128
- await client.callTool({
1129
- name: 'task.update',
1130
- arguments: { id: 's-sub', patch: { status: 'in_progress' } },
1131
- });
1132
-
1133
- const after = readToolPayload(await client.callTool({
1134
- name: 'session.subscribe_events',
1135
- arguments: { since_id: cursor },
1136
- }));
1137
- const elapsedMs = Date.now() - t0;
1138
- assert.ok(after.events.length >= 1, 'expected at least one new event');
1139
- const types = after.events.map((e) => e.type);
1140
- const sawTransition = types.some((t) => t === 'task_started' || t === 'task_status_changed');
1141
- assert.ok(sawTransition, `got event types ${JSON.stringify(types)}`);
1142
- assert.ok(elapsedMs < 1000, `latency ${elapsedMs}ms exceeds 1s budget`);
1143
- });
1144
- } finally {
1145
- fs.rmSync(proj.dir, { recursive: true, force: true });
1146
- }
1147
- });
1148
-
1149
- test('session.subscribe_events filters events by sid', async () => {
1150
- const proj = tmpProject();
1151
- try {
1152
- seedReadyBaseline(proj);
1153
- await withClient(proj, async (client) => {
1154
- await seedTask(client, 's-filter-a');
1155
- await seedTask(client, 's-filter-b');
1156
- const sessionA = readToolPayload(await client.callTool({
1157
- name: 'session.spawn',
1158
- arguments: { task_id: 's-filter-a', runtime: 'claude' },
1159
- }));
1160
- const sessionB = readToolPayload(await client.callTool({
1161
- name: 'session.spawn',
1162
- arguments: { task_id: 's-filter-b', runtime: 'codex' },
1163
- }));
1164
- const before = readToolPayload(await client.callTool({
1165
- name: 'session.subscribe_events',
1166
- arguments: { since_id: 0 },
1167
- }));
1168
-
1169
- await client.callTool({
1170
- name: 'task.append_event',
1171
- arguments: { type: 'context_updated', task_id: 's-filter-a', session_id: sessionA.sid },
1172
- });
1173
- await client.callTool({
1174
- name: 'task.append_event',
1175
- arguments: { type: 'context_updated', task_id: 's-filter-b', session_id: sessionB.sid },
1176
- });
1177
-
1178
- const filtered = readToolPayload(await client.callTool({
1179
- name: 'session.subscribe_events',
1180
- arguments: { since_id: before.next_since_id, sid: sessionA.sid },
1181
- }));
1182
- assert.equal(filtered.events.length, 1);
1183
- assert.equal(filtered.events[0].session_id, sessionA.sid);
1184
- });
1185
- } finally {
1186
- fs.rmSync(proj.dir, { recursive: true, force: true });
1187
- }
1188
- });
1189
-
1190
- test('MCP rejects declared inputs that have no runtime behavior', async () => {
1191
- const proj = tmpProject();
1192
- try {
1193
- await withClient(proj, async (client) => {
1194
- await seedTask(client, 'unused-inputs');
1195
- const session = readToolPayload(await client.callTool({
1196
- name: 'session.spawn',
1197
- arguments: { task_id: 'unused-inputs', runtime: 'claude' },
1198
- }));
1199
-
1200
- const manual = await client.callTool({
1201
- name: 'task.expand',
1202
- arguments: { id: 'unused-inputs', strategy: 'manual' },
1203
- });
1204
- assert.equal(expectError(manual).code, 'VALIDATION_ERROR');
1205
-
1206
- const notes = await client.callTool({
1207
- name: 'session.close',
1208
- arguments: { sid: session.sid, status: 'completed', notes: 'ignored before contract fix' },
1209
- });
1210
- assert.equal(expectError(notes).code, 'VALIDATION_ERROR');
1211
-
1212
- const tag = await client.callTool({
1213
- name: 'plan.get',
1214
- arguments: { tag: 'ignored-before-contract-fix' },
1215
- });
1216
- assert.equal(expectError(tag).code, 'VALIDATION_ERROR');
1217
- });
1218
- } finally {
1219
- fs.rmSync(proj.dir, { recursive: true, force: true });
1220
- }
1221
- });
1222
-
1223
- test('session.heartbeat refreshes lease; session.close marks completed', async () => {
1224
- const proj = tmpProject();
1225
- try {
1226
- seedReadyBaseline(proj);
1227
- await withClient(proj, async (client) => {
1228
- await seedTask(client, 's-heart');
1229
- const spawn = readToolPayload(await client.callTool({
1230
- name: 'session.spawn',
1231
- arguments: { task_id: 's-heart', runtime: 'claude' },
1232
- }));
1233
- const firstLease = spawn.lease_expires_at;
1234
-
1235
- // heartbeat extends the lease
1236
- await new Promise((r) => setTimeout(r, 5));
1237
- const hb = readToolPayload(await client.callTool({
1238
- name: 'session.heartbeat', arguments: { sid: spawn.sid },
1239
- }));
1240
- assert.equal(hb.ok, true);
1241
- assert.ok(Date.parse(hb.lease_expires_at) >= Date.parse(firstLease));
1242
-
1243
- // close flips status
1244
- const closed = readToolPayload(await client.callTool({
1245
- name: 'session.close',
1246
- arguments: { sid: spawn.sid, status: 'completed' },
1247
- }));
1248
- assert.equal(closed.ok, true);
1249
-
1250
- const got = readToolPayload(await client.callTool({
1251
- name: 'session.get', arguments: { sid: spawn.sid },
1252
- }));
1253
- assert.equal(got.session.status, 'completed');
1254
- });
1255
- } finally {
1256
- fs.rmSync(proj.dir, { recursive: true, force: true });
1257
- }
1258
- });
1259
-
1260
- test('retired memory tools are rejected as unknown', async () => {
1261
- const proj = tmpProject();
1262
- try {
1263
- await withClient(proj, async (client) => {
1264
- const result = await client.callTool({
1265
- name: 'memory.retain',
1266
- arguments: { kind: 'decision', content: 'must not be retained' },
1267
- });
1268
- assert.equal(expectError(result).code, 'UNKNOWN_TOOL');
1269
- });
1270
- } finally {
1271
- fs.rmSync(proj.dir, { recursive: true, force: true });
1272
- }
1273
- });
1274
-
1275
- test('task.dependency_topo: happy path groups tasks into correct waves', async () => {
1276
- const proj = tmpProject();
1277
- try {
1278
- seedReadyBaseline(proj);
1279
- await withClient(proj, async (client) => {
1280
- for (const [id, deps] of [['A', []], ['B', ['A']], ['C', ['A']]]) {
1281
- await client.callTool({
1282
- name: 'task.create',
1283
- arguments: { id, title: `task ${id}`, type: 'feature', priority: 'P2', deps },
1284
- });
1285
- }
1286
- const resp = await client.callTool({
1287
- name: 'task.dependency_topo',
1288
- arguments: { task_ids: ['A', 'B', 'C'] },
1289
- });
1290
- const data = readToolPayload(resp);
1291
- assert.equal(data.waves.length, 2);
1292
- assert.deepEqual(new Set(data.waves[0]), new Set(['A']));
1293
- assert.deepEqual(new Set(data.waves[1]), new Set(['B', 'C']));
1294
- });
1295
- } finally {
1296
- fs.rmSync(proj.dir, { recursive: true, force: true });
1297
- }
1298
- });
1299
-
1300
- test('task.parse_prd: missing host-derived tasks → validation error', async () => {
1301
- const proj = tmpProject();
1302
- try {
1303
- await withClient(proj, async (client) => {
1304
- const resp = await client.callTool({ name: 'task.parse_prd', arguments: {} });
1305
- const err = expectError(resp);
1306
- assert.equal(err.code, 'VALIDATION_ERROR');
1307
- });
1308
- } finally {
1309
- fs.rmSync(proj.dir, { recursive: true, force: true });
1310
- }
1311
- });
1312
-
1313
- test('task.parse_prd works without provider credentials', async () => {
1314
- const proj = tmpProject();
1315
- try {
1316
- await withClientNoLlmKey(proj, async (client) => {
1317
- const resp = await client.callTool({
1318
- name: 'task.parse_prd',
1319
- arguments: { tasks: prdTasks('no-key-prd'), dry_run: true },
1320
- });
1321
- const data = readToolPayload(resp);
1322
- assert.equal(data.tasks[0].id, 'no-key-prd');
1323
- });
1324
- } finally {
1325
- fs.rmSync(proj.dir, { recursive: true, force: true });
1326
- }
1327
- });
1328
-
1329
- test('task.expand: unknown parent → TASK_NOT_FOUND without a provider key', async () => {
1330
- const proj = tmpProject();
1331
- try {
1332
- await withClientNoLlmKey(proj, async (client) => {
1333
- const resp = await client.callTool({
1334
- name: 'task.expand',
1335
- arguments: {
1336
- id: 'nonexistent-parent',
1337
- children: [{
1338
- id: 'unused-child', title: 'Unused child task',
1339
- type: 'feature', priority: 'P1',
1340
- }],
1341
- },
1342
- });
1343
- const err = expectError(resp);
1344
- assert.equal(err.code, 'TASK_NOT_FOUND');
1345
- });
1346
- } finally {
1347
- fs.rmSync(proj.dir, { recursive: true, force: true });
1348
- }
1349
- });
1350
-
1351
- test('task.expand persists host-derived children without provider credentials', async () => {
1352
- const proj = tmpProject();
1353
- try {
1354
- seedReadyBaseline(proj);
1355
- await withClientNoLlmKey(proj, async (client) => {
1356
- await client.callTool({
1357
- name: 'task.create',
1358
- arguments: { id: 'parent-1', title: 'Build something complex', type: 'feature', priority: 'P1', complexity: 9 },
1359
- });
1360
- const resp = await client.callTool({
1361
- name: 'task.expand',
1362
- arguments: {
1363
- id: 'parent-1',
1364
- children: [{
1365
- id: 'child-no-key', title: 'Implement child without provider key',
1366
- type: 'feature', priority: 'P1', complexity: 4, deps: [], files_modified: [],
1367
- }],
1368
- },
1369
- });
1370
- const data = readToolPayload(resp);
1371
- assert.equal(data.children[0].id, 'child-no-key');
1372
- });
1373
- } finally {
1374
- fs.rmSync(proj.dir, { recursive: true, force: true });
1375
- }
1376
- });
1377
-
1378
- test('plan.export: no tasks → NO_TASKS', async () => {
1379
- const proj = tmpProject();
1380
- try {
1381
- await withClient(proj, async (client) => {
1382
- const resp = await client.callTool({
1383
- name: 'plan.export',
1384
- arguments: { out_path: '.ultra/execution-plan.json' },
1385
- });
1386
- const err = expectError(resp);
1387
- assert.equal(err.code, 'NO_TASKS');
1388
- });
1389
- } finally {
1390
- fs.rmSync(proj.dir, { recursive: true, force: true });
1391
- }
1392
- });
1393
-
1394
- test('plan.export → plan.get round trip: artifact on disk + retrievable', async () => {
1395
- const proj = tmpProject();
1396
- try {
1397
- seedReadyBaseline(proj);
1398
- await withClient(proj, async (client) => {
1399
- for (const [id, deps, files] of [
1400
- ['p-a', [], ['src/a.ts']],
1401
- ['p-b', ['p-a'], ['src/b.ts']],
1402
- ['p-c', ['p-a'], ['src/c.ts']],
1403
- ]) {
1404
- await client.callTool({
1405
- name: 'task.create',
1406
- arguments: { id, title: `task ${id}`, type: 'feature', priority: 'P2', complexity: 3, deps, files_modified: files },
1407
- });
1408
- }
1409
- const exp = await client.callTool({
1410
- name: 'plan.export',
1411
- arguments: { out_path: '.ultra/execution-plan.json', format: 'json' },
1412
- });
1413
- const expData = readToolPayload(exp);
1414
- assert.equal(expData.wave_count, 2);
1415
- assert.ok(fs.existsSync(expData.plan_path), 'artifact file must exist');
1416
- assert.equal(exp._meta.ultra.projection_status, 'completed');
1417
-
1418
- const got = await client.callTool({ name: 'plan.get', arguments: { section: 'topo' } });
1419
- const gotData = readToolPayload(got);
1420
- assert.ok(Array.isArray(gotData.plan.waves));
1421
- assert.equal(gotData.plan.waves.length, 2);
1422
- });
1423
- } finally {
1424
- fs.rmSync(proj.dir, { recursive: true, force: true });
1425
- }
1426
- });
1427
-
1428
- test('plan.get: no plan written yet → NO_PLAN', async () => {
1429
- const proj = tmpProject();
1430
- try {
1431
- await withClient(proj, async (client) => {
1432
- const resp = await client.callTool({ name: 'plan.get', arguments: {} });
1433
- const err = expectError(resp);
1434
- assert.equal(err.code, 'NO_PLAN');
1435
- });
1436
- } finally {
1437
- fs.rmSync(proj.dir, { recursive: true, force: true });
1438
- }
1439
- });
1440
-
1441
- test('task.dependency_topo: cycle returns CYCLE_DETECTED with cycles in details', async () => {
1442
- const proj = tmpProject();
1443
- try {
1444
- seedReadyBaseline(proj);
1445
- await withClient(proj, async (client) => {
1446
- await client.callTool({
1447
- name: 'task.create',
1448
- arguments: { id: 'X', title: 'task X', type: 'feature', priority: 'P2', deps: ['Y'] },
1449
- });
1450
- await client.callTool({
1451
- name: 'task.create',
1452
- arguments: { id: 'Y', title: 'task Y', type: 'feature', priority: 'P2', deps: ['X'] },
1453
- });
1454
- const resp = await client.callTool({
1455
- name: 'task.dependency_topo',
1456
- arguments: { task_ids: ['X', 'Y'] },
1457
- });
1458
- const err = expectError(resp);
1459
- assert.equal(err.code, 'CYCLE_DETECTED');
1460
- assert.ok(err.details && Array.isArray(err.details.cycles));
1461
- assert.equal(err.details.cycles.length, 1);
1462
- assert.deepEqual(new Set(err.details.cycles[0]), new Set(['X', 'Y']));
1463
- });
1464
- } finally {
1465
- fs.rmSync(proj.dir, { recursive: true, force: true });
1466
- }
1467
- });