@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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.ja.md +61 -7
- package/README.ko.md +15 -1
- package/README.md +92 -6
- package/README.zh.md +61 -7
- package/dist/cli.js +137 -5
- package/dist/core/auto-compound-runner.js +10 -2
- package/dist/core/doctor.js +64 -10
- package/dist/core/inspect-cli.js +65 -5
- package/dist/core/state-gc.d.ts +19 -0
- package/dist/core/state-gc.js +48 -4
- 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 +24 -1
- package/dist/core/v1-bootstrap.js +9 -1
- package/dist/engine/classify-enforce-cli.d.ts +8 -0
- package/dist/engine/classify-enforce-cli.js +61 -0
- package/dist/engine/enforce-classifier.d.ts +31 -0
- package/dist/engine/enforce-classifier.js +123 -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/rule-toggle-cli.d.ts +13 -0
- package/dist/engine/rule-toggle-cli.js +76 -0
- package/dist/forge/evidence-processor.js +10 -2
- package/dist/hooks/context-guard.js +71 -0
- package/dist/hooks/post-tool-use.js +62 -0
- package/dist/hooks/pre-tool-use.js +57 -1
- package/dist/hooks/secret-filter.d.ts +10 -0
- package/dist/hooks/secret-filter.js +20 -0
- 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 -0
- package/dist/hooks/shared/hook-response.js +18 -0
- 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/stop-guard.d.ts +84 -0
- package/dist/hooks/stop-guard.js +482 -0
- package/dist/mcp/tools.js +19 -2
- package/dist/store/evidence-store.d.ts +15 -0
- package/dist/store/evidence-store.js +50 -1
- 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 +128 -8
- 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 +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(), {
|
|
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|
|
|
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
|
|
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
|
|
216
|
-
if (
|
|
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);
|
package/dist/core/doctor.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
check('
|
|
82
|
-
|
|
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
|
}
|
package/dist/core/inspect-cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
97
|
-
forgen inspect rules
|
|
98
|
-
forgen inspect
|
|
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
|
}
|
package/dist/core/state-gc.d.ts
CHANGED
|
@@ -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.
|
package/dist/core/state-gc.js
CHANGED
|
@@ -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
|
+
}
|