@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,252 @@
1
+ // Public run-activity snapshots: atomic client file, live invocation events, and the
2
+ // significant-vs-high-frequency trace policy. All stubs — no pi process, no provider calls.
3
+ import test from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { mkdtempSync, existsSync, readFileSync, renameSync, readdirSync, mkdirSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve, dirname } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { EventEmitter } from 'node:events';
10
+ import { PassThrough } from 'node:stream';
11
+ import { loadRouting } from '../lib/routing.mjs';
12
+ import { loadAgents } from '../lib/agents.mjs';
13
+ import { DEFAULT_POLICY } from '../lib/orchestrator/policy.mjs';
14
+ import { openStore } from '../lib/orchestrator/store.mjs';
15
+ import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
16
+ import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
17
+ import { runPiSubagent } from '../adapters/pi/lib/subagent.mjs';
18
+ import { createActivityTracker, activityFileName, readActivitySnapshot, sanitizeInvocationEvent, publishActivitySnapshot } from '../lib/orchestrator/activity.mjs';
19
+
20
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
21
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
22
+ const { agents } = loadAgents(join(kit, 'agents'), routing);
23
+ const REG = { version: 1, backends: { local: { provider: 'pl', model: 'ml', thinking: 'off' } } };
24
+ const spec = (id, agent, dependencies = []) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'] });
25
+ const completed = (task) => ({ ok: true, structured: true, modelId: 'fake', result: {
26
+ status: 'completed', summary: `did ${task.id}`, artifacts: [], verification: ['checked'],
27
+ acceptance: [{ id: 'A1', met: true, evidence: 'observed' }], remainingIssues: [], decisions: [], newTasks: [] } });
28
+ const tmpKit = () => mkdtempSync(join(tmpdir(), 'ludi-activity-'));
29
+
30
+ test('snapshot file is written atomically on bind and carries version+clientContext', () => {
31
+ const dir = tmpKit();
32
+ const activity = createActivityTracker({ kit: dir, clientContext: { kind: 'pi-web', sessionId: 'abc-123' } });
33
+ activity.bindRun({ runId: 'run-1', repoRoot: '/repo' });
34
+ const file = join(dir, '.orchestration', 'activity', 'clients', 'pi-web-abc-123.json');
35
+ assert.equal(activityFileName({ kind: 'pi-web', sessionId: 'abc-123' }), 'pi-web-abc-123.json');
36
+ assert.equal(activity.file, file);
37
+ const doc = readActivitySnapshot(file);
38
+ assert.equal(doc.version, 1);
39
+ assert.deepEqual(doc.clientContext, { kind: 'pi-web', sessionId: 'abc-123' });
40
+ assert.equal(doc.runId, 'run-1');
41
+ assert.equal(doc.repoRoot, '/repo');
42
+ assert.equal(doc.activity.runId, 'run-1');
43
+ assert.equal(doc.activity.state, 'running');
44
+ });
45
+
46
+ test('Windows sharing violation retries atomic replacement and keeps the prior document readable', () => {
47
+ const dir = tmpKit(), file = join(dir, 'snapshot.json');
48
+ publishActivitySnapshot(file, { runId: 'old' });
49
+ let attempts = 0;
50
+ const waits = [];
51
+ publishActivitySnapshot(file, { runId: 'new' }, { windows: true, wait: ms => waits.push(ms), rename: (src, dest) => {
52
+ attempts++;
53
+ if (attempts < 3) {
54
+ assert.equal(readActivitySnapshot(dest).runId, 'old');
55
+ throw Object.assign(new Error('locked by reader'), { code: 'EPERM' });
56
+ }
57
+ renameSync(src, dest);
58
+ } });
59
+ assert.equal(readActivitySnapshot(file).runId, 'new');
60
+ assert.deepEqual(waits, [10, 20]);
61
+ assert.deepEqual(readdirSync(dir), ['snapshot.json']);
62
+ });
63
+
64
+ test('persistent snapshot failure does not fail a persisted run or strand temporary files', () => {
65
+ const dir = tmpKit();
66
+ const activity = createActivityTracker({ kit: dir, clientContext: { kind: 'pi-web', sessionId: 'locked' } });
67
+ mkdirSync(activity.file, { recursive: true }); // a directory at the destination makes replacement impossible
68
+ const warnings = [], originalWarn = console.warn;
69
+ try {
70
+ console.warn = message => warnings.push(message);
71
+ assert.doesNotThrow(() => activity.bindRun({ runId: 'run-1', repoRoot: dir }));
72
+ assert.equal(activity.getRunActivity('run-1').runId, 'run-1');
73
+ assert.equal(warnings.length, 1);
74
+ assert.deepEqual(readdirSync(dirname(activity.file)).filter(name => name.endsWith('.tmp')), []);
75
+ } finally { console.warn = originalWarn; }
76
+ rmSync(activity.file, { recursive: true });
77
+ activity.syncTasks({ runId: 'run-1', tasks: [] });
78
+ assert.equal(readActivitySnapshot(activity.file).runId, 'run-1');
79
+ });
80
+
81
+ test('orchestrator still completes and persists when its optional snapshot is locked', async () => {
82
+ const dir = tmpKit(), session = openStore(join(dir, 'state.db'));
83
+ const activity = createActivityTracker({ kit: dir, clientContext: { kind: 'pi-web', sessionId: 'busy-reader' } });
84
+ mkdirSync(activity.file, { recursive: true });
85
+ const originalWarn = console.warn;
86
+ try {
87
+ console.warn = () => {};
88
+ const result = await orchestrate({ request: 'fixture', plan: [spec('t1', 'scout')], agents, routing, registry: REG,
89
+ policy: DEFAULT_POLICY, runner: { run: async task => completed(task) }, session, activity, repoRoot: dir });
90
+ assert.equal(result.status, 'completed');
91
+ assert.equal(session.getRun(result.runId).status, 'completed');
92
+ } finally { console.warn = originalWarn; session.close(); }
93
+ });
94
+
95
+ test('live invocation events update activeInvocations keyed runId+taskId+invocationId', () => {
96
+ const activity = createActivityTracker({ kit: tmpKit(), clientContext: { kind: 'pi-web', sessionId: 's1' } });
97
+ activity.bindRun({ runId: 'r1', repoRoot: null });
98
+ activity.emit('invocation-start', { runId: 'r1', taskId: 't1', invocationId: 'i1', agent: 'coder', modelId: 'm1' });
99
+ activity.emit('invocation-start', { runId: 'r1', taskId: 't1', invocationId: 'i2', agent: 'coder', modelId: 'm2' });
100
+ let a = activity.getRunActivity('r1');
101
+ assert.equal(a.activeInvocations.length, 2);
102
+ activity.emit('invocation-turn', { runId: 'r1', taskId: 't1', invocationId: 'i1', turn: 3, turnCap: 12 });
103
+ activity.emit('invocation-end', { runId: 'r1', taskId: 't1', invocationId: 'i1', status: 'finished' });
104
+ a = activity.getRunActivity('r1');
105
+ assert.equal(a.activeInvocations.length, 1);
106
+ assert.equal(a.activeInvocations[0].invocationId, 'i2');
107
+ });
108
+
109
+ test('subscribeRunActivity notifies and getRunActivityByClient resolves the bound run', () => {
110
+ const activity = createActivityTracker({ kit: tmpKit(), clientContext: { kind: 'pi-web', sessionId: 'sess' } });
111
+ const seen = [];
112
+ activity.subscribeRunActivity('rX', a => seen.push(a));
113
+ activity.bindRun({ runId: 'rX', repoRoot: '/r' });
114
+ activity.syncTasks({ runId: 'rX', tasks: [{ id: 't1', title: 'x', assignedAgent: 'coder', status: 'running', attempts: 1 }] });
115
+ assert.ok(seen.length >= 2);
116
+ const byClient = activity.getRunActivityByClient('pi-web', 'sess');
117
+ assert.equal(byClient.runId, 'rX');
118
+ assert.equal(byClient.totalTasks, 1);
119
+ assert.equal(byClient.activeAgents, 1);
120
+ const restarted = createActivityTracker({ kit: activity.file.split('.orchestration')[0], clientContext: { kind: 'pi-web', sessionId: 'sess' } });
121
+ assert.equal(restarted.getRunActivityByClient('pi-web', 'sess')?.runId, 'rX');
122
+ assert.equal(activity.getRunActivityByClient('pi-web', 'other'), null);
123
+ });
124
+
125
+ test('orchestrate emits significant invocation events to trace but not high-frequency turns', async () => {
126
+ const dir = tmpKit();
127
+ const session = openStore(join(dir, 'state.db'));
128
+ const activity = createActivityTracker({ kit: dir, clientContext: { kind: 'pi-web', sessionId: 'z9' } });
129
+ const runner = { async run(t, ctx) {
130
+ ctx.onEvent?.('invocation-start', { taskId: t.id, invocationId: 'i1', agent: 'coder', modelId: 'm' });
131
+ ctx.onEvent?.('invocation-turn', { taskId: t.id, invocationId: 'i1', turn: 1 });
132
+ ctx.onEvent?.('invocation-tool', { taskId: t.id, invocationId: 'i1', tool: { name: 'read', file: 'a.mjs' } });
133
+ ctx.onEvent?.('invocation-end', { taskId: t.id, invocationId: 'i1', status: 'finished' });
134
+ return completed(t);
135
+ } };
136
+ const r = await orchestrate({
137
+ request: 'do', plan: [spec('a', 'coder')], agents, routing, registry: REG,
138
+ policy: DEFAULT_POLICY, session, runner, activity,
139
+ });
140
+ assert.equal(r.status, 'completed');
141
+ const types = session.loadTrace(r.runId).map(e => e.type);
142
+ for (const k of ['invocation-start', 'invocation-tool', 'invocation-end']) assert.ok(types.includes(k), k);
143
+ assert.ok(!types.includes('invocation-turn'), 'turns must not persist to trace');
144
+ const doc = readActivitySnapshot(join(dir, '.orchestration', 'activity', 'clients', 'pi-web-z9.json'));
145
+ assert.equal(doc.runId, r.runId);
146
+ assert.equal(doc.activity.completedTasks, 1);
147
+ assert.equal(doc.activity.state, 'completed');
148
+ session.close();
149
+ });
150
+
151
+ test('parallel live progress, safe recent tools, extension and candidate change survive snapshots', () => {
152
+ const a = createActivityTracker({ kit: tmpKit(), clientContext: { kind: 'pi-web', sessionId: 'session-a' } });
153
+ a.bindRun({ runId: 'r', repoRoot: '/repo-a' });
154
+ a.syncTasks({ runId: 'r', tasks: [{ id: 't1', status: 'running' }, { id: 't2', status: 'running' }] });
155
+ a.emit('invocation-start', { runId: 'r', taskId: 't1', invocationId: 'i1', agent: 'scout', modelId: 'qoder/Qwen' });
156
+ a.emit('invocation-start', { runId: 'r', taskId: 't2', invocationId: 'i2', agent: 'scout', modelId: 'freetoken/Free' });
157
+ a.emit('invocation-turn', { runId: 'r', taskId: 't1', invocationId: 'i1', turn: 8, turnCap: 24, toolCalls: 7 });
158
+ a.emit('invocation-tool', { runId: 'r', taskId: 't1', invocationId: 'i1', tool: { name: 'read', file: 'D:/repo/src/app.js', args: 'SECRET' } });
159
+ a.emit('invocation-extension', { runId: 'r', taskId: 't1', invocationId: 'i1', oldLimit: 24, newLimit: 32 });
160
+ a.emit('candidate-changed', { runId: 'r', taskId: 't2', invocationId: 'i2', fromModel: 'qoder/Qwen', toModel: 'freetoken/Free', reason: 'MALFORMED_RESULT' });
161
+ const live = a.getRunActivity('r');
162
+ assert.equal(live.activeAgents, 2);
163
+ assert.equal(live.activeInvocations.find(i => i.invocationId === 'i1').turnLimit, 32);
164
+ assert.equal(live.activeInvocations.find(i => i.invocationId === 'i1').turnsUsed, 8);
165
+ assert.equal(live.activeInvocations.find(i => i.invocationId === 'i1').toolCalls, 7);
166
+ assert.deepEqual(live.activeInvocations.find(i => i.invocationId === 'i1').recentTools, [{ tool: 'read', summary: 'app.js' }]);
167
+ assert.equal(live.activeInvocations.find(i => i.invocationId === 'i2').toModel, 'freetoken/Free');
168
+ a.syncTasks({ runId: 'r', tasks: [{ id: 't1', status: 'running' }, { id: 't2', status: 'running' }] });
169
+ assert.equal(a.getRunActivity('r').tasks.find(t => t.taskId === 't2').fallbackTo, 'freetoken/Free');
170
+ a.emit('fallback', { runId: 'r', taskId: 't2', from: 'cheap-code', to: 'strong-code' });
171
+ assert.equal(a.getRunActivity('r').tasks.find(t => t.taskId === 't2').capability, 'strong-code');
172
+ assert.equal(createActivityTracker({ kit: tmpKit(), clientContext: { kind: 'pi-web', sessionId: 'session-b' } }).getRunActivityByClient('pi-web', 'session-b'), null);
173
+ a.emit('invocation-end', { runId: 'r', taskId: 't1', invocationId: 'i1', status: 'finished' });
174
+ assert.equal(a.getRunActivity('r').activeInvocations.length, 1);
175
+ a.syncTasks({ runId: 'r', tasks: [{ id: 't1', status: 'completed' }, { id: 't2', status: 'running' }] });
176
+ assert.equal(a.getRunActivity('r').completedTasks, 1);
177
+ a.finishRun('r');
178
+ assert.equal(a.getRunActivity('r').activeInvocations.length, 0);
179
+ });
180
+
181
+ test('stub E2E: binding appears at send, model/tool/turn update before completion, unrelated session stays hidden', async () => {
182
+ const dir = tmpKit();
183
+ const session = openStore(join(dir, 'state.db'));
184
+ const activity = createActivityTracker({ kit: dir, clientContext: { kind: 'pi-web', sessionId: 'owner-uuid' } });
185
+ const started = Promise.withResolvers();
186
+ const release = Promise.withResolvers();
187
+ const runner = createAgentRunner({ agents, routing, registry: { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' } } }, repoRoot: dir,
188
+ invoke: async () => { throw new Error('unexpected oneshot'); },
189
+ runSubagent: async req => {
190
+ req.onEvent('invocation-turn', { turn: 8, turnCap: 24, toolCalls: 7 });
191
+ req.onEvent('invocation-tool', { tool: { name: 'read', file: join(dir, 'src', 'small.js') } });
192
+ started.resolve(req.modelId);
193
+ await release.promise;
194
+ return { ok: true, text: '```json\n{"status":"completed","summary":"done","acceptance":[{"id":"A1","met":true,"evidence":"checked"}]}\n```', child: { turns: 8, toolCalls: 7 } };
195
+ },
196
+ });
197
+ const running = orchestrate({ request: 'small read-only fixture', plan: [spec('t1', 'scout')], agents, routing,
198
+ registry: { version: 1, backends: { qoder: { provider: 'qoder', model: 'Qwen3.8-Flash', thinking: 'low' } } }, policy: DEFAULT_POLICY, runner, session, activity, repoRoot: dir });
199
+ const modelId = await started.promise;
200
+ const id = session.listRuns()[0].id;
201
+ const bound = readActivitySnapshot(activity.file);
202
+ assert.equal(bound.runId, id);
203
+ assert.equal(bound.activity.state, 'running');
204
+ assert.equal(bound.activity.activeInvocations[0].modelId, modelId);
205
+ assert.equal(bound.activity.activeInvocations[0].turnsUsed, 8);
206
+ assert.equal(bound.activity.activeInvocations[0].turnLimit, 24);
207
+ assert.deepEqual(bound.activity.activeInvocations[0].recentTools, [{ tool: 'read', summary: 'small.js' }]);
208
+ const other = createActivityTracker({ kit: dir, clientContext: { kind: 'pi-web', sessionId: 'other-uuid' } });
209
+ assert.equal(other.getRunActivityByClient('pi-web', 'other-uuid'), null);
210
+ release.resolve();
211
+ const result = await running;
212
+ assert.equal(result.status, 'completed');
213
+ assert.equal(readActivitySnapshot(activity.file).activity.activeInvocations.length, 0);
214
+ assert.equal(readActivitySnapshot(activity.file).activity.completedTasks, 1);
215
+ session.close();
216
+ });
217
+
218
+ test('pi JSON tool/turn events propagate while child is still running', async () => {
219
+ const observed = [];
220
+ const spawnImpl = () => {
221
+ const proc = new EventEmitter();
222
+ proc.stdout = new PassThrough(); proc.stderr = new PassThrough(); proc.kill = () => {};
223
+ setImmediate(() => {
224
+ proc.stdout.write(JSON.stringify({ type: 'tool_execution_start', toolCallId: 'c1', toolName: 'read', args: { path: 'src/a.js', secret: 'HIDDEN' } }) + '\n');
225
+ proc.stdout.write(JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'toolCall', name: 'read', arguments: { path: 'src/a.js' } }] } }) + '\n');
226
+ proc.stdout.write(JSON.stringify({ type: 'tool_execution_end', toolCallId: 'c1', toolName: 'read', result: { text: 'HIDDEN' } }) + '\n');
227
+ proc.stdout.write(JSON.stringify({ type: 'message_end', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } }) + '\n');
228
+ proc.emit('close', 0);
229
+ });
230
+ return proc;
231
+ };
232
+ const result = await runPiSubagent({ modelId: 'stub/model', cwd: tmpKit(), toolNames: ['read'], prompt: 'fixture', limits: { max_turns: 8 }, onEvent: (type, data) => observed.push({ type, data }) }, { piEntry: 'fake-cli', spawnImpl });
233
+ assert.equal(result.ok, true);
234
+ assert.ok(observed.some(e => e.type === 'invocation-tool' && e.data.tool.name === 'read'));
235
+ assert.ok(observed.some(e => e.type === 'invocation-tool-completed'));
236
+ assert.ok(observed.some(e => e.type === 'invocation-turn' && e.data.turn === 2 && e.data.toolCalls === 1));
237
+ assert.ok(!JSON.stringify(observed).includes('HIDDEN'));
238
+ });
239
+
240
+ test('sanitizeInvocationEvent drops payload details and bounds strings', () => {
241
+ const e = sanitizeInvocationEvent('invocation-tool', {
242
+ runId: 'r', taskId: 't', invocationId: 'i',
243
+ tool: { name: 'exec', file: 'x.mjs', arguments: { command: 'rm -rf /' } },
244
+ raw: 'SECRET', prompt: 'PROMPT', text: 'OUTPUT',
245
+ });
246
+ assert.equal(e.tool.name, 'exec');
247
+ assert.equal(e.tool.file, 'x.mjs');
248
+ assert.equal(e.tool.arguments, undefined);
249
+ assert.equal(e.raw, undefined);
250
+ assert.equal(e.prompt, undefined);
251
+ assert.equal(e.text, undefined);
252
+ });
@@ -0,0 +1,102 @@
1
+ // Attempt-budget and candidate-progression regressions for run-muf7i8e2-48e3c7.
2
+ // Only ACTUAL invocations consume max_total_attempts_per_task — health skips,
3
+ // unavailable candidates, already-tried skips and enumeration do not.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
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, mergePolicy } from '../lib/orchestrator/policy.mjs';
11
+ import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
12
+ import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
13
+ import { createScriptedInvoker } from '../adapters/pi/lib/invoke.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
+ // cheap-code: cheap(pc/mc) -> local(pl/ml) -> sol(ps/ms); strong-code adds codex(px/mx).
19
+ const REG = { version: 1, backends: {
20
+ local: { provider: 'pl', model: 'ml', thinking: 'off' },
21
+ cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
22
+ sol: { provider: 'ps', model: 'ms', thinking: 'high' },
23
+ codex: { provider: 'px', model: 'mx', thinking: 'high' },
24
+ } };
25
+ const policyWith = over => mergePolicy(DEFAULT_POLICY, over ?? {});
26
+ const jsonReply = obj => `notes\n\n\`\`\`json\n${JSON.stringify(obj)}\n\`\`\``;
27
+ const okReply = jsonReply({ status: 'completed', summary: 'done', acceptance: [{ id: 'A1', met: true, evidence: 'e' }] });
28
+ const spec = (id, agent, dependencies = [], extra = {}) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'], ...extra });
29
+
30
+ // Health monitor stub: skip returns a reason for the listed modelIds (no invocation).
31
+ const healthSkip = (skipIds) => ({ skip: c => (skipIds.includes(c.modelId) ? 'usage_exhausted until later' : null), report() {} });
32
+
33
+ // Run one scout task with a scripted invoker; runner gets the SAME resolved policy
34
+ // so the invocations budget is enforced inside withEscalation.
35
+ async function runScenario(script, { policy: over = {}, health = null } = {}) {
36
+ const policy = policyWith(over);
37
+ const calls = [];
38
+ const invoke = createScriptedInvoker(script, calls);
39
+ const runner = createAgentRunner({ invoke, agents, routing, registry: REG, policy, health });
40
+ const r = await orchestrate({ request: 'r', plan: [spec('a', 'scout')], agents, routing, registry: REG, policy, runner });
41
+ return { r, calls };
42
+ }
43
+
44
+ // A: protocol failure on Qwen consumes 1 invocation; a health-skipped candidate consumes 0.
45
+ test('A: health-skipped candidate does not consume the attempt budget', async () => {
46
+ const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply }, { health: healthSkip(['pl/ml:off']) });
47
+ assert.equal(r.status, 'completed');
48
+ assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'ps/ms:high']); // pl/ml skipped, never invoked
49
+ const resultEvent = r.trace.find(e => e.type === 'result');
50
+ assert.equal(resultEvent.counters.candidatesSkipped >= 1, true);
51
+ });
52
+
53
+ // B: after a protocol failure the NEXT untried candidate (FreeToken/local) is invocable.
54
+ test('B: protocol failure advances to the next untried candidate', async () => {
55
+ const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply });
56
+ assert.equal(r.status, 'completed');
57
+ assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'pl/ml:off']);
58
+ });
59
+
60
+ // C: cheap-code exhausted -> strong-code escalation -> first eligible candidate runs.
61
+ test('C: escalation to strong-code re-resolves candidates (codex reachable)', async () => {
62
+ const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('px/') ? okReply : 'Done' }, { policy: { limits: { max_retries: 4, model_attempts_per_task: 3, max_total_attempts_per_task: 8 } } });
63
+ assert.equal(r.status, 'completed');
64
+ assert.ok(calls.some(c => c.modelId === 'px/mx:high'), 'reached strong-code-only candidate');
65
+ });
66
+
67
+ // D: two health-skipped candidates consume zero budget.
68
+ test('D: skipped candidates do not increment invocationsStarted', async () => {
69
+ const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('ps/') ? okReply : 'Done' }, { health: healthSkip(['pl/ml:off']) });
70
+ assert.equal(r.status, 'completed');
71
+ assert.deepEqual(calls.map(c => c.modelId), ['pc/mc:low', 'ps/ms:high']); // only 2 real invocations
72
+ });
73
+
74
+ // E: budget counts real invocations; the N+1th real invoke is never started.
75
+ test('E: max_total_attempts_per_task stops before the N+1th real invocation', async () => {
76
+ const { r, calls } = await runScenario({ '*': () => 'Done' }, { policy: { limits: { max_retries: 6, model_attempts_per_task: 3, max_total_attempts_per_task: 4 } } });
77
+ assert.equal(r.tasks[0].status, 'failed');
78
+ assert.ok(calls.length <= 4, `real invocations ${calls.length} exceeded budget 4`);
79
+ });
80
+
81
+ // F: considered > invoked -> budget display uses invocationsStarted.
82
+ test('F: budget reflects invocationsStarted, not candidatesConsidered', async () => {
83
+ const { r } = await runScenario({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply }, { health: healthSkip(['pl/ml:off', 'ps/ms:high']) });
84
+ const resultEvent = r.trace.find(e => e.type === 'result');
85
+ assert.equal(resultEvent.counters.invocationsStarted, 1);
86
+ assert.ok(resultEvent.counters.candidatesConsidered >= resultEvent.counters.invocationsStarted);
87
+ });
88
+
89
+ // G: a model tried on cheap-code may be already-tried on strong-code, but a fresh
90
+ // strong-code-only candidate (codex) is NOT wrongly excluded.
91
+ test('G: escalation resets attemptedModels so new-capability candidates run', async () => {
92
+ const { r, calls } = await runScenario({ '*': req => req.modelId.startsWith('px/') ? okReply : 'Done' }, { policy: { limits: { max_retries: 4, model_attempts_per_task: 3, max_total_attempts_per_task: 8 } } });
93
+ assert.equal(r.status, 'completed');
94
+ assert.ok(calls.filter(c => c.modelId === 'px/mx:high').length >= 1, 'codex invoked after escalation');
95
+ });
96
+
97
+ // H: candidate identity is per-modelId — failing one model never marks a different
98
+ // provider's model as tried.
99
+ test('H: candidate identity is per modelId, not per provider/backend', async () => {
100
+ const { calls } = await runScenario({ '*': req => req.modelId.startsWith('pc/') ? 'Done' : okReply });
101
+ assert.ok(calls.some(c => c.modelId === 'pl/ml:off'), 'pl/ml invoked despite pc/mc failure');
102
+ });
@@ -0,0 +1,121 @@
1
+ // Browser capability: routing resolution, agent binding, wrapper command construction,
2
+ // action classification, failure normalization, and argv integrity (no shell mangling).
3
+ // These tests never launch a real browser; the opt-in real E2E is tests/e2e-browser.mjs.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join, resolve, dirname } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { loadRouting } from '../lib/routing.mjs';
11
+ import { loadAgents } from '../lib/agents.mjs';
12
+ import { resolveCapability, resolveAgents } from '../lib/resolve.mjs';
13
+ import { COMMANDS, ACTION_KINDS, classify, buildArgs, runBrowser, locateAgentBrowser } from '../adapters/pi/browser/agent-browser.mjs';
14
+
15
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
+ const routing = loadRouting(join(kit, 'routing/routing.json'));
17
+ const { agents, errors } = loadAgents(join(kit, 'agents'), routing);
18
+
19
+ const REG = { version: 1, backends: {
20
+ sol: { provider: 'ps', model: 'ms', thinking: 'high' },
21
+ codex: { provider: 'px', model: 'mx', thinking: 'high' },
22
+ } };
23
+
24
+ // ---------- capability resolution ----------
25
+ test('browser capability resolves through routing like any other', () => {
26
+ const r = resolveCapability(routing, REG, 'browser');
27
+ assert.equal(r.capability, 'browser');
28
+ assert.deepEqual(r.chain, ['sol', 'codex']);
29
+ assert.deepEqual(r.candidates.map(c => c.modelId), ['ps/ms:high', 'px/mx:high']);
30
+ assert.deepEqual(r.unbound, []);
31
+ });
32
+
33
+ test('browser agent binds to the browser capability and never names a model', () => {
34
+ assert.deepEqual(errors, []);
35
+ const browser = agents.find(a => a.meta.name === 'browser');
36
+ assert.ok(browser, 'agents/browser.md must exist');
37
+ assert.equal(browser.meta.capability, 'browser');
38
+ assert.equal(browser.meta.model, undefined);
39
+ assert.equal(browser.meta.provider, undefined);
40
+ const resolved = resolveAgents(agents, routing, REG);
41
+ assert.equal(resolved.browser.capability, 'browser');
42
+ assert.equal(resolved.browser.candidates[0].backend, 'sol');
43
+ });
44
+
45
+ // ---------- command construction ----------
46
+ test('buildArgs produces the documented agent-browser argv', () => {
47
+ assert.deepEqual(buildArgs('open', { url: 'https://example.com' }), ['open', 'https://example.com']);
48
+ assert.deepEqual(buildArgs('navigate', { url: 'https://example.com' }), ['open', 'https://example.com']);
49
+ assert.deepEqual(buildArgs('snapshot'), ['snapshot', '-i']);
50
+ assert.deepEqual(buildArgs('snapshot', { interactive: false, compact: true, depth: 3 }), ['snapshot', '-c', '-d', '3']);
51
+ assert.deepEqual(buildArgs('click', { ref: '@e2' }), ['click', '@e2']);
52
+ assert.deepEqual(buildArgs('fill', { ref: '@e3', text: 'hello' }), ['fill', '@e3', 'hello']);
53
+ assert.deepEqual(buildArgs('press', { key: 'Enter' }), ['press', 'Enter']);
54
+ assert.deepEqual(buildArgs('getText', { ref: '@e1' }), ['get', 'text', '@e1']);
55
+ assert.deepEqual(buildArgs('getValue', { ref: '@e3' }), ['get', 'value', '@e3']);
56
+ assert.deepEqual(buildArgs('getUrl'), ['get', 'url']);
57
+ assert.deepEqual(buildArgs('isVisible', { ref: '@e4' }), ['is', 'visible', '@e4']);
58
+ assert.deepEqual(buildArgs('wait', { ms: 500 }), ['wait', '500']);
59
+ assert.deepEqual(buildArgs('wait', { ref: '#done' }), ['wait', '#done']);
60
+ assert.deepEqual(buildArgs('screenshot', { path: 'p.png', annotate: true }), ['screenshot', 'p.png', '--annotate']);
61
+ assert.deepEqual(buildArgs('close'), ['close']);
62
+ });
63
+
64
+ test('unknown commands are rejected before spawn', () => {
65
+ assert.throws(() => buildArgs('deleteEverything'), /unknown command/);
66
+ assert.equal(classify('deleteEverything'), null);
67
+ });
68
+
69
+ // ---------- action classification (metadata for a future approval policy) ----------
70
+ test('every command has a safety kind and the kinds match the agreed tiers', () => {
71
+ for (const [name, spec] of Object.entries(COMMANDS)) {
72
+ assert.ok(ACTION_KINDS.includes(spec.kind), `${name} kind ${spec.kind}`);
73
+ }
74
+ for (const c of ['open', 'snapshot', 'getText', 'getValue', 'getUrl', 'getTitle', 'isVisible', 'isChecked', 'wait', 'screenshot']) {
75
+ // open navigates (state change) but is trivially reversible -> low-risk, not read-only
76
+ if (c === 'open') assert.equal(classify(c), 'low-risk');
77
+ else assert.equal(classify(c), 'read-only', c);
78
+ }
79
+ for (const c of ['click', 'navigate', 'hover', 'scroll', 'close']) assert.equal(classify(c), 'low-risk', c);
80
+ for (const c of ['fill', 'type', 'press', 'select', 'check', 'uncheck']) assert.equal(classify(c), 'write', c);
81
+ for (const c of ['eval', 'submit']) assert.equal(classify(c), 'high-impact', c);
82
+ });
83
+
84
+ // ---------- argv integrity: no shell, values with spaces/quotes stay one arg ----------
85
+ test('argv round-trip: spaces, quotes and Windows paths survive without a shell', async () => {
86
+ const dir = mkdtempSync(join(tmpdir(), 'ludi-ab-argv-'));
87
+ const echo = join(dir, 'echo.mjs');
88
+ writeFileSync(echo, 'console.log(JSON.stringify(process.argv.slice(2)))');
89
+ const env = { ...process.env, LUDI_AGENT_BROWSER_ENTRY: echo };
90
+ const url = 'file:///D:/my folder/page "one".html';
91
+ const r = await runBrowser('open', { url }, { env, session: 's1' });
92
+ assert.equal(r.ok, true, r.error ?? r.stderr);
93
+ assert.deepEqual(JSON.parse(r.stdout.trim()), ['--session', 's1', 'open', url]);
94
+ });
95
+
96
+ // ---------- failure normalization ----------
97
+ test('nonzero exit becomes {ok:false,error} without throwing', async () => {
98
+ const dir = mkdtempSync(join(tmpdir(), 'ludi-ab-fail-'));
99
+ const fail = join(dir, 'fail.mjs');
100
+ writeFileSync(fail, 'console.error("boom: no such ref"); process.exit(3)');
101
+ const env = { ...process.env, LUDI_AGENT_BROWSER_ENTRY: fail };
102
+ const r = await runBrowser('click', { ref: '@e99' }, { env });
103
+ assert.equal(r.ok, false);
104
+ assert.equal(r.status, 3);
105
+ assert.match(r.error, /exit 3/);
106
+ assert.match(r.stderr, /boom: no such ref/);
107
+ assert.equal(r.kind, 'low-risk'); // classification survives failures
108
+ });
109
+
110
+ test('missing binary throws a clear install error', () => {
111
+ const env = { ...process.env, PATH: '', Path: '', LUDI_AGENT_BROWSER_ENTRY: '', LUDI_AGENT_BROWSER_BIN: '' };
112
+ assert.equal(locateAgentBrowser(env), null);
113
+ assert.throws(() => runBrowser('snapshot', {}, { env }), /agent-browser not found/);
114
+ });
115
+
116
+ // ---------- real binary presence (informational; not a browser launch) ----------
117
+ test('installed agent-browser binary is locatable on this machine', () => {
118
+ const located = locateAgentBrowser();
119
+ assert.ok(located, 'agent-browser should be installed for the browser capability');
120
+ assert.ok(['js', 'bin'].includes(located.kind));
121
+ });
@@ -0,0 +1,98 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { parseContextPackMarkdown, validateContextPack, loadContextPack, toMarkdown, REQUIRED_FIELDS } from '../lib/context-pack.mjs';
7
+
8
+ const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
+ const example = resolve(kit, 'context-pack/examples/example-fix.md');
10
+ const schema = JSON.parse(readFileSync(resolve(kit, 'context-pack/context-pack.schema.json'), 'utf8'));
11
+
12
+ test('schema file parses and lists the same required fields as the validator', () => {
13
+ assert.deepEqual([...schema.required].sort(), [...REQUIRED_FIELDS].sort());
14
+ for (const f of ['task', 'goal', 'constraints', 'relevant_files', 'relevant_snippets', 'repo_rules', 'observed_errors', 'test_commands', 'previous_attempts', 'expected_output']) {
15
+ assert.ok(schema.properties[f], `schema missing ${f}`);
16
+ }
17
+ });
18
+
19
+ test('example markdown parses, validates and round-trips', () => {
20
+ const pack = loadContextPack(example);
21
+ assert.equal(pack.task, 'Fix crash when saving an empty project');
22
+ assert.deepEqual(pack.relevant_files[0], { path: 'src/save.ts', lines: '40-88', reason: 'serialization entry point' });
23
+ assert.equal(pack.relevant_snippets[0].language, 'ts');
24
+ assert.match(pack.relevant_snippets[0].content, /export function save/);
25
+ assert.match(pack.observed_errors[0], /TypeError/);
26
+ assert.deepEqual(pack.test_commands, ['npm test -- save']);
27
+ assert.deepEqual(pack.previous_attempts[0], { summary: 'Added a null check in `save()`', outcome: 'tests pass but empty file is still invalid.' });
28
+ const again = parseContextPackMarkdown(toMarkdown(pack));
29
+ assert.deepEqual(again, pack);
30
+ });
31
+
32
+ test('missing required sections are reported', () => {
33
+ const errs = validateContextPack(parseContextPackMarkdown('# Context Pack\n\n## task\nx\n'));
34
+ for (const f of ['goal', 'constraints', 'relevant_files', 'expected_output']) assert.ok(errs.some(e => e.includes(`"${f}"`)), f);
35
+ });
36
+
37
+ test('unknown and duplicate sections are rejected', () => {
38
+ assert.throws(() => parseContextPackMarkdown('# Context Pack\n## task\na\n## bogus\nb\n'), /unknown section/);
39
+ assert.throws(() => parseContextPackMarkdown('# Context Pack\n## task\na\n## task\nb\n'), /duplicate section/);
40
+ assert.throws(() => parseContextPackMarkdown('## task\na\n'), /missing "# Context Pack"/);
41
+ });
42
+
43
+ test('relevant_files must be non-empty, relative, with valid line ranges', () => {
44
+ const ok = { task: 't', goal: 'g', constraints: [], relevant_files: [{ path: 'a.ts' }], expected_output: 'e' };
45
+ assert.deepEqual(validateContextPack(ok), []);
46
+ assert.ok(validateContextPack({ ...ok, relevant_files: [] }).some(e => e.includes('at least one')));
47
+ assert.ok(validateContextPack({ ...ok, relevant_files: [{ path: 'C:\\Users\\someone\\a.ts' }] }).some(e => e.includes('repository-relative')));
48
+ assert.ok(validateContextPack({ ...ok, relevant_files: [{ path: 'a.ts', lines: '10' }] }).some(e => e.includes('start-end')));
49
+ });
50
+
51
+ test('unknown fields and bad metadata are rejected', () => {
52
+ const ok = { task: 't', goal: 'g', constraints: [], relevant_files: [{ path: 'a' }], expected_output: 'e' };
53
+ assert.ok(validateContextPack({ ...ok, extra: 1 }).some(e => e.includes('unknown field')));
54
+ assert.ok(validateContextPack({ ...ok, version: 2 }).some(e => e.includes('version')));
55
+ assert.ok(validateContextPack({ ...ok, capability: 'Bad' }).some(e => e.includes('capability')));
56
+ assert.ok(validateContextPack({ ...ok, budget: { max_tokens: 0 } }).some(e => e.includes('max_tokens')));
57
+ assert.deepEqual(validateContextPack({ ...ok, version: 1, capability: 'strong-code', budget: { max_tokens: 8000 } }), []);
58
+ });
59
+
60
+ test('v1.1: discovery.status=none permits empty relevant_files; create flag round-trips; v1.0 packs unchanged', () => {
61
+ const base = { task: 't', goal: 'g', constraints: [], relevant_files: [], expected_output: 'e' };
62
+ assert.ok(validateContextPack(base).some(e => e.includes('at least one')));
63
+ assert.deepEqual(validateContextPack({ ...base, discovery: { status: 'none', note: 'greenfield' } }), []);
64
+ assert.ok(validateContextPack({ ...base, discovery: { status: 'partial' } }).some(e => e.includes('at least one')));
65
+ assert.ok(validateContextPack({ ...base, discovery: { status: 'maybe' } }).some(e => e.includes('discovery.status')));
66
+ const withCreate = { ...base, relevant_files: [{ path: 'src/new.js', create: true, reason: 'new module' }, { path: 'a.js' }] };
67
+ assert.deepEqual(validateContextPack(withCreate), []);
68
+ const md = toMarkdown({ ...withCreate, discovery: { status: 'partial', note: 'only tests exist' } });
69
+ assert.match(md, /## discovery\npartial — only tests exist/);
70
+ assert.match(md, /- `src\/new.js` — \(new\) new module/);
71
+ const back = parseContextPackMarkdown(md);
72
+ assert.deepEqual(back.discovery, { status: 'partial', note: 'only tests exist' });
73
+ assert.deepEqual(back.relevant_files[0], { path: 'src/new.js', reason: 'new module', create: true });
74
+ assert.deepEqual(validateContextPack(loadContextPack(example)), []); // v1.0 example still valid
75
+ assert.equal(schema.properties.relevant_files.minItems, undefined);
76
+ assert.equal(schema.else.properties.relevant_files.minItems, 1);
77
+ });
78
+
79
+ test('lenient parsing accepts real scout output shapes; strict parsing does not', () => {
80
+ const loose = 'Preamble text.\n\n## task\nt\n## goal\ng\n## constraints\n- a\n - nested detail\n## relevant_files\n- `src/math.js:1-12` — impl\n- `test/x.js:6`\n## relevant_snippets\n- `src/math.js:9-12`\n ```js\n code\n ```\n## expected_output\ne\n';
81
+ assert.throws(() => parseContextPackMarkdown(loose), /missing "# Context Pack"/);
82
+ const p = parseContextPackMarkdown(loose, { lenient: true });
83
+ assert.deepEqual(p.constraints, ['a nested detail']);
84
+ assert.deepEqual(p.relevant_files, [{ path: 'src/math.js', lines: '1-12', reason: 'impl' }, { path: 'test/x.js', lines: '6-6' }]);
85
+ assert.equal(p.relevant_snippets[0].path, 'src/math.js');
86
+ assert.equal(p.relevant_snippets[0].lines, '9-12');
87
+ assert.equal(p.relevant_snippets[0].language, 'js');
88
+ assert.deepEqual(validateContextPack(p), []);
89
+ // Windows drive letters are not line refs
90
+ assert.equal(parseContextPackMarkdown('# Context Pack\n## relevant_files\n- `C:\\r\\a.js`\n', { lenient: true }).relevant_files[0].path, 'C:\\r\\a.js');
91
+ });
92
+
93
+ test('headings inside fenced code blocks do not start sections', () => {
94
+ const md = '# Context Pack\n## task\nt\n## goal\ng\n## constraints\n## relevant_files\n- `a.md`\n## observed_errors\n```\n## not a heading\n```\n## expected_output\ne\n';
95
+ const pack = parseContextPackMarkdown(md);
96
+ assert.deepEqual(pack.observed_errors, ['## not a heading']);
97
+ assert.deepEqual(validateContextPack(pack), []);
98
+ });