@wooojin/forgen 0.5.0 → 0.5.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +236 -0
  3. package/README.ja.md +16 -8
  4. package/README.ko.md +17 -9
  5. package/README.md +6 -1
  6. package/README.zh.md +16 -8
  7. package/assets/claude/skills/verify/SKILL.md +66 -0
  8. package/assets/shared/hook-registry.json +217 -22
  9. package/dist/cli.js +7 -3
  10. package/dist/core/auto-compound-runner.js +73 -14
  11. package/dist/core/changelog-cli.d.ts +4 -1
  12. package/dist/core/changelog-cli.js +8 -7
  13. package/dist/core/compound-sweep-cli.d.ts +4 -1
  14. package/dist/core/compound-sweep-cli.js +25 -5
  15. package/dist/core/doctor.js +37 -0
  16. package/dist/core/spawn.d.ts +26 -0
  17. package/dist/core/spawn.js +38 -3
  18. package/dist/core/uninstall.d.ts +7 -0
  19. package/dist/core/uninstall.js +46 -1
  20. package/dist/engine/compound-loop.js +20 -1
  21. package/dist/hooks/context-guard.d.ts +3 -0
  22. package/dist/hooks/context-guard.js +16 -8
  23. package/dist/hooks/hook-config.js +5 -0
  24. package/dist/hooks/hook-registry.d.ts +17 -1
  25. package/dist/hooks/hooks-generator.d.ts +2 -0
  26. package/dist/hooks/hooks-generator.js +8 -1
  27. package/dist/hooks/session-end.d.ts +41 -0
  28. package/dist/hooks/session-end.js +113 -0
  29. package/dist/hooks/session-recovery.js +18 -1
  30. package/dist/hooks/shared/hook-timing.js +6 -1
  31. package/dist/host/codex-adapter.js +5 -0
  32. package/dist/host/codex-hook-alive.d.ts +23 -0
  33. package/dist/host/codex-hook-alive.js +51 -0
  34. package/dist/host/codex-notify.d.ts +55 -0
  35. package/dist/host/codex-notify.js +153 -0
  36. package/dist/host/codex-rollout.d.ts +21 -0
  37. package/dist/host/codex-rollout.js +82 -0
  38. package/dist/host/codex-rules-context.d.ts +25 -0
  39. package/dist/host/codex-rules-context.js +62 -0
  40. package/dist/host/exec-host.d.ts +10 -0
  41. package/dist/host/exec-host.js +18 -3
  42. package/dist/host/install-claude.d.ts +14 -0
  43. package/dist/host/install-claude.js +53 -0
  44. package/dist/host/install-codex.d.ts +120 -0
  45. package/dist/host/install-codex.js +560 -52
  46. package/dist/host/install-orchestrator.d.ts +4 -0
  47. package/dist/host/install-orchestrator.js +32 -2
  48. package/dist/host/invoke-agent.js +1 -0
  49. package/dist/host/parity-harness.js +7 -3
  50. package/dist/host/projection.d.ts +37 -3
  51. package/dist/host/projection.js +93 -42
  52. package/dist/store/evidence-store.js +121 -57
  53. package/dist/store/rule-store.d.ts +41 -0
  54. package/dist/store/rule-store.js +77 -2
  55. package/hooks/hooks.json +13 -1
  56. package/package.json +1 -1
  57. package/plugin.json +1 -1
  58. package/skills/architecture-decision/SKILL.md +18 -0
  59. package/skills/calibrate/SKILL.md +18 -0
  60. package/skills/code-review/SKILL.md +17 -0
  61. package/skills/compound/SKILL.md +17 -0
  62. package/skills/deep-interview/SKILL.md +7 -0
  63. package/skills/docker/SKILL.md +18 -0
  64. package/skills/forge-loop/SKILL.md +23 -0
  65. package/skills/learn/SKILL.md +15 -0
  66. package/skills/retro/SKILL.md +16 -0
  67. package/skills/ship/SKILL.md +18 -0
@@ -112,7 +112,7 @@ function findSessionTranscript(cwd, sessionStartMs, preSnapshot, runtime = 'clau
112
112
  *
113
113
  * 단일 함수에서 둘 다 처리 — schema 자동 감지.
114
114
  */
115
- async function countUserMessages(transcriptPath) {
115
+ export async function countUserMessages(transcriptPath) {
116
116
  const { createInterface } = await import('node:readline');
117
117
  const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
118
118
  const rl = createInterface({ input: stream, crlfDelay: Infinity });
@@ -300,6 +300,18 @@ export function releaseAutoCompoundInflight(sessionId) {
300
300
  /* noop */
301
301
  }
302
302
  }
303
+ /**
304
+ * ~/.forgen/state/auto-compound/<sessionId>.log — 세션별 러너 로그 경로.
305
+ *
306
+ * 결함2 관측성 fix (2026-08-18): 이전엔 detached spawn 을 `stdio: 'ignore'` 로
307
+ * 실행해 러너의 stdout/stderr(진단 메시지 포함)를 통째로 버렸다. 그 결과 solution
308
+ * extraction 이 98/98 sweep 에서 `extractedSolutions=0` 을 기록해도 원인을 알 방법이
309
+ * 없었다(완전 무음 실패). 이제 fd 를 파일로 열어 넘겨 러너의 모든 출력을 세션별
310
+ * 로그로 남긴다 — 다음 실패 모드를 디버그 가능하게 만드는 전제 조건.
311
+ */
312
+ export function autoCompoundLogPath(sessionId) {
313
+ return path.join(STATE_DIR, 'auto-compound', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.log`);
314
+ }
303
315
  export function runAutoCompound(cwd, transcriptPath, sessionId, promptCount = 0) {
304
316
  const now = Date.now();
305
317
  // 1. 완료 마커 cooldown (secondary, 단일슬롯 — 값싼 read 먼저).
@@ -322,17 +334,40 @@ export function runAutoCompound(cwd, transcriptPath, sessionId, promptCount = 0)
322
334
  return 'skipped-inflight';
323
335
  }
324
336
  const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'auto-compound-runner.js');
337
+ // 로그 파일 fd 열기 — 실패(디스크 문제 등)해도 fail-open 으로 'ignore' 폴백.
338
+ const logPath = autoCompoundLogPath(sessionId);
339
+ let logFd;
325
340
  try {
326
- const child = spawn('node', [runnerPath, cwd, transcriptPath, sessionId, String(promptCount)], {
341
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
342
+ logFd = fs.openSync(logPath, 'a');
343
+ }
344
+ catch (e) {
345
+ log.debug('auto-compound 로그 파일 열기 실패 — stdio ignore로 폴백', e);
346
+ }
347
+ try {
348
+ // 결함2 corrected fix (2026-08-19): 리터럴 'node' 대신 process.execPath 로 spawn.
349
+ // 실 cron 은 절대 nvm node 경로로 sweep 를 돌리는데, 러너를 'node' 로 spawn 하면
350
+ // cron sparse PATH 의 /bin/node 로 해석돼 러너의 process.execPath 가 달라지고,
351
+ // 러너가 forgen(nvm 형제 바이너리)을 절대경로로 못 찾아 solution write 가 ENOENT 로
352
+ // 실패한다. execPath 를 물려주면 러너가 sweep 과 같은 node(=forgen 형제)를 상속한다.
353
+ const child = spawn(process.execPath, [runnerPath, cwd, transcriptPath, sessionId, String(promptCount)], {
327
354
  cwd,
328
355
  detached: true,
329
- stdio: 'ignore',
356
+ stdio: logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore',
330
357
  });
331
358
  child.unref();
359
+ if (logFd !== undefined)
360
+ fs.closeSync(logFd); // child가 dup — 부모 측 fd는 닫아도 안전
332
361
  console.log('\n[forgen] 세션 분석을 백그라운드로 시작했습니다 (자동 compound) — 결과는 다음 세션 시작 시 반영됩니다.\n');
333
362
  return 'spawned';
334
363
  }
335
364
  catch (e) {
365
+ if (logFd !== undefined) {
366
+ try {
367
+ fs.closeSync(logFd);
368
+ }
369
+ catch { /* noop */ }
370
+ }
336
371
  log.debug('auto-compound 시작 실패', e);
337
372
  releaseAutoCompoundInflight(sessionId); // spawn 실패 시 표식 제거 → 재시도 가능
338
373
  return 'failed';
@@ -1,3 +1,10 @@
1
+ /** ADR-016 D3 — ~/.claude/skills/verify 제거 (forgen-managed 마커가 있는 것만; 사용자 스킬은 보존) */
2
+ export declare function cleanVerifySkill(homeDir?: string): boolean;
3
+ /**
4
+ * ADR-016 D1 — Codex config.toml 의 forgen notify 블록 제거. 남겨 두면 Codex 가 매 턴 사라진 스크립트를
5
+ * spawn 한다. (Codex hooks.json / MCP 블록 정리는 아직 uninstall 범위 밖 — 알려진 갭.)
6
+ */
7
+ export declare function cleanCodexNotify(codexHome?: string): Promise<boolean>;
1
8
  /** forgen uninstall 메인 */
2
9
  export declare function handleUninstall(cwd: string, options: {
3
10
  force?: boolean;
@@ -96,6 +96,47 @@ function cleanSlashCommands() {
96
96
  console.log(' - No forgen-managed slash commands found');
97
97
  }
98
98
  }
99
+ /** ADR-016 D3 — ~/.claude/skills/verify 제거 (forgen-managed 마커가 있는 것만; 사용자 스킬은 보존) */
100
+ export function cleanVerifySkill(homeDir = os.homedir()) {
101
+ const dir = path.join(homeDir, '.claude', 'skills', 'verify');
102
+ const file = path.join(dir, 'SKILL.md');
103
+ try {
104
+ if (fs.lstatSync(dir).isSymbolicLink() || fs.lstatSync(file).isSymbolicLink())
105
+ return false;
106
+ const content = fs.readFileSync(file, 'utf-8');
107
+ if (!/^---\n[\s\S]*?\n---\n\s*<!-- forgen-managed -->/.test(content))
108
+ return false;
109
+ fs.unlinkSync(file);
110
+ try {
111
+ if (fs.readdirSync(dir).length === 0)
112
+ fs.rmdirSync(dir);
113
+ }
114
+ catch { /* ignore */ }
115
+ return true;
116
+ }
117
+ catch {
118
+ return false; // 없음
119
+ }
120
+ }
121
+ /**
122
+ * ADR-016 D1 — Codex config.toml 의 forgen notify 블록 제거. 남겨 두면 Codex 가 매 턴 사라진 스크립트를
123
+ * spawn 한다. (Codex hooks.json / MCP 블록 정리는 아직 uninstall 범위 밖 — 알려진 갭.)
124
+ */
125
+ export async function cleanCodexNotify(codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex')) {
126
+ const configPath = path.join(codexHome, 'config.toml');
127
+ try {
128
+ const current = fs.readFileSync(configPath, 'utf-8');
129
+ const { removeNotifyBlock } = await import('../host/install-codex.js');
130
+ const r = removeNotifyBlock(current);
131
+ if (!r.removed)
132
+ return false;
133
+ fs.writeFileSync(configPath, r.content, 'utf-8');
134
+ return true;
135
+ }
136
+ catch {
137
+ return false; // Codex 미사용
138
+ }
139
+ }
99
140
  /** 사용자에게 y/n 확인 */
100
141
  function confirm(message) {
101
142
  return new Promise((resolve) => {
@@ -301,7 +342,7 @@ export async function handleUninstall(cwd, options) {
301
342
  console.log(' 2. Delete .claude/agents/ch-*.md agent files');
302
343
  console.log(' 3. Delete .claude/rules/ rule files (project-context, routing, forge-*)');
303
344
  console.log(' 4. Remove forgen block from CLAUDE.md');
304
- console.log(' 5. Remove slash commands (~/.claude/commands/forgen/)');
345
+ console.log(' 5. Remove slash commands (~/.claude/commands/forgen/) and the forgen-managed verify skill (~/.claude/skills/verify/)');
305
346
  console.log(' 6. Remove plugin artifacts (cache, installed_plugins.json, plugin directory)');
306
347
  if (options.purge) {
307
348
  console.log(' 7. --purge: Delete ~/.forgen/ entirely (rules, me/, state/, solutions/, behavior/)');
@@ -330,6 +371,10 @@ export async function handleUninstall(cwd, options) {
330
371
  cleanCompoundRules(cwd);
331
372
  cleanClaudeMd(cwd);
332
373
  cleanSlashCommands();
374
+ if (cleanVerifySkill())
375
+ console.log(' ✓ Removed forgen verify skill (~/.claude/skills/verify/)');
376
+ if (await cleanCodexNotify())
377
+ console.log(' ✓ Removed forgen notify block from Codex config.toml');
333
378
  cleanPluginArtifacts();
334
379
  if (options.purge) {
335
380
  try {
@@ -210,6 +210,17 @@ export async function handleCompound(args) {
210
210
  `);
211
211
  return;
212
212
  }
213
+ // --- manual add (defense-in-depth, 2026-08-20) ---
214
+ // manual add(--solution/--rule/--convention/--pattern)를 아래 서브커맨드 dispatch 보다
215
+ // 먼저 처리한다. dispatch 는 args.includes('remove'|'clean-stale'|...) 로 전체 인자를
216
+ // 스캔하므로, add 의 위치인자(title/content)에 그런 토큰이 섞이면 삭제·정리 분기를
217
+ // 탈취할 수 있다(argument confusion — auto-compound 러너가 untrusted 모델 출력을 넘기던
218
+ // 경로에서 실증). type flag 는 add 전용이라 우선 처리해도 legit 동작 불변이며, 이렇게
219
+ // 하면 title/content 가 dispatch 스캔에 아예 닿지 않는다. (러너측 대시-선두 가드에 더한 2중 방어)
220
+ if (['--solution', '--rule', '--convention', '--pattern'].some(f => args.includes(f))) {
221
+ await handleManualAdd(args, cwd, scope);
222
+ return;
223
+ }
213
224
  // --- export command ---
214
225
  // 통짜 tar.gz export(플래그만, positional 없음) vs 이름 지정 패턴 export를
215
226
  // positional 인자 유무로 구분한다. `compound export foo` = 패턴 export,
@@ -435,10 +446,18 @@ export async function handleCompound(args) {
435
446
  console.log(' Unknown compound arguments. Run `forgen compound --help` for usage.\n');
436
447
  return;
437
448
  }
449
+ await handleManualAdd(args, cwd, scope);
450
+ }
451
+ /**
452
+ * 수동 인사이트 추가 (--solution/--rule/--convention/--pattern).
453
+ * handleCompound 에서 두 경로로 호출된다:
454
+ * (1) hoisted early-dispatch — type flag 가 있으면 서브커맨드 스캔 전에 즉시(arg-confusion 방어).
455
+ * (2) fallthrough — type flag 없이 다른 known flag(예: --to)만 있는 legacy 경로(동작 보존).
456
+ */
457
+ async function handleManualAdd(args, cwd, scope) {
438
458
  console.log('\n Compound Loop — Accumulating insights\n');
439
459
  console.log(` Scope: ${scope.summary}`);
440
460
  console.log();
441
- // 수동 인사이트 추가
442
461
  const type = args.includes('--solution') ? 'solution'
443
462
  : args.includes('--rule') ? 'rule'
444
463
  : args.includes('--convention') ? 'convention'
@@ -57,6 +57,9 @@ export declare function effectiveCooldownMs(parsed: {
57
57
  userPatternFound?: boolean;
58
58
  promptCount?: number;
59
59
  }, currentPromptCount?: number): number;
60
+ export declare function maybeSpawnAutoCompound(sessionId: string, transcriptPath: string | undefined, promptCount: number,
61
+ /** ADR-016 D1: notify 폴백은 훅 env(FORGEN_CWD) 없이 돌므로 페이로드의 cwd 를 넘긴다. */
62
+ cwdOverride?: string): Promise<boolean>;
60
63
  /**
61
64
  * forge-loop 활성 시 미완료 스토리가 있으면 Stop을 차단하고 지속 메시지 주입.
62
65
  * OMC의 persistent-mode.cjs 패턴 참고.
@@ -221,8 +221,12 @@ export async function main() {
221
221
  return;
222
222
  }
223
223
  const sessionId = input.session_id ?? 'default';
224
- // Stop 훅: stop_hook_type이 있으면 처리
225
- if (input.stop_hook_type) {
224
+ // Stop 훅 판별. Claude 는 `stop_hook_type`(user|end_turn|…) 을 주지만 Codex 의 Stop 입력에는 그 필드가
225
+ // 없고 `hook_event_name:"Stop"` 만 온다 (0.5.5 — 이전엔 Codex 세션에서 Stop 분기 전체가 건너뛰어져
226
+ // finalizeSession / Stop 트리거 auto-compound / rate-limit 감지가 돌지 않았다). Codex 의 정상 턴 종료는
227
+ // end_turn 과 동치로 취급한다.
228
+ const stopType = input.stop_hook_type ?? (input.hook_event_name === 'Stop' ? 'end_turn' : undefined);
229
+ if (stopType) {
226
230
  _hookEvent = 'Stop';
227
231
  // 세션 종료 시 pending outcome을 unknown으로 finalize.
228
232
  // 과거에는 프로덕션에서 호출되지 않아 pending이 다음 세션의 flushAccept에
@@ -289,7 +293,7 @@ export async function main() {
289
293
  }
290
294
  }
291
295
  // 정상 종료 시: 의미 있는 세션이었으면 compound 안내/자동 트리거
292
- if (input.stop_hook_type === 'user' || input.stop_hook_type === 'end_turn') {
296
+ if (stopType === 'user' || stopType === 'end_turn') {
293
297
  const state = loadContextState(sessionId);
294
298
  // ADR-002 T1 — 세션 중간에 교정이 들어와도 session-scoped rule 이 me-scope 으로
295
299
  // 승급되도록 Stop 에서 직접 auto-compound-runner 를 debounced 로 트리거.
@@ -435,9 +439,11 @@ export function effectiveCooldownMs(parsed, currentPromptCount = 0) {
435
439
  const grew = currentPromptCount - (parsed.promptCount ?? 0) >= AUTO_COMPOUND_GROWTH_THRESHOLD;
436
440
  return grew ? AUTO_COMPOUND_COOLDOWN_MS : AUTO_COMPOUND_BARREN_COOLDOWN_MS;
437
441
  }
438
- async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
442
+ export async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount,
443
+ /** ADR-016 D1: notify 폴백은 훅 env(FORGEN_CWD) 없이 돌므로 페이로드의 cwd 를 넘긴다. */
444
+ cwdOverride) {
439
445
  if (!transcriptPath || promptCount < 10)
440
- return;
446
+ return false;
441
447
  const markerPath = path.join(STATE_DIR, 'last-auto-compound.json');
442
448
  try {
443
449
  const raw = fs.readFileSync(markerPath, 'utf-8');
@@ -446,12 +452,12 @@ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
446
452
  const last = parsed.completedAt ? Date.parse(parsed.completedAt) : 0;
447
453
  const cooldown = effectiveCooldownMs(parsed, promptCount);
448
454
  if (Number.isFinite(last) && Date.now() - last < cooldown)
449
- return;
455
+ return false;
450
456
  }
451
457
  }
452
458
  catch { /* first time or corrupt — proceed */ }
453
459
  const { spawn: spawnProcess } = await import('node:child_process');
454
- const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
460
+ const cwd = cwdOverride ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
455
461
  // 기본: 번들된 auto-compound-runner. 프로덕션 빌드는 이 경로만 실행.
456
462
  const defaultRunner = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js');
457
463
  // 테스트 주입 경로 — FORGEN_TEST=1 게이트 + 경로 containment (~/.forgen 또는 /tmp 하위만 허용).
@@ -482,7 +488,7 @@ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
482
488
  const { claimAutoCompoundInflight, releaseAutoCompoundInflight } = await import('../core/spawn.js');
483
489
  if (!claimAutoCompoundInflight(sessionId)) {
484
490
  log.debug('Stop-triggered auto-compound skip: 세션 in-flight');
485
- return;
491
+ return false;
486
492
  }
487
493
  try {
488
494
  const child = spawnProcess('node', [runnerPath, cwd, transcriptPath, sessionId, String(promptCount)], {
@@ -491,10 +497,12 @@ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
491
497
  });
492
498
  child.unref();
493
499
  log.debug(`Stop-triggered auto-compound 시작: ${sessionId} (${promptCount} prompts)`);
500
+ return true;
494
501
  }
495
502
  catch (e) {
496
503
  releaseAutoCompoundInflight(sessionId); // spawn 실패 → 재시도 가능
497
504
  log.debug('Stop-triggered auto-compound spawn 실패', e);
505
+ return false;
498
506
  }
499
507
  }
500
508
  // forge-loop 차단 안전 상한 (무한 루프 방지)
@@ -146,6 +146,11 @@ export function loadHookConfig(hookName) {
146
146
  * 4. 기본값 true (하위호환)
147
147
  */
148
148
  export function isHookEnabled(hookName) {
149
+ // -1) ADR-015 C-G1: forgen 자신이 띄운 중첩 `claude -p` 추출 실행 안에서는 모든 훅을 끈다.
150
+ // (--bare 는 OAuth 를 끊어 쓸 수 없음.) 이전엔 추출 run 마다 22개 훅이 재귀 발화해
151
+ // hook-timing / sessions / usage-telemetry 를 오염시키고 auto-compound 연쇄를 유발했다.
152
+ if (process.env.FORGEN_NESTED_RUN === '1')
153
+ return false;
149
154
  // 0) compound-core 가드레일 — config 어떤 경로로도 끌 수 없음
150
155
  if (PROTECTED_HOOKS.has(hookName))
151
156
  return true;
@@ -11,7 +11,7 @@
11
11
  * - workflow: 워크플로우 스킬 훅 (다른 플러그인 감지 시 자동 비활성)
12
12
  */
13
13
  export type HookTier = 'compound-core' | 'safety' | 'workflow';
14
- export type HookEventType = 'UserPromptSubmit' | 'SessionStart' | 'Stop' | 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'SubagentStart' | 'SubagentStop' | 'PreCompact' | 'PermissionRequest';
14
+ export type HookEventType = 'UserPromptSubmit' | 'SessionStart' | 'Stop' | 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'SubagentStart' | 'SubagentStop' | 'PreCompact' | 'PermissionRequest' | 'SessionEnd';
15
15
  export interface HookEntry {
16
16
  /** 고유 이름 (hook-config.json에서 사용) */
17
17
  name: string;
@@ -27,6 +27,22 @@ export interface HookEntry {
27
27
  timeout: number;
28
28
  /** compound 피드백 루프에 필수인 훅인지 */
29
29
  compoundCritical: boolean;
30
+ /**
31
+ * ADR-015: 이 훅을 등록할 host 목록. 생략 시 모든 host. Codex 의 hooks.json 은 바이트 동일성이
32
+ * 훅 신뢰(trust) 와 묶여 있으므로, Claude 에만 추가하는 이벤트는 `["claude"]` 로 한정한다.
33
+ */
34
+ hosts?: Array<'claude' | 'codex' | 'opencode'>;
35
+ /**
36
+ * ADR-016 D2: Codex hooks.json 핸들러에만 붙는 필드. Codex 의 trust 해시는 핸들러 단위라
37
+ * 여기 값을 바꾸면 *그 핸들러만* `/hooks` 재승인이 필요하다 (다른 핸들러는 영향 없음).
38
+ */
39
+ codex?: {
40
+ /**
41
+ * `additionalContext` 를 임시 파일로 스필하는 임계(근사 토큰, 기본 2500 ≈ 10KB). 0 = 스필 안 함.
42
+ * Codex 는 PreToolUse/PostToolUse/SessionStart/UserPromptSubmit/SubagentStart 에서만 인정한다.
43
+ */
44
+ additionalContextLimit?: number;
45
+ };
30
46
  }
31
47
  export declare const HOOK_REGISTRY: HookEntry[];
32
48
  /** 티어별 훅 목록 조회 */
@@ -14,6 +14,8 @@ interface HookCommand {
14
14
  type: 'command';
15
15
  command: string;
16
16
  timeout: number;
17
+ /** Codex 전용 (ADR-016 D2). Claude 산출물에는 넣지 않는다. */
18
+ additionalContextLimit?: number;
17
19
  }
18
20
  interface HookMatcher {
19
21
  matcher: string;
@@ -57,6 +57,9 @@ export function generateHooksJson(options) {
57
57
  const hasOtherPlugins = !releaseMode && detectInstalledPlugins(cwd).length > 0;
58
58
  // 활성 훅 필터링
59
59
  const activeHooks = HOOK_REGISTRY.filter(hook => {
60
+ // 0) ADR-015: host 한정 훅 (예: SessionEnd 는 Claude 전용 — Codex hooks.json 바이트 동일성 유지)
61
+ if (hook.hosts && !hook.hosts.includes(runtime))
62
+ return false;
60
63
  // 1) hook-config.json에서 명시적 비활성화 (releaseMode 시 무시)
61
64
  if (!releaseMode && !isHookEnabled(hook.name))
62
65
  return false;
@@ -89,7 +92,11 @@ export function generateHooksJson(options) {
89
92
  matcher,
90
93
  hooks: matcherEntries.map(h => {
91
94
  const command = buildHookCommand(pluginRoot, h.script, runtime);
92
- return { type: 'command', command, timeout: h.timeout };
95
+ const handler = { type: 'command', command, timeout: h.timeout };
96
+ if (runtime === 'codex' && typeof h.codex?.additionalContextLimit === 'number') {
97
+ handler.additionalContextLimit = h.codex.additionalContextLimit;
98
+ }
99
+ return handler;
93
100
  }),
94
101
  }));
95
102
  }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — SessionEnd Hook (ADR-015 C-G6 Claude, ADR-016 D2 Codex)
4
+ *
5
+ * Claude Code `SessionEnd` (reason: clear|resume|logout|prompt_input_exit|other, 기본 예산 1.5s)
6
+ * 에서 이전 세션 transcript 를 auto-compound 러너에 넘긴다. 기존 트리거(Stop / PreCompact /
7
+ * 다음 SessionStart) 는 Stop 이 안 오는 종료(Ctrl+C, /exit 직후 종료) 와 "마지막 컴팩션 이후
8
+ * 학습" 을 놓쳤다 (memory: forgen-autocompound-gap). runAutoCompound 의 in-flight/cooldown dedup
9
+ * 이 이중 실행을 막는다.
10
+ *
11
+ * - 예산(기본 1.5s, registry timeout 3s 로 상향) 안에 끝나야 하므로: stdin 파싱 → user 메시지 수
12
+ * (앞 200KB 만 읽음, 대용량 transcript 보호) → detached spawn.
13
+ * - Codex 0.153+ 에도 등록한다 (ADR-016 D2). Codex 의 SessionEnd 는 stdin 이
14
+ * `session_id`/`transcript_path`/`cwd`/`reason`(항상 "other") 이고 stdout 을 무시하며 타임아웃을 1~3s 로
15
+ * clamp 한다 — 같은 "bounded count → detached spawn" 경로가 그대로 맞는다. trust 해시가 핸들러 단위라
16
+ * 새 이벤트 추가는 기존 훅의 신뢰를 깨지 않는다 (이 훅 1개만 `/hooks` 승인 필요).
17
+ * - fail-open: 어떤 실패도 종료를 막지 않는다.
18
+ */
19
+ /** session-recovery 와 동일 임계값 — 짧은 세션은 compound 가치가 없다. */
20
+ export declare const MIN_USER_MESSAGES = 10;
21
+ export interface SessionEndInput {
22
+ session_id?: string;
23
+ transcript_path?: string;
24
+ cwd?: string;
25
+ reason?: string;
26
+ }
27
+ /** 앞 N바이트만 읽는 상한. critic 2026-10-01: 239MB transcript 전체 스트리밍은 3.2s → 예산 초과로 SIGKILL. */
28
+ export declare const COUNT_SCAN_BYTES: number;
29
+ /**
30
+ * user 메시지 수를 transcript 앞 COUNT_SCAN_BYTES 만 읽어 센다 (session-recovery 와 동일 방식).
31
+ * 임계값(MIN_USER_MESSAGES) 판정에만 쓰이므로 하한 추정으로 충분하다. 큰 transcript 에서도 O(200KB).
32
+ */
33
+ export declare function countUserMessagesBounded(transcriptPath: string, maxBytes?: number): number;
34
+ /**
35
+ * host 별 user 메시지 수. Codex rollout 은 스키마가 달라 전용 카운터를 쓴다 (ADR-016 D2 — 실제 사용자
36
+ * 프롬프트만, raw 바이트 스캔). codex-adapter 가 delegate 훅에 FORGEN_RUNTIME=codex 를 주입한다.
37
+ */
38
+ export declare function countSessionUserMessages(transcriptPath: string, runtime?: string | undefined): Promise<number>;
39
+ /** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
40
+ export declare function shouldRunSessionEndCompound(input: SessionEndInput | null, userMessageCount: number): boolean;
41
+ export declare function main(): Promise<void>;
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — SessionEnd Hook (ADR-015 C-G6 Claude, ADR-016 D2 Codex)
4
+ *
5
+ * Claude Code `SessionEnd` (reason: clear|resume|logout|prompt_input_exit|other, 기본 예산 1.5s)
6
+ * 에서 이전 세션 transcript 를 auto-compound 러너에 넘긴다. 기존 트리거(Stop / PreCompact /
7
+ * 다음 SessionStart) 는 Stop 이 안 오는 종료(Ctrl+C, /exit 직후 종료) 와 "마지막 컴팩션 이후
8
+ * 학습" 을 놓쳤다 (memory: forgen-autocompound-gap). runAutoCompound 의 in-flight/cooldown dedup
9
+ * 이 이중 실행을 막는다.
10
+ *
11
+ * - 예산(기본 1.5s, registry timeout 3s 로 상향) 안에 끝나야 하므로: stdin 파싱 → user 메시지 수
12
+ * (앞 200KB 만 읽음, 대용량 transcript 보호) → detached spawn.
13
+ * - Codex 0.153+ 에도 등록한다 (ADR-016 D2). Codex 의 SessionEnd 는 stdin 이
14
+ * `session_id`/`transcript_path`/`cwd`/`reason`(항상 "other") 이고 stdout 을 무시하며 타임아웃을 1~3s 로
15
+ * clamp 한다 — 같은 "bounded count → detached spawn" 경로가 그대로 맞는다. trust 해시가 핸들러 단위라
16
+ * 새 이벤트 추가는 기존 훅의 신뢰를 깨지 않는다 (이 훅 1개만 `/hooks` 승인 필요).
17
+ * - fail-open: 어떤 실패도 종료를 막지 않는다.
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { createLogger } from '../core/logger.js';
23
+ import { readStdinJSON } from './shared/read-stdin.js';
24
+ import { isHookEnabled } from './hook-config.js';
25
+ import { approve } from './shared/hook-response.js';
26
+ import { recordHookTiming } from './shared/hook-timing.js';
27
+ const log = createLogger('session-end');
28
+ /** session-recovery 와 동일 임계값 — 짧은 세션은 compound 가치가 없다. */
29
+ export const MIN_USER_MESSAGES = 10;
30
+ /** 앞 N바이트만 읽는 상한. critic 2026-10-01: 239MB transcript 전체 스트리밍은 3.2s → 예산 초과로 SIGKILL. */
31
+ export const COUNT_SCAN_BYTES = 200 * 1024;
32
+ /**
33
+ * user 메시지 수를 transcript 앞 COUNT_SCAN_BYTES 만 읽어 센다 (session-recovery 와 동일 방식).
34
+ * 임계값(MIN_USER_MESSAGES) 판정에만 쓰이므로 하한 추정으로 충분하다. 큰 transcript 에서도 O(200KB).
35
+ */
36
+ export function countUserMessagesBounded(transcriptPath, maxBytes = COUNT_SCAN_BYTES) {
37
+ const fd = fs.openSync(transcriptPath, 'r');
38
+ try {
39
+ const buf = Buffer.alloc(maxBytes);
40
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
41
+ const content = buf.toString('utf-8', 0, bytesRead);
42
+ let n = 0;
43
+ for (const line of content.split('\n')) {
44
+ try {
45
+ const t = JSON.parse(line).type;
46
+ if (t === 'user' || t === 'queue-operation')
47
+ n += 1;
48
+ }
49
+ catch { /* partial last line / non-JSON */ }
50
+ }
51
+ return n;
52
+ }
53
+ finally {
54
+ fs.closeSync(fd);
55
+ }
56
+ }
57
+ /**
58
+ * host 별 user 메시지 수. Codex rollout 은 스키마가 달라 전용 카운터를 쓴다 (ADR-016 D2 — 실제 사용자
59
+ * 프롬프트만, raw 바이트 스캔). codex-adapter 가 delegate 훅에 FORGEN_RUNTIME=codex 를 주입한다.
60
+ */
61
+ export async function countSessionUserMessages(transcriptPath, runtime = process.env.FORGEN_RUNTIME) {
62
+ if (runtime === 'codex') {
63
+ const { countCodexUserPrompts } = await import('../host/codex-rollout.js');
64
+ return countCodexUserPrompts(transcriptPath);
65
+ }
66
+ return countUserMessagesBounded(transcriptPath);
67
+ }
68
+ /** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
69
+ export function shouldRunSessionEndCompound(input, userMessageCount) {
70
+ if (!input || typeof input.transcript_path !== 'string' || input.transcript_path.length === 0)
71
+ return false;
72
+ return userMessageCount >= MIN_USER_MESSAGES;
73
+ }
74
+ export async function main() {
75
+ const start = Date.now();
76
+ try {
77
+ const input = await readStdinJSON();
78
+ if (!isHookEnabled('session-end') || !input) {
79
+ console.log(approve());
80
+ return;
81
+ }
82
+ const { runAutoCompound } = await import('../core/spawn.js');
83
+ const transcript = input.transcript_path ?? '';
84
+ let count = 0;
85
+ if (transcript && fs.existsSync(transcript)) {
86
+ try {
87
+ count = await countSessionUserMessages(transcript);
88
+ }
89
+ catch (e) {
90
+ log.debug('user message count 실패', e);
91
+ }
92
+ }
93
+ if (shouldRunSessionEndCompound(input, count)) {
94
+ const cwd = input.cwd ?? process.cwd();
95
+ const sessionId = input.session_id ?? path.basename(transcript, '.jsonl');
96
+ const status = runAutoCompound(cwd, transcript, sessionId, count);
97
+ log.debug(`SessionEnd auto-compound: ${status} (reason=${input.reason ?? '?'}, msgs=${count})`);
98
+ }
99
+ console.log(approve());
100
+ }
101
+ catch (e) {
102
+ log.debug('session-end 실패 (fail-open)', e);
103
+ console.log(approve());
104
+ }
105
+ finally {
106
+ recordHookTiming('session-end', Date.now() - start, 'SessionEnd');
107
+ }
108
+ }
109
+ if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
110
+ main().catch(() => {
111
+ console.log(approve());
112
+ });
113
+ }
@@ -153,13 +153,28 @@ async function main() {
153
153
  // 이전엔 prepareHarness (fgx/forgen wrapper) 만 호출 → 직접 claude/codex 호출 시
154
154
  // ~/.forgen/state/sessions/<id>.json 미생성. SessionStart hook 에서도 호출하여
155
155
  // 양쪽 진입 경로 모두에서 session state 박제.
156
+ let v1RenderedRules = null;
156
157
  try {
157
158
  const { bootstrapV1Session } = await import('../core/v1-bootstrap.js');
158
- bootstrapV1Session();
159
+ v1RenderedRules = bootstrapV1Session().renderedRules;
159
160
  }
160
161
  catch (e) {
161
162
  log.debug('v1-bootstrap SessionStart 호출 실패 (fail-open)', e);
162
163
  }
164
+ // ADR-014 D1 — Codex 에는 .claude/rules/ 로드 표면이 없으므로 같은 룰을 SessionStart
165
+ // additionalContext 로 주입한다 (Claude 세션은 파일 경로로 이미 로드되므로 skip).
166
+ const codexRulesBlock = await (async () => {
167
+ try {
168
+ const { isCodexRuntime, buildCodexRulesContext } = await import('../host/codex-rules-context.js');
169
+ if (!isCodexRuntime())
170
+ return null;
171
+ return await buildCodexRulesContext(sessionContext.cwd, v1RenderedRules);
172
+ }
173
+ catch (e) {
174
+ log.debug('codex rules inject 실패 (fail-open)', e);
175
+ return null;
176
+ }
177
+ })();
163
178
  if (!fs.existsSync(STATE_DIR)) {
164
179
  console.log(approve());
165
180
  return;
@@ -435,6 +450,8 @@ async function main() {
435
450
  catch (e) {
436
451
  log.debug('lifecycle check 실패', e);
437
452
  }
453
+ if (codexRulesBlock)
454
+ recoveryMessages.unshift(codexRulesBlock);
438
455
  if (recoveryMessages.length > 0) {
439
456
  console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
440
457
  }
@@ -16,9 +16,14 @@ const MAX_LINES = 500;
16
16
  // MAX_LINES × 1.5 여유를 둠.
17
17
  const ROTATE_SIZE_BYTES = MAX_LINES * 80 * 2; // ~80KB
18
18
  export function recordHookTiming(hookName, durationMs, event) {
19
+ // ADR-015 C-G1 (critic #2): 중첩 추출 run 의 빠른 early-approve 타이밍이 p95 를 왜곡하지 않도록 기록 생략.
20
+ if (process.env.FORGEN_NESTED_RUN === '1')
21
+ return;
19
22
  try {
20
23
  fs.mkdirSync(STATE_DIR, { recursive: true });
21
- const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now() });
24
+ // ADR-014 D5 — host 별 발화 구분 (codex-adapter 가 FORGEN_RUNTIME=codex 주입).
25
+ const rt = process.env.FORGEN_RUNTIME === 'codex' ? 'codex' : 'claude';
26
+ const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now(), rt });
22
27
  fs.appendFileSync(TIMING_LOG, `${entry}\n`);
23
28
  // Rotate if too large — size-gated (statSync only, skip read/write 대부분의 호출)
24
29
  try {
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { spawnSync } from 'node:child_process';
14
14
  import { projectCodexToClaude } from './projection.js';
15
+ import { markCodexHookAlive } from './codex-hook-alive.js';
15
16
  function lastJSONObjectFromText(raw) {
16
17
  const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
17
18
  for (let i = lines.length - 1; i >= 0; i -= 1) {
@@ -54,6 +55,10 @@ async function main() {
54
55
  return {};
55
56
  }
56
57
  })();
58
+ // ADR-016 D1 — "Codex 가 forgen 훅을 실제로 실행 중" 마커 (notify 폴백이 신선도를 본다).
59
+ // forgen 자신의 추출용 중첩 실행은 사용자 세션이 아니므로 제외.
60
+ if (process.env.FORGEN_NESTED_RUN !== '1')
61
+ markCodexHookAlive(input);
57
62
  try {
58
63
  // 0.4.6 fix — delegate hook 이 자기가 codex runtime context 인지 알 수 있게
59
64
  // FORGEN_RUNTIME=codex 명시 주입. 이전엔 buildEnv (forgen wrapper) 경로로만
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Codex 훅 생존 마커 — ADR-016 D1
3
+ *
4
+ * Codex 는 미승인(untrusted)/변경된(modified) 훅을 조용히 skip 한다. "forgen 훅이 실제로 실행되고 있는가"
5
+ * 를 관측하는 유일한 choke point 는 codex-adapter 다 (Codex 가 forgen 훅을 실행하면 반드시 거친다).
6
+ * 어댑터가 턴 경계 이벤트에서 전역 마커를 갱신하고, notify 폴백(codex-notify)이 그 신선도를 본다.
7
+ *
8
+ * 훅 신뢰 상태는 hooks.json 단위로 모든 세션이 공유하므로 마커는 세션별이 아니라 전역 1개다.
9
+ * 어댑터가 매 훅마다 import 하므로 의존성은 node 내장 + paths 만 둔다.
10
+ */
11
+ export declare const CODEX_HOOK_ALIVE_PATH: string;
12
+ /** notify 는 Stop 훅 직후 발화한다. Stop 훅 타임아웃(10s) 대비 넉넉한 창. */
13
+ export declare const CODEX_HOOK_ALIVE_WINDOW_MS = 120000;
14
+ export interface CodexHookAliveMarker {
15
+ at: number;
16
+ event: string;
17
+ sessionId?: string;
18
+ }
19
+ /** 어댑터 입력(stdin JSON)으로 마커 갱신. 대상 이벤트가 아니면 no-op. 실패는 삼킨다 (fail-open). */
20
+ export declare function markCodexHookAlive(input: unknown, now?: number): boolean;
21
+ export declare function readCodexHookAlive(): CodexHookAliveMarker | null;
22
+ /** 최근 window 안에 forgen 훅이 Codex 에서 실행됐는가. */
23
+ export declare function isCodexHookAlive(now?: number, windowMs?: number): boolean;