@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,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
|
+
}
|
|
@@ -71,6 +71,14 @@ export declare function mutateSolutionByName(name: string, mutator: SolutionMuta
|
|
|
71
71
|
}): boolean;
|
|
72
72
|
/**
|
|
73
73
|
* Evidence 카운터 단일 증가 helper.
|
|
74
|
-
*
|
|
74
|
+
*
|
|
75
|
+
* Invariant: status/confidence 같은 lifecycle 필드는 건드리지 않는다.
|
|
76
|
+
* 모든 status 전이는 compound-lifecycle.ts::runLifecycleCheck(자동, reflected
|
|
77
|
+
* /sessions/reExtracted + age-gate 기반)과 verifySolution(수동 명령)이라는
|
|
78
|
+
* 단일 경로로만 일어난다. dual-path 금지.
|
|
79
|
+
*
|
|
80
|
+
* 과거에는 inject≥5 자동 verified 승급이 이 함수 안에 있었는데, 그것은 outcome
|
|
81
|
+
* 증거 없이도 candidate를 승급시켜 self-rewarding 편향을 만들었다. 2026-04-20
|
|
82
|
+
* 제거 (feedback_core_loop_invariant 참고).
|
|
75
83
|
*/
|
|
76
84
|
export declare function incrementEvidence(solutionName: string, field: 'reflected' | 'negative' | 'injected' | 'sessions' | 'reExtracted'): boolean;
|
|
@@ -31,6 +31,7 @@ import { atomicWriteText } from '../hooks/shared/atomic-write.js';
|
|
|
31
31
|
import { parseFrontmatterOnly, parseSolutionV3, serializeSolutionV3, } from './solution-format.js';
|
|
32
32
|
import { ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
|
|
33
33
|
import { createLogger } from '../core/logger.js';
|
|
34
|
+
import { getOrBuildIndex } from './solution-index.js';
|
|
34
35
|
const log = createLogger('solution-writer');
|
|
35
36
|
/**
|
|
36
37
|
* 단일 .md 파일에 lock 보호된 read-modify-write 수행.
|
|
@@ -96,7 +97,40 @@ export function mutateSolutionFile(filePath, mutator) {
|
|
|
96
97
|
* symlink는 보안상 무시 (lstatSync 가드).
|
|
97
98
|
*/
|
|
98
99
|
export function mutateSolutionByName(name, mutator, options) {
|
|
99
|
-
const
|
|
100
|
+
const extraDirs = options?.extraDirs ?? [];
|
|
101
|
+
const dirs = [ME_SOLUTIONS, ME_RULES, ...extraDirs];
|
|
102
|
+
// P0-3 fast path (2026-04-20): solution-index의 캐시에서 name→filePath를
|
|
103
|
+
// O(1) 조회. 과거에는 매 호출마다 dir readdir + N번 readFileSync + YAML parse
|
|
104
|
+
// (N=500일 때 hook당 최대 1500회 I/O)로 5초 timeout을 위협했다.
|
|
105
|
+
// 인덱스 miss/stale 시 아래 기존 O(N) 스캔 경로로 fallback한다.
|
|
106
|
+
try {
|
|
107
|
+
const indexDirs = [
|
|
108
|
+
{ dir: ME_SOLUTIONS, scope: 'me' },
|
|
109
|
+
{ dir: ME_RULES, scope: 'me' },
|
|
110
|
+
...extraDirs.map((d) => ({ dir: d, scope: 'project' })),
|
|
111
|
+
];
|
|
112
|
+
const idx = getOrBuildIndex(indexDirs);
|
|
113
|
+
const entry = idx.entries.find(e => e.name === name);
|
|
114
|
+
if (entry?.filePath && fs.existsSync(entry.filePath)) {
|
|
115
|
+
let isSymlink = true;
|
|
116
|
+
try {
|
|
117
|
+
isSymlink = fs.lstatSync(entry.filePath).isSymbolicLink();
|
|
118
|
+
}
|
|
119
|
+
catch { /* fall through to full scan */ }
|
|
120
|
+
if (!isSymlink) {
|
|
121
|
+
const result = mutateSolutionFile(entry.filePath, sol => {
|
|
122
|
+
if (sol.frontmatter.name !== name)
|
|
123
|
+
return false;
|
|
124
|
+
return mutator(sol);
|
|
125
|
+
});
|
|
126
|
+
if (result)
|
|
127
|
+
return true;
|
|
128
|
+
// 인덱스가 stale(이름이 바뀌었거나 파일이 재생성됨)이면 fallback 경로로.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { /* 인덱스 빌드 실패 — fallback */ }
|
|
133
|
+
// Fallback: 전체 디렉터리 스캔 (인덱스 miss / stale 시)
|
|
100
134
|
for (const dir of dirs) {
|
|
101
135
|
if (!fs.existsSync(dir))
|
|
102
136
|
continue;
|
|
@@ -144,7 +178,15 @@ export function mutateSolutionByName(name, mutator, options) {
|
|
|
144
178
|
}
|
|
145
179
|
/**
|
|
146
180
|
* Evidence 카운터 단일 증가 helper.
|
|
147
|
-
*
|
|
181
|
+
*
|
|
182
|
+
* Invariant: status/confidence 같은 lifecycle 필드는 건드리지 않는다.
|
|
183
|
+
* 모든 status 전이는 compound-lifecycle.ts::runLifecycleCheck(자동, reflected
|
|
184
|
+
* /sessions/reExtracted + age-gate 기반)과 verifySolution(수동 명령)이라는
|
|
185
|
+
* 단일 경로로만 일어난다. dual-path 금지.
|
|
186
|
+
*
|
|
187
|
+
* 과거에는 inject≥5 자동 verified 승급이 이 함수 안에 있었는데, 그것은 outcome
|
|
188
|
+
* 증거 없이도 candidate를 승급시켜 self-rewarding 편향을 만들었다. 2026-04-20
|
|
189
|
+
* 제거 (feedback_core_loop_invariant 참고).
|
|
148
190
|
*/
|
|
149
191
|
export function incrementEvidence(solutionName, field) {
|
|
150
192
|
return mutateSolutionByName(solutionName, sol => {
|
package/dist/fgx.js
CHANGED
|
@@ -15,10 +15,17 @@ if (!launchArgs.includes('--dangerously-skip-permissions')) {
|
|
|
15
15
|
launchArgs.unshift('--dangerously-skip-permissions');
|
|
16
16
|
}
|
|
17
17
|
async function main() {
|
|
18
|
-
// Security warning — fgx bypasses all Claude Code permission checks
|
|
18
|
+
// Security warning — fgx bypasses all Claude Code permission checks.
|
|
19
|
+
//
|
|
20
|
+
// Audit fix #3 (2026-04-21): The warning banner is shown regardless of
|
|
21
|
+
// the user's profile trust policy, which means "가드레일 우선" users who
|
|
22
|
+
// alias `fgx` unknowingly run with zero guardrails. Users who rely on
|
|
23
|
+
// the profile trust policy should NOT use `fgx`. Surface the mismatch
|
|
24
|
+
// loudly (harness.ts also prints the Trust 상승 warning downstream).
|
|
19
25
|
console.warn('\n ⚠ fgx: ALL permission checks are disabled (--dangerously-skip-permissions)');
|
|
20
26
|
console.warn(' ⚠ Claude Code will execute tools without asking for confirmation.');
|
|
21
|
-
console.warn(' ⚠ Use only in trusted environments
|
|
27
|
+
console.warn(' ⚠ Use only in trusted environments. If your profile trust policy is');
|
|
28
|
+
console.warn(' ⚠ "가드레일 우선" or "승인 완화", consider `forgen` (no flag) instead.\n');
|
|
22
29
|
// fgx는 서브커맨드 없이 바로 Claude Code 실행 전용
|
|
23
30
|
const firstRun = isFirstRun();
|
|
24
31
|
if (firstRun) {
|
package/dist/forge/cli.js
CHANGED
|
@@ -58,7 +58,7 @@ async function handleReset(level) {
|
|
|
58
58
|
}
|
|
59
59
|
// 동적 import로 store 모듈 로드
|
|
60
60
|
const fs = await import('node:fs');
|
|
61
|
-
const {
|
|
61
|
+
const { FORGE_PROFILE, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS } = await import('../core/paths.js');
|
|
62
62
|
const deleteDirs = (dirs) => {
|
|
63
63
|
for (const dir of dirs) {
|
|
64
64
|
try {
|
|
@@ -75,18 +75,18 @@ async function handleReset(level) {
|
|
|
75
75
|
catch { /* ignore */ }
|
|
76
76
|
};
|
|
77
77
|
if (level === 'soft') {
|
|
78
|
-
deleteFile(
|
|
79
|
-
deleteDirs([
|
|
78
|
+
deleteFile(FORGE_PROFILE);
|
|
79
|
+
deleteDirs([ME_RULES, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR]);
|
|
80
80
|
console.log('\n Soft reset 완료. Profile + Rule + Recommendation + Session 초기화.');
|
|
81
81
|
}
|
|
82
82
|
else if (level === 'learning') {
|
|
83
|
-
deleteFile(
|
|
84
|
-
deleteDirs([
|
|
83
|
+
deleteFile(FORGE_PROFILE);
|
|
84
|
+
deleteDirs([ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR]);
|
|
85
85
|
console.log('\n Learning reset 완료. 개인 학습 전체 초기화.');
|
|
86
86
|
}
|
|
87
87
|
else if (level === 'full') {
|
|
88
|
-
deleteFile(
|
|
89
|
-
deleteDirs([
|
|
88
|
+
deleteFile(FORGE_PROFILE);
|
|
89
|
+
deleteDirs([ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS]);
|
|
90
90
|
console.log('\n Full reset 완료. Compound 포함 전체 초기화.');
|
|
91
91
|
}
|
|
92
92
|
// Reset 후 자동 온보딩 (interactive 환경에서만)
|
|
@@ -19,6 +19,7 @@ import { loadHookConfig, isHookEnabled } from './hook-config.js';
|
|
|
19
19
|
import { approve, approveWithContext, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
|
|
20
20
|
import { HANDOFFS_DIR, STATE_DIR } from '../core/paths.js';
|
|
21
21
|
import { recordHookTiming } from './shared/hook-timing.js';
|
|
22
|
+
import { sanitizeId } from './shared/sanitize-id.js';
|
|
22
23
|
const log = createLogger('context-guard');
|
|
23
24
|
const CONTEXT_STATE_PATH = path.join(STATE_DIR, 'context-guard.json');
|
|
24
25
|
// 경고 임계값: 프롬프트 50회 또는 총 문자 수 200K 이상
|
|
@@ -89,6 +90,17 @@ export async function main() {
|
|
|
89
90
|
// Stop 훅: stop_hook_type이 있으면 처리
|
|
90
91
|
if (input.stop_hook_type) {
|
|
91
92
|
_hookEvent = 'Stop';
|
|
93
|
+
// 세션 종료 시 pending outcome을 unknown으로 finalize.
|
|
94
|
+
// 과거에는 프로덕션에서 호출되지 않아 pending이 다음 세션의 flushAccept에
|
|
95
|
+
// accept로 쓸려들어가는 구조적 optimistic bias가 있었다 (2026-04-20).
|
|
96
|
+
// finalizeSession은 idempotent (pending 없으면 0 반환, 에러는 log.debug만).
|
|
97
|
+
try {
|
|
98
|
+
const { finalizeSession } = await import('../engine/solution-outcomes.js');
|
|
99
|
+
finalizeSession(sessionId);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
log.debug('finalizeSession 실패 (fail-open)', e);
|
|
103
|
+
}
|
|
92
104
|
// forge-loop 활성 시 미완료 스토리 감지 → 지속 메시지 주입 (polite-stop 방지)
|
|
93
105
|
const forgeLoopBlock = checkForgeLoopActive();
|
|
94
106
|
if (forgeLoopBlock) {
|
|
@@ -181,7 +193,9 @@ export async function main() {
|
|
|
181
193
|
*/
|
|
182
194
|
function buildSessionSummary(sessionId, promptCount) {
|
|
183
195
|
try {
|
|
184
|
-
|
|
196
|
+
// P1-S3 fix (2026-04-20): sanitizeId로 path traversal 차단.
|
|
197
|
+
// 다른 세션 캐시 경로는 모두 sanitizeId 사용. 여기만 누락되어 있었다.
|
|
198
|
+
const cachePath = path.join(STATE_DIR, `solution-cache-${sanitizeId(sessionId)}.json`);
|
|
185
199
|
if (!fs.existsSync(cachePath))
|
|
186
200
|
return '';
|
|
187
201
|
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
*/
|
|
26
26
|
/** 훅 설정 파일의 전체 구조 타입 */
|
|
27
27
|
export type HookConfig = Record<string, unknown>;
|
|
28
|
+
/** 테스트/진단용: 보호된 훅 이름 집합 스냅샷. */
|
|
29
|
+
export declare function getProtectedHookNames(): string[];
|
|
28
30
|
/**
|
|
29
31
|
* 프로젝트의 작업 디렉토리를 결정합니다.
|
|
30
32
|
* FORGEN_CWD → COMPOUND_CWD → process.cwd() 순서.
|
|
@@ -46,9 +48,15 @@ export declare function loadHookConfig(hookName: string): Record<string, unknown
|
|
|
46
48
|
/**
|
|
47
49
|
* 훅이 활성화되어 있는지 확인합니다.
|
|
48
50
|
*
|
|
51
|
+
* Invariant: compound-core 티어 및 compoundCritical=true 훅은 어떤 config
|
|
52
|
+
* 경로(개별 hooks / tier / 레거시)로도 비활성화되지 않는다. config 값과 무관하게
|
|
53
|
+
* 항상 true를 반환한다. 이는 복리화 3축(승급/rollback/피드백)을 project-level
|
|
54
|
+
* config 실수로 조용히 끄는 dual-path를 차단하는 단일 진입점 가드다.
|
|
55
|
+
*
|
|
49
56
|
* 우선순위:
|
|
57
|
+
* 0. PROTECTED_HOOKS에 속하면 → 즉시 true (가드레일)
|
|
50
58
|
* 1. hooks.hookName.enabled (개별 훅 설정)
|
|
51
|
-
* 2. tiers.tierName.enabled (티어 설정)
|
|
59
|
+
* 2. tiers.tierName.enabled (티어 설정)
|
|
52
60
|
* 3. hookName.enabled (레거시 형식)
|
|
53
61
|
* 4. 기본값 true (하위호환)
|
|
54
62
|
*/
|
|
@@ -33,6 +33,19 @@ const GLOBAL_CONFIG_PATH = path.join(FORGEN_HOME, 'hook-config.json');
|
|
|
33
33
|
* 이중 구현 방지: HOOK_REGISTRY가 단일 소스 오브 트루스.
|
|
34
34
|
*/
|
|
35
35
|
const HOOK_TIER_MAP = Object.fromEntries(HOOK_REGISTRY.map(h => [h.name, h.tier]));
|
|
36
|
+
/**
|
|
37
|
+
* compound-core 티어이거나 compoundCritical=true로 선언된 훅은 project/글로벌
|
|
38
|
+
* config의 어떤 경로로도 비활성화할 수 없다. 복리화 피드백 루프(승급·outcome
|
|
39
|
+
* 추적·세션 복구)를 project-level 설정 실수로 조용히 끄는 것을 차단한다.
|
|
40
|
+
* (feedback_core_loop_invariant — 2026-04-20)
|
|
41
|
+
*/
|
|
42
|
+
const PROTECTED_HOOKS = new Set(HOOK_REGISTRY
|
|
43
|
+
.filter(h => h.tier === 'compound-core' || h.compoundCritical === true)
|
|
44
|
+
.map(h => h.name));
|
|
45
|
+
/** 테스트/진단용: 보호된 훅 이름 집합 스냅샷. */
|
|
46
|
+
export function getProtectedHookNames() {
|
|
47
|
+
return [...PROTECTED_HOOKS].sort();
|
|
48
|
+
}
|
|
36
49
|
/**
|
|
37
50
|
* 프로젝트의 작업 디렉토리를 결정합니다.
|
|
38
51
|
* FORGEN_CWD → COMPOUND_CWD → process.cwd() 순서.
|
|
@@ -120,13 +133,22 @@ export function loadHookConfig(hookName) {
|
|
|
120
133
|
/**
|
|
121
134
|
* 훅이 활성화되어 있는지 확인합니다.
|
|
122
135
|
*
|
|
136
|
+
* Invariant: compound-core 티어 및 compoundCritical=true 훅은 어떤 config
|
|
137
|
+
* 경로(개별 hooks / tier / 레거시)로도 비활성화되지 않는다. config 값과 무관하게
|
|
138
|
+
* 항상 true를 반환한다. 이는 복리화 3축(승급/rollback/피드백)을 project-level
|
|
139
|
+
* config 실수로 조용히 끄는 dual-path를 차단하는 단일 진입점 가드다.
|
|
140
|
+
*
|
|
123
141
|
* 우선순위:
|
|
142
|
+
* 0. PROTECTED_HOOKS에 속하면 → 즉시 true (가드레일)
|
|
124
143
|
* 1. hooks.hookName.enabled (개별 훅 설정)
|
|
125
|
-
* 2. tiers.tierName.enabled (티어 설정)
|
|
144
|
+
* 2. tiers.tierName.enabled (티어 설정)
|
|
126
145
|
* 3. hookName.enabled (레거시 형식)
|
|
127
146
|
* 4. 기본값 true (하위호환)
|
|
128
147
|
*/
|
|
129
148
|
export function isHookEnabled(hookName) {
|
|
149
|
+
// 0) compound-core 가드레일 — config 어떤 경로로도 끌 수 없음
|
|
150
|
+
if (PROTECTED_HOOKS.has(hookName))
|
|
151
|
+
return true;
|
|
130
152
|
const all = loadFullConfig();
|
|
131
153
|
if (!all)
|
|
132
154
|
return true;
|
|
@@ -136,9 +158,9 @@ export function isHookEnabled(hookName) {
|
|
|
136
158
|
return false;
|
|
137
159
|
if (hooksSection?.[hookName]?.enabled === true)
|
|
138
160
|
return true;
|
|
139
|
-
// 2) 티어 설정
|
|
161
|
+
// 2) 티어 설정
|
|
140
162
|
const tier = HOOK_TIER_MAP[hookName];
|
|
141
|
-
if (tier
|
|
163
|
+
if (tier) {
|
|
142
164
|
const tiers = all.tiers;
|
|
143
165
|
if (tiers?.[tier]?.enabled === false)
|
|
144
166
|
return false;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Internal runner: compound lifecycle check.
|
|
4
|
+
*
|
|
5
|
+
* Spawned by `session-recovery.ts` as a detached background process.
|
|
6
|
+
* Exists as a dedicated script file so the caller can pass `sessionId`
|
|
7
|
+
* via argv instead of interpolating it into a `-e` template literal.
|
|
8
|
+
*
|
|
9
|
+
* Audit finding #5 (2026-04-21): prior call site used
|
|
10
|
+
* spawn('node', ['--input-type=module', '-e',
|
|
11
|
+
* `import('${path}').then(m => m.runLifecycleCheck('${sessionId}'))`])
|
|
12
|
+
* which interpolated `sessionId` (originating from hook stdin) into
|
|
13
|
+
* executable JS source. An attacker-controlled session id of the shape
|
|
14
|
+
* `a'); malicious(); //` would have executed arbitrary JS under the
|
|
15
|
+
* user's Claude-Code privileges. A dedicated script + argv lookup has
|
|
16
|
+
* no shell or eval surface.
|
|
17
|
+
*
|
|
18
|
+
* Contract: `process.argv[2]` is the session id. Any extra args are
|
|
19
|
+
* ignored. stdout/stderr are ignored by the caller (`stdio: 'ignore'`).
|
|
20
|
+
*/
|
|
21
|
+
import { runLifecycleCheck } from '../../engine/compound-lifecycle.js';
|
|
22
|
+
const sessionId = process.argv[2];
|
|
23
|
+
if (!sessionId || typeof sessionId !== 'string') {
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
runLifecycleCheck(sessionId);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Detached background — best effort. Surfacing errors would have no
|
|
31
|
+
// consumer and the parent hook already logged the spawn.
|
|
32
|
+
}
|
|
@@ -21,6 +21,7 @@ import { isHookEnabled } from './hook-config.js';
|
|
|
21
21
|
import { truncateContent } from './shared/injection-caps.js';
|
|
22
22
|
import { calculateBudget } from './shared/context-budget.js';
|
|
23
23
|
import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
|
|
24
|
+
import { escapeAllXmlTags } from './prompt-injection-filter.js';
|
|
24
25
|
// ── 메인 ──
|
|
25
26
|
async function main() {
|
|
26
27
|
const input = await readStdinJSON();
|
|
@@ -39,9 +40,11 @@ async function main() {
|
|
|
39
40
|
console.log(approve());
|
|
40
41
|
return;
|
|
41
42
|
}
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// P1-S2 fix (2026-04-20): 이전에는 `</forgen-notepad>` 리터럴 하나만 치환했지만,
|
|
44
|
+
// notepad 파일에 `<system>`, `<assistant>` 같은 임의 XML 태그가 있으면 그대로
|
|
45
|
+
// LLM에 전달되어 지시 주입 위험. escapeAllXmlTags로 모든 태그를 escape한다.
|
|
46
|
+
const truncated = truncateContent(notepadContent.trim(), calculateBudget(effectiveCwd).notepadMax);
|
|
47
|
+
const safeContent = escapeAllXmlTags(truncated);
|
|
45
48
|
const injection = `<forgen-notepad>\n${safeContent}\n</forgen-notepad>`;
|
|
46
49
|
console.log(approveWithContext(injection, 'UserPromptSubmit'));
|
|
47
50
|
}
|
|
@@ -10,5 +10,13 @@
|
|
|
10
10
|
export declare const SAFE_TOOLS: Set<string>;
|
|
11
11
|
/** autopilot 모드에서도 수동 확인이 필요한 도구 */
|
|
12
12
|
export declare const ALWAYS_CONFIRM_TOOLS: Set<string>;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* 도구 분류: pass-through 결정 (순수 함수).
|
|
15
|
+
*
|
|
16
|
+
* Audit clarification #4 (2026-04-21): 본 훅은 Claude의 기본 권한 흐름을
|
|
17
|
+
* 가로채지 않는다 — 모든 return 라벨은 "어떤 pass-through 경로인가"를
|
|
18
|
+
* 의미하며, `permissionDecision: 'allow'`를 강제하지 않는다. 과거 라벨
|
|
19
|
+
* `auto-approve-safe`, `autopilot-approve`는 승인으로 오해되어 audit log가
|
|
20
|
+
* 실제 실행 신뢰도와 어긋났다.
|
|
21
|
+
*/
|
|
22
|
+
export declare function classifyTool(toolName: string, isAutopilot: boolean): 'safe-pass-through' | 'autopilot-warn-pass-through' | 'autopilot-pass-through' | 'pass-through';
|
|
@@ -24,15 +24,23 @@ export const SAFE_TOOLS = new Set([
|
|
|
24
24
|
export const ALWAYS_CONFIRM_TOOLS = new Set([
|
|
25
25
|
'Bash', 'Write', 'Edit',
|
|
26
26
|
]);
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 도구 분류: pass-through 결정 (순수 함수).
|
|
29
|
+
*
|
|
30
|
+
* Audit clarification #4 (2026-04-21): 본 훅은 Claude의 기본 권한 흐름을
|
|
31
|
+
* 가로채지 않는다 — 모든 return 라벨은 "어떤 pass-through 경로인가"를
|
|
32
|
+
* 의미하며, `permissionDecision: 'allow'`를 강제하지 않는다. 과거 라벨
|
|
33
|
+
* `auto-approve-safe`, `autopilot-approve`는 승인으로 오해되어 audit log가
|
|
34
|
+
* 실제 실행 신뢰도와 어긋났다.
|
|
35
|
+
*/
|
|
28
36
|
export function classifyTool(toolName, isAutopilot) {
|
|
29
37
|
if (SAFE_TOOLS.has(toolName))
|
|
30
|
-
return '
|
|
38
|
+
return 'safe-pass-through';
|
|
31
39
|
if (!isAutopilot)
|
|
32
40
|
return 'pass-through';
|
|
33
41
|
if (ALWAYS_CONFIRM_TOOLS.has(toolName))
|
|
34
|
-
return 'autopilot-
|
|
35
|
-
return 'autopilot-
|
|
42
|
+
return 'autopilot-warn-pass-through';
|
|
43
|
+
return 'autopilot-pass-through';
|
|
36
44
|
}
|
|
37
45
|
/** autopilot 모드 활성 여부 확인 */
|
|
38
46
|
function isAutopilotActive() {
|
|
@@ -80,9 +88,17 @@ async function main() {
|
|
|
80
88
|
}
|
|
81
89
|
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
82
90
|
const sessionId = data.session_id ?? 'default';
|
|
83
|
-
//
|
|
91
|
+
// Audit note #4 (2026-04-21): `approve()` / `approveWithWarning()` 둘 다
|
|
92
|
+
// Claude Code hook protocol에서 `permissionDecision: 'allow'`를 설정하지
|
|
93
|
+
// 않는다. 따라서 본 훅은 실제로 도구 실행을 "승인(force-allow)"하지 않고,
|
|
94
|
+
// Claude의 기본 권한 흐름으로 pass-through 시킨다 (systemMessage UI 경고는
|
|
95
|
+
// 선택사항). 과거 로그에서 `auto-approve-safe` / `autopilot-approve` 같은
|
|
96
|
+
// 결정 이름이 실제 효과와 어긋났기에 로그 라벨을 실효에 맞춰 정정했다.
|
|
97
|
+
//
|
|
98
|
+
// SAFE_TOOLS (Read/Glob/Grep 등): Claude 기본 정책상 이미 허용되는 도구이므로
|
|
99
|
+
// 이곳에서 별도 장치 없이 pass-through. 로그는 `safe-pass-through`로 기록.
|
|
84
100
|
if (SAFE_TOOLS.has(toolName)) {
|
|
85
|
-
logPermissionRequest(sessionId, toolName, '
|
|
101
|
+
logPermissionRequest(sessionId, toolName, 'safe-pass-through');
|
|
86
102
|
console.log(approve());
|
|
87
103
|
return;
|
|
88
104
|
}
|
|
@@ -94,18 +110,21 @@ async function main() {
|
|
|
94
110
|
}
|
|
95
111
|
// autopilot 모드 (2차 방어선):
|
|
96
112
|
// pre-tool-use 훅이 위험 패턴(rm -rf, git push --force 등)을 이미 block/warn 처리함.
|
|
97
|
-
// 여기 도달하는 도구는 pre-tool-use를 통과한
|
|
113
|
+
// 여기 도달하는 도구는 pre-tool-use를 통과한 것으로 pass-through + UI 경고.
|
|
114
|
+
// 여전히 Claude의 기본 confirmation은 사용자에게 노출된다 — 본 훅이 전체
|
|
115
|
+
// 승인을 가로채는 게 아니라 추적성을 위한 어노테이션이다.
|
|
98
116
|
if (ALWAYS_CONFIRM_TOOLS.has(toolName)) {
|
|
99
|
-
logPermissionRequest(sessionId, toolName, 'autopilot-
|
|
117
|
+
logPermissionRequest(sessionId, toolName, 'autopilot-warn-pass-through');
|
|
100
118
|
// Bash는 pre-tool-use를 통과했더라도 경고 강도를 높임 (임의 셸 실행 위험)
|
|
101
119
|
const warningLevel = toolName === 'Bash'
|
|
102
|
-
? `[Forgen] ⚠ Autopilot: Bash tool
|
|
103
|
-
: `[Forgen] Autopilot: ${toolName} tool
|
|
120
|
+
? `[Forgen] ⚠ Autopilot: Bash tool — passed pre-tool-use validation. Beware of unexpected commands.`
|
|
121
|
+
: `[Forgen] Autopilot: ${toolName} tool use passed through with warning.`;
|
|
104
122
|
console.log(approveWithWarning(`<compound-permission>\n${warningLevel}\n</compound-permission>`));
|
|
105
123
|
return;
|
|
106
124
|
}
|
|
107
|
-
// 기타 도구: autopilot
|
|
108
|
-
|
|
125
|
+
// 기타 도구: autopilot 모드에서도 pass-through (force-approve 아님).
|
|
126
|
+
// 과거 로그 라벨은 `autopilot-approve`였으나 실제 효과는 pass-through.
|
|
127
|
+
logPermissionRequest(sessionId, toolName, 'autopilot-pass-through');
|
|
109
128
|
console.log(approve());
|
|
110
129
|
}
|
|
111
130
|
main().catch((e) => {
|
|
@@ -105,6 +105,13 @@ async function main() {
|
|
|
105
105
|
saveFailureState(state);
|
|
106
106
|
// 컨텍스트 신호 업데이트
|
|
107
107
|
incrementFailureSignal(sessionId);
|
|
108
|
+
// Outcome tracking (Phase 1): attribute this tool failure to pending
|
|
109
|
+
// solution injections in the same session. Fail-open.
|
|
110
|
+
try {
|
|
111
|
+
const { attributeError } = await import('../engine/solution-outcomes.js');
|
|
112
|
+
attributeError(sessionId);
|
|
113
|
+
}
|
|
114
|
+
catch { /* ignore */ }
|
|
108
115
|
const failCount = state.failures[toolName].count;
|
|
109
116
|
const suggestion = getRecoverySuggestion(error, toolName);
|
|
110
117
|
// 3회 이상 반복 실패 시 강화된 경고
|