atris 3.30.8 → 3.31.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 (59) hide show
  1. package/AGENTS.md +16 -3
  2. package/FOR_AGENTS.md +81 -0
  3. package/README.md +4 -2
  4. package/atris/atris.md +51 -19
  5. package/atris/skills/README.md +1 -0
  6. package/atris/skills/blocks/SKILL.md +134 -0
  7. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
  8. package/atris/skills/youtube/SKILL.md +31 -11
  9. package/atris.md +7 -0
  10. package/ax +367 -93
  11. package/bin/atris.js +317 -155
  12. package/commands/autoland.js +319 -0
  13. package/commands/autopilot.js +94 -4
  14. package/commands/business.js +1 -1
  15. package/commands/clean.js +72 -9
  16. package/commands/codex-goal.js +72 -22
  17. package/commands/computer.js +48 -3
  18. package/commands/gm.js +1 -1
  19. package/commands/harvest.js +179 -0
  20. package/commands/init.js +1 -1
  21. package/commands/land.js +442 -0
  22. package/commands/loop-front.js +122 -4
  23. package/commands/member.js +519 -19
  24. package/commands/mission.js +3659 -282
  25. package/commands/play.js +1 -1
  26. package/commands/pulse.js +65 -7
  27. package/commands/run.js +3 -3
  28. package/commands/strings.js +301 -0
  29. package/commands/task.js +575 -102
  30. package/commands/truth.js +170 -0
  31. package/commands/xp.js +32 -8
  32. package/commands/youtube.js +72 -5
  33. package/decks/README.md +6 -12
  34. package/lib/auto-accept-certified.js +10 -0
  35. package/lib/autoland.js +283 -0
  36. package/lib/context-gatherer.js +0 -8
  37. package/lib/mission-artifact.js +504 -0
  38. package/lib/mission-room.js +846 -0
  39. package/lib/mission-runtime-loop.js +320 -0
  40. package/lib/next-moves.js +212 -6
  41. package/lib/pulse.js +74 -1
  42. package/lib/runner-command.js +20 -8
  43. package/lib/runs-prune.js +242 -0
  44. package/lib/task-proof.js +1 -1
  45. package/package.json +3 -3
  46. package/decks/atris-seed-pitch-v3.json +0 -118
  47. package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
  48. package/decks/atris-seed-pitch-v5.json +0 -109
  49. package/decks/atris-seed-pitch-v6.json +0 -137
  50. package/decks/atris-seed-pitch-v7.json +0 -133
  51. package/decks/mark-pincus-narrative.json +0 -102
  52. package/decks/mark-pincus-sourcery.json +0 -94
  53. package/decks/yash-applied-compute-detailed.json +0 -150
  54. package/decks/yash-applied-compute-generalist.json +0 -82
  55. package/decks/yash-applied-compute-narrative.json +0 -54
  56. package/lib/ax-chat-input.js +0 -164
  57. package/lib/ax-goal.js +0 -307
  58. package/lib/ax-prefs.js +0 -63
  59. package/lib/ax-shimmer.js +0 -63
@@ -0,0 +1,320 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn, spawnSync } = require('child_process');
4
+
5
+ function stripMissionQuotes(value) {
6
+ const text = String(value || '').trim();
7
+ if (text.length >= 2) {
8
+ const first = text[0];
9
+ const last = text[text.length - 1];
10
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
11
+ return text.slice(1, -1).trim();
12
+ }
13
+ }
14
+ return text;
15
+ }
16
+
17
+ function extractMissionFlag(text, flagName) {
18
+ const source = String(text || '');
19
+ const inline = new RegExp(`(^|\\s)${flagName}=([^\\s]+)(?=\\s|$)`).exec(source);
20
+ if (inline) {
21
+ return {
22
+ value: inline[2],
23
+ text: `${source.slice(0, inline.index)}${inline[1] || ''}${source.slice(inline.index + inline[0].length)}`.replace(/\s+/g, ' ').trim(),
24
+ };
25
+ }
26
+ const split = new RegExp(`(^|\\s)${flagName}\\s+([^\\s]+)(?=\\s|$)`).exec(source);
27
+ if (split) {
28
+ return {
29
+ value: split[2],
30
+ text: `${source.slice(0, split.index)}${split[1] || ''}${source.slice(split.index + split[0].length)}`.replace(/\s+/g, ' ').trim(),
31
+ };
32
+ }
33
+ return { value: null, text: source.trim() };
34
+ }
35
+
36
+ function stripBooleanFlag(text, flagName) {
37
+ return String(text || '').replace(new RegExp(`(^|\\s)${flagName}(?=\\s|$)`, 'g'), ' ').replace(/\s+/g, ' ').trim();
38
+ }
39
+
40
+ function hasBooleanFlag(text, flagName) {
41
+ return new RegExp(`(^|\\s)${flagName}(?=\\s|$)`).test(String(text || ''));
42
+ }
43
+
44
+ function missionRunIntentFromMessage(message) {
45
+ const text = String(message || '').trim();
46
+ const match = /^atris\s+mission\s+run(?:\s+([\s\S]+))?$/i.exec(text);
47
+ if (!match) return null;
48
+
49
+ let rest = String(match[1] || '').trim();
50
+ const ownerFlag = extractMissionFlag(rest, '--owner');
51
+ rest = ownerFlag.text;
52
+ const maxTicksFlag = extractMissionFlag(rest, '--max-ticks');
53
+ rest = maxTicksFlag.text;
54
+ const maxWallFlag = extractMissionFlag(rest, '--max-wall');
55
+ rest = maxWallFlag.text;
56
+ const cadenceFlag = extractMissionFlag(rest, '--cadence');
57
+ rest = cadenceFlag.text;
58
+ const noRun = hasBooleanFlag(rest, '--no-run') || hasBooleanFlag(rest, '--no-loop');
59
+ const noComplete = hasBooleanFlag(rest, '--no-complete');
60
+ rest = stripBooleanFlag(stripBooleanFlag(stripBooleanFlag(rest, '--no-run'), '--no-loop'), '--no-complete');
61
+ rest = rest.replace(/(^|\s)--json(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
62
+
63
+ const wantsLongRun = /\b(24\s*h|24\s*hours?|overnight|forever)\b/i.test(text);
64
+ return {
65
+ objective: stripMissionQuotes(rest),
66
+ owner: ownerFlag.value || process.env.ATRIS_AGENT_ID || 'mission-lead',
67
+ maxTicks: positiveInteger(maxTicksFlag.value, wantsLongRun ? 96 : 1),
68
+ maxWallSeconds: positiveInteger(maxWallFlag.value, wantsLongRun ? 86400 : 300),
69
+ cadence: cadenceFlag.value || (wantsLongRun ? '15m' : ''),
70
+ noRun,
71
+ noComplete,
72
+ };
73
+ }
74
+
75
+ function positiveInteger(value, fallback) {
76
+ const parsed = Number(value);
77
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
78
+ return fallback;
79
+ }
80
+
81
+ function parseJson(stdout) {
82
+ const text = String(stdout || '').trim();
83
+ if (!text) return null;
84
+ return JSON.parse(text);
85
+ }
86
+
87
+ function runCliJsonSync(cliPath, args, options = {}) {
88
+ const result = spawnSync(process.execPath, [cliPath, ...args], {
89
+ cwd: options.cwd || process.cwd(),
90
+ encoding: 'utf8',
91
+ timeout: options.timeoutMs || 30000,
92
+ env: {
93
+ ...process.env,
94
+ ATRIS_SKIP_UPDATE_CHECK: '1',
95
+ ...(options.env || {}),
96
+ },
97
+ });
98
+ if (result.error) {
99
+ return { ok: false, status: 1, error: result.error.message || String(result.error), stdout: result.stdout || '', stderr: result.stderr || '' };
100
+ }
101
+ let payload = null;
102
+ try { payload = parseJson(result.stdout); } catch (error) {
103
+ return { ok: false, status: result.status || 1, error: `invalid JSON: ${error.message}`, stdout: result.stdout || '', stderr: result.stderr || '' };
104
+ }
105
+ return { ok: result.status === 0 && payload?.ok !== false, status: result.status || 0, payload, stdout: result.stdout || '', stderr: result.stderr || '' };
106
+ }
107
+
108
+ function runCliJsonStreaming(cliPath, args, options = {}) {
109
+ return new Promise((resolve) => {
110
+ const startedAt = Date.now();
111
+ const proc = spawn(process.execPath, [cliPath, ...args], {
112
+ cwd: options.cwd || process.cwd(),
113
+ env: {
114
+ ...process.env,
115
+ ATRIS_SKIP_UPDATE_CHECK: '1',
116
+ ...(options.env || {}),
117
+ },
118
+ stdio: ['ignore', 'pipe', 'pipe'],
119
+ });
120
+ let stdout = '';
121
+ let stderr = '';
122
+ let done = false;
123
+ let killedByTimeout = false;
124
+ let progressTimer = null;
125
+ const finish = (status) => {
126
+ if (done) return;
127
+ done = true;
128
+ clearTimeout(timer);
129
+ if (progressTimer) clearInterval(progressTimer);
130
+ let payload = null;
131
+ let error = '';
132
+ try { payload = parseJson(stdout); } catch (err) { error = `invalid JSON: ${err.message}`; }
133
+ resolve({
134
+ ok: status === 0 && !error && payload?.ok !== false,
135
+ status: status || 0,
136
+ payload,
137
+ stdout,
138
+ stderr,
139
+ error: killedByTimeout ? 'timeout' : error,
140
+ });
141
+ };
142
+ const timer = setTimeout(() => {
143
+ killedByTimeout = true;
144
+ try { proc.kill('SIGTERM'); } catch {}
145
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 3000).unref();
146
+ }, options.timeoutMs || 660000);
147
+ progressTimer = options.onProgress
148
+ ? setInterval(() => {
149
+ options.onProgress(`still_running ${Math.max(1, Math.round((Date.now() - startedAt) / 1000))}`);
150
+ }, options.progressEveryMs || 15000)
151
+ : null;
152
+ if (progressTimer?.unref) progressTimer.unref();
153
+ proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
154
+ proc.stderr.on('data', (chunk) => {
155
+ const text = chunk.toString();
156
+ stderr += text;
157
+ if (options.onProgress) {
158
+ for (const line of text.split(/\r?\n/).map((item) => item.trim()).filter(Boolean)) {
159
+ options.onProgress(line);
160
+ }
161
+ }
162
+ });
163
+ proc.on('error', (error) => {
164
+ stderr += error.message || String(error);
165
+ finish(1);
166
+ });
167
+ proc.on('close', finish);
168
+ });
169
+ }
170
+
171
+ function missionGoalFromPayload(payload) {
172
+ const mission = payload?.mission || {};
173
+ const goal = payload?.atris_goal_state?.goal || {};
174
+ return {
175
+ objective: goal.objective || mission.objective,
176
+ mission_id: goal.mission_id || mission.id,
177
+ mission_status: goal.mission_status || mission.status,
178
+ runner: goal.runner || mission.runner || 'atris2',
179
+ next_command: goal.next_command || payload?.next_command || mission.next_action,
180
+ };
181
+ }
182
+
183
+ function firstUsefulLine(text, fallback = '') {
184
+ return String(text || '')
185
+ .split(/\r?\n/)
186
+ .map((line) => line.replace(/^[-*\s#]+/, '').trim())
187
+ .filter(Boolean)
188
+ .find((line) => !/^(receipt|summary|final|final answer|result)$/i.test(line))
189
+ || fallback;
190
+ }
191
+
192
+ function lastTickFromRun(runPayload) {
193
+ const ticks = Array.isArray(runPayload?.ticks) ? runPayload.ticks : [];
194
+ return ticks[ticks.length - 1] || null;
195
+ }
196
+
197
+ function workerLine(runPayload) {
198
+ const tick = lastTickFromRun(runPayload);
199
+ if (!tick) return '';
200
+ if (tick.atris2) return firstUsefulLine(tick.atris2.receipt_text, tick.atris2.ok ? 'Atris2 worker ran.' : 'Atris2 worker failed.');
201
+ if (tick.claude) return firstUsefulLine(tick.claude.receipt_text || tick.summary, 'Worker tick recorded.');
202
+ return tick.summary || tick.reason || '';
203
+ }
204
+
205
+ function formatRuntimeMissionLoopReceipt(result) {
206
+ const lines = [];
207
+ const goal = missionGoalFromPayload(result.start?.payload || {});
208
+ if (result.start?.payload) {
209
+ lines.push('Atris mission started');
210
+ lines.push(`Goal: ${goal.objective || '?'}`);
211
+ lines.push(`Mission: ${goal.mission_id || '?'} - ${goal.mission_status || '?'} - ${goal.runner || 'atris2'}`);
212
+ }
213
+ if (result.noRun) {
214
+ lines.push('Pursuing: not run (--no-run)');
215
+ lines.push('Achieved: no');
216
+ if (goal.next_command) lines.push(`Next: ${goal.next_command}`);
217
+ return lines.join('\n');
218
+ }
219
+ lines.push('Atris mission pursued');
220
+ if (result.run?.payload) {
221
+ const ran = Number(result.run.payload.ran_ticks || 0);
222
+ const total = Number(result.run.payload.tick_count || 0);
223
+ lines.push(`Ticks: ${ran}/${total}`);
224
+ const summary = workerLine(result.run.payload);
225
+ if (summary) lines.push(`Work: ${summary}`);
226
+ }
227
+ lines.push(`Achieved: ${result.achieved ? 'yes' : 'no'}`);
228
+ if (result.complete?.payload?.landing?.happened) lines.push(`Landing: ${result.complete.payload.landing.happened}`);
229
+ else if (result.run?.payload?.pause_reason) lines.push(`Blocked: ${result.run.payload.pause_reason}`);
230
+ else if (result.error) lines.push(`Blocked: ${result.error}`);
231
+ const proof = result.complete?.payload?.mission?.proof || result.run?.payload?.summary_receipt || result.start?.payload?.state_path;
232
+ if (proof) lines.push(`Proof: ${proof}`);
233
+ const nextGoal = result.complete?.payload?.continuation_goal?.mission?.objective;
234
+ if (nextGoal) lines.push(`Next goal: ${nextGoal}`);
235
+ const nextCommand = result.complete?.payload?.atris_goal_state?.goal?.next_command || result.run?.payload?.atris_goal_state?.goal?.next_command || goal.next_command;
236
+ if (nextCommand && !nextGoal) lines.push(`Next: ${nextCommand}`);
237
+ return lines.join('\n');
238
+ }
239
+
240
+ async function runRuntimeMissionLoop(intent, options = {}) {
241
+ const cliPath = options.cliPath;
242
+ const cwd = options.cwd || process.cwd();
243
+ if (!cliPath) return { ok: false, status: 1, error: 'missing cliPath', text: 'Blocked: missing cliPath' };
244
+ if (!intent?.objective) return { ok: false, status: 1, error: 'missing objective', text: 'Usage: atris mission run "<objective>"' };
245
+
246
+ const startArgs = ['mission', 'run', intent.objective, '--runner', 'atris2', '--owner', intent.owner || 'mission-lead', '--json'];
247
+ if (intent.cadence) startArgs.push('--cadence', intent.cadence);
248
+ const start = runCliJsonSync(cliPath, startArgs, { cwd, timeoutMs: 30000 });
249
+ const result = { ok: start.ok, status: start.status, start, noRun: intent.noRun === true, achieved: false };
250
+ if (!start.ok || !start.payload?.mission?.id) {
251
+ result.error = start.error || start.payload?.error || 'mission start failed';
252
+ result.text = formatRuntimeMissionLoopReceipt(result);
253
+ return result;
254
+ }
255
+ if (intent.noRun) {
256
+ result.text = formatRuntimeMissionLoopReceipt(result);
257
+ return result;
258
+ }
259
+
260
+ if (options.onProgress) options.onProgress(`pursuing ${start.payload.mission.id}`);
261
+ const maxWallSeconds = positiveInteger(intent.maxWallSeconds, 600);
262
+ const run = await runCliJsonStreaming(
263
+ cliPath,
264
+ ['mission', 'run', start.payload.mission.id, '--max-ticks', String(positiveInteger(intent.maxTicks, 1)), '--max-wall', String(maxWallSeconds), '--complete-on-pass', '--json'],
265
+ {
266
+ cwd,
267
+ timeoutMs: (maxWallSeconds + 60) * 1000,
268
+ onProgress: options.onProgress,
269
+ },
270
+ );
271
+ result.run = run;
272
+ if (!run.ok) {
273
+ result.ok = false;
274
+ result.status = run.status || 1;
275
+ result.error = run.error || run.payload?.error || 'mission run failed';
276
+ result.text = formatRuntimeMissionLoopReceipt(result);
277
+ return result;
278
+ }
279
+
280
+ if (run.payload?.mission?.status === 'complete') {
281
+ result.achieved = true;
282
+ result.ok = true;
283
+ result.text = formatRuntimeMissionLoopReceipt(result);
284
+ return result;
285
+ }
286
+
287
+ const mission = run.payload?.mission || {};
288
+ const ranTicks = Number(run.payload?.ran_ticks || 0);
289
+ const proof = run.payload?.summary_receipt || mission.receipt_path;
290
+ if (!intent.noComplete && ranTicks > 0 && !String(mission.verifier || '').trim() && proof) {
291
+ if (options.onProgress) options.onProgress(`landing ${mission.id}`);
292
+ const complete = runCliJsonSync(cliPath, ['mission', 'complete', mission.id, '--proof', proof, '--json'], { cwd, timeoutMs: 30000 });
293
+ result.complete = complete;
294
+ result.achieved = complete.ok && complete.payload?.action === 'mission_completed';
295
+ result.ok = result.achieved;
296
+ if (!complete.ok) result.error = complete.error || complete.payload?.error || 'mission complete failed';
297
+ } else {
298
+ result.ok = false;
299
+ result.error = run.payload?.pause_reason || (ranTicks > 0 ? 'mission still running' : 'no tick proof');
300
+ }
301
+ result.text = formatRuntimeMissionLoopReceipt(result);
302
+ return result;
303
+ }
304
+
305
+ function readAtrisGoalState(cwd = process.cwd()) {
306
+ try {
307
+ const file = path.join(cwd, '.atris', 'state', 'atris_goal.json');
308
+ if (!fs.existsSync(file)) return null;
309
+ return JSON.parse(fs.readFileSync(file, 'utf8'))?.goal || null;
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ module.exports = {
316
+ missionRunIntentFromMessage,
317
+ runRuntimeMissionLoop,
318
+ formatRuntimeMissionLoopReceipt,
319
+ readAtrisGoalState,
320
+ };
package/lib/next-moves.js CHANGED
@@ -21,6 +21,27 @@ function norm(title) {
21
21
  return String(title == null ? '' : title).trim().toLowerCase();
22
22
  }
23
23
 
24
+ function titleKey(title) {
25
+ return norm(String(title == null ? '' : title).replace(/\s+/g, ' ').replace(/^title\s*:\s*/i, ''));
26
+ }
27
+
28
+ function isGenericInboxPlaceholder(title) {
29
+ const key = titleKey(title);
30
+ return /^(dogfood tick|autopilot tick|mission tick|run (one )?tick|tick|test idea|sample idea|placeholder idea|idea|todo|tbd)$/.test(key);
31
+ }
32
+
33
+ function cleanInboxMoveTitle(title) {
34
+ const raw = String(title == null ? '' : title).trim();
35
+ if (!raw) return null;
36
+ const titleLine = raw.match(/^title\s*:\s*(.+)$/i);
37
+ if (/^(task|status|action|tag|member|actor|proof|verify|checked|saved|changed|result|files?|commands?)\s*:/i.test(raw)) {
38
+ return null;
39
+ }
40
+ const cleaned = titleLine ? titleLine[1].trim() : raw;
41
+ if (isGenericInboxPlaceholder(cleaned)) return null;
42
+ return cleaned;
43
+ }
44
+
24
45
  // Short, stable id so a kill on Tuesday still suppresses the same move on
25
46
  // Wednesday. Pure function of (source, title).
26
47
  function moveId(source, title) {
@@ -33,7 +54,8 @@ function moveId(source, title) {
33
54
  return `m_${(h >>> 0).toString(36)}`;
34
55
  }
35
56
 
36
- const WEIGHT = { roadmap: 100, task: 60, inbox: 40 };
57
+ const WEIGHT = { roadmap: 100, mission: 90, endgame: 80, task: 60, inbox: 40 };
58
+ const SELF_IMPROVEMENT_SEED_TITLE = 'Create the next proof-backed self-improvement task';
37
59
 
38
60
  // One section-boundary shape for every reader and writer: tolerate a header
39
61
  // suffix (e.g. "(priority)"), stop at a sibling/parent heading (## or #) or a
@@ -93,17 +115,119 @@ function readActiveTasks(root) {
93
115
  const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
94
116
  return tasks
95
117
  .filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
118
+ .filter((t) => !isInternalNextMoveTask(t))
96
119
  .map((t) => ({
97
120
  title: String(t.title).trim(),
98
121
  why: `task in flight (${t.status}${t.claimed_by ? `, ${t.claimed_by}` : ''})`,
99
122
  source: 'task',
123
+ task_id: t.id || null,
100
124
  ref: t.display_id || t.id || null,
101
125
  weight: WEIGHT.task,
102
126
  }));
103
127
  }
104
128
 
129
+ function isInternalNextMoveTask(task) {
130
+ const title = String(task?.title || '').trim();
131
+ const tags = [task?.tag, ...(Array.isArray(task?.tags) ? task.tags : [])]
132
+ .filter(Boolean)
133
+ .map((tag) => String(tag).toLowerCase());
134
+ return tags.includes('agent-xp') || /^mission xp\s*:/i.test(title);
135
+ }
136
+
137
+ function readHandledTaskTitles(root) {
138
+ const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
139
+ if (!text) return [];
140
+ let proj;
141
+ try { proj = JSON.parse(text); } catch { return []; }
142
+ const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
143
+ return tasks
144
+ .filter((t) => t && t.title && ['review', 'done'].includes(String(t.status || '').toLowerCase()))
145
+ .map((t) => String(t.title).trim())
146
+ .filter(Boolean);
147
+ }
148
+
149
+ function readJsonLines(file) {
150
+ const text = safeRead(file);
151
+ if (!text) return [];
152
+ return text.split(/\r?\n/)
153
+ .map((line) => line.trim())
154
+ .filter(Boolean)
155
+ .map((line) => {
156
+ try { return JSON.parse(line); } catch { return null; }
157
+ })
158
+ .filter(Boolean);
159
+ }
160
+
161
+ function readActiveMissions(root) {
162
+ const rows = readJsonLines(path.join(root, '.atris', 'state', 'missions.jsonl'));
163
+ const latest = new Map();
164
+ for (const row of rows) {
165
+ if (row && row.id) latest.set(row.id, row);
166
+ }
167
+ return Array.from(latest.values())
168
+ .filter((m) => m && m.objective && !['complete', 'stopped', 'paused'].includes(String(m.status || '').toLowerCase()))
169
+ .map((m) => ({
170
+ title: String(m.objective).trim(),
171
+ why: `active mission (${m.status || 'unknown'}${m.owner ? `, ${m.owner}` : ''})`,
172
+ source: 'mission',
173
+ ref: m.id || null,
174
+ weight: m.status === 'blocked' ? WEIGHT.mission + 5 : WEIGHT.mission,
175
+ }));
176
+ }
177
+
178
+ function activeEndgameTaskCount(root) {
179
+ const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
180
+ if (!text) return 0;
181
+ let proj;
182
+ try { proj = JSON.parse(text); } catch { return 0; }
183
+ const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
184
+ return tasks.filter((t) => {
185
+ if (!t) return false;
186
+ const status = String(t.status || '').toLowerCase();
187
+ const tags = [t.tag, ...(Array.isArray(t.tags) ? t.tags : [])].filter(Boolean).map((tag) => String(tag).toLowerCase());
188
+ return tags.includes('endgame') && ['open', 'claimed', 'review'].includes(status);
189
+ }).length;
190
+ }
191
+
192
+ function todoKeepsEndgameActive(root) {
193
+ const text = safeRead(path.join(root, 'atris', 'TODO.md'));
194
+ if (!text) return true;
195
+ if (activeEndgameTaskCount(root) > 0) return true;
196
+
197
+ let sawEndgameHeader = false;
198
+ let section = '';
199
+ for (const raw of text.split(/\r?\n/)) {
200
+ const heading = raw.match(/^##\s+(.+?)\s*$/);
201
+ if (heading) {
202
+ section = heading[1].trim().toLowerCase();
203
+ if (section === 'endgame') sawEndgameHeader = true;
204
+ continue;
205
+ }
206
+ if (!/\[endgame\]/i.test(raw)) continue;
207
+ if (['backlog', 'in progress', 'review'].includes(section)) return true;
208
+ }
209
+ return !sawEndgameHeader;
210
+ }
211
+
212
+ function readEndgameMove(root) {
213
+ if (!todoKeepsEndgameActive(root)) return [];
214
+ let state = null;
215
+ try { state = JSON.parse(safeRead(path.join(root, 'atris', 'brain', 'state.json'))); } catch { state = null; }
216
+ const horizon = String(state?.endgame?.horizon || '').trim();
217
+ if (!horizon) return [];
218
+ return [{
219
+ title: horizon,
220
+ why: state?.endgame?.source ? `endgame: ${state.endgame.source}` : 'endgame horizon from compiled brain',
221
+ source: 'endgame',
222
+ weight: WEIGHT.endgame,
223
+ }];
224
+ }
225
+
105
226
  function inboxItemsFrom(text) {
106
- return parseInboxTitles(text).map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
227
+ return parseInboxTitles(text)
228
+ .map(cleanInboxMoveTitle)
229
+ .filter(Boolean)
230
+ .map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
107
231
  }
108
232
 
109
233
  // Most recent journal under atris/logs/YYYY/, parsed for ## Inbox items. Used to
@@ -132,6 +256,8 @@ function todayInboxItems(root) {
132
256
  function gatherCandidates(root = process.cwd()) {
133
257
  return [
134
258
  ...readRoadmapOpenItems(root),
259
+ ...readActiveMissions(root),
260
+ ...readEndgameMove(root),
135
261
  ...readActiveTasks(root),
136
262
  ...latestInboxItems(root),
137
263
  ];
@@ -144,16 +270,25 @@ function gatherCandidates(root = process.cwd()) {
144
270
  // only suppressed for IDEAS (roadmap/inbox), never for a real `task`, so killing
145
271
  // or approving an idea can't hide a genuine in-flight task that happens to share
146
272
  // the title.
147
- function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [], approvedIds = [], approvedTitles = [] } = {}) {
273
+ function pickNextMoves(candidates, {
274
+ limit = 3,
275
+ killedIds = [],
276
+ killedTitles = [],
277
+ approvedIds = [],
278
+ approvedTitles = [],
279
+ handledTaskTitles = [],
280
+ } = {}) {
148
281
  const blockedIds = new Set([...killedIds, ...approvedIds]);
149
- const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(norm));
282
+ const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(titleKey));
283
+ const handledTitles = new Set(handledTaskTitles.map(titleKey));
150
284
  const seen = new Set();
151
285
  return candidates
152
- .map((c) => ({ ...c, id: moveId(c.source, c.title), _key: norm(c.title) }))
286
+ .map((c) => ({ ...c, id: moveId(c.source, c.title), _key: titleKey(c.title) }))
153
287
  .filter((c) => {
154
288
  if (!c._key) return false;
155
289
  if (blockedIds.has(c.id)) return false;
156
290
  if (c.source !== 'task' && blockedTitles.has(c._key)) return false;
291
+ if (c.source !== 'task' && handledTitles.has(c._key)) return false;
157
292
  return true;
158
293
  })
159
294
  .sort((a, b) => (b.weight || 0) - (a.weight || 0))
@@ -166,6 +301,63 @@ function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [
166
301
  .slice(0, limit);
167
302
  }
168
303
 
304
+ function cleanSuggestedTargetTitle(text) {
305
+ const clean = String(text || '')
306
+ .replace(/[`*_#>]+/g, '')
307
+ .replace(/\s+/g, ' ')
308
+ .trim()
309
+ .replace(/[.!?:;,]+$/g, '');
310
+ if (!clean) return '';
311
+ return clean.charAt(0).toUpperCase() + clean.slice(1);
312
+ }
313
+
314
+ function suggestedTargetFromReportText(text) {
315
+ const lines = String(text || '').split(/\r?\n/);
316
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
317
+ const match = lines[index].match(/^\s*Suggested target:\s*(.+?)\s*$/i);
318
+ if (match) return cleanSuggestedTargetTitle(match[1]);
319
+ }
320
+ return '';
321
+ }
322
+
323
+ function latestSuggestedTarget(root = process.cwd()) {
324
+ const reportsDir = path.join(root, 'atris', 'reports');
325
+ let files;
326
+ try {
327
+ files = fs.readdirSync(reportsDir).filter((file) => /\.md$/i.test(file)).sort().reverse();
328
+ } catch {
329
+ return '';
330
+ }
331
+ for (const file of files) {
332
+ const target = suggestedTargetFromReportText(safeRead(path.join(reportsDir, file)));
333
+ if (target) return target;
334
+ }
335
+ return '';
336
+ }
337
+
338
+ function addMissionOnlyFallback(moves, limit = 3, root = process.cwd()) {
339
+ if (!Array.isArray(moves) || moves.length !== 1 || limit <= 1) return moves;
340
+ const mission = moves[0];
341
+ if (!mission || mission.source !== 'mission') return moves;
342
+ const suggested = latestSuggestedTarget(root);
343
+ const target = suggested && !isGenericInboxPlaceholder(suggested)
344
+ ? suggested
345
+ : SELF_IMPROVEMENT_SEED_TITLE;
346
+ return [
347
+ mission,
348
+ {
349
+ title: target,
350
+ why: target === SELF_IMPROVEMENT_SEED_TITLE
351
+ ? 'active mission has no concrete task queued'
352
+ : 'latest proof timeline suggested this self-improvement target',
353
+ source: 'mission',
354
+ ref: mission.ref || null,
355
+ weight: WEIGHT.mission - 35,
356
+ id: moveId('mission', target),
357
+ },
358
+ ].slice(0, limit);
359
+ }
360
+
169
361
  const DECISIONS_FILE = ['.atris', 'state', 'moves.decisions.jsonl'];
170
362
 
171
363
  function decisionsPath(root) {
@@ -336,20 +528,34 @@ function pickRoadmapSeed(root = process.cwd()) {
336
528
  // so the ranking inputs live in one place.
337
529
  function nextMoves(root = process.cwd(), limit = 3) {
338
530
  const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
339
- return pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles });
531
+ const handledTaskTitles = readHandledTaskTitles(root);
532
+ const picked = pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles, handledTaskTitles });
533
+ return addMissionOnlyFallback(picked, limit, root);
340
534
  }
341
535
 
342
536
  module.exports = {
343
537
  moveId,
344
538
  norm,
539
+ titleKey,
540
+ isGenericInboxPlaceholder,
345
541
  WEIGHT,
542
+ SELF_IMPROVEMENT_SEED_TITLE,
543
+ cleanSuggestedTargetTitle,
544
+ suggestedTargetFromReportText,
545
+ latestSuggestedTarget,
346
546
  parseInboxTitles,
547
+ cleanInboxMoveTitle,
347
548
  readRoadmapOpenItems,
549
+ readActiveMissions,
550
+ readEndgameMove,
348
551
  readActiveTasks,
552
+ isInternalNextMoveTask,
553
+ readHandledTaskTitles,
349
554
  latestInboxItems,
350
555
  todayInboxItems,
351
556
  gatherCandidates,
352
557
  pickNextMoves,
558
+ addMissionOnlyFallback,
353
559
  nextMoves,
354
560
  readDecisions,
355
561
  recordDecision,