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.
- package/README.md +124 -34
- package/atris/CLAUDE.md +5 -1
- package/atris/atris.md +4 -0
- package/atris/features/README.md +24 -0
- package/atris/skills/autopilot/SKILL.md +74 -75
- package/atris/skills/endgame/SKILL.md +179 -0
- package/atris/skills/flow/SKILL.md +121 -0
- package/atris/skills/improve/SKILL.md +84 -0
- package/atris/skills/loop/SKILL.md +72 -0
- package/atris/skills/wiki/SKILL.md +61 -0
- package/atris/team/executor/MEMBER.md +10 -4
- package/atris/team/navigator/MEMBER.md +2 -0
- package/atris/team/validator/MEMBER.md +8 -5
- package/atris.md +33 -0
- package/bin/atris.js +210 -41
- package/commands/activate.js +28 -2
- package/commands/align.js +720 -0
- package/commands/auth.js +75 -2
- package/commands/autopilot.js +1213 -270
- package/commands/browse.js +100 -0
- package/commands/business.js +785 -12
- package/commands/clean.js +107 -2
- package/commands/computer.js +429 -0
- package/commands/context-sync.js +78 -8
- package/commands/experiments.js +351 -0
- package/commands/feedback.js +150 -0
- package/commands/fleet.js +395 -0
- package/commands/fork.js +127 -0
- package/commands/init.js +50 -1
- package/commands/learn.js +407 -0
- package/commands/lifecycle.js +94 -0
- package/commands/loop.js +114 -0
- package/commands/publish.js +129 -0
- package/commands/pull.js +369 -38
- package/commands/push.js +283 -246
- package/commands/review.js +149 -0
- package/commands/run.js +76 -43
- package/commands/serve.js +360 -0
- package/commands/setup.js +1 -1
- package/commands/soul.js +381 -0
- package/commands/status.js +119 -1
- package/commands/sync.js +147 -1
- package/commands/terminal.js +201 -0
- package/commands/wiki.js +376 -0
- package/commands/workflow.js +191 -74
- package/commands/workspace-clean.js +3 -3
- package/lib/endstate.js +259 -0
- package/lib/learnings.js +235 -0
- package/lib/manifest.js +1 -0
- package/lib/todo.js +9 -5
- package/lib/wiki.js +578 -0
- package/package.json +2 -2
- package/utils/api.js +40 -35
- package/utils/auth.js +1 -0
package/commands/autopilot.js
CHANGED
|
@@ -1,172 +1,559 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Atris Autopilot
|
|
2
|
+
* Atris Autopilot — Suggest, justify, execute. One task at a time.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
//
|
|
16
|
-
const DEFAULT_MAX_ITERATIONS = 5;
|
|
19
|
+
const PHASE_TIMEOUT = 600000; // 10 min per phase
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
|
-
*
|
|
22
|
+
* Scan workspace for the next thing worth doing.
|
|
23
|
+
* Returns { task, why, kind } or null.
|
|
20
24
|
*/
|
|
21
|
-
function
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
+
suggestions.sort((a, b) => a.priority - b.priority);
|
|
220
|
+
return suggestions[0];
|
|
108
221
|
}
|
|
109
222
|
|
|
110
223
|
/**
|
|
111
|
-
*
|
|
224
|
+
* Prompt for approval. Returns 'approve', 'skip', or 'quit'.
|
|
112
225
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
279
|
+
* Build context-aware file list for prompts.
|
|
168
280
|
*/
|
|
169
|
-
function
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
646
|
+
/**
|
|
647
|
+
* Read the current endgame slug from atris/TODO.md. Returns 'unset' on miss.
|
|
648
|
+
*/
|
|
649
|
+
function readHorizonSlug(cwd) {
|
|
219
650
|
try {
|
|
220
|
-
|
|
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
|
-
|
|
657
|
+
return 'unset';
|
|
223
658
|
}
|
|
659
|
+
}
|
|
224
660
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
250
|
-
console.log('');
|
|
683
|
+
const words = normalized.split(' ');
|
|
684
|
+
const lines = [];
|
|
685
|
+
let current = '';
|
|
251
686
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
766
|
+
return { time, identity, slug, horizon, total, done, remaining };
|
|
767
|
+
}
|
|
298
768
|
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
811
|
+
const W = 64; // total box width including borders
|
|
812
|
+
const C = W - 4; // content width per line
|
|
317
813
|
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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 {
|
|
912
|
+
return { recentCommits, wikiHealth, recentLessons };
|
|
345
913
|
}
|
|
346
914
|
|
|
347
915
|
/**
|
|
348
|
-
*
|
|
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
|
|
351
|
-
const
|
|
352
|
-
const
|
|
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
|
-
|
|
355
|
-
|
|
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
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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 (!
|
|
384
|
-
|
|
1037
|
+
if (!fs.existsSync(atrisDir)) {
|
|
1038
|
+
console.error('No atris/ folder. Run "atris init" first.');
|
|
1039
|
+
process.exit(1);
|
|
385
1040
|
}
|
|
386
1041
|
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
const
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
1340
|
+
buildPrompt,
|
|
1341
|
+
getIdleTickCount,
|
|
1342
|
+
getRecentSignals,
|
|
1343
|
+
getTickStatus,
|
|
1344
|
+
renderHumanSuggestion,
|
|
1345
|
+
renderHumanTickIntro,
|
|
1346
|
+
proposeCandidateHorizons,
|
|
1347
|
+
runTaskOnce,
|
|
1348
|
+
suggestNextTask
|
|
406
1349
|
};
|