@wooojin/forgen 0.2.1 → 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.
- package/CHANGELOG.md +76 -0
- package/README.ko.md +25 -14
- package/README.md +61 -17
- package/agents/analyst.md +48 -4
- package/agents/architect.md +39 -4
- package/agents/code-reviewer.md +107 -77
- package/agents/critic.md +47 -4
- package/agents/debugger.md +46 -4
- package/agents/designer.md +40 -4
- package/agents/executor.md +112 -30
- package/agents/explore.md +45 -5
- package/agents/git-master.md +48 -4
- package/agents/planner.md +121 -18
- package/agents/solution-evolver.md +115 -0
- package/agents/test-engineer.md +58 -4
- package/agents/verifier.md +92 -77
- package/commands/architecture-decision.md +127 -258
- package/commands/calibrate.md +225 -0
- package/commands/code-review.md +163 -178
- package/commands/compound.md +127 -68
- package/commands/deep-interview.md +212 -110
- package/commands/docker.md +68 -178
- package/commands/forge-loop.md +215 -0
- package/commands/learn.md +231 -0
- package/commands/retro.md +215 -0
- package/commands/ship.md +277 -0
- package/dist/cli.js +25 -9
- package/dist/core/auto-compound-runner.js +14 -0
- package/dist/core/config-injector.d.ts +2 -1
- package/dist/core/config-injector.js +2 -1
- package/dist/core/dashboard.d.ts +17 -0
- package/dist/core/dashboard.js +158 -2
- package/dist/core/harness.d.ts +6 -1
- package/dist/core/harness.js +75 -19
- package/dist/core/paths.d.ts +31 -1
- package/dist/core/paths.js +43 -2
- package/dist/core/spawn.d.ts +3 -2
- package/dist/core/spawn.js +27 -8
- package/dist/core/types.d.ts +34 -0
- package/dist/engine/compound-lifecycle.d.ts +4 -3
- package/dist/engine/compound-lifecycle.js +91 -46
- package/dist/engine/learn-cli.d.ts +1 -0
- package/dist/engine/learn-cli.js +182 -0
- package/dist/engine/meta-learning/adaptive-thresholds.d.ts +20 -0
- package/dist/engine/meta-learning/adaptive-thresholds.js +126 -0
- package/dist/engine/meta-learning/extraction-tuner.d.ts +15 -0
- package/dist/engine/meta-learning/extraction-tuner.js +99 -0
- package/dist/engine/meta-learning/matcher-weight-tuner.d.ts +21 -0
- package/dist/engine/meta-learning/matcher-weight-tuner.js +151 -0
- package/dist/engine/meta-learning/runner.d.ts +14 -0
- package/dist/engine/meta-learning/runner.js +90 -0
- package/dist/engine/meta-learning/scope-promoter.d.ts +21 -0
- package/dist/engine/meta-learning/scope-promoter.js +84 -0
- package/dist/engine/meta-learning/session-quality-scorer.d.ts +61 -0
- package/dist/engine/meta-learning/session-quality-scorer.js +166 -0
- package/dist/engine/meta-learning/types.d.ts +114 -0
- package/dist/engine/meta-learning/types.js +43 -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 +10 -2
- package/dist/engine/solution-format.js +287 -57
- package/dist/engine/solution-index.d.ts +1 -1
- package/dist/engine/solution-index.js +10 -0
- package/dist/engine/solution-matcher.d.ts +7 -1
- package/dist/engine/solution-matcher.js +137 -37
- package/dist/engine/solution-outcomes.d.ts +70 -0
- package/dist/engine/solution-outcomes.js +242 -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 +5 -0
- package/dist/engine/solution-writer.js +18 -0
- package/dist/fgx.js +12 -8
- package/dist/hooks/context-guard.d.ts +5 -0
- package/dist/hooks/context-guard.js +118 -2
- package/dist/hooks/hooks-generator.d.ts +3 -0
- package/dist/hooks/hooks-generator.js +23 -6
- package/dist/hooks/keyword-detector.js +16 -100
- package/dist/hooks/post-tool-failure.js +7 -0
- package/dist/hooks/skill-injector.d.ts +4 -3
- package/dist/hooks/skill-injector.js +6 -4
- package/dist/hooks/solution-injector.js +20 -0
- package/dist/host/codex-adapter.d.ts +10 -0
- package/dist/host/codex-adapter.js +154 -0
- package/dist/mcp/solution-reader.d.ts +5 -5
- package/dist/mcp/solution-reader.js +34 -24
- package/dist/mcp/tools.js +8 -0
- package/dist/services/session.d.ts +19 -0
- package/dist/services/session.js +62 -0
- package/hooks/hooks.json +2 -2
- package/package.json +2 -1
- package/skills/architecture-decision/SKILL.md +113 -257
- package/skills/calibrate/SKILL.md +207 -0
- package/skills/code-review/SKILL.md +151 -178
- package/skills/compound/SKILL.md +126 -68
- package/skills/deep-interview/SKILL.md +210 -110
- package/skills/docker/SKILL.md +57 -179
- package/skills/forge-loop/SKILL.md +198 -0
- package/skills/learn/SKILL.md +216 -0
- package/skills/retro/SKILL.md +199 -0
- package/skills/ship/SKILL.md +259 -0
- package/agents/code-simplifier.md +0 -197
- package/agents/performance-reviewer.md +0 -172
- package/agents/qa-tester.md +0 -158
- package/agents/refactoring-expert.md +0 -168
- package/agents/scientist.md +0 -144
- package/agents/security-reviewer.md +0 -137
- package/agents/writer.md +0 -184
- package/commands/api-design.md +0 -268
- package/commands/ci-cd.md +0 -270
- package/commands/database.md +0 -263
- package/commands/debug-detective.md +0 -99
- package/commands/documentation.md +0 -276
- package/commands/ecomode.md +0 -51
- package/commands/frontend.md +0 -271
- package/commands/git-master.md +0 -90
- package/commands/incident-response.md +0 -292
- package/commands/migrate.md +0 -101
- package/commands/performance.md +0 -288
- package/commands/refactor.md +0 -105
- package/commands/security-review.md +0 -288
- package/commands/specify.md +0 -128
- package/commands/tdd.md +0 -183
- package/commands/testing-strategy.md +0 -265
- package/skills/api-design/SKILL.md +0 -262
- package/skills/ci-cd/SKILL.md +0 -264
- package/skills/database/SKILL.md +0 -257
- package/skills/debug-detective/SKILL.md +0 -95
- package/skills/documentation/SKILL.md +0 -270
- package/skills/ecomode/SKILL.md +0 -46
- package/skills/frontend/SKILL.md +0 -265
- package/skills/git-master/SKILL.md +0 -86
- package/skills/incident-response/SKILL.md +0 -286
- package/skills/migrate/SKILL.md +0 -96
- package/skills/performance/SKILL.md +0 -282
- package/skills/refactor/SKILL.md +0 -100
- package/skills/security-review/SKILL.md +0 -282
- package/skills/specify/SKILL.md +0 -122
- package/skills/tdd/SKILL.md +0 -178
- package/skills/testing-strategy/SKILL.md +0 -260
|
@@ -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;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ME_SOLUTIONS, STATE_DIR } from '../core/paths.js';
|
|
4
|
+
import { parseFrontmatterOnly } from './solution-format.js';
|
|
5
|
+
import { computeFitness } from './solution-fitness.js';
|
|
6
|
+
import { readAllOutcomes } from './solution-outcomes.js';
|
|
7
|
+
import { createLogger } from '../core/logger.js';
|
|
8
|
+
const log = createLogger('solution-weakness');
|
|
9
|
+
function loadSolutionRows(solutionsDir) {
|
|
10
|
+
if (!fs.existsSync(solutionsDir))
|
|
11
|
+
return [];
|
|
12
|
+
const rows = [];
|
|
13
|
+
for (const file of fs.readdirSync(solutionsDir)) {
|
|
14
|
+
if (!file.endsWith('.md'))
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
const content = fs.readFileSync(path.join(solutionsDir, file), 'utf-8');
|
|
18
|
+
const fm = parseFrontmatterOnly(content);
|
|
19
|
+
if (!fm)
|
|
20
|
+
continue;
|
|
21
|
+
rows.push({ name: fm.name, tags: fm.tags });
|
|
22
|
+
}
|
|
23
|
+
catch { /* skip */ }
|
|
24
|
+
}
|
|
25
|
+
return rows;
|
|
26
|
+
}
|
|
27
|
+
function findUnderServedTags(rows, fitnessByName) {
|
|
28
|
+
// Read correction evidence tags from ~/.forgen/me/behavior/*.json — each
|
|
29
|
+
// entry carries a `raw_payload` with inferred tags or keywords. Be
|
|
30
|
+
// tolerant: the schema has drifted historically, so we accept any string
|
|
31
|
+
// array we can find under likely field names.
|
|
32
|
+
const behaviorDir = path.join(ME_SOLUTIONS, '..', 'behavior');
|
|
33
|
+
const correctionTags = new Map();
|
|
34
|
+
if (fs.existsSync(behaviorDir)) {
|
|
35
|
+
for (const file of fs.readdirSync(behaviorDir)) {
|
|
36
|
+
if (!file.endsWith('.json'))
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
const data = JSON.parse(fs.readFileSync(path.join(behaviorDir, file), 'utf-8'));
|
|
40
|
+
const payload = data.raw_payload ?? data.payload ?? {};
|
|
41
|
+
const tags = collectTags(payload).concat(collectTags(data.axis_refs ?? []));
|
|
42
|
+
const summary = typeof data.summary === 'string' ? data.summary.toLowerCase() : '';
|
|
43
|
+
for (const tag of new Set(tags)) {
|
|
44
|
+
correctionTags.set(tag, (correctionTags.get(tag) ?? 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
// Summary keywords fallback — split on whitespace, filter obvious fillers
|
|
47
|
+
for (const word of summary.split(/\s+/)) {
|
|
48
|
+
if (word.length >= 5 && word.length <= 20) {
|
|
49
|
+
correctionTags.set(word, (correctionTags.get(word) ?? 0) + 0.3);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch { /* skip bad json */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const result = [];
|
|
57
|
+
for (const [tag, count] of correctionTags) {
|
|
58
|
+
if (count < 2)
|
|
59
|
+
continue; // noise cutoff
|
|
60
|
+
let bestName = null;
|
|
61
|
+
let bestFitness = 0;
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
if (!row.tags.includes(tag))
|
|
64
|
+
continue;
|
|
65
|
+
const fit = fitnessByName.get(row.name)?.fitness ?? 0;
|
|
66
|
+
if (fit > bestFitness || (bestName === null && fit >= 0)) {
|
|
67
|
+
bestFitness = fit;
|
|
68
|
+
bestName = row.name;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Under-served: no matching solution, or best match is not a champion
|
|
72
|
+
const bestFit = bestName ? fitnessByName.get(bestName) : null;
|
|
73
|
+
const isChampion = bestFit?.state === 'champion';
|
|
74
|
+
if (!bestName || !isChampion) {
|
|
75
|
+
result.push({
|
|
76
|
+
tag,
|
|
77
|
+
correction_mentions: Math.round(count),
|
|
78
|
+
best_matching_champion: isChampion ? bestName : null,
|
|
79
|
+
best_fitness: bestFitness,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
result.sort((a, b) => b.correction_mentions - a.correction_mentions);
|
|
84
|
+
return result.slice(0, 10);
|
|
85
|
+
}
|
|
86
|
+
function collectTags(v) {
|
|
87
|
+
if (Array.isArray(v))
|
|
88
|
+
return v.filter((x) => typeof x === 'string');
|
|
89
|
+
if (v && typeof v === 'object') {
|
|
90
|
+
return Object.values(v)
|
|
91
|
+
.filter((x) => typeof x === 'string');
|
|
92
|
+
}
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
function findConflictClusters(rows, fitnessByName) {
|
|
96
|
+
const champions = rows.filter((r) => fitnessByName.get(r.name)?.state === 'champion');
|
|
97
|
+
const underperformers = rows.filter((r) => fitnessByName.get(r.name)?.state === 'underperform');
|
|
98
|
+
const clusters = [];
|
|
99
|
+
for (const ch of champions) {
|
|
100
|
+
for (const up of underperformers) {
|
|
101
|
+
const shared = ch.tags.filter((t) => up.tags.includes(t));
|
|
102
|
+
if (shared.length < 2)
|
|
103
|
+
continue;
|
|
104
|
+
clusters.push({
|
|
105
|
+
shared_tags: shared,
|
|
106
|
+
champion: { name: ch.name, fitness: fitnessByName.get(ch.name).fitness },
|
|
107
|
+
underperform: { name: up.name, fitness: fitnessByName.get(up.name).fitness },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
clusters.sort((a, b) => b.shared_tags.length - a.shared_tags.length);
|
|
112
|
+
return clusters.slice(0, 5);
|
|
113
|
+
}
|
|
114
|
+
function findDeadCorners(rows, fitnessByName) {
|
|
115
|
+
// Dead = injected=0. Unique tags = tags present only in this solution.
|
|
116
|
+
const injectedRows = rows.filter((r) => (fitnessByName.get(r.name)?.injected ?? 0) > 0);
|
|
117
|
+
const injectedTags = new Set();
|
|
118
|
+
for (const r of injectedRows)
|
|
119
|
+
for (const t of r.tags)
|
|
120
|
+
injectedTags.add(t);
|
|
121
|
+
const dead = [];
|
|
122
|
+
for (const r of rows) {
|
|
123
|
+
const injected = fitnessByName.get(r.name)?.injected ?? 0;
|
|
124
|
+
if (injected > 0)
|
|
125
|
+
continue;
|
|
126
|
+
const unique = r.tags.filter((t) => !injectedTags.has(t));
|
|
127
|
+
if (unique.length === 0)
|
|
128
|
+
continue;
|
|
129
|
+
dead.push({ solution: r.name, unique_tags: unique, injected });
|
|
130
|
+
}
|
|
131
|
+
dead.sort((a, b) => b.unique_tags.length - a.unique_tags.length);
|
|
132
|
+
return dead.slice(0, 10);
|
|
133
|
+
}
|
|
134
|
+
function findVolatile(_fitnessByName) {
|
|
135
|
+
const events = readAllOutcomes();
|
|
136
|
+
if (events.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
// Split events into two halves by timestamp; compute per-solution accept
|
|
139
|
+
// rate delta between halves. Volatile = |delta| > 0.3 and enough data.
|
|
140
|
+
const mid = events[Math.floor(events.length / 2)].ts;
|
|
141
|
+
const by = new Map();
|
|
142
|
+
for (const ev of events) {
|
|
143
|
+
const c = by.get(ev.solution) ?? { a_accept: 0, a_total: 0, b_accept: 0, b_total: 0 };
|
|
144
|
+
if (ev.outcome === 'accept' || ev.outcome === 'correct' || ev.outcome === 'error') {
|
|
145
|
+
const isA = ev.ts < mid;
|
|
146
|
+
if (isA) {
|
|
147
|
+
c.a_total++;
|
|
148
|
+
if (ev.outcome === 'accept')
|
|
149
|
+
c.a_accept++;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
c.b_total++;
|
|
153
|
+
if (ev.outcome === 'accept')
|
|
154
|
+
c.b_accept++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
by.set(ev.solution, c);
|
|
158
|
+
}
|
|
159
|
+
const result = [];
|
|
160
|
+
for (const [name, c] of by) {
|
|
161
|
+
if (c.a_total < 3 || c.b_total < 3)
|
|
162
|
+
continue;
|
|
163
|
+
const rateA = c.a_accept / c.a_total;
|
|
164
|
+
const rateB = c.b_accept / c.b_total;
|
|
165
|
+
const delta = rateB - rateA;
|
|
166
|
+
if (Math.abs(delta) < 0.3)
|
|
167
|
+
continue;
|
|
168
|
+
result.push({
|
|
169
|
+
solution: name,
|
|
170
|
+
accept_rate_window_a: Number(rateA.toFixed(3)),
|
|
171
|
+
accept_rate_window_b: Number(rateB.toFixed(3)),
|
|
172
|
+
delta: Number(delta.toFixed(3)),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
result.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
|
176
|
+
return result.slice(0, 5);
|
|
177
|
+
}
|
|
178
|
+
export function buildWeaknessReport(solutionsDir = ME_SOLUTIONS) {
|
|
179
|
+
const rows = loadSolutionRows(solutionsDir);
|
|
180
|
+
const fitnessList = computeFitness();
|
|
181
|
+
const fitnessByName = new Map(fitnessList.map((f) => [f.solution, f]));
|
|
182
|
+
const population = {
|
|
183
|
+
total: fitnessList.length,
|
|
184
|
+
champion: fitnessList.filter((f) => f.state === 'champion').length,
|
|
185
|
+
active: fitnessList.filter((f) => f.state === 'active').length,
|
|
186
|
+
underperform: fitnessList.filter((f) => f.state === 'underperform').length,
|
|
187
|
+
draft: fitnessList.filter((f) => f.state === 'draft').length,
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
generated_at: new Date().toISOString(),
|
|
191
|
+
population,
|
|
192
|
+
under_served_tags: findUnderServedTags(rows, fitnessByName),
|
|
193
|
+
conflict_clusters: findConflictClusters(rows, fitnessByName),
|
|
194
|
+
dead_corners: findDeadCorners(rows, fitnessByName),
|
|
195
|
+
volatile: findVolatile(fitnessByName),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export function saveWeaknessReport(report) {
|
|
199
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
200
|
+
const ts = Date.now();
|
|
201
|
+
const p = path.join(STATE_DIR, `weakness-report-${ts}.json`);
|
|
202
|
+
try {
|
|
203
|
+
fs.writeFileSync(p, JSON.stringify(report, null, 2));
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
log.debug(`save failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
207
|
+
}
|
|
208
|
+
return p;
|
|
209
|
+
}
|
|
210
|
+
export function latestWeaknessReport() {
|
|
211
|
+
if (!fs.existsSync(STATE_DIR))
|
|
212
|
+
return null;
|
|
213
|
+
const candidates = fs.readdirSync(STATE_DIR)
|
|
214
|
+
.filter((f) => f.startsWith('weakness-report-') && f.endsWith('.json'))
|
|
215
|
+
.sort()
|
|
216
|
+
.reverse();
|
|
217
|
+
if (candidates.length === 0)
|
|
218
|
+
return null;
|
|
219
|
+
try {
|
|
220
|
+
return JSON.parse(fs.readFileSync(path.join(STATE_DIR, candidates[0]), 'utf-8'));
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -72,5 +72,10 @@ export declare function mutateSolutionByName(name: string, mutator: SolutionMuta
|
|
|
72
72
|
/**
|
|
73
73
|
* Evidence 카운터 단일 증가 helper.
|
|
74
74
|
* mutateSolutionByName + 카운터 증가 패턴을 한 줄로.
|
|
75
|
+
*
|
|
76
|
+
* Also graduates Phase 4 candidates: when a `status: candidate` solution's
|
|
77
|
+
* injected count reaches `CANDIDATE_PROMOTION_INJECTIONS`, its status flips
|
|
78
|
+
* to `verified` in the same write. This keeps the exploration bonus from
|
|
79
|
+
* clinging to a solution that has had enough trials.
|
|
75
80
|
*/
|
|
76
81
|
export declare function incrementEvidence(solutionName: string, field: 'reflected' | 'negative' | 'injected' | 'sessions' | 'reExtracted'): boolean;
|
|
@@ -142,9 +142,22 @@ export function mutateSolutionByName(name, mutator, options) {
|
|
|
142
142
|
}
|
|
143
143
|
return false;
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Phase 4 candidate promotion threshold: a `status: candidate` solution
|
|
147
|
+
* automatically graduates to `status: verified` once its injected count
|
|
148
|
+
* crosses this cutoff. At that point the cold-start exploration bonus
|
|
149
|
+
* (solution-matcher.ts) disappears naturally, since the bonus keys off
|
|
150
|
+
* `candidate` status.
|
|
151
|
+
*/
|
|
152
|
+
const CANDIDATE_PROMOTION_INJECTIONS = 5;
|
|
145
153
|
/**
|
|
146
154
|
* Evidence 카운터 단일 증가 helper.
|
|
147
155
|
* mutateSolutionByName + 카운터 증가 패턴을 한 줄로.
|
|
156
|
+
*
|
|
157
|
+
* Also graduates Phase 4 candidates: when a `status: candidate` solution's
|
|
158
|
+
* injected count reaches `CANDIDATE_PROMOTION_INJECTIONS`, its status flips
|
|
159
|
+
* to `verified` in the same write. This keeps the exploration bonus from
|
|
160
|
+
* clinging to a solution that has had enough trials.
|
|
148
161
|
*/
|
|
149
162
|
export function incrementEvidence(solutionName, field) {
|
|
150
163
|
return mutateSolutionByName(solutionName, sol => {
|
|
@@ -152,6 +165,11 @@ export function incrementEvidence(solutionName, field) {
|
|
|
152
165
|
if (!(field in ev))
|
|
153
166
|
return false;
|
|
154
167
|
ev[field] = (ev[field] ?? 0) + 1;
|
|
168
|
+
if (field === 'injected' &&
|
|
169
|
+
sol.frontmatter.status === 'candidate' &&
|
|
170
|
+
ev.injected >= CANDIDATE_PROMOTION_INJECTIONS) {
|
|
171
|
+
sol.frontmatter.status = 'verified';
|
|
172
|
+
}
|
|
155
173
|
return true;
|
|
156
174
|
});
|
|
157
175
|
}
|
package/dist/fgx.js
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* fgx — forgen --dangerously-skip-permissions 의 단축 명령
|
|
4
4
|
* 모든 인자를 그대로 전달하되, --dangerously-skip-permissions 를 자동 주입
|
|
5
5
|
*/
|
|
6
|
+
import { resolveLaunchContext } from './services/session.js';
|
|
7
|
+
import { prepareHarness, isFirstRun } from './core/harness.js';
|
|
8
|
+
import { spawnClaude } from './core/spawn.js';
|
|
6
9
|
const args = process.argv.slice(2);
|
|
7
10
|
// 이미 포함되어 있으면 중복 추가하지 않음
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
const launchContext = resolveLaunchContext(args);
|
|
12
|
+
const runtime = launchContext.runtime;
|
|
13
|
+
const launchArgs = [...launchContext.args];
|
|
14
|
+
if (!launchArgs.includes('--dangerously-skip-permissions')) {
|
|
15
|
+
launchArgs.unshift('--dangerously-skip-permissions');
|
|
10
16
|
}
|
|
11
|
-
// cli.ts 의 main 로직을 재사용
|
|
12
|
-
import { prepareHarness, isFirstRun } from './core/harness.js';
|
|
13
|
-
import { spawnClaude } from './core/spawn.js';
|
|
14
17
|
async function main() {
|
|
15
18
|
// Security warning — fgx bypasses all Claude Code permission checks
|
|
16
19
|
console.warn('\n ⚠ fgx: ALL permission checks are disabled (--dangerously-skip-permissions)');
|
|
@@ -23,7 +26,7 @@ async function main() {
|
|
|
23
26
|
console.log(' Creating ~/.forgen/ directory and default philosophy.');
|
|
24
27
|
console.log(' Run `forgen onboarding` afterwards to complete personalization.\n');
|
|
25
28
|
}
|
|
26
|
-
const context = await prepareHarness(process.cwd());
|
|
29
|
+
const context = await prepareHarness(process.cwd(), { runtime });
|
|
27
30
|
if (firstRun) {
|
|
28
31
|
console.log(' [Done] Initial setup complete.\n');
|
|
29
32
|
}
|
|
@@ -33,8 +36,9 @@ async function main() {
|
|
|
33
36
|
console.log(`[forgen] Trust: ${v1.session.effective_trust_policy}`);
|
|
34
37
|
}
|
|
35
38
|
console.log('[forgen] Mode: dangerously-skip-permissions');
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
const runtimeLabel = runtime === 'codex' ? 'Codex' : 'Claude';
|
|
40
|
+
console.log(`[forgen] Starting ${runtimeLabel}...\n`);
|
|
41
|
+
await spawnClaude(launchArgs, context, runtime);
|
|
38
42
|
}
|
|
39
43
|
main().catch((err) => {
|
|
40
44
|
console.error('[forgen] Error:', err instanceof Error ? err.message : err);
|
|
@@ -32,3 +32,8 @@ export declare function buildAutoCompactMessage(totalChars: number): string;
|
|
|
32
32
|
/** 경고 메시지 생성 (순수 함수) */
|
|
33
33
|
export declare function buildContextWarningMessage(promptCount: number, totalChars: number): string;
|
|
34
34
|
export declare function main(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* forge-loop 활성 시 미완료 스토리가 있으면 Stop을 차단하고 지속 메시지 주입.
|
|
37
|
+
* OMC의 persistent-mode.cjs 패턴 참고.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkForgeLoopActive(): string | null;
|
|
@@ -89,6 +89,12 @@ export async function main() {
|
|
|
89
89
|
// Stop 훅: stop_hook_type이 있으면 처리
|
|
90
90
|
if (input.stop_hook_type) {
|
|
91
91
|
_hookEvent = 'Stop';
|
|
92
|
+
// forge-loop 활성 시 미완료 스토리 감지 → 지속 메시지 주입 (polite-stop 방지)
|
|
93
|
+
const forgeLoopBlock = checkForgeLoopActive();
|
|
94
|
+
if (forgeLoopBlock) {
|
|
95
|
+
console.log(forgeLoopBlock);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
92
98
|
// 에러가 포함된 경우: context limit 감지
|
|
93
99
|
if (input.error) {
|
|
94
100
|
const errorMsg = input.error;
|
|
@@ -119,12 +125,14 @@ export async function main() {
|
|
|
119
125
|
fs.writeFileSync(path.join(STATE_DIR, 'pending-compound.json'), JSON.stringify(marker));
|
|
120
126
|
}
|
|
121
127
|
catch { /* fail-open: marker write failure is non-critical */ }
|
|
122
|
-
|
|
128
|
+
const summary = buildSessionSummary(sessionId, state.promptCount);
|
|
129
|
+
console.log(approveWithWarning(`[Forgen] Session with ${state.promptCount} prompts ended.\n${summary}\nCompound loop will auto-trigger on next session start.`));
|
|
123
130
|
return;
|
|
124
131
|
}
|
|
125
132
|
if (state.promptCount >= 10) {
|
|
126
133
|
// 10-19 prompts: suggest /compound manually
|
|
127
|
-
|
|
134
|
+
const summary = buildSessionSummary(sessionId, state.promptCount);
|
|
135
|
+
console.log(approveWithWarning(`[Forgen] 이 세션에서 ${state.promptCount}개의 프롬프트를 처리했습니다.\n${summary}/compound 를 실행하면 이 세션의 학습 내용을 축적할 수 있습니다.`));
|
|
128
136
|
return;
|
|
129
137
|
}
|
|
130
138
|
}
|
|
@@ -166,6 +174,114 @@ export async function main() {
|
|
|
166
174
|
recordHookTiming('context-guard', Date.now() - _hookStart, _hookEvent);
|
|
167
175
|
}
|
|
168
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* 세션 종료 시 "forgen이 도움이 된 정도"를 요약.
|
|
179
|
+
* solution-cache에서 이번 세션에 주입된 compound 솔루션 수를 집계하여
|
|
180
|
+
* 카운터팩추얼 "forgen 없었으면 ~N분 더 걸렸을 것" 메시지 생성.
|
|
181
|
+
*/
|
|
182
|
+
function buildSessionSummary(sessionId, promptCount) {
|
|
183
|
+
try {
|
|
184
|
+
const cachePath = path.join(STATE_DIR, `solution-cache-${sessionId}.json`);
|
|
185
|
+
if (!fs.existsSync(cachePath))
|
|
186
|
+
return '';
|
|
187
|
+
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
188
|
+
const injected = Array.isArray(cache.injected) ? cache.injected : [];
|
|
189
|
+
if (injected.length === 0)
|
|
190
|
+
return '';
|
|
191
|
+
// 카운터팩추얼: 주입된 compound 1건당 평균 8분 절약 가정 (하한 추정)
|
|
192
|
+
const savedMins = injected.length * 8;
|
|
193
|
+
const savedStr = savedMins >= 60
|
|
194
|
+
? `${Math.floor(savedMins / 60)}시간 ${savedMins % 60}분`
|
|
195
|
+
: `${savedMins}분`;
|
|
196
|
+
// 상위 3개 솔루션
|
|
197
|
+
const topNames = injected.slice(0, 3).map(i => `"${i.name}"`).join(', ');
|
|
198
|
+
const moreCount = injected.length - 3;
|
|
199
|
+
const topStr = moreCount > 0 ? `${topNames} 외 ${moreCount}개` : topNames;
|
|
200
|
+
return [
|
|
201
|
+
`\n📊 이번 세션 forgen 효과:`,
|
|
202
|
+
` 주입된 compound: ${injected.length}건 (${topStr})`,
|
|
203
|
+
` 추정 절약 시간: ${savedStr} (forgen 없었으면 시행착오 필요)`,
|
|
204
|
+
` 프롬프트 대비 효율: ${(injected.length / promptCount * 100).toFixed(0)}% 의 대화가 축적된 지식의 도움을 받음\n`,
|
|
205
|
+
].join('\n');
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// forge-loop 상태 파일 경로
|
|
212
|
+
const FORGE_LOOP_STATE_PATH = path.join(STATE_DIR, 'forge-loop.json');
|
|
213
|
+
// forge-loop 차단 안전 상한 (무한 루프 방지)
|
|
214
|
+
const FORGE_LOOP_MAX_BLOCKS = 30;
|
|
215
|
+
const FORGE_LOOP_STALE_MS = 2 * 60 * 60 * 1000; // 2시간
|
|
216
|
+
/**
|
|
217
|
+
* forge-loop 활성 시 미완료 스토리가 있으면 Stop을 차단하고 지속 메시지 주입.
|
|
218
|
+
* OMC의 persistent-mode.cjs 패턴 참고.
|
|
219
|
+
*/
|
|
220
|
+
export function checkForgeLoopActive() {
|
|
221
|
+
try {
|
|
222
|
+
if (!fs.existsSync(FORGE_LOOP_STATE_PATH))
|
|
223
|
+
return null;
|
|
224
|
+
const state = JSON.parse(fs.readFileSync(FORGE_LOOP_STATE_PATH, 'utf-8'));
|
|
225
|
+
if (!state.active)
|
|
226
|
+
return null;
|
|
227
|
+
// Stale 감지: 2시간+ 미활동 → 자동 비활성화
|
|
228
|
+
const startedAt = new Date(state.startedAt).getTime();
|
|
229
|
+
if (Number.isFinite(startedAt) && Date.now() - startedAt > FORGE_LOOP_STALE_MS) {
|
|
230
|
+
state.active = false;
|
|
231
|
+
atomicWriteJSON(FORGE_LOOP_STATE_PATH, state);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
// 확인 대기 중이면 차단하지 않음 (사용자 개입 허용)
|
|
235
|
+
if (state.awaitingConfirmation)
|
|
236
|
+
return null;
|
|
237
|
+
// 안전 상한: 30회 이상 차단 시 무한 루프로 간주하여 해제
|
|
238
|
+
const blockCount = state.blockCount ?? 0;
|
|
239
|
+
if (blockCount >= FORGE_LOOP_MAX_BLOCKS) {
|
|
240
|
+
state.active = false;
|
|
241
|
+
atomicWriteJSON(FORGE_LOOP_STATE_PATH, state);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
// 미완료 스토리 확인
|
|
245
|
+
const stories = Array.isArray(state.stories) ? state.stories : [];
|
|
246
|
+
const pending = stories.filter((s) => !s.passes);
|
|
247
|
+
if (pending.length === 0) {
|
|
248
|
+
// 모든 스토리 완료 → forge-loop 종료
|
|
249
|
+
state.active = false;
|
|
250
|
+
atomicWriteJSON(FORGE_LOOP_STATE_PATH, state);
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
// 차단 카운트 증가 + 지속 메시지 주입
|
|
254
|
+
state.blockCount = blockCount + 1;
|
|
255
|
+
state.lastBlockAt = new Date().toISOString();
|
|
256
|
+
atomicWriteJSON(FORGE_LOOP_STATE_PATH, state);
|
|
257
|
+
const nextStory = pending[0];
|
|
258
|
+
const message = [
|
|
259
|
+
`<forgen-forge-loop iteration="${state.blockCount}/${FORGE_LOOP_MAX_BLOCKS}">`,
|
|
260
|
+
`[FORGE-LOOP] ${pending.length}개 스토리가 미완료입니다.`,
|
|
261
|
+
`현재 스토리: ${nextStory.id} — ${nextStory.title}`,
|
|
262
|
+
``,
|
|
263
|
+
`계속 진행하세요. 보고는 다음 시점에만 합니다:`,
|
|
264
|
+
` 1. 모든 스토리 완료 (최종 리포트)`,
|
|
265
|
+
` 2. 3회 실패 (에스컬레이션)`,
|
|
266
|
+
` 3. Context limit 접근 (handoff)`,
|
|
267
|
+
``,
|
|
268
|
+
`중간 "완료했습니다" 보고는 polite-stop anti-pattern입니다.`,
|
|
269
|
+
`취소하려면: "/forge-loop cancel" 또는 "cancelforgen" 입력`,
|
|
270
|
+
`</forgen-forge-loop>`,
|
|
271
|
+
].join('\n');
|
|
272
|
+
// block 결정으로 Claude가 계속 작업하도록 강제
|
|
273
|
+
return JSON.stringify({
|
|
274
|
+
continue: true,
|
|
275
|
+
decision: 'block',
|
|
276
|
+
reason: message,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
// fail-open: forge-loop 상태 읽기 실패는 차단하지 않음
|
|
281
|
+
log.debug('forge-loop 상태 확인 실패', e);
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
169
285
|
function saveHandoff(sessionId, reason, detail) {
|
|
170
286
|
fs.mkdirSync(HANDOFFS_DIR, { recursive: true });
|
|
171
287
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|