@wooojin/forgen 0.4.12 → 0.5.0

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 (139) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +109 -0
  3. package/README.ja.md +4 -4
  4. package/README.md +89 -27
  5. package/README.zh.md +4 -4
  6. package/assets/claude/commands/forge-loop.md +7 -1
  7. package/assets/claude/commands/ship.md +18 -1
  8. package/assets/opencode/forgen.ts +52 -0
  9. package/dist/checks/_shared/meta-guard-dispatch.d.ts +7 -0
  10. package/dist/checks/_shared/meta-guard-dispatch.js +12 -2
  11. package/dist/checks/_shared/model-profile.d.ts +25 -0
  12. package/dist/checks/_shared/model-profile.js +63 -0
  13. package/dist/cli.js +99 -153
  14. package/dist/core/auto-compound-runner.d.ts +0 -11
  15. package/dist/core/auto-compound-runner.js +127 -79
  16. package/dist/core/compound-consent.d.ts +15 -0
  17. package/dist/core/compound-consent.js +48 -0
  18. package/dist/core/compound-sweep-cli.d.ts +57 -0
  19. package/dist/core/compound-sweep-cli.js +354 -0
  20. package/dist/core/config-injector.d.ts +16 -1
  21. package/dist/core/config-injector.js +47 -28
  22. package/dist/core/dashboard-cli.js +40 -16
  23. package/dist/core/dashboard.d.ts +3 -4
  24. package/dist/core/dashboard.js +15 -34
  25. package/dist/core/dev-cli.d.ts +13 -0
  26. package/dist/core/dev-cli.js +70 -0
  27. package/dist/core/doctor.d.ts +16 -5
  28. package/dist/core/doctor.js +161 -176
  29. package/dist/core/drift-score.d.ts +2 -0
  30. package/dist/core/drift-score.js +9 -1
  31. package/dist/core/harness.js +115 -43
  32. package/dist/core/health-cli.d.ts +2 -0
  33. package/dist/core/health-cli.js +6 -1
  34. package/dist/core/host-detect.d.ts +3 -1
  35. package/dist/core/host-detect.js +26 -1
  36. package/dist/core/migrate-cli.js +15 -0
  37. package/dist/core/migrate-evidence-host.d.ts +2 -1
  38. package/dist/core/migrate-tenetx.d.ts +50 -0
  39. package/dist/core/migrate-tenetx.js +262 -0
  40. package/dist/core/probe-workflow-cli.d.ts +3 -3
  41. package/dist/core/probe-workflow-cli.js +13 -13
  42. package/dist/core/recall-cli.js +1 -1
  43. package/dist/core/regress-map-cli.js +1 -1
  44. package/dist/core/rendered-rules-manifest.d.ts +29 -0
  45. package/dist/core/rendered-rules-manifest.js +60 -0
  46. package/dist/core/session-store.js +14 -3
  47. package/dist/core/settings-injector.d.ts +3 -0
  48. package/dist/core/settings-injector.js +12 -16
  49. package/dist/core/spawn.d.ts +11 -1
  50. package/dist/core/spawn.js +79 -7
  51. package/dist/core/state-gc.js +1 -0
  52. package/dist/core/status-cli.d.ts +20 -0
  53. package/dist/core/status-cli.js +100 -0
  54. package/dist/core/statusline-cli.d.ts +7 -0
  55. package/dist/core/statusline-cli.js +63 -19
  56. package/dist/core/transcript-summary.d.ts +18 -0
  57. package/dist/core/transcript-summary.js +81 -0
  58. package/dist/core/trust-layer-intent.d.ts +21 -1
  59. package/dist/core/trust-layer-intent.js +7 -0
  60. package/dist/core/types.d.ts +3 -2
  61. package/dist/core/uninstall.js +12 -0
  62. package/dist/core/usage-telemetry.d.ts +7 -1
  63. package/dist/core/usage-telemetry.js +7 -9
  64. package/dist/core/v1-bootstrap.js +1 -1
  65. package/dist/core/watch-cli.js +1 -1
  66. package/dist/engine/compound-extractor.js +10 -0
  67. package/dist/engine/compound-loop.js +35 -6
  68. package/dist/engine/compound-share.d.ts +85 -0
  69. package/dist/engine/compound-share.js +606 -0
  70. package/dist/engine/correction-cluster-runner.d.ts +38 -0
  71. package/dist/engine/correction-cluster-runner.js +188 -0
  72. package/dist/engine/correction-clustering.d.ts +80 -0
  73. package/dist/engine/correction-clustering.js +167 -0
  74. package/dist/engine/enforce-classifier.d.ts +10 -1
  75. package/dist/engine/enforce-classifier.js +111 -22
  76. package/dist/engine/extraction-session.js +10 -3
  77. package/dist/engine/private-filter.d.ts +36 -0
  78. package/dist/engine/private-filter.js +100 -0
  79. package/dist/engine/ranking-pipeline.js +4 -2
  80. package/dist/engine/relevance-gate.d.ts +12 -0
  81. package/dist/engine/relevance-gate.js +12 -0
  82. package/dist/engine/roi-demotion.d.ts +79 -0
  83. package/dist/engine/roi-demotion.js +159 -0
  84. package/dist/engine/solution-format.d.ts +1 -0
  85. package/dist/engine/solution-format.js +26 -0
  86. package/dist/engine/solution-matcher.js +10 -1
  87. package/dist/fgx.js +19 -6
  88. package/dist/forge/cli.js +8 -2
  89. package/dist/hooks/compound-reflection.js +6 -1
  90. package/dist/hooks/context-guard.d.ts +16 -1
  91. package/dist/hooks/context-guard.js +92 -46
  92. package/dist/hooks/post-tool-use.js +2 -3
  93. package/dist/hooks/pre-compact.js +14 -0
  94. package/dist/hooks/pre-tool-use.js +5 -1
  95. package/dist/hooks/shared/stop-triggers.d.ts +29 -2
  96. package/dist/hooks/shared/stop-triggers.js +35 -2
  97. package/dist/hooks/solution-injector.d.ts +28 -0
  98. package/dist/hooks/solution-injector.js +126 -28
  99. package/dist/hooks/stop-guard.js +5 -2
  100. package/dist/hooks/subagent-stop-guard.js +4 -1
  101. package/dist/host/capabilities-claude.js +1 -0
  102. package/dist/host/capabilities-codex.js +1 -0
  103. package/dist/host/capabilities-opencode.d.ts +26 -0
  104. package/dist/host/capabilities-opencode.js +78 -0
  105. package/dist/host/capabilities-registry.d.ts +7 -0
  106. package/dist/host/capabilities-registry.js +14 -0
  107. package/dist/host/exec-host.d.ts +4 -3
  108. package/dist/host/exec-host.js +2 -0
  109. package/dist/host/host-binding.d.ts +27 -0
  110. package/dist/host/host-binding.js +11 -0
  111. package/dist/host/host-runtime.js +18 -0
  112. package/dist/host/install-codex.d.ts +8 -0
  113. package/dist/host/install-codex.js +2 -2
  114. package/dist/host/install-opencode.d.ts +38 -0
  115. package/dist/host/install-opencode.js +148 -0
  116. package/dist/host/install-orchestrator.d.ts +4 -1
  117. package/dist/host/install-orchestrator.js +20 -1
  118. package/dist/host/invoke-agent.d.ts +3 -2
  119. package/dist/host/opencode/context-cli.d.ts +15 -0
  120. package/dist/host/opencode/context-cli.js +25 -0
  121. package/dist/host/opencode/guard-cli.d.ts +13 -0
  122. package/dist/host/opencode/guard-cli.js +39 -0
  123. package/dist/host/opencode/plugin/forgen.d.ts +31 -0
  124. package/dist/host/opencode/plugin/forgen.js +60 -0
  125. package/dist/host/opencode/translate.d.ts +49 -0
  126. package/dist/host/opencode/translate.js +96 -0
  127. package/dist/host/parity-harness.d.ts +10 -2
  128. package/dist/host/projection.js +11 -0
  129. package/dist/mcp/tools.js +17 -4
  130. package/dist/store/evidence-store.d.ts +2 -6
  131. package/dist/store/evidence-store.js +68 -14
  132. package/dist/store/host-mismatch.d.ts +2 -1
  133. package/dist/store/host-mismatch.js +8 -8
  134. package/dist/store/profile-store.d.ts +3 -2
  135. package/dist/store/types.d.ts +9 -2
  136. package/package.json +3 -2
  137. package/plugin.json +2 -2
  138. package/skills/forge-loop/SKILL.md +7 -1
  139. package/skills/ship/SKILL.md +18 -1
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import { CLAUDE_DIR, STATE_DIR } from '../core/paths.js';
10
10
  import { createLogger } from '../core/logger.js';
11
+ import { stripPrivate } from './private-filter.js';
11
12
  const log = createLogger('extraction-session');
12
13
  function normalizeProjectPath(cwd) {
13
14
  const resolved = path.resolve(cwd);
@@ -119,7 +120,12 @@ function collectClaudeProjectSessionContext(files, cwdCandidates, cutoffMs) {
119
120
  if (entry.type === 'user') {
120
121
  const message = entry.message;
121
122
  if (message?.role === 'user' && typeof message.content === 'string') {
122
- prompts.push(message.content);
123
+ // W2-5 (private 태그): 이 함수는 ~/.claude/projects/ 원시 트랜스크립트를 직접
124
+ // 읽는 마지막 캡처 경로다(prompt-history·session-store 와 별개 소스). <private>
125
+ // 범위를 제거해 학습 신호에서 배제하고, 통째 private 프롬프트는 push 하지 않는다.
126
+ const cleaned = stripPrivate(message.content).cleaned;
127
+ if (cleaned.trim())
128
+ prompts.push(cleaned);
123
129
  }
124
130
  continue;
125
131
  }
@@ -137,8 +143,9 @@ function collectClaudeProjectSessionContext(files, cwdCandidates, cutoffMs) {
137
143
  if (toolUse.name !== 'Write' && toolUse.name !== 'Edit')
138
144
  continue;
139
145
  const filePath = String(toolUse.input?.file_path ?? toolUse.input?.filePath ?? '');
140
- const content = String(toolUse.input?.content ?? toolUse.input?.new_string ?? '');
141
- if (!filePath || !content)
146
+ // W2-5: write 스니펫도 원시 코드(<private> 가능) — 캡처 전 strip.
147
+ const content = stripPrivate(String(toolUse.input?.content ?? toolUse.input?.new_string ?? '')).cleaned;
148
+ if (!filePath || !content.trim())
142
149
  continue;
143
150
  writes.push({
144
151
  filePath: filePath.slice(-100),
@@ -0,0 +1,36 @@
1
+ /**
2
+ * private-filter — <private> 캡처 제외 태그 (Wave 2 W2-5, feature-audit 2026-07-21).
3
+ *
4
+ * 사용자가 응답/교정/코드에 민감 내용을 담을 때, 그 범위를 compound 추출·correction
5
+ * 캡처·solution 저장·세션 인덱싱에서 제외한다. secret-filter(비밀키 커밋 차단)와는
6
+ * 별개 축 — 이건 "학습 코퍼스에 넣지 말라"는 사용자 의도 마킹이다. $0-로컬·프라이버시
7
+ * 우선 도구에 정합하며, 캡처 신뢰도를 높인다(사용자가 안심하고 교정할 수 있음).
8
+ *
9
+ * 마커 (claude-mem <private> 대응 + 편의 라인 마커):
10
+ * 1. 블록: <private> … </private> (다중 라인, 대소문자 무시, 속성/공백 허용)
11
+ * 2. 라인: // forgen:private (해당 라인 전체 제외)
12
+ * # forgen:private (동일, 해시 주석 스타일)
13
+ * /* forgen:private (동일, 블록주석 시작 스타일)
14
+ *
15
+ * fail-closed 원칙 (프라이버시 필터의 핵심): 사용자가 닫는 태그를 잊거나 오타를
16
+ * 내는 것이 가장 흔한 실수다. 미닫힘 <private> 는 *조용히 누출*하지 않고 EOF 까지
17
+ * private 로 취급한다(fail-closed). 중첩은 depth 카운팅으로 바깥까지 제거한다.
18
+ *
19
+ * 제외는 *조용히* 하지 않는다 — 호출측이 hadPrivate 로 로그/공지할 수 있게 반환한다.
20
+ */
21
+ export interface StripPrivateResult {
22
+ /** private 범위를 제거한 텍스트. */
23
+ cleaned: string;
24
+ /** 제거된 private 범위가 하나라도 있었는지 (조용한 제외 방지 — 호출측 공지용). */
25
+ hadPrivate: boolean;
26
+ }
27
+ /**
28
+ * <private> 블록 및 라인 마커 범위를 제거한다.
29
+ * 완전히 private 이면 cleaned 는 (공백만 남아) '' 에 가깝다 — isFullyPrivate 로 판정.
30
+ */
31
+ export declare function stripPrivate(text: string): StripPrivateResult;
32
+ /**
33
+ * 캡처 대상이 *통째로* private 인지 — private 제거 후 의미 있는 내용이 남지 않으면 true.
34
+ * (공백/개행만 남는 경우 포함) → 호출측은 저장 자체를 skip.
35
+ */
36
+ export declare function isFullyPrivate(text: string): boolean;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * private-filter — <private> 캡처 제외 태그 (Wave 2 W2-5, feature-audit 2026-07-21).
3
+ *
4
+ * 사용자가 응답/교정/코드에 민감 내용을 담을 때, 그 범위를 compound 추출·correction
5
+ * 캡처·solution 저장·세션 인덱싱에서 제외한다. secret-filter(비밀키 커밋 차단)와는
6
+ * 별개 축 — 이건 "학습 코퍼스에 넣지 말라"는 사용자 의도 마킹이다. $0-로컬·프라이버시
7
+ * 우선 도구에 정합하며, 캡처 신뢰도를 높인다(사용자가 안심하고 교정할 수 있음).
8
+ *
9
+ * 마커 (claude-mem <private> 대응 + 편의 라인 마커):
10
+ * 1. 블록: <private> … </private> (다중 라인, 대소문자 무시, 속성/공백 허용)
11
+ * 2. 라인: // forgen:private (해당 라인 전체 제외)
12
+ * # forgen:private (동일, 해시 주석 스타일)
13
+ * /* forgen:private (동일, 블록주석 시작 스타일)
14
+ *
15
+ * fail-closed 원칙 (프라이버시 필터의 핵심): 사용자가 닫는 태그를 잊거나 오타를
16
+ * 내는 것이 가장 흔한 실수다. 미닫힘 <private> 는 *조용히 누출*하지 않고 EOF 까지
17
+ * private 로 취급한다(fail-closed). 중첩은 depth 카운팅으로 바깥까지 제거한다.
18
+ *
19
+ * 제외는 *조용히* 하지 않는다 — 호출측이 hadPrivate 로 로그/공지할 수 있게 반환한다.
20
+ */
21
+ /** 여는 태그: 속성/공백 허용 (<private>, <private >, <private foo="x">). */
22
+ const PRIVATE_OPEN_RE = /<private(?:\s[^>]*)?>/i;
23
+ /** 닫는 태그: 공백 허용 (</private>, </private >). */
24
+ const PRIVATE_CLOSE_RE = /<\/private\s*>/i;
25
+ /** 라인 마커: //, #, /* 주석 스타일 + forgen:private. 해당 라인 전체 제거. */
26
+ const PRIVATE_LINE_RE = /^.*(?:\/\/|#|\/\*)\s*forgen:private.*$/gim;
27
+ /**
28
+ * <private> 블록 범위를 fail-closed 로 제거한다.
29
+ * - 닫힌 블록: 여는~닫는 태그 사이 제거 (중첩은 depth 카운팅).
30
+ * - 미닫힘 블록: 여는 태그부터 EOF 까지 제거 (닫기를 잊은 사용자 보호).
31
+ */
32
+ function stripBlocks(text) {
33
+ let hadPrivate = false;
34
+ let result = '';
35
+ let i = 0;
36
+ while (i < text.length) {
37
+ const rest = text.slice(i);
38
+ const open = rest.match(PRIVATE_OPEN_RE);
39
+ if (!open || open.index === undefined) {
40
+ result += rest;
41
+ break;
42
+ }
43
+ const openAbs = i + open.index;
44
+ // 여는 태그 앞 텍스트는 유지
45
+ result += text.slice(i, openAbs);
46
+ hadPrivate = true;
47
+ // 여는 태그 뒤부터 nesting 을 고려해 매칭 닫는 태그를 찾는다.
48
+ let depth = 1;
49
+ let j = openAbs + open[0].length;
50
+ while (j < text.length && depth > 0) {
51
+ const tail = text.slice(j);
52
+ const nextOpen = tail.match(PRIVATE_OPEN_RE);
53
+ const nextClose = tail.match(PRIVATE_CLOSE_RE);
54
+ const openIdx = nextOpen?.index ?? Infinity;
55
+ const closeIdx = nextClose?.index ?? Infinity;
56
+ if (openIdx === Infinity && closeIdx === Infinity) {
57
+ // 닫는 태그 없음 → fail-closed: EOF 까지 private 취급.
58
+ j = text.length;
59
+ depth = 0;
60
+ break;
61
+ }
62
+ if (openIdx < closeIdx) {
63
+ depth++;
64
+ j += openIdx + (nextOpen[0].length);
65
+ }
66
+ else {
67
+ depth--;
68
+ j += closeIdx + (nextClose[0].length);
69
+ }
70
+ }
71
+ // depth>0 로 루프 종료 = EOF 도달(미닫힘) → 이미 j=text.length. 블록 전체 건너뜀.
72
+ i = j;
73
+ }
74
+ return { cleaned: result, hadPrivate };
75
+ }
76
+ /**
77
+ * <private> 블록 및 라인 마커 범위를 제거한다.
78
+ * 완전히 private 이면 cleaned 는 (공백만 남아) '' 에 가깝다 — isFullyPrivate 로 판정.
79
+ */
80
+ export function stripPrivate(text) {
81
+ if (!text)
82
+ return { cleaned: text ?? '', hadPrivate: false };
83
+ const blocks = stripBlocks(text);
84
+ let hadPrivate = blocks.hadPrivate;
85
+ const cleaned = blocks.cleaned.replace(PRIVATE_LINE_RE, () => {
86
+ hadPrivate = true;
87
+ return '';
88
+ });
89
+ return { cleaned, hadPrivate };
90
+ }
91
+ /**
92
+ * 캡처 대상이 *통째로* private 인지 — private 제거 후 의미 있는 내용이 남지 않으면 true.
93
+ * (공백/개행만 남는 경우 포함) → 호출측은 저장 자체를 skip.
94
+ */
95
+ export function isFullyPrivate(text) {
96
+ if (!text)
97
+ return false; // 빈 입력은 private 이 아니라 그냥 없음
98
+ const { cleaned, hadPrivate } = stripPrivate(text);
99
+ return hadPrivate && cleaned.trim().length === 0;
100
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { maskBlockedTokens } from './phrase-blocklist.js';
9
9
  import { calculateRelevance } from './relevance-scorer.js';
10
- import { expandCompoundTags, expandQueryBigrams } from './solution-format.js';
10
+ import { expandCompoundTags, expandQueryBigrams, expandQueryKoreanStems } from './solution-format.js';
11
11
  import { shouldRejectByR4T3Rules } from './precision-guards.js';
12
12
  import { defaultNormalizer } from './term-normalizer.js';
13
13
  /**
@@ -26,7 +26,9 @@ export function rankCandidates(promptTags, promptLower, solutions, ensembleWeigh
26
26
  if (maskedPromptTags.length === 0)
27
27
  return [];
28
28
  // R4-T1: expand prompt tags with adjacent-token bigrams
29
- const promptTagsWithBigrams = expandQueryBigrams(maskedPromptTags);
29
+ // R5(vec-probe): 한국어 활용형 어간 회복 — `검증해줘` 류 회화체 쿼리가
30
+ // `검증` 계열 솔루션 태그에 도달하게 한다 (쿼리 사이드 전용, 인덱스 불변).
31
+ const promptTagsWithBigrams = expandQueryKoreanStems(expandQueryBigrams(maskedPromptTags));
30
32
  const normalizedPromptTags = defaultNormalizer.normalizeTerms(promptTagsWithBigrams);
31
33
  return solutions
32
34
  .map((sol) => {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * relevance-gate — TF-IDF/BM25/bigram relevance 매칭의 *정준* 게이트 임계(단일 소스).
3
+ *
4
+ * 2026-04-21 gate sweep(100% precision/60% recall 데이터)로 실측 튜닝된 값. 동일한
5
+ * relevance-scorer 표현을 쓰는 소비자들이 이 상수 하나를 공유한다:
6
+ * - solution 주입 게이트 (solution-injector.MIN_INJECT_RELEVANCE)
7
+ * - 교정 클러스터링 편입 임계 (correction-clustering.CLUSTER_SIMILARITY_TAU)
8
+ *
9
+ * W3-2 리뷰(SEV-3 #1): 이전엔 각 소비자가 리터럴 0.3 을 복제해 "상속"이 주석뿐이었다.
10
+ * 여기로 단일화해 한쪽만 바뀌는 근거-정직성 드리프트를 코드로 방지한다.
11
+ */
12
+ export declare const RELEVANCE_MATCH_GATE = 0.3;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * relevance-gate — TF-IDF/BM25/bigram relevance 매칭의 *정준* 게이트 임계(단일 소스).
3
+ *
4
+ * 2026-04-21 gate sweep(100% precision/60% recall 데이터)로 실측 튜닝된 값. 동일한
5
+ * relevance-scorer 표현을 쓰는 소비자들이 이 상수 하나를 공유한다:
6
+ * - solution 주입 게이트 (solution-injector.MIN_INJECT_RELEVANCE)
7
+ * - 교정 클러스터링 편입 임계 (correction-clustering.CLUSTER_SIMILARITY_TAU)
8
+ *
9
+ * W3-2 리뷰(SEV-3 #1): 이전엔 각 소비자가 리터럴 0.3 을 복제해 "상속"이 주석뿐이었다.
10
+ * 여기로 단일화해 한쪽만 바뀌는 근거-정직성 드리프트를 코드로 방지한다.
11
+ */
12
+ export const RELEVANCE_MATCH_GATE = 0.3;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Forgen v0.5.0 — Injection ROI 루프 (ADR-010 W3-1, F2)
3
+ *
4
+ * 근거: v0.4.11 실측에서 forgen 의 δ 는 100% injection 에서 나왔다 (blocks=0).
5
+ * 따라서 injection 품질 = 효과 그 자체다. "surfaced 는 많은데 acted_on 이
6
+ * 없는" 솔루션은 컨텍스트 비용만 내는 저 ROI 주입이므로 자동 강등한다.
7
+ * native 메모리(claude-mem 등)에는 없는 acted-on 피드백 루프 — moat 기능.
8
+ *
9
+ * 설계 (Rev 2 — 리뷰에서 기존 solution-quarantine 재사용 불가 확정):
10
+ * - 전용 저장소 `~/.forgen/state/roi-demotions.json`
11
+ * (기존 quarantine 은 frontmatter 파스 에러 기반 — 시맨틱 불일치)
12
+ * - `ranking-pipeline.ts` 는 순수 유지 — 강등은 matchSolutions 결과 후처리
13
+ * - 판정 갱신은 auto-compound 세션 종료 시 (updateRoiDemotions)
14
+ * - 임계 (config.json roiDemotion 으로 조정 가능):
15
+ * surfaced_90d >= 3 && acted/surfaced < 0.1 → 강등 (relevance ×0.5)
16
+ * 2회 연속 강등 유지 → 격리 (주입 제외)
17
+ * acted_on 신규 발생 → 즉시 해제
18
+ */
19
+ import { type HitRateRow } from '../core/observability-store.js';
20
+ export interface RoiDemotionEntry {
21
+ solutionId: string;
22
+ reason: 'low-roi';
23
+ demotedAt: string;
24
+ /** 연속 강등 유지 윈도 수 — 2 이상이면 격리(주입 제외) */
25
+ windowCount: number;
26
+ /** 마지막 windowCount 증가 시점 — 24h 미만 재평가는 증가 없음 (리뷰 SEV-2:
27
+ * 평가는 auto-compound 세션마다 도는데, 같은 날 2세션으로 격리되면
28
+ * "2회 연속 윈도" 설계 의도 위반. 90d 통계는 당일 내 사실상 불변이라
29
+ * 같은 날 재평가는 새 정보가 없다.) */
30
+ lastEvaluatedAt: string;
31
+ surfaced: number;
32
+ actedOn: number;
33
+ }
34
+ export type RoiDemotions = Record<string, RoiDemotionEntry>;
35
+ export interface RoiThresholds {
36
+ /** 판정에 필요한 최소 노출 수 (90d). 소규모 사용자에서 dead-code 방지 위해 3. */
37
+ surfacedMin: number;
38
+ /** 이 미만이면 저 ROI (acted/surfaced) */
39
+ rateMax: number;
40
+ }
41
+ export declare const DEFAULT_ROI_THRESHOLDS: RoiThresholds;
42
+ export declare function roiDemotionsPath(home?: string): string;
43
+ export declare function loadRoiDemotions(home?: string): RoiDemotions;
44
+ export declare function saveRoiDemotions(demotions: RoiDemotions, home?: string): void;
45
+ /**
46
+ * 90d 윈도 기준 재판정. 반환값이 새 저장 상태다.
47
+ *
48
+ * - 신규 강등: surfaced ≥ min && rate < max → windowCount=1
49
+ * - 유지: 이미 강등 && 여전히 저 ROI → windowCount+1 (2부터 격리)
50
+ * - 해제: acted_on 이 저장 시점보다 증가 (사용자가 실제로 씀) 또는
51
+ * rate 가 임계 이상으로 회복 → 엔트리 제거
52
+ * - 유예: surfaced < min 인 솔루션은 판정하지 않음 (신규/저노출 보호)
53
+ */
54
+ export declare function evaluateRoiDemotions(rows: HitRateRow[], prev: RoiDemotions, thresholds?: RoiThresholds, now?: () => string): RoiDemotions;
55
+ /** 격리 여부 — 2회 연속 윈도 강등 유지 시 주입에서 제외 */
56
+ export declare function isRoiQuarantined(entry: RoiDemotionEntry): boolean;
57
+ export interface RoiAdjustable {
58
+ name: string;
59
+ relevance: number;
60
+ }
61
+ /**
62
+ * 강등 ×0.5, 격리는 제외. relevance 변경 후 내림차순 재정렬.
63
+ * fail-open: demotions 가 비면 원본 그대로.
64
+ *
65
+ * 실효 (리뷰에서 명시화): injector 의 MIN_INJECT_RELEVANCE 가 0.3 이므로
66
+ * relevance < 0.6 인 강등 솔루션은 사실상 주입 차단된다 — 강등은
67
+ * "중간 신뢰도엔 soft-block, 고신뢰도(≥0.6)엔 우선순위 하락"으로 동작하고,
68
+ * 격리는 relevance 무관 hard-block. 이 기울기는 의도된 것: δ 가 injection
69
+ * 품질에서 나오므로 저 ROI 주입엔 보수적으로 군다.
70
+ */
71
+ export declare function applyRoiDemotions<T extends RoiAdjustable>(matches: T[], demotions: RoiDemotions): T[];
72
+ /**
73
+ * observability 이벤트 → 판정 → 저장. fail-open.
74
+ * @returns 강등/격리 수 (로그용) 또는 null (판정 불가)
75
+ */
76
+ export declare function updateRoiDemotions(home?: string): {
77
+ demoted: number;
78
+ quarantined: number;
79
+ } | null;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Forgen v0.5.0 — Injection ROI 루프 (ADR-010 W3-1, F2)
3
+ *
4
+ * 근거: v0.4.11 실측에서 forgen 의 δ 는 100% injection 에서 나왔다 (blocks=0).
5
+ * 따라서 injection 품질 = 효과 그 자체다. "surfaced 는 많은데 acted_on 이
6
+ * 없는" 솔루션은 컨텍스트 비용만 내는 저 ROI 주입이므로 자동 강등한다.
7
+ * native 메모리(claude-mem 등)에는 없는 acted-on 피드백 루프 — moat 기능.
8
+ *
9
+ * 설계 (Rev 2 — 리뷰에서 기존 solution-quarantine 재사용 불가 확정):
10
+ * - 전용 저장소 `~/.forgen/state/roi-demotions.json`
11
+ * (기존 quarantine 은 frontmatter 파스 에러 기반 — 시맨틱 불일치)
12
+ * - `ranking-pipeline.ts` 는 순수 유지 — 강등은 matchSolutions 결과 후처리
13
+ * - 판정 갱신은 auto-compound 세션 종료 시 (updateRoiDemotions)
14
+ * - 임계 (config.json roiDemotion 으로 조정 가능):
15
+ * surfaced_90d >= 3 && acted/surfaced < 0.1 → 강등 (relevance ×0.5)
16
+ * 2회 연속 강등 유지 → 격리 (주입 제외)
17
+ * acted_on 신규 발생 → 즉시 해제
18
+ */
19
+ import * as fs from 'node:fs';
20
+ import * as os from 'node:os';
21
+ import * as path from 'node:path';
22
+ import { queryHitRate } from '../core/observability-store.js';
23
+ export const DEFAULT_ROI_THRESHOLDS = { surfacedMin: 3, rateMax: 0.1 };
24
+ export function roiDemotionsPath(home = os.homedir()) {
25
+ return path.join(home, '.forgen', 'state', 'roi-demotions.json');
26
+ }
27
+ export function loadRoiDemotions(home = os.homedir()) {
28
+ try {
29
+ const parsed = JSON.parse(fs.readFileSync(roiDemotionsPath(home), 'utf-8'));
30
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ export function saveRoiDemotions(demotions, home = os.homedir()) {
37
+ try {
38
+ const p = roiDemotionsPath(home);
39
+ fs.mkdirSync(path.dirname(p), { recursive: true });
40
+ // tmp+rename 원자 교체 — 동시 세션(auto-compound 병행 종료)에서 부분 쓰기로
41
+ // 파일이 깨지는 것을 방지. rename 은 동일 fs 내 원자적.
42
+ const tmp = `${p}.${process.pid}.tmp`;
43
+ fs.writeFileSync(tmp, `${JSON.stringify(demotions, null, 2)}\n`);
44
+ fs.renameSync(tmp, p);
45
+ }
46
+ catch { /* fail-open — 강등 실패가 주입을 막지 않는다 */ }
47
+ }
48
+ // ── 판정 (순수 함수 — 테스트 결정성) ──
49
+ /**
50
+ * 90d 윈도 기준 재판정. 반환값이 새 저장 상태다.
51
+ *
52
+ * - 신규 강등: surfaced ≥ min && rate < max → windowCount=1
53
+ * - 유지: 이미 강등 && 여전히 저 ROI → windowCount+1 (2부터 격리)
54
+ * - 해제: acted_on 이 저장 시점보다 증가 (사용자가 실제로 씀) 또는
55
+ * rate 가 임계 이상으로 회복 → 엔트리 제거
56
+ * - 유예: surfaced < min 인 솔루션은 판정하지 않음 (신규/저노출 보호)
57
+ */
58
+ export function evaluateRoiDemotions(rows, prev, thresholds = DEFAULT_ROI_THRESHOLDS, now = () => new Date().toISOString()) {
59
+ const next = {};
60
+ for (const row of rows) {
61
+ const surfaced = row.surfaced_90d;
62
+ const acted = row.acted_90d;
63
+ const existing = prev[row.solutionId];
64
+ // 해제 1: 실제 사용 발생 (저장 시점 대비 acted 증가)
65
+ if (existing && acted > existing.actedOn)
66
+ continue;
67
+ if (surfaced < thresholds.surfacedMin) {
68
+ // 유예 — 단, 기존 강등 엔트리는 노출이 줄었어도 유지 (회복은 acted 로만)
69
+ if (existing)
70
+ next[row.solutionId] = existing;
71
+ continue;
72
+ }
73
+ const rate = acted / surfaced;
74
+ if (rate < thresholds.rateMax) {
75
+ // windowCount 증가는 최소 24h 간격 — 같은 날 다중 세션의 재평가는
76
+ // 동일 스냅샷 재확인일 뿐이므로 증가 없이 엔트리를 유지한다.
77
+ const nowIso = now();
78
+ const elapsed = existing
79
+ ? new Date(nowIso).getTime() - new Date(existing.lastEvaluatedAt).getTime()
80
+ : Number.POSITIVE_INFINITY;
81
+ const advanceWindow = elapsed >= 24 * 60 * 60 * 1000;
82
+ next[row.solutionId] = existing && !advanceWindow
83
+ ? existing // 24h 미만 — 그대로 유지 (카운트/스냅샷 불변)
84
+ : {
85
+ solutionId: row.solutionId,
86
+ reason: 'low-roi',
87
+ demotedAt: existing?.demotedAt ?? nowIso,
88
+ windowCount: (existing?.windowCount ?? 0) + 1,
89
+ lastEvaluatedAt: nowIso,
90
+ surfaced,
91
+ actedOn: acted,
92
+ };
93
+ }
94
+ // 해제 2: rate 회복 → next 에 없음 = 제거
95
+ }
96
+ // rows 에 없는 기존 엔트리(이벤트가 180d 밖으로 age-out)는 함께 소멸 —
97
+ // 오래 안 쓰인 솔루션은 T4 time-decay 가 별도로 처리한다.
98
+ //
99
+ // 의도된 장주기 순환 (리뷰에서 명시화): 격리된 솔루션은 surfaced 이벤트가
100
+ // 더 이상 쌓이지 않아 ~6개월 뒤 rows 에서 사라지고 → 엔트리 소멸 → 주입
101
+ // 풀로 복귀한다. 영구 추방이 아니라 "재시도 기회"다 — 프로젝트 맥락이
102
+ // 바뀌면 같은 솔루션이 유효해질 수 있고, 여전히 안 쓰이면 다시 강등된다.
103
+ return next;
104
+ }
105
+ /** 격리 여부 — 2회 연속 윈도 강등 유지 시 주입에서 제외 */
106
+ export function isRoiQuarantined(entry) {
107
+ return entry.windowCount >= 2;
108
+ }
109
+ /**
110
+ * 강등 ×0.5, 격리는 제외. relevance 변경 후 내림차순 재정렬.
111
+ * fail-open: demotions 가 비면 원본 그대로.
112
+ *
113
+ * 실효 (리뷰에서 명시화): injector 의 MIN_INJECT_RELEVANCE 가 0.3 이므로
114
+ * relevance < 0.6 인 강등 솔루션은 사실상 주입 차단된다 — 강등은
115
+ * "중간 신뢰도엔 soft-block, 고신뢰도(≥0.6)엔 우선순위 하락"으로 동작하고,
116
+ * 격리는 relevance 무관 hard-block. 이 기울기는 의도된 것: δ 가 injection
117
+ * 품질에서 나오므로 저 ROI 주입엔 보수적으로 군다.
118
+ */
119
+ export function applyRoiDemotions(matches, demotions) {
120
+ if (Object.keys(demotions).length === 0)
121
+ return matches;
122
+ const adjusted = [];
123
+ for (const m of matches) {
124
+ const entry = demotions[m.name];
125
+ if (!entry) {
126
+ adjusted.push(m);
127
+ continue;
128
+ }
129
+ if (isRoiQuarantined(entry))
130
+ continue; // 격리 — 주입 제외
131
+ adjusted.push({ ...m, relevance: m.relevance * 0.5 });
132
+ }
133
+ adjusted.sort((a, b) => b.relevance - a.relevance);
134
+ return adjusted;
135
+ }
136
+ // ── 갱신 진입점 (auto-compound 세션 종료 시) ──
137
+ /**
138
+ * observability 이벤트 → 판정 → 저장. fail-open.
139
+ * @returns 강등/격리 수 (로그용) 또는 null (판정 불가)
140
+ */
141
+ export function updateRoiDemotions(home = os.homedir()) {
142
+ try {
143
+ // observability-store 는 sqlite 미지원/DB 부재 시 내부 fail-open ([] 반환)
144
+ const rows = queryHitRate();
145
+ if (rows.length === 0)
146
+ return { demoted: 0, quarantined: 0 };
147
+ const prev = loadRoiDemotions(home);
148
+ const next = evaluateRoiDemotions(rows, prev);
149
+ saveRoiDemotions(next, home);
150
+ const entries = Object.values(next);
151
+ return {
152
+ demoted: entries.filter(e => !isRoiQuarantined(e)).length,
153
+ quarantined: entries.filter(isRoiQuarantined).length,
154
+ };
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
@@ -162,5 +162,6 @@ export declare function expandCompoundTags(tags: readonly string[]): string[];
162
162
  * two adjacent tokens). Only ASCII-letter pairs participate.
163
163
  */
164
164
  export declare function expandQueryBigrams(tags: readonly string[]): string[];
165
+ export declare function expandQueryKoreanStems(tags: readonly string[]): string[];
165
166
  /** Migrate a V1-format solution file to V3 format */
166
167
  export declare function migrateV1toV3(content: string, filePath: string): string;
@@ -590,6 +590,32 @@ export function expandQueryBigrams(tags) {
590
590
  }
591
591
  return [...out];
592
592
  }
593
+ /**
594
+ * 한국어 활용형 어간 회복 (매칭 **쿼리 전용** — vec-probe 2026-07-20 실측 갭).
595
+ *
596
+ * `KO_SUFFIXES`(조사)와 term-matcher의 `KO_VERBAL_SUFFIXES`(`중`,`시`)는
597
+ * `검증해줘`·`최적화하자`·`배포하면` 같은 회화체 활용형에 도달하지 못한다 —
598
+ * 자연어 한국어 프롬프트에서 `검증` 계열 솔루션 태그가 영원히 매칭 불가.
599
+ * (match-eval-log 2,371행 기준 한국어 장형 토큰의 지배적 형태가 이 부류.)
600
+ *
601
+ * 규칙: `어간(≥2자) + (했|하|해|됐|되|돼) + 나머지` 형태에서 어간을 **추가**한다
602
+ * — 원본 토큰은 유지(치환 아님)라 오탐은 확장 노이즈에 그치고, ≥2자 어간
603
+ * 요구가 `이해`·`피해`·`유해물질`(하/해가 1번째 위치) 류 명사를 보호한다.
604
+ * 쿼리 사이드만 확장하므로 인덱스 재구축·ROUND3_BASELINE 재측정이 불필요
605
+ * (expandQueryBigrams와 같은 계약).
606
+ */
607
+ const KO_CONJUGATION_RE = /^([가-힣]{2,}?)(했|하|해|됐|되|돼)[가-힣]*$/;
608
+ export function expandQueryKoreanStems(tags) {
609
+ const out = new Set(tags);
610
+ for (const tag of tags) {
611
+ if (!/^[가-힣]+$/.test(tag))
612
+ continue;
613
+ const m = tag.match(KO_CONJUGATION_RE);
614
+ if (m)
615
+ out.add(m[1]);
616
+ }
617
+ return [...out];
618
+ }
593
619
  // ── Migration ──
594
620
  const V1_TYPE_MAP = {
595
621
  solution: 'pattern',
@@ -19,6 +19,7 @@ import { getOrBuildIndex } from './solution-index.js';
19
19
  import { defaultNormalizer } from './term-normalizer.js';
20
20
  import { rankCandidates } from './ranking-pipeline.js';
21
21
  import { loadTunedMatcherWeights } from './meta-learning/matcher-weight-loader.js';
22
+ import { applyRoiDemotions, loadRoiDemotions } from './roi-demotion.js';
22
23
  // ── Re-exports (backward compatibility) ──
23
24
  export { bigramSimilarity, bm25Score, COMMON_TAGS, tagWeight } from './scoring-algorithms.js';
24
25
  export { calculateRelevance } from './relevance-scorer.js';
@@ -54,7 +55,9 @@ export function matchSolutions(prompt, scope, cwd) {
54
55
  const promptLower = prompt.toLowerCase();
55
56
  const tunedWeights = loadTunedMatcherWeights();
56
57
  const ranked = rankCandidates(promptTags, promptLower, allSolutions, tunedWeights);
57
- return ranked.map((c) => ({
58
+ // ADR-010 W3-1 (F2): 저 ROI 강등 — ranking-pipeline 은 순수 유지, 여기서
59
+ // 후처리. surfaced≫acted_on 솔루션은 ×0.5, 2윈도 연속이면 주입 제외.
60
+ const matches = ranked.map((c) => ({
58
61
  name: c.solution.name,
59
62
  path: c.solution.filePath,
60
63
  scope: c.solution.scope,
@@ -68,4 +71,10 @@ export function matchSolutions(prompt, scope, cwd) {
68
71
  matchedTags: [...c.matchedTags, ...c.matchedIdentifiers],
69
72
  matchedIdentifiers: c.matchedIdentifiers,
70
73
  }));
74
+ try {
75
+ return applyRoiDemotions(matches, loadRoiDemotions());
76
+ }
77
+ catch {
78
+ return matches; // fail-open — 강등 실패가 주입을 막지 않는다
79
+ }
71
80
  }
package/dist/fgx.js CHANGED
@@ -14,12 +14,13 @@ const args = process.argv.slice(2);
14
14
  // v0.4.10: forgen 서브커맨드 인벤토리. src/cli.ts 의 commands[] 와 sync 유지.
15
15
  // 첫 비-플래그 인자가 이 집합에 들어가면 forgen cli 로 라우팅.
16
16
  const FORGEN_SUBCOMMANDS = new Set([
17
- 'forge', 'compound', 'skill', 'dashboard', 'learn', 'me', 'statusline',
18
- 'config', 'mcp', 'init', 'install', 'status', 'maintenance', 'parity',
19
- 'notepad', 'inspect', 'onboarding', 'doctor', 'uninstall', 'rule',
17
+ // Wave 1 통합(feature-audit 2026-07-21): status(←stats/health/dashboard/me/
18
+ // recall/explain/last-block/watch), dev(←probe-workflow/parity/migrate/regress-map).
19
+ 'forge', 'compound', 'skill', 'status', 'learn', 'statusline',
20
+ 'config', 'mcp', 'init', 'install', 'maintenance', 'dev',
21
+ 'notepad', 'inspect', 'doctor', 'uninstall', 'rule',
20
22
  'classify-enforce', 'rule-meta-scan', 'lifecycle-scan',
21
- 'stats', 'last-block', 'recall', 'migrate', 'suppress-rule', 'activate-rule',
22
- 'regress-map', 'watch', 'health', 'probe-workflow', 'workflows', 'explain', 'changelog',
23
+ 'suppress-rule', 'activate-rule', 'workflows', 'changelog',
23
24
  // 메타 명령도 cli.ts 가 처리 (fgx claude spawn 으로는 의미 없음)
24
25
  'help', '--help', '-h', '--version', '-V',
25
26
  ]);
@@ -61,7 +62,7 @@ async function runClaudeLauncher() {
61
62
  if (firstRun) {
62
63
  console.log('\n Forgen — Setting up for the first time.\n');
63
64
  console.log(' Creating ~/.forgen/ directory and default philosophy.');
64
- console.log(' Run `forgen onboarding` afterwards to complete personalization.\n');
65
+ console.log(' Run `forgen forge --onboarding` afterwards to complete personalization.\n');
65
66
  }
66
67
  const context = await prepareHarness(process.cwd(), { runtime });
67
68
  if (firstRun) {
@@ -80,10 +81,22 @@ async function runClaudeLauncher() {
80
81
  async function main() {
81
82
  const sub = findFirstSubcommand(args);
82
83
  if (sub !== null) {
84
+ // cli.js 위임 경로는 건드리지 않는다. watch/dashboard/workflows 등 long-running
85
+ // 서브커맨드가 여기서 강제 종료되면 잘린다. cli.js 가 자체 process.exit 를 책임진다.
83
86
  await routeToCli();
84
87
  return;
85
88
  }
86
89
  await runClaudeLauncher();
90
+ // 대화형 세션 종료 후 fgx 프로세스 종료를 명시적으로 보장한다.
91
+ //
92
+ // WHY: spawnClaude 는 세션 종료 시 auto-compound-runner 를 detached + unref 로 띄우고
93
+ // 즉시 resolve 한다(설계상 비차단). 그런데 fgx 는 성공 경로에서 process.exit 를 호출하지
94
+ // 않고 Node 이벤트 루프 자연 배수에 의존해 왔다. 지금은 미해제 핸들이 없어 정상 종료하지만,
95
+ // 향후 post-session 경로에 닫히지 않은 핸들(SQLite 커넥션, 타이머, 소켓 등)이 하나라도
96
+ // 생기면 fgx 가 종료하지 못하고 셸 프롬프트가 돌아오지 않는다(= 터미널 물림). 명시 종료로
97
+ // 이 종류의 회귀를 원천 차단한다. detached 자식은 unref 되어 독립 실행되므로 백그라운드
98
+ // compound 는 영향받지 않고 계속된다.
99
+ process.exit(0);
87
100
  }
88
101
  main().catch((err) => {
89
102
  console.error('[forgen] Error:', err instanceof Error ? err.message : err);
package/dist/forge/cli.js CHANGED
@@ -12,6 +12,12 @@ import { loadProfile, profileExists } from '../store/profile-store.js';
12
12
  import { renderProfile } from '../renderer/inspect-renderer.js';
13
13
  import { runOnboarding } from './onboarding-cli.js';
14
14
  export async function handleForge(args) {
15
+ // W1-4 (feature-audit 2026-07-21): forge 를 개인화 단일 관문으로.
16
+ // --onboarding 은 프로필 유무와 무관하게 4질문 온보딩을 재실행 (구 `forgen onboarding`).
17
+ if (args.includes('--onboarding')) {
18
+ await runOnboarding();
19
+ return;
20
+ }
15
21
  if (args.includes('--profile')) {
16
22
  handleShowProfile();
17
23
  return;
@@ -36,7 +42,7 @@ export async function handleForge(args) {
36
42
  function handleShowProfile() {
37
43
  const profile = loadProfile();
38
44
  if (!profile) {
39
- console.log('\n No v1 profile found. Run `forgen forge` or `forgen onboarding`.\n');
45
+ console.log('\n No v1 profile found. Run `forgen forge` (or `forgen forge --onboarding` to re-run).\n');
40
46
  return;
41
47
  }
42
48
  console.log(`\n${renderProfile(profile)}\n`);
@@ -95,6 +101,6 @@ async function handleReset(level) {
95
101
  await runOnboarding();
96
102
  }
97
103
  else {
98
- console.log(' forgen forge 또는 forgen onboarding 으로 새 프로필을 생성하세요.\n');
104
+ console.log(' forgen forge (또는 forgen forge --onboarding) 으로 새 프로필을 생성하세요.\n');
99
105
  }
100
106
  }
@@ -10,6 +10,7 @@
10
10
  * 별도 모듈로 분리. 이유: (1) 테스트 가능성 (순수 함수), (2) false
11
11
  * positive 문제(action-plan §2.1)의 근본 수정에 명확한 책임 경계 필요.
12
12
  */
13
+ import { stripPrivate } from '../engine/private-filter.js';
13
14
  /** 주입 후 이 시간 내에 코드에 식별자가 출현해야 reflection으로 인정 */
14
15
  export const REFLECTION_WINDOW_MS = 15 * 60 * 1000; // 15분
15
16
  /**
@@ -52,8 +53,12 @@ export const COMMON_IDENTIFIERS = new Set([
52
53
  * 4. 매칭 비율 (유효 식별자의 50% 이상, 최소 1개)
53
54
  */
54
55
  export function isReflectionCandidate(input) {
55
- const { identifiers, code, injectedAt } = input;
56
+ const { identifiers, injectedAt } = input;
56
57
  const now = input.now ?? new Date();
58
+ // W2-5 (private 태그): 사용자가 <private> 로 표시한 코드 범위는 reflection 관측에서
59
+ // 제외한다. private 범위 안의 식별자 출현을 "솔루션이 반영됐다"는 신호로 세면
60
+ // 사용자가 학습 제외를 의도한 코드를 관측하는 셈이 되므로, 매칭 전에 스트립한다.
61
+ const code = stripPrivate(input.code ?? '').cleaned;
57
62
  // Gate 1: 코드 최소 길이
58
63
  if (!code || code.length < 10) {
59
64
  return { reflected: false, matchedCount: 0, eligibleCount: 0, reason: 'code-too-short' };