@wooojin/forgen 0.2.0 → 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 (55) 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 +9 -0
  9. package/dist/core/dashboard.d.ts +91 -0
  10. package/dist/core/dashboard.js +385 -0
  11. package/dist/core/doctor.js +151 -21
  12. package/dist/core/drift-score.d.ts +49 -0
  13. package/dist/core/drift-score.js +87 -0
  14. package/dist/core/mcp-config.d.ts +2 -0
  15. package/dist/core/mcp-config.js +6 -1
  16. package/dist/core/paths.d.ts +1 -1
  17. package/dist/core/paths.js +1 -1
  18. package/dist/engine/compound-export.d.ts +41 -0
  19. package/dist/engine/compound-export.js +169 -0
  20. package/dist/engine/compound-loop.js +18 -0
  21. package/dist/engine/solution-matcher.d.ts +23 -0
  22. package/dist/engine/solution-matcher.js +124 -11
  23. package/dist/hooks/context-guard.d.ts +10 -0
  24. package/dist/hooks/context-guard.js +104 -58
  25. package/dist/hooks/db-guard.js +2 -2
  26. package/dist/hooks/hook-config.d.ts +27 -1
  27. package/dist/hooks/hook-config.js +72 -12
  28. package/dist/hooks/intent-classifier.d.ts +0 -2
  29. package/dist/hooks/intent-classifier.js +32 -18
  30. package/dist/hooks/keyword-detector.js +117 -111
  31. package/dist/hooks/notepad-injector.js +2 -2
  32. package/dist/hooks/permission-handler.js +2 -2
  33. package/dist/hooks/post-tool-failure.js +12 -6
  34. package/dist/hooks/post-tool-handlers.d.ts +1 -1
  35. package/dist/hooks/post-tool-handlers.js +14 -11
  36. package/dist/hooks/post-tool-use.d.ts +11 -0
  37. package/dist/hooks/post-tool-use.js +184 -71
  38. package/dist/hooks/pre-compact.d.ts +11 -1
  39. package/dist/hooks/pre-compact.js +112 -37
  40. package/dist/hooks/pre-tool-use.js +86 -56
  41. package/dist/hooks/rate-limiter.js +3 -3
  42. package/dist/hooks/secret-filter.js +2 -2
  43. package/dist/hooks/session-recovery.js +256 -236
  44. package/dist/hooks/shared/hook-response.d.ts +4 -4
  45. package/dist/hooks/shared/hook-response.js +13 -24
  46. package/dist/hooks/shared/hook-timing.d.ts +15 -0
  47. package/dist/hooks/shared/hook-timing.js +64 -0
  48. package/dist/hooks/skill-injector.js +41 -12
  49. package/dist/hooks/slop-detector.js +3 -3
  50. package/dist/hooks/solution-injector.js +224 -197
  51. package/dist/hooks/subagent-tracker.js +2 -2
  52. package/dist/renderer/rule-renderer.js +9 -11
  53. package/package.json +1 -1
  54. package/skills/deep-interview/SKILL.md +166 -0
  55. package/skills/specify/SKILL.md +122 -0
@@ -16,8 +16,29 @@ import { saveCheckpoint } from './session-recovery.js';
16
16
  // v1: recordWriteContent (regex 선호 감지) 제거
17
17
  import { incrementFailureCounter, checkCompoundNegative, getCompoundSuccessHint } from './post-tool-handlers.js';
18
18
  import { isHookEnabled } from './hook-config.js';
19
- import { approve, approveWithWarning, failOpen } from './shared/hook-response.js';
19
+ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
20
20
  import { STATE_DIR } from '../core/paths.js';
21
+ import { recordHookTiming } from './shared/hook-timing.js';
22
+ import { createDriftState, evaluateDrift } from '../core/drift-score.js';
23
+ /** Lightweight hash for content comparison (not cryptographic) */
24
+ function simpleHash(content) {
25
+ let hash = 0;
26
+ for (let i = 0; i < content.length; i++) {
27
+ const char = content.charCodeAt(i);
28
+ hash = ((hash << 5) - hash) + char;
29
+ hash |= 0; // Convert to 32-bit integer
30
+ }
31
+ return hash.toString(36);
32
+ }
33
+ const IMPLICIT_FEEDBACK_LOG = path.join(STATE_DIR, 'implicit-feedback.jsonl');
34
+ /** Record implicit feedback signal to JSONL */
35
+ function recordImplicitFeedback(entry) {
36
+ try {
37
+ fs.mkdirSync(STATE_DIR, { recursive: true });
38
+ fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, JSON.stringify(entry) + '\n');
39
+ }
40
+ catch { /* fail-open: implicit feedback recording must not throw */ }
41
+ }
21
42
  // ── State management ──
22
43
  function getModifiedFilesPath(sessionId) {
23
44
  return path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
@@ -53,6 +74,28 @@ export function detectErrorPattern(text) {
53
74
  }
54
75
  return null;
55
76
  }
77
+ const AGENT_MIN_OUTPUT_LENGTH = 50;
78
+ const AGENT_QUALITY_PATTERNS = [
79
+ { pattern: /I (?:couldn'?t|could not|was unable to|cannot) (?:find|locate|access|determine)/i, signal: 'agent_unable', severity: 'warning', message: 'Agent reported inability to complete the task' },
80
+ { pattern: /(?:no (?:files?|results?|matches?) found|returned? (?:no|empty|zero) results?)/i, signal: 'agent_no_results', severity: 'warning', message: 'Agent found no results' },
81
+ { pattern: /(?:timed? ?out|deadline exceeded|execution expired)/i, signal: 'agent_timeout', severity: 'error', message: 'Agent execution may have timed out' },
82
+ { pattern: /(?:context (?:window|limit) (?:exceeded|reached)|too (?:large|long) to (?:read|process))/i, signal: 'agent_context_overflow', severity: 'warning', message: 'Agent hit context limits — output may be incomplete' },
83
+ ];
84
+ export function validateAgentOutput(toolResponse) {
85
+ if (!toolResponse || toolResponse.trim().length < AGENT_MIN_OUTPUT_LENGTH) {
86
+ return {
87
+ signal: 'agent_empty_output',
88
+ severity: 'warning',
89
+ message: `Agent returned minimal output (${toolResponse?.trim().length ?? 0} chars). Verify the result is usable.`,
90
+ };
91
+ }
92
+ for (const p of AGENT_QUALITY_PATTERNS) {
93
+ if (p.pattern.test(toolResponse)) {
94
+ return { signal: p.signal, severity: p.severity, message: p.message };
95
+ }
96
+ }
97
+ return null;
98
+ }
56
99
  export function trackModifiedFile(state, filePath, toolName) {
57
100
  const existing = state.files[filePath];
58
101
  const count = (existing?.count ?? 0) + 1;
@@ -65,87 +108,157 @@ export function trackModifiedFile(state, filePath, toolName) {
65
108
  }
66
109
  // ── Main flow ──
67
110
  async function main() {
68
- const data = await readStdinJSON();
69
- if (!data) {
70
- console.log(approve());
71
- return;
72
- }
73
- if (!isHookEnabled('post-tool-use')) {
74
- console.log(approve());
75
- return;
76
- }
77
- const toolName = data.tool_name ?? data.toolName ?? '';
78
- const toolInput = data.tool_input ?? data.toolInput ?? {};
79
- const toolResponse = data.tool_response ?? data.toolOutput ?? '';
80
- const sessionId = data.session_id ?? 'default';
81
- const modState = loadModifiedFiles(sessionId);
82
- modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
83
- const messages = [];
84
- // 1. Checkpoint (every 5 calls)
85
- if (modState.toolCallCount % 5 === 0) {
86
- try {
87
- saveCheckpoint({
88
- sessionId, mode: 'active',
89
- modifiedFiles: Object.keys(modState.files),
90
- lastToolCall: toolName,
91
- toolCallCount: modState.toolCallCount,
92
- timestamp: new Date().toISOString(),
93
- cwd: data.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
94
- });
111
+ const _hookStart = Date.now();
112
+ try {
113
+ const data = await readStdinJSON();
114
+ if (!data) {
115
+ console.log(approve());
116
+ return;
95
117
  }
96
- catch (e) {
97
- log.debug('체크포인트 저장 실패', e);
118
+ if (!isHookEnabled('post-tool-use')) {
119
+ console.log(approve());
120
+ return;
98
121
  }
99
- }
100
- // 2. File change tracking (Write, Edit)
101
- if (toolName === 'Write' || toolName === 'Edit') {
102
- const filePath = toolInput.file_path ?? toolInput.filePath ?? '';
103
- if (filePath) {
122
+ const toolName = data.tool_name ?? data.toolName ?? '';
123
+ const toolInput = data.tool_input ?? data.toolInput ?? {};
124
+ const toolResponse = data.tool_response ?? data.toolOutput ?? '';
125
+ const sessionId = data.session_id ?? 'default';
126
+ const modState = loadModifiedFiles(sessionId);
127
+ modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
128
+ const messages = [];
129
+ let revertDetected = false;
130
+ // 1. Checkpoint (every 5 calls)
131
+ if (modState.toolCallCount % 5 === 0) {
104
132
  try {
105
- const { count } = trackModifiedFile(modState, filePath, toolName);
106
- if (count >= 5) {
107
- messages.push(`<compound-tool-warning>\n[Forgen] ⚠ ${path.basename(filePath)} has been modified ${count} times.\nConsider redesigning the overall structure and restarting.\n</compound-tool-warning>`);
108
- }
133
+ saveCheckpoint({
134
+ sessionId, mode: 'active',
135
+ modifiedFiles: Object.keys(modState.files),
136
+ lastToolCall: toolName,
137
+ toolCallCount: modState.toolCallCount,
138
+ timestamp: new Date().toISOString(),
139
+ cwd: data.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd(),
140
+ });
109
141
  }
110
142
  catch (e) {
111
- log.debug('파일 변경 추적 실패', e);
143
+ log.debug('체크포인트 저장 실패', e);
112
144
  }
113
145
  }
114
- // v1: regex 기반 write content 학습 제거. Evidence 기반으로 전환됨.
115
- }
116
- // 4. Bash error detection
117
- if (toolName === 'Bash' && toolResponse) {
118
- const errorMatch = detectErrorPattern(toolResponse);
119
- if (errorMatch) {
120
- incrementFailureCounter(sessionId);
121
- messages.push(`<compound-tool-info>\n[Forgen] Error pattern detected in execution result: "${errorMatch.description}". Review may be needed.\n</compound-tool-info>`);
146
+ // 2. File change tracking (Write, Edit) + implicit feedback detection
147
+ if (toolName === 'Write' || toolName === 'Edit') {
148
+ const filePath = toolInput.file_path ?? toolInput.filePath ?? '';
149
+ if (filePath) {
150
+ try {
151
+ const { count } = trackModifiedFile(modState, filePath, toolName);
152
+ // Implicit feedback: repeated edit detection (5+ edits on same file)
153
+ if (count >= 5) {
154
+ messages.push(`<compound-tool-warning>\n[Forgen] ⚠ ${path.basename(filePath)} has been modified ${count} times.\nConsider redesigning the overall structure and restarting.\n</compound-tool-warning>`);
155
+ recordImplicitFeedback({
156
+ type: 'repeated_edit',
157
+ file: filePath,
158
+ editCount: count,
159
+ at: new Date().toISOString(),
160
+ sessionId,
161
+ });
162
+ }
163
+ // Implicit feedback: revert detection
164
+ // Track content hashes of recent writes to detect when content is reverted
165
+ const newContent = toolInput.content ?? toolInput.new_string ?? '';
166
+ if (newContent) {
167
+ const hash = simpleHash(newContent);
168
+ if (!modState.recentWrites)
169
+ modState.recentWrites = {};
170
+ const prevHashes = modState.recentWrites[filePath] ?? [];
171
+ // Check if this content hash matches a previous write (revert pattern)
172
+ // Skip the most recent hash (which would be the write being "reverted from")
173
+ if (prevHashes.length >= 2 && prevHashes.slice(0, -1).includes(hash)) {
174
+ revertDetected = true;
175
+ recordImplicitFeedback({
176
+ type: 'revert_detected',
177
+ file: filePath,
178
+ at: new Date().toISOString(),
179
+ sessionId,
180
+ });
181
+ }
182
+ // Keep last 10 hashes per file
183
+ prevHashes.push(hash);
184
+ if (prevHashes.length > 10)
185
+ prevHashes.splice(0, prevHashes.length - 10);
186
+ modState.recentWrites[filePath] = prevHashes;
187
+ }
188
+ }
189
+ catch (e) {
190
+ log.debug('파일 변경 추적 실패', e);
191
+ }
192
+ }
193
+ }
194
+ // 3. Drift score evaluation
195
+ if (toolName === 'Write' || toolName === 'Edit') {
196
+ if (!modState.drift)
197
+ modState.drift = createDriftState(sessionId);
198
+ const driftResult = evaluateDrift(modState.drift, true, revertDetected);
199
+ if (driftResult.message) {
200
+ messages.push(`<compound-tool-warning>\n${driftResult.message}\n</compound-tool-warning>`);
201
+ recordImplicitFeedback({
202
+ type: driftResult.level === 'critical' || driftResult.level === 'hardcap' ? 'drift_critical' : 'drift_warning',
203
+ score: driftResult.score,
204
+ totalEdits: modState.drift.totalEdits,
205
+ totalReverts: modState.drift.totalReverts,
206
+ at: new Date().toISOString(),
207
+ sessionId,
208
+ });
209
+ }
210
+ }
211
+ // 4. Agent output validation (Tier 2-F)
212
+ if (toolName === 'Agent') {
213
+ const agentResult = validateAgentOutput(toolResponse);
214
+ if (agentResult) {
215
+ messages.push(`<compound-agent-validation>\n[Forgen] ${agentResult.severity === 'error' ? '⛔' : '⚠'} ${agentResult.message}\n</compound-agent-validation>`);
216
+ recordImplicitFeedback({
217
+ type: `agent_${agentResult.signal}`,
218
+ severity: agentResult.severity,
219
+ outputLength: toolResponse.trim().length,
220
+ at: new Date().toISOString(),
221
+ sessionId,
222
+ });
223
+ }
224
+ }
225
+ // 5. Bash error detection
226
+ if (toolName === 'Bash' && toolResponse) {
227
+ const errorMatch = detectErrorPattern(toolResponse);
228
+ if (errorMatch) {
229
+ incrementFailureCounter(sessionId);
230
+ messages.push(`<compound-tool-info>\n[Forgen] Error pattern detected in execution result: "${errorMatch.description}". Review may be needed.\n</compound-tool-info>`);
231
+ }
232
+ }
233
+ // 6. Compound negative signal (non-blocking)
234
+ try {
235
+ checkCompoundNegative(toolName, toolResponse, sessionId);
236
+ }
237
+ catch (e) {
238
+ log.debug('compound negative check 실패', e);
239
+ }
240
+ // 7. Compound success hint (non-blocking)
241
+ try {
242
+ const successHint = getCompoundSuccessHint(toolName, toolResponse, sessionId);
243
+ if (successHint)
244
+ messages.push(successHint);
245
+ }
246
+ catch (e) {
247
+ log.debug('success hint generation 실패', e);
248
+ }
249
+ saveModifiedFiles(modState);
250
+ if (messages.length > 0) {
251
+ console.log(approveWithWarning(messages.join('\n')));
252
+ }
253
+ else {
254
+ console.log(approve());
122
255
  }
123
256
  }
124
- // 5. Compound negative signal (non-blocking)
125
- try {
126
- checkCompoundNegative(toolName, toolResponse, sessionId);
127
- }
128
- catch (e) {
129
- log.debug('compound negative check 실패', e);
130
- }
131
- // 6. Compound success hint (non-blocking)
132
- try {
133
- const successHint = getCompoundSuccessHint(toolName, toolResponse, sessionId);
134
- if (successHint)
135
- messages.push(successHint);
136
- }
137
- catch (e) {
138
- log.debug('success hint generation 실패', e);
139
- }
140
- saveModifiedFiles(modState);
141
- if (messages.length > 0) {
142
- console.log(approveWithWarning(messages.join('\n')));
143
- }
144
- else {
145
- console.log(approve());
257
+ finally {
258
+ recordHookTiming('post-tool-use', Date.now() - _hookStart, 'PostToolUse');
146
259
  }
147
260
  }
148
261
  main().catch((e) => {
149
262
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
150
- console.log(failOpen());
263
+ console.log(failOpenWithTracking('post-tool-use'));
151
264
  });
@@ -7,4 +7,14 @@
7
7
  * - 진행 중인 작업 요약 저장
8
8
  * - handoff 파일 생성 (압축 후 복구용)
9
9
  */
10
- export {};
10
+ export interface SessionBrief {
11
+ sessionId: string;
12
+ mode: string;
13
+ modifiedFiles: string[];
14
+ promptCount: number;
15
+ solutionsInjected: string[];
16
+ correctionCount: number;
17
+ generatedAt: string;
18
+ }
19
+ /** 세션 브리프 JSON 생성 */
20
+ export declare function buildSessionBrief(sessionId: string): SessionBrief;
@@ -12,8 +12,9 @@ import * as path from 'node:path';
12
12
  import { createLogger } from '../core/logger.js';
13
13
  import { readStdinJSON } from './shared/read-stdin.js';
14
14
  import { isHookEnabled } from './hook-config.js';
15
- import { approve, approveWithWarning, failOpen } from './shared/hook-response.js';
16
- import { HANDOFFS_DIR, ME_BEHAVIOR, STATE_DIR } from '../core/paths.js';
15
+ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
16
+ import { HANDOFFS_DIR, ME_BEHAVIOR, ME_RULES, STATE_DIR } from '../core/paths.js';
17
+ import { sanitizeId } from './shared/sanitize-id.js';
17
18
  const log = createLogger('pre-compact');
18
19
  /** 활성 모드 상태 수집 */
19
20
  function collectActiveStates() {
@@ -40,6 +41,90 @@ function collectActiveStates() {
40
41
  }
41
42
  return active;
42
43
  }
44
+ /** 세션 브리프 JSON 생성 */
45
+ export function buildSessionBrief(sessionId) {
46
+ // modifiedFiles: read modified-files-{sessionId}.json (files field keys)
47
+ let modifiedFiles = [];
48
+ try {
49
+ const modPath = path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
50
+ if (fs.existsSync(modPath)) {
51
+ const modData = JSON.parse(fs.readFileSync(modPath, 'utf-8'));
52
+ if (modData.files && typeof modData.files === 'object') {
53
+ modifiedFiles = Object.keys(modData.files);
54
+ }
55
+ else if (Array.isArray(modData.modifiedFiles)) {
56
+ modifiedFiles = modData.modifiedFiles;
57
+ }
58
+ else if (Array.isArray(modData.fileEdits)) {
59
+ modifiedFiles = modData.fileEdits;
60
+ }
61
+ }
62
+ }
63
+ catch { /* fail-open */ }
64
+ // promptCount: read context-guard.json
65
+ let promptCount = 0;
66
+ try {
67
+ const cgPath = path.join(STATE_DIR, 'context-guard.json');
68
+ if (fs.existsSync(cgPath)) {
69
+ const cgData = JSON.parse(fs.readFileSync(cgPath, 'utf-8'));
70
+ if (typeof cgData.promptCount === 'number') {
71
+ promptCount = cgData.promptCount;
72
+ }
73
+ }
74
+ }
75
+ catch { /* fail-open */ }
76
+ // solutionsInjected: read injection-cache-*.json files, collect solutions[].name
77
+ let solutionsInjected = [];
78
+ try {
79
+ if (fs.existsSync(STATE_DIR)) {
80
+ for (const f of fs.readdirSync(STATE_DIR)) {
81
+ if (!f.startsWith('injection-cache-') || !f.endsWith('.json'))
82
+ continue;
83
+ try {
84
+ const cacheData = JSON.parse(fs.readFileSync(path.join(STATE_DIR, f), 'utf-8'));
85
+ if (Array.isArray(cacheData.solutions)) {
86
+ for (const sol of cacheData.solutions) {
87
+ if (typeof sol.name === 'string' && !solutionsInjected.includes(sol.name)) {
88
+ solutionsInjected.push(sol.name);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ catch { /* skip */ }
94
+ }
95
+ }
96
+ }
97
+ catch { /* fail-open */ }
98
+ // correctionCount: count files in ME_RULES with scope === 'session'
99
+ let correctionCount = 0;
100
+ try {
101
+ if (fs.existsSync(ME_RULES)) {
102
+ for (const f of fs.readdirSync(ME_RULES)) {
103
+ if (!f.endsWith('.json'))
104
+ continue;
105
+ try {
106
+ const rule = JSON.parse(fs.readFileSync(path.join(ME_RULES, f), 'utf-8'));
107
+ if (rule.scope === 'session')
108
+ correctionCount++;
109
+ }
110
+ catch { /* skip */ }
111
+ }
112
+ }
113
+ }
114
+ catch { /* fail-open */ }
115
+ // mode: from collectActiveStates
116
+ const activeStates = collectActiveStates();
117
+ const mode = activeStates.length > 0 ? activeStates.map(s => s.mode).join('+') : 'general';
118
+ return {
119
+ sessionId,
120
+ mode,
121
+ modifiedFiles,
122
+ promptCount,
123
+ solutionsInjected,
124
+ correctionCount,
125
+ generatedAt: new Date().toISOString(),
126
+ };
127
+ }
43
128
  /** compaction 전 스냅샷 저장 */
44
129
  function saveCompactionSnapshot(sessionId) {
45
130
  const activeStates = collectActiveStates();
@@ -68,35 +153,6 @@ function saveCompactionSnapshot(sessionId) {
68
153
  fs.writeFileSync(snapshotPath, lines.join('\n'));
69
154
  return snapshotPath;
70
155
  }
71
- /** context-guard.json에서 현재 promptCount 읽기 */
72
- function readPromptCount() {
73
- try {
74
- const guardPath = path.join(STATE_DIR, 'context-guard.json');
75
- if (fs.existsSync(guardPath)) {
76
- const data = JSON.parse(fs.readFileSync(guardPath, 'utf-8'));
77
- return typeof data.promptCount === 'number' ? data.promptCount : 0;
78
- }
79
- }
80
- catch { /* fail-open */ }
81
- return 0;
82
- }
83
- /**
84
- * 백그라운드 compound 추출 트리거 (non-blocking).
85
- * compound extract 서브커맨드가 없으므로 pending-compound.json 마커를 씀.
86
- * session-recovery가 다음 세션 시작 시 이 마커를 읽고 추출을 트리거함.
87
- */
88
- function triggerBackgroundExtraction(promptCount) {
89
- try {
90
- const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
91
- fs.mkdirSync(STATE_DIR, { recursive: true });
92
- fs.writeFileSync(pendingPath, JSON.stringify({
93
- reason: 'pre-compact',
94
- promptCount,
95
- detectedAt: new Date().toISOString(),
96
- }, null, 2));
97
- }
98
- catch { /* fail-open */ }
99
- }
100
156
  /** 7일 이상 된 handoff 파일 정리 */
101
157
  function cleanOldHandoffs() {
102
158
  if (!fs.existsSync(HANDOFFS_DIR))
@@ -175,11 +231,30 @@ Rules:
175
231
  - Skip patterns that are trivially obvious ("uses TypeScript")
176
232
  - Each pattern must be specific enough to change Claude's behavior in future sessions${existingList}
177
233
  </forgen-compound-extract>`;
178
- // promptCount >= 20이면 백그라운드 추출 마커 기록
179
- const promptCount = readPromptCount();
180
- if (promptCount >= 20) {
181
- triggerBackgroundExtraction(promptCount);
182
- log.debug(`Pre-compact: promptCount=${promptCount} >= 20, pending-compound.json 기록`);
234
+ // 세션 브리프 저장
235
+ try {
236
+ const brief = buildSessionBrief(sessionId);
237
+ fs.mkdirSync(HANDOFFS_DIR, { recursive: true });
238
+ const briefTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
239
+ const briefPath = path.join(HANDOFFS_DIR, `${briefTimestamp}-session-brief.json`);
240
+ let briefJson = JSON.stringify(brief, null, 2);
241
+ // max 1500 chars — truncate modifiedFiles and solutionsInjected if needed
242
+ if (briefJson.length > 1500) {
243
+ let truncBrief = { ...brief };
244
+ while (briefJson.length > 1500 && (truncBrief.modifiedFiles.length > 0 || truncBrief.solutionsInjected.length > 0)) {
245
+ if (truncBrief.solutionsInjected.length > 0) {
246
+ truncBrief = { ...truncBrief, solutionsInjected: truncBrief.solutionsInjected.slice(0, Math.max(0, truncBrief.solutionsInjected.length - 1)) };
247
+ }
248
+ else {
249
+ truncBrief = { ...truncBrief, modifiedFiles: truncBrief.modifiedFiles.slice(0, Math.max(0, truncBrief.modifiedFiles.length - 1)) };
250
+ }
251
+ briefJson = JSON.stringify(truncBrief, null, 2);
252
+ }
253
+ }
254
+ fs.writeFileSync(briefPath, briefJson);
255
+ }
256
+ catch (e) {
257
+ log.debug('세션 브리프 저장 실패', e);
183
258
  }
184
259
  // 스냅샷 저장
185
260
  try {
@@ -196,5 +271,5 @@ Rules:
196
271
  }
197
272
  main().catch((e) => {
198
273
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
199
- console.log(failOpen());
274
+ console.log(failOpenWithTracking('pre-compact'));
200
275
  });
@@ -19,8 +19,9 @@ import { sanitizeId } from './shared/sanitize-id.js';
19
19
  import { incrementEvidence } from '../engine/solution-writer.js';
20
20
  import { isReflectionCandidate } from './compound-reflection.js';
21
21
  import { isHookEnabled } from './hook-config.js';
22
- import { approve, approveWithWarning, deny, failOpen } from './shared/hook-response.js';
22
+ import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
23
23
  import { FORGEN_HOME, STATE_DIR } from '../core/paths.js';
24
+ import { recordHookTiming } from './shared/hook-timing.js';
24
25
  const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'pre-tool-fail-counter.json');
25
26
  const FAIL_CLOSE_THRESHOLD = 3; // 연속 3회 파싱 실패 시에만 reject
26
27
  /** RegExp 안전성 검증 (ReDoS 방지) — 매칭/비매칭 양쪽 모두 테스트 */
@@ -212,15 +213,33 @@ function checkCompoundReflection(toolName, toolInput, sessionId) {
212
213
  const now = new Date();
213
214
  let mutated = false;
214
215
  for (const sol of cache.solutions) {
215
- if (!Array.isArray(sol.identifiers) || sol.identifiers.length === 0)
216
+ const hasIdentifiers = Array.isArray(sol.identifiers) && sol.identifiers.length > 0;
217
+ const hasTags = Array.isArray(sol.tags) && sol.tags.length > 0;
218
+ if (!hasIdentifiers && !hasTags)
216
219
  continue;
217
- const result = isReflectionCandidate({
218
- identifiers: sol.identifiers,
219
- code,
220
- injectedAt: sol.injectedAt ?? '',
221
- now,
222
- });
223
- if (result.reflected) {
220
+ let reflected = false;
221
+ if (hasIdentifiers) {
222
+ const result = isReflectionCandidate({
223
+ identifiers: sol.identifiers,
224
+ code,
225
+ injectedAt: sol.injectedAt ?? '',
226
+ now,
227
+ });
228
+ reflected = result.reflected;
229
+ }
230
+ // Tag-based fallback: identifiers 없는 솔루션도 감지
231
+ // 6자 이상 non-generic 태그 2개 이상이 코드에 출현하면 반영으로 인정
232
+ if (!reflected && hasTags) {
233
+ const genericTags = new Set(['pattern', 'solution', 'workflow', 'quality', 'best-practice', 'convention']);
234
+ const eligibleTags = sol.tags.filter((t) => t.length >= 6 && !genericTags.has(t) && /^[a-zA-Z가-힣]/.test(t));
235
+ const matchedTagCount = eligibleTags.filter((t) => code.toLowerCase().includes(t.toLowerCase())).length;
236
+ const injectedTime = new Date(sol.injectedAt ?? '').getTime();
237
+ const elapsed = now.getTime() - injectedTime;
238
+ if (matchedTagCount >= 2 && elapsed <= 15 * 60 * 1000 && elapsed >= 0) {
239
+ reflected = true;
240
+ }
241
+ }
242
+ if (reflected) {
224
243
  reflectedNames.push(sol.name);
225
244
  if (!sol._sessionCounted) {
226
245
  sol._sessionCounted = true;
@@ -262,58 +281,69 @@ export function updateSolutionEvidence(solutionName, field) {
262
281
  incrementEvidence(solutionName, field);
263
282
  }
264
283
  async function main() {
265
- const data = await readStdinJSON();
266
- if (!data) {
267
- // graceful fail-close: consecutive failure counter.
268
- // At threshold, block with a user-visible deny message (the block itself
269
- // is actionable the user needs to know why their tool call was
270
- // rejected). Below threshold, pass SILENTLY via plain approve() so a
271
- // transient parse glitch doesn't leak `systemMessage` noise to the
272
- // user's terminal on every tool call. stderr still gets the counter
273
- // for `forgen doctor` / log inspection. Mirrors `db-guard.ts:85-96`.
274
- const failCount = getAndIncrementFailCount();
275
- if (failCount >= FAIL_CLOSE_THRESHOLD) {
276
- console.log(deny(`[Forgen] PreToolUse: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
284
+ const _hookStart = Date.now();
285
+ try {
286
+ const data = await readStdinJSON();
287
+ if (!data) {
288
+ // graceful fail-close: consecutive failure counter.
289
+ // At threshold, block with a user-visible deny message (the block itself
290
+ // is actionable the user needs to know why their tool call was
291
+ // rejected). Below threshold, pass SILENTLY via plain approve() so a
292
+ // transient parse glitch doesn't leak `systemMessage` noise to the
293
+ // user's terminal on every tool call. stderr still gets the counter
294
+ // for `forgen doctor` / log inspection. Mirrors `db-guard.ts:85-96`.
295
+ const failCount = getAndIncrementFailCount();
296
+ if (failCount >= FAIL_CLOSE_THRESHOLD) {
297
+ console.log(deny(`[Forgen] PreToolUse: stdin parse failed ${failCount} consecutive times — blocking for safety.`));
298
+ }
299
+ else {
300
+ process.stderr.write(`[ch-hook] pre-tool-use stdin parse failed (${failCount}/${FAIL_CLOSE_THRESHOLD})\n`);
301
+ console.log(approve());
302
+ }
303
+ return;
277
304
  }
278
- else {
279
- process.stderr.write(`[ch-hook] pre-tool-use stdin parse failed (${failCount}/${FAIL_CLOSE_THRESHOLD})\n`);
305
+ // 정상 파싱 성공 시 연속 실패 카운터 리셋
306
+ resetFailCount();
307
+ if (!isHookEnabled('pre-tool-use')) {
280
308
  console.log(approve());
309
+ return;
310
+ }
311
+ const toolName = data.tool_name ?? data.toolName ?? '';
312
+ const toolInput = data.tool_input ?? data.toolInput ?? {};
313
+ const sessionId = data.session_id ?? 'default';
314
+ // Bash 도구: 위험 명령어 감지
315
+ const check = checkDangerousCommand(toolName, toolInput);
316
+ if (check.action === 'block') {
317
+ console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
318
+ return;
319
+ }
320
+ if (check.action === 'warn') {
321
+ console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] ⚠ Dangerous command detected: ${check.description}\nProceed with caution.\n</compound-tool-warning>`));
322
+ return;
323
+ }
324
+ // Output size guard: warn when Grep is used without head_limit
325
+ if (toolName === 'Grep' && !toolInput?.head_limit) {
326
+ console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] Grep without head_limit may produce large output. Set head_limit or pipe through | head -n to limit output size.\n</compound-tool-warning>`));
327
+ return;
328
+ }
329
+ // Compound v3: Code Reflection check (non-blocking)
330
+ try {
331
+ checkCompoundReflection(toolName, toolInput, sessionId);
332
+ }
333
+ catch (e) {
334
+ log.debug('compound reflection check 실패', e);
335
+ }
336
+ // 활성 모드 리마인더 (10회 호출당 1회 — 결정적 카운터 기반)
337
+ const reminders = getActiveReminders();
338
+ if (reminders.length > 0 && shouldShowReminderIO()) {
339
+ console.log(approveWithWarning(`<compound-reminder>\n${reminders.join('\n')}\n</compound-reminder>`));
340
+ return;
281
341
  }
282
- return;
283
- }
284
- // 정상 파싱 성공 시 연속 실패 카운터 리셋
285
- resetFailCount();
286
- if (!isHookEnabled('pre-tool-use')) {
287
342
  console.log(approve());
288
- return;
289
343
  }
290
- const toolName = data.tool_name ?? data.toolName ?? '';
291
- const toolInput = data.tool_input ?? data.toolInput ?? {};
292
- const sessionId = data.session_id ?? 'default';
293
- // Bash 도구: 위험 명령어 감지
294
- const check = checkDangerousCommand(toolName, toolInput);
295
- if (check.action === 'block') {
296
- console.log(deny(`[Forgen] Dangerous command blocked: ${check.description}\nCommand: ${check.command}`));
297
- return;
298
- }
299
- if (check.action === 'warn') {
300
- console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] ⚠ Dangerous command detected: ${check.description}\nProceed with caution.\n</compound-tool-warning>`));
301
- return;
302
- }
303
- // Compound v3: Code Reflection check (non-blocking)
304
- try {
305
- checkCompoundReflection(toolName, toolInput, sessionId);
306
- }
307
- catch (e) {
308
- log.debug('compound reflection check 실패', e);
309
- }
310
- // 활성 모드 리마인더 (10회 호출당 1회 — 결정적 카운터 기반)
311
- const reminders = getActiveReminders();
312
- if (reminders.length > 0 && shouldShowReminderIO()) {
313
- console.log(approveWithWarning(`<compound-reminder>\n${reminders.join('\n')}\n</compound-reminder>`));
314
- return;
344
+ finally {
345
+ recordHookTiming('pre-tool-use', Date.now() - _hookStart, 'PreToolUse');
315
346
  }
316
- console.log(approve());
317
347
  }
318
348
  main().catch((e) => {
319
349
  const hookErr = new HookError(e instanceof Error ? e.message : String(e), {
@@ -321,5 +351,5 @@ main().catch((e) => {
321
351
  });
322
352
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
323
353
  // fail-open: approve on internal error to avoid blocking all tool calls
324
- console.log(failOpen());
354
+ console.log(failOpenWithTracking('pre-tool-use'));
325
355
  });