@wooojin/forgen 0.3.1 → 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.
Files changed (72) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +100 -0
  3. package/README.ja.md +29 -0
  4. package/README.ko.md +29 -0
  5. package/README.md +36 -3
  6. package/README.zh.md +29 -0
  7. package/dist/cli.js +3 -3
  8. package/dist/core/auto-compound-runner.js +6 -3
  9. package/dist/core/dashboard.js +11 -4
  10. package/dist/core/doctor.d.ts +6 -1
  11. package/dist/core/doctor.js +21 -1
  12. package/dist/core/global-config.d.ts +2 -2
  13. package/dist/core/global-config.js +6 -14
  14. package/dist/core/harness.d.ts +3 -5
  15. package/dist/core/harness.js +34 -338
  16. package/dist/core/installer.d.ts +10 -0
  17. package/dist/core/installer.js +185 -0
  18. package/dist/core/paths.d.ts +0 -34
  19. package/dist/core/paths.js +0 -35
  20. package/dist/core/settings-injector.d.ts +13 -0
  21. package/dist/core/settings-injector.js +167 -0
  22. package/dist/core/settings-lock.d.ts +35 -2
  23. package/dist/core/settings-lock.js +65 -7
  24. package/dist/core/spawn.js +100 -39
  25. package/dist/core/state-gc.d.ts +30 -0
  26. package/dist/core/state-gc.js +119 -0
  27. package/dist/core/uninstall.js +12 -4
  28. package/dist/core/v1-bootstrap.js +2 -2
  29. package/dist/engine/compound-cli.d.ts +27 -2
  30. package/dist/engine/compound-cli.js +69 -16
  31. package/dist/engine/compound-export.d.ts +15 -0
  32. package/dist/engine/compound-export.js +32 -5
  33. package/dist/engine/compound-loop.js +3 -2
  34. package/dist/engine/learn-cli.js +52 -0
  35. package/dist/engine/match-eval-log.js +45 -0
  36. package/dist/engine/solution-format.d.ts +0 -2
  37. package/dist/engine/solution-format.js +0 -4
  38. package/dist/engine/solution-matcher.d.ts +8 -0
  39. package/dist/engine/solution-matcher.js +7 -4
  40. package/dist/engine/solution-outcomes.d.ts +4 -0
  41. package/dist/engine/solution-outcomes.js +174 -97
  42. package/dist/engine/solution-writer.d.ts +8 -5
  43. package/dist/engine/solution-writer.js +43 -19
  44. package/dist/fgx.js +9 -2
  45. package/dist/forge/cli.js +7 -7
  46. package/dist/hooks/context-guard.js +15 -1
  47. package/dist/hooks/hook-config.d.ts +9 -1
  48. package/dist/hooks/hook-config.js +25 -3
  49. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  50. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  51. package/dist/hooks/notepad-injector.js +6 -3
  52. package/dist/hooks/permission-handler.d.ts +10 -2
  53. package/dist/hooks/permission-handler.js +31 -12
  54. package/dist/hooks/pre-tool-use.js +10 -4
  55. package/dist/hooks/secret-filter.js +6 -0
  56. package/dist/hooks/session-recovery.js +15 -7
  57. package/dist/hooks/shared/hook-response.d.ts +0 -2
  58. package/dist/hooks/shared/hook-response.js +3 -8
  59. package/dist/hooks/shared/hook-timing.js +10 -1
  60. package/dist/hooks/solution-injector.d.ts +21 -0
  61. package/dist/hooks/solution-injector.js +60 -1
  62. package/dist/mcp/solution-reader.d.ts +2 -0
  63. package/dist/mcp/solution-reader.js +28 -1
  64. package/dist/mcp/tools.js +5 -2
  65. package/dist/preset/preset-manager.js +12 -2
  66. package/dist/store/evidence-store.js +5 -5
  67. package/dist/store/profile-store.d.ts +9 -0
  68. package/dist/store/profile-store.js +25 -4
  69. package/dist/store/rule-store.js +8 -8
  70. package/package.json +1 -1
  71. package/plugin.json +7 -2
  72. package/scripts/postinstall.js +52 -5
@@ -2,6 +2,8 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { OUTCOMES_DIR, STATE_DIR } from '../core/paths.js';
4
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';
5
7
  import { createLogger } from '../core/logger.js';
6
8
  const log = createLogger('solution-outcomes');
7
9
  function pendingPath(sessionId) {
@@ -24,12 +26,41 @@ function readPending(sessionId) {
24
26
  function writePending(sessionId, state) {
25
27
  const p = pendingPath(sessionId);
26
28
  fs.mkdirSync(STATE_DIR, { recursive: true });
27
- fs.writeFileSync(p, JSON.stringify(state));
29
+ atomicWriteJSON(p, state, { mode: 0o600, dirMode: 0o700 });
28
30
  }
29
31
  function appendOutcome(event) {
30
32
  fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
31
33
  fs.appendFileSync(outcomesPath(event.session_id), JSON.stringify(event) + '\n');
32
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
+ }
33
64
  /**
34
65
  * Record that solutions were injected. Called from solution-injector right
35
66
  * after `approveWithContext` is emitted. Fails silently — outcome tracking
@@ -39,12 +70,14 @@ export function appendPending(sessionId, injections) {
39
70
  if (!sessionId || injections.length === 0)
40
71
  return;
41
72
  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);
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
+ });
48
81
  }
49
82
  catch (e) {
50
83
  log.debug(`appendPending failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -62,29 +95,37 @@ export function flushAccept(sessionId, excludeSolutions = new Set()) {
62
95
  if (!sessionId)
63
96
  return 0;
64
97
  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;
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;
88
129
  }
89
130
  catch (e) {
90
131
  log.debug(`flushAccept failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -105,38 +146,64 @@ export function attributeCorrection(sessionId) {
105
146
  if (!sessionId)
106
147
  return [];
107
148
  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;
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 ?? [];
128
172
  }
129
173
  catch (e) {
130
174
  log.debug(`attributeCorrection failed: ${e instanceof Error ? e.message : String(e)}`);
131
175
  return [];
132
176
  }
133
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;
134
197
  /**
135
198
  * Attribute a tool error to pending solutions in this session. Called from
136
199
  * post-tool-failure hook. Unlike corrections, errors do not clear pending
137
200
  * — an error is a weaker signal and the next user prompt can still produce
138
201
  * a correct/accept decision.
139
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
+ *
140
207
  * To avoid flooding the log with duplicate errors for the same pending
141
208
  * batch, we cap at one `error` event per (session, solution) pair per
142
209
  * pending-cycle by tracking a `error_flagged` set in the pending state.
@@ -145,33 +212,40 @@ export function attributeError(sessionId) {
145
212
  if (!sessionId)
146
213
  return [];
147
214
  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;
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 ?? [];
175
249
  }
176
250
  catch (e) {
177
251
  log.debug(`attributeError failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -187,26 +261,29 @@ export function finalizeSession(sessionId) {
187
261
  if (!sessionId)
188
262
  return 0;
189
263
  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;
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;
210
287
  }
211
288
  catch (e) {
212
289
  log.debug(`finalizeSession failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -71,11 +71,14 @@ export declare function mutateSolutionByName(name: string, mutator: SolutionMuta
71
71
  }): boolean;
72
72
  /**
73
73
  * Evidence 카운터 단일 증가 helper.
74
- * mutateSolutionByName + 카운터 증가 패턴을 한 줄로.
75
74
  *
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
+ * 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 참고).
80
83
  */
81
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 dirs = [ME_SOLUTIONS, ME_RULES, ...(options?.extraDirs ?? [])];
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;
@@ -142,22 +176,17 @@ export function mutateSolutionByName(name, mutator, options) {
142
176
  }
143
177
  return false;
144
178
  }
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;
153
179
  /**
154
180
  * Evidence 카운터 단일 증가 helper.
155
- * mutateSolutionByName + 카운터 증가 패턴을 한 줄로.
156
181
  *
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.
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 참고).
161
190
  */
162
191
  export function incrementEvidence(solutionName, field) {
163
192
  return mutateSolutionByName(solutionName, sol => {
@@ -165,11 +194,6 @@ export function incrementEvidence(solutionName, field) {
165
194
  if (!(field in ev))
166
195
  return false;
167
196
  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
- }
173
197
  return true;
174
198
  });
175
199
  }
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.\n');
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 { V1_PROFILE, V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, V1_SOLUTIONS_DIR } = await import('../core/paths.js');
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(V1_PROFILE);
79
- deleteDirs([V1_RULES_DIR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR]);
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(V1_PROFILE);
84
- deleteDirs([V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR]);
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(V1_PROFILE);
89
- deleteDirs([V1_RULES_DIR, V1_EVIDENCE_DIR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, V1_SOLUTIONS_DIR]);
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
- const cachePath = path.join(STATE_DIR, `solution-cache-${sessionId}.json`);
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 (티어 설정) — compound-core는 티어 비활성화 무시
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 (티어 설정) — compound-core는 티어 비활성화 무시
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) 티어 설정 — compound-core는 절대 티어 비활성화로 끄지 않음
161
+ // 2) 티어 설정
140
162
  const tier = HOOK_TIER_MAP[hookName];
141
- if (tier && tier !== 'compound-core') {
163
+ if (tier) {
142
164
  const tiers = all.tiers;
143
165
  if (tiers?.[tier]?.enabled === false)
144
166
  return false;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};