@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
@@ -16,6 +16,21 @@ export declare function createEvidence(params: {
16
16
  raw_payload?: Record<string, unknown>;
17
17
  }): Evidence;
18
18
  export declare function saveEvidence(evidence: Evidence): void;
19
+ /**
20
+ * ADR-002 T1 — explicit_correction evidence 저장 + orchestrator 호출.
21
+ *
22
+ * saveEvidence 와의 차이:
23
+ * - type='explicit_correction' 인 경우 T1 detect 실행 → 매칭된 rule 상태 전이 적용.
24
+ * - orchestrator 호출은 best-effort (실패해도 evidence 저장은 유지).
25
+ * - correction_kind 는 raw_payload.kind 에서 추론 (CorrectionRequest 와 호환).
26
+ *
27
+ * 기존 saveEvidence 를 호출하는 코드는 그대로 둬도 됨 (하위 호환). T1 emission 이 필요한
28
+ * 호출지(correction-record MCP, evidence-processor)만 이 함수로 전환.
29
+ */
30
+ export declare function appendEvidence(evidence: Evidence): {
31
+ saved: true;
32
+ t1_events: number;
33
+ };
19
34
  export declare function loadEvidence(evidenceId: string): Evidence | null;
20
35
  export declare function loadAllEvidence(): Evidence[];
21
36
  export declare function loadEvidenceBySession(sessionId: string): Evidence[];
@@ -10,6 +10,10 @@ import * as crypto from 'node:crypto';
10
10
  import { ME_BEHAVIOR } from '../core/paths.js';
11
11
  import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
12
12
  import { createRule, saveRule, loadActiveRules } from './rule-store.js';
13
+ import { classify, applyProposal } from '../engine/enforce-classifier.js';
14
+ import { detect as detectT1 } from '../engine/lifecycle/trigger-t1-correction.js';
15
+ import { foldEvents } from '../engine/lifecycle/orchestrator.js';
16
+ import { appendLifecycleEvents } from '../engine/lifecycle/meta-reclassifier.js';
13
17
  function evidencePath(evidenceId) {
14
18
  return path.join(ME_BEHAVIOR, `${evidenceId}.json`);
15
19
  }
@@ -27,9 +31,59 @@ export function createEvidence(params) {
27
31
  raw_payload: params.raw_payload ?? {},
28
32
  };
29
33
  }
34
+ /** TEST-4 / RC4: behavior_observation 의 summary 가 의미있는 내용을 담아야 분석 가능. */
35
+ const MIN_BEHAVIOR_OBSERVATION_LEN = 20;
30
36
  export function saveEvidence(evidence) {
37
+ // TEST-4 / RC4: 빈/짧은 behavior_observation 은 저장 거부.
38
+ // 결함: ~/.forgen/me/behavior/*.json 다수에 summary="" 가 누적되어 학습 데이터가
39
+ // 분석 불가능한 형태로 쌓임. saveEvidence 가 마지막 게이트라 여기서 거른다.
40
+ // 다른 evidence type (explicit_correction, session_summary) 은 backward compat.
41
+ if (evidence.type === 'behavior_observation') {
42
+ const len = (evidence.summary ?? '').trim().length;
43
+ if (len < MIN_BEHAVIOR_OBSERVATION_LEN)
44
+ return;
45
+ }
31
46
  atomicWriteJSON(evidencePath(evidence.evidence_id), evidence, { pretty: true });
32
47
  }
48
+ /**
49
+ * ADR-002 T1 — explicit_correction evidence 저장 + orchestrator 호출.
50
+ *
51
+ * saveEvidence 와의 차이:
52
+ * - type='explicit_correction' 인 경우 T1 detect 실행 → 매칭된 rule 상태 전이 적용.
53
+ * - orchestrator 호출은 best-effort (실패해도 evidence 저장은 유지).
54
+ * - correction_kind 는 raw_payload.kind 에서 추론 (CorrectionRequest 와 호환).
55
+ *
56
+ * 기존 saveEvidence 를 호출하는 코드는 그대로 둬도 됨 (하위 호환). T1 emission 이 필요한
57
+ * 호출지(correction-record MCP, evidence-processor)만 이 함수로 전환.
58
+ */
59
+ export function appendEvidence(evidence) {
60
+ saveEvidence(evidence);
61
+ if (evidence.type !== 'explicit_correction')
62
+ return { saved: true, t1_events: 0 };
63
+ try {
64
+ const rawKind = evidence.raw_payload?.kind;
65
+ const correctionKind = rawKind === 'avoid-this' || rawKind === 'fix-now' || rawKind === 'prefer-from-now'
66
+ ? rawKind
67
+ : undefined;
68
+ const rules = loadActiveRules();
69
+ const events = detectT1({ evidence, correction_kind: correctionKind, rules });
70
+ if (events.length === 0)
71
+ return { saved: true, t1_events: 0 };
72
+ const folded = foldEvents(rules, events);
73
+ for (const [ruleId, updated] of folded.entries()) {
74
+ const original = rules.find((r) => r.rule_id === ruleId);
75
+ if (!original || updated === original)
76
+ continue;
77
+ saveRule(updated);
78
+ }
79
+ appendLifecycleEvents(events);
80
+ return { saved: true, t1_events: events.length };
81
+ }
82
+ catch {
83
+ // best-effort: orchestrator 실패는 evidence 저장 자체를 막지 않는다.
84
+ return { saved: true, t1_events: 0 };
85
+ }
86
+ }
33
87
  export function loadEvidence(evidenceId) {
34
88
  return safeReadJSON(evidencePath(evidenceId), null);
35
89
  }
@@ -91,7 +145,7 @@ export function promoteSessionCandidates(sessionId) {
91
145
  const category = axisHint === 'quality_safety' ? 'quality'
92
146
  : axisHint === 'autonomy' ? 'autonomy'
93
147
  : 'workflow';
94
- const rule = createRule({
148
+ let rule = createRule({
95
149
  category,
96
150
  scope: 'me',
97
151
  trigger: target,
@@ -101,6 +155,12 @@ export function promoteSessionCandidates(sessionId) {
101
155
  evidence_refs: [candidate.evidence_id],
102
156
  render_key: renderKey,
103
157
  });
158
+ // ADR-001 auto-classify — 승격되는 rule 에도 enforce_via 자동 주입.
159
+ try {
160
+ const proposal = classify(rule);
161
+ rule = applyProposal(rule, proposal);
162
+ }
163
+ catch { /* fail-open */ }
104
164
  saveRule(rule);
105
165
  existingRenderKeys.add(renderKey);
106
166
  promoted++;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Forgen v0.4.1 — Implicit Feedback Store (TEST-5)
3
+ *
4
+ * `~/.forgen/state/implicit-feedback.jsonl` 의 append/read.
5
+ *
6
+ * TEST-5 / RC5: 누적된 엔트리들이 `type` 문자열만 가지고 category 없이 섞여 있어
7
+ * - drift_critical / drift_warning / revert_detected / repeated_edit / agent_* 가 한 스트림에 섞여
8
+ * - 집계/쿼리 시 카테고리 enum 부재로 휴리스틱 문자열 매칭에 의존
9
+ * - 스키마 검증이 없어 빈/잘못된 필드로 쓰여도 나중에 분석 불가
10
+ * 이 모듈은 category 필드를 **필수화**하고, 기존 레거시 라인은 read 시 `type→category`
11
+ * 백필로 보정한다. 새 write 는 category 없으면 drift/revert 계열은 **거부**한다.
12
+ */
13
+ export declare const IMPLICIT_FEEDBACK_LOG: string;
14
+ /**
15
+ * TEST-5/H4: 카테고리 enum.
16
+ * - drift / revert: 네거티브 signal (schema 강제)
17
+ * - edit / agent: 네거티브-ish signal (휴리스틱)
18
+ * - positive: H4 양수 신호 — assist (recommendation_surfaced, recall_referenced)
19
+ */
20
+ export type ImplicitFeedbackCategory = 'drift' | 'revert' | 'edit' | 'agent' | 'positive';
21
+ export interface ImplicitFeedbackEntry {
22
+ type: string;
23
+ category: ImplicitFeedbackCategory;
24
+ sessionId?: string;
25
+ at: string;
26
+ [key: string]: unknown;
27
+ }
28
+ /** 호출지 입력 — category 선택적 (schema 가 허용하면 inference 로 채움). */
29
+ export interface ImplicitFeedbackInput {
30
+ type: string;
31
+ category?: ImplicitFeedbackCategory;
32
+ sessionId?: string;
33
+ at: string;
34
+ [key: string]: unknown;
35
+ }
36
+ /** type → category 추론. 레거시 엔트리 마이그레이션과 호출지 기본값 계산에 공용. */
37
+ export declare function inferCategoryFromType(type: string): ImplicitFeedbackCategory | null;
38
+ /**
39
+ * TEST-5 메인 라이터. 내부에서 스키마 검증 후 append.
40
+ * drift/revert 스키마 위반 시 silent drop (hot path 에서 throw 금지).
41
+ * 반환값: 실제로 기록되었는지 (테스트 검증용).
42
+ */
43
+ export declare function appendImplicitFeedback(entry: ImplicitFeedbackInput): boolean;
44
+ /**
45
+ * TEST-5 리더. 세션 필터링 + 레거시 라인에 대한 lazy 마이그레이션 (category 백필).
46
+ * 디스크 상 파일은 건드리지 않고 읽기 시점에만 category 를 보정한다 — atomic-write
47
+ * 없이 append-only 로그를 rewrite 하면 race 위험이 있기 때문.
48
+ * 영구 백필은 `migrateImplicitFeedbackLog()` 를 명시적으로 호출한다.
49
+ */
50
+ export declare function loadImplicitFeedback(sessionId: string): ImplicitFeedbackEntry[];
51
+ /**
52
+ * 영구 마이그레이션 — 레거시 로그 파일을 읽어 category 백필 후 원자적으로 재기록.
53
+ * 마이그레이션 불가 라인 (type 도 category 도 없거나 inference 실패) 은 drop.
54
+ * 반환: { migrated: 백필된 라인 수, dropped: 버려진 라인 수 }
55
+ */
56
+ export declare function migrateImplicitFeedbackLog(): {
57
+ migrated: number;
58
+ dropped: number;
59
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Forgen v0.4.1 — Implicit Feedback Store (TEST-5)
3
+ *
4
+ * `~/.forgen/state/implicit-feedback.jsonl` 의 append/read.
5
+ *
6
+ * TEST-5 / RC5: 누적된 엔트리들이 `type` 문자열만 가지고 category 없이 섞여 있어
7
+ * - drift_critical / drift_warning / revert_detected / repeated_edit / agent_* 가 한 스트림에 섞여
8
+ * - 집계/쿼리 시 카테고리 enum 부재로 휴리스틱 문자열 매칭에 의존
9
+ * - 스키마 검증이 없어 빈/잘못된 필드로 쓰여도 나중에 분석 불가
10
+ * 이 모듈은 category 필드를 **필수화**하고, 기존 레거시 라인은 read 시 `type→category`
11
+ * 백필로 보정한다. 새 write 는 category 없으면 drift/revert 계열은 **거부**한다.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { STATE_DIR } from '../core/paths.js';
16
+ export const IMPLICIT_FEEDBACK_LOG = path.join(STATE_DIR, 'implicit-feedback.jsonl');
17
+ /** type → category 추론. 레거시 엔트리 마이그레이션과 호출지 기본값 계산에 공용. */
18
+ export function inferCategoryFromType(type) {
19
+ if (type === 'drift_critical' || type === 'drift_warning')
20
+ return 'drift';
21
+ if (type === 'revert_detected')
22
+ return 'revert';
23
+ if (type === 'repeated_edit')
24
+ return 'edit';
25
+ if (type.startsWith('agent_'))
26
+ return 'agent';
27
+ // H4: 양수 assist 신호 — 솔루션이 사용자에게 노출/참조되었음을 기록.
28
+ if (type === 'recommendation_surfaced' || type === 'recall_referenced')
29
+ return 'positive';
30
+ return null;
31
+ }
32
+ /**
33
+ * TEST-5 스키마 검증 — drift/revert 계열은 category 누락 시 쓰기 거부.
34
+ * agent/edit 은 fail-open (기존 호출지가 빠뜨려도 로깅 자체는 보존), 대신 inference
35
+ * 가 가능하면 자동 보정.
36
+ */
37
+ function validateAndNormalize(entry) {
38
+ if (!entry.type || !entry.at)
39
+ return null;
40
+ const inferred = inferCategoryFromType(entry.type);
41
+ const category = entry.category ?? inferred;
42
+ // drift/revert/positive 는 schema 강제: 명시든 추론이든 올바른 카테고리여야 함.
43
+ if (entry.type === 'drift_critical' || entry.type === 'drift_warning') {
44
+ if (category !== 'drift')
45
+ return null;
46
+ }
47
+ if (entry.type === 'revert_detected') {
48
+ if (category !== 'revert')
49
+ return null;
50
+ }
51
+ if (entry.type === 'recommendation_surfaced' || entry.type === 'recall_referenced') {
52
+ if (category !== 'positive')
53
+ return null;
54
+ }
55
+ if (!category)
56
+ return null;
57
+ return { ...entry, category };
58
+ }
59
+ /**
60
+ * TEST-5 메인 라이터. 내부에서 스키마 검증 후 append.
61
+ * drift/revert 스키마 위반 시 silent drop (hot path 에서 throw 금지).
62
+ * 반환값: 실제로 기록되었는지 (테스트 검증용).
63
+ */
64
+ export function appendImplicitFeedback(entry) {
65
+ const normalized = validateAndNormalize(entry);
66
+ if (!normalized)
67
+ return false;
68
+ try {
69
+ fs.mkdirSync(STATE_DIR, { recursive: true });
70
+ fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, JSON.stringify(normalized) + '\n');
71
+ return true;
72
+ }
73
+ catch {
74
+ // fail-open: implicit feedback recording must not throw.
75
+ return false;
76
+ }
77
+ }
78
+ /**
79
+ * TEST-5 리더. 세션 필터링 + 레거시 라인에 대한 lazy 마이그레이션 (category 백필).
80
+ * 디스크 상 파일은 건드리지 않고 읽기 시점에만 category 를 보정한다 — atomic-write
81
+ * 없이 append-only 로그를 rewrite 하면 race 위험이 있기 때문.
82
+ * 영구 백필은 `migrateImplicitFeedbackLog()` 를 명시적으로 호출한다.
83
+ */
84
+ export function loadImplicitFeedback(sessionId) {
85
+ try {
86
+ if (!fs.existsSync(IMPLICIT_FEEDBACK_LOG))
87
+ return [];
88
+ const lines = fs.readFileSync(IMPLICIT_FEEDBACK_LOG, 'utf-8').split('\n').filter(Boolean);
89
+ const entries = [];
90
+ for (const line of lines) {
91
+ try {
92
+ const raw = JSON.parse(line);
93
+ if (raw.sessionId !== sessionId)
94
+ continue;
95
+ if (!raw.type || !raw.at)
96
+ continue;
97
+ const category = raw.category ?? inferCategoryFromType(raw.type);
98
+ if (!category)
99
+ continue;
100
+ entries.push({ ...raw, category });
101
+ }
102
+ catch {
103
+ /* skip malformed lines */
104
+ }
105
+ }
106
+ return entries;
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ }
112
+ /**
113
+ * 영구 마이그레이션 — 레거시 로그 파일을 읽어 category 백필 후 원자적으로 재기록.
114
+ * 마이그레이션 불가 라인 (type 도 category 도 없거나 inference 실패) 은 drop.
115
+ * 반환: { migrated: 백필된 라인 수, dropped: 버려진 라인 수 }
116
+ */
117
+ export function migrateImplicitFeedbackLog() {
118
+ if (!fs.existsSync(IMPLICIT_FEEDBACK_LOG))
119
+ return { migrated: 0, dropped: 0 };
120
+ const lines = fs.readFileSync(IMPLICIT_FEEDBACK_LOG, 'utf-8').split('\n').filter(Boolean);
121
+ const out = [];
122
+ let migrated = 0;
123
+ let dropped = 0;
124
+ for (const line of lines) {
125
+ try {
126
+ const raw = JSON.parse(line);
127
+ if (!raw.type || !raw.at) {
128
+ dropped++;
129
+ continue;
130
+ }
131
+ if (raw.category) {
132
+ out.push(JSON.stringify(raw));
133
+ continue;
134
+ }
135
+ const inferred = inferCategoryFromType(raw.type);
136
+ if (!inferred) {
137
+ dropped++;
138
+ continue;
139
+ }
140
+ const repaired = { ...raw, category: inferred };
141
+ out.push(JSON.stringify(repaired));
142
+ migrated++;
143
+ }
144
+ catch {
145
+ dropped++;
146
+ }
147
+ }
148
+ // atomic replace via temp file
149
+ const tmp = `${IMPLICIT_FEEDBACK_LOG}.migrate.${process.pid}`;
150
+ fs.writeFileSync(tmp, out.length > 0 ? out.join('\n') + '\n' : '');
151
+ fs.renameSync(tmp, IMPLICIT_FEEDBACK_LOG);
152
+ return { migrated, dropped };
153
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Rule lifecycle factory + helpers — single source of truth for defaults/normalization.
3
+ *
4
+ * R6-F1 (2026-04-22): 이전에는 `rule.lifecycle ?? { phase: 'active', inject_count: 0, ... }`
5
+ * 리터럴이 rule-store, orchestrator, meta-reclassifier 등 5곳에 복제되어 필드 추가 시 동시
6
+ * 수정 필수였다. 한 곳에서 불변식을 걸고 모든 호출자가 이 함수를 통해 lifecycle 을 얻도록 통합.
7
+ *
8
+ * root-cause-analyst (R6) 분석: "Rule 이 data file + state machine 이중 정체성을 가지면서
9
+ * 기본값 재합성이 N 군데에 분산" 이 R4-B2(음수 corruption)/R5-B1(orphan)/R5-B2(mutex)/api-H1
10
+ * 등 버그 클러스터의 공통 뿌리. 이 factory 가 그 뿌리를 차단.
11
+ */
12
+ import type { Rule, LifecycleState, MetaPromotion } from './types.js';
13
+ /** safe non-negative integer normalization — 파일 corruption / 다중 writer race 방어. */
14
+ export declare function safeCount(n: unknown): number;
15
+ /**
16
+ * Rule 에 대해 정규화된 LifecycleState 반환 (pure — rule 을 변경하지 않음).
17
+ * 기존 lifecycle 이 있으면 카운터를 safeCount 로 정규화한 사본, 없으면 초기 상태.
18
+ */
19
+ export declare function initLifecycle(rule: Rule): LifecycleState;
20
+ /** inject count + last_inject_at 을 한 단계 증가 — markRulesInjected 의 공통 로직. */
21
+ export declare function bumpInject(lifecycle: LifecycleState, nowIso: string): LifecycleState;
22
+ /** meta_promotions 에 새 entry append (immutable). */
23
+ export declare function appendMetaPromotion(lifecycle: LifecycleState, promotion: MetaPromotion): LifecycleState;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Rule lifecycle factory + helpers — single source of truth for defaults/normalization.
3
+ *
4
+ * R6-F1 (2026-04-22): 이전에는 `rule.lifecycle ?? { phase: 'active', inject_count: 0, ... }`
5
+ * 리터럴이 rule-store, orchestrator, meta-reclassifier 등 5곳에 복제되어 필드 추가 시 동시
6
+ * 수정 필수였다. 한 곳에서 불변식을 걸고 모든 호출자가 이 함수를 통해 lifecycle 을 얻도록 통합.
7
+ *
8
+ * root-cause-analyst (R6) 분석: "Rule 이 data file + state machine 이중 정체성을 가지면서
9
+ * 기본값 재합성이 N 군데에 분산" 이 R4-B2(음수 corruption)/R5-B1(orphan)/R5-B2(mutex)/api-H1
10
+ * 등 버그 클러스터의 공통 뿌리. 이 factory 가 그 뿌리를 차단.
11
+ */
12
+ /** safe non-negative integer normalization — 파일 corruption / 다중 writer race 방어. */
13
+ export function safeCount(n) {
14
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : 0;
15
+ }
16
+ /**
17
+ * Rule 에 대해 정규화된 LifecycleState 반환 (pure — rule 을 변경하지 않음).
18
+ * 기존 lifecycle 이 있으면 카운터를 safeCount 로 정규화한 사본, 없으면 초기 상태.
19
+ */
20
+ export function initLifecycle(rule) {
21
+ const existing = rule.lifecycle;
22
+ if (existing) {
23
+ return {
24
+ phase: existing.phase,
25
+ first_active_at: existing.first_active_at,
26
+ last_inject_at: existing.last_inject_at,
27
+ last_violation_at: existing.last_violation_at,
28
+ inject_count: safeCount(existing.inject_count),
29
+ accept_count: safeCount(existing.accept_count),
30
+ violation_count: safeCount(existing.violation_count),
31
+ bypass_count: safeCount(existing.bypass_count),
32
+ conflict_refs: Array.isArray(existing.conflict_refs) ? [...existing.conflict_refs] : [],
33
+ merged_into: existing.merged_into,
34
+ superseded_by: existing.superseded_by,
35
+ meta_promotions: Array.isArray(existing.meta_promotions) ? [...existing.meta_promotions] : [],
36
+ };
37
+ }
38
+ return {
39
+ phase: 'active',
40
+ first_active_at: rule.created_at,
41
+ inject_count: 0,
42
+ accept_count: 0,
43
+ violation_count: 0,
44
+ bypass_count: 0,
45
+ conflict_refs: [],
46
+ meta_promotions: [],
47
+ };
48
+ }
49
+ /** inject count + last_inject_at 을 한 단계 증가 — markRulesInjected 의 공통 로직. */
50
+ export function bumpInject(lifecycle, nowIso) {
51
+ return {
52
+ ...lifecycle,
53
+ inject_count: lifecycle.inject_count + 1,
54
+ last_inject_at: nowIso,
55
+ };
56
+ }
57
+ /** meta_promotions 에 새 entry append (immutable). */
58
+ export function appendMetaPromotion(lifecycle, promotion) {
59
+ return {
60
+ ...lifecycle,
61
+ meta_promotions: [...lifecycle.meta_promotions, promotion],
62
+ };
63
+ }
@@ -16,9 +16,30 @@ export declare function createRule(params: {
16
16
  render_key: string;
17
17
  }): Rule;
18
18
  export declare function saveRule(rule: Rule): void;
19
+ /**
20
+ * ADR-002 T5 — rule 저장 + 기존 active rules 와 자연어 충돌 감지 + 양쪽 conflict_refs 기록.
21
+ *
22
+ * saveRule 과의 차이:
23
+ * - 저장 직후 T5 detect 실행 → 충돌 발견 시 신규 rule + 반대편 rule 모두 conflict_refs 업데이트.
24
+ * - auto-merge 안 함 (ADR-002 §Risks — 사용자 수동 해소).
25
+ * - T5 감지 실패는 저장 자체를 막지 않음 (fail-open).
26
+ *
27
+ * 반환: 저장된 rule + 감지된 충돌 rule_id 목록.
28
+ */
29
+ export declare function appendRule(rule: Rule): Promise<{
30
+ saved: true;
31
+ conflicts_with: string[];
32
+ }>;
19
33
  export declare function loadRule(ruleId: string): Rule | null;
20
34
  export declare function loadAllRules(): Rule[];
21
35
  export declare function loadActiveRules(): Rule[];
36
+ /**
37
+ * ADR-002 Meta signal — rule 들이 프롬프트에 inject 되었음을 기록.
38
+ * rule.lifecycle.inject_count +1, last_inject_at = now.
39
+ * lifecycle 없던 rule 은 auto-init 하고 phase='active'.
40
+ * Meta promotion (B→A) 의 rolling window 집계가 이 카운터를 소비한다.
41
+ */
42
+ export declare function markRulesInjected(ruleIds: string[], nowIso?: string): void;
22
43
  export declare function updateRuleStatus(ruleId: string, status: RuleStatus): boolean;
23
44
  /**
24
45
  * 현재 세션 ID와 다른 scope:'session' 규칙을 비활성화.
@@ -9,6 +9,8 @@ import * as path from 'node:path';
9
9
  import * as crypto from 'node:crypto';
10
10
  import { ME_RULES } from '../core/paths.js';
11
11
  import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
12
+ import { CURRENT_RULE_SCHEMA_VERSION } from './types.js';
13
+ import { initLifecycle, bumpInject } from './rule-lifecycle.js';
12
14
  function rulePath(ruleId) {
13
15
  return path.join(ME_RULES, `${ruleId}.json`);
14
16
  }
@@ -31,27 +33,153 @@ export function createRule(params) {
31
33
  }
32
34
  export function saveRule(rule) {
33
35
  rule.updated_at = new Date().toISOString();
36
+ // v0.4.1 audit-trail 불변식: rule 저장 시 lifecycle state 가 null/undefined 이면
37
+ // active phase 기본값 주입. 이전에는 old rule 파일이 lifecycle 없이 존재해 쌤 이후
38
+ // audit trail (phase/violation_count/meta_promotions) 추적 불가 — 이번 세션에서
39
+ // suppressed rule 의 lifecycle=null 발견. initLifecycle 은 기존 값이 있으면 normalize,
40
+ // 없으면 phase='active' + counters=0 초기화.
41
+ if (!rule.lifecycle) {
42
+ rule.lifecycle = initLifecycle(rule);
43
+ }
34
44
  atomicWriteJSON(rulePath(rule.rule_id), rule, { pretty: true });
35
45
  }
46
+ /**
47
+ * ADR-002 T5 — rule 저장 + 기존 active rules 와 자연어 충돌 감지 + 양쪽 conflict_refs 기록.
48
+ *
49
+ * saveRule 과의 차이:
50
+ * - 저장 직후 T5 detect 실행 → 충돌 발견 시 신규 rule + 반대편 rule 모두 conflict_refs 업데이트.
51
+ * - auto-merge 안 함 (ADR-002 §Risks — 사용자 수동 해소).
52
+ * - T5 감지 실패는 저장 자체를 막지 않음 (fail-open).
53
+ *
54
+ * 반환: 저장된 rule + 감지된 충돌 rule_id 목록.
55
+ */
56
+ export async function appendRule(rule) {
57
+ saveRule(rule);
58
+ try {
59
+ const [{ detect: detectT5 }, { appendLifecycleEvents }] = await Promise.all([
60
+ import('../engine/lifecycle/trigger-t5-conflict.js'),
61
+ import('../engine/lifecycle/meta-reclassifier.js'),
62
+ ]);
63
+ const all = loadAllRules();
64
+ const events = detectT5({ rules: all });
65
+ const relevant = events.filter((e) => e.evidence?.refs?.includes(rule.rule_id));
66
+ if (relevant.length === 0)
67
+ return { saved: true, conflicts_with: [] };
68
+ // conflict_refs 양방향 업데이트
69
+ const affected = new Set();
70
+ for (const ev of relevant)
71
+ affected.add(ev.rule_id);
72
+ for (const id of affected) {
73
+ const target = all.find((r) => r.rule_id === id);
74
+ if (!target)
75
+ continue;
76
+ const refs = relevant
77
+ .filter((ev) => ev.rule_id === id)
78
+ .flatMap((ev) => (ev.evidence?.refs ?? []).filter((r) => r !== id));
79
+ const currentConflicts = target.lifecycle?.conflict_refs ?? [];
80
+ const merged = [...new Set([...currentConflicts, ...refs])];
81
+ const lifecycle = target.lifecycle ?? {
82
+ phase: 'active',
83
+ first_active_at: target.created_at,
84
+ inject_count: 0, accept_count: 0, violation_count: 0, bypass_count: 0,
85
+ conflict_refs: [], meta_promotions: [],
86
+ };
87
+ saveRule({ ...target, lifecycle: { ...lifecycle, conflict_refs: merged } });
88
+ }
89
+ appendLifecycleEvents(relevant);
90
+ const conflicts_with = [
91
+ ...new Set(relevant
92
+ .filter((e) => e.rule_id === rule.rule_id)
93
+ .flatMap((e) => (e.evidence?.refs ?? []).filter((r) => r !== rule.rule_id))),
94
+ ];
95
+ return { saved: true, conflicts_with };
96
+ }
97
+ catch {
98
+ return { saved: true, conflicts_with: [] };
99
+ }
100
+ }
36
101
  export function loadRule(ruleId) {
37
102
  return safeReadJSON(rulePath(ruleId), null);
38
103
  }
39
104
  export function loadAllRules() {
40
- if (!fs.existsSync(ME_RULES))
41
- return [];
42
105
  const rules = [];
43
- for (const file of fs.readdirSync(ME_RULES)) {
44
- if (!file.endsWith('.json'))
45
- continue;
46
- const rule = safeReadJSON(path.join(ME_RULES, file), null);
47
- if (rule)
48
- rules.push(rule);
106
+ // 1) 사용자 개인 rules: ~/.forgen/me/rules
107
+ if (fs.existsSync(ME_RULES)) {
108
+ for (const file of fs.readdirSync(ME_RULES)) {
109
+ if (!file.endsWith('.json'))
110
+ continue;
111
+ const rule = safeReadJSON(path.join(ME_RULES, file), null);
112
+ if (rule && isCompatibleSchema(rule, file))
113
+ rules.push(rule);
114
+ }
115
+ }
116
+ // 2) 프로젝트 로컬 rules: <cwd>/.forgen/rules
117
+ // ADR-003 Phase 1 Dogfood — 팀/프로젝트가 git 에 committed 한 L1 정책을 자동 로드.
118
+ // 같은 rule_id 가 me 와 project 양쪽에 있으면 project 가 우선 (git 소스가 정책 진실).
119
+ const projectRulesDir = resolveProjectRulesDir();
120
+ if (projectRulesDir && fs.existsSync(projectRulesDir)) {
121
+ for (const file of fs.readdirSync(projectRulesDir)) {
122
+ if (!file.endsWith('.json'))
123
+ continue;
124
+ const rule = safeReadJSON(path.join(projectRulesDir, file), null);
125
+ if (!rule || !isCompatibleSchema(rule, file))
126
+ continue;
127
+ const existingIdx = rules.findIndex((r) => r.rule_id === rule.rule_id);
128
+ if (existingIdx >= 0)
129
+ rules[existingIdx] = rule; // project override
130
+ else
131
+ rules.push(rule);
132
+ }
49
133
  }
50
134
  return rules;
51
135
  }
136
+ /**
137
+ * R5-B3: schema_version 호환성 체크.
138
+ * - undefined / 0 / CURRENT_RULE_SCHEMA_VERSION: OK
139
+ * - > CURRENT: 상위 버전 → graceful skip (downgrade 시 silent corruption 방지)
140
+ */
141
+ function isCompatibleSchema(rule, filename) {
142
+ const v = rule.schema_version;
143
+ if (v == null || v <= CURRENT_RULE_SCHEMA_VERSION)
144
+ return true;
145
+ if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
146
+ process.stderr.write(`[forgen:rule-store] ${filename} schema_version=${v} > supported ${CURRENT_RULE_SCHEMA_VERSION} — skipped\n`);
147
+ }
148
+ return false;
149
+ }
150
+ /**
151
+ * 현재 프로젝트 cwd 의 `.forgen/rules/` 경로. FORGEN_CWD/COMPOUND_CWD 우선.
152
+ * 테스트 / CI 에서 프로젝트 스코프 로딩을 비활성화하려면 FORGEN_DISABLE_PROJECT_RULES=1.
153
+ */
154
+ function resolveProjectRulesDir() {
155
+ if (process.env.FORGEN_DISABLE_PROJECT_RULES === '1')
156
+ return null;
157
+ const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
158
+ return path.join(cwd, '.forgen', 'rules');
159
+ }
52
160
  export function loadActiveRules() {
53
161
  return loadAllRules().filter(r => r.status === 'active');
54
162
  }
163
+ /**
164
+ * ADR-002 Meta signal — rule 들이 프롬프트에 inject 되었음을 기록.
165
+ * rule.lifecycle.inject_count +1, last_inject_at = now.
166
+ * lifecycle 없던 rule 은 auto-init 하고 phase='active'.
167
+ * Meta promotion (B→A) 의 rolling window 집계가 이 카운터를 소비한다.
168
+ */
169
+ export function markRulesInjected(ruleIds, nowIso = new Date().toISOString()) {
170
+ for (const id of ruleIds) {
171
+ const rule = loadRule(id);
172
+ if (!rule)
173
+ continue;
174
+ // R6-F1: initLifecycle 이 정규화(카운터 ≥0, 배열 보장) 까지 포함.
175
+ const lifecycle = initLifecycle(rule);
176
+ const updated = {
177
+ ...rule,
178
+ lifecycle: bumpInject(lifecycle, nowIso),
179
+ };
180
+ atomicWriteJSON(rulePath(rule.rule_id), updated, { pretty: true });
181
+ }
182
+ }
55
183
  export function updateRuleStatus(ruleId, status) {
56
184
  const rule = loadRule(ruleId);
57
185
  if (!rule)