@wooojin/forgen 0.3.2 → 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 (124) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.ja.md +119 -8
  4. package/README.ko.md +73 -2
  5. package/README.md +163 -9
  6. package/README.zh.md +87 -7
  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 +158 -6
  14. package/dist/core/auto-compound-runner.js +85 -13
  15. package/dist/core/dashboard.js +9 -2
  16. package/dist/core/doctor.js +90 -15
  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 +64 -5
  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/state-gc.d.ts +19 -0
  32. package/dist/core/state-gc.js +48 -4
  33. package/dist/core/stats-cli.d.ts +36 -0
  34. package/dist/core/stats-cli.js +254 -0
  35. package/dist/core/uninstall.d.ts +1 -0
  36. package/dist/core/uninstall.js +25 -1
  37. package/dist/core/v1-bootstrap.js +9 -1
  38. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  39. package/dist/engine/classify-enforce-cli.js +61 -0
  40. package/dist/engine/compound-cli.js +1 -0
  41. package/dist/engine/compound-export.js +8 -3
  42. package/dist/engine/enforce-classifier.d.ts +31 -0
  43. package/dist/engine/enforce-classifier.js +123 -0
  44. package/dist/engine/learn-cli.js +1 -4
  45. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  46. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  47. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  48. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  49. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  50. package/dist/engine/lifecycle/meta-cli.js +7 -0
  51. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  52. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  53. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  54. package/dist/engine/lifecycle/orchestrator.js +131 -0
  55. package/dist/engine/lifecycle/signals.d.ts +30 -0
  56. package/dist/engine/lifecycle/signals.js +142 -0
  57. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  58. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  59. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  60. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  61. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  62. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  63. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  64. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  65. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  66. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  67. package/dist/engine/lifecycle/types.d.ts +52 -0
  68. package/dist/engine/lifecycle/types.js +7 -0
  69. package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
  70. package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
  71. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  72. package/dist/engine/rule-toggle-cli.js +76 -0
  73. package/dist/engine/skill-promoter.js +3 -6
  74. package/dist/forge/evidence-processor.js +10 -2
  75. package/dist/hooks/context-guard.js +72 -1
  76. package/dist/hooks/dangerous-patterns.json +3 -3
  77. package/dist/hooks/db-guard.js +18 -2
  78. package/dist/hooks/intent-classifier.js +1 -1
  79. package/dist/hooks/keyword-detector.js +1 -1
  80. package/dist/hooks/notepad-injector.js +1 -1
  81. package/dist/hooks/permission-handler.js +1 -1
  82. package/dist/hooks/post-tool-failure.js +1 -1
  83. package/dist/hooks/post-tool-use.d.ts +6 -0
  84. package/dist/hooks/post-tool-use.js +94 -14
  85. package/dist/hooks/pre-compact.js +1 -1
  86. package/dist/hooks/pre-tool-use.d.ts +7 -0
  87. package/dist/hooks/pre-tool-use.js +79 -5
  88. package/dist/hooks/rate-limiter.js +1 -1
  89. package/dist/hooks/secret-filter.d.ts +10 -0
  90. package/dist/hooks/secret-filter.js +21 -1
  91. package/dist/hooks/session-recovery.js +1 -1
  92. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  93. package/dist/hooks/shared/atomic-write.js +17 -3
  94. package/dist/hooks/shared/command-parser.d.ts +44 -0
  95. package/dist/hooks/shared/command-parser.js +50 -0
  96. package/dist/hooks/shared/hook-response.d.ts +23 -2
  97. package/dist/hooks/shared/hook-response.js +48 -3
  98. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  99. package/dist/hooks/shared/safe-regex.js +50 -0
  100. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  101. package/dist/hooks/shared/stop-triggers.js +19 -0
  102. package/dist/hooks/skill-injector.js +1 -1
  103. package/dist/hooks/slop-detector.js +2 -2
  104. package/dist/hooks/solution-injector.d.ts +9 -0
  105. package/dist/hooks/solution-injector.js +48 -5
  106. package/dist/hooks/stop-guard.d.ts +84 -0
  107. package/dist/hooks/stop-guard.js +606 -0
  108. package/dist/hooks/subagent-tracker.js +1 -1
  109. package/dist/i18n/index.js +3 -5
  110. package/dist/mcp/tools.js +19 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +61 -1
  113. package/dist/store/implicit-feedback-store.d.ts +59 -0
  114. package/dist/store/implicit-feedback-store.js +153 -0
  115. package/dist/store/rule-lifecycle.d.ts +23 -0
  116. package/dist/store/rule-lifecycle.js +63 -0
  117. package/dist/store/rule-store.d.ts +21 -0
  118. package/dist/store/rule-store.js +136 -8
  119. package/dist/store/types.d.ts +83 -0
  120. package/dist/store/types.js +7 -1
  121. package/hooks/hook-registry.json +1 -0
  122. package/hooks/hooks.json +6 -1
  123. package/package.json +11 -3
  124. package/plugin.json +1 -1
@@ -0,0 +1,78 @@
1
+ /**
2
+ * T5 — 규칙 충돌 (conflict_detected).
3
+ *
4
+ * 트리거 조건:
5
+ * 같은 category 인 rule pair 중, policy 자연어가 상반 (negation + 공통 키워드 ≥ 2).
6
+ *
7
+ * auto-merge 안 함. conflict_refs 플래그만 설정 → 사용자 수동 해소.
8
+ */
9
+ const NEGATION_RE = /\b(없|금지|마라|말라|하지\s*않|don'?t|never|not\s+|no\s+|avoid)\b/i;
10
+ function tokens(policy) {
11
+ return new Set(policy
12
+ .toLowerCase()
13
+ .replace(/[.,;:!?()[\]{}"'`~]/g, ' ')
14
+ .split(/\s+/)
15
+ .filter((w) => w.length >= 3));
16
+ }
17
+ function sharedTokens(a, b, min) {
18
+ let count = 0;
19
+ for (const t of a)
20
+ if (b.has(t)) {
21
+ count += 1;
22
+ if (count >= min)
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+ export function detect(input) {
28
+ const minShared = input.min_shared_tokens ?? 2;
29
+ const ts = input.ts ?? Date.now();
30
+ const events = [];
31
+ const reported = new Set(); // 'a|b' 쌍 중복 방지
32
+ // M/T5 fix: 짧은 policy 는 토큰 overlap 이 우연히 발생하기 쉬우므로 20자 이상만.
33
+ // scope 도 같아야 — session-scoped 임시 규칙과 me-scope 영구 규칙이 서로 충돌로 잡히면 노이즈.
34
+ const active = input.rules.filter((r) => r.status === 'active' && r.policy.length >= 20);
35
+ for (let i = 0; i < active.length; i++) {
36
+ const a = active[i];
37
+ const aTokens = tokens(a.policy);
38
+ const aNeg = NEGATION_RE.test(a.policy);
39
+ for (let j = i + 1; j < active.length; j++) {
40
+ const b = active[j];
41
+ if (a.category !== b.category)
42
+ continue;
43
+ if (a.scope !== b.scope)
44
+ continue; // M/T5: scope 불일치 시 pair 아님
45
+ const bTokens = tokens(b.policy);
46
+ const bNeg = NEGATION_RE.test(b.policy);
47
+ if (aNeg === bNeg)
48
+ continue; // 같은 어조 — 충돌 아님
49
+ if (!sharedTokens(aTokens, bTokens, minShared))
50
+ continue;
51
+ const key = [a.rule_id, b.rule_id].sort().join('|');
52
+ if (reported.has(key))
53
+ continue;
54
+ reported.add(key);
55
+ events.push({
56
+ kind: 't5_conflict_detected',
57
+ rule_id: a.rule_id,
58
+ evidence: {
59
+ source: 'rule-pairing',
60
+ refs: [a.rule_id, b.rule_id],
61
+ },
62
+ suggested_action: 'flag',
63
+ ts,
64
+ });
65
+ events.push({
66
+ kind: 't5_conflict_detected',
67
+ rule_id: b.rule_id,
68
+ evidence: {
69
+ source: 'rule-pairing',
70
+ refs: [a.rule_id, b.rule_id],
71
+ },
72
+ suggested_action: 'flag',
73
+ ts,
74
+ });
75
+ }
76
+ }
77
+ return events;
78
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ADR-002 Lifecycle event model.
3
+ *
4
+ * 오케스트레이터가 발행하는 이벤트 — rule 상태 전이의 단위.
5
+ * 이 파일은 타입만 정의. 실제 이벤트 발행/소비 로직은 각 trigger-*.ts 참조.
6
+ */
7
+ export type LifecycleEventKind = 't1_explicit_correction' | 't2_repeated_violation' | 't3_user_bypass' | 't4_time_decay' | 't5_conflict_detected' | 'meta_promote_to_a' | 'meta_demote_to_b';
8
+ export type LifecycleSuggestedAction = 'flag' | 'suppress' | 'retire' | 'merge' | 'supersede' | 'promote_mech' | 'demote_mech';
9
+ export interface LifecycleEvent {
10
+ kind: LifecycleEventKind;
11
+ rule_id: string;
12
+ session_id?: string;
13
+ evidence?: {
14
+ source: string;
15
+ refs: string[];
16
+ metrics?: Record<string, number>;
17
+ };
18
+ suggested_action: LifecycleSuggestedAction;
19
+ /** T5 merge 전용: 흡수 대상 rule_id */
20
+ merged_into?: string;
21
+ /** T1 supersede 전용: 교체 rule_id */
22
+ superseded_by?: string;
23
+ ts: number;
24
+ }
25
+ /**
26
+ * 트리거들이 공유하는 rule-level 시그널 집계.
27
+ * RuleState 는 Rule + signals (pure data). 각 detect() 는 이 상태 배열을 입력으로 받는다.
28
+ */
29
+ export interface RuleSignals {
30
+ violations_30d: number;
31
+ violation_rate_30d: number;
32
+ bypass_7d: number;
33
+ last_inject_days_ago: number;
34
+ injects_rolling_n: number;
35
+ violations_rolling_n: number;
36
+ last_updated_days_ago: number;
37
+ }
38
+ export interface ViolationEntry {
39
+ at: string;
40
+ rule_id: string;
41
+ session_id: string;
42
+ source: 'stop-guard' | 'pre-tool-guard' | 'post-tool-guard' | 'evidence-store' | 'manual';
43
+ kind: 'block' | 'deny' | 'correction';
44
+ message_preview?: string;
45
+ }
46
+ export interface BypassEntry {
47
+ at: string;
48
+ rule_id: string;
49
+ session_id: string;
50
+ tool: string;
51
+ pattern_preview: string;
52
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ADR-002 Lifecycle event model.
3
+ *
4
+ * 오케스트레이터가 발행하는 이벤트 — rule 상태 전이의 단위.
5
+ * 이 파일은 타입만 정의. 실제 이벤트 발행/소비 로직은 각 trigger-*.ts 참조.
6
+ */
7
+ export {};
@@ -12,6 +12,7 @@
12
12
  * - state/sessions/{sessionId}.json → session metadata
13
13
  */
14
14
  import type { SessionQualityScore } from './types.js';
15
+ import { type ImplicitFeedbackEntry } from '../../store/implicit-feedback-store.js';
15
16
  interface InjectionCacheData {
16
17
  injected: string[];
17
18
  totalInjectedChars: number;
@@ -29,12 +30,6 @@ interface DriftState {
29
30
  lastCriticalAt: number | null;
30
31
  hardCapReached: boolean;
31
32
  }
32
- interface ImplicitFeedbackEntry {
33
- type: string;
34
- sessionId?: string;
35
- at: string;
36
- [key: string]: unknown;
37
- }
38
33
  export declare function loadInjectionCache(sessionId: string): InjectionCacheData | null;
39
34
  export declare function loadDriftState(sessionId: string): DriftState | null;
40
35
  export declare function loadImplicitFeedback(sessionId: string): ImplicitFeedbackEntry[];
@@ -15,6 +15,7 @@ import * as fs from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { ME_BEHAVIOR, STATE_DIR } from '../../core/paths.js';
17
17
  import { safeReadJSON } from '../../hooks/shared/atomic-write.js';
18
+ import { loadImplicitFeedback as loadImplicitFeedbackFromStore, } from '../../store/implicit-feedback-store.js';
18
19
  function sanitizeId(id) {
19
20
  return id.replace(/[^a-zA-Z0-9_-]/g, '_');
20
21
  }
@@ -32,27 +33,7 @@ export function loadDriftState(sessionId) {
32
33
  return data?.drift ?? null;
33
34
  }
34
35
  export function loadImplicitFeedback(sessionId) {
35
- const logPath = path.join(STATE_DIR, 'implicit-feedback.jsonl');
36
- try {
37
- if (!fs.existsSync(logPath))
38
- return [];
39
- const lines = fs.readFileSync(logPath, 'utf-8').split('\n').filter(Boolean);
40
- const entries = [];
41
- for (const line of lines) {
42
- try {
43
- const entry = JSON.parse(line);
44
- if (entry.sessionId === sessionId)
45
- entries.push(entry);
46
- }
47
- catch {
48
- /* skip malformed lines */
49
- }
50
- }
51
- return entries;
52
- }
53
- catch {
54
- return [];
55
- }
36
+ return loadImplicitFeedbackFromStore(sessionId);
56
37
  }
57
38
  export function loadSessionCorrections(sessionId) {
58
39
  try {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * CLI handler for `forgen suppress-rule <id>` / `forgen activate-rule <id>`.
3
+ *
4
+ * R7-U2: Day-1 탈출구 — 사용자가 차단 메시지를 보고 JSON 을 손으로 편집하지 않아도
5
+ * 한 명령으로 규칙을 끄거나 되살릴 수 있도록.
6
+ *
7
+ * 구현:
8
+ * - prefix-match 지원 (첫 8자만 쳐도 OK).
9
+ * - multiple match 이면 목록 출력하고 중단.
10
+ * - hard strength rule 은 cli 로도 suppress 불가 (ADR-002 불변 원칙).
11
+ */
12
+ export declare function handleSuppressRule(args: string[]): Promise<void>;
13
+ export declare function handleActivateRule(args: string[]): Promise<void>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * CLI handler for `forgen suppress-rule <id>` / `forgen activate-rule <id>`.
3
+ *
4
+ * R7-U2: Day-1 탈출구 — 사용자가 차단 메시지를 보고 JSON 을 손으로 편집하지 않아도
5
+ * 한 명령으로 규칙을 끄거나 되살릴 수 있도록.
6
+ *
7
+ * 구현:
8
+ * - prefix-match 지원 (첫 8자만 쳐도 OK).
9
+ * - multiple match 이면 목록 출력하고 중단.
10
+ * - hard strength rule 은 cli 로도 suppress 불가 (ADR-002 불변 원칙).
11
+ */
12
+ import { loadAllRules, saveRule } from '../store/rule-store.js';
13
+ export async function handleSuppressRule(args) {
14
+ const partial = args[0];
15
+ if (!partial) {
16
+ console.error('Usage: forgen suppress-rule <rule_id | prefix>');
17
+ process.exit(2);
18
+ }
19
+ const all = loadAllRules();
20
+ const matches = all.filter((r) => r.rule_id === partial || r.rule_id.startsWith(partial));
21
+ if (matches.length === 0) {
22
+ console.error(`No rule found matching "${partial}". Try \`forgen inspect rules\`.`);
23
+ process.exit(1);
24
+ }
25
+ if (matches.length > 1) {
26
+ console.error(`Ambiguous prefix "${partial}" — ${matches.length} rules match:`);
27
+ for (const r of matches) {
28
+ console.error(` ${r.rule_id} [${r.strength}] ${r.policy.slice(0, 60)}`);
29
+ }
30
+ console.error('Use a longer prefix or the full rule_id.');
31
+ process.exit(1);
32
+ }
33
+ const rule = matches[0];
34
+ if (rule.strength === 'hard') {
35
+ console.error(`Refusing to suppress hard rule "${rule.rule_id}" (ADR-002 immutability).`);
36
+ console.error(' Hard rules require explicit removal from the rule source file.');
37
+ process.exit(1);
38
+ }
39
+ if (rule.status === 'suppressed') {
40
+ console.log(`Rule ${rule.rule_id} is already suppressed.`);
41
+ return;
42
+ }
43
+ saveRule({ ...rule, status: 'suppressed' });
44
+ console.log(`✓ Suppressed rule ${rule.rule_id}`);
45
+ console.log(` Policy: ${rule.policy.slice(0, 80)}`);
46
+ console.log(` Re-activate with: forgen activate-rule ${rule.rule_id}`);
47
+ }
48
+ export async function handleActivateRule(args) {
49
+ const partial = args[0];
50
+ if (!partial) {
51
+ console.error('Usage: forgen activate-rule <rule_id | prefix>');
52
+ process.exit(2);
53
+ }
54
+ const all = loadAllRules();
55
+ const matches = all.filter((r) => r.rule_id === partial || r.rule_id.startsWith(partial));
56
+ if (matches.length === 0) {
57
+ console.error(`No rule found matching "${partial}".`);
58
+ process.exit(1);
59
+ }
60
+ if (matches.length > 1) {
61
+ console.error(`Ambiguous prefix "${partial}" — ${matches.length} rules. Use longer prefix.`);
62
+ process.exit(1);
63
+ }
64
+ const rule = matches[0];
65
+ if (rule.status === 'active') {
66
+ console.log(`Rule ${rule.rule_id} is already active.`);
67
+ return;
68
+ }
69
+ if (rule.status === 'removed' || rule.status === 'superseded') {
70
+ console.error(`Cannot activate rule with status=${rule.status}. Edit the rule file directly.`);
71
+ process.exit(1);
72
+ }
73
+ saveRule({ ...rule, status: 'active' });
74
+ console.log(`✓ Activated rule ${rule.rule_id}`);
75
+ console.log(` Policy: ${rule.policy.slice(0, 80)}`);
76
+ }
@@ -6,15 +6,12 @@
6
6
  */
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
- import * as os from 'node:os';
10
9
  import { parseSolutionV3 } from './solution-format.js';
11
10
  import { createLogger } from '../core/logger.js';
11
+ import { ME_SOLUTIONS, ME_SKILLS, CLAUDE_DIR } from '../core/paths.js';
12
12
  const log = createLogger('skill-promoter');
13
- const FORGEN_HOME = path.join(os.homedir(), '.forgen');
14
- const ME_SOLUTIONS = path.join(FORGEN_HOME, 'me', 'solutions');
15
- const ME_SKILLS = path.join(FORGEN_HOME, 'me', 'skills');
16
- // Claude Code가 자동 인식하는 글로벌 스킬 경로
17
- const CLAUDE_SKILLS = path.join(os.homedir(), '.claude', 'skills');
13
+ // Claude Code가 자동 인식하는 글로벌 스킬 경로 (~/.claude/skills)
14
+ const CLAUDE_SKILLS = path.join(CLAUDE_DIR, 'skills');
18
15
  // 일반적인 태그 제외 (트리거로 부적합)
19
16
  const GENERIC_TAGS = new Set([
20
17
  'typescript', 'javascript', 'react', 'node', 'error', 'fix', 'code',
@@ -5,8 +5,9 @@
5
5
  * Migration Plan §5.4: 이 모듈의 "해석" 계층은 AI(Claude 세션)가 채운다.
6
6
  * 여기서는 구조화된 입출력 인터페이스와 알고리즘 적용 함수만 정의.
7
7
  */
8
- import { createEvidence, saveEvidence } from '../store/evidence-store.js';
8
+ import { createEvidence, appendEvidence } from '../store/evidence-store.js';
9
9
  import { createRule, saveRule } from '../store/rule-store.js';
10
+ import { classify, applyProposal } from '../engine/enforce-classifier.js';
10
11
  // ── Correction → Evidence + Temporary Rule ──
11
12
  /**
12
13
  * 사용자 교정을 Evidence로 기록하고, 필요 시 temporary rule 생성.
@@ -29,7 +30,7 @@ export function processCorrection(req) {
29
30
  direction: req.kind === 'avoid-this' ? 'opposite' : 'same',
30
31
  },
31
32
  });
32
- saveEvidence(evidence);
33
+ appendEvidence(evidence); // T1 lifecycle trigger fires here for explicit_correction
33
34
  // fix-now, avoid-this → temporary session rule
34
35
  let temporaryRule = null;
35
36
  if (req.kind === 'fix-now' || req.kind === 'avoid-this') {
@@ -45,6 +46,13 @@ export function processCorrection(req) {
45
46
  evidence_refs: [evidence.evidence_id],
46
47
  render_key: `${req.axis_hint ?? 'workflow'}.${req.target.toLowerCase().replace(/\s+/g, '-').slice(0, 30)}`,
47
48
  });
49
+ // ADR-001 auto-classify on creation — 교정 즉시 enforce_via 가 붙어야 다음 턴부터 Mech-A/B 발화.
50
+ // 기존 `forgen classify-enforce --apply` 수동 경로를 유지하되, 신규 rule 은 창조 시점에 자동 populate.
51
+ try {
52
+ const proposal = classify(temporaryRule);
53
+ temporaryRule = applyProposal(temporaryRule, proposal);
54
+ }
55
+ catch { /* fail-open: classify 실패는 rule 저장 자체를 막지 않음 */ }
48
56
  saveRule(temporaryRule);
49
57
  }
50
58
  return {
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
+ import * as os from 'node:os';
14
15
  import { fileURLToPath } from 'node:url';
15
16
  import { createLogger } from '../core/logger.js';
16
17
  import { readStdinJSON } from './shared/read-stdin.js';
@@ -129,6 +130,16 @@ export async function main() {
129
130
  // 정상 종료 시: 의미 있는 세션이었으면 compound 안내/자동 트리거
130
131
  if (input.stop_hook_type === 'user' || input.stop_hook_type === 'end_turn') {
131
132
  const state = loadContextState(sessionId);
133
+ // ADR-002 T1 — 세션 중간에 교정이 들어와도 session-scoped rule 이 me-scope 으로
134
+ // 승급되도록 Stop 에서 직접 auto-compound-runner 를 debounced 로 트리거.
135
+ // 'forgen' CLI 를 통하지 않는 사용자 (claude 직접 실행) 에게도 교정이 유실되지 않는 보장.
136
+ // dedup: last-auto-compound.json 의 sessionId + 5분 cooldown.
137
+ try {
138
+ await maybeSpawnAutoCompound(sessionId, input.transcript_path, state.promptCount);
139
+ }
140
+ catch (e) {
141
+ log.debug('auto-compound Stop trigger 실패', e);
142
+ }
132
143
  if (state.promptCount >= 20) {
133
144
  // 20+ prompts: auto-trigger compound by writing marker
134
145
  try {
@@ -224,6 +235,66 @@ function buildSessionSummary(sessionId, promptCount) {
224
235
  }
225
236
  // forge-loop 상태 파일 경로
226
237
  const FORGE_LOOP_STATE_PATH = path.join(STATE_DIR, 'forge-loop.json');
238
+ /**
239
+ * Stop hook 에서 auto-compound-runner 를 debounced 로 spawn.
240
+ *
241
+ * 호출 조건:
242
+ * - promptCount ≥ 10 (의미있는 세션)
243
+ * - transcript_path 유효
244
+ * - last-auto-compound.json 의 sessionId 가 다르거나 5분 전
245
+ *
246
+ * dedup 파일은 session-recovery hook 과 공유되어 double-run 방지.
247
+ * fire-and-forget (detached) — hook timeout 과 무관.
248
+ */
249
+ const AUTO_COMPOUND_COOLDOWN_MS = 5 * 60 * 1000; // 5 min
250
+ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
251
+ if (!transcriptPath || promptCount < 10)
252
+ return;
253
+ const markerPath = path.join(STATE_DIR, 'last-auto-compound.json');
254
+ try {
255
+ const raw = fs.readFileSync(markerPath, 'utf-8');
256
+ const parsed = JSON.parse(raw);
257
+ if (parsed.sessionId === sessionId) {
258
+ const last = parsed.completedAt ? Date.parse(parsed.completedAt) : 0;
259
+ if (Number.isFinite(last) && Date.now() - last < AUTO_COMPOUND_COOLDOWN_MS)
260
+ return;
261
+ }
262
+ }
263
+ catch { /* first time or corrupt — proceed */ }
264
+ const { spawn: spawnProcess } = await import('node:child_process');
265
+ const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
266
+ // 기본: 번들된 auto-compound-runner. 프로덕션 빌드는 이 경로만 실행.
267
+ const defaultRunner = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js');
268
+ // 테스트 주입 경로 — FORGEN_TEST=1 게이트 + 경로 containment (~/.forgen 또는 /tmp 하위만 허용).
269
+ // FORGEN_TEST 없이 FORGEN_AUTO_COMPOUND_RUNNER_PATH 만 설정되어도 무시 → 임의 코드 실행 방지.
270
+ let runnerPath = defaultRunner;
271
+ const override = process.env.FORGEN_AUTO_COMPOUND_RUNNER_PATH;
272
+ if (override && process.env.FORGEN_TEST === '1') {
273
+ const resolved = path.resolve(override);
274
+ const homeDir = os.homedir();
275
+ const allowed = [
276
+ path.join(homeDir, '.forgen'),
277
+ os.tmpdir(), // 플랫폼별 /tmp, /var/folders/... 등
278
+ '/tmp',
279
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'),
280
+ ];
281
+ if (allowed.some((root) => resolved === root || resolved.startsWith(root + path.sep))) {
282
+ runnerPath = resolved;
283
+ }
284
+ else {
285
+ log.debug(`FORGEN_AUTO_COMPOUND_RUNNER_PATH 무시 — ${resolved} 가 허용 루트 밖`);
286
+ }
287
+ }
288
+ else if (override) {
289
+ log.debug('FORGEN_AUTO_COMPOUND_RUNNER_PATH 무시 — FORGEN_TEST=1 가 필요');
290
+ }
291
+ const child = spawnProcess('node', [runnerPath, cwd, transcriptPath, sessionId], {
292
+ detached: true,
293
+ stdio: 'ignore',
294
+ });
295
+ child.unref();
296
+ log.debug(`Stop-triggered auto-compound 시작: ${sessionId} (${promptCount} prompts)`);
297
+ }
227
298
  // forge-loop 차단 안전 상한 (무한 루프 방지)
228
299
  const FORGE_LOOP_MAX_BLOCKS = 30;
229
300
  const FORGE_LOOP_STALE_MS = 2 * 60 * 60 * 1000; // 2시간
@@ -337,6 +408,6 @@ function saveHandoff(sessionId, reason, detail) {
337
408
  if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
338
409
  main().catch((e) => {
339
410
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
340
- console.log(failOpenWithTracking('context-guard'));
411
+ console.log(failOpenWithTracking('context-guard', e));
341
412
  });
342
413
  }
@@ -1,5 +1,5 @@
1
1
  [
2
- { "pattern": "rm\\s+(-rf|-fr)\\s+[/~]", "description": "rm -rf on root/home path", "severity": "block" },
2
+ { "pattern": "rm\\s+(-rf|-fr)\\s+(\\/(?!tmp\\b|var\\/folders\\b|var\\/tmp\\b)|~)", "description": "rm -rf on root/home path", "severity": "block" },
3
3
  { "pattern": "rm\\s+(-rf|-fr)\\s+\\.\\s", "description": "rm -rf on current directory", "severity": "block" },
4
4
  { "pattern": "git\\s+push\\s+.*--force(?!-)", "description": "git push --force", "severity": "warn" },
5
5
  { "pattern": "git\\s+reset\\s+--hard", "description": "git reset --hard", "severity": "warn" },
@@ -9,10 +9,10 @@
9
9
  { "pattern": ">\\s*\\/dev\\/sd[a-z]", "description": "write to block device", "severity": "block" },
10
10
  { "pattern": "mkfs\\s", "description": "mkfs (format filesystem)", "severity": "block" },
11
11
  { "pattern": ":\\(\\)\\s*\\{\\s*:\\|:&\\s*\\}\\s*;:", "description": "fork bomb", "severity": "block" },
12
- { "pattern": "\\beval\\s+[\"'`]", "description": "eval with string (injection risk)", "severity": "warn" },
12
+ { "pattern": "\\beval\\s+[\"'`]", "description": "eval with string (injection risk)", "severity": "warn", "match_target": "raw" },
13
13
  { "pattern": "curl\\s+.*\\|\\s*(ba)?sh", "description": "curl pipe to shell", "severity": "block" },
14
14
  { "pattern": "wget\\s+.*\\|\\s*(ba)?sh", "description": "wget pipe to shell", "severity": "block" },
15
- { "pattern": "python[23]?\\s+-c\\s+['\"].*(?:import\\s+os|subprocess|exec|eval)", "description": "python -c with dangerous imports", "severity": "warn" },
15
+ { "pattern": "python[23]?\\s+-c\\s+['\"].*(?:import\\s+os|subprocess|exec|eval)", "description": "python -c with dangerous imports", "severity": "warn", "match_target": "raw" },
16
16
  { "pattern": "\\bchmod\\s+[0-7]*777\\b", "description": "chmod 777 (overly permissive)", "severity": "warn" },
17
17
  { "pattern": "\\bdd\\s+.*of=\\/dev\\/", "description": "dd write to device", "severity": "block" }
18
18
  ]
@@ -11,6 +11,7 @@ import { atomicWriteJSON } from './shared/atomic-write.js';
11
11
  import { isHookEnabled } from './hook-config.js';
12
12
  import { approve, approveWithWarning, deny, failOpenWithTracking } from './shared/hook-response.js';
13
13
  import { STATE_DIR } from '../core/paths.js';
14
+ import { preprocessForMatch } from './shared/command-parser.js';
14
15
  const FAIL_COUNTER_PATH = path.join(STATE_DIR, 'db-guard-fail-counter.json');
15
16
  const FAIL_CLOSE_THRESHOLD = 3;
16
17
  export const DANGEROUS_SQL_PATTERNS = [
@@ -27,8 +28,23 @@ export function checkDangerousSql(toolName, toolInput) {
27
28
  const command = typeof toolInput === 'string'
28
29
  ? toolInput
29
30
  : (toolInput.command ?? '');
31
+ // TEST-6 확장 (2026-04-24): DB CLI allowlist 기반 quote-aware 전처리.
32
+ //
33
+ // 결함: 이전에는 raw command 를 직접 매칭해 `git commit -m "... DROP TABLE ..."`
34
+ // 같은 quote 안 SQL 키워드까지 block (실증: 이번 세션 내 release 커밋 메시지 차단).
35
+ //
36
+ // 단순히 masked 만 쓰면 `psql -c "DROP TABLE users"` 같은 실 DB 실행의 True-Positive
37
+ // 까지 놓친다. 해법: masked 처리 후에도 **DB CLI 토큰** 이 보이면 진짜 실행 의도
38
+ // 라고 판단해 raw 를 검사, 아니면 masked 를 검사.
39
+ // - `psql -c "DROP TABLE"` → masked: `psql -c ""` → psql 존재 → raw 검사 → block
40
+ // - `git commit -m "DROP TABLE"` → masked: `git commit -m ""` → psql 없음 → masked 검사 → pass
41
+ // - `DROP DATABASE production` (direct SQL) → masked 그대로 (quote 없음) → block
42
+ const maskedCommand = preprocessForMatch(command, 'masked');
43
+ const dbCliRe = /\b(psql|mysql|sqlite3?|pg_restore|mongosh|mysqldump|cockroach\s+sql|redis-cli)\b/i;
44
+ const hasDbCli = dbCliRe.test(maskedCommand);
45
+ const scanCommand = hasDbCli ? command : maskedCommand;
30
46
  // 주석 제거 후 SQL에 대해 패턴 매칭 (주석 안 키워드 오차단 방지)
31
- const sqlWithoutComments = command
47
+ const sqlWithoutComments = scanCommand
32
48
  .replace(/--[^\n]*/g, '') // 라인 주석 제거
33
49
  .replace(/\/\*[\s\S]*?\*\//g, ''); // 블록 주석 제거
34
50
  for (const { pattern, description, severity } of DANGEROUS_SQL_PATTERNS) {
@@ -101,5 +117,5 @@ async function main() {
101
117
  }
102
118
  main().catch((e) => {
103
119
  process.stderr.write(`[ch-hook] DB Guard error: ${e instanceof Error ? e.message : String(e)}\n`);
104
- console.log(failOpenWithTracking('db-guard'));
120
+ console.log(failOpenWithTracking('db-guard', e));
105
121
  });
@@ -83,5 +83,5 @@ async function main() {
83
83
  }
84
84
  main().catch((e) => {
85
85
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
86
- console.log(failOpenWithTracking('intent-classifier'));
86
+ console.log(failOpenWithTracking('intent-classifier', e));
87
87
  });
@@ -308,6 +308,6 @@ async function main() {
308
308
  if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
309
309
  main().catch((e) => {
310
310
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
311
- console.log(failOpenWithTracking('keyword-detector'));
311
+ console.log(failOpenWithTracking('keyword-detector', e));
312
312
  });
313
313
  }
@@ -50,5 +50,5 @@ async function main() {
50
50
  }
51
51
  main().catch((e) => {
52
52
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
53
- console.log(failOpenWithTracking('notepad-injector'));
53
+ console.log(failOpenWithTracking('notepad-injector', e));
54
54
  });
@@ -129,5 +129,5 @@ async function main() {
129
129
  }
130
130
  main().catch((e) => {
131
131
  process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
132
- console.log(failOpenWithTracking('permission-handler'));
132
+ console.log(failOpenWithTracking('permission-handler', e));
133
133
  });
@@ -127,5 +127,5 @@ main().catch((e) => {
127
127
  hookName: 'post-tool-failure', eventType: 'PostToolUseFailure', cause: e,
128
128
  });
129
129
  process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
130
- console.log(failOpenWithTracking('post-tool-failure'));
130
+ console.log(failOpenWithTracking('post-tool-failure', e));
131
131
  });
@@ -18,6 +18,12 @@ interface ModifiedFilesState {
18
18
  recentWrites?: Record<string, string[]>;
19
19
  /** Drift detection state */
20
20
  drift?: DriftState;
21
+ /**
22
+ * TEST-2 support: 최근 N개 tool 이름 (가장 최근이 마지막). 세션 시작 이래 누적된
23
+ * 도구 이름을 그대로 끝까지 보관하면 메모리 낭비이므로 slice window.
24
+ * stop-guard 가 "측정 도구 호출 수" 를 빠르게 계산.
25
+ */
26
+ recentToolNames?: string[];
21
27
  }
22
28
  export declare const ERROR_PATTERNS: Array<{
23
29
  pattern: RegExp;