atris 3.30.12 → 3.32.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 (64) hide show
  1. package/AGENTS.md +16 -3
  2. package/FOR_AGENTS.md +81 -0
  3. package/README.md +6 -4
  4. package/atris/CLAUDE.md +8 -0
  5. package/atris/atris.md +51 -19
  6. package/atris/skills/README.md +1 -0
  7. package/atris/skills/blocks/SKILL.md +134 -0
  8. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
  9. package/atris/skills/youtube/SKILL.md +31 -11
  10. package/atris.md +15 -0
  11. package/ax +189 -7
  12. package/bin/atris.js +273 -225
  13. package/commands/autoland.js +379 -0
  14. package/commands/autopilot-front.js +273 -0
  15. package/commands/autopilot.js +94 -4
  16. package/commands/business.js +1 -1
  17. package/commands/clean.js +72 -9
  18. package/commands/codex-goal.js +72 -22
  19. package/commands/computer.js +48 -3
  20. package/commands/gm.js +1 -1
  21. package/commands/harvest.js +179 -0
  22. package/commands/init.js +22 -1
  23. package/commands/land.js +442 -0
  24. package/commands/loop-front.js +122 -4
  25. package/commands/member.js +551 -19
  26. package/commands/mission.js +3674 -278
  27. package/commands/play.js +1 -1
  28. package/commands/pulse.js +65 -7
  29. package/commands/run-front.js +144 -0
  30. package/commands/run.js +10 -7
  31. package/commands/sign.js +90 -0
  32. package/commands/strings.js +301 -0
  33. package/commands/task.js +782 -108
  34. package/commands/truth.js +171 -0
  35. package/commands/xp.js +32 -8
  36. package/commands/youtube.js +72 -5
  37. package/decks/README.md +6 -12
  38. package/lib/auto-accept-certified.js +10 -0
  39. package/lib/autoland.js +391 -0
  40. package/lib/context-gatherer.js +0 -8
  41. package/lib/mission-artifact.js +504 -0
  42. package/lib/mission-room.js +846 -0
  43. package/lib/next-moves.js +212 -6
  44. package/lib/operator-next.js +7 -0
  45. package/lib/pulse.js +78 -4
  46. package/lib/runner-command.js +51 -20
  47. package/lib/runs-prune.js +242 -0
  48. package/lib/task-db.js +19 -2
  49. package/lib/task-proof.js +1 -1
  50. package/package.json +4 -4
  51. package/decks/atris-seed-pitch-v3.json +0 -118
  52. package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
  53. package/decks/atris-seed-pitch-v5.json +0 -109
  54. package/decks/atris-seed-pitch-v6.json +0 -137
  55. package/decks/atris-seed-pitch-v7.json +0 -133
  56. package/decks/mark-pincus-narrative.json +0 -102
  57. package/decks/mark-pincus-sourcery.json +0 -94
  58. package/decks/yash-applied-compute-detailed.json +0 -150
  59. package/decks/yash-applied-compute-generalist.json +0 -82
  60. package/decks/yash-applied-compute-narrative.json +0 -54
  61. package/lib/ax-chat-input.js +0 -164
  62. package/lib/ax-goal.js +0 -307
  63. package/lib/ax-prefs.js +0 -63
  64. package/lib/ax-shimmer.js +0 -63
package/lib/next-moves.js CHANGED
@@ -21,6 +21,27 @@ function norm(title) {
21
21
  return String(title == null ? '' : title).trim().toLowerCase();
22
22
  }
23
23
 
24
+ function titleKey(title) {
25
+ return norm(String(title == null ? '' : title).replace(/\s+/g, ' ').replace(/^title\s*:\s*/i, ''));
26
+ }
27
+
28
+ function isGenericInboxPlaceholder(title) {
29
+ const key = titleKey(title);
30
+ return /^(dogfood tick|autopilot tick|mission tick|run (one )?tick|tick|test idea|sample idea|placeholder idea|idea|todo|tbd)$/.test(key);
31
+ }
32
+
33
+ function cleanInboxMoveTitle(title) {
34
+ const raw = String(title == null ? '' : title).trim();
35
+ if (!raw) return null;
36
+ const titleLine = raw.match(/^title\s*:\s*(.+)$/i);
37
+ if (/^(task|status|action|tag|member|actor|proof|verify|checked|saved|changed|result|files?|commands?)\s*:/i.test(raw)) {
38
+ return null;
39
+ }
40
+ const cleaned = titleLine ? titleLine[1].trim() : raw;
41
+ if (isGenericInboxPlaceholder(cleaned)) return null;
42
+ return cleaned;
43
+ }
44
+
24
45
  // Short, stable id so a kill on Tuesday still suppresses the same move on
25
46
  // Wednesday. Pure function of (source, title).
26
47
  function moveId(source, title) {
@@ -33,7 +54,8 @@ function moveId(source, title) {
33
54
  return `m_${(h >>> 0).toString(36)}`;
34
55
  }
35
56
 
36
- const WEIGHT = { roadmap: 100, task: 60, inbox: 40 };
57
+ const WEIGHT = { roadmap: 100, mission: 90, endgame: 80, task: 60, inbox: 40 };
58
+ const SELF_IMPROVEMENT_SEED_TITLE = 'Create the next proof-backed self-improvement task';
37
59
 
38
60
  // One section-boundary shape for every reader and writer: tolerate a header
39
61
  // suffix (e.g. "(priority)"), stop at a sibling/parent heading (## or #) or a
@@ -93,17 +115,119 @@ function readActiveTasks(root) {
93
115
  const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
94
116
  return tasks
95
117
  .filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
118
+ .filter((t) => !isInternalNextMoveTask(t))
96
119
  .map((t) => ({
97
120
  title: String(t.title).trim(),
98
121
  why: `task in flight (${t.status}${t.claimed_by ? `, ${t.claimed_by}` : ''})`,
99
122
  source: 'task',
123
+ task_id: t.id || null,
100
124
  ref: t.display_id || t.id || null,
101
125
  weight: WEIGHT.task,
102
126
  }));
103
127
  }
104
128
 
129
+ function isInternalNextMoveTask(task) {
130
+ const title = String(task?.title || '').trim();
131
+ const tags = [task?.tag, ...(Array.isArray(task?.tags) ? task.tags : [])]
132
+ .filter(Boolean)
133
+ .map((tag) => String(tag).toLowerCase());
134
+ return tags.includes('agent-xp') || /^mission xp\s*:/i.test(title);
135
+ }
136
+
137
+ function readHandledTaskTitles(root) {
138
+ const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
139
+ if (!text) return [];
140
+ let proj;
141
+ try { proj = JSON.parse(text); } catch { return []; }
142
+ const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
143
+ return tasks
144
+ .filter((t) => t && t.title && ['review', 'done'].includes(String(t.status || '').toLowerCase()))
145
+ .map((t) => String(t.title).trim())
146
+ .filter(Boolean);
147
+ }
148
+
149
+ function readJsonLines(file) {
150
+ const text = safeRead(file);
151
+ if (!text) return [];
152
+ return text.split(/\r?\n/)
153
+ .map((line) => line.trim())
154
+ .filter(Boolean)
155
+ .map((line) => {
156
+ try { return JSON.parse(line); } catch { return null; }
157
+ })
158
+ .filter(Boolean);
159
+ }
160
+
161
+ function readActiveMissions(root) {
162
+ const rows = readJsonLines(path.join(root, '.atris', 'state', 'missions.jsonl'));
163
+ const latest = new Map();
164
+ for (const row of rows) {
165
+ if (row && row.id) latest.set(row.id, row);
166
+ }
167
+ return Array.from(latest.values())
168
+ .filter((m) => m && m.objective && !['complete', 'stopped', 'paused'].includes(String(m.status || '').toLowerCase()))
169
+ .map((m) => ({
170
+ title: String(m.objective).trim(),
171
+ why: `active mission (${m.status || 'unknown'}${m.owner ? `, ${m.owner}` : ''})`,
172
+ source: 'mission',
173
+ ref: m.id || null,
174
+ weight: m.status === 'blocked' ? WEIGHT.mission + 5 : WEIGHT.mission,
175
+ }));
176
+ }
177
+
178
+ function activeEndgameTaskCount(root) {
179
+ const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
180
+ if (!text) return 0;
181
+ let proj;
182
+ try { proj = JSON.parse(text); } catch { return 0; }
183
+ const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
184
+ return tasks.filter((t) => {
185
+ if (!t) return false;
186
+ const status = String(t.status || '').toLowerCase();
187
+ const tags = [t.tag, ...(Array.isArray(t.tags) ? t.tags : [])].filter(Boolean).map((tag) => String(tag).toLowerCase());
188
+ return tags.includes('endgame') && ['open', 'claimed', 'review'].includes(status);
189
+ }).length;
190
+ }
191
+
192
+ function todoKeepsEndgameActive(root) {
193
+ const text = safeRead(path.join(root, 'atris', 'TODO.md'));
194
+ if (!text) return true;
195
+ if (activeEndgameTaskCount(root) > 0) return true;
196
+
197
+ let sawEndgameHeader = false;
198
+ let section = '';
199
+ for (const raw of text.split(/\r?\n/)) {
200
+ const heading = raw.match(/^##\s+(.+?)\s*$/);
201
+ if (heading) {
202
+ section = heading[1].trim().toLowerCase();
203
+ if (section === 'endgame') sawEndgameHeader = true;
204
+ continue;
205
+ }
206
+ if (!/\[endgame\]/i.test(raw)) continue;
207
+ if (['backlog', 'in progress', 'review'].includes(section)) return true;
208
+ }
209
+ return !sawEndgameHeader;
210
+ }
211
+
212
+ function readEndgameMove(root) {
213
+ if (!todoKeepsEndgameActive(root)) return [];
214
+ let state = null;
215
+ try { state = JSON.parse(safeRead(path.join(root, 'atris', 'brain', 'state.json'))); } catch { state = null; }
216
+ const horizon = String(state?.endgame?.horizon || '').trim();
217
+ if (!horizon) return [];
218
+ return [{
219
+ title: horizon,
220
+ why: state?.endgame?.source ? `endgame: ${state.endgame.source}` : 'endgame horizon from compiled brain',
221
+ source: 'endgame',
222
+ weight: WEIGHT.endgame,
223
+ }];
224
+ }
225
+
105
226
  function inboxItemsFrom(text) {
106
- return parseInboxTitles(text).map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
227
+ return parseInboxTitles(text)
228
+ .map(cleanInboxMoveTitle)
229
+ .filter(Boolean)
230
+ .map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
107
231
  }
108
232
 
109
233
  // Most recent journal under atris/logs/YYYY/, parsed for ## Inbox items. Used to
@@ -132,6 +256,8 @@ function todayInboxItems(root) {
132
256
  function gatherCandidates(root = process.cwd()) {
133
257
  return [
134
258
  ...readRoadmapOpenItems(root),
259
+ ...readActiveMissions(root),
260
+ ...readEndgameMove(root),
135
261
  ...readActiveTasks(root),
136
262
  ...latestInboxItems(root),
137
263
  ];
@@ -144,16 +270,25 @@ function gatherCandidates(root = process.cwd()) {
144
270
  // only suppressed for IDEAS (roadmap/inbox), never for a real `task`, so killing
145
271
  // or approving an idea can't hide a genuine in-flight task that happens to share
146
272
  // the title.
147
- function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [], approvedIds = [], approvedTitles = [] } = {}) {
273
+ function pickNextMoves(candidates, {
274
+ limit = 3,
275
+ killedIds = [],
276
+ killedTitles = [],
277
+ approvedIds = [],
278
+ approvedTitles = [],
279
+ handledTaskTitles = [],
280
+ } = {}) {
148
281
  const blockedIds = new Set([...killedIds, ...approvedIds]);
149
- const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(norm));
282
+ const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(titleKey));
283
+ const handledTitles = new Set(handledTaskTitles.map(titleKey));
150
284
  const seen = new Set();
151
285
  return candidates
152
- .map((c) => ({ ...c, id: moveId(c.source, c.title), _key: norm(c.title) }))
286
+ .map((c) => ({ ...c, id: moveId(c.source, c.title), _key: titleKey(c.title) }))
153
287
  .filter((c) => {
154
288
  if (!c._key) return false;
155
289
  if (blockedIds.has(c.id)) return false;
156
290
  if (c.source !== 'task' && blockedTitles.has(c._key)) return false;
291
+ if (c.source !== 'task' && handledTitles.has(c._key)) return false;
157
292
  return true;
158
293
  })
159
294
  .sort((a, b) => (b.weight || 0) - (a.weight || 0))
@@ -166,6 +301,63 @@ function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [
166
301
  .slice(0, limit);
167
302
  }
168
303
 
304
+ function cleanSuggestedTargetTitle(text) {
305
+ const clean = String(text || '')
306
+ .replace(/[`*_#>]+/g, '')
307
+ .replace(/\s+/g, ' ')
308
+ .trim()
309
+ .replace(/[.!?:;,]+$/g, '');
310
+ if (!clean) return '';
311
+ return clean.charAt(0).toUpperCase() + clean.slice(1);
312
+ }
313
+
314
+ function suggestedTargetFromReportText(text) {
315
+ const lines = String(text || '').split(/\r?\n/);
316
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
317
+ const match = lines[index].match(/^\s*Suggested target:\s*(.+?)\s*$/i);
318
+ if (match) return cleanSuggestedTargetTitle(match[1]);
319
+ }
320
+ return '';
321
+ }
322
+
323
+ function latestSuggestedTarget(root = process.cwd()) {
324
+ const reportsDir = path.join(root, 'atris', 'reports');
325
+ let files;
326
+ try {
327
+ files = fs.readdirSync(reportsDir).filter((file) => /\.md$/i.test(file)).sort().reverse();
328
+ } catch {
329
+ return '';
330
+ }
331
+ for (const file of files) {
332
+ const target = suggestedTargetFromReportText(safeRead(path.join(reportsDir, file)));
333
+ if (target) return target;
334
+ }
335
+ return '';
336
+ }
337
+
338
+ function addMissionOnlyFallback(moves, limit = 3, root = process.cwd()) {
339
+ if (!Array.isArray(moves) || moves.length !== 1 || limit <= 1) return moves;
340
+ const mission = moves[0];
341
+ if (!mission || mission.source !== 'mission') return moves;
342
+ const suggested = latestSuggestedTarget(root);
343
+ const target = suggested && !isGenericInboxPlaceholder(suggested)
344
+ ? suggested
345
+ : SELF_IMPROVEMENT_SEED_TITLE;
346
+ return [
347
+ mission,
348
+ {
349
+ title: target,
350
+ why: target === SELF_IMPROVEMENT_SEED_TITLE
351
+ ? 'active mission has no concrete task queued'
352
+ : 'latest proof timeline suggested this self-improvement target',
353
+ source: 'mission',
354
+ ref: mission.ref || null,
355
+ weight: WEIGHT.mission - 35,
356
+ id: moveId('mission', target),
357
+ },
358
+ ].slice(0, limit);
359
+ }
360
+
169
361
  const DECISIONS_FILE = ['.atris', 'state', 'moves.decisions.jsonl'];
170
362
 
171
363
  function decisionsPath(root) {
@@ -336,20 +528,34 @@ function pickRoadmapSeed(root = process.cwd()) {
336
528
  // so the ranking inputs live in one place.
337
529
  function nextMoves(root = process.cwd(), limit = 3) {
338
530
  const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
339
- return pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles });
531
+ const handledTaskTitles = readHandledTaskTitles(root);
532
+ const picked = pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles, handledTaskTitles });
533
+ return addMissionOnlyFallback(picked, limit, root);
340
534
  }
341
535
 
342
536
  module.exports = {
343
537
  moveId,
344
538
  norm,
539
+ titleKey,
540
+ isGenericInboxPlaceholder,
345
541
  WEIGHT,
542
+ SELF_IMPROVEMENT_SEED_TITLE,
543
+ cleanSuggestedTargetTitle,
544
+ suggestedTargetFromReportText,
545
+ latestSuggestedTarget,
346
546
  parseInboxTitles,
547
+ cleanInboxMoveTitle,
347
548
  readRoadmapOpenItems,
549
+ readActiveMissions,
550
+ readEndgameMove,
348
551
  readActiveTasks,
552
+ isInternalNextMoveTask,
553
+ readHandledTaskTitles,
349
554
  latestInboxItems,
350
555
  todayInboxItems,
351
556
  gatherCandidates,
352
557
  pickNextMoves,
558
+ addMissionOnlyFallback,
353
559
  nextMoves,
354
560
  readDecisions,
355
561
  recordDecision,
@@ -0,0 +1,7 @@
1
+ function printOperatorNext(command, { label = 'Next' } = {}) {
2
+ const clean = String(command || '').trim();
3
+ if (!clean) return;
4
+ console.log(`${label}: ${clean}`);
5
+ }
6
+
7
+ module.exports = { printOperatorNext };
package/lib/pulse.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
+ const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
17
18
 
18
19
  const PULSE_RECEIPT_SCHEMA = 'atris.pulse_tick.v1';
19
20
  // Reuse the improve-tick scorecard schema so the brain + policy-lessons see
@@ -85,6 +86,21 @@ function buildPulseScorecardRow(input = {}) {
85
86
  };
86
87
  }
87
88
 
89
+ function buildInterruptedPulseReceipt(input = {}) {
90
+ const signal = input.signal || 'signal';
91
+ return buildPulseReceipt({
92
+ tickIndex: input.tickIndex,
93
+ phase: 'finished',
94
+ actor: 'pulse_signal',
95
+ actorOk: false,
96
+ actorReason: String(signal).toLowerCase(),
97
+ what: `tick interrupted by ${signal}`,
98
+ elapsedMs: input.startedAt ? Date.now() - input.startedAt : input.elapsedMs,
99
+ prevTickStale: input.prevTickStale,
100
+ reward: -1,
101
+ });
102
+ }
103
+
88
104
  // The heartbeat's full composition (mirrors the /loop skill): run the due
89
105
  // mission to continue an existing goal; if none is due, fall back to an
90
106
  // autopilot tick — that path is where proposeCandidateHorizons AUTHORS a new
@@ -198,6 +214,61 @@ function shellSingleQuote(value) {
198
214
  return `'${String(value || '').replace(/'/g, "'\\''")}'`;
199
215
  }
200
216
 
217
+ function normalizeCronCadence(value = DEFAULT_CADENCE_CRON) {
218
+ const raw = String(value || DEFAULT_CADENCE_CRON).trim();
219
+ if (!raw) return DEFAULT_CADENCE_CRON;
220
+ if (raw.toLowerCase() === 'hourly') return DEFAULT_CADENCE_CRON;
221
+ if (raw.toLowerCase() === 'daily') return '23 2 * * *';
222
+ if (raw.split(/\s+/).length === 5) return raw;
223
+
224
+ const minutes = raw.match(/^(\d+)\s*(m|min|mins|minute|minutes)$/i);
225
+ if (minutes) {
226
+ const n = Number(minutes[1]);
227
+ if (Number.isInteger(n) && n >= 1 && n <= 59) return `*/${n} * * * *`;
228
+ throw new Error(`invalid cadence "${raw}": minute cadence must be 1m-59m or a 5-field cron`);
229
+ }
230
+
231
+ const hours = raw.match(/^(\d+)\s*(h|hr|hrs|hour|hours)$/i);
232
+ if (hours) {
233
+ const n = Number(hours[1]);
234
+ if (Number.isInteger(n) && n >= 1 && n <= 23) return `23 */${n} * * *`;
235
+ if (n === 24) return '23 0 * * *';
236
+ throw new Error(`invalid cadence "${raw}": hour cadence must be 1h-24h or a 5-field cron`);
237
+ }
238
+
239
+ throw new Error(`invalid cadence "${raw}": use 13m, 2h, hourly, daily, or a 5-field cron`);
240
+ }
241
+
242
+ function normalizeExpiryDuration(input = {}) {
243
+ const hasHours = input.hours !== undefined && input.hours !== null && String(input.hours).trim() !== '';
244
+ if (hasHours) {
245
+ const hours = Number(input.hours);
246
+ if (Number.isFinite(hours) && hours > 0) {
247
+ return {
248
+ source: 'hours',
249
+ hours,
250
+ days: null,
251
+ seconds: Math.ceil(hours * 60 * 60),
252
+ };
253
+ }
254
+ throw new Error(`invalid hours "${input.hours}": use a positive number of hours`);
255
+ }
256
+
257
+ const rawDays = input.days === undefined || input.days === null || String(input.days).trim() === ''
258
+ ? 7
259
+ : input.days;
260
+ const days = Number(rawDays);
261
+ if (Number.isFinite(days) && days > 0) {
262
+ return {
263
+ source: 'days',
264
+ hours: null,
265
+ days,
266
+ seconds: Math.ceil(days * 24 * 60 * 60),
267
+ };
268
+ }
269
+ throw new Error(`invalid days "${rawDays}": use a positive number of days`);
270
+ }
271
+
201
272
  function runnerEnvAliasExport({ genericName, legacyName, value }) {
202
273
  if (!value) return '';
203
274
  return [
@@ -284,7 +355,7 @@ function buildTickScript(opts = {}) {
284
355
  stateHome,
285
356
  deadlineEpoch,
286
357
  marker = PULSE_MARKER,
287
- model = 'opus',
358
+ model = DEFAULT_CLAUDE_RUNNER_MODEL,
288
359
  runnerProfile = '',
289
360
  runnerBin = '',
290
361
  runnerCommandTemplate = '',
@@ -349,8 +420,8 @@ log="$LOG_DIR/$stamp.log"
349
420
 
350
421
  cd "$ROOT" || { echo "$(date -Iseconds) ROOT missing" >> "$LOG_DIR/control.log"; exit 1; }
351
422
 
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).
423
+ # Autonomous ticks use the pinned default unless the installer supplied a model.
424
+ # Operators can still override per install with --model or env.
354
425
  ${runnerModelExport}
355
426
  ${runnerProfileExport}
356
427
  ${runnerBinExport}
@@ -365,7 +436,7 @@ echo "done: $(date -Iseconds) exit=$?" >> "$log"
365
436
  function buildCrontabLine(opts = {}) {
366
437
  const { cron = DEFAULT_CADENCE_CRON, scriptPath, marker = PULSE_MARKER } = opts;
367
438
  if (!scriptPath) throw new Error('buildCrontabLine: scriptPath is required');
368
- return `${cron} ${scriptPath} # ${marker}`;
439
+ return `${normalizeCronCadence(cron)} ${scriptPath} # ${marker}`;
369
440
  }
370
441
 
371
442
  module.exports = {
@@ -382,7 +453,9 @@ module.exports = {
382
453
  pulseLockDir,
383
454
  buildPulseReceipt,
384
455
  buildPulseScorecardRow,
456
+ buildInterruptedPulseReceipt,
385
457
  scoreTick,
458
+ normalizeExpiryDuration,
386
459
  shouldWriteScorecard,
387
460
  shouldFallbackToAutopilot,
388
461
  findOrphanStarts,
@@ -398,4 +471,5 @@ module.exports = {
398
471
  releaseLock,
399
472
  buildTickScript,
400
473
  buildCrontabLine,
474
+ normalizeCronCadence,
401
475
  };
@@ -3,33 +3,47 @@
3
3
  // Shared worker-spawn builder for the autonomous loops (missions, autopilot, run).
4
4
  //
5
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';
6
+ // selection is fragile: the local Claude Code `opus` alias can resolve to
7
+ // different Opus releases across machines and account rollouts. Pin the default
8
+ // so autonomous runs are reproducible, while keeping explicit per-run/env knobs
9
+ // first in precedence. Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
10
+ // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
11
+ const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-4-8';
14
12
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
15
- const RUNNER_PROFILES = Object.freeze({
13
+
14
+ // Canonical runner profiles. Each entry is the config an operator would pick
15
+ // on purpose. Aliases (spelling variants of the same profile) resolve to the
16
+ // canonical config but are kept OUT of operator-facing lists so config errors
17
+ // stay honest: "one of: atris-fast", not three duplicate spellings.
18
+ const RUNNER_PROFILE_DEFS = Object.freeze({
16
19
  'atris-fast': Object.freeze({
17
20
  bin: 'ax',
18
21
  model: 'atris:fast',
19
22
  commandTemplate: '{bin} --fast {prompt}',
20
23
  }),
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
24
  });
32
25
 
26
+ // Alias -> canonical profile name. Every alias resolves to the same config as
27
+ // its canonical target; adding a spelling variant here is a config change, not
28
+ // a duplicated profile body that can drift.
29
+ const RUNNER_PROFILE_ALIASES = Object.freeze({
30
+ 'atris2-fast': 'atris-fast',
31
+ 'atris-2-fast': 'atris-fast',
32
+ });
33
+
34
+ // Back-compat surface: RUNNER_PROFILES still resolves every accepted name
35
+ // (canonical + alias) to its frozen config, so existing lookups by any spelling
36
+ // keep working.
37
+ const RUNNER_PROFILES = Object.freeze(
38
+ Object.fromEntries([
39
+ ...Object.entries(RUNNER_PROFILE_DEFS),
40
+ ...Object.entries(RUNNER_PROFILE_ALIASES).map(([alias, target]) => [alias, RUNNER_PROFILE_DEFS[target]]),
41
+ ])
42
+ );
43
+
44
+ // Operator-facing list: canonical profile names only (no alias noise).
45
+ const RUNNER_PROFILE_NAMES = Object.freeze(Object.keys(RUNNER_PROFILE_DEFS));
46
+
33
47
  function shellWord(value) {
34
48
  const s = String(value || '');
35
49
  if (/^[A-Za-z0-9_./:-]+$/.test(s)) return s;
@@ -53,7 +67,7 @@ function resolveRunnerProfile() {
53
67
  if (!name) return null;
54
68
  const profile = RUNNER_PROFILES[name];
55
69
  if (!profile) {
56
- throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${Object.keys(RUNNER_PROFILES).join(', ')}`);
70
+ throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${RUNNER_PROFILE_NAMES.join(', ')}`);
57
71
  }
58
72
  return profile;
59
73
  }
@@ -97,6 +111,19 @@ function buildRunnerAvailabilityCommand() {
97
111
  return `command -v ${shellWord(resolveClaudeRunnerBin())}`;
98
112
  }
99
113
 
114
+ function runnerAvailabilityFailureMessage(error) {
115
+ const message = error && error.message ? String(error.message).trim() : '';
116
+ if (message.startsWith('Unknown ATRIS_RUNNER_PROFILE')) {
117
+ return `${message}. Set ATRIS_RUNNER_PROFILE to one of: ${RUNNER_PROFILE_NAMES.join(', ')}.`;
118
+ }
119
+
120
+ let runnerBin = 'configured runner';
121
+ try {
122
+ runnerBin = resolveClaudeRunnerBin();
123
+ } catch {}
124
+ return `${runnerBin} CLI not found. Set ATRIS_RUNNER_BIN (or legacy ATRIS_CLAUDE_BIN), or install the configured runner first.`;
125
+ }
126
+
100
127
  function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model }) {
101
128
  const allowedToolsFlag = allowedTools ? `--allowedTools ${shellWord(allowedTools)}` : '';
102
129
  const promptFileWord = shellWord(promptFile);
@@ -143,6 +170,9 @@ module.exports = {
143
170
  DEFAULT_CLAUDE_RUNNER_MODEL,
144
171
  DEFAULT_CLAUDE_RUNNER_BIN,
145
172
  RUNNER_PROFILES,
173
+ RUNNER_PROFILE_DEFS,
174
+ RUNNER_PROFILE_ALIASES,
175
+ RUNNER_PROFILE_NAMES,
146
176
  resolveRunnerProfileName,
147
177
  resolveRunnerProfile,
148
178
  resolveRunnerModel: resolveClaudeRunnerModel,
@@ -152,5 +182,6 @@ module.exports = {
152
182
  resolveClaudeRunnerBin,
153
183
  resolveClaudeRunnerCommandTemplate,
154
184
  buildRunnerAvailabilityCommand,
185
+ runnerAvailabilityFailureMessage,
155
186
  buildRunnerCommand,
156
187
  };