atris 3.32.0 → 3.34.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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * atris loops - one view of every background loop: what runs, what died,
3
+ * what you can start or stop.
4
+ *
5
+ * atris loops - the board (registry jobs, launchd agents, missions)
6
+ * atris loops stop <id> - disable a registry job or boot out a launchd agent
7
+ * atris loops start <id> - enable a registry job or kickstart a launchd agent
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { execSync, spawnSync } = require('child_process');
14
+
15
+ function heartbeatDir() {
16
+ return process.env.ATRIS_HEARTBEAT_DIR || path.join(os.homedir(), '.atris', 'heartbeat');
17
+ }
18
+
19
+ function readJson(filePath, fallback) {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
22
+ } catch {
23
+ return fallback;
24
+ }
25
+ }
26
+
27
+ function registryPath() {
28
+ return path.join(heartbeatDir(), 'registry.json');
29
+ }
30
+
31
+ function loadRegistry() {
32
+ const registry = readJson(registryPath(), null);
33
+ return registry && Array.isArray(registry.jobs) ? registry : { jobs: [] };
34
+ }
35
+
36
+ function loadRunState() {
37
+ return readJson(path.join(heartbeatDir(), 'state.json'), {});
38
+ }
39
+
40
+ function agoText(iso) {
41
+ const then = Date.parse(iso || '');
42
+ if (!Number.isFinite(then)) return 'never';
43
+ const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
44
+ if (mins < 60) return `${mins}m ago`;
45
+ const hours = Math.floor(mins / 60);
46
+ if (hours < 48) return `${hours}h ago`;
47
+ return `${Math.floor(hours / 24)}d ago`;
48
+ }
49
+
50
+ function listLaunchdAgents() {
51
+ let raw = '';
52
+ try {
53
+ raw = execSync('launchctl list', { encoding: 'utf8', timeout: 10000 });
54
+ } catch {
55
+ return [];
56
+ }
57
+ return raw
58
+ .split('\n')
59
+ .filter(line => line.includes('com.atris.'))
60
+ .map(line => {
61
+ const [pid, exitCode, label] = line.trim().split(/\s+/);
62
+ return {
63
+ label,
64
+ running: pid !== '-',
65
+ pid: pid !== '-' ? pid : null,
66
+ lastExit: exitCode,
67
+ };
68
+ })
69
+ .filter(agent => agent.label);
70
+ }
71
+
72
+ function workspaceMission(cwd) {
73
+ const goalState = readJson(path.join(cwd, '.atris', 'state', 'atris_goal.json'), null);
74
+ const goal = goalState && goalState.goal;
75
+ if (!goal || !goal.objective) return null;
76
+ const started = Date.parse(goal.created_at || '');
77
+ const elapsedMins = Number.isFinite(started) ? Math.round((Date.now() - started) / 60000) : null;
78
+ return {
79
+ id: goal.mission_id || '?',
80
+ status: goal.mission_status || '?',
81
+ objective: String(goal.objective),
82
+ elapsedMins,
83
+ };
84
+ }
85
+
86
+ function showBoard(cwd) {
87
+ const registry = loadRegistry();
88
+ const state = loadRunState();
89
+
90
+ console.log('background loops');
91
+ console.log('');
92
+
93
+ const enabled = registry.jobs.filter(job => job.enabled !== false && job.disabled !== true);
94
+ const disabledCount = registry.jobs.length - enabled.length;
95
+ console.log(`heartbeat registry (${enabled.length} live, ${disabledCount} retired)`);
96
+ if (!enabled.length) {
97
+ console.log(' none declared');
98
+ }
99
+ for (const job of enabled) {
100
+ const run = state[job.id] || {};
101
+ const fails = Number(run.consecutive_fails || 0);
102
+ const health = fails > 2 ? `${fails} consecutive fails` : fails > 0 ? `${fails} recent fail${fails === 1 ? '' : 's'}` : 'healthy';
103
+ const cadence = job.cadence_minutes >= 60 ? `${Math.round(job.cadence_minutes / 60)}h` : `${job.cadence_minutes}m`;
104
+ console.log(` ${String(job.id).padEnd(22)} every ${String(cadence).padEnd(5)} last ${agoText(run.last_run).padEnd(9)} ${health}`);
105
+ console.log(` ${''.padEnd(22)} ${String(job.purpose || '').slice(0, 84)}`);
106
+ }
107
+ console.log('');
108
+
109
+ const agents = listLaunchdAgents();
110
+ console.log(`launchd agents (${agents.length})`);
111
+ for (const agent of agents) {
112
+ const status = agent.running ? `running pid ${agent.pid}` : `loaded, last exit ${agent.lastExit}`;
113
+ console.log(` ${agent.label.padEnd(44)} ${status}`);
114
+ }
115
+ if (!agents.length) console.log(' none');
116
+ console.log('');
117
+
118
+ const mission = workspaceMission(cwd);
119
+ console.log('mission (this workspace)');
120
+ if (mission) {
121
+ const elapsed = mission.elapsedMins === null ? '' : ` · ${mission.elapsedMins}m in`;
122
+ console.log(` ${mission.id}`);
123
+ console.log(` ${mission.status}${elapsed} · ${mission.objective.slice(0, 80)}`);
124
+ } else {
125
+ console.log(' none active');
126
+ }
127
+ console.log('');
128
+ console.log('control: atris loops stop <id> · atris loops start <id>');
129
+ console.log('ids: registry job id, or a com.atris.* launchd label');
130
+ }
131
+
132
+ function saveRegistry(registry) {
133
+ const target = registryPath();
134
+ fs.copyFileSync(target, `${target}.bak`);
135
+ fs.writeFileSync(target, `${JSON.stringify(registry, null, 2)}\n`);
136
+ }
137
+
138
+ function setRegistryJob(id, enabled) {
139
+ const registry = loadRegistry();
140
+ const job = registry.jobs.find(row => row.id === id);
141
+ if (!job) return false;
142
+ job.enabled = enabled;
143
+ if (enabled) {
144
+ delete job.disabled;
145
+ delete job.disabled_reason;
146
+ } else {
147
+ job.disabled = true;
148
+ job.disabled_reason = `stopped via atris loops ${new Date().toISOString().slice(0, 10)}`;
149
+ }
150
+ saveRegistry(registry);
151
+ return true;
152
+ }
153
+
154
+ function launchdControl(label, action) {
155
+ const uid = process.getuid();
156
+ if (action === 'stop') {
157
+ const result = spawnSync('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8' });
158
+ return result.status === 0;
159
+ }
160
+ const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
161
+ if (!fs.existsSync(plist)) return false;
162
+ const bootstrap = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { encoding: 'utf8' });
163
+ if (bootstrap.status === 0) return true;
164
+ const kick = spawnSync('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], { encoding: 'utf8' });
165
+ return kick.status === 0;
166
+ }
167
+
168
+ function controlLoop(id, action) {
169
+ const target = String(id || '').trim();
170
+ if (!target) {
171
+ console.error(`Usage: atris loops ${action} <id>`);
172
+ process.exit(1);
173
+ }
174
+ if (target.startsWith('com.atris.')) {
175
+ const ok = launchdControl(target, action);
176
+ if (!ok) {
177
+ console.error(`Could not ${action} ${target}. Check: launchctl print gui/$(id -u)/${target}`);
178
+ process.exit(1);
179
+ }
180
+ console.log(`${action === 'stop' ? 'Stopped' : 'Started'} ${target}.`);
181
+ return;
182
+ }
183
+ const ok = setRegistryJob(target, action === 'start');
184
+ if (!ok) {
185
+ console.error(`No registry job named "${target}". Run: atris loops`);
186
+ process.exit(1);
187
+ }
188
+ console.log(`${action === 'stop' ? 'Disabled' : 'Enabled'} ${target} in the heartbeat registry (backup written).`);
189
+ }
190
+
191
+ function loopsCommand(subcommand, ...args) {
192
+ switch (subcommand) {
193
+ case undefined:
194
+ case 'board':
195
+ case 'list':
196
+ showBoard(process.cwd());
197
+ break;
198
+ case 'stop':
199
+ controlLoop(args[0], 'stop');
200
+ break;
201
+ case 'start':
202
+ controlLoop(args[0], 'start');
203
+ break;
204
+ default:
205
+ console.log('Loop commands:');
206
+ console.log(' atris loops - board: registry jobs, launchd agents, mission');
207
+ console.log(' atris loops stop <id> - disable a registry job or stop a launchd agent');
208
+ console.log(' atris loops start <id> - enable a registry job or start a launchd agent');
209
+ }
210
+ }
211
+
212
+ module.exports = { loopsCommand, showBoard };
@@ -611,16 +611,48 @@ function startMemberRunMission(name, missionText, args = []) {
611
611
  }
612
612
  }
613
613
 
614
- // atris member ping <name> "<message>" — talk to an always-on member mid-run.
615
- // Finds the member's most recently touched live mission (including --worktree
616
- // missions in sibling checkouts) and leaves the note its next tick consumes.
614
+ // atris member ping <name> "<message>" — talk to a busy member mid-run.
615
+ // Two delivery lanes: the member's most recently touched live mission
616
+ // (including --worktree missions in sibling checkouts), whose next tick
617
+ // consumes the note, and the member's claimed task dialogue, which task-loop
618
+ // agents re-read every step without ever ticking a mission. The ping lands on
619
+ // every lane that exists and errors only when neither does.
620
+ function pingClaimedTaskDialogue(name, text, from) {
621
+ let taskDb;
622
+ try {
623
+ taskDb = require('../lib/task-db');
624
+ } catch {
625
+ return null;
626
+ }
627
+ try {
628
+ const db = taskDb.open();
629
+ const local = taskDb.listTasks(db, { workspaceRoot: taskDb.workspaceRoot(), status: 'claimed', claimedBy: name });
630
+ const pool = local.length ? local : taskDb.listTasks(db, { status: 'claimed', claimedBy: name });
631
+ if (!pool.length) return null;
632
+ const task = [...pool].sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0))[0];
633
+ const result = taskDb.noteTask(db, { id: task.id, actor: from, content: text });
634
+ if (!result.noted) return null;
635
+ return { task_id: task.id, title: task.title };
636
+ } catch {
637
+ return null;
638
+ }
639
+ }
640
+
617
641
  function memberPing(name, ...args) {
618
642
  if (!name || name === '--help' || name === '-h') {
619
- console.log('Usage: atris member ping <name> "<message>" [--json]');
620
- console.log('Leaves a note on the member\'s active mission; the next tick reads it as operator direction.');
643
+ console.log('Usage: atris member ping <name> "<message>" [--from <who>] [--json]');
644
+ console.log('Leaves the note on the member\'s active mission and claimed task dialogue; whichever loop the member runs reads it next.');
621
645
  return;
622
646
  }
623
- const text = args.filter((a) => !a.startsWith('--')).join(' ').trim();
647
+ const asJson = args.includes('--json');
648
+ const rest = args.filter((a) => a !== '--json');
649
+ let from = process.env.USER || 'operator';
650
+ const fromIdx = rest.indexOf('--from');
651
+ if (fromIdx !== -1) {
652
+ from = rest[fromIdx + 1] || from;
653
+ rest.splice(fromIdx, 2);
654
+ }
655
+ const text = rest.filter((a) => !a.startsWith('--')).join(' ').trim();
624
656
  if (!text) {
625
657
  console.error('atris member ping: message required');
626
658
  process.exit(2);
@@ -633,12 +665,33 @@ function memberPing(name, ...args) {
633
665
  ]
634
666
  .filter((m) => m && m.owner === name && !terminal.has(m.status))
635
667
  .sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
636
- if (!candidates.length) {
637
- console.error(`atris member ping: no live mission owned by "${name}". Start one: atris member run ${name} "<objective>"`);
668
+
669
+ const taskNote = pingClaimedTaskDialogue(name, text, from);
670
+
671
+ if (!candidates.length && !taskNote) {
672
+ console.error(`atris member ping: no live mission or claimed task for "${name}". Start one: atris member run ${name} "<objective>"`);
638
673
  process.exit(1);
639
674
  }
640
- const passthrough = args.filter((a) => a.startsWith('--'));
641
- return missionMod.pingMission([candidates[0].id, text, ...passthrough]);
675
+
676
+ const mission = candidates.length
677
+ ? missionMod.pingMission([candidates[0].id, text, '--from', from], { silent: true })
678
+ : null;
679
+ const pendingPings = mission ? (mission.pings || []).filter((p) => p && !p.consumed_at).length : null;
680
+
681
+ if (asJson) {
682
+ console.log(JSON.stringify({
683
+ ok: true,
684
+ action: 'member_ping',
685
+ member: name,
686
+ mission_id: mission ? mission.id : null,
687
+ pending_pings: pendingPings,
688
+ task_id: taskNote ? taskNote.task_id : null,
689
+ }));
690
+ } else {
691
+ if (mission) console.log(`pinged ${mission.id} — the next tick reads it (${pendingPings} unread).`);
692
+ if (taskNote) console.log(`pinged task ${taskNote.task_id} dialogue — ${name} reads it on the next step.`);
693
+ }
694
+ return { mission, task_note: taskNote };
642
695
  }
643
696
 
644
697
  function memberRun(name, ...args) {
@@ -30,6 +30,15 @@ const {
30
30
  formatBytes,
31
31
  } = require('../lib/runs-prune');
32
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');
33
42
 
34
43
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
35
44
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
@@ -194,6 +203,7 @@ function printJsonOrText(payload, lines, asJson) {
194
203
  }
195
204
 
196
205
  const MISSION_RUN_VALUE_FLAGS = [
206
+ '--slots',
197
207
  '--max-ticks',
198
208
  '--max-wall',
199
209
  '--minutes',
@@ -213,6 +223,8 @@ const MISSION_RUN_VALUE_FLAGS = [
213
223
  const MISSION_RUN_BOOLEAN_FLAGS = [
214
224
  '--json',
215
225
  '--due',
226
+ '--fleet',
227
+ '--dry-run',
216
228
  '--no-claude',
217
229
  '--no-verify',
218
230
  '--complete-on-pass',
@@ -227,6 +239,7 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
227
239
  '--no-room-auto-run',
228
240
  '--allow-native-goal-supersede',
229
241
  '--supersede-paused-native-goal',
242
+ '--take-goal-slot',
230
243
  '--always-on',
231
244
  '--xp-task',
232
245
  '--agent-xp',
@@ -1619,7 +1632,7 @@ function missionFromArgs(args) {
1619
1632
  '--native-goal-objective',
1620
1633
  '--visible-goal-status',
1621
1634
  '--visible-goal-objective',
1622
- ], ['--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', '--duplicate', '--spend-full-budget', '--use-whole-budget', '--stop-when-done', '--allow-native-goal-supersede', '--supersede-paused-native-goal', '--take-goal-slot']).join(' ').trim();
1623
1636
  if (!objective) {
1624
1637
  exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual] [--worktree]', 1, wantsJson(args));
1625
1638
  }
@@ -2765,9 +2778,53 @@ function inheritedWorktreeBase(cwd) {
2765
2778
  }
2766
2779
  }
2767
2780
 
2781
+ // Dedup gate: the same objective + owner already active anywhere in the
2782
+ // workspace family (this store or any worktree's) is reused, never cloned.
2783
+ // Born 2026-07-02: an hourly alive loop spawned six identical auto-improver
2784
+ // missions in six fresh worktrees in one day — pure token burn. --duplicate
2785
+ // is the explicit escape hatch.
2786
+ const TWIN_ACTIVE_STATUSES = new Set(['planning', 'ready', 'running']);
2787
+
2788
+ function normalizedObjective(text) {
2789
+ return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
2790
+ }
2791
+
2792
+ function findActiveTwinMission(objective, owner, root = process.cwd()) {
2793
+ const wantObjective = normalizedObjective(objective);
2794
+ const wantOwner = String(owner || '').trim().toLowerCase();
2795
+ if (!wantObjective) return null;
2796
+ const candidates = [
2797
+ ...listMissions(root),
2798
+ ...listWorktreeRollupMissions(root),
2799
+ ];
2800
+ for (const m of candidates) {
2801
+ if (!m || !TWIN_ACTIVE_STATUSES.has(m.status)) continue;
2802
+ if (normalizedObjective(m.objective) !== wantObjective) continue;
2803
+ if (String(m.owner || '').trim().toLowerCase() !== wantOwner) continue;
2804
+ return m;
2805
+ }
2806
+ return null;
2807
+ }
2808
+
2768
2809
  function startMission(args) {
2769
2810
  const asJson = wantsJson(args);
2770
2811
  const mission = missionFromArgs(args);
2812
+ if (!hasFlag(args, '--duplicate')) {
2813
+ const twin = findActiveTwinMission(mission.objective, mission.owner);
2814
+ if (twin) {
2815
+ printJsonOrText(
2816
+ { ok: true, action: 'mission_reused', reused: true, mission: twin, note: 'an active mission with this objective and owner already exists; resumed instead of cloning (pass --duplicate to force a second one)' },
2817
+ [
2818
+ `Already active: ${twin.id} (${twin.status})`,
2819
+ `Same objective, same owner — reusing it instead of starting a clone.`,
2820
+ `Resume: atris mission run ${twin.id}`,
2821
+ `Really want a second one: re-run with --duplicate`,
2822
+ ],
2823
+ asJson,
2824
+ );
2825
+ return;
2826
+ }
2827
+ }
2771
2828
  // --worktree: bind the mission to its own isolated checkout. We chdir before
2772
2829
  // any state writes so the mission record, baseline sidecar, receipts, and
2773
2830
  // member files all land inside the worktree — ticks run there, and the main
@@ -2795,6 +2852,9 @@ function startMission(args) {
2795
2852
  const warnings = [missingVerifierWarning(mission)].filter(Boolean);
2796
2853
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
2797
2854
  const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective });
2855
+ const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
2856
+ ? takeCodexGoalSlotForMission(saved, process.cwd())
2857
+ : null;
2798
2858
  const memberState = renderMemberMissionState(saved.owner);
2799
2859
  const logPath = appendMemberLog(saved.owner, 'Mission started', {
2800
2860
  mission: saved.objective,
@@ -2805,7 +2865,7 @@ function startMission(args) {
2805
2865
  });
2806
2866
  const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
2807
2867
  printJsonOrText(
2808
- { 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 },
2868
+ { 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 },
2809
2869
  [
2810
2870
  `Started mission: ${saved.objective}`,
2811
2871
  `Owner: ${saved.owner}`,
@@ -2920,6 +2980,9 @@ function startMissionFromRunObjective(objective, args) {
2920
2980
  ...(nativeGoalObjective ? { nativeGoalObjective } : {}),
2921
2981
  ...(hasFlag(args, '--allow-native-goal-supersede') || hasFlag(args, '--supersede-paused-native-goal') ? { allowNativeGoalSupersede: true } : {}),
2922
2982
  };
2983
+ const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
2984
+ ? takeCodexGoalSlotForMission(saved, process.cwd(), nativeGoalOptions)
2985
+ : null;
2923
2986
  const atrisGoalState = refreshAtrisGoalController(process.cwd(), { missionId: saved.id });
2924
2987
  const codexGoalState = runnerUsesCallerSession(saved.runner)
2925
2988
  ? refreshCodexGoalController(process.cwd(), { missionId: saved.id, ...nativeGoalOptions })
@@ -2944,6 +3007,7 @@ function startMissionFromRunObjective(objective, args) {
2944
3007
  } : null,
2945
3008
  completed_continuation_goal: completedContinuationGoal,
2946
3009
  direct_goal_request: directGoalRequest,
3010
+ goal_slot_handoff: goalSlotHandoff,
2947
3011
  atris_goal_state: atrisGoalState,
2948
3012
  codex_goal_state: codexGoalState,
2949
3013
  requires_native_goal_start: codexGoalState?.requires_native_goal_start === true || codexGoalState?.goal?.requires_native_goal_start === true,
@@ -4611,11 +4675,11 @@ function codexGoalObjective(mission) {
4611
4675
  return mission.objective;
4612
4676
  }
4613
4677
 
4614
- function codexNativeGoalAck(mission, objective = codexGoalObjective(mission)) {
4678
+ function codexNativeGoalAck(mission) {
4615
4679
  const ack = mission?.native_goal_ack || null;
4616
4680
  if (!ack || String(ack.runtime || '').toLowerCase() !== 'codex') return null;
4617
4681
  if (ack.status !== 'active') return null;
4618
- if (String(ack.objective || '') !== String(objective || '')) return null;
4682
+ if (ack.mission_id && String(ack.mission_id) !== String(mission?.id || '')) return null;
4619
4683
  return ack;
4620
4684
  }
4621
4685
 
@@ -4821,6 +4885,33 @@ function activeCodexVisibleGoalOwner(root = process.cwd(), excludeId = '', now =
4821
4885
  return candidates[0] || null;
4822
4886
  }
4823
4887
 
4888
+ function pauseMissionRecord(mission, reason, root = process.cwd()) {
4889
+ const next = {
4890
+ ...mission,
4891
+ status: 'paused',
4892
+ paused_at: stampIso(),
4893
+ stop_reason: reason,
4894
+ next_action: `resume with: atris mission tick ${mission.id}`,
4895
+ };
4896
+ const { mission: saved } = saveMission(next, root, 'mission_paused', { reason, source: 'goal_slot_handoff' });
4897
+ appendMemberLog(saved.owner, 'Mission paused', { mission: saved.objective, reason });
4898
+ return saved;
4899
+ }
4900
+
4901
+ function takeCodexGoalSlotForMission(newMission, root = process.cwd(), options = {}) {
4902
+ if (!newMission || !isCodexGoalMission(newMission)) return null;
4903
+ const runtimeGoalState = codexRuntimeGoalStateFromOptions(options);
4904
+ const activeOwner = activeCodexVisibleGoalOwner(root, newMission.id, new Date(), runtimeGoalState);
4905
+ if (!activeOwner) return null;
4906
+ const reason = `visible goal replaced by ${newMission.id}`;
4907
+ const paused = pauseMissionRecord(activeOwner, reason, root);
4908
+ return {
4909
+ paused_mission_id: paused.id,
4910
+ paused_mission: missionStatusView(paused),
4911
+ reason,
4912
+ };
4913
+ }
4914
+
4824
4915
  function codexGoalActiveConflictPayload(newMission, activeMission, request = null, heartbeatMode = false, runtimeGoalState = null) {
4825
4916
  const newObjective = codexGoalObjective(newMission);
4826
4917
  const pausedConflict = runtimeGoalState?.status === 'paused';
@@ -5119,7 +5210,7 @@ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
5119
5210
  const taskSpine = missionTaskSpine(mission);
5120
5211
  const missionView = missionStatusView(mission);
5121
5212
  const objective = codexGoalObjective(mission);
5122
- const ack = codexNativeGoalAck(mission, objective);
5213
+ const ack = codexNativeGoalAck(mission);
5123
5214
  const runtimeNeedsReplace = !ack && codexRuntimeGoalNeedsReplace(runtimeGoalState, objective);
5124
5215
  const nativeGoalAction = ack
5125
5216
  ? null
@@ -5777,6 +5868,21 @@ async function runMission(args) {
5777
5868
  help();
5778
5869
  return;
5779
5870
  }
5871
+ // --fleet: staff every idle capable engine on the board's claimable
5872
+ // safe-lane tasks, build in parallel worktrees, land serially. Humble flag,
5873
+ // full loop — see lib/fleet.js. --dry-run previews the staffing only.
5874
+ if (hasFlag(args, '--fleet')) {
5875
+ const { runFleetFlight } = require('../lib/fleet');
5876
+ const slots = Math.max(1, Number(readFlag(args, '--slots', '')) || 3);
5877
+ const flight = await runFleetFlight({
5878
+ slots,
5879
+ dryRun: hasFlag(args, '--dry-run'),
5880
+ log: asJson ? () => {} : console.log,
5881
+ });
5882
+ if (asJson) console.log(JSON.stringify(flight, null, 2));
5883
+ process.exitCode = flight.paused && flight.paused.length > 0 ? 1 : 0;
5884
+ return;
5885
+ }
5780
5886
  const dueMode = hasFlag(args, '--due');
5781
5887
  const skipClaude = hasFlag(args, '--no-claude');
5782
5888
  const verifyEach = !hasFlag(args, '--no-verify');
@@ -6723,16 +6829,15 @@ function ackMissionGoal(args) {
6723
6829
  releaseMissionLock(lock);
6724
6830
  exitMissionError(`Mission "${ref}" uses runner=${mission.runner || 'manual'}; native Codex goal ack is only for runner=codex_goal.`, 2, asJson);
6725
6831
  }
6726
- const expectedObjective = codexGoalObjective(mission);
6727
- const objective = readFlag(args, '--objective', expectedObjective);
6728
- if (objective !== expectedObjective) {
6729
- releaseMissionLock(lock);
6730
- exitMissionError(`Native goal objective mismatch. Expected: ${expectedObjective}`, 2, asJson);
6731
- }
6832
+ const canonicalObjective = codexGoalObjective(mission);
6833
+ const reportedObjective = readFlag(args, '--objective', canonicalObjective);
6834
+ const objective = canonicalObjective;
6732
6835
  const ack = {
6733
6836
  runtime: 'codex',
6734
6837
  status: 'active',
6838
+ mission_id: mission.id,
6735
6839
  objective,
6840
+ ...(reportedObjective && reportedObjective !== objective ? { reported_objective: reportedObjective } : {}),
6736
6841
  acknowledged_at: stampIso(),
6737
6842
  };
6738
6843
  const nextMission = {
@@ -6848,13 +6953,15 @@ function help() {
6848
6953
  console.log(`
6849
6954
  atris mission - durable goal + loop + owner + proof state
6850
6955
 
6851
- atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--xp-task] [--worktree]
6956
+ atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on] [--xp-task] [--worktree] [--take-goal-slot]
6852
6957
  [--runner manual|claude|atris2|codex_goal] [--model <id>]
6853
6958
  (runner claude spawns local claude -p per tick, --model passes through;
6854
6959
  runner atris2 runs each tick as one /atris2/turn on the AtrisOS backend,
6855
6960
  default model atris:fast; runner codex_goal publishes the goal for a live
6856
6961
  Codex session to pull via atris mission goal)
6857
6962
  atris mission status [id] [--status <state>] [--limit <n>] [--local] [--json]
6963
+ atris mission inspect <id> --fields status,runner,ack,pings [--json]
6964
+ Field-selectable mission state (status, runner, native goal ack, ping counts)
6858
6965
  atris mission doctor [--local] [--json] Flag no-verifier missions, help missions, stale ready receipts, and blocked always-on loops
6859
6966
  atris mission attach-task <id> [--json] Create the missing task spine for an existing active mission
6860
6967
  atris mission report [id] [--limit <n>] [--local] [--json] Plain outcome, worker receipt, verifier receipt, and next move
@@ -6869,8 +6976,9 @@ atris mission - durable goal + loop + owner + proof state
6869
6976
  atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
6870
6977
  atris mission tick <id> [--verify ["cmd"]] [--complete-on-pass] [--summary "..."] [--json]
6871
6978
  atris mission "<objective>" [--owner <member>] Shortcut for: atris mission run "<objective>"
6979
+ 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/
6872
6980
  atris mission run ["objective"|<member> ["objective"]|id|--due] [--owner <member>] [--max-ticks 4] [--max-wall 3600] [--cadence "15m"]
6873
- [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede]
6981
+ [--native-goal-status active|paused] [--native-goal-objective "..."] [--allow-native-goal-supersede] [--take-goal-slot]
6874
6982
  [--spend-full-budget|--use-whole-budget|--stop-when-done] [--room-preflight|--no-room-preflight]
6875
6983
  [--room-auto-run|--no-room-auto-run]
6876
6984
  [--no-claude] [--no-verify] [--complete-on-pass] [--no-drain] [--create-next] [--json]
@@ -7149,7 +7257,39 @@ function findMissionAcrossWorktrees(ref, root = process.cwd()) {
7149
7257
  // atris mission ping <id> "<message>" — leave a note the mission's next tick
7150
7258
  // reads (and consumes) as operator direction. This is how you talk to an
7151
7259
  // always-on member mid-run without stopping it.
7152
- function pingMission(args) {
7260
+ function inspectMission(args) {
7261
+ const asJson = wantsJson(args);
7262
+ const parsed = readFieldsFlag(args, '--fields');
7263
+ if (!parsed || parsed.error) {
7264
+ exitMissionError(
7265
+ parsed?.error || 'Usage: atris mission inspect <id> --fields status,runner,ack,pings [--json]',
7266
+ 2,
7267
+ asJson,
7268
+ );
7269
+ }
7270
+ const fieldError = validateFields(parsed.fields, MISSION_INSPECT_FIELDS, 'mission');
7271
+ if (fieldError) exitMissionError(fieldError, 2, asJson);
7272
+ const ref = stripInspectArgs(args)[0] || '';
7273
+ if (!ref) {
7274
+ exitMissionError('Usage: atris mission inspect <id> --fields status,runner,ack,pings [--json]', 2, asJson);
7275
+ }
7276
+ const found = findMissionAcrossWorktrees(ref);
7277
+ if (!found) exitMissingMission(ref, 1, asJson);
7278
+ const { mission } = found;
7279
+ const values = missionInspectFieldValues(mission, parsed.fields);
7280
+ const payload = buildInspectPayload({
7281
+ action: 'mission_inspect',
7282
+ idKey: 'mission_id',
7283
+ idValue: mission.id,
7284
+ fields: parsed.fields,
7285
+ values,
7286
+ });
7287
+ printJsonOrText(payload, inspectTextLines(parsed.fields, values), asJson);
7288
+ }
7289
+
7290
+ // always-on member mid-run without stopping it. opts.silent skips console
7291
+ // output so callers (member ping) can compose their own multi-lane report.
7292
+ function pingMission(args, opts = {}) {
7153
7293
  const asJson = args.includes('--json');
7154
7294
  const rest = args.filter((a) => a !== '--json');
7155
7295
  let from = process.env.USER || 'operator';
@@ -7181,10 +7321,12 @@ function pingMission(args) {
7181
7321
  { from, text: text.slice(0, 200) },
7182
7322
  ).mission;
7183
7323
  const pending = (saved.pings || []).filter((p) => p && !p.consumed_at).length;
7184
- if (asJson) {
7185
- console.log(JSON.stringify({ ok: true, action: 'mission_ping', mission_id: saved.id, pending_pings: pending, ping }));
7186
- } else {
7187
- console.log(`pinged ${saved.id} the next tick reads it (${pending} unread).`);
7324
+ if (!opts.silent) {
7325
+ if (asJson) {
7326
+ console.log(JSON.stringify({ ok: true, action: 'mission_ping', mission_id: saved.id, pending_pings: pending, ping }));
7327
+ } else {
7328
+ console.log(`pinged ${saved.id} — the next tick reads it (${pending} unread).`);
7329
+ }
7188
7330
  }
7189
7331
  return saved;
7190
7332
  }
@@ -7235,6 +7377,8 @@ function missionCommand(args) {
7235
7377
  return goalLoopMission(rest);
7236
7378
  case 'ping':
7237
7379
  return pingMission(rest);
7380
+ case 'inspect':
7381
+ return inspectMission(rest);
7238
7382
  case 'tick':
7239
7383
  return tickMission(rest);
7240
7384
  case 'run':
@@ -7257,6 +7401,7 @@ function missionCommand(args) {
7257
7401
 
7258
7402
  module.exports = {
7259
7403
  missionCommand,
7404
+ inspectMission,
7260
7405
  missionHeartbeatLines,
7261
7406
  listMissions,
7262
7407
  listWorktreeRollupMissions,