@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,34 @@
1
+ /**
2
+ * Bypass detector — T3 signal source.
3
+ *
4
+ * Rule.policy 자연어에서 "피해야 할 패턴" 을 추출하고, Write/Edit/Bash 도구
5
+ * 출력에서 해당 패턴을 찾아 BypassEntry 후보로 반환한다.
6
+ *
7
+ * Heuristic:
8
+ * 1) "use X not Y" / "use X instead of Y" / "X over Y" → bypass = Y
9
+ * 2) "avoid X" / "don't use X" / "never use X" / "do not use X" → bypass = X
10
+ * 3) Korean: "X 말라" / "X 금지" / "X 하지 않" → bypass = X
11
+ * 4) 그 외: 빈 배열 (탐지 불가).
12
+ *
13
+ * 반환된 패턴은 escape 된 정규식 문자열 — caller 가 `new RegExp(p)` 로 사용.
14
+ */
15
+ import type { Rule } from '../../store/types.js';
16
+ export declare function extractBypassPatterns(rule: Rule): string[];
17
+ export interface BypassScanInput {
18
+ rules: Rule[];
19
+ tool_name: string;
20
+ tool_output: string;
21
+ session_id: string;
22
+ }
23
+ export interface BypassCandidate {
24
+ rule_id: string;
25
+ session_id: string;
26
+ tool: string;
27
+ pattern_preview: string;
28
+ matched: string;
29
+ }
30
+ /**
31
+ * Pure — rules + tool output 으로 bypass candidates 추출.
32
+ * 같은 rule/pattern 이 여러 번 매칭돼도 한 번만 기록.
33
+ */
34
+ export declare function scanForBypass(input: BypassScanInput): BypassCandidate[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Bypass detector — T3 signal source.
3
+ *
4
+ * Rule.policy 자연어에서 "피해야 할 패턴" 을 추출하고, Write/Edit/Bash 도구
5
+ * 출력에서 해당 패턴을 찾아 BypassEntry 후보로 반환한다.
6
+ *
7
+ * Heuristic:
8
+ * 1) "use X not Y" / "use X instead of Y" / "X over Y" → bypass = Y
9
+ * 2) "avoid X" / "don't use X" / "never use X" / "do not use X" → bypass = X
10
+ * 3) Korean: "X 말라" / "X 금지" / "X 하지 않" → bypass = X
11
+ * 4) 그 외: 빈 배열 (탐지 불가).
12
+ *
13
+ * 반환된 패턴은 escape 된 정규식 문자열 — caller 가 `new RegExp(p)` 로 사용.
14
+ */
15
+ function escapeRegex(s) {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ }
18
+ /**
19
+ * Trim punctuation 많이 붙은 자연어 표현을 "검색용 토큰" 으로 정규화.
20
+ * Leading `.` 는 유지 — `.then`, `.mock` 같은 메서드 참조가 의도된 매칭 대상.
21
+ * Trailing `()` 는 제거 — `.then()` 을 `.then` 으로 정규화해 `.then(x=>...)` 에 매치.
22
+ */
23
+ function trimPunct(s) {
24
+ let out = s;
25
+ // Strip trailing "()" once (natural-language shorthand for method calls)
26
+ if (out.endsWith('()'))
27
+ out = out.slice(0, -2);
28
+ // Strip other leading/trailing punctuation, preserving leading `.`
29
+ out = out.replace(/^[,;:!?"'`(]+|[.,;:!?"'`)]+$/g, '');
30
+ return out;
31
+ }
32
+ export function extractBypassPatterns(rule) {
33
+ const patterns = [];
34
+ const p = rule.policy;
35
+ // use X not Y / use X instead of Y / use X over Y
36
+ // X, Y may contain dots (e.g., ".then()", "vi.mock"). Strip trailing punctuation.
37
+ const useNot = p.match(/\b(?:use|prefer|choose)\s+(\S+?)\s+(?:not|instead\s+of|over|rather\s+than)\s+(\S+)/i);
38
+ if (useNot)
39
+ patterns.push(escapeRegex(trimPunct(useNot[2])));
40
+ // avoid X / don't use X / never use X / do not use X
41
+ const avoid = p.match(/\b(?:avoid|don'?t\s+use|never\s+use|do\s+not\s+use)\s+(\S+)/i);
42
+ if (avoid)
43
+ patterns.push(escapeRegex(trimPunct(avoid[1])));
44
+ // Korean: "X 말라" / "X 금지" / "X 하지 마"
45
+ const ko = p.match(/(\S+)\s*(?:말라|금지|하지\s*마|쓰지\s*마)/);
46
+ if (ko)
47
+ patterns.push(escapeRegex(trimPunct(ko[1])));
48
+ // Dedupe + filter trivial
49
+ return [...new Set(patterns)].filter((pat) => pat.length >= 2);
50
+ }
51
+ /**
52
+ * Pure — rules + tool output 으로 bypass candidates 추출.
53
+ * 같은 rule/pattern 이 여러 번 매칭돼도 한 번만 기록.
54
+ */
55
+ export function scanForBypass(input) {
56
+ const { rules, tool_name, tool_output, session_id } = input;
57
+ const candidates = [];
58
+ const reported = new Set(); // rule_id|pattern
59
+ for (const rule of rules) {
60
+ if (rule.status !== 'active')
61
+ continue;
62
+ const patterns = extractBypassPatterns(rule);
63
+ for (const pat of patterns) {
64
+ const re = new RegExp(pat, 'i');
65
+ const m = tool_output.match(re);
66
+ if (!m)
67
+ continue;
68
+ const key = `${rule.rule_id}|${pat}`;
69
+ if (reported.has(key))
70
+ continue;
71
+ reported.add(key);
72
+ candidates.push({
73
+ rule_id: rule.rule_id,
74
+ session_id,
75
+ tool: tool_name,
76
+ pattern_preview: pat.slice(0, 40),
77
+ matched: m[0].slice(0, 40),
78
+ });
79
+ }
80
+ }
81
+ return candidates;
82
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * CLI handler for `forgen lifecycle-scan`.
3
+ *
4
+ * 전체 lifecycle 트리거(T1~T5 + Meta) 를 쓴 집계 데이터 기반으로 실행.
5
+ * default dry-run, --apply 시 rule 파일에 상태 전이 반영.
6
+ */
7
+ export declare function handleLifecycleScan(args: string[]): Promise<void>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * CLI handler for `forgen lifecycle-scan`.
3
+ *
4
+ * 전체 lifecycle 트리거(T1~T5 + Meta) 를 쓴 집계 데이터 기반으로 실행.
5
+ * default dry-run, --apply 시 rule 파일에 상태 전이 반영.
6
+ */
7
+ import * as path from 'node:path';
8
+ import { loadAllRules, saveRule } from '../../store/rule-store.js';
9
+ import { STATE_DIR } from '../../core/paths.js';
10
+ import { collectSignals, readJsonlSafe } from './signals.js';
11
+ import { detect as detectT2 } from './trigger-t2-violation.js';
12
+ import { detect as detectT3 } from './trigger-t3-bypass.js';
13
+ import { detect as detectT4 } from './trigger-t4-decay.js';
14
+ import { detect as detectT5 } from './trigger-t5-conflict.js';
15
+ import { scanDriftForDemotion, applyDemotion, scanSignalsForPromotion, applyPromotion, readDriftEntries, appendLifecycleEvents, } from './meta-reclassifier.js';
16
+ import { foldEvents } from './orchestrator.js';
17
+ const LIFECYCLE_DIR = path.join(STATE_DIR, 'lifecycle');
18
+ export async function handleLifecycleScan(args) {
19
+ const apply = args.includes('--apply');
20
+ const now = Date.now();
21
+ const rules = loadAllRules();
22
+ if (rules.length === 0) {
23
+ console.log('\n No rules in ~/.forgen/me/rules. Nothing to scan.\n');
24
+ return;
25
+ }
26
+ const violations = readJsonlSafe(path.join(STATE_DIR, 'enforcement', 'violations.jsonl'));
27
+ const bypass = readJsonlSafe(path.join(STATE_DIR, 'enforcement', 'bypass.jsonl'));
28
+ const drift = readDriftEntries();
29
+ const signals = new Map();
30
+ for (const r of rules)
31
+ signals.set(r.rule_id, collectSignals(r, { violations, bypass, now }));
32
+ const events = [
33
+ ...detectT2({ rules, signals, ts: now }),
34
+ ...detectT3({ rules, signals, ts: now }),
35
+ ...detectT4({ rules, signals, ts: now }),
36
+ ...detectT5({ rules, ts: now }),
37
+ ];
38
+ const demotionCandidates = scanDriftForDemotion({ rules, drift, now });
39
+ const promotionCandidates = scanSignalsForPromotion({ rules, signals, ts: now });
40
+ console.log(`\n Lifecycle Scan — ${rules.length} rule(s) (${apply ? 'APPLY' : 'dry-run'})\n`);
41
+ console.log(` Signals: violations.jsonl=${violations.length} bypass.jsonl=${bypass.length} drift.jsonl=${drift.length}\n`);
42
+ if (events.length === 0 && demotionCandidates.length === 0 && promotionCandidates.length === 0) {
43
+ console.log(' No lifecycle events. System stable.\n');
44
+ return;
45
+ }
46
+ console.log(` Rule health events: ${events.length}`);
47
+ const byKind = new Map();
48
+ for (const e of events)
49
+ byKind.set(e.kind, (byKind.get(e.kind) ?? 0) + 1);
50
+ for (const [k, n] of byKind.entries())
51
+ console.log(` ${k}: ${n}`);
52
+ console.log(`\n Meta candidates: promote=${promotionCandidates.length} demote=${demotionCandidates.length}`);
53
+ if (!apply) {
54
+ for (const e of events) {
55
+ console.log(` → ${e.kind.padEnd(24)} rule=${e.rule_id.slice(0, 8)} action=${e.suggested_action}`);
56
+ }
57
+ for (const c of demotionCandidates) {
58
+ console.log(` → meta_demote rule=${c.rule_id.slice(0, 8)} events=${c.event_count}`);
59
+ }
60
+ for (const c of promotionCandidates) {
61
+ console.log(` → meta_promote rule=${c.rule_id.slice(0, 8)} injects=${c.injects_rolling_n}`);
62
+ }
63
+ console.log('\n Run with --apply to persist.\n');
64
+ return;
65
+ }
66
+ // APPLY path: fold T2~T5 events into rules, save, then do Meta.
67
+ const byId = foldEvents(rules, events, now);
68
+ let saved = 0;
69
+ for (const [ruleId, updated] of byId.entries()) {
70
+ const original = rules.find((r) => r.rule_id === ruleId);
71
+ if (!original)
72
+ continue;
73
+ if (updated === original)
74
+ continue;
75
+ saveRule(updated);
76
+ saved += 1;
77
+ }
78
+ if (events.length > 0)
79
+ appendLifecycleEvents(events, now);
80
+ // Meta apply
81
+ const metaEvents = [];
82
+ for (const c of demotionCandidates) {
83
+ const rule = rules.find((r) => r.rule_id === c.rule_id);
84
+ if (!rule)
85
+ continue;
86
+ const result = applyDemotion(rule, c, now);
87
+ if (result.applied)
88
+ metaEvents.push(...result.events);
89
+ }
90
+ for (const c of promotionCandidates) {
91
+ const rule = rules.find((r) => r.rule_id === c.rule_id);
92
+ if (!rule)
93
+ continue;
94
+ const result = applyPromotion(rule, c, now);
95
+ if (result.applied)
96
+ metaEvents.push(...result.events);
97
+ }
98
+ if (metaEvents.length > 0)
99
+ appendLifecycleEvents(metaEvents, now);
100
+ console.log(`\n Applied: ${saved} rule(s) updated, ${metaEvents.length} health event(s).`);
101
+ console.log(` Log: ${LIFECYCLE_DIR}/${new Date(now).toISOString().slice(0, 10)}.jsonl\n`);
102
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * CLI handler stub for `forgen rule-meta-scan`. Full implementation in Phase 5.
3
+ */
4
+ export declare function handleRuleMetaScan(args: string[]): Promise<void>;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * CLI handler stub for `forgen rule-meta-scan`. Full implementation in Phase 5.
3
+ */
4
+ export async function handleRuleMetaScan(args) {
5
+ const { runMetaScan } = await import('./meta-reclassifier.js');
6
+ await runMetaScan(args);
7
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * ADR-002 Meta trigger — drift.jsonl 누적을 읽어 rule 의 mech 재분류 후보를 산출.
3
+ *
4
+ * 현 스코프 (v0.4.0 follow-up):
5
+ * - `stuck_loop_force_approve` 이벤트를 recent window(기본 7 일) 에서 집계.
6
+ * - 같은 rule_id 에서 임계치(기본 3) 이상 발생 시 **Mech demotion 후보** 로 분류.
7
+ * - demotion 은 Mech-A/B → 한 단계 완화:
8
+ * - A (block 강제) → B (self-check 권고)
9
+ * - B (self-check) → C (drift 측정)
10
+ * - C 는 그대로 (더 강등 불가)
11
+ * - dry-run 기본. `--apply` 시 rule 파일의 enforce_via[].mech 갱신 + meta_promotions 추가.
12
+ *
13
+ * v0.4.1+ 확장 여지 (본 파일에는 미구현):
14
+ * - Mech promotion (B → A): rolling 20 injects 중 violation 0 → 승급.
15
+ * 이 경로는 별도 evidence source (solution-outcomes) 필요.
16
+ * - 30 일 쿨다운.
17
+ */
18
+ import type { LifecycleEvent } from './types.js';
19
+ import type { EnforcementMech, Rule } from '../../store/types.js';
20
+ export interface DriftEntry {
21
+ at: string;
22
+ kind: string;
23
+ session_id: string;
24
+ rule_id: string;
25
+ count: number;
26
+ reason_preview?: string;
27
+ message_preview?: string;
28
+ }
29
+ export interface DemotionCandidate {
30
+ rule_id: string;
31
+ event_count: number;
32
+ first_at: string;
33
+ last_at: string;
34
+ sessions: string[];
35
+ window_days: number;
36
+ current_mechs: EnforcementMech[];
37
+ }
38
+ export declare function readDriftEntries(driftPath?: string): DriftEntry[];
39
+ export declare function scanDriftForDemotion(options?: {
40
+ rules: Rule[];
41
+ drift?: DriftEntry[];
42
+ windowDays?: number;
43
+ threshold?: number;
44
+ cooldownDays?: number;
45
+ now?: number;
46
+ }): DemotionCandidate[];
47
+ export declare function demoteMech(from: EnforcementMech): EnforcementMech | null;
48
+ export declare function promoteMech(from: EnforcementMech): EnforcementMech | null;
49
+ export interface PromotionCandidate {
50
+ rule_id: string;
51
+ injects_rolling_n: number;
52
+ violations_rolling_n: number;
53
+ current_mechs: EnforcementMech[];
54
+ reason: string;
55
+ }
56
+ /**
57
+ * rolling N 개 inject 중 violation 0 → Mech 승급 후보.
58
+ * inject 추적 인프라가 완비되기 전에는 `rolling_min_injects` (기본 20) 미만이면 skip.
59
+ */
60
+ export declare function scanSignalsForPromotion(options: {
61
+ rules: Rule[];
62
+ rolling_min_injects?: number;
63
+ cooldownDays?: number;
64
+ ts?: number;
65
+ signals: Map<string, import('./types.js').RuleSignals>;
66
+ }): PromotionCandidate[];
67
+ export declare function applyPromotion(rule: Rule, candidate: PromotionCandidate, now?: number): ApplyResult;
68
+ export interface ApplyResult {
69
+ rule_id: string;
70
+ before_mech: EnforcementMech[];
71
+ after_mech: EnforcementMech[];
72
+ events: LifecycleEvent[];
73
+ applied: boolean;
74
+ reason?: string;
75
+ }
76
+ export declare function applyDemotion(rule: Rule, candidate: DemotionCandidate, now?: number): ApplyResult;
77
+ export declare function appendLifecycleEvents(events: LifecycleEvent[], now?: number): void;
78
+ export declare function runMetaScan(args: string[]): Promise<void>;
@@ -0,0 +1,351 @@
1
+ /**
2
+ * ADR-002 Meta trigger — drift.jsonl 누적을 읽어 rule 의 mech 재분류 후보를 산출.
3
+ *
4
+ * 현 스코프 (v0.4.0 follow-up):
5
+ * - `stuck_loop_force_approve` 이벤트를 recent window(기본 7 일) 에서 집계.
6
+ * - 같은 rule_id 에서 임계치(기본 3) 이상 발생 시 **Mech demotion 후보** 로 분류.
7
+ * - demotion 은 Mech-A/B → 한 단계 완화:
8
+ * - A (block 강제) → B (self-check 권고)
9
+ * - B (self-check) → C (drift 측정)
10
+ * - C 는 그대로 (더 강등 불가)
11
+ * - dry-run 기본. `--apply` 시 rule 파일의 enforce_via[].mech 갱신 + meta_promotions 추가.
12
+ *
13
+ * v0.4.1+ 확장 여지 (본 파일에는 미구현):
14
+ * - Mech promotion (B → A): rolling 20 injects 중 violation 0 → 승급.
15
+ * 이 경로는 별도 evidence source (solution-outcomes) 필요.
16
+ * - 30 일 쿨다운.
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { initLifecycle } from '../../store/rule-lifecycle.js';
21
+ import { loadAllRules, saveRule } from '../../store/rule-store.js';
22
+ import { STATE_DIR } from '../../core/paths.js';
23
+ const DRIFT_LOG_PATH = path.join(STATE_DIR, 'enforcement', 'drift.jsonl');
24
+ const LIFECYCLE_DIR = path.join(STATE_DIR, 'lifecycle');
25
+ const DEFAULT_WINDOW_DAYS = 7;
26
+ const DEFAULT_THRESHOLD = 3;
27
+ /** R8-A1: Meta 재분류 쿨다운 (일). 최근 이 기간 내 변경된 rule 은 다시 재분류 금지. */
28
+ const DEFAULT_COOLDOWN_DAYS = 30;
29
+ /** R8-A1: rule 이 최근 쿨다운 내에 mech 변경됐는지 판정. */
30
+ function isRecentlyClassified(rule, nowMs, cooldownMs) {
31
+ const promotions = rule.lifecycle?.meta_promotions ?? [];
32
+ if (promotions.length === 0)
33
+ return false;
34
+ const lastAt = promotions[promotions.length - 1].at;
35
+ const lastMs = Date.parse(lastAt);
36
+ if (!Number.isFinite(lastMs))
37
+ return false;
38
+ return nowMs - lastMs < cooldownMs;
39
+ }
40
+ export function readDriftEntries(driftPath = DRIFT_LOG_PATH) {
41
+ if (!fs.existsSync(driftPath))
42
+ return [];
43
+ try {
44
+ const raw = fs.readFileSync(driftPath, 'utf-8');
45
+ return raw
46
+ .trim()
47
+ .split('\n')
48
+ .filter(Boolean)
49
+ .map((line) => {
50
+ try {
51
+ return JSON.parse(line);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ })
57
+ .filter((e) => e !== null);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ }
63
+ export function scanDriftForDemotion(options = { rules: [] }) {
64
+ const windowDays = options.windowDays ?? DEFAULT_WINDOW_DAYS;
65
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
66
+ const now = options.now ?? Date.now();
67
+ const cutoff = now - windowDays * 24 * 60 * 60 * 1000;
68
+ const drift = options.drift ?? readDriftEntries();
69
+ const byRule = new Map();
70
+ for (const e of drift) {
71
+ if (e.kind !== 'stuck_loop_force_approve')
72
+ continue;
73
+ const t = Date.parse(e.at);
74
+ if (!Number.isFinite(t) || t < cutoff)
75
+ continue;
76
+ const list = byRule.get(e.rule_id) ?? [];
77
+ list.push(e);
78
+ byRule.set(e.rule_id, list);
79
+ }
80
+ const candidates = [];
81
+ // R8-A1: Meta oscillation cooldown — 최근 N일 내 mech 변경된 rule 은 재분류 skip.
82
+ // architect 예측 "A↔B ping-pong" 차단. 기본 30일.
83
+ const cooldownMs = (options.cooldownDays ?? DEFAULT_COOLDOWN_DAYS) * 24 * 60 * 60 * 1000;
84
+ for (const [ruleId, entries] of byRule.entries()) {
85
+ if (entries.length < threshold)
86
+ continue;
87
+ // M fix: exact match — 이전에는 startsWith 로 인해 "L1" prefix 로 여러 L1-* rule 이 교차 오염됐음.
88
+ const rule = options.rules.find((r) => r.rule_id === ruleId);
89
+ // C2: hard strength rule 은 Meta demote 대상에서 제외 (ADR-002 불변 원칙).
90
+ if (rule?.strength === 'hard')
91
+ continue;
92
+ // R8-A1: cooldown 체크
93
+ if (rule && isRecentlyClassified(rule, now, cooldownMs))
94
+ continue;
95
+ const currentMechs = (rule?.enforce_via ?? [])
96
+ .map((s) => s.mech)
97
+ .filter((m, i, arr) => arr.indexOf(m) === i);
98
+ const sortedTs = entries.map((e) => Date.parse(e.at)).sort((a, b) => a - b);
99
+ candidates.push({
100
+ rule_id: ruleId,
101
+ event_count: entries.length,
102
+ first_at: new Date(sortedTs[0]).toISOString(),
103
+ last_at: new Date(sortedTs[sortedTs.length - 1]).toISOString(),
104
+ sessions: [...new Set(entries.map((e) => e.session_id))],
105
+ window_days: windowDays,
106
+ current_mechs: currentMechs,
107
+ });
108
+ }
109
+ return candidates;
110
+ }
111
+ export function demoteMech(from) {
112
+ if (from === 'A')
113
+ return 'B';
114
+ if (from === 'B')
115
+ return 'C';
116
+ return null;
117
+ }
118
+ export function promoteMech(from) {
119
+ if (from === 'B')
120
+ return 'A';
121
+ if (from === 'C')
122
+ return 'B';
123
+ return null;
124
+ }
125
+ /**
126
+ * rolling N 개 inject 중 violation 0 → Mech 승급 후보.
127
+ * inject 추적 인프라가 완비되기 전에는 `rolling_min_injects` (기본 20) 미만이면 skip.
128
+ */
129
+ export function scanSignalsForPromotion(options) {
130
+ const minInjects = options.rolling_min_injects ?? 20;
131
+ const out = [];
132
+ // R8-A1: promote 도 cooldown 적용. architect 예측대로 promote → demote → promote ping-pong 차단.
133
+ const cooldownMs = (options.cooldownDays ?? DEFAULT_COOLDOWN_DAYS) * 24 * 60 * 60 * 1000;
134
+ const nowMs = options.ts ?? Date.now();
135
+ for (const rule of options.rules) {
136
+ if (rule.status !== 'active')
137
+ continue;
138
+ // C2: hard rule 은 promote 도 불변 (이미 최강이거나 사용자 의도적 고정).
139
+ if (rule.strength === 'hard')
140
+ continue;
141
+ if (isRecentlyClassified(rule, nowMs, cooldownMs))
142
+ continue;
143
+ const s = options.signals.get(rule.rule_id);
144
+ if (!s)
145
+ continue;
146
+ if (s.injects_rolling_n < minInjects)
147
+ continue;
148
+ if (s.violations_rolling_n > 0)
149
+ continue;
150
+ const mechs = (rule.enforce_via ?? []).map((spec) => spec.mech);
151
+ const hasPromotable = mechs.some((m) => m === 'B' || m === 'C');
152
+ if (!hasPromotable)
153
+ continue;
154
+ out.push({
155
+ rule_id: rule.rule_id,
156
+ injects_rolling_n: s.injects_rolling_n,
157
+ violations_rolling_n: s.violations_rolling_n,
158
+ current_mechs: [...new Set(mechs)],
159
+ reason: `rolling ${s.injects_rolling_n} injects, 0 violations — promotion candidate`,
160
+ });
161
+ }
162
+ return out;
163
+ }
164
+ export function applyPromotion(rule, candidate, now = Date.now()) {
165
+ const specs = rule.enforce_via ?? [];
166
+ const before = specs.map((s) => s.mech);
167
+ const events = [];
168
+ let changed = false;
169
+ const updatedSpecs = specs.map((spec) => {
170
+ const to = promoteMech(spec.mech);
171
+ if (to == null)
172
+ return spec;
173
+ changed = true;
174
+ events.push({
175
+ kind: 'meta_promote_to_a',
176
+ rule_id: rule.rule_id,
177
+ evidence: {
178
+ source: 'signals',
179
+ refs: [],
180
+ metrics: { injects_rolling_n: candidate.injects_rolling_n, violations_rolling_n: candidate.violations_rolling_n },
181
+ },
182
+ suggested_action: 'promote_mech',
183
+ ts: now,
184
+ });
185
+ return { ...spec, mech: to };
186
+ });
187
+ if (!changed) {
188
+ return { rule_id: rule.rule_id, before_mech: before, after_mech: before, events: [], applied: false, reason: 'no Mech-B/C to promote' };
189
+ }
190
+ const lifecycle = initLifecycle(rule);
191
+ const promotions = updatedSpecs.map((spec, i) => ({
192
+ at: new Date(now).toISOString(),
193
+ from_mech: before[i],
194
+ to_mech: spec.mech,
195
+ reason: 'consistent_adherence',
196
+ trigger_stats: {
197
+ window_n: candidate.injects_rolling_n,
198
+ adherence_rate: 1.0,
199
+ },
200
+ })).filter((p) => p.from_mech !== p.to_mech);
201
+ const updatedRule = {
202
+ ...rule,
203
+ enforce_via: updatedSpecs,
204
+ lifecycle: {
205
+ ...lifecycle,
206
+ meta_promotions: [...lifecycle.meta_promotions, ...promotions],
207
+ },
208
+ updated_at: new Date(now).toISOString(),
209
+ };
210
+ saveRule(updatedRule);
211
+ return {
212
+ rule_id: rule.rule_id,
213
+ before_mech: before,
214
+ after_mech: updatedSpecs.map((s) => s.mech),
215
+ events,
216
+ applied: true,
217
+ };
218
+ }
219
+ export function applyDemotion(rule, candidate, now = Date.now()) {
220
+ // C2 guard: hard rule 은 demote 불가 — 호출자가 scanDriftForDemotion 을 거치면
221
+ // 이미 필터되지만, applyDemotion 을 직접 호출하는 경로도 방어.
222
+ if (rule.strength === 'hard') {
223
+ return {
224
+ rule_id: rule.rule_id, before_mech: (rule.enforce_via ?? []).map((s) => s.mech),
225
+ after_mech: (rule.enforce_via ?? []).map((s) => s.mech), events: [], applied: false,
226
+ reason: 'hard rule — demotion refused',
227
+ };
228
+ }
229
+ const specs = rule.enforce_via ?? [];
230
+ const before = specs.map((s) => s.mech);
231
+ const events = [];
232
+ let changed = false;
233
+ const updatedSpecs = specs.map((spec) => {
234
+ const to = demoteMech(spec.mech);
235
+ if (to == null)
236
+ return spec;
237
+ changed = true;
238
+ events.push({
239
+ kind: 'meta_demote_to_b',
240
+ rule_id: rule.rule_id,
241
+ evidence: {
242
+ source: 'drift-log',
243
+ refs: candidate.sessions,
244
+ metrics: { event_count: candidate.event_count, window_days: candidate.window_days },
245
+ },
246
+ suggested_action: 'demote_mech',
247
+ ts: now,
248
+ });
249
+ return { ...spec, mech: to };
250
+ });
251
+ if (!changed) {
252
+ return { rule_id: rule.rule_id, before_mech: before, after_mech: before, events: [], applied: false, reason: 'no Mech-A/B to demote' };
253
+ }
254
+ const lifecycle = initLifecycle(rule);
255
+ const demotions = updatedSpecs.map((spec, i) => ({
256
+ at: new Date(now).toISOString(),
257
+ from_mech: before[i],
258
+ to_mech: spec.mech,
259
+ reason: 'stuck_loop_force_approve',
260
+ trigger_stats: { window_n: candidate.event_count, violation_count: candidate.event_count },
261
+ })).filter((p) => p.from_mech !== p.to_mech);
262
+ const after = updatedSpecs.map((s) => s.mech);
263
+ const updatedRule = {
264
+ ...rule,
265
+ enforce_via: updatedSpecs,
266
+ lifecycle: {
267
+ ...lifecycle,
268
+ meta_promotions: [...lifecycle.meta_promotions, ...demotions],
269
+ },
270
+ updated_at: new Date(now).toISOString(),
271
+ };
272
+ saveRule(updatedRule);
273
+ return {
274
+ rule_id: rule.rule_id,
275
+ before_mech: before,
276
+ after_mech: after,
277
+ events,
278
+ applied: true,
279
+ };
280
+ }
281
+ const LIFECYCLE_ROTATION_THRESHOLD = 10 * 1024 * 1024; // 10 MB
282
+ export function appendLifecycleEvents(events, now = Date.now()) {
283
+ if (events.length === 0)
284
+ return;
285
+ try {
286
+ fs.mkdirSync(LIFECYCLE_DIR, { recursive: true });
287
+ const date = new Date(now).toISOString().slice(0, 10);
288
+ const logPath = path.join(LIFECYCLE_DIR, `${date}.jsonl`);
289
+ // H8: size-based rotation
290
+ try {
291
+ const st = fs.statSync(logPath);
292
+ if (st.size > LIFECYCLE_ROTATION_THRESHOLD) {
293
+ fs.renameSync(logPath, `${logPath}.${Date.now()}`);
294
+ }
295
+ }
296
+ catch { /* missing → no rotate */ }
297
+ const body = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
298
+ fs.appendFileSync(logPath, body);
299
+ }
300
+ catch (e) {
301
+ if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
302
+ console.error(`[forgen:lifecycle] appendLifecycleEvents failed: ${e.message}`);
303
+ }
304
+ }
305
+ }
306
+ export async function runMetaScan(args) {
307
+ const apply = args.includes('--apply');
308
+ const threshold = Number(args[args.indexOf('--threshold') + 1]) || DEFAULT_THRESHOLD;
309
+ const windowDays = Number(args[args.indexOf('--window') + 1]) || DEFAULT_WINDOW_DAYS;
310
+ const rules = loadAllRules();
311
+ const drift = readDriftEntries();
312
+ const candidates = scanDriftForDemotion({ rules, drift, windowDays, threshold });
313
+ console.log(`\n Meta Reclassifier (Rule Lifecycle)\n`);
314
+ console.log(` Window: last ${windowDays} day(s) Threshold: ${threshold} event(s)\n`);
315
+ console.log(` Scanned: drift.jsonl = ${drift.length} entries, rules = ${rules.length}\n`);
316
+ if (candidates.length === 0) {
317
+ console.log(' No demotion candidates. System stable.\n');
318
+ return;
319
+ }
320
+ const allEvents = [];
321
+ for (const c of candidates) {
322
+ console.log(` ⚠ Candidate: rule=${c.rule_id} events=${c.event_count} sessions=${c.sessions.length} mechs=[${c.current_mechs.join(',')}]`);
323
+ console.log(` window: ${c.first_at} → ${c.last_at}`);
324
+ // R4-B1: exact match only. 이전 `|| startsWith` 는 "L1-async" drift 로 "L1-async-await"
325
+ // 까지 demote 시키는 교차 오염을 야기. scanDriftForDemotion 은 이미 exact match.
326
+ const rule = rules.find((r) => r.rule_id === c.rule_id);
327
+ if (!rule) {
328
+ console.log(' (rule not found in store — likely spike scenarios.json; skip)');
329
+ continue;
330
+ }
331
+ if (apply) {
332
+ const result = applyDemotion(rule, c);
333
+ if (result.applied) {
334
+ console.log(` → APPLIED: ${result.before_mech.join(',')} → ${result.after_mech.join(',')}`);
335
+ allEvents.push(...result.events);
336
+ }
337
+ else {
338
+ console.log(` → SKIP: ${result.reason}`);
339
+ }
340
+ }
341
+ else {
342
+ const proposed = (rule.enforce_via ?? []).map((s) => demoteMech(s.mech) ?? s.mech);
343
+ console.log(` → PROPOSE: ${(rule.enforce_via ?? []).map((s) => s.mech).join(',')} → ${proposed.join(',')} (run with --apply to save)`);
344
+ }
345
+ console.log('');
346
+ }
347
+ if (apply && allEvents.length > 0) {
348
+ appendLifecycleEvents(allEvents);
349
+ console.log(` Persisted ${allEvents.length} lifecycle event(s) to ~/.forgen/state/lifecycle/\n`);
350
+ }
351
+ }