impel-cli 0.20.13 → 0.20.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -180,6 +180,21 @@ impel claude --model opus
180
180
  impel codex exec "review this repository"
181
181
  ```
182
182
 
183
+ To run one explicitly selected tenant agent in a single Codex thread, select
184
+ it by exact agent ID (preferred) or unique exact title:
185
+
186
+ ```sh
187
+ impel codex --agent research-agent exec "answer this question"
188
+ ```
189
+
190
+ This path loads a generated, tenant-bound profile with Luna/low reasoning, a
191
+ read-only local sandbox, the exact fixed MCP binding, and only that binding's
192
+ answer/run, resume, and recovery tools. It omits the normal parent specialist
193
+ instructions, so there is no parent spawn, parent wait, or relay turn. Model,
194
+ profile, developer-instruction, MCP, approval, sandbox, and feature overrides
195
+ are rejected with `--agent`. The selected tenant and profile integrity must
196
+ match before Codex starts. Ordinary `impel codex` passthrough is unchanged.
197
+
183
198
  ## Remote Fargate sessions
184
199
 
185
200
  Remote operations use the tenant broker. The laptop sends its PAT only to that
@@ -496,6 +511,7 @@ impel setup
496
511
  impel update
497
512
  impel claude
498
513
  impel codex
514
+ impel codex --agent <id|exact-title> ...
499
515
  impel tenant list|current|use
500
516
  impel tasks ...
501
517
  impel pat ...
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.15 — Direct Codex native agents
4
+
5
+ - Classifies the exact `mcp__impel_agent` namespace as direct-only in generated
6
+ Codex 0.146.0 agents, removing child code cells and code-mode waits while
7
+ preserving the fixed tool allowlist and recovery contract.
8
+ - Adds `impel codex --agent <id|exact-title>` with integrity-tracked top-level
9
+ profiles, removing the parent spawn/wait/relay thread for explicit CLI runs.
10
+ - Bumps managed agent and app profile contracts so already-fresh isolated CLI
11
+ and ChatGPT profiles regenerate immediately after update.
12
+ - Adds a private-by-default production profiler and structural analyzer with
13
+ exact-tenant checks, strict JSONL, process-tree cancellation, original versus
14
+ replacement cohorts, and prompt/answer-free aggregate reports.
15
+ - Refreshes the exact Windows ChatGPT/Codex Store build after the production
16
+ manifest advanced during release qualification.
17
+
18
+ ## 0.20.14 — Opaque direct-answer continuations
19
+
20
+ - Returns a minimal local continuation locator for read-only direct answers,
21
+ so wrapper models no longer need to copy policy or request fingerprints.
22
+ - Resolves the locator against integrity-validated tenant-local state and the
23
+ MCP server's fixed agent binding; durable and write-capable handles retain
24
+ their full fail-closed validation.
25
+ - Adds a regression test for the production case where Claude shortened a
26
+ request fingerprint while preserving the invocation id.
27
+
3
28
  ## 0.20.13 — Allocation publication race
4
29
 
5
30
  - Retries allocation when a competing process moves the published lock between
@@ -8,8 +8,8 @@ is treated as an eager host.
8
8
  | --- | --- | --- | --- | --- |
9
9
  | Claude Code | 2.1.212 | Yes, agent frontmatter `tools` | Eager | The bound run/answer, resume, and recovery tools are direct child tools. No catalog discovery or code-cell polling is required. |
10
10
  | Claude Desktop | 1.24012.9 (embedded Code 2.1.219) | Yes, agent frontmatter `tools` | Eager | Same direct-tool contract as Claude Code. |
11
- | Codex CLI | 0.146.0 | No; MCP tools remain code-mode-only | Durable fallback | `enabled_tools` narrows the server contract but the host still invokes through code mode. Discovery/polling overhead is measured separately and is not classified as eager completion. |
12
- | ChatGPT desktop Codex | 26.727.40816 (embedded Codex alpha) | No; MCP tools remain code-mode-only | Durable fallback | Uses the same bounded durable composite and recovery state as Codex CLI. |
11
+ | Codex CLI through `impel codex` | 0.146.0 | Yes, with `features.code_mode.direct_only_tool_namespaces = ["mcp__impel_agent"]` | Eager; optional single-thread profile | Existing `@Agent` children call the fixed MCP namespace directly. `impel codex --agent` also removes the parent spawn/wait/relay thread. |
12
+ | ChatGPT desktop Codex | 26.727.40816 (embedded Codex 0.146.0 contract) | Yes, with the same exact native namespace | Eager child, compatible parent | Keeps the app's supported `@Agent` parent/child route, but the child no longer needs code cells or code-mode waits. |
13
13
 
14
14
  ## Qualified contract
15
15
 
@@ -28,11 +28,19 @@ is treated as an eager host.
28
28
  decrypted provider material. Production measurements use correlation ids,
29
29
  phase timestamps, counts, durations, build identifiers, and terminal classes
30
30
  only.
31
+ - Top-level direct profiles exist only in Impel-isolated Codex and managed
32
+ ChatGPT homes. Bare/native Codex homes do not receive the 0.146.0-only key or
33
+ the new top-level profiles. `impel codex` resolves only the reviewed pinned
34
+ binary; an older or newer unqualified build must be updated and re-qualified
35
+ rather than receiving an unknown configuration key.
36
+ - `mcp__impel_agent` is the native tool namespace. The server id
37
+ `impel_agent` is not equivalent and does not qualify as direct exposure.
31
38
 
32
39
  ## Requalification
33
40
 
34
- Re-run temporary-home profile parsing, direct tool visibility, cancellation,
41
+ Re-run temporary-home profile parsing (including active, direct-answer,
42
+ durable, and recovery profiles), direct tool visibility, cancellation,
35
43
  typed continuation, byte-exact relay, and the 10/30/45/60/120-second bounded
36
- wait fixture after a pinned client changes. A build stays on durable fallback
37
- unless the child trace has zero `ALL_TOOLS`, `functions.exec`, and
38
- `functions.wait` entries.
44
+ wait fixture after a pinned client changes. A build stays fail-closed without
45
+ the direct-only key unless the child trace has zero `ALL_TOOLS`, `exec`, and
46
+ `wait` entries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.13",
3
+ "version": "0.20.15",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "bin",
16
16
  "docs/experimental-managed-cursor.md",
17
17
  "docs/native-agent-host-capability-matrix.md",
18
+ "scripts",
18
19
  "src",
19
20
  "README.md"
20
21
  ],
@@ -31,6 +32,8 @@
31
32
  "url": "git+https://github.com/UseImpel/impel-cli.git"
32
33
  },
33
34
  "scripts": {
35
+ "analyze:native-codex": "node scripts/analyze-native-codex.mjs",
36
+ "profile:native-codex": "node scripts/profile-native-codex.mjs",
34
37
  "test": "node --test"
35
38
  }
36
39
  }
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const AGGREGATE_SCHEMA = "impel.native-codex-profile.v1";
8
+ const REPORT_SCHEMA = "impel.native-codex-analysis.v1";
9
+ const SAFE_MCP_TOOLS = new Set([
10
+ "answer_native_agent",
11
+ "run_native_agent",
12
+ "resume_native_agent_run",
13
+ "recover_native_agent_runs",
14
+ ]);
15
+ const SAFE_TOOL_NAMES = new Set([
16
+ ...SAFE_MCP_TOOLS,
17
+ "spawn_agent",
18
+ "wait_agent",
19
+ "send_message",
20
+ "followup_task",
21
+ "interrupt_agent",
22
+ "exec",
23
+ "wait",
24
+ "tool_search",
25
+ "ALL_TOOLS",
26
+ ]);
27
+
28
+ function finiteNonnegative(value) {
29
+ return Number.isFinite(value) && value >= 0 ? value : null;
30
+ }
31
+
32
+ function integerNonnegative(value) {
33
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
34
+ }
35
+
36
+ export function parseStrictJsonLines(text, label = "JSONL") {
37
+ const records = [];
38
+ for (const [index, line] of text.split(/\r?\n/u).entries()) {
39
+ if (!line.trim()) continue;
40
+ let value;
41
+ try {
42
+ value = JSON.parse(line);
43
+ } catch {
44
+ throw new Error(`${label} line ${index + 1} is not valid JSON`);
45
+ }
46
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
47
+ throw new Error(`${label} line ${index + 1} is not a JSON object`);
48
+ }
49
+ records.push(value);
50
+ }
51
+ return records;
52
+ }
53
+
54
+ function safeEventType(value) {
55
+ return typeof value === "string" && /^[A-Za-z0-9_.:-]{1,120}$/u.test(value)
56
+ ? value
57
+ : null;
58
+ }
59
+
60
+ function safeToolName(value) {
61
+ if (typeof value !== "string") return null;
62
+ const unprefixed = value.replace(/^mcp__impel_agent__/u, "");
63
+ return SAFE_TOOL_NAMES.has(unprefixed) ? unprefixed : null;
64
+ }
65
+
66
+ function eventItem(event) {
67
+ return event.item && typeof event.item === "object" && !Array.isArray(event.item)
68
+ ? event.item
69
+ : event;
70
+ }
71
+
72
+ function eventTool(item) {
73
+ const server = item.server || item.server_name || item.mcp_server;
74
+ const tool = item.tool || item.tool_name || item.name;
75
+ if (server === "impel_agent" && SAFE_MCP_TOOLS.has(tool)) return tool;
76
+ return safeToolName(tool);
77
+ }
78
+
79
+ function increment(counts, key) {
80
+ if (key) counts[key] = (counts[key] || 0) + 1;
81
+ }
82
+
83
+ export function summarizeCodexEvents(events) {
84
+ const eventTypes = {};
85
+ const toolCounts = {};
86
+ const threads = new Set();
87
+ let inputTokens = 0;
88
+ let cachedInputTokens = 0;
89
+ let outputTokens = 0;
90
+ let turnCompleted = false;
91
+ let errorEvents = 0;
92
+ let codeCells = 0;
93
+ let codeWaits = 0;
94
+
95
+ for (const event of events) {
96
+ const type = safeEventType(event.type);
97
+ increment(eventTypes, type);
98
+ const threadId = event.thread_id || event.threadId;
99
+ if (typeof threadId === "string" && /^[A-Za-z0-9_.:-]{1,200}$/u.test(threadId)) threads.add(threadId);
100
+ if (type === "turn.completed") turnCompleted = true;
101
+ if (type?.includes("error") || event.error) errorEvents += 1;
102
+
103
+ const item = eventItem(event);
104
+ const itemType = safeEventType(item.type);
105
+ const tool = eventTool(item);
106
+ increment(toolCounts, tool);
107
+ if (itemType && /(?:code_mode|command_execution|code_cell)/u.test(itemType)) codeCells += 1;
108
+ if (tool === "exec") codeCells += 1;
109
+ if (tool === "wait") codeWaits += 1;
110
+
111
+ const usage = event.usage && typeof event.usage === "object" ? event.usage : null;
112
+ if (usage) {
113
+ inputTokens = Math.max(inputTokens, integerNonnegative(usage.input_tokens) || 0);
114
+ cachedInputTokens = Math.max(cachedInputTokens, integerNonnegative(usage.cached_input_tokens) || 0);
115
+ outputTokens = Math.max(outputTokens, integerNonnegative(usage.output_tokens) || 0);
116
+ }
117
+ }
118
+
119
+ return {
120
+ eventCount: events.length,
121
+ eventTypes,
122
+ toolCounts,
123
+ threadCount: threads.size,
124
+ turnCompleted,
125
+ errorEvents,
126
+ codeCells,
127
+ codeWaits,
128
+ inputTokens,
129
+ cachedInputTokens,
130
+ outputTokens,
131
+ };
132
+ }
133
+
134
+ function rolloutSessionMetadata(events) {
135
+ const metadata = events.find((event) => event.type === "session_meta")?.payload;
136
+ return metadata && typeof metadata === "object" ? {
137
+ threadId: typeof (metadata.session_id || metadata.id) === "string"
138
+ ? metadata.session_id || metadata.id
139
+ : null,
140
+ parentThreadId: typeof metadata.parent_thread_id === "string" ? metadata.parent_thread_id : null,
141
+ } : { threadId: null, parentThreadId: null };
142
+ }
143
+
144
+ export function summarizeCodexRollouts(rollouts) {
145
+ const toolCounts = {};
146
+ const parentToolCounts = {};
147
+ const childToolCounts = {};
148
+ let eventCount = 0;
149
+ let codeCells = 0;
150
+ let codeWaits = 0;
151
+ let inputTokens = 0;
152
+ let cachedInputTokens = 0;
153
+ let outputTokens = 0;
154
+
155
+ for (const events of rollouts) {
156
+ eventCount += events.length;
157
+ const metadata = rolloutSessionMetadata(events);
158
+ const scopedCounts = metadata.parentThreadId ? childToolCounts : parentToolCounts;
159
+ let latestUsage = null;
160
+ for (const event of events) {
161
+ const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
162
+ if (event.type === "response_item"
163
+ && ["function_call", "custom_tool_call"].includes(payload.type)) {
164
+ const tool = safeToolName(payload.name);
165
+ increment(toolCounts, tool);
166
+ increment(scopedCounts, tool);
167
+ if (tool === "exec" || payload.type === "custom_tool_call") codeCells += tool === "exec" ? 1 : 0;
168
+ if (tool === "wait") codeWaits += 1;
169
+ }
170
+ if (event.type === "event_msg" && payload.type === "token_count") {
171
+ const usage = payload.info?.total_token_usage;
172
+ if (usage && typeof usage === "object") latestUsage = usage;
173
+ }
174
+ }
175
+ if (latestUsage) {
176
+ inputTokens += integerNonnegative(latestUsage.input_tokens) || 0;
177
+ cachedInputTokens += integerNonnegative(latestUsage.cached_input_tokens) || 0;
178
+ outputTokens += integerNonnegative(latestUsage.output_tokens) || 0;
179
+ }
180
+ }
181
+
182
+ return {
183
+ eventCount,
184
+ threadCount: rollouts.length,
185
+ toolCounts,
186
+ parentToolCounts,
187
+ childToolCounts,
188
+ codeCells,
189
+ codeWaits,
190
+ inputTokens,
191
+ cachedInputTokens,
192
+ outputTokens,
193
+ };
194
+ }
195
+
196
+ export function summarizeNativeTelemetry(events) {
197
+ const eventCounts = {};
198
+ const toolCounts = {};
199
+ const correlations = new Set();
200
+ const gatewayRequests = new Set();
201
+ let mcpDurationMs = 0;
202
+ let localFailures = 0;
203
+ let upstreamDurationMs = 0;
204
+ for (const event of events) {
205
+ if (event.schema !== "impel.native-agent-telemetry.v1") {
206
+ throw new Error("native-agent telemetry has an unexpected schema");
207
+ }
208
+ const type = safeEventType(event.event);
209
+ increment(eventCounts, type);
210
+ const tool = safeToolName(event.tool);
211
+ increment(toolCounts, tool);
212
+ if (typeof event.correlationId === "string") correlations.add(event.correlationId);
213
+ if (typeof event.gatewayRequestId === "string") gatewayRequests.add(event.gatewayRequestId);
214
+ const duration = integerNonnegative(event.durationMs) || 0;
215
+ if (type === "local_tool_completed" && SAFE_MCP_TOOLS.has(tool)) {
216
+ mcpDurationMs += duration;
217
+ if (["failed", "cancelled"].includes(event.outcome)) localFailures += 1;
218
+ }
219
+ if (type === "upstream_request_completed") upstreamDurationMs += duration;
220
+ }
221
+ return {
222
+ eventCount: events.length,
223
+ eventCounts,
224
+ toolCounts,
225
+ correlationCount: correlations.size,
226
+ gatewayRequestCount: gatewayRequests.size,
227
+ mcpDurationMs,
228
+ upstreamDurationMs,
229
+ localFailures,
230
+ };
231
+ }
232
+
233
+ export function summarizeRawAttempt({ stdoutText, telemetryText = "", rolloutTexts = [] }) {
234
+ const codexEvents = parseStrictJsonLines(stdoutText, "Codex stdout");
235
+ const telemetryEvents = telemetryText.trim()
236
+ ? parseStrictJsonLines(telemetryText, "native-agent telemetry")
237
+ : [];
238
+ const stdout = summarizeCodexEvents(codexEvents);
239
+ const rollouts = rolloutTexts.map((text, index) => parseStrictJsonLines(text, `Codex rollout ${index + 1}`));
240
+ const rollout = summarizeCodexRollouts(rollouts);
241
+ return {
242
+ codex: {
243
+ ...stdout,
244
+ ...(rollouts.length ? {
245
+ eventCount: rollout.eventCount,
246
+ toolCounts: rollout.toolCounts,
247
+ threadCount: rollout.threadCount,
248
+ codeCells: rollout.codeCells,
249
+ codeWaits: rollout.codeWaits,
250
+ inputTokens: rollout.inputTokens,
251
+ cachedInputTokens: rollout.cachedInputTokens,
252
+ outputTokens: rollout.outputTokens,
253
+ } : {}),
254
+ parentToolCounts: rollout.parentToolCounts,
255
+ childToolCounts: rollout.childToolCounts,
256
+ },
257
+ telemetry: summarizeNativeTelemetry(telemetryEvents),
258
+ };
259
+ }
260
+
261
+ function percentile(values, probability) {
262
+ if (values.length === 0) return null;
263
+ const sorted = [...values].sort((left, right) => left - right);
264
+ if (sorted.length === 1) return sorted[0];
265
+ const position = (sorted.length - 1) * probability;
266
+ const lower = Math.floor(position);
267
+ const upper = Math.ceil(position);
268
+ const weight = position - lower;
269
+ return Math.round((sorted[lower] * (1 - weight) + sorted[upper] * weight) * 1000) / 1000;
270
+ }
271
+
272
+ function distribution(attempts, field) {
273
+ const values = attempts.map((attempt) => finiteNonnegative(attempt[field])).filter((value) => value !== null);
274
+ return { samples: values.length, p50: percentile(values, 0.5), p95: percentile(values, 0.95) };
275
+ }
276
+
277
+ function mergedCounts(attempts, field) {
278
+ const counts = {};
279
+ for (const attempt of attempts) {
280
+ for (const [key, value] of Object.entries(attempt[field] || {})) {
281
+ if (SAFE_TOOL_NAMES.has(key) && Number.isSafeInteger(value) && value >= 0) {
282
+ counts[key] = (counts[key] || 0) + value;
283
+ }
284
+ }
285
+ }
286
+ return counts;
287
+ }
288
+
289
+ function groupKey(attempt) {
290
+ return `${attempt.tenantId}\0${attempt.sloClass}\0${attempt.mode}`;
291
+ }
292
+
293
+ function directGate(report) {
294
+ return {
295
+ reliability: report.original.total === 30 && report.original.successful === 30,
296
+ e2e: report.timings.durationMs.p50 !== null
297
+ && report.timings.durationMs.p50 <= (report.sloClass === "cibi" ? 40_000 : 90_000)
298
+ && report.timings.durationMs.p95 <= (report.sloClass === "cibi" ? 50_000 : 105_000),
299
+ overhead: report.timings.nonMcpOverheadMs.p50 !== null
300
+ && report.timings.nonMcpOverheadMs.p50 <= 25_000
301
+ && report.timings.nonMcpOverheadMs.p95 <= 35_000,
302
+ input: report.tokens.input.p50 !== null
303
+ && report.tokens.input.p50 <= 45_000
304
+ && report.tokens.input.p95 <= 55_000,
305
+ structure: report.structure.maximumThreadCount <= 1
306
+ && report.structure.totalCodeCells === 0
307
+ && report.structure.totalCodeWaits === 0
308
+ && ["spawn_agent", "wait_agent", "send_message", "exec", "wait", "tool_search", "ALL_TOOLS"]
309
+ .every((tool) => !report.structure.allToolCounts[tool])
310
+ && (report.structure.toolCounts.answer_native_agent || 0) === report.successfulSamples
311
+ && !report.structure.toolCounts.run_native_agent,
312
+ };
313
+ }
314
+
315
+ function compatibleGate(report) {
316
+ return {
317
+ reliability: report.original.total === 30 && report.original.successful >= 29,
318
+ e2e: report.timings.durationMs.p50 !== null
319
+ && report.timings.durationMs.p50 <= (report.sloClass === "cibi" ? 55_000 : 100_000),
320
+ childStructure: report.structure.totalCodeCells === 0
321
+ && report.structure.totalCodeWaits === 0
322
+ && ["exec", "wait", "send_message", "tool_search", "ALL_TOOLS"]
323
+ .every((tool) => !report.structure.childToolCounts[tool]),
324
+ };
325
+ }
326
+
327
+ export function analyzeAggregates(aggregates) {
328
+ for (const aggregate of aggregates) {
329
+ if (!aggregate || aggregate.schema !== AGGREGATE_SCHEMA || !Array.isArray(aggregate.attempts)) {
330
+ throw new Error("profile input has an unexpected schema");
331
+ }
332
+ }
333
+ const groups = new Map();
334
+ for (const attempt of aggregates.flatMap((aggregate) => aggregate.attempts)) {
335
+ if (!attempt || typeof attempt !== "object") throw new Error("profile input has an invalid attempt");
336
+ const key = groupKey(attempt);
337
+ if (!groups.has(key)) groups.set(key, []);
338
+ groups.get(key).push(attempt);
339
+ }
340
+
341
+ const reports = [...groups.values()].map((attempts) => {
342
+ const original = attempts.filter((attempt) => attempt.cohort === "original");
343
+ const replacements = attempts.filter((attempt) => attempt.cohort === "replacement");
344
+ const successful = attempts.filter((attempt) => attempt.success === true);
345
+ const report = {
346
+ tenantId: attempts[0].tenantId,
347
+ sloClass: attempts[0].sloClass,
348
+ mode: attempts[0].mode,
349
+ original: {
350
+ total: original.length,
351
+ successful: original.filter((attempt) => attempt.success === true).length,
352
+ },
353
+ replacements: {
354
+ total: replacements.length,
355
+ successful: replacements.filter((attempt) => attempt.success === true).length,
356
+ },
357
+ latencySamples: {
358
+ original: original.filter((attempt) => attempt.success === true).length,
359
+ replacement: replacements.filter((attempt) => attempt.success === true).length,
360
+ },
361
+ successfulSamples: successful.length,
362
+ timings: {
363
+ durationMs: distribution(successful, "durationMs"),
364
+ mcpDurationMs: distribution(successful, "mcpDurationMs"),
365
+ nonMcpOverheadMs: distribution(successful, "nonMcpOverheadMs"),
366
+ },
367
+ tokens: {
368
+ input: distribution(successful, "inputTokens"),
369
+ cachedInput: distribution(successful, "cachedInputTokens"),
370
+ output: distribution(successful, "outputTokens"),
371
+ },
372
+ structure: {
373
+ toolCounts: mergedCounts(successful, "toolCounts"),
374
+ allToolCounts: mergedCounts(attempts, "toolCounts"),
375
+ parentToolCounts: mergedCounts(successful, "parentToolCounts"),
376
+ childToolCounts: mergedCounts(successful, "childToolCounts"),
377
+ maximumThreadCount: successful.reduce((maximum, attempt) => Math.max(maximum, attempt.threadCount || 0), 0),
378
+ totalCodeCells: successful.reduce((total, attempt) => total + (attempt.codeCells || 0), 0),
379
+ totalCodeWaits: successful.reduce((total, attempt) => total + (attempt.codeWaits || 0), 0),
380
+ },
381
+ failures: {
382
+ timeout: attempts.filter((attempt) => attempt.timedOut).length,
383
+ invalidJson: attempts.filter((attempt) => !attempt.jsonValid).length,
384
+ descendantsRemaining: attempts.filter((attempt) => attempt.descendantsRemaining === true).length,
385
+ },
386
+ };
387
+ report.gates = report.mode === "direct" ? directGate(report) : compatibleGate(report);
388
+ report.gatesPassed = Object.values(report.gates).every(Boolean);
389
+ return report;
390
+ }).sort((left, right) => left.tenantId.localeCompare(right.tenantId) || left.mode.localeCompare(right.mode));
391
+
392
+ return { schema: REPORT_SCHEMA, generatedAt: new Date().toISOString(), reports };
393
+ }
394
+
395
+ function parseArguments(argv) {
396
+ const inputs = [];
397
+ for (let index = 0; index < argv.length; index += 1) {
398
+ if (argv[index] === "--input") {
399
+ const value = argv[index += 1];
400
+ if (!value) throw new Error("--input requires an aggregate JSON path");
401
+ inputs.push(value);
402
+ } else if (argv[index] === "--help" || argv[index] === "-h") {
403
+ return { help: true, inputs: [] };
404
+ } else {
405
+ inputs.push(argv[index]);
406
+ }
407
+ }
408
+ if (inputs.length === 0) throw new Error("provide at least one aggregate JSON path");
409
+ return { help: false, inputs };
410
+ }
411
+
412
+ async function main(argv) {
413
+ const parsed = parseArguments(argv);
414
+ if (parsed.help) {
415
+ process.stdout.write("Usage: node scripts/analyze-native-codex.mjs --input <aggregate.json> [...]\n");
416
+ return;
417
+ }
418
+ const aggregates = parsed.inputs.map((input) => JSON.parse(fs.readFileSync(path.resolve(input), "utf8")));
419
+ process.stdout.write(`${JSON.stringify(analyzeAggregates(aggregates), null, 2)}\n`);
420
+ }
421
+
422
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
423
+ if (isMain) main(process.argv.slice(2)).catch((error) => {
424
+ process.stderr.write(`analyze-native-codex: ${error.message}\n`);
425
+ process.exitCode = 1;
426
+ });