@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.
Files changed (172) hide show
  1. package/AGENTS.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +107 -0
  4. package/adapters/codex/README.md +24 -0
  5. package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
  6. package/adapters/pi/README.md +88 -0
  7. package/adapters/pi/browser/agent-browser.mjs +193 -0
  8. package/adapters/pi/lib/invoke.mjs +55 -0
  9. package/adapters/pi/lib/list-models.mjs +29 -0
  10. package/adapters/pi/lib/settings-proposal.mjs +34 -0
  11. package/adapters/pi/lib/subagent.mjs +175 -0
  12. package/adapters/pi/loop-guard/index.js +51 -0
  13. package/adapters/pi/maintenance-policy.json +36 -0
  14. package/adapters/pi/mcp.template.json +4 -0
  15. package/adapters/pi/model-catalog.json +97 -0
  16. package/adapters/pi/models.json +13 -0
  17. package/adapters/pi/models.local.example.json +14 -0
  18. package/adapters/pi/orchestrator-ext/command.mjs +14 -0
  19. package/adapters/pi/orchestrator-ext/index.js +150 -0
  20. package/adapters/pi/settings.template.json +7 -0
  21. package/adapters/pi/shell-gate/index.js +70 -0
  22. package/adapters/pi/sync-pi.ps1 +137 -0
  23. package/agents/README.md +26 -0
  24. package/agents/browser.md +64 -0
  25. package/agents/coder.md +31 -0
  26. package/agents/orchestrator.md +37 -0
  27. package/agents/reviewer.md +32 -0
  28. package/agents/scout.md +35 -0
  29. package/agents/tester.md +28 -0
  30. package/agents/visual.md +28 -0
  31. package/context-pack/SPEC.md +101 -0
  32. package/context-pack/context-pack.schema.json +79 -0
  33. package/context-pack/examples/example-fix.md +44 -0
  34. package/docs/architecture.md +55 -0
  35. package/docs/migration-from-codex-setting.md +44 -0
  36. package/docs/model-maintenance.md +401 -0
  37. package/docs/orchestrator.md +155 -0
  38. package/docs/phase2-report.md +39 -0
  39. package/docs/roadmap.md +27 -0
  40. package/docs/third-party.md +15 -0
  41. package/lib/agents.mjs +79 -0
  42. package/lib/context-pack.mjs +215 -0
  43. package/lib/job.mjs +312 -0
  44. package/lib/language-policy.mjs +27 -0
  45. package/lib/maintenance-exec.mjs +377 -0
  46. package/lib/maintenance-runner.mjs +266 -0
  47. package/lib/maintenance.mjs +422 -0
  48. package/lib/normalize.mjs +101 -0
  49. package/lib/observe/differ.mjs +185 -0
  50. package/lib/observe/observation.mjs +147 -0
  51. package/lib/observe/observers.mjs +134 -0
  52. package/lib/observe/sources.mjs +154 -0
  53. package/lib/orchestrator/activity.mjs +249 -0
  54. package/lib/orchestrator/api.mjs +151 -0
  55. package/lib/orchestrator/contract.mjs +68 -0
  56. package/lib/orchestrator/escalation.mjs +84 -0
  57. package/lib/orchestrator/evaluator.mjs +92 -0
  58. package/lib/orchestrator/failures.mjs +88 -0
  59. package/lib/orchestrator/health.mjs +53 -0
  60. package/lib/orchestrator/orchestrator.mjs +483 -0
  61. package/lib/orchestrator/permissions.mjs +64 -0
  62. package/lib/orchestrator/planner.mjs +194 -0
  63. package/lib/orchestrator/policy.mjs +134 -0
  64. package/lib/orchestrator/router.mjs +45 -0
  65. package/lib/orchestrator/runner.mjs +278 -0
  66. package/lib/orchestrator/shell-policy.mjs +52 -0
  67. package/lib/orchestrator/store.mjs +581 -0
  68. package/lib/orchestrator/task-store.mjs +79 -0
  69. package/lib/orchestrator/turn-budget.mjs +63 -0
  70. package/lib/orchestrator/worktree.mjs +72 -0
  71. package/lib/pipeline.mjs +279 -0
  72. package/lib/registry.mjs +63 -0
  73. package/lib/resolve.mjs +35 -0
  74. package/lib/routing.mjs +137 -0
  75. package/lib/telemetry.mjs +222 -0
  76. package/mcp/README.md +11 -0
  77. package/mcp/servers.json +13 -0
  78. package/orchestration/decision-policy.json +66 -0
  79. package/package.json +56 -0
  80. package/routing/README.md +24 -0
  81. package/routing/routing.json +81 -0
  82. package/routing/routing.schema.json +66 -0
  83. package/rules/README.md +10 -0
  84. package/rules/common.md +52 -0
  85. package/rules/loop-prevention.md +15 -0
  86. package/rules/repo-local.md +6 -0
  87. package/scripts/check-environment.ps1 +22 -0
  88. package/scripts/context-pack.mjs +17 -0
  89. package/scripts/e2e-investigate-repro.mjs +66 -0
  90. package/scripts/model-maintenance-job.mjs +59 -0
  91. package/scripts/observe-models.mjs +97 -0
  92. package/scripts/orchestrate.mjs +137 -0
  93. package/scripts/reevaluate-models.mjs +95 -0
  94. package/scripts/report-model-maintenance.mjs +70 -0
  95. package/scripts/resolve-capabilities.mjs +39 -0
  96. package/scripts/run-pipeline.mjs +56 -0
  97. package/scripts/sync-agents-md.ps1 +10 -0
  98. package/scripts/validate.mjs +71 -0
  99. package/skills/README.md +14 -0
  100. package/skills/pi-workflow/SKILL.md +26 -0
  101. package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
  102. package/skills/pi-workflow/references/research.md +14 -0
  103. package/skills/pi-workflow/references/review.md +11 -0
  104. package/skills/pi-workflow/references/visual-work.md +14 -0
  105. package/skills/project-management/SKILL.md +106 -0
  106. package/skills/project-management/references/operations.md +52 -0
  107. package/skills/visual-verification/SKILL.md +88 -0
  108. package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
  109. package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
  110. package/skills/visual-verification/scripts/common.ps1 +387 -0
  111. package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
  112. package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
  113. package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
  114. package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
  115. package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
  116. package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
  117. package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
  118. package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
  119. package/skills/visual-verification/scripts/record-av.ps1 +102 -0
  120. package/skills/visual-verification/scripts/record.ps1 +72 -0
  121. package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
  122. package/skills/visual-verification/scripts/waveform.ps1 +450 -0
  123. package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
  124. package/tests/activity.test.mjs +252 -0
  125. package/tests/attempt-budget.test.mjs +102 -0
  126. package/tests/browser.test.mjs +121 -0
  127. package/tests/context-pack.test.mjs +98 -0
  128. package/tests/dirty-gate.test.mjs +211 -0
  129. package/tests/e2e-browser.mjs +66 -0
  130. package/tests/e2e-real-orchestrator-resume.mjs +101 -0
  131. package/tests/e2e-real-orchestrator.mjs +41 -0
  132. package/tests/e2e-real-pi.mjs +27 -0
  133. package/tests/e2e-real-tool-orchestrator.mjs +66 -0
  134. package/tests/fixtures/browser-page/index.html +20 -0
  135. package/tests/fixtures/maintenance/availability.txt +5 -0
  136. package/tests/fixtures/maintenance/catalog.json +74 -0
  137. package/tests/fixtures/maintenance/events.json +13 -0
  138. package/tests/fixtures/math-repo/README.md +3 -0
  139. package/tests/fixtures/math-repo/package.json +7 -0
  140. package/tests/fixtures/math-repo/src/math.js +11 -0
  141. package/tests/fixtures/math-repo/test/math.test.js +7 -0
  142. package/tests/fixtures/observe/announcements.json +8 -0
  143. package/tests/fixtures/orch-concurrent-child.mjs +44 -0
  144. package/tests/fixtures/orch-persist-child.mjs +61 -0
  145. package/tests/job.test.mjs +230 -0
  146. package/tests/kit.test.mjs +79 -0
  147. package/tests/language-policy.test.mjs +93 -0
  148. package/tests/loop-guard.test.mjs +60 -0
  149. package/tests/maintenance-exec.test.mjs +218 -0
  150. package/tests/maintenance-runner.test.mjs +222 -0
  151. package/tests/maintenance.test.mjs +195 -0
  152. package/tests/observe.test.mjs +283 -0
  153. package/tests/observer-registry.test.mjs +157 -0
  154. package/tests/orchestrator-cleanup.test.mjs +358 -0
  155. package/tests/orchestrator-command.test.mjs +14 -0
  156. package/tests/orchestrator-persist.test.mjs +375 -0
  157. package/tests/orchestrator-tools.test.mjs +215 -0
  158. package/tests/orchestrator.test.mjs +396 -0
  159. package/tests/package.test.mjs +37 -0
  160. package/tests/pipeline.test.mjs +239 -0
  161. package/tests/planner-classification.test.mjs +81 -0
  162. package/tests/planner-split.test.mjs +67 -0
  163. package/tests/qoder-observer.test.mjs +266 -0
  164. package/tests/reassign-progression.test.mjs +104 -0
  165. package/tests/retry-escalation.test.mjs +120 -0
  166. package/tests/routing.test.mjs +110 -0
  167. package/tests/sqlite-concurrency.test.mjs +178 -0
  168. package/tests/task-global-e2e.test.mjs +63 -0
  169. package/tests/task-global-failed.test.mjs +134 -0
  170. package/tests/telemetry.test.mjs +173 -0
  171. package/tests/test-sync-pi.ps1 +56 -0
  172. package/tests/turn-budget.test.mjs +106 -0
@@ -0,0 +1,239 @@
1
+ // Registry merge, capability resolution, settings proposal, normalizer, escalation, loop prevention,
2
+ // and a fixture-repo E2E with a scripted invoker (no model calls).
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync, cpSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { loadRouting } from '../lib/routing.mjs';
10
+ import { loadAgents } from '../lib/agents.mjs';
11
+ import { validateRegistry, mergeRegistries, loadRegistry } from '../lib/registry.mjs';
12
+ import { resolveCapability, resolveAgents, formatModelId } from '../lib/resolve.mjs';
13
+ import { normalizeContextPack, classifyPath, relativizeText } from '../lib/normalize.mjs';
14
+ import { withEscalation, runPipeline, parseFileBlocks, runTests } from '../lib/pipeline.mjs';
15
+ import { buildSettingsProposal } from '../adapters/pi/lib/settings-proposal.mjs';
16
+ import { createScriptedInvoker } from '../adapters/pi/lib/invoke.mjs';
17
+
18
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
19
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
20
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
21
+
22
+ // Test registry with neutral names: no real provider ids in tests.
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
+ qoder: { provider: 'pq', model: 'mq', thinking: 'low' },
30
+ devin: { provider: 'pd', model: 'md', thinking: 'high' },
31
+ } };
32
+
33
+ // ---------- registry ----------
34
+ test('registry: valid, unknown key, unknown backend, credential and thinking errors', () => {
35
+ assert.deepEqual(validateRegistry(REG, routing), []);
36
+ const bad = structuredClone(REG); bad.extra = 1; bad.backends.ghost = { provider: 'p', model: 'm' }; bad.backends.sol.apiKey = 'x'; bad.backends.cheap.thinking = 'ultra'; bad.backends.local.color = 'red';
37
+ const errs = validateRegistry(bad, routing);
38
+ for (const needle of ['unknown top-level key "extra"', 'backend "ghost" is not defined', 'must not store credentials', 'thinking must be one of', 'unknown key "color"']) assert.ok(errs.some(e => e.includes(needle)), needle);
39
+ });
40
+
41
+ test('registry: models.local.json overrides models.json per backend and can add backends', () => {
42
+ const base = { version: 1, backends: { cheap: { provider: 'TODO-provider', model: 'TODO', thinking: 'low' }, sol: { provider: 'ps', model: 'ms' } } };
43
+ const local = { version: 1, backends: { cheap: { provider: 'pc', model: 'mc' }, local: { provider: 'pl', model: 'ml' } } };
44
+ const m = mergeRegistries(base, local);
45
+ assert.deepEqual(m.backends.cheap, { provider: 'pc', model: 'mc', thinking: 'low' });
46
+ assert.deepEqual(m.backends.sol, { provider: 'ps', model: 'ms' });
47
+ assert.ok(m.backends.local);
48
+ });
49
+
50
+ test('registry: loadRegistry merges files and rejects invalid local file', () => {
51
+ const dir = mkdtempSync(join(tmpdir(), 'ludi-reg-'));
52
+ writeFileSync(join(dir, 'models.json'), JSON.stringify({ version: 1, backends: { sol: { provider: 'TODO-provider', model: 'TODO-m' } } }));
53
+ writeFileSync(join(dir, 'models.local.json'), JSON.stringify({ version: 1, backends: { sol: { provider: 'ps', model: 'ms' } } }));
54
+ const { registry, sources } = loadRegistry(join(dir, 'models.json'), join(dir, 'models.local.json'), routing);
55
+ assert.equal(registry.backends.sol.provider, 'ps');
56
+ assert.ok(sources.local);
57
+ writeFileSync(join(dir, 'models.local.json'), JSON.stringify({ version: 1, backends: { nope: { provider: 'p', model: 'm' } } }));
58
+ assert.throws(() => loadRegistry(join(dir, 'models.json'), join(dir, 'models.local.json'), routing), /models.local.json: backend "nope"/);
59
+ const shipped = loadRegistry(join(kit, 'adapters/pi/models.json'), null, routing);
60
+ assert.equal(shipped.sources.local, null);
61
+ });
62
+
63
+ // ---------- resolution ----------
64
+ test('resolve: agent -> capability -> backend chain -> concrete model; placeholders skipped', () => {
65
+ const r = resolveAgents(agents, routing, REG);
66
+ assert.equal(r.scout.capability, 'cheap-code');
67
+ assert.deepEqual(r.scout.candidates.map(c => c.modelId), ['pq/mq:low', 'pc/mc:low', 'pl/ml:off', 'ps/ms:high']);
68
+ assert.deepEqual(r.coder.candidates.map(c => c.backend), ['devin', 'qoder', 'sol', 'codex', 'local']);
69
+ assert.equal(r.visual.candidates[1].degraded, true);
70
+ const partial = { version: 1, backends: { sol: { provider: 'TODO-provider', model: 'TODO' }, codex: REG.backends.codex } };
71
+ const c = resolveCapability(routing, partial, 'strong-code');
72
+ assert.deepEqual(c.placeholder, ['sol']);
73
+ assert.deepEqual(c.unbound, ['devin', 'qoder', 'local']);
74
+ assert.deepEqual(c.candidates.map(x => x.backend), ['codex']);
75
+ assert.equal(formatModelId({ provider: 'a', model: 'b' }), 'a/b');
76
+ });
77
+
78
+ // ---------- settings proposal ----------
79
+ test('proposal: pi-subagents agentOverrides shape (model + thinking only) and diff against live settings', () => {
80
+ const r = resolveAgents(agents, routing, REG);
81
+ const live = { subagents: { agentOverrides: { coder: { model: 'ps/ms', thinking: 'high' }, scout: { model: 'old/x' } } } };
82
+ const p = buildSettingsProposal(r, { liveSettings: live });
83
+ assert.deepEqual(Object.keys(p.proposal), ['subagents']);
84
+ assert.deepEqual(Object.keys(p.proposal.subagents), ['agentOverrides']);
85
+ for (const o of Object.values(p.proposal.subagents.agentOverrides)) assert.deepEqual(Object.keys(o).sort(), ['model', 'thinking']);
86
+ assert.deepEqual(p.proposal.subagents.agentOverrides.coder, { model: 'pd/md', thinking: 'high' });
87
+ assert.equal(p.diff.find(d => d.agent === 'coder').change, 'update');
88
+ assert.equal(p.diff.find(d => d.agent === 'scout').change, 'update');
89
+ assert.equal(p.diff.find(d => d.agent === 'visual').change, 'add');
90
+ const none = buildSettingsProposal(resolveAgents(agents, routing, { version: 1, backends: {} }));
91
+ assert.deepEqual(none.proposal.subagents.agentOverrides, {});
92
+ assert.equal(none.notes.length, agents.length);
93
+ });
94
+
95
+ // ---------- normalizer ----------
96
+ test('normalize: repo-relative kept, in-repo absolute rewritten, outside absolute dropped explicitly', () => {
97
+ const repo = 'D:\\work\\repo';
98
+ assert.deepEqual(classifyPath('src\\a.js', repo), { kind: 'relative', path: 'src/a.js' });
99
+ assert.deepEqual(classifyPath('D:\\work\\repo\\src\\a.js', repo), { kind: 'inside', path: 'src/a.js' });
100
+ assert.equal(classifyPath('C:\\Users\\someone\\x.log', repo).kind, 'outside');
101
+ const md = '# Context Pack\n## task\nt\n## goal\ng\n## constraints\n## relevant_files\n- `D:\\work\\repo\\src\\a.js` (lines 1-2) — x\n- `src/a.js`\n- `C:\\Users\\someone\\x.log`\n## observed_errors\n```\nError at D:\\work\\repo\\src\\a.js:3 and C:\\Users\\someone\\node.exe\n```\n## expected_output\ne\n';
102
+ const n = normalizeContextPack(md, { repoRoot: repo, capability: 'strong-code', producedBy: 'scout@test' });
103
+ assert.deepEqual(n.errors, []);
104
+ assert.deepEqual(n.pack.relevant_files, [{ path: 'src/a.js', lines: '1-2', reason: 'x' }]);
105
+ assert.deepEqual(n.report.dropped, [{ field: 'relevant_files', path: 'C:/Users/someone/x.log' }]);
106
+ assert.ok(n.report.external.includes('C:/Users/someone/node.exe'));
107
+ assert.match(n.pack.observed_errors[0], /Error at src\/a\.js:3 and C:\\Users\\someone\\node\.exe/);
108
+ assert.ok(n.pack.constraints.some(c => c.startsWith('unknown: 1 path')));
109
+ assert.match(n.markdown, /^# Context Pack/);
110
+ assert.equal(relativizeText('see D:\\work\\repo\\x\\y.js', repo).text, 'see x/y.js');
111
+ });
112
+
113
+ test('normalize: fills goal/expected_output defaults and reports validation errors instead of throwing', () => {
114
+ const n = normalizeContextPack({ task: 't', relevant_files: [{ path: 'a.js' }] }, { repoRoot: 'D:\\r', task: 't' });
115
+ assert.deepEqual(n.errors, []);
116
+ assert.equal(n.pack.goal, 't');
117
+ const empty = normalizeContextPack({ task: 't', relevant_files: [] }, { repoRoot: 'D:\\r' });
118
+ assert.ok(empty.errors.some(e => e.includes('at least one')));
119
+ assert.equal(empty.markdown, null);
120
+ });
121
+
122
+ // ---------- escalation ----------
123
+ test('escalation: primary failure -> fallback[0]; previous_attempts appended; bounded by maxAttempts', async () => {
124
+ const pack = { previous_attempts: [] }; const trace = [];
125
+ const seen = [];
126
+ const r = await withEscalation({ routing, registry: REG, capability: 'strong-code', agent: 'coder', pack, trace, maxAttempts: 2,
127
+ fn: async c => { seen.push(c.backend); return c.backend === 'devin' ? { ok: false, reason: 'provider 500' } : { ok: true, reason: 'fine' }; } });
128
+ assert.equal(r.ok, true); assert.equal(r.escalated, true); assert.equal(r.attempts, 2);
129
+ assert.deepEqual(seen, ['devin', 'qoder']);
130
+ assert.equal(pack.previous_attempts.length, 1);
131
+ assert.match(pack.previous_attempts[0].summary, /backend devin \(pd\/md:high\) attempt 1/);
132
+ assert.equal(pack.previous_attempts[0].outcome, 'provider 500');
133
+ assert.deepEqual(trace.map(t => [t.backend, t.ok]), [['devin', false], ['qoder', true]]);
134
+ });
135
+
136
+ test('escalation: exhausted after maxAttempts; escalation candidate reported; thrown errors are attempts too', async () => {
137
+ const pack = {}; const trace = []; let calls = 0;
138
+ const r = await withEscalation({ routing, registry: REG, capability: 'cheap-code', agent: 'scout', pack, trace, maxAttempts: 2,
139
+ fn: async () => { calls++; throw new Error('boom'); } });
140
+ assert.equal(r.ok, false); assert.equal(calls, 2); assert.equal(r.exhausted, false);
141
+ assert.deepEqual(r.escalationCandidate, { backend: 'local', modelId: 'pl/ml:off' });
142
+ assert.equal(pack.previous_attempts.length, 2);
143
+ const single = await withEscalation({ routing, registry: { version: 1, backends: { devin: REG.backends.devin } }, capability: 'deep-review', agent: 'reviewer', pack: {}, trace: [], maxAttempts: 5, fn: async () => ({ ok: false, reason: 'no' }) });
144
+ assert.equal(single.attempts, 1); assert.equal(single.exhausted, true); assert.equal(single.escalationCandidate, null);
145
+ });
146
+
147
+ test('loop prevention: identical modelId on two backends is invoked once; no attempt exceeds the chain', async () => {
148
+ const dup = { version: 1, backends: { sol: { provider: 'p', model: 'm' }, codex: { provider: 'p', model: 'm' } } };
149
+ let calls = 0;
150
+ const r = await withEscalation({ routing, registry: dup, capability: 'strong-code', agent: 'coder', pack: {}, trace: [], maxAttempts: 10, fn: async () => { calls++; return { ok: false, reason: 'x' }; } });
151
+ assert.equal(calls, 1); assert.equal(r.ok, false);
152
+ await assert.rejects(() => withEscalation({ routing, registry: { version: 1, backends: {} }, capability: 'strong-code', agent: 'coder', pack: {}, trace: [], fn: async () => ({ ok: true }) }), /no bound model/);
153
+ });
154
+
155
+ // ---------- fixture E2E (scripted invoker) ----------
156
+ function freshFixture() {
157
+ const dir = mkdtempSync(join(tmpdir(), 'ludi-e2e-'));
158
+ cpSync(join(kit, 'tests/fixtures/math-repo'), dir, { recursive: true });
159
+ return dir;
160
+ }
161
+ const SCOUT_OUT = repo => `# Context Pack\n\n## task\nFix average()\n\n## goal\nAll tests in test/math.test.js pass.\n\n## constraints\n- Only touch src/math.js\n\n## relevant_files\n- \`${repo}\\src\\math.js\` (lines 8-11) — average divides by length + 1\n- \`test/math.test.js\` — failing expectation\n\n## observed_errors\n\`\`\`\nactual: 2, expected: 3\n\`\`\`\n\n## test_commands\n- \`npm test\`\n\n## expected_output\nDiff limited to src/math.js; npm test passes.\n`;
162
+ const GOOD_FIX = `=== FILE: src/math.js ===\nexport function sum(values) {\n let total = 0;\n for (const v of values) total += v;\n return total;\n}\n\nexport function average(values) {\n if (values.length === 0) return 0;\n return sum(values) / values.length;\n}\n=== END ===`;
163
+ const BAD_FIX = `=== FILE: src/math.js ===\nexport function sum(values) { return values.reduce((a, b) => a + b, 0); }\nexport function average(values) { return values.length === 0 ? 0 : sum(values) / (values.length + 2); }\n=== END ===`;
164
+
165
+ test('E2E: task -> scout -> Context Pack (normalized) -> coder -> tests pass; selection observable', async () => {
166
+ const repo = freshFixture();
167
+ assert.equal(runTests(repo, 'npm test').ok, false, 'fixture must start red');
168
+ const calls = [];
169
+ const invoke = createScriptedInvoker({ 'pq/mq:low': SCOUT_OUT(repo), 'pd/md:high': GOOD_FIX }, calls);
170
+ const s = await runPipeline({ repoRoot: repo, task: 'Fix the failing average test', routing, registry: REG, agents, invoke, outDir: join(repo, '.pipeline-out') });
171
+ assert.equal(s.outcome, 'success');
172
+ assert.equal(s.escalated, false);
173
+ assert.deepEqual(s.applied, ['src/math.js']);
174
+ assert.deepEqual(s.contextPackFiles, ['src/math.js', 'test/math.test.js']); // absolute in-repo path normalized
175
+ assert.deepEqual(s.steps.map(x => [x.step, x.agent, x.capability, x.backend, x.modelId, x.ok]), [
176
+ ['scout', 'scout', 'cheap-code', 'qoder', 'pq/mq:low', true],
177
+ ['coder', 'coder', 'strong-code', 'devin', 'pd/md:high', true],
178
+ ]);
179
+ assert.deepEqual(calls.map(c => c.modelId), ['pq/mq:low', 'pd/md:high']);
180
+ assert.ok(existsSync(join(repo, '.pipeline-out/context-pack.md')));
181
+ assert.ok(existsSync(join(repo, '.pipeline-out/coder.input.devin.md')));
182
+ assert.ok(existsSync(join(repo, '.pipeline-out/trace.json')));
183
+ assert.equal(runTests(repo, 'npm test').ok, true);
184
+ assert.match(readFileSync(join(repo, '.pipeline-out/context-pack.md'), 'utf8'), /produced_by|## task/);
185
+ });
186
+
187
+ test('E2E: coder primary produces a failing patch -> escalates to fallback -> success; attempts recorded in pack', async () => {
188
+ const repo = freshFixture();
189
+ const calls = [];
190
+ const invoke = createScriptedInvoker({ 'pq/mq:low': [SCOUT_OUT(repo), GOOD_FIX], 'pd/md:high': BAD_FIX }, calls);
191
+ const s = await runPipeline({ repoRoot: repo, task: 'Fix average', routing, registry: REG, agents, invoke, outDir: join(repo, '.pipeline-out') });
192
+ assert.equal(s.outcome, 'success'); assert.equal(s.escalated, true); assert.equal(s.attempts, 2);
193
+ assert.deepEqual(calls.map(c => c.modelId), ['pq/mq:low', 'pd/md:high', 'pq/mq:low']);
194
+ const pack = readFileSync(join(repo, '.pipeline-out/context-pack.md'), 'utf8');
195
+ assert.match(pack, /## previous_attempts\n- coder on backend devin \(pd\/md:high\) attempt 1 — outcome: tests failed/);
196
+ // the coder input for the fallback attempt carried the failure forward
197
+ assert.match(readFileSync(join(repo, '.pipeline-out/coder.input.qoder.md'), 'utf8'), /previous_attempts/);
198
+ });
199
+
200
+ test('E2E: all coder attempts fail -> outcome exhausted (2 candidates, maxAttempts 2), repo edits confined to relevant_files', async () => {
201
+ const repo = freshFixture();
202
+ const invoke = createScriptedInvoker({ 'pq/mq:low': SCOUT_OUT(repo), '*': BAD_FIX + `\n=== FILE: package.json ===\n{}\n=== END ===` });
203
+ const s = await runPipeline({ repoRoot: repo, task: 'Fix average', routing, registry: REG, agents, invoke, outDir: join(repo, '.pipeline-out') });
204
+ assert.equal(s.outcome, 'escalation-candidate'); assert.equal(s.attempts, 2);
205
+ assert.deepEqual(s.escalationCandidate, { backend: 'sol', modelId: 'ps/ms:high' });
206
+ assert.equal(JSON.parse(readFileSync(join(repo, 'package.json'), 'utf8')).name, 'fixture-math-repo', 'package.json must not be overwritten');
207
+ });
208
+
209
+ test('E2E: scout returns garbage -> scout escalates; --pack path bypasses scout', async () => {
210
+ const repo = freshFixture();
211
+ const calls = [];
212
+ const invoke = createScriptedInvoker({ 'pq/mq:low': 'I could not find anything.', 'pc/mc:low': SCOUT_OUT(repo), 'pd/md:high': GOOD_FIX }, calls);
213
+ const s = await runPipeline({ repoRoot: repo, task: 'Fix average', routing, registry: REG, agents, invoke, outDir: join(repo, '.pipeline-out') });
214
+ assert.equal(s.outcome, 'success');
215
+ assert.deepEqual(s.steps.filter(x => x.step === 'scout').map(x => [x.backend, x.ok]), [['qoder', false], ['cheap', true]]);
216
+ const repo2 = freshFixture();
217
+ const packPath = join(repo2, 'pack.md'); writeFileSync(packPath, SCOUT_OUT(repo2));
218
+ const s2 = await runPipeline({ repoRoot: repo2, task: 'x', routing, registry: REG, agents, invoke: createScriptedInvoker({ 'pd/md:high': GOOD_FIX }), outDir: join(repo2, '.out'), packPath });
219
+ assert.equal(s2.outcome, 'success'); assert.equal(s2.steps.length, 1);
220
+ });
221
+
222
+ test('E2E greenfield: scout marks (new) files; coder may create only those; nested test dir created', async () => {
223
+ const repo = mkdtempSync(join(tmpdir(), 'ludi-green-'));
224
+ writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'g', private: true, type: 'module', scripts: { test: 'node --test test/greet.test.js' } }));
225
+ const scoutOut = '## task\nAdd greet\n## goal\ngreet works\n## constraints\n## discovery\npartial — only package.json exists\n## relevant_files\n- `package.json` — test script\n- `src/greet.js` — (new) module\n- `test/greet.test.js` — (new) tests\n## expected_output\nnew files + passing tests\n';
226
+ const coderOut = '=== FILE: src/greet.js ===\nexport const greet = n => `Hello, ${n}!`;\n=== END ===\n=== FILE: test/greet.test.js ===\nimport test from "node:test"; import assert from "node:assert/strict"; import { greet } from "../src/greet.js";\ntest("greet", () => assert.equal(greet("A"), "Hello, A!"));\n=== END ===\n=== FILE: docs/evil.md ===\nnope\n=== END ===';
227
+ const s = await runPipeline({ repoRoot: repo, task: 'Add greet', routing, registry: REG, agents, invoke: createScriptedInvoker({ 'pq/mq:low': scoutOut, 'pd/md:high': coderOut }), outDir: join(repo, '.out') });
228
+ assert.equal(s.outcome, 'success');
229
+ assert.deepEqual(s.applied, ['src/greet.js', 'test/greet.test.js']);
230
+ assert.ok(!existsSync(join(repo, 'docs/evil.md')));
231
+ const pack = readFileSync(join(repo, '.out/context-pack.md'), 'utf8');
232
+ assert.match(pack, /## discovery\npartial/);
233
+ assert.match(pack, /`src\/greet.js` — \(new\) module/);
234
+ });
235
+
236
+ test('parseFileBlocks handles CRLF and multiple blocks', () => {
237
+ const b = parseFileBlocks('=== FILE: a.js ===\r\nx\r\n=== END ===\r\n=== FILE: b/c.js ===\ny\n=== END ===');
238
+ assert.deepEqual(b, [{ path: 'a.js', content: 'x' }, { path: 'b/c.js', content: 'y' }]);
239
+ });
@@ -0,0 +1,81 @@
1
+ // Planner request classification: history / UI concerns must not fire on everyday
2
+ // words (build, guide, linux, requires, pre-commit, から, from, instructions) and
3
+ // splitInvestigation must only run for genuinely multi-concern requests.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
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 { classifyRequest, planRules } from '../lib/orchestrator/planner.mjs';
11
+
12
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
14
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
15
+ const scouts = req => planRules(req, { agents }).tasks.filter(t => t.agent === 'scout').length;
16
+ const isSplit = req => scouts(req) > 1;
17
+
18
+ const SPLIT = [
19
+ 'コミットから好みを推測してUXを改善',
20
+ 'コミット履歴を分析してUIの好みを反映して',
21
+ 'Analyze past commits to infer my UI preferences',
22
+ 'Look at the commit history and infer my UX preferences, then apply them to the settings screen',
23
+ 'Infer preferences from previous commits and improve the layout',
24
+ ];
25
+ const NO_SPLIT = [
26
+ 'このコミットを修正して',
27
+ 'このコミットを戻して',
28
+ 'Add a pre-commit hook',
29
+ 'Add a pre-commit hook from the template and build the docs',
30
+ 'build the docs',
31
+ 'update the guide',
32
+ 'fix Linux support',
33
+ 'Run the tests',
34
+ 'The module requires a rebuild on linux',
35
+ 'Follow the instructions from the README and fix the build',
36
+ 'READMEを英語から日本語に翻訳して、コミットメッセージも直して',
37
+ 'SELECT文のパフォーマンスを調べて',
38
+ 'Fix the failing average() test',
39
+ ];
40
+
41
+ test('split: multi-concern history + UI requests produce >1 scout plus synthesis', () => {
42
+ for (const req of SPLIT) {
43
+ const c = classifyRequest(req);
44
+ assert.equal(c.history, true, `history: ${req}`);
45
+ assert.equal(c.ui || c.visual, true, `ui: ${req}`);
46
+ assert.ok(isSplit(req), `expected split: ${req}`);
47
+ const p = planRules(req, { agents });
48
+ assert.ok(p.tasks.some(t => /Synthesize/.test(t.title)), `synthesis: ${req}`);
49
+ assert.ok(p.tasks.length <= 8, `task count ${p.tasks.length} for: ${req}`);
50
+ }
51
+ });
52
+
53
+ test('no split: single-commit fixes, tooling and everyday words stay one scout', () => {
54
+ for (const req of NO_SPLIT) {
55
+ assert.equal(scouts(req), 1, `expected exactly one scout for: ${req} -> ${JSON.stringify(classifyRequest(req))}`);
56
+ }
57
+ });
58
+
59
+ test('negative: UI is not detected inside build/guide/linux/requires', () => {
60
+ for (const req of ['build the docs', 'update the guide', 'fix Linux support', 'The module requires a rebuild', 'Debug the linux build', 'quick guide']) {
61
+ assert.equal(classifyRequest(req).ui, false, req);
62
+ }
63
+ });
64
+
65
+ test('positive: UI/UX as whole tokens and Japanese 画面/好み are detected', () => {
66
+ for (const req of ['Improve the UI', 'UX review', 'Redesign the settings screen', '画面を見直す', '私の好みに合わせて']) {
67
+ assert.equal(classifyRequest(req).ui, true, req);
68
+ }
69
+ });
70
+
71
+ test('negative: history is not detected from pre-commit / from / instructions / から', () => {
72
+ for (const req of ['Add a pre-commit hook', 'configure post-commit hooks', 'Follow the instructions from the docs', 'Commit the change', 'このコミットを修正して', 'READMEを英語から日本語に翻訳して、コミットメッセージも直して', 'コミットメッセージのフォーマットを指示どおりに直して']) {
73
+ assert.equal(classifyRequest(req).history, false, req);
74
+ }
75
+ });
76
+
77
+ test('positive: history is detected with analysis context', () => {
78
+ for (const req of ['Analyze past commits to infer my UI preferences', 'コミットから好みを推測して', 'コミットの傾向を分析して', 'Look at the git log for style patterns', 'infer conventions from previous commits', 'Review the commit history for regressions']) {
79
+ assert.equal(classifyRequest(req).history, true, req);
80
+ }
81
+ });
@@ -0,0 +1,67 @@
1
+ // Planner staged-scope gating: an explicit "Phase 0 だけを開始" / "調査が終わる
2
+ // までは実装を始めない" request authorizes ONE stage only. Keywords belonging to
3
+ // later phases (実装, fix, UI, browser check) must not spawn coder/tester/
4
+ // browser/visual/reviewer tasks. Ordinary implementation requests are unaffected.
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { resolve, dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { loadRouting } from '../lib/routing.mjs';
10
+ import { loadAgents } from '../lib/agents.mjs';
11
+ import { classifyRequest, planRules } from '../lib/orchestrator/planner.mjs';
12
+
13
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
14
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
15
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
16
+ const agentsOf = req => planRules(req, { agents }).tasks.map(t => t.agent);
17
+
18
+ const STAGED = [
19
+ // The pi-console style request: explicit staged start + an implementation hold,
20
+ // while later-phase keywords (改善/実装/ブラウザ/UI) appear in the same request.
21
+ 'pi-consoleを改善してほしい。まずPhase 0だけを開始して。Phase 0の調査が終わるまでは、実装を始めないでください。Phase 1以降のUI修正やブラウザ確認はその後です',
22
+ 'まずPhase 0だけを開始して。調査が終わるまでは実装を始めないで',
23
+ 'Phase 0の調査だけをお願いします。実装はまだです',
24
+ '調査だけして。実装はまだ',
25
+ 'まずフェーズ0のみを開始して',
26
+ 'Do not implement until the investigation is done',
27
+ 'Start only phase 0: investigate the planner. The fix, browser check and review come later',
28
+ ];
29
+ const NOT_STAGED = [
30
+ 'Fix the failing average() test',
31
+ 'DOLL v2 Phase 2を進める',
32
+ 'このコミットを修正して',
33
+ 'まずこのコミットを修正して',
34
+ 'Update the settings screen UI and check it in the browser',
35
+ 'コミットや指示から僕の好みを推測して、このリポジトリを改善できますか',
36
+ ];
37
+
38
+ test('staged: explicit phase/investigation-only requests produce investigation-only tasks', () => {
39
+ for (const req of STAGED) {
40
+ const c = classifyRequest(req);
41
+ assert.equal(c.staged, true, `staged flag: ${req}`);
42
+ assert.equal(c.implement, false, `implement suppressed: ${req}`);
43
+ assert.equal(c.review, false, `review suppressed: ${req}`);
44
+ assert.equal(c.visual, false, `visual suppressed: ${req}`);
45
+ assert.equal(c.browser, false, `browser suppressed: ${req}`);
46
+ const got = agentsOf(req);
47
+ assert.ok(got.length >= 1, `at least one task: ${req}`);
48
+ assert.ok(got.every(a => a === 'scout'), `investigation-only agents, got ${JSON.stringify(got)} for: ${req}`);
49
+ }
50
+ });
51
+
52
+ test('staged: a single-scope request keeps one scout, no coder/tester/reviewer', () => {
53
+ const p = planRules(STAGED[0], { agents });
54
+ assert.deepEqual(p.tasks.map(t => t.agent), ['scout']);
55
+ assert.match(p.tasks[0].goal, /pi-console/);
56
+ });
57
+
58
+ test('not staged: ordinary implementation and phase-progress requests unchanged', () => {
59
+ for (const req of NOT_STAGED) {
60
+ const c = classifyRequest(req);
61
+ assert.equal(c.staged, false, `not staged: ${req}`);
62
+ assert.equal(c.implement, true, `implement kept: ${req}`);
63
+ }
64
+ assert.deepEqual(agentsOf('Fix the failing average() test'), ['scout', 'coder', 'tester', 'reviewer']);
65
+ assert.deepEqual(agentsOf('DOLL v2 Phase 2を進める'), ['scout', 'coder', 'tester', 'reviewer']);
66
+ assert.deepEqual(agentsOf('Update the settings screen UI and check it in the browser'), ['scout', 'coder', 'tester', 'visual', 'browser', 'reviewer']);
67
+ });