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
package/lib/pulse.js ADDED
@@ -0,0 +1,401 @@
1
+ 'use strict';
2
+
3
+ // Pulse: the durable overnight self-improvement heartbeat for atris-cli itself.
4
+ //
5
+ // The /loop skill schedules a heartbeat via Claude Code's CronCreate, but that
6
+ // only fires while Claude Code is open and idle and dies with the session. The
7
+ // proven overnight pattern is an OS cron (see the commander tick.sh template) —
8
+ // it fires regardless of whether Claude Code is running. `atris pulse` brings
9
+ // that pattern home: one OS-cron tick that runs the existing mission engine,
10
+ // verifies, and writes BOTH a pulse receipt (revives the Pulse AGI loop-health
11
+ // channel brain.js watches) and a reward scorecard (revives the feedback signal
12
+ // that policy-lessons mines). This module holds the pure, testable core; the
13
+ // command (commands/pulse.js) wires it to the engine and the cron shell.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const PULSE_RECEIPT_SCHEMA = 'atris.pulse_tick.v1';
19
+ // Reuse the improve-tick scorecard schema so the brain + policy-lessons see
20
+ // pulse reward as fresh feedback signal (source:'pulse' keeps it attributable).
21
+ const SCORECARD_SCHEMA = 'atris.improve_tick.v1';
22
+ const PULSE_MARKER = 'ATRIS_PULSE_SELF_IMPROVE';
23
+ // Hourly at an off-clock minute (avoid :00/:30 fleet sync). Each tick spawns a
24
+ // real worker + full verify, so default conservative; raise with --cadence.
25
+ const DEFAULT_CADENCE_CRON = '23 * * * *';
26
+ // Lock-steal timeout: a tick still holding the lock after 30m is hung → steal it.
27
+ const STALE_TICK_MS = 30 * 60 * 1000;
28
+ // Liveness timeout: how long since the last finished tick before the loop reads
29
+ // as "stale" (stopped firing). Must exceed the cadence or it flaps stale between
30
+ // every tick — default cadence is hourly, so allow ~2 missed ticks before alarm.
31
+ const LIVENESS_STALE_MS = 150 * 60 * 1000;
32
+
33
+ function stateDir(root) {
34
+ return path.join(root, '.atris', 'state');
35
+ }
36
+ function pulseReceiptsPath(root) {
37
+ return path.join(stateDir(root), 'pulse_agi_loop_receipts.jsonl');
38
+ }
39
+ function scorecardsPath(root) {
40
+ return path.join(stateDir(root), 'scorecards.jsonl');
41
+ }
42
+ function pulseCounterPath(root) {
43
+ return path.join(stateDir(root), 'pulse.tick-count');
44
+ }
45
+ function pulseLockDir(root) {
46
+ return path.join(stateDir(root), 'pulse.lock');
47
+ }
48
+
49
+ // --- receipt + scorecard building (pure) ---
50
+
51
+ function buildPulseReceipt(input = {}) {
52
+ return {
53
+ schema: PULSE_RECEIPT_SCHEMA,
54
+ ts: input.ts || new Date().toISOString(),
55
+ tick_index: input.tickIndex != null ? input.tickIndex : null,
56
+ phase: input.phase || 'finished', // 'started' | 'finished'
57
+ actor: input.actor || null, // 'mission_run_due' | 'noop' | ...
58
+ actor_ok: input.actorOk != null ? input.actorOk : null,
59
+ actor_reason: input.actorReason || null, // 'completed' | 'no_due_mission' | 'error'
60
+ verify_cmd: input.verifyCmd || null,
61
+ verify_passed: input.verifyPassed != null ? input.verifyPassed : null,
62
+ changed_files: Array.isArray(input.changedFiles) ? input.changedFiles : [],
63
+ what: input.what || null,
64
+ elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
65
+ prev_tick_stale: input.prevTickStale != null ? input.prevTickStale : false,
66
+ reward: input.reward != null ? input.reward : null,
67
+ };
68
+ }
69
+
70
+ function buildPulseScorecardRow(input = {}) {
71
+ return {
72
+ schema: SCORECARD_SCHEMA,
73
+ ts: input.ts || new Date().toISOString(),
74
+ source: 'pulse',
75
+ member: 'pulse',
76
+ mode: input.mode || 'tick',
77
+ reward: input.reward != null ? input.reward : 0,
78
+ verify_passed: input.verifyPassed != null ? input.verifyPassed : null,
79
+ credits_deducted: 0,
80
+ what_shipped: input.what || null,
81
+ files_written: Array.isArray(input.changedFiles) ? input.changedFiles : [],
82
+ model_used: input.model || null,
83
+ task_id: input.taskId || null,
84
+ elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
85
+ };
86
+ }
87
+
88
+ // The heartbeat's full composition (mirrors the /loop skill): run the due
89
+ // mission to continue an existing goal; if none is due, fall back to an
90
+ // autopilot tick — that path is where proposeCandidateHorizons AUTHORS a new
91
+ // goal at an endgame boundary. The fallback needs a worker, so skip it under
92
+ // --no-claude (goal-authoring can't happen without the model in the loop).
93
+ function shouldFallbackToAutopilot({ missionReason, autopilotFallback = true, noClaude = false } = {}) {
94
+ if (!autopilotFallback) return false;
95
+ if (noClaude) return false;
96
+ return missionReason === 'no_due_mission';
97
+ }
98
+
99
+ // Reward gating mirrors the improve.js tick-5 lesson: only verified,
100
+ // work-producing ticks earn positive reward; verify failure is punished;
101
+ // a tick that produced no work scores 0. `producedWork` MUST be the tick's
102
+ // actual delta (new commit or newly-dirtied files), never the whole dirty tree —
103
+ // crediting pre-existing dirt re-rewards the same change every tick (the reward
104
+ // inflation bug). The caller computes producedWork from a before/after snapshot.
105
+ function scoreTick({ verifyPassed, producedWork } = {}) {
106
+ if (verifyPassed === false) return -1;
107
+ if (!producedWork) return 0;
108
+ return verifyPassed === true ? 1 : 0;
109
+ }
110
+
111
+ // Only write a scorecard when there is signal. A pure no-op tick still leaves a
112
+ // pulse receipt (for liveness) but must not spam the reward channel with noise.
113
+ function shouldWriteScorecard({ reward } = {}) {
114
+ return reward !== 0;
115
+ }
116
+
117
+ // --- ghost / stale detection (pure) ---
118
+
119
+ // Pair started+finished by tick_index; any 'started' with no 'finished' partner
120
+ // is a tick that crashed mid-run — exactly the silent-runner-death failure mode.
121
+ function findOrphanStarts(receipts) {
122
+ if (!Array.isArray(receipts)) return [];
123
+ const finished = new Set();
124
+ for (const r of receipts) {
125
+ if (r && r.phase === 'finished' && r.tick_index != null) finished.add(r.tick_index);
126
+ }
127
+ const orphans = [];
128
+ for (const r of receipts) {
129
+ if (r && r.phase === 'started' && r.tick_index != null && !finished.has(r.tick_index)) {
130
+ orphans.push(r.tick_index);
131
+ }
132
+ }
133
+ return orphans;
134
+ }
135
+
136
+ // Liveness reflects the LATEST tick only. A historical orphan (a crash that was
137
+ // later recovered by a finished tick) is surfaced in the feed via
138
+ // findOrphanStarts, but must NOT make a recovered loop read as dead forever.
139
+ function detectStaleTick(receipts, now = Date.now(), staleMs = LIVENESS_STALE_MS) {
140
+ if (!Array.isArray(receipts) || receipts.length === 0) {
141
+ return { stale: false, reason: 'no_receipts' };
142
+ }
143
+ const last = receipts[receipts.length - 1];
144
+ if (last && last.phase === 'started') {
145
+ // the most recent thing we did was start a tick that never finished
146
+ return { stale: true, reason: 'started_without_finish', tick_index: last.tick_index };
147
+ }
148
+ const lastMs = Date.parse(last && last.ts ? last.ts : '');
149
+ if (Number.isFinite(lastMs) && now - lastMs > staleMs) {
150
+ return { stale: true, reason: 'last_tick_too_old', age_ms: now - lastMs };
151
+ }
152
+ return { stale: false, reason: 'fresh' };
153
+ }
154
+
155
+ // --- summarize (pure) ---
156
+
157
+ function summarizePulse(receipts, now = Date.now()) {
158
+ const all = Array.isArray(receipts) ? receipts : [];
159
+ const finished = all.filter((r) => r && r.phase === 'finished');
160
+ const rewardSum = finished.reduce((a, r) => a + (Number(r.reward) || 0), 0);
161
+ const last = finished.length ? finished[finished.length - 1] : null;
162
+ return {
163
+ total_ticks: finished.length,
164
+ reward_sum: rewardSum,
165
+ verify_pass: finished.filter((r) => r.verify_passed === true).length,
166
+ verify_fail: finished.filter((r) => r.verify_passed === false).length,
167
+ last_tick_ts: last ? last.ts : null,
168
+ last_verify_passed: last ? last.verify_passed : null,
169
+ orphan_ticks: findOrphanStarts(all),
170
+ stale: detectStaleTick(all, now),
171
+ };
172
+ }
173
+
174
+ // --- IO helpers ---
175
+
176
+ function appendJsonl(file, row) {
177
+ fs.mkdirSync(path.dirname(file), { recursive: true });
178
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
179
+ return file;
180
+ }
181
+
182
+ function readJsonl(file) {
183
+ if (!fs.existsSync(file)) return [];
184
+ const out = [];
185
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
186
+ const trimmed = line.trim();
187
+ if (!trimmed) continue;
188
+ try {
189
+ out.push(JSON.parse(trimmed));
190
+ } catch {
191
+ // skip foreign / partial rows
192
+ }
193
+ }
194
+ return out;
195
+ }
196
+
197
+ function shellSingleQuote(value) {
198
+ return `'${String(value || '').replace(/'/g, "'\\''")}'`;
199
+ }
200
+
201
+ function runnerEnvAliasExport({ genericName, legacyName, value }) {
202
+ if (!value) return '';
203
+ return [
204
+ `if [ -z "\${${genericName}:-}" ]; then`,
205
+ ` if [ -n "\${${legacyName}:-}" ]; then`,
206
+ ` export ${genericName}="\${${legacyName}}"`,
207
+ ' else',
208
+ ` export ${genericName}=${shellSingleQuote(value)}`,
209
+ ' fi',
210
+ 'fi',
211
+ `[ -n "\${${legacyName}:-}" ] || export ${legacyName}="\${${genericName}}"`,
212
+ ].join('\n');
213
+ }
214
+
215
+ function readPulseReceipts(root) {
216
+ return readJsonl(pulseReceiptsPath(root));
217
+ }
218
+ function appendPulseReceipt(root, receipt) {
219
+ return appendJsonl(pulseReceiptsPath(root), receipt);
220
+ }
221
+ function appendScorecard(root, row) {
222
+ return appendJsonl(scorecardsPath(root), row);
223
+ }
224
+
225
+ function nextTickIndex(root) {
226
+ const file = pulseCounterPath(root);
227
+ let n = 0;
228
+ try {
229
+ n = parseInt(fs.readFileSync(file, 'utf8').trim(), 10) || 0;
230
+ } catch {}
231
+ n += 1;
232
+ try {
233
+ fs.mkdirSync(path.dirname(file), { recursive: true });
234
+ fs.writeFileSync(file, String(n), 'utf8');
235
+ } catch {}
236
+ return n;
237
+ }
238
+
239
+ // Lock prevents overlapping ticks. A lock older than staleMs is stolen (and the
240
+ // theft is reported so the orphaned tick surfaces instead of blocking forever).
241
+ function acquireLock(root, now = Date.now(), staleMs = STALE_TICK_MS) {
242
+ const dir = pulseLockDir(root);
243
+ // The lock dir itself is created non-recursively (atomic), but its parent
244
+ // (.atris/state) must exist first or a fresh workspace can never acquire it.
245
+ try { fs.mkdirSync(path.dirname(dir), { recursive: true }); } catch {}
246
+ try {
247
+ fs.mkdirSync(dir, { recursive: false });
248
+ try {
249
+ fs.writeFileSync(path.join(dir, 'pid'), String(process.pid), 'utf8');
250
+ } catch {}
251
+ return { acquired: true, stale: false };
252
+ } catch {
253
+ let ageMs = Infinity;
254
+ try {
255
+ ageMs = now - fs.statSync(dir).mtimeMs;
256
+ } catch {}
257
+ if (ageMs > staleMs) {
258
+ try {
259
+ fs.rmSync(dir, { recursive: true, force: true });
260
+ fs.mkdirSync(dir, { recursive: false });
261
+ fs.writeFileSync(path.join(dir, 'pid'), String(process.pid), 'utf8');
262
+ return { acquired: true, stale: true, ageMs };
263
+ } catch {}
264
+ }
265
+ return { acquired: false, stale: false, ageMs };
266
+ }
267
+ }
268
+
269
+ function releaseLock(root) {
270
+ try {
271
+ fs.rmSync(pulseLockDir(root), { recursive: true, force: true });
272
+ } catch {}
273
+ }
274
+
275
+ // --- cron tick script + crontab line (pure string generation) ---
276
+
277
+ // Minimal shell wrapper modeled on the proven commander tick.sh: deadline
278
+ // self-removal, then hand off to `atris pulse tick` (all real logic lives in JS,
279
+ // testable, not duplicated in shell). The wrapper only owns scheduling concerns.
280
+ function buildTickScript(opts = {}) {
281
+ const {
282
+ root,
283
+ atrisBin = 'atris',
284
+ stateHome,
285
+ deadlineEpoch,
286
+ marker = PULSE_MARKER,
287
+ model = 'opus',
288
+ runnerProfile = '',
289
+ runnerBin = '',
290
+ runnerCommandTemplate = '',
291
+ verifyCmd = 'npm test',
292
+ pathDirs = [],
293
+ } = opts;
294
+ if (!root) throw new Error('buildTickScript: root is required');
295
+ if (!stateHome) throw new Error('buildTickScript: stateHome is required');
296
+ if (!deadlineEpoch) throw new Error('buildTickScript: deadlineEpoch is required');
297
+ const safeVerify = String(verifyCmd).replace(/'/g, "'\\''");
298
+ const runnerModelExport = runnerEnvAliasExport({
299
+ genericName: 'ATRIS_RUNNER_MODEL',
300
+ legacyName: 'ATRIS_CLAUDE_MODEL',
301
+ value: model,
302
+ });
303
+ const runnerProfileExport = runnerProfile
304
+ ? `[ -n "\${ATRIS_RUNNER_PROFILE:-}" ] || export ATRIS_RUNNER_PROFILE=${shellSingleQuote(runnerProfile)}`
305
+ : '';
306
+ const runnerBinExport = runnerEnvAliasExport({
307
+ genericName: 'ATRIS_RUNNER_BIN',
308
+ legacyName: 'ATRIS_CLAUDE_BIN',
309
+ value: runnerBin,
310
+ });
311
+ const runnerCommandTemplateExport = runnerEnvAliasExport({
312
+ genericName: 'ATRIS_RUNNER_COMMAND_TEMPLATE',
313
+ legacyName: 'ATRIS_CLAUDE_COMMAND_TEMPLATE',
314
+ value: runnerCommandTemplate,
315
+ });
316
+ // Cron runs with a minimal PATH. The engine spawns `claude`/`node`/`git` by
317
+ // bare name, so we must prepend their real locations or every tick silently
318
+ // fails to spawn the worker (looks alive, never improves). pathDirs are the
319
+ // resolved bin dirs (claude, node, atris, homebrew) discovered at install.
320
+ const dirs = Array.from(new Set([
321
+ ...pathDirs.filter(Boolean),
322
+ '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
323
+ ]));
324
+ const pathExport = `export PATH="${dirs.join(':')}:$PATH"`;
325
+ return `#!/bin/zsh
326
+ set -u
327
+
328
+ ROOT="${root}"
329
+ ATRIS="${atrisBin}"
330
+ STATE="${stateHome}"
331
+ LOG_DIR="$STATE/logs"
332
+ DEADLINE_EPOCH="${deadlineEpoch}"
333
+ MARKER="${marker}"
334
+
335
+ # Cron has a minimal PATH; restore the dirs the engine's bare-name spawns need.
336
+ ${pathExport}
337
+
338
+ mkdir -p "$LOG_DIR"
339
+
340
+ now="$(date +%s)"
341
+ if [ "$now" -ge "$DEADLINE_EPOCH" ]; then
342
+ crontab -l 2>/dev/null | grep -v "$MARKER" | crontab - 2>/dev/null || true
343
+ echo "$(date -Iseconds) pulse expired; removed cron" >> "$LOG_DIR/control.log"
344
+ exit 0
345
+ fi
346
+
347
+ stamp="$(date +"%Y%m%d-%H%M%S")"
348
+ log="$LOG_DIR/$stamp.log"
349
+
350
+ cd "$ROOT" || { echo "$(date -Iseconds) ROOT missing" >> "$LOG_DIR/control.log"; exit 1; }
351
+
352
+ # Autonomous ticks must target a live model alias, never a versioned id that can
353
+ # retire out from under the loop (lesson: retired-model-kills-loop-silently).
354
+ ${runnerModelExport}
355
+ ${runnerProfileExport}
356
+ ${runnerBinExport}
357
+ ${runnerCommandTemplateExport}
358
+ export ATRIS_SKIP_UPDATE_CHECK=1
359
+
360
+ "$ATRIS" pulse tick --json --verify '${safeVerify}' >> "$log" 2>&1
361
+ echo "done: $(date -Iseconds) exit=$?" >> "$log"
362
+ `;
363
+ }
364
+
365
+ function buildCrontabLine(opts = {}) {
366
+ const { cron = DEFAULT_CADENCE_CRON, scriptPath, marker = PULSE_MARKER } = opts;
367
+ if (!scriptPath) throw new Error('buildCrontabLine: scriptPath is required');
368
+ return `${cron} ${scriptPath} # ${marker}`;
369
+ }
370
+
371
+ module.exports = {
372
+ PULSE_RECEIPT_SCHEMA,
373
+ SCORECARD_SCHEMA,
374
+ PULSE_MARKER,
375
+ DEFAULT_CADENCE_CRON,
376
+ STALE_TICK_MS,
377
+ LIVENESS_STALE_MS,
378
+ stateDir,
379
+ pulseReceiptsPath,
380
+ scorecardsPath,
381
+ pulseCounterPath,
382
+ pulseLockDir,
383
+ buildPulseReceipt,
384
+ buildPulseScorecardRow,
385
+ scoreTick,
386
+ shouldWriteScorecard,
387
+ shouldFallbackToAutopilot,
388
+ findOrphanStarts,
389
+ detectStaleTick,
390
+ summarizePulse,
391
+ appendJsonl,
392
+ readJsonl,
393
+ readPulseReceipts,
394
+ appendPulseReceipt,
395
+ appendScorecard,
396
+ nextTickIndex,
397
+ acquireLock,
398
+ releaseLock,
399
+ buildTickScript,
400
+ buildCrontabLine,
401
+ };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ // Shared worker-spawn builder for the autonomous loops (missions, autopilot, run).
4
+ //
5
+ // Autonomous ticks must target a LIVE model. Inheriting the CLI's persisted
6
+ // selection is fragile: a *versioned* id (e.g. claude-fable-5) silently dies
7
+ // when that version is retired, and every tick then errors as a generic
8
+ // 'claude-error' with no clue why (lesson: retired-model-kills-loop-silently,
9
+ // CLI-245). Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
10
+ // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> 'opus' alias. The
11
+ // CLI resolves aliases to the latest live model, so an alias never retires out
12
+ // from under the loop.
13
+ const DEFAULT_CLAUDE_RUNNER_MODEL = 'opus';
14
+ const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
15
+ const RUNNER_PROFILES = Object.freeze({
16
+ 'atris-fast': Object.freeze({
17
+ bin: 'ax',
18
+ model: 'atris:fast',
19
+ commandTemplate: '{bin} --fast {prompt}',
20
+ }),
21
+ 'atris2-fast': Object.freeze({
22
+ bin: 'ax',
23
+ model: 'atris:fast',
24
+ commandTemplate: '{bin} --fast {prompt}',
25
+ }),
26
+ 'atris-2-fast': Object.freeze({
27
+ bin: 'ax',
28
+ model: 'atris:fast',
29
+ commandTemplate: '{bin} --fast {prompt}',
30
+ }),
31
+ });
32
+
33
+ function shellWord(value) {
34
+ const s = String(value || '');
35
+ if (/^[A-Za-z0-9_./:-]+$/.test(s)) return s;
36
+ return `'${s.replace(/'/g, "'\\''")}'`;
37
+ }
38
+
39
+ function firstConfiguredEnv(names) {
40
+ for (const name of names) {
41
+ const value = String(process.env[name] || '').trim();
42
+ if (value) return value;
43
+ }
44
+ return '';
45
+ }
46
+
47
+ function resolveRunnerProfileName() {
48
+ return String(process.env.ATRIS_RUNNER_PROFILE || '').trim();
49
+ }
50
+
51
+ function resolveRunnerProfile() {
52
+ const name = resolveRunnerProfileName();
53
+ if (!name) return null;
54
+ const profile = RUNNER_PROFILES[name];
55
+ if (!profile) {
56
+ throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${Object.keys(RUNNER_PROFILES).join(', ')}`);
57
+ }
58
+ return profile;
59
+ }
60
+
61
+ function runnerProfileValue(key) {
62
+ const profile = resolveRunnerProfile();
63
+ return profile && profile[key] ? profile[key] : '';
64
+ }
65
+
66
+ function resolveClaudeRunnerModel(mission) {
67
+ const explicit = mission && mission.model != null ? String(mission.model).trim() : '';
68
+ if (explicit) return explicit;
69
+ const env = firstConfiguredEnv(['ATRIS_RUNNER_MODEL']);
70
+ if (env) return env;
71
+ const profileModel = runnerProfileValue('model');
72
+ if (profileModel) return profileModel;
73
+ const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_MODEL']);
74
+ if (legacyEnv) return legacyEnv;
75
+ return DEFAULT_CLAUDE_RUNNER_MODEL;
76
+ }
77
+
78
+ function resolveClaudeRunnerBin() {
79
+ const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
80
+ if (env) return env;
81
+ const profileBin = runnerProfileValue('bin');
82
+ if (profileBin) return profileBin;
83
+ const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_BIN']);
84
+ if (legacyEnv) return legacyEnv;
85
+ return DEFAULT_CLAUDE_RUNNER_BIN;
86
+ }
87
+
88
+ function resolveClaudeRunnerCommandTemplate() {
89
+ const env = firstConfiguredEnv(['ATRIS_RUNNER_COMMAND_TEMPLATE']);
90
+ if (env) return env;
91
+ const profileTemplate = runnerProfileValue('commandTemplate');
92
+ if (profileTemplate) return profileTemplate;
93
+ return firstConfiguredEnv(['ATRIS_CLAUDE_COMMAND_TEMPLATE']);
94
+ }
95
+
96
+ function buildRunnerAvailabilityCommand() {
97
+ return `command -v ${shellWord(resolveClaudeRunnerBin())}`;
98
+ }
99
+
100
+ function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model }) {
101
+ const allowedToolsFlag = allowedTools ? `--allowedTools ${shellWord(allowedTools)}` : '';
102
+ const promptFileWord = shellWord(promptFile);
103
+ const values = {
104
+ bin: shellWord(resolveClaudeRunnerBin()),
105
+ promptFile: promptFileWord,
106
+ prompt: `"$(cat ${promptFileWord})"`,
107
+ model: shellWord(model),
108
+ modelFlag: `--model ${shellWord(model)}`,
109
+ allowedTools: allowedTools ? shellWord(allowedTools) : '',
110
+ allowedToolsFlag,
111
+ };
112
+ return template.replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (match, key) => {
113
+ if (Object.prototype.hasOwnProperty.call(values, key)) return values[key];
114
+ return match;
115
+ }).trim();
116
+ }
117
+
118
+ // Build the shell command that spawns one headless worker tick. `--model` is
119
+ // ALWAYS injected (resolved via resolveClaudeRunnerModel) so no spawn path can
120
+ // fall back to the CLI's mutable persisted selection. The default command shape
121
+ // remains Claude-compatible, but ATRIS_RUNNER_COMMAND_TEMPLATE can replace it
122
+ // for GLM/OpenAI/other local runners. The old ATRIS_CLAUDE_* env vars remain
123
+ // aliases for existing installs. allowedTools is optional: some call sites
124
+ // (e.g. horizon proposal) run without a tool allowlist.
125
+ function buildRunnerCommand({ promptFile, allowedTools, model } = {}) {
126
+ if (!promptFile) {
127
+ throw new Error('buildRunnerCommand: promptFile is required');
128
+ }
129
+ const resolved = resolveClaudeRunnerModel({ model });
130
+ const template = resolveClaudeRunnerCommandTemplate();
131
+ if (template) {
132
+ return renderRunnerCommandTemplate(template, { promptFile, allowedTools, model: resolved });
133
+ }
134
+ const safePath = String(promptFile).replace(/'/g, "'\\''");
135
+ let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')" --model ${shellWord(resolved)}`;
136
+ if (allowedTools) {
137
+ cmd += ` --allowedTools ${shellWord(allowedTools)}`;
138
+ }
139
+ return cmd;
140
+ }
141
+
142
+ module.exports = {
143
+ DEFAULT_CLAUDE_RUNNER_MODEL,
144
+ DEFAULT_CLAUDE_RUNNER_BIN,
145
+ RUNNER_PROFILES,
146
+ resolveRunnerProfileName,
147
+ resolveRunnerProfile,
148
+ resolveRunnerModel: resolveClaudeRunnerModel,
149
+ resolveRunnerBin: resolveClaudeRunnerBin,
150
+ resolveRunnerCommandTemplate: resolveClaudeRunnerCommandTemplate,
151
+ resolveClaudeRunnerModel,
152
+ resolveClaudeRunnerBin,
153
+ resolveClaudeRunnerCommandTemplate,
154
+ buildRunnerAvailabilityCommand,
155
+ buildRunnerCommand,
156
+ };