atris 3.31.0 → 3.33.3

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.
@@ -29,6 +29,16 @@ const {
29
29
  runsPruneLines,
30
30
  formatBytes,
31
31
  } = require('../lib/runs-prune');
32
+ const { operatorReady, hasAgentJargon } = require('./autoland');
33
+ const {
34
+ MISSION_INSPECT_FIELDS,
35
+ readFieldsFlag,
36
+ stripInspectArgs,
37
+ validateFields,
38
+ missionInspectFieldValues,
39
+ inspectTextLines,
40
+ buildInspectPayload,
41
+ } = require('../lib/inspect-fields');
32
42
 
33
43
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
34
44
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
@@ -90,6 +100,28 @@ function exitMissionError(message, code = 1, asJson = false) {
90
100
  process.exit(code);
91
101
  }
92
102
 
103
+ // Write-time warnings judge only what a machine can truly judge: identifiers,
104
+ // flags, and task ids. Whether a why is present is a judgment call — the tick
105
+ // prompt demands it, the review pass judges it. A warning that cries wolf on
106
+ // plain sentences teaches agents to ignore it (golden-path papercut). The
107
+ // strict operatorReady bar stays at the digest surface, where under-showing
108
+ // is cheap. Boundary pinned by the marker-free fixture in mission-status tests.
109
+ function warnIfSummaryNeedsOperatorWhy(summary) {
110
+ const text = String(summary || '').trim();
111
+ if (!text || !hasAgentJargon(text)) return null;
112
+ const warning = 'Warning: this tick summary contains flags, ids, or code identifiers. Rewrite it in words the operator can use; identifiers belong in the receipt body.';
113
+ console.error(warning);
114
+ return warning;
115
+ }
116
+
117
+ function warnIfTaskTitleNeedsOperatorWhy(title) {
118
+ const text = String(title || '').trim();
119
+ if (!text || !hasAgentJargon(text)) return null;
120
+ const warning = 'Warning: this task title contains flags, ids, or code identifiers. Rewrite it in words the operator can use; identifiers belong in the task body.';
121
+ console.error(warning);
122
+ return warning;
123
+ }
124
+
93
125
  function readPositiveIntegerFlag(args, name, fallback = null, options = {}) {
94
126
  const raw = readFlag(args, name, '');
95
127
  if (!raw) return fallback;
@@ -171,6 +203,7 @@ function printJsonOrText(payload, lines, asJson) {
171
203
  }
172
204
 
173
205
  const MISSION_RUN_VALUE_FLAGS = [
206
+ '--slots',
174
207
  '--max-ticks',
175
208
  '--max-wall',
176
209
  '--minutes',
@@ -190,6 +223,8 @@ const MISSION_RUN_VALUE_FLAGS = [
190
223
  const MISSION_RUN_BOOLEAN_FLAGS = [
191
224
  '--json',
192
225
  '--due',
226
+ '--fleet',
227
+ '--dry-run',
193
228
  '--no-claude',
194
229
  '--no-verify',
195
230
  '--complete-on-pass',
@@ -204,6 +239,7 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
204
239
  '--no-room-auto-run',
205
240
  '--allow-native-goal-supersede',
206
241
  '--supersede-paused-native-goal',
242
+ '--take-goal-slot',
207
243
  '--always-on',
208
244
  '--xp-task',
209
245
  '--agent-xp',
@@ -328,6 +364,7 @@ function createMissionXpTask(mission, root = process.cwd(), asJson = false) {
328
364
  const db = taskDb.open();
329
365
  const workspaceRoot = taskDb.workspaceRoot(root);
330
366
  const title = `Mission XP: ${mission.objective}`;
367
+ const operatorTitleWarning = warnIfTaskTitleNeedsOperatorWhy(title);
331
368
  const ownerResolution = resolveMissionOwner(mission, workspaceRoot);
332
369
  const owner = ownerResolution.owner;
333
370
  const metadata = {
@@ -371,6 +408,7 @@ function createMissionXpTask(mission, root = process.cwd(), asJson = false) {
371
408
  task_id: result.id,
372
409
  ref: missionTaskRef(task) || result.id,
373
410
  title,
411
+ operator_title_warning: operatorTitleWarning,
374
412
  status: task?.status || 'claimed',
375
413
  assigned_to: owner,
376
414
  owner_resolution: ownerResolution.reason,
@@ -499,10 +537,72 @@ function listWorktreeRollupMissions(root = process.cwd()) {
499
537
  return rolled;
500
538
  }
501
539
 
540
+ function missionMatchesRef(mission, ref) {
541
+ return mission && (mission.id === ref || mission.id.startsWith(ref) || mission.slug === ref);
542
+ }
543
+
544
+ function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5) {
545
+ if (!ref) return [];
546
+ const baseRoot = path.resolve(root);
547
+ const parent = path.dirname(baseRoot);
548
+ let here = baseRoot;
549
+ try {
550
+ here = fs.realpathSync(baseRoot);
551
+ } catch { /* keep raw path */ }
552
+ let entries = [];
553
+ try {
554
+ entries = fs.readdirSync(parent, { withFileTypes: true });
555
+ } catch {
556
+ return [];
557
+ }
558
+ const hints = [];
559
+ for (const entry of entries) {
560
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
561
+ const candidateRoot = path.join(parent, entry.name);
562
+ let candidateReal = candidateRoot;
563
+ try {
564
+ candidateReal = fs.realpathSync(candidateRoot);
565
+ } catch {
566
+ continue;
567
+ }
568
+ if (candidateReal === here) continue;
569
+ if (!fs.existsSync(path.join(candidateRoot, '.atris', 'state', 'missions.jsonl'))) continue;
570
+ const mission = listMissions(candidateRoot).find((row) => missionMatchesRef(row, ref));
571
+ if (!mission) continue;
572
+ hints.push({
573
+ id: mission.id,
574
+ status: mission.status,
575
+ objective: mission.objective,
576
+ workspace_root: candidateRoot,
577
+ command: `cd ${shellQuote(candidateRoot)} && atris mission status ${mission.id}`,
578
+ });
579
+ if (hints.length >= limit) break;
580
+ }
581
+ return hints;
582
+ }
583
+
584
+ function exitMissingMission(ref, code = 1, asJson = false) {
585
+ const error = `Mission "${ref}" not found.`;
586
+ const hints = listSiblingWorkspaceMissionHints(ref);
587
+ if (asJson) {
588
+ const payload = { ok: false, error };
589
+ if (hints.length) payload.workspace_hint = hints[0];
590
+ if (hints.length > 1) payload.workspace_hints = hints;
591
+ console.log(JSON.stringify(payload, null, 2));
592
+ } else {
593
+ console.error(error);
594
+ if (hints.length) {
595
+ console.error(`Workspace hint: mission ${hints[0].id} exists in ${hints[0].workspace_root}.`);
596
+ console.error(`Run: ${hints[0].command}`);
597
+ }
598
+ }
599
+ process.exit(code);
600
+ }
601
+
502
602
  function resolveMission(ref, root = process.cwd()) {
503
603
  const missions = listMissions(root);
504
604
  if (!ref) return missions.find((mission) => !TERMINAL_STATUSES.has(mission.status)) || missions[0] || null;
505
- return missions.find((mission) => mission.id === ref || mission.id.startsWith(ref) || mission.slug === ref) || null;
605
+ return missions.find((mission) => missionMatchesRef(mission, ref)) || null;
506
606
  }
507
607
 
508
608
  function missionMatchesStatusFilter(mission, statusFilter) {
@@ -1532,7 +1632,7 @@ function missionFromArgs(args) {
1532
1632
  '--native-goal-objective',
1533
1633
  '--visible-goal-status',
1534
1634
  '--visible-goal-objective',
1535
- ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--allow-native-goal-supersede', '--supersede-paused-native-goal']).join(' ').trim();
1635
+ ], ['--json', '--always-on', '--xp-task', '--agent-xp', '--worktree', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--allow-native-goal-supersede', '--supersede-paused-native-goal', '--take-goal-slot']).join(' ').trim();
1536
1636
  if (!objective) {
1537
1637
  exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual] [--worktree]', 1, wantsJson(args));
1538
1638
  }
@@ -2008,7 +2108,51 @@ function normalizeMissionOwner(value) {
2008
2108
  return String(value || '').trim().toLowerCase();
2009
2109
  }
2010
2110
 
2011
- function missionMemberTasteProfile(owner) {
2111
+ function readMissionMemberRole(owner, root = process.cwd()) {
2112
+ const dir = memberDir(owner, root);
2113
+ if (!dir) return '';
2114
+ try {
2115
+ const text = fs.readFileSync(path.join(dir, 'MEMBER.md'), 'utf8');
2116
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2117
+ if (!match) return '';
2118
+ const role = match[1].match(/^role:\s*(.+)$/im);
2119
+ return role ? role[1].replace(/^["']|["']$/g, '').trim() : '';
2120
+ } catch {
2121
+ return '';
2122
+ }
2123
+ }
2124
+
2125
+ function missionTasteProfileForRole(role) {
2126
+ const text = String(role || '').toLowerCase();
2127
+ if (!text) return null;
2128
+ if (/\b(security|risk|trust|privacy|compliance|safety|safe|guard|inspector)\b/.test(text)) {
2129
+ return {
2130
+ id: 'security_guard',
2131
+ name: 'Security / trust',
2132
+ bias: 'Prefer work that prevents unsafe behavior, privacy mistakes, or quiet trust breaks.',
2133
+ role_reason: 'Member role says guard risk before spending tokens.',
2134
+ };
2135
+ }
2136
+ if (/\b(demo|reliability|ux|usability|support|operator|launcher|customer|growth|onboarding)\b/.test(text)) {
2137
+ return {
2138
+ id: 'usability_operator',
2139
+ name: 'Usability / demo',
2140
+ bias: 'Prefer work that makes the product easier, faster, clearer, or more demo-ready.',
2141
+ role_reason: 'Member role says optimize usability before spending tokens.',
2142
+ };
2143
+ }
2144
+ if (/\b(technical|engineer|architect|research|improver|runtime|compiler|infra|backend|agent|model)\b/.test(text)) {
2145
+ return {
2146
+ id: 'technical_homerun',
2147
+ name: 'Technical homerun',
2148
+ bias: 'Prefer technical leaps, but require a usability or proof gate before spending serious tokens.',
2149
+ role_reason: 'Member role says chase validated technical homeruns.',
2150
+ };
2151
+ }
2152
+ return null;
2153
+ }
2154
+
2155
+ function missionOwnerTasteProfile(owner) {
2012
2156
  const key = normalizeMissionOwner(owner);
2013
2157
  if (/\b(security|sync-inspector|proof-inspector|validator)\b/.test(key)) {
2014
2158
  return {
@@ -2031,13 +2175,25 @@ function missionMemberTasteProfile(owner) {
2031
2175
  };
2032
2176
  }
2033
2177
 
2178
+ function missionMemberTasteProfile(owner, root = process.cwd()) {
2179
+ const role = readMissionMemberRole(owner, root);
2180
+ const roleProfile = missionTasteProfileForRole(role);
2181
+ const profile = roleProfile || missionOwnerTasteProfile(owner);
2182
+ return {
2183
+ ...profile,
2184
+ role: role || null,
2185
+ role_source: roleProfile ? `atris/team/${normalizeMissionOwner(owner)}/MEMBER.md` : null,
2186
+ role_reason: roleProfile?.role_reason || null,
2187
+ };
2188
+ }
2189
+
2034
2190
  function missionValueSignals(title) {
2035
2191
  const text = String(title || '').toLowerCase();
2036
2192
  const signals = [];
2037
2193
  const add = (id, label, pattern) => {
2038
2194
  if (pattern.test(text)) signals.push({ id, label });
2039
2195
  };
2040
- add('usability', 'simplifies use', /\b(simple|simplify|preview|clear|plain|feynman|demo|onboarding|ux|workflow|usable|understand)\b/);
2196
+ add('usability', 'simplifies use', /\b(simple|simplify|previews?|clear|plain|feynman|demos?|onboarding|ux|workflow|usable|understand)\b/);
2041
2197
  add('speed', 'speeds the process', /\b(speed|fast|faster|latency|token|waste|friction|shortcut|automation|auto)\b/);
2042
2198
  add('users_revenue', 'can help users or revenue', /\b(user|users|customer|revenue|payment|checkout|pricing|conversion|retention|demo)\b/);
2043
2199
  add('trust', 'protects trust', /\b(security|safe|safety|trust|permission|approval|auth|privacy|gmail|email|connector|leak|isolation)\b/);
@@ -2265,6 +2421,7 @@ function missionTasteMemoryReason(tasteMemory, signals) {
2265
2421
 
2266
2422
  function missionPreviewWhyNow(move, profile, signals, tasteMemory = null) {
2267
2423
  const ids = new Set((signals || []).map((signal) => signal.id));
2424
+ const roleReason = profile?.role_reason ? `${profile.role_reason} ` : '';
2268
2425
  let base = '';
2269
2426
  if (profile.id === 'technical_homerun') {
2270
2427
  base = ids.has('technical')
@@ -2282,7 +2439,7 @@ function missionPreviewWhyNow(move, profile, signals, tasteMemory = null) {
2282
2439
  base = move?.why || 'This needs a clear reason before it should spend serious tokens.';
2283
2440
  }
2284
2441
  const memoryReason = missionTasteMemoryReason(tasteMemory, signals);
2285
- return memoryReason ? `${base} Taste memory says: ${memoryReason}` : base;
2442
+ return memoryReason ? `${roleReason}${base} Taste memory says: ${memoryReason}` : `${roleReason}${base}`;
2286
2443
  }
2287
2444
 
2288
2445
  function missionPreviewRisk(signals) {
@@ -2301,7 +2458,7 @@ function missionPreviewValidation(signals) {
2301
2458
  }
2302
2459
 
2303
2460
  function missionValuePreview(move, mission, root = process.cwd()) {
2304
- const profile = missionMemberTasteProfile(mission?.owner);
2461
+ const profile = missionMemberTasteProfile(mission?.owner, root);
2305
2462
  const signals = missionValueSignals(move?.title);
2306
2463
  const tasteMemory = readMissionTasteMemory(root, mission?.owner);
2307
2464
  const score = missionValueScore(signals, profile, tasteMemory);
@@ -2651,6 +2808,9 @@ function startMission(args) {
2651
2808
  const warnings = [missingVerifierWarning(mission)].filter(Boolean);
2652
2809
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
2653
2810
  const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective });
2811
+ const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
2812
+ ? takeCodexGoalSlotForMission(saved, process.cwd())
2813
+ : null;
2654
2814
  const memberState = renderMemberMissionState(saved.owner);
2655
2815
  const logPath = appendMemberLog(saved.owner, 'Mission started', {
2656
2816
  mission: saved.objective,
@@ -2661,7 +2821,7 @@ function startMission(args) {
2661
2821
  });
2662
2822
  const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
2663
2823
  printJsonOrText(
2664
- { ok: true, action: 'mission_started', mission: saved, warnings, state_path: statePaths().missionsJsonl, member_state: memberState, log_path: logPath, worktree_baseline: worktreeBaseline ? { path: path.relative(process.cwd(), missionBaselinePath(saved.id)), dirty_count: worktreeBaseline.dirty_count, dirty_hash: worktreeBaseline.dirty_hash } : null },
2824
+ { ok: true, action: 'mission_started', mission: saved, warnings, goal_slot_handoff: goalSlotHandoff, state_path: statePaths().missionsJsonl, member_state: memberState, log_path: logPath, worktree_baseline: worktreeBaseline ? { path: path.relative(process.cwd(), missionBaselinePath(saved.id)), dirty_count: worktreeBaseline.dirty_count, dirty_hash: worktreeBaseline.dirty_hash } : null },
2665
2825
  [
2666
2826
  `Started mission: ${saved.objective}`,
2667
2827
  `Owner: ${saved.owner}`,
@@ -2776,6 +2936,9 @@ function startMissionFromRunObjective(objective, args) {
2776
2936
  ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
2777
2937
  ...(hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal') ? { allowNativeGoalSupersede: true } : {}),
2778
2938
  };
2939
+ const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
2940
+ ? takeCodexGoalSlotForMission(saved, process.cwd(), nativeGoalOptions)
2941
+ : null;
2779
2942
  const atrisGoalState = refreshAtrisGoalController(process.cwd(), { missionId: saved.id });
2780
2943
  const codexGoalState = runnerUsesCallerSession(saved.runner)
2781
2944
  ? refreshCodexGoalController(process.cwd(), { missionId: saved.id, ...nativeGoalOptions })
@@ -2800,6 +2963,7 @@ function startMissionFromRunObjective(objective, args) {
2800
2963
  } : null,
2801
2964
  completed_continuation_goal: completedContinuationGoal,
2802
2965
  direct_goal_request: directGoalRequest,
2966
+ goal_slot_handoff: goalSlotHandoff,
2803
2967
  atris_goal_state: atrisGoalState,
2804
2968
  codex_goal_state: codexGoalState,
2805
2969
  requires_native_goal_start: codexGoalState?.requires_native_goal_start === true || codexGoalState?.goal?.requires_native_goal_start === true,
@@ -2821,7 +2985,7 @@ function attachMissionTask(args) {
2821
2985
  }
2822
2986
  let mission = resolveMission(ref);
2823
2987
  if (!mission) {
2824
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
2988
+ exitMissingMission(ref, 1, asJson);
2825
2989
  }
2826
2990
 
2827
2991
  const lock = acquireMissionLock(mission.id, process.cwd(), { waitMs: 2000 });
@@ -2965,7 +3129,7 @@ function statusMission(args) {
2965
3129
  if (!ref && statusFilter) missions = missions.filter((mission) => missionMatchesStatusFilter(mission, statusFilter));
2966
3130
  if (!ref && limit) missions = missions.slice(0, limit);
2967
3131
  if (ref && !missions.length) {
2968
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3132
+ exitMissingMission(ref, 1, asJson);
2969
3133
  }
2970
3134
  const missionViews = missions.map(missionStatusView);
2971
3135
  // Member state renders are cwd-local writes; rolled-up missions stay read-only.
@@ -3147,7 +3311,7 @@ function missionReportTimeline(mission, root = process.cwd(), limit = 6) {
3147
3311
  return items.slice(Math.max(0, items.length - limit));
3148
3312
  }
3149
3313
 
3150
- function missionLandingTimeline(mission, root = process.cwd(), limit = 12) {
3314
+ function missionLandingTimeline(mission, root = process.cwd(), limit = 12, { kind = null, since = null } = {}) {
3151
3315
  const paths = statePaths(root);
3152
3316
  let files = [];
3153
3317
  try {
@@ -3158,6 +3322,8 @@ function missionLandingTimeline(mission, root = process.cwd(), limit = 12) {
3158
3322
  files = [];
3159
3323
  }
3160
3324
 
3325
+ const kindFilter = kind ? String(kind).trim() : null;
3326
+ const sinceFilter = since ? String(since).trim() : null;
3161
3327
  const items = [];
3162
3328
  const seen = new Set();
3163
3329
  for (const file of files) {
@@ -3168,6 +3334,9 @@ function missionLandingTimeline(mission, root = process.cwd(), limit = 12) {
3168
3334
  continue;
3169
3335
  }
3170
3336
  if (!receipt || receipt.mission_id !== mission.id) continue;
3337
+ const receiptKind = receipt.result && receipt.result.kind ? String(receipt.result.kind) : (receipt.kind ? String(receipt.kind) : '');
3338
+ if (kindFilter && receiptKind !== kindFilter) continue;
3339
+ if (sinceFilter && String(receipt.at || '') < sinceFilter) continue;
3171
3340
  const landing = receipt.result && receipt.result.landing;
3172
3341
  if (landing && landing.timeline_visible === false) continue;
3173
3342
  const changed = String(landing && landing.changed || '').trim();
@@ -3426,7 +3595,7 @@ function reportMission(args) {
3426
3595
  missions.sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
3427
3596
  }
3428
3597
  if (ref && !missions.length) {
3429
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3598
+ exitMissingMission(ref, 1, asJson);
3430
3599
  }
3431
3600
  missions = missions.slice(0, limit);
3432
3601
  const reports = missions.map((mission) => missionReportFor(mission, mission.worktree_root || process.cwd()));
@@ -3458,14 +3627,16 @@ function timelineMission(args) {
3458
3627
  const all = hasFlag(args, '--all');
3459
3628
  const prunePreviewRequested = hasFlag(args, '--prune-preview');
3460
3629
  const outputPath = readFlag(args, '--output', '') || readFlag(args, '--out', '');
3461
- const ref = stripKnownFlags(args, ['--limit', '--output', '--out'], ['--json', '--write', '--all', '--prune-preview'])[0] || '';
3630
+ const kindFilter = readFlag(args, '--kind', '') || null;
3631
+ const sinceFilter = readFlag(args, '--since', '') || null;
3632
+ const ref = stripKnownFlags(args, ['--limit', '--output', '--out', '--kind', '--since'], ['--json', '--write', '--all', '--prune-preview'])[0] || '';
3462
3633
  const limit = all ? Number.MAX_SAFE_INTEGER : readPositiveIntegerFlag(args, '--limit', 12, { json: asJson });
3463
3634
  const missions = listMissions();
3464
3635
  const mission = ref
3465
3636
  ? resolveMission(ref)
3466
3637
  : (missions.find((row) => !TERMINAL_STATUSES.has(row.status)) || missions[0] || null);
3467
3638
  if (ref && !mission) {
3468
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
3639
+ exitMissingMission(ref, 1, asJson);
3469
3640
  }
3470
3641
  if (!mission) {
3471
3642
  printJsonOrText(
@@ -3482,7 +3653,7 @@ function timelineMission(args) {
3482
3653
  return;
3483
3654
  }
3484
3655
  const root = mission.worktree_root || process.cwd();
3485
- const timelineResult = missionLandingTimeline(mission, root, limit);
3656
+ const timelineResult = missionLandingTimeline(mission, root, limit, { kind: kindFilter, since: sinceFilter });
3486
3657
  const timeline = timelineResult.items;
3487
3658
  const currentLanding = missionTimelineCurrentLanding(timeline);
3488
3659
  const timelineItemDisplay = (item) => ({
@@ -3652,8 +3823,14 @@ function timelineMission(args) {
3652
3823
  label: 'Filters',
3653
3824
  active_label: 'Active filter',
3654
3825
  active: all ? 'full_history' : 'latest',
3826
+ mission_label: 'Mission',
3827
+ mission: mission.id,
3655
3828
  limit_label: 'Limit',
3656
3829
  limit: timelineResult.meta.limit,
3830
+ kind_label: 'Kind',
3831
+ kind: kindFilter,
3832
+ since_label: 'Since',
3833
+ since: sinceFilter,
3657
3834
  shown_count: timelineResult.meta.shown_count,
3658
3835
  total_count: timelineResult.meta.total_count,
3659
3836
  hidden_count: timelineResult.meta.hidden_count,
@@ -3930,7 +4107,7 @@ function watchMission(args) {
3930
4107
  return listMissions().filter((mission) => !HEARTBEAT_TERMINAL_STATUSES.has(mission.status));
3931
4108
  };
3932
4109
  if (ref && !loadTargets().length) {
3933
- exitMissionError(`Mission "${ref}" not found.`, 1, false);
4110
+ exitMissingMission(ref, 1, false);
3934
4111
  }
3935
4112
  const stamp = () => new Date().toTimeString().slice(0, 8);
3936
4113
  const shortId = (mission) => mission.id.length > 20 ? `…${mission.id.slice(-8)}` : mission.id;
@@ -4225,6 +4402,15 @@ const MISSION_RUN_DEFAULTS = {
4225
4402
  backoff: { initialMs: 30_000, maxMs: 10 * 60_000, factor: 2, jitter: 0.3 },
4226
4403
  };
4227
4404
 
4405
+ // Claude sessions accumulate context across resumed ticks; an always-on
4406
+ // mission would grow without bound. Continuity lives on disk (receipts, logs,
4407
+ // now.md), so a healthy session is disposable: rotate to a fresh one every N
4408
+ // ran ticks. Failure-path rotation (stale lock) stays separate below.
4409
+ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
4410
+ 1,
4411
+ Number(process.env.ATRIS_CLAUDE_SESSION_ROTATE_TICKS) || 8,
4412
+ );
4413
+
4228
4414
  function runnerUsesCallerSession(runner) {
4229
4415
  return new Set(['codex_goal', 'caller_session', 'current_agent']).has(String(runner || '').trim().toLowerCase());
4230
4416
  }
@@ -4328,6 +4514,16 @@ function missionIsRunnable(mission) {
4328
4514
  && !missionHasHumanAsks(mission);
4329
4515
  }
4330
4516
 
4517
+ // Fresh goal selection: moving work outranks review-parked work. A ready
4518
+ // mission stays selectable (its next action is native-goal completion /
4519
+ // review), but it must never beat a running or planning mission on recency.
4520
+ const GOAL_SELECTION_STATUS_RANK = { running: 0, planning: 1, ready: 2 };
4521
+
4522
+ function missionGoalSelectionRank(mission) {
4523
+ const rank = GOAL_SELECTION_STATUS_RANK[String(mission?.status || '')];
4524
+ return Number.isFinite(rank) ? rank : 3;
4525
+ }
4526
+
4331
4527
  function missionSortTime(mission) {
4332
4528
  return Date.parse(mission?.updated_at || mission?.created_at || '') || 0;
4333
4529
  }
@@ -4382,6 +4578,10 @@ function selectCodexGoalMission(root = process.cwd(), options = {}, now = new Da
4382
4578
  }
4383
4579
 
4384
4580
  candidates.sort((a, b) => {
4581
+ const aRank = missionGoalSelectionRank(a);
4582
+ const bRank = missionGoalSelectionRank(b);
4583
+ if (aRank !== bRank) return aRank - bRank;
4584
+
4385
4585
  const aCaller = runnerUsesCallerSession(a.runner) ? 1 : 0;
4386
4586
  const bCaller = runnerUsesCallerSession(b.runner) ? 1 : 0;
4387
4587
  if (aCaller !== bCaller) return bCaller - aCaller;
@@ -4409,6 +4609,10 @@ function selectAtrisGoalMission(root = process.cwd(), options = {}, now = new Da
4409
4609
  if (exact) return { mission: exact, reason: 'selected' };
4410
4610
  }
4411
4611
  runnable.sort((a, b) => {
4612
+ const aRank = missionGoalSelectionRank(a);
4613
+ const bRank = missionGoalSelectionRank(b);
4614
+ if (aRank !== bRank) return aRank - bRank;
4615
+
4412
4616
  const aDue = effectiveMissionVerifier(a) && missionDueAt(a, now) ? 1 : 0;
4413
4617
  const bDue = effectiveMissionVerifier(b) && missionDueAt(b, now) ? 1 : 0;
4414
4618
  if (aDue !== bDue) return bDue - aDue;
@@ -4427,14 +4631,50 @@ function codexGoalObjective(mission) {
4427
4631
  return mission.objective;
4428
4632
  }
4429
4633
 
4430
- function codexNativeGoalAck(mission, objective = codexGoalObjective(mission)) {
4634
+ function codexNativeGoalAck(mission) {
4431
4635
  const ack = mission?.native_goal_ack || null;
4432
4636
  if (!ack || String(ack.runtime || '').toLowerCase() !== 'codex') return null;
4433
4637
  if (ack.status !== 'active') return null;
4434
- if (String(ack.objective || '') !== String(objective || '')) return null;
4638
+ if (ack.mission_id && String(ack.mission_id) !== String(mission?.id || '')) return null;
4435
4639
  return ack;
4436
4640
  }
4437
4641
 
4642
+ function supersedeOtherCodexNativeGoalAcks(root, activeMission, activeAck) {
4643
+ const activeMissionId = String(activeMission?.id || '');
4644
+ if (!activeMissionId) return [];
4645
+ const supersededAt = stampIso();
4646
+ const superseded = [];
4647
+ for (const mission of listMissions(root)) {
4648
+ if (String(mission.id || '') === activeMissionId) continue;
4649
+ const priorAck = codexNativeGoalAck(mission);
4650
+ if (!priorAck) continue;
4651
+ const nextAck = {
4652
+ ...priorAck,
4653
+ status: 'superseded',
4654
+ superseded_at: supersededAt,
4655
+ superseded_by_mission_id: activeMissionId,
4656
+ superseded_by_objective: activeAck?.objective || activeMission.objective,
4657
+ };
4658
+ const { mission: saved } = saveMission(
4659
+ { ...mission, native_goal_ack: nextAck },
4660
+ root,
4661
+ 'mission_native_goal_ack_superseded',
4662
+ {
4663
+ superseded_by_mission_id: activeMissionId,
4664
+ superseded_by_objective: activeAck?.objective || activeMission.objective,
4665
+ previous_ack: priorAck,
4666
+ },
4667
+ );
4668
+ superseded.push({
4669
+ mission_id: saved.id,
4670
+ objective: saved.objective,
4671
+ previous_ack: priorAck,
4672
+ native_goal_ack: nextAck,
4673
+ });
4674
+ }
4675
+ return superseded;
4676
+ }
4677
+
4438
4678
  function codexRuntimeGoalStateFromOptions(options = {}) {
4439
4679
  const status = String(options.nativeGoalStatus || options.visibleGoalStatus || '').trim().toLowerCase();
4440
4680
  const objective = String(options.nativeGoalObjective || options.visibleGoalObjective || '').trim();
@@ -4601,6 +4841,33 @@ function activeCodexVisibleGoalOwner(root = process.cwd(), excludeId = '', now =
4601
4841
  return candidates[0] || null;
4602
4842
  }
4603
4843
 
4844
+ function pauseMissionRecord(mission, reason, root = process.cwd()) {
4845
+ const next = {
4846
+ ...mission,
4847
+ status: 'paused',
4848
+ paused_at: stampIso(),
4849
+ stop_reason: reason,
4850
+ next_action: `resume with: atris mission tick ${mission.id}`,
4851
+ };
4852
+ const { mission: saved } = saveMission(next, root, 'mission_paused', { reason, source: 'goal_slot_handoff' });
4853
+ appendMemberLog(saved.owner, 'Mission paused', { mission: saved.objective, reason });
4854
+ return saved;
4855
+ }
4856
+
4857
+ function takeCodexGoalSlotForMission(newMission, root = process.cwd(), options = {}) {
4858
+ if (!newMission || !isCodexGoalMission(newMission)) return null;
4859
+ const runtimeGoalState = codexRuntimeGoalStateFromOptions(options);
4860
+ const activeOwner = activeCodexVisibleGoalOwner(root, newMission.id, new Date(), runtimeGoalState);
4861
+ if (!activeOwner) return null;
4862
+ const reason = `visible goal replaced by ${newMission.id}`;
4863
+ const paused = pauseMissionRecord(activeOwner, reason, root);
4864
+ return {
4865
+ paused_mission_id: paused.id,
4866
+ paused_mission: missionStatusView(paused),
4867
+ reason,
4868
+ };
4869
+ }
4870
+
4604
4871
  function codexGoalActiveConflictPayload(newMission, activeMission, request = null, heartbeatMode = false, runtimeGoalState = null) {
4605
4872
  const newObjective = codexGoalObjective(newMission);
4606
4873
  const pausedConflict = runtimeGoalState?.status === 'paused';
@@ -4899,7 +5166,7 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
4899
5166
  const taskSpine = missionTaskSpine(mission);
4900
5167
  const missionView = missionStatusView(mission);
4901
5168
  const objective = codexGoalObjective(mission);
4902
- const ack = codexNativeGoalAck(mission, objective);
5169
+ const ack = codexNativeGoalAck(mission);
4903
5170
  const runtimeNeedsReplace = !ack && codexRuntimeGoalNeedsReplace(runtimeGoalState, objective);
4904
5171
  const nativeGoalAction = ack
4905
5172
  ? null
@@ -5315,9 +5582,30 @@ function probeClaudeBinary() {
5315
5582
  return { ok: true };
5316
5583
  }
5317
5584
 
5318
- function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
5585
+ // Pull unread operator pings off the mission and mark them consumed, so the
5586
+ // next tick's prompt carries them exactly once. Pings are how a human talks to
5587
+ // an always-on member mid-run: atris member ping <name> "<msg>".
5588
+ function consumeMissionPings(mission, cwd) {
5589
+ const pending = (Array.isArray(mission.pings) ? mission.pings : []).filter((p) => p && !p.consumed_at);
5590
+ if (!pending.length) return { mission, pings: [] };
5591
+ const consumedAt = stampIso();
5592
+ const pings = (mission.pings || []).map((p) => (p && !p.consumed_at ? { ...p, consumed_at: consumedAt } : p));
5593
+ const saved = saveMission({ ...mission, pings }, cwd, 'mission_pings_consumed', { count: pending.length }).mission;
5594
+ return { mission: saved, pings: pending };
5595
+ }
5596
+
5597
+ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = []) {
5598
+ const pingLines = pings.length
5599
+ ? [
5600
+ ``,
5601
+ `## Operator pings (read these first)`,
5602
+ `Your operator sent ${pings.length === 1 ? 'a message' : 'messages'} mid-run. Treat them as fresh direction for this tick (they do not change the frozen verifier or lane):`,
5603
+ ...pings.map((p) => `- [${p.at}] ${p.from || 'operator'}: ${p.text}`),
5604
+ ]
5605
+ : [];
5319
5606
  const lines = [
5320
5607
  `# Mission Tick ${tickIndex}/${maxTicks}`,
5608
+ ...pingLines,
5321
5609
  ``,
5322
5610
  `**Objective:** ${mission.objective}`,
5323
5611
  `**Owner:** ${mission.owner}`,
@@ -5336,6 +5624,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
5336
5624
  `- Pick the smallest concrete action that moves the mission forward.`,
5337
5625
  `- Edit / run / research as needed for the lane.`,
5338
5626
  `- After your work, the harness runs the frozen verifier — make sure it'll pass.`,
5627
+ `- Write the tick summary in operator language: what changed and what it buys or costs in plain words, with no flags, task ids, or code identifiers.`,
5339
5628
  `- If you can't make progress this tick, say why explicitly. Don't fake it.`,
5340
5629
  ``,
5341
5630
  `## Constraints`,
@@ -5405,7 +5694,10 @@ function spawnClaudeTick(mission, opts) {
5405
5694
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
5406
5695
 
5407
5696
  const startedAt = Date.now();
5408
- const proc = spawn(resolveClaudeRunnerBin(), args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
5697
+ // detached: the runner spawns its own children; killing only the direct
5698
+ // child on timeout leaves them holding the session lock, and the next
5699
+ // tick's resume fails with "Session ID ... is already in use".
5700
+ const proc = spawn(resolveClaudeRunnerBin(), args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
5409
5701
 
5410
5702
  let stdoutBuf = '';
5411
5703
  let observedSessionIds = new Set();
@@ -5422,8 +5714,11 @@ function spawnClaudeTick(mission, opts) {
5422
5714
  let aborted = false;
5423
5715
 
5424
5716
  const kill = (reason) => {
5425
- try { proc.kill('SIGTERM'); } catch {}
5426
- setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000).unref();
5717
+ const killGroup = (sig) => {
5718
+ try { process.kill(-proc.pid, sig); } catch { try { proc.kill(sig); } catch {} }
5719
+ };
5720
+ killGroup('SIGTERM');
5721
+ setTimeout(() => killGroup('SIGKILL'), 3000).unref();
5427
5722
  };
5428
5723
  const timer = setTimeout(() => { timedOut = true; kill('timeout'); }, timeoutMs);
5429
5724
  const onAbort = () => { aborted = true; kill('aborted'); };
@@ -5529,6 +5824,21 @@ async function runMission(args) {
5529
5824
  help();
5530
5825
  return;
5531
5826
  }
5827
+ // --fleet: staff every idle capable engine on the board's claimable
5828
+ // safe-lane tasks, build in parallel worktrees, land serially. Humble flag,
5829
+ // full loop — see lib/fleet.js. --dry-run previews the staffing only.
5830
+ if (hasFlag(args, '--fleet')) {
5831
+ const { runFleetFlight } = require('../lib/fleet');
5832
+ const slots = Math.max(1, Number(readFlag(args, '--slots', '')) || 3);
5833
+ const flight = await runFleetFlight({
5834
+ slots,
5835
+ dryRun: hasFlag(args, '--dry-run'),
5836
+ log: asJson ? () => {} : console.log,
5837
+ });
5838
+ if (asJson) console.log(JSON.stringify(flight, null, 2));
5839
+ process.exitCode = flight.paused && flight.paused.length > 0 ? 1 : 0;
5840
+ return;
5841
+ }
5532
5842
  const dueMode = hasFlag(args, '--due');
5533
5843
  const skipClaude = hasFlag(args, '--no-claude');
5534
5844
  const verifyEach = !hasFlag(args, '--no-verify');
@@ -5715,7 +6025,9 @@ async function runMission(args) {
5715
6025
  claude: { skipped: true, reason: callerSessionRunner ? 'runner-uses-caller-session' : 'no-claude-mode' },
5716
6026
  };
5717
6027
  } else if (atris2Runner) {
5718
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen);
6028
+ const pingDrain = consumeMissionPings(mission, cwd);
6029
+ mission = pingDrain.mission;
6030
+ const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
5719
6031
  const { runAtris2Turn } = require('./probe');
5720
6032
  const businessId = businessIdForAtris2Mission(mission, cwd);
5721
6033
  const turn = await runAtris2Turn({
@@ -5745,7 +6057,9 @@ async function runMission(args) {
5745
6057
  } else {
5746
6058
  const sessionMode = sessionId ? 'resume' : 'set';
5747
6059
  const useId = sessionId || pendingSessionId;
5748
- const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen);
6060
+ const pingDrain = consumeMissionPings(mission, cwd);
6061
+ mission = pingDrain.mission;
6062
+ const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen, pingDrain.pings);
5749
6063
  const claudeResult = await spawnClaudeTick(mission, {
5750
6064
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
5751
6065
  timeoutMs: MISSION_RUN_DEFAULTS.claudeTimeoutMs, prompt,
@@ -5766,10 +6080,11 @@ async function runMission(args) {
5766
6080
  aborted: claudeResult.aborted,
5767
6081
  };
5768
6082
  if (claudeResult.rate_limit_info) {
5769
- lastRateLimit = claudeResult.rate_limit_info;
5770
- if (lastRateLimit.status && lastRateLimit.status !== 'allowed') {
5771
- // throttled / overage
5772
- }
6083
+ // Claude reports the five-hour window's resetsAt on every turn, even
6084
+ // when status is "allowed". Only a non-allowed status is a cooldown;
6085
+ // treating an allowed resetsAt as one pauses every timed run after
6086
+ // tick 1 with rate-limit-exceeded-wall.
6087
+ lastRateLimit = claudeResult.rate_limit_info.status === 'allowed' ? null : claudeResult.rate_limit_info;
5773
6088
  }
5774
6089
  if (claudeResult.aborted) { pauseReason = 'aborted-during-claude'; break; }
5775
6090
  if (claudeResult.authExpired) { pauseReason = 'auth-required'; break; }
@@ -5778,6 +6093,20 @@ async function runMission(args) {
5778
6093
  const deadModel = detectUnavailableModel(claudeResult.summary || claudeResult.receipt_text);
5779
6094
  let reason = claudeResult.timedOut ? 'claude-timeout' : 'claude-error';
5780
6095
  if (deadModel) { reason = 'model-unavailable'; result.model_unavailable = deadModel; }
6096
+ // A killed tick (usually claude-timeout) can leave the session lock
6097
+ // held, so the next resume fails with "already in use". Session
6098
+ // continuity is disposable — mission state lives on disk (receipts,
6099
+ // logs, now.md) — so rotate to a fresh id instead of grinding the
6100
+ // repeated-error breaker on a stale lock.
6101
+ if (/session id .* is already in use/i.test(claudeResult.stderr || '')) {
6102
+ reason = 'claude-session-busy';
6103
+ pendingSessionId = crypto.randomUUID();
6104
+ sessionId = null;
6105
+ mission = saveMission(
6106
+ { ...mission, claude_session_id: null, pending_session_id: pendingSessionId },
6107
+ cwd, 'mission_session_rotated', { reason: 'session-lock-busy', session_id: pendingSessionId },
6108
+ ).mission;
6109
+ }
5781
6110
  result = { ...result, status: 'errored', reason };
5782
6111
  } else {
5783
6112
  // Promote pending session id ONLY if claude confirmed the exact UUID we requested.
@@ -5865,6 +6194,11 @@ async function runMission(args) {
5865
6194
  } else if (result.status === 'ran' && mission.always_on) {
5866
6195
  nextAction = nextCandidateTickAction(mission);
5867
6196
  }
6197
+ // Context hygiene: count ran claude ticks on this session; at the
6198
+ // rotation threshold, drop the session so the next tick starts fresh.
6199
+ const claudeRanTick = Boolean(result.claude) && result.status === 'ran';
6200
+ const claudeSessionTicks = claudeRanTick ? Number(mission.claude_session_ticks || 0) + 1 : Number(mission.claude_session_ticks || 0);
6201
+ const rotateSessionForContext = claudeRanTick && claudeSessionTicks >= CLAUDE_SESSION_CONTEXT_ROTATE_TICKS;
5868
6202
  mission = saveMission({
5869
6203
  ...mission,
5870
6204
  status: newStatus,
@@ -5879,9 +6213,23 @@ async function runMission(args) {
5879
6213
  verifier_result: verifierResult || mission.verifier_result || null,
5880
6214
  receipt_path: receiptPath,
5881
6215
  next_action: nextAction,
6216
+ claude_session_ticks: rotateSessionForContext ? 0 : claudeSessionTicks,
5882
6217
  }, cwd, 'mission_tick', {
5883
6218
  tick_index: tickIdx, status: result.status, reason: result.reason, receipt_path: receiptPath, layer: result.layer,
5884
6219
  }).mission;
6220
+ if (rotateSessionForContext) {
6221
+ // Same shape as the stale-lock rotation above: mint the fresh pending
6222
+ // id AND reset the loop locals, or the next tick resumes the old
6223
+ // session from its stale local and the rotation is a no-op.
6224
+ pendingSessionId = crypto.randomUUID();
6225
+ sessionId = null;
6226
+ mission = saveMission(
6227
+ { ...mission, claude_session_id: null, pending_session_id: pendingSessionId },
6228
+ cwd, 'mission_session_rotated', {
6229
+ reason: 'context-refresh', after_ticks: claudeSessionTicks, session_id: pendingSessionId,
6230
+ },
6231
+ ).mission;
6232
+ }
5885
6233
  appendMemberLog(mission.owner, `Mission run tick ${tickIdx}`, {
5886
6234
  mission: mission.objective,
5887
6235
  state: mission.status,
@@ -5904,7 +6252,10 @@ async function runMission(args) {
5904
6252
  if (result.status === 'ran') {
5905
6253
  ranTicks++;
5906
6254
  backoffAttempt = 0;
5907
- } else if (result.status === 'errored') {
6255
+ } else if (result.status === 'errored' && result.reason !== 'claude-session-busy') {
6256
+ // A rotated session is already healed — the next tick starts on a
6257
+ // fresh id, so backing off just burns wall clock. If rotation itself
6258
+ // keeps failing, the repeated-error breaker below still stops the run.
5908
6259
  backoffAttempt++;
5909
6260
  }
5910
6261
 
@@ -5924,7 +6275,7 @@ async function runMission(args) {
5924
6275
 
5925
6276
  // Sleep until next tick
5926
6277
  let sleepMs = 0;
5927
- if (result.status === 'errored') {
6278
+ if (result.status === 'errored' && result.reason !== 'claude-session-busy') {
5928
6279
  sleepMs = computeBackoff(MISSION_RUN_DEFAULTS.backoff, backoffAttempt);
5929
6280
  } else if (cadenceSeconds > 0) {
5930
6281
  sleepMs = cadenceSeconds * 1000;
@@ -6009,10 +6360,12 @@ function tickMission(args) {
6009
6360
  const verifyOverride = readFlag(args, '--verify', '');
6010
6361
  const completeOnPass = hasFlag(args, '--complete-on-pass');
6011
6362
  const summary = readFlag(args, '--summary', '');
6363
+ const operatorSummaryWarning = warnIfSummaryNeedsOperatorWhy(summary);
6012
6364
  const ref = stripKnownFlags(args, ['--summary', '--verify'], ['--json', '--complete-on-pass'])[0] || '';
6013
6365
  let mission = resolveMission(ref);
6014
6366
  if (!mission) {
6015
- exitMissionError(ref ? `Mission "${ref}" not found.` : 'No mission found. Run: atris mission start "..."', 1, asJson);
6367
+ if (ref) exitMissingMission(ref, 1, asJson);
6368
+ exitMissionError('No mission found. Run: atris mission start "..."', 1, asJson);
6016
6369
  }
6017
6370
 
6018
6371
  // Same per-mission flock that `mission run` uses. Without it, a tick could
@@ -6154,7 +6507,7 @@ function tickMission(args) {
6154
6507
  const atrisGoalState = refreshAtrisGoalController(process.cwd(), { missionId: outputMission.id });
6155
6508
  const codexGoalState = refreshCodexGoalController(process.cwd());
6156
6509
  printJsonOrText(
6157
- { ok: true, action: 'mission_tick', mission: outputMission, tick: tickRecord, verifier_result: verifierResult, receipt_path: receiptPath, log_path: logPath, atris_goal_state: atrisGoalState, codex_goal_state: codexGoalState, continuation_goal: continuationGoal },
6510
+ { ok: true, action: 'mission_tick', mission: outputMission, tick: tickRecord, verifier_result: verifierResult, receipt_path: receiptPath, log_path: logPath, atris_goal_state: atrisGoalState, codex_goal_state: codexGoalState, continuation_goal: continuationGoal, operator_summary_warning: operatorSummaryWarning },
6158
6511
  [
6159
6512
  ...missionTickResultLines(outputMission, tickIdx, receiptPath, verifierResult, summary),
6160
6513
  ...(continuationGoal?.mission ? [`Next goal: ${continuationGoal.mission.objective}`] : []),
@@ -6223,7 +6576,7 @@ function completeMission(args) {
6223
6576
  }
6224
6577
  const mission = resolveMission(ref);
6225
6578
  if (!mission) {
6226
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
6579
+ exitMissingMission(ref, 1, asJson);
6227
6580
  }
6228
6581
  const gate = missionCompletionGate(mission, proof, root);
6229
6582
  if (!gate.ok && !force) {
@@ -6299,7 +6652,7 @@ function stopMission(args) {
6299
6652
  }
6300
6653
  const mission = resolveMission(ref);
6301
6654
  if (!mission) {
6302
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
6655
+ exitMissingMission(ref, 1, asJson);
6303
6656
  }
6304
6657
  const status = pause ? 'paused' : 'stopped';
6305
6658
  // Full stops abandon work, so leave evidence: snapshot the worktree against
@@ -6415,7 +6768,7 @@ function ackMissionGoal(args) {
6415
6768
  const status = String(readFlag(args, '--status', 'active') || 'active').trim().toLowerCase();
6416
6769
  let mission = resolveMission(ref);
6417
6770
  if (!mission) {
6418
- exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
6771
+ exitMissingMission(ref, 1, asJson);
6419
6772
  }
6420
6773
  if (runtime !== 'codex' || status !== 'active') {
6421
6774
  exitMissionError('Native goal ack requires --runtime codex --status active.', 2, asJson);
@@ -6432,16 +6785,15 @@ function ackMissionGoal(args) {
6432
6785
  releaseMissionLock(lock);
6433
6786
  exitMissionError(`Mission "${ref}" uses runner=${mission.runner || 'manual'}; native Codex goal ack is only for runner=codex_goal.`, 2, asJson);
6434
6787
  }
6435
- const expectedObjective = codexGoalObjective(mission);
6436
- const objective = readFlag(args, '--objective', expectedObjective);
6437
- if (objective !== expectedObjective) {
6438
- releaseMissionLock(lock);
6439
- exitMissionError(`Native goal objective mismatch. Expected: ${expectedObjective}`, 2, asJson);
6440
- }
6788
+ const canonicalObjective = codexGoalObjective(mission);
6789
+ const reportedObjective = readFlag(args, '--objective', canonicalObjective);
6790
+ const objective = canonicalObjective;
6441
6791
  const ack = {
6442
6792
  runtime: 'codex',
6443
6793
  status: 'active',
6794
+ mission_id: mission.id,
6444
6795
  objective,
6796
+ ...(reportedObjective && reportedObjective !== objective ? { reported_objective: reportedObjective } : {}),
6445
6797
  acknowledged_at: stampIso(),
6446
6798
  };
6447
6799
  const nextMission = {
@@ -6449,6 +6801,7 @@ function ackMissionGoal(args) {
6449
6801
  native_goal_ack: ack,
6450
6802
  next_action: codexGoalNextCommand({ ...mission, native_goal_ack: ack }),
6451
6803
  };
6804
+ const supersededNativeGoalAcks = supersedeOtherCodexNativeGoalAcks(process.cwd(), nextMission, ack);
6452
6805
  const { mission: saved } = saveMission(nextMission, process.cwd(), 'mission_native_goal_ack', { ack });
6453
6806
  const payload = refreshCodexGoalController(process.cwd());
6454
6807
  printJsonOrText(
@@ -6457,6 +6810,7 @@ function ackMissionGoal(args) {
6457
6810
  action: 'native_goal_acknowledged',
6458
6811
  mission: saved,
6459
6812
  native_goal_ack: ack,
6813
+ superseded_native_goal_acks: supersededNativeGoalAcks,
6460
6814
  codex_goal_state: payload,
6461
6815
  next_command: payload.goal?.next_command || `atris mission tick ${saved.id} --summary "<what changed>"`,
6462
6816
  },
@@ -6555,13 +6909,15 @@ function help() {
6555
6909
  console.log(`
6556
6910
  atris mission - durable goal + loop + owner + proof state
6557
6911
 
6558
- atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--xp-task] [--worktree]
6912
+ atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--xp-task] [--worktree] [--take-goal-slot]
6559
6913
  [--runner manual|claude|atris2|codex_goal] [--model <id>]
6560
6914
  (runner claude spawns local claude -p per tick, --model passes through;
6561
6915
  runner atris2 runs each tick as one /atris2/turn on the AtrisOS backend,
6562
6916
  default model atris:fast; runner codex_goal publishes the goal for a live
6563
6917
  Codex session to pull via atris mission goal)
6564
6918
  atris mission status [id] [--status <state>] [--limit <n>] [--local] [--json]
6919
+ atris mission inspect <id> --fields status,runner,ack,pings [--json]
6920
+ Field-selectable mission state (status, runner, native goal ack, ping counts)
6565
6921
  atris mission doctor [--local] [--json] Flag no-verifier missions, help missions, stale ready receipts, and blocked always-on loops
6566
6922
  atris mission attach-task <id> [--json] Create the missing task spine for an existing active mission
6567
6923
  atris mission report [id] [--limit <n>] [--local] [--json] Plain outcome, worker receipt, verifier receipt, and next move
@@ -6576,8 +6932,9 @@ atris mission - durable goal + loop + owner + proof state
6576
6932
  atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
6577
6933
  atris mission tick <id> [--verify ["cmd"]] [--complete-on-pass] [--summary "..."] [--json]
6578
6934
  atris mission "<objective>" [--owner <member>] Shortcut for: atris mission run "<objective>"
6935
+ atris mission run --fleet [--slots 3] [--dry-run] [--json] Staff every idle capable engine on claimable safe-lane tasks: parallel worktree builds, serial rebase-before-ship landings, receipt in atris/runs/
6579
6936
  atris mission run ["objective"|<member> ["objective"]|id|--due] [--owner <member>] [--max-ticks 4] [--max-wall 3600] [--cadence "15m"]
6580
- [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede]
6937
+ [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede] [--take-goal-slot]
6581
6938
  [--spend-full-budget|--use-whole-budget|--stop-when-done] [--room-preflight|--no-room-preflight]
6582
6939
  [--room-auto-run|--no-room-auto-run]
6583
6940
  [--no-claude] [--no-verify] [--complete-on-pass] [--no-drain] [--create-next] [--json]
@@ -6838,6 +7195,98 @@ function pruneRunsMission(args) {
6838
7195
  if (result.errors.length) process.exitCode = 1;
6839
7196
  }
6840
7197
 
7198
+ // Locate a mission whose state may live in a sibling worktree (--worktree
7199
+ // missions keep their jsonl there). Returns the mission plus the root whose
7200
+ // state file owns it, so writes land where the runner reads.
7201
+ function findMissionAcrossWorktrees(ref, root = process.cwd()) {
7202
+ const local = resolveMission(ref, root);
7203
+ if (local) return { mission: local, root };
7204
+ for (const rolled of listWorktreeRollupMissions(root)) {
7205
+ if (rolled.id === ref || rolled.id.startsWith(ref) || rolled.slug === ref) {
7206
+ const { worktree_root, worktree_branch, ...mission } = rolled;
7207
+ return { mission, root: worktree_root };
7208
+ }
7209
+ }
7210
+ return null;
7211
+ }
7212
+
7213
+ // atris mission ping <id> "<message>" — leave a note the mission's next tick
7214
+ // reads (and consumes) as operator direction. This is how you talk to an
7215
+ // always-on member mid-run without stopping it.
7216
+ function inspectMission(args) {
7217
+ const asJson = wantsJson(args);
7218
+ const parsed = readFieldsFlag(args, '--fields');
7219
+ if (!parsed || parsed.error) {
7220
+ exitMissionError(
7221
+ parsed?.error || 'Usage: atris mission inspect <id> --fields status,runner,ack,pings [--json]',
7222
+ 2,
7223
+ asJson,
7224
+ );
7225
+ }
7226
+ const fieldError = validateFields(parsed.fields, MISSION_INSPECT_FIELDS, 'mission');
7227
+ if (fieldError) exitMissionError(fieldError, 2, asJson);
7228
+ const ref = stripInspectArgs(args)[0] || '';
7229
+ if (!ref) {
7230
+ exitMissionError('Usage: atris mission inspect <id> --fields status,runner,ack,pings [--json]', 2, asJson);
7231
+ }
7232
+ const found = findMissionAcrossWorktrees(ref);
7233
+ if (!found) exitMissingMission(ref, 1, asJson);
7234
+ const { mission } = found;
7235
+ const values = missionInspectFieldValues(mission, parsed.fields);
7236
+ const payload = buildInspectPayload({
7237
+ action: 'mission_inspect',
7238
+ idKey: 'mission_id',
7239
+ idValue: mission.id,
7240
+ fields: parsed.fields,
7241
+ values,
7242
+ });
7243
+ printJsonOrText(payload, inspectTextLines(parsed.fields, values), asJson);
7244
+ }
7245
+
7246
+ // always-on member mid-run without stopping it. opts.silent skips console
7247
+ // output so callers (member ping) can compose their own multi-lane report.
7248
+ function pingMission(args, opts = {}) {
7249
+ const asJson = args.includes('--json');
7250
+ const rest = args.filter((a) => a !== '--json');
7251
+ let from = process.env.USER || 'operator';
7252
+ const fromIdx = rest.indexOf('--from');
7253
+ if (fromIdx !== -1) {
7254
+ from = rest[fromIdx + 1] || from;
7255
+ rest.splice(fromIdx, 2);
7256
+ }
7257
+ const [ref, ...textParts] = rest;
7258
+ const text = textParts.join(' ').trim();
7259
+ if (!ref || !text) {
7260
+ console.error('usage: atris mission ping <id> "<message>" [--from <name>]');
7261
+ process.exit(2);
7262
+ }
7263
+ const found = findMissionAcrossWorktrees(ref);
7264
+ if (!found) {
7265
+ exitMissingMission(ref, 1, asJson);
7266
+ }
7267
+ const { mission, root } = found;
7268
+ if (TERMINAL_STATUSES.has(mission.status)) {
7269
+ console.error(`Mission ${mission.id} is ${mission.status}; pings only reach live missions.`);
7270
+ process.exit(1);
7271
+ }
7272
+ const ping = { at: stampIso(), from, text };
7273
+ const saved = saveMission(
7274
+ { ...mission, pings: [...(Array.isArray(mission.pings) ? mission.pings : []), ping] },
7275
+ root,
7276
+ 'mission_ping',
7277
+ { from, text: text.slice(0, 200) },
7278
+ ).mission;
7279
+ const pending = (saved.pings || []).filter((p) => p && !p.consumed_at).length;
7280
+ if (!opts.silent) {
7281
+ if (asJson) {
7282
+ console.log(JSON.stringify({ ok: true, action: 'mission_ping', mission_id: saved.id, pending_pings: pending, ping }));
7283
+ } else {
7284
+ console.log(`pinged ${saved.id} — the next tick reads it (${pending} unread).`);
7285
+ }
7286
+ }
7287
+ return saved;
7288
+ }
7289
+
6841
7290
  function missionCommand(args) {
6842
7291
  const subcommand = args[0] || 'status';
6843
7292
  const rest = args.slice(1);
@@ -6882,6 +7331,10 @@ function missionCommand(args) {
6882
7331
  case 'goal-loop':
6883
7332
  case 'codex-goal-loop':
6884
7333
  return goalLoopMission(rest);
7334
+ case 'ping':
7335
+ return pingMission(rest);
7336
+ case 'inspect':
7337
+ return inspectMission(rest);
6885
7338
  case 'tick':
6886
7339
  return tickMission(rest);
6887
7340
  case 'run':
@@ -6904,11 +7357,15 @@ function missionCommand(args) {
6904
7357
 
6905
7358
  module.exports = {
6906
7359
  missionCommand,
7360
+ inspectMission,
6907
7361
  missionHeartbeatLines,
6908
7362
  listMissions,
7363
+ listWorktreeRollupMissions,
7364
+ pingMission,
6909
7365
  loadMissionMap,
6910
7366
  renderMissionStatus,
6911
7367
  selectDueMission,
7368
+ selectAtrisGoalMission,
6912
7369
  selectCodexGoalMission,
6913
7370
  usefulClaudeReceiptSummary,
6914
7371
  cappedClaudeReceiptText,