@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.
Files changed (86) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +132 -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/agents/solution-evolver.md +115 -0
  8. package/dist/cli.js +11 -3
  9. package/dist/core/auto-compound-runner.js +6 -3
  10. package/dist/core/dashboard.js +57 -4
  11. package/dist/core/doctor.d.ts +6 -1
  12. package/dist/core/doctor.js +21 -1
  13. package/dist/core/global-config.d.ts +2 -2
  14. package/dist/core/global-config.js +6 -14
  15. package/dist/core/harness.d.ts +3 -5
  16. package/dist/core/harness.js +34 -338
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +25 -34
  20. package/dist/core/paths.js +25 -35
  21. package/dist/core/settings-injector.d.ts +13 -0
  22. package/dist/core/settings-injector.js +167 -0
  23. package/dist/core/settings-lock.d.ts +35 -2
  24. package/dist/core/settings-lock.js +65 -7
  25. package/dist/core/spawn.js +100 -39
  26. package/dist/core/state-gc.d.ts +30 -0
  27. package/dist/core/state-gc.js +119 -0
  28. package/dist/core/uninstall.js +12 -4
  29. package/dist/core/v1-bootstrap.js +2 -2
  30. package/dist/engine/compound-cli.d.ts +27 -2
  31. package/dist/engine/compound-cli.js +69 -16
  32. package/dist/engine/compound-export.d.ts +15 -0
  33. package/dist/engine/compound-export.js +32 -5
  34. package/dist/engine/compound-loop.js +3 -2
  35. package/dist/engine/learn-cli.d.ts +1 -0
  36. package/dist/engine/learn-cli.js +234 -0
  37. package/dist/engine/match-eval-log.js +45 -0
  38. package/dist/engine/solution-candidate.d.ts +30 -0
  39. package/dist/engine/solution-candidate.js +124 -0
  40. package/dist/engine/solution-fitness.d.ts +52 -0
  41. package/dist/engine/solution-fitness.js +95 -0
  42. package/dist/engine/solution-fixup.d.ts +30 -0
  43. package/dist/engine/solution-fixup.js +116 -0
  44. package/dist/engine/solution-format.d.ts +8 -2
  45. package/dist/engine/solution-format.js +38 -27
  46. package/dist/engine/solution-index.js +10 -0
  47. package/dist/engine/solution-matcher.d.ts +8 -0
  48. package/dist/engine/solution-matcher.js +27 -1
  49. package/dist/engine/solution-outcomes.d.ts +74 -0
  50. package/dist/engine/solution-outcomes.js +319 -0
  51. package/dist/engine/solution-quarantine.d.ts +36 -0
  52. package/dist/engine/solution-quarantine.js +172 -0
  53. package/dist/engine/solution-weakness.d.ts +45 -0
  54. package/dist/engine/solution-weakness.js +225 -0
  55. package/dist/engine/solution-writer.d.ts +9 -1
  56. package/dist/engine/solution-writer.js +44 -2
  57. package/dist/fgx.js +9 -2
  58. package/dist/forge/cli.js +7 -7
  59. package/dist/hooks/context-guard.js +15 -1
  60. package/dist/hooks/hook-config.d.ts +9 -1
  61. package/dist/hooks/hook-config.js +25 -3
  62. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  63. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  64. package/dist/hooks/notepad-injector.js +6 -3
  65. package/dist/hooks/permission-handler.d.ts +10 -2
  66. package/dist/hooks/permission-handler.js +31 -12
  67. package/dist/hooks/post-tool-failure.js +7 -0
  68. package/dist/hooks/pre-tool-use.js +10 -4
  69. package/dist/hooks/secret-filter.js +6 -0
  70. package/dist/hooks/session-recovery.js +15 -7
  71. package/dist/hooks/shared/hook-response.d.ts +0 -2
  72. package/dist/hooks/shared/hook-response.js +3 -8
  73. package/dist/hooks/shared/hook-timing.js +10 -1
  74. package/dist/hooks/solution-injector.d.ts +21 -0
  75. package/dist/hooks/solution-injector.js +80 -1
  76. package/dist/mcp/solution-reader.d.ts +2 -0
  77. package/dist/mcp/solution-reader.js +28 -1
  78. package/dist/mcp/tools.js +13 -2
  79. package/dist/preset/preset-manager.js +12 -2
  80. package/dist/store/evidence-store.js +5 -5
  81. package/dist/store/profile-store.d.ts +9 -0
  82. package/dist/store/profile-store.js +25 -4
  83. package/dist/store/rule-store.js +8 -8
  84. package/package.json +1 -1
  85. package/plugin.json +7 -2
  86. package/scripts/postinstall.js +52 -5
@@ -0,0 +1,234 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { fixupSolutions } from './solution-fixup.js';
5
+ import { listQuarantined, pruneQuarantine } from './solution-quarantine.js';
6
+ import { computeFitness } from './solution-fitness.js';
7
+ import { buildWeaknessReport, saveWeaknessReport } from './solution-weakness.js';
8
+ import { listCandidates, promoteCandidate, rollbackSince } from './solution-candidate.js';
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');
12
+ export async function handleLearn(args) {
13
+ const sub = args[0];
14
+ if (sub === 'fix-up')
15
+ return runFixUp(args.slice(1));
16
+ if (sub === 'quarantine')
17
+ return runQuarantine(args.slice(1));
18
+ if (sub === 'fitness')
19
+ return runFitness(args.slice(1));
20
+ if (sub === 'evolve')
21
+ return runEvolve(args.slice(1));
22
+ if (sub === 'reset-outcomes')
23
+ return runResetOutcomes(args.slice(1));
24
+ printUsage();
25
+ }
26
+ function printUsage() {
27
+ console.log(`
28
+ forgen learn — solution index maintenance and fitness
29
+
30
+ Usage:
31
+ forgen learn fix-up [--apply] Repair malformed solution frontmatter (dry-run by default)
32
+ forgen learn quarantine [--prune] Show files dropped by the index; --prune removes fixed/deleted
33
+ forgen learn fitness [--json] Show per-solution fitness (accept/correct/error ratios)
34
+ forgen learn evolve [--save|--rollback <ts>|--promote <name>]
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).
40
+ `);
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
+ }
85
+ function runFixUp(args) {
86
+ const apply = args.includes('--apply');
87
+ const result = fixupSolutions(ME_SOLUTIONS, { dryRun: !apply });
88
+ console.log(`\n ${apply ? 'Applied' : 'Dry-run'}: scanned=${result.scanned} fixed=${result.fixed} untouched=${result.untouched} unfixable=${result.unfixable}`);
89
+ for (const rep of result.reports) {
90
+ const rel = path.basename(rep.path);
91
+ if (rep.changed && rep.remaining_errors.length === 0) {
92
+ console.log(` ✓ ${rel} — add: ${rep.added.join(', ')}`);
93
+ }
94
+ else {
95
+ console.log(` ✗ ${rel} — remaining: ${rep.remaining_errors.join('; ')}`);
96
+ }
97
+ }
98
+ if (!apply && result.fixed > 0) {
99
+ console.log(`\n Re-run with --apply to write changes.\n`);
100
+ }
101
+ else if (apply && result.fixed > 0) {
102
+ console.log(`\n Consider: forgen learn quarantine --prune\n`);
103
+ }
104
+ else {
105
+ console.log('');
106
+ }
107
+ }
108
+ function runQuarantine(args) {
109
+ if (args.includes('--prune')) {
110
+ const result = pruneQuarantine();
111
+ console.log(`\n Pruned: removed=${result.removed} kept=${result.kept}\n`);
112
+ return;
113
+ }
114
+ const entries = listQuarantined();
115
+ if (entries.length === 0) {
116
+ console.log(`\n No quarantined solutions. ✓\n`);
117
+ return;
118
+ }
119
+ console.log(`\n Quarantined solutions (${entries.length}):\n`);
120
+ for (const e of entries) {
121
+ const rel = path.basename(e.path);
122
+ console.log(` ${rel} (${e.at})`);
123
+ for (const err of e.errors)
124
+ console.log(` - ${err}`);
125
+ }
126
+ console.log(`\n Fix: forgen learn fix-up --apply → then: forgen learn quarantine --prune\n`);
127
+ }
128
+ function runFitness(args) {
129
+ const records = computeFitness();
130
+ if (args.includes('--json')) {
131
+ console.log(JSON.stringify(records, null, 2));
132
+ return;
133
+ }
134
+ if (records.length === 0) {
135
+ console.log(`\n No outcome events yet. Fitness becomes available after solution injections accumulate.\n`);
136
+ return;
137
+ }
138
+ console.log(`\n Solution Fitness (${records.length} tracked):\n`);
139
+ console.log(` ${'name'.padEnd(48)} ${'state'.padEnd(14)} ${'inj'.padStart(4)} ${'acc/cor/err'.padStart(11)} ${'fit'.padStart(6)}`);
140
+ console.log(` ${'-'.repeat(48)} ${'-'.repeat(14)} ${'-'.repeat(4)} ${'-'.repeat(11)} ${'-'.repeat(6)}`);
141
+ for (const r of records) {
142
+ const name = r.solution.length > 47 ? r.solution.slice(0, 45) + '..' : r.solution;
143
+ const acr = `${r.accepted}/${r.corrected}/${r.errored}`;
144
+ console.log(` ${name.padEnd(48)} ${r.state.padEnd(14)} ${String(r.injected).padStart(4)} ${acr.padStart(11)} ${r.fitness.toFixed(2).padStart(6)}`);
145
+ }
146
+ console.log('');
147
+ }
148
+ function runEvolve(args) {
149
+ const save = args.includes('--save');
150
+ const rollbackIdx = args.indexOf('--rollback');
151
+ const promoteIdx = args.indexOf('--promote');
152
+ if (rollbackIdx >= 0 && args[rollbackIdx + 1]) {
153
+ return runEvolveRollback(args[rollbackIdx + 1]);
154
+ }
155
+ if (promoteIdx >= 0 && args[promoteIdx + 1]) {
156
+ return runEvolvePromote(args[promoteIdx + 1]);
157
+ }
158
+ // Default: generate + optionally save weakness report, print proposer
159
+ // brief so the user can hand it to the ch-solution-evolver agent.
160
+ const report = buildWeaknessReport();
161
+ console.log(`\n Weakness Report @ ${report.generated_at}\n`);
162
+ console.log(` Population: ${report.population.total} solutions`);
163
+ console.log(` champion=${report.population.champion} active=${report.population.active} underperform=${report.population.underperform} draft=${report.population.draft}\n`);
164
+ renderTagRow('Under-served tags', report.under_served_tags.map((t) => `${t.tag} (×${t.correction_mentions})`));
165
+ renderTagRow('Conflict clusters', report.conflict_clusters.map((c) => `${c.shared_tags.slice(0, 2).join('+')}: ${c.champion.name} vs ${c.underperform.name}`));
166
+ renderTagRow('Dead corners', report.dead_corners.map((d) => `${d.solution}: [${d.unique_tags.slice(0, 2).join(',')}]`));
167
+ renderTagRow('Volatile', report.volatile.map((v) => `${v.solution} Δ${v.delta}`));
168
+ if (save) {
169
+ const p = saveWeaknessReport(report);
170
+ console.log(`\n Saved: ${p}`);
171
+ console.log(` Next: invoke the ch-solution-evolver agent with this report, then run:`);
172
+ console.log(` forgen learn evolve --promote <candidate-name> # accept one of the 3 proposals`);
173
+ console.log(` forgen learn evolve --rollback ${Date.now()} # undo this week's candidates`);
174
+ console.log('');
175
+ }
176
+ else {
177
+ console.log(`\n Dry-run. Re-run with --save to persist this report and proceed to proposer.\n`);
178
+ }
179
+ }
180
+ function renderTagRow(label, items) {
181
+ if (items.length === 0) {
182
+ console.log(` ${label}: (none)`);
183
+ return;
184
+ }
185
+ console.log(` ${label}:`);
186
+ for (const item of items.slice(0, 5))
187
+ console.log(` - ${item}`);
188
+ }
189
+ function runEvolveRollback(ts) {
190
+ const epochMs = /^\d+$/.test(ts) ? Number(ts) : Date.parse(ts);
191
+ if (!Number.isFinite(epochMs)) {
192
+ console.log(`\n Invalid timestamp: ${ts}. Use epoch ms or ISO-8601.\n`);
193
+ return;
194
+ }
195
+ const result = rollbackSince(epochMs);
196
+ console.log(`\n Rollback since ${new Date(epochMs).toISOString()}:`);
197
+ if (result.archived.length === 0) {
198
+ console.log(` (no evolved solutions newer than cutoff)\n`);
199
+ return;
200
+ }
201
+ console.log(` Archived ${result.archived.length} file(s) → ${result.archive_dir}`);
202
+ for (const p of result.archived)
203
+ console.log(` - ${path.basename(p)}`);
204
+ if (result.errors.length > 0) {
205
+ console.log(` Errors:`);
206
+ for (const e of result.errors)
207
+ console.log(` ! ${e}`);
208
+ }
209
+ console.log('');
210
+ }
211
+ function runEvolvePromote(candidateNameOrList) {
212
+ if (candidateNameOrList === '--list' || candidateNameOrList === 'list') {
213
+ const found = listCandidates();
214
+ if (found.length === 0) {
215
+ console.log(`\n No pending candidates in ~/.forgen/lab/candidates/\n`);
216
+ return;
217
+ }
218
+ console.log(`\n Pending candidates (${found.length}):`);
219
+ for (const p of found)
220
+ console.log(` - ${path.basename(p, '.md')}`);
221
+ console.log(`\n Promote one: forgen learn evolve --promote <name>\n`);
222
+ return;
223
+ }
224
+ const result = promoteCandidate(candidateNameOrList);
225
+ if (result.ok) {
226
+ console.log(`\n ✓ Promoted: ${path.basename(result.dest)}`);
227
+ console.log(` from: ${result.source}`);
228
+ console.log(` to: ${result.dest}`);
229
+ console.log(` Cold-start bonus active until 5 injections accumulate (auto-promotes to verified).\n`);
230
+ }
231
+ else {
232
+ console.log(`\n ✗ Promotion refused: ${result.reason}\n`);
233
+ }
234
+ }
@@ -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.
@@ -0,0 +1,30 @@
1
+ export interface PromoteResult {
2
+ ok: boolean;
3
+ source?: string;
4
+ dest?: string;
5
+ reason?: string;
6
+ }
7
+ export interface RollbackResult {
8
+ archived: string[];
9
+ archive_dir: string;
10
+ errors: string[];
11
+ }
12
+ export declare function listCandidates(): string[];
13
+ /**
14
+ * Move one candidate file from lab/candidates/ to me/solutions/ after
15
+ * schema + ownership checks. Refuses to overwrite an existing solution.
16
+ * Returns `{ok:false, reason}` for any precondition failure so the CLI
17
+ * can report exactly why promotion was rejected.
18
+ */
19
+ export declare function promoteCandidate(nameOrPath: string): PromoteResult;
20
+ /**
21
+ * Archive evolved-* solutions created at-or-after the given epoch ms.
22
+ * Looks in ME_SOLUTIONS first (live, promoted candidates) then in
23
+ * CANDIDATES_DIR (unpromoted). Archive is a timestamp-suffixed
24
+ * directory so concurrent rollbacks don't clobber each other.
25
+ *
26
+ * "evolved" is identified by `source: evolved` in frontmatter; we
27
+ * deliberately do NOT use filename prefix so a manually-renamed
28
+ * evolved solution can still be rolled back.
29
+ */
30
+ export declare function rollbackSince(epochMs: number): RollbackResult;
@@ -0,0 +1,124 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { ARCHIVED_DIR, CANDIDATES_DIR, ME_SOLUTIONS } from '../core/paths.js';
4
+ import { parseFrontmatterOnly } from './solution-format.js';
5
+ import { diagnoseFromRawContent } from './solution-quarantine.js';
6
+ import { createLogger } from '../core/logger.js';
7
+ const log = createLogger('solution-candidate');
8
+ export function listCandidates() {
9
+ if (!fs.existsSync(CANDIDATES_DIR))
10
+ return [];
11
+ return fs
12
+ .readdirSync(CANDIDATES_DIR)
13
+ .filter((f) => f.endsWith('.md'))
14
+ .map((f) => path.join(CANDIDATES_DIR, f));
15
+ }
16
+ /**
17
+ * Move one candidate file from lab/candidates/ to me/solutions/ after
18
+ * schema + ownership checks. Refuses to overwrite an existing solution.
19
+ * Returns `{ok:false, reason}` for any precondition failure so the CLI
20
+ * can report exactly why promotion was rejected.
21
+ */
22
+ export function promoteCandidate(nameOrPath) {
23
+ const source = resolveCandidatePath(nameOrPath);
24
+ if (!source)
25
+ return { ok: false, reason: `candidate not found: ${nameOrPath}` };
26
+ const content = fs.readFileSync(source, 'utf-8');
27
+ const errors = diagnoseFromRawContent(content);
28
+ if (errors.length > 0) {
29
+ return { ok: false, source, reason: `schema errors: ${errors.join('; ')}` };
30
+ }
31
+ const fm = parseFrontmatterOnly(content);
32
+ if (!fm)
33
+ return { ok: false, source, reason: 'frontmatter parse failed post-diagnose (unexpected)' };
34
+ if (fm.status !== 'candidate') {
35
+ return { ok: false, source, reason: `status must be 'candidate', got '${fm.status}'` };
36
+ }
37
+ if (fm.extractedBy !== 'auto') {
38
+ return { ok: false, source, reason: `extractedBy must be 'auto' (evolved proposals)` };
39
+ }
40
+ const dest = path.join(ME_SOLUTIONS, `${fm.name}.md`);
41
+ if (fs.existsSync(dest)) {
42
+ return { ok: false, source, reason: `name collision: ${fm.name} already exists in me/solutions` };
43
+ }
44
+ fs.mkdirSync(ME_SOLUTIONS, { recursive: true });
45
+ try {
46
+ fs.renameSync(source, dest);
47
+ }
48
+ catch {
49
+ // renameSync fails across filesystems — fall back to copy+unlink.
50
+ fs.copyFileSync(source, dest);
51
+ try {
52
+ fs.unlinkSync(source);
53
+ }
54
+ catch { /* ignore */ }
55
+ }
56
+ log.debug(`promoted: ${fm.name}`);
57
+ return { ok: true, source, dest };
58
+ }
59
+ /**
60
+ * Archive evolved-* solutions created at-or-after the given epoch ms.
61
+ * Looks in ME_SOLUTIONS first (live, promoted candidates) then in
62
+ * CANDIDATES_DIR (unpromoted). Archive is a timestamp-suffixed
63
+ * directory so concurrent rollbacks don't clobber each other.
64
+ *
65
+ * "evolved" is identified by `source: evolved` in frontmatter; we
66
+ * deliberately do NOT use filename prefix so a manually-renamed
67
+ * evolved solution can still be rolled back.
68
+ */
69
+ export function rollbackSince(epochMs) {
70
+ const archiveDir = path.join(ARCHIVED_DIR, `rollback-${Date.now()}`);
71
+ const archived = [];
72
+ const errors = [];
73
+ const dirs = [ME_SOLUTIONS, CANDIDATES_DIR];
74
+ for (const dir of dirs) {
75
+ if (!fs.existsSync(dir))
76
+ continue;
77
+ for (const file of fs.readdirSync(dir)) {
78
+ if (!file.endsWith('.md'))
79
+ continue;
80
+ const filePath = path.join(dir, file);
81
+ let content;
82
+ try {
83
+ content = fs.readFileSync(filePath, 'utf-8');
84
+ }
85
+ catch (e) {
86
+ errors.push(`read ${filePath}: ${errMsg(e)}`);
87
+ continue;
88
+ }
89
+ const fm = parseFrontmatterOnly(content);
90
+ if (!fm)
91
+ continue;
92
+ // `source` is an optional free-form field written by the evolver.
93
+ const source = fm.source;
94
+ if (source !== 'evolved')
95
+ continue;
96
+ // `created` is YAML-formatted date string. If parsing fails or the
97
+ // created date is older than epochMs, leave the file in place.
98
+ const createdMs = Date.parse(fm.created);
99
+ if (Number.isFinite(createdMs) && createdMs < epochMs)
100
+ continue;
101
+ try {
102
+ fs.mkdirSync(archiveDir, { recursive: true });
103
+ const destName = path.basename(dir) + '__' + file;
104
+ fs.renameSync(filePath, path.join(archiveDir, destName));
105
+ archived.push(filePath);
106
+ }
107
+ catch (e) {
108
+ errors.push(`archive ${filePath}: ${errMsg(e)}`);
109
+ }
110
+ }
111
+ }
112
+ return { archived, archive_dir: archiveDir, errors };
113
+ }
114
+ function resolveCandidatePath(nameOrPath) {
115
+ if (fs.existsSync(nameOrPath))
116
+ return nameOrPath;
117
+ const byBasename = path.join(CANDIDATES_DIR, nameOrPath.endsWith('.md') ? nameOrPath : `${nameOrPath}.md`);
118
+ if (fs.existsSync(byBasename))
119
+ return byBasename;
120
+ return null;
121
+ }
122
+ function errMsg(e) {
123
+ return e instanceof Error ? e.message : String(e);
124
+ }
@@ -0,0 +1,52 @@
1
+ import { type OutcomeEvent } from './solution-outcomes.js';
2
+ export type FitnessState = 'draft' | 'active' | 'champion' | 'underperform';
3
+ export interface FitnessRecord {
4
+ solution: string;
5
+ injected: number;
6
+ accepted: number;
7
+ corrected: number;
8
+ errored: number;
9
+ unknown: number;
10
+ /** Laplace-smoothed acceptance ratio × log(1+injected). */
11
+ fitness: number;
12
+ state: FitnessState;
13
+ /** ms since last injection event. Infinity if never injected. */
14
+ last_injected_ago_ms: number;
15
+ }
16
+ export interface FitnessOptions {
17
+ /**
18
+ * Minimum injections required before a solution is evaluated against the
19
+ * underperform threshold. Below this, state stays at `draft`.
20
+ */
21
+ minEvalInjections?: number;
22
+ /**
23
+ * Injections required to qualify as champion (in addition to fitness cut).
24
+ */
25
+ minChampionInjections?: number;
26
+ /**
27
+ * Champion cut: fitness must exceed this fraction of the max fitness in
28
+ * the current population. Default 0.7 → top 30% by ratio of max.
29
+ */
30
+ championFraction?: number;
31
+ /**
32
+ * Underperform cut: fitness must fall below this fraction of the median.
33
+ */
34
+ underperformFraction?: number;
35
+ /** Pre-loaded events (for tests). Defaults to `readAllOutcomes()`. */
36
+ events?: OutcomeEvent[];
37
+ }
38
+ /**
39
+ * Compute fitness scores for every solution with at least one recorded
40
+ * outcome event.
41
+ *
42
+ * Formula: `fitness = (accept + 1) / (accept + correct + error + 1) × log(1 + injected)`
43
+ * - `accept` = positive (silence = consent)
44
+ * - `correct` = negative (explicit user correction within window)
45
+ * - `error` = weak negative (tool failed while solution was pending)
46
+ * - `unknown` = ignored (session ended mid-pending; we can't tell)
47
+ *
48
+ * Epsilon smoothing (+1) means a cold solution with 1 injection and 1
49
+ * accept produces `2/2 × log(2) ≈ 0.69`, not a meaningless `1.0 × 0` or
50
+ * `∞`. Log confidence penalizes small-sample champions.
51
+ */
52
+ export declare function computeFitness(opts?: FitnessOptions): FitnessRecord[];
@@ -0,0 +1,95 @@
1
+ import { readAllOutcomes } from './solution-outcomes.js';
2
+ const DEFAULT_OPTS = {
3
+ minEvalInjections: 5,
4
+ minChampionInjections: 10,
5
+ championFraction: 0.7,
6
+ underperformFraction: 0.3,
7
+ };
8
+ /**
9
+ * Compute fitness scores for every solution with at least one recorded
10
+ * outcome event.
11
+ *
12
+ * Formula: `fitness = (accept + 1) / (accept + correct + error + 1) × log(1 + injected)`
13
+ * - `accept` = positive (silence = consent)
14
+ * - `correct` = negative (explicit user correction within window)
15
+ * - `error` = weak negative (tool failed while solution was pending)
16
+ * - `unknown` = ignored (session ended mid-pending; we can't tell)
17
+ *
18
+ * Epsilon smoothing (+1) means a cold solution with 1 injection and 1
19
+ * accept produces `2/2 × log(2) ≈ 0.69`, not a meaningless `1.0 × 0` or
20
+ * `∞`. Log confidence penalizes small-sample champions.
21
+ */
22
+ export function computeFitness(opts = {}) {
23
+ const config = { ...DEFAULT_OPTS, ...opts };
24
+ const events = opts.events ?? readAllOutcomes();
25
+ const now = Date.now();
26
+ const byName = new Map();
27
+ for (const ev of events) {
28
+ const b = byName.get(ev.solution) ?? { accept: 0, correct: 0, error: 0, unknown: 0, last_inject_ts: 0 };
29
+ if (ev.outcome === 'accept')
30
+ b.accept++;
31
+ else if (ev.outcome === 'correct')
32
+ b.correct++;
33
+ else if (ev.outcome === 'error')
34
+ b.error++;
35
+ else
36
+ b.unknown++;
37
+ // Every event is a proxy for an injection (each outcome represents one
38
+ // inject that resolved). `last_inject_ts` tracks the most recent event
39
+ // timestamp which is also the latest decision time.
40
+ if (ev.ts > b.last_inject_ts)
41
+ b.last_inject_ts = ev.ts;
42
+ byName.set(ev.solution, b);
43
+ }
44
+ // First pass: raw fitness
45
+ const records = [];
46
+ for (const [solution, b] of byName) {
47
+ const injected = b.accept + b.correct + b.error + b.unknown;
48
+ const decided = b.accept + b.correct + b.error; // unknown excluded from ratio
49
+ const ratio = (b.accept + 1) / (decided + 1);
50
+ const confidence = Math.log(1 + injected);
51
+ const fitness = ratio * confidence;
52
+ records.push({
53
+ solution,
54
+ injected,
55
+ accepted: b.accept,
56
+ corrected: b.correct,
57
+ errored: b.error,
58
+ unknown: b.unknown,
59
+ fitness,
60
+ state: 'draft',
61
+ last_injected_ago_ms: b.last_inject_ts === 0 ? Infinity : now - b.last_inject_ts,
62
+ });
63
+ }
64
+ // Population stats for state classification (only solutions past the
65
+ // eval threshold contribute — draft solutions distort max/median).
66
+ const evalPool = records.filter((r) => r.injected >= config.minEvalInjections).map((r) => r.fitness);
67
+ const maxFit = evalPool.length ? Math.max(...evalPool) : 0;
68
+ const medianFit = evalPool.length ? median(evalPool) : 0;
69
+ for (const r of records) {
70
+ r.state = classifyState(r, { maxFit, medianFit, config });
71
+ }
72
+ // Sort: champions first, then active by fitness desc, then underperform,
73
+ // then draft (cold solutions) at the bottom.
74
+ const order = { champion: 0, active: 1, underperform: 2, draft: 3 };
75
+ records.sort((a, b) => order[a.state] - order[b.state] || b.fitness - a.fitness);
76
+ return records;
77
+ }
78
+ function classifyState(r, ctx) {
79
+ const { config, maxFit, medianFit } = ctx;
80
+ if (r.injected < config.minEvalInjections)
81
+ return 'draft';
82
+ if (r.injected >= config.minChampionInjections && r.fitness >= config.championFraction * maxFit) {
83
+ return 'champion';
84
+ }
85
+ if (r.fitness < config.underperformFraction * medianFit)
86
+ return 'underperform';
87
+ return 'active';
88
+ }
89
+ function median(values) {
90
+ if (values.length === 0)
91
+ return 0;
92
+ const sorted = [...values].sort((a, b) => a - b);
93
+ const mid = Math.floor(sorted.length / 2);
94
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
95
+ }
@@ -0,0 +1,30 @@
1
+ export interface FixupReport {
2
+ path: string;
3
+ changed: boolean;
4
+ added: string[];
5
+ remaining_errors: string[];
6
+ }
7
+ export interface FixupResult {
8
+ scanned: number;
9
+ fixed: number;
10
+ untouched: number;
11
+ unfixable: number;
12
+ reports: FixupReport[];
13
+ }
14
+ /**
15
+ * Attempt to repair known-safe frontmatter defects.
16
+ *
17
+ * Handled defects (pre-0.3.1 schema drift, observed on 5 auto-extracted
18
+ * solutions from 2026-04-10):
19
+ * - `extractedBy` missing → add `extractedBy: auto`
20
+ * - `evidence` block missing → add `DEFAULT_EVIDENCE`
21
+ *
22
+ * All other validation errors (bad scope, non-numeric confidence, etc.)
23
+ * are surfaced in `remaining_errors` and the file is left untouched —
24
+ * those require human judgement, not a mechanical default.
25
+ *
26
+ * `dryRun: true` (default) reports what would change without writing.
27
+ */
28
+ export declare function fixupSolutions(solutionsDir: string, opts?: {
29
+ dryRun?: boolean;
30
+ }): FixupResult;