@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.
- package/.claude-plugin/plugin.json +7 -2
- package/CHANGELOG.md +164 -0
- package/README.ja.md +90 -7
- package/README.ko.md +44 -1
- package/README.md +128 -9
- package/README.zh.md +90 -7
- package/dist/cli.js +140 -8
- package/dist/core/auto-compound-runner.js +16 -5
- package/dist/core/dashboard.js +11 -4
- package/dist/core/doctor.d.ts +6 -1
- package/dist/core/doctor.js +85 -11
- package/dist/core/global-config.d.ts +2 -2
- package/dist/core/global-config.js +6 -14
- package/dist/core/harness.d.ts +3 -5
- package/dist/core/harness.js +34 -338
- package/dist/core/inspect-cli.js +65 -5
- package/dist/core/installer.d.ts +10 -0
- package/dist/core/installer.js +185 -0
- package/dist/core/paths.d.ts +0 -34
- package/dist/core/paths.js +0 -35
- package/dist/core/settings-injector.d.ts +13 -0
- package/dist/core/settings-injector.js +167 -0
- package/dist/core/settings-lock.d.ts +35 -2
- package/dist/core/settings-lock.js +65 -7
- package/dist/core/spawn.js +100 -39
- package/dist/core/state-gc.d.ts +49 -0
- package/dist/core/state-gc.js +163 -0
- package/dist/core/stats-cli.d.ts +15 -0
- package/dist/core/stats-cli.js +143 -0
- package/dist/core/uninstall.d.ts +1 -0
- package/dist/core/uninstall.js +36 -5
- package/dist/core/v1-bootstrap.js +11 -3
- package/dist/engine/classify-enforce-cli.d.ts +8 -0
- package/dist/engine/classify-enforce-cli.js +61 -0
- package/dist/engine/compound-cli.d.ts +27 -2
- package/dist/engine/compound-cli.js +69 -16
- package/dist/engine/compound-export.d.ts +15 -0
- package/dist/engine/compound-export.js +32 -5
- package/dist/engine/compound-loop.js +3 -2
- package/dist/engine/enforce-classifier.d.ts +31 -0
- package/dist/engine/enforce-classifier.js +123 -0
- package/dist/engine/learn-cli.js +52 -0
- package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
- package/dist/engine/lifecycle/bypass-detector.js +82 -0
- package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
- package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
- package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
- package/dist/engine/lifecycle/meta-cli.js +7 -0
- package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
- package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
- package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
- package/dist/engine/lifecycle/orchestrator.js +131 -0
- package/dist/engine/lifecycle/signals.d.ts +30 -0
- package/dist/engine/lifecycle/signals.js +142 -0
- package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
- package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
- package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
- package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
- package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
- package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
- package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
- package/dist/engine/lifecycle/types.d.ts +52 -0
- package/dist/engine/lifecycle/types.js +7 -0
- package/dist/engine/match-eval-log.js +45 -0
- package/dist/engine/rule-toggle-cli.d.ts +13 -0
- package/dist/engine/rule-toggle-cli.js +76 -0
- package/dist/engine/solution-format.d.ts +0 -2
- package/dist/engine/solution-format.js +0 -4
- package/dist/engine/solution-matcher.d.ts +8 -0
- package/dist/engine/solution-matcher.js +7 -4
- package/dist/engine/solution-outcomes.d.ts +4 -0
- package/dist/engine/solution-outcomes.js +174 -97
- package/dist/engine/solution-writer.d.ts +8 -5
- package/dist/engine/solution-writer.js +43 -19
- package/dist/fgx.js +9 -2
- package/dist/forge/cli.js +7 -7
- package/dist/forge/evidence-processor.js +10 -2
- package/dist/hooks/context-guard.js +86 -1
- package/dist/hooks/hook-config.d.ts +9 -1
- package/dist/hooks/hook-config.js +25 -3
- package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
- package/dist/hooks/internal/run-lifecycle-check.js +32 -0
- package/dist/hooks/notepad-injector.js +6 -3
- package/dist/hooks/permission-handler.d.ts +10 -2
- package/dist/hooks/permission-handler.js +31 -12
- package/dist/hooks/post-tool-use.js +62 -0
- package/dist/hooks/pre-tool-use.js +67 -5
- package/dist/hooks/secret-filter.d.ts +10 -0
- package/dist/hooks/secret-filter.js +26 -0
- package/dist/hooks/session-recovery.js +15 -7
- package/dist/hooks/shared/atomic-write.d.ts +8 -1
- package/dist/hooks/shared/atomic-write.js +17 -3
- package/dist/hooks/shared/hook-response.d.ts +11 -2
- package/dist/hooks/shared/hook-response.js +20 -7
- package/dist/hooks/shared/hook-timing.js +10 -1
- package/dist/hooks/shared/safe-regex.d.ts +25 -0
- package/dist/hooks/shared/safe-regex.js +50 -0
- package/dist/hooks/shared/stop-triggers.d.ts +19 -0
- package/dist/hooks/shared/stop-triggers.js +19 -0
- package/dist/hooks/solution-injector.d.ts +21 -0
- package/dist/hooks/solution-injector.js +60 -1
- package/dist/hooks/stop-guard.d.ts +84 -0
- package/dist/hooks/stop-guard.js +482 -0
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +24 -4
- package/dist/preset/preset-manager.js +12 -2
- package/dist/store/evidence-store.d.ts +15 -0
- package/dist/store/evidence-store.js +55 -6
- package/dist/store/profile-store.d.ts +9 -0
- package/dist/store/profile-store.js +25 -4
- package/dist/store/rule-lifecycle.d.ts +23 -0
- package/dist/store/rule-lifecycle.js +63 -0
- package/dist/store/rule-store.d.ts +21 -0
- package/dist/store/rule-store.js +133 -13
- package/dist/store/types.d.ts +83 -0
- package/dist/store/types.js +7 -1
- package/hooks/hook-registry.json +1 -0
- package/hooks/hooks.json +6 -1
- package/package.json +10 -2
- package/plugin.json +7 -2
- package/scripts/postinstall.js +52 -5
package/dist/core/spawn.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 기록 (종료 후
|
|
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 =
|
|
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
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/core/uninstall.d.ts
CHANGED
package/dist/core/uninstall.js
CHANGED
|
@@ -118,11 +118,13 @@ function cleanSettings() {
|
|
|
118
118
|
console.error('[forgen] Failed to parse settings.json — skipping.');
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
|
-
// env
|
|
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
|
|
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 === '
|
|
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,
|
|
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,
|
|
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>;
|