77import { spawn } from 'node:child_process'
88import crypto from 'node:crypto'
99import { existsSync , promises as fs } from 'node:fs'
10+ import os from 'node:os'
1011import path from 'node:path'
1112import { fileURLToPath } from 'node:url'
1213
14+ import { deleteAsync as del } from 'del'
1315import colors from 'yoctocolors-cjs'
1416
1517import { parseArgs } from '@socketsecurity/lib/argv/parse'
@@ -29,6 +31,59 @@ const SOCKET_PROJECTS = [
2931 'socket-registry' ,
3032]
3133
34+ // Storage paths.
35+ // User-level (cross-repo, persistent)
36+ const CLAUDE_HOME = path . join ( os . homedir ( ) , '.claude' )
37+ const STORAGE_PATHS = {
38+ fixMemory : path . join ( CLAUDE_HOME , 'fix-memory.db' ) ,
39+ stats : path . join ( CLAUDE_HOME , 'stats.json' ) ,
40+ history : path . join ( CLAUDE_HOME , 'history.json' ) ,
41+ config : path . join ( CLAUDE_HOME , 'config.json' ) ,
42+ cache : path . join ( CLAUDE_HOME , 'cache' ) ,
43+ }
44+
45+ // Repo-level (per-project, temporary)
46+ const REPO_STORAGE = {
47+ snapshots : path . join ( claudeDir , 'snapshots' ) ,
48+ session : path . join ( claudeDir , 'session.json' ) ,
49+ scratch : path . join ( claudeDir , 'scratch' ) ,
50+ }
51+
52+ // Retention periods (milliseconds).
53+ const RETENTION = {
54+ // 7 days
55+ snapshots : 7 * 24 * 60 * 60 * 1000 ,
56+ // 30 days
57+ cache : 30 * 24 * 60 * 60 * 1000 ,
58+ // 1 day
59+ sessions : 24 * 60 * 60 * 1000 ,
60+ }
61+
62+ // Claude API pricing (USD per token).
63+ // https://www.anthropic.com/pricing
64+ const PRICING = {
65+ 'claude-sonnet-4-5' : {
66+ // $3 per 1M input tokens
67+ input : 3.0 / 1_000_000 ,
68+ // $15 per 1M output tokens
69+ output : 15.0 / 1_000_000 ,
70+ // $3.75 per 1M cache write tokens
71+ cache_write : 3.75 / 1_000_000 ,
72+ // $0.30 per 1M cache read tokens
73+ cache_read : 0.3 / 1_000_000 ,
74+ } ,
75+ 'claude-sonnet-3-7' : {
76+ // $3 per 1M input tokens
77+ input : 3.0 / 1_000_000 ,
78+ // $15 per 1M output tokens
79+ output : 15.0 / 1_000_000 ,
80+ // $3.75 per 1M cache write tokens
81+ cache_write : 3.75 / 1_000_000 ,
82+ // $0.30 per 1M cache read tokens
83+ cache_read : 0.3 / 1_000_000 ,
84+ } ,
85+ }
86+
3287// Simple inline logger.
3388const log = {
3489 info : msg => console . log ( msg ) ,
@@ -64,6 +119,228 @@ function printFooter(message) {
64119 }
65120}
66121
122+ /**
123+ * Initialize storage directories.
124+ */
125+ async function initStorage ( ) {
126+ await fs . mkdir ( CLAUDE_HOME , { recursive : true } )
127+ await fs . mkdir ( STORAGE_PATHS . cache , { recursive : true } )
128+ await fs . mkdir ( REPO_STORAGE . snapshots , { recursive : true } )
129+ await fs . mkdir ( REPO_STORAGE . scratch , { recursive : true } )
130+ }
131+
132+ /**
133+ * Clean up old data using del package.
134+ */
135+ async function cleanupOldData ( ) {
136+ const now = Date . now ( )
137+
138+ // Clean old snapshots in current repo.
139+ try {
140+ const snapshots = await fs . readdir ( REPO_STORAGE . snapshots )
141+ const toDelete = [ ]
142+ for ( const snap of snapshots ) {
143+ const snapPath = path . join ( REPO_STORAGE . snapshots , snap )
144+ const stats = await fs . stat ( snapPath )
145+ if ( now - stats . mtime . getTime ( ) > RETENTION . snapshots ) {
146+ toDelete . push ( snapPath )
147+ }
148+ }
149+ if ( toDelete . length > 0 ) {
150+ // Force delete temp directories outside CWD.
151+ await del ( toDelete , { force : true } )
152+ }
153+ } catch {
154+ // Ignore errors if directory doesn't exist.
155+ }
156+
157+ // Clean old cache entries in ~/.claude/cache/.
158+ try {
159+ const cached = await fs . readdir ( STORAGE_PATHS . cache )
160+ const toDelete = [ ]
161+ for ( const file of cached ) {
162+ const filePath = path . join ( STORAGE_PATHS . cache , file )
163+ const stats = await fs . stat ( filePath )
164+ if ( now - stats . mtime . getTime ( ) > RETENTION . cache ) {
165+ toDelete . push ( filePath )
166+ }
167+ }
168+ if ( toDelete . length > 0 ) {
169+ // Force delete temp directories outside CWD.
170+ await del ( toDelete , { force : true } )
171+ }
172+ } catch {
173+ // Ignore errors if directory doesn't exist.
174+ }
175+ }
176+
177+ /**
178+ * Cost tracking with budget controls.
179+ */
180+ class CostTracker {
181+ constructor ( model = 'claude-sonnet-4-5' ) {
182+ this . model = model
183+ this . session = { input : 0 , output : 0 , cacheWrite : 0 , cacheRead : 0 , cost : 0 }
184+ this . monthly = this . loadMonthlyStats ( )
185+ this . startTime = Date . now ( )
186+ }
187+
188+ loadMonthlyStats ( ) {
189+ try {
190+ if ( existsSync ( STORAGE_PATHS . stats ) ) {
191+ const data = JSON . parse ( fs . readFileSync ( STORAGE_PATHS . stats , 'utf8' ) )
192+ // YYYY-MM
193+ const currentMonth = new Date ( ) . toISOString ( ) . slice ( 0 , 7 )
194+ if ( data . month === currentMonth ) {
195+ return data
196+ }
197+ }
198+ } catch {
199+ // Ignore errors, start fresh.
200+ }
201+ return {
202+ month : new Date ( ) . toISOString ( ) . slice ( 0 , 7 ) ,
203+ cost : 0 ,
204+ fixes : 0 ,
205+ sessions : 0 ,
206+ }
207+ }
208+
209+ saveMonthlyStats ( ) {
210+ try {
211+ fs . writeFileSync (
212+ STORAGE_PATHS . stats ,
213+ JSON . stringify ( this . monthly , null , 2 ) ,
214+ )
215+ } catch {
216+ // Ignore errors.
217+ }
218+ }
219+
220+ track ( usage ) {
221+ const pricing = PRICING [ this . model ]
222+ if ( ! pricing ) {
223+ return
224+ }
225+
226+ const inputTokens = usage . input_tokens || 0
227+ const outputTokens = usage . output_tokens || 0
228+ const cacheWriteTokens = usage . cache_creation_input_tokens || 0
229+ const cacheReadTokens = usage . cache_read_input_tokens || 0
230+
231+ const cost =
232+ inputTokens * pricing . input +
233+ outputTokens * pricing . output +
234+ cacheWriteTokens * pricing . cache_write +
235+ cacheReadTokens * pricing . cache_read
236+
237+ this . session . input += inputTokens
238+ this . session . output += outputTokens
239+ this . session . cacheWrite += cacheWriteTokens
240+ this . session . cacheRead += cacheReadTokens
241+ this . session . cost += cost
242+
243+ this . monthly . cost += cost
244+ this . saveMonthlyStats ( )
245+ }
246+
247+ showSessionSummary ( ) {
248+ const duration = Date . now ( ) - this . startTime
249+ console . log ( colors . cyan ( '\n💰 Cost Summary:' ) )
250+ console . log ( ` Input tokens: ${ this . session . input . toLocaleString ( ) } ` )
251+ console . log ( ` Output tokens: ${ this . session . output . toLocaleString ( ) } ` )
252+ if ( this . session . cacheWrite > 0 ) {
253+ console . log ( ` Cache write: ${ this . session . cacheWrite . toLocaleString ( ) } ` )
254+ }
255+ if ( this . session . cacheRead > 0 ) {
256+ console . log ( ` Cache read: ${ this . session . cacheRead . toLocaleString ( ) } ` )
257+ }
258+ console . log (
259+ ` Session cost: ${ colors . green ( `$${ this . session . cost . toFixed ( 4 ) } ` ) } ` ,
260+ )
261+ console . log (
262+ ` Monthly total: ${ colors . yellow ( `$${ this . monthly . cost . toFixed ( 2 ) } ` ) } ` ,
263+ )
264+ console . log ( ` Duration: ${ colors . gray ( formatDuration ( duration ) ) } ` )
265+ }
266+ }
267+
268+ /**
269+ * Format duration in human-readable form.
270+ */
271+ function formatDuration ( ms ) {
272+ const seconds = Math . floor ( ms / 1000 )
273+ const minutes = Math . floor ( seconds / 60 )
274+ const hours = Math . floor ( minutes / 60 )
275+
276+ if ( hours > 0 ) {
277+ return `${ hours } h ${ minutes % 60 } m ${ seconds % 60 } s`
278+ }
279+ if ( minutes > 0 ) {
280+ return `${ minutes } m ${ seconds % 60 } s`
281+ }
282+ return `${ seconds } s`
283+ }
284+
285+ /**
286+ * Success celebration with stats.
287+ */
288+ async function celebrateSuccess ( costTracker , stats = { } ) {
289+ const messages = [
290+ "🎉 CI is green! You're a legend!" ,
291+ "✨ All tests passed! Claude's got your back!" ,
292+ '🚀 Ship it! CI is happy!' ,
293+ '💚 Green as a well-tested cucumber!' ,
294+ '🏆 Victory! All checks passed!' ,
295+ '⚡ Flawless execution! CI approved!' ,
296+ ]
297+
298+ const message = messages [ Math . floor ( Math . random ( ) * messages . length ) ]
299+ log . success ( message )
300+
301+ // Show session stats.
302+ if ( costTracker ) {
303+ costTracker . showSessionSummary ( )
304+ }
305+
306+ // Show fix details if available.
307+ if ( stats . fixCount > 0 ) {
308+ console . log ( colors . cyan ( '\n📊 Session Stats:' ) )
309+ console . log ( ` Fixes applied: ${ stats . fixCount } ` )
310+ console . log ( ` Retries: ${ stats . retries || 0 } ` )
311+ }
312+
313+ // Update success streak.
314+ try {
315+ const streakPath = path . join ( CLAUDE_HOME , 'streak.json' )
316+ let streak = { current : 0 , best : 0 , lastSuccess : null }
317+ if ( existsSync ( streakPath ) ) {
318+ streak = JSON . parse ( await fs . readFile ( streakPath , 'utf8' ) )
319+ }
320+
321+ const now = Date . now ( )
322+ const oneDayAgo = now - 24 * 60 * 60 * 1000
323+
324+ // Reset streak if last success was more than 24h ago.
325+ if ( streak . lastSuccess && streak . lastSuccess < oneDayAgo ) {
326+ streak . current = 1
327+ } else {
328+ streak . current += 1
329+ }
330+
331+ streak . best = Math . max ( streak . best , streak . current )
332+ streak . lastSuccess = now
333+
334+ await fs . writeFile ( streakPath , JSON . stringify ( streak , null , 2 ) )
335+
336+ console . log ( colors . cyan ( '\n🔥 Success Streak:' ) )
337+ console . log ( ` Current: ${ streak . current } ` )
338+ console . log ( ` Best: ${ streak . best } ` )
339+ } catch {
340+ // Ignore errors.
341+ }
342+ }
343+
67344async function runCommand ( command , args = [ ] , options = { } ) {
68345 const opts = { __proto__ : null , ...options }
69346 return new Promise ( ( resolve , reject ) => {
@@ -3047,6 +3324,14 @@ async function runGreen(claudeCmd, options = {}) {
30473324 )
30483325 const useNoVerify = opts [ 'no-verify' ] === true
30493326
3327+ // Initialize storage and cleanup old data.
3328+ await initStorage ( )
3329+ await cleanupOldData ( )
3330+
3331+ // Initialize cost tracker.
3332+ const costTracker = new CostTracker ( )
3333+ let fixCount = 0
3334+
30503335 printHeader ( 'Green CI Pipeline' )
30513336
30523337 // Track errors to avoid checking same error repeatedly
@@ -3291,6 +3576,7 @@ Let's work through this together to get CI passing.`
32913576 await runCommand ( 'git' , commitArgs , {
32923577 cwd : rootPath ,
32933578 } )
3579+ fixCount ++
32943580
32953581 // Validate before pushing
32963582 const validation = await validateBeforePush ( rootPath )
@@ -3534,7 +3820,10 @@ Let's work through this together to get CI passing.`
35343820
35353821 if ( run . status === 'completed' ) {
35363822 if ( run . conclusion === 'success' ) {
3537- log . done ( 'CI workflow passed! 🎉' )
3823+ await celebrateSuccess ( costTracker , {
3824+ fixCount,
3825+ retries : pollAttempt ,
3826+ } )
35383827 printFooter ( 'Green CI Pipeline complete!' )
35393828 return true
35403829 }
@@ -3803,6 +4092,7 @@ Fix all issues by making necessary file changes. Be direct, don't ask questions.
38034092 } )
38044093
38054094 if ( commitResult . exitCode === 0 ) {
4095+ fixCount ++
38064096 // Push the commits
38074097 await runCommand ( 'git' , [ 'push' ] , { cwd : rootPath } )
38084098 log . done ( 'Pushed fix commits' )
@@ -4124,6 +4414,7 @@ Fix the issue by making necessary file changes. Be direct, don't ask questions.`
41244414 )
41254415
41264416 if ( commitResult . exitCode === 0 ) {
4417+ fixCount ++
41274418 log . done ( `Committed fix for ${ job . name } ` )
41284419 hasPendingCommits = true
41294420 } else {
0 commit comments