atris 3.16.0 → 3.16.1

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