atris 3.31.0 → 3.32.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.
@@ -6,6 +6,7 @@ const os = require('os');
6
6
  const { spawnSync } = require('child_process');
7
7
 
8
8
  const autoland = require('../lib/autoland');
9
+ const { operatorReady, hasAgentJargon } = autoland;
9
10
 
10
11
  function repoRoot(cwd = process.cwd()) {
11
12
  const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
@@ -17,6 +18,25 @@ function projectName(root) {
17
18
  return path.basename(root);
18
19
  }
19
20
 
21
+ // The digest's "next, if you agree" section: top candidate moves from Atris
22
+ // state, each with the member best suited to own it. Moves that can't explain
23
+ // themselves are counted, not shown. Never blocks the digest.
24
+ function digestNextMoves(root) {
25
+ try {
26
+ const { nextMoves } = require('../lib/next-moves');
27
+ const { resolveFunctionalOwner } = require('../lib/functional-owner');
28
+ const all = (nextMoves(root, 5) || []).filter((move) => move && move.title);
29
+ const ready = all.filter((move) => operatorReady(move.title)).slice(0, 3).map((move) => {
30
+ let owner = null;
31
+ try { owner = resolveFunctionalOwner({ title: move.title, root })?.owner || null; } catch {}
32
+ return { title: move.title, owner };
33
+ });
34
+ return { moves: ready, unexplained: all.length - ready.length };
35
+ } catch {
36
+ return { moves: [], unexplained: 0 };
37
+ }
38
+ }
39
+
20
40
  function flag(args, name, fallback = '') {
21
41
  const idx = args.indexOf(name);
22
42
  if (idx === -1) return fallback;
@@ -24,9 +44,11 @@ function flag(args, name, fallback = '') {
24
44
  }
25
45
 
26
46
  function runOwnCli(root, cliArgs) {
27
- const bin = path.join(root, 'bin', 'atris.js');
28
- const argv = fs.existsSync(bin) ? [process.execPath, [bin, ...cliArgs]] : ['atris', [cliArgs].flat()];
29
- const result = spawnSync(argv[0], argv[1], { cwd: root, encoding: 'utf8', timeout: 300000 });
47
+ // Always spawn the bin this module shipped with. Resolving through the
48
+ // target root or a global `atris` breaks whenever the tick runs a project
49
+ // that isn't the CLI repo on a machine without a global install (CI, cron).
50
+ const bin = path.resolve(__dirname, '..', 'bin', 'atris.js');
51
+ const result = spawnSync(process.execPath, [bin, ...cliArgs], { cwd: root, encoding: 'utf8', timeout: 300000 });
30
52
  return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
31
53
  }
32
54
 
@@ -186,6 +208,7 @@ function runDigest(root, args, { forceSend = false } = {}) {
186
208
  waiting: autoland.waitingOnHuman(tasks),
187
209
  landed: landSummarySafe(root),
188
210
  project: projectName(root),
211
+ nextMoves: digestNextMoves(root),
189
212
  });
190
213
  console.log(text);
191
214
  // the full story: what each piece actually was, in its own words
@@ -195,10 +218,15 @@ function runDigest(root, args, { forceSend = false } = {}) {
195
218
  return t && (t.review?.landing?.happened || t.metadata?.landing_happened);
196
219
  });
197
220
  if (storied.length > 0) {
198
- console.log('');
221
+ let printedStoryHeader = false;
199
222
  for (const item of storied) {
200
223
  const t = byRef.get(item.ref);
201
224
  const happened = String(t.review?.landing?.happened || t.metadata?.landing_happened || '').replace(/\s+/g, ' ').slice(0, 160);
225
+ if (!operatorReady(happened)) continue;
226
+ if (!printedStoryHeader) {
227
+ console.log('');
228
+ printedStoryHeader = true;
229
+ }
202
230
  console.log(` ${item.ref} ${happened}`);
203
231
  }
204
232
  }
@@ -221,7 +249,22 @@ function runTick(root, args) {
221
249
  return 0;
222
250
  }
223
251
 
224
- // 1. land what is eligible — the policy is the standing authorization
252
+ // 1. certify what has executable proof re-run the runnable check named in
253
+ // each Review proof as a second actor. Without this the tick only lands rows
254
+ // some always-on mission happened to certify, and everything else waits on a
255
+ // human who never needed to look. Denied lanes and check-less proofs still wait.
256
+ if (policy.drain_reviews !== false) {
257
+ const certify = runOwnCli(root, ['task', 'certify-verified', '--json']);
258
+ try {
259
+ const parsed = JSON.parse(certify.stdout);
260
+ receipt.reviews_certified = parsed.certified ?? 0;
261
+ if (parsed.ok !== true) receipt.certify_error = 'certify-verified failed';
262
+ } catch {
263
+ receipt.certify_error = certify.stderr.slice(0, 200) || 'certify-verified output unreadable';
264
+ }
265
+ }
266
+
267
+ // 2. land what is eligible — the policy is the standing authorization
225
268
  const cliArgs = ['task', 'auto-accept-certified', '--json', '--limit', '12'];
226
269
  if (policy.strict_verify !== false) cliArgs.push('--strict-verify');
227
270
  const accept = runOwnCli(root, cliArgs);
@@ -232,7 +275,23 @@ function runTick(root, args) {
232
275
  receipt.accept_error = accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
233
276
  }
234
277
 
235
- // 2. alarm on anything waiting on a human past the line
278
+ // 2b. tell the operator the moment something lands: one text, one landing
279
+ // sentence per piece (the day-one PM sentence written at finish time),
280
+ // capped at three with the rest counted. Off with live_updates: false.
281
+ const tasksForLive = readProjection(root);
282
+ if (receipt.landed.length > 0 && policy.imessage_to && policy.live_updates !== false) {
283
+ const text = autoland.composeLiveUpdate({
284
+ landedRefs: receipt.landed,
285
+ tasks: tasksForLive,
286
+ project: projectName(root),
287
+ });
288
+ if (text) {
289
+ const sent = autoland.sendImessage(root, policy.imessage_to, text);
290
+ receipt.live_update_sent = sent.ok;
291
+ }
292
+ }
293
+
294
+ // 3. alarm on anything waiting on a human past the line
236
295
  const state = autoland.readState(root);
237
296
  const tasks = readProjection(root);
238
297
  const waiting = autoland.waitingOnHuman(tasks);
@@ -247,7 +306,7 @@ function runTick(root, args) {
247
306
  }
248
307
  }
249
308
 
250
- // 3. daily digest at the configured hour
309
+ // 4. daily digest at the configured hour
251
310
  const today = new Date().toISOString().slice(0, 10);
252
311
  const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
253
312
  if (new Date().getHours() === digestHour && state.last_digest_date !== today) {
@@ -256,6 +315,7 @@ function runTick(root, args) {
256
315
  waiting,
257
316
  landed: landSummarySafe(root),
258
317
  project: projectName(root),
318
+ nextMoves: digestNextMoves(root),
259
319
  });
260
320
  if (policy.imessage_to) {
261
321
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
@@ -264,7 +324,7 @@ function runTick(root, args) {
264
324
  receipt.digest_text = text;
265
325
  state.last_digest_date = today;
266
326
 
267
- // 4. once a day, keep the receipt shelf lean: compress old run receipts
327
+ // 5. once a day, keep the receipt shelf lean: compress old run receipts
268
328
  // into the manifest and drop unreferenced clutter, newest 200 kept.
269
329
  const prune = runOwnCli(root, ['mission', 'prune-runs', '--apply', '--days', '14', '--keep-newest', '200', '--json']);
270
330
  try {
@@ -278,7 +338,7 @@ function runTick(root, args) {
278
338
 
279
339
  if (json) console.log(JSON.stringify(receipt));
280
340
  else {
281
- console.log(`autoland tick: ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}, ${receipt.alarms} alarms, digest ${receipt.digest_sent ? 'sent' : 'not due'}`);
341
+ console.log(`autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}, ${receipt.alarms} alarms, digest ${receipt.digest_sent ? 'sent' : 'not due'}`);
282
342
  }
283
343
  return 0;
284
344
  }
@@ -316,4 +376,4 @@ function autolandCommand(args = []) {
316
376
  return 1;
317
377
  }
318
378
 
319
- module.exports = { autolandCommand };
379
+ module.exports = { autolandCommand, operatorReady, hasAgentJargon };
@@ -0,0 +1,273 @@
1
+ // atris autopilot: point it at the workspace and it keeps going.
2
+ // Each leg picks the most logical work — a moving mission first, then a
3
+ // member choosing useful work, then a self-chosen mission — and drives it
4
+ // through the mission runtime. It loops until stopped:
5
+ // atris autopilot stop (from any terminal) or Ctrl-C here.
6
+ // The old suggest→justify→execute loop lives on behind `atris autopilot --legacy`.
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { spawn } = require('child_process');
10
+ const { pickRunnableMission, runBudgetSeconds } = require('./run-front');
11
+
12
+ const CLI_PATH = path.join(__dirname, '..', 'bin', 'atris.js');
13
+ const DEFAULT_LEG_WALL_SECONDS = 3600;
14
+ const LEG_TICKS = 12;
15
+ const FAST_FAIL_SECONDS = 30;
16
+ const MAX_FAST_FAILS = 5;
17
+
18
+ function statePath(root) { return path.join(root, '.atris', 'state', 'autopilot.json'); }
19
+ function stopPath(root) { return path.join(root, '.atris', 'state', 'autopilot.stop'); }
20
+
21
+ function readState(root) {
22
+ try { return JSON.parse(fs.readFileSync(statePath(root), 'utf8')); } catch { return null; }
23
+ }
24
+
25
+ function writeState(root, state) {
26
+ fs.mkdirSync(path.dirname(statePath(root)), { recursive: true });
27
+ fs.writeFileSync(statePath(root), `${JSON.stringify(state, null, 2)}\n`);
28
+ }
29
+
30
+ function clearState(root) {
31
+ try { fs.unlinkSync(statePath(root)); } catch {}
32
+ }
33
+
34
+ function stopRequested(root) { return fs.existsSync(stopPath(root)); }
35
+ function requestStop(root) {
36
+ fs.mkdirSync(path.dirname(stopPath(root)), { recursive: true });
37
+ fs.writeFileSync(stopPath(root), `${new Date().toISOString()}\n`);
38
+ }
39
+ function clearStop(root) { try { fs.unlinkSync(stopPath(root)); } catch {} }
40
+
41
+ function pidAlive(pid) {
42
+ if (!Number.isFinite(pid) || pid <= 0) return false;
43
+ try { process.kill(pid, 0); return true; } catch { return false; }
44
+ }
45
+
46
+ function readValueFlag(args, name) {
47
+ const index = args.indexOf(name);
48
+ return index >= 0 && index + 1 < args.length ? args[index + 1] : null;
49
+ }
50
+
51
+ function positiveNumber(value) {
52
+ const parsed = Number(value);
53
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
54
+ }
55
+
56
+ // Most logical member: the one who owns the newest mission, else the first
57
+ // member on the team. Null when the workspace has no members yet.
58
+ function pickMember(root, preferredOwner = '') {
59
+ let members = [];
60
+ try {
61
+ const { findAllMembers } = require('./member');
62
+ members = findAllMembers(path.join(root, 'atris', 'team')) || [];
63
+ } catch {
64
+ return null;
65
+ }
66
+ if (!members.length) return null;
67
+ const owner = String(preferredOwner || '').trim().toLowerCase();
68
+ if (owner) {
69
+ const owned = members.find((member) => String(member?.name || '').trim().toLowerCase() === owner);
70
+ if (owned) return owned.name;
71
+ }
72
+ return members[0]?.name || null;
73
+ }
74
+
75
+ function newestMissionOwner(root) {
76
+ let map;
77
+ try { map = require('./mission').loadMissionMap(root); } catch { return ''; }
78
+ const newest = Array.from(map.values())
79
+ .sort((a, b) => String(b?.updated_at || b?.created_at || '').localeCompare(String(a?.updated_at || a?.created_at || '')));
80
+ return newest[0]?.owner || '';
81
+ }
82
+
83
+ function legPlan(root, legWallSeconds) {
84
+ const mission = pickRunnableMission(root);
85
+ if (mission) {
86
+ return {
87
+ kind: 'mission',
88
+ label: `mission ${mission.id}: ${mission.objective}`,
89
+ args: ['mission', 'run', mission.id, '--max-ticks', String(LEG_TICKS), '--max-wall', String(legWallSeconds), '--complete-on-pass'],
90
+ };
91
+ }
92
+ const member = pickMember(root, newestMissionOwner(root));
93
+ if (member) {
94
+ // --runner atris2: member run defaults to codex_goal, which waits for a
95
+ // live Codex session; a headless leg would tick it without doing work.
96
+ return {
97
+ kind: 'member',
98
+ label: `member ${member} chooses useful work`,
99
+ args: ['member', 'run', member, '--runner', 'atris2', '--minutes', String(Math.max(5, Math.round(legWallSeconds / 60)))],
100
+ };
101
+ }
102
+ return {
103
+ kind: 'auto-mission',
104
+ label: 'self-chosen mission from Atris state',
105
+ args: [
106
+ 'mission', 'run',
107
+ 'Choose one bounded useful task from Atris state, complete it, and show plain proof.',
108
+ '--owner', 'mission-lead',
109
+ '--runner', 'atris2',
110
+ '--max-ticks', String(LEG_TICKS),
111
+ '--max-wall', String(legWallSeconds),
112
+ ],
113
+ };
114
+ }
115
+
116
+ function driveLeg(root, legArgs, current) {
117
+ return new Promise((resolve) => {
118
+ const child = spawn(process.execPath, [CLI_PATH, ...legArgs], { cwd: root, stdio: 'inherit' });
119
+ current.child = child;
120
+ // Stop fast: a stop file written by `atris autopilot stop` in another
121
+ // terminal terminates the running leg, not just the loop between legs.
122
+ const poll = setInterval(() => {
123
+ if (stopRequested(root)) { try { child.kill('SIGTERM'); } catch {} }
124
+ }, 2000);
125
+ if (poll.unref) poll.unref();
126
+ const finish = (code) => {
127
+ clearInterval(poll);
128
+ current.child = null;
129
+ resolve(Number.isFinite(code) ? code : 1);
130
+ };
131
+ child.on('error', () => finish(1));
132
+ child.on('close', finish);
133
+ });
134
+ }
135
+
136
+ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
137
+
138
+ function autopilotStop(root = process.cwd()) {
139
+ requestStop(root);
140
+ const state = readState(root);
141
+ if (state && pidAlive(state.pid)) {
142
+ try { process.kill(state.pid, 'SIGTERM'); } catch {}
143
+ console.log(`Stopping autopilot (pid ${state.pid}).`);
144
+ } else {
145
+ console.log('No live autopilot process; stop marker written so the next start clears it.');
146
+ }
147
+ return 0;
148
+ }
149
+
150
+ function autopilotStatus(root = process.cwd()) {
151
+ const state = readState(root);
152
+ if (!state) { console.log('Autopilot is not running.'); return 0; }
153
+ const alive = pidAlive(state.pid);
154
+ console.log(`Autopilot ${alive ? 'running' : 'not running (stale state)'} — pid ${state.pid}, started ${state.started_at}, legs ${state.legs || 0}`);
155
+ if (state.current_leg) console.log(`Current leg: ${state.current_leg}`);
156
+ if (!alive) clearState(root);
157
+ return 0;
158
+ }
159
+
160
+ function showFrontHelp() {
161
+ console.log('');
162
+ console.log('Usage: atris autopilot [options]');
163
+ console.log('');
164
+ console.log('Keeps the workspace moving: picks the most logical mission or member,');
165
+ console.log('drives it through the mission runtime, then picks the next one.');
166
+ console.log('Runs until you stop it.');
167
+ console.log('');
168
+ console.log('Options:');
169
+ console.log(' --minutes N | --hours N Total budget (default: unlimited)');
170
+ console.log(' --leg-wall N Seconds per leg (default: 3600)');
171
+ console.log(' --once Run a single leg, then exit');
172
+ console.log(' --legacy Old suggest→justify→execute loop');
173
+ console.log('');
174
+ console.log('Control:');
175
+ console.log(' atris autopilot stop Stop fast, from any terminal');
176
+ console.log(' atris autopilot status Show what the loop is doing');
177
+ console.log('');
178
+ }
179
+
180
+ async function autopilotFront(args = []) {
181
+ const root = process.cwd();
182
+ if (args[0] === 'stop') return autopilotStop(root);
183
+ if (args[0] === 'status') return autopilotStatus(root);
184
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') { showFrontHelp(); return 0; }
185
+
186
+ const existing = readState(root);
187
+ if (existing && pidAlive(existing.pid) && existing.pid !== process.pid) {
188
+ console.log(`Autopilot already running (pid ${existing.pid}). Stop it first: atris autopilot stop`);
189
+ return 1;
190
+ }
191
+
192
+ clearStop(root);
193
+ const budgetSeconds = runBudgetSeconds(args);
194
+ const legWallDefault = positiveNumber(readValueFlag(args, '--leg-wall')) || DEFAULT_LEG_WALL_SECONDS;
195
+ const once = args.includes('--once');
196
+ const startedAt = Date.now();
197
+ const state = { pid: process.pid, started_at: new Date().toISOString(), legs: 0 };
198
+ writeState(root, state);
199
+
200
+ const current = { child: null };
201
+ // `autopilot stop` SIGTERMs this pid directly, so the farewell must print
202
+ // here — the signal always beats the loop's own stop-file check.
203
+ const onSignal = () => {
204
+ if (current.child) { try { current.child.kill('SIGTERM'); } catch {} }
205
+ const reason = stopRequested(root) ? 'stop requested' : 'interrupted';
206
+ console.log(`\nAutopilot off (${reason}). Legs run: ${state.legs}.`);
207
+ clearState(root);
208
+ clearStop(root);
209
+ process.exit(130);
210
+ };
211
+ process.on('SIGINT', onSignal);
212
+ process.on('SIGTERM', onSignal);
213
+
214
+ console.log('Autopilot on. Stop anytime: Ctrl-C here, or `atris autopilot stop` anywhere.');
215
+
216
+ let fastFails = 0;
217
+ let stopReason = '';
218
+ while (true) {
219
+ if (stopRequested(root)) { stopReason = 'stop requested'; break; }
220
+ const elapsed = Math.round((Date.now() - startedAt) / 1000);
221
+ if (budgetSeconds && elapsed >= budgetSeconds) { stopReason = 'budget spent'; break; }
222
+ const legWall = budgetSeconds ? Math.min(legWallDefault, budgetSeconds - elapsed) : legWallDefault;
223
+
224
+ const plan = legPlan(root, Math.max(60, legWall));
225
+ state.legs += 1;
226
+ state.current_leg = plan.label;
227
+ state.updated_at = new Date().toISOString();
228
+ writeState(root, state);
229
+ console.log(`\nautopilot leg ${state.legs}: ${plan.label}`);
230
+
231
+ const legStarted = Date.now();
232
+ const code = await driveLeg(root, plan.args, current);
233
+ state.last_exit = code;
234
+ writeState(root, state);
235
+
236
+ if (once) { stopReason = 'single leg (--once)'; break; }
237
+ if (stopRequested(root)) { stopReason = 'stop requested'; break; }
238
+
239
+ // A leg that finishes in seconds means no real work happened — a crash,
240
+ // or a degenerate mission whose ticks record instantly (exit 0). Either
241
+ // way, back off and stop instead of hot-looping on it.
242
+ const legSeconds = (Date.now() - legStarted) / 1000;
243
+ if (legSeconds < FAST_FAIL_SECONDS) {
244
+ fastFails += 1;
245
+ if (fastFails >= MAX_FAST_FAILS) {
246
+ stopReason = `${MAX_FAST_FAILS} fast legs in a row (${code === 0 ? 'no progress' : 'failures'})`;
247
+ break;
248
+ }
249
+ await sleep(code === 0 ? 5000 : 15000);
250
+ } else {
251
+ fastFails = 0;
252
+ await sleep(2000);
253
+ }
254
+ }
255
+
256
+ clearStop(root);
257
+ clearState(root);
258
+ console.log(`\nAutopilot off (${stopReason}). Legs run: ${state.legs}.`);
259
+ return stopReason.includes('fast legs') ? 1 : 0;
260
+ }
261
+
262
+ module.exports = {
263
+ autopilotFront,
264
+ autopilotStop,
265
+ autopilotStatus,
266
+ legPlan,
267
+ pickMember,
268
+ stopRequested,
269
+ requestStop,
270
+ clearStop,
271
+ readState,
272
+ writeState,
273
+ };
package/commands/init.js CHANGED
@@ -660,6 +660,20 @@ Do not rely on chat context. Put the task, file pointers, and proof on disk.
660
660
  Do not write new operating doctrine here first; add it to Atris policy, skills,
661
661
  wiki, or \`atris/atris.md\`, then regenerate this adapter if needed.
662
662
 
663
+ ## How to Report
664
+
665
+ The human approves work by reading, so how you report IS the product. Rules:
666
+
667
+ - **Results are capabilities.** State what someone can do now that they
668
+ couldn't before, then what it means for them or the business. Tests are one
669
+ word ("verified"); the meaning is the sentence. Shape: "We did X, so you can
670
+ now Y." Detail stays under the hood; the reader asks if they want more.
671
+ - **Three results, air between them, rest on ask.** A report fits one screen
672
+ with no scrolling. Reading it is one glance.
673
+ - **Stake first, then the move.** "Agents burn tokens hand-rolling parsers:
674
+ add one shared view." Plain words; flags, ids, and identifiers belong in the
675
+ body, never the headline.
676
+
663
677
  Native goals and task approval are separate gates:
664
678
 
665
679
  \`\`\`text
@@ -980,6 +994,13 @@ Read atris/MAP.md. Begin iteration 1.`;
980
994
  console.log('✓ Created .claude/settings.json (auto-loads Atris on startup)');
981
995
  }
982
996
 
997
+ // Co-author trailer: commits in this workspace credit Atris, same as Claude/Cursor do
998
+ try {
999
+ const { installSignHook } = require('./sign');
1000
+ const { already } = installSignHook();
1001
+ if (!already) console.log('✓ Installed co-author hook (commits credit Atris — remove with `atris sign off`)');
1002
+ } catch {} // not a git repo — skip quietly
1003
+
983
1004
  // Update root CLAUDE.md with Atris block (prepend with markers)
984
1005
  const rootClaudeMd = path.join(process.cwd(), 'CLAUDE.md');
985
1006
  const atrisBlock = `<!-- ATRIS:START - Auto-generated, do not edit -->
@@ -611,6 +611,36 @@ 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.
617
+ function memberPing(name, ...args) {
618
+ 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.');
621
+ return;
622
+ }
623
+ const text = args.filter((a) => !a.startsWith('--')).join(' ').trim();
624
+ if (!text) {
625
+ console.error('atris member ping: message required');
626
+ process.exit(2);
627
+ }
628
+ const missionMod = require('./mission');
629
+ const terminal = new Set(['complete', 'stopped', 'failed']);
630
+ const candidates = [
631
+ ...missionMod.listMissions(process.cwd()),
632
+ ...missionMod.listWorktreeRollupMissions(process.cwd()),
633
+ ]
634
+ .filter((m) => m && m.owner === name && !terminal.has(m.status))
635
+ .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>"`);
638
+ process.exit(1);
639
+ }
640
+ const passthrough = args.filter((a) => a.startsWith('--'));
641
+ return missionMod.pingMission([candidates[0].id, text, ...passthrough]);
642
+ }
643
+
614
644
  function memberRun(name, ...args) {
615
645
  if (!name || name === '--help' || name === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
616
646
  console.log('Usage: atris member run <name> ["mission text"] [--minutes N|--hours N] [--json]');
@@ -8063,6 +8093,8 @@ async function memberCommand(subcommand, ...args) {
8063
8093
  return memberWake(args[0], ...args.slice(1));
8064
8094
  case 'run':
8065
8095
  return memberRun(args[0], ...args.slice(1));
8096
+ case 'ping':
8097
+ return memberPing(args[0], ...args.slice(1));
8066
8098
  case 'loop':
8067
8099
  return memberLoop(args[0], ...args.slice(1));
8068
8100
  case 'alive':