@wooojin/forgen 0.4.7 → 0.4.8

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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +40 -0
  3. package/dist/checks/self-score-deflation.js +6 -4
  4. package/dist/cli.js +5 -2
  5. package/dist/core/auto-compound-runner.js +6 -2
  6. package/dist/core/dashboard.js +2 -2
  7. package/dist/core/doctor.d.ts +10 -0
  8. package/dist/core/doctor.js +49 -8
  9. package/dist/core/harness.js +8 -2
  10. package/dist/core/inspect-cli.js +4 -4
  11. package/dist/core/migrate-evidence-host.js +1 -1
  12. package/dist/core/notify.js +7 -0
  13. package/dist/core/paths.d.ts +16 -2
  14. package/dist/core/paths.js +16 -2
  15. package/dist/core/session-store.d.ts +12 -1
  16. package/dist/core/session-store.js +73 -1
  17. package/dist/core/spawn.js +13 -7
  18. package/dist/core/v1-bootstrap.d.ts +7 -0
  19. package/dist/core/v1-bootstrap.js +28 -6
  20. package/dist/engine/compound-extractor.js +1 -1
  21. package/dist/engine/learn-cli.js +2 -2
  22. package/dist/engine/lifecycle/bypass-detector.js +3 -2
  23. package/dist/engine/lifecycle/meta-reclassifier.js +1 -1
  24. package/dist/engine/lifecycle/signals.js +2 -2
  25. package/dist/engine/lifecycle/trigger-t1-correction.js +1 -1
  26. package/dist/engine/solution-candidate.js +1 -1
  27. package/dist/engine/solution-outcomes.js +1 -1
  28. package/dist/engine/solution-quarantine.js +1 -1
  29. package/dist/engine/solution-weakness.js +8 -2
  30. package/dist/forge/cli.js +1 -1
  31. package/dist/hooks/keyword-detector.js +1 -1
  32. package/dist/hooks/secret-filter.js +2 -2
  33. package/dist/hooks/shared/hook-response.js +1 -1
  34. package/dist/hooks/shared/hook-timing.js +3 -3
  35. package/dist/hooks/solution-injector.js +1 -1
  36. package/dist/hooks/stop-guard.js +3 -3
  37. package/dist/host/install-codex.js +1 -1
  38. package/dist/mcp/tools.js +1 -1
  39. package/dist/preset/facet-catalog.js +2 -2
  40. package/dist/renderer/rule-renderer.js +7 -7
  41. package/dist/store/compound-usage-store.js +1 -1
  42. package/dist/store/implicit-feedback-store.js +2 -2
  43. package/dist/store/profile-store.d.ts +11 -0
  44. package/dist/store/profile-store.js +23 -0
  45. package/package.json +1 -1
  46. package/plugin.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://claude.ai/schemas/claude-plugin.json",
3
3
  "name": "forgen",
4
- "version": "0.4.7",
4
+ "version": "0.4.8",
5
5
  "description": "Claude Code harness — the more you use Claude, the better it gets",
6
6
  "author": {
7
7
  "name": "jang-ujin",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.8] — 2026-05-15 — Codex 동등화 마무리 + 잔재 청소
11
+
12
+ 테마: v0.4.6 (Unattended Resilience) 이후 남아 있던 **Codex 동등화 마무리**
13
+ (A 묶음) 와 v0.4.7 매트릭스 첫 활성화에서 노출된 **사전 존재 결함 청소**
14
+ (E 묶음) 을 한 번에 정리.
15
+
16
+ ### Added
17
+ - **A1**: Codex transcript FTS5 인덱싱 — `session-store.ts:indexCodexSession()`
18
+ 신설. 이전엔 spawn.ts 가 `runtime === 'claude'` 가드로 Codex 세션을 SQLite
19
+ / FTS5 인덱싱에서 제외해 `session-search` MCP 도구가 Codex 대화를 회수
20
+ 못 했음. Claude/Codex schema 별 함수 분기.
21
+ - **A2**: corrupt profile 자동 복구 — `profileExists()=true && loadProfile()=null`
22
+ 케이스 (parse 실패 / v1 shape 위반) 에서 `profile.json.corrupt-<ts>` 로
23
+ 자동 backup → `needsOnboarding=true` 흐름. 데이터 손실 없음. `harness.ts`
24
+ 가 backup 경로를 user-visible warning 으로 표시.
25
+ - **A3**: `SESSIONS_DIR` (legacy session log) 와 `V1_SESSIONS_DIR` (v1 effective
26
+ state) 정합화 — `V1_DIRS` 에 `SESSIONS_DIR` 추가 (bootstrap early-return
27
+ 경로에서도 보장), `forgen doctor` 가 두 dir 모두 노출, `paths.ts` 책임
28
+ 주석 확장.
29
+ - **E3**: `forgen doctor --repair` — plugin cache / installPath 검사 실패 시
30
+ `npm run build` + `node scripts/postinstall.js` 를 forgen pkgRoot 안에서
31
+ 자동 실행. fail-open (실패해도 doctor 진단 흐름은 계속).
32
+
33
+ ### Fixed
34
+ - **E1**: `notify.ts` spawn 'error' event 핸들러 — headless CI / Docker /
35
+ notifier 미설치 환경에서 `osascript`/`notify-send` ENOENT 가 unhandled
36
+ 로 caller process 를 죽이던 사전 존재 버그. v0.4.7 CI 매트릭스 첫
37
+ 활성화에서 노출. 추가로 `rate-limit-spawn-integration` 의 v0.4.7 CI
38
+ skip 가드를 제거 (production fail-safe 보장).
39
+ - **E2**: biome lint warnings 24 건 → 0 — `biome --unsafe` 자동 fix 17
40
+ 건 + 수동 fix 7 건 (`useTemplate`, `noAssignInExpressions`,
41
+ `noExplicitAny`, `noNonNullAssertion` 등 33 files touched).
42
+
43
+ ### Verified
44
+ - vitest 2454 / 2454 PASS (이전 2442 + 새 회귀 가드 12: notify 2, profile-
45
+ corrupt 3, doctor-repair 5, codex-fts 2).
46
+ - 로컬 build / lint 0 warning.
47
+ - CI 매트릭스 (Linux x64 + arm64, macOS, Windows hooks-portability) 모두
48
+ PASS — 별도 PR 머지 (#31 부분) 후 본 PR 에서 다시 확인.
49
+
10
50
  ## [0.4.7] — 2026-05-15 — fgx --codex 권한 플래그 수정
11
51
 
12
52
  ### Fixed
@@ -50,12 +50,13 @@ const SELF_SCORE_PATTERNS = [
50
50
  function extractDeltas(text) {
51
51
  const re = /(\d+(?:\.\d+)?)\s*(?:→|->|–>|~>)\s*(\d+(?:\.\d+)?)/g;
52
52
  const out = [];
53
- let m;
54
- while ((m = re.exec(text)) !== null) {
53
+ let m = re.exec(text);
54
+ while (m !== null) {
55
55
  const from = Number(m[1]);
56
56
  const to = Number(m[2]);
57
57
  if (Number.isFinite(from) && Number.isFinite(to))
58
58
  out.push({ from, to });
59
+ m = re.exec(text);
59
60
  }
60
61
  return out;
61
62
  }
@@ -66,9 +67,10 @@ function findScoreSignals(text, max = 3) {
66
67
  break;
67
68
  // 각 호출마다 lastIndex 초기화를 위해 새 RegExp 생성
68
69
  const re = new RegExp(p.source, p.flags);
69
- let m;
70
- while ((m = re.exec(text)) !== null && out.length < max) {
70
+ let m = re.exec(text);
71
+ while (m !== null && out.length < max) {
71
72
  out.push(m[0]);
73
+ m = re.exec(text);
72
74
  }
73
75
  }
74
76
  return out;
package/dist/cli.js CHANGED
@@ -237,10 +237,13 @@ const commands = [
237
237
  },
238
238
  {
239
239
  name: 'doctor',
240
- description: 'Diagnostics (--prune-state to GC stale session files)',
240
+ description: 'Diagnostics (--prune-state to GC stale session files, --repair to auto-fix plugin cache)',
241
241
  handler: async (args) => {
242
242
  const { runDoctor } = await import('./core/doctor.js');
243
- await runDoctor({ pruneState: args.includes('--prune-state') });
243
+ await runDoctor({
244
+ pruneState: args.includes('--prune-state'),
245
+ repair: args.includes('--repair'),
246
+ });
244
247
  },
245
248
  },
246
249
  // install --plugin 제거됨 — postinstall이 유일한 설치 경로
@@ -219,8 +219,12 @@ function validateSolutionFiles(dirBefore) {
219
219
  function extractText(c) {
220
220
  if (typeof c === 'string')
221
221
  return c;
222
- if (Array.isArray(c))
223
- return c.filter((x) => x?.type === 'text').map((x) => x.text ?? '').join('\n');
222
+ if (Array.isArray(c)) {
223
+ return c
224
+ .filter((x) => typeof x === 'object' && x !== null && x.type === 'text')
225
+ .map((x) => (typeof x.text === 'string' ? x.text : ''))
226
+ .join('\n');
227
+ }
224
228
  return '';
225
229
  }
226
230
  /**
@@ -40,13 +40,13 @@ function dim(s) { return `${DIM}${s}${RESET}`; }
40
40
  function cyan(s) { return `${CYAN}${s}${RESET}`; }
41
41
  // ── Box-drawing table helpers ──
42
42
  function tableRow(cols, widths) {
43
- return '' + cols.map((c, i) => c.padEnd(widths[i])).join(' │ ') + ' │';
43
+ return `${cols.map((c, i) => c.padEnd(widths[i])).join(' │ ')} │`;
44
44
  }
45
45
  function tableSep(widths, top = false, bottom = false) {
46
46
  const left = top ? '┌' : bottom ? '└' : '├';
47
47
  const mid = top ? '┬' : bottom ? '┴' : '┼';
48
48
  const right = top ? '┐' : bottom ? '┘' : '┤';
49
- return ' ' + left + widths.map(w => '─'.repeat(w + 2)).join(mid) + right;
49
+ return ` ${left}${widths.map(w => '─'.repeat(w + 2)).join(mid)}${right}`;
50
50
  }
51
51
  // ── Data Collection Functions ──
52
52
  /** Read all .md files in a directory and return their frontmatter. */
@@ -2,5 +2,15 @@ export interface DoctorOptions {
2
2
  /** When true, delete stale session-scoped state files instead of just
3
3
  * reporting bloat. Triggered by `forgen doctor --prune-state`. */
4
4
  pruneState?: boolean;
5
+ /**
6
+ * When true, auto-fix recoverable failures (e.g. missing plugin cache /
7
+ * stale installPath) by running `npm run build` + `node scripts/postinstall.js`
8
+ * inside the forgen install directory. Triggered by `forgen doctor --repair`.
9
+ *
10
+ * v0.4.8 (E3) — 이전엔 안내문 ("Fix: npm run build && node scripts/postinstall.js")
11
+ * 만 출력했고 사용자가 직접 실행해야 했음. fail-open: repair 실패해도
12
+ * doctor 흐름은 정상 종료.
13
+ */
14
+ repair?: boolean;
5
15
  }
6
16
  export declare function runDoctor(opts?: DoctorOptions): Promise<void>;
@@ -2,7 +2,8 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import { execFileSync } from 'node:child_process';
5
- import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_SOLUTIONS, ME_RULES, ME_SKILLS, PACKS_DIR, SESSIONS_DIR, STATE_DIR } from './paths.js';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { FORGEN_HOME, LAB_DIR, ME_BEHAVIOR, ME_DIR, ME_SOLUTIONS, ME_RULES, ME_SKILLS, PACKS_DIR, SESSIONS_DIR, STATE_DIR, V1_SESSIONS_DIR } from './paths.js';
6
7
  import { getTimingStats } from '../hooks/shared/hook-timing.js';
7
8
  import { countSessionScopedFiles, pruneState } from './state-gc.js';
8
9
  import { summarizeAllByHost } from '../store/host-mismatch.js';
@@ -87,6 +88,29 @@ function renderCodexParity() {
87
88
  console.log(` ✓ Codex parity green (last run: ${timeStr},${version})`);
88
89
  }
89
90
  }
91
+ /**
92
+ * v0.4.8 (E3): plugin cache / installPath 진단이 실패했을 때 자동 복구.
93
+ * forgen 패키지 디렉토리에서 `npm run build` + postinstall 을 차례로 실행.
94
+ * 실패해도 doctor 자체는 계속 진행 (fail-open).
95
+ */
96
+ function attemptPluginRepair() {
97
+ try {
98
+ // forgen 패키지 루트 = 현재 파일에서 dist/core/doctor.js 위치 → pkgRoot.
99
+ // dev (src/) 와 prod (dist/) 양쪽 모두 path.resolve(...,'..','..') 로 도달.
100
+ const here = path.dirname(fileURLToPath(import.meta.url));
101
+ const pkgRoot = path.resolve(here, '..', '..');
102
+ console.log(`\n [Repair] forgen 패키지 자가복구 시도 — ${pkgRoot}`);
103
+ execFileSync('npm', ['run', 'build'], { cwd: pkgRoot, stdio: 'inherit' });
104
+ execFileSync('node', ['scripts/postinstall.js'], { cwd: pkgRoot, stdio: 'inherit' });
105
+ console.log(' [Repair] 완료. 진단 재실행 권장: forgen doctor');
106
+ return true;
107
+ }
108
+ catch (e) {
109
+ console.warn(` [Repair] 실패: ${e instanceof Error ? e.message : String(e)}`);
110
+ console.warn(' [Repair] 수동 복구: cd <forgen pkgRoot> && npm run build && node scripts/postinstall.js');
111
+ return false;
112
+ }
113
+ }
90
114
  export async function runDoctor(opts = {}) {
91
115
  failedChecks = [];
92
116
  console.log('\n Forgen — Diagnostics\n');
@@ -114,7 +138,9 @@ export async function runDoctor(opts = {}) {
114
138
  });
115
139
  forgenPluginCacheOk = versions.length > 0;
116
140
  }
117
- check('forgen plugin cache', forgenPluginCacheOk, 'Hook execution requires plugin cache. Fix: npm run build && node scripts/postinstall.js');
141
+ check('forgen plugin cache', forgenPluginCacheOk, opts.repair
142
+ ? 'Hook execution requires plugin cache. Attempting auto-repair (--repair)…'
143
+ : 'Hook execution requires plugin cache. Fix: npm run build && node scripts/postinstall.js (or rerun with --repair)');
118
144
  // installed_plugins.json 정합성 확인
119
145
  const installedPluginsPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
120
146
  let pluginRegistered = false;
@@ -129,7 +155,15 @@ export async function runDoctor(opts = {}) {
129
155
  }
130
156
  catch { /* ignore */ }
131
157
  }
132
- check('forgen plugin registered & installPath exists', pluginRegistered, 'Plugin registered but installPath missing on disk. Fix: npm run build && node scripts/postinstall.js');
158
+ check('forgen plugin registered & installPath exists', pluginRegistered, opts.repair
159
+ ? 'Plugin registered but installPath missing on disk. Attempting auto-repair (--repair)…'
160
+ : 'Plugin registered but installPath missing on disk. Fix: npm run build && node scripts/postinstall.js (or rerun with --repair)');
161
+ // v0.4.8 (E3): plugin cache 또는 installPath 가 깨졌고 --repair 가 켜져
162
+ // 있으면 build + postinstall 자동 실행. doctor 진단 자체는 계속 진행하여
163
+ // 사용자가 다른 health 항목도 한 번에 확인 가능.
164
+ if (opts.repair && (!forgenPluginCacheOk || !pluginRegistered)) {
165
+ attemptPluginRepair();
166
+ }
133
167
  console.log();
134
168
  section('Directories');
135
169
  check('~/.forgen/', exists(FORGEN_HOME));
@@ -138,7 +172,8 @@ export async function runDoctor(opts = {}) {
138
172
  check('~/.forgen/me/behavior/', exists(ME_BEHAVIOR));
139
173
  check('~/.forgen/me/rules/', exists(ME_RULES));
140
174
  check('~/.forgen/packs/', exists(PACKS_DIR));
141
- check('~/.forgen/sessions/', exists(SESSIONS_DIR));
175
+ check('~/.forgen/sessions/ (session logs)', exists(SESSIONS_DIR));
176
+ check('~/.forgen/state/sessions/ (v1 effective state)', exists(V1_SESSIONS_DIR));
142
177
  // R9-IA5: warn if a user dropped rule files at ~/.forgen/rules/ by mistake.
143
178
  // That path is NOT loaded — personal rules live at ~/.forgen/me/rules/.
144
179
  const legacyRulesPath = path.join(FORGEN_HOME, 'rules');
@@ -194,10 +229,16 @@ export async function runDoctor(opts = {}) {
194
229
  }
195
230
  console.log();
196
231
  console.log(' [Log Locations]');
197
- console.log(` Session logs: ${SESSIONS_DIR}`);
232
+ console.log(` Session logs: ${SESSIONS_DIR}`);
198
233
  if (exists(SESSIONS_DIR)) {
199
234
  const sessionCount = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json')).length;
200
- console.log(` Saved sessions: ${sessionCount}`);
235
+ console.log(` Saved sessions: ${sessionCount}`);
236
+ }
237
+ // v0.4.8 (A3): v1 effective state directory 도 가시화 — 두 dir 책임 다름.
238
+ console.log(` V1 effective state: ${V1_SESSIONS_DIR}`);
239
+ if (exists(V1_SESSIONS_DIR)) {
240
+ const stateCount = fs.readdirSync(V1_SESSIONS_DIR).filter((f) => f.endsWith('.json')).length;
241
+ console.log(` V1 state count: ${stateCount}`);
201
242
  }
202
243
  console.log(` Claude Code sessions: ${CLAUDE_PROJECTS_DIR}`);
203
244
  console.log();
@@ -244,7 +285,7 @@ export async function runDoctor(opts = {}) {
244
285
  }
245
286
  else {
246
287
  console.log(' Hook Count p50ms p95ms max ms');
247
- console.log(' ' + '-'.repeat(56));
288
+ console.log(` ${'-'.repeat(56)}`);
248
289
  for (const s of timingStats) {
249
290
  const hook = s.hook.padEnd(22);
250
291
  const count = String(s.count).padStart(5);
@@ -499,7 +540,7 @@ export async function runDoctor(opts = {}) {
499
540
  for (const f of failedChecks) {
500
541
  if (!bySection.has(f.section))
501
542
  bySection.set(f.section, []);
502
- bySection.get(f.section).push(f);
543
+ bySection.get(f.section)?.push(f);
503
544
  }
504
545
  for (const [sec, items] of bySection) {
505
546
  console.log(` [${sec}]`);
@@ -165,7 +165,7 @@ function ensureCompoundMemory(cwd) {
165
165
  const content = fs.readFileSync(memoryMdPath, 'utf-8');
166
166
  if (content.includes('compound-index.md'))
167
167
  return;
168
- fs.writeFileSync(memoryMdPath, content.trimEnd() + '\n' + compoundPointer + '\n');
168
+ fs.writeFileSync(memoryMdPath, `${content.trimEnd()}\n${compoundPointer}\n`);
169
169
  }
170
170
  const indexPath = path.join(memoryDir, 'compound-index.md');
171
171
  const solutionsDir = ME_SOLUTIONS;
@@ -262,7 +262,7 @@ function migrateToForgen() {
262
262
  catch (e) {
263
263
  log.debug(`migrateToForgen: ${legacyHome} 파일 복사 중 오류`, e);
264
264
  }
265
- const backupPath = legacyHome + '.bak';
265
+ const backupPath = `${legacyHome}.bak`;
266
266
  try {
267
267
  if (!fs.existsSync(backupPath)) {
268
268
  fs.renameSync(legacyHome, backupPath);
@@ -348,6 +348,12 @@ export async function prepareHarness(cwd, options = {}) {
348
348
  if (v1Result.legacyBackupPath) {
349
349
  log.debug(`v1: 레거시 프로필 백업 완료 → ${v1Result.legacyBackupPath}`);
350
350
  }
351
+ if (v1Result.corruptProfileBackupPath) {
352
+ // v0.4.8 — corrupt profile auto-repair 결과는 debug 가 아닌 user-visible warning.
353
+ // 사용자가 onboarding 으로 보내지는 이유를 알 수 있도록.
354
+ console.warn(` ⚠ forgen: profile.json 이 깨져 있어 옆에 백업해두고 onboarding 으로 보냅니다.`);
355
+ console.warn(` ⚠ backup: ${v1Result.corruptProfileBackupPath}`);
356
+ }
351
357
  if (v1Result.session) {
352
358
  const { session } = v1Result;
353
359
  log.debug(`v1 세션 시작: ${session.quality_pack}/${session.autonomy_pack}, trust=${session.effective_trust_policy}`);
@@ -21,7 +21,7 @@ export async function handleInspect(args) {
21
21
  console.log('\n No v1 profile found. Run onboarding first.\n');
22
22
  return;
23
23
  }
24
- console.log('\n' + inspect.renderProfile(profile) + '\n');
24
+ console.log(`\n${inspect.renderProfile(profile)}\n`);
25
25
  // ── Learning Loop Status ──
26
26
  const activeRules = loadActiveRules();
27
27
  const rulesByScope = {
@@ -75,13 +75,13 @@ export async function handleInspect(args) {
75
75
  }
76
76
  if (sub === 'rules') {
77
77
  const rules = loadAllRules();
78
- console.log('\n' + inspect.renderRules(rules) + '\n');
78
+ console.log(`\n${inspect.renderRules(rules)}\n`);
79
79
  return;
80
80
  }
81
81
  // R9-IA2: user-facing name is "corrections"; "evidence" kept as back-compat alias.
82
82
  if (sub === 'corrections' || sub === 'evidence') {
83
83
  const evidence = loadRecentEvidence(20);
84
- console.log('\n' + inspect.renderEvidence(evidence) + '\n');
84
+ console.log(`\n${inspect.renderEvidence(evidence)}\n`);
85
85
  return;
86
86
  }
87
87
  if (sub === 'session') {
@@ -90,7 +90,7 @@ export async function handleInspect(args) {
90
90
  console.log('\n No session state found.\n');
91
91
  return;
92
92
  }
93
- console.log('\n' + inspect.renderSession(sessions[0]) + '\n');
93
+ console.log(`\n${inspect.renderSession(sessions[0])}\n`);
94
94
  return;
95
95
  }
96
96
  // R5-G1: 2AM 디버깅용 jsonl tail — violations/bypass/drift
@@ -35,7 +35,7 @@ export function migrateEvidenceHost(options) {
35
35
  skipped++;
36
36
  continue;
37
37
  }
38
- const host = data['host'];
38
+ const host = data.host;
39
39
  if (host === 'claude' || host === 'codex') {
40
40
  skipped++;
41
41
  continue;
@@ -37,6 +37,9 @@ function notifyDarwin(title, body) {
37
37
  try {
38
38
  const script = `display notification "${escapeForOsascript(body)}" with title "${escapeForOsascript(title)}"`;
39
39
  const child = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
40
+ // ENOENT 등 spawn 실패는 동기 throw 가 아니라 'error' event 로 emit 됨.
41
+ // 핸들러 없으면 unhandled 로 process crash → headless CI 회귀 가드.
42
+ child.on('error', (e) => log.debug('osascript notification 실패 (event)', e));
40
43
  child.unref();
41
44
  }
42
45
  catch (e) {
@@ -47,6 +50,10 @@ function notifyDarwin(title, body) {
47
50
  function notifyLinux(title, body) {
48
51
  try {
49
52
  const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
53
+ // headless 환경 (CI, Docker) 에서 notify-send 부재 시 ENOENT 가 'error' event 로 emit.
54
+ // 핸들러 없으면 unhandled 로 caller 프로세스 죽음 — rate-limit-spawn-integration
55
+ // CI 실패의 사전 존재 원인.
56
+ child.on('error', (e) => log.debug('notify-send 실패 (event)', e));
50
57
  child.unref();
51
58
  }
52
59
  catch (e) {
@@ -60,7 +60,15 @@ export declare const OUTCOMES_DIR: string;
60
60
  export declare const CANDIDATES_DIR: string;
61
61
  /** ~/.forgen/lab/archived/ — rollback destination for evolved solutions. */
62
62
  export declare const ARCHIVED_DIR: string;
63
- /** ~/.forgen/sessions/ — 세션 로그 */
63
+ /**
64
+ * ~/.forgen/sessions/ — legacy session log directory (transcript-like).
65
+ *
66
+ * session-logger.ts 가 prepareHarness step 11 에서 한 줄짜리
67
+ * `{date}_{uuid}.json` 메타 파일을 기록. v1 의 SessionEffectiveState
68
+ * 와는 다른 책임이라 별 디렉토리 (V1_SESSIONS_DIR) 와 공존:
69
+ * - SESSIONS_DIR (여기): 세션 발생 사실 + 시작/종료 시각 + cwd
70
+ * - V1_SESSIONS_DIR : profile + pack 합성 결과 (effective state)
71
+ */
64
72
  export declare const SESSIONS_DIR: string;
65
73
  /** ~/.forgen/config.json — 글로벌 설정 */
66
74
  export declare const GLOBAL_CONFIG: string;
@@ -72,7 +80,13 @@ export declare const LAB_DIR: string;
72
80
  export declare const FORGE_PROFILE: string;
73
81
  /** ~/.forgen/me/recommendations/ — Pack Recommendation */
74
82
  export declare const V1_RECOMMENDATIONS_DIR: string;
75
- /** ~/.forgen/state/sessions/ — Session Effective State */
83
+ /**
84
+ * ~/.forgen/state/sessions/ — v1 Session Effective State.
85
+ *
86
+ * session-state-store.ts 가 매 세션마다 profile + active rules + pack
87
+ * 합성 결과를 `{sessionId}.json` 으로 기록. SESSIONS_DIR (legacy session
88
+ * log) 와는 다른 책임 (정합화는 v0.4.8 A3 참조).
89
+ */
76
90
  export declare const V1_SESSIONS_DIR: string;
77
91
  /** ~/.forgen/state/raw-logs/ — Raw Log */
78
92
  export declare const V1_RAW_LOGS_DIR: string;
@@ -65,7 +65,15 @@ export const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
65
65
  export const CANDIDATES_DIR = path.join(FORGEN_HOME, 'lab', 'candidates');
66
66
  /** ~/.forgen/lab/archived/ — rollback destination for evolved solutions. */
67
67
  export const ARCHIVED_DIR = path.join(FORGEN_HOME, 'lab', 'archived');
68
- /** ~/.forgen/sessions/ — 세션 로그 */
68
+ /**
69
+ * ~/.forgen/sessions/ — legacy session log directory (transcript-like).
70
+ *
71
+ * session-logger.ts 가 prepareHarness step 11 에서 한 줄짜리
72
+ * `{date}_{uuid}.json` 메타 파일을 기록. v1 의 SessionEffectiveState
73
+ * 와는 다른 책임이라 별 디렉토리 (V1_SESSIONS_DIR) 와 공존:
74
+ * - SESSIONS_DIR (여기): 세션 발생 사실 + 시작/종료 시각 + cwd
75
+ * - V1_SESSIONS_DIR : profile + pack 합성 결과 (effective state)
76
+ */
69
77
  export const SESSIONS_DIR = path.join(FORGEN_HOME, 'sessions');
70
78
  /** ~/.forgen/config.json — 글로벌 설정 */
71
79
  export const GLOBAL_CONFIG = path.join(FORGEN_HOME, 'config.json');
@@ -77,7 +85,13 @@ export const LAB_DIR = path.join(FORGEN_HOME, 'lab');
77
85
  export const FORGE_PROFILE = path.join(ME_DIR, 'forge-profile.json');
78
86
  /** ~/.forgen/me/recommendations/ — Pack Recommendation */
79
87
  export const V1_RECOMMENDATIONS_DIR = path.join(ME_DIR, 'recommendations');
80
- /** ~/.forgen/state/sessions/ — Session Effective State */
88
+ /**
89
+ * ~/.forgen/state/sessions/ — v1 Session Effective State.
90
+ *
91
+ * session-state-store.ts 가 매 세션마다 profile + active rules + pack
92
+ * 합성 결과를 `{sessionId}.json` 으로 기록. SESSIONS_DIR (legacy session
93
+ * log) 와는 다른 책임 (정합화는 v0.4.8 A3 참조).
94
+ */
81
95
  export const V1_SESSIONS_DIR = path.join(STATE_DIR, 'sessions');
82
96
  /** ~/.forgen/state/raw-logs/ — Raw Log */
83
97
  export const V1_RAW_LOGS_DIR = path.join(STATE_DIR, 'raw-logs');
@@ -6,7 +6,18 @@
6
6
  * 외부 의존성 없음 — Node.js 22+ 내장 node:sqlite 사용.
7
7
  */
8
8
  /**
9
- * Transcript JSONL을 SQLite에 인덱싱.
9
+ * v0.4.8 (A1): Codex transcript JSONL 을 SQLite 에 인덱싱.
10
+ *
11
+ * Codex schema (Claude 와 다름):
12
+ * {type: 'response_item', payload: {type: 'message', role: 'user'|'assistant',
13
+ * content: [{type: 'input_text'|'output_text', text: '...'}]}}
14
+ *
15
+ * 결정 (v0.4.8 A1): Claude/Codex 통합 abstraction 대신 분기 함수 두 개로
16
+ * 처리. 미래에 host 가 추가될 때 통합 추상화로 리팩터.
17
+ */
18
+ export declare function indexCodexSession(cwd: string, transcriptPath: string, sessionId: string): Promise<void>;
19
+ /**
20
+ * Transcript JSONL을 SQLite에 인덱싱. (Claude schema)
10
21
  */
11
22
  export declare function indexSession(cwd: string, transcriptPath: string, sessionId: string): Promise<void>;
12
23
  /**
@@ -69,8 +69,80 @@ function openDb() {
69
69
  return null;
70
70
  }
71
71
  }
72
+ /** Codex content array → flat string. content: [{type: 'input_text', text: ...}, ...] */
73
+ function extractCodexText(content) {
74
+ if (!Array.isArray(content))
75
+ return '';
76
+ const parts = [];
77
+ for (const item of content) {
78
+ if (item && typeof item === 'object' && 'text' in item && typeof item.text === 'string') {
79
+ parts.push(item.text);
80
+ }
81
+ }
82
+ return parts.join('\n').trim();
83
+ }
84
+ /**
85
+ * v0.4.8 (A1): Codex transcript JSONL 을 SQLite 에 인덱싱.
86
+ *
87
+ * Codex schema (Claude 와 다름):
88
+ * {type: 'response_item', payload: {type: 'message', role: 'user'|'assistant',
89
+ * content: [{type: 'input_text'|'output_text', text: '...'}]}}
90
+ *
91
+ * 결정 (v0.4.8 A1): Claude/Codex 통합 abstraction 대신 분기 함수 두 개로
92
+ * 처리. 미래에 host 가 추가될 때 통합 추상화로 리팩터.
93
+ */
94
+ export async function indexCodexSession(cwd, transcriptPath, sessionId) {
95
+ const db = openDb();
96
+ if (!db)
97
+ return;
98
+ try {
99
+ const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
100
+ if (existing)
101
+ return;
102
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
103
+ const lines = content.split('\n').filter(Boolean);
104
+ db.prepare('INSERT INTO sessions (id, cwd, started_at, message_count) VALUES (?, ?, ?, 0)').run(sessionId, cwd, new Date().toISOString());
105
+ let messageCount = 0;
106
+ const insertMsg = db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)');
107
+ for (const line of lines) {
108
+ try {
109
+ const entry = JSON.parse(line);
110
+ if (entry.type !== 'response_item' || !entry.payload)
111
+ continue;
112
+ const role = entry.payload.role;
113
+ if (role !== 'user' && role !== 'assistant')
114
+ continue;
115
+ const text = extractCodexText(entry.payload.content);
116
+ if (!text)
117
+ continue;
118
+ const truncated = text.slice(0, 10000);
119
+ const ts = typeof entry.timestamp === 'string' ? entry.timestamp : '';
120
+ const result = insertMsg.run(sessionId, role, truncated, ts);
121
+ if (fts5Available) {
122
+ try {
123
+ db.prepare('INSERT INTO messages_fts(rowid, content) VALUES (?, ?)').run(result.lastInsertRowid, truncated);
124
+ }
125
+ catch { /* FTS sync failure */ }
126
+ }
127
+ messageCount++;
128
+ }
129
+ catch { /* skip malformed lines */ }
130
+ }
131
+ db.prepare('UPDATE sessions SET message_count = ? WHERE id = ?').run(messageCount, sessionId);
132
+ log.debug(`Codex 세션 인덱싱 완료: ${sessionId} (${messageCount} messages)`);
133
+ }
134
+ catch (e) {
135
+ log.debug('Codex 세션 인덱싱 실패', e);
136
+ }
137
+ finally {
138
+ try {
139
+ db.close();
140
+ }
141
+ catch { /* ignore */ }
142
+ }
143
+ }
72
144
  /**
73
- * Transcript JSONL을 SQLite에 인덱싱.
145
+ * Transcript JSONL을 SQLite에 인덱싱. (Claude schema)
74
146
  */
75
147
  export async function indexSession(cwd, transcriptPath, sessionId) {
76
148
  const db = openDb();
@@ -160,11 +160,19 @@ async function runAutoCompound(cwd, transcriptPath, sessionId) {
160
160
  }
161
161
  /**
162
162
  * Transcript를 SQLite FTS5에 인덱싱 (추후 session-search MCP 도구용).
163
+ *
164
+ * v0.4.8 (A1): runtime 별 schema 차이로 분기. Claude 는 `entry.type === 'user'|
165
+ * 'assistant'`, Codex 는 `entry.type === 'response_item' && entry.payload.role`.
163
166
  */
164
- async function indexTranscriptToFTS(cwd, transcriptPath, sessionId) {
167
+ async function indexTranscriptToFTS(cwd, transcriptPath, sessionId, runtime = 'claude') {
165
168
  try {
166
- const { indexSession } = await import('./session-store.js');
167
- await indexSession(cwd, transcriptPath, sessionId);
169
+ const store = await import('./session-store.js');
170
+ if (runtime === 'codex') {
171
+ await store.indexCodexSession(cwd, transcriptPath, sessionId);
172
+ }
173
+ else {
174
+ await store.indexSession(cwd, transcriptPath, sessionId);
175
+ }
168
176
  }
169
177
  catch (e) {
170
178
  log.debug('FTS5 인덱싱 실패 (session-store 미구현 시 정상)', e);
@@ -225,10 +233,8 @@ export async function spawnClaude(args, context, runtime = 'claude') {
225
233
  else {
226
234
  sessionId = path.basename(transcript, '.jsonl');
227
235
  }
228
- // 1. FTS5 인덱싱 (claude only codex schema FTS 호환은 미검증, 별도 작업)
229
- if (runtime === 'claude') {
230
- await indexTranscriptToFTS(context.cwd, transcript, sessionId);
231
- }
236
+ // 1. FTS5 인덱싱 — v0.4.8 (A1) 부터 Claude/Codex 모두 지원.
237
+ await indexTranscriptToFTS(context.cwd, transcript, sessionId, runtime);
232
238
  // 2. 자동 compound (10+ user 메시지인 경우만) — 양 runtime 호환
233
239
  const userMsgCount = await countUserMessages(transcript);
234
240
  if (userMsgCount >= 10) {
@@ -18,6 +18,13 @@ export declare function ensureV1Directories(): void;
18
18
  export interface V1BootstrapResult {
19
19
  needsOnboarding: boolean;
20
20
  legacyBackupPath: string | null;
21
+ /**
22
+ * profile.json 이 존재했지만 parse/shape 오류로 사용 불가하여 v0.4.8
23
+ * 의 auto-repair 가 timestamp 백업으로 치워둔 경로. legacyBackupPath
24
+ * 와 의미가 다름 (legacy = model_version 구 버전 cutover, corrupt =
25
+ * 깨진 파일 격리).
26
+ */
27
+ corruptProfileBackupPath: string | null;
21
28
  session: SessionEffectiveState | null;
22
29
  renderedRules: string | null;
23
30
  profile: Profile | null;
@@ -15,10 +15,10 @@
15
15
  import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import * as crypto from 'node:crypto';
18
- import { FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, STATE_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS } from './paths.js';
18
+ import { FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, V1_SESSIONS_DIR, STATE_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS, SESSIONS_DIR } from './paths.js';
19
19
  import { checkLegacyProfile, runLegacyCutover } from './legacy-detector.js';
20
20
  import { detectRuntimeCapability } from './runtime-detector.js';
21
- import { loadProfile, profileExists } from '../store/profile-store.js';
21
+ import { backupCorruptProfile, loadProfile, profileExists } from '../store/profile-store.js';
22
22
  import { loadActiveRules, cleanupStaleSessionRules, markRulesInjected } from '../store/rule-store.js';
23
23
  import { composeSession } from '../preset/preset-manager.js';
24
24
  import { renderRules, DEFAULT_CONTEXT } from '../renderer/rule-renderer.js';
@@ -27,7 +27,14 @@ import { loadEvidenceBySession } from '../store/evidence-store.js';
27
27
  import { computeSessionSignals, detectMismatch } from '../forge/mismatch-detector.js';
28
28
  import { createRecommendation, saveRecommendation } from '../store/recommendation-store.js';
29
29
  // ── Directory Initialization ──
30
- const V1_DIRS = [FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, STATE_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS];
30
+ // v0.4.8 (A3): SESSIONS_DIR (~/.forgen/sessions/) v1 bootstrap 보장 대상.
31
+ // 이전엔 V1_DIRS 에 누락되어 있어, prepareHarness step 11 (startSessionLog)
32
+ // 에 도달 못 하는 코드 경로 (예: forgen install 직접 실행) 에선 디렉토리가
33
+ // 끝까지 생성되지 않았음. legacy session log 와 v1 effective state 는
34
+ // 서로 다른 저장소 책임이라 두 dir 모두 명시 보장.
35
+ // - SESSIONS_DIR = ~/.forgen/sessions/ ← legacy session log (transcript-like)
36
+ // - V1_SESSIONS_DIR = ~/.forgen/state/sessions/ ← v1 effective state per session
37
+ const V1_DIRS = [FORGEN_HOME, ME_DIR, ME_RULES, ME_BEHAVIOR, V1_RECOMMENDATIONS_DIR, STATE_DIR, V1_SESSIONS_DIR, V1_RAW_LOGS_DIR, ME_SOLUTIONS, SESSIONS_DIR];
31
38
  export function ensureV1Directories() {
32
39
  for (const dir of V1_DIRS) {
33
40
  fs.mkdirSync(dir, { recursive: true });
@@ -48,6 +55,7 @@ export function bootstrapV1Session() {
48
55
  return {
49
56
  needsOnboarding: true,
50
57
  legacyBackupPath,
58
+ corruptProfileBackupPath: null,
51
59
  session: null,
52
60
  renderedRules: null,
53
61
  profile: null,
@@ -56,7 +64,20 @@ export function bootstrapV1Session() {
56
64
  }
57
65
  const profile = loadProfile();
58
66
  if (!profile) {
59
- return { needsOnboarding: true, legacyBackupPath, session: null, renderedRules: null, profile: null, mismatch: null };
67
+ // v0.4.8 corrupt/invalid profile auto-repair.
68
+ // profileExists()=true && loadProfile()=null 은 parse 실패 또는 v1
69
+ // shape 위반. 그대로 두면 다음 실행에서도 같은 분기로 빠지므로
70
+ // backup 후 새 onboarding 흐름을 강제.
71
+ const corruptProfileBackupPath = backupCorruptProfile();
72
+ return {
73
+ needsOnboarding: true,
74
+ legacyBackupPath,
75
+ corruptProfileBackupPath,
76
+ session: null,
77
+ renderedRules: null,
78
+ profile: null,
79
+ mismatch: null,
80
+ };
60
81
  }
61
82
  // 3. Runtime capability 감지
62
83
  const runtime = detectRuntimeCapability();
@@ -133,7 +154,7 @@ export function bootstrapV1Session() {
133
154
  try {
134
155
  // 세션 시작 로그
135
156
  const rawLogPath = path.join(V1_RAW_LOGS_DIR, `${sessionId}.jsonl`);
136
- fs.appendFileSync(rawLogPath, JSON.stringify({
157
+ fs.appendFileSync(rawLogPath, `${JSON.stringify({
137
158
  event: 'session-started',
138
159
  session_id: sessionId,
139
160
  timestamp: new Date().toISOString(),
@@ -142,7 +163,7 @@ export function bootstrapV1Session() {
142
163
  judgment_pack: profile.base_packs.judgment_pack,
143
164
  communication_pack: profile.base_packs.communication_pack,
144
165
  effective_trust: session.effective_trust_policy,
145
- }) + '\n');
166
+ })}\n`);
146
167
  // TTL sweep: 7일 이상 된 raw log 파일 삭제
147
168
  const TTL_MS = 7 * 24 * 60 * 60 * 1000;
148
169
  const now = Date.now();
@@ -163,6 +184,7 @@ export function bootstrapV1Session() {
163
184
  return {
164
185
  needsOnboarding: false,
165
186
  legacyBackupPath,
187
+ corruptProfileBackupPath: null,
166
188
  session,
167
189
  renderedRules,
168
190
  profile,
@@ -557,7 +557,7 @@ function findCommonPrefix(strings) {
557
557
  return prefix.replace(/-$/, '');
558
558
  }
559
559
  /** Save an extracted solution as experiment */
560
- function saveExtractedSolution(sol, sessionId) {
560
+ function saveExtractedSolution(sol, _sessionId) {
561
561
  const today = new Date().toISOString().split('T')[0];
562
562
  const slugName = sol.name.toLowerCase()
563
563
  .replace(/[^a-z0-9가-힣\s-]/g, '')
@@ -136,7 +136,7 @@ function runFitness(args) {
136
136
  console.log(` ${'name'.padEnd(48)} ${'state'.padEnd(14)} ${'inj'.padStart(4)} ${'acc/cor/err'.padStart(11)} ${'fit'.padStart(6)}`);
137
137
  console.log(` ${'-'.repeat(48)} ${'-'.repeat(14)} ${'-'.repeat(4)} ${'-'.repeat(11)} ${'-'.repeat(6)}`);
138
138
  for (const r of records) {
139
- const name = r.solution.length > 47 ? r.solution.slice(0, 45) + '..' : r.solution;
139
+ const name = r.solution.length > 47 ? `${r.solution.slice(0, 45)}..` : r.solution;
140
140
  const acr = `${r.accepted}/${r.corrected}/${r.errored}`;
141
141
  console.log(` ${name.padEnd(48)} ${r.state.padEnd(14)} ${String(r.injected).padStart(4)} ${acr.padStart(11)} ${r.fitness.toFixed(2).padStart(6)}`);
142
142
  }
@@ -222,7 +222,7 @@ function runEvolvePromote(candidateNameOrList) {
222
222
  }
223
223
  const result = promoteCandidate(candidateNameOrList);
224
224
  if (result.ok) {
225
- console.log(`\n ✓ Promoted: ${path.basename(result.dest)}`);
225
+ console.log(`\n ✓ Promoted: ${result.dest ? path.basename(result.dest) : '(unknown)'}`);
226
226
  console.log(` from: ${result.source}`);
227
227
  console.log(` to: ${result.dest}`);
228
228
  console.log(` Cold-start bonus active until 5 injections accumulate (auto-promotes to verified).\n`);
@@ -51,9 +51,10 @@ function extractParenthesizedExamples(p) {
51
51
  const out = [];
52
52
  // Match (...) groups; multiple groups in policy are uncommon but supported
53
53
  const re = /\(([^)]+)\)/g;
54
- let m;
55
- while ((m = re.exec(p))) {
54
+ let m = re.exec(p);
55
+ while (m !== null) {
56
56
  const inside = m[1];
57
+ m = re.exec(p);
57
58
  // Skip if it looks like a path (contains "/" before any obvious separator commitment)
58
59
  if (/[a-zA-Z]+\/[a-zA-Z]/.test(inside))
59
60
  continue;
@@ -294,7 +294,7 @@ export function appendLifecycleEvents(events, now = Date.now()) {
294
294
  }
295
295
  }
296
296
  catch { /* missing → no rotate */ }
297
- const body = events.map((e) => JSON.stringify(e)).join('\n') + '\n';
297
+ const body = `${events.map((e) => JSON.stringify(e)).join('\n')}\n`;
298
298
  fs.appendFileSync(logPath, body);
299
299
  }
300
300
  catch (e) {
@@ -62,7 +62,7 @@ export function recordViolation(entry) {
62
62
  fs.mkdirSync(ENFORCEMENT_DIR, { recursive: true });
63
63
  rotateIfBig(VIOLATIONS_PATH);
64
64
  const full = { at: new Date().toISOString(), ...entry };
65
- fs.appendFileSync(VIOLATIONS_PATH, JSON.stringify(full) + '\n');
65
+ fs.appendFileSync(VIOLATIONS_PATH, `${JSON.stringify(full)}\n`);
66
66
  }
67
67
  catch (e) {
68
68
  // best-effort, 실패 시 debug 로그 (silent swallow 방지)
@@ -76,7 +76,7 @@ export function recordBypass(entry) {
76
76
  fs.mkdirSync(ENFORCEMENT_DIR, { recursive: true });
77
77
  rotateIfBig(BYPASS_PATH);
78
78
  const full = { at: new Date().toISOString(), ...entry };
79
- fs.appendFileSync(BYPASS_PATH, JSON.stringify(full) + '\n');
79
+ fs.appendFileSync(BYPASS_PATH, `${JSON.stringify(full)}\n`);
80
80
  }
81
81
  catch (e) {
82
82
  if (process.env.FORGEN_DEBUG_SIGNALS === '1') {
@@ -39,7 +39,7 @@ function matchesRule(evidence, rule) {
39
39
  .toLowerCase();
40
40
  return keyTokens.some((t) => {
41
41
  const tokLower = t.toLowerCase();
42
- return summaryLower.includes(tokLower) || (targetToken && targetToken.includes(tokLower));
42
+ return summaryLower.includes(tokLower) || (targetToken?.includes(tokLower));
43
43
  });
44
44
  }
45
45
  export function detect(input) {
@@ -100,7 +100,7 @@ export function rollbackSince(epochMs) {
100
100
  continue;
101
101
  try {
102
102
  fs.mkdirSync(archiveDir, { recursive: true });
103
- const destName = path.basename(dir) + '__' + file;
103
+ const destName = `${path.basename(dir)}__${file}`;
104
104
  fs.renameSync(filePath, path.join(archiveDir, destName));
105
105
  archived.push(filePath);
106
106
  }
@@ -30,7 +30,7 @@ function writePending(sessionId, state) {
30
30
  }
31
31
  function appendOutcome(event) {
32
32
  fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
33
- fs.appendFileSync(outcomesPath(event.session_id), JSON.stringify(event) + '\n');
33
+ fs.appendFileSync(outcomesPath(event.session_id), `${JSON.stringify(event)}\n`);
34
34
  }
35
35
  /**
36
36
  * Run a read-modify-write pending-state mutation under a file lock
@@ -49,7 +49,7 @@ export function recordQuarantine(filePath, errors) {
49
49
  at: new Date().toISOString(),
50
50
  errors,
51
51
  };
52
- fs.appendFileSync(SOLUTION_QUARANTINE_PATH, JSON.stringify(entry) + '\n');
52
+ fs.appendFileSync(SOLUTION_QUARANTINE_PATH, `${JSON.stringify(entry)}\n`);
53
53
  }
54
54
  catch (e) {
55
55
  log.debug(`quarantine write failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -97,14 +97,20 @@ function findConflictClusters(rows, fitnessByName) {
97
97
  const underperformers = rows.filter((r) => fitnessByName.get(r.name)?.state === 'underperform');
98
98
  const clusters = [];
99
99
  for (const ch of champions) {
100
+ const chFitness = fitnessByName.get(ch.name)?.fitness;
101
+ if (chFitness === undefined)
102
+ continue;
100
103
  for (const up of underperformers) {
104
+ const upFitness = fitnessByName.get(up.name)?.fitness;
105
+ if (upFitness === undefined)
106
+ continue;
101
107
  const shared = ch.tags.filter((t) => up.tags.includes(t));
102
108
  if (shared.length < 2)
103
109
  continue;
104
110
  clusters.push({
105
111
  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 },
112
+ champion: { name: ch.name, fitness: chFitness },
113
+ underperform: { name: up.name, fitness: upFitness },
108
114
  });
109
115
  }
110
116
  }
package/dist/forge/cli.js CHANGED
@@ -39,7 +39,7 @@ function handleShowProfile() {
39
39
  console.log('\n No v1 profile found. Run `forgen forge` or `forgen onboarding`.\n');
40
40
  return;
41
41
  }
42
- console.log('\n' + renderProfile(profile) + '\n');
42
+ console.log(`\n${renderProfile(profile)}\n`);
43
43
  }
44
44
  function handleExport() {
45
45
  const profile = loadProfile();
@@ -192,7 +192,7 @@ async function main() {
192
192
  return;
193
193
  }
194
194
  const match = detectKeyword(input.prompt);
195
- const sessionId = input.session_id ?? 'unknown';
195
+ const _sessionId = input.session_id ?? 'unknown';
196
196
  // v1: regex 기반 prompt 학습 제거. Evidence 기반으로 전환됨.
197
197
  if (!match) {
198
198
  console.log(approve());
@@ -34,10 +34,10 @@ export function redactSecrets(text) {
34
34
  let out = text;
35
35
  for (const sp of SECRET_PATTERNS) {
36
36
  // regex 복제 (global flag 없이 repeated test 되는 경우 lastIndex 안전)
37
- const re = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
37
+ const re = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : `${sp.pattern.flags}g`));
38
38
  if (re.test(out)) {
39
39
  hits.push(sp);
40
- const re2 = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : sp.pattern.flags + 'g'));
40
+ const re2 = new RegExp(sp.pattern.source, (sp.pattern.flags.includes('g') ? sp.pattern.flags : `${sp.pattern.flags}g`));
41
41
  out = out.replace(re2, `[REDACTED:${sp.name}]`);
42
42
  }
43
43
  }
@@ -146,7 +146,7 @@ export function failOpenWithTracking(hookName, err) {
146
146
  }
147
147
  }
148
148
  const entry = JSON.stringify(payload);
149
- fs.appendFileSync(logPath, entry + '\n');
149
+ fs.appendFileSync(logPath, `${entry}\n`);
150
150
  }
151
151
  catch { /* fail-open: tracking itself must not throw */ }
152
152
  return JSON.stringify({ continue: true });
@@ -19,7 +19,7 @@ export function recordHookTiming(hookName, durationMs, event) {
19
19
  try {
20
20
  fs.mkdirSync(STATE_DIR, { recursive: true });
21
21
  const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now() });
22
- fs.appendFileSync(TIMING_LOG, entry + '\n');
22
+ fs.appendFileSync(TIMING_LOG, `${entry}\n`);
23
23
  // Rotate if too large — size-gated (statSync only, skip read/write 대부분의 호출)
24
24
  try {
25
25
  const size = fs.statSync(TIMING_LOG).size;
@@ -28,7 +28,7 @@ export function recordHookTiming(hookName, durationMs, event) {
28
28
  const content = fs.readFileSync(TIMING_LOG, 'utf-8');
29
29
  const lines = content.trim().split('\n');
30
30
  if (lines.length > MAX_LINES) {
31
- fs.writeFileSync(TIMING_LOG, lines.slice(-MAX_LINES).join('\n') + '\n');
31
+ fs.writeFileSync(TIMING_LOG, `${lines.slice(-MAX_LINES).join('\n')}\n`);
32
32
  }
33
33
  }
34
34
  catch { /* skip rotation on error */ }
@@ -52,7 +52,7 @@ export function getTimingStats() {
52
52
  for (const e of entries) {
53
53
  if (!byHook.has(e.hook))
54
54
  byHook.set(e.hook, []);
55
- byHook.get(e.hook).push(e.ms);
55
+ byHook.get(e.hook)?.push(e.ms);
56
56
  }
57
57
  const stats = [];
58
58
  for (const [hook, times] of byHook) {
@@ -409,7 +409,7 @@ async function main() {
409
409
  .filter(l => l.length > 0);
410
410
  contentSnippet = lines.slice(0, 3).join('\n');
411
411
  if (contentSnippet.length > SUMMARY_MAX_CHARS) {
412
- contentSnippet = contentSnippet.slice(0, SUMMARY_MAX_CHARS - 3) + '...';
412
+ contentSnippet = `${contentSnippet.slice(0, SUMMARY_MAX_CHARS - 3)}...`;
413
413
  }
414
414
  }
415
415
  }
@@ -370,14 +370,14 @@ export function acknowledgeSessionBlocks(sessionId) {
370
370
  try {
371
371
  fs.mkdirSync(path.dirname(ACK_LOG), { recursive: true });
372
372
  rotateIfBig(ACK_LOG);
373
- fs.appendFileSync(ACK_LOG, JSON.stringify({
373
+ fs.appendFileSync(ACK_LOG, `${JSON.stringify({
374
374
  at: now,
375
375
  session_id: state.sessionId,
376
376
  rule_id: state.ruleId,
377
377
  block_count: state.count,
378
378
  first_block_at: state.firstBlockAt,
379
379
  last_block_at: state.lastBlockAt,
380
- }) + '\n');
380
+ })}\n`);
381
381
  acked += 1;
382
382
  }
383
383
  catch { /* append failure: still try cleanup */ }
@@ -396,7 +396,7 @@ export function logDriftEvent(event) {
396
396
  try {
397
397
  fs.mkdirSync(path.dirname(DRIFT_LOG), { recursive: true });
398
398
  rotateIfBig(DRIFT_LOG);
399
- fs.appendFileSync(DRIFT_LOG, JSON.stringify({ at: new Date().toISOString(), ...event }) + '\n');
399
+ fs.appendFileSync(DRIFT_LOG, `${JSON.stringify({ at: new Date().toISOString(), ...event })}\n`);
400
400
  }
401
401
  catch {
402
402
  // best-effort
@@ -202,7 +202,7 @@ function installCodexSkills(opts) {
202
202
  return { installed: count };
203
203
  }
204
204
  // ── P3-3: AGENTS.md inject ────────────────────────────────────────────
205
- function resolveAgentsMdPath(pkgRoot) {
205
+ function resolveAgentsMdPath(_pkgRoot) {
206
206
  // Phase 3 critic fix: pkgRoot 기반 walk-up 은 `npm install -g` 시 시스템 디렉토리
207
207
  // (예: /usr/local/lib/node_modules/forgen) 에 fallback AGENTS.md 작성 위험.
208
208
  // *cwd 기반* 으로 변경 — 사용자 작업 디렉토리의 git root, 없으면 cwd 자체.
package/dist/mcp/tools.js CHANGED
@@ -239,7 +239,7 @@ export function registerTools(server) {
239
239
  }],
240
240
  };
241
241
  }
242
- catch (e) {
242
+ catch (_e) {
243
243
  return {
244
244
  content: [{
245
245
  type: 'text',
@@ -29,8 +29,8 @@ export const COMMUNICATION_CENTROIDS = {
29
29
  '상세형': { verbosity: 0.85, structure: 0.80, teaching_bias: 0.80 },
30
30
  };
31
31
  // ── Defaults (backward compat) ──
32
- export const DEFAULT_JUDGMENT_FACETS = JUDGMENT_CENTROIDS['균형형'];
33
- export const DEFAULT_COMMUNICATION_FACETS = COMMUNICATION_CENTROIDS['균형형'];
32
+ export const DEFAULT_JUDGMENT_FACETS = JUDGMENT_CENTROIDS.균형형;
33
+ export const DEFAULT_COMMUNICATION_FACETS = COMMUNICATION_CENTROIDS.균형형;
34
34
  // ── Utilities ──
35
35
  export function qualityCentroid(pack) {
36
36
  return { ...QUALITY_CENTROIDS[pack] };
@@ -171,28 +171,28 @@ export function renderRules(rules, state, profile, ctx = DEFAULT_CONTEXT) {
171
171
  for (const name of SECTION_ORDER)
172
172
  sections.set(name, []);
173
173
  for (const rule of hardRules) {
174
- sections.get('Must Not').push(ruleToText(rule));
174
+ sections.get('Must Not')?.push(ruleToText(rule));
175
175
  }
176
176
  for (const rule of otherRules) {
177
177
  const section = CATEGORY_TO_SECTION[rule.category] ?? 'Working Defaults';
178
- sections.get(section).push(ruleToText(rule));
178
+ sections.get(section)?.push(ruleToText(rule));
179
179
  }
180
180
  // 5. trust policy + pack 기본 규칙 주입
181
181
  if (ctx.include_pack_summary) {
182
- sections.get('Working Defaults').unshift(`Trust: ${trustPolicySummary(state.effective_trust_policy)}`);
182
+ sections.get('Working Defaults')?.unshift(`Trust: ${trustPolicySummary(state.effective_trust_policy)}`);
183
183
  // judgment pack 기본 규칙
184
184
  for (const rule of judgmentPackRules(state.judgment_pack)) {
185
- sections.get('Working Defaults').push(rule);
185
+ sections.get('Working Defaults')?.push(rule);
186
186
  }
187
187
  // communication pack 기본 규칙
188
188
  for (const rule of communicationPackRules(state.communication_pack)) {
189
- sections.get('How To Report').push(rule);
189
+ sections.get('How To Report')?.push(rule);
190
190
  }
191
191
  // 4축 facet 극단값 → 추가 규칙 (12-bucket pack 위에 연속 값 차별화).
192
192
  // 각 facet 0.5 default 이고 자동 갱신 ±0.1 단위이므로, 0.85/0.15 임계값은
193
193
  // 여러 세션에 걸친 강한 신호 누적 후에만 발화한다.
194
194
  for (const fr of facetDrivenRules(profile)) {
195
- sections.get(fr.section).push(fr.rule);
195
+ sections.get(fr.section)?.push(fr.rule);
196
196
  }
197
197
  }
198
198
  // 6. 섹션 조립 (AI-optimized: 간결한 태그 형식)
@@ -200,7 +200,7 @@ export function renderRules(rules, state, profile, ctx = DEFAULT_CONTEXT) {
200
200
  let totalChars = 0;
201
201
  let totalRules = 0;
202
202
  for (const name of SECTION_ORDER) {
203
- const items = sections.get(name);
203
+ const items = sections.get(name) ?? [];
204
204
  if (items.length === 0)
205
205
  continue;
206
206
  const header = `## ${name}`;
@@ -27,7 +27,7 @@ export function recordUsage(name, via = 'mcp') {
27
27
  try {
28
28
  fs.mkdirSync(STATE_DIR, { recursive: true });
29
29
  const entry = { at: new Date().toISOString(), name, via };
30
- fs.appendFileSync(COMPOUND_USAGE_LOG, JSON.stringify(entry) + '\n');
30
+ fs.appendFileSync(COMPOUND_USAGE_LOG, `${JSON.stringify(entry)}\n`);
31
31
  }
32
32
  catch {
33
33
  // fail-open: 신호 수집 실패가 사용자 경험을 방해하면 안 됨
@@ -67,7 +67,7 @@ export function appendImplicitFeedback(entry) {
67
67
  return false;
68
68
  try {
69
69
  fs.mkdirSync(STATE_DIR, { recursive: true });
70
- fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, JSON.stringify(normalized) + '\n');
70
+ fs.appendFileSync(IMPLICIT_FEEDBACK_LOG, `${JSON.stringify(normalized)}\n`);
71
71
  return true;
72
72
  }
73
73
  catch {
@@ -147,7 +147,7 @@ export function migrateImplicitFeedbackLog() {
147
147
  }
148
148
  // atomic replace via temp file
149
149
  const tmp = `${IMPLICIT_FEEDBACK_LOG}.migrate.${process.pid}`;
150
- fs.writeFileSync(tmp, out.length > 0 ? out.join('\n') + '\n' : '');
150
+ fs.writeFileSync(tmp, out.length > 0 ? `${out.join('\n')}\n` : '');
151
151
  fs.renameSync(tmp, IMPLICIT_FEEDBACK_LOG);
152
152
  return { migrated, dropped };
153
153
  }
@@ -18,6 +18,17 @@ export declare function saveProfile(profile: Profile): void;
18
18
  * whether to run `runLegacyCutover`).
19
19
  */
20
20
  export declare function profileExists(): boolean;
21
+ /**
22
+ * profile.json 이 존재하지만 parse 실패 / v1 shape 위반인 경우, 사용자가
23
+ * 다음 onboarding 으로 매끄럽게 복구되도록 corrupt 파일을 timestamp 백업
24
+ * 으로 옆에 치워둔다. 백업 경로를 반환.
25
+ *
26
+ * v0.4.8 — bootstrapV1Session 의 loadProfile()=null early-return 보강.
27
+ * 이전엔 needsOnboarding=true 만 반환했고 corrupt 파일이 그대로 남아
28
+ * 다음 실행 때도 동일 분기로 빠지면서 사용자가 정체 원인을 모른 채
29
+ * onboarding 안내만 반복 받는 패턴이었음.
30
+ */
31
+ export declare function backupCorruptProfile(): string | null;
21
32
  export declare function isV1Profile(data: unknown): data is Profile;
22
33
  /**
23
34
  * D2 fix (2026-04-27): explicit_correction 누적 시 해당 축의 confidence 를 점진
@@ -66,6 +66,29 @@ export function saveProfile(profile) {
66
66
  export function profileExists() {
67
67
  return fs.existsSync(FORGE_PROFILE);
68
68
  }
69
+ /**
70
+ * profile.json 이 존재하지만 parse 실패 / v1 shape 위반인 경우, 사용자가
71
+ * 다음 onboarding 으로 매끄럽게 복구되도록 corrupt 파일을 timestamp 백업
72
+ * 으로 옆에 치워둔다. 백업 경로를 반환.
73
+ *
74
+ * v0.4.8 — bootstrapV1Session 의 loadProfile()=null early-return 보강.
75
+ * 이전엔 needsOnboarding=true 만 반환했고 corrupt 파일이 그대로 남아
76
+ * 다음 실행 때도 동일 분기로 빠지면서 사용자가 정체 원인을 모른 채
77
+ * onboarding 안내만 반복 받는 패턴이었음.
78
+ */
79
+ export function backupCorruptProfile() {
80
+ if (!fs.existsSync(FORGE_PROFILE))
81
+ return null;
82
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
83
+ const backupPath = `${FORGE_PROFILE}.corrupt-${ts}`;
84
+ try {
85
+ fs.renameSync(FORGE_PROFILE, backupPath);
86
+ return backupPath;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ }
69
92
  export function isV1Profile(data) {
70
93
  if (!data || typeof data !== 'object')
71
94
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooojin/forgen",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "preferGlobal": true,
5
5
  "main": "dist/lib.js",
6
6
  "types": "./dist/lib.d.ts",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://claude.ai/schemas/claude-plugin.json",
3
3
  "name": "forgen",
4
- "version": "0.4.7",
4
+ "version": "0.4.8",
5
5
  "description": "Claude Code harness — the more you use Claude, the better it gets",
6
6
  "author": {
7
7
  "name": "jang-ujin",