@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.ja.md +61 -7
- package/README.ko.md +15 -1
- package/README.md +92 -6
- package/README.zh.md +61 -7
- package/dist/cli.js +137 -5
- package/dist/core/auto-compound-runner.js +10 -2
- package/dist/core/doctor.js +64 -10
- package/dist/core/inspect-cli.js +65 -5
- package/dist/core/state-gc.d.ts +19 -0
- package/dist/core/state-gc.js +48 -4
- package/dist/core/stats-cli.d.ts +15 -0
- package/dist/core/stats-cli.js +143 -0
- package/dist/core/uninstall.d.ts +1 -0
- package/dist/core/uninstall.js +24 -1
- package/dist/core/v1-bootstrap.js +9 -1
- package/dist/engine/classify-enforce-cli.d.ts +8 -0
- package/dist/engine/classify-enforce-cli.js +61 -0
- package/dist/engine/enforce-classifier.d.ts +31 -0
- package/dist/engine/enforce-classifier.js +123 -0
- package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
- package/dist/engine/lifecycle/bypass-detector.js +82 -0
- package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
- package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
- package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
- package/dist/engine/lifecycle/meta-cli.js +7 -0
- package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
- package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
- package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
- package/dist/engine/lifecycle/orchestrator.js +131 -0
- package/dist/engine/lifecycle/signals.d.ts +30 -0
- package/dist/engine/lifecycle/signals.js +142 -0
- package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
- package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
- package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
- package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
- package/dist/engine/lifecycle/types.d.ts +52 -0
- package/dist/engine/lifecycle/types.js +7 -0
- package/dist/engine/rule-toggle-cli.d.ts +13 -0
- package/dist/engine/rule-toggle-cli.js +76 -0
- package/dist/forge/evidence-processor.js +10 -2
- package/dist/hooks/context-guard.js +71 -0
- package/dist/hooks/post-tool-use.js +62 -0
- package/dist/hooks/pre-tool-use.js +57 -1
- package/dist/hooks/secret-filter.d.ts +10 -0
- package/dist/hooks/secret-filter.js +20 -0
- package/dist/hooks/shared/atomic-write.d.ts +8 -1
- package/dist/hooks/shared/atomic-write.js +17 -3
- package/dist/hooks/shared/hook-response.d.ts +11 -0
- package/dist/hooks/shared/hook-response.js +18 -0
- package/dist/hooks/shared/safe-regex.d.ts +25 -0
- package/dist/hooks/shared/safe-regex.js +50 -0
- package/dist/hooks/shared/stop-triggers.d.ts +19 -0
- package/dist/hooks/shared/stop-triggers.js +19 -0
- package/dist/hooks/stop-guard.d.ts +84 -0
- package/dist/hooks/stop-guard.js +482 -0
- package/dist/mcp/tools.js +19 -2
- package/dist/store/evidence-store.d.ts +15 -0
- package/dist/store/evidence-store.js +50 -1
- package/dist/store/rule-lifecycle.d.ts +23 -0
- package/dist/store/rule-lifecycle.js +63 -0
- package/dist/store/rule-store.d.ts +21 -0
- package/dist/store/rule-store.js +128 -8
- package/dist/store/types.d.ts +83 -0
- package/dist/store/types.js +7 -1
- package/hooks/hook-registry.json +1 -0
- package/hooks/hooks.json +6 -1
- package/package.json +10 -2
- package/plugin.json +1 -1
|
@@ -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 os from 'node:os';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { initLifecycle } from '../../store/rule-lifecycle.js';
|
|
22
|
+
import { loadAllRules, saveRule } from '../../store/rule-store.js';
|
|
23
|
+
const DRIFT_LOG_PATH = path.join(os.homedir(), '.forgen', 'state', 'enforcement', 'drift.jsonl');
|
|
24
|
+
const LIFECYCLE_DIR = path.join(os.homedir(), '.forgen', 'state', '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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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 type { Rule, LifecycleState } from '../../store/types.js';
|
|
24
|
+
import type { LifecycleEvent } from './types.js';
|
|
25
|
+
export declare function ensureLifecycle(rule: Rule): LifecycleState;
|
|
26
|
+
/** 순수: rule + event → rule'. Mech 변경은 meta-reclassifier 가 처리하므로 여기서는 제외. */
|
|
27
|
+
export declare function applyEvent(rule: Rule, event: LifecycleEvent, now?: number): Rule;
|
|
28
|
+
/**
|
|
29
|
+
* 여러 이벤트를 rule 단위로 그룹핑 후 applyEvent 로 순차 접기.
|
|
30
|
+
* 순수 — 호출자가 저장을 담당.
|
|
31
|
+
*/
|
|
32
|
+
export declare function foldEvents(rules: Rule[], events: LifecycleEvent[], now?: number): Map<string, Rule>;
|
|
@@ -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
|
+
}
|