model-orchestrator 0.1.22 → 0.1.24
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/CHANGELOG.md +27 -1
- package/README.md +10 -7
- package/bin/cli-run.mjs +488 -48
- package/docs/audit-brief.md +20 -0
- package/docs/catalog.md +9 -1
- package/docs/part-1-beginner.md +5 -1
- package/docs/part-2-intermediate.md +5 -1
- package/docs/part-3-advanced.md +4 -0
- package/package.json +1 -1
- package/src/catalog.js +12 -0
- package/src/install.js +2 -0
- package/templates/README.md +2 -2
- package/templates/advanced/vm/README.md +1 -0
- package/templates/advanced/vm/jobs/weekly-audit.sh +1 -1
- package/templates/beginner/ORCHESTRATOR.md +6 -2
- package/templates/common/README.md +2 -0
- package/templates/common/protocols/README.md +2 -1
- package/templates/common/protocols/docs-then-prove.md +26 -0
- package/templates/intermediate/CLI-RUN.md +37 -13
- package/templates/intermediate/ROUTING.md +4 -0
- package/templates/tools/README.md +2 -1
- package/templates/tools/context7/CONTEXT7.md +82 -0
- package/templates/tools/context7/mcp/context7.agy.mcp_config.json +7 -0
- package/templates/tools/context7/mcp/context7.claude-code.mcp.json +8 -0
- package/templates/tools/context7/mcp/context7.codex.config.toml +3 -0
- package/templates/tools/context7/mcp/context7.mcpServers.json +7 -0
- package/templates/tools/context7/mcp/context7.qwen.settings.json +8 -0
- package/templates/tools/context7/mcp/context7.vscode.mcp.json +8 -0
- package/templates/tools/context7/mcp/context7.zed.settings.json +9 -0
package/bin/cli-run.mjs
CHANGED
|
@@ -27,18 +27,30 @@
|
|
|
27
27
|
// bounded by the OS ARG_MAX, so: no secrets in a prompt, and very large briefs
|
|
28
28
|
// should be referenced by path in the prompt rather than pasted into it.
|
|
29
29
|
//
|
|
30
|
-
// Exit codes
|
|
31
|
-
// 0 structurally accepted non-empty response (and every --expect-* contract met)
|
|
32
|
-
// 10 ran
|
|
33
|
-
// 11 produced no output at all
|
|
34
|
-
// 12
|
|
35
|
-
// 13
|
|
30
|
+
// Exit codes: one per failure CLASS, so the code says what to do next.
|
|
31
|
+
// 0 ok: structurally accepted non-empty response (and every --expect-* contract met)
|
|
32
|
+
// 10 empty: ran and delivered nothing, or a contract was not met
|
|
33
|
+
// 11 no_output: produced no output at all
|
|
34
|
+
// 12 timeout: the lane AND its descendants are killed as a process group
|
|
35
|
+
// 13 unavailable: missing binary, disabled in lanes.json, or lanes.json malformed
|
|
36
|
+
// 14 auth: the lane's own error says a credential is missing or not logged in
|
|
37
|
+
// 15 quota: the lane's own error says usage limit, credits or rate limit
|
|
38
|
+
// 16 rejected: the upstream rejected the request (bad model id, bad request)
|
|
39
|
+
// 17 refused: no deliverable, and the lane reports tool calls a hook or deny rule blocked
|
|
40
|
+
// 18 cut_short: no trustworthy finish: a missing or non-success terminal event, a lane
|
|
41
|
+
// killed by a signal, output past the 16 MiB buffer, or a nonzero vendor exit
|
|
36
42
|
// 130 / 143 cli-run itself received SIGINT / SIGTERM: the lane's process group was killed first
|
|
37
43
|
// 2 usage error in cli-run itself
|
|
38
|
-
//
|
|
44
|
+
// A run that delivered AND had tool calls refused is still 0, with refused=N and a
|
|
45
|
+
// problem/fix pair on the terminal. The vendor's own exit code is logged as cli_rc.
|
|
46
|
+
// Precedence when several signals are present: auth, quota, rejected, refused,
|
|
47
|
+
// cut_short, empty. Only the lane's authoritative error fields are searched,
|
|
48
|
+
// never the model's prose, so an answer that merely mentions "rate limit" is
|
|
49
|
+
// not a quota failure.
|
|
39
50
|
//
|
|
40
|
-
// The durable log stores a FIXED reason code per run (see REASONS
|
|
41
|
-
// provider-supplied string. Bounded vendor stderr
|
|
51
|
+
// The durable log stores a FIXED reason code and class per run (see REASONS,
|
|
52
|
+
// CLASS_CODES), never a provider-supplied string. Bounded vendor stderr and the
|
|
53
|
+
// problem/fix lines go to your terminal only, redacted.
|
|
42
54
|
//
|
|
43
55
|
// ROUTE: which model and reasoning effort a lane ran with.
|
|
44
56
|
// A lane with no --model and no lanes.json default inherits whatever its own
|
|
@@ -55,12 +67,21 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
55
67
|
import { StringDecoder } from 'node:string_decoder';
|
|
56
68
|
import { createHash } from 'node:crypto';
|
|
57
69
|
import { readFileSync, existsSync, mkdirSync, appendFileSync, mkdtempSync, rmSync, accessSync, constants, realpathSync, statSync, lstatSync, openSync, readSync, closeSync } from 'node:fs';
|
|
58
|
-
import { join, dirname, delimiter, resolve } from 'node:path';
|
|
70
|
+
import { join, dirname, delimiter, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
59
71
|
import { tmpdir, homedir } from 'node:os';
|
|
60
72
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
73
|
|
|
62
74
|
export const LANES = ['grok', 'codex', 'agy', 'hermes', 'qwen'];
|
|
63
75
|
export const OK = 0, NO_DELIVERABLE = 10, NO_OUTPUT = 11, TIMEOUT = 12, UNAVAILABLE = 13, USAGE = 2;
|
|
76
|
+
export const AUTH = 14, QUOTA = 15, REJECTED = 16, REFUSED = 17, CUT_SHORT = 18;
|
|
77
|
+
|
|
78
|
+
// The closed set of failure classes. Every judged run lands in exactly one, and
|
|
79
|
+
// the class owns the exit code. `interrupted` (cli-run itself was signalled)
|
|
80
|
+
// is logged as a class but exits 130 or 143.
|
|
81
|
+
export const CLASS_CODES = {
|
|
82
|
+
ok: OK, empty: NO_DELIVERABLE, no_output: NO_OUTPUT, timeout: TIMEOUT, unavailable: UNAVAILABLE,
|
|
83
|
+
auth: AUTH, quota: QUOTA, rejected: REJECTED, refused: REFUSED, cut_short: CUT_SHORT
|
|
84
|
+
};
|
|
64
85
|
|
|
65
86
|
// Every reason that may reach the durable log. A judge or the wrapper picks
|
|
66
87
|
// one of these; anything else is written as 'unknown'. Provider text never
|
|
@@ -69,8 +90,8 @@ export const REASONS = new Set([
|
|
|
69
90
|
'ok', 'not_json', 'bad_stop_reason', 'empty_text', 'no_terminal_event', 'empty_output_file',
|
|
70
91
|
'bad_status', 'empty_response', 'exit_nonzero', 'empty_stdout', 'bad_event_array', 'bad_last_event',
|
|
71
92
|
'not_result', 'bad_subtype', 'is_error', 'result_not_string', 'empty_result', 'api_error_in_result',
|
|
72
|
-
'telemetry_absent', 'total_errors_unreadable', 'total_errors', 'contract_unmet',
|
|
73
|
-
'timeout', 'unavailable', 'killed', 'disabled', 'lanes_json_malformed', 'no_output', 'unknown'
|
|
93
|
+
'telemetry_absent', 'total_errors_unreadable', 'total_errors', 'contract_unmet', 'error_message_not_string',
|
|
94
|
+
'judge_raised', 'timeout', 'unavailable', 'killed', 'disabled', 'lanes_json_malformed', 'no_output', 'unknown'
|
|
74
95
|
]);
|
|
75
96
|
|
|
76
97
|
const LOG = join(homedir(), '.ai-orchestrator', 'cli-run.log.jsonl');
|
|
@@ -163,7 +184,8 @@ export function judgeCodex(rc, out, err, fileText) {
|
|
|
163
184
|
export function judgeAgy(rc, out) {
|
|
164
185
|
const ev = lastJsonLine(out, '"result"', (o) => o && o.event === 'result');
|
|
165
186
|
if (ev === null) return fail('no_terminal_event', 'no terminal result event');
|
|
166
|
-
|
|
187
|
+
if (!ev.result || typeof ev.result !== 'object' || Array.isArray(ev.result)) return fail('bad_last_event', 'terminal result was not an object');
|
|
188
|
+
const term = ev.result;
|
|
167
189
|
const status = term.status;
|
|
168
190
|
const text = typeof term.response === 'string' ? term.response.trim() : '';
|
|
169
191
|
if (status !== 'SUCCESS') return fail('bad_status', `status=${JSON.stringify(status)}`);
|
|
@@ -173,7 +195,12 @@ export function judgeAgy(rc, out) {
|
|
|
173
195
|
export function judgeHermes(rc, out, err) {
|
|
174
196
|
const text = String(out || '').trim();
|
|
175
197
|
if (rc !== 0) {
|
|
176
|
-
|
|
198
|
+
let why = { 1: 'no final response (agent produced nothing)', 2: 'bad args, or completed with an empty response' }[rc] || 'unknown failure';
|
|
199
|
+
// hermes collapses every upstream failure into one exit code; its stderr
|
|
200
|
+
// is the only place the cause is named.
|
|
201
|
+
const blob = String(err || '').toLowerCase(); // stderr only: stdout is the agent's own prose
|
|
202
|
+
if (hermesQuotaText(blob)) why += ': upstream free tier degraded or limited, a retry is reasonable';
|
|
203
|
+
else if (blob.includes('toolset')) why += ': invalid --toolsets value, a caller bug and not a lane fault';
|
|
177
204
|
return fail('exit_nonzero', `hermes exit ${rc}: ${why}`);
|
|
178
205
|
}
|
|
179
206
|
return text ? pass(text, 'exit 0') : fail('empty_stdout', 'exit 0 but empty stdout');
|
|
@@ -192,8 +219,11 @@ export function judgeQwen(rc, out) {
|
|
|
192
219
|
if (term.type !== 'result') return fail('not_result', `last event was ${JSON.stringify(term.type)}, not result`);
|
|
193
220
|
if (term.subtype !== 'success') {
|
|
194
221
|
const e = term.error;
|
|
222
|
+
if (e && typeof e === 'object' && e.message !== undefined && typeof e.message !== 'string') {
|
|
223
|
+
return fail('error_message_not_string', `subtype=${JSON.stringify(term.subtype)}, error.message was not a string`);
|
|
224
|
+
}
|
|
195
225
|
const msg = e && typeof e === 'object' && typeof e.message === 'string' ? e.message : e ? String(e) : '';
|
|
196
|
-
return fail('bad_subtype', `subtype=${JSON.stringify(term.subtype)}` + (msg ? `: ${msg.slice(0, 120)}` : ''));
|
|
226
|
+
return fail('bad_subtype', `subtype=${JSON.stringify(term.subtype)}` + (msg ? `: ${redact(msg).slice(0, 120)}` : ''));
|
|
197
227
|
}
|
|
198
228
|
if (term.is_error) return fail('is_error', 'is_error true');
|
|
199
229
|
if (term.result != null && typeof term.result !== 'string') return fail('result_not_string', `result was ${typeof term.result}, not a string`);
|
|
@@ -201,7 +231,7 @@ export function judgeQwen(rc, out) {
|
|
|
201
231
|
if (!text) return fail('empty_result', 'success but empty result');
|
|
202
232
|
// qwen reports success even when the upstream API rejected the call; the
|
|
203
233
|
// error text lands in `result`. These two checks are the honest ones.
|
|
204
|
-
if (text.startsWith('[API Error:')) return fail('api_error_in_result', `success flag lied, result is an API error: ${text.slice(0, 140)}`);
|
|
234
|
+
if (text.startsWith('[API Error:')) return fail('api_error_in_result', `success flag lied, result is an API error: ${redact(text).slice(0, 140)}`);
|
|
205
235
|
const stats = term.stats;
|
|
206
236
|
const models = stats && typeof stats === 'object' ? stats.models : null;
|
|
207
237
|
if (!models || typeof models !== 'object' || Array.isArray(models) || Object.keys(models).length === 0) {
|
|
@@ -383,6 +413,406 @@ export function judge(lane, rc, out, err, outFile) {
|
|
|
383
413
|
}
|
|
384
414
|
}
|
|
385
415
|
|
|
416
|
+
// A judge is type-guarded, but this is the backstop: an exception from any
|
|
417
|
+
// judge would escape main() as cli-run's own crash and be misread as a
|
|
418
|
+
// wrapper bug. It becomes a classifiable verdict instead (class cut_short).
|
|
419
|
+
export function safeJudge(lane, rc, out, err, outFile) {
|
|
420
|
+
try {
|
|
421
|
+
return judge(lane, rc, out, err, outFile);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
return fail('judge_raised', `judge raised ${(e && e.name) || 'Error'}: ${(e && e.message) || e}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- failure classes: WHY a run failed --------------------------------------
|
|
428
|
+
// Every function here returns a safe default instead of throwing. Signals are
|
|
429
|
+
// read from a lane's authoritative error fields only, never from assistant
|
|
430
|
+
// prose, and each one was taken from a captured vendor shape, not guessed.
|
|
431
|
+
|
|
432
|
+
// Terminal output only; nothing redacted here reaches the durable log anyway.
|
|
433
|
+
// Redact BEFORE clipping: a clip first can cut a long token so its tail no
|
|
434
|
+
// longer matches any pattern.
|
|
435
|
+
export const SECRET_PATTERNS = [
|
|
436
|
+
// A JSON string value, escapes included; an unterminated value (a clipped line) runs to the end.
|
|
437
|
+
// The optional backslashes also catch a JSON body escaped inside another string.
|
|
438
|
+
[/(\\?"(?:api[_-]?key|token|secret|password|access[_-]?token)\\?"\s*:\s*)\\?"(?:[^"\\]|\\.)*(?:"|$)/gi, '$1"[REDACTED]"'],
|
|
439
|
+
[/(authorization\s*:\s*)(\S+)\s+\S+/gi, '$1$2 [REDACTED]'],
|
|
440
|
+
[/([?&](?:token|key|api_key|access_token|sig)=)[^&\s"'<>]+/gi, '$1[REDACTED]'],
|
|
441
|
+
[/(bearer\s+)[A-Za-z0-9._+/=-]{8,}/gi, '$1[REDACTED]'],
|
|
442
|
+
[/sk-[A-Za-z0-9_-]{8,}/g, '[REDACTED]'],
|
|
443
|
+
[/xai-[A-Za-z0-9_-]{8,}/g, '[REDACTED]'],
|
|
444
|
+
[/ghp_[A-Za-z0-9_-]{8,}/g, '[REDACTED]'],
|
|
445
|
+
[/AIza[A-Za-z0-9_-]{8,}/g, '[REDACTED]']
|
|
446
|
+
];
|
|
447
|
+
export function redact(text) {
|
|
448
|
+
if (typeof text !== 'string') return text;
|
|
449
|
+
let s = text;
|
|
450
|
+
for (const [pat, repl] of SECRET_PATTERNS) s = s.replace(pat, repl);
|
|
451
|
+
return s;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function* jsonLines(out) {
|
|
455
|
+
for (const raw of String(out || '').split('\n')) {
|
|
456
|
+
const line = raw.trim();
|
|
457
|
+
if (!line.startsWith('{') || line.length > 1_000_000) continue;
|
|
458
|
+
let o;
|
|
459
|
+
try {
|
|
460
|
+
o = JSON.parse(line);
|
|
461
|
+
} catch {
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (o && typeof o === 'object' && !Array.isArray(o)) yield o;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
469
|
+
|
|
470
|
+
// codex: only its own `error`, `turn.failed` and error-item events. Never an
|
|
471
|
+
// agent_message, which is the model talking.
|
|
472
|
+
export function codexErrorEventsText(out) {
|
|
473
|
+
const parts = [];
|
|
474
|
+
for (const o of jsonLines(out)) {
|
|
475
|
+
if (o.type === 'error' && typeof o.message === 'string') parts.push(o.message);
|
|
476
|
+
else if (o.type === 'turn.failed') {
|
|
477
|
+
if (isObj(o.error) && typeof o.error.message === 'string') parts.push(o.error.message);
|
|
478
|
+
else if (typeof o.error === 'string') parts.push(o.error);
|
|
479
|
+
} else if (o.type === 'item.completed' && isObj(o.item) && o.item.type === 'error' && typeof o.item.message === 'string') {
|
|
480
|
+
parts.push(o.item.message);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return parts.join('\n');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// agy: only the terminal result event's own status and error fields.
|
|
487
|
+
export function agyResultFieldsText(out) {
|
|
488
|
+
const parts = [];
|
|
489
|
+
for (const o of jsonLines(out)) {
|
|
490
|
+
if (o.event !== 'result' || !isObj(o.result)) continue;
|
|
491
|
+
if (typeof o.result.status === 'string') parts.push(o.result.status);
|
|
492
|
+
const e = o.result.error;
|
|
493
|
+
if (typeof e === 'string') parts.push(e);
|
|
494
|
+
else if (isObj(e) && typeof e.message === 'string') parts.push(e.message);
|
|
495
|
+
}
|
|
496
|
+
return parts.join('\n');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// codex often carries the upstream's JSON error body as a string inside
|
|
500
|
+
// `message`; one level is unwrapped to the human-readable text underneath.
|
|
501
|
+
function unwrapJsonMessage(msg) {
|
|
502
|
+
try {
|
|
503
|
+
const p = JSON.parse(msg);
|
|
504
|
+
if (isObj(p) && isObj(p.error) && typeof p.error.message === 'string') return p.error.message;
|
|
505
|
+
} catch {
|
|
506
|
+
/* not JSON */
|
|
507
|
+
}
|
|
508
|
+
return msg;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The single most specific native codex error, preferring a top-level `error`
|
|
512
|
+
// event over `turn.failed`, so a problem line names the real cause instead of
|
|
513
|
+
// "no terminal turn.completed event".
|
|
514
|
+
export function codexPrimaryError(out) {
|
|
515
|
+
const errors = [], failed = [];
|
|
516
|
+
for (const o of jsonLines(out)) {
|
|
517
|
+
if (o.type === 'error' && typeof o.message === 'string' && o.message) errors.push(unwrapJsonMessage(o.message));
|
|
518
|
+
else if (o.type === 'turn.failed') {
|
|
519
|
+
const m = isObj(o.error) ? o.error.message : o.error;
|
|
520
|
+
if (typeof m === 'string' && m) failed.push(unwrapJsonMessage(m));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return errors[0] || failed[0] || null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function hermesQuotaText(lower) {
|
|
527
|
+
return lower.includes('no usable content') || lower.includes('limit') || lower.includes('degraded');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// qwen: the terminal event's full error text, and a result that is an API error.
|
|
531
|
+
// Never the display detail, which is clipped and can carry a model name.
|
|
532
|
+
export function qwenErrorText(out) {
|
|
533
|
+
let events;
|
|
534
|
+
try {
|
|
535
|
+
events = JSON.parse(out);
|
|
536
|
+
} catch {
|
|
537
|
+
return '';
|
|
538
|
+
}
|
|
539
|
+
const term = Array.isArray(events) && events.length ? events[events.length - 1] : null;
|
|
540
|
+
if (!isObj(term)) return '';
|
|
541
|
+
const parts = [];
|
|
542
|
+
if (typeof term.error === 'string') parts.push(term.error);
|
|
543
|
+
else if (isObj(term.error) && typeof term.error.message === 'string') parts.push(term.error.message);
|
|
544
|
+
if (typeof term.result === 'string' && term.result.trim().startsWith('[API Error:')) parts.push(term.result);
|
|
545
|
+
return parts.join('\n');
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function authoritativeBlob(lane, out, err, detail, rc) {
|
|
549
|
+
if (lane === 'codex') return `${codexErrorEventsText(out)}\n${err || ''}`;
|
|
550
|
+
if (lane === 'agy') return `${agyResultFieldsText(out)}\n${err || ''}`;
|
|
551
|
+
if (lane === 'hermes') return rc !== 0 ? String(err || '') : '';
|
|
552
|
+
if (lane === 'qwen') return `${qwenErrorText(out)}\n${err || ''}`;
|
|
553
|
+
return `${detail || ''}\n${err || ''}`; // grok: no auth, quota or rejected signal is defined
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function sigAuth(lane, blob) {
|
|
557
|
+
const b = blob.toLowerCase();
|
|
558
|
+
if (lane === 'qwen') return b.includes('missing api key');
|
|
559
|
+
if (lane === 'agy') return b.includes('you are not logged into antigravity') || b.includes('not authenticated');
|
|
560
|
+
return false; // codex, grok, hermes: no documented native auth signal
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function sigQuota(lane, blob) {
|
|
564
|
+
const b = blob.toLowerCase();
|
|
565
|
+
if (lane === 'qwen') return b.includes('[api error: 402') || b.includes('requires more credits') || blob.includes(' 429') || b.includes('rate limit');
|
|
566
|
+
if (lane === 'codex') return blob.includes('usage_limit_exceeded') || b.includes("you've hit your usage limit");
|
|
567
|
+
if (lane === 'hermes') return hermesQuotaText(b);
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function sigRejected(lane, blob) {
|
|
572
|
+
const b = blob.toLowerCase();
|
|
573
|
+
if (lane === 'qwen') return b.includes('[api error: 400') || b.includes('no endpoints found') || b.includes('failed to parse grammar');
|
|
574
|
+
if (lane === 'codex') return blob.includes('invalid_request_error');
|
|
575
|
+
if (lane === 'hermes') return b.includes('toolset');
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Which judge reasons mean the lane never reached a trustworthy finish, as
|
|
580
|
+
// opposed to finishing cleanly with nothing in it (class empty).
|
|
581
|
+
const CUT_SHORT_REASONS = {
|
|
582
|
+
grok: new Set(['not_json', 'bad_stop_reason']),
|
|
583
|
+
codex: new Set(['no_terminal_event']),
|
|
584
|
+
agy: new Set(['no_terminal_event', 'bad_last_event']),
|
|
585
|
+
qwen: new Set(['not_json', 'bad_event_array', 'bad_last_event', 'not_result', 'error_message_not_string']),
|
|
586
|
+
hermes: new Set()
|
|
587
|
+
};
|
|
588
|
+
function isCutShort(lane, reason, rc) {
|
|
589
|
+
if (reason === 'judge_raised') return true;
|
|
590
|
+
if (reason === 'ok') return true; // text came back but the vendor exited nonzero
|
|
591
|
+
// A nonzero vendor exit no signal explains never finished on its own terms.
|
|
592
|
+
// hermes' exit 2 is the one honest vendor code for "bad args, or an empty response".
|
|
593
|
+
if (rc !== 0) return !(lane === 'hermes' && rc === 2);
|
|
594
|
+
return !!(CUT_SHORT_REASONS[lane] && CUT_SHORT_REASONS[lane].has(reason));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// --- refused: how many tool calls a hook or deny rule blocked ----------------
|
|
598
|
+
// null means the lane gave no readable signal, which is an unknown, never 0.
|
|
599
|
+
export function refusedQwen(out) {
|
|
600
|
+
let events;
|
|
601
|
+
try {
|
|
602
|
+
events = JSON.parse(out);
|
|
603
|
+
} catch {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
if (!Array.isArray(events) || !events.length || !isObj(events[events.length - 1])) return null;
|
|
607
|
+
const pd = events[events.length - 1].permission_denials;
|
|
608
|
+
return Array.isArray(pd) ? pd.length : null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function refusedAgy(out) {
|
|
612
|
+
let found = false, count = 0;
|
|
613
|
+
for (const o of jsonLines(out)) {
|
|
614
|
+
found = true;
|
|
615
|
+
const e = isObj(o.step_update) && isObj(o.step_update.tool_info) ? o.step_update.tool_info.error : null;
|
|
616
|
+
if (isObj(e) && e.type === 'TOOL_ERROR' && typeof e.message === 'string') {
|
|
617
|
+
const m = e.message.toLowerCase();
|
|
618
|
+
if (m.includes('permission check failed') || m.includes('matches user-configured deny rule')) count++;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return found ? count : null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// codex's router writes one `Rejected(` line per blocked command on stderr.
|
|
625
|
+
// Each line counts. Prose such as "operation not permitted" in an answer does
|
|
626
|
+
// not: it matches a model merely explaining the phrase.
|
|
627
|
+
const CODEX_ROUTER_REJECTED = /codex_core::tools::router:[^\n]*Rejected\(/gi;
|
|
628
|
+
export function refusedCodex(out, err) {
|
|
629
|
+
let count = (String(err || '').match(CODEX_ROUTER_REJECTED) || []).length;
|
|
630
|
+
for (const o of jsonLines(out)) {
|
|
631
|
+
// Forward-compatible only: a structural denial item, never prose.
|
|
632
|
+
if (o.type === 'item.completed' && isObj(o.item) && (o.item.type === 'command_denied' || o.item.type === 'denied')) count++;
|
|
633
|
+
}
|
|
634
|
+
return count || null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// grok never reports a refusal in stdout. It lives in the session transcript,
|
|
638
|
+
// ~/.grok/sessions/<cwd, percent-encoded>/<sessionId>/updates.jsonl, one
|
|
639
|
+
// {"params":{"sessionId","update"}} envelope per line. Two denial shapes are
|
|
640
|
+
// counted: a PreToolUse hook run with status.blocked, and grok's own deny-rule
|
|
641
|
+
// engine ("Denied by permission policy"). The sessionId comes from lane output,
|
|
642
|
+
// so it is held to a narrow charset before any path is built, the resolved path
|
|
643
|
+
// must stay inside the sessions root, every counted line must name the same
|
|
644
|
+
// session, and the read is capped.
|
|
645
|
+
export const GROK_SESSION_ID = /^[A-Za-z0-9-]{8,64}$/;
|
|
646
|
+
export const GROK_TRANSCRIPT_CAP = 5 * 1024 * 1024;
|
|
647
|
+
// grok encodes the cwd the way Python's urllib.parse.quote(cwd, safe="") does.
|
|
648
|
+
function percentEncodePath(p) {
|
|
649
|
+
return encodeURIComponent(p).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
650
|
+
}
|
|
651
|
+
// Path-aware containment. A string-prefix test accepts a sibling such as
|
|
652
|
+
// "sessions-2", or on POSIX a directory literally named "sessions\outside".
|
|
653
|
+
export function isInsideRoot(root, p, pathApi = { relative, isAbsolute, sep }) {
|
|
654
|
+
const rel = pathApi.relative(root, p);
|
|
655
|
+
return !!rel && !pathApi.isAbsolute(rel) && rel !== '..' && !rel.startsWith('..' + pathApi.sep);
|
|
656
|
+
}
|
|
657
|
+
export function refusedGrok(out, { root = process.env.CLI_RUN_GROK_SESSIONS_ROOT || join(homedir(), '.grok', 'sessions'), cwd = process.cwd(), cap = GROK_TRANSCRIPT_CAP } = {}) {
|
|
658
|
+
let o;
|
|
659
|
+
try {
|
|
660
|
+
o = JSON.parse(out);
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
const sid = isObj(o) ? o.sessionId : null;
|
|
665
|
+
if (typeof sid !== 'string' || !GROK_SESSION_ID.test(sid)) return null;
|
|
666
|
+
const path = join(root, percentEncodePath(cwd), sid, 'updates.jsonl');
|
|
667
|
+
let data;
|
|
668
|
+
let fd;
|
|
669
|
+
try {
|
|
670
|
+
const rootReal = realpathSync(root);
|
|
671
|
+
const pathReal = realpathSync(path);
|
|
672
|
+
if (!isInsideRoot(rootReal, pathReal)) return null;
|
|
673
|
+
if (!statSync(pathReal).isFile()) return null;
|
|
674
|
+
fd = openSync(pathReal, 'r');
|
|
675
|
+
const buf = Buffer.alloc(Math.max(0, Math.min(cap, statSync(pathReal).size)));
|
|
676
|
+
let read = 0;
|
|
677
|
+
while (read < buf.length) {
|
|
678
|
+
const n = readSync(fd, buf, read, buf.length - read, read);
|
|
679
|
+
if (!n) break;
|
|
680
|
+
read += n;
|
|
681
|
+
}
|
|
682
|
+
data = buf.subarray(0, read).toString('utf8');
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
} finally {
|
|
686
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
687
|
+
}
|
|
688
|
+
let count = 0, matched = false;
|
|
689
|
+
for (const rec of jsonLines(data)) {
|
|
690
|
+
if (!isObj(rec.params) || rec.params.sessionId !== sid) continue;
|
|
691
|
+
matched = true;
|
|
692
|
+
const u = rec.params.update;
|
|
693
|
+
if (!isObj(u)) continue;
|
|
694
|
+
if (u.sessionUpdate === 'hook_execution' && Array.isArray(u.runs)) {
|
|
695
|
+
for (const r of u.runs) if (isObj(r) && isObj(r.status) && r.status.blocked === true) count++;
|
|
696
|
+
} else if (u.sessionUpdate === 'tool_call_update' && u.status === 'failed') {
|
|
697
|
+
let blob = '';
|
|
698
|
+
try { blob = JSON.stringify(u.content ?? '').toLowerCase(); } catch {}
|
|
699
|
+
if (blob.includes('denied by permission policy') || blob.includes('hook denied')) count++;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return matched ? count : null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export function countRefused(lane, out, err, opts) {
|
|
706
|
+
try {
|
|
707
|
+
if (lane === 'qwen') return refusedQwen(out);
|
|
708
|
+
if (lane === 'agy') return refusedAgy(out);
|
|
709
|
+
if (lane === 'codex') return refusedCodex(out, err);
|
|
710
|
+
if (lane === 'grok') return refusedGrok(out, opts);
|
|
711
|
+
return null; // hermes: no native refusal signal
|
|
712
|
+
} catch {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Runs after the lane has exited, so adversarial output must not make it slow:
|
|
718
|
+
// the input is bounded and every repeat is bounded. Redacted before any match
|
|
719
|
+
// can clip a secret.
|
|
720
|
+
const DENIAL_SCAN_BYTES = 256 * 1024;
|
|
721
|
+
function firstDenialText(out, err) {
|
|
722
|
+
const blob = redact(`${String(out || '').slice(0, DENIAL_SCAN_BYTES)}\n${String(err || '').slice(0, DENIAL_SCAN_BYTES)}`);
|
|
723
|
+
for (const pat of [
|
|
724
|
+
/Permission denied for command\([^)\n]{0,200}\)\. Matches user-configured deny rule\./,
|
|
725
|
+
/Denied by permission policy:[^\n"]{0,160}/,
|
|
726
|
+
/Hook denied:[^\n"]{0,160}/,
|
|
727
|
+
/denied:[^\n"]{0,160}/,
|
|
728
|
+
/[Rr]ejected:[^\n"]{0,160}/,
|
|
729
|
+
/error=exec_command failed:[^\n]{0,160}/
|
|
730
|
+
]) {
|
|
731
|
+
const m = pat.exec(blob);
|
|
732
|
+
if (m) return m[0].trim();
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// A lane often paraphrases a refusal in its own words; a short head of its
|
|
738
|
+
// answer names it when no exact denial phrase matches.
|
|
739
|
+
function deliverableSnippet(out, limit = 160) {
|
|
740
|
+
let o;
|
|
741
|
+
try {
|
|
742
|
+
o = JSON.parse(out);
|
|
743
|
+
} catch {
|
|
744
|
+
return redact(String(out || '').slice(0, 4096)).trim().slice(0, limit) || null;
|
|
745
|
+
}
|
|
746
|
+
if (!isObj(o)) return null;
|
|
747
|
+
for (const k of ['text', 'response', 'result']) if (typeof o[k] === 'string' && o[k].trim()) return redact(o[k].slice(0, 4096)).trim().slice(0, limit);
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const FIX = {
|
|
752
|
+
auth: 'set the credential the message above names (its environment variable, or the lane\'s own login command), then rerun',
|
|
753
|
+
quota: 'switch to another lane, or wait for the reset time if the message gave one',
|
|
754
|
+
rejected: 'correct the model id, flag or request the upstream message names',
|
|
755
|
+
refused: 'adjust the hook or deny rule named above, or give this lane the tool it needs',
|
|
756
|
+
cut_short: 'rerun once; if it recurs, run without --quiet and read the lane\'s stderr on the terminal',
|
|
757
|
+
empty: 'rerun once, or use another lane',
|
|
758
|
+
timeout: 'raise --timeout, or split the brief into smaller pieces',
|
|
759
|
+
no_output: 'rerun once; if it recurs, check that the lane runs on its own outside cli-run'
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
// A one-line problem naming the concrete cause, and a one-line fix. The caller
|
|
763
|
+
// relays both. Returns { problem: null, fix: null } when there is nothing to say.
|
|
764
|
+
export function problemAndFix(lane, cls, { out = '', err = '', detail = '', refused = null, authoritative = null } = {}) {
|
|
765
|
+
const cause = typeof authoritative === 'string' && authoritative.trim() ? redact(authoritative).trim() : null;
|
|
766
|
+
const d = typeof detail === 'string' && detail ? redact(detail) : null;
|
|
767
|
+
const tag = `cli-run[${lane}]`;
|
|
768
|
+
const denial = () => firstDenialText(out, err) || deliverableSnippet(out) || d;
|
|
769
|
+
switch (cls) {
|
|
770
|
+
case 'auth': return { problem: `${tag} auth: ${cause || d || 'missing or invalid credentials'}`, fix: FIX.auth };
|
|
771
|
+
case 'quota': return { problem: `${tag} quota: ${cause || d || 'rate limit or credits exhausted'}`, fix: FIX.quota };
|
|
772
|
+
case 'rejected': return { problem: `${tag} rejected: ${cause || d || 'the upstream rejected the request'}`, fix: FIX.rejected };
|
|
773
|
+
case 'refused': return { problem: `${tag} refused: ${denial() || 'a hook or deny rule blocked the call'}`, fix: FIX.refused };
|
|
774
|
+
case 'cut_short': return { problem: `${tag} cut short: ${d || 'no terminal success event, cause not identifiable'}`, fix: FIX.cut_short };
|
|
775
|
+
case 'empty': return { problem: `${tag} empty: ${d || 'completed but delivered nothing'}`, fix: FIX.empty };
|
|
776
|
+
case 'timeout': return { problem: `${tag} timeout: ${d || 'exceeded the wall clock'}`, fix: FIX.timeout };
|
|
777
|
+
case 'unavailable': return { problem: `${tag} unavailable: ${d || 'binary not found on PATH'}`, fix: `install the ${lane} CLI and put it on PATH, or enable it in lanes.json` };
|
|
778
|
+
case 'no_output': return { problem: `${tag} no output: the process wrote nothing to stdout or stderr`, fix: FIX.no_output };
|
|
779
|
+
case 'ok':
|
|
780
|
+
if (Number.isInteger(refused) && refused > 0) {
|
|
781
|
+
return { problem: `${tag} ok, but ${refused} call(s) were refused: ${denial() || 'a hook or deny rule blocked part of the call'}`, fix: FIX.refused };
|
|
782
|
+
}
|
|
783
|
+
return { problem: null, fix: null };
|
|
784
|
+
default:
|
|
785
|
+
return { problem: null, fix: null };
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Classify a judged run. `reason`/`detail` are the judge's own (not the
|
|
790
|
+
// wrapper's decorated detail). A nonzero vendor exit is never ok, whatever
|
|
791
|
+
// came back. Never throws.
|
|
792
|
+
export function classifyRun(lane, { rc = 0, out = '', err = '', reason = '', detail = '', text = '', refusedOpts } = {}) {
|
|
793
|
+
out = typeof out === 'string' ? out : '';
|
|
794
|
+
err = typeof err === 'string' ? err : '';
|
|
795
|
+
detail = typeof detail === 'string' ? detail : '';
|
|
796
|
+
const refused = countRefused(lane, out, err, refusedOpts);
|
|
797
|
+
let cls;
|
|
798
|
+
try {
|
|
799
|
+
if (text && rc === 0) cls = 'ok';
|
|
800
|
+
else if (!out.trim() && !err.trim()) cls = 'no_output';
|
|
801
|
+
else {
|
|
802
|
+
const blob = authoritativeBlob(lane, out, err, detail, rc);
|
|
803
|
+
if (sigAuth(lane, blob)) cls = 'auth';
|
|
804
|
+
else if (sigQuota(lane, blob)) cls = 'quota';
|
|
805
|
+
else if (sigRejected(lane, blob)) cls = 'rejected';
|
|
806
|
+
else if (Number.isInteger(refused) && refused > 0) cls = 'refused';
|
|
807
|
+
else if (isCutShort(lane, reason, rc)) cls = 'cut_short';
|
|
808
|
+
else cls = 'empty';
|
|
809
|
+
}
|
|
810
|
+
} catch {
|
|
811
|
+
cls = 'cut_short';
|
|
812
|
+
}
|
|
813
|
+
return { cls, refused };
|
|
814
|
+
}
|
|
815
|
+
|
|
386
816
|
// --- the process boundary --------------------------------------------------
|
|
387
817
|
// The lane runs DETACHED, so it leads its own process group. On timeout (or an
|
|
388
818
|
// output-buffer overrun) the whole group is killed, not just the direct child:
|
|
@@ -694,7 +1124,9 @@ function usage(msg) {
|
|
|
694
1124
|
--model / --effort pin what a lane runs with, instead of letting it inherit its
|
|
695
1125
|
own config. Every lane takes --model; every lane except qwen takes --effort.
|
|
696
1126
|
Levels are the vendor's own (agy low|medium|high, hermes none|minimal|...): an
|
|
697
|
-
unknown level is rejected by the lane, and reported
|
|
1127
|
+
unknown level is rejected by the lane, and reported by class (codex: rejected, 16).
|
|
1128
|
+
Exit codes: 0 ok, 10 empty, 11 no output, 12 timeout, 13 unavailable, 14 auth,
|
|
1129
|
+
15 quota, 16 rejected, 17 refused, 18 cut short. Failures print a problem and a fix line.
|
|
698
1130
|
auto sizes per call: below 4,000 prompt characters is medium, otherwise high; a codex audit is always high. Auto never resolves above high.
|
|
699
1131
|
Pin them per lane instead of per call with "defaults" in bin/lanes.json.`);
|
|
700
1132
|
return USAGE;
|
|
@@ -703,7 +1135,7 @@ function usage(msg) {
|
|
|
703
1135
|
// Bounded, control-character-free head of vendor stderr for the terminal.
|
|
704
1136
|
// Never logged: provider text can echo whatever the prompt contained.
|
|
705
1137
|
function stderrHead(err, n = 300) {
|
|
706
|
-
const s = String(err || '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').trim();
|
|
1138
|
+
const s = redact(String(err || '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')).trim();
|
|
707
1139
|
if (!s) return '';
|
|
708
1140
|
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
709
1141
|
}
|
|
@@ -877,21 +1309,21 @@ export async function main(argv) {
|
|
|
877
1309
|
effort_truncated: sizing.truncated
|
|
878
1310
|
});
|
|
879
1311
|
const enabled = cfg === null ? null : cfg.enabled;
|
|
880
|
-
|
|
881
|
-
console.error('cli-run:
|
|
882
|
-
|
|
1312
|
+
const unavailable = ({ msg, reason, fix }) => {
|
|
1313
|
+
console.error('cli-run: ' + msg);
|
|
1314
|
+
if (!opts.quiet) console.error(`cli-run fix: ${fix}`);
|
|
1315
|
+
log({ ...base, verdict: 'unavailable', class: 'unavailable', rc: UNAVAILABLE, refused: null, reason });
|
|
883
1316
|
return UNAVAILABLE;
|
|
1317
|
+
};
|
|
1318
|
+
if (enabled === null) {
|
|
1319
|
+
return unavailable({ msg: 'lanes.json exists but is not a valid {"enabled": [...]} file; refusing every lane until it is fixed', reason: 'lanes_json_malformed', fix: 'fix bin/lanes.json, or delete it to enable every lane' });
|
|
884
1320
|
}
|
|
885
1321
|
if (!enabled.includes(lane)) {
|
|
886
|
-
|
|
887
|
-
log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'disabled' });
|
|
888
|
-
return UNAVAILABLE;
|
|
1322
|
+
return unavailable({ msg: `${lane} is not enabled in lanes.json`, reason: 'disabled', fix: `add "${lane}" to "enabled" in bin/lanes.json, or use an enabled lane` });
|
|
889
1323
|
}
|
|
890
1324
|
const binary = which(lane);
|
|
891
1325
|
if (!binary) {
|
|
892
|
-
|
|
893
|
-
log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'unavailable' });
|
|
894
|
-
return UNAVAILABLE;
|
|
1326
|
+
return unavailable({ msg: `${lane} not found on PATH`, reason: 'unavailable', fix: `install the ${lane} CLI and put it on PATH` });
|
|
895
1327
|
}
|
|
896
1328
|
|
|
897
1329
|
if (route.effort === 'auto' && !opts.quiet) console.error(`cli-run: effort auto -> ${sizing.resolved} (${sizing.basis})`);
|
|
@@ -903,52 +1335,60 @@ export async function main(argv) {
|
|
|
903
1335
|
const r = await runBounded(cmd, opts.timeout);
|
|
904
1336
|
const out = r.stdout || '';
|
|
905
1337
|
const err = r.stderr || '';
|
|
906
|
-
let verdict, reason, detail,
|
|
1338
|
+
let verdict, reason, detail, cls, text = '', refused = null;
|
|
907
1339
|
if (r.interrupted) {
|
|
908
|
-
verdict = 'interrupted'; reason = 'killed'; detail = `cli-run received ${r.interrupted}; the lane's process group was killed`;
|
|
1340
|
+
verdict = 'interrupted'; reason = 'killed'; detail = `cli-run received ${r.interrupted}; the lane's process group was killed`; cls = 'interrupted';
|
|
909
1341
|
} else if (r.timedOut) {
|
|
910
|
-
verdict = 'timeout'; reason = 'timeout'; detail = `exceeded ${opts.timeout}s; process group killed`;
|
|
1342
|
+
verdict = 'timeout'; reason = 'timeout'; detail = `exceeded ${opts.timeout}s; process group killed`; cls = 'timeout';
|
|
911
1343
|
} else if (r.overrun) {
|
|
912
|
-
verdict = 'no_deliverable'; reason = 'no_output'; detail = 'output exceeded the 16 MiB buffer; process group killed';
|
|
1344
|
+
verdict = 'no_deliverable'; reason = 'no_output'; detail = 'output exceeded the 16 MiB buffer; process group killed'; cls = 'cut_short';
|
|
913
1345
|
} else if (r.error) {
|
|
914
|
-
verdict = 'unavailable'; reason = 'unavailable'; detail = r.error.message;
|
|
1346
|
+
verdict = 'unavailable'; reason = 'unavailable'; detail = r.error.message; cls = 'unavailable';
|
|
915
1347
|
} else if (r.signal || r.status === null) {
|
|
916
1348
|
// A lane killed by a signal has no honest exit status. Whatever it printed
|
|
917
1349
|
// before dying is not a deliverable; a null status must never become exit 0.
|
|
918
|
-
verdict = 'killed'; reason = 'killed'; detail = `lane killed by ${r.signal || 'unknown signal'}`;
|
|
1350
|
+
verdict = 'killed'; reason = 'killed'; detail = `lane killed by ${r.signal || 'unknown signal'}`; cls = 'cut_short';
|
|
919
1351
|
} else {
|
|
920
|
-
const j =
|
|
921
|
-
text = j.text || '';
|
|
1352
|
+
const j = safeJudge(lane, r.status, out, err, outFile);
|
|
922
1353
|
reason = j.reason;
|
|
923
1354
|
detail = j.detail;
|
|
1355
|
+
({ cls, refused } = classifyRun(lane, { rc: r.status, out, err, reason: j.reason, detail: j.detail, text: j.text || '' }));
|
|
924
1356
|
if (r.status !== 0) {
|
|
925
1357
|
// A nonzero vendor exit is a failure on the vendor's own terms, whether or
|
|
926
|
-
// not something parseable came back.
|
|
927
|
-
//
|
|
928
|
-
verdict = 'exit_nonzero';
|
|
1358
|
+
// not something parseable came back. The class names why; the vendor's
|
|
1359
|
+
// own code is kept as cli_rc, and its stderr head shows on the terminal.
|
|
1360
|
+
verdict = 'exit_nonzero';
|
|
929
1361
|
if (reason === 'ok') reason = 'exit_nonzero';
|
|
930
1362
|
const head = stderrHead(err);
|
|
931
1363
|
detail = `lane exited ${r.status}` + (head ? `; stderr: ${head}` : '') + (j.reason !== 'ok' ? `; ${j.detail}` : '');
|
|
932
|
-
} else if (
|
|
933
|
-
const unmet = checkContracts(opts, text, before);
|
|
1364
|
+
} else if (cls === 'ok') {
|
|
1365
|
+
const unmet = checkContracts(opts, j.text, before);
|
|
934
1366
|
if (unmet) {
|
|
935
|
-
verdict = 'no_deliverable'; reason = 'contract_unmet'; detail = unmet;
|
|
1367
|
+
verdict = 'no_deliverable'; reason = 'contract_unmet'; detail = unmet; cls = 'empty';
|
|
936
1368
|
} else {
|
|
937
|
-
verdict = 'ok';
|
|
1369
|
+
verdict = 'ok'; text = j.text;
|
|
938
1370
|
}
|
|
939
|
-
} else if (
|
|
940
|
-
verdict = 'no_output'; reason = 'no_output';
|
|
1371
|
+
} else if (cls === 'no_output') {
|
|
1372
|
+
verdict = 'no_output'; reason = 'no_output';
|
|
941
1373
|
} else {
|
|
942
|
-
verdict = 'no_deliverable';
|
|
1374
|
+
verdict = 'no_deliverable';
|
|
943
1375
|
const head = stderrHead(err);
|
|
944
1376
|
if (head) detail += `; stderr: ${head}`;
|
|
945
1377
|
}
|
|
946
1378
|
}
|
|
1379
|
+
const code = cls === 'interrupted' ? 128 + (r.interrupted === 'SIGINT' ? 2 : 15) : CLASS_CODES[cls];
|
|
947
1380
|
if (text && code === OK) process.stdout.write(text + '\n');
|
|
948
1381
|
const routeNote = route.model || route.effort ? `${route.model || 'lane default'}/${route.effort || 'lane default'}` : 'lane default';
|
|
949
|
-
if (!opts.quiet)
|
|
950
|
-
|
|
951
|
-
|
|
1382
|
+
if (!opts.quiet) {
|
|
1383
|
+
console.error(`cli-run[${lane}] ${verdict} rc=${code} class=${cls} refused=${refused === null ? 'null' : refused} ${r.seconds.toFixed(1)}s raw=${r.outBytes || 0}B route=${routeNote} :: ${redact(detail)}`);
|
|
1384
|
+
let authoritative = null;
|
|
1385
|
+
if (lane === 'codex' && (cls === 'quota' || cls === 'rejected')) authoritative = codexPrimaryError(out);
|
|
1386
|
+
const pf = problemAndFix(lane, cls, { out, err, detail, refused, authoritative });
|
|
1387
|
+
if (pf.problem) console.error('cli-run problem: ' + redact(pf.problem).slice(0, 600));
|
|
1388
|
+
if (pf.fix) console.error('cli-run fix: ' + pf.fix);
|
|
1389
|
+
}
|
|
1390
|
+
// Durable log: fixed reason code, fixed class and structural numbers only.
|
|
1391
|
+
log({ ...base, verdict, class: cls in CLASS_CODES || cls === 'interrupted' ? cls : 'unknown', rc: code, cli_rc: r.status, signal: r.signal || null, refused: Number.isInteger(refused) ? refused : null, seconds: Math.round(r.seconds * 100) / 100, raw_bytes: r.outBytes || 0, deliverable_bytes: Buffer.byteLength(text), reason: REASONS.has(reason) ? reason : 'unknown' });
|
|
952
1392
|
return code;
|
|
953
1393
|
} finally {
|
|
954
1394
|
rmSync(tmp, { recursive: true, force: true });
|