@miphamai/cli 0.27.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/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/ui/input.tsx
CHANGED
|
@@ -83,6 +83,16 @@ export function InputBar({
|
|
|
83
83
|
const [completionVerb, setCompletionVerb] = useState<string | null>(null)
|
|
84
84
|
const prevLoading = useRef(isLoading)
|
|
85
85
|
|
|
86
|
+
// ── Message history for arrow-key navigation (Claude Code parity) ──
|
|
87
|
+
const [submittedHistory, setSubmittedHistory] = useState<string[]>([])
|
|
88
|
+
const historyIndexRef = useRef(-1) // -1 = not browsing history
|
|
89
|
+
const savedDraftRef = useRef('') // saved user draft before browsing history
|
|
90
|
+
|
|
91
|
+
// Stabilize t ref — prevents stale closures in intervals and avoids
|
|
92
|
+
// unnecessary effect re-runs when the i18n context value object changes.
|
|
93
|
+
const tRef = useRef(t)
|
|
94
|
+
tRef.current = t
|
|
95
|
+
|
|
86
96
|
// ── Slash command hints ──
|
|
87
97
|
const allCommands = useMemo(() => getCommandList(), [])
|
|
88
98
|
const slashHints = useMemo(() => {
|
|
@@ -92,32 +102,33 @@ export function InputBar({
|
|
|
92
102
|
return allCommands.filter((c) => c.name.toLowerCase().includes(filter)).slice(0, 8)
|
|
93
103
|
}, [value, allCommands])
|
|
94
104
|
|
|
95
|
-
// Rotate gerunds while loading
|
|
105
|
+
// Rotate gerunds while loading — use tRef to avoid re-running when i18n context re-renders
|
|
96
106
|
useEffect(() => {
|
|
97
107
|
if (!isLoading) return
|
|
98
108
|
const interval = setInterval(() => {
|
|
99
|
-
setVerb(
|
|
100
|
-
},
|
|
109
|
+
setVerb(tRef.current(pick(LOADING_KEYS)))
|
|
110
|
+
}, 2000)
|
|
101
111
|
return () => clearInterval(interval)
|
|
102
|
-
}, [isLoading
|
|
112
|
+
}, [isLoading])
|
|
103
113
|
|
|
104
114
|
// Pick a fresh gerund when loading starts
|
|
105
115
|
useEffect(() => {
|
|
106
116
|
if (isLoading) {
|
|
107
|
-
setVerb(
|
|
117
|
+
setVerb(tRef.current(pick(LOADING_KEYS)))
|
|
108
118
|
setCompletionVerb(null)
|
|
109
119
|
}
|
|
110
|
-
}, [isLoading
|
|
120
|
+
}, [isLoading])
|
|
111
121
|
|
|
112
122
|
// Flash a past participle when loading stops
|
|
113
123
|
useEffect(() => {
|
|
114
124
|
if (prevLoading.current === true && isLoading === false) {
|
|
115
|
-
setCompletionVerb(
|
|
125
|
+
setCompletionVerb(tRef.current(pick(COMPLETED_KEYS)))
|
|
116
126
|
const timer = setTimeout(() => setCompletionVerb(null), 1500)
|
|
127
|
+
prevLoading.current = isLoading
|
|
117
128
|
return () => clearTimeout(timer)
|
|
118
129
|
}
|
|
119
130
|
prevLoading.current = isLoading
|
|
120
|
-
}, [isLoading
|
|
131
|
+
}, [isLoading])
|
|
121
132
|
|
|
122
133
|
const [vimMode, setVimMode] = useState<VimMode>('insert')
|
|
123
134
|
// NOTE: Dual mode state — React state (vimMode) drives UI re-renders (prompt color,
|
|
@@ -195,6 +206,39 @@ export function InputBar({
|
|
|
195
206
|
return
|
|
196
207
|
}
|
|
197
208
|
|
|
209
|
+
// ── Arrow-key history navigation (insert mode only, Claude Code parity) ──
|
|
210
|
+
if (vimEngine.current.mode === 'insert' && !searchMode) {
|
|
211
|
+
if (key.upArrow || key.downArrow) {
|
|
212
|
+
// Ignore if picker is active (command picker handles its own arrows)
|
|
213
|
+
if (value.startsWith('/')) return
|
|
214
|
+
|
|
215
|
+
if (key.upArrow) {
|
|
216
|
+
if (submittedHistory.length === 0) return
|
|
217
|
+
// Save current draft the first time we enter history browsing
|
|
218
|
+
if (historyIndexRef.current === -1) {
|
|
219
|
+
savedDraftRef.current = value
|
|
220
|
+
}
|
|
221
|
+
const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
|
|
222
|
+
historyIndexRef.current = newIndex
|
|
223
|
+
setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
if (key.downArrow) {
|
|
227
|
+
if (historyIndexRef.current === -1) return
|
|
228
|
+
const newIndex = historyIndexRef.current - 1
|
|
229
|
+
historyIndexRef.current = newIndex
|
|
230
|
+
if (newIndex === -1) {
|
|
231
|
+
// Back to the original draft
|
|
232
|
+
setValue(savedDraftRef.current)
|
|
233
|
+
savedDraftRef.current = ''
|
|
234
|
+
} else {
|
|
235
|
+
setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
|
|
236
|
+
}
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
198
242
|
if (vimEngine.current.mode !== 'normal') return
|
|
199
243
|
|
|
200
244
|
// Handle search mode — collect query characters
|
|
@@ -288,6 +332,10 @@ export function InputBar({
|
|
|
288
332
|
|
|
289
333
|
const handleSubmit = (val: string) => {
|
|
290
334
|
if (!val.trim() || isLoading) return
|
|
335
|
+
// Save to message history for arrow-key navigation
|
|
336
|
+
setSubmittedHistory((prev) => [...prev, val])
|
|
337
|
+
historyIndexRef.current = -1
|
|
338
|
+
savedDraftRef.current = ''
|
|
291
339
|
onSubmit(val)
|
|
292
340
|
setValue('')
|
|
293
341
|
setPickerActive(false)
|
|
@@ -327,6 +375,11 @@ export function InputBar({
|
|
|
327
375
|
onChange={(val) => {
|
|
328
376
|
// Block text changes during search mode — keys go to search query
|
|
329
377
|
if (searchMode) return
|
|
378
|
+
// Reset history browsing when user starts typing
|
|
379
|
+
if (historyIndexRef.current !== -1) {
|
|
380
|
+
historyIndexRef.current = -1
|
|
381
|
+
savedDraftRef.current = ''
|
|
382
|
+
}
|
|
330
383
|
setValue(val)
|
|
331
384
|
}}
|
|
332
385
|
onSubmit={handleSubmit}
|