@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,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Slop Detector Hook (PostToolUse)
|
|
4
|
+
*
|
|
5
|
+
* Write/Edit 도구 실행 후 결과물에서 AI 슬롭 패턴을 감지합니다.
|
|
6
|
+
* - TODO 주석 잔류, eslint-disable, @ts-expect-error, as any 등
|
|
7
|
+
* - Empty catch blocks, unnecessary comments, console.log debug code
|
|
8
|
+
*/
|
|
9
|
+
import { readStdinJSON } from './shared/read-stdin.js';
|
|
10
|
+
import { createLogger } from '../core/logger.js';
|
|
11
|
+
import { isHookEnabled, loadHookConfig } from './hook-config.js';
|
|
12
|
+
import { approve, approveWithWarning, failOpen } from './shared/hook-response.js';
|
|
13
|
+
const log = createLogger('slop-detector');
|
|
14
|
+
export const SLOP_PATTERNS = [
|
|
15
|
+
{ pattern: /\/\/\s*TODO:?\s*(implement|add|fix|handle)/i, message: 'Leftover TODO comment', severity: 'warn' },
|
|
16
|
+
{ pattern: /\/\/\s*eslint-disable/i, message: 'eslint-disable comment', severity: 'warn' },
|
|
17
|
+
{ pattern: /\/\/\s*@ts-ignore/i, message: '@ts-ignore comment', severity: 'warn' },
|
|
18
|
+
{ pattern: /as\s+any\b/g, message: '"as any" type assertion', severity: 'warn' },
|
|
19
|
+
{ pattern: /console\.(log|debug|info)\(/g, message: 'console.log debug code', severity: 'info' },
|
|
20
|
+
{ pattern: /catch\s*\([^)]*\)\s*\{\s*\}/m, message: 'Empty catch block', severity: 'warn' },
|
|
21
|
+
{ pattern: /\/\*\*[\s\S]*?\*\/\s*\n\s*(\/\*\*[\s\S]*?\*\/)/m, message: 'Duplicate JSDoc', severity: 'info' },
|
|
22
|
+
{ pattern: /^\s*\/\/\s*(This|The|We|Here|Note:)\s/m, message: 'Unnecessary explanatory comment', severity: 'info' },
|
|
23
|
+
];
|
|
24
|
+
/** 텍스트에서 슬롭 패턴을 감지하여 메시지 목록 반환 (순수 함수) */
|
|
25
|
+
export function detectSlop(text) {
|
|
26
|
+
const found = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
for (const entry of SLOP_PATTERNS) {
|
|
29
|
+
// RegExp에 g 플래그가 있으면 lastIndex 리셋
|
|
30
|
+
entry.pattern.lastIndex = 0;
|
|
31
|
+
if (entry.pattern.test(text) && !seen.has(entry.message)) {
|
|
32
|
+
seen.add(entry.message);
|
|
33
|
+
found.push({ message: entry.message, severity: entry.severity });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
async function main() {
|
|
39
|
+
const data = await readStdinJSON();
|
|
40
|
+
if (!isHookEnabled('slop-detector')) {
|
|
41
|
+
console.log(approve());
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// maxAllowedPatterns: config에서 읽거나 기본값(0 = 1개라도 있으면 경고) 사용
|
|
45
|
+
const config = loadHookConfig('slop-detector');
|
|
46
|
+
const maxAllowedPatterns = typeof config?.maxAllowedPatterns === 'number' ? config.maxAllowedPatterns : 0;
|
|
47
|
+
if (!data) {
|
|
48
|
+
console.log(approve());
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
52
|
+
// Write/Edit 도구일 때만 검사
|
|
53
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
54
|
+
console.log(approve());
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const toolResponse = data.tool_response ?? data.toolOutput ?? '';
|
|
58
|
+
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
59
|
+
// 검사 대상: 도구 입력의 content/new_string + 도구 응답
|
|
60
|
+
const textsToCheck = [];
|
|
61
|
+
if (typeof toolInput.content === 'string')
|
|
62
|
+
textsToCheck.push(toolInput.content);
|
|
63
|
+
if (typeof toolInput.new_string === 'string')
|
|
64
|
+
textsToCheck.push(toolInput.new_string);
|
|
65
|
+
if (toolResponse)
|
|
66
|
+
textsToCheck.push(toolResponse);
|
|
67
|
+
const combined = textsToCheck.join('\n');
|
|
68
|
+
if (!combined) {
|
|
69
|
+
console.log(approve());
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const detected = detectSlop(combined);
|
|
74
|
+
if (detected.length > maxAllowedPatterns) {
|
|
75
|
+
const lines = detected.map(d => {
|
|
76
|
+
const icon = d.severity === 'warn' ? '⚠' : 'ℹ';
|
|
77
|
+
return `- ${icon} ${d.message}`;
|
|
78
|
+
});
|
|
79
|
+
console.log(approveWithWarning(`<compound-slop-warning>\n[Forgen] AI slop detected:\n${lines.join('\n')}\nCleanup recommended.\n</compound-slop-warning>`));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
console.log(approve());
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
log.debug('슬롭 감지 실패', e);
|
|
87
|
+
console.log(failOpen());
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
main().catch((e) => {
|
|
91
|
+
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
92
|
+
console.log(failOpen());
|
|
93
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Solution Injector Hook
|
|
4
|
+
*
|
|
5
|
+
* Claude Code UserPromptSubmit 훅으로 등록.
|
|
6
|
+
* 사용자 프롬프트에 관련된 축적 솔루션을 Claude 컨텍스트에 자동 주입합니다.
|
|
7
|
+
*
|
|
8
|
+
* knowledge-comes-to-you 원칙: 필요한 지식은 찾아와야 한다
|
|
9
|
+
*/
|
|
10
|
+
interface SessionCacheCommitResult {
|
|
11
|
+
/**
|
|
12
|
+
* commit 상태:
|
|
13
|
+
* 'committed' — 정상적으로 lock 안에서 disk 갱신 완료
|
|
14
|
+
* 'lock-failed' — file lock 획득 실패 (stale recovery, timeout 등). disk는 변경 안 됨.
|
|
15
|
+
* 'error' — lock은 잡았으나 parse/write 실패. disk 상태 불명확.
|
|
16
|
+
* caller는 'lock-failed' 시 retry하거나 fail-open 처리해야 한다.
|
|
17
|
+
*/
|
|
18
|
+
status: 'committed' | 'lock-failed' | 'error';
|
|
19
|
+
/**
|
|
20
|
+
* 이번 호출에서 disk에 실제로 새로 추가된 entries.
|
|
21
|
+
* caller는 이 list로만 evidence.injected counter를 갱신해야 한다.
|
|
22
|
+
* 다른 hook이 이미 같은 entry를 추가했다면 그 entry는 newlyAdded에 포함되지 않는다.
|
|
23
|
+
*/
|
|
24
|
+
newlyAdded: Array<{
|
|
25
|
+
name: string;
|
|
26
|
+
chars: number;
|
|
27
|
+
}>;
|
|
28
|
+
/**
|
|
29
|
+
* disk에 저장된 fresh totalInjectedChars.
|
|
30
|
+
* status='committed'일 때만 정확한 값. 그 외엔 0 또는 fallback.
|
|
31
|
+
*/
|
|
32
|
+
totalInjectedChars: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 새로 inject할 entries를 disk session cache에 commit한다.
|
|
36
|
+
*
|
|
37
|
+
* H-1 + M-3 fix:
|
|
38
|
+
* - 이전 saveSessionCache는 caller의 메모리 set 전체를 저장 + Math.max로 chars 합산
|
|
39
|
+
* → disjoint write 합산 손실로 budget cap이 헐거워졌음 (H-1)
|
|
40
|
+
* - 또한 두 hook이 거의 동시에 같은 솔루션을 inject 후보로 보면 둘 다
|
|
41
|
+
* evidence.injected를 증가시켜 중복 카운트 (M-3)
|
|
42
|
+
*
|
|
43
|
+
* 이번 fix:
|
|
44
|
+
* 1. caller는 "이번에 추가하려는 entries (name+chars)"만 전달
|
|
45
|
+
* 2. lock 안에서 disk fresh를 읽어 이미 있는 name은 제외
|
|
46
|
+
* 3. 새로 추가된 것만 newlyAdded로 반환
|
|
47
|
+
* 4. disk의 fresh chars + newlyAdded chars를 합산해 새 total로 저장
|
|
48
|
+
* 5. caller는 newlyAdded로만 evidence.injected counter 갱신 → 중복 차단
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Test-only export: 격리된 회귀 테스트가 inline 재구현 대신 실 함수를 호출할 수 있도록
|
|
52
|
+
* 한다 (L-1 fix — PR2c-1 라운드 2 code-reviewer 발견).
|
|
53
|
+
*/
|
|
54
|
+
export declare function commitSessionCacheEntries(sessionId: string, newEntries: Array<{
|
|
55
|
+
name: string;
|
|
56
|
+
chars: number;
|
|
57
|
+
}>): SessionCacheCommitResult;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Solution Injector Hook
|
|
4
|
+
*
|
|
5
|
+
* Claude Code UserPromptSubmit 훅으로 등록.
|
|
6
|
+
* 사용자 프롬프트에 관련된 축적 솔루션을 Claude 컨텍스트에 자동 주입합니다.
|
|
7
|
+
*
|
|
8
|
+
* knowledge-comes-to-you 원칙: 필요한 지식은 찾아와야 한다
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { readStdinJSON } from './shared/read-stdin.js';
|
|
13
|
+
import { isHookEnabled } from './hook-config.js';
|
|
14
|
+
import { matchSolutions } from '../engine/solution-matcher.js';
|
|
15
|
+
import { extractTags } from '../engine/solution-format.js';
|
|
16
|
+
import { defaultNormalizer } from '../engine/term-normalizer.js';
|
|
17
|
+
import { logMatchDecision } from '../engine/match-eval-log.js';
|
|
18
|
+
import { resolveScope } from '../core/scope-resolver.js';
|
|
19
|
+
import { createLogger } from '../core/logger.js';
|
|
20
|
+
const log = createLogger('solution-injector');
|
|
21
|
+
import { sanitizeId } from './shared/sanitize-id.js';
|
|
22
|
+
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
23
|
+
import { withFileLock, withFileLockSync, FileLockError } from './shared/file-lock.js';
|
|
24
|
+
// filterSolutionContent는 MCP solution-reader에서 사용 (Tier 3)
|
|
25
|
+
// v1: recordPrompt (regex 선호 감지) 제거
|
|
26
|
+
import { calculateBudget } from './shared/context-budget.js';
|
|
27
|
+
import { writeSignal } from './shared/plugin-signal.js';
|
|
28
|
+
import { approve, approveWithContext, failOpen } from './shared/hook-response.js';
|
|
29
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
30
|
+
const MAX_SOLUTIONS_PER_SESSION = 10;
|
|
31
|
+
/** 세션별 이미 주입된 솔루션 추적 (중복 방지) */
|
|
32
|
+
function getSessionCachePath(sessionId) {
|
|
33
|
+
return path.join(STATE_DIR, `solution-cache-${sanitizeId(sessionId)}.json`);
|
|
34
|
+
}
|
|
35
|
+
function loadSessionCache(sessionId) {
|
|
36
|
+
const cachePath = getSessionCachePath(sessionId);
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(cachePath))
|
|
39
|
+
return { injected: new Set(), totalInjectedChars: 0 };
|
|
40
|
+
const data = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
41
|
+
const age = data.updatedAt ? Date.now() - new Date(data.updatedAt).getTime() : Infinity;
|
|
42
|
+
if (Number.isFinite(age) && age <= 24 * 60 * 60 * 1000) {
|
|
43
|
+
return { injected: new Set(data.injected ?? []), totalInjectedChars: data.totalInjectedChars ?? 0 };
|
|
44
|
+
}
|
|
45
|
+
// M-1 fix: 만료 unlink를 lock 안에서 fresh updatedAt 재검증 후에만.
|
|
46
|
+
// 이전 lock 없는 unlink는 다른 hook이 막 만든 fresh cache를 삭제할 수 있었음.
|
|
47
|
+
try {
|
|
48
|
+
withFileLockSync(cachePath, () => {
|
|
49
|
+
if (!fs.existsSync(cachePath))
|
|
50
|
+
return;
|
|
51
|
+
const fresh = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
52
|
+
const freshAge = fresh.updatedAt ? Date.now() - new Date(fresh.updatedAt).getTime() : Infinity;
|
|
53
|
+
if (!Number.isFinite(freshAge) || freshAge > 24 * 60 * 60 * 1000) {
|
|
54
|
+
// 정말 만료된 경우에만 unlink
|
|
55
|
+
fs.unlinkSync(cachePath);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
if (e instanceof FileLockError) {
|
|
61
|
+
log.warn('session cache GC lock 실패 — skip', e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { injected: new Set(), totalInjectedChars: 0 };
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
log.debug('캐시 읽기 실패', e);
|
|
68
|
+
}
|
|
69
|
+
return { injected: new Set(), totalInjectedChars: 0 };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 새로 inject할 entries를 disk session cache에 commit한다.
|
|
73
|
+
*
|
|
74
|
+
* H-1 + M-3 fix:
|
|
75
|
+
* - 이전 saveSessionCache는 caller의 메모리 set 전체를 저장 + Math.max로 chars 합산
|
|
76
|
+
* → disjoint write 합산 손실로 budget cap이 헐거워졌음 (H-1)
|
|
77
|
+
* - 또한 두 hook이 거의 동시에 같은 솔루션을 inject 후보로 보면 둘 다
|
|
78
|
+
* evidence.injected를 증가시켜 중복 카운트 (M-3)
|
|
79
|
+
*
|
|
80
|
+
* 이번 fix:
|
|
81
|
+
* 1. caller는 "이번에 추가하려는 entries (name+chars)"만 전달
|
|
82
|
+
* 2. lock 안에서 disk fresh를 읽어 이미 있는 name은 제외
|
|
83
|
+
* 3. 새로 추가된 것만 newlyAdded로 반환
|
|
84
|
+
* 4. disk의 fresh chars + newlyAdded chars를 합산해 새 total로 저장
|
|
85
|
+
* 5. caller는 newlyAdded로만 evidence.injected counter 갱신 → 중복 차단
|
|
86
|
+
*/
|
|
87
|
+
/**
|
|
88
|
+
* Test-only export: 격리된 회귀 테스트가 inline 재구현 대신 실 함수를 호출할 수 있도록
|
|
89
|
+
* 한다 (L-1 fix — PR2c-1 라운드 2 code-reviewer 발견).
|
|
90
|
+
*/
|
|
91
|
+
export function commitSessionCacheEntries(sessionId, newEntries) {
|
|
92
|
+
const cachePath = getSessionCachePath(sessionId);
|
|
93
|
+
let result = {
|
|
94
|
+
status: 'lock-failed',
|
|
95
|
+
newlyAdded: [],
|
|
96
|
+
totalInjectedChars: 0,
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
withFileLockSync(cachePath, () => {
|
|
100
|
+
// Lock 안에서 fresh re-read
|
|
101
|
+
const freshInjected = new Set();
|
|
102
|
+
let freshChars = 0;
|
|
103
|
+
let hadExpiredFresh = false;
|
|
104
|
+
try {
|
|
105
|
+
if (fs.existsSync(cachePath)) {
|
|
106
|
+
const fresh = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
107
|
+
// L-2 fix: 만료된 fresh는 무시 (24h 초과).
|
|
108
|
+
// M-2 fix: loadSessionCache와 일관되게 lock 안에서 unlink (GC).
|
|
109
|
+
const age = fresh.updatedAt ? Date.now() - new Date(fresh.updatedAt).getTime() : Infinity;
|
|
110
|
+
if (Number.isFinite(age) && age <= 24 * 60 * 60 * 1000) {
|
|
111
|
+
if (Array.isArray(fresh.injected)) {
|
|
112
|
+
for (const name of fresh.injected) {
|
|
113
|
+
if (typeof name === 'string')
|
|
114
|
+
freshInjected.add(name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (typeof fresh.totalInjectedChars === 'number') {
|
|
118
|
+
freshChars = fresh.totalInjectedChars;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
hadExpiredFresh = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
log.debug('session cache fresh re-read 실패', e);
|
|
128
|
+
}
|
|
129
|
+
if (hadExpiredFresh) {
|
|
130
|
+
// M-2 fix: 만료된 cache는 unlink해 load/commit 간 일관성 유지.
|
|
131
|
+
// load는 unlink, commit도 unlink — 두 함수의 만료 처리가 정합.
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(cachePath);
|
|
134
|
+
}
|
|
135
|
+
catch { /* 다른 hook이 이미 처리 */ }
|
|
136
|
+
}
|
|
137
|
+
// disjoint만 필터링 — 이미 disk에 있으면 다른 hook이 먼저 추가한 것
|
|
138
|
+
const newlyAdded = newEntries.filter(e => !freshInjected.has(e.name));
|
|
139
|
+
const addedChars = newlyAdded.reduce((sum, e) => sum + e.chars, 0);
|
|
140
|
+
const mergedInjected = new Set(freshInjected);
|
|
141
|
+
for (const e of newlyAdded)
|
|
142
|
+
mergedInjected.add(e.name);
|
|
143
|
+
const newTotal = freshChars + addedChars;
|
|
144
|
+
atomicWriteJSON(cachePath, {
|
|
145
|
+
injected: [...mergedInjected],
|
|
146
|
+
totalInjectedChars: newTotal,
|
|
147
|
+
updatedAt: new Date().toISOString(),
|
|
148
|
+
}, { mode: 0o600, dirMode: 0o700 });
|
|
149
|
+
result = { status: 'committed', newlyAdded, totalInjectedChars: newTotal };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
if (e instanceof FileLockError) {
|
|
154
|
+
log.warn(`session cache lock 실패 — write skipped`, e);
|
|
155
|
+
result = { status: 'lock-failed', newlyAdded: [], totalInjectedChars: 0 };
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
log.debug('session cache 저장 실패', e);
|
|
159
|
+
result = { status: 'error', newlyAdded: [], totalInjectedChars: 0 };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
/** XML 속성/내용 이스케이프 */
|
|
165
|
+
// readSolutionContent 제거됨 — Progressive Disclosure로 전문 읽기 불필요
|
|
166
|
+
// Tier 3(전문)은 MCP compound-read가 담당
|
|
167
|
+
/**
|
|
168
|
+
* 기존 injection cache에서 tags가 누락된 entry를 매칭 결과로 채운다.
|
|
169
|
+
*
|
|
170
|
+
* 호출 시점:
|
|
171
|
+
* - main()의 cache merge 단계 (in-place, 새 entry 추가와 함께)
|
|
172
|
+
* - matches.length === 0 early return 직전 (cache write만 수행)
|
|
173
|
+
*
|
|
174
|
+
* R3 sentinel 동작:
|
|
175
|
+
* - tags 키 자체가 없을 때만 backfill (`existing.tags === undefined`)
|
|
176
|
+
* - 빈 배열 (`tags: []`)은 정당한 상태로 보고 그대로 유지
|
|
177
|
+
* - 이전 `length === 0` 가드는 진짜 빈 tags 솔루션을 매번 무한 backfill 시도하던 결함
|
|
178
|
+
*
|
|
179
|
+
* 동시성: lock 없음 (PR1 의도). PR2에서 file lock으로 보호 예정.
|
|
180
|
+
*/
|
|
181
|
+
function backfillCacheTagsOnDisk(cachePath, allMatched) {
|
|
182
|
+
if (allMatched.length === 0)
|
|
183
|
+
return;
|
|
184
|
+
if (!fs.existsSync(cachePath))
|
|
185
|
+
return;
|
|
186
|
+
// PR2c-1: withFileLockSync로 read-modify-write 보호.
|
|
187
|
+
try {
|
|
188
|
+
withFileLockSync(cachePath, () => {
|
|
189
|
+
const existing = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
190
|
+
if (!Array.isArray(existing.solutions))
|
|
191
|
+
return;
|
|
192
|
+
const matchedByName = new Map(allMatched.map(m => [m.name, m]));
|
|
193
|
+
let mutated = false;
|
|
194
|
+
const updated = existing.solutions.map((sol) => {
|
|
195
|
+
// R3 sentinel: tags 키 자체가 없을 때만 backfill.
|
|
196
|
+
if (sol.tags !== undefined)
|
|
197
|
+
return sol;
|
|
198
|
+
const fresh = matchedByName.get(sol.name);
|
|
199
|
+
if (!fresh)
|
|
200
|
+
return sol;
|
|
201
|
+
mutated = true;
|
|
202
|
+
// R5: defensive copy로 fresh.tags reference 공유 차단.
|
|
203
|
+
return { ...sol, tags: [...fresh.tags] };
|
|
204
|
+
});
|
|
205
|
+
if (!mutated)
|
|
206
|
+
return;
|
|
207
|
+
atomicWriteJSON(cachePath, {
|
|
208
|
+
solutions: updated,
|
|
209
|
+
updatedAt: new Date().toISOString(),
|
|
210
|
+
}, { mode: 0o600, dirMode: 0o700 });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
if (e instanceof FileLockError) {
|
|
215
|
+
log.warn('injection cache backfill lock 실패 — write skipped', e);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
log.debug('injection cache backfill 실패', e);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function main() {
|
|
223
|
+
const input = await readStdinJSON();
|
|
224
|
+
if (!isHookEnabled('solution-injector')) {
|
|
225
|
+
console.log(approve());
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!input?.prompt) {
|
|
229
|
+
console.log(approve());
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const sessionId = input.session_id ?? 'default';
|
|
233
|
+
// v1: 교정 감지 → correction-record 호출 유도 hint
|
|
234
|
+
const correctionPatterns = /하지\s*마|그렇게\s*말고|앞으로는|이렇게\s*해|stop\s+doing|don'?t\s+do|always\s+do|never\s+do|아니\s*그게\s*아니라/i;
|
|
235
|
+
if (correctionPatterns.test(input.prompt)) {
|
|
236
|
+
try {
|
|
237
|
+
writeSignal(sessionId, 'correction-detected', 0);
|
|
238
|
+
}
|
|
239
|
+
catch { /* non-critical */ }
|
|
240
|
+
}
|
|
241
|
+
// 어댑티브 버짓: 다른 플러그인 감지 시 주입��� ���동 축소
|
|
242
|
+
const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
|
|
243
|
+
const budget = calculateBudget(cwd);
|
|
244
|
+
const cache = loadSessionCache(sessionId);
|
|
245
|
+
const injected = cache.injected;
|
|
246
|
+
// H-1 fix: `let`으로 재할당을 허락하되, commit 이후 fresh total로 갱신된다.
|
|
247
|
+
// 이전엔 dead variable이었음 (선언 후 재할당 없음).
|
|
248
|
+
let totalInjectedChars = cache.totalInjectedChars;
|
|
249
|
+
if (injected.size >= MAX_SOLUTIONS_PER_SESSION || totalInjectedChars >= budget.solutionSessionMax) {
|
|
250
|
+
if (totalInjectedChars >= budget.solutionSessionMax) {
|
|
251
|
+
log.debug(`세션 토큰 상한 도달: ${totalInjectedChars}/${budget.solutionSessionMax} chars (factor=${budget.factor})`);
|
|
252
|
+
}
|
|
253
|
+
console.log(approve());
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const scope = resolveScope(cwd);
|
|
257
|
+
// 프롬프트와 관련된 솔루션 매칭
|
|
258
|
+
// allMatched는 backfill 용도로 보존: 이미 injected된 entry라도 같은 솔루션이
|
|
259
|
+
// 다시 매칭되면 그 정보로 cache의 missing tags를 채울 수 있다.
|
|
260
|
+
// matches는 새 주입 후보 (이미 injected는 제외).
|
|
261
|
+
const allMatched = matchSolutions(input.prompt, scope, cwd);
|
|
262
|
+
const matches = allMatched.filter(m => !injected.has(m.name));
|
|
263
|
+
// T3: emit a ranking-decision record for offline analysis. Fail-open —
|
|
264
|
+
// the logger swallows any error so this never blocks hook approval.
|
|
265
|
+
// Runs AFTER ranking (plan: "Add the logging call in solution-injector
|
|
266
|
+
// after ranking, not before."). `rankedTopN` records what the matcher
|
|
267
|
+
// returned at log time; subsequent caller-side filtering (budget cap,
|
|
268
|
+
// experiment cap, session-cache disjoint) is intentionally NOT captured
|
|
269
|
+
// here — the field's contract is "matcher's top, not final injection set".
|
|
270
|
+
try {
|
|
271
|
+
const promptTags = extractTags(input.prompt);
|
|
272
|
+
const normalizedQuery = defaultNormalizer.normalizeTerms(promptTags);
|
|
273
|
+
logMatchDecision({
|
|
274
|
+
source: 'hook',
|
|
275
|
+
rawQuery: input.prompt,
|
|
276
|
+
normalizedQuery,
|
|
277
|
+
candidates: allMatched.map(m => ({
|
|
278
|
+
name: m.name,
|
|
279
|
+
relevance: m.relevance,
|
|
280
|
+
matchedTerms: m.matchedTags,
|
|
281
|
+
})),
|
|
282
|
+
rankedTopN: allMatched.slice(0, 5).map(m => m.name),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
log.debug('match-eval-log emit failed', e);
|
|
287
|
+
}
|
|
288
|
+
// 신규 주입할 게 없어도 backfill은 수행한다.
|
|
289
|
+
// R2 fix: matches.length === 0인 경우에도 allMatched에 정보가 있으면
|
|
290
|
+
// 기존 cache의 missing tags를 채울 수 있다. 이전엔 이 경로를 놓쳐서
|
|
291
|
+
// backfill fix가 절반만 적용된 상태였다 (Codex/code-reviewer 발견).
|
|
292
|
+
if (matches.length === 0) {
|
|
293
|
+
const earlyCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
|
|
294
|
+
backfillCacheTagsOnDisk(earlyCachePath, allMatched);
|
|
295
|
+
console.log(approve());
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// 어댑티브 프롬프트당 솔루션 수 제한, experiment는 1개 제한
|
|
299
|
+
let experimentCount = 0;
|
|
300
|
+
const toInject = [];
|
|
301
|
+
for (const sol of matches) {
|
|
302
|
+
if (injected.has(sol.name))
|
|
303
|
+
continue;
|
|
304
|
+
if (sol.status === 'experiment') {
|
|
305
|
+
if (experimentCount >= 1)
|
|
306
|
+
continue;
|
|
307
|
+
experimentCount++;
|
|
308
|
+
}
|
|
309
|
+
toInject.push(sol);
|
|
310
|
+
if (toInject.length >= Math.min(budget.solutionsPerPrompt, MAX_SOLUTIONS_PER_SESSION - injected.size))
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
// Progressive Disclosure Tier 2: 요약만 push, 전문은 MCP compound-read로 pull
|
|
314
|
+
// 근거: Anthropic "smallest set of high-signal tokens" + Cursor 46.9% 토큰 절감
|
|
315
|
+
const summaries = new Map();
|
|
316
|
+
const candidateEntries = [];
|
|
317
|
+
for (const sol of toInject) {
|
|
318
|
+
// Tier 2: 한 줄 요약만 생성 (전문 읽기 없음 → 토큰 대폭 절감)
|
|
319
|
+
const summary = `${sol.name} [${sol.type}|${sol.confidence.toFixed(2)}]: ${sol.matchedTags.slice(0, 5).join(', ')}`;
|
|
320
|
+
summaries.set(sol.name, summary);
|
|
321
|
+
candidateEntries.push({ name: sol.name, chars: summary.length });
|
|
322
|
+
}
|
|
323
|
+
// H-1 + M-3 fix: lock 안 disjoint 검증으로 새로 추가된 entry만 반환받는다.
|
|
324
|
+
// 다른 hook이 같은 sessionId로 동시에 같은 솔루션을 inject했다면 이 hook의
|
|
325
|
+
// commit에서는 newlyAdded에 포함되지 않아 evidence 중복 카운트가 차단된다.
|
|
326
|
+
const commitResult = commitSessionCacheEntries(sessionId, candidateEntries);
|
|
327
|
+
// M-1 fix: lock 실패와 정상 0건을 구분.
|
|
328
|
+
// lock-failed / error: disk 상태 불명 → fail-open으로 approve 하되 warn으로 가시화
|
|
329
|
+
if (commitResult.status !== 'committed') {
|
|
330
|
+
log.warn(`session cache commit ${commitResult.status} — hook approving without injection`);
|
|
331
|
+
console.log(approve());
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// H-1 fix: commit 이후 fresh disk total로 caller 변수 갱신.
|
|
335
|
+
// 이전엔 dead variable이라 budget cap이 caller-side stale 값에 의존했다.
|
|
336
|
+
totalInjectedChars = commitResult.totalInjectedChars;
|
|
337
|
+
// toInject은 commit 결과의 newlyAdded만 의미 있음 — evidence/cache 갱신은 이 list 기준
|
|
338
|
+
const newlyAddedNames = new Set(commitResult.newlyAdded.map(e => e.name));
|
|
339
|
+
const effectiveToInject = toInject.filter(sol => newlyAddedNames.has(sol.name));
|
|
340
|
+
// 다른 hook이 모두 먼저 inject했다면 effectiveToInject가 0 — 출력할 게 없음
|
|
341
|
+
if (effectiveToInject.length === 0) {
|
|
342
|
+
console.log(approve());
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// Save injection cache for Code Reflection (Phase 2) — cumulative merge
|
|
346
|
+
// PR2c-1: withFileLock으로 read-modify-write 보호. 동시 hook이 같은 cache를
|
|
347
|
+
// 만지면 last-writer-wins로 _sessionCounted 등 비트가 사라질 수 있었음.
|
|
348
|
+
const injectionCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
|
|
349
|
+
try {
|
|
350
|
+
await withFileLock(injectionCachePath, () => {
|
|
351
|
+
// Lock 안에서 fresh re-read
|
|
352
|
+
let existingSolutions = [];
|
|
353
|
+
try {
|
|
354
|
+
if (fs.existsSync(injectionCachePath)) {
|
|
355
|
+
const existing = JSON.parse(fs.readFileSync(injectionCachePath, 'utf-8'));
|
|
356
|
+
if (Array.isArray(existing.solutions))
|
|
357
|
+
existingSolutions = existing.solutions;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
log.debug('injection cache 읽기 실패 — 기존 캐시 없이 새로 시작', e);
|
|
362
|
+
}
|
|
363
|
+
// R5: defensive copy로 SolutionMatch.tags / .identifiers reference 공유 차단.
|
|
364
|
+
// M-3 fix: effectiveToInject는 commitSessionCacheEntries가 검증한 disjoint set만 포함.
|
|
365
|
+
const newSolutions = effectiveToInject.map(sol => ({
|
|
366
|
+
name: sol.name,
|
|
367
|
+
identifiers: [...sol.identifiers],
|
|
368
|
+
tags: [...sol.tags],
|
|
369
|
+
status: sol.status,
|
|
370
|
+
injectedAt: new Date().toISOString(),
|
|
371
|
+
}));
|
|
372
|
+
// BACKFILL: existing entry에 tags 키 자체가 없으면 fresh로 채움.
|
|
373
|
+
const matchedByName = new Map(allMatched.map(m => [m.name, m]));
|
|
374
|
+
const existingNames = new Set(existingSolutions.map(s => s.name));
|
|
375
|
+
const merged = [
|
|
376
|
+
...existingSolutions.map(existing => {
|
|
377
|
+
if (existing.tags !== undefined)
|
|
378
|
+
return existing;
|
|
379
|
+
const fresh = matchedByName.get(existing.name);
|
|
380
|
+
if (!fresh)
|
|
381
|
+
return existing;
|
|
382
|
+
return { ...existing, tags: [...fresh.tags] };
|
|
383
|
+
}),
|
|
384
|
+
...newSolutions.filter(s => !existingNames.has(s.name)),
|
|
385
|
+
];
|
|
386
|
+
const injectionData = {
|
|
387
|
+
solutions: merged,
|
|
388
|
+
updatedAt: new Date().toISOString(),
|
|
389
|
+
};
|
|
390
|
+
// mode 0o600 + dirMode 0o700 — STATE_DIR auto-detect 의존성을 명시화
|
|
391
|
+
atomicWriteJSON(injectionCachePath, injectionData, { mode: 0o600, dirMode: 0o700 });
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
catch (e) {
|
|
395
|
+
if (e instanceof FileLockError) {
|
|
396
|
+
log.warn(`injection cache lock 실패 — write skipped`, e);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
log.debug('injection cache 저장 실패', e);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// Update evidence.injected counters on solution files.
|
|
403
|
+
// M-3 fix: effectiveToInject(commit이 검증한 disjoint set)만 evidence 갱신 →
|
|
404
|
+
// 동시 hook이 같은 솔루션을 inject해도 한 번만 카운트됨.
|
|
405
|
+
try {
|
|
406
|
+
const { updateSolutionEvidence } = await import('./pre-tool-use.js');
|
|
407
|
+
for (const sol of effectiveToInject) {
|
|
408
|
+
updateSolutionEvidence(sol.name, 'injected');
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch (e) {
|
|
412
|
+
log.debug('evidence.injected counter 업데이트 실패', e);
|
|
413
|
+
}
|
|
414
|
+
// Progressive Disclosure: Tier 1(인덱스) + Tier 2(매칭 요약) push
|
|
415
|
+
// Tier 3(전문)은 compound-read MCP tool로 pull
|
|
416
|
+
// effectiveToInject 사용 — 다른 hook이 이미 inject한 솔루션은 사용자에게 다시 push 안 함
|
|
417
|
+
const injections = effectiveToInject.map(sol => {
|
|
418
|
+
const summary = summaries.get(sol.name) ?? sol.name;
|
|
419
|
+
return `- ${summary}`;
|
|
420
|
+
}).join('\n');
|
|
421
|
+
const header = `Matched solutions (compound-read로 전문 확인 시 더 정확한 구현 가능):\n`;
|
|
422
|
+
const footer = `\n\nIMPORTANT: When you use compound knowledge above, briefly mention it naturally (e.g., "Based on accumulated patterns..." or "From past experience..."). This helps the user see compound learning in action.`;
|
|
423
|
+
const fullInjection = header + injections + footer;
|
|
424
|
+
// 플러그인 시그널 기록 (다른 플러그인이 참고할 수 있도록)
|
|
425
|
+
try {
|
|
426
|
+
writeSignal(sessionId, 'UserPromptSubmit', fullInjection.length);
|
|
427
|
+
}
|
|
428
|
+
catch (e) {
|
|
429
|
+
log.debug('plugin signal 기록 실패', e);
|
|
430
|
+
}
|
|
431
|
+
console.log(approveWithContext(fullInjection, 'UserPromptSubmit'));
|
|
432
|
+
}
|
|
433
|
+
main().catch((e) => {
|
|
434
|
+
process.stderr.write(`[ch-hook] solution-injector: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
435
|
+
console.log(failOpen());
|
|
436
|
+
});
|