@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.
@@ -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
+ }
@@ -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
- const NEVER_AUTO_APPROVE = new Set(['delete_file']);
23
-
24
- const FORCE_APPROVAL_SHELL = [
25
- /\brm\s/,
26
- /\bunlink\s/,
27
- /\brmdir\s/,
28
- /\bgit\s+clean/,
29
- /\bgit\s+reset/,
30
- /\bgit\s+push.*--force/,
31
- ];
32
-
33
- const RISK_LEVELS = {
34
- read_file: 'none', read_files: 'none', search_code: 'none',
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
- if (!WRITE_TOOLS.has(toolName) && !requireApproval) {
113
- return { approved: true };
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
- if (toolName === 'shell') {
116
- const risk = assessShellRisk(args.command);
117
- if (risk === 'low') {
118
- this.history.push({ tool: toolName, decision: 'auto-safe', time: Date.now() });
119
- return { approved: true };
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 (FORCE_APPROVAL_SHELL.some(p => p.test(args.command || ''))) {
122
- return this._prompt(toolName, args, context);
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
- if (NEVER_AUTO_APPROVE.has(toolName)) {
126
- return this._prompt(toolName, args, context);
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 baseRisk = RISK_LEVELS[toolName] || 'medium';
141
- const assessedRisk = toolName === 'shell' ? assessShellRisk(args.command) : baseRisk;
142
- const risk = context.risk || assessedRisk;
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 isDestructive = risk === 'high';
146
-
147
- write(`\n ${isDestructive ? `${YELLOW}⚠${RST}` : `${CYAN}?${RST}`} ${BOLD}Approval required${RST}\n`);
148
- write(` ${GRAY}Action${RST} ${WHITE}${label}${RST}\n`);
149
- if (summary) write(` ${GRAY}Target${RST} ${WHITE}${summary.slice(0, 160)}${RST}\n`);
150
- write(` ${GRAY}Risk${RST} ${isDestructive ? YELLOW : CYAN}${risk}${RST}\n`);
151
- if (context.reason) write(` ${GRAY}Reason${RST} ${DIM}${String(context.reason).slice(0, 160)}${RST}\n`);
152
-
153
- if (isDestructive) {
154
- write(` ${DIM}Choose${RST} ${WHITE}[y]${RST} allow once ${WHITE}[n]${RST} deny ${WHITE}[d]${RST} details\n`);
155
- } else {
156
- write(` ${DIM}Choose${RST} ${WHITE}[y]${RST} once ${WHITE}[n]${RST} deny ${WHITE}[t]${RST} this action ${WHITE}[a]${RST} all ${WHITE}[d]${RST} details\n`);
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 key = await this._readKey();
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
- switch (key) {
162
- case 'y':
163
- case 'Y':
164
- case 'return':
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 'n':
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 'a':
177
- case 'A':
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 't':
185
- case 'T':
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 'd':
193
- case 'D':
194
- write(`\n${DIM}${JSON.stringify(args, null, 2)}${RST}\n\n`);
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
- if (bytes[0] === 0x1b) { resolve('escape'); return; }
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
  });