@ludi-uni/ludi-agent-kit 0.1.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/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Orchestrator Phase 1: planning, dependency ordering, concurrency, evaluation, retry/reassign, escalation gate,
|
|
2
|
+
// limits, dry-run and routing compatibility. No model calls: fake runners and the scripted invoker only.
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { resolve, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
9
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
10
|
+
import { resolveCapability } from '../lib/resolve.mjs';
|
|
11
|
+
import { DEFAULT_POLICY, mergePolicy, loadPolicy, validatePolicy } from '../lib/orchestrator/policy.mjs';
|
|
12
|
+
import { planRules, validatePlan, planWithModel } from '../lib/orchestrator/planner.mjs';
|
|
13
|
+
import { routeTask, nextLadderCapability } from '../lib/orchestrator/router.mjs';
|
|
14
|
+
import { evaluateDecision } from '../lib/orchestrator/escalation.mjs';
|
|
15
|
+
import { evaluateResult } from '../lib/orchestrator/evaluator.mjs';
|
|
16
|
+
import { createAgentRunner, parseStructuredResult } from '../lib/orchestrator/runner.mjs';
|
|
17
|
+
import { orchestrate, dryRun, formatPlan, formatReport } from '../lib/orchestrator/orchestrator.mjs';
|
|
18
|
+
import { createScriptedInvoker } from '../adapters/pi/lib/invoke.mjs';
|
|
19
|
+
|
|
20
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
22
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
23
|
+
const REG = { version: 1, backends: {
|
|
24
|
+
local: { provider: 'pl', model: 'ml', thinking: 'off' },
|
|
25
|
+
cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
|
|
26
|
+
sol: { provider: 'ps', model: 'ms', thinking: 'high' },
|
|
27
|
+
astra: { provider: 'pa', model: 'ma', thinking: 'medium', vision: true },
|
|
28
|
+
codex: { provider: 'px', model: 'mx', thinking: 'high' },
|
|
29
|
+
} };
|
|
30
|
+
const policyWith = over => mergePolicy(DEFAULT_POLICY, over ?? {});
|
|
31
|
+
|
|
32
|
+
const completed = (task, extra = {}) => ({ ok: true, structured: true, modelId: 'fake', result: {
|
|
33
|
+
status: 'completed', summary: `did ${task.id}`, artifacts: [], verification: ['checked'],
|
|
34
|
+
acceptance: Array.from({ length: Math.max(task.acceptance.length, 1) }, (_, i) => ({ id: `A${i + 1}`, met: true, evidence: 'observed' })),
|
|
35
|
+
remainingIssues: [], decisions: [], newTasks: [], ...extra } });
|
|
36
|
+
const blocked = decisions => ({ ok: true, structured: true, result: { status: 'blocked', summary: 'need a choice', artifacts: [], verification: [], acceptance: [], remainingIssues: [], decisions, newTasks: [] } });
|
|
37
|
+
function fakeRunner(handler) {
|
|
38
|
+
const calls = [];
|
|
39
|
+
return { calls, async run(task, ctx) { calls.push({ id: task.id, attempt: task.attempts, capability: task.capability, deps: ctx.dependencyResults.map(d => d.id), decisions: [...task.decisions] }); return handler(task, ctx, calls); } };
|
|
40
|
+
}
|
|
41
|
+
const spec = (id, agent, dependencies = [], extra = {}) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'], ...extra });
|
|
42
|
+
const run = ({ policy, ...o }) => orchestrate({ request: 'r', agents, routing, registry: REG, policy: policyWith(policy), ...o });
|
|
43
|
+
const jsonReply = obj => `notes\n\n\`\`\`json\n${JSON.stringify(obj)}\n\`\`\``;
|
|
44
|
+
|
|
45
|
+
// ---------- planning ----------
|
|
46
|
+
test('simple decomposition: a high-level request becomes >= 2 routed tasks with dependencies', () => {
|
|
47
|
+
const p = planRules('Fix the failing average() test', { agents });
|
|
48
|
+
assert.deepEqual(p.tasks.map(t => t.agent), ['scout', 'coder', 'tester', 'reviewer']);
|
|
49
|
+
assert.deepEqual(p.tasks.map(t => t.dependencies), [[], ['t1'], ['t2'], ['t3']]);
|
|
50
|
+
const ja = planRules('DOLL v2 Phase 2を進める', { agents });
|
|
51
|
+
assert.equal(ja.tasks.length, 4);
|
|
52
|
+
const ui = planRules('Update the settings screen UI and check it in the browser', { agents });
|
|
53
|
+
assert.deepEqual(ui.tasks.map(t => t.agent), ['scout', 'coder', 'tester', 'visual', 'browser', 'reviewer']);
|
|
54
|
+
assert.deepEqual(ui.tasks.at(-1).dependencies, ['t3', 't4', 't5']);
|
|
55
|
+
const { errors, tasks } = validatePlan(ui.tasks, { agents, routing, policy: DEFAULT_POLICY });
|
|
56
|
+
assert.deepEqual(errors, []);
|
|
57
|
+
assert.ok(tasks.every(t => t.acceptance.length > 0));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('plan validation rejects cycles, unknown deps/agents, self-assignment and task overflow', () => {
|
|
61
|
+
const errs = s => validatePlan(s, { agents, routing, policy: policyWith({ limits: { max_tasks: 3 } }) }).errors.join('\n');
|
|
62
|
+
assert.match(errs([spec('a', 'coder', ['b']), spec('b', 'coder', ['a'])]), /cycle/);
|
|
63
|
+
assert.match(errs([spec('a', 'coder', ['zz'])]), /unknown task "zz"/);
|
|
64
|
+
assert.match(errs([spec('a', 'ghost')]), /unknown agent/);
|
|
65
|
+
assert.match(errs([spec('a', 'orchestrator')]), /cannot be assigned to the orchestrator/);
|
|
66
|
+
assert.match(errs([1, 2, 3, 4].map(i => spec(`t${i}`, 'coder'))), /max_tasks=3/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ---------- loop ----------
|
|
70
|
+
test('dependency ordering: dependents run only after dependencies complete and receive their results', async () => {
|
|
71
|
+
const runner = fakeRunner(t => completed(t));
|
|
72
|
+
const r = await run({ request: 'Fix the failing average() test', runner });
|
|
73
|
+
assert.equal(r.status, 'completed');
|
|
74
|
+
assert.deepEqual(runner.calls.map(c => c.id), ['t1', 't2', 't3', 't4']);
|
|
75
|
+
assert.deepEqual(runner.calls[1].deps, ['t1']);
|
|
76
|
+
assert.deepEqual(runner.calls[2].deps, ['t2']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('parallel runnable tasks: independent tasks share a round; the join waits for all', async () => {
|
|
80
|
+
const runner = fakeRunner(t => completed(t));
|
|
81
|
+
const r = await run({ plan: [spec('a', 'scout'), spec('b', 'reviewer'), spec('c', 'visual'), spec('d', 'coder', ['a', 'b', 'c'])], runner });
|
|
82
|
+
const rounds = r.trace.filter(e => e.type === 'round').map(e => e.running);
|
|
83
|
+
assert.deepEqual(rounds, [['a', 'b', 'c'], ['d']]);
|
|
84
|
+
assert.equal(r.status, 'completed');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('max concurrency: never more than max_parallel_tasks agents in flight', async () => {
|
|
88
|
+
let inFlight = 0, peak = 0;
|
|
89
|
+
const runner = { async run(t) { inFlight++; peak = Math.max(peak, inFlight); await new Promise(r => setTimeout(r, 5)); inFlight--; return completed(t); } };
|
|
90
|
+
const r = await run({ plan: [1, 2, 3, 4, 5].map(i => spec(`p${i}`, 'scout')), runner, policy: { decision_policy: { max_parallel_tasks: 2 } } });
|
|
91
|
+
assert.equal(peak, 2);
|
|
92
|
+
assert.deepEqual(r.trace.filter(e => e.type === 'round').map(e => e.running.length), [2, 2, 1]);
|
|
93
|
+
assert.equal(r.status, 'completed');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('successful task through the real runner: routing picks the model and a structured result is accepted', async () => {
|
|
97
|
+
const calls = [];
|
|
98
|
+
const invoke = createScriptedInvoker({ '*': req => jsonReply({ status: 'completed', summary: `ok from ${req.modelId}`, acceptance: [{ id: 'A1', met: true, evidence: 'ran check' }] }) }, calls);
|
|
99
|
+
const runner = createAgentRunner({ invoke, agents, routing, registry: REG });
|
|
100
|
+
const r = await run({ plan: [spec('a', 'scout'), spec('b', 'coder', ['a'])], runner });
|
|
101
|
+
assert.equal(r.status, 'completed');
|
|
102
|
+
assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'ps/ms:high']);
|
|
103
|
+
assert.equal(r.tasks[1].result.summary, 'ok from ps/ms:high');
|
|
104
|
+
assert.match(formatReport(r), /完了:\n- \[a\]/);
|
|
105
|
+
assert.match(formatReport(r), /ユーザー判断が必要:\n- なし/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('failed task is detected; dependents are blocked, not run', async () => {
|
|
109
|
+
const runner = fakeRunner(t => (t.id === 'a' ? { ok: false, error: 'model unavailable' } : completed(t)));
|
|
110
|
+
const r = await run({ plan: [spec('a', 'coder'), spec('b', 'reviewer', ['a'])], runner, policy: { limits: { max_retries: 0 } } });
|
|
111
|
+
assert.equal(r.status, 'incomplete');
|
|
112
|
+
assert.equal(r.tasks[0].status, 'failed');
|
|
113
|
+
assert.match(r.tasks[0].blockedReason, /model unavailable/);
|
|
114
|
+
assert.equal(r.tasks[1].status, 'blocked');
|
|
115
|
+
assert.deepEqual(runner.calls.map(c => c.id), ['a']);
|
|
116
|
+
assert.match(formatReport(r), /未解決:\n- \[a\].*failed/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('retry: a bare "Done" is a protocol failure; the SAME attempt advances to the next candidate', async () => {
|
|
120
|
+
const replies = ['Done', jsonReply({ status: 'completed', summary: 'found files', acceptance: [{ id: 'A1', met: true, evidence: 'grep hits' }] })];
|
|
121
|
+
const calls = [];
|
|
122
|
+
const invoke = createScriptedInvoker({ '*': () => replies.shift() }, calls);
|
|
123
|
+
const runner = createAgentRunner({ invoke, agents, routing, registry: REG });
|
|
124
|
+
const r = await run({ plan: [spec('a', 'scout')], runner });
|
|
125
|
+
assert.equal(r.status, 'completed');
|
|
126
|
+
// New policy: MALFORMED_RESULT advances to the next candidate within the same
|
|
127
|
+
// orchestrator attempt, so the task completes in ONE attempt across two models
|
|
128
|
+
// rather than burning a same-model retry.
|
|
129
|
+
assert.equal(r.tasks[0].attempts, 1);
|
|
130
|
+
assert.equal(nextLadderCapability(routing, 'cheap-code'), 'strong-code');
|
|
131
|
+
assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'pl/ml:off']); // cheap -> local (next candidate)
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('retry exhaustion: attempts stop at max_retries + 1 and the task fails', async () => {
|
|
135
|
+
const runner = fakeRunner(() => ({ ok: true, structured: false, result: { status: 'unknown', summary: 'Done' } }));
|
|
136
|
+
const r = await run({ plan: [spec('a', 'coder')], runner, policy: { limits: { max_retries: 2 } } });
|
|
137
|
+
assert.equal(runner.calls.length, 3);
|
|
138
|
+
assert.equal(r.tasks[0].status, 'failed');
|
|
139
|
+
assert.equal(r.status, 'incomplete');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('rework: blocking review findings send work back to the implementer and schedule a re-review (bounded)', async () => {
|
|
143
|
+
const runner = fakeRunner(t => (t.assignedAgent === 'reviewer' ? completed(t, { remainingIssues: [{ summary: 'null check missing', severity: 'high' }] }) : completed(t)));
|
|
144
|
+
const r = await run({ request: 'Fix the failing average() test', runner, policy: { limits: { max_rework_cycles: 1 } } });
|
|
145
|
+
assert.deepEqual(runner.calls.map(c => c.id), ['t1', 't2', 't3', 't4', 't5', 't6']);
|
|
146
|
+
assert.equal(r.tasks[4].assignedAgent, 'coder');
|
|
147
|
+
assert.equal(r.tasks[5].assignedAgent, 'reviewer');
|
|
148
|
+
assert.equal(r.status, 'incomplete');
|
|
149
|
+
assert.ok(r.unresolved.some(u => /blocking issues remain/.test(u)));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---------- escalation gate ----------
|
|
153
|
+
test('auto decision: reversible / low-risk / policy choices are made without asking the user', async () => {
|
|
154
|
+
const lib = { key: 'lib', question: 'Library A or B?', recommended: 'a', options: [{ id: 'a', summary: 'A', reversible: true }, { id: 'b', summary: 'B', reversible: true }] };
|
|
155
|
+
const runner = fakeRunner((t, _ctx, calls) => (calls.length === 1 ? blocked([lib]) : completed(t)));
|
|
156
|
+
const r = await run({ plan: [spec('a', 'coder')], runner });
|
|
157
|
+
assert.equal(r.status, 'completed');
|
|
158
|
+
assert.deepEqual(r.escalations, []);
|
|
159
|
+
assert.equal(r.autoDecisions[0].step, 'reversible');
|
|
160
|
+
assert.deepEqual(runner.calls[1].decisions.map(d => d.choice), ['a: A']);
|
|
161
|
+
|
|
162
|
+
const p = DEFAULT_POLICY;
|
|
163
|
+
const lowRisk = evaluateDecision({ question: 'Add a test?', options: [{ id: 'yes', risk: 'low', cost: 'low' }, { id: 'no', risk: 'medium' }] }, { policy: p });
|
|
164
|
+
assert.deepEqual([lowRisk.action, lowRisk.step, lowRisk.optionId], ['decide', 'low-risk', 'yes']);
|
|
165
|
+
const byPolicy = evaluateDecision({ question: 'Approach?', options: [{ id: 'new', risk: 'medium' }, { id: 'reuse', risk: 'medium', usesExistingAssets: true, maintainability: 'high' }] }, { policy: p });
|
|
166
|
+
assert.deepEqual([byPolicy.action, byPolicy.step, byPolicy.optionId], ['decide', 'policy', 'reuse']);
|
|
167
|
+
const ctx = evaluateDecision({ key: 'lib', question: 'again', options: [{ id: 'a' }, { id: 'b' }] }, { policy: p, decisionLog: [{ key: 'lib', action: 'decide', optionId: 'b', step: 'policy' }] });
|
|
168
|
+
assert.deepEqual([ctx.step, ctx.optionId], ['context', 'b']);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('auto decision: an unresolved-but-cheap choice becomes an experiment task, then the blocked task resumes', async () => {
|
|
172
|
+
const d = { key: 'algo', question: 'Algorithm X or Y?', options: [{ id: 'x', risk: 'medium', estimatedHours: 1 }, { id: 'y', risk: 'medium', estimatedHours: 1.5 }] };
|
|
173
|
+
const runner = fakeRunner((t, ctx, calls) => (t.id === 'a' && calls.filter(c => c.id === 'a').length === 1 ? blocked([d]) : completed(t)));
|
|
174
|
+
const r = await run({ plan: [spec('a', 'coder')], runner });
|
|
175
|
+
assert.equal(r.status, 'completed');
|
|
176
|
+
const exp = r.tasks.find(t => t.kind === 'experiment');
|
|
177
|
+
assert.ok(exp);
|
|
178
|
+
assert.ok(r.tasks[0].dependencies.includes(exp.id));
|
|
179
|
+
assert.deepEqual(runner.calls.map(c => c.id), ['a', exp.id, 'a']);
|
|
180
|
+
assert.deepEqual(runner.calls[2].deps, [exp.id]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('user escalation: destructive / publish / high-cost / production decisions are detected and surfaced', async () => {
|
|
184
|
+
const del = { question: 'Remove the old records?', flags: ['destructive_action'], options: [{ id: 'yes', reversible: false }, { id: 'no', reversible: true }] };
|
|
185
|
+
const runner = fakeRunner(t => (t.id === 'a' ? blocked([del]) : completed(t)));
|
|
186
|
+
const r = await run({ plan: [spec('a', 'coder'), spec('b', 'reviewer', ['a']), spec('c', 'scout')], runner });
|
|
187
|
+
assert.equal(r.status, 'needs-user');
|
|
188
|
+
assert.equal(r.escalations.length, 1);
|
|
189
|
+
assert.deepEqual(r.escalations[0].flags, ['destructive_action']);
|
|
190
|
+
assert.equal(r.tasks[0].status, 'blocked');
|
|
191
|
+
assert.equal(r.tasks[1].status, 'blocked');
|
|
192
|
+
assert.equal(r.tasks[2].status, 'completed', 'independent work continues');
|
|
193
|
+
assert.match(formatReport(r), /ユーザー判断が必要:\n- \[a\] Remove the old records\?/);
|
|
194
|
+
|
|
195
|
+
const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
|
|
196
|
+
const kw = evaluateDecision({ question: 'Should we drop database tables now?', options: [{ id: 'a', reversible: true }, { id: 'b', reversible: true }] }, { policy });
|
|
197
|
+
assert.deepEqual([kw.action, kw.flags], ['escalate', ['destructive_action']]);
|
|
198
|
+
const cost = evaluateDecision({ question: 'Which GPU plan?', options: [{ id: 'big', costUsd: 500, reversible: true }, { id: 'small', costUsd: 5, reversible: true }] }, { policy });
|
|
199
|
+
assert.deepEqual(cost.flags, ['high_cost']);
|
|
200
|
+
const prod = evaluateDecision({ question: 'Deploy to production today?', options: [{ id: 'y', reversible: true }, { id: 'n', reversible: true }] }, { policy });
|
|
201
|
+
assert.equal(prod.action, 'escalate');
|
|
202
|
+
const off = evaluateDecision(del, { policy: policyWith({ decision_policy: { escalation: { destructive_action: false } } }) });
|
|
203
|
+
assert.notEqual(off.step, 'hard-gate');
|
|
204
|
+
const open = evaluateDecision({ question: 'Pick a brand color', options: [{ id: 'r', risk: 'medium' }, { id: 'b', risk: 'medium' }] }, { policy });
|
|
205
|
+
assert.deepEqual([open.action, open.step], ['escalate', 'unresolved']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---------- limits ----------
|
|
209
|
+
test('max rounds stops the loop and reports the limit; max tasks caps discovered work', async () => {
|
|
210
|
+
const chain = [spec('a', 'scout'), spec('b', 'coder', ['a']), spec('c', 'coder', ['b']), spec('d', 'reviewer', ['c'])];
|
|
211
|
+
const r = await run({ plan: chain, runner: fakeRunner(t => completed(t)), policy: { limits: { max_rounds: 2 } } });
|
|
212
|
+
assert.equal(r.rounds, 2);
|
|
213
|
+
assert.deepEqual(r.limitsHit, ['max_rounds']);
|
|
214
|
+
assert.deepEqual(r.tasks.map(t => t.status), ['completed', 'completed', 'pending', 'pending']);
|
|
215
|
+
assert.match(formatReport(r), /limit reached: max_rounds/);
|
|
216
|
+
|
|
217
|
+
const spawner = fakeRunner(t => completed(t, { newTasks: [{ title: 'more', goal: 'more work', agent: 'scout', acceptance: ['x'] }] }));
|
|
218
|
+
const r2 = await run({ plan: [spec('a', 'scout')], runner: spawner, policy: { limits: { max_tasks: 3, max_rounds: 20 } } });
|
|
219
|
+
assert.equal(r2.tasks.length, 3);
|
|
220
|
+
assert.ok(r2.limitsHit.includes('max_tasks'));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---------- evaluation ----------
|
|
224
|
+
test('result evaluation: acceptance needs evidence, outputs must exist, explicit failure is failure', () => {
|
|
225
|
+
const task = { id: 'a', kind: 'implement', acceptance: ['x', 'y'], outputs: ['nope/missing.txt'] };
|
|
226
|
+
const res = extra => ({ ok: true, structured: true, result: { status: 'completed', summary: 's', acceptance: [{ id: 'A1', met: true, evidence: 'e' }, { id: 'A2', met: true, evidence: 'e' }], remainingIssues: [], ...extra } });
|
|
227
|
+
assert.equal(evaluateResult({ ...task, outputs: [] }, res()).verdict, 'success');
|
|
228
|
+
assert.match(evaluateResult(task, res(), { repoRoot: kit }).reasons.join(), /required output missing/);
|
|
229
|
+
assert.match(evaluateResult(task, res({ acceptance: [{ id: 'A1', met: true }] })).reasons.join(), /A1 claimed without evidence.*A2 not reported/);
|
|
230
|
+
assert.match(evaluateResult(task, res({ status: 'failed' })).reasons.join(), /agent reported failure/);
|
|
231
|
+
assert.match(evaluateResult(task, res({ remainingIssues: [{ summary: 'broken', blocking: true }] })).reasons.join(), /blocking issue/);
|
|
232
|
+
assert.equal(parseStructuredResult('Done').structured, false);
|
|
233
|
+
assert.equal(parseStructuredResult(jsonReply({ status: 'completed', summary: 'x' })).structured, true);
|
|
234
|
+
// Observed from a real model: a ```bash block before the result block must not hide it.
|
|
235
|
+
const mixed = '## test_commands\n```bash\nnpm test\n```\n\n## expected_output\ntext\n\n```json\n{"status":"completed","summary":"found"}\n```\n';
|
|
236
|
+
assert.equal(parseStructuredResult(mixed).result.summary, 'found');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------- dry run + routing compatibility ----------
|
|
240
|
+
test('dry run: plan and routing are shown without running any agent', async () => {
|
|
241
|
+
const dry = await dryRun('DOLL v2 Phase 2を進める', { agents, routing, registry: REG, policy: DEFAULT_POLICY });
|
|
242
|
+
assert.deepEqual(dry.errors, []);
|
|
243
|
+
const text = formatPlan(dry);
|
|
244
|
+
assert.match(text, /Task 1 \[t1\].*\n capability: cheap-code\n agent: scout\n mode: subagent/);
|
|
245
|
+
assert.match(text, /Task 2 \[t2\].*\n capability: strong-code\n agent: coder\n mode: subagent/);
|
|
246
|
+
assert.match(text, /tools: read, grep, find, ls, edit, write, ludi_exec/);
|
|
247
|
+
assert.match(text, /Task 4 \[t4\].*\n capability: deep-review\n agent: reviewer/);
|
|
248
|
+
assert.match(text, /depends_on: Task 3/);
|
|
249
|
+
const cli = spawnSync(process.execPath, [join(kit, 'scripts/orchestrate.mjs'), '--dry-run', 'Fix the failing average() test'], { encoding: 'utf8' });
|
|
250
|
+
assert.equal(cli.status, 0, cli.stderr);
|
|
251
|
+
assert.match(cli.stdout, /Task 4 \[t4\]/);
|
|
252
|
+
assert.doesNotMatch(cli.stdout, /pi exited/);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('existing routing compatibility: agent capabilities and model chains come from routing.json + registry', async () => {
|
|
256
|
+
const dry = await dryRun('Fix the failing average() test', { agents, routing, registry: REG, policy: DEFAULT_POLICY });
|
|
257
|
+
for (const t of dry.tasks) {
|
|
258
|
+
assert.equal(t.capability, agents.find(a => a.meta.name === t.assignedAgent).meta.capability);
|
|
259
|
+
assert.deepEqual(t.models.candidates, resolveCapability(routing, REG, t.capability).candidates.map(c => c.modelId));
|
|
260
|
+
}
|
|
261
|
+
assert.deepEqual(routeTask({ id: 'x', capability: 'vision-reasoning' }, { agents, routing }), { agent: 'visual', capability: 'vision-reasoning' });
|
|
262
|
+
assert.deepEqual(routeTask({ id: 'x', agent: 'scout', capability: 'strong-code' }, { agents, routing }), { agent: 'scout', capability: 'strong-code' });
|
|
263
|
+
assert.match(routeTask({ id: 'x', agent: 'scout', capability: 'nope' }, { agents, routing }).error, /not defined in routing/);
|
|
264
|
+
assert.equal(resolveCapability(routing, REG, 'orchestration').candidates[0].modelId, 'ps/ms:high');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('model planner prefers Qoder and can fall back to Devin without Codex', async () => {
|
|
268
|
+
const reg = { version: 1, backends: {
|
|
269
|
+
qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
|
|
270
|
+
devin: { provider: 'devin', model: 'swe-2-high', thinking: 'high' },
|
|
271
|
+
sol: { provider: 'openai-codex', model: 'gpt-5.6-sol', thinking: 'medium' },
|
|
272
|
+
} };
|
|
273
|
+
assert.deepEqual(resolveCapability(routing, reg, 'orchestration').candidates.map(c => c.modelId), [
|
|
274
|
+
'qoder/Qwen3.8-Flash:low', 'devin/swe-2-high:high', 'openai-codex/gpt-5.6-sol:medium',
|
|
275
|
+
]);
|
|
276
|
+
const calls = [];
|
|
277
|
+
const plan = { tasks: [spec('t1', 'scout')] };
|
|
278
|
+
const result = await planWithModel('Do X', { agents, routing, registry: reg, policy: DEFAULT_POLICY,
|
|
279
|
+
invoke: createScriptedInvoker({
|
|
280
|
+
'qoder/Qwen3.8-Flash:low': new Error('usage limit has been reached'),
|
|
281
|
+
'devin/swe-2-high:high': jsonReply(plan),
|
|
282
|
+
}, calls),
|
|
283
|
+
});
|
|
284
|
+
assert.equal(result.planner, 'model');
|
|
285
|
+
assert.equal(result.modelId, 'devin/swe-2-high:high');
|
|
286
|
+
assert.deepEqual(calls.map(c => c.modelId), ['qoder/Qwen3.8-Flash:low', 'devin/swe-2-high:high']);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('model planner: the orchestrator agent plans via routing; invalid output falls back to rules', async () => {
|
|
290
|
+
const plan = { tasks: [spec('t1', 'scout'), spec('t2', 'coder', ['t1'])] };
|
|
291
|
+
const calls = [];
|
|
292
|
+
const good = await planWithModel('Do X', { agents, routing, registry: REG, policy: DEFAULT_POLICY, invoke: createScriptedInvoker({ '*': jsonReply(plan) }, calls) });
|
|
293
|
+
assert.equal(good.planner, 'model');
|
|
294
|
+
assert.equal(calls[0].modelId, 'ps/ms:high');
|
|
295
|
+
const bad = await planWithModel('Fix the bug', { agents, routing, registry: REG, policy: DEFAULT_POLICY, invoke: createScriptedInvoker({ '*': 'I think you should...' }) });
|
|
296
|
+
assert.equal(bad.planner, 'rules');
|
|
297
|
+
const r = await orchestrate({ request: 'Fix the bug', planner: 'model', agents, routing, registry: REG, policy: DEFAULT_POLICY, invoke: createScriptedInvoker({ '*': jsonReply({ tasks: [spec('t1', 'orchestrator')] }) }), runner: fakeRunner(t => completed(t)) });
|
|
298
|
+
assert.equal(r.planner, 'rules');
|
|
299
|
+
assert.equal(r.autoDecisions[0].subject, 'planner');
|
|
300
|
+
assert.equal(r.status, 'completed');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('codex quota 0: orchestrator completes on qoder/devin, then freetoken when they are exhausted', async () => {
|
|
304
|
+
// Every openai-codex model reports usage-limit; routing must still reach qoder (cheap-code),
|
|
305
|
+
// devin (strong-code/deep-review) and finally the freetoken local fallback.
|
|
306
|
+
const reg = { version: 1, backends: {
|
|
307
|
+
local: { provider: 'freetoken', model: 'Qwen3.6-35B-A3B-NVFP4', thinking: 'off' },
|
|
308
|
+
cheap: { provider: 'openai-codex', model: 'gpt-5.6-luna', thinking: 'low' },
|
|
309
|
+
sol: { provider: 'openai-codex', model: 'gpt-5.6-sol', thinking: 'medium' },
|
|
310
|
+
astra: { provider: 'openai-codex', model: 'gpt-6-astra', thinking: 'medium', vision: true },
|
|
311
|
+
codex: { provider: 'openai-codex', model: 'gpt-5.5', thinking: 'high' },
|
|
312
|
+
qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
|
|
313
|
+
devin: { provider: 'devin', model: 'swe-2-high', thinking: 'high' },
|
|
314
|
+
} };
|
|
315
|
+
const OK = task => jsonReply({ status: 'completed', summary: `done ${task}`, acceptance: [{ id: 'A1', met: true, evidence: 'checked' }, { id: 'A2', met: true, evidence: 'none open' }] });
|
|
316
|
+
const calls = [];
|
|
317
|
+
const quotaErr = new Error('usage limit has been reached for this subscription period');
|
|
318
|
+
const invoke = createScriptedInvoker({
|
|
319
|
+
'openai-codex/gpt-5.6-luna:low': quotaErr, 'openai-codex/gpt-5.6-sol:medium': quotaErr,
|
|
320
|
+
'openai-codex/gpt-5.5:high': quotaErr, 'openai-codex/gpt-6-astra:medium': quotaErr,
|
|
321
|
+
'qoder/Qwen3.8-Flash:low': () => OK('via qoder'),
|
|
322
|
+
'devin/swe-2-high:high': () => OK('via devin'),
|
|
323
|
+
'freetoken/Qwen3.6-35B-A3B-NVFP4:off': () => OK('via freetoken'),
|
|
324
|
+
}, calls);
|
|
325
|
+
const { createHealthMonitor } = await import('../lib/orchestrator/health.mjs');
|
|
326
|
+
const health = createHealthMonitor({ policy: DEFAULT_POLICY });
|
|
327
|
+
const runner = createAgentRunner({ invoke, agents, routing, registry: reg, repoRoot: kit, health });
|
|
328
|
+
const r = await orchestrate({ request: 'Fix the failing average() test', agents, routing, registry: reg, policy: DEFAULT_POLICY, runner, repoRoot: kit, health });
|
|
329
|
+
assert.equal(r.status, 'completed');
|
|
330
|
+
// cheap-code tasks ran on qoder (cheap/local codex slots skipped or failed), strong/deep on devin.
|
|
331
|
+
assert.deepEqual(calls.map(c => c.modelId), ['qoder/Qwen3.8-Flash:low', 'devin/swe-2-high:high', 'qoder/Qwen3.8-Flash:low', 'devin/swe-2-high:high']);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('decision policy file is valid, overridable and validated', () => {
|
|
335
|
+
const { policy, sources } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
|
|
336
|
+
assert.equal(sources.length, 1);
|
|
337
|
+
assert.deepEqual(validatePolicy(policy), []);
|
|
338
|
+
assert.equal(policy.decision_policy.max_parallel_tasks, 3);
|
|
339
|
+
const bad = policyWith({ decision_policy: { max_parallel_tasks: 0, escalation: { nuke: true }, default_behavior: { reversible_decision: 'maybe' } }, limits: { max_retries: -1 } });
|
|
340
|
+
const errs = validatePolicy(bad).join('\n');
|
|
341
|
+
for (const needle of ['max_parallel_tasks', 'unknown flag "nuke"', 'reversible_decision', 'max_retries']) assert.ok(errs.includes(needle), needle);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// ---------- regression: investigation split + blocking semantics + retry display ----------
|
|
345
|
+
test('history detection: natural Japanese request splits investigation', () => {
|
|
346
|
+
// The exact request from run-mufhpr52-d5c2f3 must detect history + ui and split.
|
|
347
|
+
const p = planRules('コミットや指示から僕の好みを推測して、このリポジトリを改善できますか', { agents });
|
|
348
|
+
const scouts = p.tasks.filter(t => t.agent === 'scout');
|
|
349
|
+
assert.ok(scouts.length >= 2, `expected split investigation, got ${scouts.length} scout task(s)`);
|
|
350
|
+
assert.ok(scouts.some(t => /history|commit/i.test(t.title)), 'expected a history-focused scout');
|
|
351
|
+
assert.ok(scouts.some(t => /synth/i.test(t.title)), 'expected a synthesis scout');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('history detection: single-commit fix does NOT split', () => {
|
|
355
|
+
const p = planRules('このコミットを修正して', { agents });
|
|
356
|
+
const scouts = p.tasks.filter(t => t.agent === 'scout');
|
|
357
|
+
assert.equal(scouts.length, 1);
|
|
358
|
+
assert.match(scouts[0].title, /Investigate scope/);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('blocking issues: advisory on investigation is success, decision-shaped is blocked', () => {
|
|
362
|
+
const task = { id: 'a', kind: 'investigate', acceptance: ['x'], outputs: [] };
|
|
363
|
+
const res = extra => ({ ok: true, structured: true, result: { status: 'completed', summary: 's', acceptance: [{ id: 'A1', met: true, evidence: 'e' }], remainingIssues: [], ...extra } });
|
|
364
|
+
// Advisory: multiple valid directions / open questions -> still success.
|
|
365
|
+
const advisory = res({ remainingIssues: [{ summary: '改善の方向性が未指定 — 複数の改善候補がある', blocking: true }] });
|
|
366
|
+
const ev1 = evaluateResult(task, advisory);
|
|
367
|
+
assert.equal(ev1.verdict, 'blocked'); // decision-shaped -> needs_decision path
|
|
368
|
+
assert.ok(ev1.decisions.length > 0);
|
|
369
|
+
// Safety: destructive/uncommitted-conflict -> failure.
|
|
370
|
+
const safety = res({ remainingIssues: [{ summary: '未コミット変更を上書きする危険がある', blocking: true }] });
|
|
371
|
+
assert.equal(evaluateResult(task, safety).verdict, 'failure');
|
|
372
|
+
// Pure advisory (not decision-shaped, not safety) -> success.
|
|
373
|
+
const note = res({ remainingIssues: [{ summary: 'context-pack/SPEC.mdは見つからなかった', blocking: true }] });
|
|
374
|
+
assert.equal(evaluateResult(task, note).verdict, 'success');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('blocking issues: implementation still fails on blocking issues', () => {
|
|
378
|
+
const task = { id: 'a', kind: 'implement', acceptance: ['x'], outputs: [] };
|
|
379
|
+
const res = { ok: true, structured: true, result: { status: 'completed', summary: 's', acceptance: [{ id: 'A1', met: true, evidence: 'e' }], remainingIssues: [{ summary: 'broken', blocking: true }] } };
|
|
380
|
+
assert.equal(evaluateResult(task, res).verdict, 'failure');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('runner-level decision (dirty worktree) becomes blocked, not failure', () => {
|
|
384
|
+
const task = { id: 'a', kind: 'implement', acceptance: ['x'] };
|
|
385
|
+
const run = { ok: false, error: 'workspace has 342 uncommitted change(s)', decision: { key: 'worktree-dirty:a', question: 'proceed?', options: [{ id: 'proceed' }, { id: 'abort' }] } };
|
|
386
|
+
const ev = evaluateResult(task, run);
|
|
387
|
+
assert.equal(ev.verdict, 'blocked');
|
|
388
|
+
assert.equal(ev.decisions[0].key, 'worktree-dirty:a');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('retry display uses attempt count, not retry/max_retries fraction', async () => {
|
|
392
|
+
const r = await run({ plan: [spec('a', 'scout')], runner: fakeRunner(() => ({ ok: false, error: 'x' })), policy: { limits: { max_retries: 2, max_total_attempts_per_task: 4 } } });
|
|
393
|
+
const report = formatReport(r);
|
|
394
|
+
assert.doesNotMatch(report, /retry \d+\/\d+/);
|
|
395
|
+
assert.match(report, /attempt \d+/);
|
|
396
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
|
+
import { resolve, dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
9
|
+
|
|
10
|
+
test('npm manifest exposes existing Pi resources without installation hooks', () => {
|
|
11
|
+
assert.equal(pkg.name, '@ludi-uni/ludi-agent-kit');
|
|
12
|
+
assert.equal(pkg.license, 'MIT');
|
|
13
|
+
assert.ok(pkg.keywords.includes('pi-package'));
|
|
14
|
+
assert.deepEqual(pkg.pi.extensions, [
|
|
15
|
+
'./adapters/pi/loop-guard/index.js',
|
|
16
|
+
'./adapters/pi/orchestrator-ext/index.js',
|
|
17
|
+
]);
|
|
18
|
+
assert.deepEqual(pkg.pi.skills, [
|
|
19
|
+
'./skills/pi-workflow', './skills/project-management', './skills/visual-verification',
|
|
20
|
+
]);
|
|
21
|
+
for (const path of pkg.pi.extensions) assert.ok(existsSync(join(root, path)), path);
|
|
22
|
+
for (const path of pkg.pi.skills) assert.ok(existsSync(join(root, path, 'SKILL.md')), path);
|
|
23
|
+
for (const field of ['preinstall', 'install', 'postinstall', 'prepare', 'prepublishOnly']) {
|
|
24
|
+
assert.equal(pkg.scripts?.[field], undefined, `${field} must not run on install/publish`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('npm file allowlist excludes local state and includes extension runtime paths', () => {
|
|
29
|
+
assert.ok(pkg.files.includes('rules/'));
|
|
30
|
+
assert.ok(pkg.files.includes('lib/'));
|
|
31
|
+
assert.ok(pkg.files.includes('adapters/pi/lib/'));
|
|
32
|
+
assert.ok(pkg.files.includes('adapters/pi/models.json'));
|
|
33
|
+
assert.ok(pkg.files.includes('adapters/pi/orchestrator-ext/'));
|
|
34
|
+
for (const path of pkg.files) {
|
|
35
|
+
assert.ok(!/(^|\/)(\.pi|\.orchestration|out|node_modules)(\/|$)|models\.local\.json$/.test(path), path);
|
|
36
|
+
}
|
|
37
|
+
});
|