@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.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +100 -0
  3. package/README.ja.md +29 -0
  4. package/README.ko.md +29 -0
  5. package/README.md +36 -3
  6. package/README.zh.md +29 -0
  7. package/dist/cli.js +3 -3
  8. package/dist/core/auto-compound-runner.js +6 -3
  9. package/dist/core/dashboard.js +11 -4
  10. package/dist/core/doctor.d.ts +6 -1
  11. package/dist/core/doctor.js +21 -1
  12. package/dist/core/global-config.d.ts +2 -2
  13. package/dist/core/global-config.js +6 -14
  14. package/dist/core/harness.d.ts +3 -5
  15. package/dist/core/harness.js +34 -338
  16. package/dist/core/installer.d.ts +10 -0
  17. package/dist/core/installer.js +185 -0
  18. package/dist/core/paths.d.ts +0 -34
  19. package/dist/core/paths.js +0 -35
  20. package/dist/core/settings-injector.d.ts +13 -0
  21. package/dist/core/settings-injector.js +167 -0
  22. package/dist/core/settings-lock.d.ts +35 -2
  23. package/dist/core/settings-lock.js +65 -7
  24. package/dist/core/spawn.js +100 -39
  25. package/dist/core/state-gc.d.ts +30 -0
  26. package/dist/core/state-gc.js +119 -0
  27. package/dist/core/uninstall.js +12 -4
  28. package/dist/core/v1-bootstrap.js +2 -2
  29. package/dist/engine/compound-cli.d.ts +27 -2
  30. package/dist/engine/compound-cli.js +69 -16
  31. package/dist/engine/compound-export.d.ts +15 -0
  32. package/dist/engine/compound-export.js +32 -5
  33. package/dist/engine/compound-loop.js +3 -2
  34. package/dist/engine/learn-cli.js +52 -0
  35. package/dist/engine/match-eval-log.js +45 -0
  36. package/dist/engine/solution-format.d.ts +0 -2
  37. package/dist/engine/solution-format.js +0 -4
  38. package/dist/engine/solution-matcher.d.ts +8 -0
  39. package/dist/engine/solution-matcher.js +7 -4
  40. package/dist/engine/solution-outcomes.d.ts +4 -0
  41. package/dist/engine/solution-outcomes.js +174 -97
  42. package/dist/engine/solution-writer.d.ts +8 -5
  43. package/dist/engine/solution-writer.js +43 -19
  44. package/dist/fgx.js +9 -2
  45. package/dist/forge/cli.js +7 -7
  46. package/dist/hooks/context-guard.js +15 -1
  47. package/dist/hooks/hook-config.d.ts +9 -1
  48. package/dist/hooks/hook-config.js +25 -3
  49. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  50. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  51. package/dist/hooks/notepad-injector.js +6 -3
  52. package/dist/hooks/permission-handler.d.ts +10 -2
  53. package/dist/hooks/permission-handler.js +31 -12
  54. package/dist/hooks/pre-tool-use.js +10 -4
  55. package/dist/hooks/secret-filter.js +6 -0
  56. package/dist/hooks/session-recovery.js +15 -7
  57. package/dist/hooks/shared/hook-response.d.ts +0 -2
  58. package/dist/hooks/shared/hook-response.js +3 -8
  59. package/dist/hooks/shared/hook-timing.js +10 -1
  60. package/dist/hooks/solution-injector.d.ts +21 -0
  61. package/dist/hooks/solution-injector.js +60 -1
  62. package/dist/mcp/solution-reader.d.ts +2 -0
  63. package/dist/mcp/solution-reader.js +28 -1
  64. package/dist/mcp/tools.js +5 -2
  65. package/dist/preset/preset-manager.js +12 -2
  66. package/dist/store/evidence-store.js +5 -5
  67. package/dist/store/profile-store.d.ts +9 -0
  68. package/dist/store/profile-store.js +25 -4
  69. package/dist/store/rule-store.js +8 -8
  70. package/package.json +1 -1
  71. package/plugin.json +7 -2
  72. 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
+ }
@@ -118,11 +118,13 @@ function cleanSettings() {
118
118
  console.error('[forgen] Failed to parse settings.json — skipping.');
119
119
  return;
120
120
  }
121
- // env에서 COMPOUND_ 접두어 키 제거
121
+ // Audit fix #7 (2026-04-21): env 정리가 `COMPOUND_` 접두어만 검사해서
122
+ // install이 주입한 `FORGEN_*` 키(예: FORGEN_HARNESS, FORGEN_CWD)가
123
+ // uninstall 후에도 settings.json에 영구 잔존했다. 이제 둘 다 정리.
122
124
  const env = settings.env;
123
125
  if (env) {
124
126
  for (const key of Object.keys(env)) {
125
- if (key.startsWith('COMPOUND_'))
127
+ if (key.startsWith('COMPOUND_') || key.startsWith('FORGEN_'))
126
128
  delete env[key];
127
129
  }
128
130
  if (Object.keys(env).length === 0) {
@@ -162,9 +164,15 @@ function cleanSettings() {
162
164
  delete settings.hooks;
163
165
  }
164
166
  }
165
- // statusLine이 forgen status면 제거
167
+ // statusLine이 forgen 설치한 command 중 하나면 제거.
168
+ //
169
+ // Audit fix #7 (2026-04-21): 이전 체크는 `'forgen status'`만 인식했지만
170
+ // 실제 install은 `settings-injector.ts:59`에서 `'forgen me'`를 주입한다.
171
+ // command 문자열이 `forgen`으로 시작하는 경우를 모두 forgen 소유로 보고
172
+ // 제거 — 사용자 커스텀 statusLine(예: `custom-cli ...`)은 건드리지 않음.
166
173
  const statusLine = settings.statusLine;
167
- if (statusLine?.command === 'forgen status') {
174
+ if (typeof statusLine?.command === 'string' &&
175
+ /^forgen(\s|$)/.test(statusLine.command.trim())) {
168
176
  delete settings.statusLine;
169
177
  }
170
178
  // enabledPlugins에서 forgen@forgen-local 제거
@@ -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, V1_ME_DIR, V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_STATE_DIR, V1_RAW_LOGS_DIR, V1_SOLUTIONS_DIR } from './paths.js';
18
+ import { FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, STATE_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS } from './paths.js';
19
19
  import { checkLegacyProfile, runLegacyCutover } from './legacy-detector.js';
20
20
  import { detectRuntimeCapability } from './runtime-detector.js';
21
21
  import { loadProfile, profileExists } from '../store/profile-store.js';
@@ -27,7 +27,7 @@ import { loadEvidenceBySession } from '../store/evidence-store.js';
27
27
  import { computeSessionSignals, detectMismatch } from '../forge/mismatch-detector.js';
28
28
  import { createRecommendation, saveRecommendation } from '../store/recommendation-store.js';
29
29
  // ── Directory Initialization ──
30
- const V1_DIRS = [FORGEN_HOME, V1_ME_DIR, V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_STATE_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, V1_SOLUTIONS_DIR];
30
+ const V1_DIRS = [FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, STATE_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS];
31
31
  export function ensureV1Directories() {
32
32
  for (const dir of V1_DIRS) {
33
33
  fs.mkdirSync(dir, { recursive: true });
@@ -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
- /** Rollback auto-extracted solutions since a given date */
24
- export declare function rollbackSolutions(sinceDate: string): void;
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
- console.log(`\n Total: ${total} entries\n`);
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
- /** Rollback auto-extracted solutions since a given date */
222
- export function rollbackSolutions(sinceDate) {
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
- return;
258
+ result.errors.push(`invalid-date:${sinceDate}`);
259
+ return result;
227
260
  }
228
261
  const solutions = scanEntries().filter((entry) => entry.category === 'solution');
229
- const toRemove = solutions.filter((solution) => {
262
+ const toRollback = solutions.filter((solution) => {
230
263
  if (solution.evidence.reflected > 0 || solution.evidence.sessions > 0)
231
- return false; // keep used ones
264
+ return false;
232
265
  const created = new Date(solution.created);
233
266
  return created >= since;
234
267
  });
235
- if (toRemove.length === 0) {
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
- console.log(`\n Rolling back ${toRemove.length} solutions since ${sinceDate}:\n`);
240
- for (const sol of toRemove) {
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.unlinkSync(sol.filePath);
243
- console.log(` Removed: ${sol.name}`);
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
- console.log(` Failed: ${sol.name}`);
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
- // Security: ensure the dest path stays within ME_DIR
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 2026-03-20\n');
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 ---
@@ -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 outcome data, after which the fitness loop
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
- * Automatic deactivation after 5 accumulated injections is handled by a
826
- * separate promoter that flips `status` to `verified`.
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.