@axplusb/kepler 1.0.10 → 2.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/package.json +5 -2
- package/src/context/retriever.mjs +42 -4
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/approval.mjs +154 -95
- package/src/core/backend-url.mjs +2 -2
- package/src/core/headless.mjs +5 -0
- package/src/core/risk-tier.mjs +239 -0
- package/src/core/tool-executor.mjs +49 -3
- package/src/onboarding/preflight.mjs +274 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/terminal/ansi.mjs +44 -22
- package/src/terminal/repl.mjs +395 -108
- package/src/ui/approval.mjs +167 -0
- package/src/ui/banner.mjs +133 -122
- package/src/ui/dock.mjs +88 -0
- package/src/ui/icons.mjs +164 -0
- package/src/ui/mission-report.mjs +264 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/status-bar.mjs +275 -0
- package/src/ui/sub-agent.mjs +152 -0
- package/src/ui/term.mjs +159 -0
- package/src/ui/tool-card.mjs +314 -0
- package/src/ui/tool-details.mjs +277 -0
|
@@ -28,6 +28,7 @@ import { execSync } from 'node:child_process';
|
|
|
28
28
|
export function createToolExecutor({
|
|
29
29
|
projectRegistry = new ProjectRegistry(),
|
|
30
30
|
skillsLoader = new SkillsLoader().load(process.cwd()),
|
|
31
|
+
checkpoints = null,
|
|
31
32
|
} = {}) {
|
|
32
33
|
const occRegistry = createToolRegistry();
|
|
33
34
|
const skillTool = occRegistry.get('Skill');
|
|
@@ -132,6 +133,30 @@ export function createToolExecutor({
|
|
|
132
133
|
return parts.length ? `\n--- Verify ---\n${parts.join('\n')}` : '';
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
// ── Solution nudge after exploration ───────────────────────
|
|
137
|
+
// After the agent has read enough code, nudge it to formulate
|
|
138
|
+
// a solution based on the goal — not to blindly edit, but to
|
|
139
|
+
// synthesize what it learned into a fix approach.
|
|
140
|
+
let _codeReadsCount = 0;
|
|
141
|
+
let _hasEdited = false;
|
|
142
|
+
|
|
143
|
+
function solutionNudge(filePath) {
|
|
144
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
145
|
+
const isCode = ['.py', '.js', '.ts', '.tsx', '.mjs', '.go', '.rs', '.java', '.rb'].includes(ext);
|
|
146
|
+
if (!isCode || _hasEdited) return '';
|
|
147
|
+
|
|
148
|
+
_codeReadsCount++;
|
|
149
|
+
if (_codeReadsCount < 4) return '';
|
|
150
|
+
|
|
151
|
+
// Only nudge once at threshold, not every read after
|
|
152
|
+
if (_codeReadsCount === 4) {
|
|
153
|
+
return '\n\n--- You have explored enough code to formulate a solution. ' +
|
|
154
|
+
'Based on what you have read, determine the fix and apply it. ' +
|
|
155
|
+
'If the approach is unclear, call plan() with your findings. ---';
|
|
156
|
+
}
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
// ── Tool mapping table ──────────────────────────────────────
|
|
136
161
|
|
|
137
162
|
const toolMap = {
|
|
@@ -258,10 +283,11 @@ export function createToolExecutor({
|
|
|
258
283
|
});
|
|
259
284
|
const output = typeof result === 'string' ? result : String(result);
|
|
260
285
|
const content = output.replace(/^\s*\d+[→\t]/gm, '');
|
|
286
|
+
const actNudge = solutionNudge(filePath);
|
|
261
287
|
return {
|
|
262
288
|
success: !isError(output),
|
|
263
289
|
content,
|
|
264
|
-
output: output + nudge,
|
|
290
|
+
output: output + nudge + actNudge,
|
|
265
291
|
_tool: 'read_file',
|
|
266
292
|
_output_type: 'file_content',
|
|
267
293
|
};
|
|
@@ -284,6 +310,10 @@ export function createToolExecutor({
|
|
|
284
310
|
await occRegistry.call('Read', { file_path: filePath, limit: 1 });
|
|
285
311
|
}
|
|
286
312
|
} catch { /* file may not exist yet */ }
|
|
313
|
+
// Checkpoint before overwrite so /undo can restore the previous content.
|
|
314
|
+
if (checkpoints && fs.existsSync(filePath)) {
|
|
315
|
+
try { checkpoints.save(filePath); } catch { /* best effort */ }
|
|
316
|
+
}
|
|
287
317
|
const result = await occRegistry.call('Write', {
|
|
288
318
|
file_path: filePath,
|
|
289
319
|
content: args.content,
|
|
@@ -380,6 +410,11 @@ export function createToolExecutor({
|
|
|
380
410
|
await occRegistry.call('Read', { file_path: filePath, limit: 1 });
|
|
381
411
|
} catch { /* best effort */ }
|
|
382
412
|
|
|
413
|
+
// Checkpoint before edit so /undo can restore the previous content.
|
|
414
|
+
if (checkpoints) {
|
|
415
|
+
try { checkpoints.save(filePath); } catch { /* best effort */ }
|
|
416
|
+
}
|
|
417
|
+
|
|
383
418
|
let result;
|
|
384
419
|
try {
|
|
385
420
|
result = await occRegistry.call('Edit', {
|
|
@@ -418,6 +453,7 @@ print('OK: replaced')
|
|
|
418
453
|
|
|
419
454
|
const wrapped = wrapResult(result, 'edit_file');
|
|
420
455
|
updateProjectIndex(filePath);
|
|
456
|
+
_hasEdited = true;
|
|
421
457
|
|
|
422
458
|
// Auto-lint the edited file
|
|
423
459
|
const lintOutput = autoLint(filePath);
|
|
@@ -484,9 +520,16 @@ print('OK: replaced')
|
|
|
484
520
|
}
|
|
485
521
|
} catch { /* rg not found or no results */ }
|
|
486
522
|
|
|
487
|
-
// Layer 2:
|
|
523
|
+
// Layer 2: Symbol search — AST-extracted functions/classes with signatures
|
|
488
524
|
if (project?.retriever) {
|
|
489
525
|
if (!project.retriever.index) project.retriever.loadIndex();
|
|
526
|
+
const symbols = project.retriever.searchSymbols(query, 5);
|
|
527
|
+
if (symbols.length > 0) {
|
|
528
|
+
const symOutput = project.retriever.formatSymbolResults(symbols);
|
|
529
|
+
parts.push(`## Symbols (functions/classes)\n${symOutput}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Layer 3: BM25 chunks — broader context when symbols aren't enough
|
|
490
533
|
const chunks = project.retriever.retrieve(query, 5);
|
|
491
534
|
if (chunks.length > 0) {
|
|
492
535
|
const bm25Output = chunks.map(c => {
|
|
@@ -609,7 +652,7 @@ print('OK: replaced')
|
|
|
609
652
|
return { success: true, files: results, _tool: 'read_files' };
|
|
610
653
|
},
|
|
611
654
|
|
|
612
|
-
// 9. delete_file + safety check
|
|
655
|
+
// 9. delete_file + safety check + checkpoint for undo
|
|
613
656
|
delete_file: async (args) => {
|
|
614
657
|
try {
|
|
615
658
|
const filePath = resolvePath(args.file_path || args.path, args);
|
|
@@ -617,6 +660,9 @@ print('OK: replaced')
|
|
|
617
660
|
if (!delCheck.safe) {
|
|
618
661
|
return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
|
|
619
662
|
}
|
|
663
|
+
if (checkpoints) {
|
|
664
|
+
try { checkpoints.save(filePath); } catch { /* best effort */ }
|
|
665
|
+
}
|
|
620
666
|
fs.unlinkSync(filePath);
|
|
621
667
|
updateProjectIndex(filePath);
|
|
622
668
|
return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight diagnostic — Mission Control (PRD-055 §9).
|
|
3
|
+
*
|
|
4
|
+
* Prints a non-blocking summary of the runtime environment before the REPL
|
|
5
|
+
* starts so the user can see what is and is not aligned:
|
|
6
|
+
*
|
|
7
|
+
* 🔭 Kepler v1.0.4 · initializing orbit
|
|
8
|
+
*
|
|
9
|
+
* [✓] Auth token
|
|
10
|
+
* [✓] OpenRouter key
|
|
11
|
+
* [✓] Backend http://127.0.0.1:8000
|
|
12
|
+
* [✓] Git repository main · clean
|
|
13
|
+
* [⚠] Linter (ruff) not found → /install ruff to enable lint_check
|
|
14
|
+
* [✓] Project map 142 files, Python + TypeScript
|
|
15
|
+
*
|
|
16
|
+
* All systems aligned. What are we building today?
|
|
17
|
+
*
|
|
18
|
+
* Checks are non-blocking. A failure shows a one-line next-step hint.
|
|
19
|
+
*
|
|
20
|
+
* Exposed via `runPreflight()` (called from REPL startup) and `/preflight`
|
|
21
|
+
* (registered as a slash command).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { execSync } from 'node:child_process';
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
import https from 'node:https';
|
|
29
|
+
import { URL } from 'node:url';
|
|
30
|
+
import { paint } from '../ui/palette.mjs';
|
|
31
|
+
import { icons } from '../ui/icons.mjs';
|
|
32
|
+
import { term } from '../ui/term.mjs';
|
|
33
|
+
|
|
34
|
+
const OK = (s) => `${paint.state.success('[✓]')} ${s}`;
|
|
35
|
+
const WARN = (s) => `${paint.state.warn('[⚠]')} ${s}`;
|
|
36
|
+
const FAIL = (s) => `${paint.state.danger('[✗]')} ${s}`;
|
|
37
|
+
|
|
38
|
+
// ── Individual checks (each returns { status, label, hint? }) ──────────
|
|
39
|
+
|
|
40
|
+
function checkAuthToken(auth) {
|
|
41
|
+
const creds = auth.loadCredentials();
|
|
42
|
+
if (creds.token) return { status: 'ok', label: `Auth token` };
|
|
43
|
+
return { status: 'warn', label: 'Auth token missing', hint: '/login to sign in' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function checkProviderKey(auth) {
|
|
47
|
+
const creds = auth.loadCredentials();
|
|
48
|
+
if (creds.openRouterKey) return { status: 'ok', label: 'OpenRouter key' };
|
|
49
|
+
if (creds.anthropicKey) return { status: 'ok', label: 'Anthropic key' };
|
|
50
|
+
if (creds.openaiKey) return { status: 'ok', label: 'OpenAI key' };
|
|
51
|
+
if (creds.googleKey) return { status: 'ok', label: 'Google key' };
|
|
52
|
+
return { status: 'warn', label: 'No model provider key configured', hint: 'set OPENROUTER_API_KEY or run /config' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function checkBackend(auth, { timeoutMs = 1500 } = {}) {
|
|
56
|
+
const creds = auth.loadCredentials();
|
|
57
|
+
const url = creds.backendUrl;
|
|
58
|
+
if (!url) return { status: 'warn', label: 'Backend not configured' };
|
|
59
|
+
try {
|
|
60
|
+
const reachable = await ping(url, timeoutMs);
|
|
61
|
+
if (reachable) return { status: 'ok', label: `Backend ${shorten(url, 48)}` };
|
|
62
|
+
return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable — check network or start backend' };
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: 'warn', label: `Backend ${shorten(url, 48)}`, hint: 'unreachable' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkGit(cwd) {
|
|
69
|
+
if (!hasGitDir(cwd)) return { status: 'warn', label: 'Not a git repository', hint: '`git init` to enable diff / checkpoints' };
|
|
70
|
+
try {
|
|
71
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', { cwd, encoding: 'utf-8' }).trim();
|
|
72
|
+
const status = execSync('git status --porcelain 2>/dev/null', { cwd, encoding: 'utf-8' });
|
|
73
|
+
const dirty = status.split('\n').filter(Boolean).length;
|
|
74
|
+
const summary = dirty > 0
|
|
75
|
+
? `${branch} · ${paint.state.warn(`${dirty} dirty`)}`
|
|
76
|
+
: `${branch} · clean`;
|
|
77
|
+
return { status: 'ok', label: `Git repository ${summary}` };
|
|
78
|
+
} catch {
|
|
79
|
+
return { status: 'warn', label: 'Git repository present but unreadable' };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function checkLinters(cwd) {
|
|
84
|
+
const present = [];
|
|
85
|
+
const missing = [];
|
|
86
|
+
for (const [name, kind] of LINTERS) {
|
|
87
|
+
if (which(name)) present.push({ name, kind });
|
|
88
|
+
else if (projectUses(cwd, kind)) missing.push({ name, kind });
|
|
89
|
+
}
|
|
90
|
+
if (present.length === 0 && missing.length === 0) {
|
|
91
|
+
return { status: 'ok', label: 'Linters none required' };
|
|
92
|
+
}
|
|
93
|
+
if (missing.length === 0) {
|
|
94
|
+
return { status: 'ok', label: `Linters ${present.map(p => p.name).join(', ')}` };
|
|
95
|
+
}
|
|
96
|
+
const hint = missing.map(m => `/install ${m.name} to enable lint_check for ${m.kind}`).join(' · ');
|
|
97
|
+
return { status: 'warn', label: `Linter (${missing.map(m => m.name).join(', ')}) not found`, hint };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const LINTERS = [
|
|
101
|
+
['ruff', 'python'],
|
|
102
|
+
['eslint', 'javascript'],
|
|
103
|
+
['tsc', 'typescript'],
|
|
104
|
+
['cargo', 'rust'],
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
function projectUses(cwd, kind) {
|
|
108
|
+
try {
|
|
109
|
+
const files = fs.readdirSync(cwd);
|
|
110
|
+
switch (kind) {
|
|
111
|
+
case 'python': return files.some(f => /\.py$/.test(f)) || files.includes('pyproject.toml') || files.includes('requirements.txt');
|
|
112
|
+
case 'javascript': return files.includes('package.json');
|
|
113
|
+
case 'typescript': return files.includes('tsconfig.json');
|
|
114
|
+
case 'rust': return files.includes('Cargo.toml');
|
|
115
|
+
default: return false;
|
|
116
|
+
}
|
|
117
|
+
} catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function checkProjectMap(cwd) {
|
|
121
|
+
try {
|
|
122
|
+
const counts = quickFileCount(cwd, { max: 5000 });
|
|
123
|
+
if (!counts.total) return { status: 'warn', label: 'Project map no files indexed yet' };
|
|
124
|
+
const langs = topLanguages(counts.byExt, 2);
|
|
125
|
+
const langStr = langs.length ? langs.join(' + ') : 'mixed';
|
|
126
|
+
return { status: 'ok', label: `Project map ${counts.total} files, ${langStr}` };
|
|
127
|
+
} catch {
|
|
128
|
+
return { status: 'warn', label: 'Project map unreadable' };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function shorten(s, n) {
|
|
135
|
+
const str = String(s || '');
|
|
136
|
+
return str.length <= n ? str : str.slice(0, n - 1) + '…';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hasGitDir(cwd) {
|
|
140
|
+
let dir = cwd;
|
|
141
|
+
for (let i = 0; i < 6; i++) {
|
|
142
|
+
if (fs.existsSync(path.join(dir, '.git'))) return true;
|
|
143
|
+
const parent = path.dirname(dir);
|
|
144
|
+
if (parent === dir) break;
|
|
145
|
+
dir = parent;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function which(name) {
|
|
151
|
+
try {
|
|
152
|
+
execSync(`command -v ${name}`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
153
|
+
return true;
|
|
154
|
+
} catch { return false; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function ping(url, timeoutMs) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
let u;
|
|
160
|
+
try { u = new URL(url); } catch { resolve(false); return; }
|
|
161
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
162
|
+
const req = lib.request({
|
|
163
|
+
hostname: u.hostname,
|
|
164
|
+
port: u.port || (u.protocol === 'https:' ? 443 : 80),
|
|
165
|
+
path: u.pathname || '/',
|
|
166
|
+
method: 'GET',
|
|
167
|
+
timeout: timeoutMs,
|
|
168
|
+
}, (res) => {
|
|
169
|
+
// Any response means the host is reachable, even 404.
|
|
170
|
+
res.resume();
|
|
171
|
+
resolve(true);
|
|
172
|
+
});
|
|
173
|
+
req.on('error', () => resolve(false));
|
|
174
|
+
req.on('timeout', () => { try { req.destroy(); } catch {} resolve(false); });
|
|
175
|
+
req.end();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const EXT_TO_LANG = {
|
|
180
|
+
'.py': 'Python', '.ts': 'TypeScript', '.tsx': 'TypeScript', '.js': 'JavaScript',
|
|
181
|
+
'.jsx': 'JavaScript', '.mjs': 'JavaScript', '.go': 'Go', '.rs': 'Rust',
|
|
182
|
+
'.java': 'Java', '.rb': 'Ruby', '.php': 'PHP', '.swift': 'Swift', '.kt': 'Kotlin',
|
|
183
|
+
'.c': 'C', '.cc': 'C++', '.cpp': 'C++', '.h': 'C/C++', '.hpp': 'C++',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function topLanguages(byExt, n) {
|
|
187
|
+
const ranked = Object.entries(byExt)
|
|
188
|
+
.map(([ext, count]) => [EXT_TO_LANG[ext], count])
|
|
189
|
+
.filter(([lang]) => lang)
|
|
190
|
+
.reduce((acc, [lang, count]) => { acc.set(lang, (acc.get(lang) || 0) + count); return acc; }, new Map());
|
|
191
|
+
return [...ranked.entries()]
|
|
192
|
+
.sort((a, b) => b[1] - a[1])
|
|
193
|
+
.slice(0, n)
|
|
194
|
+
.map(([lang]) => lang);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function quickFileCount(cwd, { max = 5000 } = {}) {
|
|
198
|
+
// Shallow walk: skip node_modules, .git, dist, build, .venv, __pycache__.
|
|
199
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.venv', 'venv', '__pycache__', '.kepler', '.terraform']);
|
|
200
|
+
const byExt = {};
|
|
201
|
+
let total = 0;
|
|
202
|
+
const stack = [cwd];
|
|
203
|
+
while (stack.length && total < max) {
|
|
204
|
+
const dir = stack.pop();
|
|
205
|
+
let entries;
|
|
206
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
207
|
+
catch { continue; }
|
|
208
|
+
for (const e of entries) {
|
|
209
|
+
if (e.name.startsWith('.') && e.name !== '.kepler') continue;
|
|
210
|
+
if (SKIP.has(e.name)) continue;
|
|
211
|
+
const full = path.join(dir, e.name);
|
|
212
|
+
if (e.isDirectory()) stack.push(full);
|
|
213
|
+
else if (e.isFile()) {
|
|
214
|
+
total++;
|
|
215
|
+
const ext = path.extname(e.name).toLowerCase();
|
|
216
|
+
if (ext) byExt[ext] = (byExt[ext] || 0) + 1;
|
|
217
|
+
if (total >= max) break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { total, byExt };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Renderer ────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
function formatRow(check) {
|
|
227
|
+
switch (check.status) {
|
|
228
|
+
case 'ok': return ` ${OK(paint.text.primary(check.label))}`;
|
|
229
|
+
case 'warn': return ` ${WARN(paint.text.primary(check.label))}` +
|
|
230
|
+
(check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
|
|
231
|
+
case 'fail': return ` ${FAIL(paint.text.primary(check.label))}` +
|
|
232
|
+
(check.hint ? ` ${paint.text.dim('→ ' + check.hint)}` : '');
|
|
233
|
+
default: return ` ${paint.text.dim(check.label)}`;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run the preflight diagnostic. Writes to stderr and resolves with the
|
|
239
|
+
* collected check results.
|
|
240
|
+
*
|
|
241
|
+
* @param {object} opts
|
|
242
|
+
* @param {object} opts.auth — TarangAuth instance
|
|
243
|
+
* @param {string} opts.cwd — working directory
|
|
244
|
+
* @param {string} opts.version — package version string
|
|
245
|
+
* @param {boolean} [opts.silent] — if true, do not write (useful for tests)
|
|
246
|
+
*/
|
|
247
|
+
export async function runPreflight({ auth, cwd, version, silent = false } = {}) {
|
|
248
|
+
const t = term();
|
|
249
|
+
const write = (s) => { if (!silent) process.stderr.write(s); };
|
|
250
|
+
|
|
251
|
+
const header = `${icons.search} ${paint.bold(paint.brand.primary('Kepler v' + (version || '?')))} ${paint.text.dim('· initializing orbit')}`;
|
|
252
|
+
write('\n' + header + '\n\n');
|
|
253
|
+
|
|
254
|
+
const checks = [];
|
|
255
|
+
checks.push(checkAuthToken(auth));
|
|
256
|
+
checks.push(checkProviderKey(auth));
|
|
257
|
+
checks.push(await checkBackend(auth));
|
|
258
|
+
checks.push(checkGit(cwd));
|
|
259
|
+
checks.push(checkLinters(cwd));
|
|
260
|
+
checks.push(checkProjectMap(cwd));
|
|
261
|
+
|
|
262
|
+
for (const c of checks) write(formatRow(c) + '\n');
|
|
263
|
+
|
|
264
|
+
const fails = checks.filter(c => c.status === 'fail').length;
|
|
265
|
+
const warns = checks.filter(c => c.status === 'warn').length;
|
|
266
|
+
const tail = fails === 0 && warns === 0
|
|
267
|
+
? paint.state.success('All systems aligned.')
|
|
268
|
+
: fails > 0
|
|
269
|
+
? paint.state.danger(`${fails} blocker${fails === 1 ? '' : 's'}, ${warns} warning${warns === 1 ? '' : 's'} — see hints above.`)
|
|
270
|
+
: paint.state.warn(`${warns} warning${warns === 1 ? '' : 's'} — non-blocking.`);
|
|
271
|
+
|
|
272
|
+
write('\n ' + tail + '\n\n');
|
|
273
|
+
return checks;
|
|
274
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orbit state machine — Mission Control (PRD-055 §5.2).
|
|
3
|
+
*
|
|
4
|
+
* The "orbit" is the current phase of the session. The status bar reads
|
|
5
|
+
* from this module; the REPL pushes events into it. It is intentionally a
|
|
6
|
+
* pure state machine — no I/O, no side effects, no globals. Each REPL
|
|
7
|
+
* creates one instance.
|
|
8
|
+
*
|
|
9
|
+
* const orbit = createOrbit();
|
|
10
|
+
* orbit.on('change', state => statusBar.render(state));
|
|
11
|
+
* orbit.onEvent({ type: 'tool_call', data: { tool: 'edit_file' } });
|
|
12
|
+
*
|
|
13
|
+
* States (PRD §5.2):
|
|
14
|
+
* IDLE — waiting for user input
|
|
15
|
+
* DISCOVERY — first message until first plan or edit
|
|
16
|
+
* PLANNING — preflight plan running OR plan() sub-agent active
|
|
17
|
+
* EXECUTION — write/edit/shell tools firing
|
|
18
|
+
* ALIGNMENT — tests / validators running
|
|
19
|
+
* AWAITING — approval required
|
|
20
|
+
* PAUSED — user pressed `p`
|
|
21
|
+
*
|
|
22
|
+
* Transitions are derived from existing backend SSE events. We never
|
|
23
|
+
* teach the backend about orbits; the CLI infers them from tool activity.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Tool families used for orbit inference. Mirrors src/ui/icons.mjs but
|
|
27
|
+
// scoped to the few orbits actually need.
|
|
28
|
+
const PLANNING_TOOLS = new Set(['plan']);
|
|
29
|
+
const EXECUTION_TOOLS = new Set(['edit_file', 'write_file', 'write_project', 'shell', 'delete_file']);
|
|
30
|
+
const ALIGNMENT_TOOLS = new Set(['run_tests', 'validate_build', 'lint_check', 'validate_file', 'validate_structure', 'git_diff', 'git_status']);
|
|
31
|
+
const RESEARCH_TOOLS = new Set(['search_code', 'search_files', 'grep', 'read_file', 'read_files', 'list_files', 'analyze_code', 'get_project_overview', 'explore']);
|
|
32
|
+
|
|
33
|
+
export const ORBITS = Object.freeze({
|
|
34
|
+
IDLE: 'IDLE',
|
|
35
|
+
DISCOVERY: 'DISCOVERY',
|
|
36
|
+
PLANNING: 'PLANNING',
|
|
37
|
+
EXECUTION: 'EXECUTION',
|
|
38
|
+
ALIGNMENT: 'ALIGNMENT',
|
|
39
|
+
AWAITING: 'AWAITING',
|
|
40
|
+
PAUSED: 'PAUSED',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @returns the snapshot consumed by the status bar.
|
|
45
|
+
*/
|
|
46
|
+
function snapshot(s) {
|
|
47
|
+
return {
|
|
48
|
+
orbit: s.orbit,
|
|
49
|
+
task: s.task || '',
|
|
50
|
+
turn: s.turn,
|
|
51
|
+
maxTurn: s.maxTurn,
|
|
52
|
+
cost: s.cost,
|
|
53
|
+
activeTool: s.activeTool || '',
|
|
54
|
+
subAgents: s.subAgents,
|
|
55
|
+
paused: s.paused,
|
|
56
|
+
awaitingTier: s.awaitingTier || null,
|
|
57
|
+
awaitingTool: s.awaitingTool || '',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createOrbit() {
|
|
62
|
+
const state = {
|
|
63
|
+
orbit: ORBITS.IDLE,
|
|
64
|
+
task: '',
|
|
65
|
+
turn: 0,
|
|
66
|
+
maxTurn: 0,
|
|
67
|
+
cost: 0,
|
|
68
|
+
activeTool: '',
|
|
69
|
+
subAgents: 0, // count of currently-active sub-agents
|
|
70
|
+
paused: false,
|
|
71
|
+
awaitingTool: '',
|
|
72
|
+
awaitingTier: null,
|
|
73
|
+
_hasEdited: false, // for DISCOVERY → EXECUTION transition
|
|
74
|
+
_resumeOrbit: null, // remembered orbit when paused
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const listeners = new Set();
|
|
78
|
+
|
|
79
|
+
function emit() {
|
|
80
|
+
const snap = snapshot(state);
|
|
81
|
+
for (const fn of listeners) {
|
|
82
|
+
try { fn(snap); } catch {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function setOrbit(next) {
|
|
87
|
+
if (state.paused && next !== ORBITS.PAUSED && next !== ORBITS.IDLE) {
|
|
88
|
+
// While paused, remember the orbit that would have applied but stay paused.
|
|
89
|
+
state._resumeOrbit = next;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (state.orbit === next) return;
|
|
93
|
+
state.orbit = next;
|
|
94
|
+
emit();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function inferOrbitFromTool(toolName) {
|
|
98
|
+
if (state.paused) return null;
|
|
99
|
+
if (state.subAgents > 0 && PLANNING_TOOLS.has(toolName)) return ORBITS.PLANNING;
|
|
100
|
+
if (PLANNING_TOOLS.has(toolName)) return ORBITS.PLANNING;
|
|
101
|
+
if (EXECUTION_TOOLS.has(toolName)) {
|
|
102
|
+
state._hasEdited = true;
|
|
103
|
+
return ORBITS.EXECUTION;
|
|
104
|
+
}
|
|
105
|
+
if (ALIGNMENT_TOOLS.has(toolName)) return ORBITS.ALIGNMENT;
|
|
106
|
+
if (RESEARCH_TOOLS.has(toolName)) {
|
|
107
|
+
// Stay in DISCOVERY until first edit; afterwards research stays in
|
|
108
|
+
// current orbit (EXECUTION) so the status doesn't flicker back.
|
|
109
|
+
return state._hasEdited ? null : ORBITS.DISCOVERY;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
state: () => snapshot(state),
|
|
116
|
+
|
|
117
|
+
on(event, fn) {
|
|
118
|
+
if (event !== 'change') return () => {};
|
|
119
|
+
listeners.add(fn);
|
|
120
|
+
return () => listeners.delete(fn);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// ── Inbound events from the REPL ──────────────────────────────────
|
|
124
|
+
|
|
125
|
+
onUserInput(text) {
|
|
126
|
+
// First user message of the session OR a new turn opens DISCOVERY.
|
|
127
|
+
state.turn++;
|
|
128
|
+
state.task = (text || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
129
|
+
state._hasEdited = false;
|
|
130
|
+
state.activeTool = '';
|
|
131
|
+
setOrbit(ORBITS.DISCOVERY);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
onMaxTurn(n) {
|
|
135
|
+
if (typeof n === 'number' && n > 0) {
|
|
136
|
+
state.maxTurn = n;
|
|
137
|
+
emit();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
onTask(text) {
|
|
142
|
+
if (!text) return;
|
|
143
|
+
state.task = String(text).replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
144
|
+
emit();
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
onCost(value) {
|
|
148
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
149
|
+
state.cost = value;
|
|
150
|
+
emit();
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
onToolCall(toolName) {
|
|
155
|
+
state.activeTool = toolName || '';
|
|
156
|
+
const next = inferOrbitFromTool(toolName);
|
|
157
|
+
if (next) setOrbit(next);
|
|
158
|
+
else emit();
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
onToolResult() {
|
|
162
|
+
state.activeTool = '';
|
|
163
|
+
emit();
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
onSubAgentStart() {
|
|
167
|
+
state.subAgents = Math.max(0, state.subAgents) + 1;
|
|
168
|
+
setOrbit(ORBITS.PLANNING);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
onSubAgentEnd() {
|
|
172
|
+
state.subAgents = Math.max(0, state.subAgents - 1);
|
|
173
|
+
// Fall back to whatever the parent was doing — we don't track that
|
|
174
|
+
// precisely, so go to EXECUTION if an edit has happened, else
|
|
175
|
+
// DISCOVERY. The next tool_call event will refine.
|
|
176
|
+
if (state.subAgents === 0) {
|
|
177
|
+
setOrbit(state._hasEdited ? ORBITS.EXECUTION : ORBITS.DISCOVERY);
|
|
178
|
+
} else {
|
|
179
|
+
emit();
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
onApprovalRequired({ tool, tier } = {}) {
|
|
184
|
+
state.awaitingTool = tool || '';
|
|
185
|
+
state.awaitingTier = tier || null;
|
|
186
|
+
setOrbit(ORBITS.AWAITING);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
onApprovalResolved() {
|
|
190
|
+
state.awaitingTool = '';
|
|
191
|
+
state.awaitingTier = null;
|
|
192
|
+
// Drop back to the inferred orbit for the active tool, or EXECUTION
|
|
193
|
+
// if we have an active tool but can't classify, or IDLE.
|
|
194
|
+
const inferred = inferOrbitFromTool(state.activeTool) || (state._hasEdited ? ORBITS.EXECUTION : ORBITS.DISCOVERY);
|
|
195
|
+
setOrbit(inferred);
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
onComplete({ cost } = {}) {
|
|
199
|
+
if (typeof cost === 'number') state.cost = cost;
|
|
200
|
+
state.activeTool = '';
|
|
201
|
+
setOrbit(ORBITS.IDLE);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
onPause() {
|
|
205
|
+
if (state.paused) return;
|
|
206
|
+
state.paused = true;
|
|
207
|
+
state._resumeOrbit = state.orbit;
|
|
208
|
+
state.orbit = ORBITS.PAUSED;
|
|
209
|
+
emit();
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
onResume() {
|
|
213
|
+
if (!state.paused) return;
|
|
214
|
+
state.paused = false;
|
|
215
|
+
const resume = state._resumeOrbit || ORBITS.IDLE;
|
|
216
|
+
state._resumeOrbit = null;
|
|
217
|
+
state.orbit = resume;
|
|
218
|
+
emit();
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Generic event router so the REPL can feed raw SSE events without a
|
|
223
|
+
* giant switch in this module. Returns true if the event was handled.
|
|
224
|
+
*/
|
|
225
|
+
onEvent(event) {
|
|
226
|
+
if (!event || !event.type) return false;
|
|
227
|
+
const { type, data } = event;
|
|
228
|
+
switch (type) {
|
|
229
|
+
case 'tool_call':
|
|
230
|
+
case 'tool_request':
|
|
231
|
+
this.onToolCall(data?.tool || '');
|
|
232
|
+
return true;
|
|
233
|
+
case 'tool_result':
|
|
234
|
+
case 'tool_done':
|
|
235
|
+
this.onToolResult();
|
|
236
|
+
return true;
|
|
237
|
+
case 'sub_agent_start':
|
|
238
|
+
this.onSubAgentStart();
|
|
239
|
+
return true;
|
|
240
|
+
case 'sub_agent_complete':
|
|
241
|
+
this.onSubAgentEnd();
|
|
242
|
+
return true;
|
|
243
|
+
case 'approval_required':
|
|
244
|
+
this.onApprovalRequired({ tool: data?.tool, tier: data?.tier });
|
|
245
|
+
return true;
|
|
246
|
+
case 'approval_granted':
|
|
247
|
+
case 'approval_denied':
|
|
248
|
+
this.onApprovalResolved();
|
|
249
|
+
return true;
|
|
250
|
+
case 'complete': {
|
|
251
|
+
const usage = data?.usage || {};
|
|
252
|
+
this.onComplete({ cost: usage.total_cost_usd ?? usage.cost_usd });
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
case 'plan_created':
|
|
256
|
+
this.onTask(data?.title || data?.task || '');
|
|
257
|
+
return true;
|
|
258
|
+
default:
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|