@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,218 @@
1
+ // Phase 2: maintenance execution tiers + cheapest-sufficient selection.
2
+ // Cases A-G from the Phase 2 spec. Selection is a dry-run decision layer: no model
3
+ // is invoked and no config is written.
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 {
9
+ selectTierModel, buildMonitorOutput, escalationDecision, runMaintenancePlan,
10
+ localElectricityCost, estimatedCostPerRun, validateExecPolicy, loadExecPolicy, DEFAULT_POLICY,
11
+ } from '../lib/maintenance-exec.mjs';
12
+ import { effectiveCatalog, evaluateMaintenance, loadCatalog, validateCatalog } from '../lib/maintenance.mjs';
13
+ import { loadRouting } from '../lib/routing.mjs';
14
+ import { loadAgents } from '../lib/agents.mjs';
15
+ import { mergeRegistries } from '../lib/registry.mjs';
16
+
17
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
19
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
20
+
21
+ // --- fixture helpers -------------------------------------------------------
22
+ const m = (provider, model, over = {}) => ({
23
+ provider, model, status: 'active', cost: { usdPerMInput: 0.5, usdPerMOutput: 2 },
24
+ contextK: 256, vision: false, toolUse: 'good', location: 'cloud',
25
+ scores: { coding: 60, reasoning: 55, speed: 70 }, ...over,
26
+ });
27
+ const cat = models => ({ version: 1, updatedAt: '2026-03-01', models });
28
+ const registryWith = backends => mergeRegistries(null, { version: 1, backends });
29
+ const sel = (models, tier, opts) => selectTierModel(models, tier, DEFAULT_POLICY, opts);
30
+
31
+ test('SWE-2 campaign cutoff changes free-capacity advice without live rebinding', () => {
32
+ const catalog = loadCatalog(join(kit, 'adapters/pi/model-catalog.json'));
33
+ const policy = loadExecPolicy(join(kit, 'adapters/pi/maintenance-policy.json'));
34
+ const reg = registryWith({ devin: { provider: 'devin', model: 'swe-2-high' } });
35
+ const before = runMaintenancePlan({ routing, registry: reg, agents, catalog, policy, asOf: '2026-10-10T14:59:59Z' });
36
+ const beforeCode = before.freeCapacityPlan.find(p => p.capability === 'strong-code');
37
+ assert.equal(beforeCode.recommendedFree, 'devin/swe-2-high');
38
+ assert.equal(before.freeCapacityPlan.find(p => p.capability === 'deep-review').recommendedFree, 'devin/swe-2-high');
39
+ assert.equal(before.freeCapacityPlan.find(p => p.capability === 'cheap-code').recommendedFree, 'qoder/Qwen3.8-Flash');
40
+ assert.equal(before.freeCapacityPlan.find(p => p.capability === 'vision-reasoning').recommendedFree, null);
41
+ assert.equal(beforeCode.freeCandidates[0].freeUntil, '2026-10-10T15:00:00Z');
42
+ assert.equal(before.outcome, 'no-change');
43
+
44
+ const after = runMaintenancePlan({ routing, registry: reg, agents, catalog, policy, asOf: '2026-10-10T15:00:00Z' });
45
+ assert.equal(after.freeCapacityPlan.find(p => p.capability === 'strong-code').recommendedFree, null);
46
+ assert.equal(after.monitor.changed, true);
47
+ assert.match(after.monitor.reasons.join(' '), /free campaign cutoff reached/);
48
+ assert.equal(after.proposal.decisions.find(d => d.backend === 'devin').decision === 'keep' || after.proposal.decisions.find(d => d.backend === 'devin').decision === 'propose', true);
49
+ assert.equal(effectiveCatalog(catalog, [], '2026-10-10T15:00:00Z').find(m => m.provider === 'devin').cost.free, false);
50
+ assert.deepEqual(validateCatalog({ version: 1, models: [{ ...catalog.models.find(m => m.provider === 'devin'), freeUntil: 'tomorrow' }] }).some(e => e.includes('freeUntil')), true);
51
+ });
52
+
53
+ const FREE_STRONG = m('freep', 'free-strong', { cost: { free: true }, scores: { coding: 78, reasoning: 70, speed: 80 } });
54
+ const FREE_WEAK = m('freep', 'free-weak', { cost: { free: true }, scores: { coding: 30, reasoning: 25, speed: 60 } });
55
+ const CHEAP_OK = m('cloudp', 'cheap-ok', { cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, scores: { coding: 68, reasoning: 62, speed: 75 } });
56
+ const EXPENSIVE_STRONG = m('cloudp', 'pro-strong', { cost: { usdPerMInput: 5, usdPerMOutput: 20 }, scores: { coding: 90, reasoning: 88, speed: 60 } });
57
+ const LOCAL = m('localp', 'local-mid', { location: 'local', cost: { free: true }, local: { powerWatts: 400, taskMinutes: 10 }, scores: { coding: 45, reasoning: 40, speed: 50 }, toolUse: 'basic' });
58
+
59
+ // --- A: strong free model -> monitor and evaluate both pick it --------------
60
+ test('Case A: a strong free model is selected for monitor and evaluate', () => {
61
+ const models = [FREE_STRONG, CHEAP_OK, EXPENSIVE_STRONG];
62
+ for (const tier of ['monitor', 'evaluate']) {
63
+ const r = sel(models, tier);
64
+ assert.equal(r.selected.model, 'freep/free-strong', `${tier}: ${r.selectionReason}`);
65
+ assert.equal(r.selectionPath, 'free-cloud');
66
+ assert.equal(r.selected.effectiveCostUsd < 0.02, true); // only the speed penalty
67
+ }
68
+ });
69
+
70
+ // --- B: free ends, cheap sufficient cloud -> cheapest-sufficient, no premium
71
+ test('Case B: free campaign end -> cheapest-sufficient cloud, no reconfigure escalation', () => {
72
+ const catalog = cat([
73
+ m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 62, reasoning: 55, speed: 85 } }),
74
+ CHEAP_OK, EXPENSIVE_STRONG,
75
+ ]);
76
+ const events = [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-01' }];
77
+ const effective = effectiveCatalog(catalog, events);
78
+ const r = sel(effective, 'evaluate');
79
+ assert.equal(r.selected.model, 'cloudp/cheap-ok', r.selectionReason);
80
+ assert.equal(r.selectionPath, 'cheapest-sufficient-cloud');
81
+ // and the pipeline: monitor detects, evaluate decides, no premium escalation
82
+ const registry = registryWith({ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' } });
83
+ const run = runMaintenancePlan({ routing, registry, agents, catalog, events });
84
+ assert.equal(run.monitor.changed, true);
85
+ assert.equal(run.escalation, null);
86
+ assert.equal(run.outcome, 'evaluated-no-change'); // advantage below margin -> keep
87
+ });
88
+
89
+ // --- C: cheap models below requiredQuality -> escalate to a stronger model -
90
+ test('Case C: models below requiredQuality are rejected; a stronger candidate is selected', () => {
91
+ const weak = m('cloudp', 'too-weak', { cost: { usdPerMInput: 0.05, usdPerMOutput: 0.2 }, scores: { coding: 30, reasoning: 25, speed: 80 } });
92
+ const r = sel([weak, EXPENSIVE_STRONG], 'evaluate'); // requiredQuality 65
93
+ assert.equal(r.selected.model, 'cloudp/pro-strong');
94
+ const rejected = r.candidates.find(c => c.model === 'cloudp/too-weak');
95
+ assert.equal(rejected.eligible, false);
96
+ assert.ok(rejected.rejectedReasons.some(x => x.includes('requiredQuality')));
97
+ });
98
+
99
+ // --- D: no eligible cloud -> local fallback --------------------------------
100
+ test('Case D: cloud unusable -> local fallback with electricity cost', () => {
101
+ const gone = m('cloudp', 'gone', { status: 'removed' });
102
+ const r = sel([gone, LOCAL], 'monitor'); // requiredQuality 40; local quality ~44
103
+ assert.equal(r.selectionPath, 'local-fallback');
104
+ assert.equal(r.selected.model, 'localp/local-mid');
105
+ assert.equal(r.fallbackOccurred, true);
106
+ assert.equal(r.selected.location, 'local');
107
+ assert.ok(r.selected.costDetail.electricityUsd > 0);
108
+ });
109
+
110
+ // --- E: cloud API cheaper than local electricity -> cloud chosen ------------
111
+ test('Case E: cheaper cloud API beats local electricity estimate', () => {
112
+ const powerHungry = structuredClone(LOCAL);
113
+ powerHungry.local = { powerWatts: 900, taskMinutes: 30 }; // 0.45kWh * $0.30 = $0.135/run
114
+ const cheapCloud = m('cloudp', 'tiny', { cost: { usdPerMInput: 0.05, usdPerMOutput: 0.1 }, scores: { coding: 55, reasoning: 50, speed: 90 } });
115
+ const r = sel([powerHungry, cheapCloud], 'monitor');
116
+ assert.equal(r.selected.location, 'cloud');
117
+ assert.equal(r.selected.model, 'cloudp/tiny');
118
+ // sanity: the local run really would cost more
119
+ assert.ok(localElectricityCost(powerHungry.local, 0.30) > estimatedCostPerRun(cheapCloud, DEFAULT_POLICY, [powerHungry, cheapCloud], 'monitor').total);
120
+ });
121
+
122
+ // --- F: multi-capability change -> reconfigure escalation -------------------
123
+ test('Case F: changes spanning multiple capabilities escalate to reconfigure', () => {
124
+ const catalog = cat([
125
+ m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 30, reasoning: 25, speed: 85 } }),
126
+ CHEAP_OK, EXPENSIVE_STRONG,
127
+ m('cloudp', 'vis-strong', { vision: true, cost: { usdPerMInput: 3, usdPerMOutput: 12 }, scores: { coding: 85, reasoning: 88, speed: 55 } }),
128
+ ]);
129
+ const registry = registryWith({
130
+ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' },
131
+ sol: { provider: 'qoder', model: 'Qwen3.8-Flash' }, // two primaries on the same dying model
132
+ astra: { provider: 'qoder', model: 'Qwen3.8-Flash' }, // vision caps too
133
+ });
134
+ const events = [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-01' }];
135
+ const run = runMaintenancePlan({ routing, registry, agents, catalog, events });
136
+ assert.ok(run.escalation, 'expected escalation');
137
+ assert.equal(run.escalation.targetTier, 'reconfigure');
138
+ assert.equal(run.escalation.sourceTier, 'evaluate');
139
+ assert.ok(run.escalation.affectedCapabilities.length >= 2);
140
+ assert.ok(run.tiers.some(t => t.role === 'reconfigure'));
141
+ assert.ok(run.escalation.estimatedDecisionCostUsd >= 0);
142
+ // proposal changes carry the escalation record
143
+ assert.ok(run.proposal.changes.every(c => c.escalation?.targetTier === 'reconfigure'));
144
+ });
145
+
146
+ // --- G: availability probe failure -> unknown, never "gone" -----------------
147
+ test('Case G: failed availability probe is unknown, not unavailable', () => {
148
+ const monitor = buildMonitorOutput({ events: [], catalog: cat([CHEAP_OK]), availability: null, availabilitySource: 'pi --list-models (failed)' });
149
+ assert.equal(monitor.infoStatus.probeFailed, true);
150
+ assert.equal(monitor.changed, false); // nothing declared missing
151
+ const r = sel([CHEAP_OK], 'monitor', { availability: null });
152
+ assert.equal(r.selected.model, 'cloudp/cheap-ok'); // still eligible
153
+ assert.equal(r.candidates[0].availability, 'unknown');
154
+ });
155
+
156
+ // --- supporting behaviour ---------------------------------------------------
157
+ test('free below requiredQuality is not used (free never overrides the quality bar)', () => {
158
+ const r = sel([FREE_WEAK, CHEAP_OK], 'evaluate');
159
+ assert.equal(r.selected.model, 'cloudp/cheap-ok');
160
+ assert.equal(r.selectionPath, 'cheapest-sufficient-cloud');
161
+ const rejected = r.candidates.find(c => c.model === 'freep/free-weak');
162
+ assert.equal(rejected.eligible, false);
163
+ });
164
+
165
+ test('localElectricityCost: watts x minutes x price', () => {
166
+ assert.ok(Math.abs(localElectricityCost({ powerWatts: 450, taskMinutes: 4 }, 0.30) - 0.009) < 1e-9);
167
+ assert.equal(localElectricityCost({ powerWatts: 450 }, 0.30), null);
168
+ assert.equal(localElectricityCost(null, 0.30), null);
169
+ });
170
+
171
+ test('monitor output shape: changed/reasons/affectedModels/severity/escalationRequired', () => {
172
+ const out = buildMonitorOutput({ events: [{ type: 'price-changed', provider: 'p', model: 'x' }], catalog: cat([m('p', 'x')]) });
173
+ assert.equal(out.changed, true);
174
+ assert.deepEqual(out.affectedModels, ['p/x']);
175
+ assert.equal(out.severity, 'low');
176
+ assert.equal(out.escalationRequired, false);
177
+ const quiet = buildMonitorOutput({ events: [], catalog: cat([m('p', 'x')]) });
178
+ assert.equal(quiet.changed, false);
179
+ assert.equal(quiet.severity, 'none');
180
+ });
181
+
182
+ test('runMaintenancePlan short-circuits on no change (only monitor tier runs)', () => {
183
+ const registry = registryWith({ cheap: { provider: 'cloudp', model: 'cheap-ok' } });
184
+ const run = runMaintenancePlan({ routing, registry, agents, catalog: cat([CHEAP_OK]), events: [] });
185
+ assert.equal(run.outcome, 'no-change');
186
+ assert.deepEqual(run.tiers.map(t => t.role), ['monitor']);
187
+ assert.equal(run.proposal, null);
188
+ });
189
+
190
+ test('escalationDecision returns null for small single-capability changes', () => {
191
+ const result = { changes: [{ affected: { capabilities: ['cheap-code'], agents: ['scout'] }, scores: { current: 50, proposed: 62, delta: 12 }, confidence: 'high' }], decisions: [], infoStatus: { eventsApplied: [] } };
192
+ assert.equal(escalationDecision(result, { severity: 'low' }, DEFAULT_POLICY), null);
193
+ });
194
+
195
+ test('exec policy validates and merges over defaults', () => {
196
+ assert.ok(validateExecPolicy({ version: 1, requiredQuality: { monitor: 200 } }).some(e => e.includes('requiredQuality')));
197
+ const p = loadExecPolicy(null);
198
+ assert.equal(p.requiredQuality.reconfigure, 80);
199
+ assert.equal(p.electricityPricePerKwh, 0.30);
200
+ });
201
+
202
+ test('run report records tier selections, candidates, costs and flags', () => {
203
+ const registry = registryWith({ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' } });
204
+ const catalog = cat([
205
+ m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 30, reasoning: 25, speed: 85 } }),
206
+ CHEAP_OK,
207
+ ]);
208
+ const events = [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash' }];
209
+ const run = runMaintenancePlan({ routing, registry, agents, catalog, events });
210
+ for (const t of run.tiers) {
211
+ assert.ok(t.selected?.model, `${t.role} selected`);
212
+ assert.ok(t.selectionReason);
213
+ assert.ok(Array.isArray(t.candidates) && t.candidates.length);
214
+ assert.ok(typeof t.selected.effectiveCostUsd === 'number');
215
+ assert.ok(typeof t.fallbackOccurred === 'boolean');
216
+ }
217
+ assert.ok(run.estimatedDecisionCostUsd > 0);
218
+ });
@@ -0,0 +1,222 @@
1
+ // Phase 3: maintenance runner — real (stubbed) tier invocation, schema validation,
2
+ // one retry, ordered fallback, deterministic authority, and per-run cost profiles.
3
+ // Cases A–J from the Phase 3 spec. No real model is invoked; `invoke` is scripted.
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 {
9
+ invokeTier, runMaintenanceLive, validateStructuredOutput, extractJson,
10
+ buildTierPrompt, TIER_SCHEMAS, MONITOR_SCHEMA,
11
+ } from '../lib/maintenance-runner.mjs';
12
+ import { selectTierModel, estimatedCostPerRun, DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
13
+ import { effectiveCatalog } from '../lib/maintenance.mjs';
14
+ import { loadRouting } from '../lib/routing.mjs';
15
+ import { loadAgents } from '../lib/agents.mjs';
16
+ import { mergeRegistries } from '../lib/registry.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
+ const m = (provider, model, over = {}) => ({
23
+ provider, model, status: 'active', cost: { usdPerMInput: 0.5, usdPerMOutput: 2 },
24
+ contextK: 256, vision: false, toolUse: 'good', location: 'cloud',
25
+ scores: { coding: 60, reasoning: 55, speed: 70 }, ...over,
26
+ });
27
+ const cat = models => ({ version: 1, updatedAt: '2026-03-01', models });
28
+ const registryWith = backends => mergeRegistries(null, { version: 1, backends });
29
+
30
+ const FREE_GOOD = m('freep', 'free-good', { cost: { free: true }, scores: { coding: 75, reasoning: 68, speed: 80 } });
31
+ const CHEAP2 = m('cloudp', 'cheap-2', { cost: { usdPerMInput: 0.1, usdPerMOutput: 0.4 }, scores: { coding: 66, reasoning: 60, speed: 78 } });
32
+ const PREMIUM = m('cloudp', 'premium-huge', { premium: true, cost: { usdPerMInput: 10, usdPerMOutput: 40 }, scores: { coding: 95, reasoning: 95, speed: 40 } });
33
+ const LOCAL = m('localp', 'local-mid', { location: 'local', cost: { free: true }, local: { powerWatts: 400, taskMinutes: 10 }, scores: { coding: 45, reasoning: 40, speed: 50 }, toolUse: 'basic' });
34
+
35
+ const json = o => JSON.stringify(o);
36
+ const ok = text => ({ ok: true, text, durationMs: 5 });
37
+ const fail = error => ({ ok: false, error, durationMs: 3 });
38
+ const scriptInvoker = (script, calls = []) => async req => {
39
+ calls.push(req.modelId);
40
+ const v = typeof script === 'function' ? script(req) : script[req.modelId.split(':')[0]] ?? script['*'];
41
+ return typeof v === 'function' ? v(req) : v;
42
+ };
43
+
44
+ const MON_OK = () => json({ decision: 'changed', confidence: 0.9, reasoningSummary: 'event diffs found', affectedModels: ['qoder/Qwen3.8-Flash'], severity: 'medium', evaluateNeeded: true, recommendedActions: [] });
45
+ const EVAL_OK = (c = 0.85) => json({ decision: 'keep', confidence: c, reasoningSummary: 'within margin', affectedCapabilities: ['cheap-code'], recommendedActions: [], recommendedModels: [] });
46
+ const REC_OK = () => json({ decision: 'propose', confidence: 0.8, reasoningSummary: 'structural', affectedCapabilities: ['cheap-code', 'strong-code'], recommendedActions: [], recommendedModels: [], routingNotes: [] });
47
+
48
+ // Post-campaign Qwen3.8-Flash stays mid-quality: the switch is simple, no escalation.
49
+ const dyingCatalog = () => cat([
50
+ m('qoder', 'Qwen3.8-Flash', { status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, scores: { coding: 62, reasoning: 55, speed: 85 } }),
51
+ CHEAP2, FREE_GOOD,
52
+ ]);
53
+
54
+ // --- A: no change -> monitor only ------------------------------------------
55
+ test('A: no-change -> only monitor is invoked, run stops', async () => {
56
+ const calls = [];
57
+ const run = await runMaintenanceLive({
58
+ routing, registry: registryWith({ cheap: { provider: 'cloudp', model: 'cheap-2' } }),
59
+ agents, catalog: cat([CHEAP2, FREE_GOOD]), events: [],
60
+ invoke: scriptInvoker({ '*': () => ok(json({ decision: 'no-change', confidence: 0.95, reasoningSummary: 'quiet', severity: 'none', evaluateNeeded: false })) }, calls),
61
+ });
62
+ assert.equal(run.outcome, 'no-change');
63
+ assert.equal(calls.length, 1);
64
+ assert.equal(run.invocations.length, 1);
65
+ assert.equal(run.invocations[0].tier, 'monitor');
66
+ });
67
+
68
+ // --- B: simple change -> monitor + evaluate, no reconfigure -----------------
69
+ test('B: simple change -> monitor and evaluate invoked, no reconfigure', async () => {
70
+ const calls = [];
71
+ const run = await runMaintenanceLive({
72
+ routing, registry: registryWith({ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' } }),
73
+ agents, catalog: dyingCatalog(),
74
+ events: [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash' }],
75
+ invoke: scriptInvoker({ '*': req => ok(req.prompt.includes('STAGE: monitor') ? MON_OK() : EVAL_OK()) }, calls),
76
+ });
77
+ assert.equal(calls.length, 2);
78
+ assert.deepEqual(run.invocations.map(i => i.tier), ['monitor', 'evaluate']);
79
+ assert.equal(run.escalation, null);
80
+ });
81
+
82
+ // --- C: complex change -> all three tiers -----------------------------------
83
+ test('C: multi-capability structural change -> reconfigure is invoked', async () => {
84
+ const calls = [];
85
+ const catalog = cat([
86
+ m('qoder', 'Qwen3.8-Flash', { scores: { coding: 30, reasoning: 25, speed: 85 } }),
87
+ CHEAP2, FREE_GOOD, m('cloudp', 'vis', { vision: true, cost: { usdPerMInput: 3, usdPerMOutput: 12 }, scores: { coding: 85, reasoning: 88, speed: 55 } }),
88
+ ]);
89
+ const registry = registryWith({
90
+ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash' },
91
+ sol: { provider: 'qoder', model: 'Qwen3.8-Flash' },
92
+ astra: { provider: 'qoder', model: 'Qwen3.8-Flash' },
93
+ });
94
+ const run = await runMaintenanceLive({
95
+ routing, registry, agents, catalog,
96
+ events: [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash' }],
97
+ invoke: scriptInvoker({ '*': req => ok(req.prompt.includes('STAGE: monitor') ? MON_OK() : req.prompt.includes('STAGE: evaluate') ? EVAL_OK() : REC_OK()) }, calls),
98
+ });
99
+ assert.deepEqual(run.invocations.map(i => i.tier), ['monitor', 'evaluate', 'reconfigure']);
100
+ assert.ok(run.escalation.escalationReason.length > 0);
101
+ assert.equal(run.escalation.targetTier, 'reconfigure');
102
+ });
103
+
104
+ // --- D: monitor model fails -> next cheap candidate --------------------------
105
+ test('D: monitor invocation failure falls back to the next ordered candidate', async () => {
106
+ const calls = [];
107
+ const run = await runMaintenanceLive({
108
+ routing, registry: registryWith({ cheap: { provider: 'cloudp', model: 'cheap-2' } }),
109
+ agents, catalog: cat([FREE_GOOD, CHEAP2]), events: [],
110
+ invoke: scriptInvoker(req => req.modelId.startsWith('freep/') ? fail('rate limit exceeded') : ok(json({ decision: 'no-change', confidence: 0.9, reasoningSummary: 'ok', severity: 'none', evaluateNeeded: false })), calls),
111
+ });
112
+ assert.deepEqual(calls.map(c => c.split('/')[0]), ['freep', 'cloudp']);
113
+ const inv = run.invocations[0];
114
+ assert.equal(inv.fallbackOccurred, true);
115
+ assert.equal(inv.selectedModel, 'cloudp/cheap-2');
116
+ assert.equal(inv.invocations[0].failureClass, 'invoke');
117
+ });
118
+
119
+ // --- E: malformed JSON -> one retry -> success -------------------------------
120
+ test('E: malformed structured output retries once then succeeds', async () => {
121
+ let n = 0;
122
+ const inv = await invokeTier('monitor', selectTierModel(effectiveCatalog(cat([FREE_GOOD])), 'monitor', DEFAULT_POLICY), {
123
+ invoke: async () => (++n === 1 ? ok('not json at all') : ok(MON_OK())),
124
+ catalogKeys: new Set(['freep/free-good']),
125
+ });
126
+ assert.equal(n, 2);
127
+ assert.equal(inv.output.decision, 'changed');
128
+ assert.equal(inv.attempts.length, 2);
129
+ assert.equal(inv.attempts[0].schemaValid, false);
130
+ assert.equal(inv.attempts[1].schemaValid, true);
131
+ });
132
+
133
+ // --- F: still invalid after retry -> deterministic continues -----------------
134
+ test('F: invalid output after retry -> tier degrades to deterministic result', async () => {
135
+ const calls = [];
136
+ const run = await runMaintenanceLive({
137
+ routing, registry: registryWith({ cheap: { provider: 'cloudp', model: 'cheap-2' } }),
138
+ agents, catalog: cat([FREE_GOOD]), events: [],
139
+ invoke: scriptInvoker({ '*': () => ok('garbage {no json') }, calls),
140
+ });
141
+ const inv = run.invocations[0];
142
+ assert.equal(inv.degradedToDeterministic, true);
143
+ assert.equal(calls.length, 2); // initial + one retry, then stop
144
+ assert.equal(run.outcome, 'no-change'); // deterministic monitor still decides
145
+ });
146
+
147
+ // --- G/H: per-run cost comparison --------------------------------------------
148
+ test('G: short profile -> cloud cheaper than local electricity -> cloud selected', () => {
149
+ const models = [LOCAL, CHEAP2];
150
+ const r = selectTierModel(models, 'monitor', DEFAULT_POLICY); // 8K in / 800 out
151
+ assert.equal(r.selected.location, 'cloud');
152
+ const localCost = estimatedCostPerRun(LOCAL, DEFAULT_POLICY, models, 'monitor');
153
+ const cloudCost = estimatedCostPerRun(CHEAP2, DEFAULT_POLICY, models, 'monitor');
154
+ assert.ok(cloudCost.api < localCost.electricity, `api ${cloudCost.api} vs elec ${localCost.electricity}`);
155
+ });
156
+
157
+ test('H: long coding profile -> token cost grows, local becomes cheaper', () => {
158
+ const expensiveCloud = m('cloudp', 'pricey', { cost: { usdPerMInput: 8, usdPerMOutput: 30 }, scores: { coding: 66, reasoning: 60, speed: 70 } });
159
+ const local = m('localp', 'local-ok', { location: 'local', cost: { free: true }, local: { powerWatts: 300, taskMinutes: 2 }, scores: { coding: 45, reasoning: 40, speed: 60 }, toolUse: 'basic' });
160
+ const policy = structuredClone(DEFAULT_POLICY);
161
+ policy.taskProfiles.monitor = { estimatedInputTokens: 500000, estimatedOutputTokens: 50000, estimatedTaskMinutes: 20 };
162
+ const models = [expensiveCloud, local];
163
+ const r = selectTierModel(models, 'monitor', policy);
164
+ const cloudApi = estimatedCostPerRun(expensiveCloud, policy, models, 'monitor').api; // ~$5.5
165
+ const localElec = estimatedCostPerRun(local, policy, models, 'monitor').electricity; // 0.3kWh*0.3=$0.03
166
+ assert.ok(cloudApi > localElec);
167
+ assert.equal(r.selected.location, 'local');
168
+ assert.equal(r.selectionPath, 'cheapest-sufficient-local'); // local wins on cost, not as a fallback
169
+ });
170
+
171
+ // --- I: premium not used for monitor -----------------------------------------
172
+ test('I: premium models are excluded from monitor/evaluate selection', () => {
173
+ const r = selectTierModel([PREMIUM, CHEAP2], 'monitor', DEFAULT_POLICY);
174
+ assert.equal(r.selected.model, 'cloudp/cheap-2');
175
+ assert.ok(!r.candidates.some(c => c.model === 'cloudp/premium-huge'));
176
+ const rec = selectTierModel([PREMIUM, CHEAP2], 'reconfigure', DEFAULT_POLICY); // reconfigure may see it
177
+ assert.ok(rec.candidates.some(c => c.model === 'cloudp/premium-huge'));
178
+ });
179
+
180
+ // --- J: LLM recommends a non-catalog model -> rejected ------------------------
181
+ test('J: recommendedModels outside the catalog are rejected and recorded', async () => {
182
+ const out = json({ decision: 'keep', confidence: 0.8, reasoningSummary: 'x', affectedCapabilities: [], recommendedActions: [], recommendedModels: ['evil/ghost-model', 'freep/free-good'] });
183
+ const inv = await invokeTier('evaluate', selectTierModel(effectiveCatalog(cat([FREE_GOOD, CHEAP2])), 'evaluate', DEFAULT_POLICY), {
184
+ invoke: async () => ok(out),
185
+ catalogKeys: new Set(['freep/free-good', 'cloudp/cheap-2']),
186
+ });
187
+ assert.deepEqual(inv.output.recommendedModels, ['freep/free-good']);
188
+ assert.deepEqual(inv.output.rejectedRecommendations, ['evil/ghost-model']);
189
+ });
190
+
191
+ // --- schema / parsing --------------------------------------------------------
192
+ test('schema validation: required keys, enums, ranges', () => {
193
+ assert.deepEqual(validateStructuredOutput(MONITOR_SCHEMA, { decision: 'changed', confidence: 0.5, reasoningSummary: 'x' }), []);
194
+ assert.ok(validateStructuredOutput(MONITOR_SCHEMA, { decision: 'maybe', confidence: 0.5, reasoningSummary: 'x' }).some(e => e.includes('decision')));
195
+ assert.ok(validateStructuredOutput(MONITOR_SCHEMA, { decision: 'changed', confidence: 2, reasoningSummary: 'x' }).some(e => e.includes('confidence')));
196
+ assert.ok(validateStructuredOutput(MONITOR_SCHEMA, { confidence: 0.5 }).some(e => e.includes('decision')));
197
+ assert.ok(validateStructuredOutput(MONITOR_SCHEMA, 'text').some(e => e.includes('object')));
198
+ });
199
+
200
+ test('extractJson tolerates fences and surrounding prose', () => {
201
+ assert.equal(extractJson('```json\n{"a":1}\n```').a, 1);
202
+ assert.equal(extractJson('here is the result: {"a":{"b":2}} done').a.b, 2);
203
+ assert.equal(extractJson('no json'), null);
204
+ });
205
+
206
+ test('tier prompts embed deterministic facts and forbid invented models', () => {
207
+ const p = buildTierPrompt('evaluate', { evaluation: { changes: 1 }, catalogKeys: ['a/b'] });
208
+ assert.ok(p.includes('STAGE: evaluate'));
209
+ assert.ok(p.includes('a/b'));
210
+ assert.ok(p.includes('Never invent provider/model ids'));
211
+ const mp = buildTierPrompt('monitor', { monitor: { changed: true, reasons: ['e'], affectedModels: ['p/m'], severity: 'low' } });
212
+ assert.ok(mp.includes('Do NOT propose routing changes'));
213
+ });
214
+
215
+ test('quota failure is classified distinctly', async () => {
216
+ const inv = await invokeTier('monitor', selectTierModel(effectiveCatalog(cat([FREE_GOOD, CHEAP2])), 'monitor', DEFAULT_POLICY), {
217
+ invoke: async req => req.modelId.startsWith('freep/') ? fail('Error: usage limit has been reached') : ok(MON_OK()),
218
+ catalogKeys: new Set(),
219
+ });
220
+ assert.equal(inv.attempts[0].failureClass, 'quota');
221
+ assert.equal(inv.selected.model, 'cloudp/cheap-2');
222
+ });
@@ -0,0 +1,195 @@
1
+ // Model-provisioning maintenance: catalog/events validation, scoring, and the
2
+ // Qoder/Qwen3.8-Flash free-campaign-end scenario. Proposals only — nothing is applied.
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { resolve, dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import {
8
+ evaluateMaintenance, loadCatalog, loadEvents, loadAvailabilityFile,
9
+ validateCatalog, validateEvents, scoreModel, DEFAULT_MARGIN,
10
+ } from '../lib/maintenance.mjs';
11
+ import { loadRouting } from '../lib/routing.mjs';
12
+ import { loadAgents } from '../lib/agents.mjs';
13
+ import { mergeRegistries } from '../lib/registry.mjs';
14
+ import { parseModelList } from '../adapters/pi/lib/list-models.mjs';
15
+
16
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const fx = join(kit, 'tests/fixtures/maintenance');
18
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
19
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
20
+ const catalog = loadCatalog(join(fx, 'catalog.json'));
21
+ const events = loadEvents(join(fx, 'events.json')); // qoder/Qwen3.8-Flash free-campaign-ended
22
+
23
+ const baseRegistry = () => mergeRegistries(null, {
24
+ version: 1,
25
+ backends: {
26
+ local: { provider: 'freetoken', model: 'Qwen3.6-35B-A3B-NVFP4', thinking: 'off' },
27
+ cheap: { provider: 'openai-codex', model: 'gpt-5.6-luna', thinking: 'low' },
28
+ qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
29
+ devin: { provider: 'devin', model: 'swe-2-high', thinking: 'high' },
30
+ sol: { provider: 'openai-codex', model: 'gpt-5.6-sol', thinking: 'medium' },
31
+ astra: { provider: 'openai-codex', model: 'gpt-6-astra', thinking: 'medium', vision: true },
32
+ codex: { provider: 'openai-codex', model: 'gpt-5.5', thinking: 'high' },
33
+ },
34
+ });
35
+ const decide = (report, backend) => report.decisions.find(d => d.backend === backend);
36
+
37
+ test('catalog and events validators reject malformed input', () => {
38
+ assert.ok(validateCatalog({ version: 1, models: [{ provider: 'p', model: 'm', status: 'gone' }] }).some(e => e.includes('status')));
39
+ assert.ok(validateCatalog({ version: 1, models: [{ provider: 'p', model: 'm', status: 'active', cost: { usdPerMInput: -1 } }] }).some(e => e.includes('usdPerMInput')));
40
+ assert.ok(validateCatalog({ version: 1, models: [
41
+ { provider: 'p', model: 'm', status: 'active' }, { provider: 'p', model: 'm', status: 'active' },
42
+ ] }).some(e => e.includes('duplicates')));
43
+ assert.ok(validateEvents({ version: 1, events: [{ type: 'exploded', provider: 'p', model: 'm' }] }).some(e => e.includes('type')));
44
+ assert.ok(validateEvents({ version: 1, events: [{ type: 'price-changed', provider: 'p' }] }).some(e => e.includes('provider and model')));
45
+ });
46
+
47
+ test('scoring is tier-weighted: the high tier values quality far more than the low tier', () => {
48
+ const free = { cost: { free: true }, contextK: 64, toolUse: 'basic', scores: { coding: 40, reasoning: 35, speed: 70 } };
49
+ const strong = { cost: { usdPerMInput: 1.5, usdPerMOutput: 6 }, contextK: 400, toolUse: 'good', scores: { coding: 85, reasoning: 82, speed: 55 } };
50
+ const lowDelta = scoreModel(strong, 'low').score - scoreModel(free, 'low').score;
51
+ const highDelta = scoreModel(strong, 'high').score - scoreModel(free, 'high').score;
52
+ assert.ok(highDelta > lowDelta, `high ${highDelta} should exceed low ${lowDelta}`);
53
+ assert.ok(scoreModel(strong, 'high').score > scoreModel(free, 'high').score);
54
+ assert.equal(scoreModel({ status: 'active' }, 'low').score, null); // nothing known
55
+ });
56
+
57
+ test('free-campaign end alone does not force a change: current model is re-scored and compared', () => {
58
+ const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events });
59
+ const cheap = decide(report, 'qoder');
60
+ assert.equal(cheap.current.model, 'Qwen3.8-Flash');
61
+ assert.ok(cheap.trigger.some(t => t.includes('free-campaign-ended')), JSON.stringify(cheap.trigger));
62
+ // Post-campaign Qwen3.8-Flash (~$0.75/M) scores close to the paid alternatives under
63
+ // low-tier weights: advantage < margin -> keep. The event alone changed nothing.
64
+ assert.equal(cheap.decision, 'keep', cheap.reason);
65
+ assert.match(cheap.reason, /below margin/);
66
+ assert.equal(report.changes.length, 0);
67
+ });
68
+
69
+ test('a forced migration (removed model) produces a full proposal with rollback', () => {
70
+ const removed = [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-15' }];
71
+ const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: removed });
72
+ const change = report.changes.find(c => c.affected.backend === 'qoder');
73
+ assert.ok(change.changeReason.includes('removed'));
74
+ assert.deepEqual(change.affected.capabilities, ['cheap-code', 'orchestration']);
75
+ assert.deepEqual(change.affected.agents, ['orchestrator', 'scout', 'tester']);
76
+ assert.equal(change.currentModel.provider, 'qoder');
77
+ assert.equal(change.proposedModel.model, 'Qwen3.9-Flash'); // best eligible under low-tier weights
78
+ assert.match(change.expectedCostImpact, /\$0 \(free\) -> proposed ~\$/);
79
+ assert.match(change.expectedQualityImpact, /coding 62->70/);
80
+ assert.equal(change.rollback.method, 'manual-edit');
81
+ assert.deepEqual(change.rollback.previousBinding, { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' });
82
+ assert.ok(change.rollback.steps.some(s => s.includes('models.local.json')));
83
+ });
84
+
85
+ test('a free-campaign end proposes a switch when a clearly better candidate exists', () => {
86
+ // Same event, but the cheap backend is bound to a weak model: the margin is exceeded.
87
+ const reg = baseRegistry();
88
+ const cat = structuredClone(catalog);
89
+ cat.models.find(m => m.model === 'Qwen3.8-Flash').scores = { coding: 30, reasoning: 25, speed: 60 };
90
+ const report = evaluateMaintenance({ routing, registry: reg, agents, catalog: cat, events });
91
+ const cheap = decide(report, 'qoder');
92
+ assert.equal(cheap.decision, 'propose', cheap.reason);
93
+ assert.equal(cheap.proposed.model, 'Qwen3.9-Flash');
94
+ assert.ok(report.changes.length >= 1);
95
+ });
96
+
97
+ test('a free-campaign end with a comparable successor keeps the current model', () => {
98
+ const cat = structuredClone(catalog);
99
+ cat.models.find(m => m.model === 'Qwen3.8-Flash').postCampaignCost = { usdPerMInput: 0.3, usdPerMOutput: 1.2 };
100
+ const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog: cat, events });
101
+ // Qwen3.9-Flash (0.25/1.0, coding 70) vs post-campaign Qwen3.8 (0.3/1.2, coding 62):
102
+ // advantage is below the margin -> keep.
103
+ const cheap = decide(report, 'qoder');
104
+ assert.equal(cheap.decision, 'keep', cheap.reason);
105
+ assert.match(cheap.reason, /below margin/);
106
+ assert.equal(report.changes.length, 0);
107
+ });
108
+
109
+ test('margin is configurable and forced migration ignores it when the model is removed', () => {
110
+ const reg = baseRegistry();
111
+ const removed = { version: 1, events: [{ type: 'removed', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-03-01' }] };
112
+ const report = evaluateMaintenance({ routing, registry: reg, agents, catalog, events: removed.events, margin: 90 });
113
+ const cheap = decide(report, 'qoder');
114
+ assert.equal(cheap.decision, 'propose'); // forced: removed model must move even with margin 90
115
+ assert.match(cheap.reason, /migration required/);
116
+ });
117
+
118
+ test('availability listing: a bound model absent from its provider listing is forced to migrate', () => {
119
+ const availability = { models: new Set(['openai-codex/gpt-5.6-sol', 'openai-codex/gpt-6-astra', 'openai-codex/gpt-5.5', 'openai-codex/gpt-5.6-luna']), providers: new Set(['openai-codex', 'qoder']), source: 'test' };
120
+ const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: [], availability });
121
+ const cheap = decide(report, 'qoder'); // qoder listed but Qwen3.8-Flash absent -> gone
122
+ assert.equal(cheap.decision, 'propose');
123
+ assert.ok(cheap.trigger.some(t => t.includes('not')));
124
+ // provider not in the listing at all -> unknown, never "missing"
125
+ const availability2 = { models: new Set(['openai-codex/gpt-5.6-sol']), providers: new Set(['openai-codex']), source: 'test' };
126
+ const report2 = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: [], availability: availability2 });
127
+ assert.equal(decide(report2, 'qoder').decision, 'keep');
128
+ assert.equal(decide(report2, 'local').decision, 'not-primary'); // fallback-only backend: reported, not evaluated
129
+ });
130
+
131
+ test('vision-requiring capability only proposes vision-capable candidates', () => {
132
+ const reg = baseRegistry();
133
+ const removed = [{ type: 'removed', provider: 'openai-codex', model: 'gpt-6-astra', asOf: '2026-03-01' }];
134
+ const report = evaluateMaintenance({ routing, registry: reg, agents, catalog, events: removed });
135
+ const astra = decide(report, 'astra');
136
+ assert.equal(astra.decision, 'insufficient-data'); // no other vision model in catalog
137
+ assert.match(astra.reason, /no eligible candidate/);
138
+ });
139
+
140
+ test('unbound/placeholder backends are skipped; fallback-only backends are reported not evaluated', () => {
141
+ const reg = mergeRegistries({ version: 1, backends: { qoder: { provider: 'TODO-provider', model: 'TODO-model' } } }, {
142
+ version: 1, backends: { codex: { provider: 'openai-codex', model: 'gpt-5.5' } },
143
+ });
144
+ const report = evaluateMaintenance({ routing, registry: reg, agents, catalog, events });
145
+ assert.equal(decide(report, 'qoder').decision, 'skip');
146
+ // codex is primary of no capability but fallback of strong-code/browser/orchestration
147
+ assert.equal(decide(report, 'codex').decision, 'not-primary');
148
+ });
149
+
150
+ test('model-added triggers comparison but keeps when the new model is not better', () => {
151
+ const added = [{ type: 'model-added', provider: 'qoder', model: 'Qwen3.9-Flash', asOf: '2026-03-05' }];
152
+ const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: added });
153
+ const cheap = decide(report, 'qoder');
154
+ // Qwen3.9-Flash is better than free Qwen3.8-Flash but not by the margin -> keep.
155
+ assert.equal(cheap.decision, 'keep', cheap.reason);
156
+ assert.ok(cheap.trigger.some(t => t.includes('Qwen3.9-Flash')));
157
+ });
158
+
159
+ test('no events and healthy catalog -> every bound primary keeps, zero changes', () => {
160
+ const report = evaluateMaintenance({ routing, registry: baseRegistry(), agents, catalog, events: [] });
161
+ assert.equal(report.changes.length, 0);
162
+ assert.deepEqual(report.decisions.filter(d => d.decision === 'keep').map(d => d.backend).sort(), ['astra', 'devin', 'qoder', 'sol']);
163
+ assert.equal(decide(report, 'local').decision, 'not-primary');
164
+ assert.equal(decide(report, 'codex').decision, 'not-primary');
165
+ });
166
+
167
+ test('parseModelList extracts provider/model pairs and tolerates noise', () => {
168
+ const { models, providers } = parseModelList('openai-codex/gpt-5.6-sol\nqoder / Qwen3.8-Flash\nnot a model line\n');
169
+ assert.ok(models.has('openai-codex/gpt-5.6-sol'));
170
+ assert.ok(models.has('qoder/Qwen3.8-Flash'));
171
+ assert.ok(providers.has('qoder'));
172
+ });
173
+
174
+ test('parseModelList reads the real `pi --list-models` table (space-separated columns, header skipped)', () => {
175
+ // Regression: the table format is `provider model context max-out thinking images`,
176
+ // not `provider/model`. The old regex silently matched nothing -> probe reported null.
177
+ const table = 'provider model context max-out thinking images\n'
178
+ + 'qoder Qwen3.8-Flash 1M 131.1K yes yes\n'
179
+ + 'devin swe-2-high 256K 128K yes yes\n'
180
+ + 'freetoken Qwen3.6-35B-A3B-NVFP4 262.1K 4.1K no yes\n'
181
+ + 'openai-codex gpt-5.6-luna 272K 128K yes yes\n';
182
+ const { models, providers } = parseModelList(table);
183
+ assert.ok(models.has('qoder/Qwen3.8-Flash'));
184
+ assert.ok(models.has('devin/swe-2-high'));
185
+ assert.ok(models.has('freetoken/Qwen3.6-35B-A3B-NVFP4'));
186
+ assert.ok(models.has('openai-codex/gpt-5.6-luna'));
187
+ assert.ok(!providers.has('provider')); // header row excluded
188
+ });
189
+
190
+ test('loadAvailabilityFile produces the same shape', () => {
191
+ const tmp = join(fx, 'availability.txt');
192
+ const a = loadAvailabilityFile(tmp);
193
+ assert.ok(a.providers.has('qoder'));
194
+ assert.ok(a.models.has('qoder/Qwen3.9-Flash'));
195
+ });