@wooojin/forgen 0.1.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 (268) hide show
  1. package/.claude-plugin/plugin.json +20 -0
  2. package/CHANGELOG.md +353 -0
  3. package/CONTRIBUTING.md +98 -0
  4. package/LICENSE +21 -0
  5. package/README.ja.md +469 -0
  6. package/README.ko.md +469 -0
  7. package/README.md +483 -0
  8. package/README.zh.md +469 -0
  9. package/agents/analyst.md +98 -0
  10. package/agents/architect.md +62 -0
  11. package/agents/code-reviewer.md +120 -0
  12. package/agents/code-simplifier.md +197 -0
  13. package/agents/critic.md +70 -0
  14. package/agents/debugger.md +117 -0
  15. package/agents/designer.md +131 -0
  16. package/agents/executor.md +54 -0
  17. package/agents/explore.md +145 -0
  18. package/agents/git-master.md +212 -0
  19. package/agents/performance-reviewer.md +172 -0
  20. package/agents/planner.md +29 -0
  21. package/agents/qa-tester.md +158 -0
  22. package/agents/refactoring-expert.md +168 -0
  23. package/agents/scientist.md +144 -0
  24. package/agents/security-reviewer.md +137 -0
  25. package/agents/test-engineer.md +153 -0
  26. package/agents/verifier.md +133 -0
  27. package/agents/writer.md +184 -0
  28. package/commands/api-design.md +268 -0
  29. package/commands/architecture-decision.md +314 -0
  30. package/commands/ci-cd.md +270 -0
  31. package/commands/code-review.md +233 -0
  32. package/commands/compound.md +117 -0
  33. package/commands/database.md +263 -0
  34. package/commands/debug-detective.md +99 -0
  35. package/commands/docker.md +274 -0
  36. package/commands/documentation.md +276 -0
  37. package/commands/ecomode.md +51 -0
  38. package/commands/frontend.md +271 -0
  39. package/commands/git-master.md +90 -0
  40. package/commands/incident-response.md +292 -0
  41. package/commands/migrate.md +101 -0
  42. package/commands/performance.md +288 -0
  43. package/commands/refactor.md +105 -0
  44. package/commands/security-review.md +288 -0
  45. package/commands/tdd.md +183 -0
  46. package/commands/testing-strategy.md +265 -0
  47. package/dist/cli.d.ts +2 -0
  48. package/dist/cli.js +295 -0
  49. package/dist/core/auto-compound-runner.d.ts +12 -0
  50. package/dist/core/auto-compound-runner.js +460 -0
  51. package/dist/core/config-hooks.d.ts +10 -0
  52. package/dist/core/config-hooks.js +112 -0
  53. package/dist/core/config-injector.d.ts +50 -0
  54. package/dist/core/config-injector.js +455 -0
  55. package/dist/core/doctor.d.ts +1 -0
  56. package/dist/core/doctor.js +163 -0
  57. package/dist/core/errors.d.ts +81 -0
  58. package/dist/core/errors.js +133 -0
  59. package/dist/core/global-config.d.ts +43 -0
  60. package/dist/core/global-config.js +25 -0
  61. package/dist/core/harness.d.ts +24 -0
  62. package/dist/core/harness.js +621 -0
  63. package/dist/core/init.d.ts +7 -0
  64. package/dist/core/init.js +37 -0
  65. package/dist/core/inspect-cli.d.ts +7 -0
  66. package/dist/core/inspect-cli.js +47 -0
  67. package/dist/core/legacy-detector.d.ts +33 -0
  68. package/dist/core/legacy-detector.js +66 -0
  69. package/dist/core/logger.d.ts +34 -0
  70. package/dist/core/logger.js +121 -0
  71. package/dist/core/mcp-config.d.ts +44 -0
  72. package/dist/core/mcp-config.js +177 -0
  73. package/dist/core/notepad.d.ts +31 -0
  74. package/dist/core/notepad.js +88 -0
  75. package/dist/core/paths.d.ts +85 -0
  76. package/dist/core/paths.js +101 -0
  77. package/dist/core/plugin-detector.d.ts +44 -0
  78. package/dist/core/plugin-detector.js +226 -0
  79. package/dist/core/runtime-detector.d.ts +8 -0
  80. package/dist/core/runtime-detector.js +49 -0
  81. package/dist/core/scope-resolver.d.ts +8 -0
  82. package/dist/core/scope-resolver.js +45 -0
  83. package/dist/core/session-logger.d.ts +6 -0
  84. package/dist/core/session-logger.js +111 -0
  85. package/dist/core/session-store.d.ts +28 -0
  86. package/dist/core/session-store.js +218 -0
  87. package/dist/core/settings-lock.d.ts +18 -0
  88. package/dist/core/settings-lock.js +125 -0
  89. package/dist/core/spawn.d.ts +3 -0
  90. package/dist/core/spawn.js +135 -0
  91. package/dist/core/types.d.ts +108 -0
  92. package/dist/core/types.js +1 -0
  93. package/dist/core/uninstall.d.ts +4 -0
  94. package/dist/core/uninstall.js +307 -0
  95. package/dist/core/v1-bootstrap.d.ts +26 -0
  96. package/dist/core/v1-bootstrap.js +155 -0
  97. package/dist/engine/compound-cli.d.ts +24 -0
  98. package/dist/engine/compound-cli.js +250 -0
  99. package/dist/engine/compound-extractor.d.ts +68 -0
  100. package/dist/engine/compound-extractor.js +860 -0
  101. package/dist/engine/compound-lifecycle.d.ts +32 -0
  102. package/dist/engine/compound-lifecycle.js +305 -0
  103. package/dist/engine/compound-loop.d.ts +32 -0
  104. package/dist/engine/compound-loop.js +511 -0
  105. package/dist/engine/match-eval-log.d.ts +139 -0
  106. package/dist/engine/match-eval-log.js +270 -0
  107. package/dist/engine/phrase-blocklist.d.ts +119 -0
  108. package/dist/engine/phrase-blocklist.js +208 -0
  109. package/dist/engine/skill-promoter.d.ts +20 -0
  110. package/dist/engine/skill-promoter.js +115 -0
  111. package/dist/engine/solution-format.d.ts +160 -0
  112. package/dist/engine/solution-format.js +432 -0
  113. package/dist/engine/solution-index.d.ts +13 -0
  114. package/dist/engine/solution-index.js +252 -0
  115. package/dist/engine/solution-matcher.d.ts +364 -0
  116. package/dist/engine/solution-matcher.js +656 -0
  117. package/dist/engine/solution-writer.d.ts +76 -0
  118. package/dist/engine/solution-writer.js +157 -0
  119. package/dist/engine/term-matcher.d.ts +81 -0
  120. package/dist/engine/term-matcher.js +268 -0
  121. package/dist/engine/term-normalizer.d.ts +116 -0
  122. package/dist/engine/term-normalizer.js +171 -0
  123. package/dist/fgx.d.ts +6 -0
  124. package/dist/fgx.js +42 -0
  125. package/dist/forge/cli.d.ts +11 -0
  126. package/dist/forge/cli.js +100 -0
  127. package/dist/forge/evidence-processor.d.ts +21 -0
  128. package/dist/forge/evidence-processor.js +87 -0
  129. package/dist/forge/mismatch-detector.d.ts +44 -0
  130. package/dist/forge/mismatch-detector.js +83 -0
  131. package/dist/forge/onboarding-cli.d.ts +6 -0
  132. package/dist/forge/onboarding-cli.js +89 -0
  133. package/dist/forge/onboarding.d.ts +25 -0
  134. package/dist/forge/onboarding.js +122 -0
  135. package/dist/hooks/compound-reflection.d.ts +45 -0
  136. package/dist/hooks/compound-reflection.js +82 -0
  137. package/dist/hooks/context-guard.d.ts +24 -0
  138. package/dist/hooks/context-guard.js +156 -0
  139. package/dist/hooks/dangerous-patterns.json +18 -0
  140. package/dist/hooks/db-guard.d.ts +17 -0
  141. package/dist/hooks/db-guard.js +105 -0
  142. package/dist/hooks/hook-config.d.ts +29 -0
  143. package/dist/hooks/hook-config.js +92 -0
  144. package/dist/hooks/hook-registry.d.ts +43 -0
  145. package/dist/hooks/hook-registry.js +31 -0
  146. package/dist/hooks/hooks-generator.d.ts +49 -0
  147. package/dist/hooks/hooks-generator.js +99 -0
  148. package/dist/hooks/intent-classifier.d.ts +12 -0
  149. package/dist/hooks/intent-classifier.js +62 -0
  150. package/dist/hooks/keyword-detector.d.ts +25 -0
  151. package/dist/hooks/keyword-detector.js +389 -0
  152. package/dist/hooks/notepad-injector.d.ts +18 -0
  153. package/dist/hooks/notepad-injector.js +51 -0
  154. package/dist/hooks/permission-handler.d.ts +14 -0
  155. package/dist/hooks/permission-handler.js +114 -0
  156. package/dist/hooks/post-tool-failure.d.ts +11 -0
  157. package/dist/hooks/post-tool-failure.js +118 -0
  158. package/dist/hooks/post-tool-handlers.d.ts +17 -0
  159. package/dist/hooks/post-tool-handlers.js +115 -0
  160. package/dist/hooks/post-tool-use.d.ts +29 -0
  161. package/dist/hooks/post-tool-use.js +151 -0
  162. package/dist/hooks/pre-compact.d.ts +10 -0
  163. package/dist/hooks/pre-compact.js +165 -0
  164. package/dist/hooks/pre-tool-use.d.ts +31 -0
  165. package/dist/hooks/pre-tool-use.js +325 -0
  166. package/dist/hooks/prompt-injection-filter.d.ts +56 -0
  167. package/dist/hooks/prompt-injection-filter.js +287 -0
  168. package/dist/hooks/rate-limiter.d.ts +21 -0
  169. package/dist/hooks/rate-limiter.js +86 -0
  170. package/dist/hooks/secret-filter.d.ts +14 -0
  171. package/dist/hooks/secret-filter.js +65 -0
  172. package/dist/hooks/session-recovery.d.ts +27 -0
  173. package/dist/hooks/session-recovery.js +406 -0
  174. package/dist/hooks/shared/atomic-write.d.ts +41 -0
  175. package/dist/hooks/shared/atomic-write.js +148 -0
  176. package/dist/hooks/shared/context-budget.d.ts +37 -0
  177. package/dist/hooks/shared/context-budget.js +45 -0
  178. package/dist/hooks/shared/file-lock.d.ts +56 -0
  179. package/dist/hooks/shared/file-lock.js +253 -0
  180. package/dist/hooks/shared/hook-response.d.ts +33 -0
  181. package/dist/hooks/shared/hook-response.js +62 -0
  182. package/dist/hooks/shared/injection-caps.d.ts +39 -0
  183. package/dist/hooks/shared/injection-caps.js +52 -0
  184. package/dist/hooks/shared/plugin-signal.d.ts +23 -0
  185. package/dist/hooks/shared/plugin-signal.js +104 -0
  186. package/dist/hooks/shared/read-stdin.d.ts +8 -0
  187. package/dist/hooks/shared/read-stdin.js +63 -0
  188. package/dist/hooks/shared/sanitize-id.d.ts +7 -0
  189. package/dist/hooks/shared/sanitize-id.js +9 -0
  190. package/dist/hooks/shared/sanitize.d.ts +7 -0
  191. package/dist/hooks/shared/sanitize.js +22 -0
  192. package/dist/hooks/skill-injector.d.ts +38 -0
  193. package/dist/hooks/skill-injector.js +285 -0
  194. package/dist/hooks/slop-detector.d.ts +18 -0
  195. package/dist/hooks/slop-detector.js +93 -0
  196. package/dist/hooks/solution-injector.d.ts +58 -0
  197. package/dist/hooks/solution-injector.js +436 -0
  198. package/dist/hooks/subagent-tracker.d.ts +10 -0
  199. package/dist/hooks/subagent-tracker.js +90 -0
  200. package/dist/i18n/index.d.ts +43 -0
  201. package/dist/i18n/index.js +224 -0
  202. package/dist/lib.d.ts +14 -0
  203. package/dist/lib.js +14 -0
  204. package/dist/mcp/server.d.ts +8 -0
  205. package/dist/mcp/server.js +40 -0
  206. package/dist/mcp/solution-reader.d.ts +90 -0
  207. package/dist/mcp/solution-reader.js +273 -0
  208. package/dist/mcp/tools.d.ts +16 -0
  209. package/dist/mcp/tools.js +302 -0
  210. package/dist/preset/facet-catalog.d.ts +17 -0
  211. package/dist/preset/facet-catalog.js +46 -0
  212. package/dist/preset/preset-manager.d.ts +31 -0
  213. package/dist/preset/preset-manager.js +111 -0
  214. package/dist/renderer/inspect-renderer.d.ts +11 -0
  215. package/dist/renderer/inspect-renderer.js +123 -0
  216. package/dist/renderer/rule-renderer.d.ts +18 -0
  217. package/dist/renderer/rule-renderer.js +159 -0
  218. package/dist/store/evidence-store.d.ts +23 -0
  219. package/dist/store/evidence-store.js +58 -0
  220. package/dist/store/profile-store.d.ts +12 -0
  221. package/dist/store/profile-store.js +53 -0
  222. package/dist/store/recommendation-store.d.ts +22 -0
  223. package/dist/store/recommendation-store.js +64 -0
  224. package/dist/store/rule-store.d.ts +22 -0
  225. package/dist/store/rule-store.js +62 -0
  226. package/dist/store/session-state-store.d.ts +11 -0
  227. package/dist/store/session-state-store.js +44 -0
  228. package/dist/store/types.d.ts +159 -0
  229. package/dist/store/types.js +7 -0
  230. package/hooks/hook-registry.json +21 -0
  231. package/hooks/hooks.json +185 -0
  232. package/package.json +89 -0
  233. package/plugin.json +20 -0
  234. package/scripts/postinstall.js +826 -0
  235. package/skills/api-design/SKILL.md +262 -0
  236. package/skills/architecture-decision/SKILL.md +309 -0
  237. package/skills/ci-cd/SKILL.md +264 -0
  238. package/skills/code-review/SKILL.md +228 -0
  239. package/skills/compound/SKILL.md +101 -0
  240. package/skills/database/SKILL.md +257 -0
  241. package/skills/debug-detective/SKILL.md +95 -0
  242. package/skills/docker/SKILL.md +268 -0
  243. package/skills/documentation/SKILL.md +270 -0
  244. package/skills/ecomode/SKILL.md +46 -0
  245. package/skills/frontend/SKILL.md +265 -0
  246. package/skills/git-master/SKILL.md +86 -0
  247. package/skills/incident-response/SKILL.md +286 -0
  248. package/skills/migrate/SKILL.md +96 -0
  249. package/skills/performance/SKILL.md +282 -0
  250. package/skills/refactor/SKILL.md +100 -0
  251. package/skills/security-review/SKILL.md +282 -0
  252. package/skills/tdd/SKILL.md +178 -0
  253. package/skills/testing-strategy/SKILL.md +260 -0
  254. package/starter-pack/solutions/starter-api-error-responses.md +37 -0
  255. package/starter-pack/solutions/starter-async-patterns.md +40 -0
  256. package/starter-pack/solutions/starter-caching-strategy.md +40 -0
  257. package/starter-pack/solutions/starter-code-review-checklist.md +39 -0
  258. package/starter-pack/solutions/starter-debugging-systematic.md +40 -0
  259. package/starter-pack/solutions/starter-dependency-injection.md +40 -0
  260. package/starter-pack/solutions/starter-error-handling-patterns.md +38 -0
  261. package/starter-pack/solutions/starter-git-atomic-commits.md +36 -0
  262. package/starter-pack/solutions/starter-input-validation.md +40 -0
  263. package/starter-pack/solutions/starter-n-plus-one-queries.md +37 -0
  264. package/starter-pack/solutions/starter-refactor-safely.md +38 -0
  265. package/starter-pack/solutions/starter-secret-management.md +37 -0
  266. package/starter-pack/solutions/starter-separation-of-concerns.md +36 -0
  267. package/starter-pack/solutions/starter-tdd-red-green-refactor.md +40 -0
  268. package/starter-pack/solutions/starter-typescript-strict-types.md +39 -0
@@ -0,0 +1,860 @@
1
+ /**
2
+ * Forgen — Compound Knowledge Extractor
3
+ *
4
+ * Extracts reusable patterns and decisions from git history and session context.
5
+ * Runs quality gates (structure, toxicity, trivial, dedup) before persisting solutions.
6
+ *
7
+ * Module Structure:
8
+ * - Lines 1-50: Imports, constants, SHA validation, LastExtraction/ExtractedSolution interfaces
9
+ * - Lines 50-115: Git helpers — getNewCommits, getCommitMessages, getGitDiff, getDiffStats
10
+ * - Lines 115-190: Quality Gates — gate0 (worth extracting), gate1 (structure), gate2 (toxicity),
11
+ * gateTrivial (trivial rejection), gate3 (dedup)
12
+ * - Lines 190-275: extractFromDiff — pattern extraction from git diff (modules, errors, imports, commits)
13
+ * - Lines 275-395: extractFromSessionContext — prompt/write history analysis (actions, hotspots, tech)
14
+ * - Lines 396-475: saveExtractedSolution, updateReExtractedCounter — solution persistence
15
+ * - Lines 477-555: runExtraction — main entry point orchestrating gates + extraction + state
16
+ * - Lines 557-634: processExtractionResults, isExtractionPaused, pauseExtraction, resumeExtraction
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { execFileSync } from 'node:child_process';
21
+ import { serializeSolutionV3, DEFAULT_EVIDENCE, extractTags } from './solution-format.js';
22
+ import { createLogger } from '../core/logger.js';
23
+ const log = createLogger('compound-extractor');
24
+ import { CLAUDE_DIR, ME_SOLUTIONS, STATE_DIR } from '../core/paths.js';
25
+ import { atomicWriteJSON, atomicWriteText } from '../hooks/shared/atomic-write.js';
26
+ import { mutateSolutionFile } from './solution-writer.js';
27
+ const LAST_EXTRACTION_PATH = path.join(STATE_DIR, 'last-extraction.json');
28
+ const MAX_EXTRACTIONS_PER_DAY = 5;
29
+ const MAX_DIFF_LENGTH = 3000;
30
+ /** Validate that a string is a valid git SHA (7-64 hex chars) */
31
+ function isValidSha(sha) {
32
+ return /^[a-f0-9]{7,64}$/.test(sha);
33
+ }
34
+ /** Load last extraction state */
35
+ function loadLastExtraction() {
36
+ try {
37
+ if (fs.existsSync(LAST_EXTRACTION_PATH)) {
38
+ return JSON.parse(fs.readFileSync(LAST_EXTRACTION_PATH, 'utf-8'));
39
+ }
40
+ }
41
+ catch (e) {
42
+ log.debug('last extraction state read failed — may cause duplicate extractions', e);
43
+ }
44
+ return { lastCommitSha: '', lastExtractedAt: '', extractionsToday: 0, todayDate: '' };
45
+ }
46
+ /** Save last extraction state */
47
+ function saveLastExtraction(state) {
48
+ fs.mkdirSync(STATE_DIR, { recursive: true });
49
+ atomicWriteJSON(LAST_EXTRACTION_PATH, state);
50
+ }
51
+ /** Get new commits since last extraction — uses execFileSync to prevent injection */
52
+ function getNewCommits(cwd, lastSha) {
53
+ try {
54
+ if (!lastSha || !isValidSha(lastSha)) {
55
+ return execFileSync('git', ['log', '--oneline', '-5'], { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
56
+ }
57
+ return execFileSync('git', ['log', '--oneline', `${lastSha}..HEAD`], { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
58
+ }
59
+ catch {
60
+ return '';
61
+ }
62
+ }
63
+ /** Get commit messages for "why" context enrichment */
64
+ function getCommitMessages(cwd, lastSha) {
65
+ try {
66
+ const args = lastSha && isValidSha(lastSha)
67
+ ? ['log', '--format=%B', `${lastSha}..HEAD`]
68
+ : ['log', '--format=%B', '-5'];
69
+ const msgs = execFileSync('git', args, { cwd, encoding: 'utf-8', timeout: 5000 });
70
+ return msgs.slice(0, 1000).trim();
71
+ }
72
+ catch {
73
+ return '';
74
+ }
75
+ }
76
+ /** Get git diff for extraction */
77
+ function getGitDiff(cwd, lastSha) {
78
+ try {
79
+ const args = lastSha && isValidSha(lastSha)
80
+ ? ['diff', `${lastSha}..HEAD`]
81
+ : ['diff', 'HEAD~1'];
82
+ const diff = execFileSync('git', args, { cwd, encoding: 'utf-8', timeout: 10000 });
83
+ return diff.slice(0, MAX_DIFF_LENGTH);
84
+ }
85
+ catch {
86
+ return '';
87
+ }
88
+ }
89
+ /** Get diff stats for Gate 0 */
90
+ function getDiffStats(cwd, lastSha) {
91
+ try {
92
+ const args = lastSha && isValidSha(lastSha)
93
+ ? ['diff', '--stat', `${lastSha}..HEAD`]
94
+ : ['diff', '--stat', 'HEAD~1'];
95
+ const stat = execFileSync('git', args, { cwd, encoding: 'utf-8', timeout: 5000 });
96
+ const lines = stat.split('\n').filter(l => l.trim());
97
+ const codeExts = /\.(ts|tsx|js|jsx|py|rs|go|java|rb|c|cpp|h|swift|kt)$/;
98
+ const hasCodeFiles = lines.some(line => {
99
+ const filePath = line.split('|')[0]?.trim() ?? '';
100
+ return codeExts.test(filePath);
101
+ });
102
+ const lastLine = lines[lines.length - 1] ?? '';
103
+ const changedMatch = lastLine.match(/(\d+)\s+files?\s+changed/);
104
+ const insertMatch = lastLine.match(/(\d+)\s+insertion/);
105
+ const deleteMatch = lastLine.match(/(\d+)\s+deletion/);
106
+ const fileCount = parseInt(changedMatch?.[1] ?? '0', 10);
107
+ const lineCount = parseInt(insertMatch?.[1] ?? '0', 10) + parseInt(deleteMatch?.[1] ?? '0', 10);
108
+ return { files: fileCount, lines: lineCount, hasCodeFiles };
109
+ }
110
+ catch {
111
+ return { files: 0, lines: 0, hasCodeFiles: false };
112
+ }
113
+ }
114
+ // --- Blocklist for Gate 2 (Toxicity Filter) ---
115
+ const TOXICITY_PATTERNS = [
116
+ /@ts-ignore/i, /@ts-nocheck/i, /as\s+any\b/i,
117
+ /--force\b/i, /--no-verify\b/i, /--skip-ci\b/i,
118
+ /eslint-disable/i, /prettier-ignore/i, /noqa/i,
119
+ /\bTODO:/i, /\bFIXME:/i, /\bHACK:/i, /\bXXX:/i,
120
+ /\/Users\//i, /\/home\//i, /C:\\\\Users/i,
121
+ ];
122
+ // --- Quality Gates ---
123
+ /** Gate 0: Is this extraction worth doing? */
124
+ function gate0(stats) {
125
+ if (stats.files < 1)
126
+ return false;
127
+ if (stats.lines < 30)
128
+ return false;
129
+ if (!stats.hasCodeFiles)
130
+ return false;
131
+ return true;
132
+ }
133
+ /** Gate 1: Structural validation (pure — does not mutate input) */
134
+ function gate1(sol) {
135
+ if (!sol.name || sol.name.length < 3)
136
+ return false;
137
+ if (!sol.tags || sol.tags.length === 0)
138
+ return false;
139
+ if (!sol.content || sol.content.length < 50)
140
+ return false;
141
+ if (!sol.context)
142
+ return false;
143
+ return true;
144
+ }
145
+ /** Gate 2: Toxicity filter */
146
+ function gate2(sol) {
147
+ const text = `${sol.context} ${sol.content}`;
148
+ return !TOXICITY_PATTERNS.some(p => p.test(text));
149
+ }
150
+ /**
151
+ * Gate 2.5: Trivial pattern rejection — 자명한 패턴은 축적할 가치 없음.
152
+ * "주로 TypeScript를 작성합니다" 수준의 솔루션은 Claude가 코드를 보면 알 수 있으므로
153
+ * compound에 저장하면 컨텍스트만 낭비됨.
154
+ */
155
+ function gateTrivial(sol) {
156
+ const content = sol.content.trim();
157
+ // 내용이 너무 짧으면 자명함 (한 줄짜리)
158
+ if (content.length < 80)
159
+ return false;
160
+ // "주로 X를 Y합니다" 패턴
161
+ if (/^주로\s/.test(content) && content.split('\n').length < 3)
162
+ return false;
163
+ // 식별자가 하나도 없으면 구체적인 기술 패턴이 아님
164
+ if (sol.identifiers.length === 0 && sol.tags.length < 3)
165
+ return false;
166
+ return true;
167
+ }
168
+ /** Gate 3: Dedup check against existing solutions */
169
+ function gate3(sol) {
170
+ if (!fs.existsSync(ME_SOLUTIONS))
171
+ return 'new';
172
+ try {
173
+ const files = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md'));
174
+ for (const file of files) {
175
+ const content = fs.readFileSync(path.join(ME_SOLUTIONS, file), 'utf-8');
176
+ const tagMatch = content.match(/tags:\s*\[([^\]]*)\]/);
177
+ if (!tagMatch)
178
+ continue;
179
+ const existingTags = tagMatch[1].split(',').map(t => t.trim().replace(/"/g, ''));
180
+ const overlap = sol.tags.filter(t => existingTags.includes(t));
181
+ const overlapRatio = overlap.length / Math.max(sol.tags.length, existingTags.length, 1);
182
+ if (overlapRatio >= 0.7) {
183
+ if (content.includes('status: "experiment"') || content.includes("status: 'experiment'") || content.includes('status: experiment')) {
184
+ return 're-extract';
185
+ }
186
+ return 'duplicate';
187
+ }
188
+ }
189
+ }
190
+ catch (e) {
191
+ log.debug('gate3 기존 솔루션 파일 읽기 실패 — new로 간주', e);
192
+ }
193
+ return 'new';
194
+ }
195
+ /** Simple local extraction from git diff (no LLM needed) */
196
+ function extractFromDiff(gitLog, gitDiff) {
197
+ const solutions = [];
198
+ // 1. Detect new files/modules created
199
+ const newFiles = gitDiff.match(/^\+\+\+ b\/(.+)$/gm);
200
+ if (newFiles && newFiles.length >= 2) {
201
+ const fileNames = newFiles.map(f => f.replace('+++ b/', ''));
202
+ const ext = path.extname(fileNames[0]);
203
+ const dir = path.dirname(fileNames[0]).split('/').pop() ?? '';
204
+ if (ext && dir) {
205
+ const basenames = fileNames.map(f => path.basename(f, ext));
206
+ const commonPrefix = findCommonPrefix(basenames);
207
+ if (commonPrefix.length >= 3) {
208
+ solutions.push({
209
+ name: `module-${commonPrefix}-pattern`,
210
+ type: 'pattern',
211
+ tags: extractTags(`${fileNames.join(' ')} ${dir}`),
212
+ identifiers: basenames.filter(b => b.length >= 4).slice(0, 5),
213
+ context: `File organization pattern in ${dir}/`,
214
+ content: `Files follow the naming pattern: ${commonPrefix}*${ext} in ${dir}/`,
215
+ });
216
+ }
217
+ }
218
+ }
219
+ // 2. Detect error handling patterns from diff
220
+ const errorPatterns = gitDiff.match(/^\+.*(?:try\s*\{|catch\s*[({]|\.catch\(|throw new|Error\()/gm);
221
+ if (errorPatterns && errorPatterns.length >= 3) {
222
+ const sample = errorPatterns.slice(0, 3).map(l => l.replace(/^\+\s*/, '').trim());
223
+ solutions.push({
224
+ name: 'error-handling-pattern',
225
+ type: 'pattern',
226
+ tags: ['error', 'handling', 'try-catch', 'pattern'],
227
+ identifiers: sample.filter(s => s.length >= 4).slice(0, 3),
228
+ context: 'Error handling approach used in this codebase',
229
+ content: `Consistent error handling: ${sample.join('; ')}`.slice(0, 500),
230
+ });
231
+ }
232
+ // 3. Detect import/dependency patterns
233
+ const imports = gitDiff.match(/^\+\s*import\s+.+from\s+['"]([^'"]+)['"]/gm);
234
+ if (imports && imports.length >= 3) {
235
+ const packages = imports
236
+ .map(i => i.match(/from\s+['"]([^'"]+)['"]/)?.[1])
237
+ .filter((p) => !!p && !p.startsWith('.'))
238
+ .filter((v, i, a) => a.indexOf(v) === i);
239
+ if (packages.length >= 2) {
240
+ solutions.push({
241
+ name: 'dependency-stack',
242
+ type: 'decision',
243
+ tags: ['dependency', 'stack', ...packages.slice(0, 3)],
244
+ identifiers: packages.filter(p => p.length >= 4).slice(0, 5),
245
+ context: 'Technology stack and dependency choices',
246
+ content: `Project uses: ${packages.join(', ')}`,
247
+ });
248
+ }
249
+ }
250
+ // 4. Detect from commit messages
251
+ const commitKeywords = {
252
+ 'fix': { type: 'troubleshoot', tags: ['bugfix', 'troubleshoot'] },
253
+ 'refactor': { type: 'pattern', tags: ['refactor', 'cleanup'] },
254
+ 'test': { type: 'pattern', tags: ['testing', 'tdd'] },
255
+ 'security': { type: 'pattern', tags: ['security', 'hardening'] },
256
+ };
257
+ for (const [keyword, meta] of Object.entries(commitKeywords)) {
258
+ const re = new RegExp(`^[a-f0-9]+\\s+${keyword}[:\\s](.+)$`, 'gim');
259
+ const matches = [...gitLog.matchAll(re)];
260
+ if (matches.length >= 2) {
261
+ const descriptions = matches.map(m => m[1].trim()).slice(0, 3);
262
+ // commit 메시지에서 identifier 후보 추출 (camelCase, PascalCase, snake_case, 6자 이상)
263
+ const commitIdentifiers = descriptions
264
+ .join(' ')
265
+ .match(/\b[a-zA-Z][a-zA-Z0-9]*(?:[A-Z][a-z]+)+\b|\b[a-z]+(?:_[a-z]+)+\b/g)
266
+ ?.filter(id => id.length >= 6)
267
+ ?.filter((v, i, a) => a.indexOf(v) === i)
268
+ ?.slice(0, 5) ?? [];
269
+ solutions.push({
270
+ name: `${keyword}-pattern`,
271
+ type: meta.type,
272
+ tags: [...meta.tags, keyword],
273
+ identifiers: commitIdentifiers,
274
+ context: `Recurring ${keyword} pattern from commit history`,
275
+ content: descriptions.join('. ').slice(0, 500),
276
+ });
277
+ }
278
+ }
279
+ return solutions.slice(0, 3); // max 3
280
+ }
281
+ /** Extract patterns from accumulated session context (prompts + writes + diff) */
282
+ function extractFromSessionContext(gitDiff, cwd, lastExtractedAt) {
283
+ const solutions = [];
284
+ const claudeContext = loadClaudeProjectSessionContext(cwd, lastExtractedAt);
285
+ // Load recent prompts (still consumed by tech-stack-decision below).
286
+ let prompts = claudeContext.prompts;
287
+ if (prompts.length === 0) {
288
+ prompts = loadPromptHistoryFallback();
289
+ }
290
+ // C4 removal (2026-04-09): `recurring-task-pattern` and `modification-
291
+ // hotspot` extractors were deleted here. They produced word-frequency
292
+ // histograms and directory counts masquerading as "patterns", with
293
+ // generic "consider automating/refactoring" advice that applied to
294
+ // any project. Observed in production: one `recurring-task-pattern`
295
+ // solution whose entire content was `User frequently requests:
296
+ // test(39회), 테스트(36회), 추가(32회). Consider automating...` was
297
+ // injected into 105 sessions before the quality problem was spotted.
298
+ // The `extractFromSessionContext` function now only emits the
299
+ // tech-stack-decision pattern below, which at least cross-validates
300
+ // against both prompts and diff before writing. If session-level
301
+ // extraction needs to come back, the replacement MUST pass a
302
+ // content-level sniff test: does the extracted solution teach
303
+ // something a new developer wouldn't already infer from `git log`?
304
+ //
305
+ // M-1 (review follow-up): the `writes` loader was removed from this
306
+ // function as dead code. Pre-M-1 `loadWriteHistoryFallback()` ran on
307
+ // every extraction, touching the filesystem to load data no
308
+ // downstream extractor consumed. If a future extractor needs writes,
309
+ // restore `claudeContext.writes` (already loaded above) or reintroduce
310
+ // the fallback loader at that point.
311
+ // 3. Detect decision patterns from prompt + diff correlation
312
+ // When user asks about X and diff shows Y, the decision is "for X, use Y"
313
+ const techDecisions = [];
314
+ const techTerms = ['react', 'vue', 'next', 'express', 'fastify', 'prisma', 'drizzle', 'zustand', 'redux', 'tailwind', 'styled', 'vitest', 'jest', 'playwright', 'cypress'];
315
+ for (const term of techTerms) {
316
+ const inPrompts = prompts.some(p => p.toLowerCase().includes(term));
317
+ const inDiff = gitDiff.toLowerCase().includes(term);
318
+ if (inPrompts && inDiff) {
319
+ techDecisions.push(term);
320
+ }
321
+ }
322
+ if (techDecisions.length >= 2) {
323
+ solutions.push({
324
+ name: 'tech-stack-decision',
325
+ type: 'decision',
326
+ tags: ['stack', 'technology', ...techDecisions.slice(0, 5)],
327
+ identifiers: techDecisions.filter(t => t.length >= 4).slice(0, 5),
328
+ context: 'Technology choices confirmed by both discussion and implementation',
329
+ content: `Active technology stack: ${techDecisions.join(', ')}. Both discussed in prompts and present in code changes.`,
330
+ });
331
+ }
332
+ return solutions;
333
+ }
334
+ function normalizeProjectPath(cwd) {
335
+ const resolved = path.resolve(cwd);
336
+ try {
337
+ return typeof fs.realpathSync.native === 'function'
338
+ ? fs.realpathSync.native(resolved)
339
+ : fs.realpathSync(resolved);
340
+ }
341
+ catch {
342
+ return resolved;
343
+ }
344
+ }
345
+ function getProjectPathCandidates(cwd) {
346
+ const resolved = path.resolve(cwd);
347
+ const candidates = new Set([resolved, normalizeProjectPath(cwd)]);
348
+ try {
349
+ if (fs.lstatSync(resolved).isSymbolicLink()) {
350
+ candidates.add(path.resolve(path.dirname(resolved), fs.readlinkSync(resolved)));
351
+ }
352
+ }
353
+ catch {
354
+ // Ignore lstat/readlink failures; raw + realpath candidates are enough.
355
+ }
356
+ for (const candidate of [...candidates]) {
357
+ candidates.add(normalizeProjectPath(candidate));
358
+ }
359
+ return [...candidates];
360
+ }
361
+ function getClaudeProjectDirs(cwd) {
362
+ return getProjectPathCandidates(cwd)
363
+ .map(candidate => path.join(CLAUDE_DIR, 'projects', candidate.replace(/[:\\/]/g, '-')));
364
+ }
365
+ function listClaudeSessionFiles(projectDirs, maxFiles) {
366
+ // Symlink hardening (INFO from security review, 2026-04-09):
367
+ // `~/.claude/projects/` is inside the user's HOME so in the normal
368
+ // threat model it's trusted, but we mirror the `solution-index.ts:135`
369
+ // defensive posture and refuse to follow symlinks. A local attacker
370
+ // with HOME write access could otherwise plant a symlink pointing at
371
+ // arbitrary JSONL files on disk and cause `collectClaudeProjectSessionContext`
372
+ // to ingest their contents as "Claude session prompts".
373
+ return projectDirs
374
+ .flatMap(projectDir => {
375
+ let entries;
376
+ try {
377
+ entries = fs.readdirSync(projectDir);
378
+ }
379
+ catch {
380
+ return [];
381
+ }
382
+ const out = [];
383
+ for (const file of entries) {
384
+ if (!file.endsWith('.jsonl'))
385
+ continue;
386
+ const filePath = path.join(projectDir, file);
387
+ try {
388
+ if (fs.lstatSync(filePath).isSymbolicLink())
389
+ continue;
390
+ out.push({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs });
391
+ }
392
+ catch {
393
+ // unreadable / vanished between readdir and stat — skip
394
+ }
395
+ }
396
+ return out;
397
+ })
398
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
399
+ .slice(0, maxFiles);
400
+ }
401
+ function getAllClaudeProjectDirs() {
402
+ const projectsRoot = path.join(CLAUDE_DIR, 'projects');
403
+ if (!fs.existsSync(projectsRoot))
404
+ return [];
405
+ return fs.readdirSync(projectsRoot)
406
+ .map(name => path.join(projectsRoot, name))
407
+ .filter(dir => {
408
+ try {
409
+ return fs.statSync(dir).isDirectory();
410
+ }
411
+ catch {
412
+ return false;
413
+ }
414
+ });
415
+ }
416
+ function collectClaudeProjectSessionContext(files, cwdCandidates, cutoffMs) {
417
+ const prompts = [];
418
+ const writes = [];
419
+ for (const file of files) {
420
+ // Defense in depth: even though listClaudeSessionFiles already
421
+ // rejects symlinks, re-check here in case a caller bypasses the
422
+ // lister. A TOCTOU race between lister's lstat and this read is
423
+ // theoretically possible but requires local HOME write access,
424
+ // at which point the attacker already has easier vectors.
425
+ try {
426
+ if (fs.lstatSync(file.filePath).isSymbolicLink())
427
+ continue;
428
+ }
429
+ catch {
430
+ continue;
431
+ }
432
+ let lines;
433
+ try {
434
+ lines = fs.readFileSync(file.filePath, 'utf-8').split('\n').filter(Boolean);
435
+ }
436
+ catch {
437
+ continue;
438
+ }
439
+ for (const line of lines) {
440
+ let entry;
441
+ try {
442
+ entry = JSON.parse(line);
443
+ }
444
+ catch {
445
+ continue;
446
+ }
447
+ const entryCandidates = typeof entry.cwd === 'string' ? getProjectPathCandidates(entry.cwd) : [];
448
+ if (!entryCandidates.some(candidate => cwdCandidates.has(candidate)))
449
+ continue;
450
+ const timestamp = typeof entry.timestamp === 'string' ? new Date(entry.timestamp).getTime() : Number.NaN;
451
+ if (cutoffMs && Number.isFinite(timestamp) && timestamp <= cutoffMs)
452
+ continue;
453
+ if (entry.type === 'user') {
454
+ const message = entry.message;
455
+ if (message?.role === 'user' && typeof message.content === 'string') {
456
+ prompts.push(message.content);
457
+ }
458
+ continue;
459
+ }
460
+ if (entry.type !== 'assistant')
461
+ continue;
462
+ const message = entry.message;
463
+ if (message?.role !== 'assistant' || !Array.isArray(message.content))
464
+ continue;
465
+ for (const item of message.content) {
466
+ if (typeof item !== 'object' || item === null)
467
+ continue;
468
+ const toolUse = item;
469
+ if (toolUse.type !== 'tool_use')
470
+ continue;
471
+ if (toolUse.name !== 'Write' && toolUse.name !== 'Edit')
472
+ continue;
473
+ const filePath = String(toolUse.input?.file_path ?? toolUse.input?.filePath ?? '');
474
+ const content = String(toolUse.input?.content ?? toolUse.input?.new_string ?? '');
475
+ if (!filePath || !content)
476
+ continue;
477
+ writes.push({
478
+ filePath: filePath.slice(-100),
479
+ contentSnippet: content.slice(0, 200),
480
+ fileExtension: path.extname(filePath).toLowerCase(),
481
+ });
482
+ }
483
+ }
484
+ }
485
+ return {
486
+ prompts: prompts.slice(-50),
487
+ writes: writes.slice(-30),
488
+ };
489
+ }
490
+ function loadPromptHistoryFallback() {
491
+ const promptHistoryPath = path.join(STATE_DIR, 'prompt-history.jsonl');
492
+ try {
493
+ if (!fs.existsSync(promptHistoryPath))
494
+ return [];
495
+ const lines = fs.readFileSync(promptHistoryPath, 'utf-8').split('\n').filter(Boolean);
496
+ return lines.slice(-50).map(l => {
497
+ try {
498
+ return JSON.parse(l).prompt;
499
+ }
500
+ catch {
501
+ return '';
502
+ }
503
+ }).filter(Boolean);
504
+ }
505
+ catch (e) {
506
+ log.debug('prompt-history.jsonl 읽기 실패 — session context fallback 건너뜀', e);
507
+ return [];
508
+ }
509
+ }
510
+ // M-1 follow-up (2026-04-09): `loadWriteHistoryFallback` was removed
511
+ // alongside the C4 extractor cleanup. Its only caller was the now-deleted
512
+ // session-context loader for writes, so keeping it would be dead code on
513
+ // the extraction hot path (per-session I/O against a file that nobody
514
+ // reads). If a future write-based extractor is reintroduced, either
515
+ // restore this loader or call it via `claudeContext.writes` once session
516
+ // correlation picks writes up.
517
+ /**
518
+ * Load Claude session prompts + writes correlated to `cwd`.
519
+ *
520
+ * Exported primarily for test assertions (the `claude-session-context`
521
+ * tests need to verify that correlation picks the right project's
522
+ * sessions and ignores unrelated ones). Before C4 the tests could
523
+ * observe this indirectly via the now-removed `recurring-task-pattern`
524
+ * extractor; now they check this loader directly. Not intended for
525
+ * production callers outside the extractor pipeline.
526
+ */
527
+ export function loadClaudeProjectSessionContext(cwd, lastExtractedAt) {
528
+ const cwdCandidates = new Set(getProjectPathCandidates(cwd));
529
+ const projectDirs = getClaudeProjectDirs(cwd).filter(dir => fs.existsSync(dir));
530
+ const cutoffMs = lastExtractedAt ? new Date(lastExtractedAt).getTime() : 0;
531
+ try {
532
+ if (projectDirs.length > 0) {
533
+ const primary = collectClaudeProjectSessionContext(listClaudeSessionFiles(projectDirs, 5), cwdCandidates, cutoffMs);
534
+ if (primary.prompts.length > 0 || primary.writes.length > 0)
535
+ return primary;
536
+ }
537
+ const fallbackDirs = getAllClaudeProjectDirs().filter(dir => !projectDirs.includes(dir));
538
+ if (fallbackDirs.length === 0)
539
+ return { prompts: [], writes: [] };
540
+ return collectClaudeProjectSessionContext(listClaudeSessionFiles(fallbackDirs, 20), cwdCandidates, cutoffMs);
541
+ }
542
+ catch (e) {
543
+ log.debug('Claude project session context 로드 실패 — fallback 사용', e);
544
+ return { prompts: [], writes: [] };
545
+ }
546
+ }
547
+ function findCommonPrefix(strings) {
548
+ if (strings.length === 0)
549
+ return '';
550
+ let prefix = strings[0];
551
+ for (const s of strings.slice(1)) {
552
+ while (!s.startsWith(prefix) && prefix.length > 0) {
553
+ prefix = prefix.slice(0, -1);
554
+ }
555
+ }
556
+ return prefix.replace(/-$/, '');
557
+ }
558
+ /** Save an extracted solution as experiment */
559
+ function saveExtractedSolution(sol, sessionId) {
560
+ const today = new Date().toISOString().split('T')[0];
561
+ const slugName = sol.name.toLowerCase()
562
+ .replace(/[^a-z0-9가-힣\s-]/g, '')
563
+ .replace(/\s+/g, '-')
564
+ .replace(/-+/g, '-')
565
+ .replace(/^-|-$/g, '')
566
+ .slice(0, 60) || `untitled-${Date.now()}`;
567
+ const solution = {
568
+ frontmatter: {
569
+ name: slugName,
570
+ version: 1,
571
+ status: 'experiment',
572
+ confidence: 0.3,
573
+ type: sol.type,
574
+ scope: 'me',
575
+ tags: sol.tags.slice(0, 5),
576
+ identifiers: sol.identifiers.filter(id => id.length >= 4),
577
+ evidence: { ...DEFAULT_EVIDENCE },
578
+ created: today,
579
+ updated: today,
580
+ supersedes: null,
581
+ extractedBy: 'auto',
582
+ },
583
+ context: sol.context,
584
+ content: sol.content,
585
+ };
586
+ const filePath = path.join(ME_SOLUTIONS, `${slugName}.md`);
587
+ if (fs.existsSync(filePath))
588
+ return null;
589
+ fs.mkdirSync(ME_SOLUTIONS, { recursive: true });
590
+ // PR2b: 새 파일 create는 atomicWriteText로. O_EXCL이 race를 차단한다.
591
+ atomicWriteText(filePath, serializeSolutionV3(solution));
592
+ return slugName;
593
+ }
594
+ /**
595
+ * Increment reExtracted counter on existing solution that matches given tags.
596
+ * PR2b 라운드 2 (M-2 fix): mutateSolutionFile로 통합. parse → 카운터 증가 →
597
+ * serialize. 이전 regex in-place mutation은 frontmatter 외 body의 우연 매칭
598
+ * 위험이 있었고 다른 mutator와 일관성이 깨졌다.
599
+ */
600
+ function updateReExtractedCounter(tags) {
601
+ if (!fs.existsSync(ME_SOLUTIONS))
602
+ return;
603
+ const files = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md'));
604
+ for (const file of files) {
605
+ const filePath = path.join(ME_SOLUTIONS, file);
606
+ // PR2c-4 (security L-1): symlink을 통한 임의 파일 read 차단.
607
+ try {
608
+ if (fs.lstatSync(filePath).isSymbolicLink())
609
+ continue;
610
+ }
611
+ catch {
612
+ continue;
613
+ }
614
+ // 사전 필터 (lock 없이 read) — frontmatter parse가 더 정확하지만,
615
+ // 70% overlap 조건은 frontmatter 안의 tags만 보는 게 의도라
616
+ // tagMatch regex가 frontmatter에 우선 매칭됨 (frontmatter가 항상 앞).
617
+ let preview;
618
+ try {
619
+ preview = fs.readFileSync(filePath, 'utf-8');
620
+ }
621
+ catch {
622
+ continue;
623
+ }
624
+ const tagMatch = preview.match(/tags:\s*\[([^\]]*)\]/);
625
+ if (!tagMatch)
626
+ continue;
627
+ const existingTags = tagMatch[1].split(',').map(t => t.trim().replace(/"/g, ''));
628
+ const overlap = tags.filter(t => existingTags.includes(t));
629
+ if (overlap.length / Math.max(tags.length, existingTags.length, 1) < 0.7)
630
+ continue;
631
+ // lock + fresh re-read + parse-modify-serialize
632
+ mutateSolutionFile(filePath, sol => {
633
+ sol.frontmatter.evidence.reExtracted = (sol.frontmatter.evidence.reExtracted ?? 0) + 1;
634
+ return true;
635
+ });
636
+ return;
637
+ }
638
+ }
639
+ /** Main extraction function — called from SessionStart or CLI */
640
+ function analyzeExtraction(cwd, options) {
641
+ const state = loadLastExtraction();
642
+ const today = new Date().toISOString().split('T')[0];
643
+ // Reset daily counter if new day
644
+ if (state.todayDate !== today) {
645
+ state.extractionsToday = 0;
646
+ state.todayDate = today;
647
+ }
648
+ // Daily limit check
649
+ if (options?.enforceDailyLimit !== false && state.extractionsToday >= MAX_EXTRACTIONS_PER_DAY) {
650
+ return {
651
+ state,
652
+ today,
653
+ headSha: '',
654
+ extracted: [],
655
+ reason: `일일 추출 한도 도달 (${MAX_EXTRACTIONS_PER_DAY}/일)`,
656
+ persistStateWithoutSaving: false,
657
+ };
658
+ }
659
+ // Check for new commits
660
+ const gitLog = getNewCommits(cwd, state.lastCommitSha);
661
+ if (!gitLog.trim()) {
662
+ return {
663
+ state,
664
+ today,
665
+ headSha: '',
666
+ extracted: [],
667
+ reason: '새 커밋 없음',
668
+ persistStateWithoutSaving: false,
669
+ };
670
+ }
671
+ // Get current HEAD sha
672
+ let headSha = '';
673
+ try {
674
+ headSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
675
+ }
676
+ catch {
677
+ return {
678
+ state,
679
+ today,
680
+ headSha: '',
681
+ extracted: [],
682
+ reason: 'git HEAD 조회 실패',
683
+ persistStateWithoutSaving: false,
684
+ };
685
+ }
686
+ // Gate 0: Worth extracting?
687
+ const stats = getDiffStats(cwd, state.lastCommitSha);
688
+ if (!gate0(stats)) {
689
+ return {
690
+ state,
691
+ today,
692
+ headSha,
693
+ extracted: [],
694
+ reason: `Gate 0: 추출 가치 부족 (${stats.files} files, ${stats.lines} lines)`,
695
+ stats,
696
+ persistStateWithoutSaving: true,
697
+ };
698
+ }
699
+ // Get diff for extraction prompt
700
+ const gitDiff = getGitDiff(cwd, state.lastCommitSha);
701
+ // Get commit messages for "why" context (addresses feedback: auto-extraction loses reasoning)
702
+ const commitMessages = getCommitMessages(cwd, state.lastCommitSha);
703
+ // Combine git diff analysis + session context analysis.
704
+ // C3 fix: track provenance so commit context is only attached to
705
+ // solutions that were actually derived from the diff. Pre-C3 the commit
706
+ // message was blindly copy-pasted onto every extracted solution —
707
+ // including session-context-derived patterns (word frequency
708
+ // histograms, recurring task stats) that had nothing to do with the
709
+ // commit. Observed failure mode: a "recurring-task-pattern" solution
710
+ // about `test/테스트/추가` word counts was annotated with a completely
711
+ // unrelated `Phase 1.5 + 2.5 — surprise detection + contextual bandit`
712
+ // commit message, producing a misleading audit trail + noise in the
713
+ // MCP context returned to Claude.
714
+ const diffPatterns = extractFromDiff(gitLog, gitDiff);
715
+ const contextPatterns = extractFromSessionContext(gitDiff, cwd, state.lastExtractedAt);
716
+ // Attach commit context ONLY to diff-derived patterns (they're
717
+ // genuinely about the commit). Session-context patterns keep their
718
+ // own context unchanged — if they don't have a context, they don't
719
+ // get a fake one.
720
+ if (commitMessages) {
721
+ for (const sol of diffPatterns) {
722
+ sol.context = sol.context
723
+ ? `${sol.context}\n\nCommit context:\n${commitMessages.slice(0, 300)}`
724
+ : `Commit context:\n${commitMessages.slice(0, 300)}`;
725
+ }
726
+ }
727
+ const extracted = [...diffPatterns, ...contextPatterns].slice(0, 3); // max 3 total
728
+ return {
729
+ state,
730
+ today,
731
+ headSha,
732
+ extracted,
733
+ stats,
734
+ persistStateWithoutSaving: false,
735
+ };
736
+ }
737
+ function evaluateExtractedSolution(sol) {
738
+ if (!gate1(sol))
739
+ return { action: 'skip', message: `${sol.name ?? 'unnamed'}: Gate 1 실패 (구조 검증)` };
740
+ if (!gate2(sol))
741
+ return { action: 'skip', message: `${sol.name}: Gate 2 실패 (독성 필터)` };
742
+ if (!gateTrivial(sol))
743
+ return { action: 'skip', message: `${sol.name}: Gate 2.5 실패 (자명한 패턴)` };
744
+ const dupResult = gate3(sol);
745
+ if (dupResult === 'duplicate')
746
+ return { action: 'duplicate', message: `${sol.name}: Gate 3 중복` };
747
+ if (dupResult === 're-extract')
748
+ return { action: 're-extract', message: `${sol.name}: 재추출 (기존 솔루션 강화)` };
749
+ return { action: 'accept' };
750
+ }
751
+ export async function previewExtraction(cwd) {
752
+ const analysis = analyzeExtraction(cwd, { enforceDailyLimit: false });
753
+ if (analysis.reason) {
754
+ return { preview: [], skipped: [], reason: analysis.reason };
755
+ }
756
+ const preview = [];
757
+ const skipped = [];
758
+ for (const sol of analysis.extracted.slice(0, 3)) {
759
+ const evaluation = evaluateExtractedSolution(sol);
760
+ if (evaluation.action === 'accept') {
761
+ preview.push(sol);
762
+ continue;
763
+ }
764
+ if (evaluation.action === 're-extract') {
765
+ skipped.push(evaluation.message ?? `${sol.name}: 재추출`);
766
+ continue;
767
+ }
768
+ skipped.push(evaluation.message ?? `${sol.name}: skipped`);
769
+ }
770
+ return { preview, skipped };
771
+ }
772
+ /** Main extraction function — called from SessionStart or CLI */
773
+ export async function runExtraction(cwd, sessionId) {
774
+ const result = { extracted: [], skipped: [] };
775
+ const analysis = analyzeExtraction(cwd);
776
+ if (analysis.reason) {
777
+ if (analysis.persistStateWithoutSaving && analysis.headSha) {
778
+ saveLastExtraction({
779
+ ...analysis.state,
780
+ lastCommitSha: analysis.headSha,
781
+ lastExtractedAt: new Date().toISOString(),
782
+ });
783
+ }
784
+ return { ...result, reason: analysis.reason };
785
+ }
786
+ if (analysis.extracted.length > 0) {
787
+ const { saved, skipped } = processExtractionResults(JSON.stringify(analysis.extracted), sessionId);
788
+ result.extracted = saved;
789
+ result.skipped = skipped;
790
+ }
791
+ // Update extraction state
792
+ analysis.state.lastCommitSha = analysis.headSha;
793
+ analysis.state.lastExtractedAt = new Date().toISOString();
794
+ analysis.state.extractionsToday++;
795
+ saveLastExtraction(analysis.state);
796
+ if (analysis.stats) {
797
+ log.debug(`로컬 추출 완료: ${result.extracted.length} saved, ${result.skipped.length} skipped (${analysis.stats.files} files, ${analysis.stats.lines} lines)`);
798
+ }
799
+ return result;
800
+ }
801
+ /** Process LLM extraction results (called after LLM returns) */
802
+ export function processExtractionResults(rawJson, sessionId) {
803
+ const saved = [];
804
+ const skipped = [];
805
+ let solutions;
806
+ try {
807
+ solutions = JSON.parse(rawJson);
808
+ if (!Array.isArray(solutions))
809
+ return { saved, skipped };
810
+ }
811
+ catch {
812
+ return { saved, skipped };
813
+ }
814
+ // Max 3 per extraction
815
+ for (const sol of solutions.slice(0, 3)) {
816
+ const evaluation = evaluateExtractedSolution(sol);
817
+ if (evaluation.action === 'skip' || evaluation.action === 'duplicate') {
818
+ skipped.push(evaluation.message ?? `${sol.name}: skipped`);
819
+ continue;
820
+ }
821
+ if (evaluation.action === 're-extract') {
822
+ // Increment reExtracted counter on existing solution
823
+ try {
824
+ updateReExtractedCounter(sol.tags);
825
+ }
826
+ catch (e) {
827
+ log.debug('re-extract 카운터 업데이트 실패', e);
828
+ }
829
+ skipped.push(evaluation.message ?? `${sol.name}: 재추출`);
830
+ continue;
831
+ }
832
+ // Clean identifiers before saving (short identifiers are noise)
833
+ sol.identifiers = sol.identifiers.filter(id => id.length >= 4);
834
+ // Save as experiment
835
+ const savedName = saveExtractedSolution(sol, sessionId);
836
+ if (savedName) {
837
+ saved.push(savedName);
838
+ }
839
+ else {
840
+ skipped.push(`${sol.name}: 파일 이미 존재`);
841
+ }
842
+ }
843
+ return { saved, skipped };
844
+ }
845
+ /** Check if extraction is paused */
846
+ export function isExtractionPaused() {
847
+ const pausePath = path.join(STATE_DIR, 'extraction-paused');
848
+ return fs.existsSync(pausePath);
849
+ }
850
+ /** Pause auto-extraction */
851
+ export function pauseExtraction() {
852
+ fs.mkdirSync(STATE_DIR, { recursive: true });
853
+ fs.writeFileSync(path.join(STATE_DIR, 'extraction-paused'), new Date().toISOString());
854
+ }
855
+ /** Resume auto-extraction */
856
+ export function resumeExtraction() {
857
+ const pausePath = path.join(STATE_DIR, 'extraction-paused');
858
+ if (fs.existsSync(pausePath))
859
+ fs.unlinkSync(pausePath);
860
+ }