@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
package/dist/cli.js CHANGED
@@ -169,13 +169,141 @@ const commands = [
169
169
  // 수동 재설치: node scripts/postinstall.js
170
170
  {
171
171
  name: 'uninstall',
172
- description: 'Remove forgen from settings [--force]',
172
+ description: 'Remove forgen from settings [--force] [--purge (also deletes ~/.forgen/)]',
173
173
  handler: async (args) => {
174
174
  const { handleUninstall } = await import('./core/uninstall.js');
175
- await handleUninstall(process.cwd(), { force: args.includes('--force') });
175
+ await handleUninstall(process.cwd(), {
176
+ force: args.includes('--force'),
177
+ purge: args.includes('--purge'),
178
+ });
179
+ },
180
+ },
181
+ {
182
+ name: 'rule',
183
+ description: 'Rule management (list|suppress|activate|scan|health-scan|classify)',
184
+ handler: async (args) => {
185
+ await handleRuleNamespace(args);
186
+ },
187
+ },
188
+ {
189
+ name: 'classify-enforce',
190
+ aliases: ['rule-classify'],
191
+ description: '[alias: rule classify] Propose enforce_via for rules (ADR-001 migration).',
192
+ handler: async (args) => {
193
+ const { handleClassifyEnforce } = await import('./engine/classify-enforce-cli.js');
194
+ await handleClassifyEnforce(args);
195
+ },
196
+ },
197
+ {
198
+ name: 'rule-meta-scan',
199
+ description: '[alias: rule health-scan] Scan drift for stuck-loop events and demote Mech-A rules.',
200
+ handler: async (args) => {
201
+ const { handleRuleMetaScan } = await import('./engine/lifecycle/meta-cli.js');
202
+ await handleRuleMetaScan(args);
203
+ },
204
+ },
205
+ {
206
+ name: 'lifecycle-scan',
207
+ description: '[alias: rule scan] Run all rule lifecycle triggers (T1~T5 + Meta).',
208
+ handler: async (args) => {
209
+ const { handleLifecycleScan } = await import('./engine/lifecycle/lifecycle-cli.js');
210
+ await handleLifecycleScan(args);
211
+ },
212
+ },
213
+ {
214
+ name: 'stats',
215
+ description: 'One-screen dashboard: active rules, corrections, blocks/bypass/drift (7d).',
216
+ handler: async (args) => {
217
+ const { handleStats } = await import('./core/stats-cli.js');
218
+ await handleStats(args);
219
+ },
220
+ },
221
+ {
222
+ name: 'last-block',
223
+ description: 'Show the most recent Mech-A/B block event with rule detail (R6-UX2).',
224
+ handler: async (_args) => {
225
+ const { handleInspect } = await import('./core/inspect-cli.js');
226
+ await handleInspect(['violations', '--last', '1']);
227
+ },
228
+ },
229
+ {
230
+ name: 'suppress-rule',
231
+ description: '[alias: rule suppress] Disable a rule by id/prefix. Hard rules refused.',
232
+ handler: async (args) => {
233
+ const { handleSuppressRule } = await import('./engine/rule-toggle-cli.js');
234
+ await handleSuppressRule(args);
235
+ },
236
+ },
237
+ {
238
+ name: 'activate-rule',
239
+ description: '[alias: rule activate] Re-activate a suppressed rule by id/prefix.',
240
+ handler: async (args) => {
241
+ const { handleActivateRule } = await import('./engine/rule-toggle-cli.js');
242
+ await handleActivateRule(args);
176
243
  },
177
244
  },
178
245
  ];
246
+ // ---------------------------------------------------------------------------
247
+ // `forgen rule <subcommand>` — user-facing namespace (R9-IA1)
248
+ // Thin dispatcher that routes to existing handlers. Top-level legacy commands
249
+ // (suppress-rule, activate-rule, lifecycle-scan, rule-meta-scan, classify-enforce)
250
+ // remain as backward-compatible aliases.
251
+ // ---------------------------------------------------------------------------
252
+ async function handleRuleNamespace(args) {
253
+ const sub = args[0];
254
+ const rest = args.slice(1);
255
+ if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
256
+ console.log(`
257
+ forgen rule — manage personalization rules
258
+
259
+ Usage:
260
+ forgen rule list List all rules (alias: inspect rules)
261
+ forgen rule suppress <id-or-prefix> Disable a rule (hard rules refused)
262
+ forgen rule activate <id-or-prefix> Re-activate a suppressed rule
263
+ forgen rule scan [--apply] Run lifecycle triggers (promote/demote/retire)
264
+ forgen rule health-scan [--apply] Scan drift → Mech downgrade candidates
265
+ forgen rule classify [--apply] [--force]
266
+ Propose enforce_via for legacy rules
267
+ `);
268
+ return;
269
+ }
270
+ switch (sub) {
271
+ case 'list': {
272
+ const { handleInspect } = await import('./core/inspect-cli.js');
273
+ await handleInspect(['rules', ...rest]);
274
+ return;
275
+ }
276
+ case 'suppress': {
277
+ const { handleSuppressRule } = await import('./engine/rule-toggle-cli.js');
278
+ await handleSuppressRule(rest);
279
+ return;
280
+ }
281
+ case 'activate': {
282
+ const { handleActivateRule } = await import('./engine/rule-toggle-cli.js');
283
+ await handleActivateRule(rest);
284
+ return;
285
+ }
286
+ case 'scan': {
287
+ const { handleLifecycleScan } = await import('./engine/lifecycle/lifecycle-cli.js');
288
+ await handleLifecycleScan(rest);
289
+ return;
290
+ }
291
+ case 'health-scan': {
292
+ const { handleRuleMetaScan } = await import('./engine/lifecycle/meta-cli.js');
293
+ await handleRuleMetaScan(rest);
294
+ return;
295
+ }
296
+ case 'classify': {
297
+ const { handleClassifyEnforce } = await import('./engine/classify-enforce-cli.js');
298
+ await handleClassifyEnforce(rest);
299
+ return;
300
+ }
301
+ default: {
302
+ console.error(`[forgen] Unknown rule subcommand: ${sub}\n Run "forgen rule help" for options.`);
303
+ process.exit(1);
304
+ }
305
+ }
306
+ }
179
307
  /** 최소 편집 거리 (유사 명령 제안용) */
180
308
  function levenshtein(a, b) {
181
309
  const m = a.length, n = b.length;
@@ -298,8 +426,12 @@ function printHelp() {
298
426
  Commands:
299
427
  forgen forge Personalize your coding profile
300
428
  forgen onboarding Run 2-question onboarding
301
- forgen inspect [profile|rules|evidence|session]
302
- Inspect v1 state
429
+ forgen inspect [profile|rules|corrections|session]
430
+ Inspect v1 state (alias: evidence → corrections)
431
+ forgen rule <list|suppress|activate|scan|health-scan|classify>
432
+ Rule management (see: forgen rule help)
433
+ forgen stats One-screen trust-layer dashboard
434
+ forgen last-block Show the most recent block event
303
435
  forgen compound Manage accumulated knowledge
304
436
  forgen dashboard Compound system dashboard
305
437
  forgen me Personal dashboard
@@ -308,7 +440,7 @@ function printHelp() {
308
440
  forgen mcp MCP server management
309
441
  forgen skill promote|list Skill management
310
442
  forgen notepad show|add|clear Session notepad
311
- forgen doctor System diagnostics
443
+ forgen doctor [--prune-state] System diagnostics (+ daily T4 decay on prune)
312
444
  forgen uninstall Remove forgen
313
445
 
314
446
  Harness mode (default):
@@ -14,6 +14,7 @@ import * as path from 'node:path';
14
14
  import * as os from 'node:os';
15
15
  import { execFileSync } from 'node:child_process';
16
16
  import { containsPromptInjection, filterSolutionContent } from '../hooks/prompt-injection-filter.js';
17
+ import { redactSecrets } from '../hooks/secret-filter.js';
17
18
  import { createEvidence, saveEvidence, promoteSessionCandidates } from '../store/evidence-store.js';
18
19
  import { loadProfile } from '../store/profile-store.js';
19
20
  /** Auto-compound에 사용할 모델 — background 추출이므로 haiku로 충분 */
@@ -212,9 +213,16 @@ function mergeOrCreateBehavior(dir, newContent, kind, today) {
212
213
  return false;
213
214
  }
214
215
  try {
215
- const summary = extractSummary(transcriptPath);
216
- if (summary.length < 200)
216
+ const rawSummary = extractSummary(transcriptPath);
217
+ if (rawSummary.length < 200)
217
218
  process.exit(0);
219
+ // R5-G2 (P0 security): transcript 를 Claude 로 송신하기 전 API key / 토큰 / 비밀번호 /
220
+ // private key blocks 를 [REDACTED:...] 로 치환. 사용자가 채팅에 pasted 한 자격증명이
221
+ // auto-compound 를 통해 외부 API 로 누출되는 채널 차단.
222
+ const { redacted: summary, hits: secretHits } = redactSecrets(rawSummary);
223
+ if (secretHits.length > 0) {
224
+ process.stderr.write(`[forgen-auto-compound] redacted ${secretHits.length} secret(s) before send: ${secretHits.map((s) => s.name).join(', ')}\n`);
225
+ }
218
226
  // 보안: 프롬프트 인젝션이 포함된 transcript는 분석하지 않음
219
227
  if (containsPromptInjection(summary)) {
220
228
  process.exit(0);
@@ -2,15 +2,24 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
- import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_PHILOSOPHY, ME_SOLUTIONS, ME_RULES, ME_SKILLS, PACKS_DIR, SESSIONS_DIR, STATE_DIR } from './paths.js';
5
+ import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_SOLUTIONS, ME_RULES, ME_SKILLS, PACKS_DIR, SESSIONS_DIR, STATE_DIR } from './paths.js';
6
6
  import { getTimingStats } from '../hooks/shared/hook-timing.js';
7
7
  import { countSessionScopedFiles, pruneState } from './state-gc.js';
8
8
  /** ~/.claude/projects/ — Claude Code 세션 저장 경로 */
9
9
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
10
+ let currentSection = '';
11
+ let failedChecks = [];
12
+ function section(name) {
13
+ currentSection = name;
14
+ console.log(` [${name}]`);
15
+ }
10
16
  function check(label, condition, hint) {
11
17
  const icon = condition ? '✓' : '✗';
12
18
  const hintStr = !condition && hint ? ` — ${hint}` : '';
13
19
  console.log(` ${icon} ${label}${hintStr}`);
20
+ if (!condition) {
21
+ failedChecks.push({ section: currentSection, label, hint });
22
+ }
14
23
  }
15
24
  function exists(p) {
16
25
  return fs.existsSync(p);
@@ -26,14 +35,15 @@ function commandExists(cmd) {
26
35
  }
27
36
  }
28
37
  export async function runDoctor(opts = {}) {
38
+ failedChecks = [];
29
39
  console.log('\n Forgen — Diagnostics\n');
30
- console.log(' [Tools]');
40
+ section('Tools');
31
41
  check('claude CLI', commandExists('claude'));
32
42
  check('tmux', commandExists('tmux'));
33
43
  check('git', commandExists('git'));
34
44
  check('gh (GitHub CLI)', commandExists('gh'), 'Required for team PR features: brew install gh');
35
45
  console.log();
36
- console.log(' [Plugins]');
46
+ section('Plugins');
37
47
  const ralphLoopInstalled = exists(path.join(os.homedir(), '.claude', 'plugins', 'cache', 'claude-plugins-official', 'ralph-loop'));
38
48
  check('ralph-loop plugin', ralphLoopInstalled, 'Required for ralph mode auto-iteration. Install: claude plugins install ralph-loop');
39
49
  // forgen 플러그인 캐시 디렉토리 확인 — 훅 실행의 필수 전제
@@ -68,7 +78,7 @@ export async function runDoctor(opts = {}) {
68
78
  }
69
79
  check('forgen plugin registered & installPath exists', pluginRegistered, 'Plugin registered but installPath missing on disk. Fix: npm run build && node scripts/postinstall.js');
70
80
  console.log();
71
- console.log(' [Directories]');
81
+ section('Directories');
72
82
  check('~/.forgen/', exists(FORGEN_HOME));
73
83
  check('~/.forgen/me/', exists(ME_DIR));
74
84
  check('~/.forgen/me/solutions/', exists(ME_SOLUTIONS));
@@ -76,13 +86,24 @@ export async function runDoctor(opts = {}) {
76
86
  check('~/.forgen/me/rules/', exists(ME_RULES));
77
87
  check('~/.forgen/packs/', exists(PACKS_DIR));
78
88
  check('~/.forgen/sessions/', exists(SESSIONS_DIR));
89
+ // R9-IA5: warn if a user dropped rule files at ~/.forgen/rules/ by mistake.
90
+ // That path is NOT loaded — personal rules live at ~/.forgen/me/rules/.
91
+ const legacyRulesPath = path.join(FORGEN_HOME, 'rules');
92
+ if (exists(legacyRulesPath) && legacyRulesPath !== ME_RULES) {
93
+ try {
94
+ const files = fs.readdirSync(legacyRulesPath).filter((f) => f.endsWith('.json'));
95
+ if (files.length > 0) {
96
+ check(`~/.forgen/rules/ (${files.length} orphan file(s))`, false, `This path is NOT loaded. Move files to ~/.forgen/me/rules/ or delete them.`);
97
+ }
98
+ }
99
+ catch {
100
+ // permission / symlink issue — diagnostics must not crash
101
+ }
102
+ }
79
103
  console.log();
80
- console.log(' [Philosophy]');
81
- check('philosophy.json', exists(ME_PHILOSOPHY));
82
- console.log();
83
- console.log(' [Environment]');
84
- check('Inside tmux session', !!process.env.TMUX);
85
- check('FORGEN_HARNESS env var', (process.env.FORGEN_HARNESS ?? process.env.COMPOUND_HARNESS) === '1');
104
+ section('Environment');
105
+ check('Inside tmux session', !!process.env.TMUX, 'FORGEN auto-compound relies on tmux. Launch: tmux new -s forgen');
106
+ check('FORGEN_HARNESS env var', (process.env.FORGEN_HARNESS ?? process.env.COMPOUND_HARNESS) === '1', 'Set by `forgen` / `fgx` launcher. Hooks assume harness mode is active.');
86
107
  console.log();
87
108
  // 솔루션/규칙 수
88
109
  if (exists(ME_SOLUTIONS)) {
@@ -323,6 +344,15 @@ export async function runDoctor(opts = {}) {
323
344
  const report = pruneState({ dryRun: false });
324
345
  const mb = (report.bytesFreed / 1024 / 1024).toFixed(2);
325
346
  console.log(` → Pruned ${report.pruned}/${report.scanned} files (${mb} MB freed, >${report.retentionDays}d old)`);
347
+ // ADR-002 T4 — 90d 미주입 rule retire. pruneState 와 함께 "하루 한번 정돈" 의미 공유.
348
+ try {
349
+ const { runDailyT4Decay } = await import('./state-gc.js');
350
+ const t4 = await runDailyT4Decay({ dryRun: false });
351
+ if (t4.retired > 0) {
352
+ console.log(` → Retired ${t4.retired} rule(s) (T4 time-decay): ${t4.sample.join(', ')}`);
353
+ }
354
+ }
355
+ catch { /* fail-open */ }
326
356
  }
327
357
  console.log();
328
358
  // 현재 디렉토리 git 정보
@@ -336,4 +366,28 @@ export async function runDoctor(opts = {}) {
336
366
  console.log(' git remote: (none)');
337
367
  }
338
368
  console.log();
369
+ // [Summary] — 최종 상태 요약과 복구 액션을 한눈에 보이게
370
+ console.log(' [Summary]');
371
+ if (failedChecks.length === 0) {
372
+ console.log(' ✓ All diagnostics passed. Forgen is ready.');
373
+ }
374
+ else {
375
+ console.log(` ✗ ${failedChecks.length} check(s) failed:\n`);
376
+ const bySection = new Map();
377
+ for (const f of failedChecks) {
378
+ if (!bySection.has(f.section))
379
+ bySection.set(f.section, []);
380
+ bySection.get(f.section).push(f);
381
+ }
382
+ for (const [sec, items] of bySection) {
383
+ console.log(` [${sec}]`);
384
+ for (const item of items) {
385
+ console.log(` • ${item.label}`);
386
+ if (item.hint)
387
+ console.log(` → ${item.hint}`);
388
+ }
389
+ }
390
+ console.log('\n Run `forgen doctor` again after applying the fixes above.');
391
+ }
392
+ console.log();
339
393
  }
@@ -5,6 +5,7 @@
5
5
  * Authoritative: docs/plans/2026-04-03-forgen-rule-renderer-spec.md §6
6
6
  */
7
7
  import * as fs from 'node:fs';
8
+ import * as os from 'node:os';
8
9
  import * as path from 'node:path';
9
10
  import { loadProfile } from '../store/profile-store.js';
10
11
  import { loadAllRules, loadActiveRules } from '../store/rule-store.js';
@@ -78,7 +79,8 @@ export async function handleInspect(args) {
78
79
  console.log('\n' + inspect.renderRules(rules) + '\n');
79
80
  return;
80
81
  }
81
- if (sub === 'evidence') {
82
+ // R9-IA2: user-facing name is "corrections"; "evidence" kept as back-compat alias.
83
+ if (sub === 'corrections' || sub === 'evidence') {
82
84
  const evidence = loadRecentEvidence(20);
83
85
  console.log('\n' + inspect.renderEvidence(evidence) + '\n');
84
86
  return;
@@ -92,9 +94,67 @@ export async function handleInspect(args) {
92
94
  console.log('\n' + inspect.renderSession(sessions[0]) + '\n');
93
95
  return;
94
96
  }
97
+ // R5-G1: 2AM 디버깅용 jsonl tail — violations/bypass/drift
98
+ if (sub === 'violations' || sub === 'bypass' || sub === 'drift') {
99
+ const limit = Number(args[args.indexOf('--last') + 1]) || 20;
100
+ const fileMap = {
101
+ violations: 'violations.jsonl',
102
+ bypass: 'bypass.jsonl',
103
+ drift: 'drift.jsonl',
104
+ };
105
+ const p = path.join(os.homedir(), '.forgen', 'state', 'enforcement', fileMap[sub]);
106
+ if (!fs.existsSync(p)) {
107
+ console.log(`\n No ${sub} data (${p} not found).\n`);
108
+ return;
109
+ }
110
+ const lines = fs.readFileSync(p, 'utf-8').trim().split('\n').filter(Boolean);
111
+ const tail = lines.slice(-limit);
112
+ console.log(`\n ${sub} (last ${tail.length} of ${lines.length}):`);
113
+ // rule_id별 집계
114
+ const byRule = new Map();
115
+ for (const line of lines) {
116
+ try {
117
+ const entry = JSON.parse(line);
118
+ const rid = entry.rule_id ?? 'unknown';
119
+ byRule.set(rid, (byRule.get(rid) ?? 0) + 1);
120
+ }
121
+ catch { /* skip malformed */ }
122
+ }
123
+ if (byRule.size > 0) {
124
+ console.log(' Aggregate (rule_id → count):');
125
+ for (const [rid, count] of [...byRule.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)) {
126
+ console.log(` ${rid.slice(0, 24).padEnd(24)} ${count}`);
127
+ }
128
+ }
129
+ // R7-U3: rule_id 전체 표시 + kind + source 분리 + resolve hint footer.
130
+ console.log('\n Recent (time — rule_id — kind@source — preview):');
131
+ for (const line of tail) {
132
+ try {
133
+ const e = JSON.parse(line);
134
+ const when = (e.at ?? '').slice(0, 19);
135
+ const rid = (e.rule_id ?? '-').slice(0, 24); // 8자→24자 (prefix match 가능 길이)
136
+ const kind = (e.kind ?? '-');
137
+ const source = (e.source ?? '-');
138
+ const preview = (e.message_preview ?? e.reason_preview ?? e.pattern_preview ?? '').slice(0, 60);
139
+ console.log(` ${when} ${rid.padEnd(24)} ${String(kind).padEnd(10)}@${String(source).padEnd(14)} ${preview}`);
140
+ }
141
+ catch { /* skip */ }
142
+ }
143
+ console.log('');
144
+ // R7-U3 footer: resolve hint
145
+ console.log(' Resolve:');
146
+ console.log(' Disable a rule: forgen suppress-rule <rule_id>');
147
+ console.log(' Re-enable: forgen activate-rule <rule_id>');
148
+ console.log(' Temp bypass turn: set FORGEN_USER_CONFIRMED=1 (audited)');
149
+ console.log('');
150
+ return;
151
+ }
95
152
  console.log(` Usage:
96
- forgen inspect profile — 현재 profile 상태
97
- forgen inspect rules — active/suppressed 규칙 목록
98
- forgen inspect evidence — 최근 evidence 목록
99
- forgen inspect session — 현재/최근 세션 상태`);
153
+ forgen inspect profile — 현재 profile 상태
154
+ forgen inspect rules — active/suppressed 규칙 목록
155
+ forgen inspect corrections — 최근 corrections / behavior 기록 (alias: evidence)
156
+ forgen inspect session — 현재/최근 세션 상태
157
+ forgen inspect violations [--last N] — 최근 block 기록
158
+ forgen inspect bypass [--last N] — 사용자 우회 기록
159
+ forgen inspect drift [--last N] — stuck-loop force-approve 기록`);
100
160
  }
@@ -23,6 +23,25 @@ export interface PruneOptions {
23
23
  * deletion via `dryRun: false`.
24
24
  */
25
25
  export declare function pruneState(opts?: PruneOptions): PruneReport;
26
+ /**
27
+ * ADR-002 T4 — daily rule decay scanner.
28
+ *
29
+ * `~/.forgen/me/rules` 전체를 훑어 `last_inject_at < now - decay_days` 인 active rule 을
30
+ * retire phase 로 전이시킨다. 실제 파일 삭제가 아니라 status='removed' + phase='retired'.
31
+ *
32
+ * 호출 지점: `forgen doctor --prune-state` 또는 `forgen lifecycle-scan --apply` 그리고
33
+ * 별도 cron/CI scheduler 에서도 호출 가능. dryRun=true 기본.
34
+ */
35
+ export declare function runDailyT4Decay(opts?: {
36
+ decayDays?: number;
37
+ dryRun?: boolean;
38
+ now?: number;
39
+ }): Promise<{
40
+ scanned: number;
41
+ retired: number;
42
+ sample: string[];
43
+ dryRun: boolean;
44
+ }>;
26
45
  /**
27
46
  * Count session-scoped files in STATE_DIR without deleting. Used by doctor
28
47
  * to surface a warning when the directory is bloated.
@@ -94,15 +94,59 @@ export function pruneState(opts = {}) {
94
94
  // outcomes/*.jsonl: one file per session, session-scoped by design.
95
95
  // These compound over time exactly like state session files.
96
96
  const outcomes = pruneDir(outcomesDir, cutoff, dryRun, (n) => n.endsWith('.jsonl'));
97
+ // ADR-002 block-count directory — session-scoped per rule. F-M block-count GC.
98
+ const blockCountDir = path.join(stateDir, 'enforcement', 'block-count');
99
+ const blockCounters = pruneDir(blockCountDir, cutoff, dryRun, (n) => n.endsWith('.json'));
97
100
  return {
98
- scanned: state.scanned + outcomes.scanned,
99
- pruned: state.pruned + outcomes.pruned,
100
- bytesFreed: state.bytes + outcomes.bytes,
101
+ scanned: state.scanned + outcomes.scanned + blockCounters.scanned,
102
+ pruned: state.pruned + outcomes.pruned + blockCounters.pruned,
103
+ bytesFreed: state.bytes + outcomes.bytes + blockCounters.bytes,
101
104
  retentionDays: Math.round(retentionMs / (24 * 60 * 60 * 1000)),
102
105
  dryRun,
103
- sample: [...state.sample, ...outcomes.sample].slice(0, 20),
106
+ sample: [...state.sample, ...outcomes.sample, ...blockCounters.sample].slice(0, 20),
104
107
  };
105
108
  }
109
+ /**
110
+ * ADR-002 T4 — daily rule decay scanner.
111
+ *
112
+ * `~/.forgen/me/rules` 전체를 훑어 `last_inject_at < now - decay_days` 인 active rule 을
113
+ * retire phase 로 전이시킨다. 실제 파일 삭제가 아니라 status='removed' + phase='retired'.
114
+ *
115
+ * 호출 지점: `forgen doctor --prune-state` 또는 `forgen lifecycle-scan --apply` 그리고
116
+ * 별도 cron/CI scheduler 에서도 호출 가능. dryRun=true 기본.
117
+ */
118
+ export async function runDailyT4Decay(opts = {}) {
119
+ const decayDays = opts.decayDays ?? 90;
120
+ const dryRun = opts.dryRun ?? true;
121
+ const now = opts.now ?? Date.now();
122
+ try {
123
+ const [{ loadAllRules, saveRule }, { detect: detectT4 }, { collectAllSignals }, { appendLifecycleEvents }, { foldEvents }] = await Promise.all([
124
+ import('../store/rule-store.js'),
125
+ import('../engine/lifecycle/trigger-t4-decay.js'),
126
+ import('../engine/lifecycle/signals.js'),
127
+ import('../engine/lifecycle/meta-reclassifier.js'),
128
+ import('../engine/lifecycle/orchestrator.js'),
129
+ ]);
130
+ const rules = loadAllRules();
131
+ const signals = collectAllSignals(rules, { now });
132
+ const events = detectT4({ rules, signals, decay_days: decayDays, ts: now });
133
+ const report = { scanned: rules.length, retired: events.length, sample: events.map((e) => e.rule_id.slice(0, 8)), dryRun };
134
+ if (!dryRun && events.length > 0) {
135
+ const folded = foldEvents(rules, events, now);
136
+ for (const [id, updated] of folded.entries()) {
137
+ const original = rules.find((r) => r.rule_id === id);
138
+ if (!original || updated === original)
139
+ continue;
140
+ saveRule(updated);
141
+ }
142
+ appendLifecycleEvents(events, now);
143
+ }
144
+ return report;
145
+ }
146
+ catch {
147
+ return { scanned: 0, retired: 0, sample: [], dryRun };
148
+ }
149
+ }
106
150
  /**
107
151
  * Count session-scoped files in STATE_DIR without deleting. Used by doctor
108
152
  * to surface a warning when the directory is bloated.
@@ -0,0 +1,15 @@
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
+ export declare function computeStats(): StatsSnapshot;
14
+ export declare function renderStats(s: StatsSnapshot): string;
15
+ export declare function handleStats(_args: string[]): Promise<void>;
@@ -0,0 +1,143 @@
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 os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import { loadAllRules } from '../store/rule-store.js';
12
+ import { loadRecentEvidence } from '../store/evidence-store.js';
13
+ const ENFORCEMENT_DIR = path.join(os.homedir(), '.forgen', 'state', 'enforcement');
14
+ const LIFECYCLE_DIR = path.join(os.homedir(), '.forgen', 'state', 'lifecycle');
15
+ const STATE_DIR = path.join(os.homedir(), '.forgen', 'state');
16
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
17
+ function readJsonl(p) {
18
+ if (!fs.existsSync(p))
19
+ return [];
20
+ const out = [];
21
+ for (const line of fs.readFileSync(p, 'utf-8').split('\n')) {
22
+ if (!line.trim())
23
+ continue;
24
+ try {
25
+ out.push(JSON.parse(line));
26
+ }
27
+ catch { /* skip malformed */ }
28
+ }
29
+ return out;
30
+ }
31
+ function countWithin(entries, days, tsKey = 'at') {
32
+ const cutoff = Date.now() - days * MS_PER_DAY;
33
+ let n = 0;
34
+ for (const e of entries) {
35
+ const raw = e[tsKey];
36
+ if (typeof raw !== 'string')
37
+ continue;
38
+ const t = Date.parse(raw);
39
+ if (Number.isFinite(t) && t >= cutoff)
40
+ n += 1;
41
+ }
42
+ return n;
43
+ }
44
+ function readLifecycleRetired(days) {
45
+ if (!fs.existsSync(LIFECYCLE_DIR))
46
+ return 0;
47
+ const cutoff = Date.now() - days * MS_PER_DAY;
48
+ let n = 0;
49
+ for (const f of fs.readdirSync(LIFECYCLE_DIR)) {
50
+ if (!f.endsWith('.jsonl'))
51
+ continue;
52
+ for (const entry of readJsonl(path.join(LIFECYCLE_DIR, f))) {
53
+ const action = entry.suggested_action;
54
+ const ts = typeof entry.ts === 'number' ? entry.ts : Date.parse(String(entry.ts ?? ''));
55
+ if (!Number.isFinite(ts) || ts < cutoff)
56
+ continue;
57
+ if (action === 'retire' || action === 'supersede')
58
+ n += 1;
59
+ }
60
+ }
61
+ return n;
62
+ }
63
+ function readLastExtraction() {
64
+ const p = path.join(STATE_DIR, 'last-extraction.json');
65
+ if (!fs.existsSync(p))
66
+ return 'never';
67
+ try {
68
+ const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
69
+ const ts = data.timestamp ?? data.date;
70
+ if (!ts)
71
+ return 'never';
72
+ const diffDays = Math.floor((Date.now() - Date.parse(ts)) / MS_PER_DAY);
73
+ const dateStr = new Date(ts).toISOString().slice(0, 10);
74
+ if (diffDays === 0)
75
+ return `${dateStr} (today)`;
76
+ if (diffDays === 1)
77
+ return `${dateStr} (yesterday)`;
78
+ return `${dateStr} (${diffDays}d ago)`;
79
+ }
80
+ catch {
81
+ return 'unknown';
82
+ }
83
+ }
84
+ export function computeStats() {
85
+ const rules = loadAllRules();
86
+ const activeRules = rules.filter((r) => r.status === 'active').length;
87
+ const suppressedRules = rules.filter((r) => r.status === 'suppressed').length;
88
+ const evidence = loadRecentEvidence(500);
89
+ const corrections = evidence.filter((e) => e.type === 'explicit_correction');
90
+ const correctionsTotal = corrections.length;
91
+ const cutoff7d = Date.now() - 7 * MS_PER_DAY;
92
+ const corrections7d = corrections.filter((e) => Date.parse(e.timestamp) >= cutoff7d).length;
93
+ const violations = readJsonl(path.join(ENFORCEMENT_DIR, 'violations.jsonl'));
94
+ const bypass = readJsonl(path.join(ENFORCEMENT_DIR, 'bypass.jsonl'));
95
+ const drift = readJsonl(path.join(ENFORCEMENT_DIR, 'drift.jsonl'));
96
+ const acks = readJsonl(path.join(ENFORCEMENT_DIR, 'acknowledgments.jsonl'));
97
+ // R9-PA2: violations 는 'block' (stop-guard/post-tool) + 'deny' (pre-tool Mech-A)
98
+ // + 'correction' (user bypass audit) 혼재. 사용자 관점에서 "Block" 은 앞의 2종이며
99
+ // correction 은 제외해야 ack ratio 가 의미를 갖는다. legacy-undefined 엔트리도 포함.
100
+ const realBlocks = violations.filter((e) => e.kind === 'block' || e.kind === 'deny' || e.kind === undefined);
101
+ return {
102
+ activeRules,
103
+ suppressedRules,
104
+ correctionsTotal,
105
+ corrections7d,
106
+ blocks7d: countWithin(realBlocks, 7),
107
+ acks7d: countWithin(acks, 7),
108
+ bypass7d: countWithin(bypass, 7),
109
+ drift7d: countWithin(drift, 7),
110
+ retired7d: readLifecycleRetired(7),
111
+ lastExtraction: readLastExtraction(),
112
+ };
113
+ }
114
+ function padNum(n, width = 4) {
115
+ return String(n).padStart(width);
116
+ }
117
+ export function renderStats(s) {
118
+ const lines = [];
119
+ lines.push('');
120
+ lines.push(' forgen — trust layer status');
121
+ lines.push(' ───────────────────────────');
122
+ lines.push(` Active rules ${padNum(s.activeRules)} (${s.suppressedRules} suppressed)`);
123
+ lines.push(` Corrections (total) ${padNum(s.correctionsTotal)} (+${s.corrections7d} last 7d)`);
124
+ lines.push('');
125
+ lines.push(' Last 7 days');
126
+ // R9-PA2: ack rate = block→retract→pass 루프가 실제 작동한 비율.
127
+ const ackRateLabel = s.blocks7d > 0
128
+ ? `(${Math.round((s.acks7d / s.blocks7d) * 100)}% acknowledged)`
129
+ : '';
130
+ lines.push(` Blocks ${padNum(s.blocks7d)} — times Claude was asked to retract ${ackRateLabel}`);
131
+ lines.push(` Acknowledgments ${padNum(s.acks7d)} — block → retract → pass loops`);
132
+ lines.push(` Bypass ${padNum(s.bypass7d)} — user overrides`);
133
+ lines.push(` Drift events ${padNum(s.drift7d)} — stuck-loop force-approves`);
134
+ lines.push(` Retired rules ${padNum(s.retired7d)} — superseded or timed out`);
135
+ lines.push('');
136
+ lines.push(` Last extraction: ${s.lastExtraction}`);
137
+ lines.push('');
138
+ return lines.join('\n');
139
+ }
140
+ export async function handleStats(_args) {
141
+ const snap = computeStats();
142
+ console.log(renderStats(snap));
143
+ }
@@ -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>;