@wooojin/forgen 0.3.1 → 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 +100 -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/dist/cli.js +3 -3
- package/dist/core/auto-compound-runner.js +6 -3
- package/dist/core/dashboard.js +11 -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 +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 +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.js +52 -0
- package/dist/engine/match-eval-log.js +45 -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/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/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 +60 -1
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +5 -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
|
@@ -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 ---
|
package/dist/engine/learn-cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
1
2
|
import * as path from 'node:path';
|
|
2
3
|
import * as os from 'node:os';
|
|
3
4
|
import { fixupSolutions } from './solution-fixup.js';
|
|
@@ -6,6 +7,8 @@ import { computeFitness } from './solution-fitness.js';
|
|
|
6
7
|
import { buildWeaknessReport, saveWeaknessReport } from './solution-weakness.js';
|
|
7
8
|
import { listCandidates, promoteCandidate, rollbackSince } from './solution-candidate.js';
|
|
8
9
|
const ME_SOLUTIONS = path.join(os.homedir(), '.forgen', 'me', 'solutions');
|
|
10
|
+
const STATE_DIR = path.join(os.homedir(), '.forgen', 'state');
|
|
11
|
+
const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
|
|
9
12
|
export async function handleLearn(args) {
|
|
10
13
|
const sub = args[0];
|
|
11
14
|
if (sub === 'fix-up')
|
|
@@ -16,6 +19,8 @@ export async function handleLearn(args) {
|
|
|
16
19
|
return runFitness(args.slice(1));
|
|
17
20
|
if (sub === 'evolve')
|
|
18
21
|
return runEvolve(args.slice(1));
|
|
22
|
+
if (sub === 'reset-outcomes')
|
|
23
|
+
return runResetOutcomes(args.slice(1));
|
|
19
24
|
printUsage();
|
|
20
25
|
}
|
|
21
26
|
function printUsage() {
|
|
@@ -28,8 +33,55 @@ function printUsage() {
|
|
|
28
33
|
forgen learn fitness [--json] Show per-solution fitness (accept/correct/error ratios)
|
|
29
34
|
forgen learn evolve [--save|--rollback <ts>|--promote <name>]
|
|
30
35
|
Phase 4 evolution: weakness report + candidate lifecycle
|
|
36
|
+
forgen learn reset-outcomes [--apply] Archive pre-audit outcome history (dry-run by default).
|
|
37
|
+
Use after upgrading from <0.3.2 to start fitness fresh
|
|
38
|
+
under the new attribution gates. Old data is preserved
|
|
39
|
+
at ~/.forgen/state/outcomes.archive-<ts>/ (never deleted).
|
|
31
40
|
`);
|
|
32
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Archive the outcomes directory to a timestamped sibling so fitness
|
|
44
|
+
* computation starts fresh under v0.3.2's corrected attribution gates
|
|
45
|
+
* (match_score≥0.3, lag≤5min, top-3, single-tag rejection). Pre-0.3.2
|
|
46
|
+
* outcomes were recorded with blanket error-attribution on every tool
|
|
47
|
+
* failure in the session window, producing a 91% global error rate even
|
|
48
|
+
* for solutions that weren't actually causing the failures.
|
|
49
|
+
*
|
|
50
|
+
* Archive, never delete — users who want to audit their pre-0.3.2
|
|
51
|
+
* history can still read `outcomes.archive-<ts>/*.jsonl`.
|
|
52
|
+
*/
|
|
53
|
+
function runResetOutcomes(args) {
|
|
54
|
+
const apply = args.includes('--apply');
|
|
55
|
+
if (!fs.existsSync(OUTCOMES_DIR)) {
|
|
56
|
+
console.log(`\n No outcomes directory yet — nothing to reset.\n`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const files = fs.readdirSync(OUTCOMES_DIR).filter((f) => f.endsWith('.jsonl'));
|
|
60
|
+
if (files.length === 0) {
|
|
61
|
+
console.log(`\n Outcomes directory is empty — nothing to reset.\n`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
let totalLines = 0;
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
try {
|
|
67
|
+
totalLines += fs.readFileSync(path.join(OUTCOMES_DIR, f), 'utf-8').split('\n').filter(Boolean).length;
|
|
68
|
+
}
|
|
69
|
+
catch { /* ignore */ }
|
|
70
|
+
}
|
|
71
|
+
console.log(`\n Outcomes snapshot: ${files.length} session files, ${totalLines} events.`);
|
|
72
|
+
if (!apply) {
|
|
73
|
+
console.log(`\n Dry-run. Re-run with --apply to archive:`);
|
|
74
|
+
console.log(` mv ~/.forgen/state/outcomes → ~/.forgen/state/outcomes.archive-<ts>`);
|
|
75
|
+
console.log(` new empty ~/.forgen/state/outcomes/\n`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const archivePath = `${OUTCOMES_DIR}.archive-${Date.now()}`;
|
|
79
|
+
fs.renameSync(OUTCOMES_DIR, archivePath);
|
|
80
|
+
fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
|
|
81
|
+
console.log(`\n ✓ Archived to ${archivePath}`);
|
|
82
|
+
console.log(` ✓ Fresh ${OUTCOMES_DIR} created.`);
|
|
83
|
+
console.log(`\n Next fitness snapshot reflects v0.3.2 attribution gates only.\n`);
|
|
84
|
+
}
|
|
33
85
|
function runFixUp(args) {
|
|
34
86
|
const apply = args.includes('--apply');
|
|
35
87
|
const result = fixupSolutions(ME_SOLUTIONS, { dryRun: !apply });
|
|
@@ -69,6 +69,47 @@ const MAX_NORMALIZED_QUERY_LOGGED = 64;
|
|
|
69
69
|
const MAX_MATCHED_TERMS_PER_CANDIDATE = 16;
|
|
70
70
|
/** Read-side DoS guard: refuse to load if the JSONL file is larger than this. */
|
|
71
71
|
const MAX_LOG_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
72
|
+
/**
|
|
73
|
+
* Write-side rotation threshold (2026-04-21). When the active log reaches
|
|
74
|
+
* this size, it is renamed to `<path>.1` (clobbering any previous rotation)
|
|
75
|
+
* and a fresh empty file is opened. Keeps one generation of history for
|
|
76
|
+
* offline forensics without letting the log grow unbounded. Chosen so
|
|
77
|
+
* typical installs retain ~10-20k records — enough to spot recurrent
|
|
78
|
+
* matcher surprises, not enough to silently fill disk.
|
|
79
|
+
*/
|
|
80
|
+
const ROTATION_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
81
|
+
/**
|
|
82
|
+
* Internal: rotate the log if it exceeds ROTATION_SIZE_BYTES. Called from
|
|
83
|
+
* inside the file lock so no concurrent writer observes a torn state. A
|
|
84
|
+
* rotation failure is swallowed — the caller will still attempt to append
|
|
85
|
+
* and either succeed (no-op rotation) or the append itself will surface
|
|
86
|
+
* the underlying fs error via the outer catch.
|
|
87
|
+
*/
|
|
88
|
+
function maybeRotate(logPath) {
|
|
89
|
+
let size = 0;
|
|
90
|
+
try {
|
|
91
|
+
const st = fs.statSync(logPath);
|
|
92
|
+
if (!st.isFile())
|
|
93
|
+
return;
|
|
94
|
+
size = st.size;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return; // missing file is fine, append will create it
|
|
98
|
+
}
|
|
99
|
+
if (size < ROTATION_SIZE_BYTES)
|
|
100
|
+
return;
|
|
101
|
+
const rotated = `${logPath}.1`;
|
|
102
|
+
try {
|
|
103
|
+
// rename is atomic on POSIX within the same directory; overwrites any
|
|
104
|
+
// previous rotation. We intentionally keep only one generation.
|
|
105
|
+
fs.renameSync(logPath, rotated);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Best effort. If rotation fails (permissions, cross-device link)
|
|
109
|
+
// we leave the original file alone and the next append just continues
|
|
110
|
+
// growing. The 50 MB read-side cap still protects offline tools.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
72
113
|
/**
|
|
73
114
|
* Check whether logging is disabled via environment variable.
|
|
74
115
|
* Accepts `off`, `disabled`, `0`, `false`, `no` (case-insensitive).
|
|
@@ -150,6 +191,10 @@ export function logMatchDecision(input) {
|
|
|
150
191
|
// so concurrent writers could interleave without this lock. The lock
|
|
151
192
|
// is taken on the log file itself, and cleaned up by withFileLockSync.
|
|
152
193
|
withFileLockSync(MATCH_EVAL_LOG_PATH, () => {
|
|
194
|
+
// Rotate BEFORE opening the fd so the new fd points at the fresh
|
|
195
|
+
// file. Doing this after open would append to the file that is
|
|
196
|
+
// about to be renamed into the previous-generation slot.
|
|
197
|
+
maybeRotate(MATCH_EVAL_LOG_PATH);
|
|
153
198
|
// O_NOFOLLOW: refuse to follow a symlink at the target path. This
|
|
154
199
|
// blocks a local-attacker symlink swap attack where the log file
|
|
155
200
|
// is replaced with a link to e.g. ~/.ssh/authorized_keys.
|
|
@@ -74,8 +74,6 @@ export declare function parseFrontmatterOnly(content: string): SolutionFrontmatt
|
|
|
74
74
|
export declare function parseSolutionV3(content: string): SolutionV3 | null;
|
|
75
75
|
/** Serialize a SolutionV3 to a markdown string with YAML frontmatter */
|
|
76
76
|
export declare function serializeSolutionV3(solution: SolutionV3): string;
|
|
77
|
-
/** Check if content is in V3 format (YAML frontmatter) */
|
|
78
|
-
export declare function isV3Format(content: string): boolean;
|
|
79
77
|
/** Check if content is in V1 format (# Title + > Type: pattern) */
|
|
80
78
|
export declare function isV1Format(content: string): boolean;
|
|
81
79
|
/** 한국어 일반 조사/어미 — strip 대상 (긴 것부터 매칭)
|
|
@@ -162,10 +162,6 @@ export function serializeSolutionV3(solution) {
|
|
|
162
162
|
return `---\n${yamlStr}---\n\n## Context\n${solution.context}\n\n## Content\n${solution.content}\n`;
|
|
163
163
|
}
|
|
164
164
|
// ── Format Detection ──
|
|
165
|
-
/** Check if content is in V3 format (YAML frontmatter) */
|
|
166
|
-
export function isV3Format(content) {
|
|
167
|
-
return content.trimStart().startsWith('---');
|
|
168
|
-
}
|
|
169
165
|
/** Check if content is in V1 format (# Title + > Type: pattern) */
|
|
170
166
|
export function isV1Format(content) {
|
|
171
167
|
const lines = content.split('\n');
|
|
@@ -41,6 +41,14 @@ export interface SolutionMatch {
|
|
|
41
41
|
tags: string[];
|
|
42
42
|
identifiers: string[];
|
|
43
43
|
matchedTags: string[];
|
|
44
|
+
/**
|
|
45
|
+
* Identifier substrings (function/file names) that appeared literally in the
|
|
46
|
+
* prompt. Added 2026-04-21 so solution-injector can enforce a precision gate
|
|
47
|
+
* distinguishing "user typed a specific identifier" (strong signal, survives
|
|
48
|
+
* 1-tag overlap) from "only 1 tag happens to overlap" (often noise — common
|
|
49
|
+
* nouns like 'type', 'file', 'forgen' trigger rare-tag BM25 boost).
|
|
50
|
+
*/
|
|
51
|
+
matchedIdentifiers: string[];
|
|
44
52
|
}
|
|
45
53
|
/**
|
|
46
54
|
* Optional hints for the v3 `calculateRelevance` path. Used by hot-path
|
|
@@ -818,12 +818,14 @@ function loadTunedMatcherWeights() {
|
|
|
818
818
|
* entries and almost always lose the first few rounds — not because
|
|
819
819
|
* they're worse, but because matchers favor solutions with richer tag
|
|
820
820
|
* histories. A small confidence multiplier lets candidates surface often
|
|
821
|
-
* enough to accumulate
|
|
822
|
-
* decides their fate.
|
|
821
|
+
* enough to accumulate reflected/sessions evidence, after which the
|
|
822
|
+
* lifecycle loop decides their fate.
|
|
823
823
|
*
|
|
824
824
|
* The 1.3× factor is a starting point (Q1 in docs/design-solution-evolution.md).
|
|
825
|
-
*
|
|
826
|
-
*
|
|
825
|
+
* Bonus deactivation happens implicitly when compound-lifecycle.ts::
|
|
826
|
+
* runLifecycleCheck promotes the candidate to `verified` based on accumulated
|
|
827
|
+
* reflected/sessions evidence. There is no inject-count-based auto promotion
|
|
828
|
+
* (removed 2026-04-20 — see feedback_core_loop_invariant).
|
|
827
829
|
*/
|
|
828
830
|
const CANDIDATE_EXPLORATION_MULTIPLIER = 1.3;
|
|
829
831
|
function applyCandidateExplorationBonus(entries) {
|
|
@@ -865,5 +867,6 @@ export function matchSolutions(prompt, scope, cwd) {
|
|
|
865
867
|
tags: c.solution.tags,
|
|
866
868
|
identifiers: c.solution.identifiers,
|
|
867
869
|
matchedTags: [...c.matchedTags, ...c.matchedIdentifiers],
|
|
870
|
+
matchedIdentifiers: c.matchedIdentifiers,
|
|
868
871
|
}));
|
|
869
872
|
}
|
|
@@ -52,6 +52,10 @@ export declare function attributeCorrection(sessionId: string): string[];
|
|
|
52
52
|
* — an error is a weaker signal and the next user prompt can still produce
|
|
53
53
|
* a correct/accept decision.
|
|
54
54
|
*
|
|
55
|
+
* Only the top-K most-relevant, recent, above-threshold pending solutions
|
|
56
|
+
* are attributed (see gates above). Below-threshold or stale pending
|
|
57
|
+
* entries are left untouched — they will resolve via accept/unknown later.
|
|
58
|
+
*
|
|
55
59
|
* To avoid flooding the log with duplicate errors for the same pending
|
|
56
60
|
* batch, we cap at one `error` event per (session, solution) pair per
|
|
57
61
|
* pending-cycle by tracking a `error_flagged` set in the pending state.
|