atris 2.6.3 → 3.0.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 (54) hide show
  1. package/README.md +124 -34
  2. package/atris/CLAUDE.md +5 -1
  3. package/atris/atris.md +4 -0
  4. package/atris/features/README.md +24 -0
  5. package/atris/skills/autopilot/SKILL.md +74 -75
  6. package/atris/skills/endgame/SKILL.md +179 -0
  7. package/atris/skills/flow/SKILL.md +121 -0
  8. package/atris/skills/improve/SKILL.md +84 -0
  9. package/atris/skills/loop/SKILL.md +72 -0
  10. package/atris/skills/wiki/SKILL.md +61 -0
  11. package/atris/team/executor/MEMBER.md +10 -4
  12. package/atris/team/navigator/MEMBER.md +2 -0
  13. package/atris/team/validator/MEMBER.md +8 -5
  14. package/atris.md +33 -0
  15. package/bin/atris.js +210 -41
  16. package/commands/activate.js +28 -2
  17. package/commands/align.js +720 -0
  18. package/commands/auth.js +75 -2
  19. package/commands/autopilot.js +1213 -270
  20. package/commands/browse.js +100 -0
  21. package/commands/business.js +785 -12
  22. package/commands/clean.js +107 -2
  23. package/commands/computer.js +429 -0
  24. package/commands/context-sync.js +78 -8
  25. package/commands/experiments.js +351 -0
  26. package/commands/feedback.js +150 -0
  27. package/commands/fleet.js +395 -0
  28. package/commands/fork.js +127 -0
  29. package/commands/init.js +50 -1
  30. package/commands/learn.js +407 -0
  31. package/commands/lifecycle.js +94 -0
  32. package/commands/loop.js +114 -0
  33. package/commands/publish.js +129 -0
  34. package/commands/pull.js +369 -38
  35. package/commands/push.js +283 -246
  36. package/commands/review.js +149 -0
  37. package/commands/run.js +76 -43
  38. package/commands/serve.js +360 -0
  39. package/commands/setup.js +1 -1
  40. package/commands/soul.js +381 -0
  41. package/commands/status.js +119 -1
  42. package/commands/sync.js +147 -1
  43. package/commands/terminal.js +201 -0
  44. package/commands/wiki.js +376 -0
  45. package/commands/workflow.js +191 -74
  46. package/commands/workspace-clean.js +3 -3
  47. package/lib/endstate.js +259 -0
  48. package/lib/learnings.js +235 -0
  49. package/lib/manifest.js +1 -0
  50. package/lib/todo.js +9 -5
  51. package/lib/wiki.js +578 -0
  52. package/package.json +2 -2
  53. package/utils/api.js +40 -35
  54. package/utils/auth.js +1 -0
@@ -1,172 +1,559 @@
1
1
  /**
2
- * Atris Autopilot - PRD-driven autonomous execution
2
+ * Atris Autopilot Suggest, justify, execute. One task at a time.
3
3
  *
4
- * Uses claude -p to execute plan do review cycles autonomously.
5
- * Supports features and bugs with different acceptance criteria templates.
4
+ * Scans the workspace for signals (stale pages, broken refs, abandoned tasks,
5
+ * inbox items, backlog) and suggests the most important thing to do next.
6
+ * Human approves, skips, or cancels. In --auto mode, runs without asking.
6
7
  */
7
8
 
8
9
  const fs = require('fs');
9
10
  const path = require('path');
10
- const { execSync, spawn } = require('child_process');
11
+ const { execSync } = require('child_process');
12
+ const readline = require('readline');
11
13
  const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
14
+ const { parseTodo } = require('../lib/todo');
15
+ const { findStalePages, findStaleTasks, healBrokenMapRefs } = require('./clean');
12
16
 
13
17
  const pkg = require('../package.json');
14
18
 
15
- // Default max iterations before stopping
16
- const DEFAULT_MAX_ITERATIONS = 5;
19
+ const PHASE_TIMEOUT = 600000; // 10 min per phase
17
20
 
18
21
  /**
19
- * Generate PRD from feature/bug description
22
+ * Scan workspace for the next thing worth doing.
23
+ * Returns { task, why, kind } or null.
20
24
  */
21
- function generatePRD(description, options = {}) {
22
- const { type = 'feature', file = null } = options;
23
- const id = type === 'bug' ? 'BUG-001' : 'FEAT-001';
24
-
25
- // Generate acceptance criteria based on type
26
- let acceptance;
27
- if (type === 'bug') {
28
- acceptance = [
29
- 'Bug is fixed and no longer reproducible',
30
- 'Regression test added (if applicable)',
31
- 'Build passes: npm run build (or equivalent)',
32
- 'No new bugs introduced'
33
- ];
34
- } else {
35
- acceptance = [
36
- 'Feature implemented and working as described',
37
- 'Tests pass (if test suite exists)',
38
- 'Build passes: npm run build (or equivalent)',
39
- 'Code follows project patterns (check MAP.md)'
40
- ];
41
- }
42
-
43
- const prd = {
44
- project: path.basename(process.cwd()),
45
- type,
46
- stories: [
47
- {
48
- id,
49
- title: description,
50
- file: file || '(auto-detect from MAP.md)',
51
- acceptance,
52
- passes: false,
53
- priority: 1
54
- }
55
- ]
56
- };
57
-
58
- return prd;
59
- }
60
-
61
- /**
62
- * Build prompt for each phase (plan/do/review)
63
- */
64
- function buildPrompt(phase, prd) {
65
- const prdJson = JSON.stringify(prd, null, 2);
66
-
67
- if (phase === 'plan') {
68
- return `Navigator: Plan this PRD story.
69
-
70
- PRD: ${prdJson}
25
+ async function suggestNextTask(cwd, skipped = new Set()) {
26
+ const atrisDir = path.join(cwd, 'atris');
27
+ const suggestions = [];
28
+
29
+ // --- Endgame tasks (highest priority — pursue the current horizon to completion) ---
30
+ const todoPath = path.join(atrisDir, 'TODO.md');
31
+ const todo = parseTodo(todoPath);
32
+
33
+ for (const t of todo.backlog) {
34
+ if (t.tag === 'endgame' && !skipped.has(t.title)) {
35
+ suggestions.push({
36
+ task: t.title,
37
+ why: `Next step in the current endgame. Endgame steps are pursued to completion before any reactive signal.`,
38
+ kind: 'endgame',
39
+ priority: 0
40
+ });
41
+ break;
42
+ }
43
+ }
71
44
 
72
- 1. Read atris/MAP.md for file locations
73
- 2. Identify files to change
74
- 3. Create ASCII diagram of approach
75
- 4. Add tasks to atris/TODO.md Backlog
45
+ // --- Resume interrupted work ---
46
+ if (todo.inProgress.length > 0) {
47
+ const t = todo.inProgress[0];
48
+ if (!skipped.has(t.title)) {
49
+ suggestions.push({
50
+ task: t.title,
51
+ why: `This was already started${t.claimed ? ` by ${t.claimed}` : ''} but never finished.`,
52
+ kind: 'resume',
53
+ priority: 1
54
+ });
55
+ }
56
+ }
76
57
 
77
- DO NOT write code. Planning only.
78
- Reply [PLAN_COMPLETE] when done.`;
58
+ // --- Stale wiki pages (knowledge rot) ---
59
+ const stalePages = findStalePages(cwd, atrisDir);
60
+ for (const sp of stalePages.slice(0, 2)) {
61
+ const pageName = path.relative(cwd, sp.page);
62
+ const key = `recompile:${pageName}`;
63
+ if (skipped.has(key)) continue;
64
+ suggestions.push({
65
+ task: `Re-read sources and update ${pageName}`,
66
+ why: `"${sp.staleSource}" changed on ${sp.sourceDate} but the page was last compiled ${sp.compiledDate}. The content may be wrong.`,
67
+ kind: 'staleness',
68
+ priority: 2
69
+ });
70
+ break;
79
71
  }
80
72
 
81
- if (phase === 'do') {
82
- return `Executor: Build the PRD story.
73
+ // --- Stale tasks (claimed but abandoned >3 days) ---
74
+ const staleTasks = findStaleTasks(atrisDir);
75
+ for (const st of staleTasks.slice(0, 1)) {
76
+ const key = `stale:${st.title}`;
77
+ if (skipped.has(key)) continue;
78
+ suggestions.push({
79
+ task: `Finish or remove stale task: ${st.title}`,
80
+ why: `Claimed ${st.daysSinceClaim} days ago and never completed. Either finish it or delete it — stale tasks add noise.`,
81
+ kind: 'cleanup',
82
+ priority: 3
83
+ });
84
+ }
83
85
 
84
- PRD: ${prdJson}
86
+ // --- Broken MAP.md references ---
87
+ const { unhealable } = healBrokenMapRefs(cwd, atrisDir, true); // dry-run
88
+ if (unhealable.length > 0 && !skipped.has('fix-map-refs')) {
89
+ const sample = unhealable.slice(0, 3).map(r => `${r.file}:${r.line}`).join(', ');
90
+ suggestions.push({
91
+ task: `Fix ${unhealable.length} broken reference${unhealable.length > 1 ? 's' : ''} in MAP.md`,
92
+ why: `These file:line references point to code that moved or was deleted: ${sample}. MAP.md is the navigation — it needs to be accurate.`,
93
+ kind: 'docs',
94
+ priority: 4
95
+ });
96
+ }
85
97
 
86
- 1. Read atris/TODO.md for tasks
87
- 2. Implement each task
88
- 3. Verify changes work
89
- 4. Commit: git add -A && git commit -m "autopilot: [title]"
98
+ // --- Backlog tasks ---
99
+ for (const t of todo.backlog.slice(0, 1)) {
100
+ if (skipped.has(t.title)) continue;
101
+ const remaining = todo.backlog.length;
102
+ suggestions.push({
103
+ task: t.title,
104
+ why: `Next in the backlog${t.tag ? ` (${t.tag})` : ''}. ${remaining} task${remaining > 1 ? 's' : ''} waiting.`,
105
+ kind: 'backlog',
106
+ priority: 5
107
+ });
108
+ }
90
109
 
91
- Reply [DO_COMPLETE] when done.`;
110
+ // --- Unprocessed inbox items ---
111
+ const { logFile } = getLogPath();
112
+ if (fs.existsSync(logFile)) {
113
+ const content = fs.readFileSync(logFile, 'utf8');
114
+ const inboxMatch = content.match(/## Inbox\n([\s\S]*?)(?=\n##|$)/);
115
+ if (inboxMatch && inboxMatch[1].trim()) {
116
+ const items = inboxMatch[1].trim().split('\n').filter(l => {
117
+ const t = l.trim();
118
+ return t.startsWith('- ') && t.length > 2;
119
+ });
120
+ if (items.length > 0) {
121
+ const firstItem = items[0].replace(/^-\s*\*\*I\d+:\*\*\s*/, '').replace(/^-\s*/, '').trim();
122
+ const inboxTaskTitle = `Break down inbox idea: "${firstItem}"`;
123
+ if (!skipped.has(inboxTaskTitle)) {
124
+ suggestions.push({
125
+ task: inboxTaskTitle,
126
+ why: `${items.length} raw idea${items.length > 1 ? 's' : ''} sitting in the inbox. Needs to become concrete tasks before anything can happen.`,
127
+ kind: 'inbox',
128
+ priority: 6
129
+ });
130
+ }
131
+ }
132
+ }
92
133
  }
93
134
 
94
- if (phase === 'review') {
95
- return `Validator: Review the PRD story.
135
+ // --- Incomplete features (idea.md exists but no build.md or validate.md) ---
136
+ const featuresDir = path.join(atrisDir, 'features');
137
+ if (fs.existsSync(featuresDir) && !skipped.has('incomplete-features')) {
138
+ try {
139
+ const featureDirs = fs.readdirSync(featuresDir, { withFileTypes: true })
140
+ .filter(d => d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.'));
141
+ for (const dir of featureDirs) {
142
+ const fp = path.join(featuresDir, dir.name);
143
+ const hasIdea = fs.existsSync(path.join(fp, 'idea.md'));
144
+ const hasBuild = fs.existsSync(path.join(fp, 'build.md'));
145
+ const hasValidate = fs.existsSync(path.join(fp, 'validate.md'));
146
+ if (hasIdea && (!hasBuild || !hasValidate)) {
147
+ const missing = [];
148
+ if (!hasBuild) missing.push('build.md');
149
+ if (!hasValidate) missing.push('validate.md');
150
+ const key = `feature:${dir.name}`;
151
+ if (!skipped.has(key)) {
152
+ suggestions.push({
153
+ task: `Complete feature spec for "${dir.name}" — missing ${missing.join(' and ')}`,
154
+ why: `idea.md exists but the feature is incomplete. Navigator needs to create ${missing.join(' and ')} so executor can build it.`,
155
+ kind: 'feature',
156
+ priority: 6.5
157
+ });
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ } catch {}
163
+ }
96
164
 
97
- PRD: ${prdJson}
165
+ // --- Periodic review (suggest when nothing else is urgent) ---
166
+ if (!skipped.has('review')) {
167
+ const mapPath = path.join(atrisDir, 'MAP.md');
168
+ if (fs.existsSync(mapPath)) {
169
+ const mapStat = fs.statSync(mapPath);
170
+ const daysSinceMapUpdate = (Date.now() - mapStat.mtime.getTime()) / (1000 * 60 * 60 * 24);
171
+ if (daysSinceMapUpdate > 7) {
172
+ suggestions.push({
173
+ task: 'Review and refresh MAP.md — it hasn\'t been updated in over a week',
174
+ why: `Last modified ${Math.floor(daysSinceMapUpdate)} days ago. Code may have drifted from the map. A quick review keeps navigation accurate.`,
175
+ kind: 'review',
176
+ priority: 7
177
+ });
178
+ }
179
+ }
180
+ }
98
181
 
99
- 1. Check acceptance criteria are met
100
- 2. Verify the changes work
101
- 3. If issues: reply [REVIEW_FAILED] reason
102
- 4. If all good: update prd.json passes:true, reply <promise>COMPLETE</promise>
182
+ // --- Lessons harvest (suggest if recent completions but no recent lessons) ---
183
+ if (!skipped.has('lessons')) {
184
+ const lessonsPath = path.join(atrisDir, 'lessons.md');
185
+ const { logFile } = getLogPath();
186
+ if (fs.existsSync(logFile)) {
187
+ const journalContent = fs.readFileSync(logFile, 'utf8');
188
+ const completions = (journalContent.match(/\*\*C\d+:/g) || []).length;
189
+ if (completions >= 3) {
190
+ const lessonsFresh = fs.existsSync(lessonsPath) &&
191
+ (Date.now() - fs.statSync(lessonsPath).mtime.getTime()) < 3 * 24 * 60 * 60 * 1000;
192
+ if (!lessonsFresh) {
193
+ suggestions.push({
194
+ task: 'Harvest lessons from recent work into lessons.md',
195
+ why: `${completions} tasks completed today but lessons.md hasn't been updated. Patterns worth remembering should be captured while they're fresh.`,
196
+ kind: 'lessons',
197
+ priority: 7.5
198
+ });
199
+ }
200
+ }
201
+ }
202
+ }
103
203
 
104
- Be thorough.`;
204
+ if (suggestions.length === 0) {
205
+ try {
206
+ const candidates = await proposeCandidateHorizons(cwd);
207
+ const top = candidates.reduce((best, c) => (c.confidence > best.confidence ? c : best), candidates[0]);
208
+ return {
209
+ task: top.title,
210
+ why: top.rationale,
211
+ kind: 'imagined',
212
+ priority: 99
213
+ };
214
+ } catch {
215
+ return null;
216
+ }
105
217
  }
106
218
 
107
- return '';
219
+ suggestions.sort((a, b) => a.priority - b.priority);
220
+ return suggestions[0];
108
221
  }
109
222
 
110
223
  /**
111
- * Execute a phase using claude -p
224
+ * Prompt for approval. Returns 'approve', 'skip', or 'quit'.
112
225
  */
113
- async function executePhase(phase, prd, options = {}) {
114
- const { verbose = false, timeout = 300000 } = options;
115
-
116
- const prompt = buildPrompt(phase, prd);
226
+ function askApproval() {
227
+ return new Promise((resolve) => {
228
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
229
+ rl.question(' enter = go, s = skip, q = stop → ', (answer) => {
230
+ rl.close();
231
+ const a = (answer || '').trim().toLowerCase();
232
+ if (a === 'q' || a === 'quit' || a === 'exit') resolve('quit');
233
+ else if (a === 's' || a === 'skip') resolve('skip');
234
+ else resolve('approve');
235
+ });
236
+ });
237
+ }
117
238
 
118
- console.log(`\n[${phase.toUpperCase()}] Executing...`);
239
+ /**
240
+ * Run a phase via claude -p subprocess.
241
+ */
242
+ function executePhaseDetailed(phase, context, options = {}) {
243
+ const { verbose = false, timeout = PHASE_TIMEOUT } = options;
119
244
 
120
- // Write prompt to temp file to avoid shell escaping issues
245
+ const prompt = buildPrompt(phase, context, options);
121
246
  const tmpFile = path.join(process.cwd(), '.autopilot-prompt.tmp');
122
247
  fs.writeFileSync(tmpFile, prompt);
123
248
 
124
249
  try {
125
250
  const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')" --allowedTools "Bash,Read,Write,Edit,Glob,Grep"`;
251
+ const env = { ...process.env };
252
+ delete env.CLAUDECODE;
126
253
  const output = execSync(cmd, {
127
254
  cwd: process.cwd(),
128
255
  encoding: 'utf8',
129
256
  timeout,
130
257
  stdio: verbose ? 'inherit' : 'pipe',
131
- maxBuffer: 10 * 1024 * 1024
258
+ maxBuffer: 10 * 1024 * 1024,
259
+ env
132
260
  });
133
261
 
134
- // Clean up
135
262
  try { fs.unlinkSync(tmpFile); } catch {}
136
-
137
- const result = output || '';
138
-
139
- if (phase === 'plan') {
140
- console.log('✓ Planning complete');
141
- return { success: true, output: result };
142
- } else if (phase === 'do') {
143
- console.log('✓ Execution complete');
144
- return { success: true, output: result };
145
- } else if (phase === 'review') {
146
- if (result.includes('<promise>COMPLETE</promise>')) {
147
- console.log('✓ Review passed - all criteria met');
148
- return { success: true, complete: true, output: result };
149
- } else if (result.includes('[REVIEW_FAILED]')) {
150
- console.log('✗ Review failed - issues found');
151
- return { success: true, complete: false, output: result };
152
- } else {
153
- return { success: true, complete: true, output: result };
154
- }
155
- }
156
- return { success: true, output: result };
263
+ return { prompt, output: output || '' };
157
264
  } catch (err) {
158
265
  try { fs.unlinkSync(tmpFile); } catch {}
159
- if (err.killed) {
160
- throw new Error(`Phase timed out after ${timeout / 1000}s`);
266
+ if (err.killed) throw new Error(`${phase} timed out after ${timeout / 1000}s`);
267
+ if (err.stdout) {
268
+ return { prompt, output: err.stdout };
161
269
  }
162
270
  throw err;
163
271
  }
164
272
  }
165
273
 
274
+ function executePhase(phase, context, options = {}) {
275
+ return executePhaseDetailed(phase, context, options).output;
276
+ }
277
+
166
278
  /**
167
- * Log completion to journal
279
+ * Build context-aware file list for prompts.
168
280
  */
169
- function logToJournal(description, type) {
281
+ function getContextFiles(phase, options = {}) {
282
+ const cwd = process.cwd();
283
+ const { extraReadFiles = [] } = options;
284
+ const agentSpec = {
285
+ plan: 'atris/team/navigator/MEMBER.md',
286
+ do: 'atris/team/executor/MEMBER.md',
287
+ review: 'atris/team/validator/MEMBER.md'
288
+ }[phase];
289
+
290
+ const files = [
291
+ agentSpec && fs.existsSync(path.join(cwd, agentSpec)) ? agentSpec : null,
292
+ 'atris/PERSONA.md',
293
+ 'atris/MAP.md',
294
+ 'atris/TODO.md',
295
+ fs.existsSync(path.join(cwd, 'atris/lessons.md')) ? 'atris/lessons.md' : null,
296
+ (() => { const { logFile } = getLogPath(); return fs.existsSync(logFile) ? path.relative(cwd, logFile) : null; })(),
297
+ ...extraReadFiles.filter((file) => fs.existsSync(path.join(cwd, file)) || fs.existsSync(path.resolve(cwd, file))),
298
+ ];
299
+
300
+ return [...new Set(files.filter(Boolean))].map((f) => `- ${f}`).join('\n');
301
+ }
302
+
303
+ /**
304
+ * Build the right prompt for each phase, adapting to the kind of work.
305
+ */
306
+ function buildPrompt(phase, context, options = {}) {
307
+ const { task, kind } = context;
308
+ const {
309
+ benchmarkStrategy = '',
310
+ contextNote = '',
311
+ runnerName = '',
312
+ } = options;
313
+ const readFiles = getContextFiles(phase, options);
314
+ const benchmarkProtocol = benchmarkStrategy === 'stack'
315
+ ? 'coordinated stack run'
316
+ : (benchmarkStrategy === 'single' ? 'pinned single-model baseline run' : '');
317
+ const benchmarkContextLines = [
318
+ runnerName ? `Runner profile: ${runnerName}` : '',
319
+ benchmarkProtocol ? `Protocol: ${benchmarkProtocol}` : '',
320
+ contextNote,
321
+ ].filter(Boolean);
322
+ const noteBlock = benchmarkContextLines.length > 0
323
+ ? `\nBenchmark context:\n${benchmarkContextLines.join('\n')}\n`
324
+ : '';
325
+
326
+ if (phase === 'plan') {
327
+ const baseRules = `You are the navigator. Read your MEMBER.md spec first if available.
328
+
329
+ Rules:
330
+ - You can read files and plan. You CANNOT write code or edit source files.
331
+ - Check MAP.md before grepping. If MAP has the answer, use it.
332
+ - Tasks must be small: one job, 1-2 files, clear exit condition.
333
+ - Format: - **T#:** Description [execute] or [explore]
334
+ - Read lessons.md to avoid repeating past mistakes.
335
+
336
+ Read these files first:
337
+ ${readFiles}`;
338
+
339
+ if (kind === 'benchmark') {
340
+ return `${baseRules}${noteBlock}
341
+
342
+ Pinned benchmark task:
343
+ ${task}
344
+
345
+ Rules for this run:
346
+ - Treat this as a ${benchmarkProtocol || 'pinned benchmark run'}.
347
+ - ${benchmarkStrategy === 'stack'
348
+ ? 'Split the work into explicit repo lanes only when the task truly separates.'
349
+ : 'Stay single-threaded and solve it directly without delegation theater.'}
350
+ - Do NOT write to TODO.md, journal, or feature specs.
351
+ - Do NOT invent follow-up tasks or widen scope.
352
+ - Read the benchmark contract and pack files in the read list before deciding.
353
+ - Produce the smallest honest plan for this exact task, then reply: done.`;
354
+ }
355
+
356
+ if (kind === 'inbox') {
357
+ return `${baseRules}
358
+
359
+ Convert this inbox idea into concrete tasks:
360
+ ${task}
361
+
362
+ Break it down. Add tasks to atris/TODO.md under ## Backlog.
363
+ If it's substantial (multi-file, needs design), create atris/features/<slug>/idea.md first.
364
+
365
+ When done, reply: done.`;
366
+ }
367
+
368
+ if (kind === 'staleness' || kind === 'docs' || kind === 'review') {
369
+ return `${baseRules}
370
+
371
+ Maintenance task: ${task}
372
+
373
+ Figure out what needs to change and why. Create focused tasks in atris/TODO.md.
374
+ For stale pages, read both the page and its sources to understand the drift.
375
+
376
+ When done, reply: done.`;
377
+ }
378
+
379
+ if (kind === 'cleanup') {
380
+ return `${baseRules}
381
+
382
+ Stale work: ${task}
383
+
384
+ Check if this is actually done (grep for the implementation). If done, delete the task.
385
+ If not done, either re-scope it into something actionable or remove it.
386
+
387
+ When done, reply: done.`;
388
+ }
389
+
390
+ if (kind === 'feature') {
391
+ return `${baseRules}
392
+
393
+ Incomplete feature: ${task}
394
+
395
+ Read the existing idea.md in the feature directory.
396
+ Create the missing specs (build.md and/or validate.md) following the templates in atris/features/.
397
+ build.md should have: files_touched, steps with file:line refs, testing strategy.
398
+ validate.md should have: verification checklist, checks to run.
399
+
400
+ When done, reply: done.`;
401
+ }
402
+
403
+ if (kind === 'lessons') {
404
+ return `${baseRules}
405
+
406
+ Task: ${task}
407
+
408
+ Read today's journal completions and the git log from the past few days.
409
+ Extract patterns worth remembering — things that surprised you, approaches that worked,
410
+ mistakes that were caught. Append to atris/lessons.md. One line per lesson. Be specific.
411
+
412
+ When done, reply: done.`;
413
+ }
414
+
415
+ return `${baseRules}
416
+
417
+ Task: ${task}
418
+
419
+ Understand the scope — what files need to change? Break into sub-tasks if needed.
420
+ Add tasks to atris/TODO.md under ## Backlog.
421
+
422
+ When done, reply: done.`;
423
+ }
424
+
425
+ if (phase === 'do') {
426
+ if (kind === 'benchmark') {
427
+ return `You are the executor. Read your MEMBER.md spec first if available.
428
+
429
+ Rules:
430
+ - This is a ${benchmarkProtocol || 'pinned benchmark run'}. Execute the task directly.
431
+ - You CAN read and write code. Do NOT modify TODO.md or journal state.
432
+ - Stay inside the exact task brief. No side quests.
433
+ - Check MAP.md before grepping.
434
+ - Do NOT create a git commit automatically. The benchmark runner records the result.
435
+
436
+ Read these files first:
437
+ ${readFiles}${noteBlock}
438
+
439
+ Task: ${task}
440
+
441
+ 1. Read the benchmark contract and pack files in the read list.
442
+ 2. Make the smallest changes that satisfy the task brief.
443
+ 3. Verify locally if you can.
444
+ 4. Update MAP.md only if file locations truly shifted because of your change.
445
+ 5. If updating wiki pages, set last_compiled in frontmatter to today's date.
446
+
447
+ When done, reply: done.`;
448
+ }
449
+
450
+ return `You are the executor. Read your MEMBER.md spec first if available.
451
+
452
+ Rules:
453
+ - You CAN read and write code. You CANNOT plan or create new tasks.
454
+ - Execute ONE step at a time. Verify each step before moving on.
455
+ - Check MAP.md for file locations before grepping.
456
+ - If you hit two errors on the same step, stop and flag for re-scope.
457
+ - Stay in scope. Don't touch files outside the task boundary.
458
+
459
+ Read these files first:
460
+ ${readFiles}
461
+
462
+ Task: ${task}
463
+
464
+ 1. Find the task in TODO.md, move to In Progress with: Claimed by: Executor at ${new Date().toISOString()}
465
+ 2. Read MAP.md for exact file:line locations
466
+ 3. Make the changes, verify they work
467
+ 4. Update MAP.md if file locations shifted
468
+ 5. If updating wiki pages, set last_compiled in frontmatter to today's date
469
+ 6. Commit: git add <specific-files> && git commit -m "description"
470
+
471
+ When done, reply: done.`;
472
+ }
473
+
474
+ if (phase === 'review') {
475
+ if (kind === 'benchmark') {
476
+ return `You are the validator. Read your MEMBER.md spec first if available.
477
+
478
+ Rules:
479
+ - This is a ${benchmarkProtocol || 'pinned benchmark'} review. Check quality without widening scope.
480
+ - You CAN fix issues but CANNOT add new features.
481
+ - Run targeted verification if you can and name the commands explicitly in your response.
482
+ - Do NOT delete tasks from TODO.md or append completions to the journal. The outer benchmark runner records the receipt.
483
+
484
+ Read these files first:
485
+ ${readFiles}${noteBlock}
486
+
487
+ Task: ${task}
488
+
489
+ 1. Does it work for the exact task brief?
490
+ 2. Name any tests or checks you ran and whether they passed.
491
+ 3. Call out bugs, edge cases, or drift.
492
+ 4. Reply \`done\` if this run passes the review bar.
493
+ 5. Reply \`failed — [reason]\` if it does not.`;
494
+ }
495
+
496
+ return `You are the validator. Read your MEMBER.md spec first if available.
497
+
498
+ Rules:
499
+ - You check quality. You CAN fix issues but CANNOT add new features.
500
+ - Ultrathink: spec match, scope check, edge cases, integration.
501
+ - Run tests if they exist. Check MAP.md is still accurate.
502
+ - If you halted, were surprised, or learned a non-obvious lesson, append ONE line to atris/lessons.md in this exact format:
503
+ - **[YYYY-MM-DD] short-slug** — pass|fail — One sentence on what surprised you or what to remember.
504
+ Skip if the tick taught nothing non-obvious. Lessons compound — future /endgame runs read this file before picking horizons.
505
+
506
+ Read these files first:
507
+ ${readFiles}
508
+
509
+ Task: ${task}
510
+
511
+ 1. Does it actually work? Test if you can.
512
+ 2. Does it match existing patterns? Check MAP.md.
513
+ 3. Any bugs, edge cases, or security issues?
514
+ 4. Check for stale wiki pages (source changed since last_compiled).
515
+ 5. When satisfied:
516
+ - Delete the task from TODO.md (target state: 0)
517
+ - Add to Completed in today's journal: - **C#:** Description [reviewed]
518
+ - Append any lessons to lessons.md
519
+ 6. If something is wrong, fix it before signing off.
520
+
521
+ When done, reply: done.
522
+ If broken beyond quick fix, reply: failed — [reason].`;
523
+ }
524
+
525
+ return '';
526
+ }
527
+
528
+ function runTaskOnce(context, options = {}) {
529
+ const { verbose = false } = options;
530
+ const phaseResults = {};
531
+ const startedAt = Date.now();
532
+
533
+ for (const phase of ['plan', 'do', 'review']) {
534
+ const t0 = Date.now();
535
+ const result = executePhaseDetailed(phase, context, options);
536
+ phaseResults[phase] = {
537
+ prompt: result.prompt,
538
+ output: result.output || '',
539
+ elapsedSeconds: Math.round((Date.now() - t0) / 1000),
540
+ };
541
+ }
542
+
543
+ const reviewOutput = phaseResults.review.output || '';
544
+
545
+ return {
546
+ success: !reviewOutput.includes('failed'),
547
+ elapsedSeconds: Math.round((Date.now() - startedAt) / 1000),
548
+ phaseResults,
549
+ reviewOutput,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Append a completion to today's journal.
555
+ */
556
+ function logCompletion(description) {
170
557
  ensureLogDirectory();
171
558
  const { logFile, dateFormatted } = getLogPath();
172
559
 
@@ -176,231 +563,787 @@ function logToJournal(description, type) {
176
563
 
177
564
  let content = fs.readFileSync(logFile, 'utf8');
178
565
 
179
- // Find next completion ID
180
566
  const completionMatch = content.match(/\*\*C(\d+):/g);
181
567
  const nextId = completionMatch
182
568
  ? Math.max(...completionMatch.map(m => parseInt(m.match(/\d+/)[0]))) + 1
183
569
  : 1;
184
570
 
185
- const label = type === 'bug' ? 'fix' : 'feat';
186
- const entry = `- **C${nextId}:** [${label}] ${description} [✓ REVIEWED]`;
571
+ const entry = `- **C${nextId}:** ${description} [reviewed]`;
187
572
 
188
- // Add to Completed section
189
573
  if (content.includes('## Completed')) {
190
- content = content.replace(
191
- /(## Completed[^\n]*\n)/,
192
- `$1\n${entry}\n`
193
- );
574
+ content = content.replace(/(## Completed[^\n]*\n)/, `$1\n${entry}\n`);
194
575
  } else {
195
- content += `\n## Completed ✅\n\n${entry}\n`;
576
+ content += `\n## Completed\n\n${entry}\n`;
196
577
  }
197
578
 
198
579
  fs.writeFileSync(logFile, content);
199
- console.log(`✓ Logged to journal: ${entry}`);
200
580
  }
201
581
 
202
582
  /**
203
- * Main autopilot function
583
+ * Append a plain-language tick summary block to today's journal `## Notes`.
584
+ * Fields:
585
+ * - time: human clock string, e.g. "11:20 a.m."
586
+ * - outcome: one-sentence description of what happened this tick
587
+ * - horizon: current endgame slug (or "unset")
588
+ * - nextStep: what the next tick will do
589
+ * - idle: when true, block must contain literal "0 tasks in 0s"
590
+ * so getIdleTickCount still works.
591
+ * Safe to call inside a try/catch — a write failure must never crash a tick.
204
592
  */
205
- async function autopilotAtris(description, options = {}) {
206
- const {
207
- type = 'feature',
208
- maxIterations = DEFAULT_MAX_ITERATIONS,
209
- verbose = false,
210
- dryRun = false
211
- } = options;
593
+ function appendTickSummary(cwd, { time, outcome, horizon, nextStep, idle } = {}) {
594
+ const now = new Date();
595
+ const yyyy = now.getFullYear();
596
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
597
+ const dd = String(now.getDate()).padStart(2, '0');
598
+ const journalPath = path.join(cwd, 'atris', 'logs', String(yyyy), `${yyyy}-${mm}-${dd}.md`);
599
+ const dateFormatted = `${yyyy}-${mm}-${dd}`;
600
+
601
+ if (!fs.existsSync(journalPath)) {
602
+ const dir = path.dirname(journalPath);
603
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
604
+ createLogFile(journalPath, dateFormatted);
605
+ }
212
606
 
213
- const targetDir = path.join(process.cwd(), 'atris');
214
- if (!fs.existsSync(targetDir)) {
215
- throw new Error('atris/ folder not found. Run "atris init" first.');
607
+ const timeLabel = time || new Date().toLocaleTimeString('en-US', {
608
+ hour: 'numeric',
609
+ minute: '2-digit'
610
+ }).toLowerCase();
611
+ const outcomeLine = outcome || 'I ran an autopilot tick.';
612
+ const horizonLine = horizon
613
+ ? `We are still on the ${horizon} endgame.`
614
+ : 'No endgame is set right now.';
615
+ const nextLine = nextStep
616
+ ? `Next tick will ${nextStep}.`
617
+ : 'Next tick will look for new work.';
618
+ const idleLine = idle ? 'This tick moved 0 tasks in 0s.' : null;
619
+
620
+ const blockLines = [
621
+ `- ${timeLabel}`,
622
+ ` ${outcomeLine}`,
623
+ ` ${horizonLine}`,
624
+ ` ${nextLine}`,
625
+ ];
626
+ // Idle marker must be the last non-empty line so getIdleTickCount, which
627
+ // scans bottom-up, counts this block when idle=true.
628
+ if (idleLine) blockLines.push(` ${idleLine}`);
629
+ blockLines.push('');
630
+ const block = blockLines.join('\n');
631
+
632
+ let content = fs.readFileSync(journalPath, 'utf8');
633
+ const notesMatch = content.match(/(##\s+Notes\s*\n)([\s\S]*?)(?=\n##\s|$)/);
634
+ if (notesMatch) {
635
+ const header = notesMatch[1];
636
+ const body = notesMatch[2].replace(/\s*$/, '');
637
+ const newSection = `${header}${body ? body + '\n\n' : ''}${block}\n`;
638
+ content = content.replace(notesMatch[0], newSection);
639
+ } else {
640
+ const trimmed = content.replace(/\s*$/, '');
641
+ content = `${trimmed}\n\n## Notes\n\n${block}\n`;
216
642
  }
643
+ fs.writeFileSync(journalPath, content);
644
+ }
217
645
 
218
- // Check if claude CLI is available
646
+ /**
647
+ * Read the current endgame slug from atris/TODO.md. Returns 'unset' on miss.
648
+ */
649
+ function readHorizonSlug(cwd) {
219
650
  try {
220
- execSync('which claude', { stdio: 'pipe' });
651
+ const todoPath = path.join(cwd, 'atris', 'TODO.md');
652
+ if (!fs.existsSync(todoPath)) return 'unset';
653
+ const content = fs.readFileSync(todoPath, 'utf8');
654
+ const match = content.match(/\*\*Slug:\*\*\s*(\S+)/);
655
+ return match ? match[1].trim() : 'unset';
221
656
  } catch {
222
- throw new Error('claude CLI not found. Install Claude Code first.');
657
+ return 'unset';
223
658
  }
659
+ }
224
660
 
225
- console.log('');
226
- console.log('┌─────────────────────────────────────────────────────────────┐');
227
- console.log(`│ Atris Autopilot v${pkg.version} — PRD-driven autonomous execution │`);
228
- console.log('│ plan → do → review (powered by claude -p) │');
229
- console.log('└─────────────────────────────────────────────────────────────┘');
230
- console.log('');
231
- console.log(`Type: ${type}`);
232
- console.log(`Description: ${description}`);
233
- console.log(`Max iterations: ${maxIterations}`);
234
- console.log('');
235
-
236
- // Generate PRD
237
- const prd = generatePRD(description, { type });
238
- const prdPath = path.join(process.cwd(), 'prd.json');
239
- const progressPath = path.join(process.cwd(), 'progress.txt');
661
+ /**
662
+ * Main loop. Suggest → justify → approve → execute, one at a time.
663
+ */
664
+ /**
665
+ * Parse duration string like "1h", "30m", "90m", "2h" into milliseconds.
666
+ */
667
+ function parseDuration(str) {
668
+ if (!str) return null;
669
+ const match = str.match(/^(\d+)(h|m|s)?$/i);
670
+ if (!match) return null;
671
+ const val = parseInt(match[1], 10);
672
+ const unit = (match[2] || 'm').toLowerCase();
673
+ if (unit === 'h') return val * 60 * 60 * 1000;
674
+ if (unit === 'm') return val * 60 * 1000;
675
+ if (unit === 's') return val * 1000;
676
+ return null;
677
+ }
240
678
 
241
- fs.writeFileSync(prdPath, JSON.stringify(prd, null, 2));
242
- fs.appendFileSync(progressPath, `\n🔁 Autopilot starting at ${new Date().toISOString()}\n`);
243
- fs.appendFileSync(progressPath, ` Type: ${type}\n`);
244
- fs.appendFileSync(progressPath, ` Description: ${description}\n`);
679
+ function wrapText(text, width = 74) {
680
+ const normalized = String(text || '').replace(/\s+/g, ' ').trim();
681
+ if (!normalized) return [''];
245
682
 
246
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
247
- console.log('PRD generated:');
248
- console.log(JSON.stringify(prd, null, 2));
249
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
250
- console.log('');
683
+ const words = normalized.split(' ');
684
+ const lines = [];
685
+ let current = '';
251
686
 
252
- if (dryRun) {
253
- console.log('[DRY RUN] Would execute plan → do → review cycle');
254
- console.log('[DRY RUN] PRD saved to prd.json');
255
- return;
687
+ for (const word of words) {
688
+ if (!current) {
689
+ current = word;
690
+ continue;
691
+ }
692
+ if ((current + ' ' + word).length <= width) {
693
+ current += ' ' + word;
694
+ } else {
695
+ lines.push(current);
696
+ current = word;
697
+ }
256
698
  }
257
699
 
258
- // Context for prompts
259
- const context = {
260
- mapPath: 'atris/MAP.md',
261
- todoPath: 'atris/TODO.md',
262
- journalPath: getLogPath().logFile,
263
- personaPath: 'atris/PERSONA.md'
264
- };
700
+ if (current) lines.push(current);
701
+ return lines;
702
+ }
265
703
 
266
- // Main loop
267
- for (let iteration = 1; iteration <= maxIterations; iteration++) {
268
- console.log(`\n${'═'.repeat(60)}`);
269
- console.log(`ITERATION ${iteration}/${maxIterations}`);
270
- console.log(`${'═'.repeat(60)}`);
704
+ function compactWrappedText(text, width = 74, maxLines = 2) {
705
+ const lines = wrapText(text, width);
706
+ if (lines.length <= maxLines) return lines;
271
707
 
272
- fs.appendFileSync(progressPath, `\n--- Iteration ${iteration} ---\n`);
708
+ const kept = lines.slice(0, maxLines);
709
+ const head = kept.slice(0, -1);
710
+ let tail = kept[kept.length - 1].replace(/[ .,;:!?-]+$/, '');
711
+ if (tail.length >= width) {
712
+ tail = tail.slice(0, width - 1).replace(/[ .,;:!?-]+$/, '');
713
+ }
714
+ return [...head, `${tail}…`];
715
+ }
273
716
 
274
- try {
275
- // PLAN phase
276
- console.log('\n[1/3] PLAN — Navigator creating tasks...');
277
- await executePhase('plan', prd, { ...context, verbose });
278
- fs.appendFileSync(progressPath, `[${new Date().toISOString()}] PLAN complete\n`);
717
+ function printPlainBlock(text) {
718
+ for (const line of String(text || '').split('\n')) {
719
+ console.log(` ${line}`);
720
+ }
721
+ console.log('');
722
+ }
279
723
 
280
- // DO phase
281
- console.log('\n[2/3] DO Executor building...');
282
- await executePhase('do', prd, { ...context, verbose });
283
- fs.appendFileSync(progressPath, `[${new Date().toISOString()}] DO complete\n`);
724
+ function getTickStatus(cwd) {
725
+ const atrisDir = path.join(cwd, 'atris');
726
+
727
+ let identity = '(no identity set — see atris/PERSONA.md)';
728
+ const personaPath = path.join(atrisDir, 'PERSONA.md');
729
+ if (fs.existsSync(personaPath)) {
730
+ const lines = fs.readFileSync(personaPath, 'utf8').split('\n');
731
+ for (const l of lines) {
732
+ const t = l.trim();
733
+ if (t && !t.startsWith('#') && !t.startsWith('>') && !t.startsWith('---') && !t.startsWith('*') && !t.startsWith('-') && !t.startsWith('|')) {
734
+ identity = t;
735
+ break;
736
+ }
737
+ }
738
+ }
284
739
 
285
- // REVIEW phase
286
- console.log('\n[3/3] REVIEW Validator checking...');
287
- const reviewResult = await executePhase('review', prd, { ...context, verbose });
288
- fs.appendFileSync(progressPath, `[${new Date().toISOString()}] REVIEW complete\n`);
740
+ let slug = '(no endgame active — feed inbox or /endgame)';
741
+ let horizon = '';
742
+ const todoPath = path.join(atrisDir, 'TODO.md');
743
+ let remaining = 0;
744
+ let completedEndgame = 0;
745
+ if (fs.existsSync(todoPath)) {
746
+ const todoContent = fs.readFileSync(todoPath, 'utf8');
747
+ const endgameMatch = todoContent.match(/##\s+Endgame\s*\n([\s\S]*?)(?=\n##|$)/);
748
+ if (endgameMatch) {
749
+ const slugMatch = endgameMatch[1].match(/\*\*Slug:\*\*\s*(.+)/);
750
+ const horizonMatch = endgameMatch[1].match(/\*\*Horizon:\*\*\s*(.+)/);
751
+ if (slugMatch) slug = slugMatch[1].trim();
752
+ if (horizonMatch) horizon = horizonMatch[1].trim();
753
+ }
754
+ const todo = parseTodo(todoPath);
755
+ remaining = todo.backlog.filter(t => t.tag === 'endgame').length;
756
+ completedEndgame = todo.completed.filter(t => /^[A-Z]\d+[a-z]?[:\s]/.test((t.title || '').trim())).length;
757
+ }
289
758
 
290
- // Check if complete
291
- if (reviewResult.complete) {
292
- // Update PRD
293
- prd.stories[0].passes = true;
294
- fs.writeFileSync(prdPath, JSON.stringify(prd, null, 2));
759
+ const total = remaining + completedEndgame;
760
+ const done = completedEndgame;
761
+ const time = new Date().toLocaleTimeString('en-US', {
762
+ hour: 'numeric',
763
+ minute: '2-digit'
764
+ }).toLowerCase();
295
765
 
296
- // Log to journal
297
- logToJournal(description, type);
766
+ return { time, identity, slug, horizon, total, done, remaining };
767
+ }
298
768
 
299
- fs.appendFileSync(progressPath, `\n🏁 Autopilot finished at ${new Date().toISOString()} - SUCCESS\n`);
769
+ function renderHumanTickIntro(status, options = {}) {
770
+ const modeLabel = options.auto ? 'autonomous' : 'interactive';
771
+ const horizonLines = status.horizon
772
+ ? compactWrappedText(`Horizon: ${status.slug}. ${status.horizon}`, 74, 2)
773
+ : compactWrappedText(`Horizon: ${status.slug}.`, 74, 2);
774
+ const progressSentence = status.remaining === 0
775
+ ? 'No tagged endgame steps are queued right now.'
776
+ : status.total > 0
777
+ ? `Progress is ${status.done} of ${status.total} endgame steps.`
778
+ : 'No endgame steps are queued right now.';
779
+
780
+ return [
781
+ status.time,
782
+ `I am starting an autopilot tick in ${modeLabel} mode. Limit: ${options.durationLabel || 'until clean'}.`,
783
+ ...horizonLines,
784
+ progressSentence,
785
+ 'Next I will scan the workspace and choose one task.'
786
+ ].join('\n');
787
+ }
300
788
 
301
- console.log('');
302
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
303
- console.log('🎉 AUTOPILOT COMPLETE');
304
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
305
- console.log('');
306
- console.log(`✓ ${type === 'bug' ? 'Bug fixed' : 'Feature implemented'}: ${description}`);
307
- console.log('✓ All acceptance criteria passed');
308
- console.log('✓ Logged to journal');
309
- console.log('');
789
+ function renderHumanSuggestion(suggestion, step, maxIterations) {
790
+ return [
791
+ `I picked task ${step} of ${maxIterations}.`,
792
+ ...compactWrappedText(`Task: ${suggestion.task}`, 74, 2),
793
+ ...compactWrappedText(`Why now: ${suggestion.why}`, 74, 2),
794
+ 'Next: approve it, skip it, or stop the loop.'
795
+ ].join('\n');
796
+ }
310
797
 
311
- // Clean up temp files on success
312
- try { fs.unlinkSync(path.join(process.cwd(), 'prd.json')); } catch {}
313
- try { fs.unlinkSync(path.join(process.cwd(), 'progress.txt')); } catch {}
798
+ /**
799
+ * Print the visual ASCII tick status block. Shows identity (forward / flow),
800
+ * current endgame slug + horizon (backward / endgame), and progress through
801
+ * endgame steps. Two halves of the same engine — flow and endgame — meeting
802
+ * at the next tick. Called at the start of each autopilot run.
803
+ */
804
+ function printTickStatus(cwd, options = {}) {
805
+ const status = getTickStatus(cwd);
806
+ if (!options.verbose) {
807
+ printPlainBlock(renderHumanTickIntro(status, options));
808
+ return;
809
+ }
314
810
 
315
- return { success: true, iterations: iteration };
316
- }
811
+ const W = 64; // total box width including borders
812
+ const C = W - 4; // content width per line
317
813
 
318
- console.log(`\n⚠️ Review found issues, continuing to iteration ${iteration + 1}...`);
814
+ const trim = (s, w) => {
815
+ if (!s) return '';
816
+ s = String(s).replace(/\s+/g, ' ').trim();
817
+ if (s.length > w) return s.slice(0, Math.max(0, w - 1)) + '…';
818
+ return s;
819
+ };
820
+ const line = (content) => ' │ ' + content.padEnd(C) + ' │';
319
821
 
320
- } catch (error) {
321
- console.error(`\n❌ Error in iteration ${iteration}: ${error.message}`);
322
- fs.appendFileSync(progressPath, `[${new Date().toISOString()}] ERROR: ${error.message}\n`);
822
+ const barWidth = 12;
823
+ const filled = status.total > 0 ? Math.round((status.done / status.total) * barWidth) : 0;
824
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
825
+ const ratio = status.total > 0 ? `${status.done}/${status.total}` : '0/0';
826
+ const time = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
323
827
 
324
- if (iteration === maxIterations) {
325
- throw error;
326
- }
828
+ console.log('');
829
+ console.log(' ┌' + '─'.repeat(W - 2) + '┐');
830
+ console.log(line(`tick · ${time}`));
831
+ console.log(line(`identity: ${trim(status.identity, C - 11)}`));
832
+ console.log(line(`horizon: ${trim(status.slug, C - 11)}`));
833
+ if (status.horizon) {
834
+ console.log(line(` ${trim(status.horizon, C - 11)}`));
835
+ }
836
+ console.log(line(`progress: ${bar} ${ratio} endgame steps`));
837
+ console.log(' └' + '─'.repeat(W - 2) + '┘');
838
+ }
327
839
 
328
- console.log('Continuing to next iteration...');
840
+ /**
841
+ * Count consecutive idle-tick markers at the bottom of today's journal `## Notes`.
842
+ * Idle marker is the literal substring `0 tasks in 0s` (case-insensitive). Scans
843
+ * the Notes section bottom-up; the first non-marker, non-blank line breaks the
844
+ * streak. Returns 0 when the journal is missing or has no `## Notes` section.
845
+ * Pure read-only — no side effects, no callers yet.
846
+ */
847
+ function getIdleTickCount(cwd) {
848
+ const now = new Date();
849
+ const yyyy = now.getFullYear();
850
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
851
+ const dd = String(now.getDate()).padStart(2, '0');
852
+ const journalPath = path.join(cwd, 'atris', 'logs', String(yyyy), `${yyyy}-${mm}-${dd}.md`);
853
+
854
+ if (!fs.existsSync(journalPath)) return 0;
855
+
856
+ const content = fs.readFileSync(journalPath, 'utf8');
857
+ const notesMatch = content.match(/##\s+Notes\s*\n([\s\S]*?)(?=\n##\s|$)/);
858
+ if (!notesMatch) return 0;
859
+
860
+ const marker = '0 tasks in 0s';
861
+ const lines = notesMatch[1].split('\n');
862
+ let count = 0;
863
+ for (let i = lines.length - 1; i >= 0; i--) {
864
+ const line = lines[i];
865
+ if (!line.trim()) continue;
866
+ if (line.toLowerCase().includes(marker)) {
867
+ count += 1;
868
+ continue;
329
869
  }
870
+ break;
871
+ }
872
+ return count;
873
+ }
874
+
875
+ /**
876
+ * Read recent project signals for horizon proposals. Returns:
877
+ * - recentCommits: string[] from `git log --oneline -20` (empty on failure)
878
+ * - wikiHealth: string of `atris/wiki/STATUS.md` contents, or null if missing
879
+ * - recentLessons: string[] of last 10 non-empty lines from `atris/lessons.md`
880
+ * Pure read-only — try/catch each source, safe defaults on failure. No callers yet.
881
+ */
882
+ function getRecentSignals(cwd) {
883
+ let recentCommits = [];
884
+ try {
885
+ const out = execSync('git log --oneline -20', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
886
+ recentCommits = out.split('\n').filter(l => l.trim().length > 0);
887
+ } catch {
888
+ recentCommits = [];
330
889
  }
331
890
 
332
- // Max iterations reached
333
- fs.appendFileSync(progressPath, `\n⏰ Autopilot stopped at ${new Date().toISOString()} - max iterations\n`);
891
+ let wikiHealth = null;
892
+ try {
893
+ const wikiStatusPath = path.join(cwd, 'atris', 'wiki', 'STATUS.md');
894
+ if (fs.existsSync(wikiStatusPath)) {
895
+ wikiHealth = fs.readFileSync(wikiStatusPath, 'utf8');
896
+ }
897
+ } catch {
898
+ wikiHealth = null;
899
+ }
334
900
 
335
- console.log('');
336
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
337
- console.log('⏰ AUTOPILOT STOPPED Max iterations reached');
338
- console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
339
- console.log('');
340
- console.log('Check progress.txt and prd.json for details.');
341
- console.log('Run `atris autopilot` again to continue, or fix issues manually.');
342
- console.log('');
901
+ let recentLessons = [];
902
+ try {
903
+ const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
904
+ if (fs.existsSync(lessonsPath)) {
905
+ const lines = fs.readFileSync(lessonsPath, 'utf8').split('\n').filter(l => l.trim().length > 0);
906
+ recentLessons = lines.slice(-10);
907
+ }
908
+ } catch {
909
+ recentLessons = [];
910
+ }
343
911
 
344
- return { success: false, iterations: maxIterations };
912
+ return { recentCommits, wikiHealth, recentLessons };
345
913
  }
346
914
 
347
915
  /**
348
- * Pick next item from TODO.md backlog and run autopilot on it
916
+ * Propose 3 candidate next horizons for the autopilot loop. Combines
917
+ * `getIdleTickCount` + `getRecentSignals` into a prompt asking the LLM
918
+ * to imagine what to work on next, spawns `claude -p`, and parses the
919
+ * JSON response into `[{ title, confidence, rationale }]`.
920
+ *
921
+ * Throws on subprocess failure or when fewer than 3 valid candidates
922
+ * come back. Callers are responsible for catching and falling back.
349
923
  */
350
- async function autopilotFromTodo(options = {}) {
351
- const targetDir = path.join(process.cwd(), 'atris');
352
- const todoPath = path.join(targetDir, 'TODO.md');
924
+ async function proposeCandidateHorizons(cwd) {
925
+ const idleTicks = getIdleTickCount(cwd);
926
+ const signals = getRecentSignals(cwd);
927
+
928
+ const commitsBlock = signals.recentCommits.length > 0
929
+ ? signals.recentCommits.slice(0, 20).join('\n')
930
+ : '(no recent commits)';
931
+ const wikiBlock = signals.wikiHealth
932
+ ? signals.wikiHealth.slice(0, 2000)
933
+ : '(no atris/wiki/STATUS.md)';
934
+ const lessonsBlock = signals.recentLessons.length > 0
935
+ ? signals.recentLessons.join('\n')
936
+ : '(no atris/lessons.md)';
937
+
938
+ const prompt = `You are helping the Atris autopilot loop imagine the next horizon to pursue.
939
+
940
+ The loop has been idle for ${idleTicks} tick(s) (ticks where 0 tasks were picked up in 0s).
353
941
 
354
- if (!fs.existsSync(todoPath)) {
355
- throw new Error('atris/TODO.md not found. Run "atris init" first.');
942
+ Recent commits (git log --oneline -20):
943
+ ${commitsBlock}
944
+
945
+ Wiki STATUS (atris/wiki/STATUS.md):
946
+ ${wikiBlock}
947
+
948
+ Recent lessons (tail of atris/lessons.md):
949
+ ${lessonsBlock}
950
+
951
+ Based on these signals, propose exactly 3 candidate next horizons for the loop to pursue. Each candidate must be:
952
+ - A real, concrete horizon tied to what the signals actually reveal (no placeholders, no "candidate 1", no TODO/FIXME stubs).
953
+ - Something the loop can actually work on in this repo right now.
954
+ - Distinct from the other two candidates.
955
+
956
+ Output STRICT JSON ONLY — no prose, no markdown code fences, no commentary. The output must be a single JSON array with exactly 3 objects, each shaped:
957
+
958
+ [
959
+ { "title": "one-line horizon title", "confidence": 0.0-1.0, "rationale": "one sentence why this is worth pursuing now" },
960
+ { "title": "...", "confidence": 0.0-1.0, "rationale": "..." },
961
+ { "title": "...", "confidence": 0.0-1.0, "rationale": "..." }
962
+ ]
963
+
964
+ Reply with the JSON array and nothing else.`;
965
+
966
+ const tmpFile = path.join(cwd, '.autopilot-horizons-prompt.tmp');
967
+ fs.writeFileSync(tmpFile, prompt);
968
+
969
+ let output = '';
970
+ try {
971
+ const cmd = `claude -p "$(cat '${tmpFile.replace(/'/g, "'\\''")}')"`;
972
+ const env = { ...process.env };
973
+ delete env.CLAUDECODE;
974
+ output = execSync(cmd, {
975
+ cwd,
976
+ encoding: 'utf8',
977
+ timeout: PHASE_TIMEOUT,
978
+ stdio: ['ignore', 'pipe', 'pipe'],
979
+ maxBuffer: 10 * 1024 * 1024,
980
+ env
981
+ }).toString();
982
+ } finally {
983
+ try { fs.unlinkSync(tmpFile); } catch {}
356
984
  }
357
985
 
358
- const content = fs.readFileSync(todoPath, 'utf8');
986
+ const start = output.indexOf('[');
987
+ const end = output.lastIndexOf(']');
988
+ if (start === -1 || end === -1 || end <= start) {
989
+ throw new Error('proposeCandidateHorizons: claude -p returned no JSON array');
990
+ }
991
+ const jsonText = output.slice(start, end + 1);
359
992
 
360
- // Parse backlog items
361
- const backlogMatch = content.match(/## Backlog\n([\s\S]*?)(?=\n##|$)/);
362
- if (!backlogMatch) {
363
- console.log('No backlog items found in TODO.md');
364
- return;
993
+ let parsed;
994
+ try {
995
+ parsed = JSON.parse(jsonText);
996
+ } catch (err) {
997
+ throw new Error(`proposeCandidateHorizons: JSON parse failed — ${err.message}`);
365
998
  }
366
999
 
367
- // Support both formats: "- [ ] Task" (checkbox) and "- **T1:** Task" (Atris standard)
368
- const backlogLines = backlogMatch[1].split('\n').filter(line => {
369
- const trimmed = line.trim();
370
- return trimmed.startsWith('- [ ]') || trimmed.match(/^- \*\*T\d+:\*\*\s/);
371
- });
1000
+ if (!Array.isArray(parsed)) {
1001
+ throw new Error('proposeCandidateHorizons: expected JSON array');
1002
+ }
372
1003
 
373
- if (backlogLines.length === 0) {
374
- console.log('No unchecked items in backlog. TODO.md is at target state (0 tasks).');
375
- return;
1004
+ const candidates = parsed
1005
+ .filter(c => c && typeof c === 'object')
1006
+ .map(c => ({
1007
+ title: typeof c.title === 'string' ? c.title.trim() : '',
1008
+ confidence: typeof c.confidence === 'number' ? c.confidence : Number(c.confidence),
1009
+ rationale: typeof c.rationale === 'string' ? c.rationale.trim() : ''
1010
+ }))
1011
+ .filter(c =>
1012
+ c.title.length > 0 &&
1013
+ typeof c.confidence === 'number' && !Number.isNaN(c.confidence) &&
1014
+ c.confidence >= 0 && c.confidence <= 1 &&
1015
+ c.rationale.length > 0
1016
+ );
1017
+
1018
+ if (candidates.length < 3) {
1019
+ throw new Error(`proposeCandidateHorizons: expected 3 valid candidates, got ${candidates.length}`);
376
1020
  }
377
1021
 
378
- // Pick first item
379
- const firstItem = backlogLines[0].trim();
380
- // Try checkbox format first, then Atris standard format
381
- const itemMatch = firstItem.match(/- \[ \] (.+)/) || firstItem.match(/- \*\*T\d+:\*\*\s*(.+)/);
1022
+ return candidates.slice(0, 3);
1023
+ }
1024
+
1025
+ async function autopilotAtris(description, options = {}) {
1026
+ const {
1027
+ maxIterations = 100,
1028
+ verbose = false,
1029
+ dryRun = false,
1030
+ auto = false,
1031
+ duration = null
1032
+ } = options;
1033
+
1034
+ const cwd = process.cwd();
1035
+ const atrisDir = path.join(cwd, 'atris');
382
1036
 
383
- if (!itemMatch) {
384
- throw new Error('Could not parse backlog item');
1037
+ if (!fs.existsSync(atrisDir)) {
1038
+ console.error('No atris/ folder. Run "atris init" first.');
1039
+ process.exit(1);
385
1040
  }
386
1041
 
387
- const description = itemMatch[1].trim();
1042
+ try { execSync('which claude', { stdio: 'pipe' }); } catch {
1043
+ console.error('claude CLI not found. Install Claude Code first.');
1044
+ process.exit(1);
1045
+ }
388
1046
 
389
- // Detect if it's a bug
390
- const isBug = /bug|fix|broken|error|issue|crash/i.test(description);
1047
+ const durationMs = parseDuration(duration);
1048
+ const durationLabel = duration
1049
+ ? duration
1050
+ : (maxIterations < 100 ? `${maxIterations} task${maxIterations === 1 ? '' : 's'}` : 'until clean');
1051
+
1052
+ if (verbose) {
1053
+ console.log('');
1054
+ console.log(' atris autopilot v' + pkg.version);
1055
+ console.log(` mode: ${auto ? 'autonomous' : 'interactive'} · limit: ${durationLabel}`);
1056
+ printTickStatus(cwd, { verbose: true });
1057
+ console.log('');
1058
+ console.log(' scanning workspace for work...');
1059
+ console.log('');
1060
+ } else {
1061
+ printTickStatus(cwd, { auto, durationLabel });
1062
+ }
391
1063
 
392
- console.log(`\nPicked from backlog: "${description}"`);
393
- console.log(`Detected type: ${isBug ? 'bug' : 'feature'}`);
394
- console.log('');
1064
+ // Seed inbox if a description was given
1065
+ if (description) {
1066
+ ensureLogDirectory();
1067
+ const { logFile, dateFormatted } = getLogPath();
1068
+ if (!fs.existsSync(logFile)) createLogFile(logFile, dateFormatted);
1069
+
1070
+ let content = fs.readFileSync(logFile, 'utf8');
1071
+ const idMatch = content.match(/\*\*I(\d+):/g);
1072
+ const nextId = idMatch
1073
+ ? Math.max(...idMatch.map(m => parseInt(m.match(/\d+/)[0]))) + 1
1074
+ : 1;
1075
+
1076
+ const entry = `- **I${nextId}:** ${description}`;
1077
+ if (content.includes('## Inbox')) {
1078
+ content = content.replace(/(## Inbox[^\n]*\n)/, `$1${entry}\n`);
1079
+ } else {
1080
+ content += `\n## Inbox\n${entry}\n`;
1081
+ }
1082
+ fs.writeFileSync(logFile, content);
1083
+ if (verbose) {
1084
+ console.log(` added to inbox: "${description}"`);
1085
+ console.log('');
1086
+ } else {
1087
+ printPlainBlock([
1088
+ 'I added this request to the inbox.',
1089
+ `"${description}"`,
1090
+ '',
1091
+ 'Next I will scan the workspace with that request in mind.'
1092
+ ].join('\n'));
1093
+ }
1094
+ }
395
1095
 
396
- return autopilotAtris(description, {
397
- ...options,
398
- type: isBug ? 'bug' : 'feature'
399
- });
1096
+ const startTime = Date.now();
1097
+ let completed = 0;
1098
+ const skipped = new Set();
1099
+ let tickOutcome = 'halted';
1100
+ let tickOutcomeText = 'I stopped for a manual check.';
1101
+ let tickNextStep = 'look for new work';
1102
+ let lastTaskTitle = null;
1103
+
1104
+ for (let i = 0; i < maxIterations; i++) {
1105
+ // Check time budget
1106
+ if (durationMs && (Date.now() - startTime) >= durationMs) {
1107
+ const mins = Math.round((Date.now() - startTime) / 60000);
1108
+ if (verbose) {
1109
+ console.log(` time's up (${mins}m elapsed). stopping.`);
1110
+ } else {
1111
+ printPlainBlock([
1112
+ `I hit the time limit after ${mins} minute${mins === 1 ? '' : 's'}.`,
1113
+ '',
1114
+ 'Next I am stopping the loop.'
1115
+ ].join('\n'));
1116
+ }
1117
+ break;
1118
+ }
1119
+
1120
+ const suggestion = await suggestNextTask(cwd, skipped);
1121
+
1122
+ if (!suggestion) {
1123
+ tickOutcome = 'idle';
1124
+ tickOutcomeText = 'I checked the repo and found no work to pick up this tick.';
1125
+ tickNextStep = 'scan for new signals and propose the next horizon';
1126
+ if (verbose) {
1127
+ console.log(' nothing to do. workspace is clean.');
1128
+ } else {
1129
+ printPlainBlock([
1130
+ 'I found no work this tick.',
1131
+ 'The workspace looks clean.',
1132
+ '',
1133
+ 'Next I will stop until a new signal appears.'
1134
+ ].join('\n'));
1135
+ }
1136
+ break;
1137
+ }
1138
+
1139
+ // Present the suggestion
1140
+ if (verbose) {
1141
+ console.log(` ── suggestion ${i + 1}/${maxIterations} ──────────────────────────────`);
1142
+ console.log('');
1143
+ console.log(` ${suggestion.task}`);
1144
+ console.log(` why: ${suggestion.why}`);
1145
+ console.log(` kind: ${suggestion.kind}`);
1146
+ console.log('');
1147
+ } else {
1148
+ printPlainBlock(renderHumanSuggestion(suggestion, i + 1, maxIterations));
1149
+ }
1150
+
1151
+ if (dryRun) {
1152
+ if (verbose) {
1153
+ console.log(' (dry run — would execute this)');
1154
+ console.log('');
1155
+ } else {
1156
+ printPlainBlock([
1157
+ 'This was a dry run, so I did not execute the task.',
1158
+ '',
1159
+ 'Next I will look for another task on the next pass.'
1160
+ ].join('\n'));
1161
+ }
1162
+ // Track as skipped so dry-run shows variety
1163
+ skipped.add(suggestion.task);
1164
+ if (suggestion.kind === 'docs') skipped.add('fix-map-refs');
1165
+ if (suggestion.kind === 'review') skipped.add('review');
1166
+ if (suggestion.kind === 'lessons') skipped.add('lessons');
1167
+ if (suggestion.kind === 'feature') skipped.add('incomplete-features');
1168
+ continue;
1169
+ }
1170
+
1171
+ // Get approval
1172
+ let decision;
1173
+ if (auto) {
1174
+ decision = 'approve';
1175
+ } else {
1176
+ decision = await askApproval();
1177
+ }
1178
+
1179
+ if (decision === 'quit') {
1180
+ if (verbose) {
1181
+ console.log(' stopped.');
1182
+ } else {
1183
+ printPlainBlock([
1184
+ 'I stopped the loop.',
1185
+ '',
1186
+ 'Next nothing will run until autopilot starts again.'
1187
+ ].join('\n'));
1188
+ }
1189
+ break;
1190
+ }
1191
+
1192
+ if (decision === 'skip') {
1193
+ skipped.add(suggestion.task);
1194
+ if (suggestion.kind === 'staleness') skipped.add(`recompile:${suggestion.task}`);
1195
+ if (suggestion.kind === 'docs') skipped.add('fix-map-refs');
1196
+ if (suggestion.kind === 'review') skipped.add('review');
1197
+ if (suggestion.kind === 'lessons') skipped.add('lessons');
1198
+ if (suggestion.kind === 'feature') skipped.add('incomplete-features');
1199
+ if (verbose) {
1200
+ console.log(' skipped.');
1201
+ console.log('');
1202
+ } else {
1203
+ printPlainBlock([
1204
+ 'I skipped that task.',
1205
+ '',
1206
+ 'Next I will look for another one.'
1207
+ ].join('\n'));
1208
+ }
1209
+ continue;
1210
+ }
1211
+
1212
+ // Execute: plan → do → review
1213
+ lastTaskTitle = suggestion.task;
1214
+ const context = { task: suggestion.task, kind: suggestion.kind };
1215
+
1216
+ try {
1217
+ if (verbose) {
1218
+ console.log('');
1219
+ console.log(' planning...');
1220
+ } else {
1221
+ printPlainBlock([
1222
+ 'I am running that task now.',
1223
+ '',
1224
+ 'Next I will report what happened and whether review passed.'
1225
+ ].join('\n'));
1226
+ }
1227
+ const execution = runTaskOnce(context, { verbose });
1228
+ const planTime = execution.phaseResults.plan.elapsedSeconds;
1229
+ if (verbose) console.log(` planned (${planTime}s)`);
1230
+
1231
+ if (verbose) console.log(' building...');
1232
+ const doTime = execution.phaseResults.do.elapsedSeconds;
1233
+ if (verbose) console.log(` built (${doTime}s)`);
1234
+
1235
+ if (verbose) console.log(' reviewing...');
1236
+ const reviewOutput = execution.reviewOutput;
1237
+ const reviewTime = execution.phaseResults.review.elapsedSeconds;
1238
+
1239
+ if (reviewOutput.includes('failed')) {
1240
+ tickOutcome = 'halted';
1241
+ tickOutcomeText = `I built "${lastTaskTitle}" but review flagged issues.`;
1242
+ tickNextStep = 'wait for a human to check the review output';
1243
+ if (verbose) {
1244
+ console.log(` review flagged issues (${reviewTime}s). stopping for manual check.`);
1245
+ } else {
1246
+ printPlainBlock([
1247
+ `I planned and built the task, but review found issues after ${reviewTime}s.`,
1248
+ '',
1249
+ 'Next I stopped for a manual check.'
1250
+ ].join('\n'));
1251
+ }
1252
+ break;
1253
+ }
1254
+ if (verbose) console.log(` reviewed (${reviewTime}s)`);
1255
+
1256
+ completed++;
1257
+ tickOutcome = 'built';
1258
+ tickOutcomeText = `I planned, built, and reviewed "${suggestion.task}".`;
1259
+ tickNextStep = 'pick the next endgame task';
1260
+ logCompletion(suggestion.task);
1261
+ if (verbose) {
1262
+ console.log(` done. ${completed} task${completed > 1 ? 's' : ''} completed.`);
1263
+ console.log('');
1264
+ } else {
1265
+ printPlainBlock([
1266
+ 'I planned, built, and reviewed the task.',
1267
+ `Plan took ${planTime}s, build took ${doTime}s, and review took ${reviewTime}s.`,
1268
+ '',
1269
+ `This tick has completed ${completed} task${completed > 1 ? 's' : ''}.`,
1270
+ '',
1271
+ 'Next I will look for the next task.'
1272
+ ].join('\n'));
1273
+ }
1274
+
1275
+ } catch (err) {
1276
+ tickOutcome = 'halted';
1277
+ tickOutcomeText = `I hit an error while running "${lastTaskTitle || 'a task'}": ${err.message}`;
1278
+ tickNextStep = 'stop until a human looks at the error';
1279
+ if (verbose) {
1280
+ console.error(` error: ${err.message}`);
1281
+ } else {
1282
+ printPlainBlock([
1283
+ 'I hit an error while running the task.',
1284
+ err.message,
1285
+ '',
1286
+ 'Next I stopped the loop.'
1287
+ ].join('\n'));
1288
+ }
1289
+ break;
1290
+ }
1291
+ }
1292
+
1293
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
1294
+
1295
+ // Heartbeat: plain-language tick summary into today's journal `## Notes`.
1296
+ // Guarded — a journal write failure must never crash the tick.
1297
+ try {
1298
+ const horizonSlug = readHorizonSlug(cwd);
1299
+ const time = new Date().toLocaleTimeString('en-US', {
1300
+ hour: 'numeric',
1301
+ minute: '2-digit'
1302
+ }).toLowerCase();
1303
+ const idle = tickOutcome === 'idle' || (completed === 0 && tickOutcome !== 'halted');
1304
+ appendTickSummary(cwd, {
1305
+ time,
1306
+ outcome: tickOutcomeText,
1307
+ horizon: horizonSlug === 'unset' ? null : horizonSlug,
1308
+ nextStep: tickNextStep,
1309
+ idle
1310
+ });
1311
+ } catch {
1312
+ /* journal write failure must not crash the tick */
1313
+ }
1314
+
1315
+ if (verbose) {
1316
+ console.log('');
1317
+ console.log(` autopilot finished. ${completed} task${completed !== 1 ? 's' : ''} in ${elapsed}s.`);
1318
+ console.log('');
1319
+ } else {
1320
+ printPlainBlock([
1321
+ 'Autopilot finished.',
1322
+ `It completed ${completed} task${completed !== 1 ? 's' : ''} in ${elapsed}s.`
1323
+ ].join('\n'));
1324
+ }
1325
+
1326
+ return { success: completed > 0, completed };
1327
+ }
1328
+
1329
+ /**
1330
+ * Entry point when called without a description.
1331
+ */
1332
+ async function autopilotFromTodo(options = {}) {
1333
+ return autopilotAtris(null, options);
400
1334
  }
401
1335
 
402
1336
  module.exports = {
1337
+ appendTickSummary,
403
1338
  autopilotAtris,
404
1339
  autopilotFromTodo,
405
- generatePRD
1340
+ buildPrompt,
1341
+ getIdleTickCount,
1342
+ getRecentSignals,
1343
+ getTickStatus,
1344
+ renderHumanSuggestion,
1345
+ renderHumanTickIntro,
1346
+ proposeCandidateHorizons,
1347
+ runTaskOnce,
1348
+ suggestNextTask
406
1349
  };