@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
package/lib/job.mjs
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Phase 5 — maintenance job orchestrator: observe -> dedupe -> catalog diff ->
|
|
2
|
+
// meaningful-change gate -> proposal -> maintenance preview -> (optional live tiers)
|
|
3
|
+
// -> notification decision -> sinks. Quiet by default: only human-worthy changes
|
|
4
|
+
// produce a notification. Reuses Phase 1-4 modules; nothing is duplicated, and
|
|
5
|
+
// nothing outside out/ is ever written.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, openSync, closeSync, unlinkSync, statSync } from 'node:fs';
|
|
8
|
+
import { join, dirname, resolve } from 'node:path';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { loadObservationStore, ingestObservations, productionObservations } from './observe/observation.mjs';
|
|
11
|
+
import { SOURCES } from './observe/sources.mjs';
|
|
12
|
+
import { resolveRequestedObservers, runObservers } from './observe/observers.mjs';
|
|
13
|
+
import { diffCatalog, buildCatalogProposal, applyProposalToCatalog, proposalToEvents } from './observe/differ.mjs';
|
|
14
|
+
import { runMaintenancePlan, loadExecPolicy, DEFAULT_POLICY } from './maintenance-exec.mjs';
|
|
15
|
+
import { runMaintenanceLive } from './maintenance-runner.mjs';
|
|
16
|
+
import { persistRun } from './telemetry.mjs';
|
|
17
|
+
|
|
18
|
+
const sha = obj => createHash('sha256').update(JSON.stringify(obj)).digest('hex').slice(0, 16);
|
|
19
|
+
const SEVERITY_RANK = { none: 0, info: 1, action: 2, urgent: 3 };
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Lock — exclusive lock file, stale after TTL. Atomic artifacts via tmp+rename.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export function acquireLock(lockPath, { staleMs = 30 * 60 * 1000, now = Date.now() } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const fd = openSync(lockPath, 'wx');
|
|
28
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: new Date(now).toISOString() }));
|
|
29
|
+
closeSync(fd);
|
|
30
|
+
return { acquired: true, path: lockPath };
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (e.code !== 'EEXIST') throw e;
|
|
33
|
+
try {
|
|
34
|
+
const stat = statSync(lockPath);
|
|
35
|
+
if (now - stat.mtimeMs > staleMs) { unlinkSync(lockPath); return acquireLock(lockPath, { staleMs, now }); }
|
|
36
|
+
} catch { /* raced: treat as held */ }
|
|
37
|
+
return { acquired: false, path: lockPath };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function releaseLock(lock) { if (lock?.acquired) try { unlinkSync(lock.path); } catch { /* already gone */ } }
|
|
41
|
+
|
|
42
|
+
export function atomicWriteJson(path, obj) {
|
|
43
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
44
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
45
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
|
|
46
|
+
renameSync(tmp, path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Run state — notification dedupe across runs. Same payload hash is never resent;
|
|
51
|
+
// a severity increase re-notifies.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export function loadRunState(path) {
|
|
55
|
+
if (!existsSync(path)) return { version: 1 };
|
|
56
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return { version: 1, _corrupt: true }; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function shouldNotify(state, notification) {
|
|
60
|
+
if (!notification) return { notify: false, reason: 'no notification' };
|
|
61
|
+
const hash = notificationHash(notification);
|
|
62
|
+
if (state.lastNotificationHash === hash) {
|
|
63
|
+
if (SEVERITY_RANK[notification.severity] > SEVERITY_RANK[state.lastSeverity ?? 'none']) {
|
|
64
|
+
return { notify: true, reason: `severity rose ${state.lastSeverity} -> ${notification.severity}`, hash };
|
|
65
|
+
}
|
|
66
|
+
return { notify: false, reason: 'identical notification already sent', hash };
|
|
67
|
+
}
|
|
68
|
+
return { notify: true, reason: 'new notification content', hash };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Content identity excludes severity so a severity rise on the same facts re-notifies.
|
|
72
|
+
export function notificationHash(n) {
|
|
73
|
+
return sha({ title: n.title, changes: n.changes, routingImpact: n.routingImpact });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Meaningful-change gate + severity classification (deterministic authority)
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const QUIET_REASONS = [
|
|
81
|
+
'no observations', 'duplicates only', 'stale only', 'catalog unchanged',
|
|
82
|
+
'no routing impact', 'availability probe failed only', 'unknown observations only',
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
export function classifyRun({ ingest, diff = { diffs: [] }, proposal = {}, preview, probeFailed }) {
|
|
86
|
+
const proposed = (diff.diffs ?? []).filter(d => d.status === 'proposed');
|
|
87
|
+
const conflicts = (diff.diffs ?? []).filter(d => d.status === 'conflict');
|
|
88
|
+
const boundModels = new Set(); // filled by caller via preview decisions
|
|
89
|
+
const changes = preview?.proposal?.changes ?? [];
|
|
90
|
+
const routingImpact = changes.map(c => ({ backend: c.affected.backend, capabilities: c.affected.capabilities, agents: c.affected.agents, currentModel: `${c.currentModel.provider}/${c.currentModel.model}`, proposedModel: `${c.proposedModel.provider}/${c.proposedModel.model}` }));
|
|
91
|
+
|
|
92
|
+
// --- urgency signals -----------------------------------------------------
|
|
93
|
+
const currentRemoved = (proposal.deprecations ?? []).filter(d => d.observedValue === 'removed' || d.observedValue === 'deprecated');
|
|
94
|
+
// insufficient-data is urgent only when the CURRENT model is unusable (removed/
|
|
95
|
+
// deprecated/missing) — a healthy backend with no better candidate is not an emergency.
|
|
96
|
+
const insufficient = (preview?.proposal?.decisions ?? []).filter(d =>
|
|
97
|
+
d.decision === 'insufficient-data' && (d.currentStatus === 'removed' || d.currentStatus === 'deprecated' || d.currentStatus === 'unknown'));
|
|
98
|
+
const localFallback = (preview?.tiers ?? []).some(t => t.selectionPath === 'local-fallback');
|
|
99
|
+
const multiCap = new Set(changes.flatMap(c => c.affected?.capabilities ?? [])).size >= 2;
|
|
100
|
+
const allFailed = (preview?.invocations ?? []).some(i => i.degradedToDeterministic && i.invocations.length > 0 && i.invocations.every(x => !x.ok));
|
|
101
|
+
|
|
102
|
+
// --- decision ------------------------------------------------------------
|
|
103
|
+
// Quiet gate: fresh ingest matters, but the accumulated store still drives the diff.
|
|
104
|
+
// 'no observations'/'duplicates only'/'stale only' apply only when the diff itself is empty.
|
|
105
|
+
const stored = ingest?.stored?.length ?? 0, dups = ingest?.duplicates?.length ?? 0, staleN = ingest?.stale?.length ?? 0;
|
|
106
|
+
const diffEmpty = proposed.length === 0 && conflicts.length === 0 && !(proposal.additions?.length) && !(proposal.deprecations?.length);
|
|
107
|
+
const onlyQuietSignals = diffEmpty
|
|
108
|
+
? (stored === 0 && dups > 0 ? 'duplicates only' : stored === 0 && staleN > 0 ? 'stale only' : stored === 0 && !probeFailed ? 'no observations' : 'catalog unchanged')
|
|
109
|
+
: null;
|
|
110
|
+
|
|
111
|
+
if (onlyQuietSignals && !probeFailed) return { meaningful: false, quietReason: onlyQuietSignals, severity: 'none' };
|
|
112
|
+
if (probeFailed && proposed.length === 0) return { meaningful: false, quietReason: 'availability probe failed only', severity: 'none' };
|
|
113
|
+
|
|
114
|
+
let severity = 'info';
|
|
115
|
+
const reasons = [];
|
|
116
|
+
if (proposal.additions?.length) reasons.push(`${proposal.additions.length} new model(s) observed`);
|
|
117
|
+
if (proposed.length) reasons.push(`${proposed.length} catalog field change(s) proposed`);
|
|
118
|
+
if (conflicts.length) reasons.push(`${conflicts.length} unresolved conflict(s)`);
|
|
119
|
+
if (routingImpact.length) { severity = 'action'; reasons.push(`${routingImpact.length} backend binding change(s) in preview`); }
|
|
120
|
+
if (currentRemoved.length) { severity = 'urgent'; reasons.push(`current model ${currentRemoved.map(d => `${d.model} -> ${d.observedValue}`).join(', ')}`); }
|
|
121
|
+
if (insufficient.length) { severity = 'urgent'; reasons.push(`no eligible candidate for ${insufficient.map(d => d.backend).join(', ')}`); }
|
|
122
|
+
if (localFallback) { severity = 'urgent'; reasons.push('selection fell back to a local model'); }
|
|
123
|
+
if (allFailed) { severity = SEVERITY_RANK[severity] < 2 ? 'action' : severity; reasons.push('all candidates for a tier failed'); }
|
|
124
|
+
if (multiCap) { severity = 'urgent'; reasons.push('changes span multiple capabilities'); }
|
|
125
|
+
|
|
126
|
+
if (!proposed.length && !conflicts.length && !routingImpact.length && !(proposal.additions?.length) && !(proposal.deprecations?.length)) {
|
|
127
|
+
return { meaningful: false, quietReason: 'no routing impact', severity: 'none' };
|
|
128
|
+
}
|
|
129
|
+
return { meaningful: true, severity, reasons, routingImpact };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Notification payload + sinks
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
export function buildNotification({ severity, reasons, proposal, preview, runId, proposalPath, previewPath, budgetLimited }) {
|
|
137
|
+
const changes = [
|
|
138
|
+
...(proposal.updates ?? []).map(u => `${u.model}: ${u.field} ${JSON.stringify(u.currentValue)} -> ${JSON.stringify(u.observedValue)}`),
|
|
139
|
+
...(proposal.additions ?? []).map(a => `${a.model}: new model observed`),
|
|
140
|
+
...(proposal.deprecations ?? []).map(d => `${d.model}: ${d.currentValue} -> ${d.observedValue}`),
|
|
141
|
+
...(proposal.conflicts ?? []).map(c => `${c.model}: conflict on ${c.field}`),
|
|
142
|
+
];
|
|
143
|
+
const routingImpact = (preview?.proposal?.changes ?? []).map(c => `${c.affected.backend}: ${c.currentModel.provider}/${c.currentModel.model} -> ${c.proposedModel.provider}/${c.proposedModel.model} (${c.affected.agents.join(',')})`);
|
|
144
|
+
const title = severity === 'urgent' ? 'Model maintenance: urgent action needed'
|
|
145
|
+
: severity === 'action' ? 'Model maintenance: action recommended' : 'Model maintenance: info';
|
|
146
|
+
const recommendedNextStep = routingImpact.length
|
|
147
|
+
? `Review ${proposalPath} and preview; if accepted, edit models.local.json per proposedModel and re-run resolve-capabilities.mjs`
|
|
148
|
+
: proposal.deprecations?.length || proposal.updates?.length
|
|
149
|
+
? `Review ${proposalPath}; apply catalog edits manually if accepted`
|
|
150
|
+
: `Review ${proposalPath}; no routing impact expected`;
|
|
151
|
+
return {
|
|
152
|
+
severity, title,
|
|
153
|
+
summary: reasons.join('; ') || 'no significant change',
|
|
154
|
+
changes, routingImpact, recommendedNextStep,
|
|
155
|
+
requiresApproval: true,
|
|
156
|
+
budgetLimited: budgetLimited === true,
|
|
157
|
+
proposalPath, previewPath, runId, observedAt: new Date().toISOString(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const sinks = {
|
|
162
|
+
stdout: n => { console.log(`[${n.severity}] ${n.title}: ${n.summary}`); return { sink: 'stdout', ok: true }; },
|
|
163
|
+
file: (n, { path }) => { atomicWriteJson(path, n); return { sink: 'file', ok: true, path }; },
|
|
164
|
+
command: (n, { command }) => {
|
|
165
|
+
const r = spawnSync(command, { input: JSON.stringify(n), encoding: 'utf8', shell: true, timeout: 30000, windowsHide: true });
|
|
166
|
+
return { sink: 'command', ok: r.status === 0, exitCode: r.status, error: r.error?.message ?? (r.status !== 0 ? (r.stderr || '').slice(-300) : null) };
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Job
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One scheduled maintenance run. Options mirror the CLI. `invoke` (optional) makes
|
|
176
|
+
* the run live; `tierAllowed` is the budget gate. Returns the run record; writes
|
|
177
|
+
* only under outDir (gitignored). Never touches catalog/routing/settings/~/.pi.
|
|
178
|
+
*/
|
|
179
|
+
export async function runMaintenanceJob({
|
|
180
|
+
outDir, adapterDir, kit, catalog,
|
|
181
|
+
source = null, input = null, checkPi = false, listing = null,
|
|
182
|
+
checkQoder = false, qoderCachePath = null, qoderListing = undefined,
|
|
183
|
+
check = null, observers = null,
|
|
184
|
+
preview = true, live = false, invoke = null, notifyCommand = null,
|
|
185
|
+
policy = DEFAULT_POLICY, routing, registry, agents,
|
|
186
|
+
now = () => new Date().toISOString(), lockStaleMs,
|
|
187
|
+
shadow = false, shadowNotify = false,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const runId = `run-${Date.now()}-${process.pid}`;
|
|
190
|
+
const lockPath = join(outDir, 'model-maintenance.lock');
|
|
191
|
+
mkdirSync(outDir, { recursive: true });
|
|
192
|
+
const lock = acquireLock(lockPath, { staleMs: lockStaleMs });
|
|
193
|
+
if (!lock.acquired) return { runId, status: 'skipped-locked', quiet: true };
|
|
194
|
+
|
|
195
|
+
const statePath = join(outDir, 'model-maintenance.state.json');
|
|
196
|
+
const state = loadRunState(statePath);
|
|
197
|
+
const run = { runId, startedAt: now(), status: 'running', quiet: true, artifacts: {} };
|
|
198
|
+
const log = [];
|
|
199
|
+
try {
|
|
200
|
+
// 1. observe — an explicit --input file, plus any requested live observers.
|
|
201
|
+
// Observer selection/execution is registry-driven (lib/observe/observers.mjs):
|
|
202
|
+
// --check-pi/--check-qoder/--check <list> resolve to observer ids and run once
|
|
203
|
+
// each, in order. One observer's failure is isolated — it sets probeFailed and
|
|
204
|
+
// records per-source metadata, never stops the others.
|
|
205
|
+
let fresh = [], probeFailed = false;
|
|
206
|
+
if (input) {
|
|
207
|
+
const fn = SOURCES[source ?? 'manual'];
|
|
208
|
+
if (!fn) throw new Error(`unknown source "${source}"`);
|
|
209
|
+
fresh = fn(resolve(input));
|
|
210
|
+
}
|
|
211
|
+
const requested = resolveRequestedObservers({ checkPi, checkQoder, check, observers });
|
|
212
|
+
if (requested.unknown.length) {
|
|
213
|
+
run.status = 'error';
|
|
214
|
+
run.error = `unknown observer id(s): ${requested.unknown.join(', ')}`;
|
|
215
|
+
throw new Error(run.error);
|
|
216
|
+
}
|
|
217
|
+
if (requested.ids.length) {
|
|
218
|
+
const r = await runObservers(requested.ids, { catalog, outDir, observedAt: now(), listing, qoderListing, qoderCachePath });
|
|
219
|
+
fresh.push(...r.observations);
|
|
220
|
+
if (r.probeFailed) probeFailed = true;
|
|
221
|
+
run.observers = r.results; // per-source audit: which observer ran/failed/produced what
|
|
222
|
+
for (const res of r.results) if (res.probeFailed) log.push(`${res.id}: ${res.metadata?.reason ?? 'probe failed'} -> unknown, not absent`);
|
|
223
|
+
}
|
|
224
|
+
const storePath = join(outDir, 'model-observations.jsonl');
|
|
225
|
+
const ingest = fresh.length ? ingestObservations(storePath, fresh, { now }) : { stored: [], duplicates: [], stale: [], invalid: [] };
|
|
226
|
+
run.ingest = { fresh: fresh.length, stored: ingest.stored.length, duplicates: ingest.duplicates.length, stale: ingest.stale.length, invalid: ingest.invalid.length, probeFailed };
|
|
227
|
+
|
|
228
|
+
// 2. diff + proposal — production observations only; test/fixture records are
|
|
229
|
+
// stored for audit but never drive a real catalog/routing proposal.
|
|
230
|
+
const store = loadObservationStore(storePath);
|
|
231
|
+
const prodObs = productionObservations(store.observations);
|
|
232
|
+
run.excludedTestObservations = store.observations.length - prodObs.length;
|
|
233
|
+
const diff = diffCatalog(catalog, prodObs);
|
|
234
|
+
const proposal = buildCatalogProposal(catalog, diff, prodObs);
|
|
235
|
+
const diffPath = join(outDir, 'catalog-diff.json');
|
|
236
|
+
const proposalPath = join(outDir, 'model-catalog.proposal.json');
|
|
237
|
+
atomicWriteJson(diffPath, diff);
|
|
238
|
+
atomicWriteJson(proposalPath, proposal);
|
|
239
|
+
run.artifacts.diff = diffPath; run.artifacts.proposal = proposalPath;
|
|
240
|
+
|
|
241
|
+
// 3. preview on hypothetical catalog
|
|
242
|
+
let previewResult = null, previewPath = null;
|
|
243
|
+
if (preview) {
|
|
244
|
+
const hypothetical = applyProposalToCatalog(catalog, proposal);
|
|
245
|
+
const planOpts = { routing, registry, agents, catalog: hypothetical, events: proposalToEvents(proposal), policy };
|
|
246
|
+
if (live && invoke) {
|
|
247
|
+
const budget = policy.budget ?? DEFAULT_POLICY.budget;
|
|
248
|
+
let invocations = 0, premium = 0, spent = 0;
|
|
249
|
+
const tierAllowed = tier => {
|
|
250
|
+
const sel = previewResult?.tiers?.find(t => t.role === tier);
|
|
251
|
+
const est = sel?.selected?.effectiveCostUsd ?? 0;
|
|
252
|
+
if (invocations >= budget.maxTotalInvocationsPerRun) return false;
|
|
253
|
+
if (tier === 'reconfigure' && premium >= budget.maxPremiumInvocationsPerRun) return false;
|
|
254
|
+
if (spent + est > budget.maxEstimatedCostPerRunUsd) return false;
|
|
255
|
+
return true;
|
|
256
|
+
};
|
|
257
|
+
previewResult = await runMaintenanceLive({ ...planOpts, invoke, tierAllowed });
|
|
258
|
+
run.budgetLimited = previewResult.invocations?.some(i => i.skippedByBudget) === true;
|
|
259
|
+
} else {
|
|
260
|
+
previewResult = runMaintenancePlan(planOpts);
|
|
261
|
+
}
|
|
262
|
+
previewPath = join(outDir, 'maintenance-preview.json');
|
|
263
|
+
atomicWriteJson(previewPath, { ...previewResult, hypothetical: true, note: 'proposal applied in memory only' });
|
|
264
|
+
run.artifacts.preview = previewPath;
|
|
265
|
+
// Embed the preview's decision layer so telemetry/reporting can aggregate it.
|
|
266
|
+
run.tiers = previewResult.tiers;
|
|
267
|
+
run.invocations = previewResult.invocations;
|
|
268
|
+
run.escalation = previewResult.escalation;
|
|
269
|
+
run.proposal = previewResult.proposal ? { changes: previewResult.proposal.changes, decisions: previewResult.proposal.decisions } : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 4. meaningful-change gate + severity
|
|
273
|
+
const verdict = classifyRun({ ingest, diff, proposal, preview: previewResult, probeFailed });
|
|
274
|
+
run.verdict = verdict;
|
|
275
|
+
run.quiet = !verdict.meaningful;
|
|
276
|
+
|
|
277
|
+
// 5. notification (dedupe via state)
|
|
278
|
+
if (verdict.meaningful) {
|
|
279
|
+
const notification = buildNotification({
|
|
280
|
+
severity: verdict.severity, reasons: verdict.reasons, proposal, preview: previewResult,
|
|
281
|
+
runId, proposalPath, previewPath, budgetLimited: run.budgetLimited,
|
|
282
|
+
});
|
|
283
|
+
const decision = shouldNotify(state, notification);
|
|
284
|
+
run.notification = { ...notification, sent: decision.notify, dedupeReason: decision.reason };
|
|
285
|
+
if (decision.notify) {
|
|
286
|
+
run.notification.sinks = [sinks.stdout(notification), sinks.file(notification, { path: join(outDir, 'model-maintenance.notification.json') })];
|
|
287
|
+
run.artifacts.notification = join(outDir, 'model-maintenance.notification.json');
|
|
288
|
+
// Shadow mode: external sinks are opt-in via shadowNotify only.
|
|
289
|
+
if (notifyCommand && (!shadow || shadowNotify)) run.notification.sinks.push(sinks.command(notification, { command: notifyCommand }));
|
|
290
|
+
else if (notifyCommand && shadow) run.notification.shadowSuppressed = true;
|
|
291
|
+
}
|
|
292
|
+
state.lastNotificationHash = decision.hash;
|
|
293
|
+
state.lastSeverity = notification.severity;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
state.lastSuccessfulRunAt = now();
|
|
297
|
+
state.lastProposalHash = sha(proposal);
|
|
298
|
+
state.lastKnownRoutingImpact = verdict.routingImpact ?? [];
|
|
299
|
+
atomicWriteJson(statePath, state);
|
|
300
|
+
run.status = 'ok';
|
|
301
|
+
} catch (e) {
|
|
302
|
+
run.status = 'error';
|
|
303
|
+
run.error = e.message;
|
|
304
|
+
} finally {
|
|
305
|
+
run.completedAt = now();
|
|
306
|
+
run.shadow = shadow === true;
|
|
307
|
+
atomicWriteJson(join(outDir, 'model-maintenance.lastrun.json'), run);
|
|
308
|
+
try { persistRun(outDir, run); } catch { /* telemetry must never break the job */ }
|
|
309
|
+
releaseLock(lock);
|
|
310
|
+
}
|
|
311
|
+
return run;
|
|
312
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Shared language policy appended to every agent's system prompt. One place, no
|
|
2
|
+
// per-agent duplication, no provider-specific handling — it applies identically to
|
|
3
|
+
// qoder/devin/openai-codex/freetoken because it is prepended at the prompt layer.
|
|
4
|
+
//
|
|
5
|
+
// Japanese is the DEFAULT, not a fixed mandate: an explicit user language request
|
|
6
|
+
// ("英語で", "in English", ...) wins. Machine-readable schema keys/enums
|
|
7
|
+
// (status/completed/failed/blocked, Context Pack ## field names, JSON keys) are
|
|
8
|
+
// never translated — only natural-language values are.
|
|
9
|
+
|
|
10
|
+
export const LANGUAGE_POLICY = `
|
|
11
|
+
## Response language
|
|
12
|
+
|
|
13
|
+
Default response language: Japanese.
|
|
14
|
+
|
|
15
|
+
Unless the user explicitly requests another language:
|
|
16
|
+
- Write all user-facing explanations, summaries, reports, decisions, and status messages in Japanese.
|
|
17
|
+
- Keep source code, identifiers, filenames, commands, stack traces, API names, and quoted external text in their original language where appropriate.
|
|
18
|
+
- Technical terms may remain in English when that is clearer, but explanations around them should be Japanese.
|
|
19
|
+
- Structured field names defined by schemas (e.g. "status", "summary", "completed", Context Pack "## task" / "## goal" section names) must not be translated — only their natural-language values are written in Japanese.
|
|
20
|
+
- NEVER translate, rename, or annotate section headings, JSON keys, enum values, or field names. A heading is exactly "## task", never "## 課題" or "## task Japanese". When in doubt, keep the exact ASCII identifier.
|
|
21
|
+
`.trim();
|
|
22
|
+
|
|
23
|
+
/** Agent system prompt with the shared language policy appended. */
|
|
24
|
+
export function withLanguagePolicy(body) {
|
|
25
|
+
const b = String(body ?? '').trim();
|
|
26
|
+
return b ? `${b}\n\n${LANGUAGE_POLICY}` : LANGUAGE_POLICY;
|
|
27
|
+
}
|