atris 3.30.12 → 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.
Files changed (64) hide show
  1. package/AGENTS.md +16 -3
  2. package/FOR_AGENTS.md +81 -0
  3. package/README.md +6 -4
  4. package/atris/CLAUDE.md +8 -0
  5. package/atris/atris.md +51 -19
  6. package/atris/skills/README.md +1 -0
  7. package/atris/skills/blocks/SKILL.md +134 -0
  8. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
  9. package/atris/skills/youtube/SKILL.md +31 -11
  10. package/atris.md +15 -0
  11. package/ax +189 -7
  12. package/bin/atris.js +273 -225
  13. package/commands/autoland.js +379 -0
  14. package/commands/autopilot-front.js +273 -0
  15. package/commands/autopilot.js +94 -4
  16. package/commands/business.js +1 -1
  17. package/commands/clean.js +72 -9
  18. package/commands/codex-goal.js +72 -22
  19. package/commands/computer.js +48 -3
  20. package/commands/gm.js +1 -1
  21. package/commands/harvest.js +179 -0
  22. package/commands/init.js +22 -1
  23. package/commands/land.js +442 -0
  24. package/commands/loop-front.js +122 -4
  25. package/commands/member.js +551 -19
  26. package/commands/mission.js +3674 -278
  27. package/commands/play.js +1 -1
  28. package/commands/pulse.js +65 -7
  29. package/commands/run-front.js +144 -0
  30. package/commands/run.js +10 -7
  31. package/commands/sign.js +90 -0
  32. package/commands/strings.js +301 -0
  33. package/commands/task.js +782 -108
  34. package/commands/truth.js +171 -0
  35. package/commands/xp.js +32 -8
  36. package/commands/youtube.js +72 -5
  37. package/decks/README.md +6 -12
  38. package/lib/auto-accept-certified.js +10 -0
  39. package/lib/autoland.js +391 -0
  40. package/lib/context-gatherer.js +0 -8
  41. package/lib/mission-artifact.js +504 -0
  42. package/lib/mission-room.js +846 -0
  43. package/lib/next-moves.js +212 -6
  44. package/lib/operator-next.js +7 -0
  45. package/lib/pulse.js +78 -4
  46. package/lib/runner-command.js +51 -20
  47. package/lib/runs-prune.js +242 -0
  48. package/lib/task-db.js +19 -2
  49. package/lib/task-proof.js +1 -1
  50. package/package.json +4 -4
  51. package/decks/atris-seed-pitch-v3.json +0 -118
  52. package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
  53. package/decks/atris-seed-pitch-v5.json +0 -109
  54. package/decks/atris-seed-pitch-v6.json +0 -137
  55. package/decks/atris-seed-pitch-v7.json +0 -133
  56. package/decks/mark-pincus-narrative.json +0 -102
  57. package/decks/mark-pincus-sourcery.json +0 -94
  58. package/decks/yash-applied-compute-detailed.json +0 -150
  59. package/decks/yash-applied-compute-generalist.json +0 -82
  60. package/decks/yash-applied-compute-narrative.json +0 -54
  61. package/lib/ax-chat-input.js +0 -164
  62. package/lib/ax-goal.js +0 -307
  63. package/lib/ax-prefs.js +0 -63
  64. package/lib/ax-shimmer.js +0 -63
@@ -0,0 +1,379 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ const autoland = require('../lib/autoland');
9
+ const { operatorReady, hasAgentJargon } = autoland;
10
+
11
+ function repoRoot(cwd = process.cwd()) {
12
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
13
+ if (result.status !== 0) return cwd;
14
+ return result.stdout.trim() || cwd;
15
+ }
16
+
17
+ function projectName(root) {
18
+ return path.basename(root);
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
+
40
+ function flag(args, name, fallback = '') {
41
+ const idx = args.indexOf(name);
42
+ if (idx === -1) return fallback;
43
+ return args[idx + 1] || fallback;
44
+ }
45
+
46
+ function runOwnCli(root, cliArgs) {
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 });
52
+ return { status: result.status, stdout: String(result.stdout || ''), stderr: String(result.stderr || '') };
53
+ }
54
+
55
+ function readProjection(root) {
56
+ try {
57
+ const raw = fs.readFileSync(path.join(root, '.atris', 'state', 'tasks.projection.json'), 'utf8');
58
+ const projection = JSON.parse(raw);
59
+ return Array.isArray(projection.tasks) ? projection.tasks : [];
60
+ } catch (err) {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ function landSummarySafe(root) {
66
+ try {
67
+ return require('./land').landSummary(root);
68
+ } catch (err) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function evaluateQueue(root, { strictVerify }) {
74
+ const cliArgs = ['task', 'auto-accept-certified', '--dry-run', '--json', '--limit', '50'];
75
+ if (strictVerify) cliArgs.push('--strict-verify');
76
+ const result = runOwnCli(root, cliArgs);
77
+ try {
78
+ const parsed = JSON.parse(result.stdout);
79
+ return Array.isArray(parsed.results) ? parsed.results : [];
80
+ } catch (err) {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function plainReason(reason) {
86
+ const map = {
87
+ denied_tag_billing: 'money — yours to approve',
88
+ denied_tag_deploy: 'a deploy — yours to approve',
89
+ denied_tag_security: 'security — yours to approve',
90
+ denied_tag_customer: 'customer-facing — yours to approve',
91
+ denied_tag_external: 'outward-facing — yours to approve',
92
+ denied_tag_feedback: 'customer feedback — yours to approve',
93
+ denied_tag_voice: 'voice/comms — yours to approve',
94
+ needs_second_reviewer_or_third_pass: 'needs one more independent check first',
95
+ not_agent_certified: 'not certified yet',
96
+ insufficient_review_passes: 'not enough review passes yet',
97
+ strict_verify_missing: 'no recorded check command to re-run',
98
+ verify_failed: 'its check command failed on re-run',
99
+ proof_unmerged_or_draft_pr_boundary: 'its proof points at an unmerged draft',
100
+ };
101
+ return map[reason] || reason.replace(/_/g, ' ');
102
+ }
103
+
104
+ function showStatus(root, args) {
105
+ const json = args.includes('--json');
106
+ const policy = autoland.readPolicy(root);
107
+ const enabled = Boolean(policy && policy.enabled);
108
+ const strictVerify = policy ? policy.strict_verify !== false : true;
109
+ const results = evaluateQueue(root, { strictVerify })
110
+ .filter((r) => r.reason !== 'not_in_review');
111
+ const wouldLand = results.filter((r) => r.action === 'would_accept');
112
+ const blocked = results.filter((r) => r.action !== 'would_accept');
113
+ const tasks = readProjection(root);
114
+ const waiting = autoland.waitingOnHuman(tasks);
115
+
116
+ if (json) {
117
+ console.log(JSON.stringify({
118
+ enabled,
119
+ policy,
120
+ heartbeat_installed: autoland.cronInstalled(root),
121
+ would_land: wouldLand,
122
+ blocked,
123
+ waiting_on_human: waiting,
124
+ }, null, 2));
125
+ return 0;
126
+ }
127
+
128
+ console.log('');
129
+ console.log(`autoland — certified work lands itself; you keep the irreversible calls`);
130
+ console.log('');
131
+ console.log(` policy: ${enabled ? `on (flipped by ${policy.enabled_by}${policy.enabled_at ? ` on ${String(policy.enabled_at).slice(0, 10)}` : ''})` : 'off — everything waits for you'}`);
132
+ console.log(` heartbeat: ${autoland.cronInstalled(root) ? 'running hourly' : 'not installed'}`);
133
+ if (policy && policy.imessage_to) console.log(` daily message: ${policy.imessage_to} at ${policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR}:00`);
134
+ console.log('');
135
+ if (wouldLand.length > 0) {
136
+ console.log(` ready to land on their own: ${wouldLand.length}`);
137
+ for (const r of wouldLand.slice(0, 10)) console.log(` lands itself ${r.ref}`);
138
+ } else {
139
+ console.log(' nothing is ready to land on its own right now.');
140
+ }
141
+ const humanOnly = blocked.filter((r) => String(r.reason || '').startsWith('denied_tag_'));
142
+ const needsWork = blocked.filter((r) => !String(r.reason || '').startsWith('denied_tag_'));
143
+ if (humanOnly.length > 0) {
144
+ console.log(` yours to approve (protected lanes): ${humanOnly.length}`);
145
+ for (const r of humanOnly.slice(0, 10)) console.log(` waits for you ${r.ref} — ${plainReason(r.reason)}`);
146
+ }
147
+ if (needsWork.length > 0) {
148
+ console.log(` not ready yet: ${needsWork.length}`);
149
+ for (const r of needsWork.slice(0, 10)) console.log(` held back ${r.ref} — ${plainReason(String(r.reason || ''))}`);
150
+ }
151
+ if (waiting.length > 0) {
152
+ console.log('');
153
+ console.log(` waiting on a human right now: ${waiting.length}, oldest ${waiting[0].hours}h`);
154
+ }
155
+ console.log('');
156
+ if (!enabled) console.log(" flip it on: atris autoland on (one decision; everything after is receipts)");
157
+ console.log('');
158
+ return 0;
159
+ }
160
+
161
+ function turnOn(root, args) {
162
+ const owner = flag(args, '--as', os.userInfo().username);
163
+ const to = flag(args, '--to', '');
164
+ const digestHour = Number(flag(args, '--digest-hour', autoland.DEFAULT_DIGEST_HOUR));
165
+ const alarmHours = Number(flag(args, '--alarm-hours', autoland.DEFAULT_ALARM_HOURS));
166
+ const policy = autoland.writePolicy(root, {
167
+ enabled: true,
168
+ enabled_by: owner,
169
+ enabled_at: new Date().toISOString(),
170
+ imessage_to: to || null,
171
+ digest_hour: Number.isFinite(digestHour) ? digestHour : autoland.DEFAULT_DIGEST_HOUR,
172
+ alarm_hours: Number.isFinite(alarmHours) && alarmHours > 0 ? alarmHours : autoland.DEFAULT_ALARM_HOURS,
173
+ strict_verify: !args.includes('--no-strict-verify'),
174
+ });
175
+ const cronOk = autoland.installCron(root);
176
+ console.log('');
177
+ console.log('autoland is on.');
178
+ console.log(` certified, verified, reversible work now lands itself, accepted as ${owner}.`);
179
+ console.log(' protected lanes (money, deploys, security, customer, outward) still wait for you.');
180
+ console.log(` heartbeat: ${cronOk ? 'installed, runs hourly' : 'could not install cron — run atris autoland tick yourself'}`);
181
+ if (policy.imessage_to) {
182
+ console.log(` daily message to ${policy.imessage_to} at ${policy.digest_hour}:00; anything waiting on you past ${policy.alarm_hours}h pings you.`);
183
+ } else {
184
+ console.log(' no phone number set — digest goes to the log only. add one: atris autoland on --to <your number>');
185
+ }
186
+ console.log(' turn it off any time: atris autoland off');
187
+ console.log('');
188
+ return 0;
189
+ }
190
+
191
+ function turnOff(root) {
192
+ const policy = autoland.readPolicy(root) || {};
193
+ autoland.writePolicy(root, { ...policy, enabled: false, disabled_at: new Date().toISOString() });
194
+ const cronOk = autoland.uninstallCron(root);
195
+ console.log('');
196
+ console.log('autoland is off. everything waits for your accept again.');
197
+ console.log(` heartbeat ${cronOk ? 'removed' : 'removal failed — check crontab -l'}.`);
198
+ console.log('');
199
+ return 0;
200
+ }
201
+
202
+ function runDigest(root, args, { forceSend = false } = {}) {
203
+ const policy = autoland.readPolicy(root) || {};
204
+ const tasks = readProjection(root);
205
+ const accepted = autoland.acceptedInLastDay(tasks);
206
+ const text = autoland.composeDigest({
207
+ accepted,
208
+ waiting: autoland.waitingOnHuman(tasks),
209
+ landed: landSummarySafe(root),
210
+ project: projectName(root),
211
+ nextMoves: digestNextMoves(root),
212
+ });
213
+ console.log(text);
214
+ // the full story: what each piece actually was, in its own words
215
+ const byRef = new Map(tasks.map((t) => [t.display_id || t.legacy_ref || t.id, t]));
216
+ const storied = accepted.auto.filter((item) => {
217
+ const t = byRef.get(item.ref);
218
+ return t && (t.review?.landing?.happened || t.metadata?.landing_happened);
219
+ });
220
+ if (storied.length > 0) {
221
+ let printedStoryHeader = false;
222
+ for (const item of storied) {
223
+ const t = byRef.get(item.ref);
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
+ }
230
+ console.log(` ${item.ref} ${happened}`);
231
+ }
232
+ }
233
+ const shouldSend = (forceSend || args.includes('--send')) && policy.imessage_to;
234
+ if (shouldSend) {
235
+ const sent = autoland.sendImessage(root, policy.imessage_to, text);
236
+ console.log(sent.ok ? `(sent to ${policy.imessage_to})` : `(send failed: ${sent.output})`);
237
+ return sent.ok ? 0 : 1;
238
+ }
239
+ return 0;
240
+ }
241
+
242
+ function runTick(root, args) {
243
+ const json = args.includes('--json');
244
+ const policy = autoland.readPolicy(root);
245
+ const receipt = { at: new Date().toISOString(), landed: [], alarms: 0, digest_sent: false, enabled: Boolean(policy && policy.enabled) };
246
+ if (!policy || policy.enabled !== true) {
247
+ if (json) console.log(JSON.stringify(receipt));
248
+ else console.log('autoland is off; tick did nothing.');
249
+ return 0;
250
+ }
251
+
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
268
+ const cliArgs = ['task', 'auto-accept-certified', '--json', '--limit', '12'];
269
+ if (policy.strict_verify !== false) cliArgs.push('--strict-verify');
270
+ const accept = runOwnCli(root, cliArgs);
271
+ try {
272
+ const parsed = JSON.parse(accept.stdout);
273
+ receipt.landed = (parsed.results || []).filter((r) => r.action === 'accepted').map((r) => r.ref);
274
+ } catch (err) {
275
+ receipt.accept_error = accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
276
+ }
277
+
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
295
+ const state = autoland.readState(root);
296
+ const tasks = readProjection(root);
297
+ const waiting = autoland.waitingOnHuman(tasks);
298
+ const alarmHours = Number(policy.alarm_hours) || autoland.DEFAULT_ALARM_HOURS;
299
+ const due = autoland.dueForAlarm(waiting, state, { alarmHours });
300
+ if (due.length > 0 && policy.imessage_to) {
301
+ const text = autoland.composeAlarm({ waiting: due, project: projectName(root), alarmHours });
302
+ const sent = autoland.sendImessage(root, policy.imessage_to, text);
303
+ if (sent.ok) {
304
+ autoland.markAlerted(state, due);
305
+ receipt.alarms = due.length;
306
+ }
307
+ }
308
+
309
+ // 4. daily digest at the configured hour
310
+ const today = new Date().toISOString().slice(0, 10);
311
+ const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
312
+ if (new Date().getHours() === digestHour && state.last_digest_date !== today) {
313
+ const text = autoland.composeDigest({
314
+ accepted: autoland.acceptedInLastDay(tasks),
315
+ waiting,
316
+ landed: landSummarySafe(root),
317
+ project: projectName(root),
318
+ nextMoves: digestNextMoves(root),
319
+ });
320
+ if (policy.imessage_to) {
321
+ const sent = autoland.sendImessage(root, policy.imessage_to, text);
322
+ receipt.digest_sent = sent.ok;
323
+ }
324
+ receipt.digest_text = text;
325
+ state.last_digest_date = today;
326
+
327
+ // 5. once a day, keep the receipt shelf lean: compress old run receipts
328
+ // into the manifest and drop unreferenced clutter, newest 200 kept.
329
+ const prune = runOwnCli(root, ['mission', 'prune-runs', '--apply', '--days', '14', '--keep-newest', '200', '--json']);
330
+ try {
331
+ const pruned = JSON.parse(prune.stdout);
332
+ receipt.receipts_pruned = pruned.pruned_count ?? pruned.pruned ?? pruned.removed ?? 0;
333
+ } catch {
334
+ receipt.receipts_pruned = null;
335
+ }
336
+ }
337
+ autoland.writeState(root, state);
338
+
339
+ if (json) console.log(JSON.stringify(receipt));
340
+ else {
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'}`);
342
+ }
343
+ return 0;
344
+ }
345
+
346
+ function showHelp() {
347
+ console.log('');
348
+ console.log('atris autoland — you approve the policy once; certified work lands itself');
349
+ console.log('');
350
+ console.log('finished work that passed its checks and two independent reviews lands');
351
+ console.log('automatically with a receipt. money, deploys, security, customer, and');
352
+ console.log('outward-facing work always waits for you.');
353
+ console.log('');
354
+ console.log(' atris autoland what would land, what waits for you');
355
+ console.log(' atris autoland on [--to <phone>] flip it on: hourly heartbeat, daily');
356
+ console.log(' message, ping when something waits');
357
+ console.log(' on you past 24h');
358
+ console.log(' atris autoland off back to approving every item');
359
+ console.log(' atris autoland digest [--send] the daily message, now');
360
+ console.log(' atris autoland tick one heartbeat (what the hourly cron runs)');
361
+ console.log('');
362
+ return 0;
363
+ }
364
+
365
+ function autolandCommand(args = []) {
366
+ const [sub, ...rest] = args;
367
+ if (!sub || sub.startsWith('--')) return showStatus(repoRoot(), args);
368
+ if (sub === 'help' || sub === '--help' || sub === '-h') return showHelp();
369
+ const root = repoRoot();
370
+ if (sub === 'on') return turnOn(root, rest);
371
+ if (sub === 'off') return turnOff(root);
372
+ if (sub === 'tick') return runTick(root, rest);
373
+ if (sub === 'digest') return runDigest(root, rest);
374
+ if (sub === 'status') return showStatus(root, rest);
375
+ console.error(`atris autoland: unknown subcommand '${sub}' (try: atris autoland help)`);
376
+ return 1;
377
+ }
378
+
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
+ };