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,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 6.4 — code-graph watcher:
|
|
4
|
+
// • fs-watch project code paths, debounce bursts, deliver batches via
|
|
5
|
+
// onBatch callback. The watcher itself does not talk to the external
|
|
6
|
+
// code-review-graph MCP server — that glue lives in callers (daemon /
|
|
7
|
+
// CLI / smoke scripts) so the watcher stays testable with just an
|
|
8
|
+
// onBatch spy.
|
|
9
|
+
|
|
10
|
+
const { test } = require('node:test');
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const watcher = require('../code-graph-watcher.cjs');
|
|
17
|
+
|
|
18
|
+
function mkTree(layout = {}) {
|
|
19
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-watch-'));
|
|
20
|
+
for (const [rel, content] of Object.entries(layout)) {
|
|
21
|
+
const file = path.join(root, rel);
|
|
22
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
+
fs.writeFileSync(file, content);
|
|
24
|
+
}
|
|
25
|
+
return root;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cleanupTree(root) {
|
|
29
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function waitForBatches(batches, count, timeoutMs = 1500) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const start = Date.now();
|
|
35
|
+
const timer = setInterval(() => {
|
|
36
|
+
if (batches.length >= count) { clearInterval(timer); resolve(); return; }
|
|
37
|
+
if (Date.now() - start > timeoutMs) { clearInterval(timer); reject(new Error(`timed out waiting for ${count} batches, got ${batches.length}`)); }
|
|
38
|
+
}, 20);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function waitStable(handle) {
|
|
43
|
+
// Block on chokidar's 'ready' so initial add events from pre-existing
|
|
44
|
+
// files don't leak into test assertions.
|
|
45
|
+
await handle.ready();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('touch a watched file → onBatch fires with that path after debounce', async () => {
|
|
49
|
+
const root = mkTree({ 'src/a.js': '// initial\n' });
|
|
50
|
+
const batches = [];
|
|
51
|
+
const handle = watcher.startWatcher({
|
|
52
|
+
repoRoot: root,
|
|
53
|
+
debounceMs: 80,
|
|
54
|
+
onBatch: (b) => batches.push(b),
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
await waitStable(handle);
|
|
58
|
+
fs.writeFileSync(path.join(root, 'src/a.js'), '// changed\n');
|
|
59
|
+
await waitForBatches(batches, 1);
|
|
60
|
+
assert.equal(batches.length, 1);
|
|
61
|
+
assert.ok(batches[0].changes.some((c) => c.path.endsWith('src/a.js')));
|
|
62
|
+
} finally {
|
|
63
|
+
handle.stop();
|
|
64
|
+
cleanupTree(root);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('debounce merges bursts: 3 writes within window → 1 batch', async () => {
|
|
69
|
+
const root = mkTree({ 'src/a.js': '0', 'src/b.js': '0', 'src/c.js': '0' });
|
|
70
|
+
const batches = [];
|
|
71
|
+
const handle = watcher.startWatcher({
|
|
72
|
+
repoRoot: root,
|
|
73
|
+
debounceMs: 100,
|
|
74
|
+
onBatch: (b) => batches.push(b),
|
|
75
|
+
});
|
|
76
|
+
try {
|
|
77
|
+
await waitStable(handle);
|
|
78
|
+
fs.writeFileSync(path.join(root, 'src/a.js'), '1');
|
|
79
|
+
fs.writeFileSync(path.join(root, 'src/b.js'), '1');
|
|
80
|
+
fs.writeFileSync(path.join(root, 'src/c.js'), '1');
|
|
81
|
+
await waitForBatches(batches, 1);
|
|
82
|
+
// Give a bit extra time in case a 2nd batch is coming (it shouldn't).
|
|
83
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
84
|
+
assert.equal(batches.length, 1, `expected 1 merged batch, got ${batches.length}`);
|
|
85
|
+
const paths = new Set(batches[0].changes.map((c) => path.basename(c.path)));
|
|
86
|
+
assert.ok(paths.has('a.js') && paths.has('b.js') && paths.has('c.js'));
|
|
87
|
+
} finally {
|
|
88
|
+
handle.stop();
|
|
89
|
+
cleanupTree(root);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('excludePaths: node_modules changes are ignored', async () => {
|
|
94
|
+
const root = mkTree({
|
|
95
|
+
'src/a.js': '0',
|
|
96
|
+
'node_modules/lib/x.js': '0',
|
|
97
|
+
});
|
|
98
|
+
const batches = [];
|
|
99
|
+
const handle = watcher.startWatcher({
|
|
100
|
+
repoRoot: root,
|
|
101
|
+
debounceMs: 80,
|
|
102
|
+
onBatch: (b) => batches.push(b),
|
|
103
|
+
});
|
|
104
|
+
try {
|
|
105
|
+
await waitStable(handle);
|
|
106
|
+
fs.writeFileSync(path.join(root, 'node_modules/lib/x.js'), '1');
|
|
107
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
108
|
+
// If only node_modules was changed, no batch should fire.
|
|
109
|
+
assert.equal(batches.length, 0, 'node_modules write should be ignored');
|
|
110
|
+
// Sanity: a real source change still fires.
|
|
111
|
+
fs.writeFileSync(path.join(root, 'src/a.js'), '1');
|
|
112
|
+
await waitForBatches(batches, 1);
|
|
113
|
+
assert.equal(batches.length, 1);
|
|
114
|
+
assert.ok(batches[0].changes.every((c) => !c.path.includes('node_modules')));
|
|
115
|
+
} finally {
|
|
116
|
+
handle.stop();
|
|
117
|
+
cleanupTree(root);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('stop(): subsequent writes do not deliver', async () => {
|
|
122
|
+
const root = mkTree({ 'src/a.js': '0' });
|
|
123
|
+
const batches = [];
|
|
124
|
+
const handle = watcher.startWatcher({
|
|
125
|
+
repoRoot: root,
|
|
126
|
+
debounceMs: 80,
|
|
127
|
+
onBatch: (b) => batches.push(b),
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
await waitStable(handle);
|
|
131
|
+
handle.stop();
|
|
132
|
+
fs.writeFileSync(path.join(root, 'src/a.js'), '1');
|
|
133
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
134
|
+
assert.equal(batches.length, 0);
|
|
135
|
+
} finally {
|
|
136
|
+
cleanupTree(root);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('large_batch flag: >threshold changes in one burst', async () => {
|
|
141
|
+
const files = {};
|
|
142
|
+
for (let i = 0; i < 12; i++) files[`src/f${i}.js`] = '0';
|
|
143
|
+
const root = mkTree(files);
|
|
144
|
+
const batches = [];
|
|
145
|
+
const handle = watcher.startWatcher({
|
|
146
|
+
repoRoot: root,
|
|
147
|
+
debounceMs: 120,
|
|
148
|
+
largeBatchThreshold: 10, // lower for test speed
|
|
149
|
+
onBatch: (b) => batches.push(b),
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
await waitStable(handle);
|
|
153
|
+
for (let i = 0; i < 12; i++) fs.writeFileSync(path.join(root, `src/f${i}.js`), '1');
|
|
154
|
+
await waitForBatches(batches, 1);
|
|
155
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
156
|
+
assert.equal(batches.length, 1);
|
|
157
|
+
assert.equal(batches[0].changes.length, 12);
|
|
158
|
+
assert.equal(batches[0].large_batch, true);
|
|
159
|
+
} finally {
|
|
160
|
+
handle.stop();
|
|
161
|
+
cleanupTree(root);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('non-code files ignored (e.g. .log, .tmp)', async () => {
|
|
166
|
+
const root = mkTree({ 'src/a.js': '0', 'run.log': '0', 'tmp.tmp': '0' });
|
|
167
|
+
const batches = [];
|
|
168
|
+
const handle = watcher.startWatcher({
|
|
169
|
+
repoRoot: root,
|
|
170
|
+
debounceMs: 80,
|
|
171
|
+
onBatch: (b) => batches.push(b),
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
await waitStable(handle);
|
|
175
|
+
fs.writeFileSync(path.join(root, 'run.log'), '1');
|
|
176
|
+
fs.writeFileSync(path.join(root, 'tmp.tmp'), '1');
|
|
177
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
178
|
+
assert.equal(batches.length, 0);
|
|
179
|
+
} finally {
|
|
180
|
+
handle.stop();
|
|
181
|
+
cleanupTree(root);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 5.4 — Orchestrator daemon:
|
|
4
|
+
// • routeTask: pure function — complexity_hint + availableRuntimes → runtime
|
|
5
|
+
// • runDaemon: poll loop that spawns pending tasks, respects admission +
|
|
6
|
+
// circuit breaker, and shuts down gracefully via stop().
|
|
7
|
+
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
const assert = require('node:assert/strict');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { execFileSync } = require('node:child_process');
|
|
14
|
+
|
|
15
|
+
const { initStateDb, closeStateDb } = require('../../mcp-server/lib/state-db.cjs');
|
|
16
|
+
const ops = require('../../mcp-server/lib/state-ops.cjs');
|
|
17
|
+
const daemon = require('../daemon.cjs');
|
|
18
|
+
|
|
19
|
+
const LONG_SLEEP_CMD = process.execPath;
|
|
20
|
+
const LONG_SLEEP_ARGS = ['-e', 'setInterval(() => {}, 60000);'];
|
|
21
|
+
|
|
22
|
+
function mkRepo() {
|
|
23
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ubp-daemon-repo-'));
|
|
24
|
+
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
25
|
+
execFileSync('git', ['config', 'user.email', 'test@ubp.dev'], { cwd: dir });
|
|
26
|
+
execFileSync('git', ['config', 'user.name', 'ubp-test'], { cwd: dir });
|
|
27
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
|
|
28
|
+
execFileSync('git', ['add', '-A'], { cwd: dir });
|
|
29
|
+
execFileSync('git', ['commit', '-q', '-m', 'seed'], { cwd: dir });
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mkDb(repoRoot) {
|
|
34
|
+
const { db } = initStateDb(path.join(repoRoot, '.ultra', 'state.db'));
|
|
35
|
+
return db;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanup(repoRoot, db, handle) {
|
|
39
|
+
if (handle) {
|
|
40
|
+
try { handle.stop(); } catch (_) { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
// Kill any stray children spawned by the daemon.
|
|
43
|
+
try {
|
|
44
|
+
const rows = db.prepare("SELECT pid FROM sessions WHERE pid IS NOT NULL AND status = 'running'").all();
|
|
45
|
+
for (const r of rows) {
|
|
46
|
+
try { process.kill(r.pid, 'SIGKILL'); } catch (_) { /* ignore */ }
|
|
47
|
+
}
|
|
48
|
+
} catch (_) { /* ignore */ }
|
|
49
|
+
try { closeStateDb(db); } catch (_) { /* ignore */ }
|
|
50
|
+
try { fs.rmSync(repoRoot, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── routeTask ────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
test('routeTask: haiku hint prefers cheap runtimes', () => {
|
|
56
|
+
const runtime = daemon.routeTask(
|
|
57
|
+
{ id: 't', complexity_hint: 'haiku' },
|
|
58
|
+
['claude', 'opencode', 'codex', 'gemini'],
|
|
59
|
+
);
|
|
60
|
+
assert.ok(['opencode', 'gemini'].includes(runtime), `got ${runtime}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('routeTask: opus hint prefers strong runtimes', () => {
|
|
64
|
+
const runtime = daemon.routeTask(
|
|
65
|
+
{ id: 't', complexity_hint: 'opus' },
|
|
66
|
+
['claude', 'opencode', 'codex', 'gemini'],
|
|
67
|
+
);
|
|
68
|
+
assert.ok(['claude', 'codex'].includes(runtime), `got ${runtime}`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('routeTask: sonnet hint middle-ground', () => {
|
|
72
|
+
const runtime = daemon.routeTask(
|
|
73
|
+
{ id: 't', complexity_hint: 'sonnet' },
|
|
74
|
+
['claude', 'opencode', 'codex', 'gemini'],
|
|
75
|
+
);
|
|
76
|
+
assert.ok(['claude', 'codex', 'opencode', 'gemini'].includes(runtime));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('routeTask: respects availability constraint', () => {
|
|
80
|
+
const runtime = daemon.routeTask(
|
|
81
|
+
{ id: 't', complexity_hint: 'opus' },
|
|
82
|
+
['opencode', 'gemini'],
|
|
83
|
+
);
|
|
84
|
+
// Prefers claude/codex but those aren't available → fall back to any.
|
|
85
|
+
assert.ok(['opencode', 'gemini'].includes(runtime));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('routeTask: no hint → first available', () => {
|
|
89
|
+
const runtime = daemon.routeTask({ id: 't' }, ['gemini', 'claude']);
|
|
90
|
+
assert.ok(['gemini', 'claude'].includes(runtime));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('routeTask: no runtimes available → null', () => {
|
|
94
|
+
assert.equal(daemon.routeTask({ id: 't' }, []), null);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── runDaemon ────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
test('runDaemon spawns pending task within pollMs window', async () => {
|
|
100
|
+
const repoRoot = mkRepo();
|
|
101
|
+
const db = mkDb(repoRoot);
|
|
102
|
+
let handle;
|
|
103
|
+
try {
|
|
104
|
+
ops.createTask(db, { id: 'd-1', title: 'pending target', type: 'feature', priority: 'P1' });
|
|
105
|
+
handle = daemon.runDaemon({
|
|
106
|
+
db, repoRoot,
|
|
107
|
+
runtimes: ['claude'],
|
|
108
|
+
pollMs: 50,
|
|
109
|
+
command: LONG_SLEEP_CMD,
|
|
110
|
+
commandArgs: LONG_SLEEP_ARGS,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Wait up to 500ms for the daemon to pick up the task.
|
|
114
|
+
const start = Date.now();
|
|
115
|
+
let spawned = null;
|
|
116
|
+
while (Date.now() - start < 500) {
|
|
117
|
+
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-1'").all();
|
|
118
|
+
if (sessions.length > 0) { spawned = sessions[0]; break; }
|
|
119
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
120
|
+
}
|
|
121
|
+
assert.ok(spawned, 'daemon should have spawned a session within 500ms');
|
|
122
|
+
assert.equal(spawned.status, 'running');
|
|
123
|
+
assert.equal(spawned.runtime, 'claude');
|
|
124
|
+
} finally {
|
|
125
|
+
cleanup(repoRoot, db, handle);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('runDaemon skips tripped tasks', async () => {
|
|
130
|
+
const repoRoot = mkRepo();
|
|
131
|
+
const db = mkDb(repoRoot);
|
|
132
|
+
let handle;
|
|
133
|
+
try {
|
|
134
|
+
ops.createTask(db, { id: 'd-trip', title: 'tripped', type: 'feature', priority: 'P1' });
|
|
135
|
+
for (let i = 0; i < 3; i += 1) ops.recordTaskFailure(db, 'd-trip', { reason: 'x' });
|
|
136
|
+
assert.equal(ops.isCircuitTripped(db, 'd-trip'), true);
|
|
137
|
+
|
|
138
|
+
handle = daemon.runDaemon({
|
|
139
|
+
db, repoRoot,
|
|
140
|
+
runtimes: ['claude'],
|
|
141
|
+
pollMs: 50,
|
|
142
|
+
command: LONG_SLEEP_CMD,
|
|
143
|
+
commandArgs: LONG_SLEEP_ARGS,
|
|
144
|
+
});
|
|
145
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
146
|
+
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-trip'").all();
|
|
147
|
+
assert.equal(sessions.length, 0, 'tripped task must not be spawned');
|
|
148
|
+
} finally {
|
|
149
|
+
cleanup(repoRoot, db, handle);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('runDaemon does not double-spawn same task', async () => {
|
|
154
|
+
const repoRoot = mkRepo();
|
|
155
|
+
const db = mkDb(repoRoot);
|
|
156
|
+
let handle;
|
|
157
|
+
try {
|
|
158
|
+
ops.createTask(db, { id: 'd-once', title: 'single', type: 'feature', priority: 'P1' });
|
|
159
|
+
handle = daemon.runDaemon({
|
|
160
|
+
db, repoRoot,
|
|
161
|
+
runtimes: ['claude'],
|
|
162
|
+
pollMs: 30,
|
|
163
|
+
command: LONG_SLEEP_CMD,
|
|
164
|
+
commandArgs: LONG_SLEEP_ARGS,
|
|
165
|
+
});
|
|
166
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
167
|
+
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-once'").all();
|
|
168
|
+
assert.equal(sessions.length, 1, 'daemon should not double-spawn');
|
|
169
|
+
} finally {
|
|
170
|
+
cleanup(repoRoot, db, handle);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('runDaemon.stop() halts polling; existing children stay alive', async () => {
|
|
175
|
+
const repoRoot = mkRepo();
|
|
176
|
+
const db = mkDb(repoRoot);
|
|
177
|
+
let handle;
|
|
178
|
+
try {
|
|
179
|
+
ops.createTask(db, { id: 'd-stop', title: 'stop test', type: 'feature', priority: 'P1' });
|
|
180
|
+
handle = daemon.runDaemon({
|
|
181
|
+
db, repoRoot,
|
|
182
|
+
runtimes: ['claude'],
|
|
183
|
+
pollMs: 30,
|
|
184
|
+
command: LONG_SLEEP_CMD,
|
|
185
|
+
commandArgs: LONG_SLEEP_ARGS,
|
|
186
|
+
});
|
|
187
|
+
// Wait for spawn.
|
|
188
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
189
|
+
const before = db.prepare("SELECT pid FROM sessions WHERE task_id = 'd-stop'").all();
|
|
190
|
+
assert.equal(before.length, 1);
|
|
191
|
+
const childPid = before[0].pid;
|
|
192
|
+
|
|
193
|
+
handle.stop();
|
|
194
|
+
// After stop: child still alive, no new spawn for a fresh task.
|
|
195
|
+
ops.createTask(db, { id: 'd-after-stop', title: 'after stop', type: 'feature', priority: 'P1' });
|
|
196
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
197
|
+
const afterStop = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-after-stop'").all();
|
|
198
|
+
assert.equal(afterStop.length, 0, 'no new spawns after stop');
|
|
199
|
+
|
|
200
|
+
try { process.kill(childPid, 0); } catch (err) {
|
|
201
|
+
assert.fail(`child ${childPid} should still be alive after daemon stop: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
} finally {
|
|
204
|
+
cleanup(repoRoot, db, handle);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('runDaemon branchScoped=true only spawns tasks matching cwd branch tag', async () => {
|
|
209
|
+
const repoRoot = mkRepo();
|
|
210
|
+
const db = mkDb(repoRoot);
|
|
211
|
+
let handle;
|
|
212
|
+
try {
|
|
213
|
+
// Normalize to a known branch name so the test doesn't depend on
|
|
214
|
+
// the dev machine's git init.defaultBranch config.
|
|
215
|
+
execFileSync('git', ['checkout', '-q', '-B', 'main'], { cwd: repoRoot });
|
|
216
|
+
ops.createTask(db, { id: 'd-branch-match', title: 'main task', type: 'feature', priority: 'P1', tag: 'main' });
|
|
217
|
+
ops.createTask(db, { id: 'd-branch-other', title: 'other task', type: 'feature', priority: 'P1', tag: 'feat-other' });
|
|
218
|
+
handle = daemon.runDaemon({
|
|
219
|
+
db, repoRoot,
|
|
220
|
+
runtimes: ['claude'],
|
|
221
|
+
pollMs: 50,
|
|
222
|
+
command: LONG_SLEEP_CMD,
|
|
223
|
+
commandArgs: LONG_SLEEP_ARGS,
|
|
224
|
+
branchScoped: true,
|
|
225
|
+
});
|
|
226
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
227
|
+
const match = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-branch-match'").all();
|
|
228
|
+
const other = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-branch-other'").all();
|
|
229
|
+
assert.equal(match.length, 1, 'current-branch task must spawn');
|
|
230
|
+
assert.equal(other.length, 0, 'other-branch task must stay pending');
|
|
231
|
+
} finally {
|
|
232
|
+
cleanup(repoRoot, db, handle);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('runDaemon applies complexity_hint route', async () => {
|
|
237
|
+
const repoRoot = mkRepo();
|
|
238
|
+
const db = mkDb(repoRoot);
|
|
239
|
+
let handle;
|
|
240
|
+
try {
|
|
241
|
+
ops.createTask(db, {
|
|
242
|
+
id: 'd-opus', title: 'opus task', type: 'architecture', priority: 'P0',
|
|
243
|
+
complexity_hint: 'opus',
|
|
244
|
+
});
|
|
245
|
+
handle = daemon.runDaemon({
|
|
246
|
+
db, repoRoot,
|
|
247
|
+
runtimes: ['claude', 'opencode', 'gemini'],
|
|
248
|
+
pollMs: 50,
|
|
249
|
+
command: LONG_SLEEP_CMD,
|
|
250
|
+
commandArgs: LONG_SLEEP_ARGS,
|
|
251
|
+
});
|
|
252
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
253
|
+
const sessions = db.prepare("SELECT * FROM sessions WHERE task_id = 'd-opus'").all();
|
|
254
|
+
assert.equal(sessions.length, 1);
|
|
255
|
+
// opus should prefer claude over opencode/gemini.
|
|
256
|
+
assert.equal(sessions[0].runtime, 'claude');
|
|
257
|
+
} finally {
|
|
258
|
+
cleanup(repoRoot, db, handle);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 8B.1 — Dispatch rules declarative table.
|
|
4
|
+
//
|
|
5
|
+
// evaluate(ctx, rules) → { rule_id, action, runtime }.
|
|
6
|
+
// DEFAULT_RULES must reproduce Phase 5.4 routeTask behavior (ROUTE_PREFERENCES
|
|
7
|
+
// by complexity_hint, fall back to first available) so daemon.cjs keeps green.
|
|
8
|
+
// New dimensions: breaker_state / deps_ready / wave conflict / custom rules.
|
|
9
|
+
|
|
10
|
+
const { test } = require('node:test');
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
|
|
13
|
+
const { evaluate, DEFAULT_RULES, ROUTE_PREFERENCES } = require('../dispatch-rules.cjs');
|
|
14
|
+
|
|
15
|
+
function baseCtx(overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
task: { id: 't1' },
|
|
18
|
+
deps_ready: true,
|
|
19
|
+
available_runtimes: ['claude', 'opencode', 'codex', 'gemini'],
|
|
20
|
+
breaker_state: 'ok',
|
|
21
|
+
wave: null,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── spawn_agent path (preference routing, 6 cases mirror routeTask) ──────
|
|
27
|
+
|
|
28
|
+
test('evaluate: opus hint + all available → claude (preference head)', () => {
|
|
29
|
+
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'opus' } }));
|
|
30
|
+
assert.equal(d.action, 'spawn_agent');
|
|
31
|
+
assert.equal(d.runtime, 'claude');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('evaluate: opus hint + only codex available → codex', () => {
|
|
35
|
+
const d = evaluate(baseCtx({
|
|
36
|
+
task: { id: 't', complexity_hint: 'opus' },
|
|
37
|
+
available_runtimes: ['codex', 'opencode'],
|
|
38
|
+
}));
|
|
39
|
+
assert.equal(d.runtime, 'codex');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('evaluate: opus hint + preference exhausted → first available fallback', () => {
|
|
43
|
+
const d = evaluate(baseCtx({
|
|
44
|
+
task: { id: 't', complexity_hint: 'opus' },
|
|
45
|
+
available_runtimes: ['opencode', 'gemini'],
|
|
46
|
+
}));
|
|
47
|
+
assert.equal(d.action, 'spawn_agent');
|
|
48
|
+
assert.equal(d.runtime, 'opencode');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('evaluate: haiku hint + all available → opencode (cheapest preference)', () => {
|
|
52
|
+
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'haiku' } }));
|
|
53
|
+
assert.equal(d.runtime, 'opencode');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('evaluate: sonnet hint + all available → claude', () => {
|
|
57
|
+
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'sonnet' } }));
|
|
58
|
+
assert.equal(d.runtime, 'claude');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('evaluate: no hint → first available fallback', () => {
|
|
62
|
+
const d = evaluate(baseCtx({
|
|
63
|
+
task: { id: 't' },
|
|
64
|
+
available_runtimes: ['gemini', 'claude'],
|
|
65
|
+
}));
|
|
66
|
+
assert.equal(d.action, 'spawn_agent');
|
|
67
|
+
assert.equal(d.runtime, 'gemini');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ─── block / defer paths (new in 8B.1) ────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
test('evaluate: empty runtimes → block', () => {
|
|
73
|
+
const d = evaluate(baseCtx({ available_runtimes: [] }));
|
|
74
|
+
assert.equal(d.action, 'block');
|
|
75
|
+
assert.equal(d.runtime, null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('evaluate: breaker tripped → block (wins over spawn rules)', () => {
|
|
79
|
+
const d = evaluate(baseCtx({ breaker_state: 'tripped' }));
|
|
80
|
+
assert.equal(d.action, 'block');
|
|
81
|
+
assert.equal(d.rule_id, 'breaker-blocked');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('evaluate: deps_ready=false → defer', () => {
|
|
85
|
+
const d = evaluate(baseCtx({ deps_ready: false }));
|
|
86
|
+
assert.equal(d.action, 'defer');
|
|
87
|
+
assert.equal(d.rule_id, 'deps-not-ready');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('evaluate: serial wave already has running task → defer', () => {
|
|
91
|
+
const d = evaluate(baseCtx({
|
|
92
|
+
wave: { parallel: false, running_count: 1 },
|
|
93
|
+
}));
|
|
94
|
+
assert.equal(d.action, 'defer');
|
|
95
|
+
assert.equal(d.rule_id, 'wave-conflict');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── composability: custom rules override defaults ────────────────────────
|
|
99
|
+
|
|
100
|
+
test('evaluate: custom high-priority rule overrides defaults', () => {
|
|
101
|
+
const customRules = [
|
|
102
|
+
...DEFAULT_RULES,
|
|
103
|
+
{
|
|
104
|
+
id: 'force-gemini',
|
|
105
|
+
priority: 999,
|
|
106
|
+
when: () => true,
|
|
107
|
+
action: 'spawn_agent',
|
|
108
|
+
resolve: () => 'gemini',
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
const d = evaluate(baseCtx({ task: { id: 't', complexity_hint: 'opus' } }), customRules);
|
|
112
|
+
assert.equal(d.runtime, 'gemini');
|
|
113
|
+
assert.equal(d.rule_id, 'force-gemini');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ─── ROUTE_PREFERENCES shape guard (regression: don't drift from Phase 5.4) ─
|
|
117
|
+
|
|
118
|
+
test('DEFAULT_RULES preserves ROUTE_PREFERENCES shape', () => {
|
|
119
|
+
assert.deepEqual(Object.keys(ROUTE_PREFERENCES).sort(), ['haiku', 'opus', 'sonnet']);
|
|
120
|
+
assert.equal(ROUTE_PREFERENCES.opus[0], 'claude');
|
|
121
|
+
assert.equal(ROUTE_PREFERENCES.haiku[0], 'opencode');
|
|
122
|
+
});
|