@miphamai/cli 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/mipham/self-audit.mipham-skill.md +171 -0
- package/src/core/auto-memory.ts +613 -0
- package/src/core/crsi-sandbox.ts +546 -0
- package/src/core/engine.ts +20 -0
- package/src/core/permission.ts +65 -10
- package/src/tools/agent/enter-plan.ts +5 -5
- package/src/tools/agent/exit-plan.ts +36 -27
- package/src/tools/exec/bash.ts +113 -0
- package/src/ui/input.tsx +61 -8
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRSI Phase 3: Security Sandbox for Controlled Code Self-Modification
|
|
3
|
+
*
|
|
4
|
+
* Provides a safe execution environment where AI-generated code changes
|
|
5
|
+
* are:
|
|
6
|
+
* 1. Applied in an isolated git worktree (no effect on working tree)
|
|
7
|
+
* 2. Validated against the full test suite
|
|
8
|
+
* 3. Auto-rolled back on failure
|
|
9
|
+
* 4. Gated by human approval before merging
|
|
10
|
+
*
|
|
11
|
+
* Architecture:
|
|
12
|
+
* AI proposes change → CrsiSandbox applies in worktree → runs tests →
|
|
13
|
+
* if pass → presents diff for human approval → if approved → merges
|
|
14
|
+
* if fail → auto-discards worktree → logs failure for CRSI learning
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execSync } from 'node:child_process'
|
|
18
|
+
import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
|
|
19
|
+
import { join, resolve } from 'node:path'
|
|
20
|
+
import { tmpdir, homedir } from 'node:os'
|
|
21
|
+
import { randomUUID } from 'node:crypto'
|
|
22
|
+
|
|
23
|
+
// ── Types ──
|
|
24
|
+
|
|
25
|
+
export interface CrsiModification {
|
|
26
|
+
/** Unique ID for this modification attempt */
|
|
27
|
+
id: string
|
|
28
|
+
/** Human-readable description of what this change does */
|
|
29
|
+
description: string
|
|
30
|
+
/** The file path (relative to repo root) to modify */
|
|
31
|
+
filePath: string
|
|
32
|
+
/** The file content after modification */
|
|
33
|
+
newContent: string
|
|
34
|
+
/** The original file content (for rollback verification) */
|
|
35
|
+
originalContent: string
|
|
36
|
+
/** CRSI insight that triggered this modification */
|
|
37
|
+
crsiInsightId?: string
|
|
38
|
+
/** CRSI rule that generated this modification */
|
|
39
|
+
crsiRuleId?: string
|
|
40
|
+
/** Timestamp */
|
|
41
|
+
timestamp: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CrsiModificationResult {
|
|
45
|
+
modification: CrsiModification
|
|
46
|
+
/** Whether the modification was applied successfully */
|
|
47
|
+
applied: boolean
|
|
48
|
+
/** Test results after applying the modification */
|
|
49
|
+
testResult?: CrsiTestResult
|
|
50
|
+
/** Git diff of the change */
|
|
51
|
+
diff?: string
|
|
52
|
+
/** Error message if something failed */
|
|
53
|
+
error?: string
|
|
54
|
+
/** Current phase of the sandbox pipeline */
|
|
55
|
+
phase: 'pending' | 'applied' | 'testing' | 'passed' | 'failed' | 'approved' | 'merged' | 'rolled-back'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface CrsiTestResult {
|
|
59
|
+
/** Whether all tests passed */
|
|
60
|
+
passed: boolean
|
|
61
|
+
/** Total test count */
|
|
62
|
+
totalTests: number
|
|
63
|
+
/** Failed test count */
|
|
64
|
+
failedTests: number
|
|
65
|
+
/** Test output (truncated) */
|
|
66
|
+
output: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CrsiSessionReport {
|
|
70
|
+
sessionId: string
|
|
71
|
+
modifications: CrsiModificationResult[]
|
|
72
|
+
summary: {
|
|
73
|
+
total: number
|
|
74
|
+
applied: number
|
|
75
|
+
passed: number
|
|
76
|
+
merged: number
|
|
77
|
+
rolledBack: number
|
|
78
|
+
}
|
|
79
|
+
startedAt: string
|
|
80
|
+
completedAt?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Constants ──
|
|
84
|
+
|
|
85
|
+
const WORKTREE_PREFIX = 'crsi-sandbox-'
|
|
86
|
+
const MAX_WORKTREE_AGE_MS = 30 * 60 * 1000 // 30 minutes
|
|
87
|
+
const TEST_TIMEOUT_MS = 120_000 // 2 minutes
|
|
88
|
+
const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
|
|
89
|
+
|
|
90
|
+
// ── Sandbox ──
|
|
91
|
+
|
|
92
|
+
export class CrsiSandbox {
|
|
93
|
+
private repoRoot: string
|
|
94
|
+
private worktreePath?: string
|
|
95
|
+
private worktreeBranch?: string
|
|
96
|
+
private sessionReport: CrsiSessionReport
|
|
97
|
+
private currentModification?: CrsiModification
|
|
98
|
+
|
|
99
|
+
constructor(repoRoot: string = process.cwd()) {
|
|
100
|
+
this.repoRoot = resolve(repoRoot)
|
|
101
|
+
this.sessionReport = {
|
|
102
|
+
sessionId: `crsi-session-${Date.now().toString(36)}`,
|
|
103
|
+
modifications: [],
|
|
104
|
+
summary: { total: 0, applied: 0, passed: 0, merged: 0, rolledBack: 0 },
|
|
105
|
+
startedAt: new Date().toISOString(),
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ensure report directory exists
|
|
109
|
+
mkdirSync(REPORT_DIR, { recursive: true })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Public API ──
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stage 1: Create an isolated git worktree for safe modification.
|
|
116
|
+
*
|
|
117
|
+
* Creates a temporary worktree on a new branch. All subsequent
|
|
118
|
+
* modifications happen in this isolated environment — the user's
|
|
119
|
+
* working tree is never touched.
|
|
120
|
+
*/
|
|
121
|
+
createWorktree(): { worktreePath: string; branch: string } {
|
|
122
|
+
// Clean up any stale worktrees from previous crashed sessions
|
|
123
|
+
this.cleanupStaleWorktrees()
|
|
124
|
+
|
|
125
|
+
const branchName = `${WORKTREE_PREFIX}${randomUUID().slice(0, 8)}`
|
|
126
|
+
const worktreeDir = join(tmpdir(), branchName)
|
|
127
|
+
|
|
128
|
+
// Ensure the dir doesn't already exist
|
|
129
|
+
if (existsSync(worktreeDir)) {
|
|
130
|
+
rmSync(worktreeDir, { recursive: true, force: true })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
execSync(`git worktree add -b "${branchName}" "${worktreeDir}" HEAD`, {
|
|
135
|
+
cwd: this.repoRoot,
|
|
136
|
+
timeout: 30_000,
|
|
137
|
+
encoding: 'utf-8',
|
|
138
|
+
})
|
|
139
|
+
} catch (err) {
|
|
140
|
+
throw new Error(`Failed to create CRSI worktree: ${String(err)}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.worktreePath = worktreeDir
|
|
144
|
+
this.worktreeBranch = branchName
|
|
145
|
+
|
|
146
|
+
return { worktreePath: worktreeDir, branch: branchName }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Stage 2: Apply a modification in the sandbox worktree.
|
|
151
|
+
*
|
|
152
|
+
* Writes the new content to the target file inside the worktree.
|
|
153
|
+
* The modification is NOT applied to the user's working tree.
|
|
154
|
+
*/
|
|
155
|
+
applyModification(mod: CrsiModification): CrsiModificationResult {
|
|
156
|
+
if (!this.worktreePath) {
|
|
157
|
+
return { modification: mod, applied: false, phase: 'pending', error: 'No worktree created. Call createWorktree() first.' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.sessionReport.summary.total++
|
|
161
|
+
this.currentModification = mod
|
|
162
|
+
|
|
163
|
+
const result: CrsiModificationResult = {
|
|
164
|
+
modification: mod,
|
|
165
|
+
applied: false,
|
|
166
|
+
phase: 'pending',
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const targetPath = join(this.worktreePath, mod.filePath)
|
|
170
|
+
|
|
171
|
+
// Verify the file exists in the worktree
|
|
172
|
+
if (!existsSync(targetPath)) {
|
|
173
|
+
result.error = `File not found in worktree: ${mod.filePath}`
|
|
174
|
+
result.phase = 'failed'
|
|
175
|
+
this.sessionReport.modifications.push(result)
|
|
176
|
+
return result
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Verify original content matches (safety check)
|
|
180
|
+
try {
|
|
181
|
+
const currentContent = readFileSync(targetPath, 'utf-8')
|
|
182
|
+
if (currentContent !== mod.originalContent && mod.originalContent) {
|
|
183
|
+
result.error = 'Original content mismatch — file may have been modified since the audit. Aborting.'
|
|
184
|
+
result.phase = 'failed'
|
|
185
|
+
this.sessionReport.modifications.push(result)
|
|
186
|
+
return result
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// If we can't read the file, proceed anyway — it's a worktree
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Apply the modification
|
|
193
|
+
try {
|
|
194
|
+
writeFileSync(targetPath, mod.newContent, 'utf-8')
|
|
195
|
+
result.applied = true
|
|
196
|
+
result.phase = 'applied'
|
|
197
|
+
this.sessionReport.summary.applied++
|
|
198
|
+
|
|
199
|
+
// Generate diff
|
|
200
|
+
try {
|
|
201
|
+
result.diff = execSync(`git diff -- "${mod.filePath}"`, {
|
|
202
|
+
cwd: this.worktreePath,
|
|
203
|
+
timeout: 10_000,
|
|
204
|
+
encoding: 'utf-8',
|
|
205
|
+
})
|
|
206
|
+
} catch {
|
|
207
|
+
// Diff generation is best-effort
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
result.error = `Failed to apply modification: ${String(err)}`
|
|
211
|
+
result.phase = 'failed'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.sessionReport.modifications.push(result)
|
|
215
|
+
return result
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Stage 3: Run the test suite against the modified worktree.
|
|
220
|
+
*
|
|
221
|
+
* Only modifications that have been successfully applied can be tested.
|
|
222
|
+
* If tests fail, the modification is marked as 'failed' and will be
|
|
223
|
+
* rolled back.
|
|
224
|
+
*/
|
|
225
|
+
runTests(): CrsiTestResult {
|
|
226
|
+
if (!this.worktreePath) {
|
|
227
|
+
return { passed: false, totalTests: 0, failedTests: 0, output: 'No worktree available.' }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result: CrsiTestResult = { passed: false, totalTests: 0, failedTests: 0, output: '' }
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const output = execSync('pnpm test 2>&1', {
|
|
234
|
+
cwd: this.worktreePath,
|
|
235
|
+
timeout: TEST_TIMEOUT_MS,
|
|
236
|
+
encoding: 'utf-8',
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
result.output = output.slice(-5000) // Keep last 5000 chars
|
|
240
|
+
|
|
241
|
+
// Parse test results from vitest output
|
|
242
|
+
const testMatch = output.match(/Tests\s+(\d+)\s+passed/)
|
|
243
|
+
const failMatch = output.match(/(\d+)\s+failed/)
|
|
244
|
+
const totalMatch = output.match(/Tests\s+(\d+)\s+passed.*?(\d+)\s+total/)
|
|
245
|
+
|
|
246
|
+
if (totalMatch) {
|
|
247
|
+
result.totalTests = parseInt(totalMatch[2]!, 10)
|
|
248
|
+
}
|
|
249
|
+
if (testMatch && !failMatch) {
|
|
250
|
+
result.passed = true
|
|
251
|
+
result.failedTests = 0
|
|
252
|
+
} else if (failMatch) {
|
|
253
|
+
result.failedTests = parseInt(failMatch[1]!, 10)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Update all pending modifications to 'testing' → 'passed'/'failed'
|
|
257
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
258
|
+
if (modResult.phase === 'applied') {
|
|
259
|
+
modResult.phase = 'testing'
|
|
260
|
+
modResult.testResult = result
|
|
261
|
+
}
|
|
262
|
+
if (modResult.phase === 'testing') {
|
|
263
|
+
modResult.phase = result.passed ? 'passed' : 'failed'
|
|
264
|
+
if (result.passed) {
|
|
265
|
+
this.sessionReport.summary.passed++
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
result.output = String(err).slice(0, 5000)
|
|
271
|
+
result.failedTests = 1
|
|
272
|
+
|
|
273
|
+
// Mark all applied modifications as failed
|
|
274
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
275
|
+
if (modResult.phase === 'applied' || modResult.phase === 'testing') {
|
|
276
|
+
modResult.phase = 'failed'
|
|
277
|
+
modResult.testResult = result
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return result
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Stage 4: Get the human-approvable diff of all modifications.
|
|
287
|
+
*
|
|
288
|
+
* Returns the full git diff so a human can review before approving.
|
|
289
|
+
*/
|
|
290
|
+
getDiff(): string {
|
|
291
|
+
if (!this.worktreePath) return ''
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
return execSync('git diff', {
|
|
295
|
+
cwd: this.worktreePath,
|
|
296
|
+
timeout: 10_000,
|
|
297
|
+
encoding: 'utf-8',
|
|
298
|
+
})
|
|
299
|
+
} catch {
|
|
300
|
+
return '(diff unavailable)'
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Stage 5: Merge modifications back to the main repo.
|
|
306
|
+
*
|
|
307
|
+
* ONLY call this after human approval and successful test run.
|
|
308
|
+
* Cherry-picks the changes from the worktree branch to the current branch.
|
|
309
|
+
*/
|
|
310
|
+
merge(): { success: boolean; message: string } {
|
|
311
|
+
if (!this.worktreePath || !this.worktreeBranch) {
|
|
312
|
+
return { success: false, message: 'No worktree to merge from.' }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Check that all modifications passed tests
|
|
316
|
+
const failedCount = this.sessionReport.modifications.filter(
|
|
317
|
+
(m) => m.phase === 'failed',
|
|
318
|
+
).length
|
|
319
|
+
if (failedCount > 0) {
|
|
320
|
+
return {
|
|
321
|
+
success: false,
|
|
322
|
+
message: `${failedCount} modification(s) failed tests. Cannot merge.`,
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
// Commit in worktree
|
|
328
|
+
execSync('git add -A', { cwd: this.worktreePath, timeout: 10_000 })
|
|
329
|
+
execSync(
|
|
330
|
+
`git commit -m "CRSI auto-modification: ${this.sessionReport.sessionId}" --allow-empty`,
|
|
331
|
+
{ cwd: this.worktreePath, timeout: 10_000 },
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
// Cherry-pick to main repo
|
|
335
|
+
const commitHash = execSync('git rev-parse HEAD', {
|
|
336
|
+
cwd: this.worktreePath,
|
|
337
|
+
timeout: 10_000,
|
|
338
|
+
encoding: 'utf-8',
|
|
339
|
+
}).trim()
|
|
340
|
+
|
|
341
|
+
execSync(`git cherry-pick "${commitHash}"`, {
|
|
342
|
+
cwd: this.repoRoot,
|
|
343
|
+
timeout: 30_000,
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// Mark as merged
|
|
347
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
348
|
+
if (modResult.phase === 'passed' || modResult.phase === 'approved') {
|
|
349
|
+
modResult.phase = 'merged'
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
this.sessionReport.summary.merged = this.sessionReport.modifications.filter(
|
|
353
|
+
(m) => m.phase === 'merged',
|
|
354
|
+
).length
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
success: true,
|
|
358
|
+
message: `Successfully merged ${this.sessionReport.summary.merged} modification(s).`,
|
|
359
|
+
}
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return { success: false, message: `Merge failed: ${String(err)}` }
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Stage 5-alt: Roll back all modifications.
|
|
367
|
+
*
|
|
368
|
+
* Discards the worktree and all changes. Called automatically when
|
|
369
|
+
* tests fail, or manually by the user if they reject the diff.
|
|
370
|
+
*/
|
|
371
|
+
rollback(): { success: boolean; message: string } {
|
|
372
|
+
if (!this.worktreePath || !this.worktreeBranch) {
|
|
373
|
+
return { success: false, message: 'No worktree to roll back.' }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Mark all modifications as rolled-back
|
|
377
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
378
|
+
if (modResult.phase !== 'merged') {
|
|
379
|
+
modResult.phase = 'rolled-back'
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
this.sessionReport.summary.rolledBack = this.sessionReport.modifications.filter(
|
|
383
|
+
(m) => m.phase === 'rolled-back',
|
|
384
|
+
).length
|
|
385
|
+
|
|
386
|
+
// Remove the worktree
|
|
387
|
+
this.removeWorktree()
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
success: true,
|
|
391
|
+
message: `Rolled back ${this.sessionReport.summary.rolledBack} modification(s).`,
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Clean up: remove the worktree and its branch.
|
|
397
|
+
* Safe to call at any time — does nothing if no worktree exists.
|
|
398
|
+
*/
|
|
399
|
+
removeWorktree(): void {
|
|
400
|
+
if (!this.worktreePath || !this.worktreeBranch) return
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
// Remove worktree
|
|
404
|
+
execSync(`git worktree remove --force "${this.worktreePath}"`, {
|
|
405
|
+
cwd: this.repoRoot,
|
|
406
|
+
timeout: 15_000,
|
|
407
|
+
})
|
|
408
|
+
} catch {
|
|
409
|
+
// Best-effort: if worktree remove fails, try manual cleanup
|
|
410
|
+
try {
|
|
411
|
+
if (existsSync(this.worktreePath)) {
|
|
412
|
+
rmSync(this.worktreePath, { recursive: true, force: true })
|
|
413
|
+
}
|
|
414
|
+
execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 10_000 })
|
|
415
|
+
} catch {
|
|
416
|
+
// Give up — stale worktrees will be cleaned up on next createWorktree()
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Delete the branch
|
|
421
|
+
try {
|
|
422
|
+
execSync(`git branch -D "${this.worktreeBranch}"`, {
|
|
423
|
+
cwd: this.repoRoot,
|
|
424
|
+
timeout: 10_000,
|
|
425
|
+
})
|
|
426
|
+
} catch {
|
|
427
|
+
// Branch may already be gone
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this.worktreePath = undefined
|
|
431
|
+
this.worktreeBranch = undefined
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Generate and persist the session report.
|
|
436
|
+
*/
|
|
437
|
+
finalize(): CrsiSessionReport {
|
|
438
|
+
this.sessionReport.completedAt = new Date().toISOString()
|
|
439
|
+
|
|
440
|
+
// Persist to ~/.mipham/crsi-sandbox/
|
|
441
|
+
const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
|
|
442
|
+
try {
|
|
443
|
+
writeFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), 'utf-8')
|
|
444
|
+
} catch {
|
|
445
|
+
// Best-effort persistence
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Clean up worktree
|
|
449
|
+
this.removeWorktree()
|
|
450
|
+
|
|
451
|
+
return this.sessionReport
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Get the current session report (for live status checks).
|
|
456
|
+
*/
|
|
457
|
+
getReport(): CrsiSessionReport {
|
|
458
|
+
return this.sessionReport
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Load a historical session report.
|
|
463
|
+
*/
|
|
464
|
+
static loadReport(sessionId: string): CrsiSessionReport | null {
|
|
465
|
+
const reportPath = join(REPORT_DIR, `${sessionId}.json`)
|
|
466
|
+
if (!existsSync(reportPath)) return null
|
|
467
|
+
try {
|
|
468
|
+
return JSON.parse(readFileSync(reportPath, 'utf-8'))
|
|
469
|
+
} catch {
|
|
470
|
+
return null
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* List all session reports.
|
|
476
|
+
*/
|
|
477
|
+
static listReports(): string[] {
|
|
478
|
+
if (!existsSync(REPORT_DIR)) return []
|
|
479
|
+
try {
|
|
480
|
+
return readdirSync(REPORT_DIR)
|
|
481
|
+
.filter((f: string) => f.endsWith('.json'))
|
|
482
|
+
.map((f: string) => f.replace('.json', ''))
|
|
483
|
+
} catch {
|
|
484
|
+
return []
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Private Helpers ──
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Remove stale worktrees from previous sessions that may have crashed
|
|
492
|
+
* before cleanup.
|
|
493
|
+
*/
|
|
494
|
+
private cleanupStaleWorktrees(): void {
|
|
495
|
+
try {
|
|
496
|
+
const listOutput = execSync('git worktree list --porcelain', {
|
|
497
|
+
cwd: this.repoRoot,
|
|
498
|
+
timeout: 10_000,
|
|
499
|
+
encoding: 'utf-8',
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
const worktrees = this.parseWorktreeList(listOutput)
|
|
503
|
+
|
|
504
|
+
for (const wt of worktrees) {
|
|
505
|
+
// Only clean up CRSI sandbox worktrees
|
|
506
|
+
if (!wt.branch.includes(WORKTREE_PREFIX)) continue
|
|
507
|
+
|
|
508
|
+
// Check if the worktree path still exists
|
|
509
|
+
if (!existsSync(wt.path)) {
|
|
510
|
+
// Stale entry — prune it
|
|
511
|
+
try {
|
|
512
|
+
execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 5_000 })
|
|
513
|
+
execSync(`git branch -D "${wt.branch}"`, { cwd: this.repoRoot, timeout: 5_000 })
|
|
514
|
+
} catch {
|
|
515
|
+
// Best-effort cleanup
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// Not a git repo or worktree command failed — skip cleanup
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private parseWorktreeList(output: string): Array<{ path: string; branch: string }> {
|
|
525
|
+
const worktrees: Array<{ path: string; branch: string }> = []
|
|
526
|
+
let current: { path?: string; branch?: string } = {}
|
|
527
|
+
|
|
528
|
+
for (const line of output.split('\n')) {
|
|
529
|
+
if (line.startsWith('worktree ')) {
|
|
530
|
+
if (current.path && current.branch) {
|
|
531
|
+
worktrees.push({ path: current.path, branch: current.branch })
|
|
532
|
+
}
|
|
533
|
+
current = { path: line.slice('worktree '.length) }
|
|
534
|
+
}
|
|
535
|
+
if (line.startsWith('branch ')) {
|
|
536
|
+
current.branch = line.slice('branch '.length).replace('refs/heads/', '')
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (current.path && current.branch) {
|
|
541
|
+
worktrees.push({ path: current.path, branch: current.branch })
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return worktrees
|
|
545
|
+
}
|
|
546
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { ArtifactServer } from '../artifacts/server'
|
|
|
14
14
|
import type { AgentRegistry } from '../agent/agent-registry'
|
|
15
15
|
import { analyzeForMemory } from './memory/memory-writer'
|
|
16
16
|
import { getMemoryManager } from './memory/memory-loader'
|
|
17
|
+
import { AutoMemoryEngine } from './auto-memory.js'
|
|
17
18
|
import type { AgentViewManager } from '../agent-view/agent-view-manager'
|
|
18
19
|
import type { SkillsLoader } from '../skills/loader'
|
|
19
20
|
import { getBackgroundAgentRegistry } from '../agent/background-registry'
|
|
@@ -45,6 +46,7 @@ export class QueryEngine {
|
|
|
45
46
|
private ruleEngine?: ExperienceRuleEngine
|
|
46
47
|
private _patternAnalyzer?: PatternAnalyzer
|
|
47
48
|
private _effectivenessTracker?: EffectivenessTracker
|
|
49
|
+
private _autoMemory?: AutoMemoryEngine
|
|
48
50
|
private goal?: string
|
|
49
51
|
private maxGoalLoops = 20
|
|
50
52
|
private lastAssistantContent?: string
|
|
@@ -1017,6 +1019,24 @@ export class QueryEngine {
|
|
|
1017
1019
|
return this._effectivenessTracker
|
|
1018
1020
|
}
|
|
1019
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* CRSI Phase 2: Lazily-initialized AutoMemoryEngine singleton.
|
|
1024
|
+
* Wires the CRSI Phase 1 pipeline (PatternAnalyzer, RuleEngine, EffectivenessTracker)
|
|
1025
|
+
* into the auto-reflection engine so that post-turn insights can feed rule generation.
|
|
1026
|
+
*/
|
|
1027
|
+
getAutoMemory(): AutoMemoryEngine {
|
|
1028
|
+
if (!this._autoMemory) {
|
|
1029
|
+
this._autoMemory = new AutoMemoryEngine()
|
|
1030
|
+
// Wire CRSI Phase 1 → Phase 2 bridge
|
|
1031
|
+
this._autoMemory.setCrsiPipeline(
|
|
1032
|
+
this.getPatternAnalyzer(),
|
|
1033
|
+
this.getRuleEngine() || new ExperienceRuleEngine(),
|
|
1034
|
+
this.getEffectivenessTracker(),
|
|
1035
|
+
)
|
|
1036
|
+
}
|
|
1037
|
+
return this._autoMemory
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1020
1040
|
/** Register a tool dynamically (used by MCP auto-registration). */
|
|
1021
1041
|
registerTool(tool: ToolDefinition): void {
|
|
1022
1042
|
if (this.tools.has(tool.name)) {
|
package/src/core/permission.ts
CHANGED
|
@@ -9,6 +9,53 @@ import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
|
9
9
|
import { matchBashRule, compileRule } from './permission-rules'
|
|
10
10
|
import { loadPermissionConfig, nextMode, clampMode, MODE_CYCLE } from './permission-config'
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Check if a Bash command is a "verification-only" command that should be
|
|
14
|
+
* auto-approved in acceptEdits mode. These are non-destructive read/check
|
|
15
|
+
* operations that form the core of the vibe coding edit→test→fix loop.
|
|
16
|
+
*/
|
|
17
|
+
function isVerificationCommand(input: Record<string, unknown>): boolean {
|
|
18
|
+
const cmd = (input.command as string) || ''
|
|
19
|
+
// Patterns for verification-only commands (no side effects on codebase)
|
|
20
|
+
const verifyPatterns = [
|
|
21
|
+
/\bpnpm\s+test\b/, // test runner
|
|
22
|
+
/\bpnpm\s+t\b/, // shorthand test
|
|
23
|
+
/\bpnpm\s+typecheck\b/, // type checking
|
|
24
|
+
/\bpnpm\s+lint\b/, // linting
|
|
25
|
+
/\bpnpm\s+format:check\b/, // format check
|
|
26
|
+
/\bnpm\s+test\b/, // npm test
|
|
27
|
+
/\bnpm\s+run\s+test\b/, // npm run test
|
|
28
|
+
/\bvitest\b/, // vitest runner
|
|
29
|
+
/\bjest\b/, // jest runner
|
|
30
|
+
/\btsc\s+(?!init)/, // TypeScript compiler (not tsc init)
|
|
31
|
+
/\btsc\s+--noEmit\b/, // type check only
|
|
32
|
+
/\beslint\b/, // eslint
|
|
33
|
+
/\bprettier\s+--check\b/, // prettier check
|
|
34
|
+
/\bpytest\b/, // python test runner
|
|
35
|
+
/\bruff\s+check\b/, // python linter
|
|
36
|
+
/\bcargo\s+test\b/, // rust test
|
|
37
|
+
/\bcargo\s+check\b/, // rust check
|
|
38
|
+
/\bgo\s+test\b/, // go test
|
|
39
|
+
/\bgo\s+vet\b/, // go vet
|
|
40
|
+
/\bmake\s+test\b/, // make test
|
|
41
|
+
/\bgit\s+status\b/, // git status (read-only)
|
|
42
|
+
/\bgit\s+diff\b/, // git diff (read-only)
|
|
43
|
+
/\bgit\s+log\b/, // git log (read-only)
|
|
44
|
+
/\bgit\s+branch\b/, // git branch (read-only)
|
|
45
|
+
/\bls\b/, // list files
|
|
46
|
+
/\bcat\b/, // read file
|
|
47
|
+
/\bhead\b/, // read file start
|
|
48
|
+
/\btail\b/, // read file end
|
|
49
|
+
/\bwhich\b/, // find binary
|
|
50
|
+
/\becho\b/, // print text
|
|
51
|
+
/\bnode\s+-v\b/, // node version
|
|
52
|
+
/\bpython\s+--version\b/, // python version
|
|
53
|
+
/\bwhoami\b/, // current user
|
|
54
|
+
/\bpwd\b/, // current directory
|
|
55
|
+
]
|
|
56
|
+
return verifyPatterns.some((p) => p.test(cmd))
|
|
57
|
+
}
|
|
58
|
+
|
|
12
59
|
const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
|
|
13
60
|
|
|
14
61
|
export class PermissionSystem {
|
|
@@ -276,7 +323,7 @@ export class PermissionSystem {
|
|
|
276
323
|
}
|
|
277
324
|
|
|
278
325
|
// 5. Mode baseline
|
|
279
|
-
const baseline = this.modeBaseline(tool)
|
|
326
|
+
const baseline = this.modeBaseline(tool, input)
|
|
280
327
|
if (baseline !== 'mode-baseline') {
|
|
281
328
|
this.checkCache.set(cacheKey, baseline)
|
|
282
329
|
return baseline
|
|
@@ -323,21 +370,29 @@ export class PermissionSystem {
|
|
|
323
370
|
return rule.pattern === tool.name || rule.compiled.test(tool.name)
|
|
324
371
|
}
|
|
325
372
|
|
|
326
|
-
private modeBaseline(
|
|
373
|
+
private modeBaseline(
|
|
374
|
+
tool: ToolDefinition,
|
|
375
|
+
input?: Record<string, unknown>,
|
|
376
|
+
): PermissionLevel | 'mode-baseline' {
|
|
327
377
|
switch (this.mode) {
|
|
328
378
|
case 'default':
|
|
329
379
|
// Delegate to tool.permission (backward compat)
|
|
330
380
|
return 'mode-baseline'
|
|
331
381
|
|
|
332
382
|
case 'acceptEdits':
|
|
333
|
-
// Reads + file edits free; Bash
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
:
|
|
339
|
-
|
|
340
|
-
|
|
383
|
+
// Reads + file edits free; Bash auto-approved for verification commands
|
|
384
|
+
if (tool.category === 'file' && tool.name !== 'Bash') {
|
|
385
|
+
return 'bypass'
|
|
386
|
+
}
|
|
387
|
+
if (tool.name === 'Bash') {
|
|
388
|
+
// Vibe coding fix: auto-approve verification commands
|
|
389
|
+
// so the edit→test→fix loop isn't interrupted by permission prompts
|
|
390
|
+
if (input && isVerificationCommand(input)) {
|
|
391
|
+
return 'bypass'
|
|
392
|
+
}
|
|
393
|
+
return 'ask'
|
|
394
|
+
}
|
|
395
|
+
return 'ask'
|
|
341
396
|
|
|
342
397
|
case 'plan':
|
|
343
398
|
// Only reads, no writes or executes
|
|
@@ -80,12 +80,12 @@ export const enterPlanModeTool: ToolDefinition = {
|
|
|
80
80
|
' ⚠️ All other tools — require confirmation',
|
|
81
81
|
'',
|
|
82
82
|
'Design your approach, explore the codebase, then:',
|
|
83
|
-
' • Use ExitPlanMode to submit your plan for
|
|
84
|
-
' •
|
|
83
|
+
' • Use ExitPlanMode to submit your plan for user review',
|
|
84
|
+
' • Present your plan and ask for explicit approval',
|
|
85
|
+
' • The user must say "approved" before you switch to acceptEdits mode',
|
|
85
86
|
'',
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
' approved: false → revert to default mode',
|
|
87
|
+
'⚠️ You CANNOT self-approve your plan. The user must explicitly confirm.',
|
|
88
|
+
' After ExitPlanMode, wait for user approval before making code changes.',
|
|
89
89
|
].join('\n'),
|
|
90
90
|
}
|
|
91
91
|
},
|