@wooojin/forgen 0.3.1 → 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 (125) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +164 -0
  3. package/README.ja.md +90 -7
  4. package/README.ko.md +44 -1
  5. package/README.md +128 -9
  6. package/README.zh.md +90 -7
  7. package/dist/cli.js +140 -8
  8. package/dist/core/auto-compound-runner.js +16 -5
  9. package/dist/core/dashboard.js +11 -4
  10. package/dist/core/doctor.d.ts +6 -1
  11. package/dist/core/doctor.js +85 -11
  12. package/dist/core/global-config.d.ts +2 -2
  13. package/dist/core/global-config.js +6 -14
  14. package/dist/core/harness.d.ts +3 -5
  15. package/dist/core/harness.js +34 -338
  16. package/dist/core/inspect-cli.js +65 -5
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +0 -34
  20. package/dist/core/paths.js +0 -35
  21. package/dist/core/settings-injector.d.ts +13 -0
  22. package/dist/core/settings-injector.js +167 -0
  23. package/dist/core/settings-lock.d.ts +35 -2
  24. package/dist/core/settings-lock.js +65 -7
  25. package/dist/core/spawn.js +100 -39
  26. package/dist/core/state-gc.d.ts +49 -0
  27. package/dist/core/state-gc.js +163 -0
  28. package/dist/core/stats-cli.d.ts +15 -0
  29. package/dist/core/stats-cli.js +143 -0
  30. package/dist/core/uninstall.d.ts +1 -0
  31. package/dist/core/uninstall.js +36 -5
  32. package/dist/core/v1-bootstrap.js +11 -3
  33. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  34. package/dist/engine/classify-enforce-cli.js +61 -0
  35. package/dist/engine/compound-cli.d.ts +27 -2
  36. package/dist/engine/compound-cli.js +69 -16
  37. package/dist/engine/compound-export.d.ts +15 -0
  38. package/dist/engine/compound-export.js +32 -5
  39. package/dist/engine/compound-loop.js +3 -2
  40. package/dist/engine/enforce-classifier.d.ts +31 -0
  41. package/dist/engine/enforce-classifier.js +123 -0
  42. package/dist/engine/learn-cli.js +52 -0
  43. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  44. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  45. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  46. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  47. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  48. package/dist/engine/lifecycle/meta-cli.js +7 -0
  49. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  50. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  51. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  52. package/dist/engine/lifecycle/orchestrator.js +131 -0
  53. package/dist/engine/lifecycle/signals.d.ts +30 -0
  54. package/dist/engine/lifecycle/signals.js +142 -0
  55. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  56. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  57. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  58. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  59. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  60. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  61. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  62. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  63. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  64. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  65. package/dist/engine/lifecycle/types.d.ts +52 -0
  66. package/dist/engine/lifecycle/types.js +7 -0
  67. package/dist/engine/match-eval-log.js +45 -0
  68. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  69. package/dist/engine/rule-toggle-cli.js +76 -0
  70. package/dist/engine/solution-format.d.ts +0 -2
  71. package/dist/engine/solution-format.js +0 -4
  72. package/dist/engine/solution-matcher.d.ts +8 -0
  73. package/dist/engine/solution-matcher.js +7 -4
  74. package/dist/engine/solution-outcomes.d.ts +4 -0
  75. package/dist/engine/solution-outcomes.js +174 -97
  76. package/dist/engine/solution-writer.d.ts +8 -5
  77. package/dist/engine/solution-writer.js +43 -19
  78. package/dist/fgx.js +9 -2
  79. package/dist/forge/cli.js +7 -7
  80. package/dist/forge/evidence-processor.js +10 -2
  81. package/dist/hooks/context-guard.js +86 -1
  82. package/dist/hooks/hook-config.d.ts +9 -1
  83. package/dist/hooks/hook-config.js +25 -3
  84. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  85. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  86. package/dist/hooks/notepad-injector.js +6 -3
  87. package/dist/hooks/permission-handler.d.ts +10 -2
  88. package/dist/hooks/permission-handler.js +31 -12
  89. package/dist/hooks/post-tool-use.js +62 -0
  90. package/dist/hooks/pre-tool-use.js +67 -5
  91. package/dist/hooks/secret-filter.d.ts +10 -0
  92. package/dist/hooks/secret-filter.js +26 -0
  93. package/dist/hooks/session-recovery.js +15 -7
  94. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  95. package/dist/hooks/shared/atomic-write.js +17 -3
  96. package/dist/hooks/shared/hook-response.d.ts +11 -2
  97. package/dist/hooks/shared/hook-response.js +20 -7
  98. package/dist/hooks/shared/hook-timing.js +10 -1
  99. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  100. package/dist/hooks/shared/safe-regex.js +50 -0
  101. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  102. package/dist/hooks/shared/stop-triggers.js +19 -0
  103. package/dist/hooks/solution-injector.d.ts +21 -0
  104. package/dist/hooks/solution-injector.js +60 -1
  105. package/dist/hooks/stop-guard.d.ts +84 -0
  106. package/dist/hooks/stop-guard.js +482 -0
  107. package/dist/mcp/solution-reader.d.ts +2 -0
  108. package/dist/mcp/solution-reader.js +28 -1
  109. package/dist/mcp/tools.js +24 -4
  110. package/dist/preset/preset-manager.js +12 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +55 -6
  113. package/dist/store/profile-store.d.ts +9 -0
  114. package/dist/store/profile-store.js +25 -4
  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 +133 -13
  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 +10 -2
  124. package/plugin.json +7 -2
  125. package/scripts/postinstall.js +52 -5
@@ -15,21 +15,96 @@ function findClaude() {
15
15
  function findRuntimeLauncher(runtime) {
16
16
  return runtime === 'codex' ? 'codex' : findClaude();
17
17
  }
18
- /**
19
- * 가장 최근 transcript 파일을 찾는다.
20
- * Claude Code는 세션 대화를 ~/.claude/projects/{sanitized-cwd}/{uuid}.jsonl에 저장.
21
- */
22
- function findLatestTranscript(cwd) {
18
+ function transcriptProjectDir(cwd) {
23
19
  // Claude Code는 cwd의 /를 -로 치환하고 선행 -를 유지
24
20
  const sanitized = cwd.replace(/\//g, '-');
25
- const projectDir = path.join(os.homedir(), '.claude', 'projects', sanitized);
26
- if (!fs.existsSync(projectDir))
21
+ return path.join(os.homedir(), '.claude', 'projects', sanitized);
22
+ }
23
+ /** 스냅샷용 — 세션 시작 전 존재하는 transcript basename 집합. */
24
+ function snapshotExistingTranscripts(cwd) {
25
+ const dir = transcriptProjectDir(cwd);
26
+ if (!fs.existsSync(dir))
27
+ return new Set();
28
+ try {
29
+ return new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')));
30
+ }
31
+ catch {
32
+ return new Set();
33
+ }
34
+ }
35
+ /**
36
+ * 세션 시작 후 새로 생성된 transcript 파일을 고른다.
37
+ *
38
+ * Audit fix #8 (2026-04-21): 이전 findLatestTranscript는 mtime 최신 파일을
39
+ * 선택했기에, 같은 cwd에서 동시에 두 세션이 돌면 더 늦게 시작된 세션의
40
+ * transcript가 두 세션의 exit 핸들러 모두에서 선택되어 transcript
41
+ * attribution이 섞였다. 이제는
42
+ * 1) 세션 시작 시점의 "이미 존재하던" 파일 스냅샷을 preSnapshot으로 전달받고
43
+ * 2) exit 시점에 스냅샷에 없던 새 파일만 후보로 보고
44
+ * 3) mtime이 세션 시작 시각 이후인 것 중 최신을 선택한다.
45
+ * 여전히 후보가 여러 개이면 (rare: 훅이 추가 파일을 쓴 경우) 가장 최근 수정본
46
+ * 을 고르되 debug 로그를 남긴다.
47
+ */
48
+ function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
49
+ const dir = transcriptProjectDir(cwd);
50
+ if (!fs.existsSync(dir))
27
51
  return null;
28
- const jsonlFiles = fs.readdirSync(projectDir)
29
- .filter(f => f.endsWith('.jsonl'))
30
- .map(f => ({ name: f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
31
- .sort((a, b) => b.mtime - a.mtime);
32
- return jsonlFiles.length > 0 ? path.join(projectDir, jsonlFiles[0].name) : null;
52
+ let candidates;
53
+ try {
54
+ candidates = fs
55
+ .readdirSync(dir)
56
+ .filter((f) => f.endsWith('.jsonl') && !preSnapshot.has(f))
57
+ .map((f) => {
58
+ try {
59
+ return { name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ })
65
+ .filter((x) => x !== null && x.mtime >= sessionStartMs);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ if (candidates.length === 0)
71
+ return null;
72
+ candidates.sort((a, b) => b.mtime - a.mtime);
73
+ if (candidates.length > 1) {
74
+ log.debug(`multiple new transcripts after session start — picking ${candidates[0].name} ` +
75
+ `(others: ${candidates.slice(1).map((c) => c.name).join(', ')})`);
76
+ }
77
+ return path.join(dir, candidates[0].name);
78
+ }
79
+ /**
80
+ * 사용자 메시지 수 카운트 (streaming).
81
+ *
82
+ * Audit fix #8 (2026-04-21): 이전에는 `fs.readFileSync(transcript, 'utf-8')`로
83
+ * 파일 전체를 메모리에 올렸다. 수백 MB 규모 transcript에서는 heap spike가
84
+ * 발생했고, 카운트 외엔 내용이 필요 없으니 streaming line-by-line로 충분하다.
85
+ */
86
+ async function countUserMessages(transcriptPath) {
87
+ const { createInterface } = await import('node:readline');
88
+ const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
89
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
90
+ let count = 0;
91
+ try {
92
+ for await (const line of rl) {
93
+ if (!line)
94
+ continue;
95
+ try {
96
+ const t = JSON.parse(line).type;
97
+ if (t === 'user' || t === 'queue-operation')
98
+ count++;
99
+ }
100
+ catch { /* skip malformed */ }
101
+ }
102
+ }
103
+ finally {
104
+ rl.close();
105
+ stream.close();
106
+ }
107
+ return count;
33
108
  }
34
109
  /**
35
110
  * 세션 종료 후 자동 compound 추출 + USER.md 업데이트.
@@ -74,8 +149,10 @@ export async function spawnClaude(args, context, runtime = 'claude') {
74
149
  !cleanArgs.includes('--dangerously-skip-permissions')) {
75
150
  cleanArgs.unshift('--dangerously-skip-permissions');
76
151
  }
77
- // 세션 시작 전 timestamp 기록 (종료 후 transcript 찾기 위해)
152
+ // 세션 시작 전 timestamp + 기존 transcript 스냅샷 기록 (종료 후 finder ).
153
+ // Audit fix #8 (2026-04-21): 스냅샷으로 동시 세션 transcript 오선택을 차단.
78
154
  const sessionStartTime = Date.now();
155
+ const preSnapshot = snapshotExistingTranscripts(context.cwd);
79
156
  return new Promise((resolve, reject) => {
80
157
  const child = spawn(launcher, cleanArgs, {
81
158
  stdio: 'inherit',
@@ -102,37 +179,21 @@ export async function spawnClaude(args, context, runtime = 'claude') {
102
179
  }
103
180
  // 세션 종료 후 하네스 작업
104
181
  try {
105
- const transcript = findLatestTranscript(context.cwd);
182
+ const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot);
106
183
  if (!transcript) {
107
- log.debug('transcript 파일을 찾을 수 없음');
184
+ log.debug('이 세션에서 생성된 transcript 찾을 수 없음 (snapshot diff)');
108
185
  }
109
186
  else {
110
- const stat = fs.statSync(transcript);
111
- // 세션에서 생성/수정된 transcript만
112
- if (stat.mtimeMs <= sessionStartTime) {
113
- log.debug(`transcript mtime(${stat.mtimeMs}) <= sessionStart(${sessionStartTime}), 건너뜀`);
187
+ const sessionId = path.basename(transcript, '.jsonl');
188
+ // 1. FTS5 인덱싱
189
+ await indexTranscriptToFTS(context.cwd, transcript, sessionId);
190
+ // 2. 자동 compound (10+ user 메시지인 경우만) — streaming line count
191
+ const userMsgCount = await countUserMessages(transcript);
192
+ if (userMsgCount >= 10) {
193
+ await runAutoCompound(context.cwd, transcript, sessionId);
114
194
  }
115
195
  else {
116
- const sessionId = path.basename(transcript, '.jsonl');
117
- // 1. FTS5 인덱싱
118
- await indexTranscriptToFTS(context.cwd, transcript, sessionId);
119
- // 2. 자동 compound (10+ user 메시지인 경우만)
120
- const content = fs.readFileSync(transcript, 'utf-8');
121
- const userMsgCount = content.split('\n')
122
- .filter(l => { try {
123
- const t = JSON.parse(l).type;
124
- return t === 'user' || t === 'queue-operation';
125
- }
126
- catch {
127
- return false;
128
- } })
129
- .length;
130
- if (userMsgCount >= 10) {
131
- await runAutoCompound(context.cwd, transcript, sessionId);
132
- }
133
- else {
134
- console.log(`[forgen] 세션이 짧아 auto-compound 생략 (${userMsgCount} messages)`);
135
- }
196
+ console.log(`[forgen] 세션이 짧아 auto-compound 생략 (${userMsgCount} messages)`);
136
197
  }
137
198
  }
138
199
  }
@@ -0,0 +1,49 @@
1
+ export interface PruneReport {
2
+ scanned: number;
3
+ pruned: number;
4
+ bytesFreed: number;
5
+ retentionDays: number;
6
+ dryRun: boolean;
7
+ /** First 20 pruned file basenames for user confirmation */
8
+ sample: string[];
9
+ }
10
+ export interface PruneOptions {
11
+ retentionMs?: number;
12
+ dryRun?: boolean;
13
+ /** Override the state directory. Used by tests. */
14
+ stateDir?: string;
15
+ /** Override the outcomes directory. Used by tests. */
16
+ outcomesDir?: string;
17
+ /** Current time for deterministic tests. Defaults to Date.now(). */
18
+ now?: number;
19
+ }
20
+ /**
21
+ * Prune session-scoped files older than `retentionMs` from the state and
22
+ * outcomes directories. Defaults to a dry-run so callers must opt-in to
23
+ * deletion via `dryRun: false`.
24
+ */
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
+ }>;
45
+ /**
46
+ * Count session-scoped files in STATE_DIR without deleting. Used by doctor
47
+ * to surface a warning when the directory is bloated.
48
+ */
49
+ export declare function countSessionScopedFiles(stateDir?: string): number;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * State directory garbage collector.
3
+ *
4
+ * `~/.forgen/state/` accumulates per-session files that are never cleaned
5
+ * up (injection-cache, active-agents, checkpoint, modified-files,
6
+ * outcome-pending, permissions, skill-trigger, tool-state, etc.). A field
7
+ * audit on 2026-04-21 found one installation with 10,802 files in a single
8
+ * flat directory — SessionStart hook scans linearly on each session, and
9
+ * `ls` / `rsync` / backup tools all pay the cost.
10
+ *
11
+ * This module scans session-scoped files by filename prefix and prunes
12
+ * those older than a configurable retention window (default 7 days). The
13
+ * jsonl aggregate logs (hook-errors.jsonl, hook-timing.jsonl,
14
+ * implicit-feedback.jsonl, match-eval-log.jsonl, solution-quarantine.jsonl)
15
+ * are left alone — they are tracked append-only and handled by #5
16
+ * (log rotation).
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { STATE_DIR, OUTCOMES_DIR } from './paths.js';
21
+ /** Filename prefixes that identify session-scoped ephemeral files. */
22
+ const SESSION_SCOPED_PREFIXES = [
23
+ 'active-agents-',
24
+ 'checkpoint-',
25
+ 'injection-cache-',
26
+ 'modified-files-',
27
+ 'outcome-pending-',
28
+ 'permissions-',
29
+ 'skill-trigger-',
30
+ 'tool-state-',
31
+ 'reminder-',
32
+ 'context-',
33
+ 'last-',
34
+ ];
35
+ const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
36
+ function hasSessionPrefix(name) {
37
+ return SESSION_SCOPED_PREFIXES.some((pfx) => name.startsWith(pfx));
38
+ }
39
+ function pruneDir(dir, cutoff, dryRun, filter) {
40
+ const out = { scanned: 0, pruned: 0, bytes: 0, sample: [] };
41
+ if (!fs.existsSync(dir))
42
+ return out;
43
+ let entries;
44
+ try {
45
+ entries = fs.readdirSync(dir);
46
+ }
47
+ catch {
48
+ return out;
49
+ }
50
+ for (const name of entries) {
51
+ if (!filter(name))
52
+ continue;
53
+ const full = path.join(dir, name);
54
+ let stat;
55
+ try {
56
+ stat = fs.statSync(full);
57
+ }
58
+ catch {
59
+ continue;
60
+ }
61
+ if (!stat.isFile())
62
+ continue;
63
+ out.scanned++;
64
+ if (stat.mtimeMs >= cutoff)
65
+ continue;
66
+ if (!dryRun) {
67
+ try {
68
+ fs.unlinkSync(full);
69
+ }
70
+ catch {
71
+ continue;
72
+ }
73
+ }
74
+ out.pruned++;
75
+ out.bytes += stat.size;
76
+ if (out.sample.length < 20)
77
+ out.sample.push(name);
78
+ }
79
+ return out;
80
+ }
81
+ /**
82
+ * Prune session-scoped files older than `retentionMs` from the state and
83
+ * outcomes directories. Defaults to a dry-run so callers must opt-in to
84
+ * deletion via `dryRun: false`.
85
+ */
86
+ export function pruneState(opts = {}) {
87
+ const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
88
+ const dryRun = opts.dryRun ?? true;
89
+ const stateDir = opts.stateDir ?? STATE_DIR;
90
+ const outcomesDir = opts.outcomesDir ?? OUTCOMES_DIR;
91
+ const now = opts.now ?? Date.now();
92
+ const cutoff = now - retentionMs;
93
+ const state = pruneDir(stateDir, cutoff, dryRun, hasSessionPrefix);
94
+ // outcomes/*.jsonl: one file per session, session-scoped by design.
95
+ // These compound over time exactly like state session files.
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'));
100
+ return {
101
+ scanned: state.scanned + outcomes.scanned + blockCounters.scanned,
102
+ pruned: state.pruned + outcomes.pruned + blockCounters.pruned,
103
+ bytesFreed: state.bytes + outcomes.bytes + blockCounters.bytes,
104
+ retentionDays: Math.round(retentionMs / (24 * 60 * 60 * 1000)),
105
+ dryRun,
106
+ sample: [...state.sample, ...outcomes.sample, ...blockCounters.sample].slice(0, 20),
107
+ };
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
+ }
150
+ /**
151
+ * Count session-scoped files in STATE_DIR without deleting. Used by doctor
152
+ * to surface a warning when the directory is bloated.
153
+ */
154
+ export function countSessionScopedFiles(stateDir = STATE_DIR) {
155
+ if (!fs.existsSync(stateDir))
156
+ return 0;
157
+ try {
158
+ return fs.readdirSync(stateDir).filter(hasSessionPrefix).length;
159
+ }
160
+ catch {
161
+ return 0;
162
+ }
163
+ }
@@ -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>;
@@ -118,11 +118,13 @@ function cleanSettings() {
118
118
  console.error('[forgen] Failed to parse settings.json — skipping.');
119
119
  return;
120
120
  }
121
- // env에서 COMPOUND_ 접두어 키 제거
121
+ // Audit fix #7 (2026-04-21): env 정리가 `COMPOUND_` 접두어만 검사해서
122
+ // install이 주입한 `FORGEN_*` 키(예: FORGEN_HARNESS, FORGEN_CWD)가
123
+ // uninstall 후에도 settings.json에 영구 잔존했다. 이제 둘 다 정리.
122
124
  const env = settings.env;
123
125
  if (env) {
124
126
  for (const key of Object.keys(env)) {
125
- if (key.startsWith('COMPOUND_'))
127
+ if (key.startsWith('COMPOUND_') || key.startsWith('FORGEN_'))
126
128
  delete env[key];
127
129
  }
128
130
  if (Object.keys(env).length === 0) {
@@ -162,9 +164,15 @@ function cleanSettings() {
162
164
  delete settings.hooks;
163
165
  }
164
166
  }
165
- // statusLine이 forgen status면 제거
167
+ // statusLine이 forgen 설치한 command 중 하나면 제거.
168
+ //
169
+ // Audit fix #7 (2026-04-21): 이전 체크는 `'forgen status'`만 인식했지만
170
+ // 실제 install은 `settings-injector.ts:59`에서 `'forgen me'`를 주입한다.
171
+ // command 문자열이 `forgen`으로 시작하는 경우를 모두 forgen 소유로 보고
172
+ // 제거 — 사용자 커스텀 statusLine(예: `custom-cli ...`)은 건드리지 않음.
166
173
  const statusLine = settings.statusLine;
167
- if (statusLine?.command === 'forgen status') {
174
+ if (typeof statusLine?.command === 'string' &&
175
+ /^forgen(\s|$)/.test(statusLine.command.trim())) {
168
176
  delete settings.statusLine;
169
177
  }
170
178
  // enabledPlugins에서 forgen@forgen-local 제거
@@ -283,8 +291,16 @@ export async function handleUninstall(cwd, options) {
283
291
  console.log(' 4. Remove forgen block from CLAUDE.md');
284
292
  console.log(' 5. Remove slash commands (~/.claude/commands/forgen/)');
285
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
+ }
286
303
  console.log('');
287
- console.log('Note: ~/.forgen/ directory is preserved (manual deletion: rm -rf ~/.forgen)\n');
288
304
  if (!options.force) {
289
305
  if (!process.stdin.isTTY) {
290
306
  console.error('[forgen] Use --force flag in non-interactive environments.');
@@ -303,5 +319,20 @@ export async function handleUninstall(cwd, options) {
303
319
  cleanClaudeMd(cwd);
304
320
  cleanSlashCommands();
305
321
  cleanPluginArtifacts();
322
+ if (options.purge) {
323
+ try {
324
+ const forgenHome = path.join(os.homedir(), '.forgen');
325
+ if (fs.existsSync(forgenHome)) {
326
+ fs.rmSync(forgenHome, { recursive: true, force: true });
327
+ console.log(' ✓ Deleted ~/.forgen/ (all rules, state, solutions, behavior)');
328
+ }
329
+ else {
330
+ console.log(' ✓ ~/.forgen/ already absent');
331
+ }
332
+ }
333
+ catch (e) {
334
+ console.log(` ✗ ~/.forgen/ deletion failed: ${e.message}`);
335
+ }
336
+ }
306
337
  console.log('\n[forgen] Uninstall complete. Restart Claude Code for a clean state.\n');
307
338
  }
@@ -15,11 +15,11 @@
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import * as crypto from 'node:crypto';
18
- import { FORGEN_HOME, V1_ME_DIR, V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_STATE_DIR, V1_RAW_LOGS_DIR, V1_SOLUTIONS_DIR } from './paths.js';
18
+ import { FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, STATE_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS } from './paths.js';
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';
@@ -27,7 +27,7 @@ import { loadEvidenceBySession } from '../store/evidence-store.js';
27
27
  import { computeSessionSignals, detectMismatch } from '../forge/mismatch-detector.js';
28
28
  import { createRecommendation, saveRecommendation } from '../store/recommendation-store.js';
29
29
  // ── Directory Initialization ──
30
- const V1_DIRS = [FORGEN_HOME, V1_ME_DIR, V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_STATE_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, V1_SOLUTIONS_DIR];
30
+ const V1_DIRS = [FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, STATE_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS];
31
31
  export function ensureV1Directories() {
32
32
  for (const dir of V1_DIRS) {
33
33
  fs.mkdirSync(dir, { recursive: true });
@@ -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>;