shoud-cli 1.0.11 → 3.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/bin/shoud.js +220 -116
- package/install.sh +13 -8
- package/package.json +10 -11
- package/src/auth/credentials.js +49 -0
- package/src/auth/deviceFlow.js +89 -111
- package/src/job/baseline.js +82 -0
- package/src/job/checkpoint.js +61 -0
- package/src/job/manager.js +89 -0
- package/src/job/model.js +54 -0
- package/src/job/receipt.js +92 -0
- package/src/job/undo.js +104 -0
- package/src/project/discovery.js +93 -0
- package/src/project/ignore.js +31 -0
- package/src/project/shoudMd.js +38 -0
- package/src/runtime/agentLoop.js +281 -98
- package/src/runtime/budget.js +30 -0
- package/src/runtime/noProgress.js +38 -0
- package/src/runtime/verification.js +39 -0
- package/src/security/permissions.js +132 -0
- package/src/security/riskClassifier.js +127 -0
- package/src/security/secrets.js +57 -0
- package/src/security/shellParser.js +56 -0
- package/src/tools/files.js +81 -0
- package/src/tools/git.js +32 -0
- package/src/tools/index.js +92 -156
- package/src/tools/project.js +7 -0
- package/src/tools/search.js +49 -0
- package/src/tools/shell.js +71 -0
- package/src/ui/banner.js +20 -0
- package/src/ui/output.js +17 -0
- package/src/utils/config.js +47 -0
- package/src/utils/errors.js +21 -0
- package/src/context/checkpoint.js +0 -82
- package/src/permissions/engine.js +0 -133
package/src/runtime/agentLoop.js
CHANGED
|
@@ -1,130 +1,313 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
2
|
const chalk = require('chalk');
|
|
3
3
|
const ora = require('ora');
|
|
4
|
-
const open = require('open');
|
|
5
4
|
const { getToken } = require('../auth/deviceFlow');
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
8
|
-
const {
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
5
|
+
const { loadSettings } = require('../utils/config');
|
|
6
|
+
const { verifyPermission, createPermissionSession } = require('../security/permissions');
|
|
7
|
+
const { executeTool, toolList } = require('../tools');
|
|
8
|
+
const { redactDeep } = require('../security/secrets');
|
|
9
|
+
const { saveJob, appendAction, appendMessage } = require('../job/manager');
|
|
10
|
+
const { collectBaseline } = require('../job/baseline');
|
|
11
|
+
const { createCheckpoint, finalizeCheckpoint } = require('../job/checkpoint');
|
|
12
|
+
const { runVerification } = require('./verification');
|
|
13
|
+
const { createBudgetTracker, formatBudgetStatus } = require('./budget');
|
|
14
|
+
const { NoProgressDetector } = require('./noProgress');
|
|
15
|
+
const { JobStatus } = require('../job/model');
|
|
16
|
+
const { discoverProject, formatProject } = require('../project/discovery');
|
|
17
|
+
const { loadShoudMd } = require('../project/shoudMd');
|
|
18
|
+
const { formatReceipt } = require('../job/receipt');
|
|
19
|
+
const { ExitCodes } = require('../utils/errors');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Main agent loop. Orchestrates discovery → baseline → checkpoint → loop →
|
|
23
|
+
* verification → receipt.
|
|
24
|
+
*/
|
|
25
|
+
async function executeTask(job) {
|
|
26
|
+
const settings = loadSettings();
|
|
27
|
+
const token = await getToken();
|
|
28
|
+
if (!token) throw Object.assign(new Error('Not signed in. Run `shoud login`.'), { code: ExitCodes.AUTH_REQUIRED });
|
|
29
|
+
|
|
30
|
+
const projectRoot = job.projectRoot;
|
|
31
|
+
const budget = createBudgetTracker(job.budget);
|
|
32
|
+
const session = createPermissionSession();
|
|
33
|
+
const noProgress = new NoProgressDetector();
|
|
34
|
+
|
|
35
|
+
// ── 1. Project discovery ──────────────────────────────────────
|
|
36
|
+
console.log(chalk.cyan('\n● Discovering project...'));
|
|
37
|
+
const project = discoverProject(projectRoot);
|
|
38
|
+
job.project = project;
|
|
39
|
+
console.log(chalk.gray(formatProject(project)));
|
|
40
|
+
|
|
41
|
+
// ── 2. Load SHOUD.md ─────────────────────────────────────────
|
|
42
|
+
const shoud = loadShoudMd(projectRoot);
|
|
43
|
+
if (shoud.source) console.log(chalk.gray(`\n Loaded ${shoud.source}`));
|
|
44
|
+
|
|
45
|
+
// Combine verification commands: SHOUD.md overrides discovery
|
|
46
|
+
const verificationCommands = (shoud.verification.length ? shoud.verification : project.verification) || [];
|
|
47
|
+
job.verification.commands = verificationCommands;
|
|
48
|
+
|
|
49
|
+
// ── 3. Baseline ───────────────────────────────────────────────
|
|
50
|
+
if (verificationCommands.length) {
|
|
51
|
+
console.log(chalk.cyan('\n● Establishing baseline...'));
|
|
52
|
+
const baseline = await collectBaseline(project, verificationCommands, {
|
|
53
|
+
onProgress: (cmd) => process.stdout.write(chalk.gray(` → ${cmd}\n`)),
|
|
54
|
+
});
|
|
55
|
+
job.baseline = baseline;
|
|
56
|
+
if (baseline.git.dirty) {
|
|
57
|
+
console.log(chalk.yellow(`\n ⚠ ${baseline.git.modifiedFiles.length} pre-existing modified file(s) will be preserved.`));
|
|
58
|
+
}
|
|
18
59
|
}
|
|
19
60
|
|
|
20
|
-
|
|
21
|
-
const
|
|
61
|
+
// ── 4. Checkpoint ─────────────────────────────────────────────
|
|
62
|
+
const cp = createCheckpoint(job.id, projectRoot);
|
|
63
|
+
job.checkpoint = cp;
|
|
64
|
+
console.log(chalk.gray(`\n Checkpoint saved (undo with: shoud undo ${job.id})`));
|
|
22
65
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
66
|
+
// ── 5. Build the system prompt ────────────────────────────────
|
|
67
|
+
const systemPrompt = buildSystemPrompt(project, shoud, verificationCommands);
|
|
68
|
+
|
|
69
|
+
// ── 6. Initial message ────────────────────────────────────────
|
|
70
|
+
if (!job.messages.length) {
|
|
71
|
+
job.messages.push({ role: 'user', content: job.prompt });
|
|
28
72
|
}
|
|
29
73
|
|
|
74
|
+
job.status = JobStatus.RUNNING;
|
|
75
|
+
saveJob(job);
|
|
76
|
+
|
|
77
|
+
// ── 7. Agent loop ─────────────────────────────────────────────
|
|
30
78
|
let taskComplete = false;
|
|
31
|
-
|
|
79
|
+
let finalText = '';
|
|
80
|
+
const MAX_ITERATIONS = 50;
|
|
81
|
+
let iteration = 0;
|
|
82
|
+
|
|
83
|
+
while (!taskComplete && iteration < MAX_ITERATIONS) {
|
|
84
|
+
iteration++;
|
|
85
|
+
const spinner = ora('SHOUD is thinking...').start();
|
|
32
86
|
|
|
33
|
-
|
|
87
|
+
let response;
|
|
34
88
|
try {
|
|
35
|
-
|
|
36
|
-
messages,
|
|
37
|
-
systemPrompt
|
|
89
|
+
response = await axios.post(`${settings.apiUrl}/agent/infer`, {
|
|
90
|
+
messages: redactDeep(job.messages),
|
|
91
|
+
systemPrompt,
|
|
92
|
+
tools: toolList(),
|
|
38
93
|
}, {
|
|
39
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
94
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
95
|
+
timeout: 120000,
|
|
40
96
|
});
|
|
97
|
+
} catch (err) {
|
|
98
|
+
spinner.stop();
|
|
99
|
+
if (err.response?.status === 402) {
|
|
100
|
+
job.status = JobStatus.BUDGET_EXCEEDED;
|
|
101
|
+
job.completedAt = new Date().toISOString();
|
|
102
|
+
finalizeCheckpoint(job.id, projectRoot);
|
|
103
|
+
saveJob(job);
|
|
104
|
+
console.log(chalk.red('\n⏸ Credits exhausted. Job checkpointed.'));
|
|
105
|
+
console.log(chalk.gray('Run your command again after adding credits.'));
|
|
106
|
+
return job;
|
|
107
|
+
}
|
|
108
|
+
if (err.response?.status === 401) {
|
|
109
|
+
spinner.stop();
|
|
110
|
+
throw Object.assign(new Error('Session expired. Run `shoud login`.'), { code: ExitCodes.AUTH_REQUIRED });
|
|
111
|
+
}
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
spinner.stop();
|
|
41
115
|
|
|
42
|
-
|
|
116
|
+
// Cost estimate (backend should return this; fallback to a heuristic)
|
|
117
|
+
const cost = response.data.cost ?? estimateCost(response.data);
|
|
118
|
+
budget.add(cost);
|
|
119
|
+
job.spent = budget.spent;
|
|
43
120
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
121
|
+
const aiMessage = response.data;
|
|
122
|
+
if (!aiMessage?.content || !Array.isArray(aiMessage.content)) {
|
|
123
|
+
throw new Error('Invalid response from SHOUD agent.');
|
|
124
|
+
}
|
|
48
125
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
126
|
+
job.messages.push({ role: 'assistant', content: aiMessage.content });
|
|
127
|
+
saveJob(job);
|
|
128
|
+
|
|
129
|
+
// Budget check
|
|
130
|
+
const status = budget.status();
|
|
131
|
+
if (status.warn) {
|
|
132
|
+
console.log(chalk.yellow(`\n Budget: ${formatBudgetStatus(status)}`));
|
|
133
|
+
}
|
|
134
|
+
if (status.exceeded) {
|
|
135
|
+
job.status = JobStatus.BUDGET_EXCEEDED;
|
|
136
|
+
job.completedAt = new Date().toISOString();
|
|
137
|
+
finalizeCheckpoint(job.id, projectRoot);
|
|
138
|
+
saveJob(job);
|
|
139
|
+
console.log(chalk.red('\n⏸ Budget exhausted. Job checkpointed.'));
|
|
140
|
+
return job;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (aiMessage.stop_reason === 'tool_use') {
|
|
144
|
+
const toolResults = [];
|
|
145
|
+
for (const block of aiMessage.content) {
|
|
146
|
+
if (block.type !== 'tool_use') continue;
|
|
147
|
+
|
|
148
|
+
console.log(chalk.blue(`\n● ${block.name}`));
|
|
149
|
+
const permission = await verifyPermission(session, block.name, block.input);
|
|
52
150
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} catch (toolError) {
|
|
69
|
-
spinner.stop();
|
|
70
|
-
console.log(chalk.red(`✕ Tool execution failed: ${toolError.message}`));
|
|
71
|
-
toolResult = `Error: ${toolError.message}`;
|
|
72
|
-
}
|
|
73
|
-
} else {
|
|
74
|
-
toolResult = 'User denied permission to execute this tool.';
|
|
75
|
-
console.log(chalk.red(`✕ Permission denied.`));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Send tool result back to the agent
|
|
79
|
-
messages.push({
|
|
80
|
-
role: 'user',
|
|
81
|
-
content: [{ type: 'tool_result', tool_use_id: block.id, content: toolResult }]
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
// Save checkpoint after each tool interaction
|
|
85
|
-
saveCheckpoint({ messages });
|
|
151
|
+
let result;
|
|
152
|
+
if (!permission.allowed) {
|
|
153
|
+
result = { ok: false, error: `Permission denied: ${permission.reason}` };
|
|
154
|
+
console.log(chalk.red(` ✕ ${permission.reason}`));
|
|
155
|
+
} else {
|
|
156
|
+
const ctx = {
|
|
157
|
+
projectRoot,
|
|
158
|
+
onOutput: (chunk) => process.stdout.write(chalk.gray(chunk)),
|
|
159
|
+
};
|
|
160
|
+
try {
|
|
161
|
+
result = await executeTool(block.name, ctx, block.input);
|
|
162
|
+
console.log(chalk.green(` ✓ done`));
|
|
163
|
+
} catch (e) {
|
|
164
|
+
result = { ok: false, error: e.message };
|
|
165
|
+
console.log(chalk.red(` ✕ ${e.message}`));
|
|
86
166
|
}
|
|
87
167
|
}
|
|
88
|
-
spinner.start('Analyzing tool results...');
|
|
89
|
-
} else {
|
|
90
|
-
// Task completed (no more tool calls)
|
|
91
|
-
taskComplete = true;
|
|
92
|
-
clearCheckpoint();
|
|
93
168
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
169
|
+
noProgress.recordAction({}, block.name, block.input);
|
|
170
|
+
noProgress.recordResult(block.name, block.input, result);
|
|
171
|
+
|
|
172
|
+
// Track changed files
|
|
173
|
+
if (['write_file', 'apply_patch'].includes(block.name) && block.input.path) {
|
|
174
|
+
if (!job.changedFiles.includes(block.input.path)) {
|
|
175
|
+
job.changedFiles.push(block.input.path);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
99
178
|
|
|
100
|
-
|
|
101
|
-
|
|
179
|
+
appendAction(job.id, {
|
|
180
|
+
at: new Date().toISOString(),
|
|
181
|
+
tool: block.name,
|
|
182
|
+
input: redactDeep(block.input),
|
|
183
|
+
ok: result?.ok !== false,
|
|
184
|
+
permission: permission.reason,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const toolResult = {
|
|
188
|
+
type: 'tool_result',
|
|
189
|
+
tool_use_id: block.id,
|
|
190
|
+
content: serializeResult(block.name, result),
|
|
191
|
+
is_error: result?.ok === false,
|
|
192
|
+
};
|
|
193
|
+
toolResults.push(toolResult);
|
|
102
194
|
}
|
|
103
195
|
|
|
104
|
-
|
|
105
|
-
|
|
196
|
+
job.messages.push({ role: 'user', content: toolResults });
|
|
197
|
+
saveJob(job);
|
|
106
198
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
console.log(chalk.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return;
|
|
199
|
+
// No-progress detection
|
|
200
|
+
const stuck = noProgress.isStuck();
|
|
201
|
+
if (stuck) {
|
|
202
|
+
job.status = JobStatus.BLOCKED;
|
|
203
|
+
job.completedAt = new Date().toISOString();
|
|
204
|
+
job.error = `No progress detected: ${stuck.reason}`;
|
|
205
|
+
finalizeCheckpoint(job.id, projectRoot);
|
|
206
|
+
saveJob(job);
|
|
207
|
+
console.log(chalk.yellow(`\n■ SHOUD is blocked: ${stuck.reason}.`));
|
|
208
|
+
console.log(chalk.gray('No further changes will be attempted.'));
|
|
209
|
+
return job;
|
|
119
210
|
}
|
|
211
|
+
} else {
|
|
212
|
+
// Agent claims completion. We do NOT trust this. Run verification.
|
|
213
|
+
finalText = aiMessage.content
|
|
214
|
+
.filter(c => c.type === 'text')
|
|
215
|
+
.map(t => t.text).join('\n');
|
|
216
|
+
|
|
217
|
+
// ── 8. Verification ──────────────────────────────────────
|
|
218
|
+
if (verificationCommands.length) {
|
|
219
|
+
console.log(chalk.cyan('\n● Agent claims completion. Verifying...'));
|
|
220
|
+
job.status = JobStatus.VERIFYING;
|
|
221
|
+
saveJob(job);
|
|
222
|
+
|
|
223
|
+
const results = await runVerification(projectRoot, verificationCommands, {
|
|
224
|
+
onOutput: (chunk) => process.stdout.write(chalk.gray(chunk)),
|
|
225
|
+
});
|
|
226
|
+
job.verification.results = results;
|
|
227
|
+
|
|
228
|
+
const allPassed = results.every(r => r.ok);
|
|
120
229
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
230
|
+
if (allPassed) {
|
|
231
|
+
job.status = JobStatus.VERIFIED;
|
|
232
|
+
taskComplete = true;
|
|
233
|
+
} else {
|
|
234
|
+
// Feed failures back to agent
|
|
235
|
+
const failures = results.filter(r => !r.ok)
|
|
236
|
+
.map(r => `Command "${r.command}" failed (exit ${r.exitCode}):\n${r.summary}`)
|
|
237
|
+
.join('\n\n');
|
|
238
|
+
job.messages.push({
|
|
239
|
+
role: 'user',
|
|
240
|
+
content: [{
|
|
241
|
+
type: 'text',
|
|
242
|
+
text: `Verification failed. You claimed completion but the following checks did not pass. Fix the root cause and try again.\n\n${failures}`,
|
|
243
|
+
}],
|
|
244
|
+
});
|
|
245
|
+
job.status = JobStatus.RUNNING;
|
|
246
|
+
saveJob(job);
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
// No verification commands → IMPLEMENTED_NOT_VERIFIED
|
|
250
|
+
job.status = JobStatus.IMPLEMENTED_NOT_VERIFIED;
|
|
251
|
+
taskComplete = true;
|
|
252
|
+
}
|
|
126
253
|
}
|
|
127
254
|
}
|
|
255
|
+
|
|
256
|
+
if (iteration >= MAX_ITERATIONS) {
|
|
257
|
+
job.status = JobStatus.BLOCKED;
|
|
258
|
+
job.error = 'Max iterations reached without completion.';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── 9. Finalize ───────────────────────────────────────────────
|
|
262
|
+
job.completedAt = new Date().toISOString();
|
|
263
|
+
finalizeCheckpoint(job.id, projectRoot);
|
|
264
|
+
job.completion = { finalText };
|
|
265
|
+
saveJob(job);
|
|
266
|
+
|
|
267
|
+
// ── 10. Receipt ───────────────────────────────────────────────
|
|
268
|
+
console.log('\n' + formatReceipt(job));
|
|
269
|
+
|
|
270
|
+
return job;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildSystemPrompt(project, shoud, verificationCommands) {
|
|
274
|
+
return `You are SHOUD, an autonomous engineering agent operating on a local project.
|
|
275
|
+
|
|
276
|
+
PROJECT
|
|
277
|
+
Root: ${project.root}
|
|
278
|
+
Language: ${project.language || 'unknown'}
|
|
279
|
+
Framework: ${project.framework || '—'}
|
|
280
|
+
Package mgr: ${project.packageManager || '—'}
|
|
281
|
+
Testing: ${project.testing || '—'}
|
|
282
|
+
Git: ${project.git ? 'yes' : 'no'}
|
|
283
|
+
|
|
284
|
+
VERIFICATION COMMANDS
|
|
285
|
+
${verificationCommands.map(c => ' - ' + c).join('\n') || ' (none configured)'}
|
|
286
|
+
|
|
287
|
+
${shoud.rules ? `PROJECT RULES (from SHOUD.md — follow strictly):\n${shoud.rules}\n` : ''}
|
|
288
|
+
GUIDELINES
|
|
289
|
+
- Use structured tools (read_file_range, apply_patch, search_text, git_diff) instead of shelling out for everything.
|
|
290
|
+
- Prefer apply_patch over write_file for small edits.
|
|
291
|
+
- Never modify .env files, secrets, or files inside .shoudignore.
|
|
292
|
+
- Never weaken tests to make them pass. If a test asserts behaviour, fix the code, not the test.
|
|
293
|
+
- When you believe the task is complete, stop calling tools and reply with a short summary.
|
|
294
|
+
The runtime will independently run verification. Do not claim success prematurely.
|
|
295
|
+
- If blocked by an external prerequisite you cannot resolve, say so clearly and stop.
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function serializeResult(toolName, result) {
|
|
300
|
+
const { redactDeep } = require('../security/secrets');
|
|
301
|
+
const cleaned = redactDeep(result);
|
|
302
|
+
let s = typeof cleaned === 'string' ? cleaned : JSON.stringify(cleaned, null, 2);
|
|
303
|
+
if (s.length > 16000) s = s.slice(0, 16000) + '\n... [truncated]';
|
|
304
|
+
return s;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function estimateCost(data) {
|
|
308
|
+
// Fallback heuristic — backend should really send cost.
|
|
309
|
+
const text = JSON.stringify(data);
|
|
310
|
+
return text.length / 40000; // ~$0.000025 per char, rough
|
|
128
311
|
}
|
|
129
312
|
|
|
130
313
|
module.exports = { executeTask };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
function createBudgetTracker(limit) {
|
|
4
|
+
return {
|
|
5
|
+
limit,
|
|
6
|
+
spent: 0,
|
|
7
|
+
exceeded() { return this.spent >= this.limit; },
|
|
8
|
+
add(amount) {
|
|
9
|
+
this.spent += amount;
|
|
10
|
+
return this.status();
|
|
11
|
+
},
|
|
12
|
+
status() {
|
|
13
|
+
const remaining = this.limit - this.spent;
|
|
14
|
+
return {
|
|
15
|
+
limit: this.limit, spent: this.spent, remaining,
|
|
16
|
+
warn: remaining <= this.limit * 0.1,
|
|
17
|
+
exceeded: remaining <= 0,
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatBudgetStatus(b) {
|
|
24
|
+
const pct = Math.min(100, (b.spent / b.limit) * 100).toFixed(0);
|
|
25
|
+
const bar = '█'.repeat(Math.round(pct / 5)).padEnd(20, '░');
|
|
26
|
+
const color = b.exceeded ? chalk.red : b.warn ? chalk.yellow : chalk.green;
|
|
27
|
+
return color(` $${b.spent.toFixed(4)} / $${b.limit.toFixed(2)} ${bar}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { createBudgetTracker, formatBudgetStatus };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
|
|
3
|
+
Detects when the agent is spinning: same error, same patch, no file changes.
|
|
4
|
+
*/
|
|
5
|
+
class NoProgressDetector {
|
|
6
|
+
constructor({ maxRepeats = 3 } = {}) {
|
|
7
|
+
this.maxRepeats = maxRepeats;
|
|
8
|
+
this.errorHistory = new Map();
|
|
9
|
+
this.patchHistory = new Map();
|
|
10
|
+
this.actionsSinceChange = 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
recordAction(action, toolName, input) {
|
|
14
|
+
if (toolName === 'write_file' || toolName === 'apply_patch') {
|
|
15
|
+
const key = JSON.stringify({ path: input.path, content: input.content || input.new_text || '' });
|
|
16
|
+
this.patchHistory.set(key, (this.patchHistory.get(key) || 0) + 1);
|
|
17
|
+
this.actionsSinceChange = 0;
|
|
18
|
+
} else {
|
|
19
|
+
this.actionsSinceChange++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
recordResult(toolName, input, result) {
|
|
24
|
+
if (toolName === 'execute_shell' && result && !result.ok) {
|
|
25
|
+
const key = (result.stderr || result.stdout || '').slice(0, 500);
|
|
26
|
+
this.errorHistory.set(key, (this.errorHistory.get(key) || 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
isStuck() {
|
|
31
|
+
for (const count of this.errorHistory.values()) if (count >= this.maxRepeats) return { reason: 'repeated error' };
|
|
32
|
+
for (const count of this.patchHistory.values()) if (count >= this.maxRepeats) return { reason: 'repeated patch' };
|
|
33
|
+
if (this.actionsSinceChange >= 8) return { reason: 'no file changes after 8 actions' };
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { NoProgressDetector };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const { runShell } = require('../tools/shell');
|
|
2
|
+
const { redact } = require('../security/secrets');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run verification commands. Returns results in the same shape the
|
|
6
|
+
* receipt expects. Streams output to onOutput.
|
|
7
|
+
*/
|
|
8
|
+
async function runVerification(projectRoot, commands, { onOutput, timeout = 600000 } = {}) {
|
|
9
|
+
const results = [];
|
|
10
|
+
for (const cmd of commands) {
|
|
11
|
+
if (onOutput) onOutput(`\n● Verifying: ${cmd}\n`);
|
|
12
|
+
const r = await runShell(cmd, {
|
|
13
|
+
cwd: projectRoot,
|
|
14
|
+
timeout,
|
|
15
|
+
onOutput: onOutput ? (chunk) => onOutput(chunk) : undefined,
|
|
16
|
+
});
|
|
17
|
+
const summary = summarizeOutput(r);
|
|
18
|
+
results.push({
|
|
19
|
+
command: cmd,
|
|
20
|
+
ok: r.ok,
|
|
21
|
+
exitCode: r.exitCode,
|
|
22
|
+
duration: r.duration,
|
|
23
|
+
summary: summary.text,
|
|
24
|
+
redactions: summary.redactions,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function summarizeOutput(result) {
|
|
31
|
+
const combined = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
32
|
+
const lines = combined.split('\n');
|
|
33
|
+
// Keep the last 200 lines; redact secrets
|
|
34
|
+
const truncated = lines.slice(-200).join('\n');
|
|
35
|
+
const { text, redactions } = redact(truncated);
|
|
36
|
+
return { text, redactions };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { runVerification };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const inquirer = require('inquirer');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const { paths } = require('../utils/config');
|
|
5
|
+
const { Risk, RiskLabel, classifyCommand } = require('./riskClassifier');
|
|
6
|
+
|
|
7
|
+
function loadAllowlist() {
|
|
8
|
+
try {
|
|
9
|
+
if (fs.existsSync(paths.ALLOWLIST_FILE)) {
|
|
10
|
+
return JSON.parse(fs.readFileSync(paths.ALLOWLIST_FILE, 'utf-8'));
|
|
11
|
+
}
|
|
12
|
+
} catch (_) {}
|
|
13
|
+
return { commands: [], paths: [], risks: [] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function saveAllowlist(data) {
|
|
17
|
+
fs.writeFileSync(paths.ALLOWLIST_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* In-memory session allowlist. Cleared when CLI exits.
|
|
22
|
+
*/
|
|
23
|
+
function createPermissionSession() {
|
|
24
|
+
return {
|
|
25
|
+
sessionCommands: new Set(),
|
|
26
|
+
sessionPaths: new Set(),
|
|
27
|
+
sessionRisks: new Set(),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPersistentlyAllowed(session, toolName, input) {
|
|
32
|
+
const allowlist = loadAllowlist();
|
|
33
|
+
|
|
34
|
+
if (toolName === 'read_file' || toolName === 'list_directory' ||
|
|
35
|
+
toolName === 'search_text' || toolName === 'search_files' ||
|
|
36
|
+
toolName === 'read_file_range' || toolName === 'git_status' ||
|
|
37
|
+
toolName === 'git_diff' || toolName === 'git_log' ||
|
|
38
|
+
toolName === 'project_info') {
|
|
39
|
+
return { allowed: true, reason: 'read-only tool' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (toolName === 'execute_shell') {
|
|
43
|
+
const { risk } = classifyCommand(input.command || '');
|
|
44
|
+
if (risk <= Risk.PROJECT_LOCAL) return { allowed: true, reason: 'low risk' };
|
|
45
|
+
|
|
46
|
+
if (risk >= Risk.LOCAL_MODIFICATION && allowlist.risks.includes(Risk[risk])) {
|
|
47
|
+
return { allowed: true, reason: 'risk-level allowlisted' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const first = (input.command || '').trim().split(/\s+/)[0];
|
|
51
|
+
if (allowlist.commands.includes(first)) return { allowed: true, reason: 'command allowlisted' };
|
|
52
|
+
|
|
53
|
+
if (session.sessionCommands.has(first)) return { allowed: true, reason: 'session allow' };
|
|
54
|
+
if (session.sessionRisks.has(risk)) return { allowed: true, reason: 'session risk allow' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (toolName === 'write_file' || toolName === 'apply_patch') {
|
|
58
|
+
const p = input.path || '';
|
|
59
|
+
if (allowlist.paths.includes(p) || session.sessionPaths.has(p)) {
|
|
60
|
+
return { allowed: true, reason: 'path allowlisted' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { allowed: false };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function verifyPermission(session, toolName, input) {
|
|
68
|
+
const pre = isPersistentlyAllowed(session, toolName, input);
|
|
69
|
+
if (pre.allowed) return { allowed: true, reason: pre.reason };
|
|
70
|
+
|
|
71
|
+
// Compute risk for display
|
|
72
|
+
let risk = null;
|
|
73
|
+
let details = '';
|
|
74
|
+
if (toolName === 'execute_shell') {
|
|
75
|
+
const c = classifyCommand(input.command || '');
|
|
76
|
+
risk = c.risk;
|
|
77
|
+
details = c.reasons.join(' · ');
|
|
78
|
+
} else if (toolName === 'write_file' || toolName === 'apply_patch') {
|
|
79
|
+
risk = Risk.LOCAL_MODIFICATION;
|
|
80
|
+
details = `path: ${input.path}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const riskLabel = risk !== null ? RiskLabel[risk] : 'WRITE';
|
|
84
|
+
|
|
85
|
+
console.log(chalk.yellow(`\n⚠ SHOUD requests permission: ${chalk.bold(toolName)} [${riskLabel}]`));
|
|
86
|
+
if (toolName === 'execute_shell') console.log(chalk.gray(` ${input.command}`));
|
|
87
|
+
else if (input.path) console.log(chalk.gray(` ${input.path}`));
|
|
88
|
+
if (details) console.log(chalk.gray(` (${details})`));
|
|
89
|
+
|
|
90
|
+
const choices = [
|
|
91
|
+
{ name: 'Allow once', value: 'once' },
|
|
92
|
+
{ name: 'Allow for this session', value: 'session' },
|
|
93
|
+
{ name: 'Always allow', value: 'always' },
|
|
94
|
+
{ name: 'Deny', value: 'deny' },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// For destructive commands, require type-to-confirm
|
|
98
|
+
if (risk === Risk.DESTRUCTIVE) {
|
|
99
|
+
console.log(chalk.red.bold('\n ⚠ DESTRUCTIVE OPERATION — requires explicit confirmation.'));
|
|
100
|
+
const { confirm } = await inquirer.prompt([{
|
|
101
|
+
type: 'input', name: 'confirm',
|
|
102
|
+
message: 'Type "confirm" to proceed:',
|
|
103
|
+
}]);
|
|
104
|
+
if (confirm !== 'confirm') return { allowed: false, reason: 'user refused destructive op' };
|
|
105
|
+
// Still confirm permission below
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { permission } = await inquirer.prompt([{
|
|
109
|
+
type: 'list', name: 'permission',
|
|
110
|
+
message: 'Allow this operation?',
|
|
111
|
+
choices,
|
|
112
|
+
}]);
|
|
113
|
+
|
|
114
|
+
if (permission === 'deny') return { allowed: false, reason: 'user denied' };
|
|
115
|
+
|
|
116
|
+
if (permission === 'session' || permission === 'always') {
|
|
117
|
+
const allowlist = loadAllowlist();
|
|
118
|
+
if (toolName === 'execute_shell') {
|
|
119
|
+
const first = (input.command || '').trim().split(/\s+/)[0];
|
|
120
|
+
if (permission === 'session') session.sessionCommands.add(first);
|
|
121
|
+
else if (!allowlist.commands.includes(first)) { allowlist.commands.push(first); saveAllowlist(allowlist); }
|
|
122
|
+
} else if (toolName === 'write_file' || toolName === 'apply_patch') {
|
|
123
|
+
const p = input.path;
|
|
124
|
+
if (permission === 'session') session.sessionPaths.add(p);
|
|
125
|
+
else if (!allowlist.paths.includes(p)) { allowlist.paths.push(p); saveAllowlist(allowlist); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { allowed: true, reason: permission };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { verifyPermission, createPermissionSession, loadAllowlist };
|