@wooojin/forgen 0.3.2 → 0.4.0

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 +64 -0
  3. package/README.ja.md +61 -7
  4. package/README.ko.md +15 -1
  5. package/README.md +92 -6
  6. package/README.zh.md +61 -7
  7. package/dist/cli.js +137 -5
  8. package/dist/core/auto-compound-runner.js +10 -2
  9. package/dist/core/doctor.js +64 -10
  10. package/dist/core/inspect-cli.js +65 -5
  11. package/dist/core/state-gc.d.ts +19 -0
  12. package/dist/core/state-gc.js +48 -4
  13. package/dist/core/stats-cli.d.ts +15 -0
  14. package/dist/core/stats-cli.js +143 -0
  15. package/dist/core/uninstall.d.ts +1 -0
  16. package/dist/core/uninstall.js +24 -1
  17. package/dist/core/v1-bootstrap.js +9 -1
  18. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  19. package/dist/engine/classify-enforce-cli.js +61 -0
  20. package/dist/engine/enforce-classifier.d.ts +31 -0
  21. package/dist/engine/enforce-classifier.js +123 -0
  22. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  23. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  24. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  25. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  26. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  27. package/dist/engine/lifecycle/meta-cli.js +7 -0
  28. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  29. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  30. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  31. package/dist/engine/lifecycle/orchestrator.js +131 -0
  32. package/dist/engine/lifecycle/signals.d.ts +30 -0
  33. package/dist/engine/lifecycle/signals.js +142 -0
  34. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  35. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  36. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  37. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  38. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  39. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  40. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  41. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  42. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  43. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  44. package/dist/engine/lifecycle/types.d.ts +52 -0
  45. package/dist/engine/lifecycle/types.js +7 -0
  46. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  47. package/dist/engine/rule-toggle-cli.js +76 -0
  48. package/dist/forge/evidence-processor.js +10 -2
  49. package/dist/hooks/context-guard.js +71 -0
  50. package/dist/hooks/post-tool-use.js +62 -0
  51. package/dist/hooks/pre-tool-use.js +57 -1
  52. package/dist/hooks/secret-filter.d.ts +10 -0
  53. package/dist/hooks/secret-filter.js +20 -0
  54. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  55. package/dist/hooks/shared/atomic-write.js +17 -3
  56. package/dist/hooks/shared/hook-response.d.ts +11 -0
  57. package/dist/hooks/shared/hook-response.js +18 -0
  58. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  59. package/dist/hooks/shared/safe-regex.js +50 -0
  60. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  61. package/dist/hooks/shared/stop-triggers.js +19 -0
  62. package/dist/hooks/stop-guard.d.ts +84 -0
  63. package/dist/hooks/stop-guard.js +482 -0
  64. package/dist/mcp/tools.js +19 -2
  65. package/dist/store/evidence-store.d.ts +15 -0
  66. package/dist/store/evidence-store.js +50 -1
  67. package/dist/store/rule-lifecycle.d.ts +23 -0
  68. package/dist/store/rule-lifecycle.js +63 -0
  69. package/dist/store/rule-store.d.ts +21 -0
  70. package/dist/store/rule-store.js +128 -8
  71. package/dist/store/types.d.ts +83 -0
  72. package/dist/store/types.js +7 -1
  73. package/hooks/hook-registry.json +1 -0
  74. package/hooks/hooks.json +6 -1
  75. package/package.json +10 -2
  76. package/plugin.json +1 -1
@@ -0,0 +1,23 @@
1
+ /**
2
+ * T1 — 사용자 명시 교정 (explicit_correction).
3
+ *
4
+ * 입력: Evidence(type='explicit_correction') + rules[].
5
+ * 로직:
6
+ * 1. evidence.axis_refs 에 rule.category 매칭 OR evidence.summary 에 rule.render_key 토큰 포함 → rule 매칭
7
+ * 2. correction kind 에 따라 suggested_action 결정:
8
+ * - 'avoid-this' (이 rule 을 따르지 말아라) → retire
9
+ * - 'fix-now' (이 rule 을 수정해야 한다) → flag (사용자 후속 편집 대기)
10
+ * - 'prefer-from-now' (새 선호로 대체) → supersede
11
+ *
12
+ * 출력: LifecycleEvent[]. 순수 — IO 없음.
13
+ */
14
+ import type { Evidence, Rule } from '../../store/types.js';
15
+ import type { LifecycleEvent } from './types.js';
16
+ export interface T1Input {
17
+ evidence: Evidence;
18
+ /** 교정의 행동 종류 — 있으면 더 정확한 action 결정. */
19
+ correction_kind?: 'avoid-this' | 'fix-now' | 'prefer-from-now';
20
+ rules: Rule[];
21
+ ts?: number;
22
+ }
23
+ export declare function detect(input: T1Input): LifecycleEvent[];
@@ -0,0 +1,78 @@
1
+ /**
2
+ * T1 — 사용자 명시 교정 (explicit_correction).
3
+ *
4
+ * 입력: Evidence(type='explicit_correction') + rules[].
5
+ * 로직:
6
+ * 1. evidence.axis_refs 에 rule.category 매칭 OR evidence.summary 에 rule.render_key 토큰 포함 → rule 매칭
7
+ * 2. correction kind 에 따라 suggested_action 결정:
8
+ * - 'avoid-this' (이 rule 을 따르지 말아라) → retire
9
+ * - 'fix-now' (이 rule 을 수정해야 한다) → flag (사용자 후속 편집 대기)
10
+ * - 'prefer-from-now' (새 선호로 대체) → supersede
11
+ *
12
+ * 출력: LifecycleEvent[]. 순수 — IO 없음.
13
+ */
14
+ const CATEGORY_AXIS_MAP = {
15
+ quality: ['quality_safety', 'quality'],
16
+ autonomy: ['autonomy'],
17
+ communication: ['communication_style', 'communication'],
18
+ workflow: ['workflow', 'judgment_philosophy'],
19
+ safety: ['quality_safety', 'safety'],
20
+ };
21
+ function matchesRule(evidence, rule) {
22
+ // 가장 강한 신호: 사용자가 명시적으로 rule_id 지목.
23
+ if (evidence.candidate_rule_refs.includes(rule.rule_id))
24
+ return true;
25
+ // 그 외: axis 일치만으로는 과매칭 (예: "typescript-any" correction 이 "early-return"
26
+ // rule 까지 끌어오는 FP). axis AND 키워드 둘 다 요구.
27
+ const axes = CATEGORY_AXIS_MAP[rule.category] ?? [];
28
+ const axisMatch = axes.some((a) => evidence.axis_refs.includes(a));
29
+ if (!axisMatch)
30
+ return false;
31
+ const keyTokens = rule.render_key
32
+ .split(/[._-]/)
33
+ .filter((t) => t.length >= 3);
34
+ if (keyTokens.length === 0)
35
+ return false;
36
+ const summaryLower = evidence.summary.toLowerCase();
37
+ const targetToken = (evidence.raw_payload?.target ?? '')
38
+ .toString()
39
+ .toLowerCase();
40
+ return keyTokens.some((t) => {
41
+ const tokLower = t.toLowerCase();
42
+ return summaryLower.includes(tokLower) || (targetToken && targetToken.includes(tokLower));
43
+ });
44
+ }
45
+ export function detect(input) {
46
+ const { evidence, rules } = input;
47
+ if (evidence.type !== 'explicit_correction')
48
+ return [];
49
+ const ts = input.ts ?? Date.now();
50
+ const action = input.correction_kind === 'avoid-this'
51
+ ? 'retire'
52
+ : input.correction_kind === 'prefer-from-now'
53
+ ? 'supersede'
54
+ : 'flag';
55
+ const events = [];
56
+ for (const rule of rules) {
57
+ if (rule.status !== 'active')
58
+ continue;
59
+ if (!matchesRule(evidence, rule))
60
+ continue;
61
+ // C2: hard rule 은 retire/supersede 불변. fix-now(flag) 만 허용 — 관찰을 위한 소프트 신호.
62
+ if (rule.strength === 'hard' && action !== 'flag')
63
+ continue;
64
+ events.push({
65
+ kind: 't1_explicit_correction',
66
+ rule_id: rule.rule_id,
67
+ session_id: evidence.session_id,
68
+ evidence: {
69
+ source: 'evidence-store',
70
+ refs: [evidence.evidence_id],
71
+ metrics: { confidence: evidence.confidence },
72
+ },
73
+ suggested_action: action,
74
+ ts,
75
+ });
76
+ }
77
+ return events;
78
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * T2 — 반복 Mech 위반.
3
+ *
4
+ * 트리거 조건 (ADR-002):
5
+ * violations_30d ≥ 3 AND violation_rate_30d > 0.3 → flag (재검토 대기)
6
+ *
7
+ * rolling 30d 는 collectSignals 가 계산. 여기는 임계 체크와 event 생성만.
8
+ */
9
+ import type { Rule } from '../../store/types.js';
10
+ import type { LifecycleEvent, RuleSignals } from './types.js';
11
+ export interface T2Input {
12
+ rules: Rule[];
13
+ signals: Map<string, RuleSignals>;
14
+ threshold_count?: number;
15
+ threshold_rate?: number;
16
+ ts?: number;
17
+ }
18
+ export declare function detect(input: T2Input): LifecycleEvent[];
@@ -0,0 +1,42 @@
1
+ /**
2
+ * T2 — 반복 Mech 위반.
3
+ *
4
+ * 트리거 조건 (ADR-002):
5
+ * violations_30d ≥ 3 AND violation_rate_30d > 0.3 → flag (재검토 대기)
6
+ *
7
+ * rolling 30d 는 collectSignals 가 계산. 여기는 임계 체크와 event 생성만.
8
+ */
9
+ export function detect(input) {
10
+ const thresholdCount = input.threshold_count ?? 3;
11
+ const thresholdRate = input.threshold_rate ?? 0.3;
12
+ const ts = input.ts ?? Date.now();
13
+ const events = [];
14
+ for (const rule of input.rules) {
15
+ if (rule.status !== 'active')
16
+ continue;
17
+ if (rule.lifecycle?.phase === 'flagged')
18
+ continue; // 이미 flagged — 중복 이벤트 방지
19
+ const s = input.signals.get(rule.rule_id);
20
+ if (!s)
21
+ continue;
22
+ if (s.violations_30d < thresholdCount)
23
+ continue;
24
+ if (s.violation_rate_30d <= thresholdRate)
25
+ continue;
26
+ events.push({
27
+ kind: 't2_repeated_violation',
28
+ rule_id: rule.rule_id,
29
+ evidence: {
30
+ source: 'violations-log',
31
+ refs: [],
32
+ metrics: {
33
+ violations_30d: s.violations_30d,
34
+ violation_rate_30d: Number(s.violation_rate_30d.toFixed(3)),
35
+ },
36
+ },
37
+ suggested_action: 'flag',
38
+ ts,
39
+ });
40
+ }
41
+ return events;
42
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * T3 — 사용자 반복 우회 (user_bypass).
3
+ *
4
+ * 트리거 조건 (ADR-002):
5
+ * 7d 내 bypass_count ≥ 5 → suppress (일시 비활성 + 7일 후 자동 재활성)
6
+ *
7
+ * bypass 기록은 post-tool-use 측 확장이 bypass.jsonl 에 append (별도 wiring).
8
+ */
9
+ import type { Rule } from '../../store/types.js';
10
+ import type { LifecycleEvent, RuleSignals } from './types.js';
11
+ export interface T3Input {
12
+ rules: Rule[];
13
+ signals: Map<string, RuleSignals>;
14
+ threshold_count?: number;
15
+ ts?: number;
16
+ }
17
+ export declare function detect(input: T3Input): LifecycleEvent[];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * T3 — 사용자 반복 우회 (user_bypass).
3
+ *
4
+ * 트리거 조건 (ADR-002):
5
+ * 7d 내 bypass_count ≥ 5 → suppress (일시 비활성 + 7일 후 자동 재활성)
6
+ *
7
+ * bypass 기록은 post-tool-use 측 확장이 bypass.jsonl 에 append (별도 wiring).
8
+ */
9
+ export function detect(input) {
10
+ const threshold = input.threshold_count ?? 5;
11
+ const ts = input.ts ?? Date.now();
12
+ const events = [];
13
+ for (const rule of input.rules) {
14
+ if (rule.status !== 'active')
15
+ continue;
16
+ if (rule.lifecycle?.phase === 'flagged' || rule.lifecycle?.phase === 'suppressed')
17
+ continue; // 이미 주의 환기됨
18
+ const s = input.signals.get(rule.rule_id);
19
+ if (!s)
20
+ continue;
21
+ if (s.bypass_7d < threshold)
22
+ continue;
23
+ // R6-P1: PM 지적 — "우회할수록 규칙이 약해진다" 는 Trust Restoration 미션과 역방향.
24
+ // T3 는 이제 자동 suppress 대신 flag 만 (사용자 주의 환기). 실제 suppress 는 사용자가
25
+ // 명시적으로 결정하도록 `forgen inspect rules --conflicts` + 수동 편집 경로 유지.
26
+ events.push({
27
+ kind: 't3_user_bypass',
28
+ rule_id: rule.rule_id,
29
+ evidence: {
30
+ source: 'bypass-log',
31
+ refs: [],
32
+ metrics: { bypass_7d: s.bypass_7d },
33
+ },
34
+ suggested_action: 'flag',
35
+ ts,
36
+ });
37
+ }
38
+ return events;
39
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * T4 — 시간 경과 retire (time_decay).
3
+ *
4
+ * 트리거 조건 (ADR-002):
5
+ * last_inject_at < now - 90d → retire 후보.
6
+ * inject 추적 인프라 완비 전에는 updated_at 을 proxy 로 사용 (signals 가 이미 폴백).
7
+ *
8
+ * retired 는 파일 삭제가 아니라 phase 변경 — N개월 후 별도 GC.
9
+ */
10
+ import type { Rule } from '../../store/types.js';
11
+ import type { LifecycleEvent, RuleSignals } from './types.js';
12
+ export interface T4Input {
13
+ rules: Rule[];
14
+ signals: Map<string, RuleSignals>;
15
+ decay_days?: number;
16
+ ts?: number;
17
+ }
18
+ export declare function detect(input: T4Input): LifecycleEvent[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * T4 — 시간 경과 retire (time_decay).
3
+ *
4
+ * 트리거 조건 (ADR-002):
5
+ * last_inject_at < now - 90d → retire 후보.
6
+ * inject 추적 인프라 완비 전에는 updated_at 을 proxy 로 사용 (signals 가 이미 폴백).
7
+ *
8
+ * retired 는 파일 삭제가 아니라 phase 변경 — N개월 후 별도 GC.
9
+ */
10
+ export function detect(input) {
11
+ const decayDays = input.decay_days ?? 90;
12
+ const ts = input.ts ?? Date.now();
13
+ const events = [];
14
+ for (const rule of input.rules) {
15
+ if (rule.status !== 'active')
16
+ continue;
17
+ if (rule.lifecycle?.phase === 'retired')
18
+ continue;
19
+ // C2: hard rule 은 time decay 로도 retire 불가.
20
+ if (rule.strength === 'hard')
21
+ continue;
22
+ const s = input.signals.get(rule.rule_id);
23
+ if (!s)
24
+ continue;
25
+ if (s.last_inject_days_ago < decayDays)
26
+ continue;
27
+ events.push({
28
+ kind: 't4_time_decay',
29
+ rule_id: rule.rule_id,
30
+ evidence: {
31
+ source: 'rule-store',
32
+ refs: [],
33
+ metrics: { last_inject_days_ago: s.last_inject_days_ago, threshold: decayDays },
34
+ },
35
+ suggested_action: 'retire',
36
+ ts,
37
+ });
38
+ }
39
+ return events;
40
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * T5 — 규칙 충돌 (conflict_detected).
3
+ *
4
+ * 트리거 조건:
5
+ * 같은 category 인 rule pair 중, policy 자연어가 상반 (negation + 공통 키워드 ≥ 2).
6
+ *
7
+ * auto-merge 안 함. conflict_refs 플래그만 설정 → 사용자 수동 해소.
8
+ */
9
+ import type { Rule } from '../../store/types.js';
10
+ import type { LifecycleEvent } from './types.js';
11
+ export interface T5Input {
12
+ rules: Rule[];
13
+ min_shared_tokens?: number;
14
+ ts?: number;
15
+ }
16
+ export declare function detect(input: T5Input): LifecycleEvent[];
@@ -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 {};
@@ -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
+ }
@@ -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시간