@wooojin/forgen 0.4.0 → 0.4.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 (76) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +30 -0
  3. package/README.ja.md +58 -1
  4. package/README.ko.md +58 -1
  5. package/README.md +83 -15
  6. package/README.zh.md +26 -0
  7. package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
  8. package/dist/checks/conclusion-verification-ratio.js +86 -0
  9. package/dist/checks/fact-vs-agreement.d.ts +47 -0
  10. package/dist/checks/fact-vs-agreement.js +92 -0
  11. package/dist/checks/self-score-deflation.d.ts +38 -0
  12. package/dist/checks/self-score-deflation.js +108 -0
  13. package/dist/cli.js +22 -2
  14. package/dist/core/auto-compound-runner.js +75 -11
  15. package/dist/core/dashboard.js +9 -2
  16. package/dist/core/doctor.js +26 -5
  17. package/dist/core/extraction-notice.d.ts +18 -0
  18. package/dist/core/extraction-notice.js +64 -0
  19. package/dist/core/init-cli.d.ts +26 -0
  20. package/dist/core/init-cli.js +104 -0
  21. package/dist/core/init.js +17 -0
  22. package/dist/core/inspect-cli.js +1 -2
  23. package/dist/core/migrate-cli.d.ts +10 -0
  24. package/dist/core/migrate-cli.js +34 -0
  25. package/dist/core/paths.d.ts +8 -1
  26. package/dist/core/paths.js +11 -2
  27. package/dist/core/recall-cli.d.ts +26 -0
  28. package/dist/core/recall-cli.js +125 -0
  29. package/dist/core/recall-reference-detector.d.ts +43 -0
  30. package/dist/core/recall-reference-detector.js +65 -0
  31. package/dist/core/stats-cli.d.ts +21 -0
  32. package/dist/core/stats-cli.js +121 -10
  33. package/dist/core/uninstall.js +2 -1
  34. package/dist/engine/compound-cli.js +1 -0
  35. package/dist/engine/compound-export.js +8 -3
  36. package/dist/engine/learn-cli.js +1 -4
  37. package/dist/engine/lifecycle/lifecycle-cli.js +4 -4
  38. package/dist/engine/lifecycle/meta-reclassifier.js +3 -3
  39. package/dist/engine/lifecycle/orchestrator.js +2 -2
  40. package/dist/engine/lifecycle/signals.js +6 -6
  41. package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
  42. package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
  43. package/dist/engine/skill-promoter.js +3 -6
  44. package/dist/hooks/context-guard.js +1 -1
  45. package/dist/hooks/dangerous-patterns.json +3 -3
  46. package/dist/hooks/db-guard.js +18 -2
  47. package/dist/hooks/intent-classifier.js +1 -1
  48. package/dist/hooks/keyword-detector.js +1 -1
  49. package/dist/hooks/notepad-injector.js +1 -1
  50. package/dist/hooks/permission-handler.js +1 -1
  51. package/dist/hooks/post-tool-failure.js +1 -1
  52. package/dist/hooks/post-tool-use.d.ts +6 -0
  53. package/dist/hooks/post-tool-use.js +37 -19
  54. package/dist/hooks/pre-compact.js +1 -1
  55. package/dist/hooks/pre-tool-use.d.ts +7 -0
  56. package/dist/hooks/pre-tool-use.js +24 -6
  57. package/dist/hooks/rate-limiter.js +1 -1
  58. package/dist/hooks/secret-filter.js +1 -1
  59. package/dist/hooks/session-recovery.js +1 -1
  60. package/dist/hooks/shared/command-parser.d.ts +44 -0
  61. package/dist/hooks/shared/command-parser.js +50 -0
  62. package/dist/hooks/shared/hook-response.d.ts +12 -2
  63. package/dist/hooks/shared/hook-response.js +30 -3
  64. package/dist/hooks/skill-injector.js +1 -1
  65. package/dist/hooks/slop-detector.js +2 -2
  66. package/dist/hooks/solution-injector.d.ts +9 -0
  67. package/dist/hooks/solution-injector.js +48 -5
  68. package/dist/hooks/stop-guard.js +137 -13
  69. package/dist/hooks/subagent-tracker.js +1 -1
  70. package/dist/i18n/index.js +3 -5
  71. package/dist/store/evidence-store.js +11 -0
  72. package/dist/store/implicit-feedback-store.d.ts +59 -0
  73. package/dist/store/implicit-feedback-store.js +153 -0
  74. package/dist/store/rule-store.js +8 -0
  75. package/package.json +2 -2
  76. package/plugin.json +1 -1
@@ -20,6 +20,8 @@ import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook
20
20
  import { STATE_DIR } from '../core/paths.js';
21
21
  import { recordHookTiming } from './shared/hook-timing.js';
22
22
  import { createDriftState, evaluateDrift } from '../core/drift-score.js';
23
+ import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
24
+ const RECENT_TOOL_NAMES_WINDOW = 20;
23
25
  /** Lightweight hash for content comparison (not cryptographic) */
24
26
  function simpleHash(content) {
25
27
  let hash = 0;
@@ -30,15 +32,6 @@ function simpleHash(content) {
30
32
  }
31
33
  return hash.toString(36);
32
34
  }
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
- }
42
35
  // ── State management ──
43
36
  function getModifiedFilesPath(sessionId) {
44
37
  return path.join(STATE_DIR, `modified-files-${sanitizeId(sessionId)}.json`);
@@ -125,6 +118,15 @@ async function main() {
125
118
  const sessionId = data.session_id ?? 'default';
126
119
  const modState = loadModifiedFiles(sessionId);
127
120
  modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
121
+ // TEST-2: recent tool name window — stop-guard 의 self-score inflation 가드가
122
+ // "최근 세션에서 측정 도구 몇 번 불렸나?" 를 이 배열로 계산한다.
123
+ if (toolName) {
124
+ const names = modState.recentToolNames ?? [];
125
+ names.push(toolName);
126
+ if (names.length > RECENT_TOOL_NAMES_WINDOW)
127
+ names.splice(0, names.length - RECENT_TOOL_NAMES_WINDOW);
128
+ modState.recentToolNames = names;
129
+ }
128
130
  const messages = [];
129
131
  let revertDetected = false;
130
132
  // 1. Checkpoint (every 5 calls)
@@ -152,8 +154,9 @@ async function main() {
152
154
  // Implicit feedback: repeated edit detection (5+ edits on same file)
153
155
  if (count >= 5) {
154
156
  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({
157
+ appendImplicitFeedback({
156
158
  type: 'repeated_edit',
159
+ category: 'edit',
157
160
  file: filePath,
158
161
  editCount: count,
159
162
  at: new Date().toISOString(),
@@ -172,8 +175,9 @@ async function main() {
172
175
  // Skip the most recent hash (which would be the write being "reverted from")
173
176
  if (prevHashes.length >= 2 && prevHashes.slice(0, -1).includes(hash)) {
174
177
  revertDetected = true;
175
- recordImplicitFeedback({
178
+ appendImplicitFeedback({
176
179
  type: 'revert_detected',
180
+ category: 'revert',
177
181
  file: filePath,
178
182
  at: new Date().toISOString(),
179
183
  sessionId,
@@ -198,8 +202,9 @@ async function main() {
198
202
  const driftResult = evaluateDrift(modState.drift, true, revertDetected);
199
203
  if (driftResult.message) {
200
204
  messages.push(`<compound-tool-warning>\n${driftResult.message}\n</compound-tool-warning>`);
201
- recordImplicitFeedback({
205
+ appendImplicitFeedback({
202
206
  type: driftResult.level === 'critical' || driftResult.level === 'hardcap' ? 'drift_critical' : 'drift_warning',
207
+ category: 'drift',
203
208
  score: driftResult.score,
204
209
  totalEdits: modState.drift.totalEdits,
205
210
  totalReverts: modState.drift.totalReverts,
@@ -213,8 +218,9 @@ async function main() {
213
218
  const agentResult = validateAgentOutput(toolResponse);
214
219
  if (agentResult) {
215
220
  messages.push(`<compound-agent-validation>\n[Forgen] ${agentResult.severity === 'error' ? '⛔' : '⚠'} ${agentResult.message}\n</compound-agent-validation>`);
216
- recordImplicitFeedback({
221
+ appendImplicitFeedback({
217
222
  type: `agent_${agentResult.signal}`,
223
+ category: 'agent',
218
224
  severity: agentResult.severity,
219
225
  outputLength: toolResponse.trim().length,
220
226
  at: new Date().toISOString(),
@@ -254,14 +260,18 @@ async function main() {
254
260
  })() || toolResponse;
255
261
  if (target) {
256
262
  try {
257
- const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
263
+ const [{ loadActiveRules }, { recordViolation, recordBypass }, { scanForBypass }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
258
264
  import('../store/rule-store.js'),
259
265
  import('../engine/lifecycle/signals.js'),
260
266
  import('../engine/lifecycle/bypass-detector.js'),
261
267
  import('./shared/safe-regex.js'),
268
+ import('./shared/command-parser.js'),
262
269
  ]);
263
270
  const rules = loadActiveRules();
264
- // Mech-A pattern_match dispatcher
271
+ // Mech-A pattern_match dispatcher — match_target 은 **rule-per-rule**.
272
+ // AWS key / DROP 류 secret/dangerous SQL 은 파일 content 에 들어있어도
273
+ // 실제 leak 이라 raw 검사가 맞고, rm -rf 류 shell 명령은 quote 안 본문이면
274
+ // false-positive 이므로 masked 가 맞다. pre-tool-use 와 동일한 spec 기반 분기.
265
275
  for (const rule of rules) {
266
276
  for (const spec of rule.enforce_via ?? []) {
267
277
  if (spec.hook !== 'PostToolUse' || spec.mech !== 'A')
@@ -277,7 +287,9 @@ async function main() {
277
287
  log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
278
288
  continue;
279
289
  }
280
- if (!safeRegexTest(re.regex, target))
290
+ const matchTarget = (v.params?.match_target ?? 'raw');
291
+ const mechTarget = preprocessForMatch(target, matchTarget);
292
+ if (!safeRegexTest(re.regex, mechTarget))
281
293
  continue;
282
294
  recordViolation({
283
295
  rule_id: rule.rule_id, session_id: sessionId,
@@ -288,8 +300,14 @@ async function main() {
288
300
  messages.push(`<compound-rule-violation>\n[Forgen] Rule ${rule.rule_id.slice(0, 8)} pattern matched in ${toolName} output.\n${spec.block_message ?? rule.policy.slice(0, 120)}\n</compound-rule-violation>`);
289
301
  }
290
302
  }
291
- // T3 bypass detection (same rules, same target)
292
- const candidates = scanForBypass({ rules, tool_name: toolName, tool_output: target, session_id: sessionId });
303
+ // T3 bypass detection scanForBypass rule.policy 자연어에서 패턴 추출이라
304
+ // match_target 개념 없음. Write/Edit 파일 본문이라 bypass-detector
305
+ // 자연어 휴리스틱이 false-positive 과다 (L1-no-rm-rf-unconfirmed bypass 20건
306
+ // 중 Write/Edit 15건이 실측). 이 경로만 masked. Bash 는 실제 실행된 명령이라
307
+ // raw 유지. Mech-A pattern_match 는 위에서 rule-per-rule 로 이미 처리.
308
+ const isFileContentTool = toolName === 'Write' || toolName === 'Edit';
309
+ const bypassTarget = isFileContentTool ? preprocessForMatch(target, 'masked') : target;
310
+ const candidates = scanForBypass({ rules, tool_name: toolName, tool_output: bypassTarget, session_id: sessionId });
293
311
  for (const c of candidates) {
294
312
  recordBypass({ rule_id: c.rule_id, session_id: c.session_id, tool: c.tool, pattern_preview: c.pattern_preview });
295
313
  }
@@ -322,5 +340,5 @@ async function main() {
322
340
  }
323
341
  main().catch((e) => {
324
342
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
325
- console.log(failOpenWithTracking('post-tool-use'));
343
+ console.log(failOpenWithTracking('post-tool-use', e));
326
344
  });
@@ -271,5 +271,5 @@ Rules:
271
271
  }
272
272
  main().catch((e) => {
273
273
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
274
- console.log(failOpenWithTracking('pre-compact'));
274
+ console.log(failOpenWithTracking('pre-compact', e));
275
275
  });
@@ -10,6 +10,13 @@ interface DangerousPatternEntry {
10
10
  pattern: RegExp;
11
11
  description: string;
12
12
  severity: 'block' | 'warn';
13
+ /**
14
+ * match_target (v0.4.1): 'masked' (default) — quote/heredoc 본문 제거 후 매칭.
15
+ * 실 shell 실행 토큰 검사에 적합. 'raw' — 원본 command 그대로 매칭.
16
+ * `python -c "..."`, `eval "..."` 처럼 **quote 안 본문이 실제 payload 로 실행**
17
+ * 되는 패턴에 사용.
18
+ */
19
+ matchTarget?: 'raw' | 'masked';
13
20
  }
14
21
  /** 위험 Bash 명령어 패턴 (패키지 내장 + 사용자 커스텀 병합) */
15
22
  export declare const DANGEROUS_PATTERNS: DangerousPatternEntry[];
@@ -22,6 +22,7 @@ import { isHookEnabled } from './hook-config.js';
22
22
  import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
23
23
  import { FORGEN_HOME, STATE_DIR } from '../core/paths.js';
24
24
  import { recordHookTiming } from './shared/hook-timing.js';
25
+ import { maskQuotedContent } from './shared/command-parser.js';
25
26
  const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'pre-tool-fail-counter.json');
26
27
  const FAIL_CLOSE_THRESHOLD = 3; // 연속 3회 파싱 실패 시에만 reject
27
28
  /** RegExp 안전성 검증 (ReDoS 방지) — 매칭/비매칭 양쪽 모두 테스트 */
@@ -59,12 +60,16 @@ function loadDangerousPatterns() {
59
60
  pattern: new RegExp(entry.pattern, entry.flags ?? ''),
60
61
  description: entry.description,
61
62
  severity: entry.severity,
63
+ matchTarget: entry.match_target === 'raw' ? 'raw' : 'masked',
62
64
  });
63
65
  }
64
66
  }
65
67
  catch {
66
68
  // JSON 로드 실패 시 하드코딩 폴백 (최소 안전장치)
67
- results.push({ pattern: /rm\s+(-rf|-fr)\s+[/~]/, description: 'rm -rf on root/home path', severity: 'block' }, { pattern: /curl\s+.*\|\s*(ba)?sh/, description: 'curl pipe to shell', severity: 'block' }, { pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, description: 'fork bomb', severity: 'block' });
69
+ results.push(
70
+ // v0.4.1 false-positive fix: /tmp, /var/folders, /var/tmp 같은 임시 경로는
71
+ // 일반 개발에서 매일 정리 대상. 위험한 시스템 경로만 blacklist.
72
+ { pattern: /rm\s+(-rf|-fr)\s+(\/(?!tmp\b|var\/folders\b|var\/tmp\b)|~)/, description: 'rm -rf on root/home path', severity: 'block' }, { pattern: /curl\s+.*\|\s*(ba)?sh/, description: 'curl pipe to shell', severity: 'block' }, { pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, description: 'fork bomb', severity: 'block' });
68
73
  }
69
74
  // 2. 사용자 커스텀 패턴 (~/.compound/dangerous-patterns.json)
70
75
  try {
@@ -100,8 +105,14 @@ export function checkDangerousCommand(toolName, toolInput) {
100
105
  const command = typeof toolInput === 'string'
101
106
  ? toolInput
102
107
  : (toolInput.command ?? '');
103
- for (const { pattern, description, severity } of DANGEROUS_PATTERNS) {
104
- if (pattern.test(command)) {
108
+ // v0.4.1 (2026-04-24) quote-aware built-in scan:
109
+ // 기본은 masked (quote/heredoc 본문 제거) — shell 실행 토큰 검사. 하지만
110
+ // `python -c "..."` / `eval "..."` 처럼 quote 안 본문이 실 payload 로 실행되는
111
+ // 패턴은 match_target:raw 로 지정해 원본 command 전체 검사.
112
+ const maskedCommand = maskQuotedContent(command);
113
+ for (const { pattern, description, severity, matchTarget } of DANGEROUS_PATTERNS) {
114
+ const target = matchTarget === 'raw' ? command : maskedCommand;
115
+ if (pattern.test(target)) {
105
116
  return { action: severity, description, command: command.slice(0, 100) };
106
117
  }
107
118
  }
@@ -315,10 +326,11 @@ async function main() {
315
326
  // 이렇게 해야 rule.block_message (맥락 있는 안내) 가 제네릭 "Dangerous command blocked" 대신 노출됨.
316
327
  // fail-open: 예외는 hook 차단 안 함.
317
328
  try {
318
- const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest },] = await Promise.all([
329
+ const [{ loadActiveRules }, { recordViolation }, { compileSafeRegex, safeRegexTest }, { preprocessForMatch },] = await Promise.all([
319
330
  import('../store/rule-store.js'),
320
331
  import('../engine/lifecycle/signals.js'),
321
332
  import('./shared/safe-regex.js'),
333
+ import('./shared/command-parser.js'),
322
334
  ]);
323
335
  const rules = loadActiveRules();
324
336
  const command = typeof toolInput.command === 'string'
@@ -339,7 +351,13 @@ async function main() {
339
351
  log.debug(`rule ${rule.rule_id} unsafe regex: ${re.reason}`);
340
352
  continue;
341
353
  }
342
- if (!safeRegexTest(re.regex, command))
354
+ // TEST-6 / RC5: quote-aware preprocessing. Default 'raw' = backward compat.
355
+ // Rules that target real command invocations should set match_target: 'masked'
356
+ // so quoted argument text (e.g. body of `forgen compound --solution "..."`)
357
+ // doesn't trigger false positive blocks.
358
+ const matchTarget = (v.params?.match_target ?? 'raw');
359
+ const target = preprocessForMatch(command, matchTarget);
360
+ if (!safeRegexTest(re.regex, target))
343
361
  continue;
344
362
  const requiresFlag = v.params?.requires_flag;
345
363
  const confirmed = process.env.FORGEN_USER_CONFIRMED === '1';
@@ -413,5 +431,5 @@ main().catch((e) => {
413
431
  });
414
432
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
415
433
  // fail-open: approve on internal error to avoid blocking all tool calls
416
- console.log(failOpenWithTracking('pre-tool-use'));
434
+ console.log(failOpenWithTracking('pre-tool-use', e));
417
435
  });
@@ -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(failOpenWithTracking('rate-limiter'));
85
+ console.log(failOpenWithTracking('rate-limiter', e));
86
86
  });
@@ -87,5 +87,5 @@ main().catch((e) => {
87
87
  hookName: 'secret-filter', eventType: 'PostToolUse', cause: e,
88
88
  });
89
89
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
90
- console.log(failOpenWithTracking('secret-filter'));
90
+ console.log(failOpenWithTracking('secret-filter', e));
91
91
  });
@@ -429,6 +429,6 @@ async function main() {
429
429
  if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
430
430
  main().catch((e) => {
431
431
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
432
- console.log(failOpenWithTracking('session-recovery'));
432
+ console.log(failOpenWithTracking('session-recovery', e));
433
433
  });
434
434
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Command-token parser — quote-aware shell command preprocessing.
3
+ *
4
+ * 목적: PreToolUse enforce_via 룰의 정규식이 quote된 인자 텍스트와
5
+ * 명령 토큰을 구분 못 해서 false positive block 발생 (TEST-6, RC5).
6
+ *
7
+ * 사례: forgen compound --solution "title" "본문에 rm -rf 텍스트 포함" 명령이
8
+ * "rm\s+-rf" 패턴에 매칭되어 차단됨. 실제 rm 명령이 아닌데도.
9
+ *
10
+ * 해법: quote된 문자열을 마스킹한 뒤 패턴 매칭. 99% 케이스 커버.
11
+ * 완벽한 shell 파싱은 아니지만 정직하게 한정된 범위.
12
+ */
13
+ /**
14
+ * Mask quoted string contents in a shell command so that text inside
15
+ * single/double quotes, backticks, or $(...) is not matched by patterns
16
+ * intended for command tokens.
17
+ *
18
+ * Examples:
19
+ * maskQuotedContent('rm -rf /') → 'rm -rf /'
20
+ * maskQuotedContent('echo "rm -rf foo"') → 'echo ""'
21
+ * maskQuotedContent("forgen save 'rm -rf body'") → "forgen save ''"
22
+ * maskQuotedContent('rm -rf $(pwd)') → 'rm -rf $()'
23
+ * maskQuotedContent('echo `rm -rf x`') → 'echo ``'
24
+ *
25
+ * Limitations (documented, not silently broken):
26
+ * - escaped quotes inside quoted strings: best-effort only
27
+ * - heredoc bodies (<<EOF ... EOF): masked as `<<HEREDOC>>` (v0.4.1+)
28
+ * - nested $(...) / `...`: outer level masked
29
+ */
30
+ export declare function maskQuotedContent(cmd: string): string;
31
+ /**
32
+ * Decide if a verifier should match against the raw command, masked command,
33
+ * or the leading command tokens of each statement.
34
+ *
35
+ * 'raw' — backward compat. Match against the unmodified command string.
36
+ * 'masked' — Strip quoted contents first. Use this when the rule wants to
37
+ * guard a real command invocation (e.g. rm -rf) and not text
38
+ * inside string literals passed as arguments to other commands.
39
+ * 'command_tokens' — Reserved for future use (per-statement leading-token check).
40
+ * Currently behaves like 'masked' to avoid silently breaking
41
+ * when rule files use it.
42
+ */
43
+ export type MatchTarget = 'raw' | 'masked' | 'command_tokens';
44
+ export declare function preprocessForMatch(cmd: string, target: MatchTarget | undefined): string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Command-token parser — quote-aware shell command preprocessing.
3
+ *
4
+ * 목적: PreToolUse enforce_via 룰의 정규식이 quote된 인자 텍스트와
5
+ * 명령 토큰을 구분 못 해서 false positive block 발생 (TEST-6, RC5).
6
+ *
7
+ * 사례: forgen compound --solution "title" "본문에 rm -rf 텍스트 포함" 명령이
8
+ * "rm\s+-rf" 패턴에 매칭되어 차단됨. 실제 rm 명령이 아닌데도.
9
+ *
10
+ * 해법: quote된 문자열을 마스킹한 뒤 패턴 매칭. 99% 케이스 커버.
11
+ * 완벽한 shell 파싱은 아니지만 정직하게 한정된 범위.
12
+ */
13
+ /**
14
+ * Mask quoted string contents in a shell command so that text inside
15
+ * single/double quotes, backticks, or $(...) is not matched by patterns
16
+ * intended for command tokens.
17
+ *
18
+ * Examples:
19
+ * maskQuotedContent('rm -rf /') → 'rm -rf /'
20
+ * maskQuotedContent('echo "rm -rf foo"') → 'echo ""'
21
+ * maskQuotedContent("forgen save 'rm -rf body'") → "forgen save ''"
22
+ * maskQuotedContent('rm -rf $(pwd)') → 'rm -rf $()'
23
+ * maskQuotedContent('echo `rm -rf x`') → 'echo ``'
24
+ *
25
+ * Limitations (documented, not silently broken):
26
+ * - escaped quotes inside quoted strings: best-effort only
27
+ * - heredoc bodies (<<EOF ... EOF): masked as `<<HEREDOC>>` (v0.4.1+)
28
+ * - nested $(...) / `...`: outer level masked
29
+ */
30
+ export function maskQuotedContent(cmd) {
31
+ if (!cmd)
32
+ return cmd;
33
+ let out = cmd;
34
+ // v0.4.1 (2026-04-24) — heredoc body 마스킹 추가. 이전엔 `cat > f <<EOF\n rm -rf /tmp \nEOF`
35
+ // 처럼 heredoc 본문이 command string 에 포함돼 false-positive block 발생.
36
+ // 지원 형식: <<EOF / <<'EOF' / <<"EOF" / <<-EOF (indent 무시 변종).
37
+ // <<-MARK 은 indent 허용 (terminator 앞 whitespace). `\n\s*\2` 로 반영.
38
+ out = out.replace(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?\n\s*\2\b/g, '<<HEREDOC>>');
39
+ // Order matters: command substitution before plain quotes (they may contain quotes themselves).
40
+ out = out.replace(/\$\([^)]*\)/g, '$()');
41
+ out = out.replace(/`[^`]*`/g, '``');
42
+ out = out.replace(/'[^']*'/g, "''");
43
+ out = out.replace(/"[^"]*"/g, '""');
44
+ return out;
45
+ }
46
+ export function preprocessForMatch(cmd, target) {
47
+ if (!target || target === 'raw')
48
+ return cmd;
49
+ return maskQuotedContent(cmd);
50
+ }
@@ -18,8 +18,13 @@ export declare function approve(): string;
18
18
  /**
19
19
  * 통과 + 모델에 컨텍스트 주입.
20
20
  * UserPromptSubmit, SessionStart 이벤트에서만 모델에 도달함.
21
+ *
22
+ * H1 (v0.4.1): optional `userNotice` 로 사용자 UI (systemMessage) 에도 동시
23
+ * 1줄 노출. additionalContext 는 모델 전용이라 기존 recall hit 이 8,000+ 번
24
+ * 주입되었는데도 사용자는 0 건을 봤음. userNotice 로 같은 hit 을 사용자
25
+ * 에게 가시화한다.
21
26
  */
22
- export declare function approveWithContext(context: string, eventName: string): string;
27
+ export declare function approveWithContext(context: string, eventName: string, userNotice?: string): string;
23
28
  /**
24
29
  * 통과 + UI 경고 표시 (모델에는 전달되지 않음).
25
30
  * PostToolUse, PreToolUse 경고 등 모델 도달이 불필요한 경우 사용.
@@ -44,6 +49,11 @@ export declare function blockStop(reason: string, systemMessage?: string): strin
44
49
  * fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
45
50
  * forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
46
51
  *
52
+ * v0.4.1 (2026-04-24): optional `err` 매개변수 추가. 실 데이터상 106건의 hook 에러가
53
+ * 누적됐으나 전부 `{hook,at}` 만이라 근원 조사 불가했다. 이제 `error`/`stack` 을
54
+ * 함께 기록해 `forgen doctor` 가 원인 카테고리별로 빈도 surface 가능.
55
+ * payload 는 한 줄 cap(400자)로 잘라 JSONL 크기 폭주 방지.
56
+ *
47
57
  * @fail-open: hook failure must never block the user's workflow
48
58
  */
49
- export declare function failOpenWithTracking(hookName: string): string;
59
+ export declare function failOpenWithTracking(hookName: string, err?: unknown): string;
@@ -23,11 +23,17 @@ export function approve() {
23
23
  /**
24
24
  * 통과 + 모델에 컨텍스트 주입.
25
25
  * UserPromptSubmit, SessionStart 이벤트에서만 모델에 도달함.
26
+ *
27
+ * H1 (v0.4.1): optional `userNotice` 로 사용자 UI (systemMessage) 에도 동시
28
+ * 1줄 노출. additionalContext 는 모델 전용이라 기존 recall hit 이 8,000+ 번
29
+ * 주입되었는데도 사용자는 0 건을 봤음. userNotice 로 같은 hit 을 사용자
30
+ * 에게 가시화한다.
26
31
  */
27
- export function approveWithContext(context, eventName) {
32
+ export function approveWithContext(context, eventName, userNotice) {
28
33
  return JSON.stringify({
29
34
  continue: true,
30
35
  hookSpecificOutput: { hookEventName: eventName, additionalContext: context },
36
+ ...(userNotice ? { systemMessage: userNotice } : {}),
31
37
  });
32
38
  }
33
39
  /**
@@ -81,13 +87,34 @@ export function blockStop(reason, systemMessage) {
81
87
  * fail-open with error tracking: 에러 시 안전하게 통과하되, 실패 정보를 기록.
82
88
  * forgen doctor의 Hook Health 섹션에서 실패 이력을 표시할 수 있도록 JSONL 로그에 기록.
83
89
  *
90
+ * v0.4.1 (2026-04-24): optional `err` 매개변수 추가. 실 데이터상 106건의 hook 에러가
91
+ * 누적됐으나 전부 `{hook,at}` 만이라 근원 조사 불가했다. 이제 `error`/`stack` 을
92
+ * 함께 기록해 `forgen doctor` 가 원인 카테고리별로 빈도 surface 가능.
93
+ * payload 는 한 줄 cap(400자)로 잘라 JSONL 크기 폭주 방지.
94
+ *
84
95
  * @fail-open: hook failure must never block the user's workflow
85
96
  */
86
- export function failOpenWithTracking(hookName) {
97
+ export function failOpenWithTracking(hookName, err) {
87
98
  try {
88
99
  fs.mkdirSync(STATE_DIR, { recursive: true });
89
100
  const logPath = path.join(STATE_DIR, 'hook-errors.jsonl');
90
- const entry = JSON.stringify({ hook: hookName, at: Date.now() });
101
+ const payload = { hook: hookName, at: Date.now() };
102
+ if (err !== undefined && err !== null) {
103
+ if (err instanceof Error) {
104
+ payload.error = err.message.slice(0, 400);
105
+ if (err.stack) {
106
+ // 스택 첫 3줄만 — 어느 파일/라인에서 throw 됐는지만 알면 충분.
107
+ payload.stack = err.stack.split('\n').slice(0, 3).join(' | ').slice(0, 400);
108
+ }
109
+ const maybeCode = err.code;
110
+ if (typeof maybeCode === 'string')
111
+ payload.code = maybeCode;
112
+ }
113
+ else {
114
+ payload.error = String(err).slice(0, 400);
115
+ }
116
+ }
117
+ const entry = JSON.stringify(payload);
91
118
  fs.appendFileSync(logPath, entry + '\n');
92
119
  }
93
120
  catch { /* fail-open: tracking itself must not throw */ }
@@ -312,5 +312,5 @@ async function main() {
312
312
  }
313
313
  main().catch((e) => {
314
314
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
315
- console.log(failOpenWithTracking('skill-injector'));
315
+ console.log(failOpenWithTracking('skill-injector', e));
316
316
  });
@@ -84,10 +84,10 @@ async function main() {
84
84
  }
85
85
  catch (e) {
86
86
  log.debug('슬롭 감지 실패', e);
87
- console.log(failOpenWithTracking('slop-detector'));
87
+ console.log(failOpenWithTracking('slop-detector', e));
88
88
  }
89
89
  }
90
90
  main().catch((e) => {
91
91
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
92
- console.log(failOpenWithTracking('slop-detector'));
92
+ console.log(failOpenWithTracking('slop-detector', e));
93
93
  });
@@ -28,6 +28,15 @@
28
28
  */
29
29
  export declare const MIN_INJECT_RELEVANCE = 0.3;
30
30
  export declare const MIN_INJECT_RELEVANCE_TRUSTED = 0.25;
31
+ /**
32
+ * v0.4.1 — cold-start 사용자 threshold. outcomes 이벤트가 거의 없는 신규 사용자
33
+ * 는 starter-pack 이 champion/active 로 승격될 기회가 없어 0.3 gate 에 막혀 주입 0.
34
+ * 실측 (buyer-day1 v2): starter 15개 중 recall 5건 전부 relevance 0.08~0.25, 주입 0.
35
+ * 이 값으로 첫날부터 매칭 가능성 제공 + 누적 후엔 표준 threshold 로 자연 전환.
36
+ */
37
+ export declare const MIN_INJECT_RELEVANCE_COLD_START = 0.2;
38
+ /** cold-start 판정 임계 — fitness state 있는 솔루션이 이 수 미만이면 신규 사용자 간주. */
39
+ export declare const COLD_START_FITNESS_THRESHOLD = 5;
31
40
  interface SessionCacheCommitResult {
32
41
  /**
33
42
  * commit 상태:
@@ -29,6 +29,7 @@ import { approve, approveWithContext, failOpenWithTracking } from './shared/hook
29
29
  import { STATE_DIR } from '../core/paths.js';
30
30
  import { recordHookTiming } from './shared/hook-timing.js';
31
31
  import { appendPending, flushAccept } from '../engine/solution-outcomes.js';
32
+ import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
32
33
  const MAX_SOLUTIONS_PER_SESSION = 10;
33
34
  /**
34
35
  * Minimum relevance thresholds by fitness state (2026-04-21 gate sweep).
@@ -51,6 +52,15 @@ const MAX_SOLUTIONS_PER_SESSION = 10;
51
52
  */
52
53
  export const MIN_INJECT_RELEVANCE = 0.3;
53
54
  export const MIN_INJECT_RELEVANCE_TRUSTED = 0.25;
55
+ /**
56
+ * v0.4.1 — cold-start 사용자 threshold. outcomes 이벤트가 거의 없는 신규 사용자
57
+ * 는 starter-pack 이 champion/active 로 승격될 기회가 없어 0.3 gate 에 막혀 주입 0.
58
+ * 실측 (buyer-day1 v2): starter 15개 중 recall 5건 전부 relevance 0.08~0.25, 주입 0.
59
+ * 이 값으로 첫날부터 매칭 가능성 제공 + 누적 후엔 표준 threshold 로 자연 전환.
60
+ */
61
+ export const MIN_INJECT_RELEVANCE_COLD_START = 0.2;
62
+ /** cold-start 판정 임계 — fitness state 있는 솔루션이 이 수 미만이면 신규 사용자 간주. */
63
+ export const COLD_START_FITNESS_THRESHOLD = 5;
54
64
  /** 세션별 이미 주입된 솔루션 추적 (중복 방지) */
55
65
  function getSessionCachePath(sessionId) {
56
66
  return path.join(STATE_DIR, `solution-cache-${sanitizeId(sessionId)}.json`);
@@ -347,11 +357,17 @@ async function main() {
347
357
  catch (e) {
348
358
  log.debug('fitness state load 실패 — default 0.3 적용', e);
349
359
  }
360
+ // v0.4.1 cold-start boost: outcome 이벤트가 누적되지 않은 신규 사용자 (fitness
361
+ // state 있는 솔루션 < THRESHOLD) 는 champion/active 로 승격될 기회가 없으므로
362
+ // 보정된 낮은 threshold 적용. 누적되면 자동으로 표준 경로로 전환.
363
+ const isColdStart = fitnessStateMap.size < COLD_START_FITNESS_THRESHOLD;
350
364
  function minRelevanceFor(name) {
351
365
  const state = fitnessStateMap.get(name);
352
- return (state === 'champion' || state === 'active')
353
- ? MIN_INJECT_RELEVANCE_TRUSTED
354
- : MIN_INJECT_RELEVANCE;
366
+ if (state === 'champion' || state === 'active')
367
+ return MIN_INJECT_RELEVANCE_TRUSTED;
368
+ if (isColdStart)
369
+ return MIN_INJECT_RELEVANCE_COLD_START;
370
+ return MIN_INJECT_RELEVANCE;
355
371
  }
356
372
  let experimentCount = 0;
357
373
  const toInject = [];
@@ -530,7 +546,34 @@ async function main() {
530
546
  catch (e) {
531
547
  log.debug('outcome appendPending 실패', e);
532
548
  }
533
- console.log(approveWithContext(fullInjection, 'UserPromptSubmit'));
549
+ // H4: 양수 implicit-feedback — 솔루션이 실제로 사용자에게 surface 되었음을 기록.
550
+ // v0.4.0 의 enforcement 축은 block/violation 만 카운트했고 assist (solution 노출)
551
+ // 은 0건이었다. 이 emit 으로 forgen stats / session-quality-scorer 가 "오늘
552
+ // N개 surfaced" 를 계산할 수 있다.
553
+ try {
554
+ const now = new Date().toISOString();
555
+ for (const sol of effectiveToInject) {
556
+ appendImplicitFeedback({
557
+ type: 'recommendation_surfaced',
558
+ category: 'positive',
559
+ solution: sol.name,
560
+ match_score: sol.relevance,
561
+ at: now,
562
+ sessionId,
563
+ });
564
+ }
565
+ }
566
+ catch (e) {
567
+ log.debug('recommendation_surfaced emit 실패', e);
568
+ }
569
+ // H1: 사용자 UI 에 recall hit 1줄 노출. additionalContext 는 모델 전용이라
570
+ // v0.4.0 에서 8,000+ 주입이 발생했는데도 사용자는 0건을 봤다. systemMessage
571
+ // 로 "N개 솔루션 참조" 를 surface → 사용자가 어떤 축적 지식이 붙었는지 인식.
572
+ const topNames = effectiveToInject.slice(0, 3).map((s) => s.name);
573
+ const more = effectiveToInject.length - topNames.length;
574
+ const noticeNames = more > 0 ? `${topNames.join(', ')} (+${more})` : topNames.join(', ');
575
+ const userNotice = `[Forgen] 🔎 ${effectiveToInject.length} solution${effectiveToInject.length === 1 ? '' : 's'} recalled: ${noticeNames}`;
576
+ console.log(approveWithContext(fullInjection, 'UserPromptSubmit', userNotice));
534
577
  }
535
578
  finally {
536
579
  recordHookTiming('solution-injector', Date.now() - _hookStart, 'UserPromptSubmit');
@@ -538,5 +581,5 @@ async function main() {
538
581
  }
539
582
  main().catch((e) => {
540
583
  process.stderr.write(`[ch-hook] solution-injector: ${e instanceof Error ? e.message : String(e)}\n`);
541
- console.log(failOpenWithTracking('solution-injector'));
584
+ console.log(failOpenWithTracking('solution-injector', e));
542
585
  });