@wooojin/forgen 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +7 -2
- package/CHANGELOG.md +132 -0
- package/README.ja.md +29 -0
- package/README.ko.md +29 -0
- package/README.md +36 -3
- package/README.zh.md +29 -0
- package/agents/solution-evolver.md +115 -0
- package/dist/cli.js +11 -3
- package/dist/core/auto-compound-runner.js +6 -3
- package/dist/core/dashboard.js +57 -4
- package/dist/core/doctor.d.ts +6 -1
- package/dist/core/doctor.js +21 -1
- package/dist/core/global-config.d.ts +2 -2
- package/dist/core/global-config.js +6 -14
- package/dist/core/harness.d.ts +3 -5
- package/dist/core/harness.js +34 -338
- package/dist/core/installer.d.ts +10 -0
- package/dist/core/installer.js +185 -0
- package/dist/core/paths.d.ts +25 -34
- package/dist/core/paths.js +25 -35
- package/dist/core/settings-injector.d.ts +13 -0
- package/dist/core/settings-injector.js +167 -0
- package/dist/core/settings-lock.d.ts +35 -2
- package/dist/core/settings-lock.js +65 -7
- package/dist/core/spawn.js +100 -39
- package/dist/core/state-gc.d.ts +30 -0
- package/dist/core/state-gc.js +119 -0
- package/dist/core/uninstall.js +12 -4
- package/dist/core/v1-bootstrap.js +2 -2
- package/dist/engine/compound-cli.d.ts +27 -2
- package/dist/engine/compound-cli.js +69 -16
- package/dist/engine/compound-export.d.ts +15 -0
- package/dist/engine/compound-export.js +32 -5
- package/dist/engine/compound-loop.js +3 -2
- package/dist/engine/learn-cli.d.ts +1 -0
- package/dist/engine/learn-cli.js +234 -0
- package/dist/engine/match-eval-log.js +45 -0
- package/dist/engine/solution-candidate.d.ts +30 -0
- package/dist/engine/solution-candidate.js +124 -0
- package/dist/engine/solution-fitness.d.ts +52 -0
- package/dist/engine/solution-fitness.js +95 -0
- package/dist/engine/solution-fixup.d.ts +30 -0
- package/dist/engine/solution-fixup.js +116 -0
- package/dist/engine/solution-format.d.ts +8 -2
- package/dist/engine/solution-format.js +38 -27
- package/dist/engine/solution-index.js +10 -0
- package/dist/engine/solution-matcher.d.ts +8 -0
- package/dist/engine/solution-matcher.js +27 -1
- package/dist/engine/solution-outcomes.d.ts +74 -0
- package/dist/engine/solution-outcomes.js +319 -0
- package/dist/engine/solution-quarantine.d.ts +36 -0
- package/dist/engine/solution-quarantine.js +172 -0
- package/dist/engine/solution-weakness.d.ts +45 -0
- package/dist/engine/solution-weakness.js +225 -0
- package/dist/engine/solution-writer.d.ts +9 -1
- package/dist/engine/solution-writer.js +44 -2
- package/dist/fgx.js +9 -2
- package/dist/forge/cli.js +7 -7
- package/dist/hooks/context-guard.js +15 -1
- package/dist/hooks/hook-config.d.ts +9 -1
- package/dist/hooks/hook-config.js +25 -3
- package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
- package/dist/hooks/internal/run-lifecycle-check.js +32 -0
- package/dist/hooks/notepad-injector.js +6 -3
- package/dist/hooks/permission-handler.d.ts +10 -2
- package/dist/hooks/permission-handler.js +31 -12
- package/dist/hooks/post-tool-failure.js +7 -0
- package/dist/hooks/pre-tool-use.js +10 -4
- package/dist/hooks/secret-filter.js +6 -0
- package/dist/hooks/session-recovery.js +15 -7
- package/dist/hooks/shared/hook-response.d.ts +0 -2
- package/dist/hooks/shared/hook-response.js +3 -8
- package/dist/hooks/shared/hook-timing.js +10 -1
- package/dist/hooks/solution-injector.d.ts +21 -0
- package/dist/hooks/solution-injector.js +80 -1
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +13 -2
- package/dist/preset/preset-manager.js +12 -2
- package/dist/store/evidence-store.js +5 -5
- package/dist/store/profile-store.d.ts +9 -0
- package/dist/store/profile-store.js +25 -4
- package/dist/store/rule-store.js +8 -8
- package/package.json +1 -1
- package/plugin.json +7 -2
- package/scripts/postinstall.js +52 -5
|
@@ -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;
|