atris 3.33.3 → 3.35.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 (54) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +19 -0
  6. package/ax +475 -17
  7. package/bin/atris.js +206 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/loops.js +212 -0
  24. package/commands/member.js +398 -42
  25. package/commands/mission.js +598 -64
  26. package/commands/now.js +25 -1
  27. package/commands/radar.js +259 -14
  28. package/commands/serve.js +54 -0
  29. package/commands/status.js +50 -5
  30. package/commands/stripe.js +38 -0
  31. package/commands/supabase.js +39 -0
  32. package/commands/task.js +935 -71
  33. package/commands/truth.js +29 -3
  34. package/commands/unknowns.js +627 -0
  35. package/commands/update.js +44 -0
  36. package/commands/vercel.js +38 -0
  37. package/commands/worktree.js +68 -10
  38. package/commands/write.js +399 -0
  39. package/lib/auto-accept-certified.js +70 -19
  40. package/lib/autoland.js +39 -3
  41. package/lib/fleet.js +256 -13
  42. package/lib/memory-view.js +14 -5
  43. package/lib/mission-runtime-loop.js +7 -0
  44. package/lib/official-cli-integration.js +174 -0
  45. package/lib/outbound-send-gate.js +165 -0
  46. package/lib/permission-grants.js +293 -0
  47. package/lib/review-integrity.js +147 -0
  48. package/lib/runner-command.js +23 -0
  49. package/lib/task-db.js +220 -7
  50. package/lib/task-proof.js +20 -0
  51. package/lib/task-receipt.js +93 -0
  52. package/package.json +1 -1
  53. package/utils/update-check.js +27 -6
  54. package/atris/learnings.jsonl +0 -1
@@ -8,6 +8,11 @@ const { spawn, spawnSync } = require('child_process');
8
8
  const {
9
9
  resolveClaudeRunnerModel,
10
10
  resolveClaudeRunnerBin,
11
+ resolveClaudeRunnerCommandTemplate,
12
+ buildRunnerCommand,
13
+ RUNNER_PROFILE_DEFS,
14
+ RUNNER_PROFILE_ALIASES,
15
+ RUNNER_PROFILE_NAMES,
11
16
  } = require('../lib/runner-command');
12
17
  const {
13
18
  FUNCTIONAL_MEMBER_TOPICS,
@@ -94,6 +99,76 @@ function readFlag(args, name, fallback = '') {
94
99
  return fallback;
95
100
  }
96
101
 
102
+ const MISSION_NATIVE_RUNNER_NAMES = Object.freeze(['manual', 'claude', 'atris2', 'codex_goal', 'caller_session', 'current_agent']);
103
+ const MISSION_NATIVE_RUNNER_SET = new Set(MISSION_NATIVE_RUNNER_NAMES);
104
+
105
+ function canonicalEngineName(name) {
106
+ const trimmed = String(name || '').trim();
107
+ if (!trimmed) return '';
108
+ if (RUNNER_PROFILE_DEFS[trimmed]) return trimmed;
109
+ if (RUNNER_PROFILE_ALIASES[trimmed]) return RUNNER_PROFILE_ALIASES[trimmed];
110
+ return '';
111
+ }
112
+
113
+ function knownMissionRunnerText() {
114
+ return `Known runners: ${MISSION_NATIVE_RUNNER_NAMES.join(', ')}. Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`;
115
+ }
116
+
117
+ function resolveMissionRunnerSelection(value, options = {}) {
118
+ const raw = String(value || '').trim();
119
+ const asJson = options.asJson === true;
120
+ const engineOnly = options.engineOnly === true;
121
+ if (!raw) {
122
+ exitMissionError(`${options.label || 'runner'} is required. ${knownMissionRunnerText()}.`, 2, asJson);
123
+ }
124
+ const engine = canonicalEngineName(raw);
125
+ if (engine) {
126
+ return { requested: raw, runner: engine, engine, kind: 'engine' };
127
+ }
128
+ const runner = raw.toLowerCase();
129
+ if (!engineOnly && MISSION_NATIVE_RUNNER_SET.has(runner)) {
130
+ return { requested: raw, runner, engine: null, kind: 'runner' };
131
+ }
132
+ const noun = engineOnly ? 'engine' : 'runner';
133
+ exitMissionError(`Unknown ${noun} "${raw}". ${knownMissionRunnerText()}.`, 2, asJson);
134
+ }
135
+
136
+ function runnerModelPatch(runner, model) {
137
+ const explicitModel = String(model || '').trim();
138
+ if (explicitModel) return { model: explicitModel };
139
+ if (String(runner || '').trim().toLowerCase() === 'atris2') return { model: 'atris:fast' };
140
+ return {};
141
+ }
142
+
143
+ function missionRunRuntimeView(mission, runnerOverride = null, modelOverride = '') {
144
+ if (!runnerOverride) return mission;
145
+ const next = {
146
+ ...mission,
147
+ runner: runnerOverride.runner,
148
+ run_runner_override: {
149
+ requested: runnerOverride.requested,
150
+ runner: runnerOverride.runner,
151
+ stored_runner: mission.runner || null,
152
+ stored_model: mission.model || null,
153
+ },
154
+ };
155
+ delete next.model;
156
+ Object.assign(next, runnerModelPatch(runnerOverride.runner, modelOverride));
157
+ if (next.model) next.run_runner_override.model = next.model;
158
+ return next;
159
+ }
160
+
161
+ function applyMissionRunnerProfile(runner) {
162
+ const engine = canonicalEngineName(runner);
163
+ if (!engine) return () => {};
164
+ const previous = process.env.ATRIS_RUNNER_PROFILE;
165
+ process.env.ATRIS_RUNNER_PROFILE = engine;
166
+ return () => {
167
+ if (previous === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
168
+ else process.env.ATRIS_RUNNER_PROFILE = previous;
169
+ };
170
+ }
171
+
97
172
  function exitMissionError(message, code = 1, asJson = false) {
98
173
  if (asJson) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
99
174
  else console.error(message);
@@ -215,6 +290,7 @@ const MISSION_RUN_VALUE_FLAGS = [
215
290
  '--verify',
216
291
  '--stop',
217
292
  '--model',
293
+ '--engine',
218
294
  '--native-goal-status',
219
295
  '--native-goal-objective',
220
296
  '--visible-goal-status',
@@ -233,10 +309,13 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
233
309
  '--spend-full-budget',
234
310
  '--use-whole-budget',
235
311
  '--stop-when-done',
312
+ '--preflight',
313
+ '--no-preflight',
236
314
  '--room-preflight',
237
315
  '--no-room-preflight',
238
316
  '--room-auto-run',
239
317
  '--no-room-auto-run',
318
+ '--manual-ack',
240
319
  '--allow-native-goal-supersede',
241
320
  '--supersede-paused-native-goal',
242
321
  '--take-goal-slot',
@@ -594,6 +673,8 @@ function exitMissingMission(ref, code = 1, asJson = false) {
594
673
  if (hints.length) {
595
674
  console.error(`Workspace hint: mission ${hints[0].id} exists in ${hints[0].workspace_root}.`);
596
675
  console.error(`Run: ${hints[0].command}`);
676
+ } else {
677
+ console.error('Mission ids are workspace-local — run this from the workspace that created the mission.');
597
678
  }
598
679
  }
599
680
  process.exit(code);
@@ -675,6 +756,14 @@ function memberMissionFile(owner, root = process.cwd()) {
675
756
  return path.join(dir, 'MISSION.md');
676
757
  }
677
758
 
759
+ function missingOwnerMemberWarning(owner, root = process.cwd()) {
760
+ if (!owner || fs.existsSync(path.join(root, 'atris', 'team', owner))) return null;
761
+ return {
762
+ code: 'missing_owner_member',
763
+ message: `owner "${owner}" has no atris/team/${owner}/ member. Create it (atris member create ${owner}) or pick an existing member (ls atris/team/).`,
764
+ };
765
+ }
766
+
678
767
  function ensureMemberMissionFile(owner, root = process.cwd(), objective = '') {
679
768
  const missionPath = memberMissionFile(owner, root);
680
769
  if (!missionPath || fs.existsSync(missionPath)) return missionPath;
@@ -1075,7 +1164,8 @@ function normalizeMissionReceiptResult(mission, result, receiptPath = '') {
1075
1164
  }
1076
1165
 
1077
1166
  function missionRunStartNextLine(mission, nextCommand, warnings = []) {
1078
- if (warnings.length) return 'Add a verifier before completion, then run the first proof tick.';
1167
+ const missingVerifier = warnings.some((warning) => warning && warning.code === 'missing_verifier');
1168
+ if (missingVerifier) return 'Add a verifier before completion, then run the first proof tick.';
1079
1169
  if (isCodexGoalMission(mission) && !codexNativeGoalAck(mission)) {
1080
1170
  return 'Start the visible goal, then continue this mission.';
1081
1171
  }
@@ -1105,9 +1195,11 @@ function missionRunPreflightSignals(text) {
1105
1195
  }
1106
1196
 
1107
1197
  function shouldMissionRunRoomPreflight(objective, args = []) {
1198
+ if (hasFlag(args, '--no-preflight')) return false;
1108
1199
  if (hasFlag(args, '--no-room-preflight')) return false;
1200
+ if (hasFlag(args, '--preflight')) return true;
1109
1201
  if (hasFlag(args, '--room-preflight')) return true;
1110
- return missionRunPreflightSignals(objective) || missionRunTrustedRoomSignals(objective);
1202
+ return true;
1111
1203
  }
1112
1204
 
1113
1205
  function missionRunTrustedRoomSignals(text) {
@@ -1169,6 +1261,8 @@ function buildMissionRunRoomPreflight(rawObjective, args = [], options = {}) {
1169
1261
  if (!shouldMissionRunRoomPreflight(rawObjective, args)) return null;
1170
1262
  const root = options.root || process.cwd();
1171
1263
  const owner = options.owner || readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
1264
+ const explicitPreflight = hasFlag(args, '--preflight') || hasFlag(args, '--room-preflight');
1265
+ const signalPreflight = missionRunPreflightSignals(rawObjective) || missionRunTrustedRoomSignals(rawObjective);
1172
1266
  const trustedRun = options.allowTrustedRun !== false && shouldMissionRunTrustedRoom(rawObjective, args);
1173
1267
  const selectedTarget = trustedRun ? selectMissionRunUsefulTarget(rawObjective, root) : null;
1174
1268
  const ownerResolution = resolveFunctionalOwner({
@@ -1191,6 +1285,7 @@ function buildMissionRunRoomPreflight(rawObjective, args = [], options = {}) {
1191
1285
  const shapedObjective = trustedRun
1192
1286
  ? missionRunTrustedObjective(rawObjective, written.room, selectedTarget)
1193
1287
  : missionRunPreflightObjective(rawObjective, written.room, ownerResolution.owner);
1288
+ const taskSpineRequired = !selectedTarget && (explicitPreflight || signalPreflight);
1194
1289
  return {
1195
1290
  schema: 'atris.mission_run_preflight.v1',
1196
1291
  source: 'mission_room',
@@ -1209,10 +1304,10 @@ function buildMissionRunRoomPreflight(rawObjective, args = [], options = {}) {
1209
1304
  ref: selectedTarget.ref || null,
1210
1305
  why: selectedTarget.why || '',
1211
1306
  } : null,
1212
- task_spine_required: !selectedTarget,
1307
+ task_spine_required: taskSpineRequired,
1213
1308
  next_action: selectedTarget
1214
1309
  ? 'run one proof tick for the selected existing task'
1215
- : 'attach task spine, then run one proof tick',
1310
+ : (taskSpineRequired ? 'attach task spine, then run one proof tick' : 'run one proof tick'),
1216
1311
  };
1217
1312
  }
1218
1313
 
@@ -1613,7 +1708,7 @@ function missionStatusView(mission) {
1613
1708
  }
1614
1709
 
1615
1710
  function missionFromArgs(args) {
1616
- const objective = stripKnownFlags(args, [
1711
+ const objectiveParts = stripKnownFlags(args, [
1617
1712
  '--owner',
1618
1713
  '--cadence',
1619
1714
  '--loop',
@@ -1632,7 +1727,9 @@ function missionFromArgs(args) {
1632
1727
  '--native-goal-objective',
1633
1728
  '--visible-goal-status',
1634
1729
  '--visible-goal-objective',
1635
- ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--allow-native-goal-supersede', '--supersede-paused-native-goal', '--take-goal-slot']).join(' ').trim();
1730
+ ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree', '--duplicate', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--preflight', '--no-preflight', '--room-preflight', '--no-room-preflight', '--manual-ack', '--allow-native-goal-supersede', '--supersede-paused-native-goal', '--take-goal-slot'])
1731
+ .filter((part) => String(part || '').trim() !== '...');
1732
+ const objective = objectiveParts.join(' ').trim();
1636
1733
  if (!objective) {
1637
1734
  exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual] [--worktree]', 1, wantsJson(args));
1638
1735
  }
@@ -2778,9 +2875,82 @@ function inheritedWorktreeBase(cwd) {
2778
2875
  }
2779
2876
  }
2780
2877
 
2878
+ // Dedup gate: the same objective + owner already active anywhere in the
2879
+ // workspace family (this store or any worktree's) is reused, never cloned.
2880
+ // Born 2026-07-02: an hourly alive loop spawned six identical auto-improver
2881
+ // missions in six fresh worktrees in one day — pure token burn. --duplicate
2882
+ // is the explicit escape hatch.
2883
+ const TWIN_ACTIVE_STATUSES = new Set(['planning', 'ready', 'running']);
2884
+
2885
+ function normalizedObjective(text) {
2886
+ return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
2887
+ }
2888
+
2889
+ function findActiveTwinMission(objective, owner, root = process.cwd()) {
2890
+ const wantObjective = normalizedObjective(objective);
2891
+ const wantOwner = String(owner || '').trim().toLowerCase();
2892
+ if (!wantObjective) return null;
2893
+ const candidates = [
2894
+ ...listMissions(root),
2895
+ ...listWorktreeRollupMissions(root),
2896
+ ];
2897
+ for (const m of candidates) {
2898
+ if (!m || !TWIN_ACTIVE_STATUSES.has(m.status)) continue;
2899
+ if (normalizedObjective(m.objective) !== wantObjective) continue;
2900
+ if (String(m.owner || '').trim().toLowerCase() !== wantOwner) continue;
2901
+ return m;
2902
+ }
2903
+ return null;
2904
+ }
2905
+
2781
2906
  function startMission(args) {
2782
2907
  const asJson = wantsJson(args);
2908
+ const firstArg = String(args[0] || '').trim().toLowerCase();
2909
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || firstArg === 'help') {
2910
+ console.log('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--runner manual|claude|atris2|codex_goal]');
2911
+ console.log('Run `atris mission --help` for the full option list.');
2912
+ process.exit(0);
2913
+ }
2783
2914
  const mission = missionFromArgs(args);
2915
+ // A flag-looking or empty objective is a typo, not a mission.
2916
+ const rawObjective = String(mission.objective || '').trim();
2917
+ if (!rawObjective || rawObjective.startsWith('-')) {
2918
+ exitMissionError(`mission start needs a quoted objective (got ${rawObjective ? `"${rawObjective}"` : 'nothing'}). Usage: atris mission start "<objective>" --owner <member>`, 1, asJson);
2919
+ }
2920
+ // Pasting a mission id where an objective goes is an id mismatch, not a new
2921
+ // mission: recover the existing record or explain where it actually lives.
2922
+ const idLikeObjective = String(mission.objective || '').trim();
2923
+ if (/^mission-\d{4}-\d{2}-\d{2}-/.test(idLikeObjective)) {
2924
+ const existing = resolveMission(idLikeObjective);
2925
+ if (existing) {
2926
+ printJsonOrText(
2927
+ { ok: true, action: 'mission_recovered', recovered: true, mission: existing, note: 'objective looked like a mission id; recovered the existing mission instead of creating a new one' },
2928
+ [
2929
+ `That's a mission id, not an objective — recovered ${existing.id} (${existing.status}).`,
2930
+ `Resume: atris mission run ${existing.id}`,
2931
+ ],
2932
+ asJson,
2933
+ );
2934
+ return;
2935
+ }
2936
+ exitMissingMission(idLikeObjective, 1, asJson);
2937
+ }
2938
+ if (!hasFlag(args, '--duplicate')) {
2939
+ const twin = findActiveTwinMission(mission.objective, mission.owner);
2940
+ if (twin) {
2941
+ printJsonOrText(
2942
+ { ok: true, action: 'mission_reused', reused: true, mission: twin, note: 'an active mission with this objective and owner already exists; resumed instead of cloning (pass --duplicate to force a second one)' },
2943
+ [
2944
+ `Already active: ${twin.id} (${twin.status})`,
2945
+ `Same objective, same owner — reusing it instead of starting a clone.`,
2946
+ `Resume: atris mission run ${twin.id}`,
2947
+ `Really want a second one: re-run with --duplicate`,
2948
+ ],
2949
+ asJson,
2950
+ );
2951
+ return;
2952
+ }
2953
+ }
2784
2954
  // --worktree: bind the mission to its own isolated checkout. We chdir before
2785
2955
  // any state writes so the mission record, baseline sidecar, receipts, and
2786
2956
  // member files all land inside the worktree — ticks run there, and the main
@@ -2805,7 +2975,7 @@ function startMission(args) {
2805
2975
  mission.next_action = `work task then run: atris task current-step --goal-id ${mission.id} --as ${mission.owner} --proof "<proof>" --json`;
2806
2976
  }
2807
2977
  }
2808
- const warnings = [missingVerifierWarning(mission)].filter(Boolean);
2978
+ const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
2809
2979
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
2810
2980
  const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective });
2811
2981
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
@@ -2915,7 +3085,7 @@ function startMissionFromRunObjective(objective, args) {
2915
3085
  mission.xp_task = xpTask;
2916
3086
  mission.task_ids = Array.from(new Set([...(mission.task_ids || []), xpTask.task_id]));
2917
3087
  }
2918
- const warnings = [missingVerifierWarning(mission)].filter(Boolean);
3088
+ const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
2919
3089
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
2920
3090
  const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective, source: 'mission_run_objective' });
2921
3091
  const directGoalRequest = writeDirectRunCodexGoalRequest(saved, process.cwd());
@@ -2934,6 +3104,7 @@ function startMissionFromRunObjective(objective, args) {
2934
3104
  const nativeGoalOptions = {
2935
3105
  ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
2936
3106
  ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
3107
+ ...(hasFlag(args, '--manual-ack') ? { manualAck: true } : {}),
2937
3108
  ...(hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal') ? { allowNativeGoalSupersede: true } : {}),
2938
3109
  };
2939
3110
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
@@ -2943,6 +3114,7 @@ function startMissionFromRunObjective(objective, args) {
2943
3114
  const codexGoalState = runnerUsesCallerSession(saved.runner)
2944
3115
  ? refreshCodexGoalController(process.cwd(), { missionId: saved.id, ...nativeGoalOptions })
2945
3116
  : null;
3117
+ const outputMission = resolveMission(saved.id, process.cwd()) || saved;
2946
3118
  const nativeGoal = codexGoalState?.native_goal_action
2947
3119
  || (codexGoalState?.goal?.requires_native_goal_start ? codexGoalState.goal.native_goal_action : null);
2948
3120
  const nextCommand = codexGoalState?.next_command || codexGoalState?.goal?.next_command || atrisGoalState.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`;
@@ -2950,7 +3122,7 @@ function startMissionFromRunObjective(objective, args) {
2950
3122
  {
2951
3123
  ok: true,
2952
3124
  action: 'mission_run_started',
2953
- mission: saved,
3125
+ mission: outputMission,
2954
3126
  budget_contract: saved.budget_contract || null,
2955
3127
  warnings,
2956
3128
  state_path: statePaths().missionsJsonl,
@@ -2972,7 +3144,7 @@ function startMissionFromRunObjective(objective, args) {
2972
3144
  native_goal_ack_command: codexGoalState?.goal?.native_goal_ack_command || codexGoalState?.active_goal_conflict?.commands?.ack_new_mission || null,
2973
3145
  next_command: nextCommand,
2974
3146
  },
2975
- missionRunTakeoffLines(saved, { warnings, nextCommand }),
3147
+ missionRunTakeoffLines(outputMission, { warnings, nextCommand }),
2976
3148
  asJson,
2977
3149
  );
2978
3150
  }
@@ -3107,6 +3279,74 @@ function attachMissionTask(args) {
3107
3279
  }
3108
3280
  }
3109
3281
 
3282
+ function setMissionRunner(args) {
3283
+ const asJson = wantsJson(args);
3284
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
3285
+ console.log('Usage: atris mission set-runner <id> <runner|engine> [--model <id>] [--json]');
3286
+ console.log(knownMissionRunnerText());
3287
+ return;
3288
+ }
3289
+ const positionals = stripKnownFlags(args, ['--model'], ['--json']);
3290
+ const ref = String(positionals[0] || '').trim();
3291
+ const runnerArg = String(positionals[1] || '').trim();
3292
+ if (!ref || !runnerArg) {
3293
+ exitMissionError(`Usage: atris mission set-runner <id> <runner|engine> [--model <id>]. ${knownMissionRunnerText()}.`, 1, asJson);
3294
+ }
3295
+
3296
+ const selection = resolveMissionRunnerSelection(runnerArg, { asJson, label: 'runner' });
3297
+ const model = readFlag(args, '--model', '');
3298
+ let mission = resolveMission(ref);
3299
+ if (!mission) exitMissingMission(ref, 1, asJson);
3300
+
3301
+ const lock = acquireMissionLock(mission.id);
3302
+ if (!lock.ok) {
3303
+ exitMissionError(`[mission set-runner] lock busy (held by pid ${lock.holder?.pid || '?'} since ${lock.holder?.started_at || '?'}). Exit.`, 3, asJson);
3304
+ }
3305
+
3306
+ try {
3307
+ mission = resolveMission(mission.id) || mission;
3308
+ const previous = { runner: mission.runner || null, model: mission.model || null };
3309
+ const nextMission = {
3310
+ ...mission,
3311
+ runner: selection.runner,
3312
+ next_action: mission.next_action || `run: atris mission run ${mission.id}`,
3313
+ };
3314
+ delete nextMission.model;
3315
+ Object.assign(nextMission, runnerModelPatch(selection.runner, model));
3316
+
3317
+ const { mission: saved, event } = saveMission(
3318
+ nextMission,
3319
+ process.cwd(),
3320
+ 'mission_runner_changed',
3321
+ {
3322
+ previous_runner: previous.runner,
3323
+ previous_model: previous.model,
3324
+ runner: nextMission.runner,
3325
+ requested_runner: selection.requested,
3326
+ kind: selection.kind,
3327
+ model: nextMission.model || null,
3328
+ },
3329
+ );
3330
+ const logPath = appendMemberLog(saved.owner, 'Mission runner changed', {
3331
+ mission: saved.objective,
3332
+ previous_runner: previous.runner || undefined,
3333
+ runner: saved.runner,
3334
+ model: saved.model || undefined,
3335
+ });
3336
+ printJsonOrText(
3337
+ { ok: true, action: 'mission_runner_changed', mission: saved, event, log_path: logPath },
3338
+ [
3339
+ `Mission runner changed: ${saved.id}`,
3340
+ `Runner: ${previous.runner || 'none'} -> ${saved.runner}${saved.model ? ` (${saved.model})` : ''}`,
3341
+ `Next: atris mission run ${saved.id}`,
3342
+ ],
3343
+ asJson,
3344
+ );
3345
+ } finally {
3346
+ releaseMissionLock(lock);
3347
+ }
3348
+ }
3349
+
3110
3350
  function statusMission(args) {
3111
3351
  const asJson = wantsJson(args);
3112
3352
  const localOnly = hasFlag(args, '--local');
@@ -3986,6 +4226,12 @@ function missionReceiptHealth(mission, root = process.cwd()) {
3986
4226
  return { ok: true, reason: 'verifier_passed', receipt_path: receiptPath };
3987
4227
  }
3988
4228
 
4229
+ function missionIsDriveParked(mission) {
4230
+ if (String(mission?.status || '').toLowerCase() !== 'paused') return false;
4231
+ const reason = String(mission?.stop_reason || '').toLowerCase();
4232
+ return reason.startsWith('drive:') && reason.includes('auto-parked');
4233
+ }
4234
+
3989
4235
  function collectMissionDoctorFindings(root = process.cwd(), options = {}) {
3990
4236
  const localOnly = options.localOnly === true;
3991
4237
  let missions = listMissions(root);
@@ -4017,7 +4263,10 @@ function collectMissionDoctorFindings(root = process.cwd(), options = {}) {
4017
4263
  const status = String(mission.status || '');
4018
4264
  const active = !TERMINAL_STATUSES.has(status);
4019
4265
  const objective = String(mission.objective || '').trim();
4020
- if (active && !effectiveMissionVerifier(mission)) {
4266
+ // Only drive's explicit auto-park reason settles a no-verifier mission.
4267
+ // Other paused no-verifier missions still need a verifier or a deliberate
4268
+ // park, otherwise doctor and drive silently disagree about stale work.
4269
+ if (active && !missionIsDriveParked(mission) && !effectiveMissionVerifier(mission)) {
4021
4270
  add(
4022
4271
  mission,
4023
4272
  'missing_verifier',
@@ -4187,6 +4436,12 @@ function resolveVerifierCommand(command) {
4187
4436
  return `${leading}${shellQuote(process.execPath)} ${shellQuote(cliPath)}${trimmed.slice('atris'.length)}`;
4188
4437
  }
4189
4438
 
4439
+ function missionVerifierTimeoutMs(env = process.env) {
4440
+ const parsed = Number(env.ATRIS_MISSION_VERIFIER_TIMEOUT_MS);
4441
+ if (Number.isFinite(parsed) && parsed >= 1000) return Math.floor(parsed);
4442
+ return 120000;
4443
+ }
4444
+
4190
4445
  function runVerifier(command, root = process.cwd()) {
4191
4446
  if (!command) return null;
4192
4447
  const resolvedCommand = resolveVerifierCommand(command);
@@ -4194,7 +4449,7 @@ function runVerifier(command, root = process.cwd()) {
4194
4449
  cwd: root,
4195
4450
  shell: true,
4196
4451
  encoding: 'utf8',
4197
- timeout: 120000,
4452
+ timeout: missionVerifierTimeoutMs(),
4198
4453
  env: process.env,
4199
4454
  });
4200
4455
  return {
@@ -4692,10 +4947,55 @@ function codexRuntimeStateBlocksMissionSlot(runtimeGoalState, mission) {
4692
4947
  return runtimeGoalState.objective === codexGoalObjective(mission);
4693
4948
  }
4694
4949
 
4950
+ function codexRuntimeStateCanAutoAckMission(runtimeGoalState, mission) {
4951
+ if (!runtimeGoalState || !mission) return false;
4952
+ if (runtimeGoalState.status !== 'active') return false;
4953
+ if (!runtimeGoalState.objective) return false;
4954
+ return codexRuntimeStateBlocksMissionSlot(runtimeGoalState, mission);
4955
+ }
4956
+
4695
4957
  function codexGoalAckCommand(mission, objective = codexGoalObjective(mission)) {
4696
4958
  return `atris mission goal ack ${mission.id} --runtime codex --status active --objective ${shellQuote(objective)} --json`;
4697
4959
  }
4698
4960
 
4961
+ function recordCodexNativeGoalAck(mission, root = process.cwd(), options = {}) {
4962
+ const canonicalObjective = codexGoalObjective(mission);
4963
+ const reportedObjective = String(options.reportedObjective || canonicalObjective).trim();
4964
+ const ack = {
4965
+ runtime: 'codex',
4966
+ status: 'active',
4967
+ mission_id: mission.id,
4968
+ objective: canonicalObjective,
4969
+ ...(reportedObjective && reportedObjective !== canonicalObjective ? { reported_objective: reportedObjective } : {}),
4970
+ acknowledged_at: stampIso(),
4971
+ };
4972
+ const nextMission = {
4973
+ ...mission,
4974
+ native_goal_ack: ack,
4975
+ next_action: codexGoalNextCommand({ ...mission, native_goal_ack: ack }),
4976
+ };
4977
+ const supersededNativeGoalAcks = supersedeOtherCodexNativeGoalAcks(root, nextMission, ack);
4978
+ const { mission: saved } = saveMission(
4979
+ nextMission,
4980
+ root,
4981
+ options.eventName || 'mission_native_goal_ack',
4982
+ { ack, ...(options.eventDetail || {}) },
4983
+ );
4984
+ return { saved, ack, supersededNativeGoalAcks };
4985
+ }
4986
+
4987
+ function maybeAutoAckCodexNativeGoal(mission, root = process.cwd(), options = {}) {
4988
+ if (options.manualAck === true) return null;
4989
+ if (!isCodexGoalMission(mission) || codexNativeGoalAck(mission)) return null;
4990
+ const runtimeGoalState = codexRuntimeGoalStateFromOptions(options);
4991
+ if (!codexRuntimeStateCanAutoAckMission(runtimeGoalState, mission)) return null;
4992
+ return recordCodexNativeGoalAck(mission, root, {
4993
+ reportedObjective: runtimeGoalState.objective,
4994
+ eventName: 'mission_native_goal_auto_ack',
4995
+ eventDetail: { runtime_goal_state: runtimeGoalState },
4996
+ });
4997
+ }
4998
+
4699
4999
  function codexNativeGoalReplaceAction(newMission, activeMission, runtimeGoalState = null, commands = {}) {
4700
5000
  const fromObjective = codexGoalObjective(activeMission);
4701
5001
  const toObjective = codexGoalObjective(newMission);
@@ -4966,16 +5266,18 @@ function codexNativeGoalBlockPayload(mission) {
4966
5266
  };
4967
5267
  }
4968
5268
 
4969
- function maybeBlockUntilCodexNativeGoalStarted(mission, asJson) {
5269
+ function maybeBlockUntilCodexNativeGoalStarted(mission, asJson, options = {}) {
4970
5270
  if (!isCodexGoalMission(mission) || codexNativeGoalAck(mission)) return;
5271
+ if (options.manualAck === false) return;
4971
5272
  const payload = codexNativeGoalBlockPayload(mission);
4972
5273
  if (asJson) console.log(JSON.stringify(payload, null, 2));
4973
5274
  else console.error(payload.next_action);
4974
5275
  process.exit(2);
4975
5276
  }
4976
5277
 
4977
- function returnIfCodexNativeGoalNotStarted(mission, asJson) {
5278
+ function returnIfCodexNativeGoalNotStarted(mission, asJson, options = {}) {
4978
5279
  if (!isCodexGoalMission(mission) || codexNativeGoalAck(mission)) return false;
5280
+ if (options.manualAck === false) return false;
4979
5281
  const payload = codexNativeGoalBlockPayload(mission);
4980
5282
  if (asJson) console.log(JSON.stringify(payload, null, 2));
4981
5283
  else console.error(payload.next_action);
@@ -5162,7 +5464,10 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
5162
5464
  };
5163
5465
  }
5164
5466
 
5165
- const { mission, reason, direct_goal_request: directGoalRequest, seeded_continuation_goal: seededContinuationGoal } = selected;
5467
+ let { mission } = selected;
5468
+ const { reason, direct_goal_request: directGoalRequest, seeded_continuation_goal: seededContinuationGoal } = selected;
5469
+ const autoNativeGoalAck = maybeAutoAckCodexNativeGoal(mission, root, options);
5470
+ if (autoNativeGoalAck) mission = autoNativeGoalAck.saved;
5166
5471
  const taskSpine = missionTaskSpine(mission);
5167
5472
  const missionView = missionStatusView(mission);
5168
5473
  const objective = codexGoalObjective(mission);
@@ -5198,6 +5503,10 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
5198
5503
  native_goal_action: nativeGoalAction,
5199
5504
  native_goal_ack_command: codexGoalAckCommand(mission, objective),
5200
5505
  native_goal_ack: ack,
5506
+ auto_native_goal_ack: autoNativeGoalAck ? {
5507
+ native_goal_ack: autoNativeGoalAck.ack,
5508
+ superseded_native_goal_acks: autoNativeGoalAck.supersededNativeGoalAcks,
5509
+ } : null,
5201
5510
  direct_goal_request: directGoalRequest || null,
5202
5511
  seeded_continuation_goal: seededContinuationGoal || null,
5203
5512
  next_action_preview: missionChoosesNextMission(mission) ? chooseNextMissionPreview(mission, root) : (mission.next_action_preview || null),
@@ -5213,6 +5522,7 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
5213
5522
  requires_native_goal_start: goal.requires_native_goal_start,
5214
5523
  requires_native_goal_replace: goal.requires_native_goal_replace,
5215
5524
  native_goal_action: goal.native_goal_action,
5525
+ auto_native_goal_ack: goal.auto_native_goal_ack,
5216
5526
  runtime_goal_state: runtimeGoalState,
5217
5527
  };
5218
5528
  }
@@ -5517,6 +5827,25 @@ function consecutiveSameReasonErrors(ticks) {
5517
5827
  return { reason: last.reason, count };
5518
5828
  }
5519
5829
 
5830
+ function isTransientAtris2BackendError(error) {
5831
+ const text = String(error || '');
5832
+ if (!text) return false;
5833
+ return /\b(?:ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE)\b/i.test(text)
5834
+ || /\b(?:socket hang up|request timeout|no response headers|stream stalled)\b/i.test(text)
5835
+ || /\bHTTP\s+(?:502|503|504|522|523|524)\b/i.test(text)
5836
+ // A stopped AtrisOS computer answers 409 "Computer must be running": the
5837
+ // backend will come back, so the mission keeps waiting instead of pausing.
5838
+ || (/\bHTTP\s+409\b/i.test(text) && /computer must be running/i.test(text));
5839
+ }
5840
+
5841
+ function atris2TurnErrorReason(error) {
5842
+ return isTransientAtris2BackendError(error) ? 'atris2-backend-unavailable' : 'atris2-error';
5843
+ }
5844
+
5845
+ function missionRunKeepsRetryingError(reason) {
5846
+ return reason === 'atris2-backend-unavailable';
5847
+ }
5848
+
5520
5849
  function isWithinActiveHours(activeHours, now = new Date()) {
5521
5850
  if (!activeHours || !activeHours.start || !activeHours.end) return true;
5522
5851
  const tz = activeHours.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@@ -5573,6 +5902,11 @@ function releaseMissionLock(lock) {
5573
5902
 
5574
5903
  function probeClaudeBinary() {
5575
5904
  const runnerBin = resolveClaudeRunnerBin();
5905
+ if (resolveClaudeRunnerCommandTemplate()) {
5906
+ const probe = spawnSync('sh', ['-c', `command -v ${shellQuote(runnerBin)}`], { encoding: 'utf8', timeout: 8000 });
5907
+ if (probe.status !== 0) return { ok: false, error: `${runnerBin} CLI not found` };
5908
+ return { ok: true };
5909
+ }
5576
5910
  const help = spawnSync(runnerBin, ['--help'], { encoding: 'utf8', timeout: 8000 });
5577
5911
  if (help.status !== 0) return { ok: false, error: `${runnerBin} --help failed` };
5578
5912
  const text = String(help.stdout || '');
@@ -5679,7 +6013,91 @@ function missionPauseNextAction(pauseReason, missionId, deadModel = null, lastEr
5679
6013
  return `resume with: atris mission run ${missionId}`;
5680
6014
  }
5681
6015
 
6016
+ function writeRunnerPromptFile(cwd, missionId, prompt) {
6017
+ const dir = path.join(cwd, '.atris', 'state', 'runner-prompts');
6018
+ fs.mkdirSync(dir, { recursive: true });
6019
+ const file = path.join(dir, `${missionId || 'mission'}-${Date.now()}-${crypto.randomUUID()}.md`);
6020
+ fs.writeFileSync(file, prompt, 'utf8');
6021
+ return file;
6022
+ }
6023
+
6024
+ function spawnGenericRunnerTick(mission, opts) {
6025
+ const { sessionId, cwd, signal, timeoutMs, prompt, model } = opts;
6026
+ return new Promise((resolve) => {
6027
+ let promptFile = null;
6028
+ let cmd = '';
6029
+ try {
6030
+ promptFile = writeRunnerPromptFile(cwd, mission.id, prompt);
6031
+ cmd = buildRunnerCommand({ promptFile, model });
6032
+ } catch (e) {
6033
+ resolve({ ok: false, error: e.message, sessionIds: [], aborted: false, timedOut: false, authExpired: false });
6034
+ return;
6035
+ }
6036
+
6037
+ const startedAt = Date.now();
6038
+ const proc = spawn('sh', ['-lc', cmd], { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
6039
+ let stdout = '';
6040
+ let stderr = '';
6041
+ let timedOut = false;
6042
+ let aborted = false;
6043
+
6044
+ const cleanupPrompt = () => {
6045
+ try { if (promptFile) fs.unlinkSync(promptFile); } catch {}
6046
+ };
6047
+ const kill = () => {
6048
+ const killGroup = (sig) => {
6049
+ try { process.kill(-proc.pid, sig); } catch { try { proc.kill(sig); } catch {} }
6050
+ };
6051
+ killGroup('SIGTERM');
6052
+ setTimeout(() => killGroup('SIGKILL'), 3000).unref();
6053
+ };
6054
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
6055
+ const onAbort = () => { aborted = true; kill(); };
6056
+ if (signal) {
6057
+ if (signal.aborted) onAbort();
6058
+ else signal.addEventListener('abort', onAbort, { once: true });
6059
+ }
6060
+
6061
+ proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
6062
+ proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
6063
+
6064
+ proc.on('close', (code) => {
6065
+ clearTimeout(timer);
6066
+ cleanupPrompt();
6067
+ if (signal) signal.removeEventListener?.('abort', onAbort);
6068
+ const finalText = String(stdout || '').trim();
6069
+ const errStr = String(stderr || '').slice(-2000);
6070
+ const ok = code === 0 && !timedOut && !aborted;
6071
+ const authExpired = /not authenticated|please log in|login required|auth(?:entication)? expired/i.test(errStr);
6072
+ resolve({
6073
+ ok,
6074
+ timedOut,
6075
+ aborted,
6076
+ authExpired,
6077
+ exitCode: code,
6078
+ sessionIds: sessionId ? [sessionId] : [],
6079
+ result: finalText,
6080
+ summary: usefulClaudeReceiptSummary(finalText || errStr, ok ? 'no-text' : 'error'),
6081
+ receipt_text: cappedClaudeReceiptText(finalText || errStr),
6082
+ duration_total_ms: Date.now() - startedAt,
6083
+ num_turns: null,
6084
+ stop_reason: null,
6085
+ stderr: errStr,
6086
+ parse_errors: 0,
6087
+ });
6088
+ });
6089
+
6090
+ proc.on('error', (e) => {
6091
+ clearTimeout(timer);
6092
+ cleanupPrompt();
6093
+ if (signal) signal.removeEventListener?.('abort', onAbort);
6094
+ resolve({ ok: false, error: e.message, sessionIds: [], aborted, timedOut, authExpired: false });
6095
+ });
6096
+ });
6097
+ }
6098
+
5682
6099
  function spawnClaudeTick(mission, opts) {
6100
+ if (resolveClaudeRunnerCommandTemplate()) return spawnGenericRunnerTick(mission, opts);
5683
6101
  const { sessionMode, sessionId, cwd, signal, timeoutMs, prompt, model } = opts;
5684
6102
  return new Promise((resolve) => {
5685
6103
  const args = [
@@ -5830,10 +6248,14 @@ async function runMission(args) {
5830
6248
  if (hasFlag(args, '--fleet')) {
5831
6249
  const { runFleetFlight } = require('../lib/fleet');
5832
6250
  const slots = Math.max(1, Number(readFlag(args, '--slots', '')) || 3);
6251
+ // Default: cut build worktrees from origin/master (checkoutBase). --base
6252
+ // keeps launcher-HEAD only when the operator asks for it explicitly.
6253
+ const baseOverride = readFlag(args, '--base', '');
5833
6254
  const flight = await runFleetFlight({
5834
6255
  slots,
5835
6256
  dryRun: hasFlag(args, '--dry-run'),
5836
6257
  log: asJson ? () => {} : console.log,
6258
+ ...(baseOverride ? { checkoutBase: baseOverride } : {}),
5837
6259
  });
5838
6260
  if (asJson) console.log(JSON.stringify(flight, null, 2));
5839
6261
  process.exitCode = flight.paused && flight.paused.length > 0 ? 1 : 0;
@@ -5850,6 +6272,12 @@ async function runMission(args) {
5850
6272
  const maxWallFlag = readFlag(args, '--max-wall', '');
5851
6273
  let maxWallSeconds = Math.max(60, Number(maxWallFlag) || MISSION_RUN_DEFAULTS.maxWallSeconds);
5852
6274
  const cadenceOverride = readFlag(args, '--cadence', '');
6275
+ const engineOverrideRaw = readFlag(args, '--engine', '');
6276
+ const runnerOverride = engineOverrideRaw
6277
+ ? resolveMissionRunnerSelection(engineOverrideRaw, { asJson, engineOnly: true, label: 'engine' })
6278
+ : null;
6279
+ const modelOverride = readFlag(args, '--model', '');
6280
+ const runtimeView = (baseMission) => missionRunRuntimeView(baseMission, runnerOverride, modelOverride);
5853
6281
  const input = missionRunInputFromArgs(args);
5854
6282
  const ref = input.ref;
5855
6283
  const runArgs = input.args;
@@ -5877,7 +6305,8 @@ async function runMission(args) {
5877
6305
  return;
5878
6306
  }
5879
6307
  if (!mission) {
5880
- exitMissionError(ref ? `Mission "${ref}" not found.` : 'Usage: atris mission run <id|objective> [--max-ticks 4] [--max-wall 3600]', 1, asJson);
6308
+ if (ref) exitMissingMission(ref, 1, asJson);
6309
+ exitMissionError('Usage: atris mission run <id|objective> [--max-ticks 4] [--max-wall 3600]', 1, asJson);
5881
6310
  }
5882
6311
  if (!maxWallFlag && Number(mission.budget_contract?.requested_seconds) > 0) {
5883
6312
  maxWallSeconds = Math.max(60, Number(mission.budget_contract.requested_seconds));
@@ -5895,17 +6324,22 @@ async function runMission(args) {
5895
6324
  process.exit(0);
5896
6325
  }
5897
6326
 
5898
- maybeBlockUntilCodexNativeGoalStarted(mission, asJson);
6327
+ const manualAck = hasFlag(args, '--manual-ack');
6328
+ const nativeGoalStatus = readFlag(args, '--native-goal-status', readFlag(args, '--visible-goal-status', ''));
6329
+ const nativeGoalObjective = readFlag(args, '--native-goal-objective', readFlag(args, '--visible-goal-objective', ''));
6330
+ const nativeGoalRunOptions = {
6331
+ ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
6332
+ ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
6333
+ ...(manualAck ? { manualAck: true } : {}),
6334
+ };
6335
+ const autoAck = maybeAutoAckCodexNativeGoal(mission, process.cwd(), nativeGoalRunOptions);
6336
+ if (autoAck) mission = autoAck.saved;
6337
+ maybeBlockUntilCodexNativeGoalStarted(runtimeView(mission), asJson, { manualAck });
5899
6338
 
5900
- const preLockRunner = String(mission.runner || '').trim().toLowerCase();
5901
- const preLockCallerSession = runnerUsesCallerSession(mission.runner);
5902
- if (!skipClaude && !preLockCallerSession && preLockRunner !== 'atris2') {
5903
- const probe = probeClaudeBinary();
5904
- if (!probe.ok) {
5905
- console.error(`[mission run] claude probe failed: ${probe.error}`);
5906
- process.exit(2);
5907
- }
5908
- }
6339
+ // No pre-lock claude probe here: the runner profile isn't applied yet, so
6340
+ // probing would test the default claude bin even when the effective runner
6341
+ // (e.g. --engine cursor) never invokes claude. The in-lock probe below runs
6342
+ // after applyMissionRunnerProfile and checks the right binary.
5909
6343
 
5910
6344
  const lock = acquireMissionLock(mission.id);
5911
6345
  if (!lock.ok) {
@@ -5920,6 +6354,7 @@ async function runMission(args) {
5920
6354
  let ranTicks = 0;
5921
6355
  const ticks = [];
5922
6356
  let onSig = null;
6357
+ let restoreRunnerProfile = null;
5923
6358
 
5924
6359
  try {
5925
6360
  const cwd = process.cwd();
@@ -5933,11 +6368,12 @@ async function runMission(args) {
5933
6368
  // Derive sessionId, pendingSessionId, and the frozen contract from the fresh record
5934
6369
  // so a fast tick's writes can't be silently overwritten by this run loop.
5935
6370
  mission = resolveMission(mission.id) || mission;
6371
+ let runtimeMission = runtimeView(mission);
5936
6372
  if (['complete', 'stopped'].includes(mission.status)) {
5937
6373
  console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
5938
6374
  return;
5939
6375
  }
5940
- if (returnIfCodexNativeGoalNotStarted(mission, asJson)) return;
6376
+ if (returnIfCodexNativeGoalNotStarted(runtimeMission, asJson, { manualAck })) return;
5941
6377
  if (mission.status === 'paused') {
5942
6378
  mission = saveMission({
5943
6379
  ...mission,
@@ -5947,17 +6383,29 @@ async function runMission(args) {
5947
6383
  stop_reason: null,
5948
6384
  next_action: `running: atris mission run ${mission.id}`,
5949
6385
  }, cwd, 'mission_run_resumed', { reason: 'operator-resume' }).mission;
6386
+ runtimeMission = runtimeView(mission);
5950
6387
  }
5951
6388
  sessionId = mission.claude_session_id || null;
5952
6389
  pendingSessionId = mission.pending_session_id || null;
5953
- const callerSessionRunner = runnerUsesCallerSession(mission.runner);
5954
- const atris2Runner = String(mission.runner || '').trim().toLowerCase() === 'atris2';
6390
+ restoreRunnerProfile = applyMissionRunnerProfile(runtimeMission.runner);
6391
+ const callerSessionRunner = runnerUsesCallerSession(runtimeMission.runner);
6392
+ const atris2Runner = String(runtimeMission.runner || '').trim().toLowerCase() === 'atris2';
5955
6393
  const skipWorker = skipClaude || callerSessionRunner;
6394
+ if (!skipClaude && !callerSessionRunner && !atris2Runner) {
6395
+ const probe = probeClaudeBinary();
6396
+ if (!probe.ok) {
6397
+ console.error(`[mission run] claude probe failed: ${probe.error}`);
6398
+ process.exit(2);
6399
+ }
6400
+ }
5956
6401
 
5957
6402
  // Freeze run-start contract (verifier, lane). Stored on receipts, not the mission record.
5958
6403
  const frozen = {
5959
6404
  verifier: effectiveMissionVerifier(mission),
5960
6405
  lane: mission.lane || 'workspace',
6406
+ runner: runtimeMission.runner || 'manual',
6407
+ model: runtimeMission.model || null,
6408
+ ...(runnerOverride ? { stored_runner: mission.runner || null, stored_model: mission.model || null } : {}),
5961
6409
  started_at: stampIso(),
5962
6410
  };
5963
6411
  const runWorktreeBefore = gitWorktreeSnapshot(cwd);
@@ -5972,6 +6420,7 @@ async function runMission(args) {
5972
6420
  if (!skipWorker && !atris2Runner && !sessionId && !pendingSessionId) {
5973
6421
  pendingSessionId = crypto.randomUUID();
5974
6422
  mission = saveMission({ ...mission, pending_session_id: pendingSessionId }, cwd, 'mission_session_pending', { session_id: pendingSessionId }).mission;
6423
+ runtimeMission = runtimeView(mission);
5975
6424
  }
5976
6425
 
5977
6426
  const startedAt = Date.now();
@@ -5982,7 +6431,7 @@ async function runMission(args) {
5982
6431
  const sessionLabel = skipWorker
5983
6432
  ? 'caller-session'
5984
6433
  : atris2Runner
5985
- ? `atris2 (${mission.model || 'atris:fast'})`
6434
+ ? `atris2 (${runtimeMission.model || 'atris:fast'})`
5986
6435
  : (sessionId || `pending=${pendingSessionId}`);
5987
6436
  if (!asJson) {
5988
6437
  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}`);
@@ -5996,6 +6445,7 @@ async function runMission(args) {
5996
6445
 
5997
6446
  // Re-read mission, detect mutation of frozen fields
5998
6447
  mission = resolveMission(mission.id) || mission;
6448
+ runtimeMission = runtimeView(mission);
5999
6449
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
6000
6450
  if (effectiveMissionVerifier(mission) !== frozen.verifier) { pauseReason = 'verifier-mutated'; break; }
6001
6451
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
@@ -6027,12 +6477,13 @@ async function runMission(args) {
6027
6477
  } else if (atris2Runner) {
6028
6478
  const pingDrain = consumeMissionPings(mission, cwd);
6029
6479
  mission = pingDrain.mission;
6030
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
6480
+ runtimeMission = runtimeView(mission);
6481
+ const prompt = buildTickPrompt(runtimeMission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
6031
6482
  const { runAtris2Turn } = require('./probe');
6032
- const businessId = businessIdForAtris2Mission(mission, cwd);
6483
+ const businessId = businessIdForAtris2Mission(runtimeMission, cwd);
6033
6484
  const turn = await runAtris2Turn({
6034
6485
  prompt,
6035
- model: mission.model || 'atris:fast',
6486
+ model: runtimeMission.model || 'atris:fast',
6036
6487
  business: businessId,
6037
6488
  maxTurns: 16,
6038
6489
  signal: controller.signal,
@@ -6040,17 +6491,18 @@ async function runMission(args) {
6040
6491
  result.atris2 = {
6041
6492
  ok: turn.ok,
6042
6493
  engine: turn.engine,
6043
- model: mission.model || 'atris:fast',
6494
+ model: runtimeMission.model || 'atris:fast',
6044
6495
  tools_run: turn.tools_run,
6045
6496
  unsupported: turn.unsupported,
6046
6497
  duration_ms: turn.duration_ms,
6047
6498
  error: turn.error,
6499
+ backend_unavailable: isTransientAtris2BackendError(turn.error) || undefined,
6048
6500
  receipt_text: String(turn.text || '').slice(0, 4000),
6049
6501
  };
6050
6502
  if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
6051
6503
  if (turn.error === 'not-logged-in') { pauseReason = 'auth-required'; break; }
6052
6504
  if (!turn.ok || !String(turn.text || '').trim()) {
6053
- result = { ...result, status: 'errored', reason: 'atris2-error' };
6505
+ result = { ...result, status: 'errored', reason: atris2TurnErrorReason(turn.error) };
6054
6506
  } else {
6055
6507
  result = { ...result, status: 'ran', reason: 'tick-ok', ran: true };
6056
6508
  }
@@ -6059,11 +6511,12 @@ async function runMission(args) {
6059
6511
  const useId = sessionId || pendingSessionId;
6060
6512
  const pingDrain = consumeMissionPings(mission, cwd);
6061
6513
  mission = pingDrain.mission;
6062
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
6063
- const claudeResult = await spawnClaudeTick(mission, {
6514
+ runtimeMission = runtimeView(mission);
6515
+ const prompt = buildTickPrompt(runtimeMission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
6516
+ const claudeResult = await spawnClaudeTick(runtimeMission, {
6064
6517
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
6065
6518
  timeoutMs: MISSION_RUN_DEFAULTS.claudeTimeoutMs, prompt,
6066
- model: resolveClaudeRunnerModel(mission),
6519
+ model: resolveClaudeRunnerModel(runtimeMission),
6067
6520
  });
6068
6521
  result.claude = {
6069
6522
  ok: claudeResult.ok,
@@ -6159,7 +6612,7 @@ async function runMission(args) {
6159
6612
  const finishedAt = stampIso();
6160
6613
  const tickRecord = { ...result, started_at: tickStart, finished_at: finishedAt, worktree: tickWorktree };
6161
6614
  ticks.push(tickRecord);
6162
- receiptPath = writeReceipt(mission, {
6615
+ receiptPath = writeReceipt(runtimeMission, {
6163
6616
  kind: 'mission_run_tick',
6164
6617
  tick: tickRecord,
6165
6618
  frozen,
@@ -6271,7 +6724,9 @@ async function runMission(args) {
6271
6724
  // is the same trap one step less deterministic: keep retrying and the loop burns every
6272
6725
  // tick + cron firing on it. Halt at two-in-a-row and surface the reason for a human.
6273
6726
  const errStreak = consecutiveSameReasonErrors(ticks);
6274
- if (errStreak.count >= 2) { pauseReason = `repeated-error:${errStreak.reason}`; break; }
6727
+ // Sleeping Atris2 backends are different: leave the mission running so the
6728
+ // next tick or heartbeat can catch the backend after it wakes.
6729
+ if (errStreak.count >= 2 && !missionRunKeepsRetryingError(errStreak.reason)) { pauseReason = `repeated-error:${errStreak.reason}`; break; }
6275
6730
 
6276
6731
  // Sleep until next tick
6277
6732
  let sleepMs = 0;
@@ -6294,7 +6749,7 @@ async function runMission(args) {
6294
6749
 
6295
6750
  if (!pauseReason && ticks.length >= effectiveMaxTicks) {
6296
6751
  const lastTick = ticks[ticks.length - 1];
6297
- if (lastTick && lastTick.status !== 'ran') pauseReason = 'max-ticks-reached';
6752
+ if (lastTick && lastTick.status !== 'ran' && !missionRunKeepsRetryingError(lastTick.reason)) pauseReason = 'max-ticks-reached';
6298
6753
  }
6299
6754
 
6300
6755
  if (pauseReason && !['complete', 'ready', 'max-wall-reached'].includes(pauseReason)) {
@@ -6322,7 +6777,8 @@ async function runMission(args) {
6322
6777
  next: missionRunCreatedNextLine(createdNext, continuationGoal, mission),
6323
6778
  };
6324
6779
  landingSummary.reason = missionHumanReasonText(mission, landingSummary.changed);
6325
- const finalReceipt = writeReceipt(mission, {
6780
+ const finalRuntimeMission = runtimeView(mission);
6781
+ const finalReceipt = writeReceipt(finalRuntimeMission, {
6326
6782
  kind: 'mission_run_summary',
6327
6783
  frozen,
6328
6784
  pause_reason: pauseReason,
@@ -6341,7 +6797,7 @@ async function runMission(args) {
6341
6797
  const codexGoalState = refreshCodexGoalController(cwd);
6342
6798
 
6343
6799
  printJsonOrText(
6344
- { ok: true, action: 'mission_run', mission, ran_ticks: ranTicks, tick_count: ticks.length, ticks, pause_reason: pauseReason, session_id: sessionId, summary_receipt: finalReceipt, budget_contract: mission.budget_contract || null, worktree: summaryWorktree, atris_goal_state: atrisGoalState, codex_goal_state: codexGoalState, continuation_goal: continuationGoal, created_next: createdNext },
6800
+ { ok: true, action: 'mission_run', mission, runner_override: runnerOverride ? finalRuntimeMission.run_runner_override : null, ran_ticks: ranTicks, tick_count: ticks.length, ticks, pause_reason: pauseReason, session_id: sessionId, summary_receipt: finalReceipt, budget_contract: mission.budget_contract || null, worktree: summaryWorktree, atris_goal_state: atrisGoalState, codex_goal_state: codexGoalState, continuation_goal: continuationGoal, created_next: createdNext },
6345
6801
  missionRunSummaryLines(mission, ranTicks, effectiveMaxTicks, finalReceipt, pauseReason, continuationGoal, ticks, createdNext),
6346
6802
  asJson,
6347
6803
  );
@@ -6350,12 +6806,20 @@ async function runMission(args) {
6350
6806
  try { process.removeListener('SIGINT', onSig); } catch {}
6351
6807
  try { process.removeListener('SIGTERM', onSig); } catch {}
6352
6808
  }
6809
+ if (restoreRunnerProfile) {
6810
+ try { restoreRunnerProfile(); } catch {}
6811
+ }
6353
6812
  releaseMissionLock(lock);
6354
6813
  }
6355
6814
  }
6356
6815
 
6357
6816
  function tickMission(args) {
6358
6817
  const asJson = wantsJson(args);
6818
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
6819
+ console.log('Usage: atris mission tick <id> [--verify] [--summary "..."] [--complete-on-pass]');
6820
+ console.log('Run `atris mission --help` for the full option list.');
6821
+ process.exit(0);
6822
+ }
6359
6823
  const verify = hasFlag(args, '--verify');
6360
6824
  const verifyOverride = readFlag(args, '--verify', '');
6361
6825
  const completeOnPass = hasFlag(args, '--complete-on-pass');
@@ -6691,6 +7155,60 @@ function stopMission(args) {
6691
7155
  );
6692
7156
  }
6693
7157
 
7158
+ // A mission parked in paused/planning/ready and untouched for a week is
7159
+ // abandoned in practice — nobody resumes it, and each one is a line the
7160
+ // operator re-reads forever. The daily autoland tick expires them to
7161
+ // stopped with a revive hint; running missions and anything touched
7162
+ // recently are never aged out, and a tick on an expired id revives it.
7163
+ const MISSION_IDLE_EXPIRY_DAYS = 7;
7164
+ const EXPIRABLE_STATUSES = new Set(['paused', 'planning', 'ready']);
7165
+
7166
+ function expireStaleMissions(root = process.cwd(), { idleDays = MISSION_IDLE_EXPIRY_DAYS, idleHours = null, dryRun = false, statuses = EXPIRABLE_STATUSES } = {}) {
7167
+ const windowMs = idleHours != null ? idleHours * 60 * 60 * 1000 : idleDays * 24 * 60 * 60 * 1000;
7168
+ const windowLabel = idleHours != null
7169
+ ? `${idleHours}+ idle hour${idleHours === 1 ? '' : 's'}`
7170
+ : `${idleDays}+ idle day${idleDays === 1 ? '' : 's'}`;
7171
+ const cutoff = Date.now() - windowMs;
7172
+ const expired = [];
7173
+ for (const mission of listMissions(root)) {
7174
+ const status = String(mission.status || '').toLowerCase();
7175
+ if (!statuses.has(status)) continue;
7176
+ // updated_at and paused_at are machine-polluted — status renders and
7177
+ // goal controllers re-save parked missions daily, so a mission nobody
7178
+ // has run since May reads as "touched today". Real activity is the
7179
+ // last tick (or creation, for missions that never ran).
7180
+ const touched = Math.max(
7181
+ Date.parse(mission.last_tick_at || '') || 0,
7182
+ Date.parse(mission.created_at || '') || 0,
7183
+ );
7184
+ if (!touched || touched > cutoff) continue;
7185
+ const reason = `expired after ${windowLabel} (was ${status}); revive with: atris mission tick ${mission.id}`;
7186
+ if (!dryRun) {
7187
+ const next = {
7188
+ ...mission,
7189
+ status: 'stopped',
7190
+ stopped_at: stampIso(),
7191
+ stop_reason: reason,
7192
+ };
7193
+ const { mission: saved } = saveMission(next, root, 'mission_expired', { reason, previous_status: status, idle_days: idleDays, idle_hours: idleHours });
7194
+ appendMemberLog(saved.owner, 'Mission expired', { mission: saved.objective, reason }, root);
7195
+ }
7196
+ expired.push({ id: mission.id, owner: mission.owner, previous_status: status, objective: String(mission.objective || '').slice(0, 120) });
7197
+ }
7198
+ return expired;
7199
+ }
7200
+
7201
+ // Zombie-mission reap: a mission left paused past a short leash (48h default)
7202
+ // is dead in practice long before the 7-day general idle expiry ever fires —
7203
+ // nobody is coming back to it inside a session, and it just sits on
7204
+ // `mission list` as noise. Narrower than expireStaleMissions on purpose: only
7205
+ // `paused`, never planning/ready, so the weekly cadence for those is unchanged.
7206
+ const MISSION_PAUSED_REAP_HOURS = 48;
7207
+
7208
+ function reapPausedMissions(root = process.cwd(), { hours = MISSION_PAUSED_REAP_HOURS, dryRun = false } = {}) {
7209
+ return expireStaleMissions(root, { idleHours: hours, dryRun, statuses: new Set(['paused']) });
7210
+ }
7211
+
6694
7212
  function goalMission(args) {
6695
7213
  const asJson = wantsJson(args);
6696
7214
  if (args[0] === 'ack') {
@@ -6724,6 +7242,7 @@ function goalMission(args) {
6724
7242
  heartbeat: heartbeatMode,
6725
7243
  ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
6726
7244
  ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
7245
+ ...(hasFlag(args, '--manual-ack') ? { manualAck: true } : {}),
6727
7246
  ...(allowNativeGoalSupersede ? { allowNativeGoalSupersede: true } : {}),
6728
7247
  });
6729
7248
  if (payload.active_goal_conflict) {
@@ -6760,7 +7279,7 @@ function goalMission(args) {
6760
7279
 
6761
7280
  function ackMissionGoal(args) {
6762
7281
  const asJson = wantsJson(args);
6763
- const ref = stripKnownFlags(args, ['--runtime', '--status', '--objective'], ['--json'])[0] || '';
7282
+ const ref = stripKnownFlags(args, ['--runtime', '--status', '--objective'], ['--json', '--manual-ack'])[0] || '';
6764
7283
  if (!ref) {
6765
7284
  exitMissionError('Usage: atris mission goal ack <mission-id> --runtime codex --status active --objective "<objective>" --json', 1, asJson);
6766
7285
  }
@@ -6770,7 +7289,28 @@ function ackMissionGoal(args) {
6770
7289
  if (!mission) {
6771
7290
  exitMissingMission(ref, 1, asJson);
6772
7291
  }
6773
- if (runtime !== 'codex' || status !== 'active') {
7292
+ if (runtime !== 'codex') {
7293
+ const payload = {
7294
+ ok: true,
7295
+ action: 'native_goal_ack_skipped',
7296
+ mission: missionStatusView(mission),
7297
+ runtime,
7298
+ status,
7299
+ reason: 'runtime_not_codex',
7300
+ requires_native_goal_start: false,
7301
+ next_command: codexGoalNextCommand(mission),
7302
+ };
7303
+ printJsonOrText(
7304
+ payload,
7305
+ [
7306
+ `Native goal ack skipped for runtime=${runtime}.`,
7307
+ `Next: ${payload.next_command}`,
7308
+ ],
7309
+ asJson,
7310
+ );
7311
+ return;
7312
+ }
7313
+ if (status !== 'active') {
6774
7314
  exitMissionError('Native goal ack requires --runtime codex --status active.', 2, asJson);
6775
7315
  }
6776
7316
 
@@ -6787,22 +7327,7 @@ function ackMissionGoal(args) {
6787
7327
  }
6788
7328
  const canonicalObjective = codexGoalObjective(mission);
6789
7329
  const reportedObjective = readFlag(args, '--objective', canonicalObjective);
6790
- const objective = canonicalObjective;
6791
- const ack = {
6792
- runtime: 'codex',
6793
- status: 'active',
6794
- mission_id: mission.id,
6795
- objective,
6796
- ...(reportedObjective && reportedObjective !== objective ? { reported_objective: reportedObjective } : {}),
6797
- acknowledged_at: stampIso(),
6798
- };
6799
- const nextMission = {
6800
- ...mission,
6801
- native_goal_ack: ack,
6802
- next_action: codexGoalNextCommand({ ...mission, native_goal_ack: ack }),
6803
- };
6804
- const supersededNativeGoalAcks = supersedeOtherCodexNativeGoalAcks(process.cwd(), nextMission, ack);
6805
- const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_native_goal_ack', { ack });
7330
+ const { saved, ack, supersededNativeGoalAcks } = recordCodexNativeGoalAck(mission, process.cwd(), { reportedObjective });
6806
7331
  const payload = refreshCodexGoalController(process.cwd());
6807
7332
  printJsonOrText(
6808
7333
  {
@@ -6927,15 +7452,17 @@ atris mission - durable goal + loop + owner + proof state
6927
7452
  (rolls up sibling git-worktree missions; --local scopes to this checkout)
6928
7453
  atris mission room "<messy input>" [--owner <member>] [--room-auto-run] [--json] Create a Mission Room card and shareable receipt from messy intent
6929
7454
  atris mission prune-runs [--apply] [--days <n>] [--keep-newest <n>] [--json] Compress old run receipts into a manifest and prune unreferenced clutter
6930
- atris mission goal [--runtime codex|atris] [--heartbeat] [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede] [--json]
7455
+ atris mission goal [--runtime codex|atris] [--heartbeat] [--native-goal-status active|paused] [--native-goal-objective "..."] [--manual-ack] [--allow-native-goal-supersede] [--json]
6931
7456
  atris mission goal ack <id> --runtime codex --status active --objective "<objective>" --json
6932
7457
  atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
6933
7458
  atris mission tick <id> [--verify ["cmd"]] [--complete-on-pass] [--summary "..."] [--json]
7459
+ atris mission set-runner <id> <runner|engine> [--model <id>] [--json]
6934
7460
  atris mission "<objective>" [--owner <member>] Shortcut for: atris mission run "<objective>"
6935
7461
  atris mission run --fleet [--slots 3] [--dry-run] [--json] Staff every idle capable engine on claimable safe-lane tasks: parallel worktree builds, serial rebase-before-ship landings, receipt in atris/runs/
6936
7462
  atris mission run ["objective"|<member> ["objective"]|id|--due] [--owner <member>] [--max-ticks 4] [--max-wall 3600] [--cadence "15m"]
6937
- [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede] [--take-goal-slot]
6938
- [--spend-full-budget|--use-whole-budget|--stop-when-done] [--room-preflight|--no-room-preflight]
7463
+ [--native-goal-status active|paused] [--native-goal-objective "..."] [--manual-ack] [--allow-native-goal-supersede] [--take-goal-slot]
7464
+ [--engine <name>]
7465
+ [--spend-full-budget|--use-whole-budget|--stop-when-done] [--preflight|--no-preflight|--room-preflight|--no-room-preflight]
6939
7466
  [--room-auto-run|--no-room-auto-run]
6940
7467
  [--no-claude] [--no-verify] [--complete-on-pass] [--no-drain] [--create-next] [--json]
6941
7468
  (bare/member-only run prompts; one-word fuzzy intent starts a new visible-goal mission; --due runs the saved queue)
@@ -7337,6 +7864,9 @@ function missionCommand(args) {
7337
7864
  return inspectMission(rest);
7338
7865
  case 'tick':
7339
7866
  return tickMission(rest);
7867
+ case 'set-runner':
7868
+ case 'runner':
7869
+ return setMissionRunner(rest);
7340
7870
  case 'run':
7341
7871
  return runMission(rest);
7342
7872
  case 'complete':
@@ -7358,6 +7888,8 @@ function missionCommand(args) {
7358
7888
  module.exports = {
7359
7889
  missionCommand,
7360
7890
  inspectMission,
7891
+ expireStaleMissions,
7892
+ reapPausedMissions,
7361
7893
  missionHeartbeatLines,
7362
7894
  listMissions,
7363
7895
  listWorktreeRollupMissions,
@@ -7367,6 +7899,7 @@ module.exports = {
7367
7899
  selectDueMission,
7368
7900
  selectAtrisGoalMission,
7369
7901
  selectCodexGoalMission,
7902
+ codexGoalNextCommand,
7370
7903
  usefulClaudeReceiptSummary,
7371
7904
  cappedClaudeReceiptText,
7372
7905
  extractLayerFromReceiptText,
@@ -7378,4 +7911,5 @@ module.exports = {
7378
7911
  detectUnavailableModel,
7379
7912
  missionPauseNextAction,
7380
7913
  consecutiveSameReasonErrors,
7914
+ missionVerifierTimeoutMs,
7381
7915
  };