@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.
- package/.claude-plugin/plugin.json +20 -0
- package/CHANGELOG.md +353 -0
- package/CONTRIBUTING.md +98 -0
- package/LICENSE +21 -0
- package/README.ja.md +469 -0
- package/README.ko.md +469 -0
- package/README.md +483 -0
- package/README.zh.md +469 -0
- package/agents/analyst.md +98 -0
- package/agents/architect.md +62 -0
- package/agents/code-reviewer.md +120 -0
- package/agents/code-simplifier.md +197 -0
- package/agents/critic.md +70 -0
- package/agents/debugger.md +117 -0
- package/agents/designer.md +131 -0
- package/agents/executor.md +54 -0
- package/agents/explore.md +145 -0
- package/agents/git-master.md +212 -0
- package/agents/performance-reviewer.md +172 -0
- package/agents/planner.md +29 -0
- package/agents/qa-tester.md +158 -0
- package/agents/refactoring-expert.md +168 -0
- package/agents/scientist.md +144 -0
- package/agents/security-reviewer.md +137 -0
- package/agents/test-engineer.md +153 -0
- package/agents/verifier.md +133 -0
- package/agents/writer.md +184 -0
- package/commands/api-design.md +268 -0
- package/commands/architecture-decision.md +314 -0
- package/commands/ci-cd.md +270 -0
- package/commands/code-review.md +233 -0
- package/commands/compound.md +117 -0
- package/commands/database.md +263 -0
- package/commands/debug-detective.md +99 -0
- package/commands/docker.md +274 -0
- package/commands/documentation.md +276 -0
- package/commands/ecomode.md +51 -0
- package/commands/frontend.md +271 -0
- package/commands/git-master.md +90 -0
- package/commands/incident-response.md +292 -0
- package/commands/migrate.md +101 -0
- package/commands/performance.md +288 -0
- package/commands/refactor.md +105 -0
- package/commands/security-review.md +288 -0
- package/commands/tdd.md +183 -0
- package/commands/testing-strategy.md +265 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +295 -0
- package/dist/core/auto-compound-runner.d.ts +12 -0
- package/dist/core/auto-compound-runner.js +460 -0
- package/dist/core/config-hooks.d.ts +10 -0
- package/dist/core/config-hooks.js +112 -0
- package/dist/core/config-injector.d.ts +50 -0
- package/dist/core/config-injector.js +455 -0
- package/dist/core/doctor.d.ts +1 -0
- package/dist/core/doctor.js +163 -0
- package/dist/core/errors.d.ts +81 -0
- package/dist/core/errors.js +133 -0
- package/dist/core/global-config.d.ts +43 -0
- package/dist/core/global-config.js +25 -0
- package/dist/core/harness.d.ts +24 -0
- package/dist/core/harness.js +621 -0
- package/dist/core/init.d.ts +7 -0
- package/dist/core/init.js +37 -0
- package/dist/core/inspect-cli.d.ts +7 -0
- package/dist/core/inspect-cli.js +47 -0
- package/dist/core/legacy-detector.d.ts +33 -0
- package/dist/core/legacy-detector.js +66 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +121 -0
- package/dist/core/mcp-config.d.ts +44 -0
- package/dist/core/mcp-config.js +177 -0
- package/dist/core/notepad.d.ts +31 -0
- package/dist/core/notepad.js +88 -0
- package/dist/core/paths.d.ts +85 -0
- package/dist/core/paths.js +101 -0
- package/dist/core/plugin-detector.d.ts +44 -0
- package/dist/core/plugin-detector.js +226 -0
- package/dist/core/runtime-detector.d.ts +8 -0
- package/dist/core/runtime-detector.js +49 -0
- package/dist/core/scope-resolver.d.ts +8 -0
- package/dist/core/scope-resolver.js +45 -0
- package/dist/core/session-logger.d.ts +6 -0
- package/dist/core/session-logger.js +111 -0
- package/dist/core/session-store.d.ts +28 -0
- package/dist/core/session-store.js +218 -0
- package/dist/core/settings-lock.d.ts +18 -0
- package/dist/core/settings-lock.js +125 -0
- package/dist/core/spawn.d.ts +3 -0
- package/dist/core/spawn.js +135 -0
- package/dist/core/types.d.ts +108 -0
- package/dist/core/types.js +1 -0
- package/dist/core/uninstall.d.ts +4 -0
- package/dist/core/uninstall.js +307 -0
- package/dist/core/v1-bootstrap.d.ts +26 -0
- package/dist/core/v1-bootstrap.js +155 -0
- package/dist/engine/compound-cli.d.ts +24 -0
- package/dist/engine/compound-cli.js +250 -0
- package/dist/engine/compound-extractor.d.ts +68 -0
- package/dist/engine/compound-extractor.js +860 -0
- package/dist/engine/compound-lifecycle.d.ts +32 -0
- package/dist/engine/compound-lifecycle.js +305 -0
- package/dist/engine/compound-loop.d.ts +32 -0
- package/dist/engine/compound-loop.js +511 -0
- package/dist/engine/match-eval-log.d.ts +139 -0
- package/dist/engine/match-eval-log.js +270 -0
- package/dist/engine/phrase-blocklist.d.ts +119 -0
- package/dist/engine/phrase-blocklist.js +208 -0
- package/dist/engine/skill-promoter.d.ts +20 -0
- package/dist/engine/skill-promoter.js +115 -0
- package/dist/engine/solution-format.d.ts +160 -0
- package/dist/engine/solution-format.js +432 -0
- package/dist/engine/solution-index.d.ts +13 -0
- package/dist/engine/solution-index.js +252 -0
- package/dist/engine/solution-matcher.d.ts +364 -0
- package/dist/engine/solution-matcher.js +656 -0
- package/dist/engine/solution-writer.d.ts +76 -0
- package/dist/engine/solution-writer.js +157 -0
- package/dist/engine/term-matcher.d.ts +81 -0
- package/dist/engine/term-matcher.js +268 -0
- package/dist/engine/term-normalizer.d.ts +116 -0
- package/dist/engine/term-normalizer.js +171 -0
- package/dist/fgx.d.ts +6 -0
- package/dist/fgx.js +42 -0
- package/dist/forge/cli.d.ts +11 -0
- package/dist/forge/cli.js +100 -0
- package/dist/forge/evidence-processor.d.ts +21 -0
- package/dist/forge/evidence-processor.js +87 -0
- package/dist/forge/mismatch-detector.d.ts +44 -0
- package/dist/forge/mismatch-detector.js +83 -0
- package/dist/forge/onboarding-cli.d.ts +6 -0
- package/dist/forge/onboarding-cli.js +89 -0
- package/dist/forge/onboarding.d.ts +25 -0
- package/dist/forge/onboarding.js +122 -0
- package/dist/hooks/compound-reflection.d.ts +45 -0
- package/dist/hooks/compound-reflection.js +82 -0
- package/dist/hooks/context-guard.d.ts +24 -0
- package/dist/hooks/context-guard.js +156 -0
- package/dist/hooks/dangerous-patterns.json +18 -0
- package/dist/hooks/db-guard.d.ts +17 -0
- package/dist/hooks/db-guard.js +105 -0
- package/dist/hooks/hook-config.d.ts +29 -0
- package/dist/hooks/hook-config.js +92 -0
- package/dist/hooks/hook-registry.d.ts +43 -0
- package/dist/hooks/hook-registry.js +31 -0
- package/dist/hooks/hooks-generator.d.ts +49 -0
- package/dist/hooks/hooks-generator.js +99 -0
- package/dist/hooks/intent-classifier.d.ts +12 -0
- package/dist/hooks/intent-classifier.js +62 -0
- package/dist/hooks/keyword-detector.d.ts +25 -0
- package/dist/hooks/keyword-detector.js +389 -0
- package/dist/hooks/notepad-injector.d.ts +18 -0
- package/dist/hooks/notepad-injector.js +51 -0
- package/dist/hooks/permission-handler.d.ts +14 -0
- package/dist/hooks/permission-handler.js +114 -0
- package/dist/hooks/post-tool-failure.d.ts +11 -0
- package/dist/hooks/post-tool-failure.js +118 -0
- package/dist/hooks/post-tool-handlers.d.ts +17 -0
- package/dist/hooks/post-tool-handlers.js +115 -0
- package/dist/hooks/post-tool-use.d.ts +29 -0
- package/dist/hooks/post-tool-use.js +151 -0
- package/dist/hooks/pre-compact.d.ts +10 -0
- package/dist/hooks/pre-compact.js +165 -0
- package/dist/hooks/pre-tool-use.d.ts +31 -0
- package/dist/hooks/pre-tool-use.js +325 -0
- package/dist/hooks/prompt-injection-filter.d.ts +56 -0
- package/dist/hooks/prompt-injection-filter.js +287 -0
- package/dist/hooks/rate-limiter.d.ts +21 -0
- package/dist/hooks/rate-limiter.js +86 -0
- package/dist/hooks/secret-filter.d.ts +14 -0
- package/dist/hooks/secret-filter.js +65 -0
- package/dist/hooks/session-recovery.d.ts +27 -0
- package/dist/hooks/session-recovery.js +406 -0
- package/dist/hooks/shared/atomic-write.d.ts +41 -0
- package/dist/hooks/shared/atomic-write.js +148 -0
- package/dist/hooks/shared/context-budget.d.ts +37 -0
- package/dist/hooks/shared/context-budget.js +45 -0
- package/dist/hooks/shared/file-lock.d.ts +56 -0
- package/dist/hooks/shared/file-lock.js +253 -0
- package/dist/hooks/shared/hook-response.d.ts +33 -0
- package/dist/hooks/shared/hook-response.js +62 -0
- package/dist/hooks/shared/injection-caps.d.ts +39 -0
- package/dist/hooks/shared/injection-caps.js +52 -0
- package/dist/hooks/shared/plugin-signal.d.ts +23 -0
- package/dist/hooks/shared/plugin-signal.js +104 -0
- package/dist/hooks/shared/read-stdin.d.ts +8 -0
- package/dist/hooks/shared/read-stdin.js +63 -0
- package/dist/hooks/shared/sanitize-id.d.ts +7 -0
- package/dist/hooks/shared/sanitize-id.js +9 -0
- package/dist/hooks/shared/sanitize.d.ts +7 -0
- package/dist/hooks/shared/sanitize.js +22 -0
- package/dist/hooks/skill-injector.d.ts +38 -0
- package/dist/hooks/skill-injector.js +285 -0
- package/dist/hooks/slop-detector.d.ts +18 -0
- package/dist/hooks/slop-detector.js +93 -0
- package/dist/hooks/solution-injector.d.ts +58 -0
- package/dist/hooks/solution-injector.js +436 -0
- package/dist/hooks/subagent-tracker.d.ts +10 -0
- package/dist/hooks/subagent-tracker.js +90 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +224 -0
- package/dist/lib.d.ts +14 -0
- package/dist/lib.js +14 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +40 -0
- package/dist/mcp/solution-reader.d.ts +90 -0
- package/dist/mcp/solution-reader.js +273 -0
- package/dist/mcp/tools.d.ts +16 -0
- package/dist/mcp/tools.js +302 -0
- package/dist/preset/facet-catalog.d.ts +17 -0
- package/dist/preset/facet-catalog.js +46 -0
- package/dist/preset/preset-manager.d.ts +31 -0
- package/dist/preset/preset-manager.js +111 -0
- package/dist/renderer/inspect-renderer.d.ts +11 -0
- package/dist/renderer/inspect-renderer.js +123 -0
- package/dist/renderer/rule-renderer.d.ts +18 -0
- package/dist/renderer/rule-renderer.js +159 -0
- package/dist/store/evidence-store.d.ts +23 -0
- package/dist/store/evidence-store.js +58 -0
- package/dist/store/profile-store.d.ts +12 -0
- package/dist/store/profile-store.js +53 -0
- package/dist/store/recommendation-store.d.ts +22 -0
- package/dist/store/recommendation-store.js +64 -0
- package/dist/store/rule-store.d.ts +22 -0
- package/dist/store/rule-store.js +62 -0
- package/dist/store/session-state-store.d.ts +11 -0
- package/dist/store/session-state-store.js +44 -0
- package/dist/store/types.d.ts +159 -0
- package/dist/store/types.js +7 -0
- package/hooks/hook-registry.json +21 -0
- package/hooks/hooks.json +185 -0
- package/package.json +89 -0
- package/plugin.json +20 -0
- package/scripts/postinstall.js +826 -0
- package/skills/api-design/SKILL.md +262 -0
- package/skills/architecture-decision/SKILL.md +309 -0
- package/skills/ci-cd/SKILL.md +264 -0
- package/skills/code-review/SKILL.md +228 -0
- package/skills/compound/SKILL.md +101 -0
- package/skills/database/SKILL.md +257 -0
- package/skills/debug-detective/SKILL.md +95 -0
- package/skills/docker/SKILL.md +268 -0
- package/skills/documentation/SKILL.md +270 -0
- package/skills/ecomode/SKILL.md +46 -0
- package/skills/frontend/SKILL.md +265 -0
- package/skills/git-master/SKILL.md +86 -0
- package/skills/incident-response/SKILL.md +286 -0
- package/skills/migrate/SKILL.md +96 -0
- package/skills/performance/SKILL.md +282 -0
- package/skills/refactor/SKILL.md +100 -0
- package/skills/security-review/SKILL.md +282 -0
- package/skills/tdd/SKILL.md +178 -0
- package/skills/testing-strategy/SKILL.md +260 -0
- package/starter-pack/solutions/starter-api-error-responses.md +37 -0
- package/starter-pack/solutions/starter-async-patterns.md +40 -0
- package/starter-pack/solutions/starter-caching-strategy.md +40 -0
- package/starter-pack/solutions/starter-code-review-checklist.md +39 -0
- package/starter-pack/solutions/starter-debugging-systematic.md +40 -0
- package/starter-pack/solutions/starter-dependency-injection.md +40 -0
- package/starter-pack/solutions/starter-error-handling-patterns.md +38 -0
- package/starter-pack/solutions/starter-git-atomic-commits.md +36 -0
- package/starter-pack/solutions/starter-input-validation.md +40 -0
- package/starter-pack/solutions/starter-n-plus-one-queries.md +37 -0
- package/starter-pack/solutions/starter-refactor-safely.md +38 -0
- package/starter-pack/solutions/starter-secret-management.md +37 -0
- package/starter-pack/solutions/starter-separation-of-concerns.md +36 -0
- package/starter-pack/solutions/starter-tdd-red-green-refactor.md +40 -0
- 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
|
+
}
|