ultra-builder-pro-cli 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.ultra-template/delivery-report.json +9 -0
- package/.ultra-template/docs/research/README.md +53 -0
- package/.ultra-template/specs/architecture.md +250 -0
- package/.ultra-template/specs/discovery.md +248 -0
- package/.ultra-template/specs/product.md +223 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +57 -0
- package/.ultra-template/tasks/tasks.json +33 -0
- package/.ultra-template/test-report.json +8 -0
- package/CHANGELOG.md +194 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/adapters/_shared/codex-assets.cjs +1128 -0
- package/adapters/_shared/codex-ultra-tools-entry.cjs +11 -0
- package/adapters/_shared/file-ops.cjs +115 -0
- package/adapters/_shared/frontmatter.cjs +52 -0
- package/adapters/_shared/handbook.cjs +144 -0
- package/adapters/_shared/md-to-toml.cjs +54 -0
- package/adapters/_shared/path-rewrite.cjs +79 -0
- package/adapters/_shared/rtk-detect.cjs +86 -0
- package/adapters/_shared/runtime-assets.cjs +70 -0
- package/adapters/_shared/settings-merge.cjs +76 -0
- package/adapters/_shared/tests/file-ops.test.cjs +54 -0
- package/adapters/_shared/tests/frontmatter.test.cjs +47 -0
- package/adapters/_shared/tests/handbook.test.cjs +86 -0
- package/adapters/_shared/tests/md-to-toml.test.cjs +48 -0
- package/adapters/_shared/tests/path-rewrite.test.cjs +60 -0
- package/adapters/_shared/tests/resolve-target.test.cjs +85 -0
- package/adapters/_shared/tests/rtk-detect.test.cjs +116 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +107 -0
- package/adapters/_shared/tests/settings-merge.test.cjs +85 -0
- package/adapters/_shared/tests/validate.test.cjs +30 -0
- package/adapters/_shared/validate.cjs +24 -0
- package/adapters/claude.js +182 -0
- package/adapters/codex.js +385 -0
- package/adapters/gemini.js +204 -0
- package/adapters/opencode.js +506 -0
- package/adapters/tests/claude.test.cjs +117 -0
- package/adapters/tests/codex-hook.test.cjs +90 -0
- package/adapters/tests/codex.test.cjs +356 -0
- package/adapters/tests/gemini.test.cjs +112 -0
- package/adapters/tests/opencode.test.cjs +246 -0
- package/agents/code-reviewer.md +102 -0
- package/agents/debugger.md +127 -0
- package/agents/review-code.md +125 -0
- package/agents/review-comments.md +98 -0
- package/agents/review-coordinator.md +146 -0
- package/agents/review-design.md +133 -0
- package/agents/review-errors.md +87 -0
- package/agents/review-tests.md +93 -0
- package/agents/tdd-runner.md +92 -0
- package/bin/handbook.js +54 -0
- package/bin/install.js +254 -0
- package/bin/orchestrator.js +214 -0
- package/commands/learn.md +41 -0
- package/commands/ultra-deliver.md +45 -0
- package/commands/ultra-dev.md +50 -0
- package/commands/ultra-init.md +51 -0
- package/commands/ultra-plan.md +49 -0
- package/commands/ultra-research.md +59 -0
- package/commands/ultra-status.md +46 -0
- package/commands/ultra-test.md +48 -0
- package/commands/ultra-think.md +45 -0
- package/docs/AGENT-CONTEXT.md +143 -0
- package/docs/ARCHITECTURE.md +197 -0
- package/docs/COMMIT-HASH-BACKFILL.md +101 -0
- package/docs/LEGACY-HERMES.md +1175 -0
- package/docs/PLAN.zh-CN.md +1683 -0
- package/docs/ROADMAP.md +102 -0
- package/docs/RUNTIME-COMPAT-MATRIX.md +98 -0
- package/docs/STATE-DB-ACCESS-POLICY.md +109 -0
- package/docs/USER-HANDBOOK-CONTRACT.md +61 -0
- package/hooks/README.md +24 -0
- package/hooks/active_task_context.py +44 -0
- package/hooks/adapters/codex.py +302 -0
- package/hooks/health_check.py +61 -0
- package/hooks/pre_stop_check.py +56 -0
- package/hooks/subagent_tracker.py +116 -0
- package/hooks/workflow_checkpoint.py +56 -0
- package/hooks/workflow_context.py +63 -0
- package/hooks/workflow_resume.py +50 -0
- package/mcp-server/lib/init-project.cjs +162 -0
- package/mcp-server/lib/llm-client.cjs +157 -0
- package/mcp-server/lib/llm-client.test.cjs +156 -0
- package/mcp-server/lib/plan-store.cjs +96 -0
- package/mcp-server/lib/plan-store.test.cjs +132 -0
- package/mcp-server/lib/prd-parser.cjs +110 -0
- package/mcp-server/lib/prd-parser.test.cjs +171 -0
- package/mcp-server/lib/pricing.cjs +53 -0
- package/mcp-server/lib/projector.cjs +158 -0
- package/mcp-server/lib/state-db.cjs +113 -0
- package/mcp-server/lib/state-db.test.cjs +116 -0
- package/mcp-server/lib/state-ops.cjs +725 -0
- package/mcp-server/lib/task-expander.cjs +123 -0
- package/mcp-server/lib/task-expander.test.cjs +253 -0
- package/mcp-server/lib/telemetry.cjs +72 -0
- package/mcp-server/lib/topo.cjs +131 -0
- package/mcp-server/lib/topo.test.cjs +158 -0
- package/mcp-server/server.cjs +482 -0
- package/mcp-server/tests/circuit-breaker.test.cjs +125 -0
- package/mcp-server/tests/commit-hash-flow.test.cjs +117 -0
- package/mcp-server/tests/concurrency-update.test.cjs +96 -0
- package/mcp-server/tests/concurrency.test.cjs +112 -0
- package/mcp-server/tests/db-ops.test.cjs +117 -0
- package/mcp-server/tests/init-project.test.cjs +141 -0
- package/mcp-server/tests/migrate.test.cjs +144 -0
- package/mcp-server/tests/projector.test.cjs +130 -0
- package/mcp-server/tests/server-contract.test.cjs +750 -0
- package/mcp-server/tests/staleness.test.cjs +190 -0
- package/mcp-server/tests/state-ops.test.cjs +286 -0
- package/mcp-server/tests/tagged-tasks.test.cjs +163 -0
- package/mcp-server/tests/telemetry.test.cjs +205 -0
- package/orchestrator/auto-merge.cjs +109 -0
- package/orchestrator/code-graph-watcher.cjs +121 -0
- package/orchestrator/daemon.cjs +112 -0
- package/orchestrator/dispatch-rules.cjs +93 -0
- package/orchestrator/parallel-orchestrator.cjs +195 -0
- package/orchestrator/planner/expand-prompt.cjs +62 -0
- package/orchestrator/planner/expand-prompt.test.cjs +52 -0
- package/orchestrator/planner/plan-builder.cjs +116 -0
- package/orchestrator/planner/plan-builder.test.cjs +165 -0
- package/orchestrator/planner/prd-prompt.cjs +44 -0
- package/orchestrator/planner/prd-prompt.test.cjs +30 -0
- package/orchestrator/recovery.cjs +70 -0
- package/orchestrator/session-runner.cjs +224 -0
- package/orchestrator/skill-miner.cjs +133 -0
- package/orchestrator/tests/auto-merge.test.cjs +216 -0
- package/orchestrator/tests/code-graph-watcher.test.cjs +183 -0
- package/orchestrator/tests/daemon.test.cjs +260 -0
- package/orchestrator/tests/dispatch-rules.test.cjs +122 -0
- package/orchestrator/tests/parallel-orchestrator.test.cjs +233 -0
- package/orchestrator/tests/phase-8b-gate.test.cjs +183 -0
- package/orchestrator/tests/recovery.test.cjs +190 -0
- package/orchestrator/tests/session-runner.test.cjs +314 -0
- package/orchestrator/tests/skill-miner.test.cjs +147 -0
- package/orchestrator/tests/worktree-manager.test.cjs +148 -0
- package/orchestrator/worktree-manager.cjs +131 -0
- package/output-styles/coding-vibes.md +67 -0
- package/output-styles/structural-thinking.md +61 -0
- package/package.json +109 -0
- package/settings.json +8 -0
- package/skills/cc-collab/SKILL.md +41 -0
- package/skills/code-review-expert/SKILL.md +118 -0
- package/skills/code-review-expert/references/code-quality-checklist.md +112 -0
- package/skills/code-review-expert/references/integration-checklist.md +46 -0
- package/skills/code-review-expert/references/removal-plan.md +52 -0
- package/skills/code-review-expert/references/security-checklist.md +100 -0
- package/skills/code-review-expert/references/solid-checklist.md +65 -0
- package/skills/codex-collab/SKILL.md +52 -0
- package/skills/codex-collab/evals/evals.json +40 -0
- package/skills/gemini-collab/SKILL.md +51 -0
- package/skills/gemini-collab/evals/evals.json +42 -0
- package/skills/integration-rules/SKILL.md +71 -0
- package/skills/learn/SKILL.md +117 -0
- package/skills/security-rules/SKILL.md +60 -0
- package/skills/testing-rules/SKILL.md +56 -0
- package/skills/ultra-deliver/SKILL.md +171 -0
- package/skills/ultra-dev/SKILL.md +324 -0
- package/skills/ultra-init/SKILL.md +148 -0
- package/skills/ultra-plan/SKILL.md +314 -0
- package/skills/ultra-research/SKILL.md +156 -0
- package/skills/ultra-research/steps/step-00-problem-validation.md +154 -0
- package/skills/ultra-research/steps/step-01-opportunity-discovery.md +172 -0
- package/skills/ultra-research/steps/step-02-market-assessment.md +195 -0
- package/skills/ultra-research/steps/step-03-competitive-landscape.md +197 -0
- package/skills/ultra-research/steps/step-04-product-strategy.md +196 -0
- package/skills/ultra-research/steps/step-05-assumptions-validation.md +234 -0
- package/skills/ultra-research/steps/step-10-user-personas.md +169 -0
- package/skills/ultra-research/steps/step-11-user-scenarios.md +181 -0
- package/skills/ultra-research/steps/step-20-user-stories.md +156 -0
- package/skills/ultra-research/steps/step-21-features-scope.md +157 -0
- package/skills/ultra-research/steps/step-22-success-metrics.md +195 -0
- package/skills/ultra-research/steps/step-30-architecture-context.md +154 -0
- package/skills/ultra-research/steps/step-31-solution-strategy.md +172 -0
- package/skills/ultra-research/steps/step-32-building-blocks.md +188 -0
- package/skills/ultra-research/steps/step-40-deployment.md +123 -0
- package/skills/ultra-research/steps/step-41-quality-risks.md +135 -0
- package/skills/ultra-research/steps/step-99-synthesis.md +188 -0
- package/skills/ultra-review/SKILL.md +437 -0
- package/skills/ultra-review/references/unified-schema.md +127 -0
- package/skills/ultra-review/scripts/review_verdict_update.py +99 -0
- package/skills/ultra-review/scripts/review_wait.py +120 -0
- package/skills/ultra-status/SKILL.md +162 -0
- package/skills/ultra-test/SKILL.md +195 -0
- package/skills/ultra-think/SKILL.md +109 -0
- package/skills/ultra-verify/SKILL.md +111 -0
- package/skills/ultra-verify/evals/evals.json +51 -0
- package/skills/ultra-verify/references/confidence-system.md +38 -0
- package/skills/ultra-verify/references/cross-verify-modes.md +73 -0
- package/skills/ultra-verify/scripts/verify_wait.py +131 -0
- package/spec/cli-protocol.md +118 -0
- package/spec/mcp-tools.yaml +658 -0
- package/spec/schemas/command-manifest.schema.json +47 -0
- package/spec/schemas/context-file.v4.5.schema.json +54 -0
- package/spec/schemas/mcp-tools.schema.json +136 -0
- package/spec/schemas/skill-manifest.schema.json +94 -0
- package/spec/schemas/state-db.sql +154 -0
- package/spec/schemas/tasks.v4.5.schema.json +66 -0
- package/ultra-tools/cli.cjs +121 -0
- package/ultra-tools/commands/db.cjs +175 -0
- package/ultra-tools/commands/legacy-memory.cjs +159 -0
- package/ultra-tools/commands/migrate.cjs +354 -0
- package/ultra-tools/commands/session.cjs +180 -0
- package/ultra-tools/commands/status.cjs +158 -0
- package/ultra-tools/commands/task.cjs +95 -0
- package/ultra-tools/legacy-memory.test.cjs +80 -0
- package/ultra-tools/status-command.test.cjs +132 -0
|
@@ -0,0 +1,750 @@
|
|
|
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
|
+
|
|
10
|
+
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
|
11
|
+
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
|
|
12
|
+
|
|
13
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
14
|
+
const SERVER = path.join(REPO_ROOT, 'mcp-server', 'server.cjs');
|
|
15
|
+
const PACKAGE_VERSION = require(path.join(REPO_ROOT, 'package.json')).version;
|
|
16
|
+
|
|
17
|
+
function tmpProject() {
|
|
18
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-mcp-'));
|
|
19
|
+
return { dir, dbPath: path.join(dir, '.ultra', 'state.db') };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function withClient({ dir, dbPath }, fn) {
|
|
23
|
+
const transport = new StdioClientTransport({
|
|
24
|
+
command: process.execPath,
|
|
25
|
+
args: [SERVER],
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
UBP_DB_PATH: dbPath,
|
|
29
|
+
UBP_ROOT_DIR: dir,
|
|
30
|
+
},
|
|
31
|
+
stderr: 'pipe',
|
|
32
|
+
});
|
|
33
|
+
const client = new Client({ name: 'ubp-test', version: '0.0.0' }, { capabilities: {} });
|
|
34
|
+
await client.connect(transport);
|
|
35
|
+
try {
|
|
36
|
+
await fn(client);
|
|
37
|
+
} finally {
|
|
38
|
+
await client.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function withClientNoLlmKey({ dir, dbPath }, fn) {
|
|
43
|
+
const env = { ...process.env, UBP_DB_PATH: dbPath, UBP_ROOT_DIR: dir };
|
|
44
|
+
delete env.ANTHROPIC_API_KEY;
|
|
45
|
+
delete env.OPENAI_API_KEY;
|
|
46
|
+
const transport = new StdioClientTransport({
|
|
47
|
+
command: process.execPath,
|
|
48
|
+
args: [SERVER],
|
|
49
|
+
env,
|
|
50
|
+
stderr: 'pipe',
|
|
51
|
+
});
|
|
52
|
+
const client = new Client({ name: 'ubp-test', version: '0.0.0' }, { capabilities: {} });
|
|
53
|
+
await client.connect(transport);
|
|
54
|
+
try { await fn(client); } finally { await client.close(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readToolPayload(result) {
|
|
58
|
+
if (result.structuredContent) return result.structuredContent;
|
|
59
|
+
const text = result.content[0].text;
|
|
60
|
+
return JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function expectError(result) {
|
|
64
|
+
assert.equal(result.isError, true, 'expected isError result');
|
|
65
|
+
return JSON.parse(result.content[0].text).error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('listTools returns workflow tools and exposes no Ultra memory API', async () => {
|
|
69
|
+
const proj = tmpProject();
|
|
70
|
+
try {
|
|
71
|
+
await withClient(proj, async (client) => {
|
|
72
|
+
const list = await client.listTools();
|
|
73
|
+
const names = list.tools.map((t) => t.name).sort();
|
|
74
|
+
assert.deepEqual(names, [
|
|
75
|
+
'plan.export',
|
|
76
|
+
'plan.get',
|
|
77
|
+
'session.admission_check',
|
|
78
|
+
'session.close',
|
|
79
|
+
'session.get',
|
|
80
|
+
'session.heartbeat',
|
|
81
|
+
'session.list',
|
|
82
|
+
'session.spawn',
|
|
83
|
+
'session.subscribe_events',
|
|
84
|
+
'task.append_event',
|
|
85
|
+
'task.create',
|
|
86
|
+
'task.delete',
|
|
87
|
+
'task.dependency_topo',
|
|
88
|
+
'task.expand',
|
|
89
|
+
'task.get',
|
|
90
|
+
'task.init_project',
|
|
91
|
+
'task.list',
|
|
92
|
+
'task.parse_prd',
|
|
93
|
+
'task.subscribe_events',
|
|
94
|
+
'task.switch_tag',
|
|
95
|
+
'task.update',
|
|
96
|
+
]);
|
|
97
|
+
assert.ok(!names.some((name) => name.startsWith('memory.')));
|
|
98
|
+
for (const t of list.tools) {
|
|
99
|
+
assert.equal(typeof t.inputSchema, 'object');
|
|
100
|
+
assert.equal(t.inputSchema.type, 'object');
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
} finally {
|
|
104
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('MCP metadata matches the package and tools/list does not create project state', async () => {
|
|
109
|
+
const proj = tmpProject();
|
|
110
|
+
try {
|
|
111
|
+
await withClient(proj, async (client) => {
|
|
112
|
+
assert.equal(client.getServerVersion().version, PACKAGE_VERSION);
|
|
113
|
+
await client.listTools();
|
|
114
|
+
assert.equal(fs.existsSync(proj.dbPath), false, 'tool discovery must not create .ultra/state.db');
|
|
115
|
+
});
|
|
116
|
+
} finally {
|
|
117
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('published MCP contract contains exactly the live server tools', () => {
|
|
122
|
+
const manifest = yaml.load(fs.readFileSync(path.join(REPO_ROOT, 'spec', 'mcp-tools.yaml'), 'utf8'));
|
|
123
|
+
const declared = manifest.tools.map((tool) => tool.name).sort();
|
|
124
|
+
const { REGISTERED_TOOLS } = require(SERVER);
|
|
125
|
+
assert.deepEqual(declared, [...REGISTERED_TOOLS].sort());
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('task.create + task.get round trip via MCP', async () => {
|
|
129
|
+
const proj = tmpProject();
|
|
130
|
+
try {
|
|
131
|
+
await withClient(proj, async (client) => {
|
|
132
|
+
const created = await client.callTool({
|
|
133
|
+
name: 'task.create',
|
|
134
|
+
arguments: { id: 'mcp-1', title: 'first', type: 'feature', priority: 'P1' },
|
|
135
|
+
});
|
|
136
|
+
const createdData = readToolPayload(created);
|
|
137
|
+
assert.equal(createdData.id, 'mcp-1');
|
|
138
|
+
assert.equal(createdData.status, 'pending');
|
|
139
|
+
|
|
140
|
+
const got = await client.callTool({ name: 'task.get', arguments: { id: 'mcp-1' } });
|
|
141
|
+
const gotData = readToolPayload(got);
|
|
142
|
+
assert.equal(gotData.task.id, 'mcp-1');
|
|
143
|
+
assert.equal(gotData.task.title, 'first');
|
|
144
|
+
});
|
|
145
|
+
} finally {
|
|
146
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('task.update enforces the status state machine', async () => {
|
|
151
|
+
const proj = tmpProject();
|
|
152
|
+
try {
|
|
153
|
+
await withClient(proj, async (client) => {
|
|
154
|
+
await client.callTool({
|
|
155
|
+
name: 'task.create',
|
|
156
|
+
arguments: { id: 'sm-1', title: 'state-machine task', type: 'feature', priority: 'P0' },
|
|
157
|
+
});
|
|
158
|
+
const updated = await client.callTool({
|
|
159
|
+
name: 'task.update',
|
|
160
|
+
arguments: { id: 'sm-1', patch: { status: 'in_progress' } },
|
|
161
|
+
});
|
|
162
|
+
assert.equal(readToolPayload(updated).task.status, 'in_progress');
|
|
163
|
+
|
|
164
|
+
const completed = await client.callTool({
|
|
165
|
+
name: 'task.update',
|
|
166
|
+
arguments: { id: 'sm-1', patch: { status: 'completed' } },
|
|
167
|
+
});
|
|
168
|
+
assert.equal(readToolPayload(completed).task.status, 'completed');
|
|
169
|
+
|
|
170
|
+
const illegal = await client.callTool({
|
|
171
|
+
name: 'task.update',
|
|
172
|
+
arguments: { id: 'sm-1', patch: { status: 'pending' } },
|
|
173
|
+
});
|
|
174
|
+
const err = expectError(illegal);
|
|
175
|
+
assert.equal(err.code, 'ILLEGAL_STATUS_TRANSITION');
|
|
176
|
+
});
|
|
177
|
+
} finally {
|
|
178
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('task.append_event + subscribe_events drive a monotonic cursor', async () => {
|
|
183
|
+
const proj = tmpProject();
|
|
184
|
+
try {
|
|
185
|
+
await withClient(proj, async (client) => {
|
|
186
|
+
await client.callTool({
|
|
187
|
+
name: 'task.create',
|
|
188
|
+
arguments: { id: 'sub-1', title: 'subscribe target', type: 'feature', priority: 'P1' },
|
|
189
|
+
});
|
|
190
|
+
// task.create already emitted event_id=1 (task_created); add 4 more.
|
|
191
|
+
for (let i = 0; i < 4; i++) {
|
|
192
|
+
await client.callTool({
|
|
193
|
+
name: 'task.append_event',
|
|
194
|
+
arguments: { type: 'task_started', task_id: 'sub-1', payload: { i } },
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const first = readToolPayload(await client.callTool({
|
|
199
|
+
name: 'task.subscribe_events',
|
|
200
|
+
arguments: { since_id: 0, limit: 3 },
|
|
201
|
+
}));
|
|
202
|
+
assert.equal(first.events.length, 3);
|
|
203
|
+
assert.equal(first.events[0].id, 1);
|
|
204
|
+
assert.equal(first.next_since_id, 3);
|
|
205
|
+
|
|
206
|
+
const tail = readToolPayload(await client.callTool({
|
|
207
|
+
name: 'task.subscribe_events',
|
|
208
|
+
arguments: { since_id: first.next_since_id, limit: 100 },
|
|
209
|
+
}));
|
|
210
|
+
assert.equal(tail.events.length, 2);
|
|
211
|
+
assert.equal(tail.events[0].id, 4);
|
|
212
|
+
assert.equal(tail.next_since_id, 5);
|
|
213
|
+
});
|
|
214
|
+
} finally {
|
|
215
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('task.list filters by status and tag', async () => {
|
|
220
|
+
const proj = tmpProject();
|
|
221
|
+
try {
|
|
222
|
+
await withClient(proj, async (client) => {
|
|
223
|
+
await client.callTool({ name: 'task.create', arguments: { id: 'l-1', title: 'list one', type: 'feature', priority: 'P0', tag: 'main' } });
|
|
224
|
+
await client.callTool({ name: 'task.create', arguments: { id: 'l-2', title: 'list two', type: 'feature', priority: 'P1', tag: 'main' } });
|
|
225
|
+
await client.callTool({ name: 'task.create', arguments: { id: 'l-3', title: 'list three', type: 'feature', priority: 'P2', tag: 'feat-x' } });
|
|
226
|
+
await client.callTool({ name: 'task.update', arguments: { id: 'l-2', patch: { status: 'in_progress' } } });
|
|
227
|
+
|
|
228
|
+
const inProg = readToolPayload(await client.callTool({
|
|
229
|
+
name: 'task.list', arguments: { status: 'in_progress' },
|
|
230
|
+
}));
|
|
231
|
+
assert.equal(inProg.count, 1);
|
|
232
|
+
assert.equal(inProg.tasks[0].id, 'l-2');
|
|
233
|
+
|
|
234
|
+
const onMain = readToolPayload(await client.callTool({
|
|
235
|
+
name: 'task.list', arguments: { tag: 'main' },
|
|
236
|
+
}));
|
|
237
|
+
assert.equal(onMain.count, 2);
|
|
238
|
+
});
|
|
239
|
+
} finally {
|
|
240
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('task.create rejects bad input via the JSON Schema validator', async () => {
|
|
245
|
+
const proj = tmpProject();
|
|
246
|
+
try {
|
|
247
|
+
await withClient(proj, async (client) => {
|
|
248
|
+
const bad = await client.callTool({
|
|
249
|
+
name: 'task.create',
|
|
250
|
+
arguments: { title: 'no priority field', type: 'feature' },
|
|
251
|
+
});
|
|
252
|
+
const err = expectError(bad);
|
|
253
|
+
assert.equal(err.code, 'VALIDATION_ERROR');
|
|
254
|
+
});
|
|
255
|
+
} finally {
|
|
256
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('mutating tools trigger the projector — tasks.json appears under .ultra/', async () => {
|
|
261
|
+
const proj = tmpProject();
|
|
262
|
+
try {
|
|
263
|
+
await withClient(proj, async (client) => {
|
|
264
|
+
await client.callTool({
|
|
265
|
+
name: 'task.create',
|
|
266
|
+
arguments: { id: 'pj-1', title: 'projection wired', type: 'feature', priority: 'P1' },
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
const tasksJson = path.join(proj.dir, '.ultra', 'tasks', 'tasks.json');
|
|
270
|
+
assert.ok(fs.existsSync(tasksJson), 'projector should have written tasks.json');
|
|
271
|
+
const data = JSON.parse(fs.readFileSync(tasksJson, 'utf8'));
|
|
272
|
+
assert.equal(data.schema_version, '4.5');
|
|
273
|
+
assert.equal(data.tasks[0].id, 'pj-1');
|
|
274
|
+
} finally {
|
|
275
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('task.init_project creates .ultra/ skeleton in a fresh target directory', async () => {
|
|
280
|
+
const proj = tmpProject();
|
|
281
|
+
const freshTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-target-'));
|
|
282
|
+
try {
|
|
283
|
+
await withClient(proj, async (client) => {
|
|
284
|
+
const res = await client.callTool({
|
|
285
|
+
name: 'task.init_project',
|
|
286
|
+
arguments: { target_dir: freshTarget, project_name: 'mcp-init', project_type: 'cli' },
|
|
287
|
+
});
|
|
288
|
+
const payload = readToolPayload(res);
|
|
289
|
+
assert.equal(payload.status, 'created');
|
|
290
|
+
assert.equal(payload.created_path, path.join(freshTarget, '.ultra'));
|
|
291
|
+
assert.ok(payload.copied_files.includes('tasks/tasks.json'));
|
|
292
|
+
const tasksJson = JSON.parse(fs.readFileSync(path.join(payload.created_path, 'tasks', 'tasks.json'), 'utf8'));
|
|
293
|
+
assert.equal(tasksJson.project.name, 'mcp-init');
|
|
294
|
+
assert.equal(tasksJson.project.type, 'cli');
|
|
295
|
+
});
|
|
296
|
+
} finally {
|
|
297
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
298
|
+
fs.rmSync(freshTarget, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('task.init_project returns ULTRA_DIR_EXISTS on re-init without overwrite', async () => {
|
|
303
|
+
const proj = tmpProject();
|
|
304
|
+
const freshTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-init-target-'));
|
|
305
|
+
try {
|
|
306
|
+
await withClient(proj, async (client) => {
|
|
307
|
+
await client.callTool({
|
|
308
|
+
name: 'task.init_project',
|
|
309
|
+
arguments: { target_dir: freshTarget, project_name: 'once' },
|
|
310
|
+
});
|
|
311
|
+
const second = await client.callTool({
|
|
312
|
+
name: 'task.init_project',
|
|
313
|
+
arguments: { target_dir: freshTarget, project_name: 'twice' },
|
|
314
|
+
});
|
|
315
|
+
const err = expectError(second);
|
|
316
|
+
assert.equal(err.code, 'ULTRA_DIR_EXISTS');
|
|
317
|
+
});
|
|
318
|
+
} finally {
|
|
319
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
320
|
+
fs.rmSync(freshTarget, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
async function seedTask(client, id = 's-1') {
|
|
325
|
+
await client.callTool({
|
|
326
|
+
name: 'task.create',
|
|
327
|
+
arguments: { id, title: 'session target', type: 'feature', priority: 'P1' },
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
test('session.admission_check + session.spawn: happy path returns sid and paths', async () => {
|
|
332
|
+
const proj = tmpProject();
|
|
333
|
+
try {
|
|
334
|
+
await withClient(proj, async (client) => {
|
|
335
|
+
await seedTask(client, 's-happy');
|
|
336
|
+
|
|
337
|
+
const admission = await client.callTool({
|
|
338
|
+
name: 'session.admission_check',
|
|
339
|
+
arguments: { task_id: 's-happy' },
|
|
340
|
+
});
|
|
341
|
+
const verdict = readToolPayload(admission);
|
|
342
|
+
assert.equal(verdict.can_spawn, true);
|
|
343
|
+
assert.equal(verdict.recommended_action, 'spawn');
|
|
344
|
+
|
|
345
|
+
const spawn = await client.callTool({
|
|
346
|
+
name: 'session.spawn',
|
|
347
|
+
arguments: { task_id: 's-happy', runtime: 'claude' },
|
|
348
|
+
});
|
|
349
|
+
const session = readToolPayload(spawn);
|
|
350
|
+
assert.match(session.sid, /^sess-/);
|
|
351
|
+
assert.ok(session.worktree_path.includes(session.sid));
|
|
352
|
+
assert.ok(session.artifact_dir.endsWith(path.join('.ultra', 'sessions', session.sid)));
|
|
353
|
+
assert.ok(session.lease_expires_at);
|
|
354
|
+
});
|
|
355
|
+
} finally {
|
|
356
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('session.spawn refuses second session for same task without takeover (ADMISSION_DENIED)', async () => {
|
|
361
|
+
const proj = tmpProject();
|
|
362
|
+
try {
|
|
363
|
+
await withClient(proj, async (client) => {
|
|
364
|
+
await seedTask(client, 's-conflict');
|
|
365
|
+
await client.callTool({
|
|
366
|
+
name: 'session.spawn',
|
|
367
|
+
arguments: { task_id: 's-conflict', runtime: 'claude' },
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const admissionAgain = await client.callTool({
|
|
371
|
+
name: 'session.admission_check',
|
|
372
|
+
arguments: { task_id: 's-conflict' },
|
|
373
|
+
});
|
|
374
|
+
const verdict = readToolPayload(admissionAgain);
|
|
375
|
+
assert.equal(verdict.can_spawn, false);
|
|
376
|
+
assert.ok(verdict.conflict);
|
|
377
|
+
|
|
378
|
+
const second = await client.callTool({
|
|
379
|
+
name: 'session.spawn',
|
|
380
|
+
arguments: { task_id: 's-conflict', runtime: 'opencode' },
|
|
381
|
+
});
|
|
382
|
+
const err = expectError(second);
|
|
383
|
+
assert.equal(err.code, 'ADMISSION_DENIED');
|
|
384
|
+
});
|
|
385
|
+
} finally {
|
|
386
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('session.spawn with takeover=true crashes the old session and succeeds', async () => {
|
|
391
|
+
const proj = tmpProject();
|
|
392
|
+
try {
|
|
393
|
+
await withClient(proj, async (client) => {
|
|
394
|
+
await seedTask(client, 's-takeover');
|
|
395
|
+
const first = readToolPayload(await client.callTool({
|
|
396
|
+
name: 'session.spawn',
|
|
397
|
+
arguments: { task_id: 's-takeover', runtime: 'claude' },
|
|
398
|
+
}));
|
|
399
|
+
|
|
400
|
+
const second = readToolPayload(await client.callTool({
|
|
401
|
+
name: 'session.spawn',
|
|
402
|
+
arguments: { task_id: 's-takeover', runtime: 'codex', takeover: true },
|
|
403
|
+
}));
|
|
404
|
+
assert.notEqual(second.sid, first.sid);
|
|
405
|
+
|
|
406
|
+
const firstAfter = readToolPayload(await client.callTool({
|
|
407
|
+
name: 'session.get', arguments: { sid: first.sid },
|
|
408
|
+
}));
|
|
409
|
+
assert.equal(firstAfter.session.status, 'crashed');
|
|
410
|
+
});
|
|
411
|
+
} finally {
|
|
412
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('session.subscribe_events sees task events with ≤1s latency (D31 id cursor)', async () => {
|
|
417
|
+
const proj = tmpProject();
|
|
418
|
+
try {
|
|
419
|
+
await withClient(proj, async (client) => {
|
|
420
|
+
await seedTask(client, 's-sub');
|
|
421
|
+
|
|
422
|
+
// pin cursor before any writes
|
|
423
|
+
const before = readToolPayload(await client.callTool({
|
|
424
|
+
name: 'session.subscribe_events',
|
|
425
|
+
arguments: { since_id: 0 },
|
|
426
|
+
}));
|
|
427
|
+
const cursor = before.next_since_id;
|
|
428
|
+
|
|
429
|
+
const t0 = Date.now();
|
|
430
|
+
await client.callTool({
|
|
431
|
+
name: 'task.update',
|
|
432
|
+
arguments: { id: 's-sub', patch: { status: 'in_progress' } },
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const after = readToolPayload(await client.callTool({
|
|
436
|
+
name: 'session.subscribe_events',
|
|
437
|
+
arguments: { since_id: cursor },
|
|
438
|
+
}));
|
|
439
|
+
const elapsedMs = Date.now() - t0;
|
|
440
|
+
assert.ok(after.events.length >= 1, 'expected at least one new event');
|
|
441
|
+
const types = after.events.map((e) => e.type);
|
|
442
|
+
const sawTransition = types.some((t) => t === 'task_started' || t === 'task_status_changed');
|
|
443
|
+
assert.ok(sawTransition, `got event types ${JSON.stringify(types)}`);
|
|
444
|
+
assert.ok(elapsedMs < 1000, `latency ${elapsedMs}ms exceeds 1s budget`);
|
|
445
|
+
});
|
|
446
|
+
} finally {
|
|
447
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('session.subscribe_events filters events by sid', async () => {
|
|
452
|
+
const proj = tmpProject();
|
|
453
|
+
try {
|
|
454
|
+
await withClient(proj, async (client) => {
|
|
455
|
+
await seedTask(client, 's-filter-a');
|
|
456
|
+
await seedTask(client, 's-filter-b');
|
|
457
|
+
const sessionA = readToolPayload(await client.callTool({
|
|
458
|
+
name: 'session.spawn',
|
|
459
|
+
arguments: { task_id: 's-filter-a', runtime: 'claude' },
|
|
460
|
+
}));
|
|
461
|
+
const sessionB = readToolPayload(await client.callTool({
|
|
462
|
+
name: 'session.spawn',
|
|
463
|
+
arguments: { task_id: 's-filter-b', runtime: 'codex' },
|
|
464
|
+
}));
|
|
465
|
+
const before = readToolPayload(await client.callTool({
|
|
466
|
+
name: 'session.subscribe_events',
|
|
467
|
+
arguments: { since_id: 0 },
|
|
468
|
+
}));
|
|
469
|
+
|
|
470
|
+
await client.callTool({
|
|
471
|
+
name: 'task.append_event',
|
|
472
|
+
arguments: { type: 'context_updated', task_id: 's-filter-a', session_id: sessionA.sid },
|
|
473
|
+
});
|
|
474
|
+
await client.callTool({
|
|
475
|
+
name: 'task.append_event',
|
|
476
|
+
arguments: { type: 'context_updated', task_id: 's-filter-b', session_id: sessionB.sid },
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const filtered = readToolPayload(await client.callTool({
|
|
480
|
+
name: 'session.subscribe_events',
|
|
481
|
+
arguments: { since_id: before.next_since_id, sid: sessionA.sid },
|
|
482
|
+
}));
|
|
483
|
+
assert.equal(filtered.events.length, 1);
|
|
484
|
+
assert.equal(filtered.events[0].session_id, sessionA.sid);
|
|
485
|
+
});
|
|
486
|
+
} finally {
|
|
487
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test('MCP rejects declared inputs that have no runtime behavior', async () => {
|
|
492
|
+
const proj = tmpProject();
|
|
493
|
+
try {
|
|
494
|
+
await withClient(proj, async (client) => {
|
|
495
|
+
await seedTask(client, 'unused-inputs');
|
|
496
|
+
const session = readToolPayload(await client.callTool({
|
|
497
|
+
name: 'session.spawn',
|
|
498
|
+
arguments: { task_id: 'unused-inputs', runtime: 'claude' },
|
|
499
|
+
}));
|
|
500
|
+
|
|
501
|
+
const manual = await client.callTool({
|
|
502
|
+
name: 'task.expand',
|
|
503
|
+
arguments: { id: 'unused-inputs', strategy: 'manual' },
|
|
504
|
+
});
|
|
505
|
+
assert.equal(expectError(manual).code, 'VALIDATION_ERROR');
|
|
506
|
+
|
|
507
|
+
const notes = await client.callTool({
|
|
508
|
+
name: 'session.close',
|
|
509
|
+
arguments: { sid: session.sid, status: 'completed', notes: 'ignored before contract fix' },
|
|
510
|
+
});
|
|
511
|
+
assert.equal(expectError(notes).code, 'VALIDATION_ERROR');
|
|
512
|
+
|
|
513
|
+
const tag = await client.callTool({
|
|
514
|
+
name: 'plan.get',
|
|
515
|
+
arguments: { tag: 'ignored-before-contract-fix' },
|
|
516
|
+
});
|
|
517
|
+
assert.equal(expectError(tag).code, 'VALIDATION_ERROR');
|
|
518
|
+
});
|
|
519
|
+
} finally {
|
|
520
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('session.heartbeat refreshes lease; session.close marks completed', async () => {
|
|
525
|
+
const proj = tmpProject();
|
|
526
|
+
try {
|
|
527
|
+
await withClient(proj, async (client) => {
|
|
528
|
+
await seedTask(client, 's-heart');
|
|
529
|
+
const spawn = readToolPayload(await client.callTool({
|
|
530
|
+
name: 'session.spawn',
|
|
531
|
+
arguments: { task_id: 's-heart', runtime: 'claude' },
|
|
532
|
+
}));
|
|
533
|
+
const firstLease = spawn.lease_expires_at;
|
|
534
|
+
|
|
535
|
+
// heartbeat extends the lease
|
|
536
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
537
|
+
const hb = readToolPayload(await client.callTool({
|
|
538
|
+
name: 'session.heartbeat', arguments: { sid: spawn.sid },
|
|
539
|
+
}));
|
|
540
|
+
assert.equal(hb.ok, true);
|
|
541
|
+
assert.ok(Date.parse(hb.lease_expires_at) >= Date.parse(firstLease));
|
|
542
|
+
|
|
543
|
+
// close flips status
|
|
544
|
+
const closed = readToolPayload(await client.callTool({
|
|
545
|
+
name: 'session.close',
|
|
546
|
+
arguments: { sid: spawn.sid, status: 'completed' },
|
|
547
|
+
}));
|
|
548
|
+
assert.equal(closed.ok, true);
|
|
549
|
+
|
|
550
|
+
const got = readToolPayload(await client.callTool({
|
|
551
|
+
name: 'session.get', arguments: { sid: spawn.sid },
|
|
552
|
+
}));
|
|
553
|
+
assert.equal(got.session.status, 'completed');
|
|
554
|
+
});
|
|
555
|
+
} finally {
|
|
556
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('retired memory tools are rejected as unknown', async () => {
|
|
561
|
+
const proj = tmpProject();
|
|
562
|
+
try {
|
|
563
|
+
await withClient(proj, async (client) => {
|
|
564
|
+
const result = await client.callTool({
|
|
565
|
+
name: 'memory.retain',
|
|
566
|
+
arguments: { kind: 'decision', content: 'must not be retained' },
|
|
567
|
+
});
|
|
568
|
+
assert.equal(expectError(result).code, 'UNKNOWN_TOOL');
|
|
569
|
+
});
|
|
570
|
+
} finally {
|
|
571
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test('task.dependency_topo: happy path groups tasks into correct waves', async () => {
|
|
576
|
+
const proj = tmpProject();
|
|
577
|
+
try {
|
|
578
|
+
await withClient(proj, async (client) => {
|
|
579
|
+
for (const [id, deps] of [['A', []], ['B', ['A']], ['C', ['A']]]) {
|
|
580
|
+
await client.callTool({
|
|
581
|
+
name: 'task.create',
|
|
582
|
+
arguments: { id, title: `task ${id}`, type: 'feature', priority: 'P2', deps },
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
const resp = await client.callTool({
|
|
586
|
+
name: 'task.dependency_topo',
|
|
587
|
+
arguments: { task_ids: ['A', 'B', 'C'] },
|
|
588
|
+
});
|
|
589
|
+
const data = readToolPayload(resp);
|
|
590
|
+
assert.equal(data.waves.length, 2);
|
|
591
|
+
assert.deepEqual(new Set(data.waves[0]), new Set(['A']));
|
|
592
|
+
assert.deepEqual(new Set(data.waves[1]), new Set(['B', 'C']));
|
|
593
|
+
});
|
|
594
|
+
} finally {
|
|
595
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('task.parse_prd: neither prd_path nor prd_text → NO_INPUT', async () => {
|
|
600
|
+
const proj = tmpProject();
|
|
601
|
+
try {
|
|
602
|
+
await withClient(proj, async (client) => {
|
|
603
|
+
const resp = await client.callTool({ name: 'task.parse_prd', arguments: {} });
|
|
604
|
+
const err = expectError(resp);
|
|
605
|
+
assert.equal(err.code, 'NO_INPUT');
|
|
606
|
+
});
|
|
607
|
+
} finally {
|
|
608
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('task.parse_prd: missing LLM credentials → NO_LLM_CREDENTIALS', async () => {
|
|
613
|
+
const proj = tmpProject();
|
|
614
|
+
try {
|
|
615
|
+
await withClientNoLlmKey(proj, async (client) => {
|
|
616
|
+
const resp = await client.callTool({
|
|
617
|
+
name: 'task.parse_prd',
|
|
618
|
+
arguments: { prd_text: 'Build a login feature with email and password.' },
|
|
619
|
+
});
|
|
620
|
+
const err = expectError(resp);
|
|
621
|
+
assert.equal(err.code, 'NO_LLM_CREDENTIALS');
|
|
622
|
+
});
|
|
623
|
+
} finally {
|
|
624
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', async () => {
|
|
629
|
+
const proj = tmpProject();
|
|
630
|
+
try {
|
|
631
|
+
await withClientNoLlmKey(proj, async (client) => {
|
|
632
|
+
const resp = await client.callTool({
|
|
633
|
+
name: 'task.expand',
|
|
634
|
+
arguments: { id: 'nonexistent-parent' },
|
|
635
|
+
});
|
|
636
|
+
const err = expectError(resp);
|
|
637
|
+
assert.equal(err.code, 'TASK_NOT_FOUND');
|
|
638
|
+
});
|
|
639
|
+
} finally {
|
|
640
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIALS', async () => {
|
|
645
|
+
const proj = tmpProject();
|
|
646
|
+
try {
|
|
647
|
+
await withClientNoLlmKey(proj, async (client) => {
|
|
648
|
+
await client.callTool({
|
|
649
|
+
name: 'task.create',
|
|
650
|
+
arguments: { id: 'parent-1', title: 'Build something complex', type: 'feature', priority: 'P1', complexity: 9 },
|
|
651
|
+
});
|
|
652
|
+
const resp = await client.callTool({
|
|
653
|
+
name: 'task.expand',
|
|
654
|
+
arguments: { id: 'parent-1' },
|
|
655
|
+
});
|
|
656
|
+
const err = expectError(resp);
|
|
657
|
+
assert.equal(err.code, 'NO_LLM_CREDENTIALS');
|
|
658
|
+
});
|
|
659
|
+
} finally {
|
|
660
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test('plan.export: no tasks → NO_TASKS', async () => {
|
|
665
|
+
const proj = tmpProject();
|
|
666
|
+
try {
|
|
667
|
+
await withClient(proj, async (client) => {
|
|
668
|
+
const resp = await client.callTool({
|
|
669
|
+
name: 'plan.export',
|
|
670
|
+
arguments: { out_path: '.ultra/execution-plan.json' },
|
|
671
|
+
});
|
|
672
|
+
const err = expectError(resp);
|
|
673
|
+
assert.equal(err.code, 'NO_TASKS');
|
|
674
|
+
});
|
|
675
|
+
} finally {
|
|
676
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('plan.export → plan.get round trip: artifact on disk + retrievable', async () => {
|
|
681
|
+
const proj = tmpProject();
|
|
682
|
+
try {
|
|
683
|
+
await withClient(proj, async (client) => {
|
|
684
|
+
for (const [id, deps, files] of [
|
|
685
|
+
['p-a', [], ['src/a.ts']],
|
|
686
|
+
['p-b', ['p-a'], ['src/b.ts']],
|
|
687
|
+
['p-c', ['p-a'], ['src/c.ts']],
|
|
688
|
+
]) {
|
|
689
|
+
await client.callTool({
|
|
690
|
+
name: 'task.create',
|
|
691
|
+
arguments: { id, title: `task ${id}`, type: 'feature', priority: 'P2', complexity: 3, deps, files_modified: files },
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const exp = await client.callTool({
|
|
695
|
+
name: 'plan.export',
|
|
696
|
+
arguments: { out_path: '.ultra/execution-plan.json', format: 'json' },
|
|
697
|
+
});
|
|
698
|
+
const expData = readToolPayload(exp);
|
|
699
|
+
assert.equal(expData.wave_count, 2);
|
|
700
|
+
assert.ok(fs.existsSync(expData.plan_path), 'artifact file must exist');
|
|
701
|
+
|
|
702
|
+
const got = await client.callTool({ name: 'plan.get', arguments: { section: 'topo' } });
|
|
703
|
+
const gotData = readToolPayload(got);
|
|
704
|
+
assert.ok(Array.isArray(gotData.plan.waves));
|
|
705
|
+
assert.equal(gotData.plan.waves.length, 2);
|
|
706
|
+
});
|
|
707
|
+
} finally {
|
|
708
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test('plan.get: no plan written yet → NO_PLAN', async () => {
|
|
713
|
+
const proj = tmpProject();
|
|
714
|
+
try {
|
|
715
|
+
await withClient(proj, async (client) => {
|
|
716
|
+
const resp = await client.callTool({ name: 'plan.get', arguments: {} });
|
|
717
|
+
const err = expectError(resp);
|
|
718
|
+
assert.equal(err.code, 'NO_PLAN');
|
|
719
|
+
});
|
|
720
|
+
} finally {
|
|
721
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('task.dependency_topo: cycle returns CYCLE_DETECTED with cycles in details', async () => {
|
|
726
|
+
const proj = tmpProject();
|
|
727
|
+
try {
|
|
728
|
+
await withClient(proj, async (client) => {
|
|
729
|
+
await client.callTool({
|
|
730
|
+
name: 'task.create',
|
|
731
|
+
arguments: { id: 'X', title: 'task X', type: 'feature', priority: 'P2', deps: ['Y'] },
|
|
732
|
+
});
|
|
733
|
+
await client.callTool({
|
|
734
|
+
name: 'task.create',
|
|
735
|
+
arguments: { id: 'Y', title: 'task Y', type: 'feature', priority: 'P2', deps: ['X'] },
|
|
736
|
+
});
|
|
737
|
+
const resp = await client.callTool({
|
|
738
|
+
name: 'task.dependency_topo',
|
|
739
|
+
arguments: { task_ids: ['X', 'Y'] },
|
|
740
|
+
});
|
|
741
|
+
const err = expectError(resp);
|
|
742
|
+
assert.equal(err.code, 'CYCLE_DETECTED');
|
|
743
|
+
assert.ok(err.details && Array.isArray(err.details.cycles));
|
|
744
|
+
assert.equal(err.details.cycles.length, 1);
|
|
745
|
+
assert.deepEqual(new Set(err.details.cycles[0]), new Set(['X', 'Y']));
|
|
746
|
+
});
|
|
747
|
+
} finally {
|
|
748
|
+
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
749
|
+
}
|
|
750
|
+
});
|