@axplusb/kepler 1.0.10 → 2.0.2
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/pulse/app/api/benchmark/route.ts +113 -0
- package/pulse/app/api/benchmarks/route.ts +195 -0
- package/pulse/app/benchmarks/page.tsx +224 -0
- package/pulse/components/layout/bottom-nav.tsx +2 -1
- package/pulse/components/layout/sidebar.tsx +2 -1
- 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 +245 -0
- package/src/core/stream-client.mjs +24 -1
- package/src/core/tool-executor.mjs +58 -5
- package/src/onboarding/preflight.mjs +292 -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 +487 -133
- package/src/tools/project-overview.mjs +109 -16
- 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 +322 -0
- package/src/ui/tool-details.mjs +277 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol Indexer — AST-based code search using tree-sitter.
|
|
3
|
+
*
|
|
4
|
+
* Parses source files into symbols (functions, classes, methods) with
|
|
5
|
+
* signatures and line numbers. Indexes symbols in BM25 for search.
|
|
6
|
+
*
|
|
7
|
+
* Memory efficient: stores symbol signatures (~50 chars) not file chunks
|
|
8
|
+
* (~2000 chars). One tree-sitter parse per file, O(n) on file size.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const indexer = new SymbolIndexer();
|
|
12
|
+
* await indexer.init(); // load WASM grammars once
|
|
13
|
+
* indexer.indexFile('/path/to/file.py', content);
|
|
14
|
+
* const results = indexer.search('find_ordering_name');
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { BM25Index } from './bm25.mjs';
|
|
20
|
+
|
|
21
|
+
const GRAMMAR_DIR = new URL('./grammars/', import.meta.url).pathname;
|
|
22
|
+
|
|
23
|
+
const LANG_MAP = {
|
|
24
|
+
'.py': 'python',
|
|
25
|
+
'.js': 'javascript',
|
|
26
|
+
'.mjs': 'javascript',
|
|
27
|
+
'.jsx': 'javascript',
|
|
28
|
+
'.ts': 'typescript',
|
|
29
|
+
'.tsx': 'typescript',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} Symbol
|
|
34
|
+
* @property {string} name
|
|
35
|
+
* @property {string} kind - 'function' | 'class' | 'method'
|
|
36
|
+
* @property {string} file - relative path
|
|
37
|
+
* @property {number} line
|
|
38
|
+
* @property {number} endLine
|
|
39
|
+
* @property {string} signature - e.g., "def find_ordering_name(self, name, opts)"
|
|
40
|
+
* @property {string} [parent] - parent class name if method
|
|
41
|
+
* @property {string} [docstring] - first line of docstring
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export class SymbolIndexer {
|
|
45
|
+
constructor() {
|
|
46
|
+
this._Parser = null;
|
|
47
|
+
this._languages = {}; // ext → Language
|
|
48
|
+
this._symbols = []; // all extracted symbols
|
|
49
|
+
this._symbolMap = new Map(); // id → Symbol
|
|
50
|
+
this._bm25 = new BM25Index();
|
|
51
|
+
this._initialized = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load tree-sitter WASM runtime + grammars. Call once per session.
|
|
56
|
+
* Lazy — only loads grammars for languages actually encountered.
|
|
57
|
+
*/
|
|
58
|
+
async init() {
|
|
59
|
+
if (this._initialized) return;
|
|
60
|
+
try {
|
|
61
|
+
const TreeSitter = (await import('web-tree-sitter')).default;
|
|
62
|
+
await TreeSitter.init();
|
|
63
|
+
this._Parser = new TreeSitter();
|
|
64
|
+
this._TreeSitter = TreeSitter;
|
|
65
|
+
this._initialized = true;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// Fallback: tree-sitter not available, use regex parser
|
|
68
|
+
this._initialized = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async _getLanguage(ext) {
|
|
73
|
+
if (this._languages[ext]) return this._languages[ext];
|
|
74
|
+
const langName = LANG_MAP[ext];
|
|
75
|
+
if (!langName || !this._TreeSitter) return null;
|
|
76
|
+
|
|
77
|
+
// Try bundled WASM from tree-sitter-wasms package
|
|
78
|
+
const wasmPaths = [
|
|
79
|
+
path.join(GRAMMAR_DIR, `tree-sitter-${langName}.wasm`),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// Also check node_modules
|
|
83
|
+
try {
|
|
84
|
+
const modPath = new URL(`../../node_modules/tree-sitter-wasms/out/tree-sitter-${langName}.wasm`, import.meta.url).pathname;
|
|
85
|
+
wasmPaths.push(modPath);
|
|
86
|
+
} catch { /* ignore */ }
|
|
87
|
+
|
|
88
|
+
for (const p of wasmPaths) {
|
|
89
|
+
try {
|
|
90
|
+
if (fs.existsSync(p)) {
|
|
91
|
+
const lang = await this._TreeSitter.Language.load(p);
|
|
92
|
+
this._languages[ext] = lang;
|
|
93
|
+
return lang;
|
|
94
|
+
}
|
|
95
|
+
} catch { /* try next */ }
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Index a single file. Extracts symbols and adds to BM25.
|
|
102
|
+
* @param {string} relPath - relative path (used as ID)
|
|
103
|
+
* @param {string} content - file content
|
|
104
|
+
*/
|
|
105
|
+
async indexFile(relPath, content) {
|
|
106
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
107
|
+
let symbols;
|
|
108
|
+
|
|
109
|
+
const lang = await this._getLanguage(ext);
|
|
110
|
+
if (lang && this._Parser) {
|
|
111
|
+
this._Parser.setLanguage(lang);
|
|
112
|
+
const tree = this._Parser.parse(content);
|
|
113
|
+
symbols = this._extractSymbols(tree.rootNode, relPath, ext);
|
|
114
|
+
tree.delete();
|
|
115
|
+
} else {
|
|
116
|
+
symbols = this._regexExtract(relPath, content, ext);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const sym of symbols) {
|
|
120
|
+
const id = `${sym.file}:${sym.line}:${sym.name}`;
|
|
121
|
+
this._symbols.push(sym);
|
|
122
|
+
this._symbolMap.set(id, sym);
|
|
123
|
+
|
|
124
|
+
// BM25 document: name + signature + parent + docstring
|
|
125
|
+
const text = [
|
|
126
|
+
sym.name,
|
|
127
|
+
sym.signature || '',
|
|
128
|
+
sym.parent ? `${sym.parent}.${sym.name}` : '',
|
|
129
|
+
sym.docstring || '',
|
|
130
|
+
sym.file,
|
|
131
|
+
].join(' ');
|
|
132
|
+
this._bm25.addDocument(id, text);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Search for symbols matching a query.
|
|
138
|
+
* @param {string} query
|
|
139
|
+
* @param {number} [topK=10]
|
|
140
|
+
* @returns {Array<{symbol: Symbol, score: number}>}
|
|
141
|
+
*/
|
|
142
|
+
search(query, topK = 10) {
|
|
143
|
+
const results = this._bm25.search(query, topK);
|
|
144
|
+
return results.map(r => ({
|
|
145
|
+
symbol: this._symbolMap.get(r.id),
|
|
146
|
+
score: r.score,
|
|
147
|
+
id: r.id,
|
|
148
|
+
})).filter(r => r.symbol);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Format search results for the agent.
|
|
153
|
+
*/
|
|
154
|
+
formatResults(results) {
|
|
155
|
+
if (!results.length) return '';
|
|
156
|
+
return results.map(r => {
|
|
157
|
+
const s = r.symbol;
|
|
158
|
+
const parent = s.parent ? `${s.parent}.` : '';
|
|
159
|
+
const doc = s.docstring ? ` "${s.docstring}"` : '';
|
|
160
|
+
return `${s.file}:${s.line} ${parent}${s.signature || s.name}${doc}`;
|
|
161
|
+
}).join('\n');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
get symbolCount() { return this._symbols.length; }
|
|
165
|
+
|
|
166
|
+
// ── Tree-sitter extraction ──
|
|
167
|
+
|
|
168
|
+
_extractSymbols(node, file, ext) {
|
|
169
|
+
const symbols = [];
|
|
170
|
+
const langName = LANG_MAP[ext];
|
|
171
|
+
|
|
172
|
+
if (langName === 'python') {
|
|
173
|
+
this._walkPython(node, file, symbols, null);
|
|
174
|
+
} else if (langName === 'javascript' || langName === 'typescript') {
|
|
175
|
+
this._walkJS(node, file, symbols, null);
|
|
176
|
+
}
|
|
177
|
+
return symbols;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_walkPython(node, file, symbols, parentClass) {
|
|
181
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
182
|
+
const child = node.child(i);
|
|
183
|
+
const type = child.type;
|
|
184
|
+
|
|
185
|
+
if (type === 'class_definition') {
|
|
186
|
+
const nameNode = child.childForFieldName('name');
|
|
187
|
+
const name = nameNode?.text || '';
|
|
188
|
+
const bases = child.childForFieldName('superclasses')?.text || '';
|
|
189
|
+
symbols.push({
|
|
190
|
+
name, kind: 'class', file,
|
|
191
|
+
line: child.startPosition.row + 1,
|
|
192
|
+
endLine: child.endPosition.row + 1,
|
|
193
|
+
signature: `class ${name}${bases ? `(${bases})` : ''}`,
|
|
194
|
+
docstring: this._pyDocstring(child),
|
|
195
|
+
});
|
|
196
|
+
// Recurse into class body for methods
|
|
197
|
+
const body = child.childForFieldName('body');
|
|
198
|
+
if (body) this._walkPython(body, file, symbols, name);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
else if (type === 'function_definition') {
|
|
202
|
+
const nameNode = child.childForFieldName('name');
|
|
203
|
+
const name = nameNode?.text || '';
|
|
204
|
+
const params = child.childForFieldName('parameters')?.text || '()';
|
|
205
|
+
const returnType = child.childForFieldName('return_type')?.text || '';
|
|
206
|
+
const sig = `def ${name}${params}${returnType ? ' -> ' + returnType : ''}`;
|
|
207
|
+
symbols.push({
|
|
208
|
+
name,
|
|
209
|
+
kind: parentClass ? 'method' : 'function',
|
|
210
|
+
file,
|
|
211
|
+
line: child.startPosition.row + 1,
|
|
212
|
+
endLine: child.endPosition.row + 1,
|
|
213
|
+
signature: sig,
|
|
214
|
+
parent: parentClass || undefined,
|
|
215
|
+
docstring: this._pyDocstring(child),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
else if (type === 'decorated_definition') {
|
|
220
|
+
// Unwrap decorator to get the actual definition
|
|
221
|
+
for (let j = 0; j < child.childCount; j++) {
|
|
222
|
+
const inner = child.child(j);
|
|
223
|
+
if (inner.type === 'function_definition' || inner.type === 'class_definition') {
|
|
224
|
+
this._walkPython(child, file, symbols, parentClass);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
else {
|
|
231
|
+
// Recurse for module-level statements
|
|
232
|
+
if (!parentClass && child.childCount > 0) {
|
|
233
|
+
this._walkPython(child, file, symbols, parentClass);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_pyDocstring(defNode) {
|
|
240
|
+
const body = defNode.childForFieldName('body');
|
|
241
|
+
if (!body || body.childCount === 0) return '';
|
|
242
|
+
const first = body.child(0);
|
|
243
|
+
if (first?.type === 'expression_statement') {
|
|
244
|
+
const expr = first.child(0);
|
|
245
|
+
if (expr?.type === 'string' || expr?.type === 'concatenated_string') {
|
|
246
|
+
const raw = expr.text;
|
|
247
|
+
// Extract first line of docstring
|
|
248
|
+
const content = raw.replace(/^['"`]{1,3}/, '').replace(/['"`]{1,3}$/, '');
|
|
249
|
+
const firstLine = content.split('\n')[0].trim();
|
|
250
|
+
return firstLine.slice(0, 120);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return '';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_walkJS(node, file, symbols, parentClass) {
|
|
257
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
258
|
+
const child = node.child(i);
|
|
259
|
+
const type = child.type;
|
|
260
|
+
|
|
261
|
+
if (type === 'class_declaration' || type === 'class') {
|
|
262
|
+
const nameNode = child.childForFieldName('name');
|
|
263
|
+
const name = nameNode?.text || '';
|
|
264
|
+
symbols.push({
|
|
265
|
+
name, kind: 'class', file,
|
|
266
|
+
line: child.startPosition.row + 1,
|
|
267
|
+
endLine: child.endPosition.row + 1,
|
|
268
|
+
signature: `class ${name}`,
|
|
269
|
+
});
|
|
270
|
+
const body = child.childForFieldName('body');
|
|
271
|
+
if (body) this._walkJS(body, file, symbols, name);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
else if (type === 'function_declaration' || type === 'method_definition') {
|
|
275
|
+
const nameNode = child.childForFieldName('name');
|
|
276
|
+
const name = nameNode?.text || '';
|
|
277
|
+
const params = child.childForFieldName('parameters')?.text || '()';
|
|
278
|
+
symbols.push({
|
|
279
|
+
name,
|
|
280
|
+
kind: parentClass ? 'method' : 'function',
|
|
281
|
+
file,
|
|
282
|
+
line: child.startPosition.row + 1,
|
|
283
|
+
endLine: child.endPosition.row + 1,
|
|
284
|
+
signature: `${parentClass ? '' : 'function '}${name}${params}`,
|
|
285
|
+
parent: parentClass || undefined,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
else if (type === 'export_statement' || type === 'lexical_declaration') {
|
|
290
|
+
this._walkJS(child, file, symbols, parentClass);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
else if (child.childCount > 0 && !parentClass) {
|
|
294
|
+
this._walkJS(child, file, symbols, parentClass);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Regex fallback (no tree-sitter) ──
|
|
300
|
+
|
|
301
|
+
_regexExtract(file, content, ext) {
|
|
302
|
+
const symbols = [];
|
|
303
|
+
const lines = content.split('\n');
|
|
304
|
+
let currentClass = null;
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < lines.length; i++) {
|
|
307
|
+
const line = lines[i];
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
const lineNum = i + 1;
|
|
310
|
+
const indent = line.length - line.trimStart().length;
|
|
311
|
+
|
|
312
|
+
// Python
|
|
313
|
+
if (ext === '.py') {
|
|
314
|
+
const classMatch = trimmed.match(/^class\s+(\w+)(?:\(([^)]*)\))?/);
|
|
315
|
+
if (classMatch) {
|
|
316
|
+
currentClass = classMatch[1];
|
|
317
|
+
symbols.push({
|
|
318
|
+
name: currentClass, kind: 'class', file, line: lineNum,
|
|
319
|
+
signature: `class ${currentClass}${classMatch[2] ? `(${classMatch[2]})` : ''}`,
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const fnMatch = trimmed.match(/^(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)/);
|
|
324
|
+
if (fnMatch) {
|
|
325
|
+
const isMethod = indent >= 4 && currentClass;
|
|
326
|
+
symbols.push({
|
|
327
|
+
name: fnMatch[1],
|
|
328
|
+
kind: isMethod ? 'method' : 'function',
|
|
329
|
+
file, line: lineNum,
|
|
330
|
+
signature: `def ${fnMatch[1]}(${fnMatch[2]})`,
|
|
331
|
+
parent: isMethod ? currentClass : undefined,
|
|
332
|
+
});
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (indent === 0 && !trimmed.startsWith('#') && trimmed) {
|
|
336
|
+
currentClass = null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// JS/TS
|
|
341
|
+
if (['.js', '.mjs', '.ts', '.tsx', '.jsx'].includes(ext)) {
|
|
342
|
+
const fnMatch = trimmed.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
|
|
343
|
+
if (fnMatch) {
|
|
344
|
+
symbols.push({ name: fnMatch[1], kind: 'function', file, line: lineNum, signature: `function ${fnMatch[1]}(${fnMatch[2]})` });
|
|
345
|
+
}
|
|
346
|
+
const classMatch = trimmed.match(/^(?:export\s+)?class\s+(\w+)/);
|
|
347
|
+
if (classMatch) {
|
|
348
|
+
symbols.push({ name: classMatch[1], kind: 'class', file, line: lineNum, signature: `class ${classMatch[1]}` });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return symbols;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── Serialization ──
|
|
356
|
+
|
|
357
|
+
toJSON() {
|
|
358
|
+
return {
|
|
359
|
+
symbols: this._symbols,
|
|
360
|
+
bm25: this._bm25.toJSON(),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
static fromJSON(data) {
|
|
365
|
+
const indexer = new SymbolIndexer();
|
|
366
|
+
indexer._initialized = true; // don't need tree-sitter for search
|
|
367
|
+
indexer._symbols = data.symbols || [];
|
|
368
|
+
indexer._bm25 = BM25Index.fromJSON(data.bm25);
|
|
369
|
+
for (const sym of indexer._symbols) {
|
|
370
|
+
const id = `${sym.file}:${sym.line}:${sym.name}`;
|
|
371
|
+
indexer._symbolMap.set(id, sym);
|
|
372
|
+
}
|
|
373
|
+
return indexer;
|
|
374
|
+
}
|
|
375
|
+
}
|
package/src/core/approval.mjs
CHANGED
|
@@ -11,45 +11,42 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { toolDisplayLabel, toolDisplaySummary } from '../terminal/tool-display.mjs';
|
|
14
|
+
import {
|
|
15
|
+
classify as classifyTier,
|
|
16
|
+
TIERS,
|
|
17
|
+
requiresExplicitApproval,
|
|
18
|
+
requiresCheckpoint,
|
|
19
|
+
label as tierLabel,
|
|
20
|
+
} from './risk-tier.mjs';
|
|
21
|
+
import {
|
|
22
|
+
renderApprovalPrompt,
|
|
23
|
+
renderInlinePrompt,
|
|
24
|
+
defaultOptions as approvalOptions,
|
|
25
|
+
} from '../ui/approval.mjs';
|
|
14
26
|
|
|
15
27
|
// ── Tool Classification ──
|
|
28
|
+
//
|
|
29
|
+
// Risk tiering moved to src/core/risk-tier.mjs (PRD-055 §8.1). WRITE_TOOLS
|
|
30
|
+
// stays here only because `planMode` blocks anything that writes.
|
|
16
31
|
|
|
17
32
|
const WRITE_TOOLS = new Set([
|
|
18
33
|
'shell', 'write_file', 'write_project', 'edit_file', 'delete_file',
|
|
19
34
|
'validate_build', 'lint_check',
|
|
20
35
|
]);
|
|
21
36
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
search_files: 'none', list_files: 'none', get_file_info: 'none',
|
|
36
|
-
validate_file: 'none', validate_structure: 'none',
|
|
37
|
-
write_file: 'low', write_project: 'low', edit_file: 'low',
|
|
38
|
-
lint_check: 'low', validate_build: 'medium',
|
|
39
|
-
shell: 'medium',
|
|
40
|
-
delete_file: 'high',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
function assessShellRisk(command) {
|
|
44
|
-
if (!command) return 'medium';
|
|
45
|
-
if (/rm\s+-r/i.test(command)) return 'high';
|
|
46
|
-
if (/git\s+(push|reset|clean|checkout\s+\.)/i.test(command)) return 'high';
|
|
47
|
-
if (/drop\s+(table|database)/i.test(command)) return 'high';
|
|
48
|
-
if (/sudo\s/i.test(command)) return 'high';
|
|
49
|
-
if (/^(ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)/i.test(command)) return 'low';
|
|
50
|
-
if (/^git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|blame|shortlog|describe|rev-parse|ls-files|ls-tree)/i.test(command)) return 'low';
|
|
51
|
-
if (/^(npm\s+(test|run|list|ls|view|info|outdated|audit)|node\s+--check|python3?\s+-m\s+py_compile|cargo\s+(check|test|clippy))/i.test(command)) return 'low';
|
|
52
|
-
return 'medium';
|
|
37
|
+
function defaultWhy(tier, tool, args) {
|
|
38
|
+
switch (tier) {
|
|
39
|
+
case TIERS.SHELL_DANGEROUS:
|
|
40
|
+
return `Shell command matches a high-risk pattern (rm -rf, sudo, force push, etc.). Confirm before running.`;
|
|
41
|
+
case TIERS.DESTRUCTIVE:
|
|
42
|
+
return `${tool} permanently mutates project state. Confirm before running.`;
|
|
43
|
+
case TIERS.SHELL_MEDIUM:
|
|
44
|
+
return `Mutates the workspace or environment (install, build, commit, push).`;
|
|
45
|
+
case TIERS.NETWORK:
|
|
46
|
+
return `Reaches an external network endpoint.`;
|
|
47
|
+
default:
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
53
50
|
}
|
|
54
51
|
|
|
55
52
|
// ── ANSI helpers ──
|
|
@@ -107,93 +104,146 @@ export class ApprovalManager {
|
|
|
107
104
|
// Auto-approve everything in headless/autoApprove mode (no TTY prompts)
|
|
108
105
|
if (this.autoApprove) {
|
|
109
106
|
this.history.push({ tool: toolName, decision: 'auto', time: Date.now() });
|
|
110
|
-
return { approved: true };
|
|
107
|
+
return { approved: true, tier: classifyTier(toolName, args) };
|
|
111
108
|
}
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
|
|
110
|
+
const tier = classifyTier(toolName, args);
|
|
111
|
+
|
|
112
|
+
// 'auto' tiers: read, shell-safe.
|
|
113
|
+
if (tier === TIERS.READ || tier === TIERS.SHELL_SAFE) {
|
|
114
|
+
this.history.push({ tool: toolName, decision: 'auto-tier', tier, time: Date.now() });
|
|
115
|
+
return { approved: true, tier };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 'auto-with-undo' tier: local-edit. Checkpoint is taken by the tool
|
|
119
|
+
// executor before the edit; here we just approve.
|
|
120
|
+
if (tier === TIERS.LOCAL_EDIT) {
|
|
121
|
+
this.history.push({ tool: toolName, decision: 'auto-with-undo', tier, time: Date.now() });
|
|
122
|
+
return { approved: true, tier, requireCheckpoint: true };
|
|
114
123
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
|
|
125
|
+
// Honor approve-all / type-allow shortcuts for non-explicit tiers only.
|
|
126
|
+
if (!requiresExplicitApproval(tier)) {
|
|
127
|
+
if (this.approveAll) {
|
|
128
|
+
this.history.push({ tool: toolName, decision: 'auto-all', tier, time: Date.now() });
|
|
129
|
+
return { approved: true, tier };
|
|
120
130
|
}
|
|
121
|
-
if (
|
|
122
|
-
|
|
131
|
+
if (this.approvedToolTypes.has(toolName)) {
|
|
132
|
+
this.history.push({ tool: toolName, decision: 'type-auto', tier, time: Date.now() });
|
|
133
|
+
return { approved: true, tier };
|
|
123
134
|
}
|
|
124
135
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
if (this.approveAll) {
|
|
129
|
-
this.history.push({ tool: toolName, decision: 'auto', time: Date.now() });
|
|
130
|
-
return { approved: true };
|
|
131
|
-
}
|
|
132
|
-
if (this.approvedToolTypes.has(toolName)) {
|
|
133
|
-
this.history.push({ tool: toolName, decision: 'type-auto', time: Date.now() });
|
|
134
|
-
return { approved: true };
|
|
135
|
-
}
|
|
136
|
-
return this._prompt(toolName, args, context);
|
|
136
|
+
|
|
137
|
+
return this._prompt(toolName, args, { ...context, tier });
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
async _prompt(toolName, args, context = {}) {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
143
|
-
const label = toolDisplayLabel(toolName);
|
|
141
|
+
const tier = context.tier || classifyTier(toolName, args);
|
|
142
|
+
const explicit = requiresExplicitApproval(tier);
|
|
143
|
+
const why = context.reason || context.why || defaultWhy(tier, toolName, args);
|
|
144
144
|
const summary = toolDisplaySummary(toolName, args);
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
write(
|
|
155
|
-
|
|
156
|
-
|
|
145
|
+
const options = approvalOptions(tier);
|
|
146
|
+
|
|
147
|
+
let selected = 0; // arrow-driven cursor
|
|
148
|
+
let printedHeight = 0;
|
|
149
|
+
|
|
150
|
+
// For TTYs we redraw in place on every arrow key so the prompt feels
|
|
151
|
+
// live. For non-TTYs / pipes we just print once and read a line.
|
|
152
|
+
const isInteractive = process.stdin.isTTY;
|
|
153
|
+
if (!isInteractive) {
|
|
154
|
+
write(explicit
|
|
155
|
+
? renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options }) + '\n'
|
|
156
|
+
: renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
const
|
|
159
|
+
const drawExplicit = () => {
|
|
160
|
+
// Move up over the previous render before re-printing.
|
|
161
|
+
if (printedHeight > 0) {
|
|
162
|
+
write(`\x1b[${printedHeight}F`); // cursor to start of N lines above
|
|
163
|
+
write('\x1b[J'); // clear from cursor to end of screen
|
|
164
|
+
}
|
|
165
|
+
const block = renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options });
|
|
166
|
+
write(block + '\n');
|
|
167
|
+
printedHeight = block.split('\n').length;
|
|
168
|
+
};
|
|
160
169
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
170
|
+
if (isInteractive && explicit) drawExplicit();
|
|
171
|
+
if (isInteractive && !explicit) write(renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
|
|
172
|
+
|
|
173
|
+
// ── Input loop ─────────────────────────────────────────────────
|
|
174
|
+
const choose = async () => {
|
|
175
|
+
for (;;) {
|
|
176
|
+
const k = await this._readKey();
|
|
177
|
+
|
|
178
|
+
if (k === 'up' || k === 'left') {
|
|
179
|
+
if (!explicit || !isInteractive) continue;
|
|
180
|
+
selected = (selected - 1 + options.length) % options.length;
|
|
181
|
+
drawExplicit();
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (k === 'down' || k === 'right' || k === 'tab') {
|
|
185
|
+
if (!explicit || !isInteractive) continue;
|
|
186
|
+
selected = (selected + 1) % options.length;
|
|
187
|
+
drawExplicit();
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (k === 'return') {
|
|
191
|
+
return options[selected].value;
|
|
192
|
+
}
|
|
193
|
+
if (k === 'escape') return 'reject';
|
|
194
|
+
|
|
195
|
+
// Letter shortcut: match against option.key
|
|
196
|
+
if (typeof k === 'string' && k.length === 1) {
|
|
197
|
+
const lower = k.toLowerCase();
|
|
198
|
+
const idx = options.findIndex(o => o.key === lower);
|
|
199
|
+
if (idx >= 0) {
|
|
200
|
+
selected = idx;
|
|
201
|
+
if (isInteractive && explicit) drawExplicit();
|
|
202
|
+
return options[idx].value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Anything else: ignore and re-read.
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const value = await choose();
|
|
210
|
+
|
|
211
|
+
switch (value) {
|
|
212
|
+
case 'approve':
|
|
165
213
|
write(` ${GREEN}✓${RST} ${DIM}${toolName}${RST} ${DIM}${summary.slice(0, 60)}${RST}\n\n`);
|
|
166
|
-
this.history.push({ tool: toolName, decision: 'yes', time: Date.now() });
|
|
167
|
-
return { approved: true };
|
|
214
|
+
this.history.push({ tool: toolName, decision: 'yes', tier, time: Date.now() });
|
|
215
|
+
return { approved: true, tier };
|
|
168
216
|
|
|
169
|
-
case '
|
|
170
|
-
case 'N':
|
|
171
|
-
case 'escape':
|
|
217
|
+
case 'reject':
|
|
172
218
|
write(` ${RED}✗${RST} ${DIM}denied${RST}\n\n`);
|
|
173
|
-
this.history.push({ tool: toolName, decision: 'no', time: Date.now() });
|
|
174
|
-
return { approved: false, reason: 'User denied' };
|
|
219
|
+
this.history.push({ tool: toolName, decision: 'no', tier, time: Date.now() });
|
|
220
|
+
return { approved: false, tier, reason: 'User denied' };
|
|
175
221
|
|
|
176
|
-
case '
|
|
177
|
-
|
|
178
|
-
if (isDestructive) return this._prompt(toolName, args, context);
|
|
222
|
+
case 'allow-all':
|
|
223
|
+
if (explicit) return this._prompt(toolName, args, context);
|
|
179
224
|
this.approveAll = true;
|
|
180
225
|
write(` ${GREEN}✓✓${RST} ${DIM}allow-all activated${RST}\n\n`);
|
|
181
|
-
this.history.push({ tool: toolName, decision: 'approve-all', time: Date.now() });
|
|
182
|
-
return { approved: true };
|
|
226
|
+
this.history.push({ tool: toolName, decision: 'approve-all', tier, time: Date.now() });
|
|
227
|
+
return { approved: true, tier };
|
|
183
228
|
|
|
184
|
-
case '
|
|
185
|
-
|
|
186
|
-
if (isDestructive) return this._prompt(toolName, args, context);
|
|
229
|
+
case 'allow-type':
|
|
230
|
+
if (explicit) return this._prompt(toolName, args, context);
|
|
187
231
|
this.approvedToolTypes.add(toolName);
|
|
188
232
|
write(` ${GREEN}✓${RST} ${DIM}always allow ${toolName}${RST}\n\n`);
|
|
189
|
-
this.history.push({ tool: toolName, decision: 'type-approve', time: Date.now() });
|
|
190
|
-
return { approved: true };
|
|
233
|
+
this.history.push({ tool: toolName, decision: 'type-approve', tier, time: Date.now() });
|
|
234
|
+
return { approved: true, tier };
|
|
191
235
|
|
|
192
|
-
case '
|
|
193
|
-
|
|
194
|
-
|
|
236
|
+
case 'why':
|
|
237
|
+
write(`\n ${DIM}${(context.reason || why).slice(0, 400)}${RST}\n\n`);
|
|
238
|
+
printedHeight = 0;
|
|
195
239
|
return this._prompt(toolName, args, context);
|
|
196
240
|
|
|
241
|
+
case 'edit':
|
|
242
|
+
case 'replan':
|
|
243
|
+
write(` ${YELLOW}↩${RST} ${DIM}reject with hint — rework the plan${RST}\n\n`);
|
|
244
|
+
this.history.push({ tool: toolName, decision: 'replan', tier, time: Date.now() });
|
|
245
|
+
return { approved: false, tier, reason: 'User asked to re-plan' };
|
|
246
|
+
|
|
197
247
|
default:
|
|
198
248
|
return this._prompt(toolName, args, context);
|
|
199
249
|
}
|
|
@@ -221,7 +271,16 @@ export class ApprovalManager {
|
|
|
221
271
|
const str = data.toString();
|
|
222
272
|
|
|
223
273
|
if (bytes[0] === 0x03) process.exit(0);
|
|
224
|
-
|
|
274
|
+
// Arrow keys: ESC [ A/B/C/D (3-byte CSI sequences)
|
|
275
|
+
if (bytes.length === 3 && bytes[0] === 0x1b && bytes[1] === 0x5b) {
|
|
276
|
+
if (bytes[2] === 0x41) { resolve('up'); return; }
|
|
277
|
+
if (bytes[2] === 0x42) { resolve('down'); return; }
|
|
278
|
+
if (bytes[2] === 0x43) { resolve('right'); return; }
|
|
279
|
+
if (bytes[2] === 0x44) { resolve('left'); return; }
|
|
280
|
+
}
|
|
281
|
+
// Bare Esc (single byte) — explicit reject signal
|
|
282
|
+
if (bytes.length === 1 && bytes[0] === 0x1b) { resolve('escape'); return; }
|
|
283
|
+
if (bytes[0] === 0x09) { resolve('tab'); return; }
|
|
225
284
|
if (str === '\r' || str === '\n') { resolve('return'); return; }
|
|
226
285
|
resolve(str);
|
|
227
286
|
});
|