proteum 2.3.0 → 2.4.2

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 (56) hide show
  1. package/AGENTS.md +8 -3
  2. package/README.md +20 -15
  3. package/agents/project/AGENTS.md +16 -10
  4. package/agents/project/DOCUMENTATION.md +1326 -0
  5. package/agents/project/app-root/AGENTS.md +2 -2
  6. package/agents/project/diagnostics.md +10 -9
  7. package/agents/project/optimizations.md +1 -1
  8. package/agents/project/root/AGENTS.md +15 -8
  9. package/agents/project/server/services/AGENTS.md +1 -0
  10. package/agents/project/tests/AGENTS.md +1 -0
  11. package/cli/commands/db.ts +160 -0
  12. package/cli/commands/dev.ts +148 -25
  13. package/cli/commands/diagnose.ts +2 -0
  14. package/cli/commands/explain.ts +38 -9
  15. package/cli/commands/mcp.ts +126 -9
  16. package/cli/commands/orient.ts +44 -17
  17. package/cli/commands/runtime.ts +100 -17
  18. package/cli/mcp/router.ts +1028 -0
  19. package/cli/presentation/commands.ts +56 -25
  20. package/cli/presentation/help.ts +1 -1
  21. package/cli/runtime/commands.ts +163 -21
  22. package/cli/runtime/devSessions.ts +328 -2
  23. package/cli/runtime/mcpDaemon.ts +288 -0
  24. package/cli/runtime/ports.ts +151 -0
  25. package/cli/utils/agents.ts +94 -17
  26. package/cli/utils/appRoots.ts +232 -0
  27. package/common/dev/database.ts +226 -0
  28. package/common/dev/diagnostics.ts +1 -1
  29. package/common/dev/inspection.ts +8 -1
  30. package/common/dev/mcpPayloads.ts +456 -17
  31. package/common/dev/mcpServer.ts +51 -0
  32. package/docs/agent-routing.md +32 -21
  33. package/docs/dev-commands.md +1 -1
  34. package/docs/dev-sessions.md +3 -1
  35. package/docs/diagnostics.md +21 -20
  36. package/docs/mcp.md +114 -50
  37. package/docs/migrate-from-2.1.3.md +3 -5
  38. package/docs/request-tracing.md +3 -3
  39. package/package.json +10 -3
  40. package/server/app/devDiagnostics.ts +92 -0
  41. package/server/app/devMcp.ts +55 -0
  42. package/server/services/prisma/mariadb.ts +7 -3
  43. package/server/services/router/http/index.ts +25 -0
  44. package/server/services/router/request/ip.test.cjs +0 -1
  45. package/tests/agents-utils.test.cjs +58 -3
  46. package/tests/cli-mcp-command.test.cjs +327 -0
  47. package/tests/codex-mcp-usage.test.cjs +307 -0
  48. package/tests/dev-sessions.test.cjs +113 -0
  49. package/tests/dev-transpile-watch.test.cjs +0 -1
  50. package/tests/eslint-rules.test.cjs +0 -1
  51. package/tests/inspection.test.cjs +0 -1
  52. package/tests/mcp.test.cjs +769 -2
  53. package/tests/router-cache-config.test.cjs +0 -1
  54. package/vitest.config.mjs +9 -0
  55. package/cli/mcp/provider.ts +0 -365
  56. package/cli/mcp/stdio.ts +0 -16
@@ -0,0 +1,327 @@
1
+ const assert = require('node:assert/strict');
2
+ const { spawn, spawnSync } = require('node:child_process');
3
+ const fs = require('node:fs');
4
+ const http = require('node:http');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const coreRoot = path.resolve(__dirname, '..');
9
+ const cliBin = path.join(coreRoot, 'cli', 'bin.js');
10
+
11
+ const writeFile = (filepath, content) => {
12
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
13
+ fs.writeFileSync(filepath, content);
14
+ };
15
+
16
+ const createProteumApp = (appRoot, { routerPort = 3020 } = {}) => {
17
+ writeFile(path.join(appRoot, 'package.json'), '{"name":"fixture"}\n');
18
+ writeFile(path.join(appRoot, 'identity.config.ts'), 'export default {};\n');
19
+ writeFile(path.join(appRoot, 'proteum.config.ts'), 'export default {};\n');
20
+ fs.mkdirSync(path.join(appRoot, 'client'), { recursive: true });
21
+ fs.mkdirSync(path.join(appRoot, 'server'), { recursive: true });
22
+ writeFile(
23
+ path.join(appRoot, '.proteum', 'manifest.json'),
24
+ JSON.stringify({
25
+ version: 10,
26
+ app: {
27
+ root: appRoot,
28
+ coreRoot,
29
+ identityFilepath: path.join(appRoot, 'identity.config.ts'),
30
+ setupFilepath: path.join(appRoot, 'proteum.config.ts'),
31
+ identity: { name: 'Product', identifier: 'ProductApp', description: '' },
32
+ setup: {},
33
+ },
34
+ conventions: { routeOptionKeys: [], reservedRouteOptionKeys: [] },
35
+ env: {
36
+ source: 'test',
37
+ loadedVariableKeys: [],
38
+ requiredVariables: [],
39
+ resolved: {
40
+ name: 'test',
41
+ profile: 'dev',
42
+ routerPort,
43
+ routerCurrentDomain: 'localhost',
44
+ routerInternalUrl: `http://localhost:${routerPort}`,
45
+ },
46
+ },
47
+ connectedProjects: [],
48
+ services: { app: [], routerPlugins: [] },
49
+ controllers: [],
50
+ commands: [],
51
+ routes: { client: [], server: [] },
52
+ layouts: [],
53
+ diagnostics: [],
54
+ }),
55
+ );
56
+ };
57
+
58
+ const listen = async (server, port = 0) =>
59
+ await new Promise((resolve, reject) => {
60
+ server.once('error', reject);
61
+ server.listen(port, '127.0.0.1', () => resolve(server.address().port));
62
+ });
63
+
64
+ const closeServer = async (server) =>
65
+ await new Promise((resolve, reject) => {
66
+ server.close((error) => (error ? reject(error) : resolve()));
67
+ });
68
+
69
+ const runCli = async (args, { cwd }) =>
70
+ await new Promise((resolve, reject) => {
71
+ const child = spawn(process.execPath, [cliBin, ...args], {
72
+ cwd,
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ });
75
+ let stdout = '';
76
+ let stderr = '';
77
+
78
+ child.stdout.on('data', (chunk) => {
79
+ stdout += chunk.toString();
80
+ });
81
+ child.stderr.on('data', (chunk) => {
82
+ stderr += chunk.toString();
83
+ });
84
+ child.once('error', reject);
85
+ child.once('close', (status) => resolve({ status, stdout, stderr }));
86
+ });
87
+
88
+ test('top-level help lists the machine-scope mcp router', () => {
89
+ const result = spawnSync(process.execPath, [cliBin, '--help'], {
90
+ cwd: coreRoot,
91
+ encoding: 'utf8',
92
+ });
93
+
94
+ assert.equal(result.status, 0);
95
+ assert.match(result.stdout, /proteum mcp\b/);
96
+ assert.match(result.stdout, /machine-scope MCP router/);
97
+ });
98
+
99
+ test('mcp help describes projectId routing', () => {
100
+ const result = spawnSync(process.execPath, [cliBin, 'mcp', '--help'], {
101
+ cwd: coreRoot,
102
+ encoding: 'utf8',
103
+ });
104
+ const output = `${result.stdout}\n${result.stderr}`;
105
+
106
+ assert.equal(result.status, 0);
107
+ assert.match(output, /machine-scope MCP router/);
108
+ assert.match(output, /projectId/);
109
+ assert.match(output, /--daemon/);
110
+ assert.match(output, /--stdio/);
111
+ });
112
+
113
+ test('db help describes read-only SQL diagnostics', () => {
114
+ const result = spawnSync(process.execPath, [cliBin, 'db', '--help'], {
115
+ cwd: coreRoot,
116
+ encoding: 'utf8',
117
+ });
118
+ const output = `${result.stdout}\n${result.stderr}`;
119
+
120
+ assert.equal(result.status, 0);
121
+ assert.match(output, /SELECT, SHOW, and EXPLAIN/);
122
+ assert.match(output, /--limit/);
123
+ assert.match(output, /--timeout/);
124
+ });
125
+
126
+ test('explain help describes compact section summaries', () => {
127
+ const result = spawnSync(process.execPath, [cliBin, 'explain', '--help'], {
128
+ cwd: coreRoot,
129
+ encoding: 'utf8',
130
+ });
131
+ const output = `${result.stdout}\n${result.stderr}`;
132
+
133
+ assert.equal(result.status, 0);
134
+ assert.match(output, /Summarize generated routes, controllers, and commands together/);
135
+ assert.match(output, /--routes --controllers --commands --full/);
136
+ assert.match(output, /Explicit section flags summarize those sections by default/);
137
+ });
138
+
139
+ test('runtime status from a monorepo wrapper returns app candidates instead of treating wrapper as app', () => {
140
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-wrapper-'));
141
+ createProteumApp(path.join(repoRoot, 'apps', 'product'));
142
+
143
+ const result = spawnSync(process.execPath, [cliBin, 'runtime', 'status'], {
144
+ cwd: repoRoot,
145
+ encoding: 'utf8',
146
+ });
147
+ const payload = JSON.parse(result.stdout);
148
+
149
+ assert.equal(result.status, 1);
150
+ assert.equal(payload.ok, false);
151
+ assert.equal(payload.data.appCandidates.length, 1);
152
+ assert.match(payload.nextActions[0].command, /cd "apps\/product"/);
153
+ assert.match(payload.nextActions[0].command, /npx proteum runtime status/);
154
+ });
155
+
156
+ test('dev from a monorepo wrapper returns exact app-root start command', () => {
157
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-dev-wrapper-'));
158
+ createProteumApp(path.join(repoRoot, 'apps', 'product'));
159
+
160
+ const result = spawnSync(process.execPath, [cliBin, 'dev', 'list'], {
161
+ cwd: repoRoot,
162
+ encoding: 'utf8',
163
+ });
164
+ const payload = JSON.parse(result.stdout);
165
+
166
+ assert.equal(result.status, 1);
167
+ assert.equal(payload.ok, false);
168
+ assert.match(payload.nextActions[0].command, /cd "apps\/product"/);
169
+ assert.match(payload.nextActions[0].command, /npx proteum dev --session-file/);
170
+ });
171
+
172
+ test('runtime status manifest guard points to explain manifest', () => {
173
+ const result = spawnSync(process.execPath, [cliBin, 'runtime', 'status', '--manifest'], {
174
+ cwd: coreRoot,
175
+ encoding: 'utf8',
176
+ });
177
+ const payload = JSON.parse(result.stdout);
178
+
179
+ assert.equal(result.status, 1);
180
+ assert.equal(payload.ok, false);
181
+ assert.match(payload.summary, /not supported/);
182
+ assert.match(payload.nextActions[0].command, /proteum explain --manifest/);
183
+ });
184
+
185
+ test('runtime status reports occupied configured port without probing page bodies', async () => {
186
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-port-'));
187
+ const otherRoot = path.join(repoRoot, 'apps', 'other');
188
+ const appRoot = path.join(repoRoot, 'apps', 'product');
189
+ const server = http.createServer((req, res) => {
190
+ if (req.url && req.url.startsWith('/__proteum/explain')) {
191
+ res.setHeader('content-type', 'application/json');
192
+ res.end(
193
+ JSON.stringify({
194
+ app: {
195
+ root: otherRoot,
196
+ identity: { identifier: 'OtherApp', name: 'Other' },
197
+ },
198
+ }),
199
+ );
200
+ return;
201
+ }
202
+
203
+ res.setHeader('content-type', 'text/html');
204
+ res.end('<html><body>large wrong app page that should never be included</body></html>');
205
+ });
206
+ const occupiedPort = await listen(server);
207
+
208
+ try {
209
+ createProteumApp(appRoot, { routerPort: occupiedPort });
210
+
211
+ const result = await runCli(['runtime', 'status'], {
212
+ cwd: appRoot,
213
+ });
214
+ const payload = JSON.parse(result.stdout);
215
+
216
+ assert.equal(result.status, 0);
217
+ assert.equal(payload.ok, true);
218
+ assert.equal(payload.data.configuredDevPort.router.port, occupiedPort);
219
+ assert.equal(payload.data.configuredDevPort.router.available, false);
220
+ assert.equal(payload.data.configuredDevPort.router.proteum, true);
221
+ assert.equal(payload.data.configuredDevPort.router.matchesApp, false);
222
+ assert.equal(payload.data.configuredDevPort.router.app.identifier, 'OtherApp');
223
+ assert.notEqual(payload.data.configuredDevPort.recommendedPort, occupiedPort);
224
+ assert.match(payload.summary, /occupied by OtherApp/);
225
+ assert.match(payload.nextActions[0].command, /(npx )?proteum dev --session-file/);
226
+ assert.doesNotMatch(payload.nextActions[0].command, new RegExp(`--port ${occupiedPort}(\\D|$)`));
227
+ assert.match(payload.nextActions[0].reason, /do not probe page bodies/);
228
+ assert.doesNotMatch(result.stdout, /large wrong app page/);
229
+ assert.doesNotMatch(result.stdout, /<html>/);
230
+ } finally {
231
+ await closeServer(server);
232
+ }
233
+ });
234
+
235
+ test('db query posts one read-only SQL statement to the running dev endpoint', async () => {
236
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-db-'));
237
+ const appRoot = path.join(repoRoot, 'apps', 'product');
238
+ let receivedBody = '';
239
+ const server = http.createServer((req, res) => {
240
+ if (req.url === '/__proteum/db/query' && req.method === 'POST') {
241
+ req.on('data', (chunk) => {
242
+ receivedBody += chunk.toString();
243
+ });
244
+ req.on('end', () => {
245
+ res.setHeader('content-type', 'application/json');
246
+ res.end(
247
+ JSON.stringify({
248
+ kind: 'select',
249
+ sql: 'SELECT 1',
250
+ elapsedMs: 7,
251
+ limit: 5,
252
+ limited: false,
253
+ rowCount: 1,
254
+ columns: [{ name: 'value', type: 3 }],
255
+ rows: [{ value: 1 }],
256
+ }),
257
+ );
258
+ });
259
+ return;
260
+ }
261
+
262
+ res.statusCode = 404;
263
+ res.end('not found');
264
+ });
265
+ const port = await listen(server);
266
+
267
+ try {
268
+ createProteumApp(appRoot, { routerPort: port });
269
+
270
+ const result = await runCli(['db', 'query', 'SELECT 1', '--limit', '5'], {
271
+ cwd: appRoot,
272
+ });
273
+ const payload = JSON.parse(result.stdout);
274
+ const body = JSON.parse(receivedBody);
275
+
276
+ assert.equal(result.status, 0);
277
+ assert.equal(body.sql, 'SELECT 1');
278
+ assert.equal(body.limit, 5);
279
+ assert.equal(payload.ok, true);
280
+ assert.equal(payload.data.elapsedMs, 7);
281
+ assert.deepEqual(payload.data.rows, [{ value: 1 }]);
282
+ } finally {
283
+ await closeServer(server);
284
+ }
285
+ });
286
+
287
+ test('runtime status avoids starting a second dev server when the same app owns the port', async () => {
288
+ const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-cli-same-port-'));
289
+ const appRoot = path.join(repoRoot, 'apps', 'product');
290
+ const server = http.createServer((req, res) => {
291
+ if (req.url && req.url.startsWith('/__proteum/explain')) {
292
+ res.setHeader('content-type', 'application/json');
293
+ res.end(
294
+ JSON.stringify({
295
+ app: {
296
+ root: appRoot,
297
+ identity: { identifier: 'ProductApp', name: 'Product' },
298
+ },
299
+ }),
300
+ );
301
+ return;
302
+ }
303
+
304
+ res.setHeader('content-type', 'text/html');
305
+ res.end('<html><body>same app page body that should not be included</body></html>');
306
+ });
307
+ const occupiedPort = await listen(server);
308
+
309
+ try {
310
+ createProteumApp(appRoot, { routerPort: occupiedPort });
311
+
312
+ const result = await runCli(['runtime', 'status'], {
313
+ cwd: appRoot,
314
+ });
315
+ const payload = JSON.parse(result.stdout);
316
+
317
+ assert.equal(result.status, 0);
318
+ assert.equal(payload.data.configuredDevPort.router.matchesApp, true);
319
+ assert.equal(payload.nextActions[0].label, 'Use Existing Runtime');
320
+ assert.match(payload.nextActions[0].command, new RegExp(`proteum diagnose "/" --port ${occupiedPort}`));
321
+ assert.match(payload.nextActions[0].reason, /Do not start a second dev server/);
322
+ assert.equal(payload.nextActions.some((action) => action.label === 'Start Dev'), false);
323
+ assert.doesNotMatch(result.stdout, /same app page body/);
324
+ } finally {
325
+ await closeServer(server);
326
+ }
327
+ });
@@ -0,0 +1,307 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { spawn } = require('node:child_process');
6
+
7
+ const TEST_PROMPT = `Follow this project's agent instructions exactly.
8
+
9
+ Do a read-only runtime orientation and health pass for this app.
10
+
11
+ 1. Confirm the intended app/worktree and whether exactly one usable dev server is running for it. If no usable dev server exists, start one using the project's prescribed dev-session workflow. Do not start a second live server for the same worktree.
12
+ 2. Report compact runtime status: app root, dev URL, package manager, route count, controller count, connected apps, and manifest freshness.
13
+ 3. Resolve selected project instruction files and briefly say why each one was selected.
14
+ 4. Pick one real browser route from the compact project map, prefer a public route; explain which page/controller owns it, then reproduce one request to that route.
15
+ 5. Diagnose that request and summarize only the important result: status, owner, hot calls, SQL count, error events, and likely next action.
16
+ 6. Show the latest trace/perf summary for that same request or route, capped to the smallest useful detail.
17
+ 7. Include recent dev log lines only if they explain a warning or failure.
18
+ 8. Do not edit files. Do not run broad tests unless the runtime evidence points to a concrete failure.
19
+
20
+ Return a compact report with:
21
+ - Runtime
22
+ - Selected Instructions
23
+ - Route Owner
24
+ - Diagnosis
25
+ - Trace/Perf
26
+ - Any Follow-up Needed
27
+ `;
28
+
29
+ const enabled = process.env.PROTEUM_RUN_CODEX_MCP_USAGE_TEST === '1';
30
+ const codexUsageTest = enabled ? test : test.skip;
31
+
32
+ const parsePositiveInteger = (value, fallback) => {
33
+ const parsed = Number.parseInt(String(value || ''), 10);
34
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
35
+ };
36
+
37
+ const createOutputDir = () => {
38
+ const configured = process.env.PROTEUM_CODEX_MCP_USAGE_OUTPUT_DIR;
39
+ if (configured) {
40
+ fs.mkdirSync(configured, { recursive: true });
41
+ return configured;
42
+ }
43
+
44
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-codex-mcp-usage-'));
45
+ };
46
+
47
+ const runCodex = async ({ appRoot, codexCli, outputDir, timeoutMs }) =>
48
+ await new Promise((resolve, reject) => {
49
+ const lastMessageFile = path.join(outputDir, 'last-message.md');
50
+ const child = spawn(
51
+ codexCli,
52
+ [
53
+ 'exec',
54
+ '--json',
55
+ '--color',
56
+ 'never',
57
+ '--cd',
58
+ appRoot,
59
+ '--skip-git-repo-check',
60
+ '--sandbox',
61
+ 'workspace-write',
62
+ '--ask-for-approval',
63
+ 'never',
64
+ '--output-last-message',
65
+ lastMessageFile,
66
+ '-',
67
+ ],
68
+ {
69
+ cwd: appRoot,
70
+ env: {
71
+ ...process.env,
72
+ NO_COLOR: '1',
73
+ },
74
+ stdio: ['pipe', 'pipe', 'pipe'],
75
+ },
76
+ );
77
+ let stdout = '';
78
+ let stderr = '';
79
+ let timedOut = false;
80
+ const timeout = setTimeout(() => {
81
+ timedOut = true;
82
+ child.kill('SIGTERM');
83
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
84
+ }, timeoutMs);
85
+
86
+ child.stdout.on('data', (chunk) => {
87
+ stdout += chunk.toString();
88
+ });
89
+ child.stderr.on('data', (chunk) => {
90
+ stderr += chunk.toString();
91
+ });
92
+ child.stdin.end(TEST_PROMPT);
93
+ child.once('error', reject);
94
+ child.once('close', (status) => {
95
+ clearTimeout(timeout);
96
+ resolve({
97
+ lastMessage: fs.existsSync(lastMessageFile) ? fs.readFileSync(lastMessageFile, 'utf8') : '',
98
+ lastMessageFile,
99
+ status,
100
+ stderr,
101
+ stdout,
102
+ timedOut,
103
+ });
104
+ });
105
+ });
106
+
107
+ const parseJsonl = (content) =>
108
+ content
109
+ .split(/\r?\n/)
110
+ .map((line) => line.trim())
111
+ .filter(Boolean)
112
+ .map((line) => {
113
+ try {
114
+ return { json: JSON.parse(line), line };
115
+ } catch {
116
+ return { json: undefined, line };
117
+ }
118
+ });
119
+
120
+ const walk = (value, visitor, seen = new Set()) => {
121
+ if (value === null || value === undefined) return;
122
+ if (typeof value !== 'object') {
123
+ visitor(value, undefined);
124
+ return;
125
+ }
126
+ if (seen.has(value)) return;
127
+ seen.add(value);
128
+
129
+ if (Array.isArray(value)) {
130
+ for (const entry of value) walk(entry, visitor, seen);
131
+ return;
132
+ }
133
+
134
+ for (const [key, entry] of Object.entries(value)) {
135
+ visitor(entry, key);
136
+ walk(entry, visitor, seen);
137
+ }
138
+ };
139
+
140
+ const tokenKeys = {
141
+ cached: ['cached_input_tokens', 'cachedInputTokens', 'cached_tokens'],
142
+ input: ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens'],
143
+ output: ['output_tokens', 'outputTokens', 'completion_tokens', 'completionTokens'],
144
+ reasoning: ['reasoning_tokens', 'reasoningTokens', 'reasoning_output_tokens', 'reasoningOutputTokens'],
145
+ total: ['total_tokens', 'totalTokens'],
146
+ };
147
+
148
+ const getNumericTokenField = (object, keys) => {
149
+ for (const key of keys) {
150
+ if (typeof object[key] === 'number' && Number.isFinite(object[key])) return object[key];
151
+ }
152
+
153
+ return 0;
154
+ };
155
+
156
+ const collectTokenUsage = (events) => {
157
+ const samples = [];
158
+
159
+ for (const event of events) {
160
+ walk(event.json, (value) => {
161
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return;
162
+ const sample = {
163
+ cached: getNumericTokenField(value, tokenKeys.cached),
164
+ input: getNumericTokenField(value, tokenKeys.input),
165
+ output: getNumericTokenField(value, tokenKeys.output),
166
+ reasoning: getNumericTokenField(value, tokenKeys.reasoning),
167
+ total: getNumericTokenField(value, tokenKeys.total),
168
+ };
169
+ if (!sample.total) sample.total = sample.input + sample.output;
170
+ if (sample.total || sample.input || sample.output || sample.cached || sample.reasoning) samples.push(sample);
171
+ });
172
+ }
173
+
174
+ return samples.reduce(
175
+ (summary, sample) => ({
176
+ cached: Math.max(summary.cached, sample.cached),
177
+ input: Math.max(summary.input, sample.input),
178
+ output: Math.max(summary.output, sample.output),
179
+ reasoning: Math.max(summary.reasoning, sample.reasoning),
180
+ samples: summary.samples + 1,
181
+ total: Math.max(summary.total, sample.total),
182
+ }),
183
+ { cached: 0, input: 0, output: 0, reasoning: 0, samples: 0, total: 0 },
184
+ );
185
+ };
186
+
187
+ const countByName = (names) =>
188
+ names.reduce((counts, name) => {
189
+ counts[name] = (counts[name] || 0) + 1;
190
+ return counts;
191
+ }, {});
192
+
193
+ const collectProteumMcpCalls = (events) => {
194
+ const calls = [];
195
+
196
+ for (const [index, event] of events.entries()) {
197
+ const names = new Set();
198
+ walk(event.json, (value, key) => {
199
+ if (typeof value !== 'string') return;
200
+ if (!['name', 'toolName', 'tool_name', 'recipient', 'recipient_name'].includes(String(key))) return;
201
+
202
+ const match = value.match(/(?:mcp__proteum__|proteum[.:/])([A-Za-z0-9_]+)/);
203
+ if (match) names.add(match[1]);
204
+ });
205
+
206
+ if (names.size === 0 && event.line.includes('mcp__proteum__')) {
207
+ for (const match of event.line.matchAll(/mcp__proteum__([A-Za-z0-9_]+)/g)) names.add(match[1]);
208
+ }
209
+
210
+ for (const name of names) calls.push({ event: index, name });
211
+ }
212
+
213
+ const names = calls.map((call) => call.name);
214
+ return { byName: countByName(names), calls, total: calls.length };
215
+ };
216
+
217
+ const collectProteumCliCalls = (events) => {
218
+ const commands = [];
219
+ const commandPattern = /(?:^|\s)(?:npx\s+)?proteum\s+[A-Za-z0-9:_-]+|node\s+\S*cli\/bin\.js\s+[A-Za-z0-9:_-]+/;
220
+
221
+ for (const [index, event] of events.entries()) {
222
+ const eventCommands = new Set();
223
+ walk(event.json, (value, key) => {
224
+ if (typeof value !== 'string') return;
225
+ if (!['cmd', 'command', 'shell', 'args', 'arguments'].includes(String(key))) return;
226
+ const match = value.match(commandPattern);
227
+ if (match) eventCommands.add(match[0].trim());
228
+ });
229
+
230
+ for (const command of eventCommands) commands.push({ command, event: index });
231
+ }
232
+
233
+ return {
234
+ byCommand: countByName(commands.map((entry) => entry.command)),
235
+ commands,
236
+ total: commands.length,
237
+ };
238
+ };
239
+
240
+ const analyzeCodexOutput = ({ stdout, stderr, lastMessage }) => {
241
+ const events = parseJsonl(stdout);
242
+ const parseErrors = events.filter((event) => !event.json).length;
243
+ const mcpCalls = collectProteumMcpCalls(events);
244
+ const cliCalls = collectProteumCliCalls(events);
245
+
246
+ return {
247
+ cliCalls,
248
+ eventCount: events.length,
249
+ lastMessageCharacters: lastMessage.length,
250
+ mcpCalls,
251
+ parseErrors,
252
+ stderrCharacters: stderr.length,
253
+ tokenUsage: collectTokenUsage(events),
254
+ };
255
+ };
256
+
257
+ codexUsageTest('Codex runtime health prompt uses Proteum MCP before CLI fallbacks', async () => {
258
+ const appRoot = process.env.PROTEUM_CODEX_MCP_USAGE_CWD;
259
+ assert.ok(appRoot, 'Set PROTEUM_CODEX_MCP_USAGE_CWD to the Proteum app root to test.');
260
+ assert.equal(fs.existsSync(appRoot), true, `Proteum app root does not exist: ${appRoot}`);
261
+
262
+ const outputDir = createOutputDir();
263
+ const codexCli = process.env.CODEX_CLI || 'codex';
264
+ const timeoutMs = parsePositiveInteger(process.env.PROTEUM_CODEX_MCP_USAGE_TIMEOUT_MS, 20 * 60 * 1000);
265
+ const minMcpCalls = parsePositiveInteger(process.env.PROTEUM_CODEX_MCP_MIN_MCP_CALLS, 4);
266
+ const maxCliCalls = parsePositiveInteger(process.env.PROTEUM_CODEX_MCP_MAX_CLI_CALLS, 4);
267
+
268
+ const result = await runCodex({ appRoot, codexCli, outputDir, timeoutMs });
269
+ const transcriptFile = path.join(outputDir, 'codex-events.jsonl');
270
+ const stderrFile = path.join(outputDir, 'stderr.txt');
271
+ const summaryFile = path.join(outputDir, 'summary.json');
272
+ fs.writeFileSync(transcriptFile, result.stdout);
273
+ fs.writeFileSync(stderrFile, result.stderr);
274
+
275
+ const summary = analyzeCodexOutput(result);
276
+ fs.writeFileSync(
277
+ summaryFile,
278
+ JSON.stringify(
279
+ {
280
+ ...summary,
281
+ appRoot,
282
+ codexCli,
283
+ status: result.status,
284
+ timedOut: result.timedOut,
285
+ transcriptFile,
286
+ stderrFile,
287
+ lastMessageFile: result.lastMessageFile,
288
+ },
289
+ null,
290
+ 2,
291
+ ),
292
+ );
293
+
294
+ assert.equal(result.timedOut, false, `Codex CLI timed out. Summary: ${summaryFile}`);
295
+ assert.equal(result.status, 0, `Codex CLI exited with ${result.status}. Summary: ${summaryFile}`);
296
+ assert.equal(summary.parseErrors, 0, `Codex JSONL output contained parse errors. Summary: ${summaryFile}`);
297
+ assert.ok(summary.tokenUsage.samples > 0, `Codex JSONL output did not include token usage. Summary: ${summaryFile}`);
298
+ assert.ok(summary.tokenUsage.total > 0, `Codex token usage was not quantified. Summary: ${summaryFile}`);
299
+ assert.ok(summary.mcpCalls.total >= minMcpCalls, `Expected at least ${minMcpCalls} Proteum MCP calls. Summary: ${summaryFile}`);
300
+ assert.ok(
301
+ (summary.mcpCalls.byName.workflow_start || 0) >= 1,
302
+ `Expected at least one Proteum MCP workflow_start call. Summary: ${summaryFile}`,
303
+ );
304
+ assert.ok(summary.cliCalls.total <= maxCliCalls, `Expected at most ${maxCliCalls} Proteum CLI calls. Summary: ${summaryFile}`);
305
+
306
+ console.log(`Codex MCP usage summary: ${summaryFile}`);
307
+ }, 25 * 60 * 1000);