@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
@@ -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();
@@ -146,6 +231,31 @@ Rules:
146
231
  - Skip patterns that are trivially obvious ("uses TypeScript")
147
232
  - Each pattern must be specific enough to change Claude's behavior in future sessions${existingList}
148
233
  </forgen-compound-extract>`;
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);
258
+ }
149
259
  // 스냅샷 저장
150
260
  try {
151
261
  const snapshotPath = saveCompactionSnapshot(sessionId);
@@ -161,5 +271,5 @@ Rules:
161
271
  }
162
272
  main().catch((e) => {
163
273
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
164
- console.log(failOpen());
274
+ console.log(failOpenWithTracking('pre-compact'));
165
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
  });
@@ -10,7 +10,7 @@ import * as path from 'node:path';
10
10
  import { readStdinJSON } from './shared/read-stdin.js';
11
11
  import { atomicWriteJSON } from './shared/atomic-write.js';
12
12
  import { isHookEnabled } from './hook-config.js';
13
- import { approve, deny, failOpen } from './shared/hook-response.js';
13
+ import { approve, deny, failOpenWithTracking } from './shared/hook-response.js';
14
14
  import { STATE_DIR } from '../core/paths.js';
15
15
  const RATE_LIMIT_PATH = path.join(STATE_DIR, 'rate-limit.json');
16
16
  const DEFAULT_LIMIT = 30; // calls per minute
@@ -55,7 +55,7 @@ async function main() {
55
55
  const data = await readStdinJSON(1500); // Must finish within plugin.json timeout (2000ms)
56
56
  if (!data) {
57
57
  // stdin 파싱 실패 — 통과 (rate limiter는 fail-open)
58
- console.log(failOpen());
58
+ console.log(failOpenWithTracking('rate-limiter'));
59
59
  return;
60
60
  }
61
61
  if (!isHookEnabled('rate-limiter')) {
@@ -82,5 +82,5 @@ async function main() {
82
82
  }
83
83
  main().catch((e) => {
84
84
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
85
- console.log(failOpen());
85
+ console.log(failOpenWithTracking('rate-limiter'));
86
86
  });
@@ -8,7 +8,7 @@
8
8
  import { HookError } from '../core/errors.js';
9
9
  import { readStdinJSON } from './shared/read-stdin.js';
10
10
  import { isHookEnabled } from './hook-config.js';
11
- import { approve, approveWithWarning, failOpen } from './shared/hook-response.js';
11
+ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
12
12
  export const SECRET_PATTERNS = [
13
13
  { name: 'API Key', pattern: /(sk|pk|api[_-]?key)[_-][\w\-.]{20,}/i },
14
14
  { name: 'AWS Access Key', pattern: /AKIA[\w]{16}/ },
@@ -61,5 +61,5 @@ main().catch((e) => {
61
61
  hookName: 'secret-filter', eventType: 'PostToolUse', cause: e,
62
62
  });
63
63
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
64
- console.log(failOpen());
64
+ console.log(failOpenWithTracking('secret-filter'));
65
65
  });