atris 3.16.0 → 3.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +33 -7
  2. package/atris/skills/atris/SKILL.md +15 -2
  3. package/atris/skills/atris-feedback/SKILL.md +7 -0
  4. package/atris/skills/design/SKILL.md +29 -2
  5. package/atris/skills/engines/SKILL.md +44 -0
  6. package/atris/skills/flow/SKILL.md +1 -1
  7. package/atris/skills/wake/SKILL.md +37 -0
  8. package/atris/skills/youtube/SKILL.md +13 -39
  9. package/atris/team/validator/MEMBER.md +1 -0
  10. package/atris/wiki/concepts/agent-activation-contract.md +3 -3
  11. package/atris/wiki/concepts/workspace-initialization-contract.md +3 -3
  12. package/atris/wiki/index.md +1 -0
  13. package/atris.md +43 -19
  14. package/bin/atris.js +446 -43
  15. package/commands/agent-spawn.js +480 -0
  16. package/commands/analytics.js +6 -3
  17. package/commands/apps.js +11 -0
  18. package/commands/autopilot.js +466 -20
  19. package/commands/brain.js +74 -7
  20. package/commands/brainstorm.js +9 -58
  21. package/commands/clean.js +1 -4
  22. package/commands/compile.js +574 -0
  23. package/commands/console.js +8 -3
  24. package/commands/deck.js +135 -0
  25. package/commands/init.js +22 -11
  26. package/commands/lesson.js +76 -0
  27. package/commands/member.js +252 -48
  28. package/commands/mission.js +405 -13
  29. package/commands/now.js +4 -2
  30. package/commands/probe.js +444 -0
  31. package/commands/pulse.js +504 -0
  32. package/commands/radar.js +1 -0
  33. package/commands/recap.js +233 -0
  34. package/commands/run.js +615 -22
  35. package/commands/skill.js +6 -2
  36. package/commands/slop.js +173 -0
  37. package/commands/spaceship.js +39 -0
  38. package/commands/sync.js +0 -2
  39. package/commands/task.js +458 -43
  40. package/commands/verify.js +7 -3
  41. package/lib/activity-stream.js +166 -0
  42. package/lib/auto-accept-certified.js +23 -1
  43. package/lib/context-gatherer.js +170 -0
  44. package/lib/escape-regexp.js +13 -0
  45. package/lib/file-ops.js +6 -3
  46. package/lib/journal.js +1 -1
  47. package/lib/lesson-contradiction.js +113 -0
  48. package/lib/policy-lessons.js +3 -2
  49. package/lib/pulse.js +401 -0
  50. package/lib/runner-command.js +156 -0
  51. package/lib/slides-deck.js +236 -0
  52. package/lib/state-detection.js +40 -3
  53. package/lib/task-db.js +101 -4
  54. package/lib/task-proof.js +1 -1
  55. package/lib/todo-fallback.js +2 -1
  56. package/lib/todo-sections.js +33 -0
  57. package/package.json +1 -2
  58. package/utils/api.js +14 -2
  59. package/atris/atrisDev.md +0 -717
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Atris Compile — learn like AI, run like code.
3
+ *
4
+ * The compile loop: execution records → compile-to-deterministic-code →
5
+ * statistical backtest → gated promote. The LLM is only in the build path;
6
+ * the promoted artifact runs token-free.
7
+ *
8
+ * atris compile record <name> --input <json|@file> --output <json|@file>
9
+ * atris compile build <name> (uses the shared runner command)
10
+ * atris compile backtest <name>
11
+ * atris compile promote <name>
12
+ * atris compile exec <name> --input <json|@file> [--record]
13
+ * atris compile status
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { execSync } = require('child_process');
21
+ const {
22
+ buildRunnerAvailabilityCommand,
23
+ buildRunnerCommand,
24
+ resolveClaudeRunnerBin,
25
+ } = require('../lib/runner-command');
26
+
27
+ const DEFAULT_THRESHOLD = 0.99;
28
+ const SAMPLE_RECORDS_FOR_BUILD = 25;
29
+ const LOW_RECORD_WARNING = 20;
30
+
31
+ // ── paths ──────────────────────────────────────────────────────────
32
+
33
+ function processDir(root, name) {
34
+ return path.join(root, 'atris', 'processes', name);
35
+ }
36
+
37
+ function recordsPath(root, name) {
38
+ return path.join(root, '.atris', 'state', 'processes', name, 'records.jsonl');
39
+ }
40
+
41
+ function manifestPath(root, name) {
42
+ return path.join(processDir(root, name), 'process.json');
43
+ }
44
+
45
+ function runnerPath(root, name) {
46
+ return path.join(processDir(root, name), 'run.js');
47
+ }
48
+
49
+ function specPath(root, name) {
50
+ return path.join(processDir(root, name), 'spec.md');
51
+ }
52
+
53
+ function validName(name) {
54
+ return typeof name === 'string' && /^[a-z0-9][a-z0-9-_]*$/.test(name);
55
+ }
56
+
57
+ // ── manifest ───────────────────────────────────────────────────────
58
+
59
+ function readManifest(root, name) {
60
+ const p = manifestPath(root, name);
61
+ if (!fs.existsSync(p)) return null;
62
+ try {
63
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function writeManifest(root, name, manifest) {
70
+ fs.mkdirSync(processDir(root, name), { recursive: true });
71
+ const target = manifestPath(root, name);
72
+ const tmp = `${target}.tmp`;
73
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + '\n');
74
+ fs.renameSync(tmp, target);
75
+ return manifest;
76
+ }
77
+
78
+ function defaultManifest(name) {
79
+ return {
80
+ name,
81
+ version: 0,
82
+ status: 'draft', // draft | active | drifted
83
+ threshold: DEFAULT_THRESHOLD,
84
+ compiledAt: null,
85
+ backtest: null, // { version, total, passed, accuracy, at, failures }
86
+ };
87
+ }
88
+
89
+ // ── records ────────────────────────────────────────────────────────
90
+
91
+ function parseValueArg(raw) {
92
+ if (raw === undefined || raw === null) return undefined;
93
+ let text = String(raw);
94
+ if (text.startsWith('@')) {
95
+ text = fs.readFileSync(text.slice(1), 'utf8');
96
+ }
97
+ try {
98
+ return JSON.parse(text);
99
+ } catch {
100
+ return text; // plain string payloads are fine
101
+ }
102
+ }
103
+
104
+ function appendRecord(root, name, record) {
105
+ const p = recordsPath(root, name);
106
+ fs.mkdirSync(path.dirname(p), { recursive: true });
107
+ const entry = { ts: new Date().toISOString(), ...record };
108
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n');
109
+ return entry;
110
+ }
111
+
112
+ function readRecords(root, name) {
113
+ const p = recordsPath(root, name);
114
+ if (!fs.existsSync(p)) return [];
115
+ return fs.readFileSync(p, 'utf8')
116
+ .split('\n')
117
+ .filter((line) => line.trim())
118
+ .map((line) => {
119
+ try { return JSON.parse(line); } catch { return null; }
120
+ })
121
+ .filter(Boolean);
122
+ }
123
+
124
+ function listProcesses(root) {
125
+ const names = new Set();
126
+ const docsDir = path.join(root, 'atris', 'processes');
127
+ const stateDir = path.join(root, '.atris', 'state', 'processes');
128
+ for (const dir of [docsDir, stateDir]) {
129
+ if (!fs.existsSync(dir)) continue;
130
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
131
+ if (entry.isDirectory()) names.add(entry.name);
132
+ }
133
+ }
134
+ return [...names].sort();
135
+ }
136
+
137
+ // ── compare ────────────────────────────────────────────────────────
138
+
139
+ function deepEqual(a, b) {
140
+ if (a === b) return true;
141
+ if (typeof a !== typeof b) return false;
142
+ if (a === null || b === null) return false;
143
+ if (typeof a !== 'object') {
144
+ return Number.isNaN(a) && Number.isNaN(b);
145
+ }
146
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
147
+ const keysA = Object.keys(a);
148
+ const keysB = Object.keys(b);
149
+ if (keysA.length !== keysB.length) return false;
150
+ return keysA.every((k) => deepEqual(a[k], b[k]));
151
+ }
152
+
153
+ // ── runner loading / execution ─────────────────────────────────────
154
+
155
+ function loadRunner(root, name) {
156
+ const p = runnerPath(root, name);
157
+ if (!fs.existsSync(p)) {
158
+ throw new Error(`no compiled artifact at ${path.relative(root, p)} — run "atris compile build ${name}" first`);
159
+ }
160
+ const resolved = require.resolve(p);
161
+ delete require.cache[resolved]; // always load the latest compiled version
162
+ const mod = require(resolved);
163
+ if (!mod || typeof mod.run !== 'function') {
164
+ throw new Error(`${path.relative(root, p)} must export { run(input) }`);
165
+ }
166
+ return mod;
167
+ }
168
+
169
+ // ── backtest ───────────────────────────────────────────────────────
170
+
171
+ async function runBacktest(root, name, options = {}) {
172
+ const records = readRecords(root, name);
173
+ if (records.length === 0) {
174
+ throw new Error(`no execution records for "${name}" — add some with "atris compile record ${name} --input ... --output ..."`);
175
+ }
176
+ const runner = loadRunner(root, name);
177
+ const manifest = readManifest(root, name) || defaultManifest(name);
178
+ const threshold = options.threshold !== undefined ? options.threshold : manifest.threshold;
179
+
180
+ let passed = 0;
181
+ const failures = [];
182
+ for (let i = 0; i < records.length; i++) {
183
+ const record = records[i];
184
+ const expected = record.expected !== undefined ? record.expected : record.output;
185
+ try {
186
+ const actual = await runner.run(record.input);
187
+ if (deepEqual(actual, expected)) {
188
+ passed++;
189
+ } else {
190
+ failures.push({ index: i, input: record.input, expected, actual });
191
+ }
192
+ } catch (err) {
193
+ failures.push({ index: i, input: record.input, expected, error: err.message });
194
+ }
195
+ }
196
+
197
+ const total = records.length;
198
+ const accuracy = passed / total;
199
+ const result = {
200
+ version: manifest.version,
201
+ total,
202
+ passed,
203
+ accuracy,
204
+ threshold,
205
+ at: new Date().toISOString(),
206
+ failures: failures.slice(0, 10),
207
+ };
208
+
209
+ manifest.backtest = result;
210
+ // a one-off --threshold applies to this run only; the promote gate stays as set
211
+ // drift detection: an active process falling below its standing gate is drifted
212
+ if (manifest.status === 'active' && accuracy < manifest.threshold) {
213
+ manifest.status = 'drifted';
214
+ }
215
+ writeManifest(root, name, manifest);
216
+
217
+ return { result, manifest, failureCount: failures.length };
218
+ }
219
+
220
+ // ── promote gate ───────────────────────────────────────────────────
221
+
222
+ function promoteProcess(root, name, options = {}) {
223
+ const manifest = readManifest(root, name);
224
+ if (!manifest) {
225
+ throw new Error(`no manifest for "${name}" — compile and backtest it first`);
226
+ }
227
+ if (options.threshold !== undefined) {
228
+ // the only way to change the standing gate: deliberate, at promote time
229
+ manifest.threshold = options.threshold;
230
+ }
231
+ const bt = manifest.backtest;
232
+ if (!bt) {
233
+ throw new Error(`"${name}" has no backtest — run "atris compile backtest ${name}" first`);
234
+ }
235
+ if (bt.version !== manifest.version) {
236
+ throw new Error(`backtest is for version ${bt.version} but current version is ${manifest.version} — re-run the backtest`);
237
+ }
238
+ if (bt.accuracy < manifest.threshold) {
239
+ throw new Error(`backtest accuracy ${(bt.accuracy * 100).toFixed(2)}% is below the ${(manifest.threshold * 100).toFixed(2)}% gate (${bt.passed}/${bt.total}) — not promoting`);
240
+ }
241
+ manifest.status = 'active';
242
+ manifest.promotedAt = new Date().toISOString();
243
+ writeManifest(root, name, manifest);
244
+ return manifest;
245
+ }
246
+
247
+ // ── build (the only step that spends tokens) ───────────────────────
248
+
249
+ function buildCompilePrompt(root, name, records, notes) {
250
+ const runRel = path.relative(root, runnerPath(root, name));
251
+ const specRel = path.relative(root, specPath(root, name));
252
+ const hasSpec = fs.existsSync(specPath(root, name));
253
+ const sample = records.slice(-SAMPLE_RECORDS_FOR_BUILD);
254
+
255
+ return `You are the Atris process compiler. Compile the recurring process "${name}" into deterministic code.
256
+
257
+ ${hasSpec ? `Read the process spec first: ${specRel}` : `There is no spec file yet. Infer the process from the execution records below, then write what you inferred to ${specRel} (short markdown: purpose, input shape, output shape, rules).`}
258
+ ${notes ? `\nOperator notes for this build:\n${notes}\n` : ''}
259
+ Execution records (most recent ${sample.length} of ${records.length}; input is what the process received, expected/output is the correct result):
260
+ ${JSON.stringify(sample, null, 2)}
261
+
262
+ Write the compiled artifact to ${runRel} with this exact contract:
263
+ - CommonJS module exporting { run }: \`module.exports = { run };\`
264
+ - \`run(input)\` takes one input value and returns the output (sync return or promise both fine)
265
+ - deterministic: no network calls, no Date.now()/new Date() in outputs, no randomness, no environment reads
266
+ - Node.js built-ins only — zero npm dependencies
267
+ - handle malformed input by throwing a clear Error, never by guessing
268
+
269
+ The artifact will be verified by replaying every execution record through run() and requiring near-perfect accuracy, so generalize the rules — do not hardcode a lookup table of the sample records.
270
+
271
+ Reply [COMPILE_COMPLETE] when ${runRel} is written.`;
272
+ }
273
+
274
+ function executeBuild(root, name, options = {}) {
275
+ const { verbose = false, timeout = 600000, notes = '', cmdOverride } = options;
276
+ const records = readRecords(root, name);
277
+ if (records.length === 0) {
278
+ throw new Error(`no execution records for "${name}" — record real runs first, then compile`);
279
+ }
280
+
281
+ if (!cmdOverride) {
282
+ try {
283
+ execSync(buildRunnerAvailabilityCommand(), { stdio: 'pipe' });
284
+ } catch {
285
+ throw new Error(`${resolveClaudeRunnerBin()} CLI not found. Set ATRIS_RUNNER_BIN (or legacy ATRIS_CLAUDE_BIN), or install the configured runner first.`);
286
+ }
287
+ }
288
+
289
+ fs.mkdirSync(processDir(root, name), { recursive: true });
290
+ const prompt = buildCompilePrompt(root, name, records, notes);
291
+ const tmpFile = path.join(root, '.compile-prompt.tmp');
292
+ fs.writeFileSync(tmpFile, prompt);
293
+
294
+ try {
295
+ const cmd = cmdOverride || buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Read,Write,Edit,Glob,Grep' });
296
+ const env = { ...process.env };
297
+ delete env.CLAUDECODE;
298
+ execSync(cmd, {
299
+ cwd: root,
300
+ encoding: 'utf8',
301
+ timeout,
302
+ stdio: verbose ? 'inherit' : 'pipe',
303
+ maxBuffer: 10 * 1024 * 1024,
304
+ env,
305
+ });
306
+ } finally {
307
+ try { fs.unlinkSync(tmpFile); } catch {}
308
+ }
309
+
310
+ if (!fs.existsSync(runnerPath(root, name))) {
311
+ throw new Error(`compile finished but ${path.relative(root, runnerPath(root, name))} was not written`);
312
+ }
313
+ loadRunner(root, name); // validates the contract
314
+
315
+ const manifest = readManifest(root, name) || defaultManifest(name);
316
+ manifest.version += 1;
317
+ manifest.compiledAt = new Date().toISOString();
318
+ manifest.status = 'draft'; // every rebuild must re-earn promotion through the gate
319
+ manifest.backtest = null; // new version must re-earn its backtest
320
+ writeManifest(root, name, manifest);
321
+ return manifest;
322
+ }
323
+
324
+ // ── arg parsing ────────────────────────────────────────────────────
325
+
326
+ function parseFlags(args) {
327
+ const flags = {};
328
+ const positional = [];
329
+ for (let i = 0; i < args.length; i++) {
330
+ const arg = args[i];
331
+ if (!arg.startsWith('--')) {
332
+ positional.push(arg);
333
+ continue;
334
+ }
335
+ const eq = arg.indexOf('=');
336
+ if (eq !== -1) {
337
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
338
+ } else if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
339
+ flags[arg.slice(2)] = args[++i];
340
+ } else {
341
+ flags[arg.slice(2)] = true;
342
+ }
343
+ }
344
+ return { flags, positional };
345
+ }
346
+
347
+ // ── formatting ─────────────────────────────────────────────────────
348
+
349
+ function formatAccuracy(accuracy) {
350
+ return `${(accuracy * 100).toFixed(2)}%`;
351
+ }
352
+
353
+ function printBacktest(name, result, failureCount, manifest) {
354
+ console.log(`backtest ${name} v${result.version}: ${result.passed}/${result.total} passed (${formatAccuracy(result.accuracy)}), gate ${formatAccuracy(result.threshold)}`);
355
+ if (result.total < LOW_RECORD_WARNING) {
356
+ console.log(`note: only ${result.total} record${result.total === 1 ? '' : 's'} — accuracy means more with ${LOW_RECORD_WARNING}+`);
357
+ }
358
+ if (failureCount > 0) {
359
+ console.log(`${failureCount} mismatch${failureCount === 1 ? '' : 'es'}. first ${Math.min(failureCount, 3)}:`);
360
+ for (const f of result.failures.slice(0, 3)) {
361
+ if (f.error) {
362
+ console.log(` record ${f.index}: threw "${f.error}"`);
363
+ } else {
364
+ console.log(` record ${f.index}: expected ${JSON.stringify(f.expected)} got ${JSON.stringify(f.actual)}`);
365
+ }
366
+ }
367
+ }
368
+ if (manifest.status === 'drifted') {
369
+ console.log(`drift detected: active process fell below its gate. recompile with "atris compile build ${name}"`);
370
+ } else if (result.accuracy >= result.threshold && manifest.status !== 'active') {
371
+ console.log(`gate passed. promote with "atris compile promote ${name}"`);
372
+ }
373
+ }
374
+
375
+ // ── command dispatcher ─────────────────────────────────────────────
376
+
377
+ function showCompileHelp() {
378
+ console.log('');
379
+ console.log('Usage: atris compile <subcommand> [args]');
380
+ console.log('');
381
+ console.log('Compile a recurring process into deterministic code, verified by');
382
+ console.log('backtesting against real execution records. LLM only at build time;');
383
+ console.log('the promoted artifact runs token-free.');
384
+ console.log('');
385
+ console.log(' record <name> --input <json|@file> --output <json|@file> [--expected ...]');
386
+ console.log(' append an execution record');
387
+ console.log(' build <name> [--notes "..."] [--verbose]');
388
+ console.log(' compile records + spec into atris/processes/<name>/run.js');
389
+ console.log(' backtest <name> [--threshold 0.99] [--json]');
390
+ console.log(' replay every record through run.js, score accuracy');
391
+ console.log(' (--threshold judges this run only; the gate is unchanged)');
392
+ console.log(' promote <name> [--gate 0.99]');
393
+ console.log(' mark active (gate: backtest accuracy >= threshold);');
394
+ console.log(' --gate is the only way to change the standing threshold');
395
+ console.log(' exec <name> --input <json|@file> [--record]');
396
+ console.log(' run the compiled process on new input');
397
+ console.log(' status [<name>] [--json] list processes, versions, accuracy, drift');
398
+ console.log('');
399
+ console.log('Loop: record real runs -> build -> backtest -> promote -> exec --record');
400
+ console.log('When backtest accuracy drops below the gate, the process is marked drifted');
401
+ console.log('and a recompile is suggested — self-healing against process drift.');
402
+ console.log('');
403
+ }
404
+
405
+ async function compileCommand(subcommand, ...rawArgs) {
406
+ const root = process.cwd();
407
+ const { flags, positional } = parseFlags(rawArgs);
408
+
409
+ if (!subcommand || subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
410
+ showCompileHelp();
411
+ return;
412
+ }
413
+
414
+ // `atris compile <name>` with an existing process name = build shortcut
415
+ let cmd = subcommand;
416
+ let name = positional[0];
417
+ const subcommands = ['record', 'build', 'backtest', 'promote', 'exec', 'status'];
418
+ if (!subcommands.includes(subcommand)) {
419
+ if (validName(subcommand) && listProcesses(root).includes(subcommand)) {
420
+ cmd = 'build';
421
+ name = subcommand;
422
+ } else {
423
+ console.error(`unknown subcommand "${subcommand}". try: atris compile help`);
424
+ process.exitCode = 1;
425
+ return;
426
+ }
427
+ }
428
+
429
+ if (cmd === 'status') {
430
+ const names = name ? [name] : listProcesses(root);
431
+ const rows = names.map((n) => {
432
+ const manifest = readManifest(root, n) || defaultManifest(n);
433
+ const records = readRecords(root, n).length;
434
+ return {
435
+ name: n,
436
+ version: manifest.version,
437
+ status: manifest.status,
438
+ records,
439
+ accuracy: manifest.backtest ? manifest.backtest.accuracy : null,
440
+ compiledAt: manifest.compiledAt,
441
+ };
442
+ });
443
+ if (flags.json) {
444
+ console.log(JSON.stringify({ ok: true, processes: rows }, null, 2));
445
+ return;
446
+ }
447
+ if (rows.length === 0) {
448
+ console.log('no compiled processes yet. start with: atris compile record <name> --input ... --output ...');
449
+ return;
450
+ }
451
+ for (const row of rows) {
452
+ const acc = row.accuracy === null ? 'no backtest' : formatAccuracy(row.accuracy);
453
+ console.log(`${row.name} v${row.version} ${row.status} ${row.records} record${row.records === 1 ? '' : 's'} ${acc}`);
454
+ }
455
+ return;
456
+ }
457
+
458
+ if (!validName(name)) {
459
+ console.error('process name required (lowercase letters, digits, dashes). example: atris compile record invoice-triage --input ... --output ...');
460
+ process.exitCode = 1;
461
+ return;
462
+ }
463
+
464
+ if (cmd === 'record') {
465
+ const input = parseValueArg(flags.input);
466
+ const output = parseValueArg(flags.output);
467
+ if (input === undefined || output === undefined) {
468
+ console.error('record needs --input and --output (inline json, a plain string, or @file)');
469
+ process.exitCode = 1;
470
+ return;
471
+ }
472
+ const record = { input, output };
473
+ const expected = parseValueArg(flags.expected);
474
+ if (expected !== undefined) record.expected = expected;
475
+ if (flags.source) record.source = flags.source;
476
+ appendRecord(root, name, record);
477
+ const total = readRecords(root, name).length;
478
+ console.log(`recorded ${name} run ${total} -> ${path.relative(root, recordsPath(root, name))}`);
479
+ return;
480
+ }
481
+
482
+ if (cmd === 'build') {
483
+ const records = readRecords(root, name);
484
+ console.log(`compiling ${name} from ${records.length} execution record${records.length === 1 ? '' : 's'}…`);
485
+ const manifest = executeBuild(root, name, {
486
+ verbose: Boolean(flags.verbose),
487
+ notes: typeof flags.notes === 'string' ? flags.notes : '',
488
+ timeout: flags.timeout ? Number(flags.timeout) * 1000 : undefined,
489
+ });
490
+ console.log(`compiled ${name} v${manifest.version} -> ${path.relative(root, runnerPath(root, name))}`);
491
+ console.log(`next: atris compile backtest ${name}`);
492
+ return;
493
+ }
494
+
495
+ if (cmd === 'backtest') {
496
+ const options = {};
497
+ if (flags.threshold !== undefined) {
498
+ const t = Number(flags.threshold);
499
+ if (!(t > 0 && t <= 1)) {
500
+ console.error('--threshold must be between 0 and 1 (e.g. 0.99)');
501
+ process.exitCode = 1;
502
+ return;
503
+ }
504
+ options.threshold = t;
505
+ }
506
+ const { result, manifest, failureCount } = await runBacktest(root, name, options);
507
+ if (flags.json) {
508
+ console.log(JSON.stringify({ ok: true, name, ...result, status: manifest.status }, null, 2));
509
+ } else {
510
+ printBacktest(name, result, failureCount, manifest);
511
+ }
512
+ if (result.accuracy < result.threshold) process.exitCode = 1;
513
+ return;
514
+ }
515
+
516
+ if (cmd === 'promote') {
517
+ const options = {};
518
+ if (flags.gate !== undefined) {
519
+ const t = Number(flags.gate);
520
+ if (!(t > 0 && t <= 1)) {
521
+ console.error('--gate must be between 0 and 1 (e.g. 0.99)');
522
+ process.exitCode = 1;
523
+ return;
524
+ }
525
+ options.threshold = t;
526
+ }
527
+ const manifest = promoteProcess(root, name, options);
528
+ console.log(`${name} v${manifest.version} promoted to active (${formatAccuracy(manifest.backtest.accuracy)} over ${manifest.backtest.total} records, gate ${formatAccuracy(manifest.threshold)})`);
529
+ console.log(`run it token-free: atris compile exec ${name} --input '<json>' --record`);
530
+ return;
531
+ }
532
+
533
+ if (cmd === 'exec') {
534
+ const input = parseValueArg(flags.input);
535
+ if (input === undefined) {
536
+ console.error('exec needs --input (inline json, a plain string, or @file)');
537
+ process.exitCode = 1;
538
+ return;
539
+ }
540
+ const manifest = readManifest(root, name);
541
+ if (manifest && manifest.status === 'drifted') {
542
+ console.error(`warning: ${name} is marked drifted — outputs may be stale. recompile with "atris compile build ${name}"`);
543
+ }
544
+ const runner = loadRunner(root, name);
545
+ const output = await runner.run(input);
546
+ console.log(JSON.stringify(output, null, 2));
547
+ if (flags.record) {
548
+ appendRecord(root, name, { input, output, source: 'exec' });
549
+ }
550
+ return;
551
+ }
552
+ }
553
+
554
+ module.exports = {
555
+ compileCommand,
556
+ // exported for tests
557
+ appendRecord,
558
+ readRecords,
559
+ readManifest,
560
+ writeManifest,
561
+ defaultManifest,
562
+ deepEqual,
563
+ runBacktest,
564
+ promoteProcess,
565
+ executeBuild,
566
+ buildCompilePrompt,
567
+ parseValueArg,
568
+ parseFlags,
569
+ listProcesses,
570
+ recordsPath,
571
+ runnerPath,
572
+ manifestPath,
573
+ DEFAULT_THRESHOLD,
574
+ };
@@ -3,6 +3,7 @@ const path = require('path');
3
3
  const os = require('os');
4
4
  const { spawn, spawnSync } = require('child_process');
5
5
  const readline = require('readline');
6
+ const { resolveClaudeRunnerBin } = require('../lib/runner-command');
6
7
 
7
8
  // ── Context Gathering ──────────────────────────────────────────────
8
9
 
@@ -206,7 +207,10 @@ function renderSkillsBar(ctx) {
206
207
  // ── Backend Detection & Auth ───────────────────────────────────────
207
208
 
208
209
  function detectBackend(requested) {
209
- const hasClaude = spawnSync('which', ['claude'], { stdio: 'pipe' }).status === 0;
210
+ const claudeBin = resolveClaudeRunnerBin();
211
+ const hasClaude = claudeBin.includes(path.sep)
212
+ ? fs.existsSync(claudeBin)
213
+ : spawnSync('which', [claudeBin], { stdio: 'pipe' }).status === 0;
210
214
  const hasCodex = spawnSync('which', ['codex'], { stdio: 'pipe' }).status === 0;
211
215
 
212
216
  if (requested) {
@@ -277,20 +281,21 @@ function checkAuth(backend) {
277
281
  // ── Launch ──────────────────────────────────────────────────────────
278
282
 
279
283
  function launchClaude(systemPrompt, extraArgs) {
284
+ const runnerBin = resolveClaudeRunnerBin();
280
285
  const args = [
281
286
  '--dangerously-skip-permissions',
282
287
  '--append-system-prompt', systemPrompt,
283
288
  ...extraArgs,
284
289
  ];
285
290
 
286
- const child = spawnSync('claude', args, {
291
+ const child = spawnSync(runnerBin, args, {
287
292
  cwd: process.cwd(),
288
293
  stdio: 'inherit',
289
294
  env: { ...process.env, CLAUDECODE: undefined },
290
295
  });
291
296
 
292
297
  if (child.error) {
293
- console.error(`✗ Failed to start claude: ${child.error.message}`);
298
+ console.error(`✗ Failed to start ${runnerBin}: ${child.error.message}`);
294
299
  process.exit(1);
295
300
  }
296
301
  process.exit(child.status ?? 0);