model-orchestrator 0.1.4

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 (81) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -0
  4. package/SECURITY.md +17 -0
  5. package/bin/README.md +10 -0
  6. package/bin/cli-run.mjs +599 -0
  7. package/bin/cli.js +372 -0
  8. package/docs/README.md +13 -0
  9. package/docs/audit-brief.md +83 -0
  10. package/docs/catalog.md +113 -0
  11. package/docs/part-1-beginner.md +65 -0
  12. package/docs/part-2-intermediate.md +65 -0
  13. package/docs/part-3-advanced.md +65 -0
  14. package/package.json +52 -0
  15. package/scripts/README.md +5 -0
  16. package/scripts/gen-catalog.js +37 -0
  17. package/src/README.md +9 -0
  18. package/src/catalog.js +277 -0
  19. package/src/detect.js +26 -0
  20. package/src/install.js +628 -0
  21. package/src/prompt.js +34 -0
  22. package/src/render.js +8 -0
  23. package/templates/README.md +14 -0
  24. package/templates/advanced/README.md +14 -0
  25. package/templates/advanced/vm/ENVIRONMENT.md +18 -0
  26. package/templates/advanced/vm/PRIVACY_GATES.md +33 -0
  27. package/templates/advanced/vm/README.md +60 -0
  28. package/templates/advanced/vm/box-CLAUDE.md +28 -0
  29. package/templates/advanced/vm/docker-compose.yml +19 -0
  30. package/templates/advanced/vm/gateway.config.yaml +12 -0
  31. package/templates/advanced/vm/jobs/README.md +39 -0
  32. package/templates/advanced/vm/jobs/weekly-audit.service +17 -0
  33. package/templates/advanced/vm/jobs/weekly-audit.sh +107 -0
  34. package/templates/advanced/vm/jobs/weekly-audit.timer +10 -0
  35. package/templates/advanced/vm/setup-vm.sh +46 -0
  36. package/templates/agents/README.md +13 -0
  37. package/templates/agents/agy/README.md +5 -0
  38. package/templates/agents/agy/builder.md +17 -0
  39. package/templates/agents/agy/bulk-worker.md +17 -0
  40. package/templates/agents/agy/code-reviewer.md +17 -0
  41. package/templates/agents/agy/deep-planner.md +17 -0
  42. package/templates/agents/agy/live-researcher.md +17 -0
  43. package/templates/agents/claude-code/README.md +13 -0
  44. package/templates/agents/claude-code/builder.md +17 -0
  45. package/templates/agents/claude-code/bulk-worker.md +18 -0
  46. package/templates/agents/claude-code/code-reviewer.md +19 -0
  47. package/templates/agents/claude-code/deep-planner.md +18 -0
  48. package/templates/agents/claude-code/live-researcher.md +18 -0
  49. package/templates/agents/snippets/chat.md +25 -0
  50. package/templates/agents/snippets/claude-code.md +27 -0
  51. package/templates/agents/snippets/generic.md +21 -0
  52. package/templates/beginner/ORCHESTRATOR.md +55 -0
  53. package/templates/beginner/README.md +3 -0
  54. package/templates/common/README.md +52 -0
  55. package/templates/common/TASK_BUNDLE.md +56 -0
  56. package/templates/common/protocols/README.md +14 -0
  57. package/templates/common/protocols/build-protocol.md +133 -0
  58. package/templates/common/protocols/deep-research.md +44 -0
  59. package/templates/common/protocols/gap-analysis.md +28 -0
  60. package/templates/common/protocols/memory-and-record.md +30 -0
  61. package/templates/common/protocols/numbers-and-logic.md +35 -0
  62. package/templates/common/protocols/propagate.md +34 -0
  63. package/templates/intermediate/CLI-RUN.md +100 -0
  64. package/templates/intermediate/DELEGATION_MATRIX.md +41 -0
  65. package/templates/intermediate/README.md +13 -0
  66. package/templates/intermediate/RESEARCH_TRIAGE.md +30 -0
  67. package/templates/intermediate/ROUTING.md +73 -0
  68. package/templates/intermediate/TIERS.md +44 -0
  69. package/templates/tools/README.md +10 -0
  70. package/templates/tools/codecalc/CODECALC.md +43 -0
  71. package/templates/tools/codecalc/mcp/agy.mcp_config.json +8 -0
  72. package/templates/tools/codecalc/mcp/codex.config.toml +4 -0
  73. package/templates/tools/codecalc/mcp/mcpServers.json +8 -0
  74. package/templates/tools/codecalc/mcp/vscode.mcp.json +8 -0
  75. package/templates/tools/codecalc/mcp/zed.settings.json +9 -0
  76. package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +65 -0
  77. package/templates/tools/obsidian-tc/mcp/obsidian-tc.agy.mcp_config.json +9 -0
  78. package/templates/tools/obsidian-tc/mcp/obsidian-tc.codex.config.toml +7 -0
  79. package/templates/tools/obsidian-tc/mcp/obsidian-tc.mcpServers.json +9 -0
  80. package/templates/tools/obsidian-tc/mcp/obsidian-tc.vscode.mcp.json +10 -0
  81. package/templates/tools/obsidian-tc/mcp/obsidian-tc.zed.settings.json +9 -0
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+ // cli-run: one entrypoint for the agent CLI lanes.
3
+ //
4
+ // THE GUARANTEE, stated exactly: exit 0 means the lane returned a STRUCTURALLY
5
+ // ACCEPTED, NON-EMPTY final response, judged on that lane's native terminal
6
+ // event, with the lane-specific error checks applied. It does not mean the
7
+ // task was done. A refusal that parses cleanly is exit 0. When you have a real
8
+ // contract, say so: --expect-file PATH (a non-empty file written during this
9
+ // run) or --expect-json (the response parses as JSON) turn an unmet contract
10
+ // into exit 10.
11
+ //
12
+ // Why the wrapper exists: every agent CLI can exit 0 having produced nothing.
13
+ // A run that reports success and delivers nothing is indistinguishable from a
14
+ // model failure, so it gets blamed on the model. This tool reads each lane's
15
+ // NATIVE terminal event and refuses to call an empty run a success.
16
+ //
17
+ // grok --output-format json -> stopReason == "end_turn" and text non-empty
18
+ // codex exec --json --color never -o F -> terminal {"type":"turn.completed"} and F non-empty
19
+ // agy --output-format stream-json -> terminal {"event":"result"} status SUCCESS, response non-empty
20
+ // hermes -z -> its exit code is already honest (0 ok / 1 none / 2 bad args)
21
+ // qwen -o json -> terminal {"type":"result"} subtype "success", is_error false,
22
+ // result non-empty AND not "[API Error: ...]",
23
+ // AND every stats.models.*.api.totalErrors == 0
24
+ //
25
+ // The prompt travels in argv because that is each vendor's documented headless
26
+ // shape (-p / exec). argv is visible to other processes on the machine and is
27
+ // bounded by the OS ARG_MAX, so: no secrets in a prompt, and very large briefs
28
+ // should be referenced by path in the prompt rather than pasted into it.
29
+ //
30
+ // Exit codes
31
+ // 0 structurally accepted non-empty response (and every --expect-* contract met)
32
+ // 10 ran, produced no deliverable, or a contract was not met, or killed by signal
33
+ // 11 produced no output at all
34
+ // 12 timed out (the lane AND its descendants are killed as a process group)
35
+ // 13 lane unavailable (missing binary, disabled in lanes.json, or lanes.json malformed)
36
+ // 130 / 143 cli-run itself received SIGINT / SIGTERM: the lane's process group was killed first
37
+ // 2 usage error in cli-run itself
38
+ // N the lane exited N != 0: passed through, verdict exit_nonzero, even if text came back
39
+ //
40
+ // The durable log stores a FIXED reason code per run (see REASONS), never a
41
+ // provider-supplied string. Bounded vendor stderr goes to your terminal only.
42
+
43
+ import { spawn } from 'node:child_process';
44
+ import { StringDecoder } from 'node:string_decoder';
45
+ import { createHash } from 'node:crypto';
46
+ import { readFileSync, existsSync, mkdirSync, appendFileSync, mkdtempSync, rmSync, accessSync, constants, realpathSync, statSync } from 'node:fs';
47
+ import { join, dirname, delimiter, resolve } from 'node:path';
48
+ import { tmpdir, homedir } from 'node:os';
49
+ import { fileURLToPath, pathToFileURL } from 'node:url';
50
+
51
+ export const LANES = ['grok', 'codex', 'agy', 'hermes', 'qwen'];
52
+ export const OK = 0, NO_DELIVERABLE = 10, NO_OUTPUT = 11, TIMEOUT = 12, UNAVAILABLE = 13, USAGE = 2;
53
+
54
+ // Every reason that may reach the durable log. A judge or the wrapper picks
55
+ // one of these; anything else is written as 'unknown'. Provider text never
56
+ // enters this field, whatever it contains.
57
+ export const REASONS = new Set([
58
+ 'ok', 'not_json', 'bad_stop_reason', 'empty_text', 'no_terminal_event', 'empty_output_file',
59
+ 'bad_status', 'empty_response', 'exit_nonzero', 'empty_stdout', 'bad_event_array', 'bad_last_event',
60
+ 'not_result', 'bad_subtype', 'is_error', 'result_not_string', 'empty_result', 'api_error_in_result',
61
+ 'telemetry_absent', 'total_errors_unreadable', 'total_errors', 'contract_unmet',
62
+ 'timeout', 'unavailable', 'killed', 'disabled', 'lanes_json_malformed', 'no_output', 'unknown'
63
+ ]);
64
+
65
+ const LOG = join(homedir(), '.ai-orchestrator', 'cli-run.log.jsonl');
66
+
67
+ function which(bin) {
68
+ const dirs = (process.env['PATH'] || '').split(delimiter).filter(Boolean);
69
+ const home = homedir();
70
+ dirs.push(join(home, '.local', 'bin'), join(home, '.grok', 'bin'), join(home, '.npm-global', 'bin'));
71
+ for (const d of dirs) {
72
+ const p = join(d, bin);
73
+ try {
74
+ if (!statSync(p).isFile()) continue; // a directory named like the binary is not the binary
75
+ accessSync(p, constants.X_OK);
76
+ return p;
77
+ } catch {
78
+ /* next */
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Scan JSON-lines output for the LAST line that satisfies `want`, parsing only
85
+ // candidate lines and retaining one object. Untrusted CLI output can be large.
86
+ function lastJsonLine(out, needle, want) {
87
+ let found = null;
88
+ let start = 0;
89
+ const text = String(out);
90
+ while (start < text.length) {
91
+ let end = text.indexOf('\n', start);
92
+ if (end === -1) end = text.length;
93
+ const line = text.slice(start, end);
94
+ start = end + 1;
95
+ if (line.length > 1_000_000 || line.indexOf(needle) === -1) continue;
96
+ const t = line.trim();
97
+ if (!t.startsWith('{')) continue;
98
+ try {
99
+ const o = JSON.parse(t);
100
+ if (want(o)) found = o;
101
+ } catch {
102
+ /* not JSON, skip */
103
+ }
104
+ }
105
+ return found;
106
+ }
107
+
108
+ // --- judges: (rc, out, err, extra) -> { text, reason, detail } ---------------
109
+ // `reason` is a fixed code from REASONS (durable). `detail` is a human line for
110
+ // the terminal and MAY contain provider values; it is never logged.
111
+ // Every field access is type-guarded: a malformed payload returns a verdict,
112
+ // never throws.
113
+ const fail = (reason, detail) => ({ text: null, reason, detail });
114
+ const pass = (text, detail) => ({ text, reason: 'ok', detail });
115
+
116
+ export function judgeGrok(rc, out) {
117
+ let o;
118
+ try {
119
+ o = JSON.parse(out);
120
+ } catch {
121
+ return fail('not_json', 'stdout was not JSON');
122
+ }
123
+ if (!o || typeof o !== 'object' || Array.isArray(o)) return fail('not_json', 'JSON was not an object');
124
+ const stop = o.stopReason;
125
+ const text = typeof o.text === 'string' ? o.text.trim() : '';
126
+ if (stop !== 'end_turn') return fail('bad_stop_reason', `stopReason=${JSON.stringify(stop)}`);
127
+ return text ? pass(text, 'stopReason=end_turn') : fail('empty_text', 'end_turn but empty text');
128
+ }
129
+
130
+ export function judgeCodex(rc, out, err, fileText) {
131
+ const completed = lastJsonLine(out, 'turn.completed', (o) => o && o.type === 'turn.completed') !== null;
132
+ const text = typeof fileText === 'string' ? fileText.trim() : '';
133
+ if (!completed) return fail('no_terminal_event', 'no terminal turn.completed event');
134
+ return text ? pass(text, 'turn.completed') : fail('empty_output_file', 'turn.completed but -o file empty');
135
+ }
136
+
137
+ export function judgeAgy(rc, out) {
138
+ const ev = lastJsonLine(out, '"result"', (o) => o && o.event === 'result');
139
+ if (ev === null) return fail('no_terminal_event', 'no terminal result event');
140
+ const term = ev.result && typeof ev.result === 'object' && !Array.isArray(ev.result) ? ev.result : {};
141
+ const status = term.status;
142
+ const text = typeof term.response === 'string' ? term.response.trim() : '';
143
+ if (status !== 'SUCCESS') return fail('bad_status', `status=${JSON.stringify(status)}`);
144
+ return text ? pass(text, 'status=SUCCESS') : fail('empty_response', 'SUCCESS but empty response');
145
+ }
146
+
147
+ export function judgeHermes(rc, out, err) {
148
+ const text = String(out || '').trim();
149
+ if (rc !== 0) {
150
+ const why = { 1: 'no final response (agent produced nothing)', 2: 'bad args, or completed with an empty response' }[rc] || 'unknown failure';
151
+ return fail('exit_nonzero', `hermes exit ${rc}: ${why}`);
152
+ }
153
+ return text ? pass(text, 'exit 0') : fail('empty_stdout', 'exit 0 but empty stdout');
154
+ }
155
+
156
+ export function judgeQwen(rc, out) {
157
+ let events;
158
+ try {
159
+ events = JSON.parse(out);
160
+ } catch {
161
+ return fail('not_json', 'stdout was not JSON');
162
+ }
163
+ if (!Array.isArray(events) || events.length === 0) return fail('bad_event_array', 'JSON was not a non-empty event array');
164
+ const term = events[events.length - 1];
165
+ if (!term || typeof term !== 'object' || Array.isArray(term)) return fail('bad_last_event', 'last event was not an object');
166
+ if (term.type !== 'result') return fail('not_result', `last event was ${JSON.stringify(term.type)}, not result`);
167
+ if (term.subtype !== 'success') {
168
+ const e = term.error;
169
+ const msg = e && typeof e === 'object' && typeof e.message === 'string' ? e.message : e ? String(e) : '';
170
+ return fail('bad_subtype', `subtype=${JSON.stringify(term.subtype)}` + (msg ? `: ${msg.slice(0, 120)}` : ''));
171
+ }
172
+ if (term.is_error) return fail('is_error', 'is_error true');
173
+ if (term.result != null && typeof term.result !== 'string') return fail('result_not_string', `result was ${typeof term.result}, not a string`);
174
+ const text = (term.result || '').trim();
175
+ if (!text) return fail('empty_result', 'success but empty result');
176
+ // qwen reports success even when the upstream API rejected the call; the
177
+ // error text lands in `result`. These two checks are the honest ones.
178
+ if (text.startsWith('[API Error:')) return fail('api_error_in_result', `success flag lied, result is an API error: ${text.slice(0, 140)}`);
179
+ const stats = term.stats;
180
+ const models = stats && typeof stats === 'object' ? stats.models : null;
181
+ if (!models || typeof models !== 'object' || Array.isArray(models) || Object.keys(models).length === 0) {
182
+ return fail('telemetry_absent', 'success but stats.models absent: cannot verify totalErrors'); // absent telemetry is an unknown, not a zero
183
+ }
184
+ for (const [name, m] of Object.entries(models)) {
185
+ const api = m && typeof m === 'object' ? m.api : null;
186
+ const errs = api && typeof api === 'object' ? api.totalErrors : undefined;
187
+ if (!Number.isInteger(errs)) return fail('total_errors_unreadable', `success but ${name} has no readable totalErrors`);
188
+ if (errs) return fail('total_errors', `success flag lied, ${name} reported ${errs} API error(s)`);
189
+ }
190
+ return pass(text, `subtype=success, totalErrors=0 across ${Object.keys(models).length} model(s)`);
191
+ }
192
+
193
+ // --- adapters: build argv for a lane -------------------------------------
194
+ export function buildArgv(lane, binary, prompt, opts, tmp) {
195
+ const timeout = opts.timeout;
196
+ switch (lane) {
197
+ case 'grok':
198
+ return { argv: [binary, '--output-format', 'json', '-p', prompt] };
199
+ case 'codex': {
200
+ const last = join(tmp, 'last.txt');
201
+ const argv = [binary, 'exec', '--json', '--color', 'never', '--skip-git-repo-check', '-o', last];
202
+ if (opts.audit) argv.push('--sandbox', 'read-only'); // an audit lane that can write is a bug
203
+ argv.push(prompt);
204
+ return { argv, outFile: last };
205
+ }
206
+ case 'agy': {
207
+ const mins = Math.max(1, Math.round(timeout / 60));
208
+ return { argv: [binary, '--print-timeout', `${mins}m`, '--output-format', 'stream-json', '-p', prompt] };
209
+ }
210
+ case 'hermes':
211
+ return { argv: [binary, '-z', prompt, '--usage-file', join(tmp, 'usage.json')] };
212
+ case 'qwen': {
213
+ const argv = [binary, '-o', 'json'];
214
+ if (opts.model) argv.push('-m', opts.model);
215
+ if (opts.safeMode) argv.push('--safe-mode');
216
+ argv.push('-p', prompt);
217
+ return { argv };
218
+ }
219
+ default:
220
+ throw new Error('unknown lane ' + lane);
221
+ }
222
+ }
223
+
224
+ export function judge(lane, rc, out, err, outFile) {
225
+ switch (lane) {
226
+ case 'grok':
227
+ return judgeGrok(rc, out);
228
+ case 'codex':
229
+ return judgeCodex(rc, out, err, outFile && existsSync(outFile) ? readFileSync(outFile, 'utf8') : '');
230
+ case 'agy':
231
+ return judgeAgy(rc, out);
232
+ case 'hermes':
233
+ return judgeHermes(rc, out, err);
234
+ case 'qwen':
235
+ return judgeQwen(rc, out);
236
+ default:
237
+ throw new Error('unknown lane ' + lane);
238
+ }
239
+ }
240
+
241
+ // --- the process boundary --------------------------------------------------
242
+ // The lane runs DETACHED, so it leads its own process group. On timeout (or an
243
+ // output-buffer overrun) the whole group is killed, not just the direct child:
244
+ // an agent CLI that shelled out to a tool must not keep working after the
245
+ // wrapper has reported 12. A child that calls setsid() itself escapes this
246
+ // boundary; that is documented, not hidden.
247
+ export function runBounded(argv, timeoutSec, maxBuffer = 16 * 1024 * 1024) {
248
+ return new Promise((resolveRun) => {
249
+ const t0 = Date.now();
250
+ let child = null;
251
+ let interrupted = null;
252
+ // Signal handlers go on BEFORE the spawn. The child starts running the
253
+ // moment spawn() forks, so a handler registered afterwards leaves a window
254
+ // in which the lane is alive and a SIGTERM to the wrapper would take the
255
+ // default action: the wrapper dies, the detached lane lives on. CI on a slow
256
+ // runner hit exactly that window.
257
+ const killGroup = () => {
258
+ if (!child) return;
259
+ try {
260
+ if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL');
261
+ else child.kill('SIGKILL');
262
+ } catch {
263
+ try {
264
+ child.kill('SIGKILL');
265
+ } catch {
266
+ /* already gone */
267
+ }
268
+ }
269
+ };
270
+ const onSignal = (sig) => {
271
+ if (interrupted) {
272
+ killGroup();
273
+ process.exit(128 + (sig === 'SIGINT' ? 2 : 15));
274
+ }
275
+ interrupted = sig;
276
+ killGroup();
277
+ };
278
+ process.on('SIGINT', onSignal);
279
+ process.on('SIGTERM', onSignal);
280
+ try {
281
+ child = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
282
+ } catch (e) {
283
+ process.off('SIGINT', onSignal);
284
+ process.off('SIGTERM', onSignal);
285
+ return resolveRun({ status: null, signal: null, stdout: '', stderr: '', error: e, seconds: 0 });
286
+ }
287
+ if (interrupted) killGroup(); // a signal landed between registering and forking
288
+ // Streaming decoders: a multibyte UTF-8 character split across two chunks
289
+ // must not become replacement characters. Limits are counted in BYTES.
290
+ const outDec = new StringDecoder('utf8');
291
+ const errDec = new StringDecoder('utf8');
292
+ let out = '';
293
+ let err = '';
294
+ let outBytes = 0;
295
+ let errBytes = 0;
296
+ let timedOut = false;
297
+ let overrun = false;
298
+ let settled = false;
299
+ const timer = setTimeout(() => {
300
+ timedOut = true;
301
+ killGroup();
302
+ }, timeoutSec * 1000);
303
+ child.stdout.on('data', (d) => {
304
+ outBytes += d.length;
305
+ if (outBytes > maxBuffer) {
306
+ if (!overrun) {
307
+ overrun = true;
308
+ killGroup();
309
+ }
310
+ return;
311
+ }
312
+ out += outDec.write(d);
313
+ });
314
+ child.stderr.on('data', (d) => {
315
+ errBytes += d.length;
316
+ if (errBytes <= 64 * 1024) err += errDec.write(d);
317
+ });
318
+ const finish = (status, signal, error) => {
319
+ if (settled) return;
320
+ settled = true;
321
+ clearTimeout(timer);
322
+ process.off('SIGINT', onSignal);
323
+ process.off('SIGTERM', onSignal);
324
+ out += outDec.end();
325
+ err += errDec.end();
326
+ // Resolve on the child's exit with a short grace for the pipes, so a stray
327
+ // descendant holding stdout cannot keep this promise open.
328
+ setTimeout(() => resolveRun({ status, signal, stdout: out, stderr: err, error, timedOut, overrun, interrupted, outBytes, seconds: (Date.now() - t0) / 1000 }), 20);
329
+ };
330
+ child.on('error', (e) => finish(null, null, e));
331
+ child.on('exit', (status, signal) => {
332
+ // exit fires when the direct child ends; kill the rest of its group so a
333
+ // detached grandchild cannot outlive a run that ended normally either.
334
+ killGroup();
335
+ finish(status, signal, null);
336
+ });
337
+ });
338
+ }
339
+
340
+ function log(rec) {
341
+ try {
342
+ mkdirSync(dirname(LOG), { recursive: true });
343
+ appendFileSync(LOG, JSON.stringify(rec) + '\n');
344
+ } catch {
345
+ /* logging never changes the outcome */
346
+ }
347
+ }
348
+
349
+ // lanes.json sits beside this script. ABSENT = every lane enabled (the
350
+ // documented default). PRESENT BUT UNREADABLE OR MALFORMED = no lane enabled:
351
+ // a half-written config must fail closed, never re-enable what the installer
352
+ // disabled. Returns null when the file is bad so the caller can say so.
353
+ export function enabledLanes(here = dirname(fileURLToPath(import.meta.url))) {
354
+ const p = join(here, 'lanes.json');
355
+ if (!existsSync(p)) return LANES;
356
+ try {
357
+ const j = JSON.parse(readFileSync(p, 'utf8'));
358
+ if (!j || typeof j !== 'object' || !Array.isArray(j.enabled)) return null;
359
+ if (!j.enabled.every((l) => typeof l === 'string' && LANES.includes(l))) return null;
360
+ return j.enabled;
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ function usage(msg) {
367
+ if (msg) console.error('cli-run: ' + msg);
368
+ console.error(`usage: cli-run <${LANES.join('|')}> "<prompt>" [--brief FILE] [--timeout SECS] [--quiet]
369
+ [--expect-file PATH] [--expect-json]
370
+ cli-run codex --audit "<prompt>" read-only sandbox (audit shape)
371
+ cli-run qwen [--model ID] [--safe-mode] "<prompt>"
372
+ cli-run --doctor [--run] enabled lanes, binaries on PATH; --run sends each a tiny prompt`);
373
+ return USAGE;
374
+ }
375
+
376
+ // Bounded, control-character-free head of vendor stderr for the terminal.
377
+ // Never logged: provider text can echo whatever the prompt contained.
378
+ function stderrHead(err, n = 300) {
379
+ const s = String(err || '').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').trim();
380
+ if (!s) return '';
381
+ return s.length > n ? s.slice(0, n) + '…' : s;
382
+ }
383
+
384
+ // MANIFEST.json sits one level above bin/. Only the primary id is read from it,
385
+ // and only to explain why the primary is absent from the lane list.
386
+ function installedPrimary(here = dirname(fileURLToPath(import.meta.url))) {
387
+ const p = join(here, '..', 'MANIFEST.json');
388
+ try {
389
+ if (!existsSync(p) || statSync(p).size > 1024 * 1024) return null;
390
+ const m = JSON.parse(readFileSync(p, 'utf8'));
391
+ return m && typeof m.primary === 'string' && /^[a-z0-9-]+$/.test(m.primary) ? m.primary : null;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
397
+ // --doctor: the first thing to run after install.
398
+ export async function doctor(run) {
399
+ const enabled = enabledLanes();
400
+ if (enabled === null) {
401
+ console.error('doctor: lanes.json exists but is malformed; fix it first');
402
+ return USAGE;
403
+ }
404
+ let bad = 0;
405
+ console.log(`doctor: ${enabled.length} enabled lane(s): ${enabled.join(', ') || 'none'}`);
406
+ const primary = installedPrimary();
407
+ if (primary && !enabled.includes(primary)) console.log(` note: ${primary} is the primary agent; it calls cli-run and is not a lane`);
408
+ for (const lane of LANES) {
409
+ const on = enabled.includes(lane);
410
+ const bin = which(lane);
411
+ let line = ` ${lane.padEnd(7)} ${on ? 'enabled ' : 'disabled'} ${bin ? 'binary ok' : 'binary MISSING'}`;
412
+ if (on && !bin) bad++;
413
+ if (on && bin && run) {
414
+ const rc = await main([lane, 'Reply with exactly the word OK and nothing else.', '--timeout', '120', '--quiet']);
415
+ line += rc === OK ? ' canary ok' : ` canary FAILED rc=${rc}`;
416
+ if (rc !== OK) bad++;
417
+ }
418
+ console.log(line);
419
+ }
420
+ console.log(bad ? `doctor: ${bad} problem(s)` : 'doctor: all enabled lanes ' + (run ? 'answered' : 'present'));
421
+ console.log('doctor checks presence and, with --run, a one-word canary. It does not check vendor versions.');
422
+ return bad ? NO_DELIVERABLE : OK;
423
+ }
424
+
425
+ // Opt-in contracts. A refusal that parses cleanly is a structurally accepted
426
+ // response; these are how a caller says "that is not enough for this task".
427
+ // --expect-file compares the artifact AFTER the run with a snapshot taken
428
+ // BEFORE it: the file must exist, be non-empty, and be new or changed (a
429
+ // different content hash, or a later mtime). An artifact that already existed
430
+ // and was not touched fails, however recent it is; a timestamp window alone
431
+ // cannot prove this run produced it.
432
+ export function snapshotFile(p) {
433
+ try {
434
+ const st = statSync(p);
435
+ if (!st.isFile()) return { exists: true, file: false };
436
+ return { exists: true, file: true, mtimeMs: st.mtimeMs, size: st.size, sha: createHash('sha256').update(readFileSync(p)).digest('hex') };
437
+ } catch {
438
+ return { exists: false };
439
+ }
440
+ }
441
+
442
+ export function checkContracts(opts, text, before) {
443
+ if (opts.expectFile) {
444
+ const p = resolve(opts.expectFile);
445
+ const after = snapshotFile(p);
446
+ if (!after.exists) return `--expect-file: ${p} does not exist after the run`;
447
+ if (!after.file || after.size === 0) return `--expect-file: ${p} is empty or not a regular file`;
448
+ if (before && before.exists) {
449
+ const changed = !before.file || after.sha !== before.sha || after.mtimeMs > before.mtimeMs;
450
+ if (!changed) return `--expect-file: ${p} existed before the run and was not changed by it (same content, same mtime); a pre-existing artifact is not this run's deliverable`;
451
+ }
452
+ }
453
+ if (opts.expectJson) {
454
+ try {
455
+ JSON.parse(text);
456
+ } catch {
457
+ return '--expect-json: the response is not valid JSON';
458
+ }
459
+ }
460
+ return null;
461
+ }
462
+
463
+ export async function main(argv) {
464
+ const VALUE = new Set(['--brief', '--timeout', '--model', '--expect-file']);
465
+ const BOOL = new Set(['--quiet', '--audit', '--safe-mode', '--doctor', '--run', '--expect-json']);
466
+ const args = [...argv];
467
+ const opts = { timeout: 900, quiet: false, audit: false, model: null, safeMode: false, brief: null, doctor: false, run: false, expectFile: null, expectJson: false };
468
+ const positional = [];
469
+ while (args.length) {
470
+ const a = args.shift();
471
+ if (VALUE.has(a)) {
472
+ const v = args.shift();
473
+ if (v === undefined || v.startsWith('--')) return usage(`${a} requires a value`);
474
+ if (a === '--brief') opts.brief = v;
475
+ else if (a === '--timeout') opts.timeout = Number(v);
476
+ else if (a === '--expect-file') opts.expectFile = v;
477
+ else opts.model = v;
478
+ } else if (BOOL.has(a)) {
479
+ if (a === '--quiet') opts.quiet = true;
480
+ else if (a === '--audit') opts.audit = true;
481
+ else if (a === '--doctor') opts.doctor = true;
482
+ else if (a === '--run') opts.run = true;
483
+ else if (a === '--expect-json') opts.expectJson = true;
484
+ else opts.safeMode = true;
485
+ } else if (a.startsWith('--')) return usage('unknown flag ' + a);
486
+ else positional.push(a);
487
+ }
488
+ if (opts.doctor) {
489
+ if (positional.length) return usage('--doctor takes no lane or prompt');
490
+ return doctor(opts.run);
491
+ }
492
+ if (opts.run) return usage('--run only applies with --doctor');
493
+ const lane = positional[0];
494
+ if (!LANES.includes(lane)) return usage('lane must be one of ' + LANES.join(', '));
495
+ if (positional.length > 2) return usage('unexpected extra argument: ' + positional.slice(2).join(' '));
496
+ if (positional[1] !== undefined && opts.brief) return usage('give a prompt OR --brief, not both');
497
+ let prompt = positional[1];
498
+ if (opts.brief) {
499
+ try {
500
+ if (!statSync(opts.brief).isFile()) return usage('--brief must be a file: ' + opts.brief);
501
+ prompt = readFileSync(opts.brief, 'utf8');
502
+ } catch (e) {
503
+ return usage('cannot read --brief ' + opts.brief + ': ' + (e && e.code ? e.code : e));
504
+ }
505
+ }
506
+ if (!prompt) return usage('give a prompt or --brief FILE');
507
+ if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) return usage('--timeout must be a positive number of seconds');
508
+ if (opts.audit && lane !== 'codex') return usage('--audit is codex-only');
509
+ if ((opts.model || opts.safeMode) && lane !== 'qwen') return usage('--model and --safe-mode are qwen-only');
510
+
511
+ const digest = createHash('sha256').update(prompt).digest('hex').slice(0, 12);
512
+ const base = { lane, prompt_sha256_12: digest, prompt_chars: prompt.length };
513
+ const enabled = enabledLanes();
514
+ if (enabled === null) {
515
+ console.error('cli-run: lanes.json exists but is not a valid {"enabled": [...]} file; refusing every lane until it is fixed');
516
+ log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'lanes_json_malformed' });
517
+ return UNAVAILABLE;
518
+ }
519
+ if (!enabled.includes(lane)) {
520
+ console.error(`cli-run: ${lane} is not enabled in lanes.json`);
521
+ log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'disabled' });
522
+ return UNAVAILABLE;
523
+ }
524
+ const binary = which(lane);
525
+ if (!binary) {
526
+ console.error(`cli-run: ${lane} not found on PATH`);
527
+ log({ ...base, verdict: 'unavailable', rc: UNAVAILABLE, reason: 'unavailable' });
528
+ return UNAVAILABLE;
529
+ }
530
+
531
+ const tmp = mkdtempSync(join(tmpdir(), 'cli-run-'));
532
+ const before = opts.expectFile ? snapshotFile(resolve(opts.expectFile)) : null;
533
+ try {
534
+ const { argv: cmd, outFile } = buildArgv(lane, binary, prompt, opts, tmp);
535
+ const r = await runBounded(cmd, opts.timeout);
536
+ const out = r.stdout || '';
537
+ const err = r.stderr || '';
538
+ let verdict, reason, detail, code, text = '';
539
+ if (r.interrupted) {
540
+ verdict = 'interrupted'; reason = 'killed'; detail = `cli-run received ${r.interrupted}; the lane's process group was killed`; code = 128 + (r.interrupted === 'SIGINT' ? 2 : 15);
541
+ } else if (r.timedOut) {
542
+ verdict = 'timeout'; reason = 'timeout'; detail = `exceeded ${opts.timeout}s; process group killed`; code = TIMEOUT;
543
+ } else if (r.overrun) {
544
+ verdict = 'no_deliverable'; reason = 'no_output'; detail = 'output exceeded the 16 MiB buffer; process group killed'; code = NO_DELIVERABLE;
545
+ } else if (r.error) {
546
+ verdict = 'unavailable'; reason = 'unavailable'; detail = r.error.message; code = UNAVAILABLE;
547
+ } else if (r.signal || r.status === null) {
548
+ // A lane killed by a signal has no honest exit status. Whatever it printed
549
+ // before dying is not a deliverable; a null status must never become exit 0.
550
+ verdict = 'killed'; reason = 'killed'; detail = `lane killed by ${r.signal || 'unknown signal'}`; code = NO_DELIVERABLE;
551
+ } else {
552
+ const j = judge(lane, r.status, out, err, outFile);
553
+ text = j.text || '';
554
+ reason = j.reason;
555
+ detail = j.detail;
556
+ if (r.status !== 0) {
557
+ // A nonzero vendor exit is a failure on the vendor's own terms, whether or
558
+ // not something parseable came back. Pass the code through, keep the
559
+ // verdict honest, and show what the vendor said on stderr.
560
+ verdict = 'exit_nonzero'; code = r.status;
561
+ if (reason === 'ok') reason = 'exit_nonzero';
562
+ const head = stderrHead(err);
563
+ detail = `lane exited ${r.status}` + (head ? `; stderr: ${head}` : '') + (j.reason !== 'ok' ? `; ${j.detail}` : '');
564
+ } else if (text) {
565
+ const unmet = checkContracts(opts, text, before);
566
+ if (unmet) {
567
+ verdict = 'no_deliverable'; reason = 'contract_unmet'; detail = unmet; code = NO_DELIVERABLE;
568
+ } else {
569
+ verdict = 'ok'; code = OK;
570
+ }
571
+ } else if (!out.trim() && !err.trim()) {
572
+ verdict = 'no_output'; reason = 'no_output'; code = NO_OUTPUT;
573
+ } else {
574
+ verdict = 'no_deliverable'; code = NO_DELIVERABLE;
575
+ const head = stderrHead(err);
576
+ if (head) detail += `; stderr: ${head}`;
577
+ }
578
+ }
579
+ if (text && code === OK) process.stdout.write(text + '\n');
580
+ if (!opts.quiet) console.error(`cli-run[${lane}] ${verdict} rc=${code} ${r.seconds.toFixed(1)}s raw=${r.outBytes || 0}B :: ${detail}`);
581
+ // Durable log: fixed reason code and structural numbers only.
582
+ log({ ...base, verdict, rc: code, cli_rc: r.status, signal: r.signal || null, seconds: Math.round(r.seconds * 100) / 100, raw_bytes: r.outBytes || 0, deliverable_bytes: Buffer.byteLength(text), reason: REASONS.has(reason) ? reason : 'unknown' });
583
+ return code;
584
+ } finally {
585
+ rmSync(tmp, { recursive: true, force: true });
586
+ }
587
+ }
588
+
589
+ function isEntryPoint() {
590
+ if (!process.argv[1]) return false;
591
+ try {
592
+ return pathToFileURL(realpathSync(process.argv[1])).href === pathToFileURL(realpathSync(fileURLToPath(import.meta.url))).href;
593
+ } catch {
594
+ return false;
595
+ }
596
+ }
597
+ if (isEntryPoint()) {
598
+ main(process.argv.slice(2)).then((code) => process.exit(code));
599
+ }