@ludi-uni/ludi-agent-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Model-provisioning maintenance: re-evaluate capability -> backend -> model bindings when
|
|
2
|
+
// provider conditions change (free campaign end, quota/rate-limit change, deprecation,
|
|
3
|
+
// price change, new model added). Neutral: no provider or model names here — concrete data
|
|
4
|
+
// comes from an adapter catalog (adapters/<x>/model-catalog.json) plus an events file.
|
|
5
|
+
// Pure evaluation: reads config objects and returns a proposal. It never writes
|
|
6
|
+
// models.json / models.local.json / settings.json or anything under ~/.pi.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { isPlaceholder } from './registry.mjs';
|
|
9
|
+
|
|
10
|
+
export const EVENT_TYPES = new Set([
|
|
11
|
+
'free-campaign-started', 'free-campaign-ended', 'free-quota-changed', 'rate-limits-changed',
|
|
12
|
+
'price-changed', 'deprecated', 'removed', 'model-added',
|
|
13
|
+
]);
|
|
14
|
+
export const MODEL_STATUS = new Set(['active', 'free-campaign', 'deprecated', 'removed']);
|
|
15
|
+
export const TOOL_USE = new Set(['good', 'basic', 'poor', 'none']);
|
|
16
|
+
export const LOCATION = new Set(['cloud', 'local']);
|
|
17
|
+
export const AVAILABILITY = new Set(['available', 'unavailable', 'unknown']);
|
|
18
|
+
const TOOL_POINTS = { good: 100, basic: 60, poor: 30, none: 0 };
|
|
19
|
+
const USABLE = new Set(['active', 'free-campaign']);
|
|
20
|
+
|
|
21
|
+
// Heuristic point scales: $0/M blended -> 100, >= $15/M -> 0 (linear); 400K context -> 100.
|
|
22
|
+
const COST_FULL_PRICE = 15;
|
|
23
|
+
const CONTEXT_FULL_K = 400;
|
|
24
|
+
|
|
25
|
+
// Dimension weights per backend tier: cheap tiers care about cost, high tiers about quality.
|
|
26
|
+
export const TIER_WEIGHTS = {
|
|
27
|
+
free: { cost: 5, coding: 2, reasoning: 1, speed: 2, context: 1, tool: 2 },
|
|
28
|
+
low: { cost: 4, coding: 3, reasoning: 2, speed: 2, context: 1, tool: 2 },
|
|
29
|
+
mid: { cost: 2, coding: 4, reasoning: 3, speed: 1, context: 2, tool: 3 },
|
|
30
|
+
high: { cost: 1, coding: 4, reasoning: 4, speed: 1, context: 2, tool: 3 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Minimum score advantage a candidate must have before a change is "rational".
|
|
34
|
+
// A free offer ending is by itself NOT a reason to switch: the current model is
|
|
35
|
+
// re-scored with its post-event cost and only loses if a candidate beats it by margin.
|
|
36
|
+
export const DEFAULT_MARGIN = 8;
|
|
37
|
+
|
|
38
|
+
export function validateCatalog(cat) {
|
|
39
|
+
const errors = [];
|
|
40
|
+
if (!cat || typeof cat !== 'object' || Array.isArray(cat)) return ['catalog: root must be an object'];
|
|
41
|
+
if (cat.version !== 1) errors.push('catalog: version must be 1');
|
|
42
|
+
if (!Array.isArray(cat.models)) { errors.push('catalog: models must be an array'); return errors; }
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
cat.models.forEach((m, i) => {
|
|
45
|
+
const at = `catalog: models[${i}]`;
|
|
46
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push(`${at} must be an object`); return; }
|
|
47
|
+
for (const k of ['provider', 'model']) {
|
|
48
|
+
if (typeof m[k] !== 'string' || !m[k].trim()) errors.push(`${at}.${k} must be a non-empty string`);
|
|
49
|
+
}
|
|
50
|
+
const key = `${m.provider}/${m.model}`;
|
|
51
|
+
if (seen.has(key)) errors.push(`${at} duplicates ${key}`);
|
|
52
|
+
seen.add(key);
|
|
53
|
+
if (!MODEL_STATUS.has(m.status)) errors.push(`${at}.status must be one of ${[...MODEL_STATUS].join('|')}`);
|
|
54
|
+
for (const field of ['cost', 'postCampaignCost']) {
|
|
55
|
+
const c = m[field];
|
|
56
|
+
if (c === undefined || c === null) continue;
|
|
57
|
+
if (typeof c !== 'object' || Array.isArray(c)) { errors.push(`${at}.${field} must be an object`); continue; }
|
|
58
|
+
for (const kk of ['usdPerMInput', 'usdPerMOutput']) {
|
|
59
|
+
if (c[kk] !== undefined && c[kk] !== null && (typeof c[kk] !== 'number' || c[kk] < 0)) errors.push(`${at}.${field}.${kk} must be a number >= 0 or null`);
|
|
60
|
+
}
|
|
61
|
+
if (c.free !== undefined && typeof c.free !== 'boolean') errors.push(`${at}.${field}.free must be boolean`);
|
|
62
|
+
}
|
|
63
|
+
if (m.freeUntil != null && (typeof m.freeUntil !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(m.freeUntil) || !Number.isFinite(Date.parse(m.freeUntil)))) errors.push(`${at}.freeUntil must be a UTC ISO timestamp`);
|
|
64
|
+
if (m.contextK !== undefined && m.contextK !== null && (typeof m.contextK !== 'number' || m.contextK <= 0)) errors.push(`${at}.contextK must be a number > 0 or null`);
|
|
65
|
+
if (m.vision !== undefined && typeof m.vision !== 'boolean') errors.push(`${at}.vision must be boolean`);
|
|
66
|
+
if (m.location !== undefined && !LOCATION.has(m.location)) errors.push(`${at}.location must be one of ${[...LOCATION].join('|')}`);
|
|
67
|
+
if (m.availability !== undefined && !AVAILABILITY.has(m.availability)) errors.push(`${at}.availability must be one of ${[...AVAILABILITY].join('|')}`);
|
|
68
|
+
if (m.local !== undefined) {
|
|
69
|
+
if (typeof m.local !== 'object' || m.local === null || Array.isArray(m.local)) { errors.push(`${at}.local must be an object`); return; }
|
|
70
|
+
if (m.local.powerWatts !== undefined && (typeof m.local.powerWatts !== 'number' || m.local.powerWatts < 0)) errors.push(`${at}.local.powerWatts must be a number >= 0`);
|
|
71
|
+
if (m.local.taskMinutes !== undefined && (typeof m.local.taskMinutes !== 'number' || m.local.taskMinutes <= 0)) errors.push(`${at}.local.taskMinutes must be a number > 0`);
|
|
72
|
+
}
|
|
73
|
+
if (m.toolUse !== undefined && !TOOL_USE.has(m.toolUse)) errors.push(`${at}.toolUse must be one of ${[...TOOL_USE].join('|')}`);
|
|
74
|
+
if (m.thinking !== undefined && typeof m.thinking !== 'string') errors.push(`${at}.thinking must be a string`);
|
|
75
|
+
if (m.scores !== undefined) {
|
|
76
|
+
if (typeof m.scores !== 'object' || m.scores === null) { errors.push(`${at}.scores must be an object`); return; }
|
|
77
|
+
for (const [sk, sv] of Object.entries(m.scores)) {
|
|
78
|
+
if (!['coding', 'reasoning', 'speed'].includes(sk)) errors.push(`${at}.scores.${sk} is not a known dimension`);
|
|
79
|
+
else if (sv !== null && (typeof sv !== 'number' || sv < 0 || sv > 100)) errors.push(`${at}.scores.${sk} must be 0-100 or null`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return errors;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateEvents(doc) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return ['events: root must be an object'];
|
|
89
|
+
if (doc.version !== 1) errors.push('events: version must be 1');
|
|
90
|
+
if (!Array.isArray(doc.events)) { errors.push('events: events must be an array'); return errors; }
|
|
91
|
+
doc.events.forEach((e, i) => {
|
|
92
|
+
const at = `events[${i}]`;
|
|
93
|
+
if (!e || typeof e !== 'object' || Array.isArray(e)) { errors.push(`${at} must be an object`); return; }
|
|
94
|
+
if (!EVENT_TYPES.has(e.type)) errors.push(`${at}.type must be one of ${[...EVENT_TYPES].join('|')}`);
|
|
95
|
+
if (typeof e.provider !== 'string' || !e.provider.trim() || typeof e.model !== 'string' || !e.model.trim()) {
|
|
96
|
+
errors.push(`${at} requires non-empty provider and model`);
|
|
97
|
+
}
|
|
98
|
+
if (e.cost !== undefined && (typeof e.cost !== 'object' || e.cost === null || Array.isArray(e.cost))) errors.push(`${at}.cost must be an object`);
|
|
99
|
+
});
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadCatalog(path) {
|
|
104
|
+
const cat = JSON.parse(readFileSync(path, 'utf8'));
|
|
105
|
+
const errors = validateCatalog(cat);
|
|
106
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
107
|
+
return cat;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function loadEvents(path) {
|
|
111
|
+
const doc = JSON.parse(readFileSync(path, 'utf8'));
|
|
112
|
+
const errors = validateEvents(doc);
|
|
113
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
114
|
+
return doc.events;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Availability list file: one "provider/model" per line. Providers seen -> models known. */
|
|
118
|
+
export function loadAvailabilityFile(path) {
|
|
119
|
+
const models = new Set(), providers = new Set();
|
|
120
|
+
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
|
121
|
+
const m = line.trim().match(/^([A-Za-z0-9_-]+)\/(\S+)$/);
|
|
122
|
+
if (m) { providers.add(m[1]); models.add(`${m[1]}/${m[2]}`); }
|
|
123
|
+
}
|
|
124
|
+
return { models, providers, source: `file:${path}` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Index events by "provider/model": effective status/cost overrides + human-readable notes. */
|
|
128
|
+
function applyEvents(events) {
|
|
129
|
+
const overrides = new Map();
|
|
130
|
+
for (const e of events ?? []) {
|
|
131
|
+
const key = `${e.provider}/${e.model}`;
|
|
132
|
+
const prev = overrides.get(key) ?? { notes: [] };
|
|
133
|
+
prev.eventType = e.type;
|
|
134
|
+
if (e.asOf) prev.asOf = e.asOf;
|
|
135
|
+
prev.notes.push(e.note ? `${e.type} (${e.asOf ?? 'n/a'}): ${e.note}` : e.type);
|
|
136
|
+
switch (e.type) {
|
|
137
|
+
case 'free-campaign-started': prev.started = true; break;
|
|
138
|
+
case 'free-campaign-ended': prev.ended = true; break;
|
|
139
|
+
case 'price-changed': if (e.cost) prev.cost = e.cost; break;
|
|
140
|
+
case 'free-quota-changed': case 'rate-limits-changed': prev.limits = e.note ?? e.type; break;
|
|
141
|
+
case 'deprecated': prev.status = 'deprecated'; break;
|
|
142
|
+
case 'removed': prev.status = 'removed'; break;
|
|
143
|
+
case 'model-added': prev.added = true; break;
|
|
144
|
+
}
|
|
145
|
+
overrides.set(key, prev);
|
|
146
|
+
}
|
|
147
|
+
return overrides;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Catalog entries with all event overrides applied — the view selection and evaluation share. */
|
|
151
|
+
export function effectiveCatalog(catalog, events = [], asOf = new Date().toISOString()) {
|
|
152
|
+
const overrides = applyEvents(events);
|
|
153
|
+
return (catalog?.models ?? []).map(m => effectiveEntry(m, overrides.get(`${m.provider}/${m.model}`), asOf));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Catalog entry with event overrides applied. Free-campaign end re-prices to postCampaignCost. */
|
|
157
|
+
function effectiveEntry(entry, ov, asOf = new Date().toISOString()) {
|
|
158
|
+
if (!entry) return null;
|
|
159
|
+
const m = structuredClone(entry);
|
|
160
|
+
// A published cutoff is a safety boundary: never plan a $0 run past it merely
|
|
161
|
+
// because no explicit end event has arrived. An observed start event cannot
|
|
162
|
+
// extend a stale deadline; update freeUntil from new evidence first.
|
|
163
|
+
if (m.status === 'free-campaign' && m.freeUntil && Date.parse(asOf) >= Date.parse(m.freeUntil)) {
|
|
164
|
+
m.status = 'active';
|
|
165
|
+
m.cost = m.postCampaignCost ?? null;
|
|
166
|
+
if (m.cost) m.cost.free = false; else m.costUnknown = true;
|
|
167
|
+
m.campaignExpired = true;
|
|
168
|
+
}
|
|
169
|
+
if (!ov) return m;
|
|
170
|
+
if (ov.status) m.status = ov.status;
|
|
171
|
+
if (ov.started) {
|
|
172
|
+
// Campaign (re)started: usable again at $0. postCampaignCost is kept for the
|
|
173
|
+
// next end; it is never applied while the campaign is live.
|
|
174
|
+
m.status = 'free-campaign';
|
|
175
|
+
m.cost = { free: true };
|
|
176
|
+
delete m.costUnknown;
|
|
177
|
+
} else if (ov.ended) {
|
|
178
|
+
m.status = 'active';
|
|
179
|
+
m.cost = ov.cost ?? m.postCampaignCost ?? null;
|
|
180
|
+
if (m.cost) m.cost.free = false; else m.costUnknown = true;
|
|
181
|
+
} else if (ov.cost) m.cost = ov.cost;
|
|
182
|
+
if (ov.limits) m.limitsNote = ov.limits;
|
|
183
|
+
if (m.campaignExpired && !ov.ended) {
|
|
184
|
+
m.status = 'active';
|
|
185
|
+
m.cost = m.postCampaignCost ?? null;
|
|
186
|
+
if (m.cost) m.cost.free = false; else m.costUnknown = true;
|
|
187
|
+
}
|
|
188
|
+
return m;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function costPoints(cost) {
|
|
192
|
+
if (!cost) return null;
|
|
193
|
+
if (cost.free === true) return 100;
|
|
194
|
+
const i = cost.usdPerMInput, o = cost.usdPerMOutput;
|
|
195
|
+
if (i == null && o == null) return null;
|
|
196
|
+
return Math.max(0, 100 - (((i ?? 0) + (o ?? 0)) / 2) * (100 / COST_FULL_PRICE));
|
|
197
|
+
}
|
|
198
|
+
const contextPoints = k => (typeof k === 'number' ? Math.min(100, (k / CONTEXT_FULL_K) * 100) : null);
|
|
199
|
+
|
|
200
|
+
/** Weighted 0-100 score for a catalog entry under a backend tier. Unknown dims are skipped. */
|
|
201
|
+
export function scoreModel(entry, tier = 'mid') {
|
|
202
|
+
const w = TIER_WEIGHTS[tier] ?? TIER_WEIGHTS.mid;
|
|
203
|
+
const points = {
|
|
204
|
+
cost: costPoints(entry?.cost),
|
|
205
|
+
coding: entry?.scores?.coding ?? null,
|
|
206
|
+
reasoning: entry?.scores?.reasoning ?? null,
|
|
207
|
+
speed: entry?.scores?.speed ?? null,
|
|
208
|
+
context: contextPoints(entry?.contextK),
|
|
209
|
+
tool: entry?.toolUse ? TOOL_POINTS[entry.toolUse] : null,
|
|
210
|
+
};
|
|
211
|
+
let sum = 0, wsum = 0;
|
|
212
|
+
const unknown = [];
|
|
213
|
+
for (const [d, pts] of Object.entries(points)) {
|
|
214
|
+
if (pts === null || pts === undefined) { unknown.push(d); continue; }
|
|
215
|
+
sum += w[d] * pts; wsum += w[d];
|
|
216
|
+
}
|
|
217
|
+
if (!wsum) return { score: null, confidence: 'low', unknown: Object.keys(points), points };
|
|
218
|
+
const confidence = (unknown.includes('cost') || unknown.includes('coding') || unknown.length >= 3) ? 'low' : 'high';
|
|
219
|
+
return { score: sum / wsum, confidence, unknown, points };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Quality-only delta (all dims except cost) between two scored point maps. Null if incomparable. */
|
|
223
|
+
function qualityDelta(curPoints, newPoints, tier) {
|
|
224
|
+
const w = TIER_WEIGHTS[tier] ?? TIER_WEIGHTS.mid;
|
|
225
|
+
let s = 0, ws = 0;
|
|
226
|
+
for (const d of ['coding', 'reasoning', 'speed', 'context', 'tool']) {
|
|
227
|
+
if (curPoints[d] == null || newPoints[d] == null) continue;
|
|
228
|
+
s += w[d] * (newPoints[d] - curPoints[d]); ws += w[d];
|
|
229
|
+
}
|
|
230
|
+
return ws ? s / ws : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const round1 = v => (v === null || v === undefined ? null : Math.round(v * 10) / 10);
|
|
234
|
+
const fmtDim = v => (v === null || v === undefined ? '?' : String(Math.round(v)));
|
|
235
|
+
|
|
236
|
+
function fmtCost(cost) {
|
|
237
|
+
if (!cost) return 'unknown';
|
|
238
|
+
if (cost.free === true) return '$0 (free)';
|
|
239
|
+
const i = cost.usdPerMInput, o = cost.usdPerMOutput;
|
|
240
|
+
if (i == null && o == null) return 'unknown';
|
|
241
|
+
return `~$${(((i ?? 0) + (o ?? 0)) / 2).toFixed(2)}/M blended`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function costImpact(cur, best) {
|
|
245
|
+
return `current ${fmtCost(cur?.cost)} -> proposed ${fmtCost(best?.cost)}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function qualityImpact(cur, best) {
|
|
249
|
+
const parts = ['coding', 'reasoning', 'speed'].map(d => {
|
|
250
|
+
const a = cur?.scores?.[d], b = best?.scores?.[d];
|
|
251
|
+
const delta = a != null && b != null ? ` (${b - a >= 0 ? '+' : ''}${Math.round(b - a)})` : '';
|
|
252
|
+
return `${d} ${fmtDim(a)}->${fmtDim(b)}${delta}`;
|
|
253
|
+
});
|
|
254
|
+
parts.push(`context ${cur?.contextK ?? '?'}K->${best?.contextK ?? '?'}K`);
|
|
255
|
+
parts.push(`toolUse ${cur?.toolUse ?? '?'}->${best?.toolUse ?? '?'}`);
|
|
256
|
+
return parts.join(', ');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Re-evaluate every bound primary backend against the catalog + events.
|
|
261
|
+
* routing loaded routing.json
|
|
262
|
+
* registry merged models.json + models.local.json ({ backends: {name: {provider, model, ...}} })
|
|
263
|
+
* agents loaded agent definitions (for affected-agent reporting)
|
|
264
|
+
* catalog loaded model catalog ({ models: [...] })
|
|
265
|
+
* events provisioning events (see EVENT_TYPES)
|
|
266
|
+
* availability optional { models:Set<"p/m">, providers:Set<p>, source } — a provider absent
|
|
267
|
+
* from `providers` means "unknown", never "missing".
|
|
268
|
+
* margin minimum score advantage for a non-forced proposal (DEFAULT_MARGIN)
|
|
269
|
+
* Returns { infoStatus, changes, decisions }. `changes` is the proposal; `decisions` is the audit trail.
|
|
270
|
+
*/
|
|
271
|
+
export function evaluateMaintenance({ routing, registry, agents = [], catalog, events = [], availability = null, margin = DEFAULT_MARGIN, asOf = new Date().toISOString() }) {
|
|
272
|
+
const overrides = applyEvents(events);
|
|
273
|
+
const effectiveModels = effectiveCatalog(catalog, events, asOf);
|
|
274
|
+
const byKey = new Map((catalog?.models ?? []).map(m => [`${m.provider}/${m.model}`, m]));
|
|
275
|
+
const addedKeys = new Set((events ?? []).filter(e => e.type === 'model-added').map(e => `${e.provider}/${e.model}`));
|
|
276
|
+
const warnings = [];
|
|
277
|
+
for (const k of addedKeys) if (!byKey.has(k)) warnings.push(`model-added event for ${k} but no catalog entry; it cannot be scored`);
|
|
278
|
+
|
|
279
|
+
const capsByBackend = {}, agentsByCap = {};
|
|
280
|
+
for (const [name, c] of Object.entries(routing.capabilities ?? {})) (capsByBackend[c.primary] ??= []).push(name);
|
|
281
|
+
for (const a of agents) (agentsByCap[a.meta?.capability] ??= []).push(a.meta?.name);
|
|
282
|
+
|
|
283
|
+
const modelAvailable = m => {
|
|
284
|
+
if (!availability) return true;
|
|
285
|
+
if (!availability.providers?.has(m.provider)) return true; // provider unseen -> unknown, keep
|
|
286
|
+
return availability.models.has(`${m.provider}/${m.model}`);
|
|
287
|
+
};
|
|
288
|
+
const currentMissing = b => Boolean(availability && availability.providers?.has(b.provider) && !availability.models.has(`${b.provider}/${b.model}`));
|
|
289
|
+
|
|
290
|
+
const decisions = [], changes = [];
|
|
291
|
+
for (const [backend, capNames] of Object.entries(capsByBackend)) {
|
|
292
|
+
const tier = routing.backends[backend]?.tier ?? 'mid';
|
|
293
|
+
const sortedCaps = [...capNames].sort();
|
|
294
|
+
const affectedAgents = [...new Set(sortedCaps.flatMap(c => agentsByCap[c] ?? []))].sort();
|
|
295
|
+
const base = { backend, tier, capabilities: sortedCaps, agents: affectedAgents };
|
|
296
|
+
const binding = registry?.backends?.[backend];
|
|
297
|
+
if (!binding || isPlaceholder(binding)) {
|
|
298
|
+
decisions.push({ ...base, decision: 'skip', reason: 'no bound model (unbound or placeholder)' });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const key = `${binding.provider}/${binding.model}`;
|
|
302
|
+
const entry = byKey.get(key);
|
|
303
|
+
const ov = overrides.get(key);
|
|
304
|
+
const eff = effectiveEntry(entry, ov, asOf);
|
|
305
|
+
const missing = currentMissing(binding);
|
|
306
|
+
const status = eff?.status ?? 'unknown';
|
|
307
|
+
const current = { provider: binding.provider, model: binding.model, ...(binding.thinking ? { thinking: binding.thinking } : {}) };
|
|
308
|
+
|
|
309
|
+
const triggered = Boolean(ov) || eff?.campaignExpired || addedKeys.size > 0 || status === 'deprecated' || status === 'removed' || missing;
|
|
310
|
+
if (!triggered) {
|
|
311
|
+
decisions.push({ ...base, decision: 'keep', current, reason: 'no triggering event; catalog status healthy' });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const reasons = [];
|
|
316
|
+
if (ov) reasons.push(...ov.notes);
|
|
317
|
+
if (eff?.campaignExpired) reasons.push(`free campaign expired at ${eff.freeUntil}`);
|
|
318
|
+
if (status === 'deprecated' && ov?.eventType !== 'deprecated') reasons.push('catalog status: deprecated');
|
|
319
|
+
if (status === 'removed' && ov?.eventType !== 'removed') reasons.push('catalog status: removed');
|
|
320
|
+
if (missing) reasons.push(`provider "${binding.provider}" is present in the availability listing but ${key} is not`);
|
|
321
|
+
if (addedKeys.size) reasons.push(`new model(s) announced: ${[...addedKeys].join(', ')}`);
|
|
322
|
+
|
|
323
|
+
const requiresVision = sortedCaps.some(c => routing.capabilities[c]?.requires?.vision === true);
|
|
324
|
+
const pool = effectiveModels.filter(m =>
|
|
325
|
+
`${m.provider}/${m.model}` !== key &&
|
|
326
|
+
USABLE.has(m.status) &&
|
|
327
|
+
(!requiresVision || m.vision === true) &&
|
|
328
|
+
m.toolUse !== 'none' &&
|
|
329
|
+
modelAvailable(m));
|
|
330
|
+
const curScore = scoreModel(eff, tier);
|
|
331
|
+
const scored = pool
|
|
332
|
+
.map(m => ({ entry: m, ...scoreModel(m, tier) }))
|
|
333
|
+
.filter(s => s.score !== null)
|
|
334
|
+
.sort((a, b) => b.score - a.score);
|
|
335
|
+
const best = scored[0];
|
|
336
|
+
const detail = {
|
|
337
|
+
...base, current, currentStatus: status, trigger: reasons,
|
|
338
|
+
currentScore: { score: round1(curScore.score), confidence: curScore.confidence, unknown: curScore.unknown },
|
|
339
|
+
topCandidates: scored.slice(0, 3).map(s => ({ model: `${s.entry.provider}/${s.entry.model}`, score: round1(s.score), confidence: s.confidence })),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
if (!best) {
|
|
343
|
+
decisions.push({ ...detail, decision: 'insufficient-data', reason: 'no eligible candidate in the catalog (all filtered by usability, vision/tool requirements or availability)' });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const forced = status === 'removed' || status === 'deprecated' || missing;
|
|
348
|
+
const delta = curScore.score === null ? null : best.score - curScore.score;
|
|
349
|
+
const curCostUnknown = eff?.costUnknown === true || curScore.unknown.includes('cost');
|
|
350
|
+
const bestIsFree = best.entry.cost?.free === true || ((best.entry.cost?.usdPerMInput ?? 0) === 0 && (best.entry.cost?.usdPerMOutput ?? 0) === 0);
|
|
351
|
+
const qDelta = curScore.points && best.points ? qualityDelta(curScore.points, best.points, tier) : null;
|
|
352
|
+
|
|
353
|
+
let decision, reason;
|
|
354
|
+
if (forced) {
|
|
355
|
+
decision = 'propose';
|
|
356
|
+
reason = `current model is ${status !== 'unknown' ? status : 'unavailable'}; migration required — proposing best eligible candidate regardless of margin`;
|
|
357
|
+
} else if (curScore.score === null) {
|
|
358
|
+
decision = 'insufficient-data';
|
|
359
|
+
reason = 'current model has no usable catalog data; refusing to compare on speculation';
|
|
360
|
+
} else if (delta !== null && delta >= margin && best.confidence !== 'low') {
|
|
361
|
+
decision = 'propose';
|
|
362
|
+
reason = `best candidate scores +${delta.toFixed(1)} over current (margin ${margin}) under tier "${tier}" weights`;
|
|
363
|
+
} else if (curCostUnknown && bestIsFree && (qDelta === null || qDelta >= -margin)) {
|
|
364
|
+
decision = 'propose';
|
|
365
|
+
reason = 'post-event cost of the current model is unknown and a free candidate with comparable quality exists';
|
|
366
|
+
} else {
|
|
367
|
+
decision = 'keep';
|
|
368
|
+
reason = delta !== null && delta < margin
|
|
369
|
+
? `best candidate advantage ${delta.toFixed(1)} is below margin ${margin}; keeping current model`
|
|
370
|
+
: 'best candidate has low-confidence catalog data; keeping current model';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const proposedModel = {
|
|
374
|
+
provider: best.entry.provider, model: best.entry.model,
|
|
375
|
+
...(best.entry.thinking ?? binding.thinking ? { thinking: best.entry.thinking ?? binding.thinking } : {}),
|
|
376
|
+
...(best.entry.vision ? { vision: true } : {}),
|
|
377
|
+
};
|
|
378
|
+
decisions.push({ ...detail, decision, reason, proposed: decision === 'propose' ? proposedModel : undefined });
|
|
379
|
+
|
|
380
|
+
if (decision === 'propose') {
|
|
381
|
+
changes.push({
|
|
382
|
+
changeReason: reasons.join('; '),
|
|
383
|
+
affected: { backend, capabilities: sortedCaps, agents: affectedAgents },
|
|
384
|
+
currentModel: current,
|
|
385
|
+
proposedModel,
|
|
386
|
+
expectedCostImpact: costImpact(eff, best.entry),
|
|
387
|
+
expectedQualityImpact: qualityImpact(eff, best.entry),
|
|
388
|
+
scores: { current: round1(curScore.score), proposed: round1(best.score), delta: round1(delta), margin },
|
|
389
|
+
confidence: curScore.confidence === 'high' && best.confidence === 'high' ? 'high' : 'low',
|
|
390
|
+
rollback: {
|
|
391
|
+
method: 'manual-edit',
|
|
392
|
+
previousBinding: binding,
|
|
393
|
+
steps: [
|
|
394
|
+
`edit the adapter models.local.json: set backends.${backend} to the embedded previousBinding`,
|
|
395
|
+
're-run scripts/resolve-capabilities.mjs to regenerate settings.proposal.json',
|
|
396
|
+
'if agent overrides were merged into live settings.json, re-merge the regenerated proposal',
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Bound backends that are only fallbacks: report, do not evaluate.
|
|
404
|
+
for (const [backend, binding] of Object.entries(registry?.backends ?? {})) {
|
|
405
|
+
if (capsByBackend[backend] || !(backend in (routing.backends ?? {}))) continue;
|
|
406
|
+
if (isPlaceholder(binding)) continue;
|
|
407
|
+
decisions.push({ backend, tier: routing.backends[backend]?.tier ?? null, capabilities: [], agents: [], decision: 'not-primary', current: { provider: binding.provider, model: binding.model }, reason: 'bound only as a fallback; primary re-evaluation covers routing changes' });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
version: 1,
|
|
412
|
+
kind: 'model-maintenance',
|
|
413
|
+
infoStatus: {
|
|
414
|
+
catalogUpdatedAt: catalog?.updatedAt ?? null,
|
|
415
|
+
eventsApplied: (events ?? []).map(e => `${e.type}:${e.provider}/${e.model}`),
|
|
416
|
+
availability: availability?.source ?? 'not-checked (catalog status only)',
|
|
417
|
+
warnings,
|
|
418
|
+
},
|
|
419
|
+
changes,
|
|
420
|
+
decisions,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Normalize loosely-formed scout output into a Context Pack v1: parse -> normalize -> validate -> serialize.
|
|
2
|
+
// Path policy: relevant_files / snippet paths must be repo-relative. Absolute paths inside the repo are
|
|
3
|
+
// rewritten; absolute paths outside the repo are NOT silently rewritten — they are moved to
|
|
4
|
+
// `external_paths` in the returned report and dropped from relevant_files (constraints gains a note).
|
|
5
|
+
import { resolve, relative, isAbsolute, sep } from 'node:path';
|
|
6
|
+
import { parseContextPackMarkdown, validateContextPack, toMarkdown, REQUIRED_FIELDS } from './context-pack.mjs';
|
|
7
|
+
|
|
8
|
+
const WIN_ABS = /^(?:[A-Za-z]:[\\/]|\\\\)/;
|
|
9
|
+
const ABS_PATH_IN_TEXT = /(?:[A-Za-z]:\\(?:[^\s:*?"<>|\\]+\\)*[^\s:*?"<>|\\]+|[A-Za-z]:\/(?:[^\s:*?"<>|/]+\/)*[^\s:*?"<>|/]+)/g;
|
|
10
|
+
|
|
11
|
+
export function toPosix(p) { return p.replace(/\\/g, '/'); }
|
|
12
|
+
|
|
13
|
+
/** Classify a path against repoRoot. Returns {kind:'relative'|'inside'|'outside', path}. */
|
|
14
|
+
export function classifyPath(p, repoRoot) {
|
|
15
|
+
const raw = p.trim().replace(/^`|`$/g, '');
|
|
16
|
+
if (!isAbsolute(raw) && !WIN_ABS.test(raw)) return { kind: 'relative', path: toPosix(raw).replace(/^\.\//, '') };
|
|
17
|
+
const root = resolve(repoRoot);
|
|
18
|
+
const abs = resolve(raw);
|
|
19
|
+
const rel = relative(root, abs);
|
|
20
|
+
if (!rel || (!rel.startsWith('..') && !isAbsolute(rel))) return { kind: 'inside', path: toPosix(rel) };
|
|
21
|
+
return { kind: 'outside', path: toPosix(abs) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Rewrite in-repo absolute paths inside free text (errors, attempts) to repo-relative; leave others. */
|
|
25
|
+
export function relativizeText(text, repoRoot) {
|
|
26
|
+
const external = new Set();
|
|
27
|
+
const out = text.replace(ABS_PATH_IN_TEXT, m => {
|
|
28
|
+
const c = classifyPath(m, repoRoot);
|
|
29
|
+
if (c.kind === 'inside') return c.path;
|
|
30
|
+
if (c.kind === 'outside') external.add(c.path);
|
|
31
|
+
return m;
|
|
32
|
+
});
|
|
33
|
+
return { text: out, external: [...external] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fillDefaults(pack, { task, capability, producedBy } = {}) {
|
|
37
|
+
const p = { version: 1, ...pack };
|
|
38
|
+
if (task && !p.task) p.task = task;
|
|
39
|
+
if (!p.goal && p.task) p.goal = p.task;
|
|
40
|
+
if (!Array.isArray(p.constraints)) p.constraints = [];
|
|
41
|
+
if (!Array.isArray(p.relevant_files)) p.relevant_files = [];
|
|
42
|
+
if (!p.expected_output) p.expected_output = 'A minimal diff limited to relevant_files plus the output of test_commands.';
|
|
43
|
+
if (capability) p.capability = capability;
|
|
44
|
+
if (producedBy) p.produced_by = producedBy;
|
|
45
|
+
return p;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalize a pack object (or scout markdown) for repoRoot.
|
|
50
|
+
* Returns { pack, markdown, errors, report: { rewritten, external, dropped } }.
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeContextPack(input, { repoRoot, task, capability, producedBy } = {}) {
|
|
53
|
+
if (!repoRoot) throw new Error('normalize: repoRoot is required');
|
|
54
|
+
let pack = typeof input === 'string' ? parseContextPackMarkdown(input, { lenient: true }) : structuredClone(input);
|
|
55
|
+
pack = fillDefaults(pack, { task, capability, producedBy });
|
|
56
|
+
const report = { rewritten: [], external: [], dropped: [] };
|
|
57
|
+
|
|
58
|
+
const fixList = (list, field) => {
|
|
59
|
+
const kept = [];
|
|
60
|
+
for (const item of list ?? []) {
|
|
61
|
+
const c = classifyPath(item.path ?? '', repoRoot);
|
|
62
|
+
if (c.kind === 'outside') { report.external.push(c.path); report.dropped.push({ field, path: c.path }); continue; }
|
|
63
|
+
if (c.kind === 'inside') report.rewritten.push({ from: item.path, to: c.path });
|
|
64
|
+
kept.push({ ...item, path: c.path });
|
|
65
|
+
}
|
|
66
|
+
return kept;
|
|
67
|
+
};
|
|
68
|
+
pack.relevant_files = dedupeFiles(fixList(pack.relevant_files, 'relevant_files'));
|
|
69
|
+
if (pack.relevant_snippets) pack.relevant_snippets = fixList(pack.relevant_snippets, 'relevant_snippets');
|
|
70
|
+
|
|
71
|
+
for (const field of ['observed_errors', 'repo_rules', 'constraints', 'test_commands']) {
|
|
72
|
+
if (!Array.isArray(pack[field])) continue;
|
|
73
|
+
pack[field] = pack[field].map(t => { const r = relativizeText(String(t), repoRoot); report.external.push(...r.external); return r.text; });
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(pack.previous_attempts)) {
|
|
76
|
+
pack.previous_attempts = pack.previous_attempts.map(a => {
|
|
77
|
+
const s = relativizeText(a.summary ?? '', repoRoot), o = relativizeText(a.outcome ?? '', repoRoot);
|
|
78
|
+
report.external.push(...s.external, ...o.external);
|
|
79
|
+
return { ...a, summary: s.text, ...(a.outcome !== undefined ? { outcome: o.text } : {}) };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
report.external = [...new Set(report.external)];
|
|
83
|
+
if (report.dropped.length) pack.constraints.push(`unknown: ${report.dropped.length} path(s) outside the repository were excluded from relevant_files (see normalizer report)`);
|
|
84
|
+
|
|
85
|
+
const errors = validateContextPack(pack);
|
|
86
|
+
const markdown = errors.length ? null : toMarkdown(pack);
|
|
87
|
+
return { pack, markdown, errors, report };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dedupeFiles(files) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
for (const f of files) {
|
|
93
|
+
const prev = seen.get(f.path);
|
|
94
|
+
if (!prev) { seen.set(f.path, { ...f }); continue; }
|
|
95
|
+
if (!prev.lines && f.lines) prev.lines = f.lines;
|
|
96
|
+
if (!prev.reason && f.reason) prev.reason = f.reason;
|
|
97
|
+
}
|
|
98
|
+
return [...seen.values()];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { REQUIRED_FIELDS };
|