atris 3.16.1 → 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 (58) hide show
  1. package/README.md +32 -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 +400 -30
  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 +42 -18
  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 +9 -4
  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 +105 -27
  31. package/commands/pulse.js +504 -0
  32. package/commands/radar.js +1 -0
  33. package/commands/recap.js +55 -25
  34. package/commands/run.js +615 -22
  35. package/commands/slop.js +173 -0
  36. package/commands/spaceship.js +39 -0
  37. package/commands/sync.js +0 -2
  38. package/commands/task.js +429 -37
  39. package/commands/verify.js +7 -3
  40. package/lib/activity-stream.js +166 -0
  41. package/lib/auto-accept-certified.js +23 -1
  42. package/lib/context-gatherer.js +170 -0
  43. package/lib/escape-regexp.js +13 -0
  44. package/lib/file-ops.js +6 -3
  45. package/lib/journal.js +1 -1
  46. package/lib/lesson-contradiction.js +113 -0
  47. package/lib/policy-lessons.js +3 -2
  48. package/lib/pulse.js +401 -0
  49. package/lib/runner-command.js +156 -0
  50. package/lib/slides-deck.js +236 -0
  51. package/lib/state-detection.js +1 -4
  52. package/lib/task-db.js +101 -4
  53. package/lib/task-proof.js +1 -1
  54. package/lib/todo-fallback.js +2 -1
  55. package/lib/todo-sections.js +33 -0
  56. package/package.json +1 -2
  57. package/utils/api.js +14 -2
  58. package/atris/atrisDev.md +0 -717
@@ -4,6 +4,10 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const { spawn, spawnSync } = require('child_process');
7
+ const {
8
+ resolveClaudeRunnerModel,
9
+ resolveClaudeRunnerBin,
10
+ } = require('../lib/runner-command');
7
11
 
8
12
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
9
13
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
@@ -425,7 +429,7 @@ function renderMemberNowMarkdown(owner, missions) {
425
429
  lines.push(`- id: ${mission.id}`);
426
430
  lines.push(`- status: ${mission.status}`);
427
431
  lines.push(`- cadence: ${mission.cadence}`);
428
- lines.push(`- runner: ${mission.runner}`);
432
+ lines.push(`- runner: ${mission.runner}${mission.model ? ` (${mission.model})` : ''}`);
429
433
  lines.push(`- lane: ${mission.lane}`);
430
434
  if (mission.xp_task?.ref) lines.push(`- AgentXP task: ${mission.xp_task.ref}`);
431
435
  if (mission.verifier) lines.push(`- verifier: ${mission.verifier}`);
@@ -515,6 +519,7 @@ function missionFromArgs(args) {
515
519
  '--stop',
516
520
  '--task',
517
521
  '--ask',
522
+ '--model',
518
523
  ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree']).join(' ').trim();
519
524
  if (!objective) {
520
525
  exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual] [--worktree]', 1, wantsJson(args));
@@ -522,6 +527,7 @@ function missionFromArgs(args) {
522
527
  const owner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
523
528
  const cadence = readFlag(args, '--cadence', readFlag(args, '--loop', 'manual')) || 'manual';
524
529
  const runner = readFlag(args, '--runner', 'manual');
530
+ const model = readFlag(args, '--model', '') || (String(runner).toLowerCase() === 'atris2' ? 'atris:fast' : '');
525
531
  const lane = readFlag(args, '--lane', 'workspace');
526
532
  const verifier = readFlag(args, '--verify', '');
527
533
  assertMissionVerifier(verifier, wantsJson(args));
@@ -540,6 +546,7 @@ function missionFromArgs(args) {
540
546
  status: 'planning',
541
547
  cadence,
542
548
  runner,
549
+ ...(model ? { model } : {}),
543
550
  lane,
544
551
  verifier,
545
552
  always_on: alwaysOn,
@@ -661,6 +668,7 @@ function statusMission(args) {
661
668
  ` id: ${mission.id}`,
662
669
  ` owner: ${mission.owner}`,
663
670
  ` state: ${mission.status}`,
671
+ ...missionHeartbeatLines(mission),
664
672
  ...(mission.worktree_root ? [` worktree: ${mission.worktree_root}`] : []),
665
673
  ` next: ${mission.next_action || 'tick or verify'}`,
666
674
  ...(mission.receipt_path ? [` proof: ${mission.receipt_path}`] : []),
@@ -671,6 +679,68 @@ function statusMission(args) {
671
679
  );
672
680
  }
673
681
 
682
+ // `atris mission watch [id]` — read-only live heartbeat. Prints a line per tick as it
683
+ // lands so a human (or any terminal) can see the loop is alive without rerunning status.
684
+ function watchMission(args) {
685
+ const ref = stripKnownFlags(args, ['--interval', '--idle-every'], [])[0] || '';
686
+ const intervalSeconds = Math.max(1, parseInt(readFlag(args, '--interval', '2'), 10) || 2);
687
+ const idleEverySeconds = Math.max(1, parseInt(readFlag(args, '--idle-every', '30'), 10) || 30);
688
+ const loadTargets = () => {
689
+ if (ref) {
690
+ const mission = resolveMission(ref);
691
+ return mission ? [mission] : [];
692
+ }
693
+ return listMissions().filter((mission) => !HEARTBEAT_TERMINAL_STATUSES.has(mission.status));
694
+ };
695
+ if (ref && !loadTargets().length) {
696
+ exitMissionError(`Mission "${ref}" not found.`, 1, false);
697
+ }
698
+ const stamp = () => new Date().toTimeString().slice(0, 8);
699
+ const shortId = (mission) => mission.id.length > 20 ? `…${mission.id.slice(-8)}` : mission.id;
700
+ const emit = (mission, note) => console.log(`[${stamp()}] ${mission.owner} ${shortId(mission)} — ${note}`);
701
+ const fingerprint = (mission) => [mission.status, mission.last_tick_at, mission.last_tick_index, mission.receipt_path].join('|');
702
+ const tickNote = (mission) => {
703
+ const heartbeat = missionHeartbeatLines(mission).map((line) => line.trim()).join(', ');
704
+ return `${heartbeat || `state: ${mission.status}`}${mission.receipt_path ? ` — proof: ${mission.receipt_path}` : ''}`;
705
+ };
706
+ const seen = new Map();
707
+ let lastIdleAt = Date.now();
708
+ console.log(`watching ${ref || 'active missions'} every ${intervalSeconds}s — ctrl+c to stop`);
709
+ const poll = () => {
710
+ const targets = loadTargets();
711
+ if (!targets.length && !seen.size) {
712
+ emitOnce('no active missions yet — waiting');
713
+ }
714
+ let changed = false;
715
+ for (const mission of targets) {
716
+ const fp = fingerprint(mission);
717
+ if (seen.get(mission.id) !== fp) {
718
+ seen.set(mission.id, fp);
719
+ emit(mission, tickNote(mission));
720
+ changed = true;
721
+ }
722
+ }
723
+ if (changed) {
724
+ lastIdleAt = Date.now();
725
+ } else if (Date.now() - lastIdleAt >= idleEverySeconds * 1000) {
726
+ lastIdleAt = Date.now();
727
+ for (const mission of targets) {
728
+ emit(mission, `alive, ${missionHeartbeatLines(mission).map((line) => line.trim()).join(', ') || `state: ${mission.status}`}`);
729
+ }
730
+ }
731
+ };
732
+ let warnedEmpty = false;
733
+ const emitOnce = (message) => {
734
+ if (warnedEmpty) return;
735
+ warnedEmpty = true;
736
+ console.log(`[${stamp()}] ${message}`);
737
+ };
738
+ poll();
739
+ setInterval(poll, intervalSeconds * 1000);
740
+ // The bin router exits when the command's promise settles; watch runs until ctrl+c.
741
+ return new Promise(() => {});
742
+ }
743
+
674
744
  function writeReceipt(mission, result, root = process.cwd()) {
675
745
  const paths = statePaths(root);
676
746
  fs.mkdirSync(paths.runsDir, { recursive: true });
@@ -957,6 +1027,37 @@ function secondsUntilMissionDue(mission, now = new Date()) {
957
1027
  return Math.max(0, Math.ceil((dueAt - now.getTime()) / 1000));
958
1028
  }
959
1029
 
1030
+ function formatDurationShort(seconds) {
1031
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
1032
+ if (seconds < 60) return `${Math.round(seconds)}s`;
1033
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
1034
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
1035
+ return `${Math.floor(seconds / 86400)}d`;
1036
+ }
1037
+
1038
+ const HEARTBEAT_TERMINAL_STATUSES = new Set(['complete', 'stopped', 'paused']);
1039
+
1040
+ function missionHeartbeatLines(mission, now = new Date()) {
1041
+ const lines = [];
1042
+ const lastTickAt = mission.last_tick_at ? Date.parse(mission.last_tick_at) : NaN;
1043
+ if (Number.isFinite(lastTickAt)) {
1044
+ const age = formatDurationShort((now.getTime() - lastTickAt) / 1000);
1045
+ const verifier = mission.verifier
1046
+ ? (mission.verifier_result ? (mission.verifier_result.passed ? 'verifier passed' : 'verifier failed') : 'verifier not run')
1047
+ : 'no verifier';
1048
+ const tickIdx = mission.last_tick_index != null ? `#${mission.last_tick_index}, ` : '';
1049
+ const layerSuffix = mission.last_tick_layer ? `, layer: ${mission.last_tick_layer}` : '';
1050
+ lines.push(` last tick: ${age} ago (${tickIdx}${mission.last_tick_status || 'unknown'}, ${verifier}${layerSuffix})`);
1051
+ } else if (!HEARTBEAT_TERMINAL_STATUSES.has(mission.status)) {
1052
+ lines.push(' last tick: never');
1053
+ }
1054
+ if (parseCadenceSeconds(mission.cadence) > 0 && !HEARTBEAT_TERMINAL_STATUSES.has(mission.status)) {
1055
+ const dueIn = secondsUntilMissionDue(mission, now);
1056
+ lines.push(dueIn === 0 ? ' due: now' : ` due: in ${formatDurationShort(dueIn)}`);
1057
+ }
1058
+ return lines;
1059
+ }
1060
+
960
1061
  function missionHasHumanAsks(mission) {
961
1062
  return Array.isArray(mission?.human_asks)
962
1063
  && mission.human_asks.some((ask) => String(ask || '').trim());
@@ -1214,6 +1315,22 @@ function consecutiveVerifierFails(ticks) {
1214
1315
  return n;
1215
1316
  }
1216
1317
 
1318
+ // Count the trailing run of errored ticks that all share the most-recent error's
1319
+ // reason. Backoff caps at 10min, so an error that recurs identically (claude-timeout,
1320
+ // atris2-error, a dead model) otherwise retries forever until max-ticks/max-wall.
1321
+ // Two identical failures in a row is the MEMBER.md "same approach failed twice" signal.
1322
+ function consecutiveSameReasonErrors(ticks) {
1323
+ const last = ticks[ticks.length - 1];
1324
+ if (!last || last.status !== 'errored' || !last.reason) return { reason: null, count: 0 };
1325
+ let count = 0;
1326
+ for (let i = ticks.length - 1; i >= 0; i--) {
1327
+ const t = ticks[i];
1328
+ if (t.status === 'errored' && t.reason === last.reason) count++;
1329
+ else break;
1330
+ }
1331
+ return { reason: last.reason, count };
1332
+ }
1333
+
1217
1334
  function isWithinActiveHours(activeHours, now = new Date()) {
1218
1335
  if (!activeHours || !activeHours.start || !activeHours.end) return true;
1219
1336
  const tz = activeHours.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@@ -1255,8 +1372,9 @@ function releaseMissionLock(lock) {
1255
1372
  }
1256
1373
 
1257
1374
  function probeClaudeBinary() {
1258
- const help = spawnSync('claude', ['--help'], { encoding: 'utf8', timeout: 8000 });
1259
- if (help.status !== 0) return { ok: false, error: 'claude --help failed' };
1375
+ const runnerBin = resolveClaudeRunnerBin();
1376
+ const help = spawnSync(runnerBin, ['--help'], { encoding: 'utf8', timeout: 8000 });
1377
+ if (help.status !== 0) return { ok: false, error: `${runnerBin} --help failed` };
1260
1378
  const text = String(help.stdout || '');
1261
1379
  const required = ['--output-format', '--permission-mode', '--resume', '--session-id', '--include-partial-messages'];
1262
1380
  const missing = required.filter((flag) => !text.includes(flag));
@@ -1279,6 +1397,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
1279
1397
  ``,
1280
1398
  `## Your task`,
1281
1399
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
1400
+ `- You are the member "${mission.owner}". Read atris/team/${mission.owner}/MEMBER.md (and SOUL.md if present) before acting — work in that identity, inside its scope and stop rules. After your work, append what you did and what you learned to atris/team/${mission.owner}/logs/<today's date>.md.`,
1282
1401
  `- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
1283
1402
  `- Pick the smallest concrete action that moves the mission forward.`,
1284
1403
  `- Edit / run / research as needed for the lane.`,
@@ -1291,7 +1410,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
1291
1410
  `- Do NOT start new missions, modify other missions, or expand scope.`,
1292
1411
  `- Do NOT run destructive commands without strong evidence they're correct.`,
1293
1412
  ``,
1294
- `When done, output a short receipt: (1) the exact files edited / commands run / artifacts produced — name them, (2) the metric of progress, (3) what the next tick should pick up.`,
1413
+ `When done, output a short receipt: (1) the exact files edited / commands run / artifacts produced — name them, (2) the metric of progress, (3) what the next tick should pick up. End the receipt with one line naming the layer this tick touched: \`layer: identity|beliefs|capabilities|behaviors|environment\` (final line — the harness parses it).`,
1295
1414
  ];
1296
1415
  if (mission.task_ids?.length) {
1297
1416
  lines.push('', `## Task ids`, mission.task_ids.map((t) => `- ${t}`).join('\n'));
@@ -1302,8 +1421,43 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
1302
1421
  return lines.join('\n');
1303
1422
  }
1304
1423
 
1424
+ // resolveClaudeRunnerModel / resolveClaudeRunnerBin + the runner-resolution rationale now live in
1425
+ // lib/runner-command.js so missions, autopilot, and run share one resolver
1426
+ // (imported at the top, re-exported below for test/mission-model-resolution.test.js).
1427
+
1428
+ // The claude CLI prints "...issue with the selected model (<id>). It may not exist
1429
+ // or you may not have access to it." when a model id is retired or inaccessible.
1430
+ // Detect it so the tick reason becomes the actionable 'model-unavailable' (+the id)
1431
+ // instead of a generic 'claude-error' that buries the root cause.
1432
+ function detectUnavailableModel(text) {
1433
+ const s = String(text || '');
1434
+ const m = s.match(/issue with the selected model \(([^)]+)\)/i);
1435
+ if (m) return m[1].trim();
1436
+ if (/selected model/i.test(s) && /may not (?:exist|have access)/i.test(s)) return 'unknown';
1437
+ return null;
1438
+ }
1439
+
1440
+ // Human-facing guidance written to a paused mission's next_action. Most pauses just
1441
+ // need a resume; a model-unavailable pause is a config error a bare resume won't fix,
1442
+ // so name the dead id and the two knobs that change it.
1443
+ function missionPauseNextAction(pauseReason, missionId, deadModel = null, lastErrorReason = null) {
1444
+ if (pauseReason === 'model-unavailable' && deadModel) {
1445
+ return `model "${deadModel}" is unavailable — set a live model (mission.model, ATRIS_RUNNER_MODEL, or legacy ATRIS_CLAUDE_MODEL), then: atris mission run ${missionId}`;
1446
+ }
1447
+ if (typeof pauseReason === 'string' && pauseReason.startsWith('repeated-error:')) {
1448
+ const reason = pauseReason.slice('repeated-error:'.length);
1449
+ return `tick kept failing with "${reason}" — inspect the last receipt, fix the cause, then: atris mission run ${missionId}`;
1450
+ }
1451
+ // Single-tick cron runs pause via max-ticks-reached on the very first errored tick.
1452
+ // A bare "resume" there just re-errors; point the operator at the cause instead.
1453
+ if (pauseReason === 'max-ticks-reached' && lastErrorReason) {
1454
+ return `hit the tick budget while erroring ("${lastErrorReason}") — inspect the last receipt before resuming: atris mission run ${missionId}`;
1455
+ }
1456
+ return `resume with: atris mission run ${missionId}`;
1457
+ }
1458
+
1305
1459
  function spawnClaudeTick(mission, opts) {
1306
- const { sessionMode, sessionId, cwd, signal, timeoutMs, prompt } = opts;
1460
+ const { sessionMode, sessionId, cwd, signal, timeoutMs, prompt, model } = opts;
1307
1461
  return new Promise((resolve) => {
1308
1462
  const args = [
1309
1463
  '-p', prompt,
@@ -1312,11 +1466,12 @@ function spawnClaudeTick(mission, opts) {
1312
1466
  '--permission-mode', 'bypassPermissions',
1313
1467
  '--include-partial-messages',
1314
1468
  ];
1469
+ if (model) args.push('--model', model);
1315
1470
  if (sessionMode === 'set') args.push('--session-id', sessionId);
1316
1471
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
1317
1472
 
1318
1473
  const startedAt = Date.now();
1319
- const proc = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
1474
+ const proc = spawn(resolveClaudeRunnerBin(), args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
1320
1475
 
1321
1476
  let stdoutBuf = '';
1322
1477
  let observedSessionIds = new Set();
@@ -1523,6 +1678,7 @@ async function runMission(args) {
1523
1678
  sessionId = mission.claude_session_id || null;
1524
1679
  pendingSessionId = mission.pending_session_id || null;
1525
1680
  const callerSessionRunner = runnerUsesCallerSession(mission.runner);
1681
+ const atris2Runner = String(mission.runner || '').trim().toLowerCase() === 'atris2';
1526
1682
  const skipWorker = skipClaude || callerSessionRunner;
1527
1683
 
1528
1684
  // Freeze run-start contract (verifier, lane). Stored on receipts, not the mission record.
@@ -1539,7 +1695,8 @@ async function runMission(args) {
1539
1695
  const effectiveMaxTicks = (cadenceSeconds === 0 && !maxTicksFlag) ? 1 : maxTicks;
1540
1696
 
1541
1697
  // Session setup: only Claude-backed workers need a persisted session id.
1542
- if (!skipWorker && !sessionId && !pendingSessionId) {
1698
+ // atris2 turns are stateless per tick — continuity lives on disk (logs, receipts, now.md).
1699
+ if (!skipWorker && !atris2Runner && !sessionId && !pendingSessionId) {
1543
1700
  pendingSessionId = crypto.randomUUID();
1544
1701
  mission = saveMission({ ...mission, pending_session_id: pendingSessionId }, cwd, 'mission_session_pending', { session_id: pendingSessionId }).mission;
1545
1702
  }
@@ -1548,7 +1705,11 @@ async function runMission(args) {
1548
1705
  let backoffAttempt = 0;
1549
1706
  let lastRateLimit = null;
1550
1707
 
1551
- const sessionLabel = skipWorker ? 'caller-session' : (sessionId || `pending=${pendingSessionId}`);
1708
+ const sessionLabel = skipWorker
1709
+ ? 'caller-session'
1710
+ : atris2Runner
1711
+ ? `atris2 (${mission.model || 'atris:fast'})`
1712
+ : (sessionId || `pending=${pendingSessionId}`);
1552
1713
  console.error(`[mission run] ${mission.id}\n objective: ${mission.objective}\n lane: ${frozen.lane}\n cadence: ${cadence} (${cadenceSeconds}s)\n max_ticks: ${effectiveMaxTicks}, max_wall: ${maxWallSeconds}s\n session: ${sessionLabel}`);
1553
1714
 
1554
1715
  while (ticks.length < effectiveMaxTicks) {
@@ -1587,6 +1748,32 @@ async function runMission(args) {
1587
1748
  ran: true,
1588
1749
  claude: { skipped: true, reason: callerSessionRunner ? 'runner-uses-caller-session' : 'no-claude-mode' },
1589
1750
  };
1751
+ } else if (atris2Runner) {
1752
+ const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen);
1753
+ const { runAtris2Turn } = require('./probe');
1754
+ const turn = await runAtris2Turn({
1755
+ prompt,
1756
+ model: mission.model || 'atris:fast',
1757
+ maxTurns: 16,
1758
+ signal: controller.signal,
1759
+ });
1760
+ result.atris2 = {
1761
+ ok: turn.ok,
1762
+ engine: turn.engine,
1763
+ model: mission.model || 'atris:fast',
1764
+ tools_run: turn.tools_run,
1765
+ unsupported: turn.unsupported,
1766
+ duration_ms: turn.duration_ms,
1767
+ error: turn.error,
1768
+ receipt_text: String(turn.text || '').slice(0, 4000),
1769
+ };
1770
+ if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
1771
+ if (turn.error === 'not-logged-in') { pauseReason = 'auth-required'; break; }
1772
+ if (!turn.ok || !String(turn.text || '').trim()) {
1773
+ result = { ...result, status: 'errored', reason: 'atris2-error' };
1774
+ } else {
1775
+ result = { ...result, status: 'ran', reason: 'tick-ok', ran: true };
1776
+ }
1590
1777
  } else {
1591
1778
  const sessionMode = sessionId ? 'resume' : 'set';
1592
1779
  const useId = sessionId || pendingSessionId;
@@ -1594,6 +1781,7 @@ async function runMission(args) {
1594
1781
  const claudeResult = await spawnClaudeTick(mission, {
1595
1782
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
1596
1783
  timeoutMs: MISSION_RUN_DEFAULTS.claudeTimeoutMs, prompt,
1784
+ model: resolveClaudeRunnerModel(mission),
1597
1785
  });
1598
1786
  result.claude = {
1599
1787
  ok: claudeResult.ok,
@@ -1619,7 +1807,10 @@ async function runMission(args) {
1619
1807
  if (claudeResult.authExpired) { pauseReason = 'auth-required'; break; }
1620
1808
 
1621
1809
  if (!claudeResult.ok) {
1622
- result = { ...result, status: 'errored', reason: claudeResult.timedOut ? 'claude-timeout' : 'claude-error' };
1810
+ const deadModel = detectUnavailableModel(claudeResult.summary || claudeResult.receipt_text);
1811
+ let reason = claudeResult.timedOut ? 'claude-timeout' : 'claude-error';
1812
+ if (deadModel) { reason = 'model-unavailable'; result.model_unavailable = deadModel; }
1813
+ result = { ...result, status: 'errored', reason };
1623
1814
  } else {
1624
1815
  // Promote pending session id ONLY if claude confirmed the exact UUID we requested.
1625
1816
  // Mismatch is an invariant failure (we sent --session-id X, got Y) → pause, don't rotate.
@@ -1660,6 +1851,13 @@ async function runMission(args) {
1660
1851
  }
1661
1852
  const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier, baseline: runWorktreeBaseline });
1662
1853
 
1854
+ // Layer classification needs the receipt text AND the worktree receipt, so it
1855
+ // runs here — after both exist — covering the claude and atris2 branches alike.
1856
+ const tickReceiptText = result.atris2?.receipt_text || result.claude?.receipt_text || '';
1857
+ const layerInfo = extractLayerFromReceiptText(tickReceiptText, tickWorktree?.new_since_baseline_sample);
1858
+ result.layer = layerInfo.layer;
1859
+ result.layer_source = layerInfo.source;
1860
+
1663
1861
  // Persist tick to mission state + write structured receipt
1664
1862
  const finishedAt = stampIso();
1665
1863
  const tickRecord = { ...result, started_at: tickStart, finished_at: finishedAt, worktree: tickWorktree };
@@ -1699,17 +1897,20 @@ async function runMission(args) {
1699
1897
  last_tick_status: result.status,
1700
1898
  last_tick_reason: result.reason,
1701
1899
  last_tick_index: tickIdx,
1900
+ last_tick_layer: result.layer,
1901
+ last_tick_layer_source: result.layer_source,
1702
1902
  verifier_result: verifierResult || mission.verifier_result || null,
1703
1903
  receipt_path: receiptPath,
1704
1904
  next_action: nextAction,
1705
1905
  }, cwd, 'mission_tick', {
1706
- tick_index: tickIdx, status: result.status, reason: result.reason, receipt_path: receiptPath,
1906
+ tick_index: tickIdx, status: result.status, reason: result.reason, receipt_path: receiptPath, layer: result.layer,
1707
1907
  }).mission;
1708
1908
  appendMemberLog(mission.owner, `Mission run tick ${tickIdx}`, {
1709
1909
  mission: mission.objective,
1710
1910
  state: mission.status,
1711
1911
  tick_status: result.status,
1712
1912
  reason: result.reason,
1913
+ layer: result.layer || undefined,
1713
1914
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
1714
1915
  receipt: receiptPath,
1715
1916
  });
@@ -1726,6 +1927,16 @@ async function runMission(args) {
1726
1927
 
1727
1928
  if (newStatus === 'complete' || (newStatus === 'ready' && !mission.always_on)) break;
1728
1929
  if (consecutiveVerifierFails(ticks) >= 2) { pauseReason = 'consecutive-verifier-fails'; break; }
1930
+ // A retired/inaccessible model is deterministic: the id is fixed for the run, so
1931
+ // every remaining tick (and every future cron firing) fails identically. Backoff
1932
+ // only slows the bleeding. Stop on first detection and surface the dead id —
1933
+ // CLI-245 named this failure; this stops the loop from grinding on it forever.
1934
+ if (result.status === 'errored' && result.reason === 'model-unavailable') { pauseReason = 'model-unavailable'; break; }
1935
+ // Any OTHER error that recurs identically (claude-timeout, atris2-error, claude-error)
1936
+ // is the same trap one step less deterministic: keep retrying and the loop burns every
1937
+ // tick + cron firing on it. Halt at two-in-a-row and surface the reason for a human.
1938
+ const errStreak = consecutiveSameReasonErrors(ticks);
1939
+ if (errStreak.count >= 2) { pauseReason = `repeated-error:${errStreak.reason}`; break; }
1729
1940
 
1730
1941
  // Sleep until next tick
1731
1942
  let sleepMs = 0;
@@ -1752,13 +1963,16 @@ async function runMission(args) {
1752
1963
  }
1753
1964
 
1754
1965
  if (pauseReason && !['complete', 'ready', 'max-wall-reached'].includes(pauseReason)) {
1966
+ const lastTick = ticks[ticks.length - 1];
1967
+ const deadModel = pauseReason === 'model-unavailable' ? (lastTick && lastTick.model_unavailable) || null : null;
1968
+ const lastErrorReason = lastTick && lastTick.status === 'errored' ? lastTick.reason : null;
1755
1969
  mission = saveMission({
1756
1970
  ...mission,
1757
1971
  status: 'paused',
1758
1972
  paused_at: stampIso(),
1759
1973
  stop_reason: pauseReason,
1760
- next_action: `resume with: atris mission run ${mission.id}`,
1761
- }, cwd, 'mission_run_paused', { reason: pauseReason }).mission;
1974
+ next_action: missionPauseNextAction(pauseReason, mission.id, deadModel, lastErrorReason),
1975
+ }, cwd, 'mission_run_paused', { reason: pauseReason, ...(deadModel ? { model_unavailable: deadModel } : {}) }).mission;
1762
1976
  }
1763
1977
 
1764
1978
  const summaryWorktree = worktreeReceipt(runWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier, baseline: runWorktreeBaseline });
@@ -1844,6 +2058,9 @@ function tickMission(args) {
1844
2058
  }
1845
2059
  const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: mission.verifier, baseline: worktreeBaseline });
1846
2060
 
2061
+ // Same layer classification as the run-tick path; manual ticks carry their
2062
+ // receipt text in --summary.
2063
+ const layerInfo = extractLayerFromReceiptText(summary || '', tickWorktree?.new_since_baseline_sample);
1847
2064
  const tickRecord = {
1848
2065
  status: 'ran',
1849
2066
  reason: 'tick-recorded',
@@ -1852,6 +2069,8 @@ function tickMission(args) {
1852
2069
  started_at: tickStart,
1853
2070
  claude: { skipped: true, reason: 'orchestrator-is-caller-session' },
1854
2071
  summary: summary || null,
2072
+ layer: layerInfo.layer,
2073
+ layer_source: layerInfo.source,
1855
2074
  verifier_passed: verifierResult ? !!verifierResult.passed : null,
1856
2075
  finished_at: stampIso(),
1857
2076
  worktree: tickWorktree,
@@ -1880,24 +2099,31 @@ function tickMission(args) {
1880
2099
  status = 'blocked';
1881
2100
  nextAction = 'fix verifier failure or revise mission';
1882
2101
  }
2102
+ const clearsPauseState = !['paused', 'stopped'].includes(status);
1883
2103
  const nextMission = {
1884
2104
  ...mission,
1885
2105
  status,
2106
+ paused_at: clearsPauseState ? null : mission.paused_at || null,
2107
+ stop_reason: clearsPauseState ? null : mission.stop_reason || null,
2108
+ resumed_at: clearsPauseState && mission.status === 'paused' ? tickRecord.finished_at : mission.resumed_at || null,
1886
2109
  receipt_path: receiptPath,
1887
2110
  last_tick_at: tickRecord.finished_at,
1888
2111
  last_tick_status: tickRecord.status,
1889
2112
  last_tick_reason: tickRecord.reason,
1890
2113
  last_tick_index: tickIdx,
2114
+ last_tick_layer: tickRecord.layer,
2115
+ last_tick_layer_source: tickRecord.layer_source,
1891
2116
  verifier_result: verifierResult || mission.verifier_result || null,
1892
2117
  next_action: nextAction,
1893
2118
  };
1894
2119
  const { mission: saved } = saveMission(nextMission, cwd, 'mission_tick', {
1895
- tick_index: tickIdx, verify, verifier_result: verifierResult, receipt_path: receiptPath,
2120
+ tick_index: tickIdx, verify, verifier_result: verifierResult, receipt_path: receiptPath, layer: tickRecord.layer,
1896
2121
  });
1897
2122
  const logPath = appendMemberLog(saved.owner, 'Mission tick', {
1898
2123
  mission: saved.objective,
1899
2124
  state: saved.status,
1900
2125
  tick_index: tickIdx,
2126
+ layer: tickRecord.layer || undefined,
1901
2127
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
1902
2128
  receipt: receiptPath,
1903
2129
  summary: summary || undefined,
@@ -2140,7 +2366,14 @@ function help() {
2140
2366
  atris mission - durable goal + loop + owner + proof state
2141
2367
 
2142
2368
  atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--xp-task] [--worktree]
2369
+ [--runner manual|claude|atris2|codex_goal] [--model <id>]
2370
+ (runner claude spawns local claude -p per tick, --model passes through;
2371
+ runner atris2 runs each tick as one /atris2/turn on the AtrisOS backend,
2372
+ default model atris:fast; runner codex_goal publishes the goal for a live
2373
+ Codex session to pull via atris mission goal)
2143
2374
  atris mission status [id] [--status <state>] [--limit <n>] [--local] [--json]
2375
+ atris mission watch [id] [--interval <s>] [--idle-every <s>] Live heartbeat: prints a line per tick as it lands
2376
+ atris mission layers [--mission <id-substr>] [--since <date>] [--json] Per-layer growth curve across tick receipts
2144
2377
  (rolls up sibling git-worktree missions; --local scopes to this checkout)
2145
2378
  atris mission goal [--heartbeat] [--json]
2146
2379
  atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
@@ -2189,6 +2422,153 @@ State:
2189
2422
  `.trim());
2190
2423
  }
2191
2424
 
2425
+ // Extract layer classification from receipt text.
2426
+ // Priority: layer tag on the last non-empty line (source: explicit) >
2427
+ // last strictly-matching layer line anywhere in the text (source: explicit-inline) >
2428
+ // changed-path classification (source: fallback). The single-token regex keeps the
2429
+ // enum docs line ("layer: identity|beliefs|...") from ever matching.
2430
+ function extractLayerFromReceiptText(text, fallbackPaths = []) {
2431
+ const text_str = String(text || '').trim();
2432
+
2433
+ if (text_str) {
2434
+ // Matches a standalone "layer: x" line, or a one-line summary ending in
2435
+ // "...; layer: x". Quoted bullets ("- layer: x") and the enum-doc line stay inert.
2436
+ const layerPattern = /^(?:.*;\s*)?layer:\s*(identity|beliefs|capabilities|behaviors|environment)\s*$/i;
2437
+ const lines = text_str.split(/\r?\n/).reverse();
2438
+ let isLastNonEmpty = true;
2439
+ for (const line of lines) {
2440
+ const trimmed = line.trim();
2441
+ if (!trimmed) continue;
2442
+ const match = trimmed.match(layerPattern);
2443
+ if (match) {
2444
+ return { layer: match[1].toLowerCase(), source: isLastNonEmpty ? 'explicit' : 'explicit-inline' };
2445
+ }
2446
+ isLastNonEmpty = false;
2447
+ }
2448
+ }
2449
+
2450
+ // Fallback to path classification
2451
+ if (Array.isArray(fallbackPaths) && fallbackPaths.length > 0) {
2452
+ const classified = classifyPathsByLayer(fallbackPaths);
2453
+ if (classified && classified.layer) {
2454
+ return { layer: classified.layer, source: classified.source };
2455
+ }
2456
+ }
2457
+
2458
+ return { layer: null, source: 'unknown' };
2459
+ }
2460
+
2461
+ // Classify paths into layers. Returns { layer: string, source: string, confidence: number }
2462
+ // Rules: atris/team/ => identity, atris/lessons.md|atris/wiki/ => beliefs,
2463
+ // test/|skills/ => capabilities, commands/|bin/ => behaviors, else environment
2464
+ function classifyPathsByLayer(paths) {
2465
+ if (!Array.isArray(paths) || paths.length === 0) {
2466
+ return { layer: null, source: 'unknown' };
2467
+ }
2468
+
2469
+ const counts = {
2470
+ identity: 0,
2471
+ beliefs: 0,
2472
+ capabilities: 0,
2473
+ behaviors: 0,
2474
+ environment: 0,
2475
+ };
2476
+
2477
+ for (const pathStr of paths) {
2478
+ const p = String(pathStr || '');
2479
+ if (p.includes('atris/team/')) {
2480
+ counts.identity++;
2481
+ } else if (p.includes('atris/lessons.md') || p.includes('atris/wiki/')) {
2482
+ counts.beliefs++;
2483
+ } else if (p.includes('test/') || p.includes('skills/')) {
2484
+ counts.capabilities++;
2485
+ } else if (p.includes('commands/') || p.includes('bin/')) {
2486
+ counts.behaviors++;
2487
+ } else {
2488
+ counts.environment++;
2489
+ }
2490
+ }
2491
+
2492
+ // Find max with tie-break: identity > beliefs > capabilities > behaviors > environment
2493
+ const tieBreakOrder = ['identity', 'beliefs', 'capabilities', 'behaviors', 'environment'];
2494
+ let maxCount = 0;
2495
+ let winnerLayer = null;
2496
+ for (const layer of tieBreakOrder) {
2497
+ if (counts[layer] > maxCount) {
2498
+ maxCount = counts[layer];
2499
+ winnerLayer = layer;
2500
+ }
2501
+ }
2502
+
2503
+ return winnerLayer ? { layer: winnerLayer, source: 'fallback' } : { layer: null, source: 'unknown' };
2504
+ }
2505
+
2506
+ // `atris mission layers` — per-layer growth curve across tick receipts. The member
2507
+ // proof standard says: if every tick is one layer and none touch the others, the
2508
+ // loop is doing work but not getting smarter. This makes that check one command.
2509
+ function layersMission(args) {
2510
+ const asJson = args.includes('--json');
2511
+ const missionFilter = readFlag(args, '--mission', '');
2512
+ const sinceRaw = readFlag(args, '--since', '');
2513
+ // --since accepts a date (2026-06-13) or full ISO; filters on the receipt's `at`
2514
+ // stamp so the curve can be read over a window (e.g. "what did today's ticks touch?").
2515
+ const sinceMs = sinceRaw ? Date.parse(sinceRaw) : null;
2516
+ if (sinceRaw && Number.isNaN(sinceMs)) {
2517
+ exitMissionError(`--since "${sinceRaw}" is not a parseable date (try 2026-06-13 or an ISO timestamp).`, 1, asJson);
2518
+ }
2519
+ const paths = statePaths(process.cwd());
2520
+ const LAYERS = ['identity', 'beliefs', 'capabilities', 'behaviors', 'environment'];
2521
+ const byLayer = Object.fromEntries(LAYERS.map((l) => [l, 0]));
2522
+ const bySource = { explicit: 0, 'explicit-inline': 0, fallback: 0, unknown: 0 };
2523
+ let total = 0;
2524
+ let untagged = 0;
2525
+ let files = [];
2526
+ try {
2527
+ files = fs.readdirSync(paths.runsDir).filter((f) => f.startsWith('mission-') && f.endsWith('.json'));
2528
+ } catch {
2529
+ files = [];
2530
+ }
2531
+ for (const file of files) {
2532
+ if (missionFilter && !file.includes(missionFilter)) continue;
2533
+ let receipt;
2534
+ try {
2535
+ receipt = JSON.parse(fs.readFileSync(path.join(paths.runsDir, file), 'utf8'));
2536
+ } catch {
2537
+ continue;
2538
+ }
2539
+ if (sinceMs != null) {
2540
+ const atMs = Date.parse(receipt && receipt.at);
2541
+ if (Number.isNaN(atMs) || atMs < sinceMs) continue;
2542
+ }
2543
+ const tick = receipt?.result?.tick;
2544
+ if (!tick) continue; // summaries, stop receipts, legacy shapes
2545
+ total++;
2546
+ const layer = String(tick.layer || '').toLowerCase();
2547
+ const source = String(tick.layer_source || 'unknown');
2548
+ if (LAYERS.includes(layer)) {
2549
+ byLayer[layer]++;
2550
+ bySource[source in bySource ? source : 'unknown']++;
2551
+ } else {
2552
+ untagged++;
2553
+ }
2554
+ }
2555
+ const tagged = total - untagged;
2556
+ const dominant = LAYERS.reduce((a, b) => (byLayer[b] > byLayer[a] ? b : a), LAYERS[0]);
2557
+ const skewed = tagged >= 5 && byLayer[dominant] / tagged >= 0.8;
2558
+ const scopeBits = [
2559
+ missionFilter ? `mission filter: ${missionFilter}` : null,
2560
+ sinceRaw ? `since: ${sinceRaw}` : null,
2561
+ ].filter(Boolean);
2562
+ const lines = [
2563
+ `Layer growth curve${scopeBits.length ? ` (${scopeBits.join(', ')})` : ''}: ${tagged} tagged / ${total} tick receipts`,
2564
+ ...LAYERS.map((l) => ` ${l.padEnd(12)} ${String(byLayer[l]).padStart(3)}${byLayer[l] ? ' ' + '█'.repeat(Math.min(byLayer[l], 40)) : ''}`),
2565
+ ...(untagged ? [` untagged ${String(untagged).padStart(3)} (pre-layer receipts or missing tag)`] : []),
2566
+ ` provenance: explicit ${bySource.explicit}, explicit-inline ${bySource['explicit-inline']}, fallback ${bySource.fallback}`,
2567
+ ...(skewed ? [` rebalance: ${Math.round((byLayer[dominant] / tagged) * 100)}% of tagged ticks are "${dominant}" — the proof standard wants the other layers moving too`] : []),
2568
+ ];
2569
+ printJsonOrText({ ok: true, since: sinceRaw || null, total, tagged, untagged, by_layer: byLayer, by_source: bySource, dominant: tagged ? dominant : null, skewed }, lines, asJson);
2570
+ }
2571
+
2192
2572
  function missionCommand(args) {
2193
2573
  const subcommand = args[0] || 'status';
2194
2574
  const rest = args.slice(1);
@@ -2201,6 +2581,10 @@ function missionCommand(args) {
2201
2581
  case 'list':
2202
2582
  case 'ls':
2203
2583
  return statusMission(rest);
2584
+ case 'watch':
2585
+ return watchMission(rest);
2586
+ case 'layers':
2587
+ return layersMission(rest);
2204
2588
  case 'goal':
2205
2589
  case 'codex-goal':
2206
2590
  return goalMission(rest);
@@ -2228,6 +2612,7 @@ function missionCommand(args) {
2228
2612
 
2229
2613
  module.exports = {
2230
2614
  missionCommand,
2615
+ missionHeartbeatLines,
2231
2616
  listMissions,
2232
2617
  loadMissionMap,
2233
2618
  renderMissionStatus,
@@ -2235,4 +2620,11 @@ module.exports = {
2235
2620
  selectCodexGoalMission,
2236
2621
  usefulClaudeReceiptSummary,
2237
2622
  cappedClaudeReceiptText,
2623
+ extractLayerFromReceiptText,
2624
+ classifyPathsByLayer,
2625
+ resolveClaudeRunnerModel,
2626
+ resolveClaudeRunnerBin,
2627
+ detectUnavailableModel,
2628
+ missionPauseNextAction,
2629
+ consecutiveSameReasonErrors,
2238
2630
  };