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,442 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawnSync } = require('child_process');
4
+
5
+ const { listWorktrees, statusCounts } = require('./worktree');
6
+
7
+ const DEFAULT_TTL_DAYS = 7;
8
+ const PROTECTED_BRANCHES = new Set(['main', 'master']);
9
+
10
+ function runGit(args, { cwd = process.cwd(), check = true } = {}) {
11
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
12
+ if (check && result.status !== 0) {
13
+ throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
14
+ }
15
+ return result;
16
+ }
17
+
18
+ function repoRoot(cwd = process.cwd()) {
19
+ const result = runGit(['rev-parse', '--show-toplevel'], { cwd, check: false });
20
+ if (result.status !== 0) return '';
21
+ return result.stdout.trim();
22
+ }
23
+
24
+ function refExists(root, ref) {
25
+ return runGit(['rev-parse', '--verify', '--quiet', ref], { cwd: root, check: false }).status === 0;
26
+ }
27
+
28
+ function baseBranch(root, override = '') {
29
+ if (override && refExists(root, override)) return override;
30
+ if (refExists(root, 'master')) return 'master';
31
+ if (refExists(root, 'main')) return 'main';
32
+ return '';
33
+ }
34
+
35
+ function ageDays(unixSeconds, now = Date.now()) {
36
+ return Math.floor((now / 1000 - Number(unixSeconds)) / 86400);
37
+ }
38
+
39
+ function listBranches(root) {
40
+ const result = runGit(
41
+ ['for-each-ref', 'refs/heads', '--format=%(refname:short)%09%(committerdate:unix)'],
42
+ { cwd: root, check: false }
43
+ );
44
+ if (result.status !== 0) return [];
45
+ return result.stdout
46
+ .split(/\r?\n/)
47
+ .filter(Boolean)
48
+ .map((line) => {
49
+ const [name, date] = line.split('\t');
50
+ return { name, lastCommitUnix: Number(date) };
51
+ });
52
+ }
53
+
54
+ function aheadCount(root, base, ref) {
55
+ const result = runGit(['rev-list', '--count', `${base}..${ref}`], { cwd: root, check: false });
56
+ if (result.status !== 0) return 0;
57
+ return Number(result.stdout.trim()) || 0;
58
+ }
59
+
60
+ // Board: every branch and worktree with unlanded state, classified.
61
+ // landed — no commits ahead of base; the branch pointer is residue
62
+ // active — has unlanded commits, younger than TTL
63
+ // due — has unlanded commits, older than TTL; --reap collects it
64
+ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '' } = {}) {
65
+ const base = baseBranch(root, baseOverride);
66
+ if (!base) throw new Error('no master/main branch found');
67
+
68
+ const branches = [];
69
+ for (const branch of listBranches(root)) {
70
+ if (PROTECTED_BRANCHES.has(branch.name)) continue;
71
+ const ahead = aheadCount(root, base, branch.name);
72
+ const age = ageDays(branch.lastCommitUnix);
73
+ let state = 'active';
74
+ if (ahead === 0) state = 'landed';
75
+ else if (age > ttlDays) state = 'due';
76
+ branches.push({ name: branch.name, ahead, ageDays: age, state });
77
+ }
78
+
79
+ const worktrees = [];
80
+ const all = listWorktrees(root);
81
+ for (const wt of all.slice(1)) {
82
+ const branch = (wt.branch || '').replace(/^refs\/heads\//, '');
83
+ const counts = statusCounts(wt.path) || { staged: 0, unstaged: 0, untracked: 0 };
84
+ const dirty = counts.staged + counts.unstaged + counts.untracked;
85
+ const entry = branches.find((b) => b.name === branch) || null;
86
+ worktrees.push({
87
+ path: wt.path,
88
+ branch,
89
+ dirty,
90
+ ageDays: entry ? entry.ageDays : null,
91
+ state: entry ? entry.state : 'detached',
92
+ });
93
+ }
94
+
95
+ const due = branches.filter((b) => b.state === 'due');
96
+ const landed = branches.filter((b) => b.state === 'landed');
97
+ const active = branches.filter((b) => b.state === 'active');
98
+ return {
99
+ base,
100
+ ttlDays,
101
+ branches,
102
+ worktrees,
103
+ summary: {
104
+ unlanded: branches.length,
105
+ active: active.length,
106
+ due: due.length,
107
+ landed: landed.length,
108
+ worktrees: worktrees.length,
109
+ },
110
+ };
111
+ }
112
+
113
+ // Fast counts for the boot banner: no per-branch ahead checks.
114
+ function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
115
+ const root = repoRoot(cwd);
116
+ if (!root) return null;
117
+ let branches = 0;
118
+ let due = 0;
119
+ for (const branch of listBranches(root)) {
120
+ if (PROTECTED_BRANCHES.has(branch.name)) continue;
121
+ branches += 1;
122
+ if (ageDays(branch.lastCommitUnix) > ttlDays) due += 1;
123
+ }
124
+ return { branches, due, ttlDays };
125
+ }
126
+
127
+ function salvageDir(root) {
128
+ const stamp = new Date().toISOString().slice(0, 10);
129
+ const dir = path.join(root, '.atris', 'salvage', stamp);
130
+ fs.mkdirSync(dir, { recursive: true });
131
+ return dir;
132
+ }
133
+
134
+ function timeStamp() {
135
+ return new Date().toISOString().slice(11, 19).replace(/:/g, '');
136
+ }
137
+
138
+ function remoteHeads(root) {
139
+ const result = runGit(['ls-remote', '--heads', 'origin'], { cwd: root, check: false });
140
+ if (result.status !== 0) return null;
141
+ const heads = new Set();
142
+ for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
143
+ const ref = line.split(/\s+/)[1] || '';
144
+ if (ref.startsWith('refs/heads/')) heads.add(ref.slice('refs/heads/'.length));
145
+ }
146
+ return heads;
147
+ }
148
+
149
+ // Reap: salvage then delete everything landed (residue) or past TTL.
150
+ // Salvage first, always: unlanded commits go into a git bundle, dirty
151
+ // worktrees into patch files, so a reap is never a loss of work.
152
+ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRun = false, remote = true } = {}) {
153
+ const board = collectBoard(root, { ttlDays, base: baseOverride });
154
+ const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
155
+ const targetNames = new Set(targets.map((b) => b.name));
156
+ const worktreeTargets = board.worktrees.filter(
157
+ (w) => targetNames.has(w.branch) || w.state === 'detached' || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
158
+ );
159
+ for (const w of worktreeTargets) {
160
+ if (w.branch && !targetNames.has(w.branch)) targetNames.add(w.branch);
161
+ }
162
+
163
+ const receipt = {
164
+ base: board.base,
165
+ ttlDays,
166
+ dryRun,
167
+ bundle: null,
168
+ patches: [],
169
+ removedWorktrees: [],
170
+ deletedBranches: [],
171
+ deletedRemote: [],
172
+ kept: board.branches.filter((b) => !targetNames.has(b.name)).map((b) => b.name),
173
+ };
174
+ if (targetNames.size === 0 && worktreeTargets.length === 0) return receipt;
175
+ if (dryRun) {
176
+ receipt.removedWorktrees = worktreeTargets.map((w) => w.path);
177
+ receipt.deletedBranches = [...targetNames];
178
+ return receipt;
179
+ }
180
+
181
+ const dir = salvageDir(root);
182
+ const withCommits = [...targetNames].filter((name) => {
183
+ const entry = board.branches.find((b) => b.name === name);
184
+ return entry && entry.ahead > 0;
185
+ });
186
+ if (withCommits.length > 0) {
187
+ const bundlePath = path.join(dir, `reap-${timeStamp()}.bundle`);
188
+ const result = runGit(['bundle', 'create', bundlePath, ...withCommits, '--not', board.base], {
189
+ cwd: root,
190
+ check: false,
191
+ });
192
+ if (result.status === 0) receipt.bundle = bundlePath;
193
+ }
194
+
195
+ for (const w of worktreeTargets) {
196
+ if (w.dirty > 0) {
197
+ const diff = runGit(['diff'], { cwd: w.path, check: false });
198
+ if (diff.status === 0 && diff.stdout.trim()) {
199
+ const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
200
+ fs.writeFileSync(patchPath, diff.stdout);
201
+ receipt.patches.push(patchPath);
202
+ }
203
+ }
204
+ const removed = runGit(['worktree', 'remove', '--force', w.path], { cwd: root, check: false });
205
+ if (removed.status === 0) receipt.removedWorktrees.push(w.path);
206
+ }
207
+
208
+ for (const name of targetNames) {
209
+ const deleted = runGit(['branch', '-D', name], { cwd: root, check: false });
210
+ if (deleted.status === 0) receipt.deletedBranches.push(name);
211
+ }
212
+
213
+ if (remote && receipt.deletedBranches.length > 0) {
214
+ const heads = remoteHeads(root);
215
+ if (heads) {
216
+ const remoteTargets = receipt.deletedBranches.filter((name) => heads.has(name));
217
+ for (let i = 0; i < remoteTargets.length; i += 25) {
218
+ const batch = remoteTargets.slice(i, i + 25);
219
+ const pushed = runGit(['push', 'origin', '--delete', ...batch], { cwd: root, check: false });
220
+ if (pushed.status === 0) receipt.deletedRemote.push(...batch);
221
+ }
222
+ }
223
+ }
224
+
225
+ return receipt;
226
+ }
227
+
228
+ // The story of one piece of work: what it tried to do, whether it made it
229
+ // into master some other way, and what would be lost if it were cleared.
230
+ function collectStory(root, name, { base: baseOverride = '' } = {}) {
231
+ const base = baseBranch(root, baseOverride);
232
+ if (!base) throw new Error('no master/main branch found');
233
+ if (!refExists(root, name)) return null;
234
+
235
+ const logResult = runGit(
236
+ ['log', '--format=%h%x09%cs%x09%an%x09%s', `${base}..${name}`],
237
+ { cwd: root, check: false }
238
+ );
239
+ const changes = logResult.stdout
240
+ .split(/\r?\n/)
241
+ .filter(Boolean)
242
+ .map((line) => {
243
+ const [hash, date, author, subject] = line.split('\t');
244
+ return { hash, date, author, subject };
245
+ });
246
+
247
+ // git cherry marks a change '-' when an equivalent patch already exists
248
+ // in base — the honest "it landed some other way" signal.
249
+ const cherry = runGit(['cherry', base, name], { cwd: root, check: false });
250
+ const landedHashes = new Set();
251
+ for (const line of cherry.stdout.split(/\r?\n/).filter(Boolean)) {
252
+ if (line.startsWith('- ')) landedHashes.add(line.slice(2).trim());
253
+ }
254
+ const fullHashes = runGit(['log', '--format=%H %h', `${base}..${name}`], { cwd: root, check: false });
255
+ const shortToLanded = new Set();
256
+ for (const line of fullHashes.stdout.split(/\r?\n/).filter(Boolean)) {
257
+ const [full, short] = line.split(' ');
258
+ if (landedHashes.has(full)) shortToLanded.add(short);
259
+ }
260
+ for (const c of changes) c.landedElsewhere = shortToLanded.has(c.hash);
261
+
262
+ const stat = runGit(['diff', '--stat', `${base}...${name}`], { cwd: root, check: false });
263
+ const statLines = stat.stdout.split(/\r?\n/).filter(Boolean);
264
+ const files = statLines.slice(0, -1).map((l) => l.split('|')[0].trim());
265
+
266
+ const info = listBranches(root).find((b) => b.name === name);
267
+ return {
268
+ name,
269
+ base,
270
+ ageDays: info ? ageDays(info.lastCommitUnix) : null,
271
+ changes,
272
+ landedElsewhere: changes.filter((c) => c.landedElsewhere).length,
273
+ uniqueChanges: changes.filter((c) => !c.landedElsewhere).length,
274
+ files,
275
+ };
276
+ }
277
+
278
+ function printStory(story) {
279
+ console.log('');
280
+ console.log(`what happened in ${story.name}`);
281
+ console.log('');
282
+ if (story.changes.length === 0) {
283
+ console.log(` everything here is already in ${story.base}. this is an empty leftover,`);
284
+ console.log(" safe to clear. nothing would be lost.");
285
+ console.log('');
286
+ return;
287
+ }
288
+ const who = [...new Set(story.changes.map((c) => c.author))].join(', ');
289
+ const when = story.changes[story.changes.length - 1].date;
290
+ const last = story.changes[0].date;
291
+ const age = story.ageDays === null ? '' : ` (${story.ageDays} days ago)`;
292
+ console.log(` started ${when}, last touched ${last}${age}, by ${who}.`);
293
+ console.log('');
294
+ console.log(' what it tried to do:');
295
+ for (const c of [...story.changes].reverse()) {
296
+ const mark = c.landedElsewhere ? 'made it in ' : 'never made it';
297
+ console.log(` ${mark} ${c.subject}`);
298
+ }
299
+ console.log('');
300
+ if (story.files.length > 0) {
301
+ const shown = story.files.slice(0, 6).join(', ');
302
+ const more = story.files.length > 6 ? ` and ${story.files.length - 6} more` : '';
303
+ console.log(` it touched: ${shown}${more}`);
304
+ console.log('');
305
+ }
306
+ if (story.uniqueChanges === 0) {
307
+ console.log(` bottom line: all ${story.changes.length} changes made it into ${story.base}`);
308
+ console.log(' some other way. nothing here would be lost by clearing it.');
309
+ } else if (story.landedElsewhere > 0) {
310
+ console.log(` bottom line: ${story.landedElsewhere} of ${story.changes.length} changes made it into ${story.base} another`);
311
+ console.log(` way. ${story.uniqueChanges} exist only here (marked "never made it") — they may have`);
312
+ console.log(' been redone differently, or they may be real lost work. clearing backs');
313
+ console.log(' them up first either way.');
314
+ } else {
315
+ console.log(` bottom line: none of this is in ${story.base}. this is real work that only`);
316
+ console.log(' exists here. land it or clear it knowing the backup keeps a copy.');
317
+ }
318
+ console.log('');
319
+ console.log(` see the actual changes: git diff ${story.base}...${story.name}`);
320
+ console.log('');
321
+ }
322
+
323
+ function printBoard(board) {
324
+ console.log('');
325
+ console.log('the landing — what is actually done vs still in the air');
326
+ console.log('');
327
+ if (board.branches.length === 0 && board.worktrees.length === 0) {
328
+ console.log(' everything has landed. nothing in the air, nothing stuck.');
329
+ console.log('');
330
+ return;
331
+ }
332
+ const order = { due: 0, detached: 0, landed: 1, active: 2 };
333
+ const rows = [...board.branches].sort((a, b) => (order[a.state] ?? 3) - (order[b.state] ?? 3) || b.ageDays - a.ageDays);
334
+ const labels = { due: 'overdue', landed: 'landed', active: 'in the air' };
335
+ for (const b of rows) {
336
+ const changes = b.ahead === 1 ? '1 change ' : `${b.ahead} changes`;
337
+ console.log(` ${(labels[b.state] || b.state).padEnd(11)} ${String(b.ageDays).padStart(3)}d ${changes} ${b.name}`);
338
+ }
339
+ for (const w of board.worktrees) {
340
+ const edits = w.dirty === 1 ? '1 unsaved edit' : `${w.dirty} unsaved edits`;
341
+ console.log(` side copy ${w.path} (${edits})`);
342
+ }
343
+ console.log('');
344
+ const s = board.summary;
345
+ console.log(` ${s.unlanded} pieces of work in the air, ${s.due} overdue, ${s.landed} landed and safe to clear.`);
346
+ console.log(' work counts as done only when it lands in master.');
347
+ if (s.due + s.landed > 0) {
348
+ console.log(` back up + clear the ${s.due + s.landed} overdue/landed: atris land --reap`);
349
+ }
350
+ console.log('');
351
+ }
352
+
353
+ function printReceipt(receipt) {
354
+ console.log('');
355
+ if (receipt.dryRun) {
356
+ console.log(`landing cleanup preview — would clear ${receipt.deletedBranches.length} pieces of work, ${receipt.removedWorktrees.length} side copies:`);
357
+ for (const b of receipt.deletedBranches) console.log(` would clear ${b}`);
358
+ for (const w of receipt.removedWorktrees) console.log(` would remove ${w}`);
359
+ } else {
360
+ console.log(`landing cleanup done — ${receipt.deletedBranches.length} pieces cleared, ${receipt.removedWorktrees.length} side copies removed`);
361
+ if (receipt.bundle) console.log(` backed up first, nothing lost: ${receipt.bundle}`);
362
+ for (const p of receipt.patches) console.log(` unsaved edits saved: ${p}`);
363
+ if (receipt.deletedRemote.length > 0) console.log(` also cleared on github: ${receipt.deletedRemote.length}`);
364
+ }
365
+ console.log(` still flying (recent work, left alone): ${receipt.kept.length}`);
366
+ console.log('');
367
+ }
368
+
369
+ function readFlag(args, name, fallback = '') {
370
+ const idx = args.indexOf(name);
371
+ if (idx === -1) return fallback;
372
+ return args[idx + 1] || fallback;
373
+ }
374
+
375
+ function showHelp() {
376
+ console.log('');
377
+ console.log('atris land — the landing: what is actually done vs still in the air');
378
+ console.log('');
379
+ console.log('work counts as done only when it lands in master. everything else is');
380
+ console.log('in the air, and this shows it so nothing quietly dies.');
381
+ console.log('');
382
+ console.log(' atris land show everything still in the air');
383
+ console.log(' atris land <name> the story of one piece: what it tried,');
384
+ console.log(' whether it landed some other way, what');
385
+ console.log(' would be lost by clearing it');
386
+ console.log(' atris land --reap back up, then clear anything overdue');
387
+ console.log(' or already landed (here and on github)');
388
+ console.log(' atris land --reap --dry-run preview what would be cleared');
389
+ console.log(' atris land --ttl <days> change the 7-day overdue line');
390
+ console.log(' atris land --json machine-readable output');
391
+ console.log('');
392
+ console.log('backups go to .atris/salvage/<date>/ — clearing never loses work.');
393
+ console.log('');
394
+ }
395
+
396
+ function landCommand(args = []) {
397
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
398
+ showHelp();
399
+ return 0;
400
+ }
401
+ const root = repoRoot();
402
+ if (!root) {
403
+ console.error('not a git repository');
404
+ return 1;
405
+ }
406
+ const ttlRaw = readFlag(args, '--ttl', '');
407
+ const ttlParsed = Number(ttlRaw);
408
+ const ttlDays = ttlRaw !== '' && Number.isFinite(ttlParsed) && ttlParsed >= 0 ? ttlParsed : DEFAULT_TTL_DAYS;
409
+ const base = readFlag(args, '--base', '');
410
+ const json = args.includes('--json');
411
+
412
+ if (args.includes('--reap')) {
413
+ const receipt = reap(root, { ttlDays, base, dryRun: args.includes('--dry-run'), remote: !args.includes('--no-remote') });
414
+ if (json) console.log(JSON.stringify(receipt, null, 2));
415
+ else printReceipt(receipt);
416
+ return 0;
417
+ }
418
+
419
+ const name = args.find((a) => !a.startsWith('-') && a !== readFlag(args, '--ttl') && a !== readFlag(args, '--base'));
420
+ if (name) {
421
+ const story = collectStory(root, name, { base });
422
+ if (!story) {
423
+ console.error(`nothing called '${name}' is in the air right now`);
424
+ return 1;
425
+ }
426
+ if (json) console.log(JSON.stringify(story, null, 2));
427
+ else printStory(story);
428
+ return 0;
429
+ }
430
+
431
+ const board = collectBoard(root, { ttlDays, base });
432
+ if (json) console.log(JSON.stringify(board, null, 2));
433
+ else printBoard(board);
434
+ return 0;
435
+ }
436
+
437
+ module.exports = {
438
+ collectBoard,
439
+ landCommand,
440
+ landSummary,
441
+ reap,
442
+ };
@@ -14,6 +14,7 @@
14
14
  // atris loop status liveness, last tick, reward -> pulse.js
15
15
  // atris loop stop remove the durable heartbeat -> pulse.js
16
16
  // atris loop wiki wiki upkeep (the old `loop`) -> loop.js
17
+ // atris loop create-next create + claim the suggested task
17
18
  //
18
19
  // The loop reads ROADMAP.md for what to pursue.
19
20
 
@@ -21,6 +22,13 @@ function isFlag(arg) {
21
22
  return typeof arg === 'string' && arg.startsWith('-');
22
23
  }
23
24
 
25
+ function flagValue(argv, name) {
26
+ const index = argv.indexOf(name);
27
+ if (index === -1) return null;
28
+ const value = argv[index + 1];
29
+ return value && !isFlag(value) ? value : true;
30
+ }
31
+
24
32
  // Pure router: decide what `atris loop ...` means without executing anything.
25
33
  // Kept side-effect-free so it is trivially testable.
26
34
  function routeLoop(argv = []) {
@@ -36,6 +44,10 @@ function routeLoop(argv = []) {
36
44
  return { action: 'status' };
37
45
  case 'stop':
38
46
  return { action: 'stop' };
47
+ case 'create-next':
48
+ case 'claim-next':
49
+ case 'take-next':
50
+ return { action: 'create-next' };
39
51
  case 'wiki':
40
52
  // Forward the remaining args (e.g. --json, --limit=) to wiki upkeep.
41
53
  return { action: 'wiki', rest: argv.filter((a) => a.toLowerCase() !== 'wiki') };
@@ -57,7 +69,7 @@ function routeLoop(argv = []) {
57
69
  }
58
70
  }
59
71
 
60
- function renderLoopHome(route = { action: 'home' }) {
72
+ function renderLoopHome(route = { action: 'home' }, moves = []) {
61
73
  const lines = [
62
74
  '',
63
75
  'atris loop: the self-improvement loop',
@@ -73,6 +85,7 @@ function renderLoopHome(route = { action: 'home' }) {
73
85
  ' atris loop start run it now, here (local)',
74
86
  ' atris loop start --once one cycle, then stop',
75
87
  ' atris loop start --overnight install the durable heartbeat (~15m)',
88
+ ' atris loop start --overnight --hours 6',
76
89
  '',
77
90
  ' watch it',
78
91
  ' atris loop status liveness, last tick, reward',
@@ -85,6 +98,16 @@ function renderLoopHome(route = { action: 'home' }) {
85
98
  ' wiki upkeep is at: atris loop wiki',
86
99
  '',
87
100
  ];
101
+ if (Array.isArray(moves) && moves.length) {
102
+ lines.splice(lines.length - 2, 0, ' ranked next moves');
103
+ moves.slice(0, 3).forEach((move) => {
104
+ lines.splice(lines.length - 2, 0, ` [${move.source}] ${move.title}`);
105
+ });
106
+ lines.splice(lines.length - 2, 0, '');
107
+ }
108
+ lines.splice(lines.length - 2, 0, ' act on it');
109
+ lines.splice(lines.length - 2, 0, ' atris loop create-next create + claim the suggested task');
110
+ lines.splice(lines.length - 2, 0, '');
88
111
  if (route && route.unknown) {
89
112
  lines.splice(1, 0, ` (unknown: "${route.unknown}". here is the loop:)`);
90
113
  }
@@ -95,7 +118,11 @@ function renderLoopHome(route = { action: 'home' }) {
95
118
  }
96
119
 
97
120
  function printLoopHome(route) {
98
- console.log(renderLoopHome(route));
121
+ let moves = [];
122
+ try {
123
+ moves = require('../lib/next-moves').nextMoves(process.cwd(), 3);
124
+ } catch { /* next moves are optional on a fresh checkout */ }
125
+ console.log(renderLoopHome(route, moves));
99
126
  }
100
127
 
101
128
  // Parse the handful of local-run flags `atris loop start` forwards to run.js.
@@ -148,7 +175,7 @@ function printLocalRunSummary() {
148
175
  // Combined machine-readable status: the overnight pulse heartbeat and the local
149
176
  // runs in one object. Best-effort per engine so a missing one does not fail it.
150
177
  function loopStatusJson(root = process.cwd()) {
151
- const out = { ok: true, action: 'loop_status', pulse: null, local_runs: { count: 0, latest: null } };
178
+ const out = { ok: true, action: 'loop_status', pulse: null, local_runs: { count: 0, latest: null }, next_moves: [] };
152
179
  try {
153
180
  const lp = require('../lib/pulse');
154
181
  const { cronInstalled } = require('./pulse');
@@ -158,9 +185,88 @@ function loopStatusJson(root = process.cwd()) {
158
185
  try {
159
186
  out.local_runs = localRunSummary(require('./run').getRunLogDir());
160
187
  } catch { /* runs optional */ }
188
+ try {
189
+ out.next_moves = require('../lib/next-moves').nextMoves(root, 5);
190
+ } catch { /* next moves optional */ }
161
191
  return out;
162
192
  }
163
193
 
194
+ function loopSeedMove(root = process.cwd()) {
195
+ const moves = require('../lib/next-moves').nextMoves(root, 5);
196
+ const activeTask = moves.find((move) => move && move.source === 'task');
197
+ if (activeTask) return { ok: false, reason: 'active_task', move: activeTask, moves };
198
+ const seed = moves.find((move) => (
199
+ move
200
+ && move.source === 'mission'
201
+ && (
202
+ move.why === 'active mission has no concrete task queued'
203
+ || move.why === 'latest proof timeline suggested this self-improvement target'
204
+ )
205
+ ));
206
+ if (!seed) return { ok: false, reason: 'no_seed', moves };
207
+ return { ok: true, move: seed, moves };
208
+ }
209
+
210
+ function createNextLoopTask(argv = [], root = process.cwd(), options = {}) {
211
+ const shouldPrint = options.print !== false;
212
+ const json = argv.includes('--json');
213
+ const owner = flagValue(argv, '--as') || flagValue(argv, '--owner') || process.env.ATRIS_AGENT_ID || 'auto-improver';
214
+ const seed = loopSeedMove(root);
215
+ if (!seed.ok) {
216
+ const payload = { ok: false, action: 'create_next_skipped', reason: seed.reason, move: seed.move || null };
217
+ if (shouldPrint) {
218
+ if (json) console.log(JSON.stringify(payload, null, 2));
219
+ else if (seed.reason === 'active_task') console.log(`not created: active task already exists (${seed.move.ref || seed.move.title})`);
220
+ else console.log('not created: no loop seed is available');
221
+ }
222
+ return payload;
223
+ }
224
+
225
+ const note = `Goal: ${seed.move.title}. Files: inspect atris/MAP.md first, then the relevant code. Done: one bounded proof-backed self-improvement task is moved to Review. Check: focused verifier; git diff --check; atris clean --dry-run --json; atris brain compile --root . --verify.`;
226
+ const taskArgs = [
227
+ 'task',
228
+ 'delegate',
229
+ seed.move.title,
230
+ '--tag', 'loop',
231
+ '--claim',
232
+ '--as', String(owner),
233
+ '--note', note,
234
+ '--json',
235
+ ];
236
+ const { spawnSync } = require('child_process');
237
+ const path = require('path');
238
+ const cli = path.join(__dirname, '..', 'bin', 'atris.js');
239
+ const child = spawnSync(process.execPath, [cli, ...taskArgs], {
240
+ cwd: root,
241
+ encoding: 'utf8',
242
+ timeout: 20000,
243
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
244
+ });
245
+ if (child.status !== 0) {
246
+ const payload = { ok: false, action: 'create_next_failed', reason: 'task_delegate_failed', stderr: child.stderr, stdout: child.stdout };
247
+ if (shouldPrint) {
248
+ if (json) console.log(JSON.stringify(payload, null, 2));
249
+ else console.error(child.stderr || child.stdout || 'task creation failed');
250
+ }
251
+ return payload;
252
+ }
253
+ let delegated = null;
254
+ try { delegated = JSON.parse(child.stdout); } catch { delegated = null; }
255
+ const task = delegated && delegated.task ? delegated.task : null;
256
+ const payload = { ok: true, action: 'created_next', owner: String(owner), move: seed.move, task, delegated };
257
+ if (shouldPrint) {
258
+ if (json) {
259
+ console.log(JSON.stringify(payload, null, 2));
260
+ } else {
261
+ const ref = task?.display_id || task?.id || 'task';
262
+ console.log(`created next loop task: ${ref} ${seed.move.title}`);
263
+ console.log(`claimed by: ${owner}`);
264
+ console.log(`next: atris task show ${ref}`);
265
+ }
266
+ }
267
+ return payload;
268
+ }
269
+
164
270
  // The evidence that the loop is improving things: ROADMAP items it has handled,
165
271
  // what is in flight and queued, and the heartbeat's reward/verify trend. Pure of
166
272
  // console so it is testable.
@@ -170,7 +276,7 @@ function loopReport(root = process.cwd()) {
170
276
  try {
171
277
  roadmap = require('../lib/next-moves').roadmapItemsByState(root);
172
278
  } catch { /* roadmap optional */ }
173
- return { ok: true, action: 'loop_report', roadmap, pulse: status.pulse, local_runs: status.local_runs };
279
+ return { ok: true, action: 'loop_report', roadmap, next_moves: status.next_moves, pulse: status.pulse, local_runs: status.local_runs };
174
280
  }
175
281
 
176
282
  function renderLoopReport(rep) {
@@ -193,6 +299,11 @@ function renderLoopReport(rep) {
193
299
  lines.push(' next up:');
194
300
  r.open.slice(0, 4).forEach((t) => lines.push(` [ ] ${t}`));
195
301
  }
302
+ if (Array.isArray(rep.next_moves) && rep.next_moves.length) {
303
+ lines.push('');
304
+ lines.push(' ranked next:');
305
+ rep.next_moves.slice(0, 5).forEach((move) => lines.push(` [${move.source}] ${move.title}`));
306
+ }
196
307
  if (rep.pulse) {
197
308
  lines.push('');
198
309
  lines.push(` heartbeat: ${rep.pulse.total_ticks} ticks, reward ${rep.pulse.reward_sum}, verify ${rep.pulse.verify_pass}/${rep.pulse.verify_fail}`);
@@ -236,6 +347,11 @@ function loopFront(argv = []) {
236
347
  return Promise.resolve(0);
237
348
  }
238
349
 
350
+ case 'create-next': {
351
+ const result = createNextLoopTask(argv, process.cwd());
352
+ return Promise.resolve(result.ok ? 0 : 1);
353
+ }
354
+
239
355
  case 'status': {
240
356
  if (jsonFlag.length) {
241
357
  // One machine-readable object covering BOTH engines (overnight pulse
@@ -284,6 +400,8 @@ module.exports = {
284
400
  startLocalOptions,
285
401
  localRunSummary,
286
402
  loopStatusJson,
403
+ loopSeedMove,
404
+ createNextLoopTask,
287
405
  loopReport,
288
406
  renderLoopReport,
289
407
  loopFront,