atris 3.37.1 → 3.40.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 (164) hide show
  1. package/AGENTS.md +25 -6
  2. package/README.md +2 -0
  3. package/atris/PERSONA.md +11 -11
  4. package/atris.md +17 -2
  5. package/ax +160 -34
  6. package/bin/atris.js +78 -6
  7. package/commands/activate.js +16 -3
  8. package/commands/agent-spawn.js +13 -18
  9. package/commands/autoland.js +76 -47
  10. package/commands/autopilot-front.js +0 -4
  11. package/commands/autopilot.js +0 -12
  12. package/commands/bench.js +10 -12
  13. package/commands/brain.js +0 -7
  14. package/commands/brief.js +0 -2
  15. package/commands/business-sync.js +0 -1
  16. package/commands/business.js +452 -12
  17. package/commands/chat-scan.js +5 -7
  18. package/commands/clean.js +31 -2
  19. package/commands/close.js +46 -11
  20. package/commands/cloud.js +0 -1
  21. package/commands/codex-goal.js +8 -12
  22. package/commands/computer.js +6 -5
  23. package/commands/console.js +64 -11
  24. package/commands/decide.js +166 -0
  25. package/commands/deck.js +30 -12
  26. package/commands/dream.js +0 -7
  27. package/commands/drill.js +14 -25
  28. package/commands/engine.js +206 -26
  29. package/commands/experiments.js +0 -4
  30. package/commands/fleet-report.js +12 -4
  31. package/commands/game.js +261 -0
  32. package/commands/gm.js +17 -7
  33. package/commands/goal.js +0 -6
  34. package/commands/harvest.js +1 -5
  35. package/commands/improve.js +29 -8
  36. package/commands/init.js +41 -37
  37. package/commands/integrations.js +0 -7
  38. package/commands/land.js +77 -20
  39. package/commands/launchpad.js +1 -16
  40. package/commands/lesson.js +17 -7
  41. package/commands/lifecycle.js +5 -5
  42. package/commands/log.js +55 -5
  43. package/commands/loop-front.js +0 -2
  44. package/commands/loops.js +6 -5
  45. package/commands/meet.js +0 -2
  46. package/commands/member.js +622 -573
  47. package/commands/mission.js +790 -314
  48. package/commands/next.js +0 -1
  49. package/commands/now.js +0 -12
  50. package/commands/one-lap.js +8 -8
  51. package/commands/orb.js +0 -4
  52. package/commands/pack-craft.js +0 -1
  53. package/commands/pack.js +3599 -424
  54. package/commands/play.js +6 -6
  55. package/commands/probe.js +12 -6
  56. package/commands/pulse.js +88 -64
  57. package/commands/push.js +0 -4
  58. package/commands/radar.js +14 -11
  59. package/commands/recover.js +340 -0
  60. package/commands/release.js +10 -9
  61. package/commands/report.js +0 -1
  62. package/commands/router.js +320 -0
  63. package/commands/run-front.js +0 -2
  64. package/commands/search.js +0 -3
  65. package/commands/site-deploy.js +870 -0
  66. package/commands/site.js +11 -2
  67. package/commands/skill.js +7 -2
  68. package/commands/slop.js +237 -7
  69. package/commands/status.js +6 -0
  70. package/commands/stream.js +4 -20
  71. package/commands/study.js +0 -3
  72. package/commands/sync-checkout.js +51 -0
  73. package/commands/sync.js +11 -40
  74. package/commands/task.js +999 -595
  75. package/commands/taste.js +101 -0
  76. package/commands/teach.js +86 -0
  77. package/commands/team.js +83 -3
  78. package/commands/unknowns.js +0 -10
  79. package/commands/update.js +0 -1
  80. package/commands/vercel.js +4 -2
  81. package/commands/visualize.js +0 -5
  82. package/commands/voice.js +195 -0
  83. package/commands/watch.js +1 -28
  84. package/commands/wiki.js +42 -15
  85. package/commands/wish.js +0 -1
  86. package/commands/workflow.js +2 -2
  87. package/commands/worktree.js +88 -28
  88. package/commands/xp.js +28 -30
  89. package/commands/youtube.js +0 -2
  90. package/lib/accept-verify-gate.js +126 -0
  91. package/lib/activity-stream.js +0 -2
  92. package/lib/arg-parser.js +41 -0
  93. package/lib/auto-accept-certified.js +261 -3
  94. package/lib/autoland.js +66 -3
  95. package/lib/ax-auto-lane.js +68 -7
  96. package/lib/bench/context.js +0 -2
  97. package/lib/bench/report.js +0 -1
  98. package/lib/bench/runner.js +18 -11
  99. package/lib/brief-ledger.js +0 -6
  100. package/lib/business-simulate.js +382 -0
  101. package/lib/chat-log-scan.js +0 -5
  102. package/lib/checkout-sync.js +52 -0
  103. package/lib/claude-boot-block.js +48 -0
  104. package/lib/cloud-mission.js +0 -6
  105. package/lib/codex-flight.js +0 -1
  106. package/lib/conductor-artifacts.js +25 -0
  107. package/lib/context-gatherer.js +7 -2
  108. package/lib/deck-compose.js +0 -1
  109. package/lib/deck-history.js +0 -1
  110. package/lib/deck-layout.js +0 -5
  111. package/lib/deck-review.js +0 -7
  112. package/lib/deck-schema.js +0 -1
  113. package/lib/default-verifier.js +0 -2
  114. package/lib/engine-registry.js +157 -28
  115. package/lib/experiments/daily.js +0 -6
  116. package/lib/falsifier-probe.js +84 -0
  117. package/lib/fleet.js +275 -42
  118. package/lib/functional-owner.js +0 -3
  119. package/lib/git-spawn.js +15 -0
  120. package/lib/inspect-fields.js +0 -1
  121. package/lib/json-file.js +37 -0
  122. package/lib/known-commands.js +2 -2
  123. package/lib/learnings.js +0 -2
  124. package/lib/lesson-preflight.js +146 -0
  125. package/lib/loop-doctor.js +11 -19
  126. package/lib/manifest.js +0 -2
  127. package/lib/member-alive.js +0 -1
  128. package/lib/member-switches.js +0 -5
  129. package/lib/mission-artifact.js +0 -1
  130. package/lib/mission-human-asks.js +28 -0
  131. package/lib/mission-protected-lane.js +296 -0
  132. package/lib/mission-room.js +0 -2
  133. package/lib/mission-runtime-loop.js +0 -1
  134. package/lib/next-moves.js +0 -18
  135. package/lib/official-cli-integration.js +47 -6
  136. package/lib/orb-context.js +14 -4
  137. package/lib/orb-scorecard.js +0 -1
  138. package/lib/outbound-send-gate.js +0 -6
  139. package/lib/pack-capabilities.js +685 -0
  140. package/lib/permission-grants.js +0 -6
  141. package/lib/policy-lessons.js +0 -2
  142. package/lib/pulse.js +68 -15
  143. package/lib/review-integrity.js +0 -2
  144. package/lib/router-brain.js +51 -3
  145. package/lib/runner-command.js +0 -10
  146. package/lib/runtime-bootstrap.js +0 -1
  147. package/lib/scorecard.js +0 -1
  148. package/lib/self-drive.js +45 -14
  149. package/lib/state-detection.js +0 -6
  150. package/lib/task-db.js +137 -7
  151. package/lib/task-decision.js +50 -0
  152. package/lib/taste-lessons.js +153 -0
  153. package/lib/team-presence.js +0 -1
  154. package/lib/tool-result-encode.js +17 -1
  155. package/lib/voice-gate.js +129 -2
  156. package/lib/wiki.js +152 -9
  157. package/lib/wish-audit.js +21 -7
  158. package/lib/wish-delegate.js +27 -21
  159. package/lib/wish-stats.js +0 -5
  160. package/lib/wish-store.js +0 -1
  161. package/lib/workspace-safety.js +0 -2
  162. package/lib/zip.js +95 -8
  163. package/package.json +2 -1
  164. package/templates/business-starter/persona.md +9 -0
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const readline = require('readline');
7
7
  const { spawn, spawnSync } = require('child_process');
8
+ const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
8
9
  const {
9
10
  appendBriefRecord,
10
11
  stampBriefOutcome,
@@ -78,6 +79,16 @@ const {
78
79
  resolveDefaultVerifier,
79
80
  } = require('../lib/default-verifier');
80
81
  const { redirectToWorkspaceRoot } = require('../lib/mission-root');
82
+ const { readJson, writeJson } = require('../lib/json-file');
83
+ const {
84
+ normalizeHumanAsks,
85
+ openHumanAsks,
86
+ } = require('../lib/mission-human-asks');
87
+ const {
88
+ inspectMissionProtectedDiff,
89
+ prepareMissionGitGuard,
90
+ unreadableMissionGuard,
91
+ } = require('../lib/mission-protected-lane');
81
92
 
82
93
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
83
94
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
@@ -313,28 +324,6 @@ function readMissionRouteProposal(args, asJson) {
313
324
  };
314
325
  }
315
326
 
316
- function hasFlag(args, name) {
317
- return args.includes(name);
318
- }
319
-
320
- function unquote(value) {
321
- const text = String(value);
322
- if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
323
- return text.slice(1, -1);
324
- }
325
- return text;
326
- }
327
-
328
- function readFlag(args, name, fallback = '') {
329
- const prefix = `${name}=`;
330
- for (let i = 0; i < args.length; i += 1) {
331
- const arg = String(args[i]);
332
- if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) return unquote(args[i + 1]);
333
- if (arg.startsWith(prefix)) return unquote(arg.slice(prefix.length));
334
- }
335
- return fallback;
336
- }
337
-
338
327
  const MISSION_NATIVE_RUNNER_NAMES = Object.freeze(['manual', 'claude', 'atris2', 'codex_goal', 'caller_session', 'current_agent', 'drill']);
339
328
  const MISSION_NATIVE_RUNNER_SET = new Set(MISSION_NATIVE_RUNNER_NAMES);
340
329
  const MISSION_AUTO_RUNNER = 'auto';
@@ -508,11 +497,11 @@ function readRepeatedFlag(args, name) {
508
497
  for (let i = 0; i < args.length; i += 1) {
509
498
  const arg = String(args[i]);
510
499
  if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
511
- values.push(unquote(args[i + 1]));
500
+ values.push(readFlag([name, args[i + 1]], name, ''));
512
501
  i += 1;
513
502
  continue;
514
503
  }
515
- if (arg.startsWith(prefix)) values.push(unquote(arg.slice(prefix.length)));
504
+ if (arg.startsWith(prefix)) values.push(readFlag([arg], name, ''));
516
505
  }
517
506
  return values.filter(Boolean);
518
507
  }
@@ -734,8 +723,7 @@ function loadTaskDb(asJson = false) {
734
723
  function writeMissionTaskProjection(taskDb, db, workspaceRoot) {
735
724
  const projection = taskDb.taskProjection(db, { workspaceRoot, limit: 500 });
736
725
  const outPath = path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json');
737
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
738
- fs.writeFileSync(outPath, JSON.stringify(projection, null, 2) + '\n', 'utf8');
726
+ writeJson(outPath, projection);
739
727
  return { projection, outPath };
740
728
  }
741
729
 
@@ -834,16 +822,13 @@ function statePaths(root = process.cwd()) {
834
822
 
835
823
  function readBusinessBinding(root = process.cwd()) {
836
824
  const file = path.join(root, '.atris', 'business.json');
837
- try {
838
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
839
- return {
840
- business_id: parsed.business_id || '',
841
- workspace_id: parsed.workspace_id || '',
842
- slug: parsed.slug || '',
843
- };
844
- } catch {
845
- return null;
846
- }
825
+ const parsed = readJson(file);
826
+ if (parsed == null) return null;
827
+ return {
828
+ business_id: parsed.business_id || '',
829
+ workspace_id: parsed.workspace_id || '',
830
+ slug: parsed.slug || '',
831
+ };
847
832
  }
848
833
 
849
834
  function businessIdForAtris2Mission(mission, cwd = process.cwd()) {
@@ -930,9 +915,11 @@ function terminalNextAction(status) {
930
915
 
931
916
  function normalizeMissionState(mission) {
932
917
  if (!mission) return mission;
933
- let normalized = mission;
934
- const nextAction = terminalNextAction(mission.status);
935
- if (nextAction && mission.next_action !== nextAction) {
918
+ let normalized = Array.isArray(mission.human_asks)
919
+ ? { ...mission, human_asks: normalizeHumanAsks(mission.human_asks) }
920
+ : mission;
921
+ const nextAction = terminalNextAction(normalized.status);
922
+ if (nextAction && normalized.next_action !== nextAction) {
936
923
  normalized = { ...normalized, next_action: nextAction };
937
924
  }
938
925
  const effectiveVerifier = effectiveMissionVerifier(normalized);
@@ -1519,9 +1506,10 @@ function renderMemberNowMarkdown(owner, missions, root = process.cwd()) {
1519
1506
  if (mission.stop_condition) lines.push(`- stop: ${mission.stop_condition}`);
1520
1507
  if (budgetContinuation || mission.next_action) lines.push(`- next: ${budgetContinuation || mission.next_action}`);
1521
1508
  if (mission.receipt_path) lines.push(`- proof: ${missionStatusProofText(mission)}`);
1522
- if (mission.human_asks?.length) {
1509
+ const humanAsks = openHumanAsks(mission.human_asks);
1510
+ if (humanAsks.length) {
1523
1511
  lines.push('- human asks:');
1524
- for (const ask of mission.human_asks) lines.push(` - ${ask}`);
1512
+ for (const ask of humanAsks) lines.push(` - ${ask.text}`);
1525
1513
  }
1526
1514
  lines.push('');
1527
1515
  }
@@ -1697,6 +1685,16 @@ function missionLastStepSummary(ticks = []) {
1697
1685
  return '';
1698
1686
  }
1699
1687
 
1688
+ function verifierElapsedSeconds(verifierResult) {
1689
+ const ms = Number(verifierResult && verifierResult.elapsed_ms);
1690
+ return Number.isFinite(ms) ? Math.round(ms / 1000) : 0;
1691
+ }
1692
+
1693
+ function verifierTimeoutSeconds(verifierResult) {
1694
+ const ms = Number(verifierResult && verifierResult.timeout_ms);
1695
+ return Number.isFinite(ms) ? Math.round(ms / 1000) : 0;
1696
+ }
1697
+
1700
1698
  function missionVerifierCheckedText(verifierResult, mission) {
1701
1699
  if (!verifierResult) return 'UNVERIFIED: tick recorded but nothing was checked; treat this increment as unproven.';
1702
1700
  if (verifierResult.mode === 'engine-unavailable') {
@@ -1708,6 +1706,9 @@ function missionVerifierCheckedText(verifierResult, mission) {
1708
1706
  : 'VERIFY FAILED: the engine verify pass did not end with VERDICT: PASS.';
1709
1707
  }
1710
1708
  const command = verifierResult.command || mission.verifier || 'configured verifier';
1709
+ if (!verifierResult.passed && verifierResult.timed_out) {
1710
+ return `VERIFY TIMED OUT: the check ran past its ${verifierTimeoutSeconds(verifierResult)}s window (took ${verifierElapsedSeconds(verifierResult)}s); the code is not failing, the suite is slow. Rerun with more room.`;
1711
+ }
1711
1712
  if (verifierResult.passed) {
1712
1713
  if (/^git\s+diff\s+--check\b/i.test(command)) return 'I ran the diff cleanliness check.';
1713
1714
  if (/\bnode\s+--test\b/i.test(command)) return 'I ran the behavior checks.';
@@ -1716,7 +1717,7 @@ function missionVerifierCheckedText(verifierResult, mission) {
1716
1717
  if (/(?:node\s+\S*atris\.js|\batris)\s+drill\b/i.test(command)) return 'I ran the no-model end-to-end workflow drill.';
1717
1718
  return `Verifier passed: ${command}.`;
1718
1719
  }
1719
- if (/^git\s+diff\s+--check\b/i.test(command)) return 'VERIFY FAILED: diff cleanliness check failed.';
1720
+ if (/^git\s+diff\s+--check\b/i.test(command)) return 'VERIFY FAILED: diff cleanliness check failed. Trailing whitespace in markdown is auto-fixable: npm run audit:markdown-whitespace -- --fix';
1720
1721
  if (/\bnode\s+--test\b/i.test(command)) return 'VERIFY FAILED: behavior checks failed.';
1721
1722
  return `VERIFY FAILED: ${command}.`;
1722
1723
  }
@@ -1732,9 +1733,13 @@ function missionVerifierHighLevelTestText(verifierResult, mission) {
1732
1733
  : 'The verify engine did not provide a passing changed-surface verdict.';
1733
1734
  }
1734
1735
  const command = verifierResult.command || mission.verifier || 'configured verifier';
1736
+ if (!verifierResult.passed && verifierResult.timed_out) {
1737
+ return `Verifier command timed out after ${verifierElapsedSeconds(verifierResult)}s against a ${verifierTimeoutSeconds(verifierResult)}s window: ${command}. No pass/fail verdict was reached; the suite outran the window rather than the code failing.`;
1738
+ }
1735
1739
  const outcome = verifierResult.passed ? 'passed' : 'failed';
1736
1740
  if (/^git\s+diff\s+--check\b/i.test(command)) {
1737
- return `Diff cleanliness check ${outcome}: no whitespace or patch-format issues in the changed files.`;
1741
+ if (verifierResult.passed) return `Diff cleanliness check passed: no whitespace or patch-format issues in the changed files.`;
1742
+ return `Diff cleanliness check ${outcome}: whitespace or patch-format issues in the changed files. Trailing whitespace in markdown is auto-fixable: npm run audit:markdown-whitespace -- --fix`;
1738
1743
  }
1739
1744
  if (/\bnode\s+--test\b/i.test(command) && /\btest\/mission-status\.test\.js\b/i.test(command)) {
1740
1745
  return `Mission behavior checks ${outcome}: mission start, tick, completion, timeline landing, goal-chain, next-mission, and human-accept boundaries were exercised.`;
@@ -1964,12 +1969,22 @@ function shouldMissionRunTrustedRoom(rawObjective, args = []) {
1964
1969
  return missionRunTrustedRoomSignals(rawObjective);
1965
1970
  }
1966
1971
 
1972
+ const MISSION_BARE_COMMAND_WORDS = new Set(['go', 'keep', 'going', 'do', 'it', 'start', 'run', 'continue']);
1973
+
1974
+ function missionObjectiveIsJunk(value) {
1975
+ const objective = String(value || '').trim();
1976
+ if (!objective || objective.startsWith('-')) return true;
1977
+ if (/[\u0000-\u001f\u007f-\u009f\ufffd]/u.test(objective)) return true;
1978
+ if (objective.replace(/\s/g, '').length < 8) return true;
1979
+ const words = objective.toLowerCase().match(/[a-z]+/g) || [];
1980
+ return words.length > 0
1981
+ && objective.replace(/[^a-z]+/gi, '').length > 0
1982
+ && words.every((word) => MISSION_BARE_COMMAND_WORDS.has(word));
1983
+ }
1984
+
1967
1985
  function missionRunConcreteTitle(value) {
1968
1986
  const title = String(value || '').replace(/\s+/g, ' ').trim();
1969
- if (!title) return '';
1970
- if (/^(go|go go|go go go|keep going|do it|start|run|continue)$/i.test(title)) return '';
1971
- if (title.length < 8) return '';
1972
- return title;
1987
+ return missionObjectiveIsJunk(title) ? '' : title;
1973
1988
  }
1974
1989
 
1975
1990
  function selectMissionRunUsefulTarget(rawObjective, root = process.cwd()) {
@@ -2207,6 +2222,9 @@ function missionFullBudgetRemainingSeconds(mission, nowMs = Date.now()) {
2207
2222
  }
2208
2223
 
2209
2224
  function missionBudgetContinuationText(mission, nowMs = Date.now()) {
2225
+ // Paused/stopped/blocked missions are not running: never sell remaining time
2226
+ // as a live commitment (CLI-1192: paused overnight loops read as healthy).
2227
+ if (!GOAL_LOOP_STATUSES.has(String(mission?.status || ''))) return null;
2210
2228
  const remaining = missionFullBudgetRemainingSeconds(mission, nowMs);
2211
2229
  if (remaining <= 0) return null;
2212
2230
  const budget = String(mission?.budget_contract?.budget_label || 'promised time').trim();
@@ -2218,10 +2236,15 @@ function missionBudgetContinuationText(mission, nowMs = Date.now()) {
2218
2236
  }
2219
2237
 
2220
2238
  function missionHumanStatusText(mission, nowMs = Date.now()) {
2239
+ const rawStatus = String(mission?.status || 'unknown');
2240
+ if (rawStatus === 'paused') {
2241
+ const reason = String(mission?.stop_reason || mission?.pause_reason || '').trim();
2242
+ return gateForHuman(reason ? `paused: ${reason}` : 'paused').text;
2243
+ }
2221
2244
  const continuation = missionBudgetContinuationText(mission, nowMs);
2222
2245
  const status = continuation
2223
2246
  ? `working for the full ${String(mission?.budget_contract?.budget_label || 'promised time').trim()}`
2224
- : String(mission?.status || 'unknown');
2247
+ : rawStatus;
2225
2248
  return gateForHuman(status).text;
2226
2249
  }
2227
2250
 
@@ -2941,12 +2964,8 @@ function handledContinuationTargetKeys(root, moves) {
2941
2964
 
2942
2965
  function readTaskProjectionForMission(root = process.cwd()) {
2943
2966
  const file = path.join(root, '.atris', 'state', 'tasks.projection.json');
2944
- try {
2945
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
2946
- return Array.isArray(parsed?.tasks) ? parsed.tasks : [];
2947
- } catch {
2948
- return [];
2949
- }
2967
+ const parsed = readJson(file);
2968
+ return Array.isArray(parsed?.tasks) ? parsed.tasks : [];
2950
2969
  }
2951
2970
 
2952
2971
  function taskTags(task) {
@@ -3328,8 +3347,7 @@ function readRecentTasteLogs(root, owner, limit = 3) {
3328
3347
 
3329
3348
  function readTasteReviewHistory(root, limit = 4) {
3330
3349
  const file = path.join(root, '.atris', 'state', 'tasks.projection.json');
3331
- let projection = null;
3332
- try { projection = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { projection = null; }
3350
+ const projection = readJson(file);
3333
3351
  const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3334
3352
  const accepted = [];
3335
3353
  const revised = [];
@@ -3852,16 +3870,15 @@ function findActiveTwinMission(objective, owner, root = process.cwd()) {
3852
3870
  function startMission(args, options = {}) {
3853
3871
  const asJson = wantsJson(args);
3854
3872
  const firstArg = String(args[0] || '').trim().toLowerCase();
3855
- if (hasFlag(args, '--help') || hasFlag(args, '-h') || firstArg === 'help') {
3873
+ if (args.length === 1 && (firstArg === '--help' || firstArg === '-h' || firstArg === 'help')) {
3856
3874
  console.log('Usage: atris mission start "<objective>" --owner <member> --verify "..." [--destination "..."] [--no-verify] [--always-on] [--budget quick|long|deep] [--runner manual|claude|atris2|codex_goal|auto]');
3857
3875
  console.log('Run `atris mission --help` for the full option list.');
3858
3876
  process.exit(0);
3859
3877
  }
3860
3878
  let mission = applyMissionStartPatch(missionFromArgs(args), options.missionPatch);
3861
- // A flag-looking or empty objective is a typo, not a mission.
3862
3879
  const rawObjective = String(mission.objective || '').trim();
3863
- if (!rawObjective || rawObjective.startsWith('-')) {
3864
- exitMissionError(`mission start needs a quoted objective (got ${rawObjective ? `"${rawObjective}"` : 'nothing'}). Usage: atris mission start "<objective>" --owner <member>`, 1, asJson);
3880
+ if (missionObjectiveIsJunk(rawObjective)) {
3881
+ exitMissionError(`mission start refused junk objective: ${rawObjective ? `"${rawObjective}"` : 'nothing'}`, 1, asJson);
3865
3882
  }
3866
3883
  // Pasting a mission id where an objective goes is an id mismatch, not a new
3867
3884
  // mission: recover the existing record or explain where it actually lives.
@@ -4251,6 +4268,11 @@ async function startMissionFromRunObjective(objective, args) {
4251
4268
 
4252
4269
  function attachMissionTask(args) {
4253
4270
  const asJson = wantsJson(args);
4271
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
4272
+ console.log('Usage: atris mission attach-task <id> [--json]');
4273
+ console.log('Run `atris mission --help` for the full option list.');
4274
+ process.exit(0);
4275
+ }
4254
4276
  const ref = stripKnownFlags(args, [], ['--json'])[0] || '';
4255
4277
  if (!ref) {
4256
4278
  exitMissionError('Usage: atris mission attach-task <id> [--json]', 1, asJson);
@@ -4695,11 +4717,7 @@ function statusMission(args) {
4695
4717
  function readMissionReceipt(receiptPath, root = process.cwd()) {
4696
4718
  if (!receiptPath) return null;
4697
4719
  const file = path.isAbsolute(receiptPath) ? receiptPath : path.join(root, receiptPath);
4698
- try {
4699
- return JSON.parse(fs.readFileSync(file, 'utf8'));
4700
- } catch {
4701
- return null;
4702
- }
4720
+ return readJson(file);
4703
4721
  }
4704
4722
 
4705
4723
  function firstUsefulLine(text, fallback = '') {
@@ -4824,12 +4842,7 @@ function missionVerificationDebt(mission, root = process.cwd()) {
4824
4842
  let unchecked = 0;
4825
4843
  const seen = new Set();
4826
4844
  for (const file of files) {
4827
- let receipt = null;
4828
- try {
4829
- receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
4830
- } catch {
4831
- continue;
4832
- }
4845
+ const receipt = readJson(file);
4833
4846
  if (!receipt || receipt.mission_id !== mission.id) continue;
4834
4847
  for (const tick of missionReceiptTicks(receipt)) {
4835
4848
  if (!tick || tick.status !== 'ran') continue;
@@ -4866,12 +4879,7 @@ function missionReportTimeline(mission, root = process.cwd(), limit = 6) {
4866
4879
  const items = [];
4867
4880
  const seen = new Set();
4868
4881
  for (const file of files) {
4869
- let receipt = null;
4870
- try {
4871
- receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
4872
- } catch {
4873
- continue;
4874
- }
4882
+ const receipt = readJson(file);
4875
4883
  if (!receipt || receipt.mission_id !== mission.id) continue;
4876
4884
  const receiptPath = path.relative(root, file);
4877
4885
  for (const tick of missionReceiptTicks(receipt)) {
@@ -4914,12 +4922,7 @@ function missionLandingTimeline(mission, root = process.cwd(), limit = 12, { kin
4914
4922
  const items = [];
4915
4923
  const seen = new Set();
4916
4924
  for (const file of files) {
4917
- let receipt = null;
4918
- try {
4919
- receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
4920
- } catch {
4921
- continue;
4922
- }
4925
+ const receipt = readJson(file);
4923
4926
  if (!receipt || receipt.mission_id !== mission.id) continue;
4924
4927
  const receiptKind = receipt.result && receipt.result.kind ? String(receipt.result.kind) : (receipt.kind ? String(receipt.kind) : '');
4925
4928
  if (kindFilter && receiptKind !== kindFilter) continue;
@@ -5148,12 +5151,24 @@ function missionReportFor(mission, root = process.cwd()) {
5148
5151
  const timeline = missionReportTimeline(mission, root);
5149
5152
  const verificationDebt = missionVerificationDebt(mission, root);
5150
5153
  const workerCheckedIn = Boolean(explicitWorkerReceipt) || timeline.length > 0;
5154
+ const driver = missionDriverHealth(mission, root);
5155
+ const driverAlive = Boolean(driver && driver.alive);
5151
5156
  const workerReceiptPath = mission.worker_receipt_path || (receipt && verifierReceiptPath) || null;
5152
5157
  const verifierPassed = mission.verifier_result && mission.verifier_result.passed === true;
5153
5158
  const pendingBudgetContinuation = missionBudgetContinuationText(mission);
5154
5159
  const budgetContinuation = workerCheckedIn ? pendingBudgetContinuation : null;
5155
- const operatorOutcome = !workerCheckedIn && pendingBudgetContinuation
5160
+ const awaitingFirstWorker = !workerCheckedIn && Boolean(pendingBudgetContinuation);
5161
+ // An active status implies a worker is running. With no receipt AND no live
5162
+ // driver, that claim is a lie: report that no worker is running and name the
5163
+ // command that starts one instead of saying the mission is in progress.
5164
+ const noLiveWorker = !workerCheckedIn
5165
+ && !driverAlive
5166
+ && !pendingBudgetContinuation
5167
+ && GOAL_LOOP_STATUSES.has(String(mission.status || ''));
5168
+ const operatorOutcome = awaitingFirstWorker
5156
5169
  ? 'No worker has checked in yet.'
5170
+ : noLiveWorker
5171
+ ? 'No worker is running; nothing has produced a receipt yet.'
5157
5172
  : budgetContinuation
5158
5173
  ? 'The last verifier passed; full-budget work is continuing.'
5159
5174
  : mission.operator_outcome
@@ -5162,8 +5177,10 @@ function missionReportFor(mission, root = process.cwd()) {
5162
5177
  id: mission.id,
5163
5178
  objective: mission.objective,
5164
5179
  status: mission.status,
5165
- human_status: !workerCheckedIn && pendingBudgetContinuation
5180
+ human_status: awaitingFirstWorker
5166
5181
  ? 'waiting for the first worker check-in'
5182
+ : noLiveWorker
5183
+ ? 'no live worker (nothing is running)'
5167
5184
  : missionHumanStatusText(mission),
5168
5185
  budget_continuation: budgetContinuation,
5169
5186
  operator_outcome: operatorOutcome,
@@ -5175,9 +5192,10 @@ function missionReportFor(mission, root = process.cwd()) {
5175
5192
  verifier_receipt_path: verifierReceiptPath,
5176
5193
  proof_text: mission.receipt_path ? missionStatusProofText(mission) : null,
5177
5194
  operator_next: budgetContinuation
5178
- || (!workerCheckedIn && pendingBudgetContinuation ? 'Wait for the first worker check-in.' : null)
5195
+ || (awaitingFirstWorker ? 'Wait for the first worker check-in.' : null)
5179
5196
  || mission.operator_next
5180
5197
  || mission.next_action
5198
+ || (noLiveWorker ? `Start a worker: atris mission run ${mission.id}` : null)
5181
5199
  || 'Review the mission state.',
5182
5200
  };
5183
5201
  }
@@ -5254,14 +5272,11 @@ function timelineMission(args) {
5254
5272
  const sinceFilter = readFlag(args, '--since', '') || null;
5255
5273
  const ref = stripKnownFlags(args, ['--limit', '--output', '--out', '--kind', '--since'], ['--json', '--write', '--all', '--prune-preview'])[0] || '';
5256
5274
  const limit = all ? Number.MAX_SAFE_INTEGER : readPositiveIntegerFlag(args, '--limit', 12, { json: asJson });
5257
- const missions = listMissions();
5258
- const mission = ref
5259
- ? resolveMission(ref)
5260
- : (missions.find((row) => !TERMINAL_STATUSES.has(row.status)) || missions[0] || null);
5261
- if (ref && !mission) {
5275
+ const data = loadTimelineData(ref, { limit, kindFilter, sinceFilter, write, prunePreviewRequested, outputPath });
5276
+ if (ref && !data.mission) {
5262
5277
  exitMissingMission(ref, 1, asJson);
5263
5278
  }
5264
- if (!mission) {
5279
+ if (!data.mission) {
5265
5280
  printJsonOrText(
5266
5281
  {
5267
5282
  ok: true,
@@ -5275,8 +5290,40 @@ function timelineMission(args) {
5275
5290
  );
5276
5291
  return;
5277
5292
  }
5293
+ const { payload, lines } = groupTimeline(data, { all, kindFilter, sinceFilter });
5294
+ renderTimeline(payload, lines, asJson);
5295
+ }
5296
+
5297
+ function loadTimelineData(ref, { limit, kindFilter, sinceFilter, write, prunePreviewRequested, outputPath }) {
5298
+ const missions = listMissions();
5299
+ const mission = ref
5300
+ ? resolveMission(ref)
5301
+ : (missions.find((row) => !TERMINAL_STATUSES.has(row.status)) || missions[0] || null);
5302
+ if (!mission) return { mission: null };
5278
5303
  const root = mission.worktree_root || process.cwd();
5279
5304
  const timelineResult = missionLandingTimeline(mission, root, limit, { kind: kindFilter, since: sinceFilter });
5305
+ const generatedAt = stampIso();
5306
+ let artifactPath = null;
5307
+ let prunePreview = null;
5308
+ if (write || prunePreviewRequested) {
5309
+ try {
5310
+ prunePreview = pruneRuns(root, { keepNewest: 200, keepDays: 14 });
5311
+ } catch (error) {
5312
+ prunePreview = { error: error.message || String(error) };
5313
+ }
5314
+ }
5315
+ if (write) {
5316
+ const outPath = outputPath
5317
+ ? path.resolve(root, outputPath)
5318
+ : defaultMissionTimelinePath(root, mission);
5319
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
5320
+ fs.writeFileSync(outPath, missionTimelineMarkdown(mission, timelineResult.items, { prunePreview, generatedAt, timelineMeta: timelineResult.meta }), 'utf8');
5321
+ artifactPath = path.relative(root, outPath);
5322
+ }
5323
+ return { mission, timelineResult, generatedAt, prunePreview, artifactPath };
5324
+ }
5325
+
5326
+ function groupTimeline({ mission, timelineResult, generatedAt, prunePreview, artifactPath }, { all, kindFilter, sinceFilter }) {
5280
5327
  const timeline = timelineResult.items;
5281
5328
  const currentLanding = missionTimelineCurrentLanding(timeline);
5282
5329
  const timelineItemDisplay = (item) => ({
@@ -5305,24 +5352,6 @@ function timelineMission(args) {
5305
5352
  ...timelineItemDisplay(item),
5306
5353
  }));
5307
5354
  const nextMove = missionTimelineNextMove(mission, currentLanding);
5308
- const generatedAt = stampIso();
5309
- let artifactPath = null;
5310
- let prunePreview = null;
5311
- if (write || prunePreviewRequested) {
5312
- try {
5313
- prunePreview = pruneRuns(root, { keepNewest: 200, keepDays: 14 });
5314
- } catch (error) {
5315
- prunePreview = { error: error.message || String(error) };
5316
- }
5317
- }
5318
- if (write) {
5319
- const outPath = outputPath
5320
- ? path.resolve(root, outputPath)
5321
- : defaultMissionTimelinePath(root, mission);
5322
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
5323
- fs.writeFileSync(outPath, missionTimelineMarkdown(mission, timeline, { prunePreview, generatedAt, timelineMeta: timelineResult.meta }), 'utf8');
5324
- artifactPath = path.relative(root, outPath);
5325
- }
5326
5355
  const pruneSummary = missionTimelinePruneSummaryObject(prunePreview);
5327
5356
  const pruneDisplay = {
5328
5357
  label: 'Prune preview',
@@ -5587,6 +5616,10 @@ function timelineMission(args) {
5587
5616
  if (artifactPath) lines.push(`Saved: ${artifactPath}`);
5588
5617
  const pruneLine = missionTimelinePruneSummaryLine(prunePreview);
5589
5618
  if (pruneLine) lines.push(pruneLine);
5619
+ return { payload, lines };
5620
+ }
5621
+
5622
+ function renderTimeline(payload, lines, asJson) {
5590
5623
  printJsonOrText(payload, lines, asJson);
5591
5624
  }
5592
5625
 
@@ -5756,8 +5789,8 @@ function watchMission(args) {
5756
5789
  return;
5757
5790
  }
5758
5791
  const ref = stripKnownFlags(args, ['--interval', '--idle-every'], [])[0] || '';
5759
- const intervalSeconds = Math.max(1, parseInt(readFlag(args, '--interval', '2'), 10) || 2);
5760
- const idleEverySeconds = Math.max(1, parseInt(readFlag(args, '--idle-every', '30'), 10) || 30);
5792
+ const intervalSeconds = Math.max(1, readIntFlag(args, '--interval', 2) || 2);
5793
+ const idleEverySeconds = Math.max(1, readIntFlag(args, '--idle-every', 30) || 30);
5761
5794
  const loadTargets = () => {
5762
5795
  if (ref) {
5763
5796
  const mission = resolveMission(ref);
@@ -5857,19 +5890,29 @@ function runVerifier(command, root = process.cwd()) {
5857
5890
  if (!command) return null;
5858
5891
  const resolvedCommand = resolveVerifierCommand(command);
5859
5892
  const { envWithNodeDir } = require('../lib/spawn-env');
5893
+ const timeoutMs = missionVerifierTimeoutMs();
5894
+ const startedAt = Date.now();
5860
5895
  const result = spawnSync(resolvedCommand, {
5861
5896
  cwd: root,
5862
5897
  shell: true,
5863
5898
  encoding: 'utf8',
5864
- timeout: missionVerifierTimeoutMs(),
5899
+ timeout: timeoutMs,
5865
5900
  env: envWithNodeDir(process.env),
5866
5901
  });
5902
+ const elapsedMs = Date.now() - startedAt;
5903
+ // A verifier that blows its window is not a failing check: the code may be
5904
+ // fine and the suite merely slow (or starved by a concurrent flight). Report
5905
+ // it as a distinct timed-out state so operators do not read slow as broken.
5906
+ const timedOut = result.error ? result.error.code === 'ETIMEDOUT' : false;
5867
5907
  return {
5868
5908
  command,
5869
5909
  resolved_command: resolvedCommand === command ? null : resolvedCommand,
5870
5910
  status: result.status,
5871
5911
  signal: result.signal || null,
5872
5912
  passed: result.status === 0,
5913
+ timed_out: timedOut,
5914
+ elapsed_ms: elapsedMs,
5915
+ timeout_ms: timeoutMs,
5873
5916
  stdout: String(result.stdout || '').slice(-4000),
5874
5917
  stderr: String(result.stderr || '').slice(-4000),
5875
5918
  };
@@ -5952,8 +5995,14 @@ function gitWorktreeSnapshot(root = process.cwd()) {
5952
5995
  }
5953
5996
  const entries = String(status.stdout || '').split(/\r?\n/).filter(Boolean).sort();
5954
5997
  const digest = crypto.createHash('sha1').update(entries.join('\n')).digest('hex');
5998
+ const head = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], {
5999
+ cwd: root,
6000
+ encoding: 'utf8',
6001
+ timeout: 5000,
6002
+ });
5955
6003
  return {
5956
6004
  available: true,
6005
+ head: head.status === 0 ? String(head.stdout || '').trim() : null,
5957
6006
  dirty_count: entries.length,
5958
6007
  dirty_hash: digest,
5959
6008
  dirty_sample: entries.slice(0, 25),
@@ -5961,6 +6010,47 @@ function gitWorktreeSnapshot(root = process.cwd()) {
5961
6010
  };
5962
6011
  }
5963
6012
 
6013
+ function missionProtectedTags(mission, root = process.cwd()) {
6014
+ const tags = [
6015
+ mission?.tag,
6016
+ mission?.lane,
6017
+ ...(Array.isArray(mission?.tags) ? mission.tags : []),
6018
+ ].filter(Boolean);
6019
+ const taskRefs = new Set([
6020
+ mission?.task_id,
6021
+ mission?.task_ref,
6022
+ ...(Array.isArray(mission?.task_ids) ? mission.task_ids : []),
6023
+ ].filter(Boolean).map(String));
6024
+ if (!taskRefs.size) return tags;
6025
+ const projection = readJson(path.join(root, '.atris', 'state', 'tasks.projection.json'));
6026
+ for (const task of Array.isArray(projection?.tasks) ? projection.tasks : []) {
6027
+ if (!task || ![task.id, task.display_id, task.legacy_ref].some((ref) => taskRefs.has(String(ref || '')))) continue;
6028
+ if (task.tag) tags.push(task.tag);
6029
+ if (Array.isArray(task.tags)) tags.push(...task.tags);
6030
+ }
6031
+ return tags;
6032
+ }
6033
+
6034
+ function inspectMissionTickProtectedDiff(mission, before, root = process.cwd()) {
6035
+ const after = gitWorktreeSnapshot(root);
6036
+ if (before?.reason === 'not-git-worktree' && after?.reason === 'not-git-worktree') {
6037
+ return {
6038
+ ok: true,
6039
+ allowed: true,
6040
+ status: 'clear',
6041
+ reason: 'no git worktree exists, so this tick cannot commit or land',
6042
+ unreadable: false,
6043
+ surfaces: [],
6044
+ matches: [],
6045
+ };
6046
+ }
6047
+ return inspectMissionProtectedDiff({
6048
+ root,
6049
+ baseRef: before?.head || '',
6050
+ tags: missionProtectedTags(mission, root),
6051
+ });
6052
+ }
6053
+
5964
6054
  // Porcelain v1 entries are "XY path" or "XY old -> new"; baselines compare by
5965
6055
  // post-rename path so a status-letter change alone never counts as new dirt.
5966
6056
  function porcelainEntryPath(line) {
@@ -6001,18 +6091,13 @@ function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
6001
6091
  dirty_hash: snapshot.dirty_hash,
6002
6092
  paths: Array.from(paths).sort(),
6003
6093
  };
6004
- fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
6005
- fs.writeFileSync(baselineFile, JSON.stringify(baseline, null, 2) + '\n', 'utf8');
6094
+ writeJson(baselineFile, baseline);
6006
6095
  return baseline;
6007
6096
  }
6008
6097
 
6009
6098
  function loadMissionWorktreeBaseline(missionId, root = process.cwd()) {
6010
- try {
6011
- const baseline = JSON.parse(fs.readFileSync(missionBaselinePath(missionId, root), 'utf8'));
6012
- return Array.isArray(baseline?.paths) ? baseline : null;
6013
- } catch {
6014
- return null;
6015
- }
6099
+ const baseline = readJson(missionBaselinePath(missionId, root));
6100
+ return Array.isArray(baseline?.paths) ? baseline : null;
6016
6101
  }
6017
6102
 
6018
6103
  // Closed missions no longer tick, so the sidecar is dead weight; prune it and
@@ -6091,6 +6176,12 @@ const MISSION_RUN_DEFAULTS = {
6091
6176
  backoff: { initialMs: 30_000, maxMs: 10 * 60_000, factor: 2, jitter: 0.3 },
6092
6177
  };
6093
6178
 
6179
+ // Clamp a remaining-wall timeout to [1, capMs]. Used by spawn plumbing so a
6180
+ // nearly-expired wall cannot schedule a zero or multi-hour child wait.
6181
+ function clampTimeoutMs(remainingMs, capMs = MISSION_RUN_DEFAULTS.claudeTimeoutMs) {
6182
+ return Math.min(capMs, Math.max(1, Math.floor(remainingMs)));
6183
+ }
6184
+
6094
6185
  // Claude sessions accumulate context across resumed ticks; an always-on
6095
6186
  // mission would grow without bound. Continuity lives on disk (receipts, logs,
6096
6187
  // now.md), so a healthy session is disposable: rotate to a fresh one every N
@@ -6100,6 +6191,25 @@ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
6100
6191
  Number(process.env.ATRIS_CLAUDE_SESSION_ROTATE_TICKS) || 8,
6101
6192
  );
6102
6193
 
6194
+ // A mission that sits in a protected lane must not tick unattended: the
6195
+ // worker lands its own diffs, so the only safe moment to stop a wrong one is
6196
+ // before the tick fires. Same tag-plus-text routing the task lane uses
6197
+ // (CLI-1189). An operator override is explicit: protected_lane_ack in the
6198
+ // mission metadata, set by a human, or ATRIS_ALLOW_PROTECTED_MISSION=1.
6199
+ function missionProtectedLaneHold(mission) {
6200
+ if (process.env.ATRIS_ALLOW_PROTECTED_MISSION === '1') return null;
6201
+ if (mission?.metadata?.protected_lane_ack) return null;
6202
+ const { DENIED_TAGS } = require('../lib/fleet');
6203
+ const { declaredProtectedLane, sniffedProtectedLane } = require('../lib/auto-accept-certified');
6204
+ const lane = String(mission?.lane || '').toLowerCase();
6205
+ if (DENIED_TAGS.includes(lane)) return { pause_reason: `protected-lane-${lane}` };
6206
+ const probe = { title: mission?.objective, objective: mission?.stop_condition, metadata: mission?.metadata || {} };
6207
+ if (declaredProtectedLane(probe)) return { pause_reason: 'protected-lane-declared' };
6208
+ const sniffed = sniffedProtectedLane(probe);
6209
+ if (sniffed) return { pause_reason: `protected-lane-${sniffed.lane}` };
6210
+ return null;
6211
+ }
6212
+
6103
6213
  function runnerUsesCallerSession(runner) {
6104
6214
  return new Set(['codex_goal', 'caller_session', 'current_agent']).has(String(runner || '').trim().toLowerCase());
6105
6215
  }
@@ -6222,8 +6332,7 @@ function missionHeartbeatLines(mission, now = new Date()) {
6222
6332
  }
6223
6333
 
6224
6334
  function missionHasHumanAsks(mission) {
6225
- return Array.isArray(mission?.human_asks)
6226
- && mission.human_asks.some((ask) => String(ask || '').trim());
6335
+ return openHumanAsks(mission?.human_asks).length > 0;
6227
6336
  }
6228
6337
 
6229
6338
  function missionTaskHumanAcceptWaiting(mission) {
@@ -6264,7 +6373,14 @@ function missionSortTime(mission) {
6264
6373
  }
6265
6374
 
6266
6375
  function selectDueMission(root = process.cwd(), now = new Date(), options = {}) {
6267
- const candidates = listMissions(root)
6376
+ const missions = listMissions(root);
6377
+ for (const mission of missions) {
6378
+ if (!missionIsRunnable(mission) || !missionObjectiveIsJunk(mission.objective)) continue;
6379
+ stopMissionRecord(mission, 'junk_objective_gate', root, { writeStopReceipt: false });
6380
+ console.error(`auto-stopped junk mission: "${String(mission.objective || '')}"`);
6381
+ }
6382
+ const candidates = missions
6383
+ .filter((mission) => !missionObjectiveIsJunk(mission.objective))
6268
6384
  .filter((mission) => missionSelectableForLoop(mission, now))
6269
6385
  .filter((mission) => !options.headlessOnly || !runnerUsesCallerSession(mission.runner))
6270
6386
  .filter((mission) => !missionTaskHumanAcceptWaiting(mission))
@@ -6303,7 +6419,10 @@ function missionSelectableForCodexGoal(mission, now = new Date()) {
6303
6419
  function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Date()) {
6304
6420
  const requestedId = String(options.missionId || '').trim();
6305
6421
  const candidates = listMissions(root)
6306
- .filter((mission) => runnerUsesCallerSession(mission.runner))
6422
+ // caller_session/current_agent missions run in this process, but they do
6423
+ // not own the native Codex goal handshake. Keep selection aligned with
6424
+ // ackMissionGoal(), which intentionally accepts only codex_goal runners.
6425
+ .filter((mission) => isCodexGoalMission(mission))
6307
6426
  .filter((mission) => missionSelectableForCodexGoal(mission, now));
6308
6427
  if (requestedId) {
6309
6428
  const exact = candidates.find((mission) => missionMatchesRef(mission, requestedId));
@@ -6318,10 +6437,6 @@ function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Da
6318
6437
  const bRank = missionGoalSelectionRank(b);
6319
6438
  if (aRank !== bRank) return aRank - bRank;
6320
6439
 
6321
- const aCaller = runnerUsesCallerSession(a.runner) ? 1 : 0;
6322
- const bCaller = runnerUsesCallerSession(b.runner) ? 1 : 0;
6323
- if (aCaller !== bCaller) return bCaller - aCaller;
6324
-
6325
6440
  const aVerifier = effectiveMissionVerifier(a) ? 1 : 0;
6326
6441
  const bVerifier = effectiveMissionVerifier(b) ? 1 : 0;
6327
6442
  if (aVerifier !== bVerifier) return bVerifier - aVerifier;
@@ -6614,19 +6729,13 @@ function writeDirectRunCodexGoalRequest(mission, root = process.cwd()) {
6614
6729
  requested_at: stampIso(),
6615
6730
  };
6616
6731
  const paths = statePaths(root);
6617
- fs.mkdirSync(path.dirname(paths.codexGoalRequestJson), { recursive: true });
6618
- fs.writeFileSync(paths.codexGoalRequestJson, JSON.stringify(request, null, 2) + '\n', 'utf8');
6732
+ writeJson(paths.codexGoalRequestJson, request);
6619
6733
  return request;
6620
6734
  }
6621
6735
 
6622
6736
  function readDirectRunCodexGoalRequest(root = process.cwd(), now = new Date()) {
6623
6737
  const file = statePaths(root).codexGoalRequestJson;
6624
- let request = null;
6625
- try {
6626
- request = JSON.parse(fs.readFileSync(file, 'utf8'));
6627
- } catch {
6628
- return null;
6629
- }
6738
+ const request = readJson(file);
6630
6739
  const missionId = String(request?.mission_id || '').trim();
6631
6740
  if (!missionId) return null;
6632
6741
  const mission = resolveMission(missionId, root);
@@ -6637,13 +6746,9 @@ function readDirectRunCodexGoalRequest(root = process.cwd(), now = new Date()) {
6637
6746
 
6638
6747
  function clearDirectRunCodexGoalRequestForMission(missionId, root = process.cwd()) {
6639
6748
  const file = statePaths(root).codexGoalRequestJson;
6640
- let request = null;
6641
- try {
6642
- request = JSON.parse(fs.readFileSync(file, 'utf8'));
6643
- } catch {
6644
- return false;
6645
- }
6646
- if (String(request?.mission_id || '') !== String(missionId || '')) return false;
6749
+ const request = readJson(file);
6750
+ if (!request) return false;
6751
+ if (String(request.mission_id || '') !== String(missionId || '')) return false;
6647
6752
  try {
6648
6753
  fs.rmSync(file, { force: true });
6649
6754
  return true;
@@ -6961,8 +7066,7 @@ function writeCodexGoalState(payload, root = process.cwd()) {
6961
7066
  updated_at: stampIso(),
6962
7067
  ...payload,
6963
7068
  };
6964
- fs.mkdirSync(path.dirname(paths.codexGoalJson), { recursive: true });
6965
- fs.writeFileSync(paths.codexGoalJson, JSON.stringify(state, null, 2) + '\n', 'utf8');
7069
+ writeJson(paths.codexGoalJson, state);
6966
7070
 
6967
7071
  const lines = [
6968
7072
  '# Codex Goal Controller',
@@ -7233,8 +7337,7 @@ function writeAtrisGoalState(payload, root = process.cwd()) {
7233
7337
  updated_at: stampIso(),
7234
7338
  ...payload,
7235
7339
  };
7236
- fs.mkdirSync(path.dirname(paths.atrisGoalJson), { recursive: true });
7237
- fs.writeFileSync(paths.atrisGoalJson, JSON.stringify(state, null, 2) + '\n', 'utf8');
7340
+ writeJson(paths.atrisGoalJson, state);
7238
7341
 
7239
7342
  const lines = [
7240
7343
  '# Atris Goal Controller',
@@ -7476,8 +7579,37 @@ function missionJudgmentTopic(mission) {
7476
7579
  return plain.split(/\s+/).filter(Boolean).slice(0, 14).join(' ');
7477
7580
  }
7478
7581
 
7582
+ // Words that leave a title hanging mid-thought when they land at the end:
7583
+ // articles, prepositions, conjunctions, subordinators, and auxiliary/linking
7584
+ // verbs. A hard 8-word slice of an objective often stops on one of these
7585
+ // ("...runner away from", "...say work is"), which reads as a truncation bug.
7586
+ const DANGLING_TAIL_WORDS = new Set([
7587
+ 'a', 'an', 'the',
7588
+ 'of', 'to', 'in', 'on', 'at', 'for', 'from', 'with', 'by', 'into', 'onto',
7589
+ 'over', 'under', 'about', 'after', 'before', 'between', 'through', 'during',
7590
+ 'without', 'within', 'away', 'up', 'off', 'out',
7591
+ 'and', 'or', 'but', 'nor', 'so', 'yet', 'when', 'while', 'if', 'that',
7592
+ 'which', 'because', 'although', 'though', 'since', 'whether', 'as',
7593
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'am', 'has', 'have',
7594
+ 'had', 'do', 'does', 'did', 'will', 'would', 'shall', 'should', 'can',
7595
+ 'could', 'may', 'might', 'must',
7596
+ ]);
7597
+
7598
+ // Drop trailing function words so the title ends where a thought ends, never
7599
+ // on a preposition or auxiliary. Falls back to the raw slice if trimming would
7600
+ // empty the title.
7601
+ function trimDanglingTail(words) {
7602
+ const trimmed = [...words];
7603
+ while (trimmed.length > 1 && DANGLING_TAIL_WORDS.has(trimmed[trimmed.length - 1].toLowerCase())) {
7604
+ trimmed.pop();
7605
+ }
7606
+ return trimmed.length ? trimmed : words;
7607
+ }
7608
+
7479
7609
  function missionJudgmentTitle(mission) {
7480
- const words = missionJudgmentTopic(mission).split(/\s+/).filter(Boolean).slice(0, 8);
7610
+ const words = trimDanglingTail(
7611
+ missionJudgmentTopic(mission).split(/\s+/).filter(Boolean).slice(0, 8),
7612
+ );
7481
7613
  const title = words.join(' ') || 'Mission needs a new direction';
7482
7614
  return title.charAt(0).toUpperCase() + title.slice(1);
7483
7615
  }
@@ -7642,12 +7774,11 @@ function missionDriverPaths(mission, root = process.cwd()) {
7642
7774
  }
7643
7775
 
7644
7776
  function readMissionDriverState(stateFile) {
7645
- try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; }
7777
+ return readJson(stateFile, {}) || {};
7646
7778
  }
7647
7779
 
7648
7780
  function writeMissionDriverState(stateFile, state) {
7649
- fs.mkdirSync(path.dirname(stateFile), { recursive: true });
7650
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n', 'utf8');
7781
+ writeJson(stateFile, state);
7651
7782
  }
7652
7783
 
7653
7784
  function missionDriverError(error) {
@@ -7740,10 +7871,8 @@ function installDetachedMissionDriverLifecycle(mission, root = process.cwd()) {
7740
7871
 
7741
7872
  function missionDriverHealth(mission, root = process.cwd()) {
7742
7873
  const paths = missionDriverPaths(mission, root);
7743
- let driverState = null;
7744
- let lockState = null;
7745
- try { driverState = JSON.parse(fs.readFileSync(paths.stateFile, 'utf8')); } catch {}
7746
- try { lockState = JSON.parse(fs.readFileSync(paths.lockFile, 'utf8')); } catch {}
7874
+ const driverState = readJson(paths.stateFile);
7875
+ const lockState = readJson(paths.lockFile);
7747
7876
  const candidates = [
7748
7877
  driverState ? { ...driverState, source: 'driver_state' } : null,
7749
7878
  lockState ? { ...lockState, source: 'mission_lock' } : null,
@@ -7778,8 +7907,7 @@ function acquireMissionLock(missionId, root = process.cwd(), options = {}) {
7778
7907
  return { ok: true, lockFile, fd, missionId, driverPid: process.pid, startedAt, recordLength: record.length };
7779
7908
  } catch (e) {
7780
7909
  if (e.code === 'EEXIST') {
7781
- let info = {};
7782
- try { info = JSON.parse(fs.readFileSync(lockFile, 'utf8') || '{}'); } catch {}
7910
+ const info = readJson(lockFile, {}) || {};
7783
7911
  const holderPid = Number(info.pid);
7784
7912
  const holderKnown = Number.isInteger(holderPid) && holderPid > 0;
7785
7913
  // A lock is created empty (openSync 'wx') and its pid record is written a
@@ -7876,6 +8004,14 @@ function spawnMissionDriver(mission, root = process.cwd()) {
7876
8004
  log_path: logPath,
7877
8005
  }, null, 2) + '\n', 'utf8');
7878
8006
  child.unref();
8007
+ // A returned pid is not proof the engine is running: spawn can hand back a
8008
+ // pid for a child that never execs or exits immediately. Verify the process
8009
+ // is actually alive before claiming a live worker, and drop the misleading
8010
+ // driver-state file when it is not.
8011
+ if (!child.pid || !missionLockOwnerIsAlive(child.pid)) {
8012
+ try { fs.rmSync(paths.stateFile, { force: true }); } catch {}
8013
+ return { ok: false, error: 'mission driver exited before it could start', pid: child.pid || null };
8014
+ }
7879
8015
  return {
7880
8016
  ok: true,
7881
8017
  pid: child.pid,
@@ -7949,7 +8085,13 @@ function detachMissionRun(args, mission, root = process.cwd(), asJson = false) {
7949
8085
 
7950
8086
  function probeClaudeBinary() {
7951
8087
  const runnerBin = resolveClaudeRunnerBin();
7952
- if (resolveClaudeRunnerCommandTemplate()) {
8088
+ const commandTemplate = resolveClaudeRunnerCommandTemplate();
8089
+ if (commandTemplate) {
8090
+ // A command template fully replaces the claude invocation (GLM/OpenAI/
8091
+ // local runners). The runner bin only matters when the template actually
8092
+ // references {bin}; requiring `claude` on PATH otherwise defeats the
8093
+ // template's purpose and breaks generic runners on claude-less machines.
8094
+ if (!commandTemplate.includes('{bin}')) return { ok: true };
7953
8095
  const probe = spawnSync('sh', ['-c', `command -v ${shellQuote(runnerBin)}`], { encoding: 'utf8', timeout: 8000 });
7954
8096
  if (probe.status !== 0) return { ok: false, error: `${runnerBin} CLI not found` };
7955
8097
  return { ok: true };
@@ -8046,8 +8188,9 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = []) {
8046
8188
  if (mission.task_ids?.length) {
8047
8189
  lines.push('', `## Task ids`, mission.task_ids.map((t) => `- ${t}`).join('\n'));
8048
8190
  }
8049
- if (mission.human_asks?.length) {
8050
- lines.push('', `## Human asks (don't act on these — surface them)`, mission.human_asks.map((t) => `- ${t}`).join('\n'));
8191
+ const humanAsks = openHumanAsks(mission.human_asks);
8192
+ if (humanAsks.length) {
8193
+ lines.push('', `## Human asks (don't act on these — surface them)`, humanAsks.map((ask) => `- ${ask.text}`).join('\n'));
8051
8194
  }
8052
8195
  return lines.join('\n');
8053
8196
  }
@@ -8072,6 +8215,9 @@ function detectUnavailableModel(text) {
8072
8215
  // need a resume; a model-unavailable pause is a config error a bare resume won't fix,
8073
8216
  // so name the dead id and the two knobs that change it.
8074
8217
  function missionPauseNextAction(pauseReason, missionId, deadModel = null, lastErrorReason = null) {
8218
+ if (pauseReason === 'protected-lane-review') {
8219
+ return `review the protected diff and its receipt before resuming: atris mission run ${missionId}`;
8220
+ }
8075
8221
  if (pauseReason === 'too-deep') {
8076
8222
  return 'stopped: helpers were spawning helpers too many levels deep';
8077
8223
  }
@@ -8260,13 +8406,31 @@ function spawnGenericRunnerTick(mission, opts) {
8260
8406
  resolve({ ok: false, error: e.message, sessionIds: [], aborted: false, timedOut: false, authExpired: false, brief_id: briefId });
8261
8407
  return;
8262
8408
  }
8409
+ let gitGuard;
8410
+ try {
8411
+ gitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd) });
8412
+ } catch (error) {
8413
+ resolve({
8414
+ ok: false,
8415
+ reason: 'protected-diff-unreadable',
8416
+ error: error.message || String(error),
8417
+ protected_lane_guard: unreadableMissionGuard(error),
8418
+ sessionIds: [],
8419
+ aborted: false,
8420
+ timedOut: false,
8421
+ authExpired: false,
8422
+ brief_id: briefId,
8423
+ });
8424
+ return;
8425
+ }
8263
8426
 
8264
8427
  const startedAt = Date.now();
8265
- const proc = spawn('sh', ['-lc', cmd], {
8428
+ const guardedCommand = `PATH=${shellQuote(gitGuard.pathPrefix)}:$PATH; export PATH; ${cmd}`;
8429
+ const proc = spawn('sh', ['-lc', guardedCommand], {
8266
8430
  cwd,
8267
8431
  stdio: ['ignore', 'pipe', 'pipe'],
8268
8432
  detached: true,
8269
- env: { ...process.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
8433
+ env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
8270
8434
  });
8271
8435
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
8272
8436
  let stdout = '';
@@ -8297,6 +8461,7 @@ function spawnGenericRunnerTick(mission, opts) {
8297
8461
  proc.on('close', (code) => {
8298
8462
  clearTimeout(timer);
8299
8463
  cleanupPrompt();
8464
+ gitGuard.cleanup();
8300
8465
  if (signal) signal.removeEventListener?.('abort', onAbort);
8301
8466
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
8302
8467
  const finalText = String(stdout || '').trim();
@@ -8325,6 +8490,7 @@ function spawnGenericRunnerTick(mission, opts) {
8325
8490
  proc.on('error', (e) => {
8326
8491
  clearTimeout(timer);
8327
8492
  cleanupPrompt();
8493
+ gitGuard.cleanup();
8328
8494
  if (signal) signal.removeEventListener?.('abort', onAbort);
8329
8495
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
8330
8496
  resolve({ ok: false, error: e.message, sessionIds: [], aborted, timedOut, authExpired: false, brief_id: briefId });
@@ -8373,6 +8539,22 @@ function spawnClaudeTick(mission, opts) {
8373
8539
  const dispatchDepth = missionDispatchDepth();
8374
8540
  if (dispatchDepth.tooDeep) return Promise.resolve(tooDeepRunnerResult(dispatchDepth));
8375
8541
  return new Promise((resolve) => {
8542
+ let gitGuard;
8543
+ try {
8544
+ gitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd) });
8545
+ } catch (error) {
8546
+ resolve({
8547
+ ok: false,
8548
+ reason: 'protected-diff-unreadable',
8549
+ error: error.message || String(error),
8550
+ protected_lane_guard: unreadableMissionGuard(error),
8551
+ sessionIds: [],
8552
+ aborted: false,
8553
+ timedOut: false,
8554
+ authExpired: false,
8555
+ });
8556
+ return;
8557
+ }
8376
8558
  const args = [
8377
8559
  '-p', prompt,
8378
8560
  '--output-format', 'stream-json',
@@ -8392,7 +8574,7 @@ function spawnClaudeTick(mission, opts) {
8392
8574
  cwd,
8393
8575
  stdio: ['ignore', 'pipe', 'pipe'],
8394
8576
  detached: true,
8395
- env: { ...process.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
8577
+ env: { ...gitGuard.env, ATRIS_DISPATCH_DEPTH: String(dispatchDepth.depth + 1) },
8396
8578
  });
8397
8579
  updateMissionLockOwner(missionLock, proc.pid, { phase: 'tick' });
8398
8580
 
@@ -8455,6 +8637,7 @@ function spawnClaudeTick(mission, opts) {
8455
8637
 
8456
8638
  proc.on('close', (code) => {
8457
8639
  clearTimeout(timer);
8640
+ gitGuard.cleanup();
8458
8641
  if (signal) signal.removeEventListener?.('abort', onAbort);
8459
8642
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
8460
8643
  const ok = code === 0 && !isError && !timedOut && !aborted;
@@ -8484,6 +8667,7 @@ function spawnClaudeTick(mission, opts) {
8484
8667
 
8485
8668
  proc.on('error', (e) => {
8486
8669
  clearTimeout(timer);
8670
+ gitGuard.cleanup();
8487
8671
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
8488
8672
  resolve({ ok: false, error: e.message, sessionIds: [], aborted, timedOut, authExpired: false });
8489
8673
  });
@@ -8582,16 +8766,28 @@ function cappedClaudeReceiptText(text, limit = 4000) {
8582
8766
  return clean.slice(0, limit - 16).trimEnd() + '\n...[truncated]';
8583
8767
  }
8584
8768
 
8585
- async function runMission(args) {
8769
+ async function parseAndValidateMissionRunPhase(args) {
8586
8770
  const asJson = wantsJson(args);
8771
+ const context = {
8772
+ args,
8773
+ asJson,
8774
+ cwd: process.cwd(),
8775
+ handled: false,
8776
+ returnValue: undefined,
8777
+ lock: null,
8778
+ error: null,
8779
+ };
8587
8780
  if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
8588
8781
  help();
8589
- return;
8782
+ context.handled = true;
8783
+ return context;
8590
8784
  }
8591
8785
  if (hasFlag(args, '--cloud')) {
8592
8786
  const result = await runCloudMissionCommand(args);
8593
8787
  process.exitCode = result.exitCode;
8594
- return result;
8788
+ context.handled = true;
8789
+ context.returnValue = result;
8790
+ return context;
8595
8791
  }
8596
8792
  // --fleet: staff every idle capable engine on the board's claimable
8597
8793
  // safe-lane tasks, build in parallel worktrees, land serially. Humble flag,
@@ -8610,7 +8806,8 @@ async function runMission(args) {
8610
8806
  });
8611
8807
  if (asJson) console.log(JSON.stringify(flight, null, 2));
8612
8808
  process.exitCode = flight.paused && flight.paused.length > 0 ? 1 : 0;
8613
- return;
8809
+ context.handled = true;
8810
+ return context;
8614
8811
  }
8615
8812
  const dueMode = hasFlag(args, '--due');
8616
8813
  const headlessOnly = hasFlag(args, '--headless');
@@ -8646,22 +8843,70 @@ async function runMission(args) {
8646
8843
  const ref = input.ref;
8647
8844
  const runArgs = input.args;
8648
8845
 
8846
+ Object.assign(context, {
8847
+ dueMode,
8848
+ headlessOnly,
8849
+ selfDrive,
8850
+ skipClaude,
8851
+ verifyEach,
8852
+ completeOnPass,
8853
+ skipDrain,
8854
+ createNext,
8855
+ budgetTier,
8856
+ maxTicksFlag,
8857
+ maxTicks,
8858
+ maxWallFlag,
8859
+ explicitMaxWallSeconds,
8860
+ runBudgetContract,
8861
+ maxWallSeconds,
8862
+ cadenceOverride,
8863
+ runnerOverride,
8864
+ modelOverride,
8865
+ runtimeView,
8866
+ input,
8867
+ ref,
8868
+ runArgs,
8869
+ });
8870
+ return context;
8871
+ }
8872
+
8873
+ async function resolveMissionRunPhase(context) {
8874
+ const {
8875
+ args,
8876
+ asJson,
8877
+ cwd,
8878
+ dueMode,
8879
+ headlessOnly,
8880
+ budgetTier,
8881
+ maxTicksFlag,
8882
+ maxWallFlag,
8883
+ explicitMaxWallSeconds,
8884
+ runBudgetContract,
8885
+ runtimeView,
8886
+ input,
8887
+ ref,
8888
+ runArgs,
8889
+ } = context;
8890
+ let { maxTicks, maxWallSeconds } = context;
8891
+
8649
8892
  if (!dueMode && !ref) {
8650
8893
  if (asJson || !process.stdin.isTTY || !process.stderr.isTTY) {
8651
8894
  missionRunInputRequired(asJson, input.owner);
8652
8895
  }
8653
8896
  const prompted = await promptMissionRunInput(runArgs);
8654
8897
  await startMissionFromRunObjective(prompted.objective, prompted.args);
8898
+ context.handled = true;
8655
8899
  return;
8656
8900
  }
8657
8901
 
8658
- let mission = dueMode && !ref ? selectDueMission(process.cwd(), new Date(), { headlessOnly }) : resolveMission(ref);
8902
+ let mission = dueMode && !ref ? selectDueMission(cwd, new Date(), { headlessOnly }) : resolveMission(ref, cwd);
8659
8903
  if (!mission && dueMode && !ref) {
8660
8904
  printJsonOrText(
8661
8905
  { ok: true, action: 'run_skipped', reason: 'no_due_mission', mission: null },
8662
8906
  ['No due mission found.'],
8663
8907
  asJson,
8664
8908
  );
8909
+ context.handled = true;
8665
8910
  return;
8666
8911
  }
8667
8912
  // BCK-1319: a bare single token that looks like an id/suffix/number (no
@@ -8675,6 +8920,7 @@ async function runMission(args) {
8675
8920
  }
8676
8921
  if (!mission && ref) {
8677
8922
  await startMissionFromRunObjective(ref, runArgs);
8923
+ context.handled = true;
8678
8924
  return;
8679
8925
  }
8680
8926
  if (!mission) {
@@ -8688,7 +8934,8 @@ async function runMission(args) {
8688
8934
  } else if (!maxWallFlag && Number(mission.budget_contract?.requested_seconds) > 0) {
8689
8935
  maxWallSeconds = Math.max(60, Number(mission.budget_contract.requested_seconds));
8690
8936
  }
8691
- const detachedDriverLifecycle = installDetachedMissionDriverLifecycle(mission, process.cwd());
8937
+ const detachedDriverLifecycle = installDetachedMissionDriverLifecycle(mission, cwd);
8938
+ context.detachedDriverLifecycle = detachedDriverLifecycle;
8692
8939
  if (['complete', 'stopped'].includes(mission.status)) {
8693
8940
  detachedDriverLifecycle?.setExitReason(mission.status);
8694
8941
  if (asJson) {
@@ -8697,18 +8944,20 @@ async function runMission(args) {
8697
8944
  [],
8698
8945
  true,
8699
8946
  );
8947
+ context.handled = true;
8700
8948
  return;
8701
8949
  }
8702
8950
  console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
8703
8951
  process.exit(0);
8704
8952
  }
8705
8953
  if (hasFlag(args, '--detach')) {
8706
- detachMissionRun(args, mission, process.cwd(), asJson);
8954
+ detachMissionRun(args, mission, cwd, asJson);
8955
+ context.handled = true;
8707
8956
  return;
8708
8957
  }
8709
8958
 
8710
8959
  const nativeGoalRunOptions = codexNativeGoalOptionsFromArgs(args);
8711
- const autoAck = maybeAutoAckCodexNativeGoal(mission, process.cwd(), nativeGoalRunOptions);
8960
+ const autoAck = maybeAutoAckCodexNativeGoal(mission, cwd, nativeGoalRunOptions);
8712
8961
  if (autoAck) mission = autoAck.saved;
8713
8962
  maybeBlockUntilCodexNativeGoalStarted(runtimeView(mission), asJson, nativeGoalRunOptions);
8714
8963
 
@@ -8717,37 +8966,63 @@ async function runMission(args) {
8717
8966
  // (e.g. --engine cursor) never invokes claude. The in-lock probe below runs
8718
8967
  // after applyMissionRunnerProfile and checks the right binary.
8719
8968
 
8720
- const lock = acquireMissionLock(mission.id);
8969
+ const lock = acquireMissionLock(mission.id, cwd);
8721
8970
  if (!lock.ok) {
8722
8971
  exitMissionError(`another driver is already running mission ${mission.id} (pid ${lock.holder?.pid || '?'})`, 3, asJson);
8723
8972
  }
8724
8973
 
8725
- // Everything past lock acquisition runs inside try/finally so the lock + signal handlers
8726
- // always get cleaned up — including saveMission failures during pending-session setup.
8727
- let pauseReason = null;
8728
- let sessionId = null;
8729
- let pendingSessionId = null;
8730
- let ranTicks = 0;
8731
- const ticks = [];
8732
- let onSig = null;
8733
- let restoreRunnerProfile = null;
8734
- let blocker = null;
8974
+ Object.assign(context, {
8975
+ mission,
8976
+ maxTicks,
8977
+ maxWallSeconds,
8978
+ nativeGoalRunOptions,
8979
+ lock,
8980
+ pauseReason: null,
8981
+ sessionId: null,
8982
+ pendingSessionId: null,
8983
+ ranTicks: 0,
8984
+ ticks: [],
8985
+ onSig: null,
8986
+ restoreRunnerProfile: null,
8987
+ blocker: null,
8988
+ });
8989
+ }
8735
8990
 
8736
- try {
8737
- const cwd = process.cwd();
8991
+ async function executeMissionRunTicksPhase(context) {
8992
+ const {
8993
+ asJson,
8994
+ cwd,
8995
+ explicitMaxWallSeconds,
8996
+ runBudgetContract,
8997
+ runtimeView,
8998
+ runnerOverride,
8999
+ skipClaude,
9000
+ verifyEach,
9001
+ completeOnPass,
9002
+ skipDrain,
9003
+ maxTicksFlag,
9004
+ budgetTier,
9005
+ cadenceOverride,
9006
+ detachedDriverLifecycle,
9007
+ lock,
9008
+ nativeGoalRunOptions,
9009
+ } = context;
9010
+ let { mission, maxTicks, maxWallSeconds, pauseReason, sessionId, pendingSessionId, ranTicks } = context;
9011
+ const ticks = context.ticks;
8738
9012
  const controller = new AbortController();
8739
- onSig = (signal) => {
9013
+ context.controller = controller;
9014
+ context.onSig = (signal) => {
8740
9015
  detachedDriverLifecycle?.setExitReason(`signal-${String(signal || 'abort').toLowerCase()}`);
8741
9016
  controller.abort();
8742
9017
  };
8743
- process.on('SIGINT', onSig);
8744
- process.on('SIGTERM', onSig);
9018
+ process.on('SIGINT', context.onSig);
9019
+ process.on('SIGTERM', context.onSig);
8745
9020
 
8746
9021
  // Re-read inside the lock. The initial resolveMission ran pre-lock, so a concurrent
8747
9022
  // `mission tick` could have written between resolveMission and acquireMissionLock.
8748
9023
  // Derive sessionId, pendingSessionId, and the frozen contract from the fresh record
8749
9024
  // so a fast tick's writes can't be silently overwritten by this run loop.
8750
- mission = resolveMission(mission.id) || mission;
9025
+ mission = resolveMission(mission.id, cwd) || mission;
8751
9026
  if (explicitMaxWallSeconds && runBudgetContract) {
8752
9027
  mission = saveMission({
8753
9028
  ...mission,
@@ -8762,10 +9037,12 @@ async function runMission(args) {
8762
9037
  if (['complete', 'stopped'].includes(mission.status)) {
8763
9038
  detachedDriverLifecycle?.setExitReason(mission.status);
8764
9039
  console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
9040
+ context.handled = true;
8765
9041
  return;
8766
9042
  }
8767
9043
  if (returnIfCodexNativeGoalNotStarted(runtimeMission, asJson, nativeGoalRunOptions)) {
8768
9044
  detachedDriverLifecycle?.setExitReason('native-goal-not-started');
9045
+ context.handled = true;
8769
9046
  return;
8770
9047
  }
8771
9048
  if (mission.status === 'paused') {
@@ -8782,7 +9059,7 @@ async function runMission(args) {
8782
9059
  sessionId = mission.claude_session_id || null;
8783
9060
  pendingSessionId = mission.pending_session_id || null;
8784
9061
  const autoRunner = String(runtimeMission.runner || '').trim().toLowerCase() === MISSION_AUTO_RUNNER;
8785
- restoreRunnerProfile = autoRunner ? () => {} : applyMissionRunnerProfile(runtimeMission.runner);
9062
+ context.restoreRunnerProfile = autoRunner ? () => {} : applyMissionRunnerProfile(runtimeMission.runner);
8786
9063
  const callerSessionRunner = runnerUsesCallerSession(runtimeMission.runner);
8787
9064
  const runnerName = String(runtimeMission.runner || '').trim().toLowerCase();
8788
9065
  const atris2Runner = runnerName === 'atris2';
@@ -8826,6 +9103,10 @@ async function runMission(args) {
8826
9103
  const startedAt = Date.now();
8827
9104
  let backoffAttempt = 0;
8828
9105
  let lastRateLimit = null;
9106
+ // A usage-wall cooldown belongs to the engine that hit it, not the whole run.
9107
+ // Tracking which engine set it lets an auto-runner swap to another ready engine
9108
+ // and keep working instead of waiting the wall out (or pausing for a human).
9109
+ let rateLimitedEngineId = null;
8829
9110
  let continuationGoal = null;
8830
9111
 
8831
9112
  const sessionLabel = skipWorker
@@ -8845,7 +9126,7 @@ async function runMission(args) {
8845
9126
  // Re-read before measuring the wall. Detached full-budget drivers use
8846
9127
  // the mission contract as their wall so process startup drift cannot
8847
9128
  // shorten the promised work window.
8848
- mission = resolveMission(mission.id) || mission;
9129
+ mission = resolveMission(mission.id, cwd) || mission;
8849
9130
  runtimeMission = runtimeView(mission);
8850
9131
  const contractualRemaining = detachedDriverLifecycle && mission.always_on && missionSpendsFullBudget(mission)
8851
9132
  ? missionFullBudgetRemainingSeconds(mission)
@@ -8862,6 +9143,8 @@ async function runMission(args) {
8862
9143
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
8863
9144
  if (effectiveMissionVerifier(mission) !== storedVerifier) { pauseReason = 'verifier-mutated'; break; }
8864
9145
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
9146
+ const protectedHold = missionProtectedLaneHold(mission);
9147
+ if (protectedHold) { pauseReason = protectedHold.pause_reason; break; }
8865
9148
 
8866
9149
  const tickIdx = Number(mission.last_tick_index || 0) + 1;
8867
9150
  const tickStart = stampIso();
@@ -8885,6 +9168,18 @@ async function runMission(args) {
8885
9168
  result.engine_fallback_reason = tickSelection.engine_fallback_reason;
8886
9169
  }
8887
9170
 
9171
+ // Usage-wall engine swap: when a prior tick's engine hit a rate-limit wall and
9172
+ // the auto runner has already resolved this tick to a *different* ready engine
9173
+ // (the walled one is now credit_out and excluded from resolution), the run-wide
9174
+ // cooldown no longer applies. Clear it so the fresh engine runs now instead of
9175
+ // the loop sleeping through — or pausing on `rate-limit-exceeded-wall` for — a
9176
+ // wall this engine never hit.
9177
+ if (lastRateLimit && rateLimitedEngineId && tickEngineId && tickEngineId !== rateLimitedEngineId) {
9178
+ result.engine_swapped_from = rateLimitedEngineId;
9179
+ lastRateLimit = null;
9180
+ rateLimitedEngineId = null;
9181
+ }
9182
+
8888
9183
  // Active-hours gate
8889
9184
  if (engineBackedTick && !tickEngineId) {
8890
9185
  result = { ...result, status: 'errored', reason: 'no-ready-engine' };
@@ -8916,55 +9211,76 @@ async function runMission(args) {
8916
9211
  drill: drillResult,
8917
9212
  };
8918
9213
  } else if (tickAtris2Runner) {
8919
- const pingDrain = consumeMissionPings(mission, cwd);
8920
- mission = pingDrain.mission;
8921
- runtimeMission = runtimeView(mission);
8922
- const prompt = buildTickPrompt(tickRuntimeMission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
8923
- const { runAtris2Turn } = require('./probe');
8924
- const businessId = businessIdForAtris2Mission(tickRuntimeMission, cwd);
8925
- const tickController = new AbortController();
8926
- let wallExpired = false;
8927
- const abortTick = () => tickController.abort();
8928
- controller.signal.addEventListener('abort', abortTick, { once: true });
8929
- const tickTimer = setTimeout(() => {
8930
- wallExpired = true;
8931
- tickController.abort();
8932
- }, Math.max(1, Math.floor(remainingWall * 1000)));
8933
- let turn;
9214
+ let atris2GitGuard = null;
8934
9215
  try {
8935
- turn = await runAtris2Turn({
8936
- prompt,
8937
- model: tickRuntimeMission.model || 'atris:fast',
8938
- business: businessId,
8939
- maxTurns: 16,
8940
- signal: tickController.signal,
8941
- // CLI-231: mission ticks execute relayed ops in the mission's own
8942
- // workspace, not the hosted ai-computer filesystem.
8943
- localCwd: cwd,
8944
- });
8945
- } finally {
8946
- clearTimeout(tickTimer);
8947
- controller.signal.removeEventListener('abort', abortTick);
9216
+ atris2GitGuard = prepareMissionGitGuard({ root: cwd, tags: missionProtectedTags(mission, cwd) });
9217
+ } catch (error) {
9218
+ result = {
9219
+ ...result,
9220
+ status: 'ran',
9221
+ reason: 'protected-diff-unreadable',
9222
+ ran: true,
9223
+ atris2: {
9224
+ ok: false,
9225
+ error: error.message || String(error),
9226
+ protected_lane_guard: unreadableMissionGuard(error),
9227
+ },
9228
+ };
8948
9229
  }
8949
- result.atris2 = {
8950
- ok: turn.ok,
8951
- engine: turn.engine,
8952
- model: tickRuntimeMission.model || 'atris:fast',
8953
- tools_run: turn.tools_run,
8954
- unsupported: turn.unsupported,
8955
- duration_ms: turn.duration_ms,
8956
- error: turn.error,
8957
- backend_unavailable: isTransientAtris2BackendError(turn.error) || undefined,
8958
- receipt_text: String(turn.text || '').slice(0, 4000),
8959
- };
8960
- if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
8961
- if (turn.error === 'not-logged-in') { pauseReason = 'auth-required'; break; }
8962
- if (wallExpired) {
8963
- result = { ...result, status: 'errored', reason: 'wall-exceeded-during-tick' };
8964
- } else if (!turn.ok || !String(turn.text || '').trim()) {
8965
- result = { ...result, status: 'errored', reason: atris2TurnErrorReason(turn.error) };
8966
- } else {
8967
- result = { ...result, status: 'ran', reason: 'tick-ok', ran: true };
9230
+ if (atris2GitGuard) {
9231
+ const pingDrain = consumeMissionPings(mission, cwd);
9232
+ mission = pingDrain.mission;
9233
+ runtimeMission = runtimeView(mission);
9234
+ const prompt = buildTickPrompt(tickRuntimeMission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
9235
+ const { runAtris2Turn } = require('./probe');
9236
+ const businessId = businessIdForAtris2Mission(tickRuntimeMission, cwd);
9237
+ const tickController = new AbortController();
9238
+ let wallExpired = false;
9239
+ const abortTick = () => tickController.abort();
9240
+ controller.signal.addEventListener('abort', abortTick, { once: true });
9241
+ const tickTimer = setTimeout(() => {
9242
+ wallExpired = true;
9243
+ tickController.abort();
9244
+ }, Math.max(1, Math.floor(remainingWall * 1000)));
9245
+ let turn;
9246
+ try {
9247
+ turn = await runAtris2Turn({
9248
+ prompt,
9249
+ model: tickRuntimeMission.model || 'atris:fast',
9250
+ business: businessId,
9251
+ maxTurns: 16,
9252
+ signal: tickController.signal,
9253
+ // CLI-231: mission ticks execute relayed ops in the mission's own
9254
+ // workspace, not the hosted ai-computer filesystem.
9255
+ localCwd: cwd,
9256
+ localEnv: atris2GitGuard.env,
9257
+ localPathPrefix: atris2GitGuard.pathPrefix,
9258
+ });
9259
+ } finally {
9260
+ atris2GitGuard.cleanup();
9261
+ clearTimeout(tickTimer);
9262
+ controller.signal.removeEventListener('abort', abortTick);
9263
+ }
9264
+ result.atris2 = {
9265
+ ok: turn.ok,
9266
+ engine: turn.engine,
9267
+ model: tickRuntimeMission.model || 'atris:fast',
9268
+ tools_run: turn.tools_run,
9269
+ unsupported: turn.unsupported,
9270
+ duration_ms: turn.duration_ms,
9271
+ error: turn.error,
9272
+ backend_unavailable: isTransientAtris2BackendError(turn.error) || undefined,
9273
+ receipt_text: String(turn.text || '').slice(0, 4000),
9274
+ };
9275
+ if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
9276
+ if (turn.error === 'not-logged-in') { pauseReason = 'auth-required'; break; }
9277
+ if (wallExpired) {
9278
+ result = { ...result, status: 'errored', reason: 'wall-exceeded-during-tick' };
9279
+ } else if (!turn.ok || !String(turn.text || '').trim()) {
9280
+ result = { ...result, status: 'errored', reason: atris2TurnErrorReason(turn.error) };
9281
+ } else {
9282
+ result = { ...result, status: 'ran', reason: 'tick-ok', ran: true };
9283
+ }
8968
9284
  }
8969
9285
  } else {
8970
9286
  let sessionMode = sessionId ? 'resume' : 'set';
@@ -8979,9 +9295,9 @@ async function runMission(args) {
8979
9295
  const runClaudeSession = () => spawnClaudeTick(tickRuntimeMission, {
8980
9296
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
8981
9297
  missionLock: lock,
8982
- timeoutMs: Math.min(
9298
+ timeoutMs: clampTimeoutMs(
9299
+ (maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000,
8983
9300
  MISSION_RUN_DEFAULTS.claudeTimeoutMs,
8984
- Math.max(1, Math.floor((maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000)),
8985
9301
  ),
8986
9302
  prompt,
8987
9303
  model: resolveClaudeRunnerModel(tickRuntimeMission),
@@ -9006,6 +9322,7 @@ async function runMission(args) {
9006
9322
  result.claude = {
9007
9323
  ok: claudeResult.ok,
9008
9324
  reason: claudeResult.reason,
9325
+ protected_lane_guard: claudeResult.protected_lane_guard || null,
9009
9326
  brief_id: claudeResult.brief_id || null,
9010
9327
  summary: claudeResult.summary,
9011
9328
  receipt_text: claudeResult.receipt_text,
@@ -9026,6 +9343,7 @@ async function runMission(args) {
9026
9343
  // treating an allowed resetsAt as one pauses every timed run after
9027
9344
  // tick 1 with rate-limit-exceeded-wall.
9028
9345
  lastRateLimit = claudeResult.rate_limit_info.status === 'allowed' ? null : claudeResult.rate_limit_info;
9346
+ rateLimitedEngineId = lastRateLimit ? tickEngineId : null;
9029
9347
  }
9030
9348
  if (claudeResult.aborted) { pauseReason = 'aborted-during-claude'; break; }
9031
9349
  if (claudeResult.authExpired) { pauseReason = 'auth-required'; break; }
@@ -9086,26 +9404,22 @@ async function runMission(args) {
9086
9404
  if (engineHealth) result.engine_health = engineHealth.health;
9087
9405
  }
9088
9406
 
9089
- // An explicit verifier is safe and useful in no-worker mode. The
9090
- // fallback engine verifier is itself worker activity, so --no-claude
9091
- // and caller-session ticks must not launch it behind the operator's
9092
- // back. Leaving verifier_passed unset also lets the idle-stop breaker
9093
- // judge these ticks from the worktree signal instead of hanging here.
9094
- let verifierResult = null;
9407
+ context.pauseReason = pauseReason;
9408
+ context.currentTick = {
9409
+ mission,
9410
+ result,
9411
+ tickWorktreeBefore,
9412
+ tickRuntimeMission,
9413
+ tickSkipWorker,
9414
+ tickIdx,
9415
+ frozen,
9416
+ };
9417
+ await verifyMissionRunTickPhase(context);
9418
+ result = context.currentTick.result;
9419
+ const verifierResult = context.currentTick.verifierResult;
9420
+ pauseReason = context.pauseReason;
9421
+ context.currentTick = null;
9095
9422
  let receiptPath = null;
9096
- if (result.status === 'ran' && verifyEach) {
9097
- if (frozen.verifier) {
9098
- verifierResult = runVerifier(frozen.verifier);
9099
- } else if (!tickSkipWorker) {
9100
- verifierResult = await runEngineVerifier(tickRuntimeMission, {
9101
- cwd,
9102
- signal: controller.signal,
9103
- tickIndex: tickIdx,
9104
- });
9105
- }
9106
- if (verifierResult) result.verifier_passed = verifierResult.passed;
9107
- }
9108
- stampMissionRunnerBrief(cwd, result.claude?.brief_id, result, verifierResult);
9109
9423
 
9110
9424
  // Review-lane drain: always-on loops sweep the agent-safe review actions
9111
9425
  // each tick so proof-backed work reaches certified on cadence with zero
@@ -9246,6 +9560,7 @@ async function runMission(args) {
9246
9560
  break;
9247
9561
  }
9248
9562
 
9563
+ if (['mission-diff-unreadable', 'protected-lane-review'].includes(pauseReason)) break;
9249
9564
  if (callerSessionRunner && result.status === 'ran') break;
9250
9565
  if (newStatus === 'complete' || (newStatus === 'ready' && !mission.always_on && !fullBudgetMode)) break;
9251
9566
  // BCK-1324: two consecutive ticks that each self-report "ran" but leave no
@@ -9293,7 +9608,7 @@ async function runMission(args) {
9293
9608
  if (lastTick && lastTick.status !== 'ran' && !missionRunKeepsRetryingError(lastTick.reason)) pauseReason = 'max-ticks-reached';
9294
9609
  }
9295
9610
 
9296
- mission = resolveMission(mission.id) || mission;
9611
+ mission = resolveMission(mission.id, cwd) || mission;
9297
9612
  const remainingBudgetSeconds = missionFullBudgetRemainingSeconds(mission);
9298
9613
  const explicitExit = ['complete', 'stopped', 'paused'].includes(String(mission.status || ''));
9299
9614
  const healthyCycleBoundary = !pauseReason || pauseReason === 'max-ticks-reached';
@@ -9325,6 +9640,97 @@ async function runMission(args) {
9325
9640
  cycleTickLimit = ticks.length + effectiveMaxTicks;
9326
9641
  }
9327
9642
 
9643
+ Object.assign(context, {
9644
+ mission,
9645
+ pauseReason,
9646
+ sessionId,
9647
+ pendingSessionId,
9648
+ ranTicks,
9649
+ frozen,
9650
+ runWorktreeBefore,
9651
+ runWorktreeBaseline,
9652
+ effectiveMaxTicks,
9653
+ startedAt,
9654
+ continuationGoal,
9655
+ runtimeMission,
9656
+ });
9657
+ }
9658
+
9659
+ async function verifyMissionRunTickPhase(context) {
9660
+ const { cwd, verifyEach, controller, currentTick } = context;
9661
+ let { result } = currentTick;
9662
+
9663
+ // An explicit verifier is safe and useful in no-worker mode. The fallback
9664
+ // engine verifier is worker activity, so no-worker ticks must not launch it.
9665
+ const runnerGuard = result.claude?.protected_lane_guard || result.atris2?.protected_lane_guard || null;
9666
+ if (result.status === 'ran' || runnerGuard) {
9667
+ const protectedLaneGuard = runnerGuard?.allowed === false
9668
+ ? runnerGuard
9669
+ : inspectMissionTickProtectedDiff(currentTick.mission, currentTick.tickWorktreeBefore, cwd);
9670
+ result.protected_lane_guard = protectedLaneGuard;
9671
+ if (!protectedLaneGuard.allowed) {
9672
+ const guardPauseReason = protectedLaneGuard.unreadable
9673
+ ? 'mission-diff-unreadable'
9674
+ : 'protected-lane-review';
9675
+ result.status = protectedLaneGuard.status;
9676
+ result.reason = guardPauseReason;
9677
+ result.ran = false;
9678
+ context.pauseReason = guardPauseReason;
9679
+ }
9680
+ }
9681
+
9682
+ let verifierResult = null;
9683
+ if (result.status === 'ran' && verifyEach) {
9684
+ if (currentTick.frozen.verifier) {
9685
+ verifierResult = runVerifier(currentTick.frozen.verifier);
9686
+ } else if (!currentTick.tickSkipWorker) {
9687
+ verifierResult = await runEngineVerifier(currentTick.tickRuntimeMission, {
9688
+ cwd,
9689
+ signal: controller.signal,
9690
+ tickIndex: currentTick.tickIdx,
9691
+ });
9692
+ }
9693
+ if (verifierResult) result.verifier_passed = verifierResult.passed;
9694
+ }
9695
+ stampMissionRunnerBrief(cwd, result.claude?.brief_id, result, verifierResult);
9696
+ currentTick.result = result;
9697
+ currentTick.verifierResult = verifierResult;
9698
+ }
9699
+
9700
+ function completeMissionRunPhase(context) {
9701
+ const {
9702
+ asJson,
9703
+ cwd,
9704
+ selfDrive,
9705
+ createNext,
9706
+ runtimeView,
9707
+ runnerOverride,
9708
+ runBudgetContract,
9709
+ detachedDriverLifecycle,
9710
+ lock,
9711
+ frozen,
9712
+ runWorktreeBefore,
9713
+ runWorktreeBaseline,
9714
+ effectiveMaxTicks,
9715
+ startedAt,
9716
+ } = context;
9717
+ const ticks = context.ticks || [];
9718
+ let {
9719
+ mission,
9720
+ pauseReason,
9721
+ ranTicks,
9722
+ sessionId,
9723
+ blocker,
9724
+ continuationGoal,
9725
+ } = context;
9726
+
9727
+ try {
9728
+ if (context.error) {
9729
+ detachedDriverLifecycle?.finish('run-error', context.error);
9730
+ return;
9731
+ }
9732
+ if (context.handled) return;
9733
+
9328
9734
  // BCK-1324: no-progress is a clean, honest stop — the run did what it
9329
9735
  // could and correctly recognized there was nothing left to do. It is NOT
9330
9736
  // a failure/blocker: pausing it (resumable, retried by cron/self-drive)
@@ -9438,18 +9844,35 @@ async function runMission(args) {
9438
9844
  detachedDriverLifecycle?.finish('run-error', error);
9439
9845
  throw error;
9440
9846
  } finally {
9441
- if (onSig) {
9442
- try { process.removeListener('SIGINT', onSig); } catch {}
9443
- try { process.removeListener('SIGTERM', onSig); } catch {}
9847
+ if (context.onSig) {
9848
+ try { process.removeListener('SIGINT', context.onSig); } catch {}
9849
+ try { process.removeListener('SIGTERM', context.onSig); } catch {}
9444
9850
  }
9445
- if (restoreRunnerProfile) {
9446
- try { restoreRunnerProfile(); } catch {}
9851
+ if (context.restoreRunnerProfile) {
9852
+ try { context.restoreRunnerProfile(); } catch {}
9447
9853
  }
9448
9854
  releaseMissionLock(lock);
9449
9855
  detachedDriverLifecycle?.finish();
9450
9856
  }
9451
9857
  }
9452
9858
 
9859
+ async function runMission(args) {
9860
+ const context = await parseAndValidateMissionRunPhase(args);
9861
+ if (context.handled) return context.returnValue;
9862
+
9863
+ try {
9864
+ await resolveMissionRunPhase(context);
9865
+ if (!context.handled) await executeMissionRunTicksPhase(context);
9866
+ } catch (error) {
9867
+ context.error = error;
9868
+ throw error;
9869
+ } finally {
9870
+ if (context.lock) completeMissionRunPhase(context);
9871
+ }
9872
+
9873
+ return context.returnValue;
9874
+ }
9875
+
9453
9876
  function tickMission(args) {
9454
9877
  const asJson = wantsJson(args);
9455
9878
  if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
@@ -9516,6 +9939,7 @@ function tickMission(args) {
9516
9939
  const tickIdx = lastTickIndex + 1;
9517
9940
  const tickWorktreeBefore = gitWorktreeSnapshot(cwd);
9518
9941
  const worktreeBaseline = loadMissionWorktreeBaseline(mission.id, cwd);
9942
+ const protectedLaneGuard = inspectMissionTickProtectedDiff(mission, tickWorktreeBefore, cwd);
9519
9943
 
9520
9944
  const effectiveVerifier = effectiveMissionVerifier(mission);
9521
9945
  const verifierCommand = verify
@@ -9524,7 +9948,7 @@ function tickMission(args) {
9524
9948
  if (verifierCommand) assertMissionVerifier(verifierCommand, asJson);
9525
9949
 
9526
9950
  let verifierResult = null;
9527
- if (verify && verifierCommand) {
9951
+ if (protectedLaneGuard.allowed && verify && verifierCommand) {
9528
9952
  verifierResult = runVerifier(verifierCommand);
9529
9953
  }
9530
9954
  const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: verifierCommand || effectiveVerifier, baseline: worktreeBaseline });
@@ -9532,17 +9956,21 @@ function tickMission(args) {
9532
9956
  // Same layer classification as the run-tick path; manual ticks carry their
9533
9957
  // receipt text in --summary.
9534
9958
  const layerInfo = extractLayerFromReceiptText(summary || '', tickWorktree?.new_since_baseline_sample);
9959
+ const guardPauseReason = protectedLaneGuard.unreadable
9960
+ ? 'mission-diff-unreadable'
9961
+ : 'protected-lane-review';
9535
9962
  const tickRecord = {
9536
- status: 'ran',
9537
- reason: 'tick-recorded',
9963
+ status: protectedLaneGuard.allowed ? 'ran' : protectedLaneGuard.status,
9964
+ reason: protectedLaneGuard.allowed ? 'tick-recorded' : guardPauseReason,
9538
9965
  tick_index: tickIdx,
9539
- ran: true,
9966
+ ran: protectedLaneGuard.allowed,
9540
9967
  started_at: tickStart,
9541
9968
  claude: { skipped: true, reason: 'orchestrator-is-caller-session' },
9542
9969
  summary: summary || null,
9543
9970
  layer: layerInfo.layer,
9544
9971
  layer_source: layerInfo.source,
9545
9972
  verifier_passed: verifierResult ? !!verifierResult.passed : null,
9973
+ protected_lane_guard: protectedLaneGuard,
9546
9974
  finished_at: stampIso(),
9547
9975
  worktree: tickWorktree,
9548
9976
  };
@@ -9555,6 +9983,7 @@ function tickMission(args) {
9555
9983
  started_at: tickStart,
9556
9984
  },
9557
9985
  verifier_result: verifierResult,
9986
+ protected_lane_guard: protectedLaneGuard,
9558
9987
  rate_limit_info: null,
9559
9988
  worktree: tickWorktree,
9560
9989
  });
@@ -9566,7 +9995,12 @@ function tickMission(args) {
9566
9995
  ? nextCandidateTickAction(mission)
9567
9996
  : 'attach task, verifier, or proof');
9568
9997
  const nextGoalChain = advanceMissionGoalChain(mission.goal_chain, summary, verifierResult);
9569
- if (verifierResult?.passed && nextGoalChain && !nextGoalChain.pause_ready) {
9998
+ if (!protectedLaneGuard.allowed) {
9999
+ status = 'paused';
10000
+ nextAction = protectedLaneGuard.unreadable
10001
+ ? `repair the mission diff tooling failure, then rerun: atris mission run ${mission.id}`
10002
+ : `review the protected diff and receipt before resuming: atris mission run ${mission.id}`;
10003
+ } else if (verifierResult?.passed && nextGoalChain && !nextGoalChain.pause_ready) {
9570
10004
  status = 'running';
9571
10005
  nextAction = missionGoalChainNextAction(nextGoalChain);
9572
10006
  } else if (verifierResult?.passed) {
@@ -9591,10 +10025,10 @@ function tickMission(args) {
9591
10025
  }
9592
10026
  const clearsPauseState = !['paused', 'stopped'].includes(status);
9593
10027
  const nextMission = {
9594
- ...mission,
9595
- status,
9596
- paused_at: clearsPauseState ? null : mission.paused_at || null,
9597
- stop_reason: clearsPauseState ? null : mission.stop_reason || null,
10028
+ ...mission,
10029
+ status,
10030
+ paused_at: clearsPauseState ? null : tickRecord.finished_at,
10031
+ stop_reason: clearsPauseState ? null : tickRecord.reason,
9598
10032
  resumed_at: clearsPauseState && mission.status === 'paused' ? tickRecord.finished_at : mission.resumed_at || null,
9599
10033
  receipt_path: receiptPath,
9600
10034
  last_tick_at: tickRecord.finished_at,
@@ -9654,12 +10088,8 @@ function tickMission(args) {
9654
10088
  // (free text, command strings, missing paths) reads as null and falls back
9655
10089
  // to durable mission state.
9656
10090
  function readReceiptProof(proof, root = process.cwd()) {
9657
- try {
9658
- const parsed = JSON.parse(fs.readFileSync(path.resolve(root, String(proof || '')), 'utf8'));
9659
- return parsed?.schema === 'atris.mission_receipt.v1' ? parsed : null;
9660
- } catch {
9661
- return null;
9662
- }
10091
+ const parsed = readJson(path.resolve(root, String(proof || '')));
10092
+ return parsed?.schema === 'atris.mission_receipt.v1' ? parsed : null;
9663
10093
  }
9664
10094
 
9665
10095
  function receiptShowsPass(receipt) {
@@ -9698,6 +10128,11 @@ function missionCompletionGate(mission, proof, root = process.cwd()) {
9698
10128
 
9699
10129
  function completeMission(args) {
9700
10130
  const asJson = wantsJson(args);
10131
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
10132
+ console.log('Usage: atris mission complete <id> --proof "..."');
10133
+ console.log('Run `atris mission --help` for the full option list.');
10134
+ process.exit(0);
10135
+ }
9701
10136
  const force = hasFlag(args, '--force');
9702
10137
  const proof = readFlag(args, '--proof', '');
9703
10138
  const ref = stripKnownFlags(args, ['--proof'], ['--json', '--force'])[0] || '';
@@ -9773,31 +10208,21 @@ function completeMission(args) {
9773
10208
  );
9774
10209
  }
9775
10210
 
9776
- function stopMission(args) {
9777
- const asJson = wantsJson(args);
9778
- const reason = readFlag(args, '--reason', 'stopped by operator');
9779
- const pause = hasFlag(args, '--pause');
9780
- const ref = stripKnownFlags(args, ['--reason'], ['--json', '--pause'])[0] || '';
9781
- if (!ref) {
9782
- exitMissionError('Usage: atris mission stop <id> [--pause] [--reason "..."]', 1, asJson);
9783
- }
9784
- const mission = resolveMission(ref);
9785
- if (!mission) {
9786
- exitMissingMission(ref, 1, asJson);
9787
- }
10211
+ function stopMissionRecord(mission, reason, root = process.cwd(), options = {}) {
10212
+ const pause = options.pause === true;
9788
10213
  const status = pause ? 'paused' : 'stopped';
9789
10214
  // Full stops abandon work, so leave evidence: snapshot the worktree against
9790
10215
  // the mission baseline (what did this mission leave dirty?) before pruning it.
9791
10216
  let receiptPath = null;
9792
- if (!pause) {
9793
- const snapshot = gitWorktreeSnapshot(process.cwd());
10217
+ if (!pause && options.writeStopReceipt !== false) {
10218
+ const snapshot = gitWorktreeSnapshot(root);
9794
10219
  const worktree = worktreeReceipt(snapshot, snapshot, {
9795
10220
  verifier: mission.verifier,
9796
- baseline: loadMissionWorktreeBaseline(mission.id, process.cwd()),
10221
+ baseline: loadMissionWorktreeBaseline(mission.id, root),
9797
10222
  });
9798
- receiptPath = writeReceipt(mission, { kind: 'mission_stop', reason, worktree });
10223
+ receiptPath = writeReceipt(mission, { kind: 'mission_stop', reason, worktree }, root);
9799
10224
  }
9800
- const baselineSummary = pause ? null : pruneMissionWorktreeBaseline(mission, process.cwd());
10225
+ const baselineSummary = pause ? null : pruneMissionWorktreeBaseline(mission, root);
9801
10226
  const next = {
9802
10227
  ...mission,
9803
10228
  status,
@@ -9808,9 +10233,32 @@ function stopMission(args) {
9808
10233
  worktree_baseline: baselineSummary || mission.worktree_baseline || null,
9809
10234
  next_action: status === 'paused' ? `resume with: atris mission tick ${mission.id}` : 'mission stopped',
9810
10235
  };
9811
- const { mission: saved } = saveMission(next, process.cwd(), pause ? 'mission_paused' : 'mission_stopped', { reason, receipt_path: receiptPath });
9812
- const directGoalRequestCleared = pause ? false : clearDirectRunCodexGoalRequestForMission(saved.id, process.cwd());
9813
- const logPath = appendMemberLog(saved.owner, pause ? 'Mission paused' : 'Mission stopped', { mission: saved.objective, reason });
10236
+ const { mission: saved } = saveMission(next, root, pause ? 'mission_paused' : 'mission_stopped', { reason, receipt_path: receiptPath });
10237
+ const directGoalRequestCleared = pause ? false : clearDirectRunCodexGoalRequestForMission(saved.id, root);
10238
+ const logPath = appendMemberLog(saved.owner, pause ? 'Mission paused' : 'Mission stopped', { mission: saved.objective, reason }, root);
10239
+ return { saved, receiptPath, directGoalRequestCleared, logPath };
10240
+ }
10241
+
10242
+ function stopMission(args) {
10243
+ const asJson = wantsJson(args);
10244
+ // Help is read-only: `mission stop <id> --help` must never resolve the id and
10245
+ // stop the mission just to be asked for usage.
10246
+ if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
10247
+ console.log('Usage: atris mission stop <id> [--pause] [--reason "..."]');
10248
+ console.log('Run `atris mission --help` for the full option list.');
10249
+ process.exit(0);
10250
+ }
10251
+ const reason = readFlag(args, '--reason', 'stopped by operator');
10252
+ const pause = hasFlag(args, '--pause');
10253
+ const ref = stripKnownFlags(args, ['--reason'], ['--json', '--pause'])[0] || '';
10254
+ if (!ref) {
10255
+ exitMissionError('Usage: atris mission stop <id> [--pause] [--reason "..."]', 1, asJson);
10256
+ }
10257
+ const mission = resolveMission(ref);
10258
+ if (!mission) {
10259
+ exitMissingMission(ref, 1, asJson);
10260
+ }
10261
+ const { saved, receiptPath, directGoalRequestCleared, logPath } = stopMissionRecord(mission, reason, process.cwd(), { pause });
9814
10262
  printJsonOrText(
9815
10263
  { ok: true, action: pause ? 'mission_paused' : 'mission_stopped', mission: saved, receipt_path: receiptPath, log_path: logPath, direct_goal_request_cleared: directGoalRequestCleared },
9816
10264
  [
@@ -10343,12 +10791,7 @@ function layersMission(args) {
10343
10791
  }
10344
10792
  for (const file of files) {
10345
10793
  if (missionFilter && !file.includes(missionFilter)) continue;
10346
- let receipt;
10347
- try {
10348
- receipt = JSON.parse(fs.readFileSync(path.join(paths.runsDir, file), 'utf8'));
10349
- } catch {
10350
- continue;
10351
- }
10794
+ const receipt = readJson(path.join(paths.runsDir, file));
10352
10795
  if (sinceMs != null) {
10353
10796
  const atMs = Date.parse(receipt && receipt.at);
10354
10797
  if (Number.isNaN(atMs) || atMs < sinceMs) continue;
@@ -10506,6 +10949,11 @@ function inspectMission(args) {
10506
10949
  // output so callers (member ping) can compose their own multi-lane report.
10507
10950
  function pingMission(args, opts = {}) {
10508
10951
  const asJson = args.includes('--json');
10952
+ if (!opts.silent && (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help')) {
10953
+ console.log('Usage: atris mission ping <id> "<note>" [--from <who>] [--json]');
10954
+ console.log('Run `atris mission --help` for the full option list.');
10955
+ process.exit(0);
10956
+ }
10509
10957
  const rest = args.filter((a) => a !== '--json');
10510
10958
  let from = process.env.USER || 'operator';
10511
10959
  const fromIdx = rest.indexOf('--from');
@@ -10546,6 +10994,36 @@ function pingMission(args, opts = {}) {
10546
10994
  return saved;
10547
10995
  }
10548
10996
 
10997
+ function answerMissionHumanAsk(ref, askIndex, answer, note = '') {
10998
+ const found = findMissionAcrossWorktrees(ref);
10999
+ if (!found) throw new Error(`mission not found: ${ref}`);
11000
+ const { mission, root } = found;
11001
+ const humanAsks = normalizeHumanAsks(mission.human_asks);
11002
+ const ask = humanAsks[askIndex];
11003
+ if (!ask || !ask.text.trim()) throw new Error(`human ask not found at index ${askIndex}`);
11004
+ if (ask.answered_at) throw new Error('human ask is already answered');
11005
+ const normalizedAnswer = String(answer || '').toLowerCase();
11006
+ if (!['yes', 'no'].includes(normalizedAnswer)) throw new Error('human ask answer must be yes or no');
11007
+ const answeredAt = stampIso();
11008
+ humanAsks[askIndex] = {
11009
+ ...ask,
11010
+ answered_at: answeredAt,
11011
+ answer: normalizedAnswer,
11012
+ note: String(note || '').trim(),
11013
+ };
11014
+ return saveMission(
11015
+ { ...mission, human_asks: humanAsks },
11016
+ root,
11017
+ 'mission_human_ask_answered',
11018
+ {
11019
+ ask_index: askIndex,
11020
+ text: ask.text.slice(0, 200),
11021
+ answer: normalizedAnswer,
11022
+ note: String(note || '').trim(),
11023
+ },
11024
+ ).mission;
11025
+ }
11026
+
10549
11027
  function missionCommand(args) {
10550
11028
  const subcommand = args[0] || 'status';
10551
11029
  const rest = args.slice(1);
@@ -10654,6 +11132,7 @@ function missionCommand(args) {
10654
11132
  }
10655
11133
 
10656
11134
  module.exports = {
11135
+ missionProtectedLaneHold,
10657
11136
  missionCommand,
10658
11137
  startMission,
10659
11138
  spawnMissionDriver,
@@ -10670,10 +11149,12 @@ module.exports = {
10670
11149
  findActiveTwinMission,
10671
11150
  TWIN_ACTIVE_STATUSES,
10672
11151
  pingMission,
11152
+ answerMissionHumanAsk,
10673
11153
  buildTickPrompt,
10674
11154
  extractCheckFeedback,
10675
11155
  loadMissionMap,
10676
11156
  renderMissionStatus,
11157
+ missionObjectiveIsJunk,
10677
11158
  selectDueMission,
10678
11159
  selectAtrisGoalMission,
10679
11160
  selectCodexGoalMission,
@@ -10682,29 +11163,22 @@ module.exports = {
10682
11163
  cappedClaudeReceiptText,
10683
11164
  extractLayerFromReceiptText,
10684
11165
  classifyPathsByLayer,
10685
- collectMissionDoctorFindings,
10686
11166
  resolveClaudeRunnerModel,
10687
11167
  resolveClaudeRunnerBin,
10688
11168
  businessIdForAtris2Mission,
10689
11169
  detectUnavailableModel,
10690
11170
  missionPauseNextAction,
10691
- HUMAN_BLOCKING_PAUSE_REASONS,
10692
11171
  composeHumanBlockingPauseMessage,
10693
11172
  escalateHumanBlockingPause,
10694
- humanBlockingPauseReason,
10695
11173
  consecutiveSameReasonErrors,
10696
11174
  missionVerifierTimeoutMs,
10697
11175
  missionLandingStepSummary,
10698
11176
  missionLandingLines,
10699
11177
  missionVerifierCheckedText,
10700
11178
  missionVerifierHighLevelTestText,
10701
- missionTickVerification,
10702
- missionVerificationDebt,
10703
- missionVerificationDebtLine,
10704
- missionReportFor,
10705
- renderMemberNowMarkdown,
10706
11179
  buildEngineVerifyPrompt,
10707
11180
  engineVerifierResultFromRun,
11181
+ runVerifier,
10708
11182
  missionFullBudgetRemainingSeconds,
10709
11183
  missionBudgetContinuationText,
10710
11184
  missionHumanStatusText,
@@ -10718,4 +11192,6 @@ module.exports = {
10718
11192
  missionJudgmentCard,
10719
11193
  missionShippedCard,
10720
11194
  appendMissionJudgmentCard,
11195
+ acquireMissionLock,
11196
+ releaseMissionLock,
10721
11197
  };