@wooojin/forgen 0.4.5 → 0.4.7

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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Forgen — Desktop / webhook notification helper (ADR-008 §"테마 A 알림").
3
+ *
4
+ * 0.4.6 신설 — rate-limit auto-resume 이 sleep 끝나고 재기동될 때 사용자에게
5
+ * 알림을 보냄 (노트북 닫고 잘 때 끝났는지 알 수 있어야 함).
6
+ *
7
+ * 정책:
8
+ * - macOS: osascript 'display notification'
9
+ * - Linux: notify-send (있으면)
10
+ * - Windows: 생략 (PowerShell BurntToast 의존성 회피)
11
+ * - webhook: ~/.forgen/config.json 의 notifyWebhookUrl 설정 시 POST
12
+ * - fail-open: 모든 실패는 silent log
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import * as os from 'node:os';
16
+ import * as path from 'node:path';
17
+ import * as fs from 'node:fs';
18
+ import { FORGEN_HOME } from './paths.js';
19
+ import { createLogger } from './logger.js';
20
+ const log = createLogger('notify');
21
+ function loadConfig() {
22
+ try {
23
+ const cfgPath = path.join(FORGEN_HOME, 'config.json');
24
+ if (!fs.existsSync(cfgPath))
25
+ return {};
26
+ return JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ function escapeForOsascript(s) {
33
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
34
+ }
35
+ /** macOS native notification via osascript. silent fail. */
36
+ function notifyDarwin(title, body) {
37
+ try {
38
+ const script = `display notification "${escapeForOsascript(body)}" with title "${escapeForOsascript(title)}"`;
39
+ const child = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
40
+ child.unref();
41
+ }
42
+ catch (e) {
43
+ log.debug('osascript notification 실패', e);
44
+ }
45
+ }
46
+ /** Linux notify-send. silent fail (notify-send 미설치 OK). */
47
+ function notifyLinux(title, body) {
48
+ try {
49
+ const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
50
+ child.unref();
51
+ }
52
+ catch (e) {
53
+ log.debug('notify-send 실패', e);
54
+ }
55
+ }
56
+ /** webhook POST (slack/discord 호환). silent fail. */
57
+ async function notifyWebhook(url, title, body) {
58
+ try {
59
+ // Slack / Discord 호환 — text 필드 우선, fallback 으로 content
60
+ const payload = JSON.stringify({
61
+ text: `*${title}*\n${body}`,
62
+ content: `**${title}**\n${body}`, // Discord
63
+ });
64
+ await fetch(url, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: payload,
68
+ });
69
+ }
70
+ catch (e) {
71
+ log.debug('webhook notify 실패', e);
72
+ }
73
+ }
74
+ /**
75
+ * 통합 알림 진입점. desktop + webhook 둘 다 시도.
76
+ * 모든 실패는 silent — 호출 측 차단 안 함.
77
+ */
78
+ export function sendNotification(title, body) {
79
+ const cfg = loadConfig();
80
+ const desktopEnabled = cfg.notifyDesktop !== false; // default true
81
+ if (desktopEnabled) {
82
+ const platform = os.platform();
83
+ if (platform === 'darwin')
84
+ notifyDarwin(title, body);
85
+ else if (platform === 'linux')
86
+ notifyLinux(title, body);
87
+ // win32: 생략
88
+ }
89
+ if (cfg.notifyWebhookUrl) {
90
+ // fire-and-forget — await 하지 않음 (caller 차단 방지)
91
+ void notifyWebhook(cfg.notifyWebhookUrl, title, body);
92
+ }
93
+ }
@@ -2,8 +2,19 @@ import type { V1HarnessContext } from './harness.js';
2
2
  import type { RuntimeHost } from './types.js';
3
3
  /** Claude Code를 하네스 환경으로 실행. exit code를 반환. */
4
4
  export declare function spawnClaude(args: string[], context: V1HarnessContext, runtime?: RuntimeHost): Promise<number>;
5
+ declare const MAX_RESUMES = 3;
5
6
  /**
6
- * 토큰 한도 도달 자동 재시작을 지원하는 claude 실행 래퍼.
7
- * context-guard가 pending-resume.json 마커를 생성하면 쿨다운 재시작.
7
+ * Exponential backoff for rate-limit when resetAt 파싱 실패.
8
+ * 1m 5m 15m 30m → 1h → 2h cap. 합 ≤ 6h.
9
+ */
10
+ export declare function rateLimitBackoffMs(attempt: number): number;
11
+ /**
12
+ * 토큰 한도 / API rate-limit 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
13
+ * context-guard가 pending-resume.json 마커를 생성하면 reason 별 정책으로 처리.
14
+ *
15
+ * - reason='token-limit': 30s 쿨다운, MAX_RESUMES_TOKEN=3
16
+ * - reason='rate-limit': resetAt 정밀 sleep (+60s 버퍼) 또는 exponential backoff,
17
+ * MAX_RESUMES_RATE=10, hard cap 6h
8
18
  */
9
19
  export declare function spawnClaudeWithResume(args: string[], context: V1HarnessContext, contextFactory: () => Promise<V1HarnessContext>, runtime?: RuntimeHost): Promise<void>;
20
+ export { MAX_RESUMES };
@@ -8,19 +8,39 @@ import { loadGlobalConfig } from './global-config.js';
8
8
  import { createLogger } from './logger.js';
9
9
  import { STATE_DIR } from './paths.js';
10
10
  import { getHostRuntime } from '../host/host-runtime.js';
11
+ import { sendNotification } from './notify.js';
11
12
  const log = createLogger('spawn');
12
13
  /** Phase 2: host-runtime 어댑터 위임. */
13
14
  function findRuntimeLauncher(runtime) {
14
15
  return getHostRuntime(runtime).launcher;
15
16
  }
16
- function transcriptProjectDir(cwd) {
17
+ /**
18
+ * 0.4.6 — runtime 별 transcript 디렉토리.
19
+ *
20
+ * - claude: ~/.claude/projects/<sanitized-cwd>/<session>.jsonl
21
+ * - codex: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl
22
+ *
23
+ * Codex 는 cwd 별 격리 없이 날짜별 격리 — 같은 사용자의 모든 codex 세션이
24
+ * 동일 날짜 dir 에 들어감. session attribution 은 파일 basename 의 session-id 로.
25
+ *
26
+ * Note: codex 는 일별 dir 라 자정 가로지르는 세션은 두 dir 에 걸칠 수 있음 —
27
+ * 본 함수는 시작 시점 dir 만 반환. 호출 측이 needed 시 보강.
28
+ */
29
+ function transcriptProjectDir(cwd, runtime = 'claude') {
30
+ if (runtime === 'codex') {
31
+ const d = new Date();
32
+ const y = d.getUTCFullYear();
33
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
34
+ const day = String(d.getUTCDate()).padStart(2, '0');
35
+ return path.join(os.homedir(), '.codex', 'sessions', String(y), m, day);
36
+ }
17
37
  // Claude Code는 cwd의 /를 -로 치환하고 선행 -를 유지
18
38
  const sanitized = cwd.replace(/\//g, '-');
19
39
  return path.join(os.homedir(), '.claude', 'projects', sanitized);
20
40
  }
21
41
  /** 스냅샷용 — 세션 시작 전 존재하는 transcript basename 집합. */
22
- function snapshotExistingTranscripts(cwd) {
23
- const dir = transcriptProjectDir(cwd);
42
+ function snapshotExistingTranscripts(cwd, runtime = 'claude') {
43
+ const dir = transcriptProjectDir(cwd, runtime);
24
44
  if (!fs.existsSync(dir))
25
45
  return new Set();
26
46
  try {
@@ -43,8 +63,8 @@ function snapshotExistingTranscripts(cwd) {
43
63
  * 여전히 후보가 여러 개이면 (rare: 훅이 추가 파일을 쓴 경우) 가장 최근 수정본
44
64
  * 을 고르되 debug 로그를 남긴다.
45
65
  */
46
- function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
47
- const dir = transcriptProjectDir(cwd);
66
+ function findSessionTranscript(cwd, sessionStartMs, preSnapshot, runtime = 'claude') {
67
+ const dir = transcriptProjectDir(cwd, runtime);
48
68
  if (!fs.existsSync(dir))
49
69
  return null;
50
70
  let candidates;
@@ -81,6 +101,14 @@ function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
81
101
  * 파일 전체를 메모리에 올렸다. 수백 MB 규모 transcript에서는 heap spike가
82
102
  * 발생했고, 카운트 외엔 내용이 필요 없으니 streaming line-by-line로 충분하다.
83
103
  */
104
+ /**
105
+ * 0.4.6 — claude/codex 양 schema 호환.
106
+ *
107
+ * Claude JSONL: {type: 'user' | 'queue-operation', ...}
108
+ * Codex JSONL: {type: 'response_item', payload: {role: 'user' | 'developer' | 'assistant', ...}}
109
+ *
110
+ * 단일 함수에서 둘 다 처리 — schema 자동 감지.
111
+ */
84
112
  async function countUserMessages(transcriptPath) {
85
113
  const { createInterface } = await import('node:readline');
86
114
  const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
@@ -91,8 +119,15 @@ async function countUserMessages(transcriptPath) {
91
119
  if (!line)
92
120
  continue;
93
121
  try {
94
- const t = JSON.parse(line).type;
95
- if (t === 'user' || t === 'queue-operation')
122
+ const obj = JSON.parse(line);
123
+ const t = obj.type;
124
+ // Claude schema
125
+ if (t === 'user' || t === 'queue-operation') {
126
+ count++;
127
+ continue;
128
+ }
129
+ // Codex schema (response_item with role=user)
130
+ if (t === 'response_item' && obj.payload?.role === 'user')
96
131
  count++;
97
132
  }
98
133
  catch { /* skip malformed */ }
@@ -150,7 +185,7 @@ export async function spawnClaude(args, context, runtime = 'claude') {
150
185
  // 세션 시작 전 timestamp + 기존 transcript 스냅샷 기록 (종료 후 finder 용).
151
186
  // Audit fix #8 (2026-04-21): 스냅샷으로 동시 세션 transcript 오선택을 차단.
152
187
  const sessionStartTime = Date.now();
153
- const preSnapshot = snapshotExistingTranscripts(context.cwd);
188
+ const preSnapshot = snapshotExistingTranscripts(context.cwd, runtime);
154
189
  return new Promise((resolve, reject) => {
155
190
  const child = spawn(launcher, cleanArgs, {
156
191
  stdio: 'inherit',
@@ -166,21 +201,35 @@ export async function spawnClaude(args, context, runtime = 'claude') {
166
201
  }
167
202
  });
168
203
  child.on('exit', async (code) => {
169
- if (runtime !== 'claude') {
204
+ if (runtime !== 'claude' && runtime !== 'codex') {
170
205
  resolve(code ?? 0);
171
206
  return;
172
207
  }
173
- // 세션 종료 후 하네스 작업
208
+ // 세션 종료 후 하네스 작업.
209
+ // 0.4.6 — codex 도 transcript 인식 (~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl).
210
+ // Codex transcript 의 schema 는 claude 와 다르므로 auto-compound 의 input parsing 은
211
+ // 별개 작업 (0.4.7). 현재 단계는 *transcript 위치 인식 + count* 만 codex 호환.
174
212
  try {
175
- const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot);
213
+ const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot, runtime);
176
214
  if (!transcript) {
177
215
  log.debug('이 세션에서 생성된 transcript를 찾을 수 없음 (snapshot diff)');
178
216
  }
179
217
  else {
180
- const sessionId = path.basename(transcript, '.jsonl');
181
- // 1. FTS5 인덱싱
182
- await indexTranscriptToFTS(context.cwd, transcript, sessionId);
183
- // 2. 자동 compound (10+ user 메시지인 경우만) — streaming line count
218
+ // 0.4.6 claude/codex 양 schema 호환 (countUserMessages + extractSummary).
219
+ // codex transcript 의 sessionId 는 파일명 패턴 'rollout-<ts>-<sid>.jsonl' 의 끝부분.
220
+ let sessionId;
221
+ if (runtime === 'codex') {
222
+ const m = path.basename(transcript, '.jsonl').match(/rollout-[\dT-]+-(.+)$/);
223
+ sessionId = m ? m[1] : path.basename(transcript, '.jsonl');
224
+ }
225
+ else {
226
+ sessionId = path.basename(transcript, '.jsonl');
227
+ }
228
+ // 1. FTS5 인덱싱 (claude only — codex schema FTS 호환은 미검증, 별도 작업)
229
+ if (runtime === 'claude') {
230
+ await indexTranscriptToFTS(context.cwd, transcript, sessionId);
231
+ }
232
+ // 2. 자동 compound (10+ user 메시지인 경우만) — 양 runtime 호환
184
233
  const userMsgCount = await countUserMessages(transcript);
185
234
  if (userMsgCount >= 10) {
186
235
  await runAutoCompound(context.cwd, transcript, sessionId);
@@ -198,17 +247,52 @@ export async function spawnClaude(args, context, runtime = 'claude') {
198
247
  });
199
248
  }
200
249
  const RESUME_COOLDOWN_MS = 30_000;
201
- const MAX_RESUMES = 3;
250
+ const MAX_RESUMES_TOKEN = 3;
251
+ const MAX_RESUMES_RATE = 10; // ADR-008: rate-limit wait 은 5h 단위라 더 관대
252
+ const MAX_RESUMES = MAX_RESUMES_TOKEN; // backward compat — 기존 token-limit 호출자
253
+ const RATE_LIMIT_HARD_CAP_MS = 6 * 60 * 60 * 1000; // 6h max single wait
254
+ const COUNTDOWN_INTERVAL_MS = 30_000;
255
+ /**
256
+ * Exponential backoff for rate-limit when resetAt 파싱 실패.
257
+ * 1m → 5m → 15m → 30m → 1h → 2h cap. 합 ≤ 6h.
258
+ */
259
+ export function rateLimitBackoffMs(attempt) {
260
+ const schedule = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000, 2 * 60 * 60_000];
261
+ return schedule[Math.min(attempt, schedule.length - 1)];
262
+ }
202
263
  /**
203
- * 토큰 한도 도달 자동 재시작을 지원하는 claude 실행 래퍼.
204
- * context-guard가 pending-resume.json 마커를 생성하면 쿨다운 후 재시작.
264
+ * Foreground countdown 30s 마다 남은 시간 단일 라인 갱신, Ctrl+C 시 abort.
265
+ */
266
+ async function countdownSleep(totalMs, label) {
267
+ const deadline = Date.now() + totalMs;
268
+ while (Date.now() < deadline) {
269
+ const remainMs = deadline - Date.now();
270
+ const remainMin = Math.floor(remainMs / 60_000);
271
+ const remainSec = Math.floor((remainMs % 60_000) / 1000);
272
+ const eta = remainMin >= 60
273
+ ? `${Math.floor(remainMin / 60)}h ${remainMin % 60}m`
274
+ : `${remainMin}m ${remainSec}s`;
275
+ process.stdout.write(`\r[forgen] ${label} — ${eta} remaining (Ctrl+C to abort) `);
276
+ const tick = Math.min(COUNTDOWN_INTERVAL_MS, remainMs);
277
+ await new Promise(resolve => setTimeout(resolve, tick));
278
+ }
279
+ process.stdout.write('\n');
280
+ }
281
+ /**
282
+ * 토큰 한도 / API rate-limit 도달 시 자동 재시작을 지원하는 claude 실행 래퍼.
283
+ * context-guard가 pending-resume.json 마커를 생성하면 reason 별 정책으로 처리.
284
+ *
285
+ * - reason='token-limit': 30s 쿨다운, MAX_RESUMES_TOKEN=3
286
+ * - reason='rate-limit': resetAt 정밀 sleep (+60s 버퍼) 또는 exponential backoff,
287
+ * MAX_RESUMES_RATE=10, hard cap 6h
205
288
  */
206
289
  export async function spawnClaudeWithResume(args, context, contextFactory, runtime = 'claude') {
207
- let resumeCount = 0;
290
+ let tokenResumeCount = 0;
291
+ let rateResumeCount = 0;
208
292
  let currentContext = context;
209
293
  while (true) {
210
294
  const exitCode = await spawnClaude(args, currentContext, runtime);
211
- if (runtime !== 'claude') {
295
+ if (runtime !== 'claude' && runtime !== 'codex') {
212
296
  if (exitCode !== 0)
213
297
  process.exit(exitCode);
214
298
  break;
@@ -222,20 +306,56 @@ export async function spawnClaudeWithResume(args, context, contextFactory, runti
222
306
  try {
223
307
  const marker = JSON.parse(fs.readFileSync(resumePath, 'utf-8'));
224
308
  fs.unlinkSync(resumePath);
225
- if (marker.reason !== 'token-limit') {
226
- if (exitCode !== 0)
227
- process.exit(exitCode);
228
- break;
309
+ if (marker.reason === 'token-limit') {
310
+ if (tokenResumeCount >= MAX_RESUMES_TOKEN) {
311
+ console.log(`[forgen] 최대 자동 재시작 횟수(${MAX_RESUMES_TOKEN}) 도달. 수동으로 다시 시작하세요.`);
312
+ break;
313
+ }
314
+ tokenResumeCount++;
315
+ console.log(`[forgen] 토큰 한도 도달. ${RESUME_COOLDOWN_MS / 1000}초 후 자동 재시작합니다... (${tokenResumeCount}/${MAX_RESUMES_TOKEN})`);
316
+ await new Promise(resolve => setTimeout(resolve, RESUME_COOLDOWN_MS));
317
+ console.log('[forgen] 세션 재시작 중...');
318
+ currentContext = await contextFactory();
319
+ continue;
229
320
  }
230
- if (resumeCount >= MAX_RESUMES) {
231
- console.log(`[forgen] 최대 자동 재시작 횟수(${MAX_RESUMES}) 도달. 수동으로 다시 시작하세요.`);
232
- break;
321
+ if (marker.reason === 'rate-limit') {
322
+ if (rateResumeCount >= MAX_RESUMES_RATE) {
323
+ console.log(`[forgen] rate-limit 자동 재시작 한도(${MAX_RESUMES_RATE}) 도달. 수동 재시작 필요.`);
324
+ break;
325
+ }
326
+ rateResumeCount++;
327
+ let sleepMs;
328
+ let label;
329
+ if (marker.resetAt) {
330
+ const resetMs = Date.parse(marker.resetAt);
331
+ if (Number.isFinite(resetMs)) {
332
+ const target = resetMs - Date.now() + 60_000; // 60s 버퍼
333
+ sleepMs = Math.min(Math.max(target, 0), RATE_LIMIT_HARD_CAP_MS);
334
+ label = `Rate limit. Resuming after reset (${marker.resetAt})`;
335
+ }
336
+ else {
337
+ sleepMs = rateLimitBackoffMs(rateResumeCount - 1);
338
+ label = `Rate limit. Backoff #${rateResumeCount}`;
339
+ }
340
+ }
341
+ else {
342
+ sleepMs = rateLimitBackoffMs(rateResumeCount - 1);
343
+ label = `Rate limit. Backoff #${rateResumeCount}`;
344
+ }
345
+ if (sleepMs >= RATE_LIMIT_HARD_CAP_MS) {
346
+ console.log(`[forgen] rate-limit reset 시각이 hard cap(6h) 초과 — 수동 재시작 필요. handoff 저장됨.`);
347
+ break;
348
+ }
349
+ await countdownSleep(sleepMs, label);
350
+ console.log('[forgen] 세션 재시작 중...');
351
+ sendNotification('forgen — Rate limit 회복', `${runtime} 세션 재기동 중 (${rateResumeCount}/${MAX_RESUMES_RATE} resume)`);
352
+ currentContext = await contextFactory();
353
+ continue;
233
354
  }
234
- resumeCount++;
235
- console.log(`[forgen] 토큰 한도 도달. ${RESUME_COOLDOWN_MS / 1000}초 후 자동 재시작합니다... (${resumeCount}/${MAX_RESUMES})`);
236
- await new Promise(resolve => setTimeout(resolve, RESUME_COOLDOWN_MS));
237
- console.log('[forgen] 세션 재시작 중...');
238
- currentContext = await contextFactory();
355
+ // 알 수 없는 reason → exit (fail-open, 수동 처리)
356
+ if (exitCode !== 0)
357
+ process.exit(exitCode);
358
+ break;
239
359
  }
240
360
  catch {
241
361
  if (exitCode !== 0)
@@ -244,3 +364,5 @@ export async function spawnClaudeWithResume(args, context, contextFactory, runti
244
364
  }
245
365
  }
246
366
  }
367
+ // MAX_RESUMES re-export for backward-compat tests
368
+ export { MAX_RESUMES };
@@ -17,6 +17,16 @@ export interface PruneOptions {
17
17
  /** Current time for deterministic tests. Defaults to Date.now(). */
18
18
  now?: number;
19
19
  }
20
+ export interface RotateReport {
21
+ scanned: number;
22
+ rotated: number;
23
+ bytesFreed: number;
24
+ sample: string[];
25
+ }
26
+ export declare function rotateAppendOnlyLogs(opts?: {
27
+ stateDir?: string;
28
+ maxBytes?: number;
29
+ }): RotateReport;
20
30
  /**
21
31
  * Prune session-scoped files older than `retentionMs` from the state and
22
32
  * outcomes directories. Defaults to a dry-run so callers must opt-in to
@@ -78,6 +78,77 @@ function pruneDir(dir, cutoff, dryRun, filter) {
78
78
  }
79
79
  return out;
80
80
  }
81
+ /**
82
+ * 0.4.6 #14 — append-only jsonl 로그 회전.
83
+ *
84
+ * state-gc 의 SESSION_SCOPED_PREFIXES 는 session 별 파일을 prefix-base 로 잡지만,
85
+ * 단일 aggregate jsonl (hook-timing, prompt-history, usage-telemetry 등) 은 매번
86
+ * append 되어 무한 grow. 본 함수가 size cap (default 10MB) 초과 시 `<name>.1` 로
87
+ * rotate 하고 `<name>.2` 는 삭제 (한 단계만 보존).
88
+ *
89
+ * 회전 정책:
90
+ * - cap 미만: no-op
91
+ * - cap 초과: <name>.2 삭제 → <name>.1 → <name>.2, <name> → <name>.1, 새 빈 <name>
92
+ * - 0.4.6 신설 jsonl 들 (prompt-history, usage-telemetry, rate-limit-misses) 포함
93
+ *
94
+ * fail-open: 모든 I/O 실패는 silent — 호출 측 차단 안 함.
95
+ */
96
+ const ROTATABLE_LOGS = [
97
+ 'hook-errors.jsonl',
98
+ 'hook-timing.jsonl',
99
+ 'implicit-feedback.jsonl',
100
+ 'match-eval-log.jsonl',
101
+ 'solution-quarantine.jsonl',
102
+ 'prompt-history.jsonl',
103
+ 'usage-telemetry.jsonl',
104
+ 'rate-limit-misses.jsonl',
105
+ ];
106
+ const DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024; // 10MB
107
+ export function rotateAppendOnlyLogs(opts = {}) {
108
+ const stateDir = opts.stateDir ?? STATE_DIR;
109
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_LOG_BYTES;
110
+ const out = { scanned: 0, rotated: 0, bytesFreed: 0, sample: [] };
111
+ if (!fs.existsSync(stateDir))
112
+ return out;
113
+ for (const name of ROTATABLE_LOGS) {
114
+ const full = path.join(stateDir, name);
115
+ let stat;
116
+ try {
117
+ stat = fs.statSync(full);
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ out.scanned++;
123
+ if (stat.size <= maxBytes)
124
+ continue;
125
+ try {
126
+ const r1 = `${full}.1`;
127
+ const r2 = `${full}.2`;
128
+ // .2 삭제
129
+ try {
130
+ fs.unlinkSync(r2);
131
+ out.bytesFreed += fs.existsSync(r2) ? 0 : (fs.statSync(r2).size ?? 0);
132
+ }
133
+ catch { /* no .2 */ }
134
+ // .1 → .2
135
+ try {
136
+ if (fs.existsSync(r1))
137
+ fs.renameSync(r1, r2);
138
+ }
139
+ catch { /* skip */ }
140
+ // active → .1
141
+ fs.renameSync(full, r1);
142
+ // 새 빈 파일
143
+ fs.writeFileSync(full, '');
144
+ out.rotated++;
145
+ if (out.sample.length < 20)
146
+ out.sample.push(name);
147
+ }
148
+ catch { /* fail-open per file */ }
149
+ }
150
+ return out;
151
+ }
81
152
  /**
82
153
  * Prune session-scoped files older than `retentionMs` from the state and
83
154
  * outcomes directories. Defaults to a dry-run so callers must opt-in to
@@ -15,6 +15,13 @@ import * as path from 'node:path';
15
15
  import * as os from 'node:os';
16
16
  import { execSync } from 'node:child_process';
17
17
  import { loadActiveRules } from '../store/rule-store.js';
18
+ import { getUsageStats } from './usage-telemetry.js';
19
+ import { STATE_DIR } from './paths.js';
20
+ // 0.4.6 perf #13 — statusline 출력을 5초 캐싱.
21
+ // claude statusLine 은 짧은 간격으로 재호출되는데 매번 git/find/rule-store 를
22
+ // 실행하면 ~100ms 누적. CACHE_TTL_MS 동안 동일 출력 재사용.
23
+ const STATUSLINE_CACHE_PATH = path.join(STATE_DIR, 'statusline-cache.txt');
24
+ const CACHE_TTL_MS = 5_000;
18
25
  // ANSI codes
19
26
  const DIM = '\x1b[2m';
20
27
  const CYAN = '\x1b[36m';
@@ -116,6 +123,23 @@ function buildLine1(payload, cwd) {
116
123
  parts.push(`${GREEN}${gitBranch}${RESET}`);
117
124
  return parts.join(` ${DIM}|${RESET} `);
118
125
  }
126
+ /** Build usage line: "📊 87/5h · 412/wk (claude)" — 0.4.6 신설 */
127
+ function buildUsageLine() {
128
+ try {
129
+ const stats = getUsageStats();
130
+ if (stats.week.total === 0)
131
+ return null;
132
+ const dominant = stats.week.codex > stats.week.claude ? 'codex' : 'claude';
133
+ return [
134
+ `${YELLOW}📊 ${stats.hour5.total}/5h${RESET}`,
135
+ `${YELLOW}${stats.week.total}/wk${RESET}`,
136
+ `${DIM}(${dominant})${RESET}`,
137
+ ].join(` ${DIM}·${RESET} `);
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
119
143
  function buildLine3(claudeDir, cwd) {
120
144
  const settings = getSettingsJson(claudeDir);
121
145
  const claudeMdCount = countClaudeMd(cwd);
@@ -136,15 +160,46 @@ function buildLine3(claudeDir, cwd) {
136
160
  `${YELLOW}${hookCount} hooks${RESET}`,
137
161
  ].join(` ${DIM}|${RESET} `);
138
162
  }
163
+ /** 0.4.6 perf #13: cached output if fresh. */
164
+ function readCacheIfFresh() {
165
+ try {
166
+ const stat = fs.statSync(STATUSLINE_CACHE_PATH);
167
+ if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
168
+ return fs.readFileSync(STATUSLINE_CACHE_PATH, 'utf-8');
169
+ }
170
+ }
171
+ catch { /* no cache or stale */ }
172
+ return null;
173
+ }
174
+ function writeCache(content) {
175
+ try {
176
+ fs.mkdirSync(STATE_DIR, { recursive: true });
177
+ fs.writeFileSync(STATUSLINE_CACHE_PATH, content);
178
+ }
179
+ catch { /* fail-open */ }
180
+ }
139
181
  export async function handleStatusline() {
182
+ // 캐시 hit 시 stdin payload 무시하고 바로 출력 (5초 윈도우 내 동일 출력 가정).
183
+ // 라인 단위 cache → console.log 라인별 (테스트 호환).
184
+ const cached = readCacheIfFresh();
185
+ if (cached !== null) {
186
+ for (const line of cached.split('\n').filter(Boolean))
187
+ console.log(line);
188
+ return;
189
+ }
140
190
  const payload = readStdinJson();
141
191
  const cwd = payload.workspace?.current_dir ?? process.cwd();
142
192
  const claudeDir = path.join(os.homedir(), '.claude');
143
193
  const line1 = buildLine1(payload, cwd);
144
194
  const line3 = buildLine3(claudeDir, cwd);
195
+ const usageLine = buildUsageLine();
145
196
  // Line 2 (context/usage): stdin JSON spec 미확인으로 생략 — TODO
146
197
  // Line 4 (tool counts): 추적 인프라 없음 — TODO
147
198
  // Line 5 (active task): 추적 인프라 없음 — TODO
148
199
  console.log(line1);
149
200
  console.log(line3);
201
+ if (usageLine)
202
+ console.log(usageLine);
203
+ const cacheBody = usageLine ? `${line1}\n${line3}\n${usageLine}\n` : `${line1}\n${line3}\n`;
204
+ writeCache(cacheBody);
150
205
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Forgen Usage Telemetry — ADR-008 §"테마 A unattended resilience"
3
+ *
4
+ * 5h / weekly window 의 tool call 수를 sliding window 로 추적하여 statusline
5
+ * (`forgen me`) 에 노출. rate-limit hit 전에 사용자가 사용량을 가시화할 수 있도록.
6
+ *
7
+ * 정책:
8
+ * - 각 PostToolUse 마다 append-only JSONL 에 timestamp 한 줄 기록
9
+ * - read 시 sliding window 로 필터 + 카운트 (스트리밍 — 메모리 절약)
10
+ * - 10K 엔트리 누적 시 weekly cap 밖 엔트리 prune (rewrite once)
11
+ * - fail-open: 모든 I/O 실패는 로그 후 무시, 호출 측 차단 안 함
12
+ *
13
+ * 0.4.6 신설. limit prediction 은 의도적으로 제외 — Anthropic 의 실제 limit 가
14
+ * 계정/플랜별 가변이라 hard-code 부정확. raw count 만 노출하고 사용자가 판단.
15
+ */
16
+ export interface UsageStats {
17
+ hour5: {
18
+ claude: number;
19
+ codex: number;
20
+ total: number;
21
+ };
22
+ week: {
23
+ claude: number;
24
+ codex: number;
25
+ total: number;
26
+ };
27
+ }
28
+ export declare function recordToolCall(runtime?: 'claude' | 'codex'): void;
29
+ /**
30
+ * sliding window count. fail-open: 파일 미존재/parse 실패는 0 반환.
31
+ *
32
+ * @param now epoch ms (테스트 결정성)
33
+ */
34
+ export declare function getUsageStats(now?: number): UsageStats;