@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,283 @@
1
+ // Phase 4: observation layer — schema, dedupe/freshness, trust resolution,
2
+ // catalog diff/proposal, and hypothetical-catalog maintenance preview.
3
+ // Cases A–J. Nothing here writes outside a temp dir; real catalog/routing untouched.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { resolve, dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { validateObservation, normalizeObservation, observationHash, ingestObservations, loadObservationStore } from '../lib/observe/observation.mjs';
11
+ import { fromManual, fromPiCli, fromFixture } from '../lib/observe/sources.mjs';
12
+ import { resolveObservations, diffCatalog, buildCatalogProposal, applyProposalToCatalog } from '../lib/observe/differ.mjs';
13
+ import { runMaintenancePlan, selectTierModel, DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
14
+ import { evaluateMaintenance, effectiveCatalog, loadCatalog } from '../lib/maintenance.mjs';
15
+ import { loadRouting } from '../lib/routing.mjs';
16
+ import { loadAgents } from '../lib/agents.mjs';
17
+ import { mergeRegistries } from '../lib/registry.mjs';
18
+
19
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
21
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
22
+ const tmp = () => mkdtempSync(join(tmpdir(), 'observe-'));
23
+
24
+ const CAT = {
25
+ version: 1, updatedAt: '2026-03-01',
26
+ models: [
27
+ { provider: 'qoder', model: 'Qwen3.8-Flash', status: 'free-campaign', cost: { free: true }, postCampaignCost: { usdPerMInput: 0.3, usdPerMOutput: 1.2 }, contextK: 256, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 62, reasoning: 55, speed: 85 } },
28
+ { provider: 'cloudp', model: 'cheap-ok', status: 'active', cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, contextK: 256, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 68, reasoning: 62, speed: 75 } },
29
+ { provider: 'cloudp', model: 'pro-strong', status: 'active', cost: { usdPerMInput: 5, usdPerMOutput: 20 }, contextK: 400, vision: false, toolUse: 'good', location: 'cloud', scores: { coding: 90, reasoning: 88, speed: 60 } },
30
+ ],
31
+ };
32
+ const obs = (provider, model, changes, over = {}) => normalizeObservation({
33
+ provider, model, observedAt: '2026-03-05T00:00:00Z',
34
+ source: { type: 'manual', trust: 'manual_verified', label: 'test' },
35
+ changes, confidence: 0.9, ...over,
36
+ });
37
+
38
+ // --- A: same price re-observed -> dedupe + unchanged -------------------------
39
+ test('A: re-observing the same price dedupes and diffs unchanged', () => {
40
+ const dir = tmp();
41
+ const store = join(dir, 'obs.jsonl');
42
+ const o = obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.2 });
43
+ const r1 = ingestObservations(store, [o]);
44
+ const r2 = ingestObservations(store, [o]);
45
+ assert.equal(r1.stored.length, 1);
46
+ assert.equal(r2.duplicates.length, 1);
47
+ assert.equal(r2.stored.length, 0);
48
+ const diff = diffCatalog(CAT, loadObservationStore(store).observations);
49
+ const d = diff.diffs.find(x => x.field === 'inputPricePer1M');
50
+ assert.equal(d.status, 'unchanged');
51
+ rmSync(dir, { recursive: true, force: true });
52
+ });
53
+
54
+ // --- B: free campaign end -> proposed update + preview -----------------------
55
+ test('B: free-campaign end observation -> proposed update, preview runs on hypothetical catalog', () => {
56
+ const dir = tmp();
57
+ const store = join(dir, 'obs.jsonl');
58
+ ingestObservations(store, [obs('qoder', 'Qwen3.8-Flash', { free: false, inputPricePer1M: 0.3, outputPricePer1M: 1.2 })]);
59
+ const observations = loadObservationStore(store).observations;
60
+ const diff = diffCatalog(CAT, observations);
61
+ const proposal = buildCatalogProposal(CAT, diff, observations);
62
+ assert.ok(proposal.updates.some(u => u.model === 'qoder/Qwen3.8-Flash' && u.field === 'cost.free' && u.observedValue === false));
63
+ const hypothetical = applyProposalToCatalog(CAT, proposal);
64
+ assert.equal(hypothetical.models.find(m => m.model === 'Qwen3.8-Flash').cost.free, false);
65
+ const registry = mergeRegistries(null, { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' } } });
66
+ const run = runMaintenancePlan({ routing, registry, agents, catalog: hypothetical, events: [], policy: DEFAULT_POLICY });
67
+ assert.ok(['proposal', 'evaluated-no-change', 'no-change'].includes(run.outcome));
68
+ assert.equal(CAT.models.find(m => m.model === 'Qwen3.8-Flash').cost.free, true); // real catalog untouched
69
+ rmSync(dir, { recursive: true, force: true });
70
+ });
71
+
72
+ // --- C: price drop -> proposed, preview shows selection may change -----------
73
+ test('C: price drop observation -> proposed update; preview reflects cheaper model', () => {
74
+ const dir = tmp();
75
+ const store = join(dir, 'obs.jsonl');
76
+ ingestObservations(store, [obs('cloudp', 'pro-strong', { inputPricePer1M: 0.1, outputPricePer1M: 0.4 })]);
77
+ const observations = loadObservationStore(store).observations;
78
+ const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, observations), observations);
79
+ assert.ok(proposal.updates.some(u => u.field === 'cost.usdPerMInput' && u.observedValue === 0.1));
80
+ const hypothetical = applyProposalToCatalog(CAT, proposal);
81
+ const sel = runMaintenancePlan({ routing, registry: mergeRegistries(null, { version: 1, backends: {} }), agents, catalog: hypothetical, events: [], policy: DEFAULT_POLICY });
82
+ // pro-strong now costs ~$0.2/run blended — cheapest-sufficient for evaluate tier
83
+ const evalTier = sel.tiers.find(t => t.role === 'monitor');
84
+ assert.ok(evalTier);
85
+ rmSync(dir, { recursive: true, force: true });
86
+ });
87
+
88
+ // --- D: absent from pi listing -> NOT removed --------------------------------
89
+ test('D: model absent from pi listing -> availability observation only, never removed', () => {
90
+ const listing = { models: new Set(['cloudp/cheap-ok']), providers: new Set(['cloudp', 'qoder']) };
91
+ const observations = fromPiCli(listing, { catalog: CAT, observedAt: '2026-03-05T00:00:00Z' });
92
+ const qoder = observations.find(o => o.model === 'Qwen3.8-Flash');
93
+ assert.equal(qoder.changes.availability, 'unavailable');
94
+ const diff = diffCatalog(CAT, observations);
95
+ const proposal = buildCatalogProposal(CAT, diff, observations);
96
+ assert.equal(proposal.deprecations.length, 0);
97
+ assert.ok(proposal.ignored.some(i => i.field === 'availability' && /not removal/.test(i.reason)));
98
+ });
99
+
100
+ // --- E: high-trust deprecated -> deprecation proposal -------------------------
101
+ test('E: provider_api deprecated observation -> deprecation proposal', () => {
102
+ const o = obs('cloudp', 'cheap-ok', { status: 'deprecated' }, { source: { type: 'api', trust: 'provider_api', label: 'provider status endpoint' } });
103
+ const diff = diffCatalog(CAT, [o]);
104
+ const proposal = buildCatalogProposal(CAT, diff, [o]);
105
+ assert.equal(proposal.deprecations.length, 1);
106
+ assert.equal(proposal.deprecations[0].observedValue, 'deprecated');
107
+ });
108
+
109
+ // --- F: low vs high trust conflict -------------------------------------------
110
+ test('F: conflicting sources — high trust wins; equal-trust major conflict is recorded', () => {
111
+ const low = obs('cloudp', 'cheap-ok', { inputPricePer1M: 9.9 }, { source: { type: 'web', trust: 'third_party' }, confidence: 0.4 });
112
+ const high = obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.25 }, { source: { type: 'api', trust: 'provider_api' }, confidence: 0.9 });
113
+ const resolved = resolveObservations([low, high]);
114
+ const r = resolved.get('cloudp/cheap-ok::inputPricePer1M');
115
+ assert.equal(r.winner.value, 0.25); // same timestamp -> trust decides
116
+ assert.equal(r.conflict, null);
117
+ // equal trust + equal timestamp + different major value -> conflict
118
+ const a = obs('cloudp', 'cheap-ok', { status: 'deprecated' }, { source: { type: 'api', trust: 'provider_api', label: 'a' } });
119
+ const b = obs('cloudp', 'cheap-ok', { status: 'active' }, { source: { type: 'api', trust: 'provider_api', label: 'b' } });
120
+ const r2 = resolveObservations([a, b]).get('cloudp/cheap-ok::status');
121
+ assert.ok(r2.conflict);
122
+ const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, [a, b]), [a, b]);
123
+ assert.equal(proposal.conflicts.length, 1);
124
+ assert.equal(proposal.updates.filter(u => u.field === 'status').length, 0);
125
+ });
126
+
127
+ // --- G: stale observation does not overwrite newer ----------------------------
128
+ test('G: older observation is stale and never wins resolution', () => {
129
+ const dir = tmp();
130
+ const store = join(dir, 'obs.jsonl');
131
+ ingestObservations(store, [obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.5 }, { observedAt: '2026-03-10T00:00:00Z' })]);
132
+ const r = ingestObservations(store, [obs('cloudp', 'cheap-ok', { inputPricePer1M: 0.1 }, { observedAt: '2026-03-01T00:00:00Z' })]);
133
+ assert.equal(r.stale.length, 1);
134
+ const resolved = resolveObservations(loadObservationStore(store).observations);
135
+ assert.equal(resolved.get('cloudp/cheap-ok::inputPricePer1M').winner.value, 0.5);
136
+ rmSync(dir, { recursive: true, force: true });
137
+ });
138
+
139
+ // --- H: duplicate re-ingest ignored ------------------------------------------
140
+ test('H: identical observation re-ingested -> duplicate', () => {
141
+ const dir = tmp();
142
+ const store = join(dir, 'obs.jsonl');
143
+ const o = obs('cloudp', 'cheap-ok', { contextK: 512 });
144
+ ingestObservations(store, [o]);
145
+ const r = ingestObservations(store, [o]);
146
+ assert.equal(r.duplicates.length, 1);
147
+ assert.equal(loadObservationStore(store).observations.length, 1);
148
+ rmSync(dir, { recursive: true, force: true });
149
+ });
150
+
151
+ // --- I: new model -> addition proposal, no auto-adoption ----------------------
152
+ test('I: unknown model observed active -> addition proposal; routing unchanged', () => {
153
+ const o = obs('newp', 'Brand-New-1', { status: 'active', contextK: 512 }, { source: { type: 'api', trust: 'provider_api' } });
154
+ const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, [o]), [o]);
155
+ assert.equal(proposal.additions.length, 1);
156
+ assert.equal(proposal.additions[0].model, 'newp/Brand-New-1');
157
+ const hypothetical = applyProposalToCatalog(CAT, proposal);
158
+ assert.ok(hypothetical.models.some(m => m.model === 'Brand-New-1'));
159
+ assert.equal(CAT.models.length, 3); // real catalog untouched
160
+ assert.equal(routing.capabilities['cheap-code'].primary, 'qoder'); // routing untouched
161
+ });
162
+
163
+ // --- J: preview on hypothetical catalog leaves real files unchanged -----------
164
+ test('J: maintenance preview on hypothetical catalog; real catalog/routing unchanged', () => {
165
+ const o = obs('qoder', 'Qwen3.8-Flash', { status: 'removed' }, { source: { type: 'api', trust: 'provider_api' } });
166
+ const proposal = buildCatalogProposal(CAT, diffCatalog(CAT, [o]), [o]);
167
+ const hypothetical = applyProposalToCatalog(CAT, proposal);
168
+ assert.equal(hypothetical._hypothetical, true);
169
+ const registry = mergeRegistries(null, { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' } } });
170
+ const run = runMaintenancePlan({ routing, registry, agents, catalog: hypothetical, events: [], policy: DEFAULT_POLICY });
171
+ const cheap = run.proposal?.decisions.find(d => d.backend === 'qoder');
172
+ assert.ok(cheap && cheap.decision === 'propose'); // forced migration previewed
173
+ // real objects unchanged
174
+ assert.equal(CAT.models.find(m => m.model === 'Qwen3.8-Flash').status, 'free-campaign');
175
+ assert.ok(!('_hypothetical' in CAT));
176
+ });
177
+
178
+ // --- K: Qoder free-campaign expiry end-to-end (observe -> diff -> proposal ->
179
+ // hypothetical catalog -> maintenance reevaluation -> routing decision).
180
+ // Nothing real is written: the shipped catalog and routing stay untouched. --
181
+ test('K: Qwen3.8-Flash free-campaign expiry is observed, proposed and re-evaluated without applying anything', () => {
182
+ const dir = tmp();
183
+ const store = join(dir, 'obs.jsonl');
184
+ const realCatalog = loadCatalog(join(kit, 'adapters/pi/model-catalog.json'));
185
+ const qwen = () => realCatalog.models.find(m => m.provider === 'qoder' && m.model === 'Qwen3.8-Flash');
186
+ assert.equal(qwen().status, 'free-campaign');
187
+ assert.equal(qwen().cost.free, true);
188
+
189
+ // before: free cloud wins the monitor-tier selection
190
+ const before = selectTierModel(effectiveCatalog(realCatalog, [], '2026-09-25T00:00:00Z'), 'monitor', DEFAULT_POLICY);
191
+ assert.equal(before.selectionPath, 'free-cloud');
192
+ assert.equal(before.selected.model, 'qoder/Qwen3.8-Flash');
193
+
194
+ // observation: campaign ended
195
+ const o = obs('qoder', 'Qwen3.8-Flash', { free: false }, { source: { type: 'web', trust: 'provider_web', label: 'qoder pricing page' } });
196
+ ingestObservations(store, [o]);
197
+ const observations = loadObservationStore(store).observations;
198
+ const diff = diffCatalog(realCatalog, observations);
199
+ const d = diff.diffs.find(x => x.model === 'qoder/Qwen3.8-Flash' && x.field === 'free');
200
+ assert.equal(d.status, 'proposed');
201
+ assert.equal(d.currentValue, true);
202
+ assert.equal(d.observedValue, false);
203
+
204
+ const proposal = buildCatalogProposal(realCatalog, diff, observations);
205
+ assert.ok(proposal.updates.some(u => u.model === 'qoder/Qwen3.8-Flash' && u.field === 'cost.free' && u.observedValue === false));
206
+
207
+ // hypothetical catalog: paid now; postCampaignCost is null so the entry becomes costUnknown
208
+ const hypothetical = applyProposalToCatalog(realCatalog, proposal);
209
+ const hq = hypothetical.models.find(m => m.provider === 'qoder' && m.model === 'Qwen3.8-Flash');
210
+ assert.equal(hq.cost.free, false);
211
+ assert.equal(hypothetical._hypothetical, true);
212
+
213
+ // maintenance preview on the hypothetical catalog: cheapest-sufficient re-evaluated.
214
+ // The registry binds cheap->qoder/Qwen3.8-Flash; the event-equivalent is the proposal.
215
+ const registry = mergeRegistries(null, { version: 1, backends: {
216
+ local: { provider: 'freetoken', model: 'Qwen3.6-35B-A3B-NVFP4', thinking: 'off' },
217
+ cheap: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
218
+ qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' },
219
+ devin: { provider: 'devin', model: 'swe-2-high', thinking: 'high' },
220
+ sol: { provider: 'openai-codex', model: 'gpt-5.6-sol', thinking: 'medium' },
221
+ astra: { provider: 'openai-codex', model: 'gpt-6-astra', thinking: 'medium', vision: true },
222
+ codex: { provider: 'openai-codex', model: 'gpt-5.5', thinking: 'high' },
223
+ } });
224
+ const events = [{ type: 'free-campaign-ended', provider: 'qoder', model: 'Qwen3.8-Flash', asOf: '2026-09-24' }];
225
+ const run = runMaintenancePlan({ routing, registry, agents, catalog: realCatalog, events, policy: DEFAULT_POLICY, asOf: '2026-09-25T00:00:00Z' });
226
+ assert.equal(run.monitor.changed, true);
227
+ // the qoder backend (primary of cheap-code) was re-evaluated against the post-event cost
228
+ const qoderDecision = run.proposal.decisions.find(x => x.backend === 'qoder');
229
+ assert.ok(qoderDecision, 'qoder backend must appear in maintenance decisions');
230
+ assert.ok(['keep', 'propose', 'insufficient-data'].includes(qoderDecision.decision));
231
+ // Qoder's offer ended; the separately free Devin campaign may still win.
232
+ const after = selectTierModel(effectiveCatalog(realCatalog, events, '2026-09-25T00:00:00Z'), 'monitor', DEFAULT_POLICY);
233
+ assert.notEqual(after.selected.model, 'qoder/Qwen3.8-Flash');
234
+ assert.equal(after.selected.model, 'devin/swe-2-high');
235
+ // devin stays the strong-code/deep-review primary (its own evaluation is independent)
236
+ const devinDecision = run.proposal.decisions.find(x => x.backend === 'devin');
237
+ assert.ok(!devinDecision || devinDecision.decision !== 'propose' || devinDecision.proposed?.provider !== undefined);
238
+
239
+ // nothing applied: shipped catalog + routing untouched
240
+ assert.equal(qwen().cost.free, true);
241
+ assert.equal(qwen().status, 'free-campaign');
242
+ assert.ok(!('_hypothetical' in realCatalog));
243
+ assert.equal(routing.capabilities['cheap-code'].primary, 'qoder');
244
+ rmSync(dir, { recursive: true, force: true });
245
+ });
246
+
247
+ // --- schema + sources ---------------------------------------------------------
248
+ test('observation validation: null vs false/0 are distinct, unknown fields rejected', () => {
249
+ assert.deepEqual(validateObservation(obs('p', 'm', { free: false, inputPricePer1M: 0 })), []);
250
+ assert.ok(validateObservation({ provider: 'p', model: 'm', observedAt: 'x', source: { type: 'manual' }, changes: { free: 'yes' } }).some(e => e.includes('boolean')));
251
+ assert.ok(validateObservation({ provider: 'p', model: 'm', observedAt: '2026-01-01T00:00:00Z', source: { type: 'manual' }, changes: { bogusField: 1 } }).some(e => e.includes('bogusField')));
252
+ const n = normalizeObservation({ provider: 'p', model: 'm', source: { type: 'manual' }, changes: { free: false } });
253
+ assert.equal(n.changes.free, false);
254
+ assert.equal(n.changes.contextK, null); // unobserved stays null, not 0/false
255
+ });
256
+
257
+ test('fixture source replays announcements deterministically', () => {
258
+ const doc = { announcements: [
259
+ { provider: 'qoder', model: 'Qwen3.8-Flash', type: 'free-campaign-ended', asOf: '2026-03-01' },
260
+ { provider: 'cloudp', model: 'cheap-ok', type: 'price-changed', inputPricePer1M: 0.15, outputPricePer1M: 0.6, asOf: '2026-03-02' },
261
+ { provider: 'newp', model: 'X-1', type: 'model-added', contextK: 512, asOf: '2026-03-03' },
262
+ ] };
263
+ const out = fromFixture(doc);
264
+ assert.equal(out.length, 3);
265
+ assert.equal(out[0].changes.free, false);
266
+ assert.equal(out[1].changes.inputPricePer1M, 0.15);
267
+ assert.equal(out[2].changes.status, 'active');
268
+ assert.equal(out[0].source.trust, 'provider_web');
269
+ });
270
+
271
+ test('manual source defaults trust to manual_verified', () => {
272
+ const out = fromManual({ observations: [{ provider: 'p', model: 'm', observedAt: '2026-01-01T00:00:00Z', changes: { contextK: 128 } }] });
273
+ assert.equal(out[0].source.trust, 'manual_verified');
274
+ assert.equal(out[0].source.type, 'manual');
275
+ });
276
+
277
+ test('observationHash is deterministic and field-sensitive', () => {
278
+ const a = obs('p', 'm', { contextK: 128 });
279
+ const b = obs('p', 'm', { contextK: 128 });
280
+ const c = obs('p', 'm', { contextK: 256 });
281
+ assert.equal(observationHash(a), observationHash(b));
282
+ assert.notEqual(observationHash(a), observationHash(c));
283
+ });
@@ -0,0 +1,157 @@
1
+ // Observer registry: resolution, ordering, dedupe, failure isolation, unknown ids,
2
+ // and parity between the standalone observe path and the scheduled job.
3
+ // Nothing real is written — temp outDirs only; catalog/routing/settings untouched.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { resolve, dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { OBSERVERS, OBSERVER_FLAG_MAP, resolveRequestedObservers, runObservers } from '../lib/observe/observers.mjs';
11
+ import { loadObservationStore } from '../lib/observe/observation.mjs';
12
+ import { fromQoderCache } from '../lib/observe/sources.mjs';
13
+ import { runMaintenanceJob } from '../lib/job.mjs';
14
+ import { DEFAULT_POLICY } from '../lib/maintenance-exec.mjs';
15
+ import { loadRouting } from '../lib/routing.mjs';
16
+ import { loadAgents } from '../lib/agents.mjs';
17
+ import { mergeRegistries } from '../lib/registry.mjs';
18
+
19
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
21
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
22
+ const tmp = () => mkdtempSync(join(tmpdir(), 'obsv-'));
23
+
24
+ const CAT = {
25
+ version: 1, updatedAt: '2026-03-01',
26
+ models: [
27
+ { provider: 'qoder', model: 'Qwen3.8-Flash', status: 'free-campaign', cost: { free: true }, contextK: 1024, toolUse: 'good', location: 'cloud', scores: { coding: 62, reasoning: 55, speed: 85 } },
28
+ { provider: 'cloudp', model: 'cheap-ok', status: 'active', cost: { usdPerMInput: 0.2, usdPerMOutput: 0.8 }, contextK: 256, toolUse: 'good', location: 'cloud', scores: { coding: 68, reasoning: 62, speed: 75 } },
29
+ ],
30
+ };
31
+ const REG = mergeRegistries(null, { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' }, cheap: { provider: 'cloudp', model: 'cheap-ok' } } });
32
+ const PI_LISTING = { models: new Set(['qoder/Qwen3.8-Flash', 'cloudp/cheap-ok']), providers: new Set(['qoder', 'cloudp']), source: 'test' };
33
+ const qoderCache = pf => ({ version: 2, updatedAt: Date.now(), models: [{ id: 'Qwen3.8-Flash', priceFactor: pf, provider: 'qoder' }] });
34
+ const qoderResult = pf => fromQoderCache(qoderCache(pf), { models: [{ provider: 'qoder', model: 'Qwen3.8-Flash' }], observedAt: new Date().toISOString() });
35
+ const jobBase = dir => ({ outDir: dir, adapterDir: join(kit, 'adapters/pi'), kit, catalog: CAT, routing, registry: REG, agents, policy: DEFAULT_POLICY });
36
+
37
+ // --- A: pi-cli only -----------------------------------------------------------
38
+ test('A: only --check-pi resolves to and runs only the pi-cli observer', async () => {
39
+ const { ids, unknown } = resolveRequestedObservers({ checkPi: true });
40
+ assert.deepEqual(ids, ['pi-cli']);
41
+ assert.deepEqual(unknown, []);
42
+ const dir = tmp();
43
+ const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: PI_LISTING });
44
+ assert.deepEqual(run.observers.map(o => o.id), ['pi-cli']);
45
+ const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
46
+ assert.ok(store.every(o => o.source?.trust === 'pi_cli'));
47
+ rmSync(dir, { recursive: true, force: true });
48
+ });
49
+
50
+ // --- B: qoder-cache only --------------------------------------------------------
51
+ test('B: only --check-qoder resolves to and runs only the qoder-cache observer', async () => {
52
+ const { ids } = resolveRequestedObservers({ checkQoder: true });
53
+ assert.deepEqual(ids, ['qoder-cache']);
54
+ const dir = tmp();
55
+ const run = await runMaintenanceJob({ ...jobBase(dir), checkQoder: true, qoderListing: qoderResult(0) });
56
+ assert.deepEqual(run.observers.map(o => o.id), ['qoder-cache']);
57
+ const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
58
+ assert.ok(store.every(o => o.source?.trust === 'provider_local_cache'));
59
+ rmSync(dir, { recursive: true, force: true });
60
+ });
61
+
62
+ // --- C: both run once each ------------------------------------------------------
63
+ test('C: --check-pi + --check-qoder run both observers exactly once, in order', async () => {
64
+ const { ids } = resolveRequestedObservers({ checkPi: true, checkQoder: true });
65
+ assert.deepEqual(ids, ['pi-cli', 'qoder-cache']);
66
+ const dir = tmp();
67
+ const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: PI_LISTING, checkQoder: true, qoderListing: qoderResult(0) });
68
+ assert.deepEqual(run.observers.map(o => o.id), ['pi-cli', 'qoder-cache']);
69
+ assert.equal(run.observers.filter(o => o.id === 'pi-cli').length, 1);
70
+ assert.equal(run.observers.filter(o => o.id === 'qoder-cache').length, 1);
71
+ const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
72
+ assert.ok(store.some(o => o.changes?.availability === 'available'));
73
+ assert.ok(store.some(o => o.changes?.free === true));
74
+ rmSync(dir, { recursive: true, force: true });
75
+ });
76
+
77
+ // --- D: duplicate observer deduped ------------------------------------------------
78
+ test('D: the same observer via flag + generic list runs once', async () => {
79
+ const { ids } = resolveRequestedObservers({ checkPi: true, check: 'pi-cli,qoder-cache' });
80
+ assert.deepEqual(ids, ['pi-cli', 'qoder-cache']); // pi-cli not duplicated
81
+ const dir = tmp();
82
+ const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, check: 'pi-cli,qoder-cache', listing: PI_LISTING, qoderListing: qoderResult(0) });
83
+ assert.equal(run.observers.filter(o => o.id === 'pi-cli').length, 1);
84
+ rmSync(dir, { recursive: true, force: true });
85
+ });
86
+
87
+ // --- E: pi ok + qoder fail -> pi kept, job continues ------------------------------
88
+ test('E: pi-cli success + qoder-cache failure -> pi observations kept, job continues', async () => {
89
+ const dir = tmp();
90
+ const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: PI_LISTING, checkQoder: true, qoderListing: false });
91
+ assert.equal(run.status, 'ok');
92
+ assert.equal(run.ingest.probeFailed, true);
93
+ const qoderRes = run.observers.find(o => o.id === 'qoder-cache');
94
+ assert.equal(qoderRes.probeFailed, true);
95
+ const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
96
+ assert.ok(store.some(o => o.changes?.availability === 'available'), 'pi observations kept');
97
+ rmSync(dir, { recursive: true, force: true });
98
+ });
99
+
100
+ // --- F: qoder ok + pi fail -> qoder kept, job continues ---------------------------
101
+ test('F: qoder-cache success + pi-cli failure -> qoder observations kept, job continues', async () => {
102
+ const dir = tmp();
103
+ const run = await runMaintenanceJob({ ...jobBase(dir), checkPi: true, listing: false, checkQoder: true, qoderListing: qoderResult(0) });
104
+ assert.equal(run.status, 'ok');
105
+ assert.equal(run.ingest.probeFailed, true);
106
+ const piRes = run.observers.find(o => o.id === 'pi-cli');
107
+ assert.equal(piRes.probeFailed, true);
108
+ const store = loadObservationStore(join(dir, 'model-observations.jsonl')).observations;
109
+ assert.ok(store.some(o => o.changes?.free === true), 'qoder observations kept');
110
+ rmSync(dir, { recursive: true, force: true });
111
+ });
112
+
113
+ // --- G: unknown observer id -> explicit failure -----------------------------------
114
+ test('G: unknown observer id -> structured failure, never silent', async () => {
115
+ const { ids, unknown } = resolveRequestedObservers({ check: 'pi-cli,bogus-observer' });
116
+ assert.deepEqual(ids, ['pi-cli']);
117
+ assert.deepEqual(unknown, ['bogus-observer']);
118
+ const dir = tmp();
119
+ const run = await runMaintenanceJob({ ...jobBase(dir), check: 'pi-cli,bogus-observer', listing: PI_LISTING });
120
+ assert.equal(run.status, 'error');
121
+ assert.match(run.error, /unknown observer/);
122
+ rmSync(dir, { recursive: true, force: true });
123
+ });
124
+
125
+ // --- H: deterministic ordering ----------------------------------------------------
126
+ test('H: observer execution order follows the requested order deterministically', async () => {
127
+ // requested order is honoured, not registry order
128
+ const { ids } = resolveRequestedObservers({ check: 'qoder-cache,pi-cli' });
129
+ assert.deepEqual(ids, ['qoder-cache', 'pi-cli']);
130
+ const dir = tmp();
131
+ const run = await runMaintenanceJob({ ...jobBase(dir), check: 'qoder-cache,pi-cli', listing: PI_LISTING, qoderListing: qoderResult(0) });
132
+ assert.deepEqual(run.observers.map(o => o.id), ['qoder-cache', 'pi-cli']);
133
+ rmSync(dir, { recursive: true, force: true });
134
+ });
135
+
136
+ // --- I: standalone CLI and job resolve identically --------------------------------
137
+ test('I: standalone observe options and job options resolve to the same observer set', () => {
138
+ // simulate the CLI's option mapping (has('check-pi') -> checkPi:true, --check -> check)
139
+ const cliOptions = { checkPi: true, checkQoder: true, check: null };
140
+ const jobOptions = { checkPi: true, checkQoder: true, check: null };
141
+ assert.deepEqual(resolveRequestedObservers(cliOptions), resolveRequestedObservers(jobOptions));
142
+ // and the generic form maps to the same ids
143
+ assert.deepEqual(resolveRequestedObservers({ check: 'pi-cli,qoder-cache' }).ids, ['pi-cli', 'qoder-cache']);
144
+ });
145
+
146
+ // --- registry shape ---------------------------------------------------------------
147
+ test('registry: every observer exposes the common interface', () => {
148
+ for (const [id, o] of Object.entries(OBSERVERS)) {
149
+ assert.equal(o.id, id);
150
+ assert.equal(typeof o.run, 'function');
151
+ assert.equal(typeof o.describe, 'string');
152
+ }
153
+ assert.ok(OBSERVERS['pi-cli']);
154
+ assert.ok(OBSERVERS['qoder-cache']);
155
+ assert.equal(OBSERVER_FLAG_MAP.checkPi, 'pi-cli');
156
+ assert.equal(OBSERVER_FLAG_MAP.checkQoder, 'qoder-cache');
157
+ });