atris 3.38.0 → 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 (77) hide show
  1. package/AGENTS.md +25 -6
  2. package/atris/PERSONA.md +8 -4
  3. package/atris.md +7 -0
  4. package/ax +2 -1
  5. package/bin/atris.js +30 -5
  6. package/commands/agent-spawn.js +13 -11
  7. package/commands/autoland.js +28 -77
  8. package/commands/bench.js +10 -12
  9. package/commands/business.js +345 -0
  10. package/commands/chat-scan.js +5 -7
  11. package/commands/codex-goal.js +8 -10
  12. package/commands/console.js +19 -3
  13. package/commands/decide.js +166 -0
  14. package/commands/deck.js +1 -4
  15. package/commands/drill.js +14 -24
  16. package/commands/engine.js +196 -8
  17. package/commands/gm.js +8 -6
  18. package/commands/harvest.js +1 -4
  19. package/commands/init.js +23 -3
  20. package/commands/land.js +8 -14
  21. package/commands/launchpad.js +1 -14
  22. package/commands/lifecycle.js +5 -5
  23. package/commands/log.js +55 -5
  24. package/commands/member.js +558 -574
  25. package/commands/mission.js +456 -237
  26. package/commands/pack.js +2746 -164
  27. package/commands/play.js +6 -4
  28. package/commands/probe.js +2 -2
  29. package/commands/pulse.js +15 -16
  30. package/commands/release.js +10 -9
  31. package/commands/router.js +5 -4
  32. package/commands/site-deploy.js +870 -0
  33. package/commands/site.js +11 -2
  34. package/commands/slop.js +14 -2
  35. package/commands/stream.js +4 -18
  36. package/commands/task.js +880 -558
  37. package/commands/taste.js +101 -0
  38. package/commands/team.js +83 -3
  39. package/commands/vercel.js +4 -2
  40. package/commands/voice.js +195 -0
  41. package/commands/watch.js +1 -22
  42. package/commands/wiki.js +1 -4
  43. package/commands/workflow.js +2 -2
  44. package/commands/worktree.js +1 -14
  45. package/commands/xp.js +27 -24
  46. package/lib/accept-verify-gate.js +5 -1
  47. package/lib/arg-parser.js +41 -0
  48. package/lib/auto-accept-certified.js +116 -1
  49. package/lib/autoland.js +66 -0
  50. package/lib/bench/runner.js +19 -1
  51. package/lib/context-gatherer.js +7 -1
  52. package/lib/engine-registry.js +141 -20
  53. package/lib/falsifier-probe.js +84 -0
  54. package/lib/fleet.js +65 -15
  55. package/lib/git-spawn.js +15 -0
  56. package/lib/json-file.js +37 -0
  57. package/lib/known-commands.js +2 -2
  58. package/lib/lesson-preflight.js +146 -0
  59. package/lib/loop-doctor.js +0 -2
  60. package/lib/mission-human-asks.js +28 -0
  61. package/lib/mission-protected-lane.js +4 -1
  62. package/lib/official-cli-integration.js +47 -2
  63. package/lib/orb-context.js +8 -1
  64. package/lib/pack-capabilities.js +685 -0
  65. package/lib/router-brain.js +51 -1
  66. package/lib/runner-command.js +0 -6
  67. package/lib/self-drive.js +44 -13
  68. package/lib/task-db.js +137 -3
  69. package/lib/task-decision.js +50 -0
  70. package/lib/taste-lessons.js +153 -0
  71. package/lib/tool-result-encode.js +17 -1
  72. package/lib/voice-gate.js +66 -0
  73. package/lib/wish-audit.js +1 -1
  74. package/lib/wish-delegate.js +1 -1
  75. package/lib/zip.js +95 -7
  76. package/package.json +2 -1
  77. 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,11 @@ 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');
81
87
  const {
82
88
  inspectMissionProtectedDiff,
83
89
  prepareMissionGitGuard,
@@ -318,28 +324,6 @@ function readMissionRouteProposal(args, asJson) {
318
324
  };
319
325
  }
320
326
 
321
- function hasFlag(args, name) {
322
- return args.includes(name);
323
- }
324
-
325
- function unquote(value) {
326
- const text = String(value);
327
- if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
328
- return text.slice(1, -1);
329
- }
330
- return text;
331
- }
332
-
333
- function readFlag(args, name, fallback = '') {
334
- const prefix = `${name}=`;
335
- for (let i = 0; i < args.length; i += 1) {
336
- const arg = String(args[i]);
337
- if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) return unquote(args[i + 1]);
338
- if (arg.startsWith(prefix)) return unquote(arg.slice(prefix.length));
339
- }
340
- return fallback;
341
- }
342
-
343
327
  const MISSION_NATIVE_RUNNER_NAMES = Object.freeze(['manual', 'claude', 'atris2', 'codex_goal', 'caller_session', 'current_agent', 'drill']);
344
328
  const MISSION_NATIVE_RUNNER_SET = new Set(MISSION_NATIVE_RUNNER_NAMES);
345
329
  const MISSION_AUTO_RUNNER = 'auto';
@@ -513,11 +497,11 @@ function readRepeatedFlag(args, name) {
513
497
  for (let i = 0; i < args.length; i += 1) {
514
498
  const arg = String(args[i]);
515
499
  if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
516
- values.push(unquote(args[i + 1]));
500
+ values.push(readFlag([name, args[i + 1]], name, ''));
517
501
  i += 1;
518
502
  continue;
519
503
  }
520
- if (arg.startsWith(prefix)) values.push(unquote(arg.slice(prefix.length)));
504
+ if (arg.startsWith(prefix)) values.push(readFlag([arg], name, ''));
521
505
  }
522
506
  return values.filter(Boolean);
523
507
  }
@@ -739,8 +723,7 @@ function loadTaskDb(asJson = false) {
739
723
  function writeMissionTaskProjection(taskDb, db, workspaceRoot) {
740
724
  const projection = taskDb.taskProjection(db, { workspaceRoot, limit: 500 });
741
725
  const outPath = path.join(workspaceRoot, '.atris', 'state', 'tasks.projection.json');
742
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
743
- fs.writeFileSync(outPath, JSON.stringify(projection, null, 2) + '\n', 'utf8');
726
+ writeJson(outPath, projection);
744
727
  return { projection, outPath };
745
728
  }
746
729
 
@@ -839,16 +822,13 @@ function statePaths(root = process.cwd()) {
839
822
 
840
823
  function readBusinessBinding(root = process.cwd()) {
841
824
  const file = path.join(root, '.atris', 'business.json');
842
- try {
843
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
844
- return {
845
- business_id: parsed.business_id || '',
846
- workspace_id: parsed.workspace_id || '',
847
- slug: parsed.slug || '',
848
- };
849
- } catch {
850
- return null;
851
- }
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
+ };
852
832
  }
853
833
 
854
834
  function businessIdForAtris2Mission(mission, cwd = process.cwd()) {
@@ -935,9 +915,11 @@ function terminalNextAction(status) {
935
915
 
936
916
  function normalizeMissionState(mission) {
937
917
  if (!mission) return mission;
938
- let normalized = mission;
939
- const nextAction = terminalNextAction(mission.status);
940
- 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) {
941
923
  normalized = { ...normalized, next_action: nextAction };
942
924
  }
943
925
  const effectiveVerifier = effectiveMissionVerifier(normalized);
@@ -1524,9 +1506,10 @@ function renderMemberNowMarkdown(owner, missions, root = process.cwd()) {
1524
1506
  if (mission.stop_condition) lines.push(`- stop: ${mission.stop_condition}`);
1525
1507
  if (budgetContinuation || mission.next_action) lines.push(`- next: ${budgetContinuation || mission.next_action}`);
1526
1508
  if (mission.receipt_path) lines.push(`- proof: ${missionStatusProofText(mission)}`);
1527
- if (mission.human_asks?.length) {
1509
+ const humanAsks = openHumanAsks(mission.human_asks);
1510
+ if (humanAsks.length) {
1528
1511
  lines.push('- human asks:');
1529
- for (const ask of mission.human_asks) lines.push(` - ${ask}`);
1512
+ for (const ask of humanAsks) lines.push(` - ${ask.text}`);
1530
1513
  }
1531
1514
  lines.push('');
1532
1515
  }
@@ -1734,7 +1717,7 @@ function missionVerifierCheckedText(verifierResult, mission) {
1734
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.';
1735
1718
  return `Verifier passed: ${command}.`;
1736
1719
  }
1737
- 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';
1738
1721
  if (/\bnode\s+--test\b/i.test(command)) return 'VERIFY FAILED: behavior checks failed.';
1739
1722
  return `VERIFY FAILED: ${command}.`;
1740
1723
  }
@@ -1755,7 +1738,8 @@ function missionVerifierHighLevelTestText(verifierResult, mission) {
1755
1738
  }
1756
1739
  const outcome = verifierResult.passed ? 'passed' : 'failed';
1757
1740
  if (/^git\s+diff\s+--check\b/i.test(command)) {
1758
- 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`;
1759
1743
  }
1760
1744
  if (/\bnode\s+--test\b/i.test(command) && /\btest\/mission-status\.test\.js\b/i.test(command)) {
1761
1745
  return `Mission behavior checks ${outcome}: mission start, tick, completion, timeline landing, goal-chain, next-mission, and human-accept boundaries were exercised.`;
@@ -2980,12 +2964,8 @@ function handledContinuationTargetKeys(root, moves) {
2980
2964
 
2981
2965
  function readTaskProjectionForMission(root = process.cwd()) {
2982
2966
  const file = path.join(root, '.atris', 'state', 'tasks.projection.json');
2983
- try {
2984
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
2985
- return Array.isArray(parsed?.tasks) ? parsed.tasks : [];
2986
- } catch {
2987
- return [];
2988
- }
2967
+ const parsed = readJson(file);
2968
+ return Array.isArray(parsed?.tasks) ? parsed.tasks : [];
2989
2969
  }
2990
2970
 
2991
2971
  function taskTags(task) {
@@ -3367,8 +3347,7 @@ function readRecentTasteLogs(root, owner, limit = 3) {
3367
3347
 
3368
3348
  function readTasteReviewHistory(root, limit = 4) {
3369
3349
  const file = path.join(root, '.atris', 'state', 'tasks.projection.json');
3370
- let projection = null;
3371
- try { projection = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { projection = null; }
3350
+ const projection = readJson(file);
3372
3351
  const tasks = Array.isArray(projection?.tasks) ? projection.tasks : [];
3373
3352
  const accepted = [];
3374
3353
  const revised = [];
@@ -4738,11 +4717,7 @@ function statusMission(args) {
4738
4717
  function readMissionReceipt(receiptPath, root = process.cwd()) {
4739
4718
  if (!receiptPath) return null;
4740
4719
  const file = path.isAbsolute(receiptPath) ? receiptPath : path.join(root, receiptPath);
4741
- try {
4742
- return JSON.parse(fs.readFileSync(file, 'utf8'));
4743
- } catch {
4744
- return null;
4745
- }
4720
+ return readJson(file);
4746
4721
  }
4747
4722
 
4748
4723
  function firstUsefulLine(text, fallback = '') {
@@ -4867,12 +4842,7 @@ function missionVerificationDebt(mission, root = process.cwd()) {
4867
4842
  let unchecked = 0;
4868
4843
  const seen = new Set();
4869
4844
  for (const file of files) {
4870
- let receipt = null;
4871
- try {
4872
- receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
4873
- } catch {
4874
- continue;
4875
- }
4845
+ const receipt = readJson(file);
4876
4846
  if (!receipt || receipt.mission_id !== mission.id) continue;
4877
4847
  for (const tick of missionReceiptTicks(receipt)) {
4878
4848
  if (!tick || tick.status !== 'ran') continue;
@@ -4909,12 +4879,7 @@ function missionReportTimeline(mission, root = process.cwd(), limit = 6) {
4909
4879
  const items = [];
4910
4880
  const seen = new Set();
4911
4881
  for (const file of files) {
4912
- let receipt = null;
4913
- try {
4914
- receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
4915
- } catch {
4916
- continue;
4917
- }
4882
+ const receipt = readJson(file);
4918
4883
  if (!receipt || receipt.mission_id !== mission.id) continue;
4919
4884
  const receiptPath = path.relative(root, file);
4920
4885
  for (const tick of missionReceiptTicks(receipt)) {
@@ -4957,12 +4922,7 @@ function missionLandingTimeline(mission, root = process.cwd(), limit = 12, { kin
4957
4922
  const items = [];
4958
4923
  const seen = new Set();
4959
4924
  for (const file of files) {
4960
- let receipt = null;
4961
- try {
4962
- receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
4963
- } catch {
4964
- continue;
4965
- }
4925
+ const receipt = readJson(file);
4966
4926
  if (!receipt || receipt.mission_id !== mission.id) continue;
4967
4927
  const receiptKind = receipt.result && receipt.result.kind ? String(receipt.result.kind) : (receipt.kind ? String(receipt.kind) : '');
4968
4928
  if (kindFilter && receiptKind !== kindFilter) continue;
@@ -5312,14 +5272,11 @@ function timelineMission(args) {
5312
5272
  const sinceFilter = readFlag(args, '--since', '') || null;
5313
5273
  const ref = stripKnownFlags(args, ['--limit', '--output', '--out', '--kind', '--since'], ['--json', '--write', '--all', '--prune-preview'])[0] || '';
5314
5274
  const limit = all ? Number.MAX_SAFE_INTEGER : readPositiveIntegerFlag(args, '--limit', 12, { json: asJson });
5315
- const missions = listMissions();
5316
- const mission = ref
5317
- ? resolveMission(ref)
5318
- : (missions.find((row) => !TERMINAL_STATUSES.has(row.status)) || missions[0] || null);
5319
- if (ref && !mission) {
5275
+ const data = loadTimelineData(ref, { limit, kindFilter, sinceFilter, write, prunePreviewRequested, outputPath });
5276
+ if (ref && !data.mission) {
5320
5277
  exitMissingMission(ref, 1, asJson);
5321
5278
  }
5322
- if (!mission) {
5279
+ if (!data.mission) {
5323
5280
  printJsonOrText(
5324
5281
  {
5325
5282
  ok: true,
@@ -5333,8 +5290,40 @@ function timelineMission(args) {
5333
5290
  );
5334
5291
  return;
5335
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 };
5336
5303
  const root = mission.worktree_root || process.cwd();
5337
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 }) {
5338
5327
  const timeline = timelineResult.items;
5339
5328
  const currentLanding = missionTimelineCurrentLanding(timeline);
5340
5329
  const timelineItemDisplay = (item) => ({
@@ -5363,24 +5352,6 @@ function timelineMission(args) {
5363
5352
  ...timelineItemDisplay(item),
5364
5353
  }));
5365
5354
  const nextMove = missionTimelineNextMove(mission, currentLanding);
5366
- const generatedAt = stampIso();
5367
- let artifactPath = null;
5368
- let prunePreview = null;
5369
- if (write || prunePreviewRequested) {
5370
- try {
5371
- prunePreview = pruneRuns(root, { keepNewest: 200, keepDays: 14 });
5372
- } catch (error) {
5373
- prunePreview = { error: error.message || String(error) };
5374
- }
5375
- }
5376
- if (write) {
5377
- const outPath = outputPath
5378
- ? path.resolve(root, outputPath)
5379
- : defaultMissionTimelinePath(root, mission);
5380
- fs.mkdirSync(path.dirname(outPath), { recursive: true });
5381
- fs.writeFileSync(outPath, missionTimelineMarkdown(mission, timeline, { prunePreview, generatedAt, timelineMeta: timelineResult.meta }), 'utf8');
5382
- artifactPath = path.relative(root, outPath);
5383
- }
5384
5355
  const pruneSummary = missionTimelinePruneSummaryObject(prunePreview);
5385
5356
  const pruneDisplay = {
5386
5357
  label: 'Prune preview',
@@ -5645,6 +5616,10 @@ function timelineMission(args) {
5645
5616
  if (artifactPath) lines.push(`Saved: ${artifactPath}`);
5646
5617
  const pruneLine = missionTimelinePruneSummaryLine(prunePreview);
5647
5618
  if (pruneLine) lines.push(pruneLine);
5619
+ return { payload, lines };
5620
+ }
5621
+
5622
+ function renderTimeline(payload, lines, asJson) {
5648
5623
  printJsonOrText(payload, lines, asJson);
5649
5624
  }
5650
5625
 
@@ -5814,8 +5789,8 @@ function watchMission(args) {
5814
5789
  return;
5815
5790
  }
5816
5791
  const ref = stripKnownFlags(args, ['--interval', '--idle-every'], [])[0] || '';
5817
- const intervalSeconds = Math.max(1, parseInt(readFlag(args, '--interval', '2'), 10) || 2);
5818
- 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);
5819
5794
  const loadTargets = () => {
5820
5795
  if (ref) {
5821
5796
  const mission = resolveMission(ref);
@@ -6047,14 +6022,12 @@ function missionProtectedTags(mission, root = process.cwd()) {
6047
6022
  ...(Array.isArray(mission?.task_ids) ? mission.task_ids : []),
6048
6023
  ].filter(Boolean).map(String));
6049
6024
  if (!taskRefs.size) return tags;
6050
- try {
6051
- const projection = JSON.parse(fs.readFileSync(path.join(root, '.atris', 'state', 'tasks.projection.json'), 'utf8'));
6052
- for (const task of Array.isArray(projection?.tasks) ? projection.tasks : []) {
6053
- if (!task || ![task.id, task.display_id, task.legacy_ref].some((ref) => taskRefs.has(String(ref || '')))) continue;
6054
- if (task.tag) tags.push(task.tag);
6055
- if (Array.isArray(task.tags)) tags.push(...task.tags);
6056
- }
6057
- } catch {}
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
+ }
6058
6031
  return tags;
6059
6032
  }
6060
6033
 
@@ -6118,18 +6091,13 @@ function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
6118
6091
  dirty_hash: snapshot.dirty_hash,
6119
6092
  paths: Array.from(paths).sort(),
6120
6093
  };
6121
- fs.mkdirSync(path.dirname(baselineFile), { recursive: true });
6122
- fs.writeFileSync(baselineFile, JSON.stringify(baseline, null, 2) + '\n', 'utf8');
6094
+ writeJson(baselineFile, baseline);
6123
6095
  return baseline;
6124
6096
  }
6125
6097
 
6126
6098
  function loadMissionWorktreeBaseline(missionId, root = process.cwd()) {
6127
- try {
6128
- const baseline = JSON.parse(fs.readFileSync(missionBaselinePath(missionId, root), 'utf8'));
6129
- return Array.isArray(baseline?.paths) ? baseline : null;
6130
- } catch {
6131
- return null;
6132
- }
6099
+ const baseline = readJson(missionBaselinePath(missionId, root));
6100
+ return Array.isArray(baseline?.paths) ? baseline : null;
6133
6101
  }
6134
6102
 
6135
6103
  // Closed missions no longer tick, so the sidecar is dead weight; prune it and
@@ -6208,6 +6176,12 @@ const MISSION_RUN_DEFAULTS = {
6208
6176
  backoff: { initialMs: 30_000, maxMs: 10 * 60_000, factor: 2, jitter: 0.3 },
6209
6177
  };
6210
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
+
6211
6185
  // Claude sessions accumulate context across resumed ticks; an always-on
6212
6186
  // mission would grow without bound. Continuity lives on disk (receipts, logs,
6213
6187
  // now.md), so a healthy session is disposable: rotate to a fresh one every N
@@ -6217,6 +6191,25 @@ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
6217
6191
  Number(process.env.ATRIS_CLAUDE_SESSION_ROTATE_TICKS) || 8,
6218
6192
  );
6219
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
+
6220
6213
  function runnerUsesCallerSession(runner) {
6221
6214
  return new Set(['codex_goal', 'caller_session', 'current_agent']).has(String(runner || '').trim().toLowerCase());
6222
6215
  }
@@ -6339,8 +6332,7 @@ function missionHeartbeatLines(mission, now = new Date()) {
6339
6332
  }
6340
6333
 
6341
6334
  function missionHasHumanAsks(mission) {
6342
- return Array.isArray(mission?.human_asks)
6343
- && mission.human_asks.some((ask) => String(ask || '').trim());
6335
+ return openHumanAsks(mission?.human_asks).length > 0;
6344
6336
  }
6345
6337
 
6346
6338
  function missionTaskHumanAcceptWaiting(mission) {
@@ -6427,7 +6419,10 @@ function missionSelectableForCodexGoal(mission, now = new Date()) {
6427
6419
  function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Date()) {
6428
6420
  const requestedId = String(options.missionId || '').trim();
6429
6421
  const candidates = listMissions(root)
6430
- .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))
6431
6426
  .filter((mission) => missionSelectableForCodexGoal(mission, now));
6432
6427
  if (requestedId) {
6433
6428
  const exact = candidates.find((mission) => missionMatchesRef(mission, requestedId));
@@ -6442,10 +6437,6 @@ function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Da
6442
6437
  const bRank = missionGoalSelectionRank(b);
6443
6438
  if (aRank !== bRank) return aRank - bRank;
6444
6439
 
6445
- const aCaller = runnerUsesCallerSession(a.runner) ? 1 : 0;
6446
- const bCaller = runnerUsesCallerSession(b.runner) ? 1 : 0;
6447
- if (aCaller !== bCaller) return bCaller - aCaller;
6448
-
6449
6440
  const aVerifier = effectiveMissionVerifier(a) ? 1 : 0;
6450
6441
  const bVerifier = effectiveMissionVerifier(b) ? 1 : 0;
6451
6442
  if (aVerifier !== bVerifier) return bVerifier - aVerifier;
@@ -6738,19 +6729,13 @@ function writeDirectRunCodexGoalRequest(mission, root = process.cwd()) {
6738
6729
  requested_at: stampIso(),
6739
6730
  };
6740
6731
  const paths = statePaths(root);
6741
- fs.mkdirSync(path.dirname(paths.codexGoalRequestJson), { recursive: true });
6742
- fs.writeFileSync(paths.codexGoalRequestJson, JSON.stringify(request, null, 2) + '\n', 'utf8');
6732
+ writeJson(paths.codexGoalRequestJson, request);
6743
6733
  return request;
6744
6734
  }
6745
6735
 
6746
6736
  function readDirectRunCodexGoalRequest(root = process.cwd(), now = new Date()) {
6747
6737
  const file = statePaths(root).codexGoalRequestJson;
6748
- let request = null;
6749
- try {
6750
- request = JSON.parse(fs.readFileSync(file, 'utf8'));
6751
- } catch {
6752
- return null;
6753
- }
6738
+ const request = readJson(file);
6754
6739
  const missionId = String(request?.mission_id || '').trim();
6755
6740
  if (!missionId) return null;
6756
6741
  const mission = resolveMission(missionId, root);
@@ -6761,13 +6746,9 @@ function readDirectRunCodexGoalRequest(root = process.cwd(), now = new Date()) {
6761
6746
 
6762
6747
  function clearDirectRunCodexGoalRequestForMission(missionId, root = process.cwd()) {
6763
6748
  const file = statePaths(root).codexGoalRequestJson;
6764
- let request = null;
6765
- try {
6766
- request = JSON.parse(fs.readFileSync(file, 'utf8'));
6767
- } catch {
6768
- return false;
6769
- }
6770
- 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;
6771
6752
  try {
6772
6753
  fs.rmSync(file, { force: true });
6773
6754
  return true;
@@ -7085,8 +7066,7 @@ function writeCodexGoalState(payload, root = process.cwd()) {
7085
7066
  updated_at: stampIso(),
7086
7067
  ...payload,
7087
7068
  };
7088
- fs.mkdirSync(path.dirname(paths.codexGoalJson), { recursive: true });
7089
- fs.writeFileSync(paths.codexGoalJson, JSON.stringify(state, null, 2) + '\n', 'utf8');
7069
+ writeJson(paths.codexGoalJson, state);
7090
7070
 
7091
7071
  const lines = [
7092
7072
  '# Codex Goal Controller',
@@ -7357,8 +7337,7 @@ function writeAtrisGoalState(payload, root = process.cwd()) {
7357
7337
  updated_at: stampIso(),
7358
7338
  ...payload,
7359
7339
  };
7360
- fs.mkdirSync(path.dirname(paths.atrisGoalJson), { recursive: true });
7361
- fs.writeFileSync(paths.atrisGoalJson, JSON.stringify(state, null, 2) + '\n', 'utf8');
7340
+ writeJson(paths.atrisGoalJson, state);
7362
7341
 
7363
7342
  const lines = [
7364
7343
  '# Atris Goal Controller',
@@ -7600,8 +7579,37 @@ function missionJudgmentTopic(mission) {
7600
7579
  return plain.split(/\s+/).filter(Boolean).slice(0, 14).join(' ');
7601
7580
  }
7602
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
+
7603
7609
  function missionJudgmentTitle(mission) {
7604
- 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
+ );
7605
7613
  const title = words.join(' ') || 'Mission needs a new direction';
7606
7614
  return title.charAt(0).toUpperCase() + title.slice(1);
7607
7615
  }
@@ -7766,12 +7774,11 @@ function missionDriverPaths(mission, root = process.cwd()) {
7766
7774
  }
7767
7775
 
7768
7776
  function readMissionDriverState(stateFile) {
7769
- try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; }
7777
+ return readJson(stateFile, {}) || {};
7770
7778
  }
7771
7779
 
7772
7780
  function writeMissionDriverState(stateFile, state) {
7773
- fs.mkdirSync(path.dirname(stateFile), { recursive: true });
7774
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n', 'utf8');
7781
+ writeJson(stateFile, state);
7775
7782
  }
7776
7783
 
7777
7784
  function missionDriverError(error) {
@@ -7864,10 +7871,8 @@ function installDetachedMissionDriverLifecycle(mission, root = process.cwd()) {
7864
7871
 
7865
7872
  function missionDriverHealth(mission, root = process.cwd()) {
7866
7873
  const paths = missionDriverPaths(mission, root);
7867
- let driverState = null;
7868
- let lockState = null;
7869
- try { driverState = JSON.parse(fs.readFileSync(paths.stateFile, 'utf8')); } catch {}
7870
- try { lockState = JSON.parse(fs.readFileSync(paths.lockFile, 'utf8')); } catch {}
7874
+ const driverState = readJson(paths.stateFile);
7875
+ const lockState = readJson(paths.lockFile);
7871
7876
  const candidates = [
7872
7877
  driverState ? { ...driverState, source: 'driver_state' } : null,
7873
7878
  lockState ? { ...lockState, source: 'mission_lock' } : null,
@@ -7902,8 +7907,7 @@ function acquireMissionLock(missionId, root = process.cwd(), options = {}) {
7902
7907
  return { ok: true, lockFile, fd, missionId, driverPid: process.pid, startedAt, recordLength: record.length };
7903
7908
  } catch (e) {
7904
7909
  if (e.code === 'EEXIST') {
7905
- let info = {};
7906
- try { info = JSON.parse(fs.readFileSync(lockFile, 'utf8') || '{}'); } catch {}
7910
+ const info = readJson(lockFile, {}) || {};
7907
7911
  const holderPid = Number(info.pid);
7908
7912
  const holderKnown = Number.isInteger(holderPid) && holderPid > 0;
7909
7913
  // A lock is created empty (openSync 'wx') and its pid record is written a
@@ -8184,8 +8188,9 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = []) {
8184
8188
  if (mission.task_ids?.length) {
8185
8189
  lines.push('', `## Task ids`, mission.task_ids.map((t) => `- ${t}`).join('\n'));
8186
8190
  }
8187
- if (mission.human_asks?.length) {
8188
- 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'));
8189
8194
  }
8190
8195
  return lines.join('\n');
8191
8196
  }
@@ -8761,16 +8766,28 @@ function cappedClaudeReceiptText(text, limit = 4000) {
8761
8766
  return clean.slice(0, limit - 16).trimEnd() + '\n...[truncated]';
8762
8767
  }
8763
8768
 
8764
- async function runMission(args) {
8769
+ async function parseAndValidateMissionRunPhase(args) {
8765
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
+ };
8766
8780
  if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
8767
8781
  help();
8768
- return;
8782
+ context.handled = true;
8783
+ return context;
8769
8784
  }
8770
8785
  if (hasFlag(args, '--cloud')) {
8771
8786
  const result = await runCloudMissionCommand(args);
8772
8787
  process.exitCode = result.exitCode;
8773
- return result;
8788
+ context.handled = true;
8789
+ context.returnValue = result;
8790
+ return context;
8774
8791
  }
8775
8792
  // --fleet: staff every idle capable engine on the board's claimable
8776
8793
  // safe-lane tasks, build in parallel worktrees, land serially. Humble flag,
@@ -8789,7 +8806,8 @@ async function runMission(args) {
8789
8806
  });
8790
8807
  if (asJson) console.log(JSON.stringify(flight, null, 2));
8791
8808
  process.exitCode = flight.paused && flight.paused.length > 0 ? 1 : 0;
8792
- return;
8809
+ context.handled = true;
8810
+ return context;
8793
8811
  }
8794
8812
  const dueMode = hasFlag(args, '--due');
8795
8813
  const headlessOnly = hasFlag(args, '--headless');
@@ -8825,22 +8843,70 @@ async function runMission(args) {
8825
8843
  const ref = input.ref;
8826
8844
  const runArgs = input.args;
8827
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
+
8828
8892
  if (!dueMode && !ref) {
8829
8893
  if (asJson || !process.stdin.isTTY || !process.stderr.isTTY) {
8830
8894
  missionRunInputRequired(asJson, input.owner);
8831
8895
  }
8832
8896
  const prompted = await promptMissionRunInput(runArgs);
8833
8897
  await startMissionFromRunObjective(prompted.objective, prompted.args);
8898
+ context.handled = true;
8834
8899
  return;
8835
8900
  }
8836
8901
 
8837
- 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);
8838
8903
  if (!mission && dueMode && !ref) {
8839
8904
  printJsonOrText(
8840
8905
  { ok: true, action: 'run_skipped', reason: 'no_due_mission', mission: null },
8841
8906
  ['No due mission found.'],
8842
8907
  asJson,
8843
8908
  );
8909
+ context.handled = true;
8844
8910
  return;
8845
8911
  }
8846
8912
  // BCK-1319: a bare single token that looks like an id/suffix/number (no
@@ -8854,6 +8920,7 @@ async function runMission(args) {
8854
8920
  }
8855
8921
  if (!mission && ref) {
8856
8922
  await startMissionFromRunObjective(ref, runArgs);
8923
+ context.handled = true;
8857
8924
  return;
8858
8925
  }
8859
8926
  if (!mission) {
@@ -8867,7 +8934,8 @@ async function runMission(args) {
8867
8934
  } else if (!maxWallFlag && Number(mission.budget_contract?.requested_seconds) > 0) {
8868
8935
  maxWallSeconds = Math.max(60, Number(mission.budget_contract.requested_seconds));
8869
8936
  }
8870
- const detachedDriverLifecycle = installDetachedMissionDriverLifecycle(mission, process.cwd());
8937
+ const detachedDriverLifecycle = installDetachedMissionDriverLifecycle(mission, cwd);
8938
+ context.detachedDriverLifecycle = detachedDriverLifecycle;
8871
8939
  if (['complete', 'stopped'].includes(mission.status)) {
8872
8940
  detachedDriverLifecycle?.setExitReason(mission.status);
8873
8941
  if (asJson) {
@@ -8876,18 +8944,20 @@ async function runMission(args) {
8876
8944
  [],
8877
8945
  true,
8878
8946
  );
8947
+ context.handled = true;
8879
8948
  return;
8880
8949
  }
8881
8950
  console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
8882
8951
  process.exit(0);
8883
8952
  }
8884
8953
  if (hasFlag(args, '--detach')) {
8885
- detachMissionRun(args, mission, process.cwd(), asJson);
8954
+ detachMissionRun(args, mission, cwd, asJson);
8955
+ context.handled = true;
8886
8956
  return;
8887
8957
  }
8888
8958
 
8889
8959
  const nativeGoalRunOptions = codexNativeGoalOptionsFromArgs(args);
8890
- const autoAck = maybeAutoAckCodexNativeGoal(mission, process.cwd(), nativeGoalRunOptions);
8960
+ const autoAck = maybeAutoAckCodexNativeGoal(mission, cwd, nativeGoalRunOptions);
8891
8961
  if (autoAck) mission = autoAck.saved;
8892
8962
  maybeBlockUntilCodexNativeGoalStarted(runtimeView(mission), asJson, nativeGoalRunOptions);
8893
8963
 
@@ -8896,37 +8966,63 @@ async function runMission(args) {
8896
8966
  // (e.g. --engine cursor) never invokes claude. The in-lock probe below runs
8897
8967
  // after applyMissionRunnerProfile and checks the right binary.
8898
8968
 
8899
- const lock = acquireMissionLock(mission.id);
8969
+ const lock = acquireMissionLock(mission.id, cwd);
8900
8970
  if (!lock.ok) {
8901
8971
  exitMissionError(`another driver is already running mission ${mission.id} (pid ${lock.holder?.pid || '?'})`, 3, asJson);
8902
8972
  }
8903
8973
 
8904
- // Everything past lock acquisition runs inside try/finally so the lock + signal handlers
8905
- // always get cleaned up — including saveMission failures during pending-session setup.
8906
- let pauseReason = null;
8907
- let sessionId = null;
8908
- let pendingSessionId = null;
8909
- let ranTicks = 0;
8910
- const ticks = [];
8911
- let onSig = null;
8912
- let restoreRunnerProfile = null;
8913
- 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
+ }
8914
8990
 
8915
- try {
8916
- 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;
8917
9012
  const controller = new AbortController();
8918
- onSig = (signal) => {
9013
+ context.controller = controller;
9014
+ context.onSig = (signal) => {
8919
9015
  detachedDriverLifecycle?.setExitReason(`signal-${String(signal || 'abort').toLowerCase()}`);
8920
9016
  controller.abort();
8921
9017
  };
8922
- process.on('SIGINT', onSig);
8923
- process.on('SIGTERM', onSig);
9018
+ process.on('SIGINT', context.onSig);
9019
+ process.on('SIGTERM', context.onSig);
8924
9020
 
8925
9021
  // Re-read inside the lock. The initial resolveMission ran pre-lock, so a concurrent
8926
9022
  // `mission tick` could have written between resolveMission and acquireMissionLock.
8927
9023
  // Derive sessionId, pendingSessionId, and the frozen contract from the fresh record
8928
9024
  // so a fast tick's writes can't be silently overwritten by this run loop.
8929
- mission = resolveMission(mission.id) || mission;
9025
+ mission = resolveMission(mission.id, cwd) || mission;
8930
9026
  if (explicitMaxWallSeconds && runBudgetContract) {
8931
9027
  mission = saveMission({
8932
9028
  ...mission,
@@ -8941,10 +9037,12 @@ async function runMission(args) {
8941
9037
  if (['complete', 'stopped'].includes(mission.status)) {
8942
9038
  detachedDriverLifecycle?.setExitReason(mission.status);
8943
9039
  console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
9040
+ context.handled = true;
8944
9041
  return;
8945
9042
  }
8946
9043
  if (returnIfCodexNativeGoalNotStarted(runtimeMission, asJson, nativeGoalRunOptions)) {
8947
9044
  detachedDriverLifecycle?.setExitReason('native-goal-not-started');
9045
+ context.handled = true;
8948
9046
  return;
8949
9047
  }
8950
9048
  if (mission.status === 'paused') {
@@ -8961,7 +9059,7 @@ async function runMission(args) {
8961
9059
  sessionId = mission.claude_session_id || null;
8962
9060
  pendingSessionId = mission.pending_session_id || null;
8963
9061
  const autoRunner = String(runtimeMission.runner || '').trim().toLowerCase() === MISSION_AUTO_RUNNER;
8964
- restoreRunnerProfile = autoRunner ? () => {} : applyMissionRunnerProfile(runtimeMission.runner);
9062
+ context.restoreRunnerProfile = autoRunner ? () => {} : applyMissionRunnerProfile(runtimeMission.runner);
8965
9063
  const callerSessionRunner = runnerUsesCallerSession(runtimeMission.runner);
8966
9064
  const runnerName = String(runtimeMission.runner || '').trim().toLowerCase();
8967
9065
  const atris2Runner = runnerName === 'atris2';
@@ -9028,7 +9126,7 @@ async function runMission(args) {
9028
9126
  // Re-read before measuring the wall. Detached full-budget drivers use
9029
9127
  // the mission contract as their wall so process startup drift cannot
9030
9128
  // shorten the promised work window.
9031
- mission = resolveMission(mission.id) || mission;
9129
+ mission = resolveMission(mission.id, cwd) || mission;
9032
9130
  runtimeMission = runtimeView(mission);
9033
9131
  const contractualRemaining = detachedDriverLifecycle && mission.always_on && missionSpendsFullBudget(mission)
9034
9132
  ? missionFullBudgetRemainingSeconds(mission)
@@ -9045,6 +9143,8 @@ async function runMission(args) {
9045
9143
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
9046
9144
  if (effectiveMissionVerifier(mission) !== storedVerifier) { pauseReason = 'verifier-mutated'; break; }
9047
9145
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
9146
+ const protectedHold = missionProtectedLaneHold(mission);
9147
+ if (protectedHold) { pauseReason = protectedHold.pause_reason; break; }
9048
9148
 
9049
9149
  const tickIdx = Number(mission.last_tick_index || 0) + 1;
9050
9150
  const tickStart = stampIso();
@@ -9195,9 +9295,9 @@ async function runMission(args) {
9195
9295
  const runClaudeSession = () => spawnClaudeTick(tickRuntimeMission, {
9196
9296
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
9197
9297
  missionLock: lock,
9198
- timeoutMs: Math.min(
9298
+ timeoutMs: clampTimeoutMs(
9299
+ (maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000,
9199
9300
  MISSION_RUN_DEFAULTS.claudeTimeoutMs,
9200
- Math.max(1, Math.floor((maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000)),
9201
9301
  ),
9202
9302
  prompt,
9203
9303
  model: resolveClaudeRunnerModel(tickRuntimeMission),
@@ -9304,39 +9404,22 @@ async function runMission(args) {
9304
9404
  if (engineHealth) result.engine_health = engineHealth.health;
9305
9405
  }
9306
9406
 
9307
- // An explicit verifier is safe and useful in no-worker mode. The
9308
- // fallback engine verifier is itself worker activity, so --no-claude
9309
- // and caller-session ticks must not launch it behind the operator's
9310
- // back. Leaving verifier_passed unset also lets the idle-stop breaker
9311
- // judge these ticks from the worktree signal instead of hanging here.
9312
- const runnerGuard = result.claude?.protected_lane_guard || result.atris2?.protected_lane_guard || null;
9313
- if (result.status === 'ran' || runnerGuard) {
9314
- const protectedLaneGuard = runnerGuard?.allowed === false
9315
- ? runnerGuard
9316
- : inspectMissionTickProtectedDiff(mission, tickWorktreeBefore, cwd);
9317
- result.protected_lane_guard = protectedLaneGuard;
9318
- if (!protectedLaneGuard.allowed) {
9319
- result.status = 'paused-for-review';
9320
- result.reason = 'protected-lane-review';
9321
- result.ran = false;
9322
- pauseReason = 'protected-lane-review';
9323
- }
9324
- }
9325
- 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;
9326
9422
  let receiptPath = null;
9327
- if (result.status === 'ran' && verifyEach) {
9328
- if (frozen.verifier) {
9329
- verifierResult = runVerifier(frozen.verifier);
9330
- } else if (!tickSkipWorker) {
9331
- verifierResult = await runEngineVerifier(tickRuntimeMission, {
9332
- cwd,
9333
- signal: controller.signal,
9334
- tickIndex: tickIdx,
9335
- });
9336
- }
9337
- if (verifierResult) result.verifier_passed = verifierResult.passed;
9338
- }
9339
- stampMissionRunnerBrief(cwd, result.claude?.brief_id, result, verifierResult);
9340
9423
 
9341
9424
  // Review-lane drain: always-on loops sweep the agent-safe review actions
9342
9425
  // each tick so proof-backed work reaches certified on cadence with zero
@@ -9477,7 +9560,7 @@ async function runMission(args) {
9477
9560
  break;
9478
9561
  }
9479
9562
 
9480
- if (pauseReason === 'protected-lane-review') break;
9563
+ if (['mission-diff-unreadable', 'protected-lane-review'].includes(pauseReason)) break;
9481
9564
  if (callerSessionRunner && result.status === 'ran') break;
9482
9565
  if (newStatus === 'complete' || (newStatus === 'ready' && !mission.always_on && !fullBudgetMode)) break;
9483
9566
  // BCK-1324: two consecutive ticks that each self-report "ran" but leave no
@@ -9525,7 +9608,7 @@ async function runMission(args) {
9525
9608
  if (lastTick && lastTick.status !== 'ran' && !missionRunKeepsRetryingError(lastTick.reason)) pauseReason = 'max-ticks-reached';
9526
9609
  }
9527
9610
 
9528
- mission = resolveMission(mission.id) || mission;
9611
+ mission = resolveMission(mission.id, cwd) || mission;
9529
9612
  const remainingBudgetSeconds = missionFullBudgetRemainingSeconds(mission);
9530
9613
  const explicitExit = ['complete', 'stopped', 'paused'].includes(String(mission.status || ''));
9531
9614
  const healthyCycleBoundary = !pauseReason || pauseReason === 'max-ticks-reached';
@@ -9557,6 +9640,97 @@ async function runMission(args) {
9557
9640
  cycleTickLimit = ticks.length + effectiveMaxTicks;
9558
9641
  }
9559
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
+
9560
9734
  // BCK-1324: no-progress is a clean, honest stop — the run did what it
9561
9735
  // could and correctly recognized there was nothing left to do. It is NOT
9562
9736
  // a failure/blocker: pausing it (resumable, retried by cron/self-drive)
@@ -9670,18 +9844,35 @@ async function runMission(args) {
9670
9844
  detachedDriverLifecycle?.finish('run-error', error);
9671
9845
  throw error;
9672
9846
  } finally {
9673
- if (onSig) {
9674
- try { process.removeListener('SIGINT', onSig); } catch {}
9675
- 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 {}
9676
9850
  }
9677
- if (restoreRunnerProfile) {
9678
- try { restoreRunnerProfile(); } catch {}
9851
+ if (context.restoreRunnerProfile) {
9852
+ try { context.restoreRunnerProfile(); } catch {}
9679
9853
  }
9680
9854
  releaseMissionLock(lock);
9681
9855
  detachedDriverLifecycle?.finish();
9682
9856
  }
9683
9857
  }
9684
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
+
9685
9876
  function tickMission(args) {
9686
9877
  const asJson = wantsJson(args);
9687
9878
  if (hasFlag(args, '--help') || hasFlag(args, '-h') || String(args[0] || '').trim() === 'help') {
@@ -9765,9 +9956,12 @@ function tickMission(args) {
9765
9956
  // Same layer classification as the run-tick path; manual ticks carry their
9766
9957
  // receipt text in --summary.
9767
9958
  const layerInfo = extractLayerFromReceiptText(summary || '', tickWorktree?.new_since_baseline_sample);
9959
+ const guardPauseReason = protectedLaneGuard.unreadable
9960
+ ? 'mission-diff-unreadable'
9961
+ : 'protected-lane-review';
9768
9962
  const tickRecord = {
9769
- status: protectedLaneGuard.allowed ? 'ran' : 'paused-for-review',
9770
- reason: protectedLaneGuard.allowed ? 'tick-recorded' : 'protected-lane-review',
9963
+ status: protectedLaneGuard.allowed ? 'ran' : protectedLaneGuard.status,
9964
+ reason: protectedLaneGuard.allowed ? 'tick-recorded' : guardPauseReason,
9771
9965
  tick_index: tickIdx,
9772
9966
  ran: protectedLaneGuard.allowed,
9773
9967
  started_at: tickStart,
@@ -9803,7 +9997,9 @@ function tickMission(args) {
9803
9997
  const nextGoalChain = advanceMissionGoalChain(mission.goal_chain, summary, verifierResult);
9804
9998
  if (!protectedLaneGuard.allowed) {
9805
9999
  status = 'paused';
9806
- nextAction = `review the protected diff and receipt before resuming: atris mission run ${mission.id}`;
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}`;
9807
10003
  } else if (verifierResult?.passed && nextGoalChain && !nextGoalChain.pause_ready) {
9808
10004
  status = 'running';
9809
10005
  nextAction = missionGoalChainNextAction(nextGoalChain);
@@ -9892,12 +10088,8 @@ function tickMission(args) {
9892
10088
  // (free text, command strings, missing paths) reads as null and falls back
9893
10089
  // to durable mission state.
9894
10090
  function readReceiptProof(proof, root = process.cwd()) {
9895
- try {
9896
- const parsed = JSON.parse(fs.readFileSync(path.resolve(root, String(proof || '')), 'utf8'));
9897
- return parsed?.schema === 'atris.mission_receipt.v1' ? parsed : null;
9898
- } catch {
9899
- return null;
9900
- }
10091
+ const parsed = readJson(path.resolve(root, String(proof || '')));
10092
+ return parsed?.schema === 'atris.mission_receipt.v1' ? parsed : null;
9901
10093
  }
9902
10094
 
9903
10095
  function receiptShowsPass(receipt) {
@@ -10599,12 +10791,7 @@ function layersMission(args) {
10599
10791
  }
10600
10792
  for (const file of files) {
10601
10793
  if (missionFilter && !file.includes(missionFilter)) continue;
10602
- let receipt;
10603
- try {
10604
- receipt = JSON.parse(fs.readFileSync(path.join(paths.runsDir, file), 'utf8'));
10605
- } catch {
10606
- continue;
10607
- }
10794
+ const receipt = readJson(path.join(paths.runsDir, file));
10608
10795
  if (sinceMs != null) {
10609
10796
  const atMs = Date.parse(receipt && receipt.at);
10610
10797
  if (Number.isNaN(atMs) || atMs < sinceMs) continue;
@@ -10807,6 +10994,36 @@ function pingMission(args, opts = {}) {
10807
10994
  return saved;
10808
10995
  }
10809
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
+
10810
11027
  function missionCommand(args) {
10811
11028
  const subcommand = args[0] || 'status';
10812
11029
  const rest = args.slice(1);
@@ -10915,6 +11132,7 @@ function missionCommand(args) {
10915
11132
  }
10916
11133
 
10917
11134
  module.exports = {
11135
+ missionProtectedLaneHold,
10918
11136
  missionCommand,
10919
11137
  startMission,
10920
11138
  spawnMissionDriver,
@@ -10931,6 +11149,7 @@ module.exports = {
10931
11149
  findActiveTwinMission,
10932
11150
  TWIN_ACTIVE_STATUSES,
10933
11151
  pingMission,
11152
+ answerMissionHumanAsk,
10934
11153
  buildTickPrompt,
10935
11154
  extractCheckFeedback,
10936
11155
  loadMissionMap,