atris 3.34.0 → 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 (53) 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 +4 -2
  6. package/ax +475 -17
  7. package/bin/atris.js +197 -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/member.js +398 -42
  24. package/commands/mission.js +554 -64
  25. package/commands/now.js +25 -1
  26. package/commands/radar.js +259 -14
  27. package/commands/serve.js +54 -0
  28. package/commands/status.js +50 -5
  29. package/commands/stripe.js +38 -0
  30. package/commands/supabase.js +39 -0
  31. package/commands/task.js +935 -71
  32. package/commands/truth.js +29 -3
  33. package/commands/unknowns.js +627 -0
  34. package/commands/update.js +44 -0
  35. package/commands/vercel.js +38 -0
  36. package/commands/worktree.js +68 -10
  37. package/commands/write.js +399 -0
  38. package/lib/auto-accept-certified.js +70 -19
  39. package/lib/autoland.js +39 -3
  40. package/lib/fleet.js +250 -9
  41. package/lib/memory-view.js +14 -5
  42. package/lib/mission-runtime-loop.js +7 -0
  43. package/lib/official-cli-integration.js +174 -0
  44. package/lib/outbound-send-gate.js +165 -0
  45. package/lib/permission-grants.js +293 -0
  46. package/lib/review-integrity.js +147 -0
  47. package/lib/runner-command.js +23 -0
  48. package/lib/task-db.js +220 -7
  49. package/lib/task-proof.js +20 -0
  50. package/lib/task-receipt.js +93 -0
  51. package/package.json +1 -1
  52. package/utils/update-check.js +27 -6
  53. 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', '--duplicate', '--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
  }
@@ -2808,7 +2905,36 @@ function findActiveTwinMission(objective, owner, root = process.cwd()) {
2808
2905
 
2809
2906
  function startMission(args) {
2810
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
+ }
2811
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
+ }
2812
2938
  if (!hasFlag(args, '--duplicate')) {
2813
2939
  const twin = findActiveTwinMission(mission.objective, mission.owner);
2814
2940
  if (twin) {
@@ -2849,7 +2975,7 @@ function startMission(args) {
2849
2975
  mission.next_action = `work task then run: atris task current-step --goal-id ${mission.id} --as ${mission.owner} --proof "<proof>" --json`;
2850
2976
  }
2851
2977
  }
2852
- const warnings = [missingVerifierWarning(mission)].filter(Boolean);
2978
+ const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
2853
2979
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
2854
2980
  const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective });
2855
2981
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
@@ -2959,7 +3085,7 @@ function startMissionFromRunObjective(objective, args) {
2959
3085
  mission.xp_task = xpTask;
2960
3086
  mission.task_ids = Array.from(new Set([...(mission.task_ids || []), xpTask.task_id]));
2961
3087
  }
2962
- const warnings = [missingVerifierWarning(mission)].filter(Boolean);
3088
+ const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
2963
3089
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
2964
3090
  const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective, source: 'mission_run_objective' });
2965
3091
  const directGoalRequest = writeDirectRunCodexGoalRequest(saved, process.cwd());
@@ -2978,6 +3104,7 @@ function startMissionFromRunObjective(objective, args) {
2978
3104
  const nativeGoalOptions = {
2979
3105
  ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
2980
3106
  ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
3107
+ ...(hasFlag(args, '--manual-ack') ? { manualAck: true } : {}),
2981
3108
  ...(hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal') ? { allowNativeGoalSupersede: true } : {}),
2982
3109
  };
2983
3110
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
@@ -2987,6 +3114,7 @@ function startMissionFromRunObjective(objective, args) {
2987
3114
  const codexGoalState = runnerUsesCallerSession(saved.runner)
2988
3115
  ? refreshCodexGoalController(process.cwd(), { missionId: saved.id, ...nativeGoalOptions })
2989
3116
  : null;
3117
+ const outputMission = resolveMission(saved.id, process.cwd()) || saved;
2990
3118
  const nativeGoal = codexGoalState?.native_goal_action
2991
3119
  || (codexGoalState?.goal?.requires_native_goal_start ? codexGoalState.goal.native_goal_action : null);
2992
3120
  const nextCommand = codexGoalState?.next_command || codexGoalState?.goal?.next_command || atrisGoalState.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`;
@@ -2994,7 +3122,7 @@ function startMissionFromRunObjective(objective, args) {
2994
3122
  {
2995
3123
  ok: true,
2996
3124
  action: 'mission_run_started',
2997
- mission: saved,
3125
+ mission: outputMission,
2998
3126
  budget_contract: saved.budget_contract || null,
2999
3127
  warnings,
3000
3128
  state_path: statePaths().missionsJsonl,
@@ -3016,7 +3144,7 @@ function startMissionFromRunObjective(objective, args) {
3016
3144
  native_goal_ack_command: codexGoalState?.goal?.native_goal_ack_command || codexGoalState?.active_goal_conflict?.commands?.ack_new_mission || null,
3017
3145
  next_command: nextCommand,
3018
3146
  },
3019
- missionRunTakeoffLines(saved, { warnings, nextCommand }),
3147
+ missionRunTakeoffLines(outputMission, { warnings, nextCommand }),
3020
3148
  asJson,
3021
3149
  );
3022
3150
  }
@@ -3151,6 +3279,74 @@ function attachMissionTask(args) {
3151
3279
  }
3152
3280
  }
3153
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
+
3154
3350
  function statusMission(args) {
3155
3351
  const asJson = wantsJson(args);
3156
3352
  const localOnly = hasFlag(args, '--local');
@@ -4030,6 +4226,12 @@ function missionReceiptHealth(mission, root = process.cwd()) {
4030
4226
  return { ok: true, reason: 'verifier_passed', receipt_path: receiptPath };
4031
4227
  }
4032
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
+
4033
4235
  function collectMissionDoctorFindings(root = process.cwd(), options = {}) {
4034
4236
  const localOnly = options.localOnly === true;
4035
4237
  let missions = listMissions(root);
@@ -4061,7 +4263,10 @@ function collectMissionDoctorFindings(root = process.cwd(), options = {}) {
4061
4263
  const status = String(mission.status || '');
4062
4264
  const active = !TERMINAL_STATUSES.has(status);
4063
4265
  const objective = String(mission.objective || '').trim();
4064
- 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)) {
4065
4270
  add(
4066
4271
  mission,
4067
4272
  'missing_verifier',
@@ -4231,6 +4436,12 @@ function resolveVerifierCommand(command) {
4231
4436
  return `${leading}${shellQuote(process.execPath)} ${shellQuote(cliPath)}${trimmed.slice('atris'.length)}`;
4232
4437
  }
4233
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
+
4234
4445
  function runVerifier(command, root = process.cwd()) {
4235
4446
  if (!command) return null;
4236
4447
  const resolvedCommand = resolveVerifierCommand(command);
@@ -4238,7 +4449,7 @@ function runVerifier(command, root = process.cwd()) {
4238
4449
  cwd: root,
4239
4450
  shell: true,
4240
4451
  encoding: 'utf8',
4241
- timeout: 120000,
4452
+ timeout: missionVerifierTimeoutMs(),
4242
4453
  env: process.env,
4243
4454
  });
4244
4455
  return {
@@ -4736,10 +4947,55 @@ function codexRuntimeStateBlocksMissionSlot(runtimeGoalState, mission) {
4736
4947
  return runtimeGoalState.objective === codexGoalObjective(mission);
4737
4948
  }
4738
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
+
4739
4957
  function codexGoalAckCommand(mission, objective = codexGoalObjective(mission)) {
4740
4958
  return `atris mission goal ack ${mission.id} --runtime codex --status active --objective ${shellQuote(objective)} --json`;
4741
4959
  }
4742
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
+
4743
4999
  function codexNativeGoalReplaceAction(newMission, activeMission, runtimeGoalState = null, commands = {}) {
4744
5000
  const fromObjective = codexGoalObjective(activeMission);
4745
5001
  const toObjective = codexGoalObjective(newMission);
@@ -5010,16 +5266,18 @@ function codexNativeGoalBlockPayload(mission) {
5010
5266
  };
5011
5267
  }
5012
5268
 
5013
- function maybeBlockUntilCodexNativeGoalStarted(mission, asJson) {
5269
+ function maybeBlockUntilCodexNativeGoalStarted(mission, asJson, options = {}) {
5014
5270
  if (!isCodexGoalMission(mission) || codexNativeGoalAck(mission)) return;
5271
+ if (options.manualAck === false) return;
5015
5272
  const payload = codexNativeGoalBlockPayload(mission);
5016
5273
  if (asJson) console.log(JSON.stringify(payload, null, 2));
5017
5274
  else console.error(payload.next_action);
5018
5275
  process.exit(2);
5019
5276
  }
5020
5277
 
5021
- function returnIfCodexNativeGoalNotStarted(mission, asJson) {
5278
+ function returnIfCodexNativeGoalNotStarted(mission, asJson, options = {}) {
5022
5279
  if (!isCodexGoalMission(mission) || codexNativeGoalAck(mission)) return false;
5280
+ if (options.manualAck === false) return false;
5023
5281
  const payload = codexNativeGoalBlockPayload(mission);
5024
5282
  if (asJson) console.log(JSON.stringify(payload, null, 2));
5025
5283
  else console.error(payload.next_action);
@@ -5206,7 +5464,10 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
5206
5464
  };
5207
5465
  }
5208
5466
 
5209
- 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;
5210
5471
  const taskSpine = missionTaskSpine(mission);
5211
5472
  const missionView = missionStatusView(mission);
5212
5473
  const objective = codexGoalObjective(mission);
@@ -5242,6 +5503,10 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
5242
5503
  native_goal_action: nativeGoalAction,
5243
5504
  native_goal_ack_command: codexGoalAckCommand(mission, objective),
5244
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,
5245
5510
  direct_goal_request: directGoalRequest || null,
5246
5511
  seeded_continuation_goal: seededContinuationGoal || null,
5247
5512
  next_action_preview: missionChoosesNextMission(mission) ? chooseNextMissionPreview(mission, root) : (mission.next_action_preview || null),
@@ -5257,6 +5522,7 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
5257
5522
  requires_native_goal_start: goal.requires_native_goal_start,
5258
5523
  requires_native_goal_replace: goal.requires_native_goal_replace,
5259
5524
  native_goal_action: goal.native_goal_action,
5525
+ auto_native_goal_ack: goal.auto_native_goal_ack,
5260
5526
  runtime_goal_state: runtimeGoalState,
5261
5527
  };
5262
5528
  }
@@ -5561,6 +5827,25 @@ function consecutiveSameReasonErrors(ticks) {
5561
5827
  return { reason: last.reason, count };
5562
5828
  }
5563
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
+
5564
5849
  function isWithinActiveHours(activeHours, now = new Date()) {
5565
5850
  if (!activeHours || !activeHours.start || !activeHours.end) return true;
5566
5851
  const tz = activeHours.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@@ -5617,6 +5902,11 @@ function releaseMissionLock(lock) {
5617
5902
 
5618
5903
  function probeClaudeBinary() {
5619
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
+ }
5620
5910
  const help = spawnSync(runnerBin, ['--help'], { encoding: 'utf8', timeout: 8000 });
5621
5911
  if (help.status !== 0) return { ok: false, error: `${runnerBin} --help failed` };
5622
5912
  const text = String(help.stdout || '');
@@ -5723,7 +6013,91 @@ function missionPauseNextAction(pauseReason, missionId, deadModel = null, lastEr
5723
6013
  return `resume with: atris mission run ${missionId}`;
5724
6014
  }
5725
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
+
5726
6099
  function spawnClaudeTick(mission, opts) {
6100
+ if (resolveClaudeRunnerCommandTemplate()) return spawnGenericRunnerTick(mission, opts);
5727
6101
  const { sessionMode, sessionId, cwd, signal, timeoutMs, prompt, model } = opts;
5728
6102
  return new Promise((resolve) => {
5729
6103
  const args = [
@@ -5874,10 +6248,14 @@ async function runMission(args) {
5874
6248
  if (hasFlag(args, '--fleet')) {
5875
6249
  const { runFleetFlight } = require('../lib/fleet');
5876
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', '');
5877
6254
  const flight = await runFleetFlight({
5878
6255
  slots,
5879
6256
  dryRun: hasFlag(args, '--dry-run'),
5880
6257
  log: asJson ? () => {} : console.log,
6258
+ ...(baseOverride ? { checkoutBase: baseOverride } : {}),
5881
6259
  });
5882
6260
  if (asJson) console.log(JSON.stringify(flight, null, 2));
5883
6261
  process.exitCode = flight.paused && flight.paused.length > 0 ? 1 : 0;
@@ -5894,6 +6272,12 @@ async function runMission(args) {
5894
6272
  const maxWallFlag = readFlag(args, '--max-wall', '');
5895
6273
  let maxWallSeconds = Math.max(60, Number(maxWallFlag) || MISSION_RUN_DEFAULTS.maxWallSeconds);
5896
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);
5897
6281
  const input = missionRunInputFromArgs(args);
5898
6282
  const ref = input.ref;
5899
6283
  const runArgs = input.args;
@@ -5921,7 +6305,8 @@ async function runMission(args) {
5921
6305
  return;
5922
6306
  }
5923
6307
  if (!mission) {
5924
- 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);
5925
6310
  }
5926
6311
  if (!maxWallFlag && Number(mission.budget_contract?.requested_seconds) > 0) {
5927
6312
  maxWallSeconds = Math.max(60, Number(mission.budget_contract.requested_seconds));
@@ -5939,17 +6324,22 @@ async function runMission(args) {
5939
6324
  process.exit(0);
5940
6325
  }
5941
6326
 
5942
- 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 });
5943
6338
 
5944
- const preLockRunner = String(mission.runner || '').trim().toLowerCase();
5945
- const preLockCallerSession = runnerUsesCallerSession(mission.runner);
5946
- if (!skipClaude && !preLockCallerSession && preLockRunner !== 'atris2') {
5947
- const probe = probeClaudeBinary();
5948
- if (!probe.ok) {
5949
- console.error(`[mission run] claude probe failed: ${probe.error}`);
5950
- process.exit(2);
5951
- }
5952
- }
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.
5953
6343
 
5954
6344
  const lock = acquireMissionLock(mission.id);
5955
6345
  if (!lock.ok) {
@@ -5964,6 +6354,7 @@ async function runMission(args) {
5964
6354
  let ranTicks = 0;
5965
6355
  const ticks = [];
5966
6356
  let onSig = null;
6357
+ let restoreRunnerProfile = null;
5967
6358
 
5968
6359
  try {
5969
6360
  const cwd = process.cwd();
@@ -5977,11 +6368,12 @@ async function runMission(args) {
5977
6368
  // Derive sessionId, pendingSessionId, and the frozen contract from the fresh record
5978
6369
  // so a fast tick's writes can't be silently overwritten by this run loop.
5979
6370
  mission = resolveMission(mission.id) || mission;
6371
+ let runtimeMission = runtimeView(mission);
5980
6372
  if (['complete', 'stopped'].includes(mission.status)) {
5981
6373
  console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
5982
6374
  return;
5983
6375
  }
5984
- if (returnIfCodexNativeGoalNotStarted(mission, asJson)) return;
6376
+ if (returnIfCodexNativeGoalNotStarted(runtimeMission, asJson, { manualAck })) return;
5985
6377
  if (mission.status === 'paused') {
5986
6378
  mission = saveMission({
5987
6379
  ...mission,
@@ -5991,17 +6383,29 @@ async function runMission(args) {
5991
6383
  stop_reason: null,
5992
6384
  next_action: `running: atris mission run ${mission.id}`,
5993
6385
  }, cwd, 'mission_run_resumed', { reason: 'operator-resume' }).mission;
6386
+ runtimeMission = runtimeView(mission);
5994
6387
  }
5995
6388
  sessionId = mission.claude_session_id || null;
5996
6389
  pendingSessionId = mission.pending_session_id || null;
5997
- const callerSessionRunner = runnerUsesCallerSession(mission.runner);
5998
- 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';
5999
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
+ }
6000
6401
 
6001
6402
  // Freeze run-start contract (verifier, lane). Stored on receipts, not the mission record.
6002
6403
  const frozen = {
6003
6404
  verifier: effectiveMissionVerifier(mission),
6004
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 } : {}),
6005
6409
  started_at: stampIso(),
6006
6410
  };
6007
6411
  const runWorktreeBefore = gitWorktreeSnapshot(cwd);
@@ -6016,6 +6420,7 @@ async function runMission(args) {
6016
6420
  if (!skipWorker && !atris2Runner && !sessionId && !pendingSessionId) {
6017
6421
  pendingSessionId = crypto.randomUUID();
6018
6422
  mission = saveMission({ ...mission, pending_session_id: pendingSessionId }, cwd, 'mission_session_pending', { session_id: pendingSessionId }).mission;
6423
+ runtimeMission = runtimeView(mission);
6019
6424
  }
6020
6425
 
6021
6426
  const startedAt = Date.now();
@@ -6026,7 +6431,7 @@ async function runMission(args) {
6026
6431
  const sessionLabel = skipWorker
6027
6432
  ? 'caller-session'
6028
6433
  : atris2Runner
6029
- ? `atris2 (${mission.model || 'atris:fast'})`
6434
+ ? `atris2 (${runtimeMission.model || 'atris:fast'})`
6030
6435
  : (sessionId || `pending=${pendingSessionId}`);
6031
6436
  if (!asJson) {
6032
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}`);
@@ -6040,6 +6445,7 @@ async function runMission(args) {
6040
6445
 
6041
6446
  // Re-read mission, detect mutation of frozen fields
6042
6447
  mission = resolveMission(mission.id) || mission;
6448
+ runtimeMission = runtimeView(mission);
6043
6449
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
6044
6450
  if (effectiveMissionVerifier(mission) !== frozen.verifier) { pauseReason = 'verifier-mutated'; break; }
6045
6451
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
@@ -6071,12 +6477,13 @@ async function runMission(args) {
6071
6477
  } else if (atris2Runner) {
6072
6478
  const pingDrain = consumeMissionPings(mission, cwd);
6073
6479
  mission = pingDrain.mission;
6074
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
6480
+ runtimeMission = runtimeView(mission);
6481
+ const prompt = buildTickPrompt(runtimeMission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
6075
6482
  const { runAtris2Turn } = require('./probe');
6076
- const businessId = businessIdForAtris2Mission(mission, cwd);
6483
+ const businessId = businessIdForAtris2Mission(runtimeMission, cwd);
6077
6484
  const turn = await runAtris2Turn({
6078
6485
  prompt,
6079
- model: mission.model || 'atris:fast',
6486
+ model: runtimeMission.model || 'atris:fast',
6080
6487
  business: businessId,
6081
6488
  maxTurns: 16,
6082
6489
  signal: controller.signal,
@@ -6084,17 +6491,18 @@ async function runMission(args) {
6084
6491
  result.atris2 = {
6085
6492
  ok: turn.ok,
6086
6493
  engine: turn.engine,
6087
- model: mission.model || 'atris:fast',
6494
+ model: runtimeMission.model || 'atris:fast',
6088
6495
  tools_run: turn.tools_run,
6089
6496
  unsupported: turn.unsupported,
6090
6497
  duration_ms: turn.duration_ms,
6091
6498
  error: turn.error,
6499
+ backend_unavailable: isTransientAtris2BackendError(turn.error) || undefined,
6092
6500
  receipt_text: String(turn.text || '').slice(0, 4000),
6093
6501
  };
6094
6502
  if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
6095
6503
  if (turn.error === 'not-logged-in') { pauseReason = 'auth-required'; break; }
6096
6504
  if (!turn.ok || !String(turn.text || '').trim()) {
6097
- result = { ...result, status: 'errored', reason: 'atris2-error' };
6505
+ result = { ...result, status: 'errored', reason: atris2TurnErrorReason(turn.error) };
6098
6506
  } else {
6099
6507
  result = { ...result, status: 'ran', reason: 'tick-ok', ran: true };
6100
6508
  }
@@ -6103,11 +6511,12 @@ async function runMission(args) {
6103
6511
  const useId = sessionId || pendingSessionId;
6104
6512
  const pingDrain = consumeMissionPings(mission, cwd);
6105
6513
  mission = pingDrain.mission;
6106
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
6107
- 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, {
6108
6517
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
6109
6518
  timeoutMs: MISSION_RUN_DEFAULTS.claudeTimeoutMs, prompt,
6110
- model: resolveClaudeRunnerModel(mission),
6519
+ model: resolveClaudeRunnerModel(runtimeMission),
6111
6520
  });
6112
6521
  result.claude = {
6113
6522
  ok: claudeResult.ok,
@@ -6203,7 +6612,7 @@ async function runMission(args) {
6203
6612
  const finishedAt = stampIso();
6204
6613
  const tickRecord = { ...result, started_at: tickStart, finished_at: finishedAt, worktree: tickWorktree };
6205
6614
  ticks.push(tickRecord);
6206
- receiptPath = writeReceipt(mission, {
6615
+ receiptPath = writeReceipt(runtimeMission, {
6207
6616
  kind: 'mission_run_tick',
6208
6617
  tick: tickRecord,
6209
6618
  frozen,
@@ -6315,7 +6724,9 @@ async function runMission(args) {
6315
6724
  // is the same trap one step less deterministic: keep retrying and the loop burns every
6316
6725
  // tick + cron firing on it. Halt at two-in-a-row and surface the reason for a human.
6317
6726
  const errStreak = consecutiveSameReasonErrors(ticks);
6318
- 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; }
6319
6730
 
6320
6731
  // Sleep until next tick
6321
6732
  let sleepMs = 0;
@@ -6338,7 +6749,7 @@ async function runMission(args) {
6338
6749
 
6339
6750
  if (!pauseReason && ticks.length >= effectiveMaxTicks) {
6340
6751
  const lastTick = ticks[ticks.length - 1];
6341
- if (lastTick && lastTick.status !== 'ran') pauseReason = 'max-ticks-reached';
6752
+ if (lastTick && lastTick.status !== 'ran' && !missionRunKeepsRetryingError(lastTick.reason)) pauseReason = 'max-ticks-reached';
6342
6753
  }
6343
6754
 
6344
6755
  if (pauseReason && !['complete', 'ready', 'max-wall-reached'].includes(pauseReason)) {
@@ -6366,7 +6777,8 @@ async function runMission(args) {
6366
6777
  next: missionRunCreatedNextLine(createdNext, continuationGoal, mission),
6367
6778
  };
6368
6779
  landingSummary.reason = missionHumanReasonText(mission, landingSummary.changed);
6369
- const finalReceipt = writeReceipt(mission, {
6780
+ const finalRuntimeMission = runtimeView(mission);
6781
+ const finalReceipt = writeReceipt(finalRuntimeMission, {
6370
6782
  kind: 'mission_run_summary',
6371
6783
  frozen,
6372
6784
  pause_reason: pauseReason,
@@ -6385,7 +6797,7 @@ async function runMission(args) {
6385
6797
  const codexGoalState = refreshCodexGoalController(cwd);
6386
6798
 
6387
6799
  printJsonOrText(
6388
- { 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 },
6389
6801
  missionRunSummaryLines(mission, ranTicks, effectiveMaxTicks, finalReceipt, pauseReason, continuationGoal, ticks, createdNext),
6390
6802
  asJson,
6391
6803
  );
@@ -6394,12 +6806,20 @@ async function runMission(args) {
6394
6806
  try { process.removeListener('SIGINT', onSig); } catch {}
6395
6807
  try { process.removeListener('SIGTERM', onSig); } catch {}
6396
6808
  }
6809
+ if (restoreRunnerProfile) {
6810
+ try { restoreRunnerProfile(); } catch {}
6811
+ }
6397
6812
  releaseMissionLock(lock);
6398
6813
  }
6399
6814
  }
6400
6815
 
6401
6816
  function tickMission(args) {
6402
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
+ }
6403
6823
  const verify = hasFlag(args, '--verify');
6404
6824
  const verifyOverride = readFlag(args, '--verify', '');
6405
6825
  const completeOnPass = hasFlag(args, '--complete-on-pass');
@@ -6735,6 +7155,60 @@ function stopMission(args) {
6735
7155
  );
6736
7156
  }
6737
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
+
6738
7212
  function goalMission(args) {
6739
7213
  const asJson = wantsJson(args);
6740
7214
  if (args[0] === 'ack') {
@@ -6768,6 +7242,7 @@ function goalMission(args) {
6768
7242
  heartbeat: heartbeatMode,
6769
7243
  ...(nativeGoalStatus ? { nativeGoalStatus } : {}),
6770
7244
  ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
7245
+ ...(hasFlag(args, '--manual-ack') ? { manualAck: true } : {}),
6771
7246
  ...(allowNativeGoalSupersede ? { allowNativeGoalSupersede: true } : {}),
6772
7247
  });
6773
7248
  if (payload.active_goal_conflict) {
@@ -6804,7 +7279,7 @@ function goalMission(args) {
6804
7279
 
6805
7280
  function ackMissionGoal(args) {
6806
7281
  const asJson = wantsJson(args);
6807
- const ref = stripKnownFlags(args, ['--runtime', '--status', '--objective'], ['--json'])[0] || '';
7282
+ const ref = stripKnownFlags(args, ['--runtime', '--status', '--objective'], ['--json', '--manual-ack'])[0] || '';
6808
7283
  if (!ref) {
6809
7284
  exitMissionError('Usage: atris mission goal ack <mission-id> --runtime codex --status active --objective "<objective>" --json', 1, asJson);
6810
7285
  }
@@ -6814,7 +7289,28 @@ function ackMissionGoal(args) {
6814
7289
  if (!mission) {
6815
7290
  exitMissingMission(ref, 1, asJson);
6816
7291
  }
6817
- 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') {
6818
7314
  exitMissionError('Native goal ack requires --runtime codex --status active.', 2, asJson);
6819
7315
  }
6820
7316
 
@@ -6831,22 +7327,7 @@ function ackMissionGoal(args) {
6831
7327
  }
6832
7328
  const canonicalObjective = codexGoalObjective(mission);
6833
7329
  const reportedObjective = readFlag(args, '--objective', canonicalObjective);
6834
- const objective = canonicalObjective;
6835
- const ack = {
6836
- runtime: 'codex',
6837
- status: 'active',
6838
- mission_id: mission.id,
6839
- objective,
6840
- ...(reportedObjective && reportedObjective !== objective ? { reported_objective: reportedObjective } : {}),
6841
- acknowledged_at: stampIso(),
6842
- };
6843
- const nextMission = {
6844
- ...mission,
6845
- native_goal_ack: ack,
6846
- next_action: codexGoalNextCommand({ ...mission, native_goal_ack: ack }),
6847
- };
6848
- const supersededNativeGoalAcks = supersedeOtherCodexNativeGoalAcks(process.cwd(), nextMission, ack);
6849
- const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_native_goal_ack', { ack });
7330
+ const { saved, ack, supersededNativeGoalAcks } = recordCodexNativeGoalAck(mission, process.cwd(), { reportedObjective });
6850
7331
  const payload = refreshCodexGoalController(process.cwd());
6851
7332
  printJsonOrText(
6852
7333
  {
@@ -6971,15 +7452,17 @@ atris mission - durable goal + loop + owner + proof state
6971
7452
  (rolls up sibling git-worktree missions; --local scopes to this checkout)
6972
7453
  atris mission room "<messy input>" [--owner <member>] [--room-auto-run] [--json] Create a Mission Room card and shareable receipt from messy intent
6973
7454
  atris mission prune-runs [--apply] [--days <n>] [--keep-newest <n>] [--json] Compress old run receipts into a manifest and prune unreferenced clutter
6974
- 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]
6975
7456
  atris mission goal ack <id> --runtime codex --status active --objective "<objective>" --json
6976
7457
  atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
6977
7458
  atris mission tick <id> [--verify ["cmd"]] [--complete-on-pass] [--summary "..."] [--json]
7459
+ atris mission set-runner <id> <runner|engine> [--model <id>] [--json]
6978
7460
  atris mission "<objective>" [--owner <member>] Shortcut for: atris mission run "<objective>"
6979
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/
6980
7462
  atris mission run ["objective"|<member> ["objective"]|id|--due] [--owner <member>] [--max-ticks 4] [--max-wall 3600] [--cadence "15m"]
6981
- [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede] [--take-goal-slot]
6982
- [--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]
6983
7466
  [--room-auto-run|--no-room-auto-run]
6984
7467
  [--no-claude] [--no-verify] [--complete-on-pass] [--no-drain] [--create-next] [--json]
6985
7468
  (bare/member-only run prompts; one-word fuzzy intent starts a new visible-goal mission; --due runs the saved queue)
@@ -7381,6 +7864,9 @@ function missionCommand(args) {
7381
7864
  return inspectMission(rest);
7382
7865
  case 'tick':
7383
7866
  return tickMission(rest);
7867
+ case 'set-runner':
7868
+ case 'runner':
7869
+ return setMissionRunner(rest);
7384
7870
  case 'run':
7385
7871
  return runMission(rest);
7386
7872
  case 'complete':
@@ -7402,6 +7888,8 @@ function missionCommand(args) {
7402
7888
  module.exports = {
7403
7889
  missionCommand,
7404
7890
  inspectMission,
7891
+ expireStaleMissions,
7892
+ reapPausedMissions,
7405
7893
  missionHeartbeatLines,
7406
7894
  listMissions,
7407
7895
  listWorktreeRollupMissions,
@@ -7411,6 +7899,7 @@ module.exports = {
7411
7899
  selectDueMission,
7412
7900
  selectAtrisGoalMission,
7413
7901
  selectCodexGoalMission,
7902
+ codexGoalNextCommand,
7414
7903
  usefulClaudeReceiptSummary,
7415
7904
  cappedClaudeReceiptText,
7416
7905
  extractLayerFromReceiptText,
@@ -7422,4 +7911,5 @@ module.exports = {
7422
7911
  detectUnavailableModel,
7423
7912
  missionPauseNextAction,
7424
7913
  consecutiveSameReasonErrors,
7914
+ missionVerifierTimeoutMs,
7425
7915
  };