@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,319 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { OUTCOMES_DIR, STATE_DIR } from '../core/paths.js';
|
|
4
|
+
import { sanitizeId } from '../hooks/shared/sanitize-id.js';
|
|
5
|
+
import { withFileLockSync, FileLockError } from '../hooks/shared/file-lock.js';
|
|
6
|
+
import { atomicWriteJSON } from '../hooks/shared/atomic-write.js';
|
|
7
|
+
import { createLogger } from '../core/logger.js';
|
|
8
|
+
const log = createLogger('solution-outcomes');
|
|
9
|
+
function pendingPath(sessionId) {
|
|
10
|
+
return path.join(STATE_DIR, `outcome-pending-${sanitizeId(sessionId)}.json`);
|
|
11
|
+
}
|
|
12
|
+
function outcomesPath(sessionId) {
|
|
13
|
+
return path.join(OUTCOMES_DIR, `${sanitizeId(sessionId)}.jsonl`);
|
|
14
|
+
}
|
|
15
|
+
function readPending(sessionId) {
|
|
16
|
+
const p = pendingPath(sessionId);
|
|
17
|
+
if (!fs.existsSync(p))
|
|
18
|
+
return { pending: [], last_prompt_ts: 0 };
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { pending: [], last_prompt_ts: 0 };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writePending(sessionId, state) {
|
|
27
|
+
const p = pendingPath(sessionId);
|
|
28
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
29
|
+
atomicWriteJSON(p, state, { mode: 0o600, dirMode: 0o700 });
|
|
30
|
+
}
|
|
31
|
+
function appendOutcome(event) {
|
|
32
|
+
fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
|
|
33
|
+
fs.appendFileSync(outcomesPath(event.session_id), JSON.stringify(event) + '\n');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Run a read-modify-write pending-state mutation under a file lock
|
|
37
|
+
* (2026-04-21 audit fix #9).
|
|
38
|
+
*
|
|
39
|
+
* Prior code (`readPending` → in-memory mutate → `writePending`) was not
|
|
40
|
+
* serialised, so inject + correction + error hooks racing on the same
|
|
41
|
+
* session could lose or duplicate outcome events. The entire critical
|
|
42
|
+
* section now sits inside `withFileLockSync`; the lock file lives next
|
|
43
|
+
* to the pending file itself. A lock-acquire failure falls through
|
|
44
|
+
* fail-open (outcome tracking must never block the user).
|
|
45
|
+
*/
|
|
46
|
+
function mutatePending(sessionId, fn) {
|
|
47
|
+
const p = pendingPath(sessionId);
|
|
48
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
49
|
+
try {
|
|
50
|
+
let result = null;
|
|
51
|
+
withFileLockSync(p, () => {
|
|
52
|
+
result = fn();
|
|
53
|
+
});
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
if (e instanceof FileLockError) {
|
|
58
|
+
log.debug(`pending lock 실패 — skip (fail-open): ${e.message}`);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Record that solutions were injected. Called from solution-injector right
|
|
66
|
+
* after `approveWithContext` is emitted. Fails silently — outcome tracking
|
|
67
|
+
* must never block the user's workflow.
|
|
68
|
+
*/
|
|
69
|
+
export function appendPending(sessionId, injections) {
|
|
70
|
+
if (!sessionId || injections.length === 0)
|
|
71
|
+
return;
|
|
72
|
+
try {
|
|
73
|
+
mutatePending(sessionId, () => {
|
|
74
|
+
const state = readPending(sessionId);
|
|
75
|
+
const ts = Date.now();
|
|
76
|
+
for (const inj of injections) {
|
|
77
|
+
state.pending.push({ ...inj, ts });
|
|
78
|
+
}
|
|
79
|
+
writePending(sessionId, state);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
log.debug(`appendPending failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Flush pending injections as `accept` events. Called when a new user
|
|
88
|
+
* prompt arrives without any intervening correction/error, signaling that
|
|
89
|
+
* the previous injections were silently accepted. "Silence = consent."
|
|
90
|
+
*
|
|
91
|
+
* If `excludeSolutions` is provided, those solutions are NOT flushed (e.g.
|
|
92
|
+
* because an earlier step already attributed them as `correct` or `error`).
|
|
93
|
+
*/
|
|
94
|
+
export function flushAccept(sessionId, excludeSolutions = new Set()) {
|
|
95
|
+
if (!sessionId)
|
|
96
|
+
return 0;
|
|
97
|
+
try {
|
|
98
|
+
const result = mutatePending(sessionId, () => {
|
|
99
|
+
const state = readPending(sessionId);
|
|
100
|
+
if (state.pending.length === 0)
|
|
101
|
+
return 0;
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const kept = [];
|
|
104
|
+
let flushed = 0;
|
|
105
|
+
for (const p of state.pending) {
|
|
106
|
+
// P1-L1 fix (2026-04-20): 이전에는 excluded pending을 `continue`로 건너뛰면서
|
|
107
|
+
// `kept`에도 push 안 하고 appendOutcome도 안 해서 증거 없이 사라졌다.
|
|
108
|
+
// 이미 correct/error로 attribute된 항목은 보존 (나중 prompt에서 재처리 방지).
|
|
109
|
+
if (excludeSolutions.has(p.solution)) {
|
|
110
|
+
kept.push(p);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
appendOutcome({
|
|
114
|
+
ts: now,
|
|
115
|
+
session_id: sessionId,
|
|
116
|
+
solution: p.solution,
|
|
117
|
+
match_score: p.match_score,
|
|
118
|
+
injected_chars: p.injected_chars,
|
|
119
|
+
outcome: 'accept',
|
|
120
|
+
outcome_lag_ms: now - p.ts,
|
|
121
|
+
attribution: 'default',
|
|
122
|
+
});
|
|
123
|
+
flushed++;
|
|
124
|
+
}
|
|
125
|
+
writePending(sessionId, { pending: kept, last_prompt_ts: now });
|
|
126
|
+
return flushed;
|
|
127
|
+
});
|
|
128
|
+
return result ?? 0;
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
log.debug(`flushAccept failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Attribute a correction to the most recent pending injection(s). Called
|
|
137
|
+
* from the correction-record MCP tool. Removes attributed entries from
|
|
138
|
+
* pending so subsequent `flushAccept` does not double-count them.
|
|
139
|
+
*
|
|
140
|
+
* Strategy: all currently-pending solutions in this session are marked as
|
|
141
|
+
* `correct`. This is conservative (the correction may target only one of
|
|
142
|
+
* them), but without semantic attribution we err on the side of the user's
|
|
143
|
+
* feedback signal being louder than acceptance.
|
|
144
|
+
*/
|
|
145
|
+
export function attributeCorrection(sessionId) {
|
|
146
|
+
if (!sessionId)
|
|
147
|
+
return [];
|
|
148
|
+
try {
|
|
149
|
+
const result = mutatePending(sessionId, () => {
|
|
150
|
+
const state = readPending(sessionId);
|
|
151
|
+
if (state.pending.length === 0)
|
|
152
|
+
return [];
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const attributed = [];
|
|
155
|
+
for (const p of state.pending) {
|
|
156
|
+
appendOutcome({
|
|
157
|
+
ts: now,
|
|
158
|
+
session_id: sessionId,
|
|
159
|
+
solution: p.solution,
|
|
160
|
+
match_score: p.match_score,
|
|
161
|
+
injected_chars: p.injected_chars,
|
|
162
|
+
outcome: 'correct',
|
|
163
|
+
outcome_lag_ms: now - p.ts,
|
|
164
|
+
attribution: 'explicit',
|
|
165
|
+
});
|
|
166
|
+
attributed.push(p.solution);
|
|
167
|
+
}
|
|
168
|
+
writePending(sessionId, { pending: [], last_prompt_ts: state.last_prompt_ts });
|
|
169
|
+
return attributed;
|
|
170
|
+
});
|
|
171
|
+
return result ?? [];
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
log.debug(`attributeCorrection failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Attribution gates for error events (2026-04-21 fix):
|
|
180
|
+
* - MIN_ERROR_MATCH_SCORE: solutions injected on thin relevance (< 0.3)
|
|
181
|
+
* are unlikely to have caused unrelated tool failures. Attributing
|
|
182
|
+
* error to them distorts fitness and poisons the Phase 4 evolver.
|
|
183
|
+
* - MAX_ATTRIBUTION_LAG_MS: errors more than 5 minutes after injection
|
|
184
|
+
* are too temporally distant — the user has likely moved on to work
|
|
185
|
+
* the solution has no bearing on.
|
|
186
|
+
* - MAX_ATTRIBUTED_PER_ERROR: cap at the top 3 most-relevant pending
|
|
187
|
+
* solutions so a broad inject batch doesn't scatter false blame.
|
|
188
|
+
*
|
|
189
|
+
* Data audit (2026-04-21, ~/.forgen/state/outcomes/ 260 sessions):
|
|
190
|
+
* Top-3 "error" solutions = 80% of all error events, injected at score
|
|
191
|
+
* 0.15/0.21 nearly every session. Without these gates, fitness
|
|
192
|
+
* classification drags good-but-low-score solutions into underperform.
|
|
193
|
+
*/
|
|
194
|
+
const MIN_ERROR_MATCH_SCORE = 0.3;
|
|
195
|
+
const MAX_ATTRIBUTION_LAG_MS = 5 * 60 * 1000;
|
|
196
|
+
const MAX_ATTRIBUTED_PER_ERROR = 3;
|
|
197
|
+
/**
|
|
198
|
+
* Attribute a tool error to pending solutions in this session. Called from
|
|
199
|
+
* post-tool-failure hook. Unlike corrections, errors do not clear pending
|
|
200
|
+
* — an error is a weaker signal and the next user prompt can still produce
|
|
201
|
+
* a correct/accept decision.
|
|
202
|
+
*
|
|
203
|
+
* Only the top-K most-relevant, recent, above-threshold pending solutions
|
|
204
|
+
* are attributed (see gates above). Below-threshold or stale pending
|
|
205
|
+
* entries are left untouched — they will resolve via accept/unknown later.
|
|
206
|
+
*
|
|
207
|
+
* To avoid flooding the log with duplicate errors for the same pending
|
|
208
|
+
* batch, we cap at one `error` event per (session, solution) pair per
|
|
209
|
+
* pending-cycle by tracking a `error_flagged` set in the pending state.
|
|
210
|
+
*/
|
|
211
|
+
export function attributeError(sessionId) {
|
|
212
|
+
if (!sessionId)
|
|
213
|
+
return [];
|
|
214
|
+
try {
|
|
215
|
+
const result = mutatePending(sessionId, () => {
|
|
216
|
+
const state = readPending(sessionId);
|
|
217
|
+
if (state.pending.length === 0)
|
|
218
|
+
return [];
|
|
219
|
+
const flaggedKey = `__error_flagged`;
|
|
220
|
+
const existing = state[flaggedKey];
|
|
221
|
+
const flagged = new Set(Array.isArray(existing) ? existing : []);
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
const eligible = state.pending
|
|
224
|
+
.filter((p) => !flagged.has(p.solution))
|
|
225
|
+
.filter((p) => p.match_score >= MIN_ERROR_MATCH_SCORE)
|
|
226
|
+
.filter((p) => now - p.ts <= MAX_ATTRIBUTION_LAG_MS)
|
|
227
|
+
.sort((a, b) => b.match_score - a.match_score)
|
|
228
|
+
.slice(0, MAX_ATTRIBUTED_PER_ERROR);
|
|
229
|
+
const flaggedThisCall = [];
|
|
230
|
+
for (const p of eligible) {
|
|
231
|
+
appendOutcome({
|
|
232
|
+
ts: now,
|
|
233
|
+
session_id: sessionId,
|
|
234
|
+
solution: p.solution,
|
|
235
|
+
match_score: p.match_score,
|
|
236
|
+
injected_chars: p.injected_chars,
|
|
237
|
+
outcome: 'error',
|
|
238
|
+
outcome_lag_ms: now - p.ts,
|
|
239
|
+
attribution: 'window',
|
|
240
|
+
});
|
|
241
|
+
flagged.add(p.solution);
|
|
242
|
+
flaggedThisCall.push(p.solution);
|
|
243
|
+
}
|
|
244
|
+
state[flaggedKey] = Array.from(flagged);
|
|
245
|
+
writePending(sessionId, state);
|
|
246
|
+
return flaggedThisCall;
|
|
247
|
+
});
|
|
248
|
+
return result ?? [];
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
log.debug(`attributeError failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* At session end, any still-pending entries are logged as `unknown` (we
|
|
257
|
+
* can't tell if the user was happy or just stopped). Pending file is
|
|
258
|
+
* removed.
|
|
259
|
+
*/
|
|
260
|
+
export function finalizeSession(sessionId) {
|
|
261
|
+
if (!sessionId)
|
|
262
|
+
return 0;
|
|
263
|
+
try {
|
|
264
|
+
const result = mutatePending(sessionId, () => {
|
|
265
|
+
const state = readPending(sessionId);
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
let finalized = 0;
|
|
268
|
+
for (const p of state.pending) {
|
|
269
|
+
appendOutcome({
|
|
270
|
+
ts: now,
|
|
271
|
+
session_id: sessionId,
|
|
272
|
+
solution: p.solution,
|
|
273
|
+
match_score: p.match_score,
|
|
274
|
+
injected_chars: p.injected_chars,
|
|
275
|
+
outcome: 'unknown',
|
|
276
|
+
outcome_lag_ms: now - p.ts,
|
|
277
|
+
attribution: 'session_end',
|
|
278
|
+
});
|
|
279
|
+
finalized++;
|
|
280
|
+
}
|
|
281
|
+
const pendingFile = pendingPath(sessionId);
|
|
282
|
+
if (fs.existsSync(pendingFile))
|
|
283
|
+
fs.unlinkSync(pendingFile);
|
|
284
|
+
return finalized;
|
|
285
|
+
});
|
|
286
|
+
return result ?? 0;
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
log.debug(`finalizeSession failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Read all outcome events across all sessions. Used by fitness
|
|
295
|
+
* calculation. Returns events sorted by timestamp ascending.
|
|
296
|
+
*/
|
|
297
|
+
export function readAllOutcomes() {
|
|
298
|
+
if (!fs.existsSync(OUTCOMES_DIR))
|
|
299
|
+
return [];
|
|
300
|
+
const events = [];
|
|
301
|
+
for (const file of fs.readdirSync(OUTCOMES_DIR)) {
|
|
302
|
+
if (!file.endsWith('.jsonl'))
|
|
303
|
+
continue;
|
|
304
|
+
try {
|
|
305
|
+
const text = fs.readFileSync(path.join(OUTCOMES_DIR, file), 'utf-8');
|
|
306
|
+
for (const line of text.split('\n')) {
|
|
307
|
+
if (!line)
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
events.push(JSON.parse(line));
|
|
311
|
+
}
|
|
312
|
+
catch { /* skip bad line */ }
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch { /* skip */ }
|
|
316
|
+
}
|
|
317
|
+
events.sort((a, b) => a.ts - b.ts);
|
|
318
|
+
return events;
|
|
319
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
interface QuarantineEntry {
|
|
2
|
+
path: string;
|
|
3
|
+
at: string;
|
|
4
|
+
errors: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Produce actionable frontmatter diagnostics directly from file content.
|
|
8
|
+
*
|
|
9
|
+
* This duplicates the YAML parse that `parseFrontmatterOnly` already does,
|
|
10
|
+
* but it runs only on the rare failure path (solution dropped from index),
|
|
11
|
+
* so the overhead is acceptable in exchange for a human-readable error list.
|
|
12
|
+
*/
|
|
13
|
+
export declare function diagnoseFromRawContent(content: string): string[];
|
|
14
|
+
/**
|
|
15
|
+
* Append one quarantine entry for `filePath`. Deduped by path within the
|
|
16
|
+
* current file: if the latest entry for this path already matches the
|
|
17
|
+
* current errors, skip the append.
|
|
18
|
+
*
|
|
19
|
+
* Storage: one JSONL line per quarantine event. Readers use only the
|
|
20
|
+
* latest line per path.
|
|
21
|
+
*/
|
|
22
|
+
export declare function recordQuarantine(filePath: string, errors: string[]): void;
|
|
23
|
+
/**
|
|
24
|
+
* Read the latest quarantine state: one entry per path, keyed to the most
|
|
25
|
+
* recent append. Entries whose file no longer exists are dropped.
|
|
26
|
+
*/
|
|
27
|
+
export declare function listQuarantined(): QuarantineEntry[];
|
|
28
|
+
/**
|
|
29
|
+
* Clear quarantine entries for files that now parse correctly or no longer
|
|
30
|
+
* exist. Intended to be called after `forgen learn fix-up` or a manual edit.
|
|
31
|
+
*/
|
|
32
|
+
export declare function pruneQuarantine(): {
|
|
33
|
+
removed: number;
|
|
34
|
+
kept: number;
|
|
35
|
+
};
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { SOLUTION_QUARANTINE_PATH, STATE_DIR } from '../core/paths.js';
|
|
5
|
+
import { diagnoseFrontmatter } from './solution-format.js';
|
|
6
|
+
import { createLogger } from '../core/logger.js';
|
|
7
|
+
const log = createLogger('solution-quarantine');
|
|
8
|
+
/**
|
|
9
|
+
* Produce actionable frontmatter diagnostics directly from file content.
|
|
10
|
+
*
|
|
11
|
+
* This duplicates the YAML parse that `parseFrontmatterOnly` already does,
|
|
12
|
+
* but it runs only on the rare failure path (solution dropped from index),
|
|
13
|
+
* so the overhead is acceptable in exchange for a human-readable error list.
|
|
14
|
+
*/
|
|
15
|
+
export function diagnoseFromRawContent(content) {
|
|
16
|
+
const trimmed = content.trimStart();
|
|
17
|
+
if (!trimmed.startsWith('---'))
|
|
18
|
+
return ['no YAML frontmatter (missing leading ---)'];
|
|
19
|
+
const endIdx = trimmed.indexOf('---', 3);
|
|
20
|
+
if (endIdx === -1)
|
|
21
|
+
return ['frontmatter not closed (missing trailing ---)'];
|
|
22
|
+
const raw = trimmed.slice(3, endIdx);
|
|
23
|
+
if (raw.length > 5000)
|
|
24
|
+
return ['frontmatter too large (>5000 chars — YAML bomb guard)'];
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
return [`YAML parse error: ${e instanceof Error ? e.message : String(e)}`];
|
|
31
|
+
}
|
|
32
|
+
return diagnoseFrontmatter(parsed);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Append one quarantine entry for `filePath`. Deduped by path within the
|
|
36
|
+
* current file: if the latest entry for this path already matches the
|
|
37
|
+
* current errors, skip the append.
|
|
38
|
+
*
|
|
39
|
+
* Storage: one JSONL line per quarantine event. Readers use only the
|
|
40
|
+
* latest line per path.
|
|
41
|
+
*/
|
|
42
|
+
export function recordQuarantine(filePath, errors) {
|
|
43
|
+
try {
|
|
44
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
45
|
+
if (dedupeHit(filePath, errors))
|
|
46
|
+
return;
|
|
47
|
+
const entry = {
|
|
48
|
+
path: filePath,
|
|
49
|
+
at: new Date().toISOString(),
|
|
50
|
+
errors,
|
|
51
|
+
};
|
|
52
|
+
fs.appendFileSync(SOLUTION_QUARANTINE_PATH, JSON.stringify(entry) + '\n');
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
log.debug(`quarantine write failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function dedupeHit(filePath, errors) {
|
|
59
|
+
if (!fs.existsSync(SOLUTION_QUARANTINE_PATH))
|
|
60
|
+
return false;
|
|
61
|
+
try {
|
|
62
|
+
const text = fs.readFileSync(SOLUTION_QUARANTINE_PATH, 'utf-8');
|
|
63
|
+
const lines = text.split('\n').filter(Boolean);
|
|
64
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
65
|
+
let prev;
|
|
66
|
+
try {
|
|
67
|
+
prev = JSON.parse(lines[i]);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (prev.path !== filePath)
|
|
73
|
+
continue;
|
|
74
|
+
if (sameErrors(prev.errors, errors))
|
|
75
|
+
return true;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch { /* ignore */ }
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
function sameErrors(a, b) {
|
|
83
|
+
if (a.length !== b.length)
|
|
84
|
+
return false;
|
|
85
|
+
const sa = [...a].sort();
|
|
86
|
+
const sb = [...b].sort();
|
|
87
|
+
for (let i = 0; i < sa.length; i++)
|
|
88
|
+
if (sa[i] !== sb[i])
|
|
89
|
+
return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read the latest quarantine state: one entry per path, keyed to the most
|
|
94
|
+
* recent append. Entries whose file no longer exists are dropped.
|
|
95
|
+
*/
|
|
96
|
+
export function listQuarantined() {
|
|
97
|
+
if (!fs.existsSync(SOLUTION_QUARANTINE_PATH))
|
|
98
|
+
return [];
|
|
99
|
+
let text;
|
|
100
|
+
try {
|
|
101
|
+
text = fs.readFileSync(SOLUTION_QUARANTINE_PATH, 'utf-8');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
const byPath = new Map();
|
|
107
|
+
for (const line of text.split('\n')) {
|
|
108
|
+
if (!line)
|
|
109
|
+
continue;
|
|
110
|
+
try {
|
|
111
|
+
const entry = JSON.parse(line);
|
|
112
|
+
byPath.set(entry.path, entry);
|
|
113
|
+
}
|
|
114
|
+
catch { /* skip bad line */ }
|
|
115
|
+
}
|
|
116
|
+
const result = [];
|
|
117
|
+
for (const entry of byPath.values()) {
|
|
118
|
+
try {
|
|
119
|
+
if (fs.existsSync(entry.path))
|
|
120
|
+
result.push(entry);
|
|
121
|
+
}
|
|
122
|
+
catch { /* skip */ }
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Clear quarantine entries for files that now parse correctly or no longer
|
|
128
|
+
* exist. Intended to be called after `forgen learn fix-up` or a manual edit.
|
|
129
|
+
*/
|
|
130
|
+
export function pruneQuarantine() {
|
|
131
|
+
if (!fs.existsSync(SOLUTION_QUARANTINE_PATH))
|
|
132
|
+
return { removed: 0, kept: 0 };
|
|
133
|
+
// Read raw entries without listQuarantined's existsSync filter so we can
|
|
134
|
+
// count deleted files as removed rather than silently dropping them.
|
|
135
|
+
const byPath = new Map();
|
|
136
|
+
try {
|
|
137
|
+
const text = fs.readFileSync(SOLUTION_QUARANTINE_PATH, 'utf-8');
|
|
138
|
+
for (const line of text.split('\n')) {
|
|
139
|
+
if (!line)
|
|
140
|
+
continue;
|
|
141
|
+
try {
|
|
142
|
+
const entry = JSON.parse(line);
|
|
143
|
+
byPath.set(entry.path, entry);
|
|
144
|
+
}
|
|
145
|
+
catch { /* skip bad line */ }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch { /* empty */ }
|
|
149
|
+
const stillBad = [];
|
|
150
|
+
let removed = 0;
|
|
151
|
+
for (const entry of byPath.values()) {
|
|
152
|
+
let content;
|
|
153
|
+
try {
|
|
154
|
+
content = fs.readFileSync(entry.path, 'utf-8');
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
removed++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const errors = diagnoseFromRawContent(content);
|
|
161
|
+
if (errors.length === 0) {
|
|
162
|
+
removed++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
stillBad.push({ ...entry, errors });
|
|
166
|
+
}
|
|
167
|
+
const dir = path.dirname(SOLUTION_QUARANTINE_PATH);
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
169
|
+
const text = stillBad.map((e) => JSON.stringify(e)).join('\n') + (stillBad.length ? '\n' : '');
|
|
170
|
+
fs.writeFileSync(SOLUTION_QUARANTINE_PATH, text);
|
|
171
|
+
return { removed, kept: stillBad.length };
|
|
172
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface UnderServedTag {
|
|
2
|
+
tag: string;
|
|
3
|
+
correction_mentions: number;
|
|
4
|
+
best_matching_champion: string | null;
|
|
5
|
+
best_fitness: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ConflictCluster {
|
|
8
|
+
shared_tags: string[];
|
|
9
|
+
champion: {
|
|
10
|
+
name: string;
|
|
11
|
+
fitness: number;
|
|
12
|
+
};
|
|
13
|
+
underperform: {
|
|
14
|
+
name: string;
|
|
15
|
+
fitness: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export interface DeadCorner {
|
|
19
|
+
solution: string;
|
|
20
|
+
unique_tags: string[];
|
|
21
|
+
injected: number;
|
|
22
|
+
}
|
|
23
|
+
export interface VolatileSolution {
|
|
24
|
+
solution: string;
|
|
25
|
+
accept_rate_window_a: number;
|
|
26
|
+
accept_rate_window_b: number;
|
|
27
|
+
delta: number;
|
|
28
|
+
}
|
|
29
|
+
export interface WeaknessReport {
|
|
30
|
+
generated_at: string;
|
|
31
|
+
population: {
|
|
32
|
+
total: number;
|
|
33
|
+
champion: number;
|
|
34
|
+
active: number;
|
|
35
|
+
underperform: number;
|
|
36
|
+
draft: number;
|
|
37
|
+
};
|
|
38
|
+
under_served_tags: UnderServedTag[];
|
|
39
|
+
conflict_clusters: ConflictCluster[];
|
|
40
|
+
dead_corners: DeadCorner[];
|
|
41
|
+
volatile: VolatileSolution[];
|
|
42
|
+
}
|
|
43
|
+
export declare function buildWeaknessReport(solutionsDir?: string): WeaknessReport;
|
|
44
|
+
export declare function saveWeaknessReport(report: WeaknessReport): string;
|
|
45
|
+
export declare function latestWeaknessReport(): WeaknessReport | null;
|