@wooojin/forgen 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.ja.md +61 -7
  4. package/README.ko.md +15 -1
  5. package/README.md +92 -6
  6. package/README.zh.md +61 -7
  7. package/dist/cli.js +137 -5
  8. package/dist/core/auto-compound-runner.js +10 -2
  9. package/dist/core/doctor.js +64 -10
  10. package/dist/core/inspect-cli.js +65 -5
  11. package/dist/core/state-gc.d.ts +19 -0
  12. package/dist/core/state-gc.js +48 -4
  13. package/dist/core/stats-cli.d.ts +15 -0
  14. package/dist/core/stats-cli.js +143 -0
  15. package/dist/core/uninstall.d.ts +1 -0
  16. package/dist/core/uninstall.js +24 -1
  17. package/dist/core/v1-bootstrap.js +9 -1
  18. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  19. package/dist/engine/classify-enforce-cli.js +61 -0
  20. package/dist/engine/enforce-classifier.d.ts +31 -0
  21. package/dist/engine/enforce-classifier.js +123 -0
  22. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  23. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  24. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  25. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  26. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  27. package/dist/engine/lifecycle/meta-cli.js +7 -0
  28. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  29. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  30. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  31. package/dist/engine/lifecycle/orchestrator.js +131 -0
  32. package/dist/engine/lifecycle/signals.d.ts +30 -0
  33. package/dist/engine/lifecycle/signals.js +142 -0
  34. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  35. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  36. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  37. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  38. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  39. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  40. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  41. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  42. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  43. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  44. package/dist/engine/lifecycle/types.d.ts +52 -0
  45. package/dist/engine/lifecycle/types.js +7 -0
  46. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  47. package/dist/engine/rule-toggle-cli.js +76 -0
  48. package/dist/forge/evidence-processor.js +10 -2
  49. package/dist/hooks/context-guard.js +71 -0
  50. package/dist/hooks/post-tool-use.js +62 -0
  51. package/dist/hooks/pre-tool-use.js +57 -1
  52. package/dist/hooks/secret-filter.d.ts +10 -0
  53. package/dist/hooks/secret-filter.js +20 -0
  54. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  55. package/dist/hooks/shared/atomic-write.js +17 -3
  56. package/dist/hooks/shared/hook-response.d.ts +11 -0
  57. package/dist/hooks/shared/hook-response.js +18 -0
  58. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  59. package/dist/hooks/shared/safe-regex.js +50 -0
  60. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  61. package/dist/hooks/shared/stop-triggers.js +19 -0
  62. package/dist/hooks/stop-guard.d.ts +84 -0
  63. package/dist/hooks/stop-guard.js +482 -0
  64. package/dist/mcp/tools.js +19 -2
  65. package/dist/store/evidence-store.d.ts +15 -0
  66. package/dist/store/evidence-store.js +50 -1
  67. package/dist/store/rule-lifecycle.d.ts +23 -0
  68. package/dist/store/rule-lifecycle.js +63 -0
  69. package/dist/store/rule-store.d.ts +21 -0
  70. package/dist/store/rule-store.js +128 -8
  71. package/dist/store/types.d.ts +83 -0
  72. package/dist/store/types.js +7 -1
  73. package/hooks/hook-registry.json +1 -0
  74. package/hooks/hooks.json +6 -1
  75. package/package.json +10 -2
  76. package/plugin.json +1 -1
@@ -291,8 +291,16 @@ export async function handleUninstall(cwd, options) {
291
291
  console.log(' 4. Remove forgen block from CLAUDE.md');
292
292
  console.log(' 5. Remove slash commands (~/.claude/commands/forgen/)');
293
293
  console.log(' 6. Remove plugin artifacts (cache, installed_plugins.json, plugin directory)');
294
+ if (options.purge) {
295
+ console.log(' 7. --purge: Delete ~/.forgen/ entirely (rules, me/, state/, solutions/, behavior/)');
296
+ console.log(' WARNING: this erases all accumulated corrections, rules, drift, and lifecycle history.');
297
+ }
298
+ else {
299
+ console.log('');
300
+ console.log('Note: ~/.forgen/ directory is preserved. Use --purge to also delete it.');
301
+ console.log(' (manual: rm -rf ~/.forgen)');
302
+ }
294
303
  console.log('');
295
- console.log('Note: ~/.forgen/ directory is preserved (manual deletion: rm -rf ~/.forgen)\n');
296
304
  if (!options.force) {
297
305
  if (!process.stdin.isTTY) {
298
306
  console.error('[forgen] Use --force flag in non-interactive environments.');
@@ -311,5 +319,20 @@ export async function handleUninstall(cwd, options) {
311
319
  cleanClaudeMd(cwd);
312
320
  cleanSlashCommands();
313
321
  cleanPluginArtifacts();
322
+ if (options.purge) {
323
+ try {
324
+ const forgenHome = path.join(os.homedir(), '.forgen');
325
+ if (fs.existsSync(forgenHome)) {
326
+ fs.rmSync(forgenHome, { recursive: true, force: true });
327
+ console.log(' ✓ Deleted ~/.forgen/ (all rules, state, solutions, behavior)');
328
+ }
329
+ else {
330
+ console.log(' ✓ ~/.forgen/ already absent');
331
+ }
332
+ }
333
+ catch (e) {
334
+ console.log(` ✗ ~/.forgen/ deletion failed: ${e.message}`);
335
+ }
336
+ }
314
337
  console.log('\n[forgen] Uninstall complete. Restart Claude Code for a clean state.\n');
315
338
  }
@@ -19,7 +19,7 @@ import { FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_
19
19
  import { checkLegacyProfile, runLegacyCutover } from './legacy-detector.js';
20
20
  import { detectRuntimeCapability } from './runtime-detector.js';
21
21
  import { loadProfile, profileExists } from '../store/profile-store.js';
22
- import { loadActiveRules, cleanupStaleSessionRules } from '../store/rule-store.js';
22
+ import { loadActiveRules, cleanupStaleSessionRules, markRulesInjected } from '../store/rule-store.js';
23
23
  import { composeSession } from '../preset/preset-manager.js';
24
24
  import { renderRules, DEFAULT_CONTEXT } from '../renderer/rule-renderer.js';
25
25
  import { saveSessionState, loadRecentSessions } from '../store/session-state-store.js';
@@ -82,6 +82,14 @@ export function bootstrapV1Session() {
82
82
  // 6. Rule 렌더링
83
83
  const allRules = [...personalRules];
84
84
  const renderedRules = renderRules(allRules, session, profile, DEFAULT_CONTEXT);
85
+ // 6b. Inject tracking (ADR-002 Meta signal) — rendered rules count as injected.
86
+ // Fail-open: tracking failure must not block bootstrap.
87
+ try {
88
+ const injected = allRules.filter((r) => r.status === 'active').map((r) => r.rule_id);
89
+ if (injected.length > 0)
90
+ markRulesInjected(injected);
91
+ }
92
+ catch { /* ignore */ }
85
93
  // 7. Mismatch 감지 (최근 3세션 rolling)
86
94
  let mismatchResult = null;
87
95
  try {
@@ -0,0 +1,8 @@
1
+ /**
2
+ * CLI handler for `forgen classify-enforce [--apply] [--force]`.
3
+ *
4
+ * 기본: dry-run — 각 rule 의 제안만 출력. 변경 없음.
5
+ * --apply: 제안을 rule 파일에 저장 (enforce_via 미설정 rule 만).
6
+ * --force: enforce_via 가 이미 있어도 덮어쓴다.
7
+ */
8
+ export declare function handleClassifyEnforce(args: string[]): Promise<void>;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * CLI handler for `forgen classify-enforce [--apply] [--force]`.
3
+ *
4
+ * 기본: dry-run — 각 rule 의 제안만 출력. 변경 없음.
5
+ * --apply: 제안을 rule 파일에 저장 (enforce_via 미설정 rule 만).
6
+ * --force: enforce_via 가 이미 있어도 덮어쓴다.
7
+ */
8
+ import { loadAllRules, saveRule } from '../store/rule-store.js';
9
+ import { classifyAll, applyProposal } from './enforce-classifier.js';
10
+ export async function handleClassifyEnforce(args) {
11
+ const apply = args.includes('--apply');
12
+ const force = args.includes('--force');
13
+ const rules = loadAllRules();
14
+ if (rules.length === 0) {
15
+ console.log('\n No rules in ~/.forgen/me/rules. Nothing to classify.\n');
16
+ return;
17
+ }
18
+ const proposals = classifyAll(rules);
19
+ let saved = 0;
20
+ let skipped = 0;
21
+ let alreadySet = 0;
22
+ console.log(`\n Enforce Classifier — ${rules.length} rule(s) scanned\n`);
23
+ for (let i = 0; i < proposals.length; i++) {
24
+ const p = proposals[i];
25
+ const rule = rules[i];
26
+ const marker = p.current_enforce_via ? '↻' : '+';
27
+ console.log(` ${marker} ${p.rule_id.slice(0, 8)} "${p.trigger_preview}"`);
28
+ console.log(` strength=${rule.strength} status=${rule.status}`);
29
+ for (const spec of p.proposed) {
30
+ const vparts = [spec.verifier?.kind ?? 'none'];
31
+ if (spec.drift_key)
32
+ vparts.push(`drift_key=${spec.drift_key}`);
33
+ console.log(` → Mech-${spec.mech} @ ${spec.hook} verifier=${vparts.join(' ')}`);
34
+ }
35
+ for (const reason of p.reasoning) {
36
+ console.log(` · ${reason}`);
37
+ }
38
+ if (apply) {
39
+ if (p.current_enforce_via && p.current_enforce_via.length > 0 && !force) {
40
+ alreadySet += 1;
41
+ console.log(' (skipped — enforce_via already set; use --force to overwrite)');
42
+ }
43
+ else {
44
+ const updated = applyProposal(rule, p, { force });
45
+ saveRule(updated);
46
+ saved += 1;
47
+ console.log(' (saved)');
48
+ }
49
+ }
50
+ else {
51
+ skipped += 1;
52
+ }
53
+ console.log('');
54
+ }
55
+ if (apply) {
56
+ console.log(` Summary: saved=${saved} already-set=${alreadySet} total=${rules.length}\n`);
57
+ }
58
+ else {
59
+ console.log(` Summary: ${skipped} proposal(s) previewed. Run with --apply to save.\n`);
60
+ }
61
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Forgen — Enforce Classifier (ADR-001 §Migration)
3
+ *
4
+ * 기존 Rule 에 `enforce_via: EnforceSpec[]` 이 없을 때, trigger/policy 자연어
5
+ * 패턴과 strength 조합으로 mech(A/B/C) 와 hook 을 자동 제안한다.
6
+ *
7
+ * 휴리스틱 (ADR-001 §Migration heuristics):
8
+ * - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
9
+ * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
10
+ * - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
11
+ * - 그 외 soft/default → Mech-C (drift 측정)
12
+ *
13
+ * 설계 원칙:
14
+ * - pure: classify(rule) 는 부수효과 없음. CLI 에서만 save 가 발생.
15
+ * - 미리 존재하는 enforce_via 는 덮어쓰지 않음 (`force=false` 기본).
16
+ * - 신규 제안은 reason 주석(문자열) 과 함께 반환해 사용자 리뷰 가능.
17
+ */
18
+ import type { Rule, EnforceSpec } from '../store/types.js';
19
+ export interface EnforceProposal {
20
+ rule_id: string;
21
+ trigger_preview: string;
22
+ current_enforce_via: EnforceSpec[] | null;
23
+ proposed: EnforceSpec[];
24
+ reasoning: string[];
25
+ }
26
+ export declare function classify(rule: Rule): EnforceProposal;
27
+ export declare function classifyAll(rules: Rule[]): EnforceProposal[];
28
+ /** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
29
+ export declare function applyProposal(rule: Rule, proposal: EnforceProposal, options?: {
30
+ force?: boolean;
31
+ }): Rule;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Forgen — Enforce Classifier (ADR-001 §Migration)
3
+ *
4
+ * 기존 Rule 에 `enforce_via: EnforceSpec[]` 이 없을 때, trigger/policy 자연어
5
+ * 패턴과 strength 조합으로 mech(A/B/C) 와 hook 을 자동 제안한다.
6
+ *
7
+ * 휴리스틱 (ADR-001 §Migration heuristics):
8
+ * - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
9
+ * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
10
+ * - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
11
+ * - 그 외 soft/default → Mech-C (drift 측정)
12
+ *
13
+ * 설계 원칙:
14
+ * - pure: classify(rule) 는 부수효과 없음. CLI 에서만 save 가 발생.
15
+ * - 미리 존재하는 enforce_via 는 덮어쓰지 않음 (`force=false` 기본).
16
+ * - 신규 제안은 reason 주석(문자열) 과 함께 반환해 사용자 리뷰 가능.
17
+ */
18
+ const DESTRUCTIVE_PATTERN = /\b(rm\s+-rf|rm\s+-fr|force|DROP\s+TABLE|credentials|\.env|sudo|mkfs|dd\s+if=)/i;
19
+ const COMPLETION_PATTERN = /(완료|complete|done|ready|shipped|finished|e2e|mock|verify|검증|배포)/i;
20
+ const STYLE_PATTERN = /(문체|응답|설명|톤|어투|장황|간결|verbose|tone|style)/i;
21
+ // R6-F2: shared single source of truth — stop-guard 와 동일 regex 재사용.
22
+ import { DEFAULT_STOP_TRIGGER_RE as STOP_COMPLETION_TRIGGER, DEFAULT_STOP_EXCLUDE_RE as STOP_COMPLETION_EXCLUDE, MOCK_TRIGGER_RE as STOP_MOCK_TRIGGER, MOCK_EXCLUDE_RE as STOP_MOCK_EXCLUDE, } from '../hooks/shared/stop-triggers.js';
23
+ export function classify(rule) {
24
+ const reasoning = [];
25
+ const proposed = [];
26
+ const text = `${rule.trigger}\n${rule.policy}`;
27
+ const isDestructive = DESTRUCTIVE_PATTERN.test(text);
28
+ const isCompletion = COMPLETION_PATTERN.test(text);
29
+ const isStyle = STYLE_PATTERN.test(text);
30
+ const isStrong = rule.strength === 'strong' || rule.strength === 'hard';
31
+ // Mech-A PreToolUse — 파괴적 명령 패턴.
32
+ // 이전에는 DESTRUCTIVE_PATTERN.source 를 다시 .match() 하여 alternation 의 첫 리터럴
33
+ // ("credentials") 만 반환하는 버그가 있었음. 이제 rule 텍스트에서 실제 매칭된 구문을
34
+ // 뽑아 그 구문에 맞는 runtime regex 로 변환.
35
+ if (isDestructive) {
36
+ const matched = text.match(DESTRUCTIVE_PATTERN);
37
+ const matchedLiteral = matched?.[0] ?? '';
38
+ // 안전을 위해 매칭된 literal 을 공백 보존 + escape 해서 runtime regex 로 재구성.
39
+ // 예: "rm -rf" → "rm\s+-rf" (공백 유연); "DROP TABLE" → "DROP\s+TABLE"; ".env" → "\.env"
40
+ const pattern = matchedLiteral
41
+ ? matchedLiteral
42
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex metachar
43
+ .replace(/\s+/g, '\\s+') // 공백 하나 이상
44
+ : 'rm\\s+-rf'; // fallback
45
+ proposed.push({
46
+ mech: 'A',
47
+ hook: 'PreToolUse',
48
+ verifier: {
49
+ kind: 'tool_arg_regex',
50
+ params: { pattern, requires_flag: 'user_confirmed' },
51
+ },
52
+ block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 80)}`,
53
+ });
54
+ reasoning.push(`destructive literal "${matchedLiteral}" → Mech-A PreToolUse+tool_arg_regex ${pattern}`);
55
+ }
56
+ // Mech-A Stop — 완료 선언 + 증거 요구 (destructive 와 독립적으로 평가: 하나의 rule 이 둘 다 해당 가능)
57
+ if (isCompletion) {
58
+ const mockAsProof = /mock|stub|fake/i.test(text);
59
+ // 증거 파일 경로는 v0.4.0 최종 구현에서 rule.policy 에서 추출; 지금은 default 사용
60
+ proposed.push({
61
+ mech: 'A',
62
+ hook: 'Stop',
63
+ verifier: {
64
+ kind: 'artifact_check',
65
+ params: { path: '.forgen/state/e2e-result.json', max_age_s: 3600 },
66
+ },
67
+ block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 120)}`,
68
+ trigger_keywords_regex: mockAsProof ? STOP_MOCK_TRIGGER : STOP_COMPLETION_TRIGGER,
69
+ trigger_exclude_regex: mockAsProof ? STOP_MOCK_EXCLUDE : STOP_COMPLETION_EXCLUDE,
70
+ system_tag: `rule:${rule.rule_id.slice(0, 8)} — ${mockAsProof ? 'no-mock-as-proof' : 'e2e-before-done'}`,
71
+ });
72
+ reasoning.push(mockAsProof
73
+ ? 'completion + mock keyword → Mech-A Stop+artifact_check (mock trigger)'
74
+ : 'completion keyword → Mech-A Stop+artifact_check (completion trigger)');
75
+ }
76
+ // Mech-B — 문체/응답 관련 또는 strong/hard 정책이지만 기계 판정 어려운 경우
77
+ if ((isStyle || (isStrong && !isDestructive && !isCompletion))) {
78
+ proposed.push({
79
+ mech: 'B',
80
+ hook: 'Stop',
81
+ verifier: {
82
+ kind: 'self_check_prompt',
83
+ params: {
84
+ question: `직전 응답이 다음 규칙을 위반했는지 자가점검하라: "${rule.policy.slice(0, 120)}". 위반 시 구체적 근거와 함께 수정해 재응답하라.`,
85
+ },
86
+ },
87
+ trigger_keywords_regex: STOP_COMPLETION_TRIGGER,
88
+ trigger_exclude_regex: STOP_COMPLETION_EXCLUDE,
89
+ system_tag: `rule:${rule.rule_id.slice(0, 8)} — style-check`,
90
+ });
91
+ reasoning.push(isStyle ? 'style/tone keyword → Mech-B Stop+self_check_prompt' : 'strong/hard strength + non-mechanical → Mech-B Stop+self_check_prompt');
92
+ }
93
+ // 잔여 — drift measure only (Mech-C)
94
+ if (proposed.length === 0) {
95
+ proposed.push({
96
+ mech: 'C',
97
+ hook: 'PostToolUse',
98
+ drift_key: `rule.${rule.rule_id.slice(0, 8)}`,
99
+ });
100
+ reasoning.push('no direct enforcement pattern → Mech-C drift measurement');
101
+ }
102
+ return {
103
+ rule_id: rule.rule_id,
104
+ trigger_preview: rule.trigger.slice(0, 60),
105
+ current_enforce_via: rule.enforce_via ?? null,
106
+ proposed,
107
+ reasoning,
108
+ };
109
+ }
110
+ export function classifyAll(rules) {
111
+ return rules.map(classify);
112
+ }
113
+ /** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
114
+ export function applyProposal(rule, proposal, options = {}) {
115
+ if (rule.enforce_via && rule.enforce_via.length > 0 && !options.force) {
116
+ return rule;
117
+ }
118
+ return {
119
+ ...rule,
120
+ enforce_via: proposal.proposed,
121
+ updated_at: new Date().toISOString(),
122
+ };
123
+ }
@@ -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 * as os from 'node:os';
9
+ import { loadAllRules, saveRule } from '../../store/rule-store.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(os.homedir(), '.forgen', 'state', '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(os.homedir(), '.forgen', 'state', 'enforcement', 'violations.jsonl'));
27
+ const bypass = readJsonlSafe(path.join(os.homedir(), '.forgen', 'state', '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>;