@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,3 @@
1
+ # fixture-math-repo
2
+ Deliberately broken fixture for the ludi-agent-kit pipeline E2E. `average()` divides by `length + 1`.
3
+ Do not fix in place; the E2E copies this directory to a temp location before running.
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "fixture-math-repo",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": { "test": "node --test test/math.test.js" }
6
+ }
7
+
@@ -0,0 +1,11 @@
1
+ // Tiny fixture for math helpers; average() divides by length + 1 on purpose.
2
+ export function sum(values) {
3
+ let total = 0;
4
+ for (const v of values) total += v;
5
+ return total;
6
+ }
7
+
8
+ export function average(values) {
9
+ if (values.length === 0) return 0;
10
+ return sum(values) / (values.length + 1);
11
+ }
@@ -0,0 +1,7 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { sum, average } from '../src/math.js';
4
+
5
+ test('sum adds numbers', () => assert.equal(sum([1, 2, 3]), 6));
6
+ test('average of [2, 4] is 3', () => assert.equal(average([2, 4]), 3));
7
+ test('average of empty list is 0', () => assert.equal(average([]), 0));
@@ -0,0 +1,8 @@
1
+ {
2
+ "$comment": "Fixture: provider announcements for the observation layer (Phase 4). Fictional ids.",
3
+ "announcements": [
4
+ { "provider": "qoder", "model": "Qwen3.8-Flash", "type": "free-campaign-ended", "asOf": "2026-03-01T00:00:00Z", "label": "Qoder pricing page", "url": "https://example.invalid/qoder-pricing" },
5
+ { "provider": "openai-codex", "model": "gpt-5.6-luna", "type": "price-changed", "inputPricePer1M": 0.35, "outputPricePer1M": 1.4, "asOf": "2026-03-02T00:00:00Z", "label": "provider price list", "trust": "provider_api" },
6
+ { "provider": "newp", "model": "Nova-1", "type": "model-added", "contextK": 512, "vision": true, "asOf": "2026-03-03T00:00:00Z", "label": "provider changelog" }
7
+ ]
8
+ }
@@ -0,0 +1,44 @@
1
+ // Separate process for the concurrent-writer E2E. Not part of node --test.
2
+ // node tests/fixtures/orch-concurrent-child.mjs <db> <label> <tasks> <delayMs> [throwAt]
3
+ // Runs `tasks` sequential scout tasks against the shared store, each taking `delayMs`
4
+ // so two processes overlap their writes. With `throwAt` the project-store hook throws
5
+ // after that task (an exception the main loop does NOT swallow) to exercise
6
+ // exception-safe termination.
7
+ import { resolve, dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { loadRouting } from '../../lib/routing.mjs';
10
+ import { loadAgents } from '../../lib/agents.mjs';
11
+ import { DEFAULT_POLICY } from '../../lib/orchestrator/policy.mjs';
12
+ import { openStore } from '../../lib/orchestrator/store.mjs';
13
+ import { orchestrate } from '../../lib/orchestrator/orchestrator.mjs';
14
+
15
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
16
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
17
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
18
+ const REG = { version: 1, backends: {
19
+ local: { provider: 'pl', model: 'ml', thinking: 'off' },
20
+ cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
21
+ sol: { provider: 'ps', model: 'ms', thinking: 'high' },
22
+ codex: { provider: 'px', model: 'mx', thinking: 'high' },
23
+ } };
24
+ const [storePath, label, tasksArg, delayArg, throwAt] = process.argv.slice(2);
25
+ const n = Number(tasksArg ?? 3), delayMs = Number(delayArg ?? 50);
26
+ const plan = Array.from({ length: n }, (_, i) => ({ id: `t${i + 1}`, title: `${label} ${i + 1}`, goal: `g${i + 1}`, agent: 'scout', dependencies: i ? [`t${i}`] : [], acceptance: ['done'] }));
27
+ const completed = task => ({ ok: true, structured: true, result: { status: 'completed', summary: `${label} did ${task.id}`, artifacts: [], verification: [], acceptance: [{ id: 'A1', met: true, evidence: 'observed' }], remainingIssues: [], decisions: [], newTasks: [] } });
28
+
29
+ const session = openStore(storePath);
30
+ let exitCode = 0;
31
+ try {
32
+ const result = await orchestrate({
33
+ request: `${label} request`, plan, agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
34
+ runner: { async run(task) { await new Promise(r => setTimeout(r, delayMs)); return completed(task); } },
35
+ projectStore: { name: 'inject', async onPlan() {}, async onFinal() {}, async onTaskUpdate(task) { if (throwAt && task.id === throwAt) throw new Error(`${label} injected failure at ${task.id}`); } },
36
+ });
37
+ console.log(JSON.stringify({ label, runId: result.runId, runStatus: result.runStatus, status: result.status, tasks: result.tasks.map(t => t.status) }));
38
+ } catch (e) {
39
+ console.log(JSON.stringify({ label, error: e.message, runId: e.runId ?? null, persisted: e.persisted ?? null }));
40
+ exitCode = 3;
41
+ } finally {
42
+ try { session.close(); } catch { /* ignore */ }
43
+ }
44
+ process.exit(exitCode);
@@ -0,0 +1,61 @@
1
+ // Separate process for the persistent-orchestration restart test. Not part of node --test.
2
+ // node tests/fixtures/orch-persist-child.mjs start <db>
3
+ // node tests/fixtures/orch-persist-child.mjs resume <db> <runId> [decisionId]
4
+ // node tests/fixtures/orch-persist-child.mjs crash <db> <marker>
5
+ import { writeFileSync } from 'node:fs';
6
+ import { resolve, dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { loadRouting } from '../../lib/routing.mjs';
9
+ import { loadAgents } from '../../lib/agents.mjs';
10
+ import { DEFAULT_POLICY } from '../../lib/orchestrator/policy.mjs';
11
+ import { openStore } from '../../lib/orchestrator/store.mjs';
12
+ import { orchestrate } from '../../lib/orchestrator/orchestrator.mjs';
13
+
14
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
15
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
16
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
17
+ const REG = { version: 1, backends: {
18
+ local: { provider: 'pl', model: 'ml', thinking: 'off' },
19
+ cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
20
+ sol: { provider: 'ps', model: 'ms', thinking: 'high' },
21
+ astra: { provider: 'pa', model: 'ma', thinking: 'medium', vision: true },
22
+ codex: { provider: 'px', model: 'mx', thinking: 'high' },
23
+ } };
24
+ const phase = process.argv[2];
25
+ const storePath = process.argv[3];
26
+ const spec = (id, agent, dependencies = []) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'] });
27
+ const completed = task => ({ ok: true, structured: true, result: { status: 'completed', summary: `did ${task.id}`, artifacts: [], verification: [], acceptance: [{ id: 'A1', met: true, evidence: 'observed' }], remainingIssues: [], decisions: [], newTasks: [] } });
28
+ const ask = { key: 'keep', question: 'Keep the existing approach?', flags: ['user_value_judgement'], options: [{ id: 'yes', summary: 'existing approach' }, { id: 'no', summary: 'new approach' }] };
29
+ const blocked = () => ({ ok: true, structured: true, result: { status: 'blocked', summary: 'need a choice', artifacts: [], verification: [], acceptance: [], remainingIssues: [], decisions: [ask], newTasks: [] } });
30
+
31
+ const session = openStore(storePath);
32
+ try {
33
+ if (phase === 'start') {
34
+ const result = await orchestrate({
35
+ request: 'continue the work', plan: [spec('t1', 'scout'), spec('t2', 'coder', ['t1']), spec('t3', 'reviewer', ['t2'])],
36
+ agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
37
+ runner: { async run(task) { return task.id === 't2' && !(task.decisions ?? []).length ? blocked() : completed(task); } },
38
+ });
39
+ console.log(JSON.stringify({ runId: result.runId, decisionId: result.escalations[0]?.id, runStatus: result.runStatus, tasks: result.tasks.map(t => ({ id: t.id, status: t.status, attempts: t.attempts })) }));
40
+ } else if (phase === 'resume') {
41
+ const calls = [];
42
+ const decisionId = process.argv[5];
43
+ const answers = decisionId && decisionId !== '-' ? [{ decisionId, answer: 'yes' }] : [];
44
+ const result = await orchestrate({
45
+ request: '', resumeRunId: process.argv[4], answers, agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
46
+ runner: { async run(task) { calls.push({ id: task.id, attempt: task.attempts }); return completed(task); } },
47
+ });
48
+ console.log(JSON.stringify({ status: result.status, runStatus: result.runStatus, calls, tasks: result.tasks.map(t => ({ id: t.id, status: t.status, attempts: t.attempts })) }));
49
+ } else if (phase === 'crash') {
50
+ const marker = process.argv[4];
51
+ await orchestrate({
52
+ request: 'crash me', plan: [spec('t1', 'scout')], agents, routing, registry: REG, policy: DEFAULT_POLICY, session,
53
+ runner: { async run(task) { writeFileSync(marker, JSON.stringify({ id: task.id, attempts: task.attempts, status: task.status })); process.exit(99); } },
54
+ });
55
+ } else {
56
+ console.error(`unknown phase ${phase}`);
57
+ process.exit(2);
58
+ }
59
+ } finally {
60
+ try { session.close(); } catch { /* process.exit skips this */ }
61
+ }
@@ -0,0 +1,230 @@
1
+ // Phase 5: maintenance job — quiet-by-default, severity, notification dedupe,
2
+ // lock/concurrency, failure semantics, run budget, command sink. Cases A–M.
3
+ // Everything runs against a temp outDir; real config files are never touched.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, 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 {
11
+ runMaintenanceJob, acquireLock, releaseLock, shouldNotify, notificationHash,
12
+ classifyRun, buildNotification, sinks, atomicWriteJson, loadRunState,
13
+ } 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(), 'job-'));
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 CAT_PLUS_VISION = structuredClone(CAT);
33
+ CAT_PLUS_VISION.models.push({ provider: 'cloudp', model: 'vis-ok', status: 'active', cost: { usdPerMInput: 2, usdPerMOutput: 8 }, contextK: 400, vision: true, toolUse: 'good', location: 'cloud', scores: { coding: 80, reasoning: 82, speed: 55 } });
34
+ const REG = mergeRegistries(null, { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash' }, sol: { provider: 'cloudp', model: 'pro-strong' }, astra: { provider: 'cloudp', model: 'vis-ok' } } });
35
+ const base = dir => ({ outDir: dir, adapterDir: join(kit, 'adapters/pi'), kit, catalog: CAT_PLUS_VISION, routing, registry: REG, agents, policy: DEFAULT_POLICY });
36
+ const manualObs = (dir, observations) => { const p = join(dir, 'obs.json'); writeFileSync(p, JSON.stringify({ version: 1, observations })); return p; };
37
+ const OBS = (provider, model, changes, over = {}) => ({ provider, model, observedAt: '2026-03-05T00:00:00Z', source: { type: 'manual', trust: 'manual_verified' }, changes, confidence: 0.9, ...over });
38
+
39
+ // --- A: no change -> quiet ---------------------------------------------------
40
+ test('A: no observations -> quiet exit, no notification', async () => {
41
+ const dir = tmp();
42
+ const run = await runMaintenanceJob(base(dir));
43
+ assert.equal(run.status, 'ok');
44
+ assert.equal(run.quiet, true);
45
+ assert.equal(run.notification, undefined);
46
+ assert.ok(existsSync(join(dir, 'model-maintenance.lastrun.json'))); // run log kept
47
+ rmSync(dir, { recursive: true, force: true });
48
+ });
49
+
50
+ // --- B: duplicates only -> quiet ---------------------------------------------
51
+ test('B: duplicate observations only -> quiet', async () => {
52
+ const dir = tmp();
53
+ const input = manualObs(dir, [OBS('cloudp', 'cheap-ok', { inputPricePer1M: 0.2 })]);
54
+ await runMaintenanceJob({ ...base(dir), source: 'manual', input });
55
+ // second run, same observation -> duplicate
56
+ const run = await runMaintenanceJob({ ...base(dir), source: 'manual', input });
57
+ assert.equal(run.ingest.duplicates, 1);
58
+ assert.equal(run.quiet, true);
59
+ rmSync(dir, { recursive: true, force: true });
60
+ });
61
+
62
+ // --- C: new model, no routing impact -> info ----------------------------------
63
+ test('C: new model observed -> info notification', async () => {
64
+ const dir = tmp();
65
+ const input = manualObs(dir, [OBS('newp', 'Nova-1', { status: 'active', contextK: 512 })]);
66
+ const run = await runMaintenanceJob({ ...base(dir), source: 'manual', input });
67
+ assert.equal(run.quiet, false);
68
+ assert.equal(run.notification.severity, 'info');
69
+ assert.equal(run.notification.sent, true);
70
+ assert.equal(run.notification.requiresApproval, true);
71
+ assert.ok(existsSync(join(dir, 'model-maintenance.notification.json')));
72
+ rmSync(dir, { recursive: true, force: true });
73
+ });
74
+
75
+ // --- D: free end affecting both cheap-code and orchestration -> urgent --------
76
+ test('D: free campaign end affecting multiple capabilities -> urgent notification', async () => {
77
+ const dir = tmp();
78
+ // qoder backend (cheap-code primary) bound to a weak model whose campaign ends -> preview proposes a switch
79
+ const cat = structuredClone(CAT_PLUS_VISION);
80
+ cat.models[0].scores = { coding: 30, reasoning: 25, speed: 85 };
81
+ const input = manualObs(dir, [
82
+ OBS('qoder', 'Qwen3.8-Flash', { free: false, inputPricePer1M: 0.3, outputPricePer1M: 1.2 }),
83
+ OBS('qoder', 'Qwen3.8-Flash', { status: 'active' }),
84
+ ]);
85
+ const run = await runMaintenanceJob({ ...base(dir), catalog: cat, source: 'manual', input });
86
+ assert.equal(run.notification?.severity, 'urgent');
87
+ assert.ok(run.notification.routingImpact.length >= 1);
88
+ assert.ok(run.notification.routingImpact.some(c => c.includes('orchestrator')));
89
+ rmSync(dir, { recursive: true, force: true });
90
+ });
91
+
92
+ // --- E: current model confirmed unavailable -> urgent --------------------------
93
+ test('E: current model removed via high-trust source -> urgent notification', async () => {
94
+ const dir = tmp();
95
+ const input = manualObs(dir, [OBS('qoder', 'Qwen3.8-Flash', { status: 'removed' }, { source: { type: 'api', trust: 'provider_api' } })]);
96
+ const run = await runMaintenanceJob({ ...base(dir), source: 'manual', input });
97
+ assert.equal(run.notification?.severity, 'urgent');
98
+ rmSync(dir, { recursive: true, force: true });
99
+ });
100
+
101
+ // --- F: observer failure -> unknown, never urgent ------------------------------
102
+ test('F: probe failure alone -> quiet, not urgent', async () => {
103
+ const dir = tmp();
104
+ const run = await runMaintenanceJob({ ...base(dir), checkPi: true, listing: false }); // explicit probe failure
105
+ assert.equal(run.ingest.probeFailed, true);
106
+ assert.equal(run.quiet, true);
107
+ assert.equal(run.notification, undefined);
108
+ rmSync(dir, { recursive: true, force: true });
109
+ });
110
+
111
+ // --- G: identical notification re-run -> dedupe --------------------------------
112
+ test('G: same notification content on re-run -> not resent', async () => {
113
+ const dir = tmp();
114
+ const input = manualObs(dir, [OBS('newp', 'Nova-1', { status: 'active' })]);
115
+ const r1 = await runMaintenanceJob({ ...base(dir), source: 'manual', input });
116
+ assert.equal(r1.notification.sent, true);
117
+ // re-ingest is a duplicate, but the stored observation still produces the same diff
118
+ const r2 = await runMaintenanceJob(base(dir));
119
+ assert.equal(r2.notification?.sent, false);
120
+ assert.match(r2.notification.dedupeReason, /already sent/);
121
+ rmSync(dir, { recursive: true, force: true });
122
+ });
123
+
124
+ // --- H: same event, severity rises -> re-notify --------------------------------
125
+ test('H: severity escalation re-notifies despite same payload', async () => {
126
+ const dir = tmp();
127
+ const n1 = buildNotification({ severity: 'info', reasons: ['new model'], proposal: { additions: [{ model: 'newp/Nova-1' }] }, preview: null, runId: 'r1', proposalPath: 'p', previewPath: 'v' });
128
+ const state = { lastNotificationHash: notificationHash(n1), lastSeverity: 'info' };
129
+ const n2 = { ...n1, severity: 'action' }; // same content, higher severity
130
+ const d = shouldNotify(state, n2);
131
+ assert.equal(d.notify, true);
132
+ assert.match(d.reason, /severity rose/);
133
+ });
134
+
135
+ // --- I: budget exceeded -> no premium, deterministic, budgetLimited ------------
136
+ test('I: over-budget run skips invocations, continues deterministically', async () => {
137
+ const dir = tmp();
138
+ const policy = structuredClone(DEFAULT_POLICY);
139
+ policy.budget = { maxEstimatedCostPerRunUsd: 0.0001, maxPremiumInvocationsPerRun: 0, maxTotalInvocationsPerRun: 0 };
140
+ const cat = structuredClone(CAT_PLUS_VISION);
141
+ cat.models[0].scores = { coding: 30, reasoning: 25, speed: 85 };
142
+ const input = manualObs(dir, [OBS('qoder', 'Qwen3.8-Flash', { free: false, inputPricePer1M: 0.3, outputPricePer1M: 1.2 })]);
143
+ let invoked = 0;
144
+ const run = await runMaintenanceJob({
145
+ ...base(dir), catalog: cat, source: 'manual', input, live: true, policy,
146
+ invoke: async () => { invoked++; return { ok: true, text: '{"decision":"keep","confidence":0.9,"reasoningSummary":"x"}', durationMs: 1 }; },
147
+ });
148
+ assert.equal(invoked, 0);
149
+ assert.equal(run.budgetLimited, true);
150
+ assert.ok(run.notification.budgetLimited === true || run.quiet === true);
151
+ rmSync(dir, { recursive: true, force: true });
152
+ });
153
+
154
+ // --- J: concurrent runs -> one executes, artifacts intact -----------------------
155
+ test('J: second concurrent run is skipped by the lock', async () => {
156
+ const dir = tmp();
157
+ const lock = acquireLock(join(dir, 'model-maintenance.lock'));
158
+ assert.equal(lock.acquired, true);
159
+ const run = await runMaintenanceJob(base(dir));
160
+ assert.equal(run.status, 'skipped-locked');
161
+ releaseLock(lock);
162
+ const run2 = await runMaintenanceJob(base(dir));
163
+ assert.equal(run2.status, 'ok');
164
+ rmSync(dir, { recursive: true, force: true });
165
+ });
166
+
167
+ // --- K: stale lock -> recovery -------------------------------------------------
168
+ test('K: stale lock is recovered', async () => {
169
+ const dir = tmp();
170
+ const lockPath = join(dir, 'model-maintenance.lock');
171
+ writeFileSync(lockPath, JSON.stringify({ pid: 999999, at: '2020-01-01T00:00:00Z' }));
172
+ // age the file beyond the TTL
173
+ const old = new Date(Date.now() - 60 * 60 * 1000);
174
+ const { utimesSync } = await import('node:fs');
175
+ utimesSync(lockPath, old, old);
176
+ const run = await runMaintenanceJob(base(dir));
177
+ assert.equal(run.status, 'ok');
178
+ rmSync(dir, { recursive: true, force: true });
179
+ });
180
+
181
+ // --- L: no notify-command -> no external execution ------------------------------
182
+ test('L: without --notify-command no external command runs', async () => {
183
+ const dir = tmp();
184
+ const input = manualObs(dir, [OBS('newp', 'Nova-1', { status: 'active' })]);
185
+ const run = await runMaintenanceJob({ ...base(dir), source: 'manual', input });
186
+ assert.equal(run.notification.sent, true);
187
+ assert.ok(!run.notification.sinks.some(s => s.sink === 'command'));
188
+ rmSync(dir, { recursive: true, force: true });
189
+ });
190
+
191
+ // --- M: notify-command receives JSON on stdin, failure audited ------------------
192
+ test('M: notify-command gets notification JSON on stdin; failure is audited', async () => {
193
+ const dir = tmp();
194
+ const capture = join(dir, 'captured.json');
195
+ const cmd = `node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>require('fs').writeFileSync('${capture.replace(/\\/g, '\\\\')}',s))"`;
196
+ const input = manualObs(dir, [OBS('newp', 'Nova-1', { status: 'active' })]);
197
+ const run = await runMaintenanceJob({ ...base(dir), source: 'manual', input, notifyCommand: cmd });
198
+ const cmdSink = run.notification.sinks.find(s => s.sink === 'command');
199
+ assert.equal(cmdSink.ok, true);
200
+ const sent = JSON.parse(readFileSync(capture, 'utf8'));
201
+ assert.equal(sent.severity, 'info');
202
+ assert.equal(sent.requiresApproval, true);
203
+ // failing command -> audited, job still ok
204
+ const dir2 = tmp();
205
+ const input2 = manualObs(dir2, [OBS('newp', 'Nova-2', { status: 'active' })]);
206
+ const run2 = await runMaintenanceJob({ ...base(dir2), source: 'manual', input: input2, notifyCommand: 'node -e "process.exit(3)"' });
207
+ const bad = run2.notification.sinks.find(s => s.sink === 'command');
208
+ assert.equal(bad.ok, false);
209
+ assert.equal(bad.exitCode, 3);
210
+ assert.equal(run2.status, 'ok');
211
+ rmSync(dir, { recursive: true, force: true });
212
+ rmSync(dir2, { recursive: true, force: true });
213
+ });
214
+
215
+ // --- supporting ----------------------------------------------------------------
216
+ test('classifyRun quiet reasons and severity ordering', () => {
217
+ const empty = () => ({ stored: [], duplicates: [], stale: [], invalid: [] });
218
+ assert.equal(classifyRun({ ingest: empty(), diff: { diffs: [] }, proposal: {}, preview: null }).meaningful, false);
219
+ const dup = empty(); dup.duplicates = [{}];
220
+ assert.equal(classifyRun({ ingest: dup, diff: { diffs: [] }, proposal: {}, preview: null }).quietReason, 'duplicates only');
221
+ });
222
+
223
+ test('state persists lastSuccessfulRunAt and proposal hash', async () => {
224
+ const dir = tmp();
225
+ await runMaintenanceJob(base(dir));
226
+ const state = loadRunState(join(dir, 'model-maintenance.state.json'));
227
+ assert.ok(state.lastSuccessfulRunAt);
228
+ assert.ok(state.lastProposalHash);
229
+ rmSync(dir, { recursive: true, force: true });
230
+ });
@@ -0,0 +1,79 @@
1
+ // Whole-kit structural checks: validate.mjs passes, agents are capability-bound, no hardcoded
2
+ // user paths, no secrets, adapter boundary respected.
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
6
+ import { resolve, dirname, join, relative } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { validateKit } from '../scripts/validate.mjs';
9
+ import { loadAgents, parseFrontmatter, validateAgent } from '../lib/agents.mjs';
10
+ import { loadRouting } from '../lib/routing.mjs';
11
+
12
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
13
+ const SKIP_DIRS = new Set(['.git', '.orchestration', 'node_modules', 'out']);
14
+ const TEXT = /\.(md|mjs|js|json|ps1|py|yaml|yml|txt|gitignore|gitattributes)$/;
15
+
16
+ function* walk(dir) {
17
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
18
+ if (SKIP_DIRS.has(entry.name)) continue;
19
+ const p = join(dir, entry.name);
20
+ if (entry.isDirectory()) yield* walk(p);
21
+ else yield p;
22
+ }
23
+ }
24
+ const files = [...walk(kit)].filter(f => TEXT.test(f) || !f.includes('.'));
25
+
26
+ test('validate.mjs passes for the shipped kit', () => {
27
+ const report = validateKit(kit);
28
+ assert.deepEqual(report.errors, []);
29
+ assert.equal(report.result, 'PASS');
30
+ assert.deepEqual(report.summary.agents.sort(), ['browser->browser', 'coder->strong-code', 'orchestrator->orchestration', 'reviewer->deep-review', 'scout->cheap-code', 'tester->cheap-code', 'visual->vision-reasoning']);
31
+ });
32
+
33
+ test('exactly the seven thin agents exist and none pins a model/provider', () => {
34
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
35
+ const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
36
+ assert.deepEqual(errors, []);
37
+ assert.deepEqual(agents.map(a => a.meta.name).sort(), ['browser', 'coder', 'orchestrator', 'reviewer', 'scout', 'tester', 'visual']);
38
+ const bad = parseFrontmatter('---\nname: x\ndescription: d\ncapability: strong-code\nmodel: gpt-something\n---\nbody');
39
+ assert.ok(validateAgent(bad, routing).some(e => e.includes('must not pin a model')));
40
+ });
41
+
42
+ test('no fixed Windows user-profile paths are hardcoded', () => {
43
+ const offenders = [];
44
+ for (const f of files) {
45
+ const text = readFileSync(f, 'utf8');
46
+ if (/[A-Za-z]:\\Users\\(?!someone\\)[A-Za-z0-9_.-]+/.test(text) || /\/Users\/(?!someone\/)[A-Za-z0-9_.-]+\//.test(text)) offenders.push(relative(kit, f));
47
+ }
48
+ assert.deepEqual(offenders, []);
49
+ });
50
+
51
+ test('no API keys / tokens / private keys in tracked-shape files', () => {
52
+ const pattern = /(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|-----BEGIN (?:RSA |OPENSSH |EC )?PRIVATE KEY-----)/;
53
+ const offenders = files.filter(f => pattern.test(readFileSync(f, 'utf8'))).map(f => relative(kit, f));
54
+ assert.deepEqual(offenders, []);
55
+ });
56
+
57
+ test('.gitignore excludes secrets, generated output and media', () => {
58
+ const ignore = readFileSync(join(kit, '.gitignore'), 'utf8');
59
+ for (const needle of ['auth.json', '.env', '*.key', 'adapters/pi/out/', 'models.local.json', '*.png', '*.mp4']) assert.ok(ignore.includes(needle), needle);
60
+ });
61
+
62
+ test('provider/model names live only in adapters (common layer stays neutral)', () => {
63
+ const pi = JSON.parse(readFileSync(join(kit, 'adapters/pi/models.json'), 'utf8'));
64
+ const concrete = Object.values(pi.backends).flatMap(b => [b.provider, b.model]).filter(v => v && !v.startsWith('TODO'));
65
+ const common = files.filter(f => /[\\/](lib|routing|agents|rules|context-pack|orchestration)[\\/]/.test(f));
66
+ for (const f of common) {
67
+ const text = readFileSync(f, 'utf8');
68
+ for (const name of concrete) assert.ok(!text.includes(name), `${relative(kit, f)} mentions concrete "${name}"`);
69
+ }
70
+ });
71
+
72
+ test('copied skills keep valid frontmatter and visual-verification has no Codex home dependency', () => {
73
+ const skill = readFileSync(join(kit, 'skills/visual-verification/SKILL.md'), 'utf8');
74
+ assert.ok(!/CODEX_HOME/.test(skill));
75
+ assert.match(skill, /^---\r?\nname: visual-verification/);
76
+ for (const name of ['scripts/common.ps1', 'scripts/screenshot.ps1', 'scripts/desktop-screenshot.ps1', 'scripts/winapp-common.ps1']) {
77
+ assert.ok(statSync(join(kit, 'skills/visual-verification', name)).isFile(), name);
78
+ }
79
+ });
@@ -0,0 +1,93 @@
1
+ // Language policy: Japanese is the default for user-facing output across all agents,
2
+ // applied once at the shared prompt layer — never per-provider, never per-agent-file.
3
+ // Schema keys/enums stay English; explicit user language requests override.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { readFileSync } from 'node:fs';
7
+ import { resolve, dirname, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { LANGUAGE_POLICY, withLanguagePolicy } from '../lib/language-policy.mjs';
10
+ import { loadAgents } from '../lib/agents.mjs';
11
+ import { loadRouting } from '../lib/routing.mjs';
12
+ import { buildTaskContract } from '../lib/orchestrator/contract.mjs';
13
+ import { scoutPrompt, coderPrompt } from '../lib/pipeline.mjs';
14
+ import { formatReport } from '../lib/orchestrator/orchestrator.mjs';
15
+ import { parseStructuredResult } from '../lib/orchestrator/runner.mjs';
16
+
17
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
18
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
19
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
20
+ const names = agents.map(a => a.meta.name);
21
+
22
+ // --- policy presence ----------------------------------------------------------
23
+ test('every agent system prompt carries the shared Japanese-default language policy', () => {
24
+ for (const a of agents) {
25
+ assert.ok(a.body.includes('Default response language: Japanese'), `${a.meta.name} missing language policy`);
26
+ assert.ok(a.body.includes('Japanese'), a.meta.name);
27
+ }
28
+ // the seven required agents are all covered
29
+ for (const n of ['scout', 'coder', 'tester', 'reviewer', 'browser', 'orchestrator', 'visual']) {
30
+ assert.ok(names.includes(n), `agent ${n} loaded`);
31
+ }
32
+ });
33
+
34
+ test('policy is applied once at the shared layer, not duplicated per agent file', () => {
35
+ // the raw agent .md files do NOT contain the policy — it is injected by loadAgents
36
+ for (const a of agents) {
37
+ const raw = readFileSync(join(kit, 'agents', a.file), 'utf8');
38
+ assert.ok(!raw.includes('Default response language'), `${a.file} hardcodes the policy`);
39
+ }
40
+ });
41
+
42
+ // --- structured output: keys/enums stay English, values Japanese ----------------
43
+ test('structured result: schema keys/enums stay English, natural-language value is Japanese', () => {
44
+ const text = '```json\n{"status":"completed","summary":"主要なエントリーポイントは src/main.ts です。","acceptance":[{"id":"A1","met":true,"evidence":"testを実行して確認"}]}\n```';
45
+ const p = parseStructuredResult(text);
46
+ assert.equal(p.structured, true);
47
+ assert.equal(p.result.status, 'completed'); // enum not translated
48
+ assert.match(p.result.summary, /エントリーポイント/); // value is Japanese
49
+ });
50
+
51
+ test('result contract instructs Japanese values with English keys/enums', () => {
52
+ const c = buildTaskContract({ id: 't', title: 'x', goal: 'y', acceptance: ['done'] }, {});
53
+ assert.match(c, /LANGUAGE/);
54
+ assert.match(c, /Japanese/);
55
+ assert.match(c, /never translate/i);
56
+ });
57
+
58
+ // --- prompts --------------------------------------------------------------------
59
+ test('scout and coder prompts carry the Japanese instruction', () => {
60
+ const survey = { files: [], inline: [] };
61
+ assert.match(scoutPrompt({ task: 'x', survey }), /Japanese/);
62
+ const pack = { task: 't', goal: 'g', constraints: [], relevant_files: [], expected_output: 'x' };
63
+ assert.match(coderPrompt({ pack, repoRoot: kit }), /Japanese/);
64
+ });
65
+
66
+ // --- orchestrator report --------------------------------------------------------
67
+ test('orchestrator final report is Japanese', () => {
68
+ const report = formatReport({ status: 'completed', runId: 'r1', runStatus: 'completed', rounds: 1, tasks: [{ id: 't1', title: 'task', status: 'completed', result: { summary: 'done' } }], autoDecisions: [], unresolved: [], escalations: [], errors: [], limitsHit: [] });
69
+ assert.match(report, /状態:/);
70
+ assert.match(report, /完了:/);
71
+ assert.match(report, /未解決:/);
72
+ assert.match(report, /ユーザー判断が必要:/);
73
+ });
74
+
75
+ // --- explicit language override -------------------------------------------------
76
+ test('explicit user language request overrides the Japanese default', () => {
77
+ // the policy itself states the override — it is a default, not a mandate
78
+ assert.match(LANGUAGE_POLICY, /Unless the user explicitly requests another language/);
79
+ assert.match(LANGUAGE_POLICY, /explicitly requests/);
80
+ });
81
+
82
+ // --- provider independence ------------------------------------------------------
83
+ test('language policy is provider-agnostic (single shared layer)', () => {
84
+ // withLanguagePolicy wraps any body the same way regardless of which provider
85
+ // the resolved model belongs to — no provider branching anywhere.
86
+ const wrapped = withLanguagePolicy('# Agent\nDo work.');
87
+ assert.ok(wrapped.endsWith(LANGUAGE_POLICY));
88
+ assert.ok(wrapped.startsWith('# Agent'));
89
+ // no provider names anywhere in the policy
90
+ for (const p of ['qoder', 'devin', 'openai', 'freetoken', 'codex']) {
91
+ assert.ok(!LANGUAGE_POLICY.toLowerCase().includes(p), `policy mentions provider ${p}`);
92
+ }
93
+ });
@@ -0,0 +1,60 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import guard from '../adapters/pi/loop-guard/index.js';
4
+
5
+ function harness(model = { provider: 'example', id: 'example-model' }) {
6
+ const handlers = {}, messages = [];
7
+ let aborts = 0;
8
+ const ctx = { model, hasUI: false, abort: () => { aborts++; } };
9
+ guard({ on: (event, fn) => { handlers[event] = fn; }, sendMessage: message => messages.push(message) });
10
+ const result = (command, text = 'no matches', isError = false) => handlers.tool_result({
11
+ toolName: 'bash', input: { command }, content: [{ type: 'text', text }], isError,
12
+ }, ctx);
13
+ return { handlers, ctx, messages, result, aborts: () => aborts };
14
+ }
15
+
16
+ test('stops the observed A/B/C search cycle after third identical result', () => {
17
+ const h = harness();
18
+ for (let i = 0; i < 2; i++) for (const cmd of ['search A', 'search B', 'search C']) h.result(cmd);
19
+ assert.equal(h.aborts(), 0);
20
+ h.result('search A');
21
+ assert.equal(h.aborts(), 1);
22
+ assert.equal(h.messages.length, 1);
23
+ assert.equal(h.handlers.tool_call({ toolName: 'bash', input: { command: 'search D' } }, h.ctx).terminate, true);
24
+ });
25
+ test('changed results and different inputs are progress', () => {
26
+ const h = harness();
27
+ for (let i = 0; i < 15; i++) { h.result('status', String(i)); h.result(`query ${i}`); }
28
+ assert.equal(h.aborts(), 0);
29
+ });
30
+ test('an old repeated result outside the twelve-result window does not stop', () => {
31
+ const h = harness();
32
+ h.result('query'); h.result('query');
33
+ for (let i = 0; i < 12; i++) h.result(`other ${i}`);
34
+ h.result('query');
35
+ assert.equal(h.aborts(), 0);
36
+ });
37
+ test('manual input resets; automatic extension followup does not', () => {
38
+ const h = harness();
39
+ for (let i = 0; i < 3; i++) h.result('query');
40
+ h.handlers.input({ source: 'extension' }, h.ctx);
41
+ assert.equal(h.handlers.tool_call({}, h.ctx).block, true);
42
+ h.handlers.input({ source: 'rpc' }, h.ctx);
43
+ assert.equal(h.handlers.tool_call({}, h.ctx), undefined);
44
+ h.result('query');
45
+ assert.equal(h.aborts(), 1);
46
+ });
47
+ test('all providers/models receive instructions and loop protection', () => {
48
+ for (const model of [{provider:'p1',id:'m1'}, {provider:'p2',id:'m2'}]) {
49
+ const h = harness(model);
50
+ for (let i = 0; i < 5; i++) h.result('query');
51
+ assert.equal(h.aborts(), 1);
52
+ assert.match(h.handlers.before_agent_start({systemPrompt:'original'}, h.ctx).systemPrompt, /Progress rule/);
53
+ }
54
+ });
55
+ test('repeated errors also stop; existing system prompt is preserved', () => {
56
+ const h = harness();
57
+ assert.match(h.handlers.before_agent_start({systemPrompt:'original'}, h.ctx).systemPrompt, /^original/);
58
+ for (let i = 0; i < 3; i++) h.result('query', 'failure', true);
59
+ assert.equal(h.aborts(), 1);
60
+ });