@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.
@@ -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
  });
@@ -9,8 +9,8 @@
9
9
 
10
10
  const BACKEND_URLS = {
11
11
  local: 'http://127.0.0.1:8000',
12
- development: 'https://tarang-backend-development.up.railway.app',
13
- production: 'https://tarang-backend-intl-web-app-production.up.railway.app',
12
+ development: 'https://codekepler-backend-dev.kindisland-9034322d.eastus.azurecontainerapps.io',
13
+ production: 'https://codekepler-backend-prod.redsky-6d31f3e5.eastus.azurecontainerapps.io',
14
14
  };
15
15
 
16
16
  // Aliases
@@ -188,6 +188,11 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
188
188
  toolBreakdown[t.tool] = (toolBreakdown[t.tool] || 0) + 1;
189
189
  }
190
190
 
191
+ // Include sub-agent tool counts in the total
192
+ for (const sa of subAgents) {
193
+ toolCount += sa.tool_calls || 0;
194
+ }
195
+
191
196
  emit({
192
197
  type: 'complete',
193
198
  tools: toolCount,
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Risk tier classifier — Mission Control (PRD-055 §8.1).
3
+ *
4
+ * import { classify, TIERS, behavior } from './risk-tier.mjs';
5
+ * const tier = classify('shell', { command: 'rm -rf node_modules' });
6
+ * // → 'shell-dangerous'
7
+ *
8
+ * Pure: no I/O, no async. Tested in isolation; the rest of the CLI relies on
9
+ * the return value to decide whether to auto-approve, auto-approve with
10
+ * checkpoint, or hold for explicit approval.
11
+ *
12
+ * The CLI never asks the backend for the tier. The backend's job is to say
13
+ * "this is what I want to run"; we map that to a tier locally so dangerous
14
+ * intent can't be hidden behind a friendly description.
15
+ */
16
+
17
+ // ── Tier enum ────────────────────────────────────────────────────────────
18
+
19
+ export const TIERS = Object.freeze({
20
+ READ: 'read',
21
+ LOCAL_EDIT: 'local-edit',
22
+ SHELL_SAFE: 'shell-safe',
23
+ SHELL_MEDIUM: 'shell-medium',
24
+ SHELL_DANGEROUS: 'shell-dangerous',
25
+ DESTRUCTIVE: 'destructive',
26
+ NETWORK: 'network',
27
+ });
28
+
29
+ /**
30
+ * Default behavior by tier:
31
+ * auto — proceed silently
32
+ * auto-with-undo — proceed but record a checkpoint first
33
+ * prompt-safe — prompt with Enter=approve default
34
+ * prompt-explicit — magenta-bordered prompt, no default
35
+ */
36
+ export const BEHAVIOR = Object.freeze({
37
+ [TIERS.READ]: 'auto',
38
+ [TIERS.LOCAL_EDIT]: 'auto-with-undo',
39
+ [TIERS.SHELL_SAFE]: 'auto',
40
+ [TIERS.SHELL_MEDIUM]: 'prompt-safe',
41
+ [TIERS.SHELL_DANGEROUS]: 'prompt-explicit',
42
+ [TIERS.DESTRUCTIVE]: 'prompt-explicit',
43
+ [TIERS.NETWORK]: 'prompt-safe',
44
+ });
45
+
46
+ export function behavior(tier) {
47
+ return BEHAVIOR[tier] || 'prompt-safe';
48
+ }
49
+
50
+ // ── Tool → tier (non-shell) ─────────────────────────────────────────────
51
+
52
+ const READ_TOOLS = new Set([
53
+ 'read_file', 'read_files',
54
+ 'search_code', 'search_files', 'grep',
55
+ 'list_files', 'get_file_info', 'get_project_overview',
56
+ 'git_status', 'git_diff',
57
+ 'analyze_code',
58
+ 'validate_file', 'validate_structure',
59
+ ]);
60
+
61
+ const LOCAL_EDIT_TOOLS = new Set([
62
+ 'edit_file', 'write_file', 'write_project',
63
+ ]);
64
+
65
+ const DESTRUCTIVE_TOOLS = new Set([
66
+ 'delete_file',
67
+ ]);
68
+
69
+ const NETWORK_TOOLS = new Set([
70
+ 'WebFetch', 'fetch_url',
71
+ ]);
72
+
73
+ // ── Shell sub-classifier ────────────────────────────────────────────────
74
+
75
+ const SHELL_SAFE_RE = [
76
+ // Inspection / read-only
77
+ /^\s*(ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
78
+ /^\s*git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|blame|shortlog|describe|rev-parse|ls-files|ls-tree|config\s+--get)\b/i,
79
+ // Test-only invocations
80
+ /^\s*(npm|pnpm|yarn)\s+(test|run\s+test|run\s+lint|list|ls|view|info|outdated)\b/i,
81
+ /^\s*node\s+--check\b/i,
82
+ /^\s*python3?\s+-m\s+py_compile\b/i,
83
+ /^\s*pytest\b(?!.*--?(delete|rm|destructive))/i,
84
+ /^\s*cargo\s+(check|test|clippy|build)\b/i,
85
+ /^\s*go\s+(test|vet|build)\b/i,
86
+ /^\s*make\s+(test|check|lint|build)\b/i,
87
+ ];
88
+
89
+ const SHELL_DANGEROUS_RE = [
90
+ /\brm\s+-r/i,
91
+ /\brm\s+--recursive/i,
92
+ /\brm\s+-rf?\b/i,
93
+ /\bunlink\s/i,
94
+ /\brmdir\s+-/i,
95
+ /\bgit\s+push.*--force/i,
96
+ /\bgit\s+push.*-f\b/i,
97
+ /\bgit\s+reset\s+--hard/i,
98
+ /\bgit\s+clean\s+-f/i,
99
+ /\bgit\s+checkout\s+\./i,
100
+ /\bgit\s+stash\s+drop/i,
101
+ /\bgit\s+branch\s+-D/i,
102
+ /\bgit\s+filter-branch/i,
103
+ /\bsudo\b/i,
104
+ /\bsu\s+-/i,
105
+ /\bcurl\b.*\|\s*(sh|bash|zsh)/i,
106
+ /\bwget\b.*\|\s*(sh|bash|zsh)/i,
107
+ /\beval\s+["'$(]/i,
108
+ /\bkubectl\s+delete/i,
109
+ /\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm|network\s+rm)/i,
110
+ /\bdrop\s+(table|database|schema)/i,
111
+ /\btruncate\s+table/i,
112
+ /\bmkfs\b/i,
113
+ /\bdd\s+if=/i,
114
+ /:\s*\(\s*\)\s*\{.*:\|/i, // fork bomb
115
+ />\s*\/dev\/sda/i,
116
+ ];
117
+
118
+ const SHELL_MEDIUM_RE = [
119
+ /^\s*(npm|pnpm|yarn)\s+(install|i|add|remove|uninstall|update|upgrade|publish|deploy)\b/i,
120
+ /^\s*pip\s+(install|uninstall|--upgrade)\b/i,
121
+ /^\s*pipx\s+(install|uninstall)\b/i,
122
+ /^\s*brew\s+(install|uninstall|upgrade|update)\b/i,
123
+ /^\s*apt(-get)?\s+(install|remove|upgrade|update)\b/i,
124
+ /^\s*cargo\s+(install|uninstall|publish|run)\b/i,
125
+ /^\s*go\s+(install|get|mod\s+tidy|mod\s+download)\b/i,
126
+ /^\s*make(\s|$)/i,
127
+ /^\s*git\s+(commit|push|pull|merge|rebase|fetch|checkout(?!\s+\.)|cherry-pick|revert|tag|stash(?!\s+drop))/i,
128
+ /^\s*docker\s+(build|run|exec|compose|pull|push|tag)/i,
129
+ ];
130
+
131
+ export function classifyShell(command) {
132
+ const cmd = String(command || '').trim();
133
+ if (!cmd) return TIERS.SHELL_MEDIUM;
134
+
135
+ // Dangerous wins over safe — never let a safe-looking prefix mask `&& rm -rf`.
136
+ if (SHELL_DANGEROUS_RE.some(re => re.test(cmd))) return TIERS.SHELL_DANGEROUS;
137
+
138
+ // For a chained command, classify each segment and take the riskiest —
139
+ // never let a safe-looking prefix mask `&& npm install` or worse.
140
+ if (/&&|\|\||;|\|(?!\|)/.test(cmd)) {
141
+ const segments = splitShellSegments(cmd);
142
+ if (segments.length > 1) {
143
+ let worst = TIERS.SHELL_SAFE;
144
+ for (const seg of segments) {
145
+ const t = classifyShell(seg);
146
+ worst = riskier(worst, t);
147
+ if (worst === TIERS.SHELL_DANGEROUS) return worst;
148
+ }
149
+ return worst;
150
+ }
151
+ }
152
+
153
+ if (SHELL_MEDIUM_RE.some(re => re.test(cmd))) return TIERS.SHELL_MEDIUM;
154
+ if (SHELL_SAFE_RE.some(re => re.test(cmd))) return TIERS.SHELL_SAFE;
155
+ return TIERS.SHELL_MEDIUM;
156
+ }
157
+
158
+ function splitShellSegments(cmd) {
159
+ // Split on top-level &&, ||, ;, | — naive but enough for the classifier.
160
+ return cmd.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean);
161
+ }
162
+
163
+ const TIER_ORDER = [
164
+ TIERS.READ,
165
+ TIERS.SHELL_SAFE,
166
+ TIERS.LOCAL_EDIT,
167
+ TIERS.NETWORK,
168
+ TIERS.SHELL_MEDIUM,
169
+ TIERS.DESTRUCTIVE,
170
+ TIERS.SHELL_DANGEROUS,
171
+ ];
172
+
173
+ function riskier(a, b) {
174
+ return TIER_ORDER.indexOf(b) > TIER_ORDER.indexOf(a) ? b : a;
175
+ }
176
+
177
+ // ── Top-level classify ──────────────────────────────────────────────────
178
+
179
+ /**
180
+ * Classify a tool call into a risk tier. Always returns one of TIERS.
181
+ *
182
+ * @param {string} tool Tool name (e.g. 'shell', 'edit_file')
183
+ * @param {object} args Tool arguments (e.g. { command: 'rm -rf x' })
184
+ */
185
+ export function classify(tool, args = {}) {
186
+ if (!tool) return TIERS.SHELL_MEDIUM;
187
+
188
+ if (READ_TOOLS.has(tool)) return TIERS.READ;
189
+ if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
190
+ if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
191
+ if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
192
+
193
+ if (tool === 'shell' || tool === 'run_tests' || tool === 'validate_build' || tool === 'lint_check') {
194
+ return classifyShell(args.command || args.cmd || '');
195
+ }
196
+
197
+ // Sub-agents inherit their parent's risk (read-ish by default).
198
+ if (['explore', 'plan', 'verify', 'debug', 'refactor', 'analyze_code'].includes(tool)) {
199
+ return TIERS.READ;
200
+ }
201
+
202
+ // MCP tools: assume network unless the name implies read.
203
+ if (tool.startsWith('mcp')) {
204
+ return /(?:read|get|list|search|describe|info|status)/i.test(tool) ? TIERS.READ : TIERS.NETWORK;
205
+ }
206
+
207
+ return TIERS.SHELL_MEDIUM;
208
+ }
209
+
210
+ /**
211
+ * Convenience: human label for a tier (used in approval prompts and the
212
+ * status bar). Returned strings already uppercased / hyphenated.
213
+ */
214
+ export function label(tier) {
215
+ switch (tier) {
216
+ case TIERS.READ: return 'READ';
217
+ case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
218
+ case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
219
+ case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
220
+ case TIERS.SHELL_DANGEROUS: return 'SHELL-DANGEROUS';
221
+ case TIERS.DESTRUCTIVE: return 'DESTRUCTIVE';
222
+ case TIERS.NETWORK: return 'NETWORK';
223
+ default: return String(tier || '').toUpperCase();
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Whether the tool needs an explicit human keystroke before running.
229
+ */
230
+ export function requiresExplicitApproval(tier) {
231
+ return behavior(tier) === 'prompt-explicit';
232
+ }
233
+
234
+ /**
235
+ * Whether the tier should auto-create an undo checkpoint before running.
236
+ */
237
+ export function requiresCheckpoint(tier) {
238
+ return behavior(tier) === 'auto-with-undo';
239
+ }