@wooojin/forgen 0.3.1 → 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 (125) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +164 -0
  3. package/README.ja.md +90 -7
  4. package/README.ko.md +44 -1
  5. package/README.md +128 -9
  6. package/README.zh.md +90 -7
  7. package/dist/cli.js +140 -8
  8. package/dist/core/auto-compound-runner.js +16 -5
  9. package/dist/core/dashboard.js +11 -4
  10. package/dist/core/doctor.d.ts +6 -1
  11. package/dist/core/doctor.js +85 -11
  12. package/dist/core/global-config.d.ts +2 -2
  13. package/dist/core/global-config.js +6 -14
  14. package/dist/core/harness.d.ts +3 -5
  15. package/dist/core/harness.js +34 -338
  16. package/dist/core/inspect-cli.js +65 -5
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +0 -34
  20. package/dist/core/paths.js +0 -35
  21. package/dist/core/settings-injector.d.ts +13 -0
  22. package/dist/core/settings-injector.js +167 -0
  23. package/dist/core/settings-lock.d.ts +35 -2
  24. package/dist/core/settings-lock.js +65 -7
  25. package/dist/core/spawn.js +100 -39
  26. package/dist/core/state-gc.d.ts +49 -0
  27. package/dist/core/state-gc.js +163 -0
  28. package/dist/core/stats-cli.d.ts +15 -0
  29. package/dist/core/stats-cli.js +143 -0
  30. package/dist/core/uninstall.d.ts +1 -0
  31. package/dist/core/uninstall.js +36 -5
  32. package/dist/core/v1-bootstrap.js +11 -3
  33. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  34. package/dist/engine/classify-enforce-cli.js +61 -0
  35. package/dist/engine/compound-cli.d.ts +27 -2
  36. package/dist/engine/compound-cli.js +69 -16
  37. package/dist/engine/compound-export.d.ts +15 -0
  38. package/dist/engine/compound-export.js +32 -5
  39. package/dist/engine/compound-loop.js +3 -2
  40. package/dist/engine/enforce-classifier.d.ts +31 -0
  41. package/dist/engine/enforce-classifier.js +123 -0
  42. package/dist/engine/learn-cli.js +52 -0
  43. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  44. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  45. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  46. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  47. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  48. package/dist/engine/lifecycle/meta-cli.js +7 -0
  49. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  50. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  51. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  52. package/dist/engine/lifecycle/orchestrator.js +131 -0
  53. package/dist/engine/lifecycle/signals.d.ts +30 -0
  54. package/dist/engine/lifecycle/signals.js +142 -0
  55. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  56. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  57. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  58. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  59. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  60. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  61. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  62. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  63. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  64. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  65. package/dist/engine/lifecycle/types.d.ts +52 -0
  66. package/dist/engine/lifecycle/types.js +7 -0
  67. package/dist/engine/match-eval-log.js +45 -0
  68. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  69. package/dist/engine/rule-toggle-cli.js +76 -0
  70. package/dist/engine/solution-format.d.ts +0 -2
  71. package/dist/engine/solution-format.js +0 -4
  72. package/dist/engine/solution-matcher.d.ts +8 -0
  73. package/dist/engine/solution-matcher.js +7 -4
  74. package/dist/engine/solution-outcomes.d.ts +4 -0
  75. package/dist/engine/solution-outcomes.js +174 -97
  76. package/dist/engine/solution-writer.d.ts +8 -5
  77. package/dist/engine/solution-writer.js +43 -19
  78. package/dist/fgx.js +9 -2
  79. package/dist/forge/cli.js +7 -7
  80. package/dist/forge/evidence-processor.js +10 -2
  81. package/dist/hooks/context-guard.js +86 -1
  82. package/dist/hooks/hook-config.d.ts +9 -1
  83. package/dist/hooks/hook-config.js +25 -3
  84. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  85. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  86. package/dist/hooks/notepad-injector.js +6 -3
  87. package/dist/hooks/permission-handler.d.ts +10 -2
  88. package/dist/hooks/permission-handler.js +31 -12
  89. package/dist/hooks/post-tool-use.js +62 -0
  90. package/dist/hooks/pre-tool-use.js +67 -5
  91. package/dist/hooks/secret-filter.d.ts +10 -0
  92. package/dist/hooks/secret-filter.js +26 -0
  93. package/dist/hooks/session-recovery.js +15 -7
  94. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  95. package/dist/hooks/shared/atomic-write.js +17 -3
  96. package/dist/hooks/shared/hook-response.d.ts +11 -2
  97. package/dist/hooks/shared/hook-response.js +20 -7
  98. package/dist/hooks/shared/hook-timing.js +10 -1
  99. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  100. package/dist/hooks/shared/safe-regex.js +50 -0
  101. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  102. package/dist/hooks/shared/stop-triggers.js +19 -0
  103. package/dist/hooks/solution-injector.d.ts +21 -0
  104. package/dist/hooks/solution-injector.js +60 -1
  105. package/dist/hooks/stop-guard.d.ts +84 -0
  106. package/dist/hooks/stop-guard.js +482 -0
  107. package/dist/mcp/solution-reader.d.ts +2 -0
  108. package/dist/mcp/solution-reader.js +28 -1
  109. package/dist/mcp/tools.js +24 -4
  110. package/dist/preset/preset-manager.js +12 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +55 -6
  113. package/dist/store/profile-store.d.ts +9 -0
  114. package/dist/store/profile-store.js +25 -4
  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 +133 -13
  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 +10 -2
  124. package/plugin.json +7 -2
  125. package/scripts/postinstall.js +52 -5
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Lifecycle Orchestrator — 트리거 이벤트 수신 → rule 상태 전이 적용.
3
+ *
4
+ * 데이터 플로우:
5
+ * [T1~T5 + Meta] ─detect(state)→ LifecycleEvent[]
6
+ * │
7
+ * applyEvent(rule, event) ← ──────┘ (pure)
8
+ * │
9
+ * ┌────────┴────────┐
10
+ * saveRule(rule) persistEvent(event)
11
+ * (rule-store.ts) (~/.forgen/state/lifecycle/{date}.jsonl)
12
+ *
13
+ * applyEvent 는 pure — rule → rule'. 부수효과는 saveRule / appendLifecycleEvents 에서만.
14
+ *
15
+ * 상태 전이 규칙 (ADR-002 §State transitions):
16
+ * flag → phase='flagged'
17
+ * suppress → phase='suppressed' (+ status='suppressed')
18
+ * retire → phase='retired' (+ status='removed')
19
+ * merge → phase='merged' (+ merged_into)
20
+ * supersede → phase='superseded' (+ superseded_by)
21
+ * promote/demote_mech → phase 유지, meta_promotions 는 meta-reclassifier 가 직접 기록
22
+ */
23
+ import * as fs from 'node:fs';
24
+ import * as os from 'node:os';
25
+ import * as path from 'node:path';
26
+ /**
27
+ * R5-B1: rule 이 inactive 상태로 전이될 때 block-count 디렉터리의 잔여 파일 정리.
28
+ * phantom stuck-loop (retired 된 rule 이 다시 GC 전까지 counter 에 반영되는 문제) 차단.
29
+ */
30
+ function sweepBlockCountsForRule(ruleId) {
31
+ try {
32
+ const dir = path.join(os.homedir(), '.forgen', 'state', 'enforcement', 'block-count');
33
+ if (!fs.existsSync(dir))
34
+ return;
35
+ const safeRuleId = String(ruleId).replace(/[^a-zA-Z0-9_.-]/g, '_');
36
+ for (const file of fs.readdirSync(dir)) {
37
+ if (file.endsWith(`__${safeRuleId}.json`)) {
38
+ try {
39
+ fs.unlinkSync(path.join(dir, file));
40
+ }
41
+ catch { /* best-effort */ }
42
+ }
43
+ }
44
+ }
45
+ catch { /* fail-open */ }
46
+ }
47
+ const INACTIVE_STATUSES = new Set(['removed', 'suppressed', 'superseded']);
48
+ export function ensureLifecycle(rule) {
49
+ return rule.lifecycle ?? {
50
+ phase: 'active',
51
+ first_active_at: rule.created_at,
52
+ inject_count: 0,
53
+ accept_count: 0,
54
+ violation_count: 0,
55
+ bypass_count: 0,
56
+ conflict_refs: [],
57
+ meta_promotions: [],
58
+ };
59
+ }
60
+ const ACTION_TO_PHASE = {
61
+ flag: 'flagged',
62
+ suppress: 'suppressed',
63
+ retire: 'retired',
64
+ merge: 'merged',
65
+ supersede: 'superseded',
66
+ };
67
+ const ACTION_TO_STATUS = {
68
+ suppress: 'suppressed',
69
+ retire: 'removed',
70
+ supersede: 'superseded',
71
+ };
72
+ /** 순수: rule + event → rule'. Mech 변경은 meta-reclassifier 가 처리하므로 여기서는 제외. */
73
+ export function applyEvent(rule, event, now = Date.now()) {
74
+ if (event.suggested_action === 'promote_mech' || event.suggested_action === 'demote_mech') {
75
+ // meta-reclassifier 가 rule 을 직접 변경. orchestrator 는 meta_promotions 이력만 유지.
76
+ return rule;
77
+ }
78
+ const lifecycle = ensureLifecycle(rule);
79
+ const nextPhase = ACTION_TO_PHASE[event.suggested_action];
80
+ const nextStatus = ACTION_TO_STATUS[event.suggested_action];
81
+ const updatedLifecycle = {
82
+ ...lifecycle,
83
+ phase: nextPhase ?? lifecycle.phase,
84
+ };
85
+ // R5-B2: phase 전이 시 상호 배타적 포인터 정리.
86
+ if (event.suggested_action === 'merge' && event.merged_into) {
87
+ updatedLifecycle.merged_into = event.merged_into;
88
+ delete updatedLifecycle.superseded_by;
89
+ }
90
+ if (event.suggested_action === 'supersede' && event.superseded_by) {
91
+ updatedLifecycle.superseded_by = event.superseded_by;
92
+ delete updatedLifecycle.merged_into;
93
+ }
94
+ if (event.kind === 't5_conflict_detected' && event.evidence?.refs) {
95
+ const refs = event.evidence.refs.filter((r) => r !== rule.rule_id);
96
+ updatedLifecycle.conflict_refs = [
97
+ ...new Set([...lifecycle.conflict_refs, ...refs]),
98
+ ];
99
+ }
100
+ // retired rule 은 더 이상 의미 있는 conflict 가 없으므로 정리.
101
+ if (event.suggested_action === 'retire') {
102
+ updatedLifecycle.conflict_refs = [];
103
+ }
104
+ const nextStatusValue = nextStatus ?? rule.status;
105
+ // R5-B1: inactive 전이 시 block-count orphan 파일 정리.
106
+ if (INACTIVE_STATUSES.has(nextStatusValue) && !INACTIVE_STATUSES.has(rule.status)) {
107
+ sweepBlockCountsForRule(rule.rule_id);
108
+ }
109
+ return {
110
+ ...rule,
111
+ status: nextStatusValue,
112
+ lifecycle: updatedLifecycle,
113
+ updated_at: new Date(now).toISOString(),
114
+ };
115
+ }
116
+ /**
117
+ * 여러 이벤트를 rule 단위로 그룹핑 후 applyEvent 로 순차 접기.
118
+ * 순수 — 호출자가 저장을 담당.
119
+ */
120
+ export function foldEvents(rules, events, now = Date.now()) {
121
+ const byId = new Map();
122
+ for (const r of rules)
123
+ byId.set(r.rule_id, r);
124
+ for (const ev of events) {
125
+ const current = byId.get(ev.rule_id);
126
+ if (!current)
127
+ continue;
128
+ byId.set(ev.rule_id, applyEvent(current, ev, now));
129
+ }
130
+ return byId;
131
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Signal collector — 각 rule 에 대해 트리거들이 필요로 하는 집계 수치를 계산.
3
+ *
4
+ * 입력 소스 (on-disk):
5
+ * - ~/.forgen/state/enforcement/drift.jsonl (stuck-loop 이벤트)
6
+ * - ~/.forgen/state/enforcement/violations.jsonl (rule 위반 기록)
7
+ * - ~/.forgen/state/enforcement/bypass.jsonl (T3: 사용자 우회 기록)
8
+ *
9
+ * 모든 IO 는 이 파일에 한정. 트리거들은 pure — collectSignals() 결과를 받아 detect().
10
+ */
11
+ import type { Rule } from '../../store/types.js';
12
+ import type { RuleSignals, ViolationEntry, BypassEntry } from './types.js';
13
+ /**
14
+ * Best-effort size-based rotation. When `p` exceeds 10MB, renames to
15
+ * `<p>.<timestamp>` so the next write starts fresh. Missing file or rename
16
+ * failures are swallowed — the caller's append will still succeed or fail
17
+ * on its own merits. Exported so enforcement-path jsonl writers outside
18
+ * this file (drift.jsonl, acknowledgments.jsonl) reuse the same policy.
19
+ */
20
+ export declare function rotateIfBig(p: string): void;
21
+ export declare function readJsonlSafe<T>(p: string): T[];
22
+ export declare function recordViolation(entry: Omit<ViolationEntry, 'at'>): void;
23
+ export declare function recordBypass(entry: Omit<BypassEntry, 'at'>): void;
24
+ export interface SignalInputs {
25
+ violations?: ViolationEntry[];
26
+ bypass?: BypassEntry[];
27
+ now?: number;
28
+ }
29
+ export declare function collectSignals(rule: Rule, inputs?: SignalInputs): RuleSignals;
30
+ export declare function collectAllSignals(rules: Rule[], inputs?: SignalInputs): Map<string, RuleSignals>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Signal collector — 각 rule 에 대해 트리거들이 필요로 하는 집계 수치를 계산.
3
+ *
4
+ * 입력 소스 (on-disk):
5
+ * - ~/.forgen/state/enforcement/drift.jsonl (stuck-loop 이벤트)
6
+ * - ~/.forgen/state/enforcement/violations.jsonl (rule 위반 기록)
7
+ * - ~/.forgen/state/enforcement/bypass.jsonl (T3: 사용자 우회 기록)
8
+ *
9
+ * 모든 IO 는 이 파일에 한정. 트리거들은 pure — collectSignals() 결과를 받아 detect().
10
+ */
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ const STATE_DIR = path.join(os.homedir(), '.forgen', 'state', 'enforcement');
15
+ const VIOLATIONS_PATH = path.join(STATE_DIR, 'violations.jsonl');
16
+ const BYPASS_PATH = path.join(STATE_DIR, 'bypass.jsonl');
17
+ const ROLLING_N = 20;
18
+ const VIOLATION_WINDOW_DAYS = 30;
19
+ const BYPASS_WINDOW_DAYS = 7;
20
+ /** H8: jsonl rotation threshold — append 시점마다 체크. */
21
+ const ROTATION_THRESHOLD_BYTES = 10 * 1024 * 1024; // 10 MB
22
+ /**
23
+ * Best-effort size-based rotation. When `p` exceeds 10MB, renames to
24
+ * `<p>.<timestamp>` so the next write starts fresh. Missing file or rename
25
+ * failures are swallowed — the caller's append will still succeed or fail
26
+ * on its own merits. Exported so enforcement-path jsonl writers outside
27
+ * this file (drift.jsonl, acknowledgments.jsonl) reuse the same policy.
28
+ */
29
+ export function rotateIfBig(p) {
30
+ try {
31
+ const st = fs.statSync(p);
32
+ if (st.size > ROTATION_THRESHOLD_BYTES) {
33
+ fs.renameSync(p, `${p}.${Date.now()}`);
34
+ }
35
+ }
36
+ catch { /* missing → no rotate */ }
37
+ }
38
+ export function readJsonlSafe(p) {
39
+ if (!fs.existsSync(p))
40
+ return [];
41
+ try {
42
+ return fs.readFileSync(p, 'utf-8')
43
+ .trim()
44
+ .split('\n')
45
+ .filter(Boolean)
46
+ .map((line) => {
47
+ try {
48
+ return JSON.parse(line);
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ })
54
+ .filter((e) => e !== null);
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ }
60
+ export function recordViolation(entry) {
61
+ try {
62
+ fs.mkdirSync(STATE_DIR, { recursive: true });
63
+ rotateIfBig(VIOLATIONS_PATH);
64
+ const full = { at: new Date().toISOString(), ...entry };
65
+ fs.appendFileSync(VIOLATIONS_PATH, JSON.stringify(full) + '\n');
66
+ }
67
+ catch (e) {
68
+ // best-effort, 실패 시 debug 로그 (silent swallow 방지)
69
+ if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
70
+ console.error(`[forgen:signals] recordViolation failed: ${e.message}`);
71
+ }
72
+ }
73
+ }
74
+ export function recordBypass(entry) {
75
+ try {
76
+ fs.mkdirSync(STATE_DIR, { recursive: true });
77
+ rotateIfBig(BYPASS_PATH);
78
+ const full = { at: new Date().toISOString(), ...entry };
79
+ fs.appendFileSync(BYPASS_PATH, JSON.stringify(full) + '\n');
80
+ }
81
+ catch (e) {
82
+ if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
83
+ console.error(`[forgen:signals] recordBypass failed: ${e.message}`);
84
+ }
85
+ }
86
+ }
87
+ export function collectSignals(rule, inputs = {}) {
88
+ const now = inputs.now ?? Date.now();
89
+ const violations = inputs.violations ?? readJsonlSafe(VIOLATIONS_PATH);
90
+ const bypass = inputs.bypass ?? readJsonlSafe(BYPASS_PATH);
91
+ // exact match only — M fix: startsWith 으로 prefix 교차 오염되던 부분 제거.
92
+ const matchesRule = (ruleId) => ruleId === rule.rule_id;
93
+ const vCutoff30 = now - VIOLATION_WINDOW_DAYS * 24 * 3600 * 1000;
94
+ const recent30 = violations.filter((v) => {
95
+ if (!matchesRule(v.rule_id))
96
+ return false;
97
+ const t = Date.parse(v.at);
98
+ return Number.isFinite(t) && t >= vCutoff30;
99
+ });
100
+ const bCutoff = now - BYPASS_WINDOW_DAYS * 24 * 3600 * 1000;
101
+ const recentBypass = bypass.filter((b) => {
102
+ if (!matchesRule(b.rule_id))
103
+ return false;
104
+ const t = Date.parse(b.at);
105
+ return Number.isFinite(t) && t >= bCutoff;
106
+ });
107
+ // Rolling N: take last N entries (violations + injections aggregate).
108
+ // Inject 추적 인프라가 완비되기 전까지는 violations.jsonl 길이 * proxy 사용.
109
+ // lifecycle.inject_count 필드가 채워지기 시작하면 그 값을 우선.
110
+ const injectsRolling = rule.lifecycle?.inject_count ?? 0;
111
+ const lastN = violations
112
+ .filter((v) => matchesRule(v.rule_id))
113
+ .slice(-ROLLING_N);
114
+ const violationsRolling = lastN.length;
115
+ const lastInjectTs = rule.lifecycle?.last_inject_at
116
+ ? Date.parse(rule.lifecycle.last_inject_at)
117
+ : null;
118
+ const lastInjectDays = lastInjectTs
119
+ ? Math.floor((now - lastInjectTs) / (24 * 3600 * 1000))
120
+ : Math.floor((now - Date.parse(rule.updated_at)) / (24 * 3600 * 1000));
121
+ const lastUpdatedDays = Math.floor((now - Date.parse(rule.updated_at)) / (24 * 3600 * 1000));
122
+ const injectCount = rule.lifecycle?.inject_count ?? 0;
123
+ const violationRate30 = injectCount > 0
124
+ ? recent30.length / injectCount
125
+ : (recent30.length >= 1 ? 1 : 0); // no inject tracking → treat each violation as high rate
126
+ return {
127
+ violations_30d: recent30.length,
128
+ violation_rate_30d: violationRate30,
129
+ bypass_7d: recentBypass.length,
130
+ last_inject_days_ago: lastInjectDays,
131
+ injects_rolling_n: injectsRolling,
132
+ violations_rolling_n: violationsRolling,
133
+ last_updated_days_ago: lastUpdatedDays,
134
+ };
135
+ }
136
+ export function collectAllSignals(rules, inputs = {}) {
137
+ const map = new Map();
138
+ for (const r of rules) {
139
+ map.set(r.rule_id, collectSignals(r, inputs));
140
+ }
141
+ return map;
142
+ }
@@ -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[];