@miphamai/cli 0.27.0 → 0.29.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 +616 -0
- package/src/core/crsi-sandbox.ts +551 -0
- package/src/core/engine.ts +20 -0
- package/src/ui/app.tsx +41 -10
- package/src/ui/chat.tsx +13 -5
- package/src/ui/input.tsx +61 -8
|
@@ -0,0 +1,551 @@
|
|
|
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:
|
|
56
|
+
'pending' | 'applied' | 'testing' | 'passed' | 'failed' | 'approved' | 'merged' | 'rolled-back'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CrsiTestResult {
|
|
60
|
+
/** Whether all tests passed */
|
|
61
|
+
passed: boolean
|
|
62
|
+
/** Total test count */
|
|
63
|
+
totalTests: number
|
|
64
|
+
/** Failed test count */
|
|
65
|
+
failedTests: number
|
|
66
|
+
/** Test output (truncated) */
|
|
67
|
+
output: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CrsiSessionReport {
|
|
71
|
+
sessionId: string
|
|
72
|
+
modifications: CrsiModificationResult[]
|
|
73
|
+
summary: {
|
|
74
|
+
total: number
|
|
75
|
+
applied: number
|
|
76
|
+
passed: number
|
|
77
|
+
merged: number
|
|
78
|
+
rolledBack: number
|
|
79
|
+
}
|
|
80
|
+
startedAt: string
|
|
81
|
+
completedAt?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Constants ──
|
|
85
|
+
|
|
86
|
+
const WORKTREE_PREFIX = 'crsi-sandbox-'
|
|
87
|
+
const MAX_WORKTREE_AGE_MS = 30 * 60 * 1000 // 30 minutes
|
|
88
|
+
const TEST_TIMEOUT_MS = 120_000 // 2 minutes
|
|
89
|
+
const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
|
|
90
|
+
|
|
91
|
+
// ── Sandbox ──
|
|
92
|
+
|
|
93
|
+
export class CrsiSandbox {
|
|
94
|
+
private repoRoot: string
|
|
95
|
+
private worktreePath?: string
|
|
96
|
+
private worktreeBranch?: string
|
|
97
|
+
private sessionReport: CrsiSessionReport
|
|
98
|
+
private currentModification?: CrsiModification
|
|
99
|
+
|
|
100
|
+
constructor(repoRoot: string = process.cwd()) {
|
|
101
|
+
this.repoRoot = resolve(repoRoot)
|
|
102
|
+
this.sessionReport = {
|
|
103
|
+
sessionId: `crsi-session-${Date.now().toString(36)}`,
|
|
104
|
+
modifications: [],
|
|
105
|
+
summary: { total: 0, applied: 0, passed: 0, merged: 0, rolledBack: 0 },
|
|
106
|
+
startedAt: new Date().toISOString(),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Ensure report directory exists
|
|
110
|
+
mkdirSync(REPORT_DIR, { recursive: true })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Public API ──
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Stage 1: Create an isolated git worktree for safe modification.
|
|
117
|
+
*
|
|
118
|
+
* Creates a temporary worktree on a new branch. All subsequent
|
|
119
|
+
* modifications happen in this isolated environment — the user's
|
|
120
|
+
* working tree is never touched.
|
|
121
|
+
*/
|
|
122
|
+
createWorktree(): { worktreePath: string; branch: string } {
|
|
123
|
+
// Clean up any stale worktrees from previous crashed sessions
|
|
124
|
+
this.cleanupStaleWorktrees()
|
|
125
|
+
|
|
126
|
+
const branchName = `${WORKTREE_PREFIX}${randomUUID().slice(0, 8)}`
|
|
127
|
+
const worktreeDir = join(tmpdir(), branchName)
|
|
128
|
+
|
|
129
|
+
// Ensure the dir doesn't already exist
|
|
130
|
+
if (existsSync(worktreeDir)) {
|
|
131
|
+
rmSync(worktreeDir, { recursive: true, force: true })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
execSync(`git worktree add -b "${branchName}" "${worktreeDir}" HEAD`, {
|
|
136
|
+
cwd: this.repoRoot,
|
|
137
|
+
timeout: 30_000,
|
|
138
|
+
encoding: 'utf-8',
|
|
139
|
+
})
|
|
140
|
+
} catch (err) {
|
|
141
|
+
throw new Error(`Failed to create CRSI worktree: ${String(err)}`)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.worktreePath = worktreeDir
|
|
145
|
+
this.worktreeBranch = branchName
|
|
146
|
+
|
|
147
|
+
return { worktreePath: worktreeDir, branch: branchName }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Stage 2: Apply a modification in the sandbox worktree.
|
|
152
|
+
*
|
|
153
|
+
* Writes the new content to the target file inside the worktree.
|
|
154
|
+
* The modification is NOT applied to the user's working tree.
|
|
155
|
+
*/
|
|
156
|
+
applyModification(mod: CrsiModification): CrsiModificationResult {
|
|
157
|
+
if (!this.worktreePath) {
|
|
158
|
+
return {
|
|
159
|
+
modification: mod,
|
|
160
|
+
applied: false,
|
|
161
|
+
phase: 'pending',
|
|
162
|
+
error: 'No worktree created. Call createWorktree() first.',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.sessionReport.summary.total++
|
|
167
|
+
this.currentModification = mod
|
|
168
|
+
|
|
169
|
+
const result: CrsiModificationResult = {
|
|
170
|
+
modification: mod,
|
|
171
|
+
applied: false,
|
|
172
|
+
phase: 'pending',
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const targetPath = join(this.worktreePath, mod.filePath)
|
|
176
|
+
|
|
177
|
+
// Verify the file exists in the worktree
|
|
178
|
+
if (!existsSync(targetPath)) {
|
|
179
|
+
result.error = `File not found in worktree: ${mod.filePath}`
|
|
180
|
+
result.phase = 'failed'
|
|
181
|
+
this.sessionReport.modifications.push(result)
|
|
182
|
+
return result
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Verify original content matches (safety check)
|
|
186
|
+
try {
|
|
187
|
+
const currentContent = readFileSync(targetPath, 'utf-8')
|
|
188
|
+
if (currentContent !== mod.originalContent && mod.originalContent) {
|
|
189
|
+
result.error =
|
|
190
|
+
'Original content mismatch — file may have been modified since the audit. Aborting.'
|
|
191
|
+
result.phase = 'failed'
|
|
192
|
+
this.sessionReport.modifications.push(result)
|
|
193
|
+
return result
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// If we can't read the file, proceed anyway — it's a worktree
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Apply the modification
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(targetPath, mod.newContent, 'utf-8')
|
|
202
|
+
result.applied = true
|
|
203
|
+
result.phase = 'applied'
|
|
204
|
+
this.sessionReport.summary.applied++
|
|
205
|
+
|
|
206
|
+
// Generate diff
|
|
207
|
+
try {
|
|
208
|
+
result.diff = execSync(`git diff -- "${mod.filePath}"`, {
|
|
209
|
+
cwd: this.worktreePath,
|
|
210
|
+
timeout: 10_000,
|
|
211
|
+
encoding: 'utf-8',
|
|
212
|
+
})
|
|
213
|
+
} catch {
|
|
214
|
+
// Diff generation is best-effort
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
result.error = `Failed to apply modification: ${String(err)}`
|
|
218
|
+
result.phase = 'failed'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.sessionReport.modifications.push(result)
|
|
222
|
+
return result
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Stage 3: Run the test suite against the modified worktree.
|
|
227
|
+
*
|
|
228
|
+
* Only modifications that have been successfully applied can be tested.
|
|
229
|
+
* If tests fail, the modification is marked as 'failed' and will be
|
|
230
|
+
* rolled back.
|
|
231
|
+
*/
|
|
232
|
+
runTests(): CrsiTestResult {
|
|
233
|
+
if (!this.worktreePath) {
|
|
234
|
+
return { passed: false, totalTests: 0, failedTests: 0, output: 'No worktree available.' }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const result: CrsiTestResult = { passed: false, totalTests: 0, failedTests: 0, output: '' }
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const output = execSync('pnpm test 2>&1', {
|
|
241
|
+
cwd: this.worktreePath,
|
|
242
|
+
timeout: TEST_TIMEOUT_MS,
|
|
243
|
+
encoding: 'utf-8',
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
result.output = output.slice(-5000) // Keep last 5000 chars
|
|
247
|
+
|
|
248
|
+
// Parse test results from vitest output
|
|
249
|
+
const testMatch = output.match(/Tests\s+(\d+)\s+passed/)
|
|
250
|
+
const failMatch = output.match(/(\d+)\s+failed/)
|
|
251
|
+
const totalMatch = output.match(/Tests\s+(\d+)\s+passed.*?(\d+)\s+total/)
|
|
252
|
+
|
|
253
|
+
if (totalMatch) {
|
|
254
|
+
result.totalTests = parseInt(totalMatch[2]!, 10)
|
|
255
|
+
}
|
|
256
|
+
if (testMatch && !failMatch) {
|
|
257
|
+
result.passed = true
|
|
258
|
+
result.failedTests = 0
|
|
259
|
+
} else if (failMatch) {
|
|
260
|
+
result.failedTests = parseInt(failMatch[1]!, 10)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Update all pending modifications to 'testing' → 'passed'/'failed'
|
|
264
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
265
|
+
if (modResult.phase === 'applied') {
|
|
266
|
+
modResult.phase = 'testing'
|
|
267
|
+
modResult.testResult = result
|
|
268
|
+
}
|
|
269
|
+
if (modResult.phase === 'testing') {
|
|
270
|
+
modResult.phase = result.passed ? 'passed' : 'failed'
|
|
271
|
+
if (result.passed) {
|
|
272
|
+
this.sessionReport.summary.passed++
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
} catch (err) {
|
|
277
|
+
result.output = String(err).slice(0, 5000)
|
|
278
|
+
result.failedTests = 1
|
|
279
|
+
|
|
280
|
+
// Mark all applied modifications as failed
|
|
281
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
282
|
+
if (modResult.phase === 'applied' || modResult.phase === 'testing') {
|
|
283
|
+
modResult.phase = 'failed'
|
|
284
|
+
modResult.testResult = result
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return result
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Stage 4: Get the human-approvable diff of all modifications.
|
|
294
|
+
*
|
|
295
|
+
* Returns the full git diff so a human can review before approving.
|
|
296
|
+
*/
|
|
297
|
+
getDiff(): string {
|
|
298
|
+
if (!this.worktreePath) return ''
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
return execSync('git diff', {
|
|
302
|
+
cwd: this.worktreePath,
|
|
303
|
+
timeout: 10_000,
|
|
304
|
+
encoding: 'utf-8',
|
|
305
|
+
})
|
|
306
|
+
} catch {
|
|
307
|
+
return '(diff unavailable)'
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Stage 5: Merge modifications back to the main repo.
|
|
313
|
+
*
|
|
314
|
+
* ONLY call this after human approval and successful test run.
|
|
315
|
+
* Cherry-picks the changes from the worktree branch to the current branch.
|
|
316
|
+
*/
|
|
317
|
+
merge(): { success: boolean; message: string } {
|
|
318
|
+
if (!this.worktreePath || !this.worktreeBranch) {
|
|
319
|
+
return { success: false, message: 'No worktree to merge from.' }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Check that all modifications passed tests
|
|
323
|
+
const failedCount = this.sessionReport.modifications.filter((m) => m.phase === 'failed').length
|
|
324
|
+
if (failedCount > 0) {
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
message: `${failedCount} modification(s) failed tests. Cannot merge.`,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
// Commit in worktree
|
|
333
|
+
execSync('git add -A', { cwd: this.worktreePath, timeout: 10_000 })
|
|
334
|
+
execSync(
|
|
335
|
+
`git commit -m "CRSI auto-modification: ${this.sessionReport.sessionId}" --allow-empty`,
|
|
336
|
+
{ cwd: this.worktreePath, timeout: 10_000 },
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
// Cherry-pick to main repo
|
|
340
|
+
const commitHash = execSync('git rev-parse HEAD', {
|
|
341
|
+
cwd: this.worktreePath,
|
|
342
|
+
timeout: 10_000,
|
|
343
|
+
encoding: 'utf-8',
|
|
344
|
+
}).trim()
|
|
345
|
+
|
|
346
|
+
execSync(`git cherry-pick "${commitHash}"`, {
|
|
347
|
+
cwd: this.repoRoot,
|
|
348
|
+
timeout: 30_000,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// Mark as merged
|
|
352
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
353
|
+
if (modResult.phase === 'passed' || modResult.phase === 'approved') {
|
|
354
|
+
modResult.phase = 'merged'
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.sessionReport.summary.merged = this.sessionReport.modifications.filter(
|
|
358
|
+
(m) => m.phase === 'merged',
|
|
359
|
+
).length
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
success: true,
|
|
363
|
+
message: `Successfully merged ${this.sessionReport.summary.merged} modification(s).`,
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
return { success: false, message: `Merge failed: ${String(err)}` }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Stage 5-alt: Roll back all modifications.
|
|
372
|
+
*
|
|
373
|
+
* Discards the worktree and all changes. Called automatically when
|
|
374
|
+
* tests fail, or manually by the user if they reject the diff.
|
|
375
|
+
*/
|
|
376
|
+
rollback(): { success: boolean; message: string } {
|
|
377
|
+
if (!this.worktreePath || !this.worktreeBranch) {
|
|
378
|
+
return { success: false, message: 'No worktree to roll back.' }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Mark all modifications as rolled-back
|
|
382
|
+
for (const modResult of this.sessionReport.modifications) {
|
|
383
|
+
if (modResult.phase !== 'merged') {
|
|
384
|
+
modResult.phase = 'rolled-back'
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
this.sessionReport.summary.rolledBack = this.sessionReport.modifications.filter(
|
|
388
|
+
(m) => m.phase === 'rolled-back',
|
|
389
|
+
).length
|
|
390
|
+
|
|
391
|
+
// Remove the worktree
|
|
392
|
+
this.removeWorktree()
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
success: true,
|
|
396
|
+
message: `Rolled back ${this.sessionReport.summary.rolledBack} modification(s).`,
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Clean up: remove the worktree and its branch.
|
|
402
|
+
* Safe to call at any time — does nothing if no worktree exists.
|
|
403
|
+
*/
|
|
404
|
+
removeWorktree(): void {
|
|
405
|
+
if (!this.worktreePath || !this.worktreeBranch) return
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
// Remove worktree
|
|
409
|
+
execSync(`git worktree remove --force "${this.worktreePath}"`, {
|
|
410
|
+
cwd: this.repoRoot,
|
|
411
|
+
timeout: 15_000,
|
|
412
|
+
})
|
|
413
|
+
} catch {
|
|
414
|
+
// Best-effort: if worktree remove fails, try manual cleanup
|
|
415
|
+
try {
|
|
416
|
+
if (existsSync(this.worktreePath)) {
|
|
417
|
+
rmSync(this.worktreePath, { recursive: true, force: true })
|
|
418
|
+
}
|
|
419
|
+
execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 10_000 })
|
|
420
|
+
} catch {
|
|
421
|
+
// Give up — stale worktrees will be cleaned up on next createWorktree()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Delete the branch
|
|
426
|
+
try {
|
|
427
|
+
execSync(`git branch -D "${this.worktreeBranch}"`, {
|
|
428
|
+
cwd: this.repoRoot,
|
|
429
|
+
timeout: 10_000,
|
|
430
|
+
})
|
|
431
|
+
} catch {
|
|
432
|
+
// Branch may already be gone
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
this.worktreePath = undefined
|
|
436
|
+
this.worktreeBranch = undefined
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Generate and persist the session report.
|
|
441
|
+
*/
|
|
442
|
+
finalize(): CrsiSessionReport {
|
|
443
|
+
this.sessionReport.completedAt = new Date().toISOString()
|
|
444
|
+
|
|
445
|
+
// Persist to ~/.mipham/crsi-sandbox/
|
|
446
|
+
const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
|
|
447
|
+
try {
|
|
448
|
+
writeFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), 'utf-8')
|
|
449
|
+
} catch {
|
|
450
|
+
// Best-effort persistence
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Clean up worktree
|
|
454
|
+
this.removeWorktree()
|
|
455
|
+
|
|
456
|
+
return this.sessionReport
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Get the current session report (for live status checks).
|
|
461
|
+
*/
|
|
462
|
+
getReport(): CrsiSessionReport {
|
|
463
|
+
return this.sessionReport
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Load a historical session report.
|
|
468
|
+
*/
|
|
469
|
+
static loadReport(sessionId: string): CrsiSessionReport | null {
|
|
470
|
+
const reportPath = join(REPORT_DIR, `${sessionId}.json`)
|
|
471
|
+
if (!existsSync(reportPath)) return null
|
|
472
|
+
try {
|
|
473
|
+
return JSON.parse(readFileSync(reportPath, 'utf-8'))
|
|
474
|
+
} catch {
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* List all session reports.
|
|
481
|
+
*/
|
|
482
|
+
static listReports(): string[] {
|
|
483
|
+
if (!existsSync(REPORT_DIR)) return []
|
|
484
|
+
try {
|
|
485
|
+
return readdirSync(REPORT_DIR)
|
|
486
|
+
.filter((f: string) => f.endsWith('.json'))
|
|
487
|
+
.map((f: string) => f.replace('.json', ''))
|
|
488
|
+
} catch {
|
|
489
|
+
return []
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ── Private Helpers ──
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Remove stale worktrees from previous sessions that may have crashed
|
|
497
|
+
* before cleanup.
|
|
498
|
+
*/
|
|
499
|
+
private cleanupStaleWorktrees(): void {
|
|
500
|
+
try {
|
|
501
|
+
const listOutput = execSync('git worktree list --porcelain', {
|
|
502
|
+
cwd: this.repoRoot,
|
|
503
|
+
timeout: 10_000,
|
|
504
|
+
encoding: 'utf-8',
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
const worktrees = this.parseWorktreeList(listOutput)
|
|
508
|
+
|
|
509
|
+
for (const wt of worktrees) {
|
|
510
|
+
// Only clean up CRSI sandbox worktrees
|
|
511
|
+
if (!wt.branch.includes(WORKTREE_PREFIX)) continue
|
|
512
|
+
|
|
513
|
+
// Check if the worktree path still exists
|
|
514
|
+
if (!existsSync(wt.path)) {
|
|
515
|
+
// Stale entry — prune it
|
|
516
|
+
try {
|
|
517
|
+
execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 5_000 })
|
|
518
|
+
execSync(`git branch -D "${wt.branch}"`, { cwd: this.repoRoot, timeout: 5_000 })
|
|
519
|
+
} catch {
|
|
520
|
+
// Best-effort cleanup
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
} catch {
|
|
525
|
+
// Not a git repo or worktree command failed — skip cleanup
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private parseWorktreeList(output: string): Array<{ path: string; branch: string }> {
|
|
530
|
+
const worktrees: Array<{ path: string; branch: string }> = []
|
|
531
|
+
let current: { path?: string; branch?: string } = {}
|
|
532
|
+
|
|
533
|
+
for (const line of output.split('\n')) {
|
|
534
|
+
if (line.startsWith('worktree ')) {
|
|
535
|
+
if (current.path && current.branch) {
|
|
536
|
+
worktrees.push({ path: current.path, branch: current.branch })
|
|
537
|
+
}
|
|
538
|
+
current = { path: line.slice('worktree '.length) }
|
|
539
|
+
}
|
|
540
|
+
if (line.startsWith('branch ')) {
|
|
541
|
+
current.branch = line.slice('branch '.length).replace('refs/heads/', '')
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (current.path && current.branch) {
|
|
546
|
+
worktrees.push({ path: current.path, branch: current.branch })
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return worktrees
|
|
550
|
+
}
|
|
551
|
+
}
|
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/ui/app.tsx
CHANGED
|
@@ -44,6 +44,8 @@ export interface ToolMeta {
|
|
|
44
44
|
input: string
|
|
45
45
|
output?: string
|
|
46
46
|
collapsed: boolean
|
|
47
|
+
/** Original tool name before Claude Code display renaming (e.g. Write→Update). */
|
|
48
|
+
originalName?: string
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export interface ChatMessage {
|
|
@@ -87,7 +89,8 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
|
|
|
87
89
|
bypassPermissions: 'red',
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
/** Format a tool's input parameters into a compact one-line detail string.
|
|
92
|
+
/** Format a tool's input parameters into a compact one-line detail string.
|
|
93
|
+
* Tool display names follow Claude Code convention: Write/Edit → Update. */
|
|
91
94
|
function formatToolDetail(name: string, input: Record<string, unknown>): string {
|
|
92
95
|
switch (name) {
|
|
93
96
|
case 'Bash':
|
|
@@ -95,9 +98,9 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
|
|
|
95
98
|
case 'Read':
|
|
96
99
|
return (input.file_path as string) || ''
|
|
97
100
|
case 'Write':
|
|
98
|
-
return (input.file_path as string) || ''
|
|
101
|
+
return (input.file_path as string) || '' // displayed as "Update" in chat
|
|
99
102
|
case 'Edit':
|
|
100
|
-
return (input.file_path as string) || ''
|
|
103
|
+
return `${(input.file_path as string) || ''}: ${((input.old_string as string) || '').slice(0, 60)}` // displayed as "Update" in chat
|
|
101
104
|
case 'Grep':
|
|
102
105
|
return (input.pattern as string) || ''
|
|
103
106
|
case 'Glob':
|
|
@@ -115,6 +118,12 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
|
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
/** Claude Code display name: maps Write/Edit → Update for parity. */
|
|
122
|
+
function toolDisplayName(name: string): string {
|
|
123
|
+
if (name === 'Write' || name === 'Edit') return 'Update'
|
|
124
|
+
return name
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
export function App({
|
|
119
128
|
engine,
|
|
120
129
|
config,
|
|
@@ -462,7 +471,12 @@ export function App({
|
|
|
462
471
|
{
|
|
463
472
|
role: 'system' as const,
|
|
464
473
|
content: detail,
|
|
465
|
-
toolMeta: {
|
|
474
|
+
toolMeta: {
|
|
475
|
+
name: toolDisplayName(toolName),
|
|
476
|
+
input: detail,
|
|
477
|
+
collapsed: true,
|
|
478
|
+
originalName: toolName,
|
|
479
|
+
},
|
|
466
480
|
},
|
|
467
481
|
])
|
|
468
482
|
// Flush stream buffer before showing tool card
|
|
@@ -475,18 +489,35 @@ export function App({
|
|
|
475
489
|
setAgentProgress(null)
|
|
476
490
|
// Sync background agents (e.g. Agent tool may have spawned them)
|
|
477
491
|
syncBgAgents()
|
|
478
|
-
// Only show tool results with meaningful content.
|
|
479
|
-
// Skip empty results, "(no matches)", and other noise.
|
|
480
492
|
const output = chunk.content ? String(chunk.content).trim() : ''
|
|
481
493
|
if (output && output !== '(no matches)' && output.length > 20) {
|
|
482
|
-
|
|
483
|
-
|
|
494
|
+
// Generate diff-style summary for file operations (Claude Code parity)
|
|
495
|
+
let preview: string
|
|
496
|
+
const isFileOp = chunk.toolUse?.name === 'Write' || chunk.toolUse?.name === 'Edit'
|
|
497
|
+
if (isFileOp && chunk.toolUse?.input?.file_path) {
|
|
498
|
+
const filePath = String(chunk.toolUse.input.file_path)
|
|
499
|
+
const newContent = String(
|
|
500
|
+
chunk.toolUse.input.content || chunk.toolUse.input.new_string || '',
|
|
501
|
+
)
|
|
502
|
+
// Count lines added/removed for Write operations
|
|
503
|
+
const lineCount = newContent.split('\n').length
|
|
504
|
+
preview = `Updated ${filePath} · ${lineCount} lines`
|
|
505
|
+
} else {
|
|
506
|
+
const firstLine = output.split('\n')[0]!.slice(0, 200)
|
|
507
|
+
preview = firstLine.length < output.length ? `${firstLine}...` : firstLine
|
|
508
|
+
}
|
|
484
509
|
setMessages((prev) => [
|
|
485
510
|
...prev,
|
|
486
511
|
{
|
|
487
512
|
role: 'system' as const,
|
|
488
|
-
content:
|
|
489
|
-
toolMeta: {
|
|
513
|
+
content: preview,
|
|
514
|
+
toolMeta: {
|
|
515
|
+
name: '',
|
|
516
|
+
input: '',
|
|
517
|
+
output: preview,
|
|
518
|
+
collapsed: true,
|
|
519
|
+
originalName: chunk.toolUse?.name,
|
|
520
|
+
},
|
|
490
521
|
},
|
|
491
522
|
])
|
|
492
523
|
}
|