ultra-builder-pro-cli 0.12.1 → 0.14.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/docs/research/README.md +21 -44
- package/.ultra-template/specs/discovery.md +10 -0
- package/CHANGELOG.md +95 -1
- package/README.md +31 -16
- package/adapters/_shared/codex-assets.cjs +23 -163
- package/adapters/_shared/handbook.cjs +2 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +18 -0
- package/adapters/opencode.js +30 -120
- package/adapters/tests/claude.test.cjs +3 -2
- package/adapters/tests/codex-hook.test.cjs +34 -7
- package/adapters/tests/codex.test.cjs +21 -6
- package/adapters/tests/kimi-hook.test.cjs +34 -7
- package/adapters/tests/opencode.test.cjs +70 -84
- package/commands/ultra-init.md +1 -1
- package/docs/AGENT-CONTEXT.md +10 -6
- package/docs/ARCHITECTURE.md +40 -17
- package/docs/PLAN.zh-CN.md +5 -4
- package/docs/ROADMAP.md +2 -2
- package/docs/RUNTIME-COMPAT-MATRIX.md +4 -4
- package/docs/STATE-DB-ACCESS-POLICY.md +3 -3
- package/docs/USER-HANDBOOK-CONTRACT.md +5 -4
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -299
- package/hooks/health_check.py +19 -17
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +13 -19
- package/hooks/workflow_checkpoint.py +39 -47
- package/hooks/workflow_context.py +4 -9
- package/hooks/workflow_resume.py +48 -128
- package/mcp-server/breadcrumb.cjs +22 -0
- package/mcp-server/lib/archive-journal.cjs +192 -0
- package/mcp-server/lib/archive-journal.test.cjs +35 -0
- package/mcp-server/lib/baseline-workflow.cjs +404 -38
- package/mcp-server/lib/baseline-workflow.test.cjs +252 -0
- package/mcp-server/lib/change-workflow.cjs +249 -75
- package/mcp-server/lib/change-workflow.test.cjs +235 -1
- package/mcp-server/lib/context-spine.test.cjs +5 -0
- package/mcp-server/lib/doctor.cjs +14 -1
- package/mcp-server/lib/doctor.test.cjs +42 -4
- package/mcp-server/lib/init-project.cjs +292 -9
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/prd-parser.test.cjs +49 -138
- package/mcp-server/lib/project-breadcrumb.cjs +139 -0
- package/mcp-server/lib/project-breadcrumb.test.cjs +112 -0
- package/mcp-server/lib/spec-learning.test.cjs +1 -1
- package/mcp-server/lib/state-authority.cjs +9 -3
- package/mcp-server/lib/state-db.cjs +154 -32
- package/mcp-server/lib/state-db.test.cjs +136 -4
- package/mcp-server/lib/state-ops.cjs +68 -6
- package/mcp-server/lib/task-expander.cjs +14 -37
- package/mcp-server/lib/task-expander.test.cjs +80 -164
- package/mcp-server/server.cjs +34 -37
- package/mcp-server/tests/init-project.test.cjs +162 -1
- package/mcp-server/tests/migrate.test.cjs +33 -0
- package/mcp-server/tests/server-contract.test.cjs +391 -17
- package/mcp-server/tests/state-db.test.cjs +14 -2
- package/mcp-server/tests/state-ops.test.cjs +108 -0
- package/package.json +2 -4
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +18 -10
- package/skills/ultra-deliver/SKILL.md +13 -9
- package/skills/ultra-dev/SKILL.md +6 -4
- package/skills/ultra-doctor/SKILL.md +22 -7
- package/skills/ultra-init/SKILL.md +104 -59
- package/skills/ultra-init/references/brownfield-adoption.md +65 -37
- package/skills/ultra-plan/SKILL.md +20 -7
- package/skills/ultra-research/SKILL.md +12 -5
- package/skills/ultra-status/SKILL.md +6 -4
- package/skills/ultra-verify/SKILL.md +21 -30
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +11 -1
- package/spec/mcp-tools.yaml +133 -19
- package/spec/schemas/state-db.sql +11 -0
- package/ultra-tools/cli.cjs +9 -5
- package/ultra-tools/commands/migrate.cjs +62 -36
- package/ultra-tools/commands/status.cjs +18 -9
- package/ultra-tools/commands/system.cjs +340 -6
- package/ultra-tools/commands/task.cjs +7 -1
- package/ultra-tools/status-command.test.cjs +29 -0
- package/ultra-tools/system-command.test.cjs +252 -1
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
|
@@ -122,6 +122,39 @@ test('migrate forward inserts tasks + events, records migration_history, creates
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('migrate imports a v4.5 projection into schema 12 authority and requires evidence-backed re-adoption', () => {
|
|
126
|
+
const dir = tmpProject();
|
|
127
|
+
try {
|
|
128
|
+
const tasksPath = path.join(dir, '.ultra', 'tasks', 'tasks.json');
|
|
129
|
+
const projection = JSON.parse(fs.readFileSync(tasksPath, 'utf8'));
|
|
130
|
+
delete projection.version;
|
|
131
|
+
projection.schema_version = '4.5';
|
|
132
|
+
projection.source = '.ultra/state.db';
|
|
133
|
+
fs.writeFileSync(tasksPath, `${JSON.stringify(projection, null, 2)}\n`);
|
|
134
|
+
|
|
135
|
+
const r = runCli(['migrate', '--from=4.5', '--to=12.0', '--source-dir', dir]);
|
|
136
|
+
assert.equal(r.code, 0);
|
|
137
|
+
assert.equal(r.envelope.data.from, '4.5');
|
|
138
|
+
assert.equal(r.envelope.data.to, '12.0');
|
|
139
|
+
assert.match(path.basename(r.envelope.data.backup_dir), /^backup-v4\.5-/);
|
|
140
|
+
|
|
141
|
+
const db = openStateDb(path.join(dir, '.ultra', 'state.db'));
|
|
142
|
+
try {
|
|
143
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM tasks').get().count, 3);
|
|
144
|
+
const baseline = db.prepare(
|
|
145
|
+
"SELECT mode, status, gaps_json FROM baselines WHERE id = 'migrated-baseline'",
|
|
146
|
+
).get();
|
|
147
|
+
assert.deepEqual(
|
|
148
|
+
{ mode: baseline.mode, status: baseline.status },
|
|
149
|
+
{ mode: 'migrated', status: 'adopting' },
|
|
150
|
+
);
|
|
151
|
+
assert.equal(JSON.parse(baseline.gaps_json)[0].id, 'legacy-rebaseline-required');
|
|
152
|
+
} finally { closeStateDb(db); }
|
|
153
|
+
} finally {
|
|
154
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
125
158
|
test('migrate --rollback restores .ultra contents and writes a rollback row', () => {
|
|
126
159
|
const dir = tmpProject();
|
|
127
160
|
try {
|
|
@@ -15,22 +15,35 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
|
15
15
|
const SERVER = path.join(REPO_ROOT, 'mcp-server', 'server.cjs');
|
|
16
16
|
const PACKAGE_VERSION = require(path.join(REPO_ROOT, 'package.json')).version;
|
|
17
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');
|
|
18
21
|
|
|
19
22
|
function tmpProject() {
|
|
20
23
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-mcp-'));
|
|
21
24
|
return { dir, dbPath: path.join(dir, '.ultra', 'state.db') };
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
function
|
|
27
|
+
function seedReadyBaseline(project) {
|
|
25
28
|
const { db } = initStateDb(project.dbPath);
|
|
26
29
|
db.prepare(
|
|
27
30
|
`INSERT OR IGNORE INTO baselines
|
|
28
31
|
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
29
|
-
VALUES ('test-baseline', 'fixture', '
|
|
32
|
+
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'test fixture', ?)`,
|
|
30
33
|
).run(new Date().toISOString());
|
|
31
34
|
closeStateDb(db);
|
|
32
35
|
}
|
|
33
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
|
+
|
|
34
47
|
async function withClient({ dir, dbPath }, fn, extraEnv = {}) {
|
|
35
48
|
const transport = new StdioClientTransport({
|
|
36
49
|
command: process.execPath,
|
|
@@ -78,6 +91,17 @@ function expectError(result) {
|
|
|
78
91
|
return JSON.parse(result.content[0].text).error;
|
|
79
92
|
}
|
|
80
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
|
+
|
|
81
105
|
test('listTools returns workflow tools and exposes no Ultra memory API', async () => {
|
|
82
106
|
const proj = tmpProject();
|
|
83
107
|
try {
|
|
@@ -204,7 +228,7 @@ test('baseline MCP tools adopt and converge an existing checkout without storing
|
|
|
204
228
|
test('change.create + change.context expose a continuous change unit with external provider references', async () => {
|
|
205
229
|
const proj = tmpProject();
|
|
206
230
|
try {
|
|
207
|
-
|
|
231
|
+
seedReadyBaseline(proj);
|
|
208
232
|
fs.writeFileSync(path.join(proj.dir, 'README.md'), '# project\n');
|
|
209
233
|
await withClient(proj, async (client) => {
|
|
210
234
|
const created = await client.callTool({
|
|
@@ -256,7 +280,7 @@ test('change.create + change.context expose a continuous change unit with extern
|
|
|
256
280
|
test('spec-learning MCP tools persist an approval-gated candidate and projection', async () => {
|
|
257
281
|
const proj = tmpProject();
|
|
258
282
|
try {
|
|
259
|
-
|
|
283
|
+
seedReadyBaseline(proj);
|
|
260
284
|
await withClient(proj, async (client) => {
|
|
261
285
|
await client.callTool({
|
|
262
286
|
name: 'change.create',
|
|
@@ -318,7 +342,7 @@ test('spec-learning MCP tools persist an approval-gated candidate and projection
|
|
|
318
342
|
test('system.doctor returns structured provider ownership and projection health', async () => {
|
|
319
343
|
const proj = tmpProject();
|
|
320
344
|
try {
|
|
321
|
-
|
|
345
|
+
seedReadyBaseline(proj);
|
|
322
346
|
await withClient(proj, async (client) => {
|
|
323
347
|
await client.callTool({
|
|
324
348
|
name: 'task.create',
|
|
@@ -359,6 +383,7 @@ test('published MCP contract contains exactly the live server tools', () => {
|
|
|
359
383
|
test('task.create + task.get round trip via MCP', async () => {
|
|
360
384
|
const proj = tmpProject();
|
|
361
385
|
try {
|
|
386
|
+
seedReadyBaseline(proj);
|
|
362
387
|
await withClient(proj, async (client) => {
|
|
363
388
|
const created = await client.callTool({
|
|
364
389
|
name: 'task.create',
|
|
@@ -379,6 +404,222 @@ test('task.create + task.get round trip via MCP', async () => {
|
|
|
379
404
|
}
|
|
380
405
|
});
|
|
381
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
|
+
|
|
382
623
|
test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets an empty state.db', async () => {
|
|
383
624
|
const proj = tmpProject();
|
|
384
625
|
try {
|
|
@@ -411,9 +652,35 @@ test('state-backed MCP tools fail closed when a non-empty v4.4 tasks.json meets
|
|
|
411
652
|
}
|
|
412
653
|
});
|
|
413
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
|
+
|
|
414
680
|
test('task.update enforces the status state machine', async () => {
|
|
415
681
|
const proj = tmpProject();
|
|
416
682
|
try {
|
|
683
|
+
seedReadyBaseline(proj);
|
|
417
684
|
await withClient(proj, async (client) => {
|
|
418
685
|
await client.callTool({
|
|
419
686
|
name: 'task.create',
|
|
@@ -443,9 +710,63 @@ test('task.update enforces the status state machine', async () => {
|
|
|
443
710
|
}
|
|
444
711
|
});
|
|
445
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
|
+
|
|
446
766
|
test('task.append_event + subscribe_events drive a monotonic cursor', async () => {
|
|
447
767
|
const proj = tmpProject();
|
|
448
768
|
try {
|
|
769
|
+
seedReadyBaseline(proj);
|
|
449
770
|
await withClient(proj, async (client) => {
|
|
450
771
|
await client.callTool({
|
|
451
772
|
name: 'task.create',
|
|
@@ -483,6 +804,7 @@ test('task.append_event + subscribe_events drive a monotonic cursor', async () =
|
|
|
483
804
|
test('task.list filters by status and tag', async () => {
|
|
484
805
|
const proj = tmpProject();
|
|
485
806
|
try {
|
|
807
|
+
seedReadyBaseline(proj);
|
|
486
808
|
await withClient(proj, async (client) => {
|
|
487
809
|
await client.callTool({ name: 'task.create', arguments: { id: 'l-1', title: 'list one', type: 'feature', priority: 'P0', tag: 'main' } });
|
|
488
810
|
await client.callTool({ name: 'task.create', arguments: { id: 'l-2', title: 'list two', type: 'feature', priority: 'P1', tag: 'main' } });
|
|
@@ -524,6 +846,7 @@ test('task.create rejects bad input via the JSON Schema validator', async () =>
|
|
|
524
846
|
test('mutating tools trigger the projector — tasks.json appears under .ultra/', async () => {
|
|
525
847
|
const proj = tmpProject();
|
|
526
848
|
try {
|
|
849
|
+
seedReadyBaseline(proj);
|
|
527
850
|
await withClient(proj, async (client) => {
|
|
528
851
|
await client.callTool({
|
|
529
852
|
name: 'task.create',
|
|
@@ -543,6 +866,7 @@ test('mutating tools trigger the projector — tasks.json appears under .ultra/'
|
|
|
543
866
|
test('output schema drift cannot strand a committed mutation without a projection job', async () => {
|
|
544
867
|
const proj = tmpProject();
|
|
545
868
|
try {
|
|
869
|
+
seedReadyBaseline(proj);
|
|
546
870
|
const runtimeRoot = path.join(proj.dir, 'runtime-contract');
|
|
547
871
|
fs.mkdirSync(path.join(runtimeRoot, 'spec', 'schemas'), { recursive: true });
|
|
548
872
|
fs.copyFileSync(path.join(REPO_ROOT, 'package.json'), path.join(runtimeRoot, 'package.json'));
|
|
@@ -646,6 +970,34 @@ test('task.init_project returns ULTRA_DIR_EXISTS on re-init without overwrite',
|
|
|
646
970
|
}
|
|
647
971
|
});
|
|
648
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
|
+
|
|
649
1001
|
async function seedTask(client, id = 's-1') {
|
|
650
1002
|
await client.callTool({
|
|
651
1003
|
name: 'task.create',
|
|
@@ -656,6 +1008,7 @@ async function seedTask(client, id = 's-1') {
|
|
|
656
1008
|
test('session.admission_check + session.spawn: happy path returns sid and paths', async () => {
|
|
657
1009
|
const proj = tmpProject();
|
|
658
1010
|
try {
|
|
1011
|
+
seedReadyBaseline(proj);
|
|
659
1012
|
await withClient(proj, async (client) => {
|
|
660
1013
|
await seedTask(client, 's-happy');
|
|
661
1014
|
|
|
@@ -702,6 +1055,7 @@ test('session.spawn rejects a retired runtime at the MCP schema boundary', async
|
|
|
702
1055
|
test('session.spawn refuses second session for same task without takeover (ADMISSION_DENIED)', async () => {
|
|
703
1056
|
const proj = tmpProject();
|
|
704
1057
|
try {
|
|
1058
|
+
seedReadyBaseline(proj);
|
|
705
1059
|
await withClient(proj, async (client) => {
|
|
706
1060
|
await seedTask(client, 's-conflict');
|
|
707
1061
|
await client.callTool({
|
|
@@ -732,6 +1086,7 @@ test('session.spawn refuses second session for same task without takeover (ADMIS
|
|
|
732
1086
|
test('session.spawn with takeover=true crashes the old session and succeeds', async () => {
|
|
733
1087
|
const proj = tmpProject();
|
|
734
1088
|
try {
|
|
1089
|
+
seedReadyBaseline(proj);
|
|
735
1090
|
await withClient(proj, async (client) => {
|
|
736
1091
|
await seedTask(client, 's-takeover');
|
|
737
1092
|
const first = readToolPayload(await client.callTool({
|
|
@@ -758,6 +1113,7 @@ test('session.spawn with takeover=true crashes the old session and succeeds', as
|
|
|
758
1113
|
test('session.subscribe_events sees task events with ≤1s latency (D31 id cursor)', async () => {
|
|
759
1114
|
const proj = tmpProject();
|
|
760
1115
|
try {
|
|
1116
|
+
seedReadyBaseline(proj);
|
|
761
1117
|
await withClient(proj, async (client) => {
|
|
762
1118
|
await seedTask(client, 's-sub');
|
|
763
1119
|
|
|
@@ -793,6 +1149,7 @@ test('session.subscribe_events sees task events with ≤1s latency (D31 id curso
|
|
|
793
1149
|
test('session.subscribe_events filters events by sid', async () => {
|
|
794
1150
|
const proj = tmpProject();
|
|
795
1151
|
try {
|
|
1152
|
+
seedReadyBaseline(proj);
|
|
796
1153
|
await withClient(proj, async (client) => {
|
|
797
1154
|
await seedTask(client, 's-filter-a');
|
|
798
1155
|
await seedTask(client, 's-filter-b');
|
|
@@ -866,6 +1223,7 @@ test('MCP rejects declared inputs that have no runtime behavior', async () => {
|
|
|
866
1223
|
test('session.heartbeat refreshes lease; session.close marks completed', async () => {
|
|
867
1224
|
const proj = tmpProject();
|
|
868
1225
|
try {
|
|
1226
|
+
seedReadyBaseline(proj);
|
|
869
1227
|
await withClient(proj, async (client) => {
|
|
870
1228
|
await seedTask(client, 's-heart');
|
|
871
1229
|
const spawn = readToolPayload(await client.callTool({
|
|
@@ -917,6 +1275,7 @@ test('retired memory tools are rejected as unknown', async () => {
|
|
|
917
1275
|
test('task.dependency_topo: happy path groups tasks into correct waves', async () => {
|
|
918
1276
|
const proj = tmpProject();
|
|
919
1277
|
try {
|
|
1278
|
+
seedReadyBaseline(proj);
|
|
920
1279
|
await withClient(proj, async (client) => {
|
|
921
1280
|
for (const [id, deps] of [['A', []], ['B', ['A']], ['C', ['A']]]) {
|
|
922
1281
|
await client.callTool({
|
|
@@ -938,42 +1297,48 @@ test('task.dependency_topo: happy path groups tasks into correct waves', async (
|
|
|
938
1297
|
}
|
|
939
1298
|
});
|
|
940
1299
|
|
|
941
|
-
test('task.parse_prd:
|
|
1300
|
+
test('task.parse_prd: missing host-derived tasks → validation error', async () => {
|
|
942
1301
|
const proj = tmpProject();
|
|
943
1302
|
try {
|
|
944
1303
|
await withClient(proj, async (client) => {
|
|
945
1304
|
const resp = await client.callTool({ name: 'task.parse_prd', arguments: {} });
|
|
946
1305
|
const err = expectError(resp);
|
|
947
|
-
assert.equal(err.code, '
|
|
1306
|
+
assert.equal(err.code, 'VALIDATION_ERROR');
|
|
948
1307
|
});
|
|
949
1308
|
} finally {
|
|
950
1309
|
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
951
1310
|
}
|
|
952
1311
|
});
|
|
953
1312
|
|
|
954
|
-
test('task.parse_prd
|
|
1313
|
+
test('task.parse_prd works without provider credentials', async () => {
|
|
955
1314
|
const proj = tmpProject();
|
|
956
1315
|
try {
|
|
957
1316
|
await withClientNoLlmKey(proj, async (client) => {
|
|
958
1317
|
const resp = await client.callTool({
|
|
959
1318
|
name: 'task.parse_prd',
|
|
960
|
-
arguments: {
|
|
1319
|
+
arguments: { tasks: prdTasks('no-key-prd'), dry_run: true },
|
|
961
1320
|
});
|
|
962
|
-
const
|
|
963
|
-
assert.equal(
|
|
1321
|
+
const data = readToolPayload(resp);
|
|
1322
|
+
assert.equal(data.tasks[0].id, 'no-key-prd');
|
|
964
1323
|
});
|
|
965
1324
|
} finally {
|
|
966
1325
|
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
967
1326
|
}
|
|
968
1327
|
});
|
|
969
1328
|
|
|
970
|
-
test('task.expand: unknown parent → TASK_NOT_FOUND
|
|
1329
|
+
test('task.expand: unknown parent → TASK_NOT_FOUND without a provider key', async () => {
|
|
971
1330
|
const proj = tmpProject();
|
|
972
1331
|
try {
|
|
973
1332
|
await withClientNoLlmKey(proj, async (client) => {
|
|
974
1333
|
const resp = await client.callTool({
|
|
975
1334
|
name: 'task.expand',
|
|
976
|
-
arguments: {
|
|
1335
|
+
arguments: {
|
|
1336
|
+
id: 'nonexistent-parent',
|
|
1337
|
+
children: [{
|
|
1338
|
+
id: 'unused-child', title: 'Unused child task',
|
|
1339
|
+
type: 'feature', priority: 'P1',
|
|
1340
|
+
}],
|
|
1341
|
+
},
|
|
977
1342
|
});
|
|
978
1343
|
const err = expectError(resp);
|
|
979
1344
|
assert.equal(err.code, 'TASK_NOT_FOUND');
|
|
@@ -983,9 +1348,10 @@ test('task.expand: unknown parent → TASK_NOT_FOUND (no LLM call needed)', asyn
|
|
|
983
1348
|
}
|
|
984
1349
|
});
|
|
985
1350
|
|
|
986
|
-
test('task.expand
|
|
1351
|
+
test('task.expand persists host-derived children without provider credentials', async () => {
|
|
987
1352
|
const proj = tmpProject();
|
|
988
1353
|
try {
|
|
1354
|
+
seedReadyBaseline(proj);
|
|
989
1355
|
await withClientNoLlmKey(proj, async (client) => {
|
|
990
1356
|
await client.callTool({
|
|
991
1357
|
name: 'task.create',
|
|
@@ -993,10 +1359,16 @@ test('task.expand: missing LLM credentials on valid parent → NO_LLM_CREDENTIAL
|
|
|
993
1359
|
});
|
|
994
1360
|
const resp = await client.callTool({
|
|
995
1361
|
name: 'task.expand',
|
|
996
|
-
arguments: {
|
|
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
|
+
},
|
|
997
1369
|
});
|
|
998
|
-
const
|
|
999
|
-
assert.equal(
|
|
1370
|
+
const data = readToolPayload(resp);
|
|
1371
|
+
assert.equal(data.children[0].id, 'child-no-key');
|
|
1000
1372
|
});
|
|
1001
1373
|
} finally {
|
|
1002
1374
|
fs.rmSync(proj.dir, { recursive: true, force: true });
|
|
@@ -1022,6 +1394,7 @@ test('plan.export: no tasks → NO_TASKS', async () => {
|
|
|
1022
1394
|
test('plan.export → plan.get round trip: artifact on disk + retrievable', async () => {
|
|
1023
1395
|
const proj = tmpProject();
|
|
1024
1396
|
try {
|
|
1397
|
+
seedReadyBaseline(proj);
|
|
1025
1398
|
await withClient(proj, async (client) => {
|
|
1026
1399
|
for (const [id, deps, files] of [
|
|
1027
1400
|
['p-a', [], ['src/a.ts']],
|
|
@@ -1068,6 +1441,7 @@ test('plan.get: no plan written yet → NO_PLAN', async () => {
|
|
|
1068
1441
|
test('task.dependency_topo: cycle returns CYCLE_DETECTED with cycles in details', async () => {
|
|
1069
1442
|
const proj = tmpProject();
|
|
1070
1443
|
try {
|
|
1444
|
+
seedReadyBaseline(proj);
|
|
1071
1445
|
await withClient(proj, async (client) => {
|
|
1072
1446
|
await client.callTool({
|
|
1073
1447
|
name: 'task.create',
|