@wooojin/forgen 0.1.1 → 0.2.1
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/CHANGELOG.md +28 -0
- package/README.ja.md +79 -14
- package/README.ko.md +89 -14
- package/README.md +77 -14
- package/README.zh.md +79 -14
- package/commands/deep-interview.md +171 -0
- package/commands/specify.md +128 -0
- package/dist/cli.js +11 -2
- package/dist/core/auto-compound-runner.js +34 -1
- package/dist/core/dashboard.d.ts +91 -0
- package/dist/core/dashboard.js +385 -0
- package/dist/core/doctor.js +157 -1
- package/dist/core/drift-score.d.ts +49 -0
- package/dist/core/drift-score.js +87 -0
- package/dist/core/inspect-cli.js +54 -1
- package/dist/core/mcp-config.d.ts +2 -0
- package/dist/core/mcp-config.js +6 -1
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/paths.js +1 -1
- package/dist/core/spawn.d.ts +7 -2
- package/dist/core/spawn.js +45 -7
- package/dist/core/v1-bootstrap.js +9 -2
- package/dist/engine/compound-export.d.ts +41 -0
- package/dist/engine/compound-export.js +169 -0
- package/dist/engine/compound-extractor.js +49 -0
- package/dist/engine/compound-loop.js +18 -0
- package/dist/engine/solution-matcher.d.ts +23 -0
- package/dist/engine/solution-matcher.js +124 -11
- package/dist/forge/mismatch-detector.js +3 -0
- package/dist/hooks/context-guard.d.ts +10 -0
- package/dist/hooks/context-guard.js +105 -49
- package/dist/hooks/db-guard.js +2 -2
- package/dist/hooks/hook-config.d.ts +27 -1
- package/dist/hooks/hook-config.js +72 -12
- package/dist/hooks/intent-classifier.js +29 -4
- package/dist/hooks/keyword-detector.js +114 -106
- package/dist/hooks/notepad-injector.js +2 -2
- package/dist/hooks/permission-handler.js +2 -2
- package/dist/hooks/post-tool-failure.js +12 -6
- package/dist/hooks/post-tool-handlers.d.ts +1 -1
- package/dist/hooks/post-tool-handlers.js +14 -11
- package/dist/hooks/post-tool-use.d.ts +11 -0
- package/dist/hooks/post-tool-use.js +184 -71
- package/dist/hooks/pre-compact.d.ts +11 -1
- package/dist/hooks/pre-compact.js +113 -3
- package/dist/hooks/pre-tool-use.js +86 -56
- package/dist/hooks/rate-limiter.js +3 -3
- package/dist/hooks/secret-filter.js +2 -2
- package/dist/hooks/session-recovery.js +256 -236
- package/dist/hooks/shared/hook-response.d.ts +7 -0
- package/dist/hooks/shared/hook-response.js +20 -0
- package/dist/hooks/shared/hook-timing.d.ts +15 -0
- package/dist/hooks/shared/hook-timing.js +64 -0
- package/dist/hooks/skill-injector.js +41 -12
- package/dist/hooks/slop-detector.js +3 -3
- package/dist/hooks/solution-injector.js +224 -197
- package/dist/hooks/subagent-tracker.js +2 -2
- package/dist/mcp/tools.js +114 -0
- package/dist/renderer/rule-renderer.js +9 -11
- package/dist/store/evidence-store.d.ts +8 -0
- package/dist/store/evidence-store.js +51 -0
- package/dist/store/rule-store.d.ts +5 -0
- package/dist/store/rule-store.js +22 -0
- package/package.json +1 -1
- package/skills/deep-interview/SKILL.md +166 -0
- package/skills/specify/SKILL.md +122 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Hook Timing Profiler
|
|
3
|
+
*
|
|
4
|
+
* Records hook execution durations and provides timing statistics
|
|
5
|
+
* for visibility into which hooks are slow.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
import { STATE_DIR } from '../../core/paths.js';
|
|
10
|
+
const TIMING_LOG = path.join(STATE_DIR, 'hook-timing.jsonl');
|
|
11
|
+
const MAX_LINES = 500;
|
|
12
|
+
export function recordHookTiming(hookName, durationMs, event) {
|
|
13
|
+
try {
|
|
14
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
15
|
+
const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now() });
|
|
16
|
+
fs.appendFileSync(TIMING_LOG, entry + '\n');
|
|
17
|
+
// Rotate if too large
|
|
18
|
+
try {
|
|
19
|
+
const content = fs.readFileSync(TIMING_LOG, 'utf-8');
|
|
20
|
+
const lines = content.trim().split('\n');
|
|
21
|
+
if (lines.length > MAX_LINES) {
|
|
22
|
+
fs.writeFileSync(TIMING_LOG, lines.slice(-MAX_LINES).join('\n') + '\n');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch { /* skip rotation on error */ }
|
|
26
|
+
}
|
|
27
|
+
catch { /* fail-open */ }
|
|
28
|
+
}
|
|
29
|
+
export function getTimingStats() {
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(TIMING_LOG))
|
|
32
|
+
return [];
|
|
33
|
+
const content = fs.readFileSync(TIMING_LOG, 'utf-8');
|
|
34
|
+
const entries = content.trim().split('\n')
|
|
35
|
+
.map(line => { try {
|
|
36
|
+
return JSON.parse(line);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
} })
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
const byHook = new Map();
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
if (!byHook.has(e.hook))
|
|
45
|
+
byHook.set(e.hook, []);
|
|
46
|
+
byHook.get(e.hook).push(e.ms);
|
|
47
|
+
}
|
|
48
|
+
const stats = [];
|
|
49
|
+
for (const [hook, times] of byHook) {
|
|
50
|
+
times.sort((a, b) => a - b);
|
|
51
|
+
stats.push({
|
|
52
|
+
hook,
|
|
53
|
+
count: times.length,
|
|
54
|
+
p50: times[Math.floor(times.length * 0.5)] ?? 0,
|
|
55
|
+
p95: times[Math.floor(times.length * 0.95)] ?? 0,
|
|
56
|
+
max: times[times.length - 1] ?? 0,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return stats.sort((a, b) => b.p95 - a.p95);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -34,10 +34,11 @@ function escapeXmlAttr(s) {
|
|
|
34
34
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
35
35
|
}
|
|
36
36
|
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
37
|
+
import { withFileLockSync } from './shared/file-lock.js';
|
|
37
38
|
import { FORGEN_HOME, ME_DIR, STATE_DIR } from '../core/paths.js';
|
|
38
39
|
import { KEYWORD_PATTERNS } from './keyword-detector.js';
|
|
39
40
|
import { isHookEnabled } from './hook-config.js';
|
|
40
|
-
import { approve, approveWithContext,
|
|
41
|
+
import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
|
|
41
42
|
/** keyword-detector가 처리하는 키워드 이름 집합 (skill + inject 모두 포함, 이중 주입 방지) */
|
|
42
43
|
const KEYWORD_DETECTOR_SKILL_NAMES = new Set(KEYWORD_PATTERNS
|
|
43
44
|
.filter(p => p.type === 'skill' || p.type === 'inject')
|
|
@@ -65,11 +66,39 @@ function loadSessionCache(sessionId) {
|
|
|
65
66
|
}
|
|
66
67
|
return new Set();
|
|
67
68
|
}
|
|
68
|
-
function saveSessionCache(sessionId,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
function saveSessionCache(sessionId, newSkillNames) {
|
|
70
|
+
const cachePath = getSessionCachePath(sessionId);
|
|
71
|
+
try {
|
|
72
|
+
withFileLockSync(cachePath, () => {
|
|
73
|
+
// Lock 안에서 fresh re-read → merge → write
|
|
74
|
+
const freshInjected = new Set();
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(cachePath)) {
|
|
77
|
+
const fresh = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
78
|
+
const age = fresh.updatedAt ? Date.now() - new Date(fresh.updatedAt).getTime() : Infinity;
|
|
79
|
+
if (Number.isFinite(age) && age <= 24 * 60 * 60 * 1000) {
|
|
80
|
+
for (const name of fresh.injected ?? [])
|
|
81
|
+
freshInjected.add(name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* fresh re-read 실패 시 빈 set으로 진행 */ }
|
|
86
|
+
for (const name of newSkillNames)
|
|
87
|
+
freshInjected.add(name);
|
|
88
|
+
atomicWriteJSON(cachePath, {
|
|
89
|
+
injected: [...freshInjected],
|
|
90
|
+
updatedAt: new Date().toISOString(),
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
// Lock 실패 시 fail-open: 직접 write (중복 주입 가능성 있지만 기능 차단 안 함)
|
|
96
|
+
log.debug('skill session cache lock 실패 — fallback write', e);
|
|
97
|
+
atomicWriteJSON(cachePath, {
|
|
98
|
+
injected: [...new Set([...loadSessionCache(sessionId), ...newSkillNames])],
|
|
99
|
+
updatedAt: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
73
102
|
}
|
|
74
103
|
/** YAML frontmatter 파싱 (간단한 구현) */
|
|
75
104
|
export function parseFrontmatter(content) {
|
|
@@ -258,11 +287,11 @@ async function main() {
|
|
|
258
287
|
}
|
|
259
288
|
// 최대 제한 적용
|
|
260
289
|
const toInject = matched.slice(0, MAX_SKILLS_PER_SESSION - injected.size);
|
|
261
|
-
// 파일 기반 캐시 업데이트
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
saveSessionCache(sessionId,
|
|
290
|
+
// 파일 기반 캐시 업데이트 (lock 보호)
|
|
291
|
+
const newSkillNames = toInject.map(s => s.name);
|
|
292
|
+
for (const name of newSkillNames)
|
|
293
|
+
injected.add(name);
|
|
294
|
+
saveSessionCache(sessionId, newSkillNames);
|
|
266
295
|
// Adaptive budget: 다른 플러그인 감지 시 스킬 주입량 축소
|
|
267
296
|
let skillCap = 3000; // INJECTION_CAPS.skillContentMax 기본값
|
|
268
297
|
try {
|
|
@@ -281,5 +310,5 @@ async function main() {
|
|
|
281
310
|
}
|
|
282
311
|
main().catch((e) => {
|
|
283
312
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
284
|
-
console.log(
|
|
313
|
+
console.log(failOpenWithTracking('skill-injector'));
|
|
285
314
|
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { readStdinJSON } from './shared/read-stdin.js';
|
|
10
10
|
import { createLogger } from '../core/logger.js';
|
|
11
11
|
import { isHookEnabled, loadHookConfig } from './hook-config.js';
|
|
12
|
-
import { approve, approveWithWarning,
|
|
12
|
+
import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
|
|
13
13
|
const log = createLogger('slop-detector');
|
|
14
14
|
export const SLOP_PATTERNS = [
|
|
15
15
|
{ pattern: /\/\/\s*TODO:?\s*(implement|add|fix|handle)/i, message: 'Leftover TODO comment', severity: 'warn' },
|
|
@@ -84,10 +84,10 @@ async function main() {
|
|
|
84
84
|
}
|
|
85
85
|
catch (e) {
|
|
86
86
|
log.debug('슬롭 감지 실패', e);
|
|
87
|
-
console.log(
|
|
87
|
+
console.log(failOpenWithTracking('slop-detector'));
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
main().catch((e) => {
|
|
91
91
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
92
|
-
console.log(
|
|
92
|
+
console.log(failOpenWithTracking('slop-detector'));
|
|
93
93
|
});
|
|
@@ -25,8 +25,9 @@ import { withFileLock, withFileLockSync, FileLockError } from './shared/file-loc
|
|
|
25
25
|
// v1: recordPrompt (regex 선호 감지) 제거
|
|
26
26
|
import { calculateBudget } from './shared/context-budget.js';
|
|
27
27
|
import { writeSignal } from './shared/plugin-signal.js';
|
|
28
|
-
import { approve, approveWithContext,
|
|
28
|
+
import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
|
|
29
29
|
import { STATE_DIR } from '../core/paths.js';
|
|
30
|
+
import { recordHookTiming } from './shared/hook-timing.js';
|
|
30
31
|
const MAX_SOLUTIONS_PER_SESSION = 10;
|
|
31
32
|
/** 세션별 이미 주입된 솔루션 추적 (중복 방지) */
|
|
32
33
|
function getSessionCachePath(sessionId) {
|
|
@@ -220,217 +221,243 @@ function backfillCacheTagsOnDisk(cachePath, allMatched) {
|
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
223
|
async function main() {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
224
|
+
const _hookStart = Date.now();
|
|
225
|
+
try {
|
|
226
|
+
const input = await readStdinJSON();
|
|
227
|
+
if (!isHookEnabled('solution-injector')) {
|
|
228
|
+
console.log(approve());
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (!input?.prompt) {
|
|
232
|
+
console.log(approve());
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const sessionId = input.session_id ?? 'default';
|
|
236
|
+
// v1: 교정 감지 → correction-record 호출 유도 hint
|
|
237
|
+
const correctionPatterns = /하지\s*마|그렇게\s*말고|앞으로는|이렇게\s*해|stop\s+doing|don'?t\s+do|always\s+do|never\s+do|아니\s*그게\s*아니라/i;
|
|
238
|
+
if (correctionPatterns.test(input.prompt)) {
|
|
239
|
+
try {
|
|
240
|
+
writeSignal(sessionId, 'correction-detected', 0);
|
|
241
|
+
}
|
|
242
|
+
catch { /* non-critical */ }
|
|
243
|
+
}
|
|
244
|
+
// 어댑티브 버짓: 다른 플러그인 감지 시 주입��� ���동 축소
|
|
245
|
+
const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
|
|
246
|
+
const budget = calculateBudget(cwd);
|
|
247
|
+
const cache = loadSessionCache(sessionId);
|
|
248
|
+
const injected = cache.injected;
|
|
249
|
+
// H-1 fix: `let`으로 재할당을 허락하되, commit 이후 fresh total로 갱신된다.
|
|
250
|
+
// 이전엔 dead variable이었음 (선언 후 재할당 없음).
|
|
251
|
+
let totalInjectedChars = cache.totalInjectedChars;
|
|
252
|
+
if (injected.size >= MAX_SOLUTIONS_PER_SESSION || totalInjectedChars >= budget.solutionSessionMax) {
|
|
253
|
+
if (totalInjectedChars >= budget.solutionSessionMax) {
|
|
254
|
+
log.debug(`세션 토큰 상한 도달: ${totalInjectedChars}/${budget.solutionSessionMax} chars (factor=${budget.factor})`);
|
|
255
|
+
}
|
|
256
|
+
console.log(approve());
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const scope = resolveScope(cwd);
|
|
260
|
+
// 프롬프트와 관련된 솔루션 매칭
|
|
261
|
+
// allMatched는 backfill 용도로 보존: 이미 injected된 entry라도 같은 솔루션이
|
|
262
|
+
// 다시 매칭되면 그 정보로 cache의 missing tags를 채울 수 있다.
|
|
263
|
+
// matches는 새 주입 후보 (이미 injected는 제외).
|
|
264
|
+
const allMatched = matchSolutions(input.prompt, scope, cwd);
|
|
265
|
+
const matches = allMatched.filter(m => !injected.has(m.name));
|
|
266
|
+
// T3: emit a ranking-decision record for offline analysis. Fail-open —
|
|
267
|
+
// the logger swallows any error so this never blocks hook approval.
|
|
268
|
+
// Runs AFTER ranking (plan: "Add the logging call in solution-injector
|
|
269
|
+
// after ranking, not before."). `rankedTopN` records what the matcher
|
|
270
|
+
// returned at log time; subsequent caller-side filtering (budget cap,
|
|
271
|
+
// experiment cap, session-cache disjoint) is intentionally NOT captured
|
|
272
|
+
// here — the field's contract is "matcher's top, not final injection set".
|
|
236
273
|
try {
|
|
237
|
-
|
|
274
|
+
const promptTags = extractTags(input.prompt);
|
|
275
|
+
const normalizedQuery = defaultNormalizer.normalizeTerms(promptTags);
|
|
276
|
+
logMatchDecision({
|
|
277
|
+
source: 'hook',
|
|
278
|
+
rawQuery: input.prompt,
|
|
279
|
+
normalizedQuery,
|
|
280
|
+
candidates: allMatched.map(m => ({
|
|
281
|
+
name: m.name,
|
|
282
|
+
relevance: m.relevance,
|
|
283
|
+
matchedTerms: m.matchedTags,
|
|
284
|
+
})),
|
|
285
|
+
rankedTopN: allMatched.slice(0, 5).map(m => m.name),
|
|
286
|
+
});
|
|
238
287
|
}
|
|
239
|
-
catch {
|
|
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})`);
|
|
288
|
+
catch (e) {
|
|
289
|
+
log.debug('match-eval-log emit failed', e);
|
|
252
290
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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)
|
|
291
|
+
// 신규 주입할 게 없어도 backfill은 수행한다.
|
|
292
|
+
// R2 fix: matches.length === 0인 경우에도 allMatched에 정보가 있으면
|
|
293
|
+
// 기존 cache의 missing tags를 채울 수 있다. 이전엔 이 경로를 놓쳐서
|
|
294
|
+
// backfill fix가 절반만 적용된 상태였다 (Codex/code-reviewer 발견).
|
|
295
|
+
if (matches.length === 0) {
|
|
296
|
+
const earlyCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
|
|
297
|
+
backfillCacheTagsOnDisk(earlyCachePath, allMatched);
|
|
298
|
+
console.log(approve());
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// 어댑티브 프롬프트당 솔루션 수 제한, experiment는 1개 제한
|
|
302
|
+
let experimentCount = 0;
|
|
303
|
+
const toInject = [];
|
|
304
|
+
for (const sol of matches) {
|
|
305
|
+
if (injected.has(sol.name))
|
|
306
306
|
continue;
|
|
307
|
-
|
|
307
|
+
if (sol.status === 'experiment') {
|
|
308
|
+
if (experimentCount >= 1)
|
|
309
|
+
continue;
|
|
310
|
+
experimentCount++;
|
|
311
|
+
}
|
|
312
|
+
toInject.push(sol);
|
|
313
|
+
if (toInject.length >= Math.min(budget.solutionsPerPrompt, MAX_SOLUTIONS_PER_SESSION - injected.size))
|
|
314
|
+
break;
|
|
308
315
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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 = [];
|
|
316
|
+
// Progressive Disclosure Tier 2.5: 핵심 요약 push (이름+태그+본문 핵심 3줄)
|
|
317
|
+
// 이전 Tier 2(이름+태그만)는 반영률 0% → Claude가 행동 가능한 정보 부족
|
|
318
|
+
// 토큰 예산: 솔루션당 최대 300자, 3개 제한 → 최대 ~900자
|
|
319
|
+
const SUMMARY_MAX_CHARS = 300;
|
|
320
|
+
const summaries = new Map();
|
|
321
|
+
const candidateEntries = [];
|
|
322
|
+
for (const sol of toInject) {
|
|
323
|
+
let contentSnippet = '';
|
|
353
324
|
try {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
325
|
+
const raw = fs.readFileSync(sol.path, 'utf-8');
|
|
326
|
+
const contentMatch = raw.match(/## Content\n([\s\S]*?)(?:\n## |\n---|$)/);
|
|
327
|
+
if (contentMatch) {
|
|
328
|
+
// 코드 블록 제거 후 핵심 텍스트만 추출, 최대 3줄
|
|
329
|
+
const lines = contentMatch[1]
|
|
330
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
331
|
+
.split('\n')
|
|
332
|
+
.map(l => l.trim())
|
|
333
|
+
.filter(l => l.length > 0);
|
|
334
|
+
contentSnippet = lines.slice(0, 3).join('\n');
|
|
335
|
+
if (contentSnippet.length > SUMMARY_MAX_CHARS) {
|
|
336
|
+
contentSnippet = contentSnippet.slice(0, SUMMARY_MAX_CHARS - 3) + '...';
|
|
337
|
+
}
|
|
358
338
|
}
|
|
359
339
|
}
|
|
360
|
-
catch
|
|
361
|
-
|
|
340
|
+
catch { /* fail-open: 파일 읽기 실패 시 이름+태그만 사용 */ }
|
|
341
|
+
const header = `${sol.name} [${sol.type}|${sol.confidence.toFixed(2)}]: ${sol.matchedTags.slice(0, 5).join(', ')}`;
|
|
342
|
+
const summary = contentSnippet ? `${header}\n ${contentSnippet.replace(/\n/g, '\n ')}` : header;
|
|
343
|
+
summaries.set(sol.name, summary);
|
|
344
|
+
candidateEntries.push({ name: sol.name, chars: summary.length });
|
|
345
|
+
}
|
|
346
|
+
// H-1 + M-3 fix: lock 안 disjoint 검증으로 새로 추가된 entry만 반환받는다.
|
|
347
|
+
// 다른 hook이 같은 sessionId로 동시에 같은 솔루션을 inject했다면 이 hook의
|
|
348
|
+
// commit에서는 newlyAdded에 포함되지 않아 evidence 중복 카운트가 차단된다.
|
|
349
|
+
const commitResult = commitSessionCacheEntries(sessionId, candidateEntries);
|
|
350
|
+
// M-1 fix: lock 실패와 정상 0건을 구분.
|
|
351
|
+
// lock-failed / error: disk 상태 불명 → fail-open으로 approve 하되 warn으로 가시화
|
|
352
|
+
if (commitResult.status !== 'committed') {
|
|
353
|
+
log.warn(`session cache commit ${commitResult.status} — hook approving without injection`);
|
|
354
|
+
console.log(approve());
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// H-1 fix: commit 이후 fresh disk total로 caller 변수 갱신.
|
|
358
|
+
// 이전엔 dead variable이라 budget cap이 caller-side stale 값에 의존했다.
|
|
359
|
+
totalInjectedChars = commitResult.totalInjectedChars;
|
|
360
|
+
// toInject은 commit 결과의 newlyAdded만 의미 있음 — evidence/cache 갱신은 이 list 기준
|
|
361
|
+
const newlyAddedNames = new Set(commitResult.newlyAdded.map(e => e.name));
|
|
362
|
+
const effectiveToInject = toInject.filter(sol => newlyAddedNames.has(sol.name));
|
|
363
|
+
// 다른 hook이 모두 먼저 inject했다면 effectiveToInject가 0 — 출력할 게 없음
|
|
364
|
+
if (effectiveToInject.length === 0) {
|
|
365
|
+
console.log(approve());
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
// Save injection cache for Code Reflection (Phase 2) — cumulative merge
|
|
369
|
+
// PR2c-1: withFileLock으로 read-modify-write 보호. 동시 hook이 같은 cache를
|
|
370
|
+
// 만지면 last-writer-wins로 _sessionCounted 등 비트가 사라질 수 있었음.
|
|
371
|
+
const injectionCachePath = path.join(STATE_DIR, `injection-cache-${sanitizeId(sessionId)}.json`);
|
|
372
|
+
try {
|
|
373
|
+
await withFileLock(injectionCachePath, () => {
|
|
374
|
+
// Lock 안에서 fresh re-read
|
|
375
|
+
let existingSolutions = [];
|
|
376
|
+
try {
|
|
377
|
+
if (fs.existsSync(injectionCachePath)) {
|
|
378
|
+
const existing = JSON.parse(fs.readFileSync(injectionCachePath, 'utf-8'));
|
|
379
|
+
if (Array.isArray(existing.solutions))
|
|
380
|
+
existingSolutions = existing.solutions;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
log.debug('injection cache 읽기 실패 — 기존 캐시 없이 새로 시작', e);
|
|
385
|
+
}
|
|
386
|
+
// R5: defensive copy로 SolutionMatch.tags / .identifiers reference 공유 차단.
|
|
387
|
+
// M-3 fix: effectiveToInject는 commitSessionCacheEntries가 검증한 disjoint set만 포함.
|
|
388
|
+
const newSolutions = effectiveToInject.map(sol => ({
|
|
389
|
+
name: sol.name,
|
|
390
|
+
identifiers: [...sol.identifiers],
|
|
391
|
+
tags: [...sol.tags],
|
|
392
|
+
status: sol.status,
|
|
393
|
+
injectedAt: new Date().toISOString(),
|
|
394
|
+
}));
|
|
395
|
+
// BACKFILL: existing entry에 tags 키 자체가 없으면 fresh로 채움.
|
|
396
|
+
const matchedByName = new Map(allMatched.map(m => [m.name, m]));
|
|
397
|
+
const existingNames = new Set(existingSolutions.map(s => s.name));
|
|
398
|
+
const merged = [
|
|
399
|
+
...existingSolutions.map(existing => {
|
|
400
|
+
if (existing.tags !== undefined)
|
|
401
|
+
return existing;
|
|
402
|
+
const fresh = matchedByName.get(existing.name);
|
|
403
|
+
if (!fresh)
|
|
404
|
+
return existing;
|
|
405
|
+
return { ...existing, tags: [...fresh.tags] };
|
|
406
|
+
}),
|
|
407
|
+
...newSolutions.filter(s => !existingNames.has(s.name)),
|
|
408
|
+
];
|
|
409
|
+
const injectionData = {
|
|
410
|
+
solutions: merged,
|
|
411
|
+
updatedAt: new Date().toISOString(),
|
|
412
|
+
};
|
|
413
|
+
// mode 0o600 + dirMode 0o700 — STATE_DIR auto-detect 의존성을 명시화
|
|
414
|
+
atomicWriteJSON(injectionCachePath, injectionData, { mode: 0o600, dirMode: 0o700 });
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
catch (e) {
|
|
418
|
+
if (e instanceof FileLockError) {
|
|
419
|
+
log.warn(`injection cache lock 실패 — write skipped`, e);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
log.debug('injection cache 저장 실패', e);
|
|
362
423
|
}
|
|
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
424
|
}
|
|
398
|
-
|
|
399
|
-
|
|
425
|
+
// Update evidence.injected counters on solution files.
|
|
426
|
+
// M-3 fix: effectiveToInject(commit이 검증한 disjoint set)만 evidence 갱신 →
|
|
427
|
+
// 동시 hook이 같은 솔루션을 inject해도 한 번만 카운트됨.
|
|
428
|
+
try {
|
|
429
|
+
const { updateSolutionEvidence } = await import('./pre-tool-use.js');
|
|
430
|
+
for (const sol of effectiveToInject) {
|
|
431
|
+
updateSolutionEvidence(sol.name, 'injected');
|
|
432
|
+
}
|
|
400
433
|
}
|
|
401
|
-
|
|
402
|
-
|
|
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');
|
|
434
|
+
catch (e) {
|
|
435
|
+
log.debug('evidence.injected counter 업데이트 실패', e);
|
|
409
436
|
}
|
|
437
|
+
// Progressive Disclosure: Tier 1(인덱스) + Tier 2(매칭 요약) push
|
|
438
|
+
// Tier 3(전문)은 compound-read MCP tool로 pull
|
|
439
|
+
// effectiveToInject 사용 — 다른 hook이 이미 inject한 솔루션은 사용자에게 다시 push 안 함
|
|
440
|
+
const injections = effectiveToInject.map(sol => {
|
|
441
|
+
const summary = summaries.get(sol.name) ?? sol.name;
|
|
442
|
+
return `- ${summary}`;
|
|
443
|
+
}).join('\n');
|
|
444
|
+
const header = `Matched solutions (apply these patterns to your response):\n`;
|
|
445
|
+
const footer = `\n\nAPPLY the patterns above to your response. If a pattern is directly relevant, follow its guidance. Use compound-read MCP tool for full details if needed.\nWhen using Grep or Bash, always set head_limit or pipe through | head -n to limit output size.`;
|
|
446
|
+
const fullInjection = header + injections + footer;
|
|
447
|
+
// 플러그인 시그널 기록 (다른 플러그인이 참고할 수 있도록)
|
|
448
|
+
try {
|
|
449
|
+
writeSignal(sessionId, 'UserPromptSubmit', fullInjection.length);
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
log.debug('plugin signal 기록 실패', e);
|
|
453
|
+
}
|
|
454
|
+
console.log(approveWithContext(fullInjection, 'UserPromptSubmit'));
|
|
410
455
|
}
|
|
411
|
-
|
|
412
|
-
|
|
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);
|
|
456
|
+
finally {
|
|
457
|
+
recordHookTiming('solution-injector', Date.now() - _hookStart, 'UserPromptSubmit');
|
|
430
458
|
}
|
|
431
|
-
console.log(approveWithContext(fullInjection, 'UserPromptSubmit'));
|
|
432
459
|
}
|
|
433
460
|
main().catch((e) => {
|
|
434
461
|
process.stderr.write(`[ch-hook] solution-injector: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
435
|
-
console.log(
|
|
462
|
+
console.log(failOpenWithTracking('solution-injector'));
|
|
436
463
|
});
|
|
@@ -13,7 +13,7 @@ import { readStdinJSON } from './shared/read-stdin.js';
|
|
|
13
13
|
import { isHookEnabled } from './hook-config.js';
|
|
14
14
|
import { sanitizeId } from './shared/sanitize-id.js';
|
|
15
15
|
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
16
|
-
import { approve, approveWithWarning,
|
|
16
|
+
import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
|
|
17
17
|
import { STATE_DIR } from '../core/paths.js';
|
|
18
18
|
const MAX_CONCURRENT_AGENTS = 10;
|
|
19
19
|
const AGENT_GC_AGE_MS = 60 * 60 * 1000; // 1시간 이상 종료된 에이전트는 GC
|
|
@@ -86,5 +86,5 @@ async function main() {
|
|
|
86
86
|
}
|
|
87
87
|
main().catch((e) => {
|
|
88
88
|
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
89
|
-
console.log(
|
|
89
|
+
console.log(failOpenWithTracking('subagent-tracker'));
|
|
90
90
|
});
|