atris 3.15.13 → 3.15.22

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 (93) hide show
  1. package/AGENTS.md +84 -8
  2. package/README.md +5 -1
  3. package/atris/AGENTS.md +46 -1
  4. package/atris/CLAUDE.md +36 -1
  5. package/atris/GEMINI.md +14 -1
  6. package/atris/atris.md +12 -1
  7. package/atris/atrisDev.md +3 -2
  8. package/atris/context/README.md +11 -0
  9. package/atris/features/company-brain-sync/validate.md +5 -5
  10. package/atris/learnings.jsonl +1 -0
  11. package/atris/policies/atris-design.md +2 -0
  12. package/atris/skills/aeo/SKILL.md +2 -2
  13. package/atris/skills/atris/SKILL.md +15 -62
  14. package/atris/skills/design/SKILL.md +2 -0
  15. package/atris/skills/imessage/SKILL.md +19 -2
  16. package/atris/skills/loop/SKILL.md +6 -5
  17. package/atris/skills/magic-inbox/SKILL.md +1 -1
  18. package/atris/team/_template/MEMBER.md +23 -1
  19. package/atris/team/brainstormer/START_HERE.md +6 -0
  20. package/atris/team/executor/MEMBER.md +13 -0
  21. package/atris/team/executor/START_HERE.md +6 -0
  22. package/atris/team/launcher/START_HERE.md +6 -0
  23. package/atris/team/mission-lead/MEMBER.md +39 -0
  24. package/atris/team/mission-lead/MISSION.md +33 -0
  25. package/atris/team/mission-lead/START_HERE.md +6 -0
  26. package/atris/team/navigator/MEMBER.md +11 -0
  27. package/atris/team/navigator/START_HERE.md +6 -0
  28. package/atris/team/opus-overnight/MEMBER.md +39 -0
  29. package/atris/team/opus-overnight/MISSION.md +61 -0
  30. package/atris/team/opus-overnight/START_HERE.md +6 -0
  31. package/atris/team/opus-overnight/STEERING.md +35 -0
  32. package/atris/team/researcher/START_HERE.md +6 -0
  33. package/atris/team/validator/MEMBER.md +26 -6
  34. package/atris/team/validator/START_HERE.md +6 -0
  35. package/atris/wiki/concepts/agent-activation-contract.md +79 -0
  36. package/atris/wiki/concepts/workspace-initialization-contract.md +73 -0
  37. package/atris/wiki/index.md +27 -0
  38. package/atris/wiki/sources/atris-labs-2026-05-10.txt +17 -0
  39. package/atris/wiki/sources/atris-labs-goals-2026-05-10.txt +15 -0
  40. package/atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt +10 -0
  41. package/atris/wiki/sources/jack-dorsey-2026-05-10.txt +12 -0
  42. package/atris.md +49 -13
  43. package/bin/atris.js +660 -22
  44. package/commands/activate.js +12 -3
  45. package/commands/aeo.js +1 -1
  46. package/commands/align.js +10 -10
  47. package/commands/analytics.js +9 -4
  48. package/commands/app.js +2 -0
  49. package/commands/apps.js +276 -0
  50. package/commands/auth.js +1 -1
  51. package/commands/autopilot.js +74 -5
  52. package/commands/brain.js +536 -61
  53. package/commands/brainstorm.js +12 -12
  54. package/commands/business-sync.js +142 -24
  55. package/commands/clean.js +9 -6
  56. package/commands/codex-goal.js +311 -0
  57. package/commands/errors.js +11 -1
  58. package/commands/feedback.js +55 -17
  59. package/commands/fork.js +2 -2
  60. package/commands/gm.js +376 -0
  61. package/commands/init.js +80 -3
  62. package/commands/integrations.js +524 -0
  63. package/commands/learn.js +25 -16
  64. package/commands/lesson.js +41 -0
  65. package/commands/lifecycle.js +2 -2
  66. package/commands/member.js +2416 -9
  67. package/commands/mission.js +1776 -0
  68. package/commands/now.js +48 -7
  69. package/commands/play.js +425 -0
  70. package/commands/publish.js +2 -1
  71. package/commands/pull.js +72 -29
  72. package/commands/push.js +199 -17
  73. package/commands/review.js +51 -13
  74. package/commands/skill.js +2 -2
  75. package/commands/soul.js +19 -13
  76. package/commands/status.js +6 -1
  77. package/commands/sync.js +5 -4
  78. package/commands/task.js +1041 -147
  79. package/commands/terminal.js +5 -5
  80. package/commands/verify.js +7 -5
  81. package/commands/visualize.js +7 -0
  82. package/commands/wiki.js +53 -16
  83. package/commands/workflow.js +298 -54
  84. package/commands/workspace-clean.js +1 -1
  85. package/commands/worktree.js +468 -0
  86. package/commands/xp.js +1608 -0
  87. package/lib/manifest.js +34 -4
  88. package/lib/scorecard.js +3 -2
  89. package/lib/task-db.js +408 -27
  90. package/lib/todo-fallback.js +28 -2
  91. package/lib/todo.js +5 -3
  92. package/package.json +23 -2
  93. package/utils/update-check.js +51 -1
@@ -0,0 +1,1776 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { spawn, spawnSync } = require('child_process');
7
+
8
+ const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
9
+ const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
10
+ const STATUS_ALIASES = new Set(['active']);
11
+
12
+ function stampIso() {
13
+ return new Date().toISOString();
14
+ }
15
+
16
+ function todayName() {
17
+ const now = new Date();
18
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
19
+ }
20
+
21
+ function slugify(value) {
22
+ return String(value || 'mission')
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, '-')
25
+ .replace(/^-+|-+$/g, '')
26
+ .slice(0, 48) || 'mission';
27
+ }
28
+
29
+ function shortHash(value) {
30
+ return crypto.createHash('sha1').update(String(value || '')).digest('hex').slice(0, 8);
31
+ }
32
+
33
+ function missionId(objective) {
34
+ return `mission-${todayName()}-${slugify(objective).slice(0, 28)}-${shortHash(`${objective}:${Date.now()}`)}`;
35
+ }
36
+
37
+ function hasFlag(args, name) {
38
+ return args.includes(name);
39
+ }
40
+
41
+ function unquote(value) {
42
+ const text = String(value);
43
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
44
+ return text.slice(1, -1);
45
+ }
46
+ return text;
47
+ }
48
+
49
+ function readFlag(args, name, fallback = '') {
50
+ const prefix = `${name}=`;
51
+ for (let i = 0; i < args.length; i += 1) {
52
+ const arg = String(args[i]);
53
+ if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) return unquote(args[i + 1]);
54
+ if (arg.startsWith(prefix)) return unquote(arg.slice(prefix.length));
55
+ }
56
+ return fallback;
57
+ }
58
+
59
+ function exitMissionError(message, code = 1, asJson = false) {
60
+ if (asJson) console.log(JSON.stringify({ ok: false, error: message }, null, 2));
61
+ else console.error(message);
62
+ process.exit(code);
63
+ }
64
+
65
+ function readPositiveIntegerFlag(args, name, fallback = null, options = {}) {
66
+ const raw = readFlag(args, name, '');
67
+ if (!raw) return fallback;
68
+ const value = Number(raw);
69
+ if (!Number.isInteger(value) || value < 1) {
70
+ exitMissionError(`${name} must be a positive integer`, 2, options.json);
71
+ }
72
+ return value;
73
+ }
74
+
75
+ function readRepeatedFlag(args, name) {
76
+ const values = [];
77
+ const prefix = `${name}=`;
78
+ for (let i = 0; i < args.length; i += 1) {
79
+ const arg = String(args[i]);
80
+ if (arg === name && args[i + 1] && !String(args[i + 1]).startsWith('--')) {
81
+ values.push(unquote(args[i + 1]));
82
+ i += 1;
83
+ continue;
84
+ }
85
+ if (arg.startsWith(prefix)) values.push(unquote(arg.slice(prefix.length)));
86
+ }
87
+ return values.filter(Boolean);
88
+ }
89
+
90
+ function lintMissionVerifier(command) {
91
+ const text = String(command || '').trim();
92
+ if (!text) return null;
93
+ const compact = text.replace(/\s+/g, ' ');
94
+ const staticNumericTest = /^test \d+ -(?:eq|ne|gt|ge|lt|le) \d+$/.test(compact)
95
+ || /^\[ \d+ -(?:eq|ne|gt|ge|lt|le) \d+ \]$/.test(compact);
96
+ if (!staticNumericTest) return null;
97
+ return 'looks like shell substitution expanded before Atris received it; quote dynamic verifiers with single quotes';
98
+ }
99
+
100
+ function assertMissionVerifier(command, asJson = false) {
101
+ const issue = lintMissionVerifier(command);
102
+ if (!issue) return;
103
+ exitMissionError(`Invalid --verify: ${issue}. Example: --verify 'test $(wc -l < atris/learnings.jsonl) -ge 478'`, 2, asJson);
104
+ }
105
+
106
+ function stripKnownFlags(args, valueNames, booleanNames = []) {
107
+ const valueSet = new Set(valueNames);
108
+ const booleanSet = new Set(booleanNames);
109
+ const out = [];
110
+ for (let i = 0; i < args.length; i += 1) {
111
+ const arg = String(args[i]);
112
+ const key = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
113
+ if (booleanSet.has(key)) continue;
114
+ if (valueSet.has(key)) {
115
+ if (!arg.includes('=') && args[i + 1] && !String(args[i + 1]).startsWith('--')) i += 1;
116
+ continue;
117
+ }
118
+ out.push(args[i]);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ function wantsJson(args) {
124
+ return hasFlag(args, '--json');
125
+ }
126
+
127
+ function printJsonOrText(payload, lines, asJson) {
128
+ if (asJson) {
129
+ console.log(JSON.stringify(payload, null, 2));
130
+ return;
131
+ }
132
+ for (const line of lines) console.log(line);
133
+ }
134
+
135
+ function statePaths(root = process.cwd()) {
136
+ const stateDir = path.join(root, '.atris', 'state');
137
+ return {
138
+ stateDir,
139
+ missionsJsonl: path.join(stateDir, 'missions.jsonl'),
140
+ eventsJsonl: path.join(stateDir, 'mission_events.jsonl'),
141
+ codexGoalJson: path.join(stateDir, 'codex_goal.json'),
142
+ codexGoalStatus: path.join(root, 'atris', 'status', 'codex-goal.md'),
143
+ statusNow: path.join(root, 'atris', 'status', 'now.md'),
144
+ runsDir: path.join(root, 'atris', 'runs'),
145
+ };
146
+ }
147
+
148
+ function readJsonLines(file) {
149
+ if (!fs.existsSync(file)) return [];
150
+ return fs.readFileSync(file, 'utf8')
151
+ .split(/\r?\n/)
152
+ .map((line) => line.trim())
153
+ .filter(Boolean)
154
+ .map((line) => {
155
+ try {
156
+ return JSON.parse(line);
157
+ } catch {
158
+ return null;
159
+ }
160
+ })
161
+ .filter(Boolean);
162
+ }
163
+
164
+ function loadMissionMap(root = process.cwd()) {
165
+ const paths = statePaths(root);
166
+ const map = new Map();
167
+ for (const mission of readJsonLines(paths.missionsJsonl)) {
168
+ if (mission && mission.id) map.set(mission.id, normalizeMissionState(mission));
169
+ }
170
+ return map;
171
+ }
172
+
173
+ function terminalNextAction(status) {
174
+ if (status === 'complete') return 'mission complete';
175
+ if (status === 'stopped') return 'mission stopped';
176
+ return null;
177
+ }
178
+
179
+ function normalizeMissionState(mission) {
180
+ if (!mission) return mission;
181
+ const nextAction = terminalNextAction(mission.status);
182
+ if (!nextAction || mission.next_action === nextAction) return mission;
183
+ return { ...mission, next_action: nextAction };
184
+ }
185
+
186
+ function listMissions(root = process.cwd()) {
187
+ return Array.from(loadMissionMap(root).values())
188
+ .sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
189
+ }
190
+
191
+ function resolveMission(ref, root = process.cwd()) {
192
+ const missions = listMissions(root);
193
+ if (!ref) return missions.find((mission) => !TERMINAL_STATUSES.has(mission.status)) || missions[0] || null;
194
+ return missions.find((mission) => mission.id === ref || mission.id.startsWith(ref) || mission.slug === ref) || null;
195
+ }
196
+
197
+ function missionMatchesStatusFilter(mission, statusFilter) {
198
+ if (statusFilter === 'active') return !TERMINAL_STATUSES.has(mission.status);
199
+ return mission.status === statusFilter;
200
+ }
201
+
202
+ function appendJsonLine(file, payload) {
203
+ fs.mkdirSync(path.dirname(file), { recursive: true });
204
+ fs.appendFileSync(file, JSON.stringify(payload) + '\n', 'utf8');
205
+ }
206
+
207
+ function appendEvent(type, mission, payload = {}, root = process.cwd()) {
208
+ const paths = statePaths(root);
209
+ const event = {
210
+ schema: 'atris.mission_event.v1',
211
+ type,
212
+ mission_id: mission.id,
213
+ at: stampIso(),
214
+ actor: process.env.ATRIS_AGENT_ID || process.env.USER || null,
215
+ payload,
216
+ };
217
+ appendJsonLine(paths.eventsJsonl, event);
218
+ return event;
219
+ }
220
+
221
+ function saveMission(mission, root = process.cwd(), eventType = 'mission_updated', payload = {}) {
222
+ const paths = statePaths(root);
223
+ const next = normalizeMissionState({
224
+ ...mission,
225
+ schema: 'atris.mission.v1',
226
+ updated_at: stampIso(),
227
+ });
228
+ appendJsonLine(paths.missionsJsonl, next);
229
+ const event = appendEvent(eventType, next, payload, root);
230
+ renderMissionStatus(root);
231
+ renderMemberMissionState(next.owner, root);
232
+ return { mission: next, event };
233
+ }
234
+
235
+ function memberDir(owner, root = process.cwd()) {
236
+ if (!owner || !/^[a-zA-Z0-9._-]+$/.test(owner)) return null;
237
+ const dir = path.join(root, 'atris', 'team', owner);
238
+ if (!fs.existsSync(path.join(dir, 'MEMBER.md'))) return null;
239
+ return dir;
240
+ }
241
+
242
+ function appendMemberLog(owner, title, fields = {}, root = process.cwd()) {
243
+ const dir = memberDir(owner, root);
244
+ if (!dir) return null;
245
+ const logsDir = path.join(dir, 'logs');
246
+ fs.mkdirSync(logsDir, { recursive: true });
247
+ const logPath = path.join(logsDir, `${todayName()}.md`);
248
+ const stamp = new Date().toTimeString().slice(0, 5);
249
+ const rows = [
250
+ `## ${stamp} · ${title}`,
251
+ `- member: ${owner}`,
252
+ ...Object.entries(fields)
253
+ .filter(([, value]) => value !== undefined && value !== null && value !== '')
254
+ .map(([key, value]) => `- ${key}: ${String(value).replace(/\n/g, ' ')}`),
255
+ '',
256
+ ];
257
+ fs.appendFileSync(logPath, rows.join('\n'), 'utf8');
258
+ return logPath;
259
+ }
260
+
261
+ function memberMissionFile(owner, root = process.cwd()) {
262
+ const dir = memberDir(owner, root);
263
+ if (!dir) return null;
264
+ return path.join(dir, 'MISSION.md');
265
+ }
266
+
267
+ function ensureMemberMissionFile(owner, root = process.cwd(), objective = '') {
268
+ const missionPath = memberMissionFile(owner, root);
269
+ if (!missionPath || fs.existsSync(missionPath)) return missionPath;
270
+ const purpose = String(objective || '').trim() || 'Define why this member exists and how it chooses goals.';
271
+ const content = [
272
+ '# Mission',
273
+ '',
274
+ '<!-- Human-authored purpose file. Keep this durable; runtime state belongs in .atris/state/*.jsonl and now.md. -->',
275
+ '',
276
+ '## North Star',
277
+ '',
278
+ purpose,
279
+ '',
280
+ '## How To Choose Goals',
281
+ '',
282
+ '- Read MEMBER.md, MISSION.md, current goals, now.md, and recent logs.',
283
+ '- Choose one useful bounded goal toward the mission.',
284
+ '- Verify the work, write the receipt, and update the log.',
285
+ '- Ask the human when vision, taste, risk, or uncertainty matters.',
286
+ '',
287
+ ].join('\n');
288
+ fs.writeFileSync(missionPath, content, 'utf8');
289
+ return missionPath;
290
+ }
291
+
292
+ function removeLegacyGeneratedMissionViews(dir) {
293
+ for (const name of ['missions.md', 'missions.json']) {
294
+ const legacyPath = path.join(dir, name);
295
+ if (!fs.existsSync(legacyPath)) continue;
296
+ let text = '';
297
+ try {
298
+ text = fs.readFileSync(legacyPath, 'utf8');
299
+ } catch {}
300
+ const looksGenerated = name.endsWith('.json')
301
+ ? text.includes('"schema": "atris.member_missions.v1"')
302
+ : text.includes('Generated from local Mission state');
303
+ if (looksGenerated) fs.unlinkSync(legacyPath);
304
+ }
305
+ }
306
+
307
+ function renderMemberNowMarkdown(owner, missions) {
308
+ const lines = [
309
+ '# Now',
310
+ '',
311
+ '<!-- Generated by Atris. Do not hand-edit. Durable purpose belongs in MISSION.md. -->',
312
+ '',
313
+ ];
314
+ if (!missions.length) {
315
+ lines.push('No missions yet.', '');
316
+ return lines.join('\n');
317
+ }
318
+ for (const mission of missions) {
319
+ lines.push(`## ${mission.objective}`);
320
+ lines.push('');
321
+ lines.push(`- id: ${mission.id}`);
322
+ lines.push(`- status: ${mission.status}`);
323
+ lines.push(`- cadence: ${mission.cadence}`);
324
+ lines.push(`- runner: ${mission.runner}`);
325
+ lines.push(`- lane: ${mission.lane}`);
326
+ if (mission.verifier) lines.push(`- verifier: ${mission.verifier}`);
327
+ if (mission.stop_condition) lines.push(`- stop: ${mission.stop_condition}`);
328
+ if (mission.next_action) lines.push(`- next: ${mission.next_action}`);
329
+ if (mission.receipt_path) lines.push(`- receipt: ${mission.receipt_path}`);
330
+ if (mission.human_asks?.length) {
331
+ lines.push('- human asks:');
332
+ for (const ask of mission.human_asks) lines.push(` - ${ask}`);
333
+ }
334
+ lines.push('');
335
+ }
336
+ return lines.join('\n');
337
+ }
338
+
339
+ function renderMemberMissionState(owner, root = process.cwd()) {
340
+ const dir = memberDir(owner, root);
341
+ if (!dir) return null;
342
+ const missionPath = ensureMemberMissionFile(owner, root);
343
+ const missions = listMissions(root).filter((mission) => mission.owner === owner);
344
+ const nowPath = path.join(dir, 'now.md');
345
+ removeLegacyGeneratedMissionViews(dir);
346
+ fs.writeFileSync(nowPath, renderMemberNowMarkdown(owner, missions), 'utf8');
347
+ return { missionPath, nowPath };
348
+ }
349
+
350
+ function renderMissionStatus(root = process.cwd()) {
351
+ const paths = statePaths(root);
352
+ const missions = listMissions(root);
353
+ fs.mkdirSync(path.dirname(paths.statusNow), { recursive: true });
354
+ const active = missions.filter((mission) => !TERMINAL_STATUSES.has(mission.status));
355
+ const lines = [
356
+ '# Now',
357
+ '',
358
+ '## Missions',
359
+ '',
360
+ ];
361
+ if (!missions.length) {
362
+ lines.push('No missions yet.', '');
363
+ } else {
364
+ for (const mission of missions.slice(0, 12)) {
365
+ lines.push(`- **${mission.id}** ${mission.objective}`);
366
+ lines.push(` - owner: ${mission.owner}`);
367
+ lines.push(` - state: ${mission.status}`);
368
+ lines.push(` - next: ${mission.next_action || 'tick or verify'}`);
369
+ if (mission.receipt_path) lines.push(` - proof: ${mission.receipt_path}`);
370
+ }
371
+ lines.push('');
372
+ }
373
+ lines.push(`Active missions: ${active.length}`);
374
+ lines.push('');
375
+ fs.writeFileSync(paths.statusNow, lines.join('\n'), 'utf8');
376
+ return paths.statusNow;
377
+ }
378
+
379
+ function missionFromArgs(args) {
380
+ const objective = stripKnownFlags(args, [
381
+ '--owner',
382
+ '--cadence',
383
+ '--loop',
384
+ '--runner',
385
+ '--lane',
386
+ '--verify',
387
+ '--stop',
388
+ '--task',
389
+ '--ask',
390
+ ], ['--json', '--always-on']).join(' ').trim();
391
+ if (!objective) {
392
+ exitMissionError('Usage: atris mission start "<objective>" --owner <member> [--verify "..."] [--cadence manual]', 1, wantsJson(args));
393
+ }
394
+ const owner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
395
+ const cadence = readFlag(args, '--cadence', readFlag(args, '--loop', 'manual')) || 'manual';
396
+ const runner = readFlag(args, '--runner', 'manual');
397
+ const lane = readFlag(args, '--lane', 'workspace');
398
+ const verifier = readFlag(args, '--verify', '');
399
+ assertMissionVerifier(verifier, wantsJson(args));
400
+ const stopCondition = readFlag(args, '--stop', verifier ? 'verifier passes and no human asks remain' : 'human marks complete with proof');
401
+ const taskIds = readRepeatedFlag(args, '--task');
402
+ const humanAsks = readRepeatedFlag(args, '--ask');
403
+ const alwaysOn = hasFlag(args, '--always-on');
404
+ const id = missionId(objective);
405
+ const mission = {
406
+ schema: 'atris.mission.v1',
407
+ id,
408
+ slug: slugify(objective),
409
+ objective,
410
+ owner,
411
+ status: 'planning',
412
+ cadence,
413
+ runner,
414
+ lane,
415
+ verifier,
416
+ always_on: alwaysOn,
417
+ stop_condition: stopCondition,
418
+ task_ids: taskIds,
419
+ human_asks: humanAsks,
420
+ next_action: verifier ? 'run verifier with `atris mission tick <id> --verify`' : 'define verifier or run next task',
421
+ receipt_path: null,
422
+ created_at: stampIso(),
423
+ updated_at: stampIso(),
424
+ };
425
+ if (alwaysOn) mission.next_action = nextCandidateTickAction(mission);
426
+ return mission;
427
+ }
428
+
429
+ function missingVerifierWarning(mission) {
430
+ if (String(mission.verifier || '').trim()) return null;
431
+ return {
432
+ code: 'missing_verifier',
433
+ message: 'Mission has no verifier; it cannot complete automatically and future runs will report unverified worktree side effects.',
434
+ };
435
+ }
436
+
437
+ function startMission(args) {
438
+ const asJson = wantsJson(args);
439
+ const mission = missionFromArgs(args);
440
+ const warnings = [missingVerifierWarning(mission)].filter(Boolean);
441
+ ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
442
+ const { mission: saved } = saveMission(mission, process.cwd(), 'mission_started', { objective: mission.objective });
443
+ const memberState = renderMemberMissionState(saved.owner);
444
+ const logPath = appendMemberLog(saved.owner, 'Mission started', {
445
+ mission: saved.objective,
446
+ cadence: saved.cadence,
447
+ runner: saved.runner,
448
+ lane: saved.lane,
449
+ verifier: saved.verifier,
450
+ });
451
+ printJsonOrText(
452
+ { ok: true, action: 'mission_started', mission: saved, warnings, state_path: statePaths().missionsJsonl, member_state: memberState, log_path: logPath },
453
+ [
454
+ `Started mission: ${saved.objective}`,
455
+ `Owner: ${saved.owner}`,
456
+ `State: ${saved.status}`,
457
+ ...warnings.map((warning) => `Warning: ${warning.message}`),
458
+ `Next: atris mission tick ${saved.id}`,
459
+ ],
460
+ asJson,
461
+ );
462
+ }
463
+
464
+ function statusMission(args) {
465
+ const asJson = wantsJson(args);
466
+ const ref = stripKnownFlags(args, ['--status', '--limit'], ['--json'])[0] || '';
467
+ const statusFilter = readFlag(args, '--status', '');
468
+ if (statusFilter && !VALID_STATUSES.has(statusFilter) && !STATUS_ALIASES.has(statusFilter)) {
469
+ exitMissionError(`Invalid --status: ${statusFilter}`, 2, asJson);
470
+ }
471
+ const limit = readPositiveIntegerFlag(args, '--limit', null, { json: asJson });
472
+ let missions = ref ? [resolveMission(ref)].filter(Boolean) : listMissions();
473
+ if (!ref && statusFilter) missions = missions.filter((mission) => missionMatchesStatusFilter(mission, statusFilter));
474
+ if (!ref && limit) missions = missions.slice(0, limit);
475
+ if (ref && !missions.length) {
476
+ exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
477
+ }
478
+ for (const owner of new Set(missions.map((mission) => mission.owner).filter(Boolean))) {
479
+ renderMemberMissionState(owner);
480
+ }
481
+ const payload = {
482
+ ok: true,
483
+ action: 'mission_status',
484
+ missions,
485
+ state_path: statePaths().missionsJsonl,
486
+ events_path: statePaths().eventsJsonl,
487
+ status_path: renderMissionStatus(),
488
+ };
489
+ printJsonOrText(
490
+ payload,
491
+ missions.length
492
+ ? missions.flatMap((mission) => [
493
+ `Mission: ${mission.objective}`,
494
+ ` id: ${mission.id}`,
495
+ ` owner: ${mission.owner}`,
496
+ ` state: ${mission.status}`,
497
+ ` next: ${mission.next_action || 'tick or verify'}`,
498
+ ...(mission.receipt_path ? [` proof: ${mission.receipt_path}`] : []),
499
+ ])
500
+ : ['No missions yet. Run: atris mission start "..." --owner <member>'],
501
+ asJson,
502
+ );
503
+ }
504
+
505
+ function writeReceipt(mission, result, root = process.cwd()) {
506
+ const paths = statePaths(root);
507
+ fs.mkdirSync(paths.runsDir, { recursive: true });
508
+ const safeTime = stampIso().replace(/[:.]/g, '-');
509
+ const receiptPath = path.join(paths.runsDir, `mission-${mission.id}-${safeTime}.json`);
510
+ // Back-compat: legacy consumers read receipt.result.passed (verifier-only shape).
511
+ // New shape nests verifier under result.verifier_result, so mirror .passed at top.
512
+ const finalResult = (result && typeof result === 'object' && result.verifier_result && !('passed' in result))
513
+ ? { ...result, passed: !!result.verifier_result.passed }
514
+ : result;
515
+ fs.writeFileSync(receiptPath, JSON.stringify({
516
+ schema: 'atris.mission_receipt.v1',
517
+ mission_id: mission.id,
518
+ objective: mission.objective,
519
+ owner: mission.owner,
520
+ at: stampIso(),
521
+ verifier: mission.verifier || null,
522
+ result: finalResult,
523
+ }, null, 2) + '\n', 'utf8');
524
+ return path.relative(root, receiptPath);
525
+ }
526
+
527
+ function runVerifier(command, root = process.cwd()) {
528
+ if (!command) return null;
529
+ const result = spawnSync(command, {
530
+ cwd: root,
531
+ shell: true,
532
+ encoding: 'utf8',
533
+ timeout: 120000,
534
+ env: process.env,
535
+ });
536
+ return {
537
+ command,
538
+ status: result.status,
539
+ signal: result.signal || null,
540
+ passed: result.status === 0,
541
+ stdout: String(result.stdout || '').slice(-4000),
542
+ stderr: String(result.stderr || '').slice(-4000),
543
+ };
544
+ }
545
+
546
+ function gitWorktreeSnapshot(root = process.cwd()) {
547
+ const inside = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
548
+ cwd: root,
549
+ encoding: 'utf8',
550
+ timeout: 5000,
551
+ });
552
+ if (inside.status !== 0 || String(inside.stdout || '').trim() !== 'true') {
553
+ return { available: false, reason: 'not-git-worktree' };
554
+ }
555
+ const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], {
556
+ cwd: root,
557
+ encoding: 'utf8',
558
+ timeout: 10000,
559
+ });
560
+ if (status.status !== 0) {
561
+ return {
562
+ available: false,
563
+ reason: 'git-status-failed',
564
+ stderr: String(status.stderr || '').slice(-1000),
565
+ };
566
+ }
567
+ const entries = String(status.stdout || '').split(/\r?\n/).filter(Boolean).sort();
568
+ const digest = crypto.createHash('sha1').update(entries.join('\n')).digest('hex');
569
+ return {
570
+ available: true,
571
+ dirty_count: entries.length,
572
+ dirty_hash: digest,
573
+ dirty_sample: entries.slice(0, 25),
574
+ entries,
575
+ };
576
+ }
577
+
578
+ function worktreeReceipt(before, after, { verifier = '' } = {}) {
579
+ if (!before?.available || !after?.available) {
580
+ return {
581
+ available: false,
582
+ before_reason: before?.reason || null,
583
+ after_reason: after?.reason || null,
584
+ };
585
+ }
586
+ const beforeSet = new Set(before.entries || []);
587
+ const afterSet = new Set(after.entries || []);
588
+ const newDirty = (after.entries || []).filter((entry) => !beforeSet.has(entry));
589
+ const clearedDirty = (before.entries || []).filter((entry) => !afterSet.has(entry));
590
+ const changed = before.dirty_hash !== after.dirty_hash;
591
+ const hasVerifier = !!String(verifier || '').trim();
592
+ return {
593
+ available: true,
594
+ before_dirty_count: before.dirty_count,
595
+ after_dirty_count: after.dirty_count,
596
+ changed,
597
+ unverified_dirty: !hasVerifier && after.dirty_count > 0,
598
+ unverified_change: !hasVerifier && changed,
599
+ new_dirty_count: newDirty.length,
600
+ cleared_dirty_count: clearedDirty.length,
601
+ dirty_sample_after: after.dirty_sample,
602
+ new_dirty_sample: newDirty.slice(0, 25),
603
+ cleared_dirty_sample: clearedDirty.slice(0, 25),
604
+ before_dirty_hash: before.dirty_hash,
605
+ after_dirty_hash: after.dirty_hash,
606
+ };
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // `atris mission run <id>` — bounded local headless loop. v0.1.
611
+ // Spawns `claude -p --resume <session>` per tick. Honors cadence, active-hours,
612
+ // rate-limit info, and a flock per mission. Only consumes max-ticks on `ran`.
613
+ // ---------------------------------------------------------------------------
614
+
615
+ const MISSION_RUN_DEFAULTS = {
616
+ maxTicks: 4,
617
+ maxWallSeconds: 3600,
618
+ claudeTimeoutMs: 10 * 60 * 1000,
619
+ backoff: { initialMs: 30_000, maxMs: 10 * 60_000, factor: 2, jitter: 0.3 },
620
+ };
621
+
622
+ function runnerUsesCallerSession(runner) {
623
+ return new Set(['codex_goal', 'caller_session', 'current_agent']).has(String(runner || '').trim().toLowerCase());
624
+ }
625
+
626
+ function nextCandidateTickAction(mission) {
627
+ return `next move: run atris mission run ${mission.id} --complete-on-pass`;
628
+ }
629
+
630
+ function missionVerifierPassed(mission) {
631
+ return (mission && mission.verifier_result && mission.verifier_result.passed) === true;
632
+ }
633
+
634
+ function missionDueAt(mission, now = new Date()) {
635
+ const cadenceSeconds = parseCadenceSeconds(mission.cadence);
636
+ if (!mission.last_tick_at || cadenceSeconds === 0) return true;
637
+ const lastTickAt = Date.parse(mission.last_tick_at);
638
+ if (!Number.isFinite(lastTickAt)) return true;
639
+ return now.getTime() - lastTickAt >= cadenceSeconds * 1000;
640
+ }
641
+
642
+ function secondsUntilMissionDue(mission, now = new Date()) {
643
+ const cadenceSeconds = parseCadenceSeconds(mission?.cadence);
644
+ if (!mission || !mission.last_tick_at || cadenceSeconds === 0) return 0;
645
+ const lastTickAt = Date.parse(mission.last_tick_at);
646
+ if (!Number.isFinite(lastTickAt)) return 0;
647
+ const dueAt = lastTickAt + cadenceSeconds * 1000;
648
+ return Math.max(0, Math.ceil((dueAt - now.getTime()) / 1000));
649
+ }
650
+
651
+ function selectDueMission(root = process.cwd(), now = new Date()) {
652
+ const candidates = listMissions(root)
653
+ .filter((mission) => !TERMINAL_STATUSES.has(mission.status))
654
+ .filter((mission) => mission.verifier)
655
+ .filter((mission) => mission.always_on || !missionVerifierPassed(mission))
656
+ .filter((mission) => missionDueAt(mission, now));
657
+
658
+ candidates.sort((a, b) => {
659
+ const aTime = Date.parse(a.last_tick_at || a.created_at || '') || 0;
660
+ const bTime = Date.parse(b.last_tick_at || b.created_at || '') || 0;
661
+ return aTime - bTime;
662
+ });
663
+ return candidates[0] || null;
664
+ }
665
+
666
+ function selectCodexGoalMission(root = process.cwd(), now = new Date()) {
667
+ const due = selectDueMission(root, now);
668
+ if (due) return { mission: due, reason: 'due' };
669
+
670
+ const candidates = listMissions(root)
671
+ .filter((mission) => !TERMINAL_STATUSES.has(mission.status));
672
+
673
+ candidates.sort((a, b) => {
674
+ const aCaller = runnerUsesCallerSession(a.runner) ? 1 : 0;
675
+ const bCaller = runnerUsesCallerSession(b.runner) ? 1 : 0;
676
+ if (aCaller !== bCaller) return bCaller - aCaller;
677
+
678
+ const aVerifier = a.verifier ? 1 : 0;
679
+ const bVerifier = b.verifier ? 1 : 0;
680
+ if (aVerifier !== bVerifier) return bVerifier - aVerifier;
681
+
682
+ const aTime = Date.parse(a.updated_at || a.created_at || '') || 0;
683
+ const bTime = Date.parse(b.updated_at || b.created_at || '') || 0;
684
+ return bTime - aTime;
685
+ });
686
+
687
+ const mission = candidates[0] || null;
688
+ if (!mission) return null;
689
+ return { mission, reason: 'active' };
690
+ }
691
+
692
+ function codexGoalObjective(mission) {
693
+ return `Advance Atris mission ${mission.id}: ${mission.objective}`;
694
+ }
695
+
696
+ function codexGoalNextCommand(mission) {
697
+ if (mission.verifier && missionDueAt(mission)) {
698
+ return 'atris mission run --due --max-ticks 1 --complete-on-pass';
699
+ }
700
+ if (mission.verifier) {
701
+ return `atris mission tick ${mission.id} --verify --summary "<what changed>"`;
702
+ }
703
+ return `atris mission tick ${mission.id} --summary "<what changed>"`;
704
+ }
705
+
706
+ function codexGoalToolContract(mission) {
707
+ return {
708
+ current_policy: 'keep one visible Codex /goal active for the selected Atris mission',
709
+ read_current_goal: 'get_goal',
710
+ complete_current_goal: 'update_goal({ status: "complete" })',
711
+ select_next_goal: 'atris mission goal --json',
712
+ set_next_goal: 'replace_goal(goal.objective) or create_goal(goal.objective) after the completed goal slot is reusable',
713
+ platform_requirement: 'Codex runtime must expose replace_goal/set_goal, or allow create_goal after update_goal completes the prior goal.',
714
+ blocked_without_platform_goal_write: true,
715
+ mission_id: mission.id,
716
+ };
717
+ }
718
+
719
+ function codexGoalHeartbeat(goal, mission, now = new Date()) {
720
+ const secondsUntilDue = secondsUntilMissionDue(mission, now);
721
+ return {
722
+ due: mission ? missionDueAt(mission, now) : false,
723
+ seconds_until_due: secondsUntilDue,
724
+ recommended_sleep_seconds: secondsUntilDue === 0 ? 0 : Math.min(Math.max(secondsUntilDue, 15), 900),
725
+ heavy_work_performed: false,
726
+ next_heavy_command: goal?.next_command || null,
727
+ };
728
+ }
729
+
730
+ function writeCodexGoalState(payload, root = process.cwd()) {
731
+ const paths = statePaths(root);
732
+ const state = {
733
+ schema: 'atris.codex_goal_controller.v1',
734
+ updated_at: stampIso(),
735
+ ...payload,
736
+ };
737
+ fs.mkdirSync(path.dirname(paths.codexGoalJson), { recursive: true });
738
+ fs.writeFileSync(paths.codexGoalJson, JSON.stringify(state, null, 2) + '\n', 'utf8');
739
+
740
+ const lines = [
741
+ '# Codex Goal Controller',
742
+ '',
743
+ '<!-- Generated by Atris. Do not hand-edit. -->',
744
+ '',
745
+ `- updated: ${state.updated_at}`,
746
+ `- action: ${state.action}`,
747
+ ];
748
+ if (state.goal) {
749
+ lines.push(`- mission: ${state.goal.mission_id}`);
750
+ lines.push(`- status: ${state.goal.mission_status}`);
751
+ lines.push(`- reason: ${state.goal.reason}`);
752
+ lines.push(`- objective: ${state.goal.objective}`);
753
+ lines.push(`- next: ${state.goal.next_command}`);
754
+ lines.push(`- platform write blocked: ${state.goal.codex_tool_contract.blocked_without_platform_goal_write}`);
755
+ } else {
756
+ lines.push('- mission: none');
757
+ }
758
+ lines.push('');
759
+ fs.mkdirSync(path.dirname(paths.codexGoalStatus), { recursive: true });
760
+ fs.writeFileSync(paths.codexGoalStatus, lines.join('\n'), 'utf8');
761
+ return {
762
+ state_path: paths.codexGoalJson,
763
+ status_path: paths.codexGoalStatus,
764
+ state,
765
+ };
766
+ }
767
+
768
+ function buildCodexGoalPayload(root = process.cwd(), options = {}) {
769
+ const heartbeatMode = options.heartbeat === true;
770
+ const selected = selectCodexGoalMission(root);
771
+ if (!selected) {
772
+ const heartbeat = heartbeatMode ? codexGoalHeartbeat(null, null) : undefined;
773
+ return {
774
+ ok: true,
775
+ action: heartbeatMode ? 'codex_goal_heartbeat' : 'no_goal_candidate',
776
+ mission: null,
777
+ heartbeat,
778
+ };
779
+ }
780
+
781
+ const { mission, reason } = selected;
782
+ const goal = {
783
+ objective: codexGoalObjective(mission),
784
+ mission_id: mission.id,
785
+ mission_objective: mission.objective,
786
+ mission_status: mission.status,
787
+ reason,
788
+ next_command: codexGoalNextCommand(mission),
789
+ replace_after: 'After proof or verifier pass, run atris mission goal --json again and replace the Codex /goal with the returned objective.',
790
+ codex_tool_contract: codexGoalToolContract(mission),
791
+ };
792
+ const heartbeat = heartbeatMode ? codexGoalHeartbeat(goal, mission) : undefined;
793
+ return {
794
+ ok: true,
795
+ action: heartbeatMode ? 'codex_goal_heartbeat' : 'codex_goal_candidate',
796
+ goal,
797
+ mission,
798
+ heartbeat,
799
+ };
800
+ }
801
+
802
+ function refreshCodexGoalController(root = process.cwd(), options = {}) {
803
+ const payload = buildCodexGoalPayload(root, options);
804
+ const rendered = writeCodexGoalState(payload, root);
805
+ return {
806
+ ...payload,
807
+ state_path: rendered.state_path,
808
+ status_path: rendered.status_path,
809
+ };
810
+ }
811
+
812
+ function runMissionRunDueOnce(root = process.cwd(), options = {}) {
813
+ const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
814
+ const args = ['mission', 'run', '--due', '--max-ticks', '1', '--complete-on-pass', '--json'];
815
+ if (options.noClaude) args.push('--no-claude');
816
+ const result = spawnSync(process.execPath, [cliPath, ...args], {
817
+ cwd: root,
818
+ encoding: 'utf8',
819
+ timeout: 120000,
820
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
821
+ });
822
+ let payload = null;
823
+ try {
824
+ payload = JSON.parse(result.stdout || '{}');
825
+ } catch {}
826
+ return {
827
+ command: `atris ${args.join(' ')}`,
828
+ status: result.status,
829
+ ok: result.status === 0,
830
+ stdout: String(result.stdout || '').slice(-4000),
831
+ stderr: String(result.stderr || '').slice(-4000),
832
+ payload,
833
+ };
834
+ }
835
+
836
+ function sleep(ms, signal) {
837
+ return new Promise((resolve, reject) => {
838
+ if (ms <= 0) return resolve();
839
+ const onAbort = () => { clearTimeout(timer); reject(Object.assign(new Error('aborted'), { code: 'ABORTED' })); };
840
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
841
+ if (signal) {
842
+ if (signal.aborted) return onAbort();
843
+ signal.addEventListener('abort', onAbort, { once: true });
844
+ }
845
+ });
846
+ }
847
+
848
+ function parseCadenceSeconds(cadence) {
849
+ const text = String(cadence || '').trim().toLowerCase();
850
+ if (!text || text === 'manual' || text === 'once') return 0;
851
+ const m = text.match(/^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hour|hours|d|day|days)$/);
852
+ if (!m) return 0;
853
+ const n = Number(m[1]);
854
+ const unit = m[2];
855
+ if (/^d/.test(unit)) return n * 86400;
856
+ if (/^h/.test(unit)) return n * 3600;
857
+ if (/^m(?!s)/.test(unit)) return n * 60;
858
+ return n; // seconds
859
+ }
860
+
861
+ function computeBackoff(policy, attempt) {
862
+ const base = policy.initialMs * Math.pow(policy.factor, Math.max(attempt - 1, 0));
863
+ const jitter = base * policy.jitter * Math.random();
864
+ return Math.min(policy.maxMs, Math.round(base + jitter));
865
+ }
866
+
867
+ function consecutiveVerifierFails(ticks) {
868
+ let n = 0;
869
+ for (let i = ticks.length - 1; i >= 0; i--) {
870
+ const t = ticks[i];
871
+ if (t.status !== 'ran') break;
872
+ if (t.verifier_passed === false) n++;
873
+ else break;
874
+ }
875
+ return n;
876
+ }
877
+
878
+ function isWithinActiveHours(activeHours, now = new Date()) {
879
+ if (!activeHours || !activeHours.start || !activeHours.end) return true;
880
+ const tz = activeHours.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
881
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(now);
882
+ const map = Object.fromEntries(parts.filter((p) => p.type !== 'literal').map((p) => [p.type, p.value]));
883
+ const cur = Number(map.hour) * 60 + Number(map.minute);
884
+ const [sh, sm] = String(activeHours.start).split(':').map(Number);
885
+ const [eh, em] = String(activeHours.end).split(':').map(Number);
886
+ const start = sh * 60 + (sm || 0);
887
+ const end = (eh === 24 ? 24 * 60 : eh * 60 + (em || 0));
888
+ if (start === end) return false;
889
+ if (end > start) return cur >= start && cur < end;
890
+ return cur >= start || cur < end;
891
+ }
892
+
893
+ function acquireMissionLock(missionId, root = process.cwd()) {
894
+ const dir = path.join(root, '.atris', 'state');
895
+ fs.mkdirSync(dir, { recursive: true });
896
+ const lockFile = path.join(dir, `mission-${missionId}.lock`);
897
+ let fd;
898
+ try {
899
+ fd = fs.openSync(lockFile, 'wx');
900
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: stampIso(), mission_id: missionId }));
901
+ return { ok: true, lockFile, fd };
902
+ } catch (e) {
903
+ if (e.code === 'EEXIST') {
904
+ let info = {};
905
+ try { info = JSON.parse(fs.readFileSync(lockFile, 'utf8') || '{}'); } catch {}
906
+ return { ok: false, lockFile, busy: true, holder: info };
907
+ }
908
+ return { ok: false, lockFile, error: e.message };
909
+ }
910
+ }
911
+
912
+ function releaseMissionLock(lock) {
913
+ if (!lock || !lock.ok) return;
914
+ try { if (lock.fd != null) fs.closeSync(lock.fd); } catch {}
915
+ try { fs.unlinkSync(lock.lockFile); } catch {}
916
+ }
917
+
918
+ function probeClaudeBinary() {
919
+ const help = spawnSync('claude', ['--help'], { encoding: 'utf8', timeout: 8000 });
920
+ if (help.status !== 0) return { ok: false, error: 'claude --help failed' };
921
+ const text = String(help.stdout || '');
922
+ const required = ['--output-format', '--permission-mode', '--resume', '--session-id', '--include-partial-messages'];
923
+ const missing = required.filter((flag) => !text.includes(flag));
924
+ if (missing.length) return { ok: false, error: `claude binary missing flags: ${missing.join(', ')}` };
925
+ return { ok: true };
926
+ }
927
+
928
+ function buildTickPrompt(mission, tickIndex, maxTicks, frozen) {
929
+ const lines = [
930
+ `# Mission Tick ${tickIndex}/${maxTicks}`,
931
+ ``,
932
+ `**Objective:** ${mission.objective}`,
933
+ `**Owner:** ${mission.owner}`,
934
+ `**Lane:** ${frozen.lane}`,
935
+ `**Cadence:** ${mission.cadence}`,
936
+ `**Stop condition:** ${mission.stop_condition || 'human marks complete'}`,
937
+ `**Verifier (frozen):** ${frozen.verifier || '(none — receipt only)'}`,
938
+ `**Last status:** ${mission.status}`,
939
+ `**Last tick:** ${mission.last_tick_at || 'never'}`,
940
+ ``,
941
+ `## Your task`,
942
+ `Do ONE increment of work toward the stop condition. ONE. No more.`,
943
+ `- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
944
+ `- Pick the smallest concrete action that moves the mission forward.`,
945
+ `- Edit / run / research as needed for the lane.`,
946
+ `- After your work, the harness runs the frozen verifier — make sure it'll pass.`,
947
+ `- If you can't make progress this tick, say why explicitly. Don't fake it.`,
948
+ ``,
949
+ `## Constraints`,
950
+ `- Lane = ${frozen.lane}: stay inside that lane.`,
951
+ `- Do NOT modify mission.verifier, mission.lane, or any tool policy.`,
952
+ `- Do NOT start new missions, modify other missions, or expand scope.`,
953
+ `- Do NOT run destructive commands without strong evidence they're correct.`,
954
+ ``,
955
+ `When done, output a short receipt: (1) the exact files edited / commands run / artifacts produced — name them, (2) the metric of progress, (3) what the next tick should pick up.`,
956
+ ];
957
+ if (mission.task_ids?.length) {
958
+ lines.push('', `## Task ids`, mission.task_ids.map((t) => `- ${t}`).join('\n'));
959
+ }
960
+ if (mission.human_asks?.length) {
961
+ lines.push('', `## Human asks (don't act on these — surface them)`, mission.human_asks.map((t) => `- ${t}`).join('\n'));
962
+ }
963
+ return lines.join('\n');
964
+ }
965
+
966
+ function spawnClaudeTick(mission, opts) {
967
+ const { sessionMode, sessionId, cwd, signal, timeoutMs, prompt } = opts;
968
+ return new Promise((resolve) => {
969
+ const args = [
970
+ '-p', prompt,
971
+ '--output-format', 'stream-json',
972
+ '--verbose',
973
+ '--permission-mode', 'bypassPermissions',
974
+ '--include-partial-messages',
975
+ ];
976
+ if (sessionMode === 'set') args.push('--session-id', sessionId);
977
+ else if (sessionMode === 'resume') args.push('--resume', sessionId);
978
+
979
+ const startedAt = Date.now();
980
+ const proc = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
981
+
982
+ let stdoutBuf = '';
983
+ let observedSessionIds = new Set();
984
+ let finalText = null;
985
+ let isError = false;
986
+ let costEstimate = null;
987
+ let durationApiMs = null;
988
+ let numTurns = null;
989
+ let rateLimitInfo = null;
990
+ let stopReason = null;
991
+ let parseErrors = 0;
992
+ let stderr = '';
993
+ let timedOut = false;
994
+ let aborted = false;
995
+
996
+ const kill = (reason) => {
997
+ try { proc.kill('SIGTERM'); } catch {}
998
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000).unref();
999
+ };
1000
+ const timer = setTimeout(() => { timedOut = true; kill('timeout'); }, timeoutMs);
1001
+ const onAbort = () => { aborted = true; kill('aborted'); };
1002
+ if (signal) {
1003
+ if (signal.aborted) onAbort();
1004
+ else signal.addEventListener('abort', onAbort, { once: true });
1005
+ }
1006
+
1007
+ proc.stdout.on('data', (chunk) => {
1008
+ stdoutBuf += chunk.toString();
1009
+ let nl;
1010
+ while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
1011
+ const line = stdoutBuf.slice(0, nl).trim();
1012
+ stdoutBuf = stdoutBuf.slice(nl + 1);
1013
+ if (!line) continue;
1014
+ try {
1015
+ const ev = JSON.parse(line);
1016
+ if (ev.session_id) observedSessionIds.add(ev.session_id);
1017
+ if (ev.type === 'rate_limit_event' && ev.rate_limit_info) {
1018
+ rateLimitInfo = ev.rate_limit_info;
1019
+ }
1020
+ if (ev.type === 'result') {
1021
+ if (typeof ev.result === 'string') finalText = ev.result;
1022
+ if (ev.is_error) isError = true;
1023
+ if (typeof ev.total_cost_usd === 'number') costEstimate = ev.total_cost_usd;
1024
+ if (typeof ev.duration_api_ms === 'number') durationApiMs = ev.duration_api_ms;
1025
+ if (typeof ev.num_turns === 'number') numTurns = ev.num_turns;
1026
+ if (ev.stop_reason) stopReason = ev.stop_reason;
1027
+ }
1028
+ } catch {
1029
+ parseErrors++;
1030
+ }
1031
+ }
1032
+ });
1033
+
1034
+ proc.stderr.on('data', (c) => { stderr += c.toString(); });
1035
+
1036
+ proc.on('close', (code) => {
1037
+ clearTimeout(timer);
1038
+ if (signal) signal.removeEventListener?.('abort', onAbort);
1039
+ const ok = code === 0 && !isError && !timedOut && !aborted;
1040
+ const errStr = stderr.slice(-2000);
1041
+ const authExpired = /not authenticated|please log in|login required|auth(?:entication)? expired/i.test(errStr);
1042
+ resolve({
1043
+ ok,
1044
+ timedOut,
1045
+ aborted,
1046
+ authExpired,
1047
+ exitCode: code,
1048
+ sessionIds: Array.from(observedSessionIds),
1049
+ result: finalText,
1050
+ summary: (finalText || '').split('\n').filter(Boolean)[0]?.slice(0, 240) || (ok ? 'no-text' : 'error'),
1051
+ api_equivalent_estimate: costEstimate,
1052
+ duration_api_ms: durationApiMs,
1053
+ duration_total_ms: Date.now() - startedAt,
1054
+ num_turns: numTurns,
1055
+ stop_reason: stopReason,
1056
+ is_error: isError,
1057
+ rate_limit_info: rateLimitInfo,
1058
+ stderr: errStr,
1059
+ parse_errors: parseErrors,
1060
+ });
1061
+ });
1062
+
1063
+ proc.on('error', (e) => {
1064
+ clearTimeout(timer);
1065
+ resolve({ ok: false, error: e.message, sessionIds: [], aborted, timedOut, authExpired: false });
1066
+ });
1067
+ });
1068
+ }
1069
+
1070
+ async function runMission(args) {
1071
+ const asJson = wantsJson(args);
1072
+ const dueMode = hasFlag(args, '--due');
1073
+ const skipClaude = hasFlag(args, '--no-claude');
1074
+ const verifyEach = !hasFlag(args, '--no-verify');
1075
+ const completeOnPass = hasFlag(args, '--complete-on-pass');
1076
+ const maxTicksFlag = readFlag(args, '--max-ticks', '');
1077
+ const maxTicks = Math.max(1, Number(maxTicksFlag) || MISSION_RUN_DEFAULTS.maxTicks);
1078
+ const maxWallSeconds = Math.max(60, Number(readFlag(args, '--max-wall', '')) || MISSION_RUN_DEFAULTS.maxWallSeconds);
1079
+ const cadenceOverride = readFlag(args, '--cadence', '');
1080
+ const ref = stripKnownFlags(args, ['--max-ticks', '--max-wall', '--cadence'], ['--json', '--due', '--no-claude', '--no-verify', '--complete-on-pass'])[0] || '';
1081
+
1082
+ let mission = dueMode && !ref ? selectDueMission() : resolveMission(ref);
1083
+ if (!mission && dueMode && !ref) {
1084
+ printJsonOrText(
1085
+ { ok: true, action: 'run_skipped', reason: 'no_due_mission', mission: null },
1086
+ ['No due mission found.'],
1087
+ asJson,
1088
+ );
1089
+ return;
1090
+ }
1091
+ if (!mission) {
1092
+ exitMissionError(ref ? `Mission "${ref}" not found.` : 'Usage: atris mission run <id> [--max-ticks 4] [--max-wall 3600]', 1, asJson);
1093
+ }
1094
+ if (['complete', 'stopped'].includes(mission.status)) {
1095
+ if (asJson) {
1096
+ printJsonOrText(
1097
+ { ok: true, action: 'run_skipped', reason: mission.status, mission },
1098
+ [],
1099
+ true,
1100
+ );
1101
+ return;
1102
+ }
1103
+ console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
1104
+ process.exit(0);
1105
+ }
1106
+
1107
+ const preLockCallerSession = runnerUsesCallerSession(mission.runner);
1108
+ if (!skipClaude && !preLockCallerSession) {
1109
+ const probe = probeClaudeBinary();
1110
+ if (!probe.ok) {
1111
+ console.error(`[mission run] claude probe failed: ${probe.error}`);
1112
+ process.exit(2);
1113
+ }
1114
+ }
1115
+
1116
+ const lock = acquireMissionLock(mission.id);
1117
+ if (!lock.ok) {
1118
+ exitMissionError(`[mission run] lock busy (held by pid ${lock.holder?.pid || '?'} since ${lock.holder?.started_at || '?'}). Exit.`, 3, asJson);
1119
+ }
1120
+
1121
+ // Everything past lock acquisition runs inside try/finally so the lock + signal handlers
1122
+ // always get cleaned up — including saveMission failures during pending-session setup.
1123
+ let pauseReason = null;
1124
+ let sessionId = null;
1125
+ let pendingSessionId = null;
1126
+ let ranTicks = 0;
1127
+ const ticks = [];
1128
+ let onSig = null;
1129
+
1130
+ try {
1131
+ const cwd = process.cwd();
1132
+ const controller = new AbortController();
1133
+ onSig = () => { controller.abort(); };
1134
+ process.on('SIGINT', onSig);
1135
+ process.on('SIGTERM', onSig);
1136
+
1137
+ // Re-read inside the lock. The initial resolveMission ran pre-lock, so a concurrent
1138
+ // `mission tick` could have written between resolveMission and acquireMissionLock.
1139
+ // Derive sessionId, pendingSessionId, and the frozen contract from the fresh record
1140
+ // so a fast tick's writes can't be silently overwritten by this run loop.
1141
+ mission = resolveMission(mission.id) || mission;
1142
+ if (['complete', 'stopped'].includes(mission.status)) {
1143
+ console.error(`Mission ${mission.id} is ${mission.status}; nothing to run.`);
1144
+ return;
1145
+ }
1146
+ if (mission.status === 'paused') {
1147
+ mission = saveMission({
1148
+ ...mission,
1149
+ status: 'running',
1150
+ resumed_at: stampIso(),
1151
+ stop_reason: null,
1152
+ next_action: `running: atris mission run ${mission.id}`,
1153
+ }, cwd, 'mission_run_resumed', { reason: 'operator-resume' }).mission;
1154
+ }
1155
+ sessionId = mission.claude_session_id || null;
1156
+ pendingSessionId = mission.pending_session_id || null;
1157
+ const callerSessionRunner = runnerUsesCallerSession(mission.runner);
1158
+ const skipWorker = skipClaude || callerSessionRunner;
1159
+
1160
+ // Freeze run-start contract (verifier, lane). Stored on receipts, not the mission record.
1161
+ const frozen = {
1162
+ verifier: mission.verifier || '',
1163
+ lane: mission.lane || 'workspace',
1164
+ started_at: stampIso(),
1165
+ };
1166
+ const runWorktreeBefore = gitWorktreeSnapshot(cwd);
1167
+ const cadence = cadenceOverride || mission.cadence || 'manual';
1168
+ let cadenceSeconds = parseCadenceSeconds(cadence);
1169
+ // cadence=manual|once: exactly 1 tick unless user explicitly raised --max-ticks
1170
+ const effectiveMaxTicks = (cadenceSeconds === 0 && !maxTicksFlag) ? 1 : maxTicks;
1171
+
1172
+ // Session setup: only Claude-backed workers need a persisted session id.
1173
+ if (!skipWorker && !sessionId && !pendingSessionId) {
1174
+ pendingSessionId = crypto.randomUUID();
1175
+ mission = saveMission({ ...mission, pending_session_id: pendingSessionId }, cwd, 'mission_session_pending', { session_id: pendingSessionId }).mission;
1176
+ }
1177
+
1178
+ const startedAt = Date.now();
1179
+ let backoffAttempt = 0;
1180
+ let lastRateLimit = null;
1181
+
1182
+ const sessionLabel = skipWorker ? 'caller-session' : (sessionId || `pending=${pendingSessionId}`);
1183
+ console.error(`[mission run] ${mission.id}\n objective: ${mission.objective}\n lane: ${frozen.lane}\n cadence: ${cadence} (${cadenceSeconds}s)\n max_ticks: ${effectiveMaxTicks}, max_wall: ${maxWallSeconds}s\n session: ${sessionLabel}`);
1184
+
1185
+ while (ranTicks < effectiveMaxTicks) {
1186
+ const elapsedSec = (Date.now() - startedAt) / 1000;
1187
+ const remainingWall = maxWallSeconds - elapsedSec;
1188
+ if (remainingWall <= 0) { pauseReason = 'max-wall-reached'; break; }
1189
+ if (controller.signal.aborted) { pauseReason = 'aborted'; break; }
1190
+
1191
+ // Re-read mission, detect mutation of frozen fields
1192
+ mission = resolveMission(mission.id) || mission;
1193
+ if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
1194
+ if (mission.verifier !== frozen.verifier) { pauseReason = 'verifier-mutated'; break; }
1195
+ if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
1196
+
1197
+ const tickIdx = ticks.length + 1;
1198
+ const tickStart = stampIso();
1199
+ const tickWorktreeBefore = gitWorktreeSnapshot(cwd);
1200
+ let result = { status: 'skipped', reason: 'unknown', tick_index: tickIdx, ran: false, started_at: tickStart };
1201
+
1202
+ // Active-hours gate
1203
+ if (!isWithinActiveHours(mission.active_hours)) {
1204
+ result = { ...result, status: 'skipped', reason: 'quiet-hours' };
1205
+ }
1206
+ // Rate-limit cooldown
1207
+ else if (lastRateLimit && lastRateLimit.resetsAt && Date.now() / 1000 < Number(lastRateLimit.resetsAt)) {
1208
+ const waitSec = Number(lastRateLimit.resetsAt) - Math.floor(Date.now() / 1000);
1209
+ if (waitSec > remainingWall) { pauseReason = 'rate-limit-exceeded-wall'; break; }
1210
+ result = { ...result, status: 'skipped', reason: 'rate-limited', resets_at: lastRateLimit.resetsAt };
1211
+ }
1212
+ // Real tick
1213
+ else if (skipWorker) {
1214
+ result = {
1215
+ ...result,
1216
+ status: 'ran',
1217
+ reason: callerSessionRunner ? 'caller-session-runner' : 'no-claude-mode',
1218
+ ran: true,
1219
+ claude: { skipped: true, reason: callerSessionRunner ? 'runner-uses-caller-session' : 'no-claude-mode' },
1220
+ };
1221
+ } else {
1222
+ const sessionMode = sessionId ? 'resume' : 'set';
1223
+ const useId = sessionId || pendingSessionId;
1224
+ const prompt = buildTickPrompt(mission, tickIdx, effectiveMaxTicks, frozen);
1225
+ const claudeResult = await spawnClaudeTick(mission, {
1226
+ sessionMode, sessionId: useId, cwd, signal: controller.signal,
1227
+ timeoutMs: MISSION_RUN_DEFAULTS.claudeTimeoutMs, prompt,
1228
+ });
1229
+ result.claude = {
1230
+ ok: claudeResult.ok,
1231
+ summary: claudeResult.summary,
1232
+ stop_reason: claudeResult.stop_reason,
1233
+ api_equivalent_estimate: claudeResult.api_equivalent_estimate,
1234
+ duration_total_ms: claudeResult.duration_total_ms,
1235
+ num_turns: claudeResult.num_turns,
1236
+ observed_session_ids: claudeResult.sessionIds,
1237
+ parse_errors: claudeResult.parse_errors,
1238
+ stderr: claudeResult.stderr?.slice(-1000),
1239
+ timed_out: claudeResult.timedOut,
1240
+ aborted: claudeResult.aborted,
1241
+ };
1242
+ if (claudeResult.rate_limit_info) {
1243
+ lastRateLimit = claudeResult.rate_limit_info;
1244
+ if (lastRateLimit.status && lastRateLimit.status !== 'allowed') {
1245
+ // throttled / overage
1246
+ }
1247
+ }
1248
+ if (claudeResult.aborted) { pauseReason = 'aborted-during-claude'; break; }
1249
+ if (claudeResult.authExpired) { pauseReason = 'auth-required'; break; }
1250
+
1251
+ if (!claudeResult.ok) {
1252
+ result = { ...result, status: 'errored', reason: claudeResult.timedOut ? 'claude-timeout' : 'claude-error' };
1253
+ } else {
1254
+ // Promote pending session id ONLY if claude confirmed the exact UUID we requested.
1255
+ // Mismatch is an invariant failure (we sent --session-id X, got Y) → pause, don't rotate.
1256
+ if (!sessionId && pendingSessionId) {
1257
+ if (claudeResult.sessionIds.includes(pendingSessionId)) {
1258
+ sessionId = pendingSessionId;
1259
+ mission = saveMission({ ...mission, claude_session_id: sessionId, pending_session_id: null }, cwd, 'mission_session_started', { session_id: sessionId }).mission;
1260
+ } else if (claudeResult.sessionIds.length > 0) {
1261
+ const observed = claudeResult.sessionIds[0];
1262
+ mission = saveMission({ ...mission, session_id_mismatch: { requested: pendingSessionId, observed } }, cwd, 'mission_session_mismatch', { requested: pendingSessionId, observed }).mission;
1263
+ pauseReason = 'session-id-mismatch-first-tick';
1264
+ break;
1265
+ }
1266
+ } else if (sessionId && claudeResult.sessionIds.length > 0 && !claudeResult.sessionIds.includes(sessionId)) {
1267
+ // session_id mismatch on a resumed session — abort run
1268
+ pauseReason = 'session-id-mismatch';
1269
+ break;
1270
+ }
1271
+ result = { ...result, status: 'ran', reason: 'tick-ok', ran: true };
1272
+ }
1273
+ }
1274
+
1275
+ // Verifier (only if claude succeeded or no-claude mode)
1276
+ let verifierResult = null;
1277
+ let receiptPath = null;
1278
+ if (result.status === 'ran' && verifyEach && frozen.verifier) {
1279
+ verifierResult = runVerifier(frozen.verifier);
1280
+ result.verifier_passed = verifierResult.passed;
1281
+ }
1282
+ const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier });
1283
+
1284
+ // Persist tick to mission state + write structured receipt
1285
+ const finishedAt = stampIso();
1286
+ const tickRecord = { ...result, started_at: tickStart, finished_at: finishedAt, worktree: tickWorktree };
1287
+ ticks.push(tickRecord);
1288
+ receiptPath = writeReceipt(mission, {
1289
+ kind: 'mission_run_tick',
1290
+ tick: tickRecord,
1291
+ frozen,
1292
+ verifier_result: verifierResult,
1293
+ rate_limit_info: lastRateLimit,
1294
+ worktree: tickWorktree,
1295
+ });
1296
+
1297
+ const newStatus = (verifierResult?.passed && mission.always_on) ? 'running' :
1298
+ (verifierResult?.passed && completeOnPass) ? 'complete' :
1299
+ (verifierResult?.passed ? 'ready' :
1300
+ (verifierResult ? 'blocked' :
1301
+ (result.status === 'ran' ? 'running' : mission.status)));
1302
+ let nextAction = mission.next_action;
1303
+ if (verifierResult?.passed && mission.always_on) {
1304
+ nextAction = nextCandidateTickAction(mission);
1305
+ } else if (verifierResult?.passed && completeOnPass) {
1306
+ nextAction = 'mission complete';
1307
+ } else if (verifierResult?.passed) {
1308
+ nextAction = `review proof then run: atris mission complete ${mission.id} --proof "${receiptPath}"`;
1309
+ } else if (verifierResult) {
1310
+ nextAction = 'fix verifier failure or revise mission';
1311
+ }
1312
+ mission = saveMission({
1313
+ ...mission,
1314
+ status: newStatus,
1315
+ last_tick_at: finishedAt,
1316
+ last_tick_status: result.status,
1317
+ last_tick_reason: result.reason,
1318
+ verifier_result: verifierResult || mission.verifier_result || null,
1319
+ receipt_path: receiptPath,
1320
+ next_action: nextAction,
1321
+ }, cwd, 'mission_tick', {
1322
+ tick_index: tickIdx, status: result.status, reason: result.reason, receipt_path: receiptPath,
1323
+ }).mission;
1324
+ appendMemberLog(mission.owner, `Mission run tick ${tickIdx}`, {
1325
+ mission: mission.objective,
1326
+ state: mission.status,
1327
+ tick_status: result.status,
1328
+ reason: result.reason,
1329
+ verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
1330
+ receipt: receiptPath,
1331
+ });
1332
+ refreshCodexGoalController(cwd);
1333
+
1334
+ console.error(`[tick ${tickIdx}] status=${result.status} reason=${result.reason} verifier=${verifierResult ? (verifierResult.passed ? 'pass' : 'fail') : 'skip'} -> ${receiptPath || '-'}`);
1335
+
1336
+ if (result.status === 'ran') {
1337
+ ranTicks++;
1338
+ backoffAttempt = 0;
1339
+ } else if (result.status === 'errored') {
1340
+ backoffAttempt++;
1341
+ }
1342
+
1343
+ if (newStatus === 'complete' || (newStatus === 'ready' && !mission.always_on)) break;
1344
+ if (consecutiveVerifierFails(ticks) >= 2) { pauseReason = 'consecutive-verifier-fails'; break; }
1345
+
1346
+ // Sleep until next tick
1347
+ let sleepMs = 0;
1348
+ if (result.status === 'errored') {
1349
+ sleepMs = computeBackoff(MISSION_RUN_DEFAULTS.backoff, backoffAttempt);
1350
+ } else if (cadenceSeconds > 0) {
1351
+ sleepMs = cadenceSeconds * 1000;
1352
+ } else if (result.status === 'skipped' && result.reason === 'quiet-hours') {
1353
+ sleepMs = 60_000; // 1min poll while waiting for window
1354
+ } else if (result.status === 'skipped' && result.reason === 'rate-limited') {
1355
+ sleepMs = Math.min(60_000, (Number(lastRateLimit.resetsAt) * 1000) - Date.now());
1356
+ }
1357
+ const remainingMs = remainingWall * 1000 - 1;
1358
+ sleepMs = Math.min(Math.max(0, sleepMs), Math.max(0, remainingMs));
1359
+ if (sleepMs > 0 && ranTicks < effectiveMaxTicks) {
1360
+ try { await sleep(sleepMs, controller.signal); }
1361
+ catch (e) { if (e.code === 'ABORTED') { pauseReason = 'aborted'; break; } throw e; }
1362
+ }
1363
+ }
1364
+
1365
+ if (pauseReason && !['complete', 'ready', 'max-wall-reached'].includes(pauseReason)) {
1366
+ mission = saveMission({
1367
+ ...mission,
1368
+ status: 'paused',
1369
+ paused_at: stampIso(),
1370
+ stop_reason: pauseReason,
1371
+ next_action: `resume with: atris mission run ${mission.id}`,
1372
+ }, cwd, 'mission_run_paused', { reason: pauseReason }).mission;
1373
+ }
1374
+
1375
+ const summaryWorktree = worktreeReceipt(runWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: frozen.verifier });
1376
+ const finalReceipt = writeReceipt(mission, {
1377
+ kind: 'mission_run_summary',
1378
+ frozen,
1379
+ pause_reason: pauseReason,
1380
+ ran_ticks: ranTicks,
1381
+ tick_count: ticks.length,
1382
+ ticks,
1383
+ session_id: sessionId,
1384
+ pending_session_id: mission.pending_session_id || null,
1385
+ elapsed_seconds: (Date.now() - startedAt) / 1000,
1386
+ worktree: summaryWorktree,
1387
+ });
1388
+ const codexGoalState = refreshCodexGoalController(cwd);
1389
+
1390
+ printJsonOrText(
1391
+ { ok: true, action: 'mission_run', mission, ran_ticks: ranTicks, tick_count: ticks.length, ticks, pause_reason: pauseReason, session_id: sessionId, summary_receipt: finalReceipt, worktree: summaryWorktree, codex_goal_state: codexGoalState },
1392
+ [
1393
+ `Ran mission ${mission.id}`,
1394
+ ` objective: ${mission.objective}`,
1395
+ ` ran_ticks: ${ranTicks}/${effectiveMaxTicks} (skipped/errored: ${ticks.length - ranTicks})`,
1396
+ ` final state: ${mission.status}`,
1397
+ pauseReason ? ` pause: ${pauseReason}` : null,
1398
+ ` session: ${sessionId || '(none)'}`,
1399
+ ` summary receipt: ${finalReceipt}`,
1400
+ ].filter(Boolean),
1401
+ asJson,
1402
+ );
1403
+ } finally {
1404
+ if (onSig) {
1405
+ try { process.removeListener('SIGINT', onSig); } catch {}
1406
+ try { process.removeListener('SIGTERM', onSig); } catch {}
1407
+ }
1408
+ releaseMissionLock(lock);
1409
+ }
1410
+ }
1411
+
1412
+ function tickMission(args) {
1413
+ const asJson = wantsJson(args);
1414
+ const verify = hasFlag(args, '--verify');
1415
+ const completeOnPass = hasFlag(args, '--complete-on-pass');
1416
+ const summary = readFlag(args, '--summary', '');
1417
+ const ref = stripKnownFlags(args, ['--summary'], ['--json', '--verify', '--complete-on-pass'])[0] || '';
1418
+ let mission = resolveMission(ref);
1419
+ if (!mission) {
1420
+ exitMissionError(ref ? `Mission "${ref}" not found.` : 'No mission found. Run: atris mission start "..."', 1, asJson);
1421
+ }
1422
+
1423
+ // Same per-mission flock that `mission run` uses. Without it, a tick could
1424
+ // increment last_tick_index/receipt_path concurrently with a run loop and
1425
+ // get its mutation overwritten by the run's saveMission on the next tick.
1426
+ const lock = acquireMissionLock(mission.id);
1427
+ if (!lock.ok) {
1428
+ exitMissionError(`[mission tick] lock busy (held by pid ${lock.holder?.pid || '?'} since ${lock.holder?.started_at || '?'}). Exit.`, 3, asJson);
1429
+ }
1430
+
1431
+ try {
1432
+ // Re-read inside the lock — the initial resolveMission ran before we held it.
1433
+ mission = resolveMission(mission.id) || mission;
1434
+
1435
+ if (['complete', 'stopped'].includes(mission.status)) {
1436
+ const { mission: saved } = saveMission({ ...mission, next_action: 'mission is closed' }, process.cwd(), 'mission_tick_skipped', { reason: mission.status });
1437
+ printJsonOrText({ ok: true, action: 'tick_skipped', mission: saved }, [`Skipped ${mission.id}: ${mission.status}`], asJson);
1438
+ return;
1439
+ }
1440
+
1441
+ // Per the /mission skill design, the calling Claude session IS the per-tick LLM.
1442
+ // This CLI subcommand records the tick: writes a structured receipt (matching the
1443
+ // `mission_run_tick` envelope) and runs the verifier when asked. Always emit a
1444
+ // receipt so every tick has its own audit row, not just verifier ticks.
1445
+ const cwd = process.cwd();
1446
+ const tickStart = stampIso();
1447
+ const lastTickIndex = Number(mission.last_tick_index || 0);
1448
+ const tickIdx = lastTickIndex + 1;
1449
+ const tickWorktreeBefore = gitWorktreeSnapshot(cwd);
1450
+
1451
+ let verifierResult = null;
1452
+ if (verify && mission.verifier) {
1453
+ verifierResult = runVerifier(mission.verifier);
1454
+ }
1455
+ const tickWorktree = worktreeReceipt(tickWorktreeBefore, gitWorktreeSnapshot(cwd), { verifier: mission.verifier });
1456
+
1457
+ const tickRecord = {
1458
+ status: 'ran',
1459
+ reason: 'tick-recorded',
1460
+ tick_index: tickIdx,
1461
+ ran: true,
1462
+ started_at: tickStart,
1463
+ claude: { skipped: true, reason: 'orchestrator-is-caller-session' },
1464
+ summary: summary || null,
1465
+ verifier_passed: verifierResult ? !!verifierResult.passed : null,
1466
+ finished_at: stampIso(),
1467
+ worktree: tickWorktree,
1468
+ };
1469
+ const receiptPath = writeReceipt(mission, {
1470
+ kind: 'mission_tick',
1471
+ tick: tickRecord,
1472
+ frozen: {
1473
+ verifier: mission.verifier || '',
1474
+ lane: mission.lane || 'workspace',
1475
+ started_at: tickStart,
1476
+ },
1477
+ verifier_result: verifierResult,
1478
+ rate_limit_info: null,
1479
+ worktree: tickWorktree,
1480
+ });
1481
+
1482
+ let status = 'running';
1483
+ let nextAction = mission.verifier ? `run verifier: ${mission.verifier}` : 'attach task, verifier, or proof';
1484
+ if (verifierResult?.passed) {
1485
+ status = (completeOnPass && !mission.always_on) ? 'complete' : 'ready';
1486
+ nextAction = mission.always_on ? nextCandidateTickAction(mission) :
1487
+ (completeOnPass ? 'mission complete' : `review proof then run: atris mission complete ${mission.id} --proof "${receiptPath}"`);
1488
+ } else if (verifierResult) {
1489
+ status = 'blocked';
1490
+ nextAction = 'fix verifier failure or revise mission';
1491
+ }
1492
+ const nextMission = {
1493
+ ...mission,
1494
+ status,
1495
+ receipt_path: receiptPath,
1496
+ last_tick_at: tickRecord.finished_at,
1497
+ last_tick_status: tickRecord.status,
1498
+ last_tick_reason: tickRecord.reason,
1499
+ last_tick_index: tickIdx,
1500
+ verifier_result: verifierResult || mission.verifier_result || null,
1501
+ next_action: nextAction,
1502
+ };
1503
+ const { mission: saved } = saveMission(nextMission, cwd, 'mission_tick', {
1504
+ tick_index: tickIdx, verify, verifier_result: verifierResult, receipt_path: receiptPath,
1505
+ });
1506
+ const logPath = appendMemberLog(saved.owner, 'Mission tick', {
1507
+ mission: saved.objective,
1508
+ state: saved.status,
1509
+ tick_index: tickIdx,
1510
+ verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
1511
+ receipt: receiptPath,
1512
+ summary: summary || undefined,
1513
+ });
1514
+ const codexGoalState = refreshCodexGoalController(process.cwd());
1515
+ printJsonOrText(
1516
+ { ok: true, action: 'mission_tick', mission: saved, tick: tickRecord, verifier_result: verifierResult, receipt_path: receiptPath, log_path: logPath, codex_goal_state: codexGoalState },
1517
+ [
1518
+ `Ticked mission: ${saved.objective}`,
1519
+ `State: ${saved.status}`,
1520
+ `Tick: ${tickIdx}`,
1521
+ `Next: ${saved.next_action}`,
1522
+ ...(receiptPath ? [`Receipt: ${receiptPath}`] : []),
1523
+ ],
1524
+ asJson,
1525
+ );
1526
+ } finally {
1527
+ releaseMissionLock(lock);
1528
+ }
1529
+ }
1530
+
1531
+ function completeMission(args) {
1532
+ const asJson = wantsJson(args);
1533
+ const proof = readFlag(args, '--proof', '');
1534
+ const ref = stripKnownFlags(args, ['--proof'], ['--json'])[0] || '';
1535
+ if (!ref || !proof) {
1536
+ exitMissionError('Usage: atris mission complete <id> --proof "..."', 1, asJson);
1537
+ }
1538
+ const mission = resolveMission(ref);
1539
+ if (!mission) {
1540
+ exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
1541
+ }
1542
+ const next = {
1543
+ ...mission,
1544
+ status: 'complete',
1545
+ completed_at: stampIso(),
1546
+ proof,
1547
+ next_action: 'mission complete',
1548
+ };
1549
+ const { mission: saved } = saveMission(next, process.cwd(), 'mission_completed', { proof });
1550
+ const logPath = appendMemberLog(saved.owner, 'Mission completed', { mission: saved.objective, proof });
1551
+ const codexGoalState = refreshCodexGoalController(process.cwd());
1552
+ printJsonOrText(
1553
+ { ok: true, action: 'mission_completed', mission: saved, log_path: logPath, codex_goal_state: codexGoalState },
1554
+ [`Completed mission: ${saved.objective}`, `Proof: ${proof}`],
1555
+ asJson,
1556
+ );
1557
+ }
1558
+
1559
+ function stopMission(args) {
1560
+ const asJson = wantsJson(args);
1561
+ const reason = readFlag(args, '--reason', 'stopped by operator');
1562
+ const pause = hasFlag(args, '--pause');
1563
+ const ref = stripKnownFlags(args, ['--reason'], ['--json', '--pause'])[0] || '';
1564
+ if (!ref) {
1565
+ exitMissionError('Usage: atris mission stop <id> [--pause] [--reason "..."]', 1, asJson);
1566
+ }
1567
+ const mission = resolveMission(ref);
1568
+ if (!mission) {
1569
+ exitMissionError(`Mission "${ref}" not found.`, 1, asJson);
1570
+ }
1571
+ const status = pause ? 'paused' : 'stopped';
1572
+ const next = {
1573
+ ...mission,
1574
+ status,
1575
+ stopped_at: status === 'stopped' ? stampIso() : mission.stopped_at || null,
1576
+ paused_at: status === 'paused' ? stampIso() : mission.paused_at || null,
1577
+ stop_reason: reason,
1578
+ next_action: status === 'paused' ? `resume with: atris mission tick ${mission.id}` : 'mission stopped',
1579
+ };
1580
+ const { mission: saved } = saveMission(next, process.cwd(), pause ? 'mission_paused' : 'mission_stopped', { reason });
1581
+ const logPath = appendMemberLog(saved.owner, pause ? 'Mission paused' : 'Mission stopped', { mission: saved.objective, reason });
1582
+ printJsonOrText(
1583
+ { ok: true, action: pause ? 'mission_paused' : 'mission_stopped', mission: saved, log_path: logPath },
1584
+ [`${pause ? 'Paused' : 'Stopped'} mission: ${saved.objective}`, `Reason: ${reason}`],
1585
+ asJson,
1586
+ );
1587
+ }
1588
+
1589
+ function goalMission(args) {
1590
+ const asJson = wantsJson(args);
1591
+ const heartbeatMode = hasFlag(args, '--heartbeat');
1592
+ const payload = refreshCodexGoalController(process.cwd(), { heartbeat: heartbeatMode });
1593
+ if (!payload.goal) {
1594
+ printJsonOrText(
1595
+ payload,
1596
+ ['No active mission found for Codex /goal.'],
1597
+ asJson,
1598
+ );
1599
+ return;
1600
+ }
1601
+
1602
+ printJsonOrText(
1603
+ payload,
1604
+ [
1605
+ `Codex /goal: ${payload.goal.objective}`,
1606
+ `Reason: ${payload.goal.reason}`,
1607
+ `Next: ${payload.goal.next_command}`,
1608
+ payload.goal.replace_after,
1609
+ ],
1610
+ asJson,
1611
+ );
1612
+ }
1613
+
1614
+ async function goalLoopMission(args) {
1615
+ const asJson = wantsJson(args);
1616
+ const noClaude = hasFlag(args, '--no-claude');
1617
+ const dryRun = hasFlag(args, '--dry-run');
1618
+ const once = hasFlag(args, '--once');
1619
+ const maxIterations = once ? 1 : Math.max(1, Number(readFlag(args, '--max-iterations', '')) || 32);
1620
+ const maxWallSeconds = Math.max(1, Number(readFlag(args, '--max-wall', '')) || 8 * 60 * 60);
1621
+ const root = process.cwd();
1622
+ const startedAt = Date.now();
1623
+ const events = [];
1624
+
1625
+ for (let index = 0; index < maxIterations; index += 1) {
1626
+ const heartbeat = refreshCodexGoalController(root, { heartbeat: true });
1627
+ const event = {
1628
+ iteration: index + 1,
1629
+ heartbeat,
1630
+ ran_heavy_work: false,
1631
+ dry_run: dryRun,
1632
+ };
1633
+
1634
+ if (heartbeat.heartbeat?.due && heartbeat.goal) {
1635
+ if (dryRun) {
1636
+ event.ran_heavy_work = false;
1637
+ event.run = { skipped: true, reason: 'dry-run', command: 'atris mission run --due --max-ticks 1 --complete-on-pass --json' };
1638
+ } else {
1639
+ event.run = runMissionRunDueOnce(root, { noClaude });
1640
+ event.ran_heavy_work = event.run.ok === true;
1641
+ event.after_run = refreshCodexGoalController(root, { heartbeat: true });
1642
+ }
1643
+ }
1644
+ events.push(event);
1645
+
1646
+ if (index + 1 >= maxIterations) break;
1647
+ const elapsedSeconds = (Date.now() - startedAt) / 1000;
1648
+ const remainingSeconds = maxWallSeconds - elapsedSeconds;
1649
+ if (remainingSeconds <= 0) break;
1650
+
1651
+ const sleepSeconds = Math.min(
1652
+ Number(event.after_run?.heartbeat?.recommended_sleep_seconds ?? event.heartbeat?.heartbeat?.recommended_sleep_seconds ?? 15) || 15,
1653
+ remainingSeconds,
1654
+ );
1655
+ if (sleepSeconds <= 0) continue;
1656
+ await sleep(sleepSeconds * 1000);
1657
+ }
1658
+
1659
+ const finalState = refreshCodexGoalController(root, { heartbeat: true });
1660
+ const payload = {
1661
+ ok: true,
1662
+ action: 'codex_goal_loop',
1663
+ iterations: events.length,
1664
+ max_iterations: maxIterations,
1665
+ max_wall_seconds: maxWallSeconds,
1666
+ heavy_runs: events.filter((event) => event.ran_heavy_work).length,
1667
+ events,
1668
+ final_state: finalState,
1669
+ };
1670
+ printJsonOrText(
1671
+ payload,
1672
+ [
1673
+ `Codex goal loop iterations: ${payload.iterations}`,
1674
+ `Heavy runs: ${payload.heavy_runs}`,
1675
+ `Final action: ${finalState.action}`,
1676
+ finalState.goal ? `Final mission: ${finalState.goal.mission_id}` : 'Final mission: none',
1677
+ ],
1678
+ asJson,
1679
+ );
1680
+ }
1681
+
1682
+ function help() {
1683
+ console.log(`
1684
+ atris mission - durable goal + loop + owner + proof state
1685
+
1686
+ atris mission start "<objective>" --owner <member> [--verify "..."] [--always-on]
1687
+ atris mission status [id] [--status <state>] [--limit <n>] [--json]
1688
+ atris mission goal [--heartbeat] [--json]
1689
+ atris mission goal-loop [--max-wall 28800] [--max-iterations 32] [--no-claude] [--json]
1690
+ atris mission tick <id> [--verify] [--complete-on-pass] [--summary "..."] [--json]
1691
+ atris mission run <id|--due> [--max-ticks 4] [--max-wall 3600] [--cadence "15m"]
1692
+ [--no-claude] [--no-verify] [--complete-on-pass] [--json]
1693
+ atris mission complete <id> --proof "..."
1694
+ atris mission stop <id> [--pause] [--reason "..."]
1695
+
1696
+ Autonomy recipe:
1697
+ 1. Pick an owner member: atris member create <member> (if missing)
1698
+ 2. Start a current-agent mission with a verifier:
1699
+ atris mission start "ship one proof" --owner <member> --runner codex_goal --lane code --verify "npm test" --stop "verifier passes"
1700
+ 3. Codex sessions: atris mission goal --json, then set /goal to goal.objective
1701
+ Overnight controller: atris mission goal --heartbeat --json
1702
+ Bounded overnight runner: atris mission goal-loop --max-wall 28800 --no-claude --json
1703
+ 4. Do one bounded step, then record it:
1704
+ atris mission tick <id> --verify --summary "what changed"
1705
+ 5. Close or continue from the receipt:
1706
+ atris mission complete <id> --proof "<receipt_path>"
1707
+ repeat status -> step -> tick for current-agent work
1708
+ atris mission run <id> --max-ticks 4 --complete-on-pass (Claude/always-on runner)
1709
+ atris mission run --due --max-ticks 1 --complete-on-pass (/loop heartbeat)
1710
+ Headless: start with --runner claude --cadence "15m" --always-on, then run.
1711
+
1712
+ Backend/web agents:
1713
+ In atrisos-backend and atrisos-web, check active missions before choosing work.
1714
+ If no active mission exists and autonomy was requested, create one with owner,
1715
+ verifier, lane, and stop condition before starting the loop.
1716
+
1717
+ Filters:
1718
+ --status active shows planning/running/ready/paused/blocked missions.
1719
+ --status complete|stopped|planning|running|ready|paused|blocked shows that exact state.
1720
+
1721
+ State:
1722
+ .atris/state/missions.jsonl
1723
+ .atris/state/mission_events.jsonl
1724
+ .atris/state/codex_goal.json
1725
+ atris/status/codex-goal.md
1726
+ atris/team/<owner>/MISSION.md
1727
+ atris/team/<owner>/now.md
1728
+ atris/status/now.md
1729
+ `.trim());
1730
+ }
1731
+
1732
+ function missionCommand(args) {
1733
+ const subcommand = args[0] || 'status';
1734
+ const rest = args.slice(1);
1735
+ switch (subcommand) {
1736
+ case 'start':
1737
+ case 'create':
1738
+ case 'new':
1739
+ return startMission(rest);
1740
+ case 'status':
1741
+ case 'list':
1742
+ case 'ls':
1743
+ return statusMission(rest);
1744
+ case 'goal':
1745
+ case 'codex-goal':
1746
+ return goalMission(rest);
1747
+ case 'goal-loop':
1748
+ case 'codex-goal-loop':
1749
+ return goalLoopMission(rest);
1750
+ case 'tick':
1751
+ return tickMission(rest);
1752
+ case 'run':
1753
+ return runMission(rest);
1754
+ case 'complete':
1755
+ case 'done':
1756
+ return completeMission(rest);
1757
+ case 'stop':
1758
+ case 'pause':
1759
+ return stopMission(subcommand === 'pause' ? ['--pause', ...rest] : rest);
1760
+ case 'help':
1761
+ case '--help':
1762
+ case '-h':
1763
+ return help();
1764
+ default:
1765
+ return help();
1766
+ }
1767
+ }
1768
+
1769
+ module.exports = {
1770
+ missionCommand,
1771
+ listMissions,
1772
+ loadMissionMap,
1773
+ renderMissionStatus,
1774
+ selectDueMission,
1775
+ selectCodexGoalMission,
1776
+ };