@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ja.md +79 -14
  3. package/README.ko.md +89 -14
  4. package/README.md +77 -14
  5. package/README.zh.md +79 -14
  6. package/commands/deep-interview.md +171 -0
  7. package/commands/specify.md +128 -0
  8. package/dist/cli.js +11 -2
  9. package/dist/core/auto-compound-runner.js +34 -1
  10. package/dist/core/dashboard.d.ts +91 -0
  11. package/dist/core/dashboard.js +385 -0
  12. package/dist/core/doctor.js +157 -1
  13. package/dist/core/drift-score.d.ts +49 -0
  14. package/dist/core/drift-score.js +87 -0
  15. package/dist/core/inspect-cli.js +54 -1
  16. package/dist/core/mcp-config.d.ts +2 -0
  17. package/dist/core/mcp-config.js +6 -1
  18. package/dist/core/paths.d.ts +1 -1
  19. package/dist/core/paths.js +1 -1
  20. package/dist/core/spawn.d.ts +7 -2
  21. package/dist/core/spawn.js +45 -7
  22. package/dist/core/v1-bootstrap.js +9 -2
  23. package/dist/engine/compound-export.d.ts +41 -0
  24. package/dist/engine/compound-export.js +169 -0
  25. package/dist/engine/compound-extractor.js +49 -0
  26. package/dist/engine/compound-loop.js +18 -0
  27. package/dist/engine/solution-matcher.d.ts +23 -0
  28. package/dist/engine/solution-matcher.js +124 -11
  29. package/dist/forge/mismatch-detector.js +3 -0
  30. package/dist/hooks/context-guard.d.ts +10 -0
  31. package/dist/hooks/context-guard.js +105 -49
  32. package/dist/hooks/db-guard.js +2 -2
  33. package/dist/hooks/hook-config.d.ts +27 -1
  34. package/dist/hooks/hook-config.js +72 -12
  35. package/dist/hooks/intent-classifier.js +29 -4
  36. package/dist/hooks/keyword-detector.js +114 -106
  37. package/dist/hooks/notepad-injector.js +2 -2
  38. package/dist/hooks/permission-handler.js +2 -2
  39. package/dist/hooks/post-tool-failure.js +12 -6
  40. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  41. package/dist/hooks/post-tool-handlers.js +14 -11
  42. package/dist/hooks/post-tool-use.d.ts +11 -0
  43. package/dist/hooks/post-tool-use.js +184 -71
  44. package/dist/hooks/pre-compact.d.ts +11 -1
  45. package/dist/hooks/pre-compact.js +113 -3
  46. package/dist/hooks/pre-tool-use.js +86 -56
  47. package/dist/hooks/rate-limiter.js +3 -3
  48. package/dist/hooks/secret-filter.js +2 -2
  49. package/dist/hooks/session-recovery.js +256 -236
  50. package/dist/hooks/shared/hook-response.d.ts +7 -0
  51. package/dist/hooks/shared/hook-response.js +20 -0
  52. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  53. package/dist/hooks/shared/hook-timing.js +64 -0
  54. package/dist/hooks/skill-injector.js +41 -12
  55. package/dist/hooks/slop-detector.js +3 -3
  56. package/dist/hooks/solution-injector.js +224 -197
  57. package/dist/hooks/subagent-tracker.js +2 -2
  58. package/dist/mcp/tools.js +114 -0
  59. package/dist/renderer/rule-renderer.js +9 -11
  60. package/dist/store/evidence-store.d.ts +8 -0
  61. package/dist/store/evidence-store.js +51 -0
  62. package/dist/store/rule-store.d.ts +5 -0
  63. package/dist/store/rule-store.js +22 -0
  64. package/package.json +1 -1
  65. package/skills/deep-interview/SKILL.md +166 -0
  66. package/skills/specify/SKILL.md +122 -0
@@ -31,6 +31,73 @@ export function expandTagsWithSynonyms(tags) {
31
31
  return defaultNormalizer.normalizeTerms(tags);
32
32
  }
33
33
  // ── TF-IDF weighting for common tags ──
34
+ // ── Character bigram similarity (Dice coefficient) ──
35
+ /**
36
+ * Compute the Dice coefficient between two strings using character bigrams.
37
+ *
38
+ * Dice = 2 * |intersection| / (|A| + |B|)
39
+ *
40
+ * Both strings are lowercased and whitespace-stripped before bigram generation.
41
+ * Returns 0 for empty strings or single-character strings (no bigrams possible).
42
+ * Returns 1.0 for identical non-trivial strings.
43
+ *
44
+ * This is used as a lightweight fuzzy matching signal for borderline cases
45
+ * where the TF-IDF tag intersection produces a low score but the query and
46
+ * solution tags are character-similar (e.g., "database" vs "데이터베이스"
47
+ * won't match, but "database" vs "databse" will get a high score).
48
+ */
49
+ export function bigramSimilarity(a, b) {
50
+ const na = a.toLowerCase().replace(/\s+/g, '');
51
+ const nb = b.toLowerCase().replace(/\s+/g, '');
52
+ if (na.length < 2 || nb.length < 2)
53
+ return 0;
54
+ if (na === nb)
55
+ return 1.0;
56
+ const bigramsA = new Map();
57
+ for (let i = 0; i < na.length - 1; i++) {
58
+ const bg = na.slice(i, i + 2);
59
+ bigramsA.set(bg, (bigramsA.get(bg) ?? 0) + 1);
60
+ }
61
+ const bigramsB = new Map();
62
+ for (let i = 0; i < nb.length - 1; i++) {
63
+ const bg = nb.slice(i, i + 2);
64
+ bigramsB.set(bg, (bigramsB.get(bg) ?? 0) + 1);
65
+ }
66
+ let intersectionSize = 0;
67
+ for (const [bg, countA] of bigramsA) {
68
+ const countB = bigramsB.get(bg) ?? 0;
69
+ intersectionSize += Math.min(countA, countB);
70
+ }
71
+ const totalA = na.length - 1;
72
+ const totalB = nb.length - 1;
73
+ return (2 * intersectionSize) / (totalA + totalB);
74
+ }
75
+ // ── BM25-like scoring ──
76
+ /**
77
+ * Simplified BM25 score for a single query-document pair.
78
+ * Uses tag overlap with term frequency normalization.
79
+ * k1=1.2, b=0.75 (standard BM25 parameters).
80
+ */
81
+ export function bm25Score(queryTags, docTags, avgDocLength) {
82
+ const k1 = 1.2;
83
+ const b = 0.75;
84
+ const docLen = docTags.length;
85
+ if (docLen === 0 || queryTags.length === 0 || avgDocLength === 0)
86
+ return 0;
87
+ let score = 0;
88
+ for (const qt of queryTags) {
89
+ // Term frequency in document
90
+ const tf = docTags.filter(dt => dt === qt || (dt.length > 3 && qt.length > 3 && (dt.includes(qt) || qt.includes(dt)))).length;
91
+ if (tf === 0)
92
+ continue;
93
+ // BM25 TF saturation
94
+ const numerator = tf * (k1 + 1);
95
+ const denominator = tf + k1 * (1 - b + b * (docLen / avgDocLength));
96
+ score += numerator / denominator;
97
+ }
98
+ // Normalize by query length
99
+ return score / queryTags.length;
100
+ }
34
101
  /** High-frequency tags that should be weighted lower */
35
102
  const COMMON_TAGS = new Set([
36
103
  'typescript', 'ts', 'javascript', 'js', 'fix', 'update', 'add', 'change',
@@ -72,20 +139,66 @@ export function calculateRelevance(promptOrTags, keywordsOrTags, confidence, opt
72
139
  // Apply TF-IDF weighting: common tags count less
73
140
  const weightedMatched = intersection.reduce((sum, t) => sum + tagWeight(t), 0)
74
141
  + partialMatches.reduce((sum, t) => sum + tagWeight(t) * 0.5, 0);
75
- // 완화된 임계값: 가중 점수 0.5 이상이면 후보
76
- if (weightedMatched < 0.5)
142
+ // ── Bigram similarity boost for borderline cases ──
143
+ //
144
+ // When the TF-IDF intersection score is below the match threshold (0.5),
145
+ // compute a character-bigram Dice coefficient between the query tags and
146
+ // the solution tags. If the best bigram similarity is high enough, blend
147
+ // it in at 20% weight (TF-IDF 80%, bigram 20%) to rescue fuzzy matches
148
+ // that the exact/substring intersection missed (e.g., typos, slight
149
+ // morphological variants).
150
+ //
151
+ // When TF-IDF score is already above threshold, the bigram boost is NOT
152
+ // applied — this preserves existing match quality and avoids disturbing
153
+ // already-good rankings. The bigram path is purely a rescue mechanism
154
+ // for borderline cases.
155
+ if (weightedMatched < 0.5) {
156
+ // Compute best bigram similarity across all (promptTag, solutionTag) pairs
157
+ let bestBigramScore = 0;
158
+ const bigramMatchedTags = [];
159
+ for (const st of matchTags) {
160
+ for (const pt of expandedPromptTags) {
161
+ const sim = bigramSimilarity(pt, st);
162
+ if (sim > bestBigramScore) {
163
+ bestBigramScore = sim;
164
+ }
165
+ // Track solution tags with meaningful bigram similarity (> 0.4)
166
+ if (sim > 0.4 && !bigramMatchedTags.includes(st)) {
167
+ bigramMatchedTags.push(st);
168
+ }
169
+ }
170
+ }
171
+ // Only rescue if the bigram signal is strong enough (> 0.4 threshold)
172
+ // to avoid noise from weakly similar strings
173
+ if (bestBigramScore > 0.4) {
174
+ const union = new Set([...promptOrTags, ...keywordsOrTags]).size;
175
+ const tfidfScore = weightedMatched / Math.max(union, 1);
176
+ const blendedScore = tfidfScore * 0.8 + bestBigramScore * 0.2;
177
+ return {
178
+ relevance: blendedScore * (confidence ?? 1),
179
+ matchedTags: [...intersection, ...partialMatches, ...bigramMatchedTags.filter(t => !intersection.includes(t) && !partialMatches.includes(t))],
180
+ };
181
+ }
77
182
  return { relevance: 0, matchedTags: [] };
78
- // Jaccard-like: weighted matched / union.
79
- // Union uses RAW promptTags and RAW solutionTags not the expanded set —
80
- // so that the denominator semantics are unchanged from pre-T2 behaviour.
81
- // This is intentional: expanding both sides of the Jaccard would
82
- // asymmetrically inflate recall and silently shift all baseline metrics.
83
- // R4-T1 explicitly preserves this: `keywordsOrTags` is the raw solution
84
- // tag list, not the compound-expanded `matchTags` used above.
183
+ }
184
+ // Ensemble: TF-IDF (Jaccard) 0.5 + BM25 0.3 + bigram 0.2
85
185
  const union = new Set([...promptOrTags, ...keywordsOrTags]).size;
86
- const tagScore = weightedMatched / Math.max(union, 1);
186
+ const tfidfScore = weightedMatched / Math.max(union, 1);
187
+ // BM25 component: average doc length defaults to 6 tags (typical solution)
188
+ const avgDocLen = options?.avgDocLength ?? 6;
189
+ const bm25 = bm25Score(promptOrTags, keywordsOrTags, avgDocLen);
190
+ // Bigram component (mild boost for partial string matches)
191
+ let bigramBoost = 0;
192
+ for (const st of matchTags) {
193
+ for (const pt of expandedPromptTags) {
194
+ const sim = bigramSimilarity(pt, st);
195
+ if (sim > bigramBoost)
196
+ bigramBoost = sim;
197
+ }
198
+ }
199
+ const ensembleScore = tfidfScore * 0.5 + bm25 * 0.3 + bigramBoost * 0.2;
87
200
  return {
88
- relevance: tagScore * (confidence ?? 1),
201
+ relevance: ensembleScore * (confidence ?? 1),
89
202
  matchedTags: [...intersection, ...partialMatches],
90
203
  };
91
204
  }
@@ -34,6 +34,9 @@ export function computeSessionSignals(sessionId, corrections, summaries, newStro
34
34
  if (direction === 'opposite') {
35
35
  signals.push({ session_id: sessionId, axis, score: 2, reason: `반대 방향 correction: ${c.summary}` });
36
36
  }
37
+ if (direction === 'same' && (axis === 'quality_safety' || axis === 'autonomy')) {
38
+ signals.push({ session_id: sessionId, axis, score: 1, reason: `교정 누적: ${c.summary}` });
39
+ }
37
40
  }
38
41
  }
39
42
  }
@@ -19,6 +19,16 @@ export declare function shouldWarn(contextPercent: {
19
19
  charsThreshold?: number;
20
20
  cooldownMs?: number;
21
21
  }): boolean;
22
+ /** auto-compact 트리거 여부 판정 (순수 함수) */
23
+ export declare function shouldAutoCompact(state: {
24
+ totalChars: number;
25
+ lastAutoCompactAt: number;
26
+ }, thresholds?: {
27
+ charsThreshold?: number;
28
+ cooldownMs?: number;
29
+ }): boolean;
30
+ /** auto-compact 지시 메시지 생성 (순수 함수) */
31
+ export declare function buildAutoCompactMessage(totalChars: number): string;
22
32
  /** 경고 메시지 생성 (순수 함수) */
23
33
  export declare function buildContextWarningMessage(promptCount: number, totalChars: number): string;
24
34
  export declare function main(): Promise<void>;
@@ -16,14 +16,18 @@ import { createLogger } from '../core/logger.js';
16
16
  import { readStdinJSON } from './shared/read-stdin.js';
17
17
  import { atomicWriteJSON } from './shared/atomic-write.js';
18
18
  import { loadHookConfig, isHookEnabled } from './hook-config.js';
19
- import { approve, approveWithContext, approveWithWarning, failOpen } from './shared/hook-response.js';
19
+ import { approve, approveWithContext, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
20
20
  import { HANDOFFS_DIR, STATE_DIR } from '../core/paths.js';
21
+ import { recordHookTiming } from './shared/hook-timing.js';
21
22
  const log = createLogger('context-guard');
22
23
  const CONTEXT_STATE_PATH = path.join(STATE_DIR, 'context-guard.json');
23
24
  // 경고 임계값: 프롬프트 50회 또는 총 문자 수 200K 이상
24
25
  const PROMPT_WARNING_THRESHOLD = 50;
25
26
  const CHARS_WARNING_THRESHOLD = 200_000;
26
27
  const WARNING_COOLDOWN_MS = 10 * 60 * 1000; // 10분 쿨다운
28
+ // Auto-compact 임계값: 추적된 문자 120K ≈ 실제 context ~20%
29
+ const AUTO_COMPACT_CHARS_THRESHOLD = 120_000;
30
+ const AUTO_COMPACT_COOLDOWN_MS = 5 * 60 * 1000; // 5분 쿨다운
27
31
  /** 경고 표시 여부 판정 (순수 함수) */
28
32
  export function shouldWarn(contextPercent, thresholds = {}) {
29
33
  const promptThreshold = thresholds.promptThreshold ?? PROMPT_WARNING_THRESHOLD;
@@ -33,6 +37,21 @@ export function shouldWarn(contextPercent, thresholds = {}) {
33
37
  return ((contextPercent.promptCount >= promptThreshold || contextPercent.totalChars >= charsThreshold) &&
34
38
  (now - contextPercent.lastWarningAt > cooldownMs));
35
39
  }
40
+ /** auto-compact 트리거 여부 판정 (순수 함수) */
41
+ export function shouldAutoCompact(state, thresholds = {}) {
42
+ const charsThreshold = thresholds.charsThreshold ?? AUTO_COMPACT_CHARS_THRESHOLD;
43
+ const cooldownMs = thresholds.cooldownMs ?? AUTO_COMPACT_COOLDOWN_MS;
44
+ const now = Date.now();
45
+ return state.totalChars >= charsThreshold && (now - state.lastAutoCompactAt > cooldownMs);
46
+ }
47
+ /** auto-compact 지시 메시지 생성 (순수 함수) */
48
+ export function buildAutoCompactMessage(totalChars) {
49
+ return `<forgen-auto-compact>
50
+ [Forgen] Context 사용량이 ${Math.round(totalChars / 1000)}K 문자에 도달했습니다 (추정 ~20%+).
51
+ 지금 즉시 /compact를 실행하여 컨텍스트를 압축하세요. 이 지시는 자동으로 생성되었습니다.
52
+ 현재 작업을 마무리하지 말고, 다음 응답에서 바로 compact를 실행하세요.
53
+ </forgen-auto-compact>`;
54
+ }
36
55
  /** 경고 메시지 생성 (순수 함수) */
37
56
  export function buildContextWarningMessage(promptCount, totalChars) {
38
57
  return `<compound-context-warning>\n[Forgen] Context limit approaching: ${promptCount} prompts, ${Math.round(totalChars / 1000)}K characters.\nIf you have important progress, save it now:\n- Use cancelforgen to reset mode state and start a new session\n- Or continue current work (auto compaction may occur)\n</compound-context-warning>`;
@@ -48,67 +67,104 @@ function loadContextState(sessionId) {
48
67
  catch (e) {
49
68
  log.debug('context state 파일 읽기/파싱 실패', e);
50
69
  }
51
- return { promptCount: 0, totalChars: 0, lastWarningAt: 0, sessionId };
70
+ return { promptCount: 0, totalChars: 0, lastWarningAt: 0, lastAutoCompactAt: 0, sessionId };
52
71
  }
53
72
  function saveContextState(state) {
54
73
  atomicWriteJSON(CONTEXT_STATE_PATH, state);
55
74
  }
56
75
  export async function main() {
57
- const input = await readStdinJSON();
58
- if (!isHookEnabled('context-guard')) {
59
- console.log(approve());
60
- return;
61
- }
62
- if (!input) {
63
- console.log(approve());
64
- return;
65
- }
66
- const sessionId = input.session_id ?? 'default';
67
- // Stop 훅: stop_hook_type이 있으면 처리
68
- if (input.stop_hook_type) {
69
- // 에러가 포함된 경우: context limit 감지
70
- if (input.error) {
71
- const errorMsg = input.error;
72
- if (/context.*limit|token.*limit|conversation.*too.*long/i.test(errorMsg)) {
73
- saveHandoff(sessionId, 'context-limit', errorMsg);
74
- console.log(approveWithWarning(`[Forgen] Context limit reached. Current state has been saved to ~/.forgen/handoffs/.\nThe previous work will be automatically recovered in the next session.`));
75
- return;
76
+ const _hookStart = Date.now();
77
+ let _hookEvent = 'UserPromptSubmit';
78
+ try {
79
+ const input = await readStdinJSON();
80
+ if (!isHookEnabled('context-guard')) {
81
+ console.log(approve());
82
+ return;
83
+ }
84
+ if (!input) {
85
+ console.log(approve());
86
+ return;
87
+ }
88
+ const sessionId = input.session_id ?? 'default';
89
+ // Stop 훅: stop_hook_type이 있으면 처리
90
+ if (input.stop_hook_type) {
91
+ _hookEvent = 'Stop';
92
+ // 에러가 포함된 경우: context limit 감지
93
+ if (input.error) {
94
+ const errorMsg = input.error;
95
+ if (/context.*limit|token.*limit|conversation.*too.*long/i.test(errorMsg)) {
96
+ saveHandoff(sessionId, 'context-limit', errorMsg);
97
+ try {
98
+ const resumePath = path.join(STATE_DIR, 'pending-resume.json');
99
+ fs.writeFileSync(resumePath, JSON.stringify({
100
+ reason: 'token-limit',
101
+ sessionId,
102
+ savedAt: new Date().toISOString(),
103
+ cwd: process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
104
+ }, null, 2));
105
+ }
106
+ catch { /* fail-open */ }
107
+ console.log(approveWithWarning(`[Forgen] Context limit reached. Current state has been saved to ~/.forgen/handoffs/.\nThe previous work will be automatically recovered in the next session.`));
108
+ return;
109
+ }
76
110
  }
111
+ // 정상 종료 시: 의미 있는 세션이었으면 compound 안내/자동 트리거
112
+ if (input.stop_hook_type === 'user' || input.stop_hook_type === 'end_turn') {
113
+ const state = loadContextState(sessionId);
114
+ if (state.promptCount >= 20) {
115
+ // 20+ prompts: auto-trigger compound by writing marker
116
+ try {
117
+ fs.mkdirSync(STATE_DIR, { recursive: true });
118
+ const marker = { reason: 'session-end', promptCount: state.promptCount, detectedAt: new Date().toISOString() };
119
+ fs.writeFileSync(path.join(STATE_DIR, 'pending-compound.json'), JSON.stringify(marker));
120
+ }
121
+ catch { /* fail-open: marker write failure is non-critical */ }
122
+ console.log(approveWithWarning(`[Forgen] Session with ${state.promptCount} prompts ended. Compound loop will auto-trigger on next session start.`));
123
+ return;
124
+ }
125
+ if (state.promptCount >= 10) {
126
+ // 10-19 prompts: suggest /compound manually
127
+ console.log(approveWithWarning(`[Forgen] 이 세션에서 ${state.promptCount}개의 프롬프트를 처리했습니다. /compound 를 실행하면 이 세션의 학습 내용을 축적할 수 있습니다.`));
128
+ return;
129
+ }
130
+ }
131
+ console.log(approve());
132
+ return;
133
+ }
134
+ // error만 있는 경우 (stop_hook_type 없이)
135
+ if (input.error) {
136
+ console.log(approve());
137
+ return;
77
138
  }
78
- // 정상 종료 시: 의미 있는 세션이었으면 compound 안내
79
- if (input.stop_hook_type === 'user' || input.stop_hook_type === 'end_turn') {
139
+ // UserPromptSubmit 훅: 대화 길이 추적
140
+ if (input.prompt) {
141
+ const config = loadHookConfig('context-guard');
142
+ // maxTokens가 설정되어 있으면 chars threshold로 사용 (토큰 ≈ 4자 기준 환산)
143
+ const charsThreshold = typeof config?.maxTokens === 'number' ? config.maxTokens * 4 : undefined;
80
144
  const state = loadContextState(sessionId);
81
- if (state.promptCount >= 10) {
82
- // 10 프롬프트 이상이면 의미 있는 세션 — compound 안내
83
- console.log(approveWithWarning(`[Forgen] 세션에서 ${state.promptCount}개의 프롬프트를 처리했습니다. /compound 실행하면 이 세션의 학습 내용을 축적할 수 있습니다.`));
145
+ state.promptCount++;
146
+ state.totalChars += input.prompt.length;
147
+ // auto-compact: 추적 문자 120K 이상이면 compact 지시 주입
148
+ const autoCompactThreshold = typeof config?.autoCompactChars === 'number' ? config.autoCompactChars : undefined;
149
+ if (shouldAutoCompact(state, autoCompactThreshold !== undefined ? { charsThreshold: autoCompactThreshold } : {})) {
150
+ state.lastAutoCompactAt = Date.now();
151
+ saveContextState(state);
152
+ console.log(approveWithContext(buildAutoCompactMessage(state.totalChars), 'UserPromptSubmit'));
84
153
  return;
85
154
  }
155
+ if (shouldWarn(state, charsThreshold !== undefined ? { charsThreshold } : {})) {
156
+ state.lastWarningAt = Date.now();
157
+ saveContextState(state);
158
+ console.log(approveWithContext(buildContextWarningMessage(state.promptCount, state.totalChars), 'UserPromptSubmit'));
159
+ return;
160
+ }
161
+ saveContextState(state);
86
162
  }
87
163
  console.log(approve());
88
- return;
89
- }
90
- // error만 있는 경우 (stop_hook_type 없이)
91
- if (input.error) {
92
- console.log(approve());
93
- return;
94
164
  }
95
- // UserPromptSubmit 훅: 대화 길이 추적
96
- if (input.prompt) {
97
- const config = loadHookConfig('context-guard');
98
- // maxTokens가 설정되어 있으면 chars threshold로 사용 (토큰 ≈ 4자 기준 환산)
99
- const charsThreshold = typeof config?.maxTokens === 'number' ? config.maxTokens * 4 : undefined;
100
- const state = loadContextState(sessionId);
101
- state.promptCount++;
102
- state.totalChars += input.prompt.length;
103
- if (shouldWarn(state, charsThreshold !== undefined ? { charsThreshold } : {})) {
104
- state.lastWarningAt = Date.now();
105
- saveContextState(state);
106
- console.log(approveWithContext(buildContextWarningMessage(state.promptCount, state.totalChars), 'UserPromptSubmit'));
107
- return;
108
- }
109
- saveContextState(state);
165
+ finally {
166
+ recordHookTiming('context-guard', Date.now() - _hookStart, _hookEvent);
110
167
  }
111
- console.log(approve());
112
168
  }
113
169
  function saveHandoff(sessionId, reason, detail) {
114
170
  fs.mkdirSync(HANDOFFS_DIR, { recursive: true });
@@ -151,6 +207,6 @@ function saveHandoff(sessionId, reason, detail) {
151
207
  if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
152
208
  main().catch((e) => {
153
209
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
154
- console.log(failOpen());
210
+ console.log(failOpenWithTracking('context-guard'));
155
211
  });
156
212
  }
@@ -9,7 +9,7 @@ import * as path from 'node:path';
9
9
  import { readStdinJSON } from './shared/read-stdin.js';
10
10
  import { atomicWriteJSON } from './shared/atomic-write.js';
11
11
  import { isHookEnabled } from './hook-config.js';
12
- import { approve, approveWithWarning, deny, failOpen } from './shared/hook-response.js';
12
+ import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
13
13
  import { STATE_DIR } from '../core/paths.js';
14
14
  const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'db-guard-fail-counter.json');
15
15
  const FAIL_CLOSE_THRESHOLD = 3;
@@ -101,5 +101,5 @@ async function main() {
101
101
  }
102
102
  main().catch((e) => {
103
103
  process.stderr.write(`[ch-hook] DB Guard error: ${e instanceof Error ? e.message : String(e)}\n`);
104
- console.log(failOpen());
104
+ console.log(failOpenWithTracking('db-guard'));
105
105
  });
@@ -1,9 +1,17 @@
1
1
  /**
2
2
  * Forgen — Hook Config Loader
3
3
  *
4
- * ~/.compound/hook-config.json 에서 훅별 설정을 읽어 반환합니다.
4
+ * hook-config.json 에서 훅별 설정을 읽어 반환합니다.
5
5
  * 파일이 없거나 읽기에 실패하면 null 을 반환합니다 (failure-tolerant).
6
6
  *
7
+ * 설정 로딩 우선순위:
8
+ * 1. 프로젝트 레벨: {cwd}/.forgen/hook-config.json
9
+ * 2. 글로벌 레벨: FORGEN_HOME/hook-config.json (~/.forgen/hook-config.json)
10
+ * 프로젝트 설정은 글로벌 설정과 머지됩니다 (훅 단위 오버라이드).
11
+ * 프로젝트 설정이 없으면 글로벌 설정만 사용 (하위호환).
12
+ *
13
+ * cwd 결정: process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd()
14
+ *
7
15
  * 설정 형식 (hook-config.json):
8
16
  * {
9
17
  * "tiers": { "compound-core": { "enabled": true }, "safety": { "enabled": true }, "workflow": { "enabled": true } },
@@ -15,6 +23,24 @@
15
23
  * - compound-core 티어는 tiers 설정으로 비활성화 불가 (복리화 보호)
16
24
  * - 개별 hooks.hookName.enabled: false 로만 비활성화 가능
17
25
  */
26
+ /** 훅 설정 파일의 전체 구조 타입 */
27
+ export type HookConfig = Record<string, unknown>;
28
+ /**
29
+ * 프로젝트의 작업 디렉토리를 결정합니다.
30
+ * FORGEN_CWD → COMPOUND_CWD → process.cwd() 순서.
31
+ */
32
+ export declare function resolveProjectCwd(): string;
33
+ /**
34
+ * 글로벌 설정과 프로젝트 설정을 머지합니다.
35
+ * 프로젝트 설정이 글로벌 설정을 훅 단위로 오버라이드합니다.
36
+ *
37
+ * 머지 규칙:
38
+ * - tiers: 프로젝트가 글로벌을 훅 단위로 오버라이드 (shallow merge)
39
+ * - hooks: 프로젝트가 글로벌을 훅 단위로 오버라이드 (shallow merge)
40
+ * - 최상위 레거시 키: 프로젝트가 글로벌을 키 단위로 오버라이드
41
+ * - 프로젝트에 없는 키는 글로벌에서 상속
42
+ */
43
+ export declare function mergeHookConfigs(global: HookConfig, project: HookConfig): HookConfig;
18
44
  /** 특정 훅의 설정을 반환합니다. 실패 시 null 반환. */
19
45
  export declare function loadHookConfig(hookName: string): Record<string, unknown> | null;
20
46
  /**
@@ -1,9 +1,17 @@
1
1
  /**
2
2
  * Forgen — Hook Config Loader
3
3
  *
4
- * ~/.compound/hook-config.json 에서 훅별 설정을 읽어 반환합니다.
4
+ * hook-config.json 에서 훅별 설정을 읽어 반환합니다.
5
5
  * 파일이 없거나 읽기에 실패하면 null 을 반환합니다 (failure-tolerant).
6
6
  *
7
+ * 설정 로딩 우선순위:
8
+ * 1. 프로젝트 레벨: {cwd}/.forgen/hook-config.json
9
+ * 2. 글로벌 레벨: FORGEN_HOME/hook-config.json (~/.forgen/hook-config.json)
10
+ * 프로젝트 설정은 글로벌 설정과 머지됩니다 (훅 단위 오버라이드).
11
+ * 프로젝트 설정이 없으면 글로벌 설정만 사용 (하위호환).
12
+ *
13
+ * cwd 결정: process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd()
14
+ *
7
15
  * 설정 형식 (hook-config.json):
8
16
  * {
9
17
  * "tiers": { "compound-core": { "enabled": true }, "safety": { "enabled": true }, "workflow": { "enabled": true } },
@@ -19,30 +27,82 @@ import * as fs from 'node:fs';
19
27
  import * as path from 'node:path';
20
28
  import { HOOK_REGISTRY } from './hook-registry.js';
21
29
  import { FORGEN_HOME } from '../core/paths.js';
22
- const HOOK_CONFIG_PATH = path.join(FORGEN_HOME, 'hook-config.json');
30
+ const GLOBAL_CONFIG_PATH = path.join(FORGEN_HOME, 'hook-config.json');
23
31
  /**
24
32
  * 훅 → 티어 매핑 (hook-registry.ts에서 자동 파생).
25
33
  * 이중 구현 방지: HOOK_REGISTRY가 단일 소스 오브 트루스.
26
34
  */
27
35
  const HOOK_TIER_MAP = Object.fromEntries(HOOK_REGISTRY.map(h => [h.name, h.tier]));
36
+ /**
37
+ * 프로젝트의 작업 디렉토리를 결정합니다.
38
+ * FORGEN_CWD → COMPOUND_CWD → process.cwd() 순서.
39
+ */
40
+ export function resolveProjectCwd() {
41
+ return process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
42
+ }
43
+ /** JSON 파일을 파싱하여 반환. 파일 없음 또는 파싱 실패 시 null. */
44
+ function loadJsonFile(filePath) {
45
+ try {
46
+ if (!fs.existsSync(filePath))
47
+ return null;
48
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /**
55
+ * 글로벌 설정과 프로젝트 설정을 머지합니다.
56
+ * 프로젝트 설정이 글로벌 설정을 훅 단위로 오버라이드합니다.
57
+ *
58
+ * 머지 규칙:
59
+ * - tiers: 프로젝트가 글로벌을 훅 단위로 오버라이드 (shallow merge)
60
+ * - hooks: 프로젝트가 글로벌을 훅 단위로 오버라이드 (shallow merge)
61
+ * - 최상위 레거시 키: 프로젝트가 글로벌을 키 단위로 오버라이드
62
+ * - 프로젝트에 없는 키는 글로벌에서 상속
63
+ */
64
+ export function mergeHookConfigs(global, project) {
65
+ const merged = { ...global };
66
+ // tiers 머지 (shallow per-tier)
67
+ const globalTiers = global.tiers;
68
+ const projectTiers = project.tiers;
69
+ if (globalTiers || projectTiers) {
70
+ merged.tiers = { ...globalTiers, ...projectTiers };
71
+ }
72
+ // hooks 머지 (shallow per-hook)
73
+ const globalHooks = global.hooks;
74
+ const projectHooks = project.hooks;
75
+ if (globalHooks || projectHooks) {
76
+ merged.hooks = { ...globalHooks, ...projectHooks };
77
+ }
78
+ // 나머지 최상위 키: 프로젝트가 글로벌을 오버라이드
79
+ for (const key of Object.keys(project)) {
80
+ if (key === 'tiers' || key === 'hooks')
81
+ continue;
82
+ merged[key] = project[key];
83
+ }
84
+ return merged;
85
+ }
28
86
  /** 프로세스 내 설정 캐시 (각 훅은 별도 프로세스이므로 수명 = 1회 실행) */
29
87
  let _configCache;
30
- /** 전체 설정 파일을 파싱합니다. 실패 시 null. 프로세스 내 캐싱. */
88
+ /** 전체 설정 파일을 파싱합니다 (글로벌 + 프로젝트 머지). 실패 시 null. 프로세스 내 캐싱. */
31
89
  function loadFullConfig() {
32
90
  if (_configCache !== undefined)
33
91
  return _configCache;
34
- try {
35
- if (!fs.existsSync(HOOK_CONFIG_PATH)) {
36
- _configCache = null;
37
- return null;
38
- }
39
- _configCache = JSON.parse(fs.readFileSync(HOOK_CONFIG_PATH, 'utf-8'));
40
- return _configCache;
41
- }
42
- catch {
92
+ const globalConfig = loadJsonFile(GLOBAL_CONFIG_PATH);
93
+ const projectConfigPath = path.join(resolveProjectCwd(), '.forgen', 'hook-config.json');
94
+ const projectConfig = loadJsonFile(projectConfigPath);
95
+ if (!globalConfig && !projectConfig) {
43
96
  _configCache = null;
44
97
  return null;
45
98
  }
99
+ if (globalConfig && projectConfig) {
100
+ _configCache = mergeHookConfigs(globalConfig, projectConfig);
101
+ }
102
+ else {
103
+ _configCache = globalConfig ?? projectConfig ?? null;
104
+ }
105
+ return _configCache;
46
106
  }
47
107
  /** 특정 훅의 설정을 반환합니다. 실패 시 null 반환. */
48
108
  export function loadHookConfig(hookName) {
@@ -10,9 +10,9 @@
10
10
  */
11
11
  import { readStdinJSON } from './shared/read-stdin.js';
12
12
  import { isHookEnabled } from './hook-config.js';
13
- import { approve, approveWithContext, failOpen } from './shared/hook-response.js';
13
+ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
14
14
  const INTENT_RULES = [
15
- { intent: 'implement', pattern: /(?:만들어|추가해|구현해|생성해|작성해|넣어|create|add|implement|build|write|make)\b/i },
15
+ { intent: 'implement', pattern: /(?:만들어|추가해|구현해|생성해|작성해|넣어|create|add|implement|build|write|make)(?:\b|(?=[가-힣\s]|$))/i },
16
16
  { intent: 'debug', pattern: /(?:에러|버그|안돼|안\s*되|안\s*됨|왜|고쳐|수정해|fix|bug|error|debug|문제|실패|fail|crash|broken)/i },
17
17
  { intent: 'refactor', pattern: /(?:리팩토링|리팩터|정리|개선|refactor|clean\s*up|improve|optimize|최적화)/i },
18
18
  { intent: 'explain', pattern: /(?:설명|알려|뭐야|뭔가요|어떻게|explain|what\s+is|how\s+does|why\s+does|tell\s+me)/i },
@@ -30,6 +30,29 @@ const INTENT_HINTS = {
30
30
  design: 'Design task. Specify trade-offs explicitly.',
31
31
  general: 'General request.',
32
32
  };
33
+ /** Intent-specific context rules injected via additionalContext */
34
+ const INTENT_CONTEXT = {
35
+ implement: `[quality-rules]
36
+ - Write tests for new logic (branch coverage 83%+)
37
+ - Build + lint + type-check must pass before completion
38
+ - Prefer small incremental changes (<200 lines)
39
+ - Interfaces and type contracts before implementation`,
40
+ review: `[review-rules]
41
+ - Report format: [SEVERITY] file:line — issue
42
+ - Check: logic errors, security (OWASP), performance, maintainability
43
+ - Verify edge cases and error handling at system boundaries
44
+ - No empty catch blocks, no eslint-disable without justification`,
45
+ debug: `[debug-rules]
46
+ - Reproduce the bug first, then isolate the root cause
47
+ - Write a failing test that captures the bug before fixing
48
+ - Check for regression: does the fix break anything else?
49
+ - Read error messages carefully — they usually point to the cause`,
50
+ refactor: `[refactor-rules]
51
+ - Ensure all tests pass before AND after refactoring
52
+ - Make one structural change at a time, verify between each
53
+ - Preserve external behavior — refactoring changes structure, not function
54
+ - Avoid mixing refactoring with feature changes in the same pass`,
55
+ };
33
56
  export function classifyIntent(prompt) {
34
57
  for (const rule of INTENT_RULES) {
35
58
  if (rule.pattern.test(prompt)) {
@@ -54,9 +77,11 @@ async function main() {
54
77
  return;
55
78
  }
56
79
  const hint = INTENT_HINTS[intent];
57
- console.log(approveWithContext(`[intent: ${intent}] ${hint}`, 'UserPromptSubmit'));
80
+ const extra = INTENT_CONTEXT[intent] ?? '';
81
+ const context = extra ? `[intent: ${intent}] ${hint}\n${extra}` : `[intent: ${intent}] ${hint}`;
82
+ console.log(approveWithContext(context, 'UserPromptSubmit'));
58
83
  }
59
84
  main().catch((e) => {
60
85
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
61
- console.log(failOpen());
86
+ console.log(failOpenWithTracking('intent-classifier'));
62
87
  });