@wooojin/forgen 0.1.1 → 0.2.1

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 (66) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ja.md +79 -14
  3. package/README.ko.md +89 -14
  4. package/README.md +77 -14
  5. package/README.zh.md +79 -14
  6. package/commands/deep-interview.md +171 -0
  7. package/commands/specify.md +128 -0
  8. package/dist/cli.js +11 -2
  9. package/dist/core/auto-compound-runner.js +34 -1
  10. package/dist/core/dashboard.d.ts +91 -0
  11. package/dist/core/dashboard.js +385 -0
  12. package/dist/core/doctor.js +157 -1
  13. package/dist/core/drift-score.d.ts +49 -0
  14. package/dist/core/drift-score.js +87 -0
  15. package/dist/core/inspect-cli.js +54 -1
  16. package/dist/core/mcp-config.d.ts +2 -0
  17. package/dist/core/mcp-config.js +6 -1
  18. package/dist/core/paths.d.ts +1 -1
  19. package/dist/core/paths.js +1 -1
  20. package/dist/core/spawn.d.ts +7 -2
  21. package/dist/core/spawn.js +45 -7
  22. package/dist/core/v1-bootstrap.js +9 -2
  23. package/dist/engine/compound-export.d.ts +41 -0
  24. package/dist/engine/compound-export.js +169 -0
  25. package/dist/engine/compound-extractor.js +49 -0
  26. package/dist/engine/compound-loop.js +18 -0
  27. package/dist/engine/solution-matcher.d.ts +23 -0
  28. package/dist/engine/solution-matcher.js +124 -11
  29. package/dist/forge/mismatch-detector.js +3 -0
  30. package/dist/hooks/context-guard.d.ts +10 -0
  31. package/dist/hooks/context-guard.js +105 -49
  32. package/dist/hooks/db-guard.js +2 -2
  33. package/dist/hooks/hook-config.d.ts +27 -1
  34. package/dist/hooks/hook-config.js +72 -12
  35. package/dist/hooks/intent-classifier.js +29 -4
  36. package/dist/hooks/keyword-detector.js +114 -106
  37. package/dist/hooks/notepad-injector.js +2 -2
  38. package/dist/hooks/permission-handler.js +2 -2
  39. package/dist/hooks/post-tool-failure.js +12 -6
  40. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  41. package/dist/hooks/post-tool-handlers.js +14 -11
  42. package/dist/hooks/post-tool-use.d.ts +11 -0
  43. package/dist/hooks/post-tool-use.js +184 -71
  44. package/dist/hooks/pre-compact.d.ts +11 -1
  45. package/dist/hooks/pre-compact.js +113 -3
  46. package/dist/hooks/pre-tool-use.js +86 -56
  47. package/dist/hooks/rate-limiter.js +3 -3
  48. package/dist/hooks/secret-filter.js +2 -2
  49. package/dist/hooks/session-recovery.js +256 -236
  50. package/dist/hooks/shared/hook-response.d.ts +7 -0
  51. package/dist/hooks/shared/hook-response.js +20 -0
  52. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  53. package/dist/hooks/shared/hook-timing.js +64 -0
  54. package/dist/hooks/skill-injector.js +41 -12
  55. package/dist/hooks/slop-detector.js +3 -3
  56. package/dist/hooks/solution-injector.js +224 -197
  57. package/dist/hooks/subagent-tracker.js +2 -2
  58. package/dist/mcp/tools.js +114 -0
  59. package/dist/renderer/rule-renderer.js +9 -11
  60. package/dist/store/evidence-store.d.ts +8 -0
  61. package/dist/store/evidence-store.js +51 -0
  62. package/dist/store/rule-store.d.ts +5 -0
  63. package/dist/store/rule-store.js +22 -0
  64. package/package.json +1 -1
  65. package/skills/deep-interview/SKILL.md +166 -0
  66. package/skills/specify/SKILL.md +122 -0
@@ -15,8 +15,9 @@ const log = createLogger('session-recovery');
15
15
  import { atomicWriteJSON } from './shared/atomic-write.js';
16
16
  import { sanitizeId } from './shared/sanitize-id.js';
17
17
  import { isHookEnabled } from './hook-config.js';
18
- import { approve, approveWithContext, failOpen } from './shared/hook-response.js';
18
+ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
19
19
  import { HANDOFFS_DIR, STATE_DIR } from '../core/paths.js';
20
+ import { recordHookTiming } from './shared/hook-timing.js';
20
21
  /** 체크포인트 저장 */
21
22
  export function saveCheckpoint(data) {
22
23
  try {
@@ -129,271 +130,290 @@ export function resolveSessionStartContext(rawInput) {
129
130
  }
130
131
  }
131
132
  async function main() {
132
- // SessionStart 훅은 stdin으로 세션 정보를 받음 (타임아웃 포함)
133
- const chunks = [];
134
- process.stdin.setEncoding('utf-8');
135
- await new Promise((resolve) => {
136
- const timeout = setTimeout(() => {
137
- process.stdin.removeAllListeners('data');
138
- process.stdin.removeAllListeners('end');
139
- resolve();
140
- }, 2000);
141
- process.stdin.on('data', (chunk) => chunks.push(String(chunk)));
142
- process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
143
- });
144
- const sessionContext = resolveSessionStartContext(chunks.join(''));
145
- if (!isHookEnabled('session-recovery')) {
146
- console.log(approve());
147
- return;
148
- }
149
- if (!fs.existsSync(STATE_DIR)) {
150
- console.log(approve());
151
- return;
152
- }
153
- // 활성 모드 찾기
154
- const recoveryMessages = [];
155
- for (const mode of PERSISTENT_MODES) {
156
- const statePath = path.join(STATE_DIR, `${mode}-state.json`);
157
- if (!fs.existsSync(statePath))
158
- continue;
159
- try {
160
- const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
161
- if (!isValidModeState(parsed)) {
162
- log.debug(`상태 파일 구조 검증 실패: ${mode}`);
163
- continue;
164
- }
165
- const state = parsed;
166
- if (!state.active)
167
- continue;
168
- // 24시간 이상 경과한 상태는 만료
169
- const startedAt = new Date(state.startedAt).getTime();
170
- const elapsed = Date.now() - startedAt;
171
- if (elapsed > 24 * 60 * 60 * 1000) {
172
- fs.unlinkSync(statePath);
173
- continue;
174
- }
175
- const elapsedMinutes = Math.round(elapsed / 60000);
176
- // Security: 상태 파일의 사용자 입력을 XML에 삽입하기 전 이스케이프
177
- const escXml = (s) => s.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c] ?? c);
178
- recoveryMessages.push(`<compound-recovery mode="${mode}">` +
179
- `\n${mode} mode from previous session has been recovered.` +
180
- `\nStarted: ${state.startedAt} (${elapsedMinutes} minutes ago)` +
181
- (state.prompt ? `\nOriginal request: ${escXml(state.prompt)}` : '') +
182
- (state.stage ? `\nCurrent stage: ${escXml(state.stage)}` : '') +
183
- (state.completedSteps?.length ? `\nCompleted steps: ${state.completedSteps.map((s) => escXml(s)).join(', ')}` : '') +
184
- `\n\nContinue the previous work. To stop, type "cancelforgen".` +
185
- `\n</compound-recovery>`);
186
- }
187
- catch (e) {
188
- log.debug(`상태 파일 파싱 실패`, e);
189
- }
190
- }
191
- // Phase 0: endTime이 없는 이전 세션 backfill (file mtime 기반)
192
- // 최근 7일 파일만 대상 — 오래된 파일은 무시하여 성능 보장
133
+ const _hookStart = Date.now();
193
134
  try {
194
- const { SESSIONS_DIR: sessDir } = await import('../core/paths.js');
195
- if (fs.existsSync(sessDir)) {
196
- const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
197
- const sessionFiles = fs.readdirSync(sessDir)
198
- .filter(f => f.endsWith('.json'))
199
- .filter(f => {
200
- try {
201
- return fs.statSync(path.join(sessDir, f)).mtimeMs > cutoff;
202
- }
203
- catch {
204
- return false;
205
- }
206
- });
207
- for (const file of sessionFiles) {
208
- const fp = path.join(sessDir, file);
209
- try {
210
- const raw = JSON.parse(fs.readFileSync(fp, 'utf-8'));
211
- if (raw.startTime && !raw.endTime) {
212
- const mtime = fs.statSync(fp).mtime;
213
- raw.endTime = mtime.toISOString();
214
- raw.durationMs = mtime.getTime() - new Date(raw.startTime).getTime();
215
- raw.recoveredEndTime = true;
216
- atomicWriteJSON(fp, raw);
217
- }
218
- }
219
- catch { /* individual file recovery failure — skip and continue */ }
220
- }
135
+ // SessionStart 훅은 stdin으로 세션 정보를 받음 (타임아웃 포함)
136
+ const chunks = [];
137
+ process.stdin.setEncoding('utf-8');
138
+ await new Promise((resolve) => {
139
+ const timeout = setTimeout(() => {
140
+ process.stdin.removeAllListeners('data');
141
+ process.stdin.removeAllListeners('end');
142
+ resolve();
143
+ }, 2000);
144
+ process.stdin.on('data', (chunk) => chunks.push(String(chunk)));
145
+ process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
146
+ });
147
+ const sessionContext = resolveSessionStartContext(chunks.join(''));
148
+ if (!isHookEnabled('session-recovery')) {
149
+ console.log(approve());
150
+ return;
221
151
  }
222
- }
223
- catch (e) {
224
- log.debug('세션 endTime backfill 실패', e);
225
- }
226
- // 미완료 체크포인트 감지
227
- try {
228
- const checkpointFiles = fs.readdirSync(STATE_DIR)
229
- .filter(f => f.startsWith('checkpoint-') && f.endsWith('.json'));
230
- for (const file of checkpointFiles) {
152
+ if (!fs.existsSync(STATE_DIR)) {
153
+ console.log(approve());
154
+ return;
155
+ }
156
+ // 활성 모드 찾기
157
+ const recoveryMessages = [];
158
+ for (const mode of PERSISTENT_MODES) {
159
+ const statePath = path.join(STATE_DIR, `${mode}-state.json`);
160
+ if (!fs.existsSync(statePath))
161
+ continue;
231
162
  try {
232
- const parsedCp = JSON.parse(fs.readFileSync(path.join(STATE_DIR, file), 'utf-8'));
233
- if (!isValidCheckpoint(parsedCp)) {
234
- log.debug(`체크포인트 파일 구조 검증 실패: ${file}`);
163
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
164
+ if (!isValidModeState(parsed)) {
165
+ log.debug(`상태 파일 구조 검증 실패: ${mode}`);
235
166
  continue;
236
167
  }
237
- const cp = parsedCp;
238
- const age = Date.now() - new Date(cp.timestamp).getTime();
239
- if (age > 24 * 60 * 60 * 1000) {
240
- fs.unlinkSync(path.join(STATE_DIR, file));
168
+ const state = parsed;
169
+ if (!state.active)
170
+ continue;
171
+ // 24시간 이상 경과한 상태는 만료
172
+ const startedAt = new Date(state.startedAt).getTime();
173
+ const elapsed = Date.now() - startedAt;
174
+ if (elapsed > 24 * 60 * 60 * 1000) {
175
+ fs.unlinkSync(statePath);
241
176
  continue;
242
177
  }
243
- const elapsedMin = Math.round(age / 60000);
244
- const safeSessionId = String(cp.sessionId).replace(/[&"<>]/g, '_');
245
- const safeLastTool = String(cp.lastToolCall ?? '').replace(/[<>]/g, '_');
246
- const safeCwd = String(cp.cwd ?? '').replace(/[<>]/g, '_');
247
- recoveryMessages.push(`<compound-checkpoint session="${safeSessionId}">` +
248
- `\nIncomplete checkpoint found (${elapsedMin} minutes ago)` +
249
- `\n- Modified files: ${cp.modifiedFiles.length}` +
250
- `\n- Tool calls: ${cp.toolCallCount}` +
251
- `\n- Last tool: ${safeLastTool}` +
252
- `\n- Working directory: ${safeCwd}` +
253
- `\n</compound-checkpoint>`);
178
+ const elapsedMinutes = Math.round(elapsed / 60000);
179
+ // Security: 상태 파일의 사용자 입력을 XML에 삽입하기 전 이스케이프
180
+ const escXml = (s) => s.replace(/[<>&"]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c] ?? c);
181
+ recoveryMessages.push(`<compound-recovery mode="${mode}">` +
182
+ `\n${mode} mode from previous session has been recovered.` +
183
+ `\nStarted: ${state.startedAt} (${elapsedMinutes} minutes ago)` +
184
+ (state.prompt ? `\nOriginal request: ${escXml(state.prompt)}` : '') +
185
+ (state.stage ? `\nCurrent stage: ${escXml(state.stage)}` : '') +
186
+ (state.completedSteps?.length ? `\nCompleted steps: ${state.completedSteps.map((s) => escXml(s)).join(', ')}` : '') +
187
+ `\n\nContinue the previous work. To stop, type "cancelforgen".` +
188
+ `\n</compound-recovery>`);
189
+ }
190
+ catch (e) {
191
+ log.debug(`상태 파일 파싱 실패`, e);
254
192
  }
255
- catch { /* 개별 파일 파싱 실패 무시 */ }
256
193
  }
257
- }
258
- catch (e) {
259
- log.debug('체크포인트 스캔 실패', e);
260
- }
261
- // pending-compound 마커 확인 (이전 세션에서 compound loop 필요 표시)
262
- const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
263
- if (fs.existsSync(pendingPath)) {
194
+ // Phase 0: endTime이 없는 이전 세션 backfill (file mtime 기반)
195
+ // 최근 7일 파일만 대상 — 오래된 파일은 무시하여 성능 보장
264
196
  try {
265
- const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
266
- recoveryMessages.push(`<compound-pending>` +
267
- `\nCompound loop was scheduled in the previous session (${pending.promptCount ?? '?'} prompts).` +
268
- `\nRun \`forgen compound\` to preview, then \`forgen compound --save\` to persist patterns/solutions.` +
269
- `\n</compound-pending>`);
270
- // 마커 삭제 ( 번만 안내)
271
- fs.unlinkSync(pendingPath);
197
+ const { SESSIONS_DIR: sessDir } = await import('../core/paths.js');
198
+ if (fs.existsSync(sessDir)) {
199
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
200
+ const sessionFiles = fs.readdirSync(sessDir)
201
+ .filter(f => f.endsWith('.json'))
202
+ .filter(f => {
203
+ try {
204
+ return fs.statSync(path.join(sessDir, f)).mtimeMs > cutoff;
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ });
210
+ for (const file of sessionFiles) {
211
+ const fp = path.join(sessDir, file);
212
+ try {
213
+ const raw = JSON.parse(fs.readFileSync(fp, 'utf-8'));
214
+ if (raw.startTime && !raw.endTime) {
215
+ const mtime = fs.statSync(fp).mtime;
216
+ raw.endTime = mtime.toISOString();
217
+ raw.durationMs = mtime.getTime() - new Date(raw.startTime).getTime();
218
+ raw.recoveredEndTime = true;
219
+ atomicWriteJSON(fp, raw);
220
+ }
221
+ }
222
+ catch { /* individual file recovery failure — skip and continue */ }
223
+ }
224
+ }
272
225
  }
273
226
  catch (e) {
274
- log.debug('pending-compound 마커 읽기 실패', e);
227
+ log.debug('세션 endTime backfill 실패', e);
275
228
  }
276
- }
277
- // 핸드오프 파일 확인
278
- const handoffDir = HANDOFFS_DIR;
279
- if (fs.existsSync(handoffDir)) {
229
+ // 미완료 체크포인트 감지
280
230
  try {
281
- const handoffs = fs.readdirSync(handoffDir)
282
- .filter(f => f.endsWith('.md'))
283
- .sort();
284
- if (handoffs.length > 0) {
285
- const latest = handoffs[handoffs.length - 1];
286
- const latestPath = path.join(handoffDir, latest);
287
- // Security: symlink 방지 + XML 이스케이프
288
- if (fs.lstatSync(latestPath).isSymbolicLink())
289
- throw new Error('symlink rejected');
290
- const raw = fs.readFileSync(latestPath, 'utf-8');
291
- const safeName = latest.replace(/[&"<>]/g, '_');
292
- const escaped = raw.replace(/<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?\/?>/g, m => m.replace(/</g, '&lt;').replace(/>/g, '&gt;'));
293
- recoveryMessages.push(`<compound-handoff file="${safeName}">\n${escaped}\n</compound-handoff>`);
294
- // 마커 삭제 (한 번만 안내 — pending-compound.json과 동일 패턴)
231
+ const checkpointFiles = fs.readdirSync(STATE_DIR)
232
+ .filter(f => f.startsWith('checkpoint-') && f.endsWith('.json'));
233
+ for (const file of checkpointFiles) {
295
234
  try {
296
- fs.unlinkSync(latestPath);
297
- }
298
- catch (e) {
299
- log.debug('handoff 파일 삭제 실패', e);
235
+ const parsedCp = JSON.parse(fs.readFileSync(path.join(STATE_DIR, file), 'utf-8'));
236
+ if (!isValidCheckpoint(parsedCp)) {
237
+ log.debug(`체크포인트 파일 구조 검증 실패: ${file}`);
238
+ continue;
239
+ }
240
+ const cp = parsedCp;
241
+ const age = Date.now() - new Date(cp.timestamp).getTime();
242
+ if (age > 24 * 60 * 60 * 1000) {
243
+ fs.unlinkSync(path.join(STATE_DIR, file));
244
+ continue;
245
+ }
246
+ const elapsedMin = Math.round(age / 60000);
247
+ const safeSessionId = String(cp.sessionId).replace(/[&"<>]/g, '_');
248
+ const safeLastTool = String(cp.lastToolCall ?? '').replace(/[<>]/g, '_');
249
+ const safeCwd = String(cp.cwd ?? '').replace(/[<>]/g, '_');
250
+ recoveryMessages.push(`<compound-checkpoint session="${safeSessionId}">` +
251
+ `\nIncomplete checkpoint found (${elapsedMin} minutes ago)` +
252
+ `\n- Modified files: ${cp.modifiedFiles.length}` +
253
+ `\n- Tool calls: ${cp.toolCallCount}` +
254
+ `\n- Last tool: ${safeLastTool}` +
255
+ `\n- Working directory: ${safeCwd}` +
256
+ `\n</compound-checkpoint>`);
300
257
  }
258
+ catch { /* 개별 파일 파싱 실패 무시 */ }
301
259
  }
302
260
  }
303
261
  catch (e) {
304
- log.debug('handoff 파일 읽기 실패', e);
262
+ log.debug('체크포인트 스캔 실패', e);
305
263
  }
306
- }
307
- const sessionId = sessionContext.sessionId;
308
- // 이전 세션 자동 compound (fire-and-forget)
309
- // /new로 세션 리셋 시 SessionStart가 다시 호출됨 — 이때 이전 transcript를 compound
310
- try {
311
- const cwd = sessionContext.cwd;
312
- const sanitized = cwd.replace(/\//g, '-');
313
- const projectDir = path.join(os.homedir(), '.claude', 'projects', sanitized);
314
- if (fs.existsSync(projectDir)) {
315
- const transcripts = fs.readdirSync(projectDir)
316
- .filter(f => f.endsWith('.jsonl') && f !== `${sessionId}.jsonl`) // 현재 세션 제외
317
- .map(f => ({ name: f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
318
- .sort((a, b) => b.mtime - a.mtime);
319
- if (transcripts.length > 0) {
320
- const prevTranscript = path.join(projectDir, transcripts[0].name);
321
- const lastCompoundPath = path.join(STATE_DIR, 'last-auto-compound.json');
322
- let lastCompoundedSession = '';
323
- try {
324
- lastCompoundedSession = JSON.parse(fs.readFileSync(lastCompoundPath, 'utf-8')).sessionId ?? '';
264
+ // pending-compound 마커 확인 (이전 세션에서 compound loop 필요 표시)
265
+ const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
266
+ if (fs.existsSync(pendingPath)) {
267
+ try {
268
+ const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
269
+ recoveryMessages.push(`<compound-pending>` +
270
+ `\nCompound loop was scheduled in the previous session (${pending.promptCount ?? '?'} prompts).` +
271
+ `\nRun \`forgen compound\` to preview, then \`forgen compound --save\` to persist patterns/solutions.` +
272
+ `\n</compound-pending>`);
273
+ // 마커 삭제 (한 번만 안내)
274
+ fs.unlinkSync(pendingPath);
275
+ }
276
+ catch (e) {
277
+ log.debug('pending-compound 마커 읽기 실패', e);
278
+ }
279
+ }
280
+ // 핸드오프 파일 확인
281
+ const handoffDir = HANDOFFS_DIR;
282
+ if (fs.existsSync(handoffDir)) {
283
+ try {
284
+ const handoffs = fs.readdirSync(handoffDir)
285
+ .filter(f => f.endsWith('.md'))
286
+ .sort();
287
+ if (handoffs.length > 0) {
288
+ const latest = handoffs[handoffs.length - 1];
289
+ const latestPath = path.join(handoffDir, latest);
290
+ // Security: symlink 방지 + XML 이스케이프
291
+ if (fs.lstatSync(latestPath).isSymbolicLink())
292
+ throw new Error('symlink rejected');
293
+ const raw = fs.readFileSync(latestPath, 'utf-8');
294
+ const safeName = latest.replace(/[&"<>]/g, '_');
295
+ const escaped = raw.replace(/<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?\/?>/g, m => m.replace(/</g, '&lt;').replace(/>/g, '&gt;'));
296
+ recoveryMessages.push(`<compound-handoff file="${safeName}">\n${escaped}\n</compound-handoff>`);
297
+ // 마커 삭제 (한 번만 안내 — pending-compound.json과 동일 패턴)
298
+ try {
299
+ fs.unlinkSync(latestPath);
300
+ }
301
+ catch (e) {
302
+ log.debug('handoff 파일 삭제 실패', e);
303
+ }
325
304
  }
326
- catch { /* first time */ }
327
- const prevSessionId = transcripts[0].name.replace('.jsonl', '');
328
- if (prevSessionId !== lastCompoundedSession) {
329
- // 이전 세션이 compound 안 된 상태 — 메시지 수 확인
330
- // W-D2: 대용량 transcript 보호 — 앞 200KB만 읽어 메시지 수 추정
331
- const fd = fs.openSync(prevTranscript, 'r');
332
- const buf = Buffer.alloc(200 * 1024);
333
- const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
334
- fs.closeSync(fd);
335
- const content = buf.toString('utf-8', 0, bytesRead);
336
- const userMsgCount = content.split('\n')
337
- .filter(l => { try {
338
- const t = JSON.parse(l).type;
339
- return t === 'user' || t === 'queue-operation';
305
+ }
306
+ catch (e) {
307
+ log.debug('handoff 파일 읽기 실패', e);
308
+ }
309
+ }
310
+ // Load latest session brief
311
+ try {
312
+ const briefFiles = fs.readdirSync(HANDOFFS_DIR)
313
+ .filter(f => f.endsWith('-session-brief.json'))
314
+ .sort()
315
+ .reverse();
316
+ if (briefFiles.length > 0) {
317
+ const brief = JSON.parse(fs.readFileSync(path.join(HANDOFFS_DIR, briefFiles[0]), 'utf-8'));
318
+ const briefContext = `Previous session: ${brief.mode || 'general'}, ${brief.promptCount || 0} prompts, ${(brief.modifiedFiles || []).length} files modified, ${(brief.solutionsInjected || []).length} solutions used.`;
319
+ recoveryMessages.push(briefContext);
320
+ }
321
+ }
322
+ catch { /* fail-open */ }
323
+ const sessionId = sessionContext.sessionId;
324
+ // 이전 세션 자동 compound (fire-and-forget)
325
+ // /new로 세션 리셋 시 SessionStart가 다시 호출됨 — 이때 이전 transcript를 compound
326
+ try {
327
+ const cwd = sessionContext.cwd;
328
+ const sanitized = cwd.replace(/\//g, '-');
329
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', sanitized);
330
+ if (fs.existsSync(projectDir)) {
331
+ const transcripts = fs.readdirSync(projectDir)
332
+ .filter(f => f.endsWith('.jsonl') && f !== `${sessionId}.jsonl`) // 현재 세션 제외
333
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
334
+ .sort((a, b) => b.mtime - a.mtime);
335
+ if (transcripts.length > 0) {
336
+ const prevTranscript = path.join(projectDir, transcripts[0].name);
337
+ const lastCompoundPath = path.join(STATE_DIR, 'last-auto-compound.json');
338
+ let lastCompoundedSession = '';
339
+ try {
340
+ lastCompoundedSession = JSON.parse(fs.readFileSync(lastCompoundPath, 'utf-8')).sessionId ?? '';
340
341
  }
341
- catch {
342
- return false;
343
- } })
344
- .length;
345
- if (userMsgCount >= 10) {
346
- // background로 auto-compound 실행 (hook timeout과 무관)
347
- const { spawn: spawnProcess } = await import('node:child_process');
348
- const autoCompound = spawnProcess('node', [
349
- path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js'),
350
- cwd, prevTranscript, prevSessionId,
351
- ], { detached: true, stdio: 'ignore' });
352
- autoCompound.unref();
353
- log.debug(`이전 세션 auto-compound 시작: ${prevSessionId} (${userMsgCount} messages)`);
342
+ catch { /* first time */ }
343
+ const prevSessionId = transcripts[0].name.replace('.jsonl', '');
344
+ if (prevSessionId !== lastCompoundedSession) {
345
+ // 이전 세션이 compound 안 된 상태 — 메시지 수 확인
346
+ // W-D2: 대용량 transcript 보호 — 앞 200KB만 읽어 메시지 수 추정
347
+ const fd = fs.openSync(prevTranscript, 'r');
348
+ const buf = Buffer.alloc(200 * 1024);
349
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
350
+ fs.closeSync(fd);
351
+ const content = buf.toString('utf-8', 0, bytesRead);
352
+ const userMsgCount = content.split('\n')
353
+ .filter(l => { try {
354
+ const t = JSON.parse(l).type;
355
+ return t === 'user' || t === 'queue-operation';
356
+ }
357
+ catch {
358
+ return false;
359
+ } })
360
+ .length;
361
+ if (userMsgCount >= 10) {
362
+ // background로 auto-compound 실행 (hook timeout과 무관)
363
+ const { spawn: spawnProcess } = await import('node:child_process');
364
+ const autoCompound = spawnProcess('node', [
365
+ path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js'),
366
+ cwd, prevTranscript, prevSessionId,
367
+ ], { detached: true, stdio: 'ignore' });
368
+ autoCompound.unref();
369
+ log.debug(`이전 세션 auto-compound 시작: ${prevSessionId} (${userMsgCount} messages)`);
370
+ }
354
371
  }
355
372
  }
356
373
  }
357
374
  }
358
- }
359
- catch (e) {
360
- log.debug('이전 세션 auto-compound 체크 실패', e);
361
- }
362
- // v1: regex 기반 패턴 학습(prompt-learner) 제거. Evidence 기반으로 전환됨.
363
- // Compound v3: Run lifecycle check once per day
364
- try {
365
- const lifecycleModulePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'engine', 'compound-lifecycle.js');
366
- const lastLifecyclePath = path.join(STATE_DIR, 'last-lifecycle.json');
367
- let shouldRun = true;
375
+ catch (e) {
376
+ log.debug('이전 세션 auto-compound 체크 실패', e);
377
+ }
378
+ // v1: regex 기반 패턴 학습(prompt-learner) 제거. Evidence 기반으로 전환됨.
379
+ // Compound v3: Run lifecycle check once per day
368
380
  try {
369
- if (fs.existsSync(lastLifecyclePath)) {
370
- const data = JSON.parse(fs.readFileSync(lastLifecyclePath, 'utf-8'));
371
- const last = new Date(data.lastRun).getTime();
372
- shouldRun = Date.now() - last > 24 * 60 * 60 * 1000;
381
+ const lifecycleModulePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'engine', 'compound-lifecycle.js');
382
+ const lastLifecyclePath = path.join(STATE_DIR, 'last-lifecycle.json');
383
+ let shouldRun = true;
384
+ try {
385
+ if (fs.existsSync(lastLifecyclePath)) {
386
+ const data = JSON.parse(fs.readFileSync(lastLifecyclePath, 'utf-8'));
387
+ const last = new Date(data.lastRun).getTime();
388
+ shouldRun = Date.now() - last > 24 * 60 * 60 * 1000;
389
+ }
373
390
  }
391
+ catch { /* last-lifecycle.json parse failure — run lifecycle check anyway */ }
392
+ if (shouldRun) {
393
+ // B-4: detached background spawn으로 분리 — hook timeout 초과 방지
394
+ const { spawn: spawnLifecycle } = await import('node:child_process');
395
+ const lifecycleRunner = spawnLifecycle('node', [
396
+ '--input-type=module',
397
+ '-e',
398
+ `import('${lifecycleModulePath.replace(/\\/g, '/')}').then(m => m.runLifecycleCheck('${sessionId}'))`,
399
+ ], { detached: true, stdio: 'ignore' });
400
+ lifecycleRunner.unref();
401
+ const { atomicWriteJSON: writeJSON } = await import('./shared/atomic-write.js');
402
+ writeJSON(lastLifecyclePath, { lastRun: new Date().toISOString() });
403
+ }
404
+ }
405
+ catch (e) {
406
+ log.debug('lifecycle check 실패', e);
374
407
  }
375
- catch { /* last-lifecycle.json parse failure — run lifecycle check anyway */ }
376
- if (shouldRun) {
377
- // B-4: detached background spawn으로 분리 — hook timeout 초과 방지
378
- const { spawn: spawnLifecycle } = await import('node:child_process');
379
- const lifecycleRunner = spawnLifecycle('node', [
380
- '--input-type=module',
381
- '-e',
382
- `import('${lifecycleModulePath.replace(/\\/g, '/')}').then(m => m.runLifecycleCheck('${sessionId}'))`,
383
- ], { detached: true, stdio: 'ignore' });
384
- lifecycleRunner.unref();
385
- const { atomicWriteJSON: writeJSON } = await import('./shared/atomic-write.js');
386
- writeJSON(lastLifecyclePath, { lastRun: new Date().toISOString() });
408
+ if (recoveryMessages.length > 0) {
409
+ console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
410
+ }
411
+ else {
412
+ console.log(approve());
387
413
  }
388
414
  }
389
- catch (e) {
390
- log.debug('lifecycle check 실패', e);
391
- }
392
- if (recoveryMessages.length > 0) {
393
- console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
394
- }
395
- else {
396
- console.log(approve());
415
+ finally {
416
+ recordHookTiming('session-recovery', Date.now() - _hookStart, 'SessionStart');
397
417
  }
398
418
  }
399
419
  // ESM main guard: 다른 모듈에서 import 시 main() 실행 방지
@@ -401,6 +421,6 @@ async function main() {
401
421
  if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
402
422
  main().catch((e) => {
403
423
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
404
- console.log(failOpen());
424
+ console.log(failOpenWithTracking('session-recovery'));
405
425
  });
406
426
  }
@@ -31,3 +31,10 @@ export declare function deny(reason: string): string;
31
31
  export declare function ask(reason: string): string;
32
32
  /** fail-open: 에러 시 안전하게 통과 */
33
33
  export declare function failOpen(): string;
34
+ /**
35
+ * fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
36
+ * forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
37
+ *
38
+ * @fail-open: hook failure must never block the user's workflow
39
+ */
40
+ export declare function failOpenWithTracking(hookName: string): string;
@@ -13,6 +13,9 @@
13
13
  * systemMessage 필드는 UI 표시용으로만 사용되며 모델에 전달되지 않음.
14
14
  * 모델에 컨텍스트를 주입하려면 반드시 additionalContext를 사용해야 함.
15
15
  */
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
18
+ import * as path from 'node:path';
16
19
  /** 통과 응답 (컨텍스트 없음, 모든 이벤트 공통) */
17
20
  export function approve() {
18
21
  return JSON.stringify({ continue: true });
@@ -60,3 +63,20 @@ export function ask(reason) {
60
63
  export function failOpen() {
61
64
  return JSON.stringify({ continue: true });
62
65
  }
66
+ /**
67
+ * fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
68
+ * forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
69
+ *
70
+ * @fail-open: hook failure must never block the user's workflow
71
+ */
72
+ export function failOpenWithTracking(hookName) {
73
+ try {
74
+ const stateDir = path.join(os.homedir(), '.forgen', 'state');
75
+ fs.mkdirSync(stateDir, { recursive: true });
76
+ const logPath = path.join(stateDir, 'hook-errors.jsonl');
77
+ const entry = JSON.stringify({ hook: hookName, at: Date.now() });
78
+ fs.appendFileSync(logPath, entry + '\n');
79
+ }
80
+ catch { /* fail-open: tracking itself must not throw */ }
81
+ return JSON.stringify({ continue: true });
82
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Forgen — Hook Timing Profiler
3
+ *
4
+ * Records hook execution durations and provides timing statistics
5
+ * for visibility into which hooks are slow.
6
+ */
7
+ export declare function recordHookTiming(hookName: string, durationMs: number, event: string): void;
8
+ export interface TimingStats {
9
+ hook: string;
10
+ count: number;
11
+ p50: number;
12
+ p95: number;
13
+ max: number;
14
+ }
15
+ export declare function getTimingStats(): TimingStats[];