ultra-builder-pro-cli 0.13.0 → 0.15.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 +11 -7
- package/.ultra-template/specs/architecture.md +36 -36
- package/.ultra-template/specs/discovery.md +46 -33
- package/.ultra-template/specs/product.md +38 -29
- package/.ultra-template/specs/research-distillate.md +48 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +11 -28
- package/CHANGELOG.md +86 -1
- package/README.md +40 -26
- package/adapters/_shared/codex-assets.cjs +29 -165
- package/adapters/_shared/file-ops.cjs +6 -2
- package/adapters/_shared/runtime-assets.cjs +1 -0
- package/adapters/claude.js +3 -2
- package/adapters/codex.js +1 -0
- package/adapters/kimi.js +3 -2
- package/adapters/opencode.js +33 -122
- package/agents/review-coordinator.md +9 -6
- package/docs/AGENT-CONTEXT.md +21 -11
- package/docs/ARCHITECTURE.md +41 -13
- package/docs/PLAN.zh-CN.md +3 -2
- package/docs/ROADMAP.md +9 -5
- package/docs/RUNTIME-COMPAT-MATRIX.md +6 -6
- package/docs/STATE-DB-ACCESS-POLICY.md +27 -25
- package/docs/WORKFLOW-LIFECYCLE.md +266 -0
- package/hooks/README.md +6 -1
- package/hooks/active_task_context.py +22 -50
- package/hooks/context_spine.py +61 -332
- package/hooks/health_check.py +29 -18
- package/hooks/pre_stop_check.py +28 -33
- package/hooks/subagent_tracker.py +54 -85
- 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/hook-event.cjs +34 -0
- package/mcp-server/lib/archive-journal.cjs +17 -5
- package/mcp-server/lib/baseline-workflow.cjs +158 -18
- package/mcp-server/lib/change-workflow.cjs +550 -85
- package/mcp-server/lib/context-spine.cjs +242 -24
- package/mcp-server/lib/doctor.cjs +33 -3
- package/mcp-server/lib/init-project.cjs +210 -16
- package/mcp-server/lib/plan-store.cjs +4 -1
- package/mcp-server/lib/prd-parser.cjs +14 -41
- package/mcp-server/lib/pricing.cjs +7 -10
- package/mcp-server/lib/project-breadcrumb.cjs +145 -0
- package/mcp-server/lib/projector.cjs +57 -3
- package/mcp-server/lib/spec-learning.cjs +79 -2
- package/mcp-server/lib/state-db.cjs +145 -8
- package/mcp-server/lib/state-ops.cjs +160 -9
- package/mcp-server/lib/task-expander.cjs +9 -38
- package/mcp-server/lib/workflow-state.cjs +2292 -0
- package/mcp-server/server.cjs +182 -50
- package/orchestrator/daemon.cjs +1 -2
- package/orchestrator/dispatch-rules.cjs +5 -31
- package/orchestrator/planner/plan-builder.cjs +21 -12
- package/package.json +14 -6
- package/skills/cc-collab/SKILL.md +2 -2
- package/skills/codex-collab/SKILL.md +2 -3
- package/skills/ultra-change/SKILL.md +53 -68
- package/skills/ultra-change/references/change-contract.md +36 -0
- package/skills/ultra-deliver/SKILL.md +69 -79
- package/skills/ultra-deliver/references/baseline-reconciliation.md +20 -0
- package/skills/ultra-dev/SKILL.md +54 -59
- package/skills/ultra-doctor/SKILL.md +40 -71
- package/skills/ultra-init/SKILL.md +96 -103
- package/skills/ultra-init/references/brownfield-adoption.md +2 -2
- package/skills/ultra-plan/SKILL.md +79 -75
- package/skills/ultra-plan/references/semantic-preflight.md +18 -0
- package/skills/ultra-research/SKILL.md +87 -60
- package/skills/ultra-research/references/00-problem-validation.md +36 -0
- package/skills/ultra-research/references/01-opportunity-discovery.md +33 -0
- package/skills/ultra-research/references/02-market-assessment.md +30 -0
- package/skills/ultra-research/references/03-competitive-landscape.md +31 -0
- package/skills/ultra-research/references/04-product-strategy.md +32 -0
- package/skills/ultra-research/references/05-assumptions-validation.md +31 -0
- package/skills/ultra-research/references/10-user-personas.md +29 -0
- package/skills/ultra-research/references/11-user-scenarios.md +28 -0
- package/skills/ultra-research/references/20-user-stories.md +30 -0
- package/skills/ultra-research/references/21-features-scope.md +29 -0
- package/skills/ultra-research/references/22-success-metrics.md +28 -0
- package/skills/ultra-research/references/30-architecture-context.md +30 -0
- package/skills/ultra-research/references/31-solution-strategy.md +28 -0
- package/skills/ultra-research/references/32-building-blocks.md +28 -0
- package/skills/ultra-research/references/40-deployment.md +29 -0
- package/skills/ultra-research/references/41-quality-risks.md +28 -0
- package/skills/ultra-research/references/99-synthesis.md +35 -0
- package/skills/ultra-review/SKILL.md +57 -73
- package/skills/ultra-review/references/review-modes.md +27 -0
- package/skills/ultra-review/references/unified-schema.md +25 -0
- package/skills/ultra-review/scripts/review_wait.py +40 -3
- package/skills/ultra-status/SKILL.md +41 -45
- package/skills/ultra-test/SKILL.md +48 -55
- package/skills/ultra-verify/SKILL.md +22 -31
- package/skills/ultra-verify/references/{confidence-system.md → evidence-status.md} +3 -3
- package/skills/ultra-verify/scripts/verify_wait.py +54 -54
- package/spec/cli-protocol.md +7 -2
- package/spec/mcp-tools.yaml +422 -73
- package/spec/schemas/baseline-reconciliation.v1.schema.json +73 -0
- package/spec/schemas/delivery-report.v1.schema.json +111 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +82 -1
- package/spec/schemas/tasks.v4.5.schema.json +8 -1
- package/spec/schemas/test-report.v1.schema.json +194 -0
- package/ultra-tools/cli.cjs +1 -1
- package/ultra-tools/commands/migrate.cjs +3 -3
- package/ultra-tools/commands/status.cjs +213 -12
- package/.ultra-template/delivery-report.json +0 -9
- package/.ultra-template/test-report.json +0 -8
- package/adapters/_shared/tests/file-ops.test.cjs +0 -54
- package/adapters/_shared/tests/frontmatter.test.cjs +0 -47
- package/adapters/_shared/tests/handbook.test.cjs +0 -101
- package/adapters/_shared/tests/path-rewrite.test.cjs +0 -60
- package/adapters/_shared/tests/provenance.test.cjs +0 -133
- package/adapters/_shared/tests/resolve-target.test.cjs +0 -75
- package/adapters/_shared/tests/runtime-assets.test.cjs +0 -131
- package/adapters/_shared/tests/settings-merge.test.cjs +0 -85
- package/adapters/_shared/tests/validate.test.cjs +0 -30
- package/adapters/tests/claude.test.cjs +0 -137
- package/adapters/tests/codex-hook.test.cjs +0 -115
- package/adapters/tests/codex.test.cjs +0 -423
- package/adapters/tests/kimi-hook.test.cjs +0 -122
- package/adapters/tests/kimi.test.cjs +0 -259
- package/adapters/tests/opencode.test.cjs +0 -371
- package/mcp-server/lib/archive-journal.test.cjs +0 -35
- package/mcp-server/lib/baseline-workflow.test.cjs +0 -477
- package/mcp-server/lib/change-workflow.test.cjs +0 -971
- package/mcp-server/lib/context-spine.test.cjs +0 -93
- package/mcp-server/lib/doctor.test.cjs +0 -148
- package/mcp-server/lib/llm-client.cjs +0 -157
- package/mcp-server/lib/llm-client.test.cjs +0 -156
- package/mcp-server/lib/plan-store.test.cjs +0 -132
- package/mcp-server/lib/prd-parser.test.cjs +0 -171
- package/mcp-server/lib/runtime-state.test.cjs +0 -92
- package/mcp-server/lib/spec-learning.test.cjs +0 -67
- package/mcp-server/lib/state-db.test.cjs +0 -314
- package/mcp-server/lib/task-expander.test.cjs +0 -324
- package/mcp-server/lib/topo.test.cjs +0 -158
- package/mcp-server/tests/circuit-breaker.test.cjs +0 -125
- package/mcp-server/tests/commit-hash-flow.test.cjs +0 -117
- package/mcp-server/tests/concurrency-update.test.cjs +0 -96
- package/mcp-server/tests/concurrency.test.cjs +0 -112
- package/mcp-server/tests/db-ops.test.cjs +0 -121
- package/mcp-server/tests/init-project.test.cjs +0 -393
- package/mcp-server/tests/migrate.test.cjs +0 -232
- package/mcp-server/tests/projector.test.cjs +0 -162
- package/mcp-server/tests/server-contract.test.cjs +0 -1471
- package/mcp-server/tests/staleness.test.cjs +0 -190
- package/mcp-server/tests/state-db.test.cjs +0 -106
- package/mcp-server/tests/state-ops.test.cjs +0 -449
- package/mcp-server/tests/tagged-tasks.test.cjs +0 -163
- package/mcp-server/tests/telemetry.test.cjs +0 -219
- package/orchestrator/planner/expand-prompt.cjs +0 -62
- package/orchestrator/planner/expand-prompt.test.cjs +0 -52
- package/orchestrator/planner/plan-builder.test.cjs +0 -165
- package/orchestrator/planner/prd-prompt.cjs +0 -44
- package/orchestrator/planner/prd-prompt.test.cjs +0 -30
- package/orchestrator/tests/auto-merge.test.cjs +0 -216
- package/orchestrator/tests/daemon.test.cjs +0 -311
- package/orchestrator/tests/dispatch-rules.test.cjs +0 -124
- package/orchestrator/tests/parallel-orchestrator.test.cjs +0 -233
- package/orchestrator/tests/phase-8b-gate.test.cjs +0 -183
- package/orchestrator/tests/recovery.test.cjs +0 -190
- package/orchestrator/tests/session-runner.test.cjs +0 -314
- package/orchestrator/tests/skill-miner.test.cjs +0 -147
- package/orchestrator/tests/worktree-manager.test.cjs +0 -148
- package/skills/ultra-research/references/architecture.md +0 -38
- package/skills/ultra-research/references/problem-product.md +0 -39
- package/skills/ultra-research/references/quality-delivery.md +0 -34
- package/skills/ultra-research/references/synthesis.md +0 -38
- package/skills/ultra-research/references/users-scope.md +0 -40
- package/ultra-tools/legacy-memory.test.cjs +0 -80
- package/ultra-tools/status-command.test.cjs +0 -181
- package/ultra-tools/system-command.test.cjs +0 -309
|
@@ -1,93 +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
|
-
|
|
9
|
-
const { compileRoleContext, deriveNextAction, readBreadcrumb } = require('./context-spine.cjs');
|
|
10
|
-
const { initStateDb } = require('./state-db.cjs');
|
|
11
|
-
const { createChange } = require('./change-workflow.cjs');
|
|
12
|
-
const { createTask } = require('./state-ops.cjs');
|
|
13
|
-
|
|
14
|
-
test('breadcrumb routes a project without a converged baseline back to adoption', () => {
|
|
15
|
-
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-baseline-'));
|
|
16
|
-
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
17
|
-
try {
|
|
18
|
-
const breadcrumb = readBreadcrumb(db, {}, { rootDir });
|
|
19
|
-
assert.equal(breadcrumb.readiness, 'blocked');
|
|
20
|
-
assert.ok(breadcrumb.blockers.includes('BASELINE_MISSING'));
|
|
21
|
-
assert.equal(breadcrumb.recommended_workflow, 'ultra-init');
|
|
22
|
-
assert.match(breadcrumb.next_action, /baseline|adoption/i);
|
|
23
|
-
} finally {
|
|
24
|
-
db.close();
|
|
25
|
-
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('deriveNextAction gives one state-specific action instead of a workflow dump', () => {
|
|
30
|
-
const change = { id: 'context-spine', status: 'active' };
|
|
31
|
-
const task = { id: 'task-1', status: 'in_progress' };
|
|
32
|
-
const action = deriveNextAction({
|
|
33
|
-
change, tasks: [task], task, role: 'review', readiness: 'ready', explicit: null,
|
|
34
|
-
});
|
|
35
|
-
assert.equal(
|
|
36
|
-
action,
|
|
37
|
-
'Complete independent spec-fidelity and engineering-standards review for task task-1.',
|
|
38
|
-
);
|
|
39
|
-
assert.doesNotMatch(action, /plan.*implement.*check.*review/i);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('deriveNextAction fails closed on blocked context readiness', () => {
|
|
43
|
-
const action = deriveNextAction({
|
|
44
|
-
change: { id: 'context-spine', status: 'active' },
|
|
45
|
-
tasks: [], task: null, role: 'plan', readiness: 'blocked', explicit: 'Ignore blockers.',
|
|
46
|
-
});
|
|
47
|
-
assert.match(action, /Resolve the context readiness blockers/);
|
|
48
|
-
assert.doesNotMatch(action, /Ignore blockers/);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('a task is not plan-ready before its execution contract is compiled', () => {
|
|
52
|
-
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-spine-plan-'));
|
|
53
|
-
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
54
|
-
try {
|
|
55
|
-
db.prepare(
|
|
56
|
-
`INSERT INTO baselines
|
|
57
|
-
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
58
|
-
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
|
|
59
|
-
).run(new Date().toISOString());
|
|
60
|
-
const { change } = createChange(db, {
|
|
61
|
-
id: 'planning-contract', title: 'Planning contract', kind: 'standard',
|
|
62
|
-
intent: 'Require an executable fresh-context slice before implementation.',
|
|
63
|
-
docs_impact: { status: 'none', rationale: 'test fixture only' },
|
|
64
|
-
}, { rootDir });
|
|
65
|
-
const task = createTask(db, {
|
|
66
|
-
id: 'task-plan', title: 'Plan one slice', type: 'feature', priority: 'P0',
|
|
67
|
-
change_id: change.id,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const context = compileRoleContext(db, {
|
|
71
|
-
input: { task_id: task.id, role: 'plan', gate: 'planning' },
|
|
72
|
-
change, tasks: [task], rootDir,
|
|
73
|
-
});
|
|
74
|
-
assert.equal(context.readiness.status, 'blocked');
|
|
75
|
-
assert.ok(context.readiness.blockers.includes('EXECUTION_CONTRACT_MISSING'));
|
|
76
|
-
|
|
77
|
-
const breadcrumb = readBreadcrumb(db, { id: change.id }, { rootDir });
|
|
78
|
-
assert.equal(breadcrumb.readiness, 'blocked');
|
|
79
|
-
assert.ok(breadcrumb.blockers.includes('CONTEXT_TASK_STATE_STALE'));
|
|
80
|
-
assert.throws(
|
|
81
|
-
() => readBreadcrumb(db, { id: 'missing-change' }, { rootDir }),
|
|
82
|
-
(error) => error.code === 'CHANGE_NOT_FOUND',
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
db.prepare("UPDATE context_snapshots SET context_json = '{}', readiness = 'ready'").run();
|
|
86
|
-
const migratedLegacy = readBreadcrumb(db, { id: change.id }, { rootDir });
|
|
87
|
-
assert.equal(migratedLegacy.readiness, 'blocked');
|
|
88
|
-
assert.ok(migratedLegacy.blockers.includes('CONTEXT_SNAPSHOT_UPGRADE_REQUIRED'));
|
|
89
|
-
} finally {
|
|
90
|
-
db.close();
|
|
91
|
-
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
92
|
-
}
|
|
93
|
-
});
|
|
@@ -1,148 +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
|
-
|
|
9
|
-
const { initStateDb, closeStateDb } = require('./state-db.cjs');
|
|
10
|
-
const ops = require('./state-ops.cjs');
|
|
11
|
-
const runtime = require('./runtime-state.cjs');
|
|
12
|
-
const doctor = require('./doctor.cjs');
|
|
13
|
-
const baselines = require('./baseline-workflow.cjs');
|
|
14
|
-
const changes = require('./change-workflow.cjs');
|
|
15
|
-
const archiveJournal = require('./archive-journal.cjs');
|
|
16
|
-
|
|
17
|
-
function fixture({ baseline = true } = {}) {
|
|
18
|
-
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-doctor-'));
|
|
19
|
-
const { db } = initStateDb(path.join(rootDir, '.ultra', 'state.db'));
|
|
20
|
-
if (baseline) {
|
|
21
|
-
db.prepare(
|
|
22
|
-
`INSERT INTO baselines
|
|
23
|
-
(id, project_name, mode, status, approved_by, approval_note, converged_at)
|
|
24
|
-
VALUES ('test-baseline', 'fixture', 'greenfield', 'ready', 'test', 'accepted fixture', ?)`,
|
|
25
|
-
).run(new Date().toISOString());
|
|
26
|
-
}
|
|
27
|
-
return { rootDir, db };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function cleanup({ rootDir, db }) {
|
|
31
|
-
closeStateDb(db);
|
|
32
|
-
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
test('doctor reports structured health for an initialized Ultra project', async () => {
|
|
36
|
-
const fx = fixture();
|
|
37
|
-
try {
|
|
38
|
-
const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
|
|
39
|
-
assert.equal(report.status, 'healthy');
|
|
40
|
-
assert.equal(report.repair_performed, false);
|
|
41
|
-
assert.equal(report.checks.state_db.status, 'pass');
|
|
42
|
-
assert.equal(report.checks.external_providers.ownership, 'external');
|
|
43
|
-
} finally {
|
|
44
|
-
cleanup(fx);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('doctor reports incomplete brownfield adoption as advisory rather than authority failure', async () => {
|
|
49
|
-
const fx = fixture({ baseline: false });
|
|
50
|
-
try {
|
|
51
|
-
baselines.startBaseline(fx.db, {
|
|
52
|
-
id: 'adoption', project_name: 'legacy', mode: 'brownfield', scope: ['.'],
|
|
53
|
-
}, { rootDir: fx.rootDir, emitEvent: false });
|
|
54
|
-
const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
|
|
55
|
-
assert.equal(report.status, 'healthy');
|
|
56
|
-
assert.equal(report.checks.baseline.status, 'warning');
|
|
57
|
-
assert.equal(report.checks.baseline.mode, 'brownfield');
|
|
58
|
-
assert.deepEqual(report.checks.baseline.gaps, {
|
|
59
|
-
total: 0, open: 0, blocking: 0, by_category: {},
|
|
60
|
-
});
|
|
61
|
-
assert.ok(report.checks.baseline.blockers.includes('BASELINE_NOT_READY:adopting'));
|
|
62
|
-
} finally {
|
|
63
|
-
cleanup(fx);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test('doctor repair is backup-first and drains projection/spec-event recovery work', async () => {
|
|
68
|
-
const fx = fixture();
|
|
69
|
-
try {
|
|
70
|
-
ops.createTask(fx.db, {
|
|
71
|
-
id: 'stale-target', title: 'Refresh context', type: 'feature', priority: 'P1',
|
|
72
|
-
trace_to: '.ultra/specs/product.md#continuous-change',
|
|
73
|
-
});
|
|
74
|
-
ops.appendEvent(fx.db, {
|
|
75
|
-
type: 'spec_changed',
|
|
76
|
-
payload: { sections: ['.ultra/specs/product.md#continuous-change'] },
|
|
77
|
-
});
|
|
78
|
-
runtime.enqueueProjection(fx.db, { tool_name: 'task.append_event', event_cursor: 2 });
|
|
79
|
-
|
|
80
|
-
const report = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir, repair: true });
|
|
81
|
-
assert.equal(report.repair_performed, true);
|
|
82
|
-
assert.ok(fs.existsSync(report.backup_path));
|
|
83
|
-
assert.equal(ops.readTask(fx.db, 'stale-target').stale, true);
|
|
84
|
-
assert.equal(runtime.readConsumerCursor(fx.db, 'spec-staleness'), 2);
|
|
85
|
-
assert.equal(runtime.listProjectionJobs(fx.db, { status: 'pending' }).length, 0);
|
|
86
|
-
assert.equal(report.status, 'healthy');
|
|
87
|
-
} finally {
|
|
88
|
-
cleanup(fx);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('doctor reports running projection work and repairs only stale interrupted jobs', async () => {
|
|
93
|
-
const fx = fixture();
|
|
94
|
-
try {
|
|
95
|
-
ops.appendEvent(fx.db, { type: 'spec_changed', payload: { sections: [] } });
|
|
96
|
-
const job = runtime.enqueueProjection(fx.db, { tool_name: 'task.append_event', event_cursor: 1 });
|
|
97
|
-
fx.db.prepare("UPDATE projection_jobs SET status = 'running', updated_at = ? WHERE id = ?")
|
|
98
|
-
.run('2000-01-01T00:00:00.000Z', job.id);
|
|
99
|
-
|
|
100
|
-
const before = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir });
|
|
101
|
-
assert.equal(before.status, 'degraded');
|
|
102
|
-
assert.equal(before.checks.projections.running, 1);
|
|
103
|
-
|
|
104
|
-
const after = await doctor.runDoctor(fx.db, {
|
|
105
|
-
rootDir: fx.rootDir,
|
|
106
|
-
repair: true,
|
|
107
|
-
projectionStaleAfterMs: 0,
|
|
108
|
-
});
|
|
109
|
-
assert.equal(after.repair.interrupted.requeued, 1);
|
|
110
|
-
assert.equal(after.checks.projections.running, 0);
|
|
111
|
-
assert.equal(after.status, 'healthy');
|
|
112
|
-
} finally {
|
|
113
|
-
cleanup(fx);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test('doctor repair resumes an archive journal left after a process crash', async () => {
|
|
118
|
-
const fx = fixture();
|
|
119
|
-
try {
|
|
120
|
-
const activeRoot = path.join(fx.rootDir, '.ultra', 'changes', 'active', 'doctor-archive');
|
|
121
|
-
fs.mkdirSync(activeRoot, { recursive: true });
|
|
122
|
-
fs.writeFileSync(path.join(activeRoot, 'intent.md'), '# Authorized archive\n');
|
|
123
|
-
fx.db.prepare(
|
|
124
|
-
`INSERT INTO changes
|
|
125
|
-
(id, title, kind, status, intent, docs_impact_json, provider_refs_json, artifact_root)
|
|
126
|
-
VALUES ('doctor-archive', 'Recover archive', 'quick', 'ready', 'Recover it',
|
|
127
|
-
'{"status":"none","files":[],"rationale":"recovery"}', '{}',
|
|
128
|
-
'.ultra/changes/active/doctor-archive')`,
|
|
129
|
-
).run();
|
|
130
|
-
const prepared = archiveJournal.prepareArchiveMove({
|
|
131
|
-
rootDir: fx.rootDir, change: changes.readChange(fx.db, 'doctor-archive'),
|
|
132
|
-
summary: 'Resume the authorized archive.', baselineUpdates: [],
|
|
133
|
-
noBaselineChangeReason: 'No baseline content changed.',
|
|
134
|
-
});
|
|
135
|
-
assert.equal(fs.existsSync(prepared.source), false);
|
|
136
|
-
const before = doctor.inspectSystem(fx.db, { rootDir: fx.rootDir });
|
|
137
|
-
assert.equal(before.status, 'degraded');
|
|
138
|
-
assert.equal(before.checks.archive_recovery.pending, 1);
|
|
139
|
-
|
|
140
|
-
const after = await doctor.runDoctor(fx.db, { rootDir: fx.rootDir, repair: true });
|
|
141
|
-
assert.equal(after.repair.archives.resumed, 1);
|
|
142
|
-
assert.equal(changes.readChange(fx.db, 'doctor-archive').status, 'archived');
|
|
143
|
-
assert.equal(after.checks.archive_recovery.pending, 0);
|
|
144
|
-
assert.equal(fs.existsSync(path.join(prepared.destination, archiveJournal.INTENT_FILE)), false);
|
|
145
|
-
} finally {
|
|
146
|
-
cleanup(fx);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Phase 8A.1 — Unified LLM client over @anthropic-ai/sdk and openai.
|
|
4
|
-
//
|
|
5
|
-
// Provider is inferred from model prefix (claude/opus/sonnet/haiku → Anthropic,
|
|
6
|
-
// gpt/o1/o3 → OpenAI). API keys come from env. The single surface the rest of
|
|
7
|
-
// the code depends on is `client.completeJson({ system, user, maxTokens })`,
|
|
8
|
-
// which returns `{ json, raw, usage, model, provider }` — the model's reply
|
|
9
|
-
// already parsed as JSON (caller handles schema validation on `.json`).
|
|
10
|
-
|
|
11
|
-
const Anthropic = require('@anthropic-ai/sdk');
|
|
12
|
-
const OpenAI = require('openai');
|
|
13
|
-
|
|
14
|
-
const PROVIDER_RULES = Object.freeze([
|
|
15
|
-
{ pattern: /^(claude|opus|sonnet|haiku)/i, provider: 'anthropic' },
|
|
16
|
-
{ pattern: /^(gpt|o1|o3)/i, provider: 'openai' },
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
20
|
-
const DEFAULT_MAX_TOKENS = 4096;
|
|
21
|
-
|
|
22
|
-
class LlmClientError extends Error {
|
|
23
|
-
constructor(code, message, cause) {
|
|
24
|
-
super(message);
|
|
25
|
-
this.name = 'LlmClientError';
|
|
26
|
-
this.code = code;
|
|
27
|
-
if (cause) this.cause = cause;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function inferProvider(model) {
|
|
32
|
-
if (!model || typeof model !== 'string') return null;
|
|
33
|
-
for (const rule of PROVIDER_RULES) {
|
|
34
|
-
if (rule.pattern.test(model)) return rule.provider;
|
|
35
|
-
}
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function resolveProviderKey(provider, apiKey) {
|
|
40
|
-
if (apiKey) return apiKey;
|
|
41
|
-
return provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function createLlmClient({ model, apiKey } = {}) {
|
|
45
|
-
const resolvedModel = model || process.env.UBP_PLANNER_MODEL || DEFAULT_MODEL;
|
|
46
|
-
const provider = inferProvider(resolvedModel);
|
|
47
|
-
if (!provider) {
|
|
48
|
-
throw new LlmClientError(
|
|
49
|
-
'LLM_UNSUPPORTED_MODEL',
|
|
50
|
-
`cannot infer provider from model "${resolvedModel}"`,
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
const key = resolveProviderKey(provider, apiKey);
|
|
54
|
-
if (!key) {
|
|
55
|
-
const envName = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
|
|
56
|
-
throw new LlmClientError('NO_LLM_CREDENTIALS', `${envName} not set`);
|
|
57
|
-
}
|
|
58
|
-
return provider === 'anthropic'
|
|
59
|
-
? buildAnthropicAdapter({ model: resolvedModel, apiKey: key })
|
|
60
|
-
: buildOpenAiAdapter({ model: resolvedModel, apiKey: key });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function buildAnthropicAdapter({ model, apiKey }) {
|
|
64
|
-
const sdk = new Anthropic({ apiKey });
|
|
65
|
-
return {
|
|
66
|
-
provider: 'anthropic',
|
|
67
|
-
model,
|
|
68
|
-
async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
69
|
-
let message;
|
|
70
|
-
try {
|
|
71
|
-
message = await sdk.messages.create({
|
|
72
|
-
model,
|
|
73
|
-
max_tokens: maxTokens,
|
|
74
|
-
system,
|
|
75
|
-
messages: [{ role: 'user', content: user }],
|
|
76
|
-
});
|
|
77
|
-
} catch (err) {
|
|
78
|
-
throw new LlmClientError('LLM_API_ERROR', `Anthropic API error: ${err.message}`, err);
|
|
79
|
-
}
|
|
80
|
-
const text = extractAnthropicText(message.content);
|
|
81
|
-
const usage = {
|
|
82
|
-
input_tokens: message.usage?.input_tokens ?? 0,
|
|
83
|
-
output_tokens: message.usage?.output_tokens ?? 0,
|
|
84
|
-
};
|
|
85
|
-
return parseCompletionJson({ raw: text, usage, model, provider: 'anthropic' });
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function buildOpenAiAdapter({ model, apiKey }) {
|
|
91
|
-
const sdk = new OpenAI({ apiKey });
|
|
92
|
-
return {
|
|
93
|
-
provider: 'openai',
|
|
94
|
-
model,
|
|
95
|
-
async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
96
|
-
let completion;
|
|
97
|
-
try {
|
|
98
|
-
completion = await sdk.chat.completions.create({
|
|
99
|
-
model,
|
|
100
|
-
max_tokens: maxTokens,
|
|
101
|
-
response_format: { type: 'json_object' },
|
|
102
|
-
messages: [
|
|
103
|
-
{ role: 'system', content: system },
|
|
104
|
-
{ role: 'user', content: user },
|
|
105
|
-
],
|
|
106
|
-
});
|
|
107
|
-
} catch (err) {
|
|
108
|
-
throw new LlmClientError('LLM_API_ERROR', `OpenAI API error: ${err.message}`, err);
|
|
109
|
-
}
|
|
110
|
-
const text = completion.choices?.[0]?.message?.content ?? '';
|
|
111
|
-
const usage = {
|
|
112
|
-
input_tokens: completion.usage?.prompt_tokens ?? 0,
|
|
113
|
-
output_tokens: completion.usage?.completion_tokens ?? 0,
|
|
114
|
-
};
|
|
115
|
-
return parseCompletionJson({ raw: text, usage, model, provider: 'openai' });
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function extractAnthropicText(content) {
|
|
121
|
-
if (!Array.isArray(content)) return '';
|
|
122
|
-
return content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('');
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function parseCompletionJson({ raw, usage, model, provider }) {
|
|
126
|
-
const trimmed = stripCodeFence(raw).trim();
|
|
127
|
-
if (!trimmed) {
|
|
128
|
-
throw new LlmClientError('LLM_INVALID_JSON', 'LLM returned empty content');
|
|
129
|
-
}
|
|
130
|
-
let json;
|
|
131
|
-
try {
|
|
132
|
-
json = JSON.parse(trimmed);
|
|
133
|
-
} catch (err) {
|
|
134
|
-
throw new LlmClientError(
|
|
135
|
-
'LLM_INVALID_JSON',
|
|
136
|
-
`failed to parse LLM response as JSON: ${err.message}`,
|
|
137
|
-
err,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
return { json, raw, usage, model, provider };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function stripCodeFence(s) {
|
|
144
|
-
if (!s || typeof s !== 'string') return '';
|
|
145
|
-
const fenced = s.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
|
|
146
|
-
return fenced ? fenced[1] : s;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
module.exports = {
|
|
150
|
-
createLlmClient,
|
|
151
|
-
inferProvider,
|
|
152
|
-
LlmClientError,
|
|
153
|
-
parseCompletionJson,
|
|
154
|
-
stripCodeFence,
|
|
155
|
-
DEFAULT_MODEL,
|
|
156
|
-
DEFAULT_MAX_TOKENS,
|
|
157
|
-
};
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const test = require('node:test');
|
|
4
|
-
const assert = require('node:assert/strict');
|
|
5
|
-
|
|
6
|
-
const {
|
|
7
|
-
createLlmClient,
|
|
8
|
-
inferProvider,
|
|
9
|
-
LlmClientError,
|
|
10
|
-
parseCompletionJson,
|
|
11
|
-
stripCodeFence,
|
|
12
|
-
DEFAULT_MODEL,
|
|
13
|
-
} = require('./llm-client.cjs');
|
|
14
|
-
|
|
15
|
-
test('inferProvider: claude family → anthropic', () => {
|
|
16
|
-
assert.equal(inferProvider('claude-sonnet-4-6'), 'anthropic');
|
|
17
|
-
assert.equal(inferProvider('claude-opus-4-7'), 'anthropic');
|
|
18
|
-
assert.equal(inferProvider('sonnet-legacy'), 'anthropic');
|
|
19
|
-
assert.equal(inferProvider('opus-anything'), 'anthropic');
|
|
20
|
-
assert.equal(inferProvider('haiku-4-5'), 'anthropic');
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test('inferProvider: GPT / o1 / o3 → openai', () => {
|
|
24
|
-
assert.equal(inferProvider('gpt-4o'), 'openai');
|
|
25
|
-
assert.equal(inferProvider('gpt-5.4'), 'openai');
|
|
26
|
-
assert.equal(inferProvider('o1-preview'), 'openai');
|
|
27
|
-
assert.equal(inferProvider('o3-mini'), 'openai');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('inferProvider: unknown prefix → null', () => {
|
|
31
|
-
assert.equal(inferProvider('llama-3'), null);
|
|
32
|
-
assert.equal(inferProvider(''), null);
|
|
33
|
-
assert.equal(inferProvider(null), null);
|
|
34
|
-
assert.equal(inferProvider(undefined), null);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('stripCodeFence: removes ```json ... ``` fence', () => {
|
|
38
|
-
const input = '```json\n{"a":1}\n```';
|
|
39
|
-
assert.equal(stripCodeFence(input).trim(), '{"a":1}');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('stripCodeFence: removes plain ``` ... ``` fence', () => {
|
|
43
|
-
const input = '```\n{"a":1}\n```';
|
|
44
|
-
assert.equal(stripCodeFence(input).trim(), '{"a":1}');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('stripCodeFence: leaves unfenced content alone', () => {
|
|
48
|
-
assert.equal(stripCodeFence('{"a":1}'), '{"a":1}');
|
|
49
|
-
assert.equal(stripCodeFence(' '), ' ');
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('stripCodeFence: empty / nullish → empty string', () => {
|
|
53
|
-
assert.equal(stripCodeFence(''), '');
|
|
54
|
-
assert.equal(stripCodeFence(null), '');
|
|
55
|
-
assert.equal(stripCodeFence(undefined), '');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('parseCompletionJson: valid JSON → parsed object', () => {
|
|
59
|
-
const r = parseCompletionJson({
|
|
60
|
-
raw: '{"ok":true,"n":2}',
|
|
61
|
-
usage: { input_tokens: 10, output_tokens: 5 },
|
|
62
|
-
model: 'claude-sonnet-4-6',
|
|
63
|
-
provider: 'anthropic',
|
|
64
|
-
});
|
|
65
|
-
assert.deepEqual(r.json, { ok: true, n: 2 });
|
|
66
|
-
assert.equal(r.usage.input_tokens, 10);
|
|
67
|
-
assert.equal(r.model, 'claude-sonnet-4-6');
|
|
68
|
-
assert.equal(r.provider, 'anthropic');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('parseCompletionJson: fenced JSON → parsed object', () => {
|
|
72
|
-
const r = parseCompletionJson({
|
|
73
|
-
raw: '```json\n{"x":42}\n```',
|
|
74
|
-
usage: {},
|
|
75
|
-
model: 'gpt-5',
|
|
76
|
-
provider: 'openai',
|
|
77
|
-
});
|
|
78
|
-
assert.deepEqual(r.json, { x: 42 });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('parseCompletionJson: empty content → LLM_INVALID_JSON', () => {
|
|
82
|
-
assert.throws(
|
|
83
|
-
() => parseCompletionJson({ raw: '', usage: {}, model: 'x', provider: 'anthropic' }),
|
|
84
|
-
(err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('parseCompletionJson: malformed JSON → LLM_INVALID_JSON', () => {
|
|
89
|
-
assert.throws(
|
|
90
|
-
() => parseCompletionJson({ raw: '{not json', usage: {}, model: 'x', provider: 'anthropic' }),
|
|
91
|
-
(err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('createLlmClient: unknown model → LLM_UNSUPPORTED_MODEL', () => {
|
|
96
|
-
assert.throws(
|
|
97
|
-
() => createLlmClient({ model: 'llama-3', apiKey: 'sk-test' }),
|
|
98
|
-
(err) => err instanceof LlmClientError && err.code === 'LLM_UNSUPPORTED_MODEL',
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('createLlmClient: missing key → NO_LLM_CREDENTIALS', () => {
|
|
103
|
-
const savedA = process.env.ANTHROPIC_API_KEY;
|
|
104
|
-
const savedO = process.env.OPENAI_API_KEY;
|
|
105
|
-
delete process.env.ANTHROPIC_API_KEY;
|
|
106
|
-
delete process.env.OPENAI_API_KEY;
|
|
107
|
-
try {
|
|
108
|
-
assert.throws(
|
|
109
|
-
() => createLlmClient({ model: 'claude-sonnet-4-6' }),
|
|
110
|
-
(err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
|
|
111
|
-
);
|
|
112
|
-
assert.throws(
|
|
113
|
-
() => createLlmClient({ model: 'gpt-5' }),
|
|
114
|
-
(err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
|
|
115
|
-
);
|
|
116
|
-
} finally {
|
|
117
|
-
if (savedA !== undefined) process.env.ANTHROPIC_API_KEY = savedA;
|
|
118
|
-
if (savedO !== undefined) process.env.OPENAI_API_KEY = savedO;
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('createLlmClient: explicit apiKey bypasses env check + returns adapter shape', () => {
|
|
123
|
-
const anthropicClient = createLlmClient({ model: 'claude-sonnet-4-6', apiKey: 'sk-test' });
|
|
124
|
-
assert.equal(anthropicClient.provider, 'anthropic');
|
|
125
|
-
assert.equal(anthropicClient.model, 'claude-sonnet-4-6');
|
|
126
|
-
assert.equal(typeof anthropicClient.completeJson, 'function');
|
|
127
|
-
|
|
128
|
-
const openaiClient = createLlmClient({ model: 'gpt-5', apiKey: 'sk-test' });
|
|
129
|
-
assert.equal(openaiClient.provider, 'openai');
|
|
130
|
-
assert.equal(openaiClient.model, 'gpt-5');
|
|
131
|
-
assert.equal(typeof openaiClient.completeJson, 'function');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('createLlmClient: defaults model from env UBP_PLANNER_MODEL', () => {
|
|
135
|
-
const saved = process.env.UBP_PLANNER_MODEL;
|
|
136
|
-
process.env.UBP_PLANNER_MODEL = 'gpt-5.4';
|
|
137
|
-
try {
|
|
138
|
-
const c = createLlmClient({ apiKey: 'sk-test' });
|
|
139
|
-
assert.equal(c.model, 'gpt-5.4');
|
|
140
|
-
assert.equal(c.provider, 'openai');
|
|
141
|
-
} finally {
|
|
142
|
-
if (saved === undefined) delete process.env.UBP_PLANNER_MODEL;
|
|
143
|
-
else process.env.UBP_PLANNER_MODEL = saved;
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test('createLlmClient: falls back to DEFAULT_MODEL when nothing supplied', () => {
|
|
148
|
-
const savedEnv = process.env.UBP_PLANNER_MODEL;
|
|
149
|
-
delete process.env.UBP_PLANNER_MODEL;
|
|
150
|
-
try {
|
|
151
|
-
const c = createLlmClient({ apiKey: 'sk-test' });
|
|
152
|
-
assert.equal(c.model, DEFAULT_MODEL);
|
|
153
|
-
} finally {
|
|
154
|
-
if (savedEnv !== undefined) process.env.UBP_PLANNER_MODEL = savedEnv;
|
|
155
|
-
}
|
|
156
|
-
});
|
|
@@ -1,132 +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
|
-
|
|
9
|
-
const {
|
|
10
|
-
savePlanArtifact,
|
|
11
|
-
loadPlanArtifact,
|
|
12
|
-
selectSection,
|
|
13
|
-
renderPlanMd,
|
|
14
|
-
DEFAULT_ARTIFACT_RELPATH,
|
|
15
|
-
} = require('./plan-store.cjs');
|
|
16
|
-
|
|
17
|
-
function tmpRoot() {
|
|
18
|
-
return fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-plan-'));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const SAMPLE_PLAN = Object.freeze({
|
|
22
|
-
waves: [
|
|
23
|
-
{ id: 1, tasks: ['a', 'b'], parallel: true },
|
|
24
|
-
{ id: 2, tasks: ['c'], parallel: false },
|
|
25
|
-
],
|
|
26
|
-
ownership_forecast: { a: ['src/a.ts'], b: ['src/b.ts'], c: ['src/c.ts'] },
|
|
27
|
-
conflict_surface: [],
|
|
28
|
-
estimated_cost_usd: 0.12,
|
|
29
|
-
estimated_duration_min: 35,
|
|
30
|
-
cycles: [],
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('savePlanArtifact: json format writes a file we can JSON.parse back', () => {
|
|
34
|
-
const root = tmpRoot();
|
|
35
|
-
try {
|
|
36
|
-
const outPath = path.join(root, '.ultra', 'execution-plan.json');
|
|
37
|
-
const { plan_path } = savePlanArtifact(SAMPLE_PLAN, outPath, 'json');
|
|
38
|
-
assert.equal(plan_path, path.resolve(outPath));
|
|
39
|
-
assert.ok(fs.existsSync(plan_path));
|
|
40
|
-
const parsed = JSON.parse(fs.readFileSync(plan_path, 'utf8'));
|
|
41
|
-
assert.deepEqual(parsed.waves, SAMPLE_PLAN.waves);
|
|
42
|
-
} finally {
|
|
43
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('savePlanArtifact: md format writes a human-readable report', () => {
|
|
48
|
-
const root = tmpRoot();
|
|
49
|
-
try {
|
|
50
|
-
const outPath = path.join(root, '.ultra', 'execution-plan.md');
|
|
51
|
-
savePlanArtifact(SAMPLE_PLAN, outPath, 'md');
|
|
52
|
-
const text = fs.readFileSync(outPath, 'utf8');
|
|
53
|
-
assert.match(text, /# Execution Plan/);
|
|
54
|
-
assert.match(text, /Wave 1/);
|
|
55
|
-
assert.match(text, /parallel/);
|
|
56
|
-
} finally {
|
|
57
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('savePlanArtifact: missing out_path → WRITE_FAILED', () => {
|
|
62
|
-
assert.throws(
|
|
63
|
-
() => savePlanArtifact(SAMPLE_PLAN, '', 'json'),
|
|
64
|
-
(err) => err.code === 'WRITE_FAILED',
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('loadPlanArtifact: no file → null', () => {
|
|
69
|
-
const root = tmpRoot();
|
|
70
|
-
try {
|
|
71
|
-
assert.equal(loadPlanArtifact(root), null);
|
|
72
|
-
} finally {
|
|
73
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('loadPlanArtifact: round-trip via DEFAULT_ARTIFACT_RELPATH', () => {
|
|
78
|
-
const root = tmpRoot();
|
|
79
|
-
try {
|
|
80
|
-
const target = path.join(root, DEFAULT_ARTIFACT_RELPATH);
|
|
81
|
-
savePlanArtifact(SAMPLE_PLAN, target, 'json');
|
|
82
|
-
const loaded = loadPlanArtifact(root);
|
|
83
|
-
assert.deepEqual(loaded.waves, SAMPLE_PLAN.waves);
|
|
84
|
-
assert.equal(loaded.estimated_cost_usd, 0.12);
|
|
85
|
-
} finally {
|
|
86
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('loadPlanArtifact: corrupt file → null (no throw)', () => {
|
|
91
|
-
const root = tmpRoot();
|
|
92
|
-
try {
|
|
93
|
-
const target = path.join(root, DEFAULT_ARTIFACT_RELPATH);
|
|
94
|
-
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
95
|
-
fs.writeFileSync(target, '{not json');
|
|
96
|
-
assert.equal(loadPlanArtifact(root), null);
|
|
97
|
-
} finally {
|
|
98
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test('selectSection: tasks returns ownership_forecast only', () => {
|
|
103
|
-
const r = selectSection(SAMPLE_PLAN, 'tasks');
|
|
104
|
-
assert.deepEqual(Object.keys(r), ['ownership_forecast']);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('selectSection: topo returns waves only', () => {
|
|
108
|
-
const r = selectSection(SAMPLE_PLAN, 'topo');
|
|
109
|
-
assert.deepEqual(Object.keys(r), ['waves']);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('selectSection: conflicts returns conflict_surface only', () => {
|
|
113
|
-
const r = selectSection(SAMPLE_PLAN, 'conflicts');
|
|
114
|
-
assert.deepEqual(Object.keys(r), ['conflict_surface']);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test('selectSection: all (default) returns whole plan', () => {
|
|
118
|
-
assert.deepEqual(selectSection(SAMPLE_PLAN, 'all'), SAMPLE_PLAN);
|
|
119
|
-
assert.deepEqual(selectSection(SAMPLE_PLAN), SAMPLE_PLAN);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('renderPlanMd: reports cycles + conflicts counts when present', () => {
|
|
123
|
-
const planWithConflicts = {
|
|
124
|
-
...SAMPLE_PLAN,
|
|
125
|
-
conflict_surface: [{ files: ['x.ts'], tasks: ['a', 'b'], recommend: 'sequentialize' }],
|
|
126
|
-
cycles: [['z', 'y']],
|
|
127
|
-
};
|
|
128
|
-
const md = renderPlanMd(planWithConflicts);
|
|
129
|
-
assert.match(md, /Conflicts: 1/);
|
|
130
|
-
assert.match(md, /Cycles: 1/);
|
|
131
|
-
assert.match(md, /## Conflict Surface/);
|
|
132
|
-
});
|