@wooojin/forgen 0.3.0 → 0.3.2
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 +132 -0
- package/README.ja.md +29 -0
- package/README.ko.md +29 -0
- package/README.md +36 -3
- package/README.zh.md +29 -0
- package/agents/solution-evolver.md +115 -0
- package/dist/cli.js +11 -3
- package/dist/core/auto-compound-runner.js +6 -3
- package/dist/core/dashboard.js +57 -4
- package/dist/core/doctor.d.ts +6 -1
- package/dist/core/doctor.js +21 -1
- 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/installer.d.ts +10 -0
- package/dist/core/installer.js +185 -0
- package/dist/core/paths.d.ts +25 -34
- package/dist/core/paths.js +25 -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 +30 -0
- package/dist/core/state-gc.js +119 -0
- package/dist/core/uninstall.js +12 -4
- package/dist/core/v1-bootstrap.js +2 -2
- 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/learn-cli.d.ts +1 -0
- package/dist/engine/learn-cli.js +234 -0
- package/dist/engine/match-eval-log.js +45 -0
- package/dist/engine/solution-candidate.d.ts +30 -0
- package/dist/engine/solution-candidate.js +124 -0
- package/dist/engine/solution-fitness.d.ts +52 -0
- package/dist/engine/solution-fitness.js +95 -0
- package/dist/engine/solution-fixup.d.ts +30 -0
- package/dist/engine/solution-fixup.js +116 -0
- package/dist/engine/solution-format.d.ts +8 -2
- package/dist/engine/solution-format.js +38 -27
- package/dist/engine/solution-index.js +10 -0
- package/dist/engine/solution-matcher.d.ts +8 -0
- package/dist/engine/solution-matcher.js +27 -1
- package/dist/engine/solution-outcomes.d.ts +74 -0
- package/dist/engine/solution-outcomes.js +319 -0
- package/dist/engine/solution-quarantine.d.ts +36 -0
- package/dist/engine/solution-quarantine.js +172 -0
- package/dist/engine/solution-weakness.d.ts +45 -0
- package/dist/engine/solution-weakness.js +225 -0
- package/dist/engine/solution-writer.d.ts +9 -1
- package/dist/engine/solution-writer.js +44 -2
- package/dist/fgx.js +9 -2
- package/dist/forge/cli.js +7 -7
- package/dist/hooks/context-guard.js +15 -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-failure.js +7 -0
- package/dist/hooks/pre-tool-use.js +10 -4
- package/dist/hooks/secret-filter.js +6 -0
- package/dist/hooks/session-recovery.js +15 -7
- package/dist/hooks/shared/hook-response.d.ts +0 -2
- package/dist/hooks/shared/hook-response.js +3 -8
- package/dist/hooks/shared/hook-timing.js +10 -1
- package/dist/hooks/solution-injector.d.ts +21 -0
- package/dist/hooks/solution-injector.js +80 -1
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +13 -2
- package/dist/preset/preset-manager.js +12 -2
- package/dist/store/evidence-store.js +5 -5
- package/dist/store/profile-store.d.ts +9 -0
- package/dist/store/profile-store.js +25 -4
- package/dist/store/rule-store.js +8 -8
- package/package.json +1 -1
- 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,30 @@
|
|
|
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
|
+
* Count session-scoped files in STATE_DIR without deleting. Used by doctor
|
|
28
|
+
* to surface a warning when the directory is bloated.
|
|
29
|
+
*/
|
|
30
|
+
export declare function countSessionScopedFiles(stateDir?: string): number;
|
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
return {
|
|
98
|
+
scanned: state.scanned + outcomes.scanned,
|
|
99
|
+
pruned: state.pruned + outcomes.pruned,
|
|
100
|
+
bytesFreed: state.bytes + outcomes.bytes,
|
|
101
|
+
retentionDays: Math.round(retentionMs / (24 * 60 * 60 * 1000)),
|
|
102
|
+
dryRun,
|
|
103
|
+
sample: [...state.sample, ...outcomes.sample].slice(0, 20),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Count session-scoped files in STATE_DIR without deleting. Used by doctor
|
|
108
|
+
* to surface a warning when the directory is bloated.
|
|
109
|
+
*/
|
|
110
|
+
export function countSessionScopedFiles(stateDir = STATE_DIR) {
|
|
111
|
+
if (!fs.existsSync(stateDir))
|
|
112
|
+
return 0;
|
|
113
|
+
try {
|
|
114
|
+
return fs.readdirSync(stateDir).filter(hasSessionPrefix).length;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
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 제거
|
|
@@ -15,7 +15,7 @@
|
|
|
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';
|
|
@@ -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 });
|
|
@@ -20,5 +20,30 @@ export declare function removeSolution(name: string): void;
|
|
|
20
20
|
export declare function cleanStaleSolutions(): void;
|
|
21
21
|
/** Retag all solutions using improved extractTags */
|
|
22
22
|
export declare function retagSolutions(): void;
|
|
23
|
-
/**
|
|
24
|
-
export
|
|
23
|
+
/** Result of a rollback operation — used by tests and callers that need counts. */
|
|
24
|
+
export interface RollbackCliResult {
|
|
25
|
+
archived: string[];
|
|
26
|
+
archiveDir: string | null;
|
|
27
|
+
skipped: string[];
|
|
28
|
+
errors: string[];
|
|
29
|
+
dryRun: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Rollback auto-extracted solutions created since a given date.
|
|
33
|
+
*
|
|
34
|
+
* Invariant (2026-04-20, feedback_core_loop_invariant):
|
|
35
|
+
* rollback은 **archive 이동**만 수행한다. `fs.unlinkSync`로 솔루션 파일을
|
|
36
|
+
* 영구 삭제하지 않는다. 실수로 rollback을 실행해도 `~/.forgen/lab/archived/
|
|
37
|
+
* rollback-{ts}/`에서 복구할 수 있어야 한다. (과거 `unlinkSync` 경로는
|
|
38
|
+
* time-bounded rollback이 "되돌리기 불가 영구 삭제"로 동작하던 버그였다.)
|
|
39
|
+
*
|
|
40
|
+
* 필터 기준:
|
|
41
|
+
* - category === 'solution'만 대상 (rule은 제외)
|
|
42
|
+
* - reflected > 0 OR sessions > 0인 것은 유지 (사용된 솔루션 보호)
|
|
43
|
+
* - created >= since 인 것만 대상
|
|
44
|
+
*
|
|
45
|
+
* dryRun=true면 아무 파일도 건드리지 않고 대상 목록만 반환.
|
|
46
|
+
*/
|
|
47
|
+
export declare function rollbackSolutions(sinceDate: string, opts?: {
|
|
48
|
+
dryRun?: boolean;
|
|
49
|
+
}): RollbackCliResult;
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { extractTags, parseFrontmatterOnly, parseSolutionV3 } from './solution-format.js';
|
|
4
4
|
import { mutateSolutionFile } from './solution-writer.js';
|
|
5
|
-
import { ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
|
|
5
|
+
import { ARCHIVED_DIR, ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
|
|
6
6
|
/** Scan saved compound entries and return summaries */
|
|
7
7
|
function scanEntries() {
|
|
8
8
|
const summaries = [];
|
|
@@ -78,7 +78,17 @@ export function listSolutions() {
|
|
|
78
78
|
console.log(` ${entry.name} [${entry.category}] (${entry.confidence.toFixed(2)}) ${evStr} [${entry.tags.slice(0, 3).join(', ')}]`);
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
// retired 카운트: scanEntries는 모든 상태를 포함하므로 직접 계산
|
|
82
|
+
const retiredCount = entries.filter(e => e.status === 'retired').length;
|
|
83
|
+
const activeCount = total - retiredCount;
|
|
84
|
+
const highConfidence = entries.filter(e => e.status === 'verified' || e.status === 'mature').length;
|
|
85
|
+
const denominator = total;
|
|
86
|
+
const precision = denominator > 0 ? Math.round((highConfidence / denominator) * 100) : null;
|
|
87
|
+
console.log(`\n Total: ${activeCount} active + ${retiredCount} retired`);
|
|
88
|
+
if (precision !== null) {
|
|
89
|
+
console.log(` Extraction precision: ${precision}%`);
|
|
90
|
+
}
|
|
91
|
+
console.log();
|
|
82
92
|
}
|
|
83
93
|
/** Inspect a single saved entry in detail */
|
|
84
94
|
export function inspectSolution(name) {
|
|
@@ -218,33 +228,76 @@ export function retagSolutions() {
|
|
|
218
228
|
}
|
|
219
229
|
console.log(`\n Retagged ${retagged}/${entries.length} solutions.\n`);
|
|
220
230
|
}
|
|
221
|
-
/**
|
|
222
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Rollback auto-extracted solutions created since a given date.
|
|
233
|
+
*
|
|
234
|
+
* Invariant (2026-04-20, feedback_core_loop_invariant):
|
|
235
|
+
* rollback은 **archive 이동**만 수행한다. `fs.unlinkSync`로 솔루션 파일을
|
|
236
|
+
* 영구 삭제하지 않는다. 실수로 rollback을 실행해도 `~/.forgen/lab/archived/
|
|
237
|
+
* rollback-{ts}/`에서 복구할 수 있어야 한다. (과거 `unlinkSync` 경로는
|
|
238
|
+
* time-bounded rollback이 "되돌리기 불가 영구 삭제"로 동작하던 버그였다.)
|
|
239
|
+
*
|
|
240
|
+
* 필터 기준:
|
|
241
|
+
* - category === 'solution'만 대상 (rule은 제외)
|
|
242
|
+
* - reflected > 0 OR sessions > 0인 것은 유지 (사용된 솔루션 보호)
|
|
243
|
+
* - created >= since 인 것만 대상
|
|
244
|
+
*
|
|
245
|
+
* dryRun=true면 아무 파일도 건드리지 않고 대상 목록만 반환.
|
|
246
|
+
*/
|
|
247
|
+
export function rollbackSolutions(sinceDate, opts = {}) {
|
|
248
|
+
const result = {
|
|
249
|
+
archived: [],
|
|
250
|
+
archiveDir: null,
|
|
251
|
+
skipped: [],
|
|
252
|
+
errors: [],
|
|
253
|
+
dryRun: !!opts.dryRun,
|
|
254
|
+
};
|
|
223
255
|
const since = new Date(sinceDate);
|
|
224
256
|
if (Number.isNaN(since.getTime())) {
|
|
225
257
|
console.log(`\n Invalid date: ${sinceDate}\n`);
|
|
226
|
-
|
|
258
|
+
result.errors.push(`invalid-date:${sinceDate}`);
|
|
259
|
+
return result;
|
|
227
260
|
}
|
|
228
261
|
const solutions = scanEntries().filter((entry) => entry.category === 'solution');
|
|
229
|
-
const
|
|
262
|
+
const toRollback = solutions.filter((solution) => {
|
|
230
263
|
if (solution.evidence.reflected > 0 || solution.evidence.sessions > 0)
|
|
231
|
-
return false;
|
|
264
|
+
return false;
|
|
232
265
|
const created = new Date(solution.created);
|
|
233
266
|
return created >= since;
|
|
234
267
|
});
|
|
235
|
-
if (
|
|
268
|
+
if (toRollback.length === 0) {
|
|
236
269
|
console.log(`\n No solutions to rollback since ${sinceDate}.\n`);
|
|
237
|
-
return;
|
|
270
|
+
return result;
|
|
238
271
|
}
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
if (opts.dryRun) {
|
|
273
|
+
console.log(`\n [dry-run] ${toRollback.length} solutions would be archived since ${sinceDate}:\n`);
|
|
274
|
+
for (const sol of toRollback) {
|
|
275
|
+
console.log(` Would archive: ${sol.name}`);
|
|
276
|
+
result.skipped.push(sol.filePath);
|
|
277
|
+
}
|
|
278
|
+
console.log(`\n Re-run without --dry-run to archive them.\n`);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
const archiveDir = path.join(ARCHIVED_DIR, `rollback-${Date.now()}`);
|
|
282
|
+
result.archiveDir = archiveDir;
|
|
283
|
+
console.log(`\n Rolling back ${toRollback.length} solutions since ${sinceDate} → ${archiveDir}:\n`);
|
|
284
|
+
for (const sol of toRollback) {
|
|
241
285
|
try {
|
|
242
|
-
fs.
|
|
243
|
-
|
|
286
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
287
|
+
// 원본 경로 정보를 destName에 보존 — 복원 시 원위치 판별용.
|
|
288
|
+
// 예: "solutions__my-pattern.md"
|
|
289
|
+
const originDir = path.basename(path.dirname(sol.filePath));
|
|
290
|
+
const destName = `${originDir}__${path.basename(sol.filePath)}`;
|
|
291
|
+
fs.renameSync(sol.filePath, path.join(archiveDir, destName));
|
|
292
|
+
console.log(` Archived: ${sol.name}`);
|
|
293
|
+
result.archived.push(sol.filePath);
|
|
244
294
|
}
|
|
245
|
-
catch {
|
|
246
|
-
|
|
295
|
+
catch (e) {
|
|
296
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
297
|
+
console.log(` Failed: ${sol.name} — ${msg}`);
|
|
298
|
+
result.errors.push(`${sol.filePath}: ${msg}`);
|
|
247
299
|
}
|
|
248
300
|
}
|
|
249
|
-
console.log();
|
|
301
|
+
console.log(`\n ${result.archived.length}/${toRollback.length} archived. Restore from ${archiveDir} if needed.\n`);
|
|
302
|
+
return result;
|
|
250
303
|
}
|
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
* Export creates a tar.gz archive; Import extracts it while skipping existing
|
|
8
8
|
* files to prevent accidental overwrites.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Test-friendly containment check (exported for unit tests).
|
|
12
|
+
*
|
|
13
|
+
* Audit fix (2026-04-21, follow-up #A): prior guard
|
|
14
|
+
* `realDest.startsWith(ME_DIR)` was a naive prefix match. A path like
|
|
15
|
+
* `/home/u/.forgen/me-evil/x.md` starts with `/home/u/.forgen/me` and
|
|
16
|
+
* bypassed the check — even though it's a sibling, not a child, of
|
|
17
|
+
* ME_DIR. Fix: compare with `parent + path.sep` so `/home/u/.forgen/me/`
|
|
18
|
+
* cannot prefix-match `/home/u/.forgen/me-evil/...`.
|
|
19
|
+
*
|
|
20
|
+
* @param parentWithSep absolute canonical parent path that INCLUDES a
|
|
21
|
+
* trailing `path.sep` (precomputed by caller once per archive).
|
|
22
|
+
* @param candidate any path string (will be resolved lexically).
|
|
23
|
+
*/
|
|
24
|
+
export declare function isPathInside(parentWithSep: string, candidate: string): boolean;
|
|
10
25
|
export interface ExportResult {
|
|
11
26
|
outputPath: string;
|
|
12
27
|
counts: Record<string, number>;
|
|
@@ -14,6 +14,24 @@ import { execFileSync } from 'node:child_process';
|
|
|
14
14
|
import { ME_DIR } from '../core/paths.js';
|
|
15
15
|
/** Directories within ME_DIR to include in the archive. */
|
|
16
16
|
const KNOWLEDGE_DIRS = ['solutions', 'rules', 'behavior'];
|
|
17
|
+
/**
|
|
18
|
+
* Test-friendly containment check (exported for unit tests).
|
|
19
|
+
*
|
|
20
|
+
* Audit fix (2026-04-21, follow-up #A): prior guard
|
|
21
|
+
* `realDest.startsWith(ME_DIR)` was a naive prefix match. A path like
|
|
22
|
+
* `/home/u/.forgen/me-evil/x.md` starts with `/home/u/.forgen/me` and
|
|
23
|
+
* bypassed the check — even though it's a sibling, not a child, of
|
|
24
|
+
* ME_DIR. Fix: compare with `parent + path.sep` so `/home/u/.forgen/me/`
|
|
25
|
+
* cannot prefix-match `/home/u/.forgen/me-evil/...`.
|
|
26
|
+
*
|
|
27
|
+
* @param parentWithSep absolute canonical parent path that INCLUDES a
|
|
28
|
+
* trailing `path.sep` (precomputed by caller once per archive).
|
|
29
|
+
* @param candidate any path string (will be resolved lexically).
|
|
30
|
+
*/
|
|
31
|
+
export function isPathInside(parentWithSep, candidate) {
|
|
32
|
+
const resolved = path.resolve(candidate) + path.sep;
|
|
33
|
+
return resolved.startsWith(parentWithSep) && resolved !== parentWithSep;
|
|
34
|
+
}
|
|
17
35
|
/**
|
|
18
36
|
* Count .md files in a directory (non-recursive).
|
|
19
37
|
* Returns 0 if the directory does not exist.
|
|
@@ -92,12 +110,21 @@ export function importKnowledge(archivePath) {
|
|
|
92
110
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
93
111
|
});
|
|
94
112
|
const result = { imported: 0, skipped: 0, details: [] };
|
|
113
|
+
// Audit fix (2026-04-21, follow-up #A): prior check
|
|
114
|
+
// `realDest.startsWith(ME_DIR)` was a broken prefix guard. An archive
|
|
115
|
+
// entry `../me-evil/payload.md` resolves to a sibling directory path
|
|
116
|
+
// that still startsWith ME_DIR (prefix collision) and bypasses the
|
|
117
|
+
// check. Fix: compare lexically resolved paths with an explicit
|
|
118
|
+
// `path.sep` suffix so `.../me-evil` can never masquerade as
|
|
119
|
+
// `.../me/...`. Both source and destination are validated — a
|
|
120
|
+
// malformed archive must neither read from outside tmpDir nor write
|
|
121
|
+
// outside ME_DIR.
|
|
122
|
+
const meDirCanon = path.resolve(ME_DIR) + path.sep;
|
|
123
|
+
const tmpDirCanon = path.resolve(tmpDir) + path.sep;
|
|
95
124
|
for (const relFile of archiveFiles) {
|
|
96
|
-
const srcPath = path.join(tmpDir, relFile);
|
|
97
|
-
const destPath = path.join(ME_DIR, relFile);
|
|
98
|
-
|
|
99
|
-
const realDest = path.resolve(destPath);
|
|
100
|
-
if (!realDest.startsWith(ME_DIR)) {
|
|
125
|
+
const srcPath = path.resolve(path.join(tmpDir, relFile));
|
|
126
|
+
const destPath = path.resolve(path.join(ME_DIR, relFile));
|
|
127
|
+
if (!isPathInside(meDirCanon, destPath) || !isPathInside(tmpDirCanon, srcPath)) {
|
|
101
128
|
result.skipped++;
|
|
102
129
|
result.details.push({ file: relFile, action: 'skipped' });
|
|
103
130
|
continue;
|
|
@@ -324,11 +324,12 @@ export async function handleCompound(args) {
|
|
|
324
324
|
const sinceIdx = args.indexOf('--since');
|
|
325
325
|
const since = sinceIdx !== -1 ? args[sinceIdx + 1] : undefined;
|
|
326
326
|
if (!since) {
|
|
327
|
-
console.log(' Usage: forgen compound rollback --since
|
|
327
|
+
console.log(' Usage: forgen compound rollback --since YYYY-MM-DD [--dry-run]\n');
|
|
328
328
|
return;
|
|
329
329
|
}
|
|
330
|
+
const dryRun = args.includes('--dry-run');
|
|
330
331
|
const { rollbackSolutions } = await import('./compound-cli.js');
|
|
331
|
-
rollbackSolutions(since);
|
|
332
|
+
rollbackSolutions(since, { dryRun });
|
|
332
333
|
return;
|
|
333
334
|
}
|
|
334
335
|
// --- explicit interactive command ---
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function handleLearn(args: string[]): Promise<void>;
|