@wooojin/forgen 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +94 -0
  3. package/README.ja.md +119 -8
  4. package/README.ko.md +73 -2
  5. package/README.md +163 -9
  6. package/README.zh.md +87 -7
  7. package/dist/checks/conclusion-verification-ratio.d.ts +37 -0
  8. package/dist/checks/conclusion-verification-ratio.js +86 -0
  9. package/dist/checks/fact-vs-agreement.d.ts +47 -0
  10. package/dist/checks/fact-vs-agreement.js +92 -0
  11. package/dist/checks/self-score-deflation.d.ts +38 -0
  12. package/dist/checks/self-score-deflation.js +108 -0
  13. package/dist/cli.js +158 -6
  14. package/dist/core/auto-compound-runner.js +85 -13
  15. package/dist/core/dashboard.js +9 -2
  16. package/dist/core/doctor.js +90 -15
  17. package/dist/core/extraction-notice.d.ts +18 -0
  18. package/dist/core/extraction-notice.js +64 -0
  19. package/dist/core/init-cli.d.ts +26 -0
  20. package/dist/core/init-cli.js +104 -0
  21. package/dist/core/init.js +17 -0
  22. package/dist/core/inspect-cli.js +64 -5
  23. package/dist/core/migrate-cli.d.ts +10 -0
  24. package/dist/core/migrate-cli.js +34 -0
  25. package/dist/core/paths.d.ts +8 -1
  26. package/dist/core/paths.js +11 -2
  27. package/dist/core/recall-cli.d.ts +26 -0
  28. package/dist/core/recall-cli.js +125 -0
  29. package/dist/core/recall-reference-detector.d.ts +43 -0
  30. package/dist/core/recall-reference-detector.js +65 -0
  31. package/dist/core/state-gc.d.ts +19 -0
  32. package/dist/core/state-gc.js +48 -4
  33. package/dist/core/stats-cli.d.ts +36 -0
  34. package/dist/core/stats-cli.js +254 -0
  35. package/dist/core/uninstall.d.ts +1 -0
  36. package/dist/core/uninstall.js +25 -1
  37. package/dist/core/v1-bootstrap.js +9 -1
  38. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  39. package/dist/engine/classify-enforce-cli.js +61 -0
  40. package/dist/engine/compound-cli.js +1 -0
  41. package/dist/engine/compound-export.js +8 -3
  42. package/dist/engine/enforce-classifier.d.ts +31 -0
  43. package/dist/engine/enforce-classifier.js +123 -0
  44. package/dist/engine/learn-cli.js +1 -4
  45. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  46. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  47. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  48. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  49. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  50. package/dist/engine/lifecycle/meta-cli.js +7 -0
  51. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  52. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  53. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  54. package/dist/engine/lifecycle/orchestrator.js +131 -0
  55. package/dist/engine/lifecycle/signals.d.ts +30 -0
  56. package/dist/engine/lifecycle/signals.js +142 -0
  57. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  58. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  59. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  60. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  61. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  62. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  63. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  64. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  65. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  66. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  67. package/dist/engine/lifecycle/types.d.ts +52 -0
  68. package/dist/engine/lifecycle/types.js +7 -0
  69. package/dist/engine/meta-learning/session-quality-scorer.d.ts +1 -6
  70. package/dist/engine/meta-learning/session-quality-scorer.js +2 -21
  71. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  72. package/dist/engine/rule-toggle-cli.js +76 -0
  73. package/dist/engine/skill-promoter.js +3 -6
  74. package/dist/forge/evidence-processor.js +10 -2
  75. package/dist/hooks/context-guard.js +72 -1
  76. package/dist/hooks/dangerous-patterns.json +3 -3
  77. package/dist/hooks/db-guard.js +18 -2
  78. package/dist/hooks/intent-classifier.js +1 -1
  79. package/dist/hooks/keyword-detector.js +1 -1
  80. package/dist/hooks/notepad-injector.js +1 -1
  81. package/dist/hooks/permission-handler.js +1 -1
  82. package/dist/hooks/post-tool-failure.js +1 -1
  83. package/dist/hooks/post-tool-use.d.ts +6 -0
  84. package/dist/hooks/post-tool-use.js +94 -14
  85. package/dist/hooks/pre-compact.js +1 -1
  86. package/dist/hooks/pre-tool-use.d.ts +7 -0
  87. package/dist/hooks/pre-tool-use.js +79 -5
  88. package/dist/hooks/rate-limiter.js +1 -1
  89. package/dist/hooks/secret-filter.d.ts +10 -0
  90. package/dist/hooks/secret-filter.js +21 -1
  91. package/dist/hooks/session-recovery.js +1 -1
  92. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  93. package/dist/hooks/shared/atomic-write.js +17 -3
  94. package/dist/hooks/shared/command-parser.d.ts +44 -0
  95. package/dist/hooks/shared/command-parser.js +50 -0
  96. package/dist/hooks/shared/hook-response.d.ts +23 -2
  97. package/dist/hooks/shared/hook-response.js +48 -3
  98. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  99. package/dist/hooks/shared/safe-regex.js +50 -0
  100. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  101. package/dist/hooks/shared/stop-triggers.js +19 -0
  102. package/dist/hooks/skill-injector.js +1 -1
  103. package/dist/hooks/slop-detector.js +2 -2
  104. package/dist/hooks/solution-injector.d.ts +9 -0
  105. package/dist/hooks/solution-injector.js +48 -5
  106. package/dist/hooks/stop-guard.d.ts +84 -0
  107. package/dist/hooks/stop-guard.js +606 -0
  108. package/dist/hooks/subagent-tracker.js +1 -1
  109. package/dist/i18n/index.js +3 -5
  110. package/dist/mcp/tools.js +19 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +61 -1
  113. package/dist/store/implicit-feedback-store.d.ts +59 -0
  114. package/dist/store/implicit-feedback-store.js +153 -0
  115. package/dist/store/rule-lifecycle.d.ts +23 -0
  116. package/dist/store/rule-lifecycle.js +63 -0
  117. package/dist/store/rule-store.d.ts +21 -0
  118. package/dist/store/rule-store.js +136 -8
  119. package/dist/store/types.d.ts +83 -0
  120. package/dist/store/types.js +7 -1
  121. package/hooks/hook-registry.json +1 -0
  122. package/hooks/hooks.json +6 -1
  123. package/package.json +11 -3
  124. package/plugin.json +1 -1
@@ -0,0 +1,36 @@
1
+ export interface StatsSnapshot {
2
+ activeRules: number;
3
+ suppressedRules: number;
4
+ correctionsTotal: number;
5
+ corrections7d: number;
6
+ blocks7d: number;
7
+ acks7d: number;
8
+ bypass7d: number;
9
+ drift7d: number;
10
+ retired7d: number;
11
+ lastExtraction: string;
12
+ /**
13
+ * H3 / v0.4.1 — assist 축 가시화. enforcement(block/violation) 는 이미 표시되지만
14
+ * assist(recall hit, surface, extraction) 는 v0.4.0 에서 8,000+ 번 작동했음에도
15
+ * 사용자에게 0건 노출되었다. 오늘 기준 숫자로 "지금 학습되고 있다" 를 surface.
16
+ */
17
+ assistToday: {
18
+ recallHits: number;
19
+ surfaced: number;
20
+ referenced: number;
21
+ extractedToday: number;
22
+ };
23
+ /**
24
+ * v0.4.1 철학 고도화 지표 — forge-profile.json 이 실제로 학습됐는지 한눈에.
25
+ * 값이 있으면 가시화, 없으면 undefined.
26
+ */
27
+ philosophy?: {
28
+ basePacks: string[];
29
+ trustPolicy: string;
30
+ axisScores: Record<string, number>;
31
+ lastReclassification: string | null;
32
+ };
33
+ }
34
+ export declare function computeStats(): StatsSnapshot;
35
+ export declare function renderStats(s: StatsSnapshot): string;
36
+ export declare function handleStats(_args: string[]): Promise<void>;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * R9-PA1: `forgen stats` — 7-number single-screen dashboard.
3
+ *
4
+ * Pure aggregation over existing jsonl sources. No new telemetry; surfaces
5
+ * what forgen is *already* learning so users can verify the trust layer is
6
+ * working between Claude sessions.
7
+ */
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import { loadAllRules } from '../store/rule-store.js';
11
+ import { loadAllEvidence } from '../store/evidence-store.js';
12
+ import { STATE_DIR, ME_DIR } from './paths.js';
13
+ // v0.4.1 격리 fix: 이전에는 os.homedir() 직접 사용해서 FORGEN_HOME env 로
14
+ // 홈 격리해도 이 파일의 경로는 여전히 실 홈 가리켰음. paths.ts 상수 import.
15
+ const ENFORCEMENT_DIR = path.join(STATE_DIR, 'enforcement');
16
+ const LIFECYCLE_DIR = path.join(STATE_DIR, 'lifecycle');
17
+ const SOLUTIONS_DIR = path.join(ME_DIR, 'solutions');
18
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
19
+ function readJsonl(p) {
20
+ if (!fs.existsSync(p))
21
+ return [];
22
+ const out = [];
23
+ for (const line of fs.readFileSync(p, 'utf-8').split('\n')) {
24
+ if (!line.trim())
25
+ continue;
26
+ try {
27
+ out.push(JSON.parse(line));
28
+ }
29
+ catch { /* skip malformed */ }
30
+ }
31
+ return out;
32
+ }
33
+ function countWithin(entries, days, tsKey = 'at') {
34
+ const cutoff = Date.now() - days * MS_PER_DAY;
35
+ let n = 0;
36
+ for (const e of entries) {
37
+ const raw = e[tsKey];
38
+ if (typeof raw !== 'string')
39
+ continue;
40
+ const t = Date.parse(raw);
41
+ if (Number.isFinite(t) && t >= cutoff)
42
+ n += 1;
43
+ }
44
+ return n;
45
+ }
46
+ function readLifecycleRetired(days) {
47
+ if (!fs.existsSync(LIFECYCLE_DIR))
48
+ return 0;
49
+ const cutoff = Date.now() - days * MS_PER_DAY;
50
+ let n = 0;
51
+ for (const f of fs.readdirSync(LIFECYCLE_DIR)) {
52
+ if (!f.endsWith('.jsonl'))
53
+ continue;
54
+ for (const entry of readJsonl(path.join(LIFECYCLE_DIR, f))) {
55
+ const action = entry.suggested_action;
56
+ const ts = typeof entry.ts === 'number' ? entry.ts : Date.parse(String(entry.ts ?? ''));
57
+ if (!Number.isFinite(ts) || ts < cutoff)
58
+ continue;
59
+ if (action === 'retire' || action === 'supersede')
60
+ n += 1;
61
+ }
62
+ }
63
+ return n;
64
+ }
65
+ function readLastExtraction() {
66
+ // v0.4.1 파일명 정합: auto-compound-runner 는 last-auto-compound.json 에 기록.
67
+ // 이전 코드가 last-extraction.json 을 찾아 "never" 가 만성적으로 표시됨 —
68
+ // 실은 매 auto-compound 세션마다 값이 업데이트되고 있는데도 stats 에 반영 X.
69
+ const candidates = ['last-auto-compound.json', 'last-extraction.json'];
70
+ let p = null;
71
+ for (const name of candidates) {
72
+ const candidate = path.join(STATE_DIR, name);
73
+ if (fs.existsSync(candidate)) {
74
+ p = candidate;
75
+ break;
76
+ }
77
+ }
78
+ if (!p)
79
+ return 'never';
80
+ try {
81
+ const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
82
+ const ts = data.completedAt ?? data.timestamp ?? data.date;
83
+ if (!ts)
84
+ return 'never';
85
+ const diffDays = Math.floor((Date.now() - Date.parse(ts)) / MS_PER_DAY);
86
+ const dateStr = new Date(ts).toISOString().slice(0, 10);
87
+ if (diffDays === 0)
88
+ return `${dateStr} (today)`;
89
+ if (diffDays === 1)
90
+ return `${dateStr} (yesterday)`;
91
+ return `${dateStr} (${diffDays}d ago)`;
92
+ }
93
+ catch {
94
+ return 'unknown';
95
+ }
96
+ }
97
+ /** H3: 오늘 (local midnight ~ now) 기준 assist 카운트. */
98
+ function computeAssistToday() {
99
+ const startOfDay = new Date();
100
+ startOfDay.setHours(0, 0, 0, 0);
101
+ const cutoffMs = startOfDay.getTime();
102
+ // recall hits: match-eval-log 의 오늘 entries
103
+ const matchLog = readJsonl(path.join(STATE_DIR, 'match-eval-log.jsonl'));
104
+ let recallHits = 0;
105
+ for (const e of matchLog) {
106
+ const ts = typeof e.ts === 'string' ? Date.parse(e.ts) : NaN;
107
+ if (Number.isFinite(ts) && ts >= cutoffMs)
108
+ recallHits++;
109
+ }
110
+ // surfaced + referenced — 같은 스트림 1회 loop 로.
111
+ const feedback = readJsonl(path.join(STATE_DIR, 'implicit-feedback.jsonl'));
112
+ let surfaced = 0;
113
+ let referenced = 0;
114
+ for (const e of feedback) {
115
+ const ts = typeof e.at === 'string' ? Date.parse(e.at) : NaN;
116
+ if (!Number.isFinite(ts) || ts < cutoffMs)
117
+ continue;
118
+ if (e.type === 'recommendation_surfaced')
119
+ surfaced++;
120
+ else if (e.type === 'recall_referenced')
121
+ referenced++;
122
+ }
123
+ // extracted today: solutions dir 에서 오늘 mtime 인 .md 파일
124
+ let extractedToday = 0;
125
+ try {
126
+ if (fs.existsSync(SOLUTIONS_DIR)) {
127
+ for (const f of fs.readdirSync(SOLUTIONS_DIR)) {
128
+ if (!f.endsWith('.md'))
129
+ continue;
130
+ const stat = fs.statSync(path.join(SOLUTIONS_DIR, f));
131
+ if (stat.mtimeMs >= cutoffMs)
132
+ extractedToday++;
133
+ }
134
+ }
135
+ }
136
+ catch { /* fail-open */ }
137
+ return { recallHits, surfaced, referenced, extractedToday };
138
+ }
139
+ /** v0.4.1: forge-profile 에서 고도화 지표 추출. 파일 없거나 깨지면 undefined. */
140
+ function computePhilosophy() {
141
+ try {
142
+ const profilePath = path.join(ME_DIR, 'forge-profile.json');
143
+ if (!fs.existsSync(profilePath))
144
+ return undefined;
145
+ const d = JSON.parse(fs.readFileSync(profilePath, 'utf-8'));
146
+ const axisScores = {};
147
+ for (const [k, v] of Object.entries(d.axes ?? {})) {
148
+ if (v && typeof v.score === 'number')
149
+ axisScores[k] = v.score;
150
+ }
151
+ return {
152
+ basePacks: Object.values(d.base_packs ?? {}),
153
+ trustPolicy: d.trust_preferences?.desired_policy ?? 'unknown',
154
+ axisScores,
155
+ lastReclassification: d.metadata?.last_reclassification_at ?? null,
156
+ };
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ }
162
+ export function computeStats() {
163
+ const rules = loadAllRules();
164
+ const activeRules = rules.filter((r) => r.status === 'active').length;
165
+ const suppressedRules = rules.filter((r) => r.status === 'suppressed').length;
166
+ // v0.4.1 정확도 수정: loadRecentEvidence(500) 제한은 "Corrections (total)"
167
+ // 라벨과 모순 — 실 behavior 618건 중 118건 누락됐음. 전체 evidence 스캔으로
168
+ // 교체. explicit_correction 만 filter 이므로 memory overhead 는 N * ~1KB 수준.
169
+ const evidence = loadAllEvidence();
170
+ const corrections = evidence.filter((e) => e.type === 'explicit_correction');
171
+ const correctionsTotal = corrections.length;
172
+ const cutoff7d = Date.now() - 7 * MS_PER_DAY;
173
+ const corrections7d = corrections.filter((e) => Date.parse(e.timestamp) >= cutoff7d).length;
174
+ const violations = readJsonl(path.join(ENFORCEMENT_DIR, 'violations.jsonl'));
175
+ // v0.4.1 historical false-positive 제거: pre-0.4.1 bypass-detector 가 Write/Edit
176
+ // content 의 quote 본문까지 raw 매칭해서 bypass 로 오기록. 실 관찰: L1-no-rm-rf
177
+ // -unconfirmed bypass 20건 중 Write/Edit 15건. stats 표시는 **실 실행 맥락** 인
178
+ // Bash/Agent/기타만 집계 — 앞으로의 시계열 일관성 + 과거 noise 제거.
179
+ const bypassRaw = readJsonl(path.join(ENFORCEMENT_DIR, 'bypass.jsonl'));
180
+ const bypass = bypassRaw.filter((e) => e.tool !== 'Write' && e.tool !== 'Edit');
181
+ const drift = readJsonl(path.join(ENFORCEMENT_DIR, 'drift.jsonl'));
182
+ const acks = readJsonl(path.join(ENFORCEMENT_DIR, 'acknowledgments.jsonl'));
183
+ // R9-PA2: violations 는 'block' (stop-guard/post-tool) + 'deny' (pre-tool Mech-A)
184
+ // + 'correction' (user bypass audit) 혼재. 사용자 관점에서 "Block" 은 앞의 2종이며
185
+ // correction 은 제외해야 ack ratio 가 의미를 갖는다. legacy-undefined 엔트리도 포함.
186
+ const realBlocks = violations.filter((e) => e.kind === 'block' || e.kind === 'deny' || e.kind === undefined);
187
+ return {
188
+ activeRules,
189
+ suppressedRules,
190
+ correctionsTotal,
191
+ corrections7d,
192
+ blocks7d: countWithin(realBlocks, 7),
193
+ acks7d: countWithin(acks, 7),
194
+ bypass7d: countWithin(bypass, 7),
195
+ drift7d: countWithin(drift, 7),
196
+ retired7d: readLifecycleRetired(7),
197
+ lastExtraction: readLastExtraction(),
198
+ assistToday: computeAssistToday(),
199
+ philosophy: computePhilosophy(),
200
+ };
201
+ }
202
+ function padNum(n, width = 4) {
203
+ return String(n).padStart(width);
204
+ }
205
+ export function renderStats(s) {
206
+ const lines = [];
207
+ lines.push('');
208
+ lines.push(' forgen — trust layer status');
209
+ lines.push(' ───────────────────────────');
210
+ lines.push(` Active rules ${padNum(s.activeRules)} (${s.suppressedRules} suppressed)`);
211
+ lines.push(` Corrections (total) ${padNum(s.correctionsTotal)} (+${s.corrections7d} last 7d)`);
212
+ lines.push('');
213
+ lines.push(' Last 7 days');
214
+ // R9-PA2: ack rate = block→retract→pass 루프가 실제 작동한 비율.
215
+ const ackRateLabel = s.blocks7d > 0
216
+ ? `(${Math.round((s.acks7d / s.blocks7d) * 100)}% acknowledged)`
217
+ : '';
218
+ lines.push(` Blocks ${padNum(s.blocks7d)} — times Claude was asked to retract ${ackRateLabel}`);
219
+ lines.push(` Acknowledgments ${padNum(s.acks7d)} — block → retract → pass loops`);
220
+ lines.push(` Bypass ${padNum(s.bypass7d)} — user overrides`);
221
+ lines.push(` Drift events ${padNum(s.drift7d)} — stuck-loop force-approves`);
222
+ lines.push(` Retired rules ${padNum(s.retired7d)} — superseded or timed out`);
223
+ lines.push('');
224
+ // H3: Assist 축 — enforcement 옆에 나란히 가시화.
225
+ lines.push(' Today (assist)');
226
+ lines.push(` Recall hits ${padNum(s.assistToday.recallHits)} — compound 매칭 시도 수`);
227
+ lines.push(` Surfaced ${padNum(s.assistToday.surfaced)} — 실제 주입된 솔루션 수`);
228
+ const ratio = s.assistToday.surfaced > 0
229
+ ? ` (${Math.round(100 * s.assistToday.referenced / s.assistToday.surfaced)}% referenced)`
230
+ : '';
231
+ lines.push(` Referenced ${padNum(s.assistToday.referenced)} — Claude 응답에 인용됨${ratio}`);
232
+ lines.push(` Extracted ${padNum(s.assistToday.extractedToday)} — 오늘 새로 저장된 패턴`);
233
+ lines.push('');
234
+ // v0.4.1 철학 고도화 단면 — "개인화가 어디까지 학습됐나" 1섹션.
235
+ if (s.philosophy) {
236
+ lines.push(' Philosophy (learned)');
237
+ lines.push(` Base packs ${s.philosophy.basePacks.join(' / ')}`);
238
+ lines.push(` Trust policy ${s.philosophy.trustPolicy}`);
239
+ const scores = Object.entries(s.philosophy.axisScores)
240
+ .map(([k, v]) => `${k}:${v.toFixed(2)}`)
241
+ .join(' ');
242
+ if (scores)
243
+ lines.push(` Axis scores ${scores}`);
244
+ lines.push(` Last reclass ${s.philosophy.lastReclassification ?? 'never'}`);
245
+ lines.push('');
246
+ }
247
+ lines.push(` Last extraction: ${s.lastExtraction}`);
248
+ lines.push('');
249
+ return lines.join('\n');
250
+ }
251
+ export async function handleStats(_args) {
252
+ const snap = computeStats();
253
+ console.log(renderStats(snap));
254
+ }
@@ -1,4 +1,5 @@
1
1
  /** forgen uninstall 메인 */
2
2
  export declare function handleUninstall(cwd: string, options: {
3
3
  force?: boolean;
4
+ purge?: boolean;
4
5
  }): Promise<void>;
@@ -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,21 @@ export async function handleUninstall(cwd, options) {
311
319
  cleanClaudeMd(cwd);
312
320
  cleanSlashCommands();
313
321
  cleanPluginArtifacts();
322
+ if (options.purge) {
323
+ try {
324
+ const { FORGEN_HOME } = await import('./paths.js');
325
+ const forgenHome = FORGEN_HOME;
326
+ if (fs.existsSync(forgenHome)) {
327
+ fs.rmSync(forgenHome, { recursive: true, force: true });
328
+ console.log(' ✓ Deleted ~/.forgen/ (all rules, state, solutions, behavior)');
329
+ }
330
+ else {
331
+ console.log(' ✓ ~/.forgen/ already absent');
332
+ }
333
+ }
334
+ catch (e) {
335
+ console.log(` ✗ ~/.forgen/ deletion failed: ${e.message}`);
336
+ }
337
+ }
314
338
  console.log('\n[forgen] Uninstall complete. Restart Claude Code for a clean state.\n');
315
339
  }
@@ -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
+ }
@@ -65,6 +65,7 @@ export function listSolutions() {
65
65
  }
66
66
  const order = ['mature', 'verified', 'candidate', 'experiment', 'retired'];
67
67
  console.log('\n Compound Entries\n');
68
+ console.log(' (inj: 누적 주입, ref: 명시 참조, neg: 부정 피드백 — ref 측정은 v0.4.1+ 부터 시작됨)\n');
68
69
  let total = 0;
69
70
  for (const status of order) {
70
71
  const group = groups[status];
@@ -33,14 +33,19 @@ export function isPathInside(parentWithSep, candidate) {
33
33
  return resolved.startsWith(parentWithSep) && resolved !== parentWithSep;
34
34
  }
35
35
  /**
36
- * Count .md files in a directory (non-recursive).
37
- * Returns 0 if the directory does not exist.
36
+ * Count knowledge files in a directory (non-recursive). `.md` (solutions) + `.json`
37
+ * (rules, behavior, evidence) 포함.
38
+ *
39
+ * v0.4.1 (2026-04-24): 이전에는 `.md` 만 세서 rules/*.json 전부가 count=0, export
40
+ * 대상에서도 제외됐다. 실증: `forgen compound export` 결과 rules 0, behavior 18
41
+ * (618건의 .json 누락). 사용자 철학의 **핵심**인 rules 가 하네스에서 빠진 상태
42
+ * 라 "나와 같은 데이터 하네스" 비전이 반쪽이었다.
38
43
  */
39
44
  function countFiles(dir) {
40
45
  try {
41
46
  if (!fs.existsSync(dir))
42
47
  return 0;
43
- return fs.readdirSync(dir).filter(f => f.endsWith('.md')).length;
48
+ return fs.readdirSync(dir).filter(f => f.endsWith('.md') || f.endsWith('.json')).length;
44
49
  }
45
50
  catch {
46
51
  return 0;
@@ -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
+ }
@@ -1,14 +1,11 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import * as os from 'node:os';
4
3
  import { fixupSolutions } from './solution-fixup.js';
5
4
  import { listQuarantined, pruneQuarantine } from './solution-quarantine.js';
6
5
  import { computeFitness } from './solution-fitness.js';
7
6
  import { buildWeaknessReport, saveWeaknessReport } from './solution-weakness.js';
8
7
  import { listCandidates, promoteCandidate, rollbackSince } from './solution-candidate.js';
9
- const ME_SOLUTIONS = path.join(os.homedir(), '.forgen', 'me', 'solutions');
10
- const STATE_DIR = path.join(os.homedir(), '.forgen', 'state');
11
- const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
8
+ import { ME_SOLUTIONS, OUTCOMES_DIR } from '../core/paths.js';
12
9
  export async function handleLearn(args) {
13
10
  const sub = args[0];
14
11
  if (sub === 'fix-up')