@wooojin/forgen 0.3.0 → 0.3.1

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.
@@ -0,0 +1,242 @@
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 { createLogger } from '../core/logger.js';
6
+ const log = createLogger('solution-outcomes');
7
+ function pendingPath(sessionId) {
8
+ return path.join(STATE_DIR, `outcome-pending-${sanitizeId(sessionId)}.json`);
9
+ }
10
+ function outcomesPath(sessionId) {
11
+ return path.join(OUTCOMES_DIR, `${sanitizeId(sessionId)}.jsonl`);
12
+ }
13
+ function readPending(sessionId) {
14
+ const p = pendingPath(sessionId);
15
+ if (!fs.existsSync(p))
16
+ return { pending: [], last_prompt_ts: 0 };
17
+ try {
18
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
19
+ }
20
+ catch {
21
+ return { pending: [], last_prompt_ts: 0 };
22
+ }
23
+ }
24
+ function writePending(sessionId, state) {
25
+ const p = pendingPath(sessionId);
26
+ fs.mkdirSync(STATE_DIR, { recursive: true });
27
+ fs.writeFileSync(p, JSON.stringify(state));
28
+ }
29
+ function appendOutcome(event) {
30
+ fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
31
+ fs.appendFileSync(outcomesPath(event.session_id), JSON.stringify(event) + '\n');
32
+ }
33
+ /**
34
+ * Record that solutions were injected. Called from solution-injector right
35
+ * after `approveWithContext` is emitted. Fails silently — outcome tracking
36
+ * must never block the user's workflow.
37
+ */
38
+ export function appendPending(sessionId, injections) {
39
+ if (!sessionId || injections.length === 0)
40
+ return;
41
+ try {
42
+ const state = readPending(sessionId);
43
+ const ts = Date.now();
44
+ for (const inj of injections) {
45
+ state.pending.push({ ...inj, ts });
46
+ }
47
+ writePending(sessionId, state);
48
+ }
49
+ catch (e) {
50
+ log.debug(`appendPending failed: ${e instanceof Error ? e.message : String(e)}`);
51
+ }
52
+ }
53
+ /**
54
+ * Flush pending injections as `accept` events. Called when a new user
55
+ * prompt arrives without any intervening correction/error, signaling that
56
+ * the previous injections were silently accepted. "Silence = consent."
57
+ *
58
+ * If `excludeSolutions` is provided, those solutions are NOT flushed (e.g.
59
+ * because an earlier step already attributed them as `correct` or `error`).
60
+ */
61
+ export function flushAccept(sessionId, excludeSolutions = new Set()) {
62
+ if (!sessionId)
63
+ return 0;
64
+ try {
65
+ const state = readPending(sessionId);
66
+ if (state.pending.length === 0)
67
+ return 0;
68
+ const now = Date.now();
69
+ const kept = [];
70
+ let flushed = 0;
71
+ for (const p of state.pending) {
72
+ if (excludeSolutions.has(p.solution))
73
+ continue;
74
+ appendOutcome({
75
+ ts: now,
76
+ session_id: sessionId,
77
+ solution: p.solution,
78
+ match_score: p.match_score,
79
+ injected_chars: p.injected_chars,
80
+ outcome: 'accept',
81
+ outcome_lag_ms: now - p.ts,
82
+ attribution: 'default',
83
+ });
84
+ flushed++;
85
+ }
86
+ writePending(sessionId, { pending: kept, last_prompt_ts: now });
87
+ return flushed;
88
+ }
89
+ catch (e) {
90
+ log.debug(`flushAccept failed: ${e instanceof Error ? e.message : String(e)}`);
91
+ return 0;
92
+ }
93
+ }
94
+ /**
95
+ * Attribute a correction to the most recent pending injection(s). Called
96
+ * from the correction-record MCP tool. Removes attributed entries from
97
+ * pending so subsequent `flushAccept` does not double-count them.
98
+ *
99
+ * Strategy: all currently-pending solutions in this session are marked as
100
+ * `correct`. This is conservative (the correction may target only one of
101
+ * them), but without semantic attribution we err on the side of the user's
102
+ * feedback signal being louder than acceptance.
103
+ */
104
+ export function attributeCorrection(sessionId) {
105
+ if (!sessionId)
106
+ return [];
107
+ try {
108
+ const state = readPending(sessionId);
109
+ if (state.pending.length === 0)
110
+ return [];
111
+ const now = Date.now();
112
+ const attributed = [];
113
+ for (const p of state.pending) {
114
+ appendOutcome({
115
+ ts: now,
116
+ session_id: sessionId,
117
+ solution: p.solution,
118
+ match_score: p.match_score,
119
+ injected_chars: p.injected_chars,
120
+ outcome: 'correct',
121
+ outcome_lag_ms: now - p.ts,
122
+ attribution: 'explicit',
123
+ });
124
+ attributed.push(p.solution);
125
+ }
126
+ writePending(sessionId, { pending: [], last_prompt_ts: state.last_prompt_ts });
127
+ return attributed;
128
+ }
129
+ catch (e) {
130
+ log.debug(`attributeCorrection failed: ${e instanceof Error ? e.message : String(e)}`);
131
+ return [];
132
+ }
133
+ }
134
+ /**
135
+ * Attribute a tool error to pending solutions in this session. Called from
136
+ * post-tool-failure hook. Unlike corrections, errors do not clear pending
137
+ * — an error is a weaker signal and the next user prompt can still produce
138
+ * a correct/accept decision.
139
+ *
140
+ * To avoid flooding the log with duplicate errors for the same pending
141
+ * batch, we cap at one `error` event per (session, solution) pair per
142
+ * pending-cycle by tracking a `error_flagged` set in the pending state.
143
+ */
144
+ export function attributeError(sessionId) {
145
+ if (!sessionId)
146
+ return [];
147
+ try {
148
+ const state = readPending(sessionId);
149
+ if (state.pending.length === 0)
150
+ return [];
151
+ const flaggedKey = `__error_flagged`;
152
+ const existing = state[flaggedKey];
153
+ const flagged = new Set(Array.isArray(existing) ? existing : []);
154
+ const now = Date.now();
155
+ const flaggedThisCall = [];
156
+ for (const p of state.pending) {
157
+ if (flagged.has(p.solution))
158
+ continue;
159
+ appendOutcome({
160
+ ts: now,
161
+ session_id: sessionId,
162
+ solution: p.solution,
163
+ match_score: p.match_score,
164
+ injected_chars: p.injected_chars,
165
+ outcome: 'error',
166
+ outcome_lag_ms: now - p.ts,
167
+ attribution: 'window',
168
+ });
169
+ flagged.add(p.solution);
170
+ flaggedThisCall.push(p.solution);
171
+ }
172
+ state[flaggedKey] = Array.from(flagged);
173
+ writePending(sessionId, state);
174
+ return flaggedThisCall;
175
+ }
176
+ catch (e) {
177
+ log.debug(`attributeError failed: ${e instanceof Error ? e.message : String(e)}`);
178
+ return [];
179
+ }
180
+ }
181
+ /**
182
+ * At session end, any still-pending entries are logged as `unknown` (we
183
+ * can't tell if the user was happy or just stopped). Pending file is
184
+ * removed.
185
+ */
186
+ export function finalizeSession(sessionId) {
187
+ if (!sessionId)
188
+ return 0;
189
+ try {
190
+ const state = readPending(sessionId);
191
+ const now = Date.now();
192
+ let finalized = 0;
193
+ for (const p of state.pending) {
194
+ appendOutcome({
195
+ ts: now,
196
+ session_id: sessionId,
197
+ solution: p.solution,
198
+ match_score: p.match_score,
199
+ injected_chars: p.injected_chars,
200
+ outcome: 'unknown',
201
+ outcome_lag_ms: now - p.ts,
202
+ attribution: 'session_end',
203
+ });
204
+ finalized++;
205
+ }
206
+ const p = pendingPath(sessionId);
207
+ if (fs.existsSync(p))
208
+ fs.unlinkSync(p);
209
+ return finalized;
210
+ }
211
+ catch (e) {
212
+ log.debug(`finalizeSession failed: ${e instanceof Error ? e.message : String(e)}`);
213
+ return 0;
214
+ }
215
+ }
216
+ /**
217
+ * Read all outcome events across all sessions. Used by fitness
218
+ * calculation. Returns events sorted by timestamp ascending.
219
+ */
220
+ export function readAllOutcomes() {
221
+ if (!fs.existsSync(OUTCOMES_DIR))
222
+ return [];
223
+ const events = [];
224
+ for (const file of fs.readdirSync(OUTCOMES_DIR)) {
225
+ if (!file.endsWith('.jsonl'))
226
+ continue;
227
+ try {
228
+ const text = fs.readFileSync(path.join(OUTCOMES_DIR, file), 'utf-8');
229
+ for (const line of text.split('\n')) {
230
+ if (!line)
231
+ continue;
232
+ try {
233
+ events.push(JSON.parse(line));
234
+ }
235
+ catch { /* skip bad line */ }
236
+ }
237
+ }
238
+ catch { /* skip */ }
239
+ }
240
+ events.sort((a, b) => a.ts - b.ts);
241
+ return events;
242
+ }
@@ -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;