@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,377 @@
1
+ // Phase 2 — maintenance execution tiers and cheapest-sufficient model selection.
2
+ // Neutral: no provider or model names. Concrete facts come from the adapter catalog.
3
+ //
4
+ // Execution tiers: monitor (cheapest watcher) -> evaluate (judgement) -> reconfigure
5
+ // (premium escalation). Model choice per tier: free first, then cheapest-sufficient,
6
+ // then local fallback. Selection is deterministic and explainable — no optimizer.
7
+ //
8
+ // This module is pure: it computes which model a tier *would* run on and whether
9
+ // escalation is warranted. It never invokes a model and never writes config.
10
+ import { readFileSync } from 'node:fs';
11
+ import { evaluateMaintenance, effectiveCatalog, scoreModel } from './maintenance.mjs';
12
+
13
+ export const EXEC_TIERS = ['monitor', 'evaluate', 'reconfigure'];
14
+ const USABLE_STATUS = new Set(['active', 'free-campaign']);
15
+ const TOOL_POINTS = { good: 1, basic: 0.6, poor: 0.3, none: 0 };
16
+
17
+ export const DEFAULT_POLICY = {
18
+ version: 1,
19
+ // Minimum quality (0-100) per execution tier. Free models that miss the bar are
20
+ // NOT used — selection proceeds to cheapest-sufficient.
21
+ requiredQuality: { monitor: 40, evaluate: 65, reconfigure: 80 },
22
+ // Effective-cost blend, USD per maintenance run. api: token-priced per run from
23
+ // taskProfiles (NOT $/1M compared directly to electricity). local: electricity.
24
+ // Missing facts fall back to the cheapest *known* value in the pool; a model with
25
+ // no known cost is never preferred on an assumed zero.
26
+ costWeights: { api: 1, electricity: 1, speed: 0.02 },
27
+ electricityPricePerKwh: 0.30,
28
+ // Per-tier task profile: how big one maintenance invocation is. Drives the API
29
+ // cost estimate (tokens x price) and the local electricity estimate (minutes).
30
+ taskProfiles: {
31
+ monitor: { estimatedInputTokens: 8000, estimatedOutputTokens: 800, estimatedTaskMinutes: 1 },
32
+ evaluate: { estimatedInputTokens: 20000, estimatedOutputTokens: 3000, estimatedTaskMinutes: 3 },
33
+ reconfigure: { estimatedInputTokens: 60000, estimatedOutputTokens: 8000, estimatedTaskMinutes: 8 },
34
+ },
35
+ // Tier weight profile used for per-tier quality scoring (see TIER_WEIGHTS in maintenance.mjs).
36
+ qualityTier: { monitor: 'low', evaluate: 'mid', reconfigure: 'high' },
37
+ // Escalation from evaluate to reconfigure. Any single condition suffices.
38
+ escalation: {
39
+ minCapabilities: 2, // proposed changes span >= N capabilities
40
+ minAgents: 3, // or >= N distinct agents
41
+ minQualitySwing: 15, // or |proposed - current| quality delta >= N points
42
+ maxScoreDelta: 4, // or the winning margin is < N points (hard call)
43
+ structuralEvents: ['removed', 'deprecated'], // provider/model exit is structural by default
44
+ escalateOnLowConfidence: true,
45
+ escalateOnStructural: false, // routing.json shape changes require a human anyway
46
+ minEvaluateConfidence: 0.5, // evaluate model self-confidence below this escalates
47
+ },
48
+ };
49
+
50
+ export function validateExecPolicy(policy) {
51
+ const errors = [];
52
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return ['exec-policy: root must be an object'];
53
+ for (const key of Object.keys(policy)) {
54
+ if (!['version', 'requiredQuality', 'costWeights', 'electricityPricePerKwh', 'taskProfiles', 'qualityTier', 'escalation', 'budget', 'calibration', 'capabilityRequirements', '$comment', 'description'].includes(key)) {
55
+ errors.push(`exec-policy: unknown top-level key "${key}"`);
56
+ }
57
+ }
58
+ if (policy.version !== 1) errors.push('exec-policy: version must be 1');
59
+ for (const tier of EXEC_TIERS) {
60
+ const q = policy.requiredQuality?.[tier];
61
+ if (q !== undefined && (typeof q !== 'number' || q < 0 || q > 100)) errors.push(`exec-policy: requiredQuality.${tier} must be 0-100`);
62
+ }
63
+ if (policy.electricityPricePerKwh !== undefined && (typeof policy.electricityPricePerKwh !== 'number' || policy.electricityPricePerKwh < 0)) {
64
+ errors.push('exec-policy: electricityPricePerKwh must be a number >= 0');
65
+ }
66
+ if (policy.taskProfiles !== undefined) {
67
+ if (typeof policy.taskProfiles !== 'object' || policy.taskProfiles === null) { errors.push('exec-policy: taskProfiles must be an object'); }
68
+ else for (const [tier, p] of Object.entries(policy.taskProfiles)) {
69
+ for (const k of ['estimatedInputTokens', 'estimatedOutputTokens', 'estimatedTaskMinutes']) {
70
+ if (p?.[k] !== undefined && (typeof p[k] !== 'number' || p[k] < 0)) errors.push(`exec-policy: taskProfiles.${tier}.${k} must be a number >= 0`);
71
+ }
72
+ }
73
+ }
74
+ if (policy.escalation !== undefined && (typeof policy.escalation !== 'object' || policy.escalation === null)) errors.push('exec-policy: escalation must be an object');
75
+ if (policy.budget !== undefined) {
76
+ if (typeof policy.budget !== 'object' || policy.budget === null) { errors.push('exec-policy: budget must be an object'); }
77
+ else for (const k of ['maxEstimatedCostPerRunUsd', 'maxPremiumInvocationsPerRun', 'maxTotalInvocationsPerRun']) {
78
+ if (policy.budget[k] !== undefined && (typeof policy.budget[k] !== 'number' || policy.budget[k] < 0)) errors.push(`exec-policy: budget.${k} must be a number >= 0`);
79
+ }
80
+ }
81
+ if (policy.capabilityRequirements !== undefined) {
82
+ if (typeof policy.capabilityRequirements !== 'object' || policy.capabilityRequirements === null || Array.isArray(policy.capabilityRequirements)) errors.push('exec-policy: capabilityRequirements must be an object');
83
+ else for (const [cap, req] of Object.entries(policy.capabilityRequirements)) {
84
+ if (!req || typeof req !== 'object' || Array.isArray(req)) { errors.push(`exec-policy: capabilityRequirements.${cap} must be an object`); continue; }
85
+ for (const [dim, value] of Object.entries(req)) {
86
+ if (!['coding', 'reasoning'].includes(dim) || typeof value !== 'number' || value < 0 || value > 100) errors.push(`exec-policy: capabilityRequirements.${cap}.${dim} must be coding/reasoning 0-100`);
87
+ }
88
+ }
89
+ }
90
+ if (policy.calibration !== undefined) {
91
+ if (typeof policy.calibration !== 'object' || policy.calibration === null) { errors.push('exec-policy: calibration must be an object'); }
92
+ else for (const k of ['minRuns', 'minMeaningfulEvents']) {
93
+ if (policy.calibration[k] !== undefined && (typeof policy.calibration[k] !== 'number' || policy.calibration[k] < 0)) errors.push(`exec-policy: calibration.${k} must be a number >= 0`);
94
+ }
95
+ }
96
+ return errors;
97
+ }
98
+
99
+ export function loadExecPolicy(path, defaults = DEFAULT_POLICY) {
100
+ if (!path) return structuredClone(defaults);
101
+ const doc = JSON.parse(readFileSync(path, 'utf8'));
102
+ const errors = validateExecPolicy(doc);
103
+ if (errors.length) throw new Error(errors.join('\n'));
104
+ return {
105
+ ...structuredClone(defaults), ...doc,
106
+ requiredQuality: { ...defaults.requiredQuality, ...doc.requiredQuality },
107
+ costWeights: { ...defaults.costWeights, ...doc.costWeights },
108
+ taskProfiles: { ...defaults.taskProfiles, ...doc.taskProfiles },
109
+ qualityTier: { ...defaults.qualityTier, ...doc.qualityTier },
110
+ escalation: { ...defaults.escalation, ...doc.escalation },
111
+ budget: { ...defaults.budget, ...doc.budget },
112
+ calibration: { ...defaults.calibration, ...doc.calibration },
113
+ capabilityRequirements: { ...defaults.capabilityRequirements, ...doc.capabilityRequirements },
114
+ };
115
+ }
116
+
117
+ /** Estimated electricity cost of one local inference run, USD. Null when unknown. */
118
+ export function localElectricityCost(local, pricePerKwh) {
119
+ if (!local || typeof local.powerWatts !== 'number' || typeof local.taskMinutes !== 'number') return null;
120
+ if (typeof pricePerKwh !== 'number') return null;
121
+ return (local.powerWatts / 1000) * (local.taskMinutes / 60) * pricePerKwh;
122
+ }
123
+
124
+ /** API cost of one run, USD: inputTokens x $/1M-in + outputTokens x $/1M-out. Null when unpriced. */
125
+ export function apiCostPerRun(entry, profile = {}) {
126
+ const c = entry?.cost;
127
+ if (!c) return null;
128
+ if (c.free === true) return 0;
129
+ const i = c.usdPerMInput, o = c.usdPerMOutput;
130
+ if (i == null && o == null) return null;
131
+ const tin = profile.estimatedInputTokens ?? 0, tout = profile.estimatedOutputTokens ?? 0;
132
+ return ((i ?? 0) * tin + (o ?? 0) * tout) / 1e6;
133
+ }
134
+
135
+ /**
136
+ * Estimated cost of one maintenance run, USD — comparable across cloud and local.
137
+ * cloud: token-priced API estimate from the tier task profile. local: electricity,
138
+ * using per-model taskMinutes when present else the tier profile. A speed penalty
139
+ * (slower models hold the pipeline longer) is added. Null components fall back to
140
+ * the cheapest known value so an unknown cost is never treated as free.
141
+ */
142
+ export function estimatedCostPerRun(entry, policy = DEFAULT_POLICY, pool = [], tier = 'monitor') {
143
+ const w = policy.costWeights ?? DEFAULT_POLICY.costWeights;
144
+ const profile = policy.taskProfiles?.[tier] ?? {};
145
+ const price = policy.electricityPricePerKwh ?? DEFAULT_POLICY.electricityPricePerKwh;
146
+ const api = apiCostPerRun(entry, profile);
147
+ const local = entry?.location === 'local'
148
+ ? { powerWatts: entry.local?.powerWatts, taskMinutes: entry.local?.taskMinutes ?? profile.estimatedTaskMinutes }
149
+ : null;
150
+ const elec = local ? localElectricityCost(local, price) : null;
151
+ const knownApis = pool.map(e => apiCostPerRun(e, profile)).filter(v => v !== null);
152
+ const knownElecs = pool.filter(e => e.location === 'local')
153
+ .map(e => localElectricityCost({ powerWatts: e.local?.powerWatts, taskMinutes: e.local?.taskMinutes ?? profile.estimatedTaskMinutes }, price))
154
+ .filter(v => v !== null);
155
+ const apiUsed = api ?? (knownApis.length ? Math.min(...knownApis) : 0);
156
+ const elecUsed = elec ?? (entry?.location === 'local' ? (knownElecs.length ? Math.min(...knownElecs) : 0) : 0);
157
+ const speedPenalty = (100 - (entry?.scores?.speed ?? 50)) / 100;
158
+ return {
159
+ total: w.api * apiUsed + w.electricity * elecUsed + (w.speed ?? 0) * speedPenalty,
160
+ api: apiUsed, apiKnown: api !== null,
161
+ electricity: elecUsed, electricityKnown: elec !== null,
162
+ speedPenalty: (w.speed ?? 0) * speedPenalty,
163
+ };
164
+ }
165
+
166
+ /** Back-compat alias (Phase 2 name) — prefer estimatedCostPerRun. */
167
+ export const effectiveCost = estimatedCostPerRun;
168
+
169
+ /** 0-100 quality for a maintenance task role: coding/reasoning/speed/context/toolUse, tier-weighted. */
170
+ export function taskQuality(entry, tier = 'mid') {
171
+ const s = scoreModel(entry, tier);
172
+ return { quality: s.score === null ? null : Math.round(s.score * 10) / 10, confidence: s.confidence, unknown: s.unknown };
173
+ }
174
+
175
+ // 'Free' means zero API spend — a local model with cost.free still burns electricity,
176
+ // so it is not a free run and competes on estimatedCostPerRun like any other local.
177
+ const isFree = e => e?.location !== 'local' && (e?.cost?.free === true || ((e?.cost?.usdPerMInput ?? 1) === 0 && (e?.cost?.usdPerMOutput ?? 1) === 0));
178
+ const isUsable = e => USABLE_STATUS.has(e?.status) && e?.toolUse !== 'none';
179
+ const availOf = (e, availability) => {
180
+ if (availability?.providers?.has(e.provider)) return availability.models.has(`${e.provider}/${e.model}`) ? 'available' : 'unavailable';
181
+ return e?.availability ?? 'unknown'; // probe absent or provider unseen -> unknown, never "gone"
182
+ };
183
+
184
+ function candidateRecord(entry, tier, policy, pool, availability) {
185
+ const q = taskQuality(entry, policy.qualityTier?.[tier] ?? 'mid');
186
+ const cost = estimatedCostPerRun(entry, policy, pool, tier);
187
+ const availabilityState = availOf(entry, availability);
188
+ const reasons = [];
189
+ if (!isUsable(entry)) reasons.push(`status "${entry.status}" or toolUse "${entry.toolUse}" is not usable`);
190
+ if (availabilityState === 'unavailable') reasons.push('absent from a successful availability listing');
191
+ if (q.quality === null) reasons.push('quality unknown — cannot be scored');
192
+ else if (q.quality < (policy.requiredQuality?.[tier] ?? 0)) reasons.push(`quality ${q.quality} < requiredQuality ${policy.requiredQuality[tier]}`);
193
+ return {
194
+ model: `${entry.provider}/${entry.model}`, provider: entry.provider, entry,
195
+ quality: q.quality, confidence: q.confidence, effectiveCost: Math.round(cost.total * 10000) / 10000,
196
+ costDetail: { estimatedApiCostUsd: cost.apiKnown ? cost.api : null, electricityUsd: cost.electricityKnown ? cost.electricity : null, speedPenalty: cost.speedPenalty },
197
+ location: entry.location === 'local' ? 'local' : 'cloud',
198
+ availability: availabilityState, usable: reasons.length === 0, rejectedReasons: reasons,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Deterministic selection for one execution tier.
204
+ * Order: eligible free cloud -> cheapest-sufficient cloud -> local fallback (same rule,
205
+ * local entries only). Free never overrides the quality bar; 'unknown' availability is
206
+ * eligible, 'unavailable' (absent from a successful listing) is not. Premium models
207
+ * (catalog `premium: true`) are excluded from monitor/evaluate unless opts.allowPremium.
208
+ * `ordered` is the full fallback chain (free-cloud, then cloud, then local by cost) for
209
+ * the Phase 3 runner.
210
+ */
211
+ export function selectTierModel(catalogModels, tier, policy = DEFAULT_POLICY, { availability = null, allowPremium = false } = {}) {
212
+ const pool = (catalogModels ?? []).filter(e => allowPremium || tier === 'reconfigure' || e.premium !== true);
213
+ const records = pool.map(e => candidateRecord(e, tier, policy, pool, availability));
214
+ const eligible = records.filter(r => r.usable);
215
+ const pick = list => list.slice().sort((a, b) => a.effectiveCost - b.effectiveCost || b.quality - a.quality)[0] ?? null;
216
+
217
+ const cloud = eligible.filter(r => r.location === 'cloud');
218
+ const local = eligible.filter(r => r.location === 'local');
219
+ const freeCloud = cloud.filter(r => isFree(r.entry));
220
+ const nonFreeCloud = cloud.filter(r => !isFree(r.entry));
221
+ // cheapest-sufficient pool is cloud + local ranked by estimatedCostPerRun — a local
222
+ // model wins it when its electricity estimate beats the cheapest cloud API run
223
+ // (long task profiles make token cost dominate). 'local-fallback' is reported only
224
+ // when NO cloud model is eligible at all.
225
+ const sufficientPool = [...nonFreeCloud, ...local];
226
+ const selected = pick(freeCloud) ?? pick(sufficientPool);
227
+ const ordered = [
228
+ ...freeCloud.slice().sort((a, b) => a.effectiveCost - b.effectiveCost || b.quality - a.quality),
229
+ ...sufficientPool.slice().sort((a, b) => a.effectiveCost - b.effectiveCost || b.quality - a.quality),
230
+ ];
231
+ const path = selected === null ? 'none'
232
+ : freeCloud.includes(selected) ? 'free-cloud'
233
+ : nonFreeCloud.length === 0 ? 'local-fallback'
234
+ : selected.location === 'local' ? 'cheapest-sufficient-local' : 'cheapest-sufficient-cloud';
235
+
236
+
237
+ const selectionReason = selected === null ? 'no eligible model'
238
+ : path === 'free-cloud' ? `free model meets requiredQuality ${policy.requiredQuality[tier]} (quality ${selected.quality})`
239
+ : path === 'local-fallback' ? `no eligible cloud model; local fallback at $${selected.effectiveCost}/run estimated electricity`
240
+ : `lowest estimated cost $${selected.effectiveCost}/run among eligible ${selected.location} models (quality ${selected.quality} >= ${policy.requiredQuality[tier]})`;
241
+
242
+ return {
243
+ tier, selected: selected ? { model: selected.model, provider: selected.provider, location: selected.location, quality: selected.quality, effectiveCostUsd: selected.effectiveCost, costDetail: selected.costDetail } : null,
244
+ ordered: ordered.map(r => ({ model: r.model, provider: r.provider, location: r.location, quality: r.quality, effectiveCostUsd: r.effectiveCost, thinking: r.entry.thinking ?? null })),
245
+ selectionPath: path, selectionReason,
246
+ candidates: records.map(r => ({ model: r.model, location: r.location, quality: r.quality, effectiveCostUsd: r.effectiveCost, availability: r.availability, eligible: r.usable, rejectedReasons: r.rejectedReasons })),
247
+ fallbackOccurred: path === 'local-fallback',
248
+ requiredQuality: policy.requiredQuality?.[tier],
249
+ };
250
+ }
251
+
252
+ /** Monitor output: did anything change, how severe, does evaluate need to run. */
253
+ export function buildMonitorOutput({ events = [], catalog, availability = null, availabilitySource = 'not-checked' }) {
254
+ const reasons = [], affectedModels = new Set();
255
+ for (const e of events ?? []) { reasons.push(`${e.type}: ${e.provider}/${e.model}${e.note ? ` — ${e.note}` : ''}`); affectedModels.add(`${e.provider}/${e.model}`); }
256
+ const stale = [], removed = [], expired = [];
257
+ for (const m of catalog?.models ?? []) {
258
+ if (m.campaignExpired) { expired.push(`${m.provider}/${m.model}`); affectedModels.add(`${m.provider}/${m.model}`); }
259
+ if (m.status === 'deprecated') { stale.push(`${m.provider}/${m.model}`); affectedModels.add(`${m.provider}/${m.model}`); }
260
+ if (m.status === 'removed') { removed.push(`${m.provider}/${m.model}`); affectedModels.add(`${m.provider}/${m.model}`); }
261
+ }
262
+ if (expired.length) reasons.push(`free campaign cutoff reached: ${expired.join(', ')}`);
263
+ if (stale.length) reasons.push(`catalog status deprecated: ${stale.join(', ')}`);
264
+ if (removed.length) reasons.push(`catalog status removed: ${removed.join(', ')}`);
265
+ let probeFailed = false;
266
+ if (availability?.providers?.size) {
267
+ const absent = (catalog?.models ?? []).filter(m => availability.providers.has(m.provider) && !availability.models.has(`${m.provider}/${m.model}`));
268
+ if (absent.length) { reasons.push(`absent from availability listing: ${absent.map(m => `${m.provider}/${m.model}`).join(', ')}`); absent.forEach(m => affectedModels.add(`${m.provider}/${m.model}`)); }
269
+ } else if (availabilitySource !== 'not-checked') probeFailed = true; // probe was attempted and failed -> unknown, not "gone"
270
+
271
+ const types = new Set((events ?? []).map(e => e.type));
272
+ const severity = removed.length || types.has('removed') ? 'high'
273
+ : stale.length || expired.length || types.has('deprecated') || types.has('free-campaign-ended') ? 'medium'
274
+ : reasons.length ? 'low' : 'none';
275
+ return {
276
+ changed: reasons.length > 0,
277
+ reasons,
278
+ affectedModels: [...affectedModels].sort(),
279
+ severity,
280
+ escalationRequired: severity === 'high', // probe failure alone never escalates
281
+ infoStatus: { availability: availability?.source ?? availabilitySource, probeFailed, catalogEntries: catalog?.models?.length ?? 0 },
282
+ };
283
+ }
284
+
285
+ /** Should evaluate escalate to reconfigure? Returns null or an escalation record. */
286
+ export function escalationDecision(maintResult, monitor, policy = DEFAULT_POLICY) {
287
+ const esc = policy.escalation ?? DEFAULT_POLICY.escalation;
288
+ const changes = maintResult.changes ?? [];
289
+ const caps = new Set(), agents = new Set();
290
+ for (const c of changes) {
291
+ (c.affected?.capabilities ?? []).forEach(x => caps.add(x));
292
+ (c.affected?.agents ?? []).forEach(x => agents.add(x));
293
+ }
294
+ const reasons = [];
295
+ if (caps.size >= (esc.minCapabilities ?? 2)) reasons.push(`changes span ${caps.size} capabilities (>= ${esc.minCapabilities})`);
296
+ if (agents.size >= (esc.minAgents ?? 3)) reasons.push(`changes affect ${agents.size} agents (>= ${esc.minAgents})`);
297
+ const swing = Math.max(0, ...changes.map(c => Math.abs((c.scores?.proposed ?? 0) - (c.scores?.current ?? 0))));
298
+ if (swing >= (esc.minQualitySwing ?? 15)) reasons.push(`largest quality swing ${swing.toFixed(1)} points (>= ${esc.minQualitySwing})`);
299
+ const tight = changes.filter(c => c.scores?.delta !== null && c.scores?.delta !== undefined && c.scores.delta < (esc.maxScoreDelta ?? 4));
300
+ if (tight.length) reasons.push(`${tight.length} change(s) decided within ${esc.maxScoreDelta} points — hard call`);
301
+ if (esc.escalateOnLowConfidence && (changes.some(c => c.confidence === 'low') || (maintResult.decisions ?? []).some(d => d.decision === 'insufficient-data'))) {
302
+ reasons.push('low-confidence data or insufficient-data decision present');
303
+ }
304
+ const structural = new Set(esc.structuralEvents ?? []);
305
+ if ((maintResult.infoStatus?.eventsApplied ?? []).some(a => structural.has(a.split(':')[0]))) reasons.push('structural provider event (removed/deprecated) present');
306
+ if (monitor?.severity === 'high') reasons.push('monitor severity is high');
307
+ if (!reasons.length) return null;
308
+ return { escalate: true, reasons, affectedCapabilities: [...caps].sort(), sourceTier: 'evaluate', targetTier: 'reconfigure' };
309
+ }
310
+
311
+ /** Advisory, capability-specific free-model plan. Never changes routing or bindings. */
312
+ export function planFreeCapacity({ routing, catalogModels, policy = DEFAULT_POLICY, availability = null }) {
313
+ return Object.entries(routing.capabilities ?? {}).map(([capability, route]) => {
314
+ const requirements = policy.capabilityRequirements?.[capability] ?? null;
315
+ const vision = route.requires?.vision === true;
316
+ const freeCandidates = (catalogModels ?? []).filter(m =>
317
+ requirements && isUsable(m) && isFree(m) && availOf(m, availability) !== 'unavailable' &&
318
+ (!vision || m.vision === true) &&
319
+ Object.entries(requirements).every(([dim, min]) => typeof m.scores?.[dim] === 'number' && m.scores[dim] >= min))
320
+ .sort((a, b) => {
321
+ const tier = routing.backends?.[route.primary]?.tier;
322
+ const qualityDelta = Object.keys(requirements).reduce((sum, dim) => sum + (a.scores[dim] - b.scores[dim]), 0);
323
+ return (tier === 'low' || tier === 'free' ? qualityDelta : -qualityDelta) ||
324
+ (b.scores?.speed ?? 0) - (a.scores?.speed ?? 0);
325
+ })
326
+ .map(m => ({ model: `${m.provider}/${m.model}`, scores: Object.fromEntries(Object.keys(requirements).map(dim => [dim, m.scores[dim]])),
327
+ freeUntil: m.freeUntil ?? null, availability: availOf(m, availability) }));
328
+ return { capability, requirements: { ...requirements, vision }, currentPrimary: route.primary,
329
+ recommendedFree: freeCandidates[0]?.model ?? null, freeCandidates,
330
+ fallbackBackends: [route.primary, ...(route.fallback ?? [])],
331
+ note: !requirements ? 'no quality requirements configured; manual review required'
332
+ : freeCandidates.length ? 'advisory only; verify actual task success and campaign entitlement before rebinding'
333
+ : 'no free model meets the capability bar; retain current route or review paid fallback' };
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Run the Phase 2 maintenance pipeline (decision layer only — no model is invoked).
339
+ * flow: monitor (selected model) -> if changed, evaluate + evaluateMaintenance() ->
340
+ * escalation? -> reconfigure tier selection -> proposal.
341
+ * Returns a run report: per-tier selection, monitor output, evaluation result,
342
+ * escalation record, and the Phase 1 proposal when produced. Nothing is applied.
343
+ */
344
+ export function runMaintenancePlan({ routing, registry, agents = [], catalog, events = [], availability = null, policy = DEFAULT_POLICY, margin, asOf = new Date().toISOString() } = {}) {
345
+ const effective = effectiveCatalog(catalog, events, asOf);
346
+ const run = { version: 1, kind: 'model-maintenance-run', freeCapacityPlan: planFreeCapacity({ routing, catalogModels: effective, policy, availability }), tiers: [], monitor: null, evaluation: null, escalation: null, proposal: null, estimatedDecisionCostUsd: 0 };
347
+
348
+ const monitorSel = selectTierModel(effective, 'monitor', policy, { availability });
349
+ run.tiers.push({ role: 'monitor', ...monitorSel });
350
+ run.estimatedDecisionCostUsd += monitorSel.selected?.effectiveCostUsd ?? 0;
351
+ const monitor = buildMonitorOutput({ events, catalog: { ...catalog, models: effective }, availability, availabilitySource: availability?.source ?? 'not-checked' });
352
+ run.monitor = monitor;
353
+ if (!monitor.changed) { run.outcome = 'no-change'; return run; }
354
+
355
+ const evalSel = selectTierModel(effective, 'evaluate', policy, { availability });
356
+ run.tiers.push({ role: 'evaluate', ...evalSel });
357
+ run.estimatedDecisionCostUsd += evalSel.selected?.effectiveCostUsd ?? 0;
358
+ const result = evaluateMaintenance({ routing, registry, agents, catalog, events, availability, margin, asOf });
359
+ run.evaluation = { changes: result.changes.length, decisions: result.decisions.map(d => ({ backend: d.backend, decision: d.decision, reason: d.reason })) };
360
+
361
+ const esc = escalationDecision(result, monitor, policy);
362
+ if (esc) {
363
+ const recSel = selectTierModel(effective, 'reconfigure', policy, { availability });
364
+ run.tiers.push({ role: 'reconfigure', ...recSel });
365
+ run.estimatedDecisionCostUsd += recSel.selected?.effectiveCostUsd ?? 0;
366
+ run.escalation = { ...escalationRecord(esc), estimatedDecisionCostUsd: Math.round(run.estimatedDecisionCostUsd * 10000) / 10000 };
367
+ }
368
+ run.proposal = result;
369
+ if (run.escalation) for (const c of run.proposal.changes) c.escalation = run.escalation;
370
+ run.estimatedDecisionCostUsd = Math.round(run.estimatedDecisionCostUsd * 10000) / 10000;
371
+ run.outcome = result.changes.length ? 'proposal' : 'evaluated-no-change';
372
+ return run;
373
+ }
374
+
375
+ function escalationRecord(esc) {
376
+ return { escalationReason: esc.reasons.join('; '), sourceTier: esc.sourceTier, targetTier: esc.targetTier, affectedCapabilities: esc.affectedCapabilities };
377
+ }
@@ -0,0 +1,266 @@
1
+ // Phase 3 — maintenance runner: actually invoke the selected tier models through the
2
+ // adapter invoker (pi CLI), with ordered fallback, one schema retry, and a deterministic
3
+ // authority boundary. The deterministic engine (lib/maintenance.mjs + maintenance-exec.mjs)
4
+ // decides eligible models, requiredQuality, cost, availability and escalation conditions;
5
+ // the LLM only interprets and annotates. A model recommendation for a model not in the
6
+ // catalog is rejected. This runner writes nothing but the run report assembled by the
7
+ // caller — never ~/.pi, settings.json, models*.json or routing.json.
8
+ import { selectTierModel, escalationDecision, DEFAULT_POLICY } from './maintenance-exec.mjs';
9
+ import { effectiveCatalog } from './maintenance.mjs';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Structured output schemas (validated structurally; no external deps)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const MONITOR_SCHEMA = {
16
+ name: 'monitor-report', type: 'object',
17
+ required: ['decision', 'confidence', 'reasoningSummary'],
18
+ properties: {
19
+ decision: { enum: ['changed', 'no-change'] },
20
+ confidence: { type: 'number', min: 0, max: 1 },
21
+ reasoningSummary: { type: 'string' },
22
+ affectedModels: { type: 'array', items: 'string' },
23
+ affectedCapabilities: { type: 'array', items: 'string' },
24
+ severity: { enum: ['none', 'low', 'medium', 'high'] },
25
+ evaluateNeeded: { type: 'boolean' },
26
+ recommendedActions: { type: 'array', items: 'string' },
27
+ },
28
+ };
29
+
30
+ export const EVALUATE_SCHEMA = {
31
+ name: 'evaluate-report', type: 'object',
32
+ required: ['decision', 'confidence', 'reasoningSummary'],
33
+ properties: {
34
+ decision: { enum: ['keep', 'propose', 'insufficient-data'] },
35
+ confidence: { type: 'number', min: 0, max: 1 },
36
+ reasoningSummary: { type: 'string' },
37
+ affectedCapabilities: { type: 'array', items: 'string' },
38
+ recommendedActions: { type: 'array', items: 'string' },
39
+ proposalNotes: { type: 'array', items: 'string' },
40
+ recommendedModels: { type: 'array', items: 'string', description: 'provider/model ids — must exist in the catalog' },
41
+ },
42
+ };
43
+
44
+ export const RECONFIGURE_SCHEMA = {
45
+ name: 'reconfigure-report', type: 'object',
46
+ required: ['decision', 'confidence', 'reasoningSummary'],
47
+ properties: {
48
+ decision: { enum: ['keep', 'propose', 'insufficient-data'] },
49
+ confidence: { type: 'number', min: 0, max: 1 },
50
+ reasoningSummary: { type: 'string' },
51
+ affectedCapabilities: { type: 'array', items: 'string' },
52
+ recommendedActions: { type: 'array', items: 'string' },
53
+ proposalNotes: { type: 'array', items: 'string' },
54
+ recommendedModels: { type: 'array', items: 'string' },
55
+ routingNotes: { type: 'array', items: 'string' },
56
+ },
57
+ };
58
+
59
+ export const TIER_SCHEMAS = { monitor: MONITOR_SCHEMA, evaluate: EVALUATE_SCHEMA, reconfigure: RECONFIGURE_SCHEMA };
60
+
61
+ /** Structural JSON-schema validation. Returns error strings (empty = valid). */
62
+ export function validateStructuredOutput(schema, obj) {
63
+ const errors = [];
64
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return [`${schema.name}: output must be a JSON object`];
65
+ for (const key of schema.required ?? []) if (!(key in obj)) errors.push(`${schema.name}: missing required "${key}"`);
66
+ for (const [key, prop] of Object.entries(schema.properties ?? {})) {
67
+ if (!(key in obj)) continue;
68
+ const v = obj[key];
69
+ if (prop.type === 'string' && typeof v !== 'string') errors.push(`${schema.name}.${key} must be a string`);
70
+ if (prop.type === 'number' && (typeof v !== 'number' || Number.isNaN(v))) errors.push(`${schema.name}.${key} must be a number`);
71
+ if (prop.type === 'boolean' && typeof v !== 'boolean') errors.push(`${schema.name}.${key} must be a boolean`);
72
+ if (prop.type === 'array' && !Array.isArray(v)) errors.push(`${schema.name}.${key} must be an array`);
73
+ if (prop.type === 'array' && Array.isArray(v) && prop.items === 'string' && v.some(x => typeof x !== 'string')) errors.push(`${schema.name}.${key} items must be strings`);
74
+ if (prop.enum && !prop.enum.includes(v)) errors.push(`${schema.name}.${key} must be one of ${prop.enum.join('|')}`);
75
+ if (prop.type === 'number' && typeof v === 'number') {
76
+ if (prop.min !== undefined && v < prop.min) errors.push(`${schema.name}.${key} < ${prop.min}`);
77
+ if (prop.max !== undefined && v > prop.max) errors.push(`${schema.name}.${key} > ${prop.max}`);
78
+ }
79
+ }
80
+ return errors;
81
+ }
82
+
83
+ /** Extract the first JSON object from model output (tolerates code fences / prose around it). */
84
+ export function extractJson(text) {
85
+ const s = String(text ?? '');
86
+ const fenced = s.match(/```(?:json)?\s*([\s\S]*?)```/);
87
+ const body = fenced ? fenced[1] : s;
88
+ const start = body.indexOf('{');
89
+ if (start < 0) return null;
90
+ let depth = 0, inStr = false, esc = false;
91
+ for (let i = start; i < body.length; i++) {
92
+ const ch = body[i];
93
+ if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
94
+ if (ch === '"') inStr = true;
95
+ else if (ch === '{') depth++;
96
+ else if (ch === '}') { depth--; if (depth === 0) { try { return JSON.parse(body.slice(start, i + 1)); } catch { return null; } } }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Prompts — each tier is asked ONLY for its own responsibility. The deterministic
103
+ // facts are supplied; the model interprets, never decides the final config.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ const COMMON = `You are one stage of a deterministic model-maintenance pipeline. The engine has already computed the facts below; your job is to interpret them, not to change configuration. Reply with ONE JSON object only (no prose, no markdown fence) matching the required keys. Never invent provider/model ids that are not in the supplied catalog.`;
107
+
108
+ export function buildTierPrompt(tier, { monitor, evaluation, catalogKeys, escalation } = {}) {
109
+ const facts = [];
110
+ if (monitor) facts.push(`monitor findings (deterministic): ${JSON.stringify({ changed: monitor.changed, reasons: monitor.reasons, affectedModels: monitor.affectedModels, severity: monitor.severity })}`);
111
+ if (evaluation) facts.push(`evaluation result (deterministic): ${JSON.stringify(evaluation)}`);
112
+ if (catalogKeys) facts.push(`catalog models (only these ids exist): ${catalogKeys.join(', ')}`);
113
+ if (escalation) facts.push(`escalation record: ${JSON.stringify(escalation)}`);
114
+
115
+ const schemas = {
116
+ monitor: `{"decision":"changed|no-change","confidence":0-1,"reasoningSummary":"...","affectedModels":["p/m"],"affectedCapabilities":["..."],"severity":"none|low|medium|high","evaluateNeeded":true|false,"recommendedActions":["..."]}`,
117
+ evaluate: `{"decision":"keep|propose|insufficient-data","confidence":0-1,"reasoningSummary":"...","affectedCapabilities":["..."],"recommendedActions":["..."],"proposalNotes":["..."],"recommendedModels":["p/m"]}`,
118
+ reconfigure: `{"decision":"keep|propose|insufficient-data","confidence":0-1,"reasoningSummary":"...","affectedCapabilities":["..."],"recommendedActions":["..."],"proposalNotes":["..."],"recommendedModels":["p/m"],"routingNotes":["..."]}`,
119
+ };
120
+ const duties = {
121
+ monitor: 'Organise the event/catalog/availability diffs. Say whether anything changed, which models/capabilities are affected, the severity, and whether the evaluate stage is needed. Do NOT propose routing changes.',
122
+ evaluate: 'Read the deterministic evaluation. Say whether the kept/proposed bindings are reasonable, give your confidence, and add short proposal notes. Only recommend catalog models.',
123
+ reconfigure: 'Re-review the whole routing picture for this structural/multi-capability change. Output is advisory notes for a proposal only — no config is applied.',
124
+ };
125
+ return `${COMMON}\n\nSTAGE: ${tier}\nTASK: ${duties[tier]}\n\nFACTS:\n${facts.join('\n')}\n\nREQUIRED JSON SHAPE:\n${schemas[tier]}`;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Runner
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const isQuota = e => /usage limit|insufficient_quota|quota/i.test(String(e ?? ''));
133
+
134
+ /**
135
+ * Invoke one tier: try the ordered candidate chain until a call succeeds AND returns
136
+ * schema-valid output. One retry on malformed output (same model). Premium candidates
137
+ * are only in the chain when allowPremium (reconfigure conditions) applies.
138
+ * Returns { tier, selected, output|null, attempts[], fallbackOccurred, degradedToDeterministic }.
139
+ */
140
+ export async function invokeTier(tier, selection, { invoke, catalogKeys = new Set(), policy = DEFAULT_POLICY, timeoutMs } = {}) {
141
+ const schema = TIER_SCHEMAS[tier];
142
+ const chain = selection?.ordered ?? [];
143
+ const attempts = [];
144
+ let retried = false;
145
+ for (let i = 0; i < chain.length; i++) {
146
+ const cand = chain[i];
147
+ const modelId = `${cand.provider}/${cand.model}${cand.thinking ? `:${cand.thinking}` : ''}`;
148
+ for (let attempt = 0; attempt < 2; attempt++) {
149
+ const started = Date.now();
150
+ const rec = { model: cand.model, modelId, location: cand.location, estimatedCostUsd: cand.effectiveCostUsd, attempt: attempt + 1, retry: attempt > 0 };
151
+ attempts.push(rec);
152
+ let res;
153
+ try { res = await invoke({ modelId, prompt: buildTierPrompt(tier, {}), timeoutMs }); }
154
+ catch (e) { res = { ok: false, error: e.message }; }
155
+ rec.latencyMs = res?.durationMs ?? Date.now() - started;
156
+ if (!res?.ok) {
157
+ rec.ok = false; rec.error = res?.error ?? 'invoke failed';
158
+ rec.failureClass = isQuota(rec.error) ? 'quota' : /timeout|timed out/i.test(rec.error) ? 'timeout' : 'invoke';
159
+ break; // next candidate
160
+ }
161
+ const parsed = extractJson(res.text);
162
+ const errs = parsed ? validateStructuredOutput(schema, parsed) : ['no JSON object in output'];
163
+ rec.ok = true; rec.tokens = res.tokens ?? null; rec.actualCostUsd = res.costUsd ?? null;
164
+ if (!errs.length) {
165
+ rec.schemaValid = true;
166
+ const rejected = (parsed.recommendedModels ?? []).filter(id => !catalogKeys.has(id));
167
+ if (rejected.length) { parsed.recommendedModels = (parsed.recommendedModels ?? []).filter(id => catalogKeys.has(id)); parsed.rejectedRecommendations = rejected; }
168
+ return { tier, selected: cand, output: parsed, attempts, fallbackOccurred: i > 0, degradedToDeterministic: false };
169
+ }
170
+ rec.schemaValid = false; rec.schemaErrors = errs;
171
+ if (retried || attempt === 1) break; // one retry across the run for this tier
172
+ retried = true;
173
+ }
174
+ }
175
+ return { tier, selected: null, output: null, attempts, fallbackOccurred: chain.length > 1, degradedToDeterministic: true };
176
+ }
177
+
178
+ function auditOf(tierResult) {
179
+ return {
180
+ tier: tierResult.tier,
181
+ selectedModel: tierResult.selected?.model ?? null,
182
+ fallbackChain: tierResult.attempts.map(a => `${a.model}${a.retry ? ' (retry)' : ''}:${a.ok ? (a.schemaValid ? 'ok' : 'schema-invalid') : `fail:${a.failureClass}`}`),
183
+ invocations: tierResult.attempts.map(a => ({
184
+ model: a.model, ok: a.ok, retry: a.retry, failureClass: a.failureClass ?? null,
185
+ schemaValid: a.schemaValid ?? null, schemaErrors: a.schemaErrors ?? null,
186
+ latencyMs: a.latencyMs, tokens: a.tokens ?? null, estimatedCostUsd: a.estimatedCostUsd, actualCostUsd: a.actualCostUsd ?? null,
187
+ })),
188
+ fallbackOccurred: tierResult.fallbackOccurred,
189
+ degradedToDeterministic: tierResult.degradedToDeterministic,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Phase 3 run: monitor -> evaluate -> reconfigure, invoking real models via `invoke`
195
+ * (the pi adapter invoker signature: ({modelId, prompt}) -> {ok, text, durationMs}).
196
+ * Deterministic results stay authoritative; LLM outputs annotate the run report.
197
+ * `plan` is a Phase 2 runMaintenancePlan result (or null to recompute decisions here).
198
+ * `tierAllowed(tier)` (Phase 5 budget gate): when false the tier is not invoked —
199
+ * the audit records skippedByBudget and the run continues deterministically.
200
+ */
201
+ export async function runMaintenanceLive({ routing, registry, agents = [], catalog, events = [], availability = null, policy = DEFAULT_POLICY, margin, invoke, plan = null, tierAllowed = () => true } = {}) {
202
+ if (typeof invoke !== 'function') throw new Error('runMaintenanceLive: invoke function is required');
203
+ const effective = effectiveCatalog(catalog, events);
204
+ const catalogKeys = new Set(effective.map(m => `${m.provider}/${m.model}`));
205
+ const run = plan ?? { version: 1, kind: 'model-maintenance-run', tiers: [], monitor: null, evaluation: null, escalation: null, proposal: null, estimatedDecisionCostUsd: 0 };
206
+ run.invocations = [];
207
+
208
+ // monitor
209
+ const monitorSel = selectTierModel(effective, 'monitor', policy, { availability });
210
+ const mon = tierAllowed('monitor')
211
+ ? await invokeTier('monitor', monitorSel, { invoke, catalogKeys, policy })
212
+ : { tier: 'monitor', selected: null, output: null, attempts: [], fallbackOccurred: false, degradedToDeterministic: true, skippedByBudget: true };
213
+ run.invocations.push({ ...auditOf(mon), skippedByBudget: mon.skippedByBudget === true });
214
+ const monOut = mon.output;
215
+ if (!run.monitor) {
216
+ const { buildMonitorOutput } = await import('./maintenance-exec.mjs');
217
+ run.monitor = buildMonitorOutput({ events, catalog: { ...catalog, models: effective }, availability, availabilitySource: availability?.source ?? 'not-checked' });
218
+ }
219
+ run.monitor.llm = monOut ? { decision: monOut.decision, confidence: monOut.confidence, severity: monOut.severity, evaluateNeeded: monOut.evaluateNeeded, reasoningSummary: monOut.reasoningSummary } : null;
220
+ // deterministic authority: changed flag comes from the engine, not the LLM
221
+ if (!run.monitor.changed) { run.outcome = 'no-change'; run.tiers.push({ role: 'monitor', ...monitorSel }); return run; }
222
+ run.tiers.push({ role: 'monitor', ...monitorSel });
223
+
224
+ // evaluate
225
+ const evalSel = selectTierModel(effective, 'evaluate', policy, { availability });
226
+ const evalRes = tierAllowed('evaluate')
227
+ ? await invokeTier('evaluate', evalSel, { invoke, catalogKeys, policy })
228
+ : { tier: 'evaluate', selected: null, output: null, attempts: [], fallbackOccurred: false, degradedToDeterministic: true, skippedByBudget: true };
229
+ run.invocations.push({ ...auditOf(evalRes), skippedByBudget: evalRes.skippedByBudget === true });
230
+ run.tiers.push({ role: 'evaluate', ...evalSel });
231
+ if (!run.proposal) {
232
+ const { evaluateMaintenance } = await import('./maintenance.mjs');
233
+ run.proposal = evaluateMaintenance({ routing, registry, agents, catalog, events, availability, margin });
234
+ run.evaluation = { changes: run.proposal.changes.length, decisions: run.proposal.decisions.map(d => ({ backend: d.backend, decision: d.decision, reason: d.reason })) };
235
+ }
236
+ const evalOut = evalRes.output;
237
+ if (evalOut) {
238
+ run.proposal.llmReview = { decision: evalOut.decision, confidence: evalOut.confidence, reasoningSummary: evalOut.reasoningSummary, proposalNotes: evalOut.proposalNotes ?? [], rejectedRecommendations: evalOut.rejectedRecommendations };
239
+ }
240
+
241
+ // escalation: deterministic conditions OR evaluate confidence below the bar
242
+ let esc = run.escalation?.escalationReason ? run.escalation : null;
243
+ if (!esc) {
244
+ const det = escalationDecision(run.proposal, run.monitor, policy);
245
+ const lowConf = evalOut && typeof evalOut.confidence === 'number' && evalOut.confidence < (policy.escalation?.minEvaluateConfidence ?? 0.5);
246
+ if (det || lowConf) {
247
+ const base = det ?? { reasons: [], affectedCapabilities: evalOut?.affectedCapabilities ?? [] };
248
+ const reasons = [...base.reasons];
249
+ if (lowConf) reasons.push(`evaluate model confidence ${evalOut.confidence} < ${policy.escalation?.minEvaluateConfidence ?? 0.5}`);
250
+ esc = { escalationReason: reasons.join('; '), sourceTier: 'evaluate', targetTier: 'reconfigure', affectedCapabilities: base.affectedCapabilities };
251
+ }
252
+ }
253
+ if (esc) {
254
+ const recSel = selectTierModel(effective, 'reconfigure', policy, { availability });
255
+ const rec = tierAllowed('reconfigure')
256
+ ? await invokeTier('reconfigure', recSel, { invoke, catalogKeys, policy })
257
+ : { tier: 'reconfigure', selected: null, output: null, attempts: [], fallbackOccurred: false, degradedToDeterministic: true, skippedByBudget: true };
258
+ run.invocations.push({ ...auditOf(rec), skippedByBudget: rec.skippedByBudget === true });
259
+ run.tiers.push({ role: 'reconfigure', ...recSel });
260
+ run.escalation = { ...esc, estimatedDecisionCostUsd: null };
261
+ if (rec.output) run.proposal.reconfigureReview = { decision: rec.output.decision, confidence: rec.output.confidence, reasoningSummary: rec.output.reasoningSummary, routingNotes: rec.output.routingNotes ?? [], rejectedRecommendations: rec.output.rejectedRecommendations };
262
+ for (const c of run.proposal.changes ?? []) c.escalation = run.escalation;
263
+ }
264
+ run.outcome = (run.proposal?.changes?.length ?? 0) ? 'proposal' : 'evaluated-no-change';
265
+ return run;
266
+ }