@wooojin/forgen 0.4.4 → 0.4.6

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.
Files changed (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +368 -4
  3. package/README.md +13 -0
  4. package/assets/claude/commands/forge-loop.md +62 -2
  5. package/dist/cli.js +8 -0
  6. package/dist/core/auto-compound-runner.d.ts +13 -1
  7. package/dist/core/auto-compound-runner.js +93 -1
  8. package/dist/core/doctor.js +9 -0
  9. package/dist/core/harness.js +60 -1
  10. package/dist/core/notify.d.ts +18 -0
  11. package/dist/core/notify.js +93 -0
  12. package/dist/core/settings-injector.js +8 -2
  13. package/dist/core/spawn.d.ts +13 -2
  14. package/dist/core/spawn.js +154 -32
  15. package/dist/core/state-gc.d.ts +10 -0
  16. package/dist/core/state-gc.js +71 -0
  17. package/dist/core/statusline-cli.d.ts +13 -0
  18. package/dist/core/statusline-cli.js +205 -0
  19. package/dist/core/usage-telemetry.d.ts +34 -0
  20. package/dist/core/usage-telemetry.js +101 -0
  21. package/dist/hooks/context-guard.d.ts +13 -0
  22. package/dist/hooks/context-guard.js +155 -4
  23. package/dist/hooks/hook-registry.js +9 -4
  24. package/dist/hooks/post-tool-use.js +3 -0
  25. package/dist/hooks/pre-tool-use.js +31 -0
  26. package/dist/hooks/session-recovery.js +11 -0
  27. package/dist/hooks/shared/read-stdin.js +44 -29
  28. package/dist/host/codex-adapter.js +5 -0
  29. package/dist/host/install-claude.js +34 -3
  30. package/dist/host/install-codex.js +7 -1
  31. package/dist/services/session.js +13 -0
  32. package/package.json +1 -1
  33. package/plugin.json +1 -1
  34. package/scripts/postinstall.js +61 -6
  35. package/skills/architecture-decision/SKILL.md +21 -0
  36. package/skills/calibrate/SKILL.md +21 -0
  37. package/skills/code-review/SKILL.md +21 -0
  38. package/skills/compound/SKILL.md +21 -0
  39. package/skills/deep-interview/SKILL.md +21 -0
  40. package/skills/docker/SKILL.md +21 -0
  41. package/skills/forge-loop/SKILL.md +76 -1
  42. package/skills/learn/SKILL.md +21 -0
  43. package/skills/retro/SKILL.md +21 -0
  44. package/skills/ship/SKILL.md +21 -0
@@ -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
@@ -0,0 +1,13 @@
1
+ /**
2
+ * forgen statusline — Claude Code statusLine 명령
3
+ *
4
+ * Claude Code는 statusLine.command를 주기적으로 호출하고 stdin에 JSON을 전달함.
5
+ * 이 명령은 compact multi-line 형식으로 HUD 정보를 출력함.
6
+ *
7
+ * Line 1: 모델 | cwd | git branch
8
+ * Line 2: (TODO: context/usage — stdin spec 미확인으로 생략)
9
+ * Line 3: CLAUDE.md count | rules count | MCPs count | hooks count
10
+ * Line 4: (TODO: tool counts — 추적 인프라 없음)
11
+ * Line 5: (TODO: active task — 추적 인프라 없음)
12
+ */
13
+ export declare function handleStatusline(): Promise<void>;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * forgen statusline — Claude Code statusLine 명령
3
+ *
4
+ * Claude Code는 statusLine.command를 주기적으로 호출하고 stdin에 JSON을 전달함.
5
+ * 이 명령은 compact multi-line 형식으로 HUD 정보를 출력함.
6
+ *
7
+ * Line 1: 모델 | cwd | git branch
8
+ * Line 2: (TODO: context/usage — stdin spec 미확인으로 생략)
9
+ * Line 3: CLAUDE.md count | rules count | MCPs count | hooks count
10
+ * Line 4: (TODO: tool counts — 추적 인프라 없음)
11
+ * Line 5: (TODO: active task — 추적 인프라 없음)
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as os from 'node:os';
16
+ import { execSync } from 'node:child_process';
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;
25
+ // ANSI codes
26
+ const DIM = '\x1b[2m';
27
+ const CYAN = '\x1b[36m';
28
+ const GREEN = '\x1b[32m';
29
+ const YELLOW = '\x1b[33m';
30
+ const BOLD = '\x1b[1m';
31
+ const RESET = '\x1b[0m';
32
+ function readStdinJson() {
33
+ // stdin이 TTY면 파이프 입력 없음 → 빈 payload로 fallback
34
+ if (process.stdin.isTTY)
35
+ return {};
36
+ try {
37
+ const raw = fs.readFileSync('/dev/stdin', 'utf-8').trim();
38
+ if (!raw)
39
+ return {};
40
+ return JSON.parse(raw);
41
+ }
42
+ catch {
43
+ return {};
44
+ }
45
+ }
46
+ function getGitBranch(cwd) {
47
+ try {
48
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
49
+ cwd,
50
+ stdio: ['ignore', 'pipe', 'ignore'],
51
+ timeout: 2000,
52
+ })
53
+ .toString()
54
+ .trim();
55
+ const isDirty = (() => {
56
+ try {
57
+ const status = execSync('git status --porcelain', {
58
+ cwd,
59
+ stdio: ['ignore', 'pipe', 'ignore'],
60
+ timeout: 2000,
61
+ }).toString().trim();
62
+ return status.length > 0;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ })();
68
+ return `git:(${branch}${isDirty ? '*' : ''})`;
69
+ }
70
+ catch {
71
+ return '';
72
+ }
73
+ }
74
+ function getSettingsJson(claudeDir) {
75
+ const settingsPath = path.join(claudeDir, 'settings.json');
76
+ if (!fs.existsSync(settingsPath))
77
+ return {};
78
+ try {
79
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
80
+ }
81
+ catch {
82
+ return {};
83
+ }
84
+ }
85
+ function countMcps(settings) {
86
+ const mcpServers = settings.mcpServers;
87
+ if (!mcpServers || typeof mcpServers !== 'object')
88
+ return 0;
89
+ return Object.keys(mcpServers).length;
90
+ }
91
+ function countHooks(settings) {
92
+ const hooks = settings.hooks;
93
+ if (!hooks || typeof hooks !== 'object')
94
+ return 0;
95
+ return Object.values(hooks).reduce((acc, matchers) => {
96
+ if (!Array.isArray(matchers))
97
+ return acc;
98
+ return acc + matchers.length;
99
+ }, 0);
100
+ }
101
+ function countClaudeMd(cwd) {
102
+ try {
103
+ const result = execSync('find . -maxdepth 2 -name CLAUDE.md', {
104
+ cwd,
105
+ stdio: ['ignore', 'pipe', 'ignore'],
106
+ timeout: 3000,
107
+ }).toString().trim();
108
+ if (!result)
109
+ return 0;
110
+ return result.split('\n').filter(Boolean).length;
111
+ }
112
+ catch {
113
+ return 0;
114
+ }
115
+ }
116
+ function buildLine1(payload, cwd) {
117
+ const modelName = payload.model?.display_name ?? 'Claude';
118
+ const gitBranch = getGitBranch(cwd);
119
+ const cwdDisplay = cwd.replace(os.homedir(), '~');
120
+ const parts = [`${BOLD}${CYAN}${modelName}${RESET}`];
121
+ parts.push(`${DIM}${cwdDisplay}${RESET}`);
122
+ if (gitBranch)
123
+ parts.push(`${GREEN}${gitBranch}${RESET}`);
124
+ return parts.join(` ${DIM}|${RESET} `);
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
+ }
143
+ function buildLine3(claudeDir, cwd) {
144
+ const settings = getSettingsJson(claudeDir);
145
+ const claudeMdCount = countClaudeMd(cwd);
146
+ const rulesCount = (() => {
147
+ try {
148
+ return loadActiveRules().length;
149
+ }
150
+ catch {
151
+ return 0;
152
+ }
153
+ })();
154
+ const mcpCount = countMcps(settings);
155
+ const hookCount = countHooks(settings);
156
+ return [
157
+ `${YELLOW}${claudeMdCount} CLAUDE.md${RESET}`,
158
+ `${YELLOW}${rulesCount} rules${RESET}`,
159
+ `${YELLOW}${mcpCount} MCPs${RESET}`,
160
+ `${YELLOW}${hookCount} hooks${RESET}`,
161
+ ].join(` ${DIM}|${RESET} `);
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
+ }
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
+ }
190
+ const payload = readStdinJson();
191
+ const cwd = payload.workspace?.current_dir ?? process.cwd();
192
+ const claudeDir = path.join(os.homedir(), '.claude');
193
+ const line1 = buildLine1(payload, cwd);
194
+ const line3 = buildLine3(claudeDir, cwd);
195
+ const usageLine = buildUsageLine();
196
+ // Line 2 (context/usage): stdin JSON spec 미확인으로 생략 — TODO
197
+ // Line 4 (tool counts): 추적 인프라 없음 — TODO
198
+ // Line 5 (active task): 추적 인프라 없음 — TODO
199
+ console.log(line1);
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);
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;