@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
@@ -3,24 +3,8 @@ import * as path from 'node:path';
3
3
  const HOME = os.homedir();
4
4
  /** ~/.claude/ — Claude Code 설정 디렉토리 */
5
5
  export const CLAUDE_DIR = path.join(HOME, '.claude');
6
- export const CODEX_DIR = path.join(HOME, '.codex');
7
6
  /** ~/.claude/settings.json — Claude Code 설정 파일 */
8
7
  export const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
9
- /**
10
- * ~/.compound/ — LEGACY harness home (pre-v5).
11
- *
12
- * @deprecated A5 (2026-04-09): this path must NEVER be used for WRITES.
13
- * It exists solely as the source path for `migrateToForgen()`.
14
- * All new writes must target `FORGEN_HOME`-based paths. Consumers that
15
- * read from this path should prefer the FORGEN_HOME equivalent first
16
- * and fall back here only during migration.
17
- *
18
- * Pre-A5, `harness.ts:ensureDirectories` and several hooks actively
19
- * created directories under this path, causing a dual-reality where
20
- * state could diverge between `~/.compound/` and `~/.forgen/` when the
21
- * migration symlink was broken (sudo install, SIP, CI, manual delete).
22
- */
23
- export const COMPOUND_HOME = path.join(HOME, '.compound');
24
8
  /** ~/.forgen/ — v1 하네스 홈 디렉토리 */
25
9
  export const FORGEN_HOME = path.join(HOME, '.forgen');
26
10
  /** ~/.forgen/me/ — 개인 공간 (v5.1: ~/.compound/ → ~/.forgen/ 통합) */
@@ -76,37 +60,18 @@ export const ARCHIVED_DIR = path.join(FORGEN_HOME, 'lab', 'archived');
76
60
  export const SESSIONS_DIR = path.join(FORGEN_HOME, 'sessions');
77
61
  /** ~/.forgen/config.json — 글로벌 설정 */
78
62
  export const GLOBAL_CONFIG = path.join(FORGEN_HOME, 'config.json');
79
- /** ~/.forgen/state/session-quality/ — 세션 품질 점수 */
80
- export const SESSION_QUALITY_DIR = path.join(STATE_DIR, 'session-quality');
81
63
  /** ~/.forgen/state/meta-learning/ — 메타학습 상태 파일 */
82
64
  export const META_LEARNING_DIR = path.join(STATE_DIR, 'meta-learning');
83
65
  /** ~/.forgen/lab/ — Lab 적응형 최적화 엔진 데이터 */
84
66
  export const LAB_DIR = path.join(FORGEN_HOME, 'lab');
85
- /** ~/.forgen/lab/events.jsonl — Lab 이벤트 로그 (JSONL) */
86
- export const LAB_EVENTS = path.join(LAB_DIR, 'events.jsonl');
87
67
  /** ~/.forgen/me/forge-profile.json — 글로벌 Forge 프로필 */
88
68
  export const FORGE_PROFILE = path.join(ME_DIR, 'forge-profile.json');
89
- // ── v1 호환 경로 (ME_*와 동일 — 점진 제거 예정) ──
90
- /** @deprecated use ME_DIR */
91
- export const V1_ME_DIR = ME_DIR;
92
- /** @deprecated use FORGE_PROFILE */
93
- export const V1_PROFILE = FORGE_PROFILE;
94
- /** @deprecated use ME_RULES */
95
- export const V1_RULES_DIR = ME_RULES;
96
- /** @deprecated use ME_BEHAVIOR */
97
- export const V1_EVIDENCE_DIR = ME_BEHAVIOR;
98
69
  /** ~/.forgen/me/recommendations/ — Pack Recommendation */
99
70
  export const V1_RECOMMENDATIONS_DIR = path.join(ME_DIR, 'recommendations');
100
- /** @deprecated use ME_SOLUTIONS */
101
- export const V1_SOLUTIONS_DIR = ME_SOLUTIONS;
102
- /** @deprecated use STATE_DIR */
103
- export const V1_STATE_DIR = STATE_DIR;
104
71
  /** ~/.forgen/state/sessions/ — Session Effective State */
105
72
  export const V1_SESSIONS_DIR = path.join(STATE_DIR, 'sessions');
106
73
  /** ~/.forgen/state/raw-logs/ — Raw Log */
107
74
  export const V1_RAW_LOGS_DIR = path.join(STATE_DIR, 'raw-logs');
108
- /** @deprecated use GLOBAL_CONFIG */
109
- export const V1_GLOBAL_CONFIG = GLOBAL_CONFIG;
110
75
  // ── 레거시 ──
111
76
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
112
77
  export const ALL_MODES = [
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Settings Injection — Claude Code settings.json manipulation
3
+ *
4
+ * Extracted from harness.ts (B9 decomposition).
5
+ * Handles reading, merging hooks, trust policy, and atomic write.
6
+ */
7
+ import type { RuntimeHost } from './types.js';
8
+ import type { V1BootstrapResult } from './v1-bootstrap.js';
9
+ /**
10
+ * Inject forgen settings into Claude Code settings.json.
11
+ * Coordinates: read/backup → env merge → statusLine → hooks → trust policy → atomic write.
12
+ */
13
+ export declare function injectSettings(env: Record<string, string>, v1Result: V1BootstrapResult, runtime: RuntimeHost, cwd: string, pkgRoot: string): void;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Settings Injection — Claude Code settings.json manipulation
3
+ *
4
+ * Extracted from harness.ts (B9 decomposition).
5
+ * Handles reading, merging hooks, trust policy, and atomic write.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { generateHooksJson } from '../hooks/hooks-generator.js';
10
+ import { ConfigError } from './errors.js';
11
+ import { createLogger } from './logger.js';
12
+ import { acquireLock, atomicWriteFileSync, CLAUDE_DIR, readSettingsSafely, releaseLock, rollbackSettings, SETTINGS_BACKUP_PATH, SETTINGS_PATH, } from './settings-lock.js';
13
+ const log = createLogger('settings-injector');
14
+ const FORGEN_PERMISSION_RULES = new Set([
15
+ '# forgen-managed',
16
+ 'Bash(rm -rf *)',
17
+ 'Bash(git push --force*)',
18
+ 'Bash(git reset --hard*)',
19
+ ]);
20
+ function stripForgenManagedRules(rules) {
21
+ return rules.filter((r) => !FORGEN_PERMISSION_RULES.has(r));
22
+ }
23
+ /**
24
+ * Read settings.json + create forgen-backup of the valid content.
25
+ *
26
+ * Parse-failure handling moved to `readSettingsSafely` in settings-lock.ts
27
+ * (2026-04-21 audit fix #2): prior silent `{}` fallback would let the
28
+ * caller write merged forgen settings over the user's malformed-but-
29
+ * original file, losing their data. We now preserve the corrupt file to
30
+ * `.corrupt-<ts>` and propagate the error — `injectSettings` releases
31
+ * the lock and the harness bails out of writing.
32
+ */
33
+ function readSettingsWithBackup() {
34
+ const settings = readSettingsSafely();
35
+ if (Object.keys(settings).length > 0 && fs.existsSync(SETTINGS_PATH)) {
36
+ try {
37
+ fs.copyFileSync(SETTINGS_PATH, SETTINGS_BACKUP_PATH);
38
+ }
39
+ catch (e) {
40
+ log.debug('settings.json backup 복사 실패 (쓰기는 계속 진행)', new ConfigError('settings.json backup failed', { configPath: SETTINGS_PATH, cause: e }));
41
+ }
42
+ }
43
+ return settings;
44
+ }
45
+ /** Apply forgen statusLine only if user hasn't set a custom one. */
46
+ function applyStatusLine(settings) {
47
+ const existing = settings.statusLine;
48
+ const isForgenOwned = !existing || !existing.command || existing.command.startsWith('forgen');
49
+ if (isForgenOwned) {
50
+ settings.statusLine = { type: 'command', command: 'forgen me' };
51
+ }
52
+ }
53
+ /** Check if a settings.json hook entry was installed by forgen. */
54
+ function isForgenHookEntry(entry, pkgRoot) {
55
+ const distHooksPath = path.join(pkgRoot, 'dist', 'hooks');
56
+ const matchesPath = (cmd) => cmd.includes(distHooksPath) || /[\\/]dist[\\/]hooks[\\/].*\.js/.test(cmd);
57
+ if (typeof entry.command === 'string' && matchesPath(entry.command))
58
+ return true;
59
+ const hooks = entry.hooks;
60
+ return (Array.isArray(hooks) &&
61
+ hooks.some((h) => typeof h.command === 'string' && matchesPath(h.command)));
62
+ }
63
+ /** Strip existing forgen hooks from settings, merge fresh hooks.json. */
64
+ function mergeHooksIntoSettings(settings, runtime, cwd, pkgRoot) {
65
+ const hooksConfig = settings.hooks ?? {};
66
+ // Remove existing forgen hooks (clean slate before re-inject)
67
+ for (const [event, entries] of Object.entries(hooksConfig)) {
68
+ if (!Array.isArray(entries))
69
+ continue;
70
+ const filtered = entries.filter((h) => !isForgenHookEntry(h, pkgRoot));
71
+ if (filtered.length === 0)
72
+ delete hooksConfig[event];
73
+ else
74
+ hooksConfig[event] = filtered;
75
+ }
76
+ try {
77
+ if (runtime === 'codex') {
78
+ const generated = generateHooksJson({ cwd, runtime, pluginRoot: path.join(pkgRoot, 'dist') });
79
+ for (const [event, handlers] of Object.entries(generated.hooks)) {
80
+ if (!hooksConfig[event])
81
+ hooksConfig[event] = [];
82
+ hooksConfig[event].push(...handlers);
83
+ }
84
+ }
85
+ else {
86
+ // Read hooks.json and inject, replacing ${CLAUDE_PLUGIN_ROOT}
87
+ const hooksJsonPath = path.join(pkgRoot, 'hooks', 'hooks.json');
88
+ if (fs.existsSync(hooksJsonPath)) {
89
+ const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf-8'));
90
+ const hooksData = hooksJson.hooks;
91
+ if (hooksData) {
92
+ const resolved = JSON.parse(JSON.stringify(hooksData).replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pkgRoot));
93
+ for (const [event, handlers] of Object.entries(resolved)) {
94
+ if (!hooksConfig[event])
95
+ hooksConfig[event] = [];
96
+ hooksConfig[event].push(...handlers);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ catch (e) {
103
+ log.debug('hooks.json 로드 실패', e);
104
+ }
105
+ settings.hooks = Object.keys(hooksConfig).length > 0 ? hooksConfig : undefined;
106
+ if (settings.hooks && Object.keys(settings.hooks).length === 0) {
107
+ delete settings.hooks;
108
+ }
109
+ }
110
+ /** Apply v1 trust policy → permissions (deny/ask lists). */
111
+ function applyTrustPolicyPermissions(settings, v1Result) {
112
+ if (!v1Result.session)
113
+ return;
114
+ const trust = v1Result.session.effective_trust_policy;
115
+ const permissions = settings.permissions ?? {};
116
+ const existingDeny = stripForgenManagedRules(permissions.deny ?? []);
117
+ if (trust === '가드레일 우선') {
118
+ permissions.deny = [
119
+ ...existingDeny,
120
+ '# forgen-managed',
121
+ 'Bash(rm -rf *)',
122
+ 'Bash(git push --force*)',
123
+ 'Bash(git reset --hard*)',
124
+ ];
125
+ }
126
+ else if (trust === '승인 완화') {
127
+ const existingAsk = stripForgenManagedRules(permissions.ask ?? []);
128
+ permissions.ask = [
129
+ ...existingAsk,
130
+ '# forgen-managed',
131
+ 'Bash(rm -rf *)',
132
+ 'Bash(git push --force*)',
133
+ ];
134
+ permissions.deny = existingDeny.length > 0 ? existingDeny : undefined;
135
+ }
136
+ // '완전 신뢰 실행': 추가 제한 없음
137
+ if (!permissions.deny?.length)
138
+ delete permissions.deny;
139
+ if (!permissions.ask?.length)
140
+ delete permissions.ask;
141
+ if (Object.keys(permissions).length > 0)
142
+ settings.permissions = permissions;
143
+ }
144
+ /**
145
+ * Inject forgen settings into Claude Code settings.json.
146
+ * Coordinates: read/backup → env merge → statusLine → hooks → trust policy → atomic write.
147
+ */
148
+ export function injectSettings(env, v1Result, runtime, cwd, pkgRoot) {
149
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
150
+ acquireLock();
151
+ const settings = readSettingsWithBackup();
152
+ // Merge env vars
153
+ settings.env = { ...(settings.env ?? {}), ...env };
154
+ applyStatusLine(settings);
155
+ mergeHooksIntoSettings(settings, runtime, cwd, pkgRoot);
156
+ applyTrustPolicyPermissions(settings, v1Result);
157
+ try {
158
+ atomicWriteFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
159
+ }
160
+ catch (err) {
161
+ rollbackSettings();
162
+ throw err;
163
+ }
164
+ finally {
165
+ releaseLock();
166
+ }
167
+ }
@@ -1,9 +1,30 @@
1
1
  import { CLAUDE_DIR, SETTINGS_PATH } from './paths.js';
2
2
  export { CLAUDE_DIR, SETTINGS_PATH };
3
3
  export declare const SETTINGS_BACKUP_PATH: string;
4
- /** lockfile 획득 (최대 3초 대기, 100ms 간격 재시도) */
4
+ /** settings.json 쓰기 경로가 락으로 보호받지 못할 던지는 오류. */
5
+ export declare class SettingsLockError extends Error {
6
+ constructor(message: string);
7
+ }
8
+ /**
9
+ * lockfile 획득 (최대 3초 대기, 100ms 간격 재시도).
10
+ *
11
+ * Audit fix #1 (2026-04-21): 이전 구현은 타임아웃 후 기존 holder가
12
+ * **살아있어도** 무조건 `writeFileSync`로 PID를 덮어써 동시 쓰기를 유발했다.
13
+ * 주석은 "보류"라고 되어있었지만 코드는 그렇지 않았다. 이제:
14
+ * - holder가 살아있으면 `SettingsLockError`를 throw (쓰기 중단)
15
+ * - holder가 죽었을 때만 stale recovery로 강제 획득
16
+ * 호출자는 락 실패 시 사용자 작업을 망치지 않도록 merge 결과를 버릴 책임을 진다.
17
+ */
5
18
  export declare function acquireLock(): void;
6
- /** lockfile 해제 */
19
+ /**
20
+ * lockfile 해제.
21
+ *
22
+ * Audit fix #1 (2026-04-21): 이전에는 ownership 확인 없이 `rmSync`로
23
+ * 다른 프로세스의 lock도 지울 수 있었다 (cascade lock loss). 이제 lock
24
+ * 파일의 PID가 내 PID와 일치할 때만 삭제한다. 불일치 시 조용히 no-op —
25
+ * 정상 케이스에서는 내 PID만 존재하므로 영향 없음, 비정상 경합 시에는
26
+ * 다른 프로세스의 lock을 존중한다.
27
+ */
7
28
  export declare function releaseLock(): void;
8
29
  /** 임시파일에 쓴 후 rename으로 원자적 교체 */
9
30
  export declare function atomicWriteFileSync(targetPath: string, data: string): void;
@@ -12,6 +33,18 @@ export declare function atomicWriteFileSync(targetPath: string, data: string): v
12
33
  * 파일이 없으면 빈 객체 반환. 파싱 실패 시 Error throw (빈 설정 덮어쓰기 방지).
13
34
  */
14
35
  export declare function readSettings(): Record<string, unknown>;
36
+ /**
37
+ * settings.json 안전 읽기 + 손상본 보존.
38
+ *
39
+ * 2026-04-21 audit (finding #2, #10): `readSettingsWithBackup`가 parse
40
+ * 실패 시 silent `{}` 반환했고, 이후 merged write가 사용자 원본을 덮어써서
41
+ * 데이터 손실 경로가 됐다. 이제 파싱 실패 시 원본을 `.corrupt-<ts>` 로
42
+ * 별도 보존 후 예외를 던진다 — 호출자가 덮어쓰기를 중단할 수 있도록.
43
+ *
44
+ * Fallthrough: 파일 없음 → `{}`. IO 실패 → throw. Parse 실패 → 손상본
45
+ * 보존 후 throw.
46
+ */
47
+ export declare function readSettingsSafely(): Record<string, unknown>;
15
48
  /** settings.json 안전 쓰기. backup 생성 + lock + atomic write */
16
49
  export declare function writeSettings(settings: Record<string, unknown>): void;
17
50
  /** settings.json.forgen-backup 파일에서 원본 복원 */
@@ -33,7 +33,23 @@ function isProcessAlive(pid) {
33
33
  return false;
34
34
  }
35
35
  }
36
- /** lockfile 획득 (최대 3초 대기, 100ms 간격 재시도) */
36
+ /** settings.json 쓰기 경로가 락으로 보호받지 못할 던지는 오류. */
37
+ export class SettingsLockError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'SettingsLockError';
41
+ }
42
+ }
43
+ /**
44
+ * lockfile 획득 (최대 3초 대기, 100ms 간격 재시도).
45
+ *
46
+ * Audit fix #1 (2026-04-21): 이전 구현은 타임아웃 후 기존 holder가
47
+ * **살아있어도** 무조건 `writeFileSync`로 PID를 덮어써 동시 쓰기를 유발했다.
48
+ * 주석은 "보류"라고 되어있었지만 코드는 그렇지 않았다. 이제:
49
+ * - holder가 살아있으면 `SettingsLockError`를 throw (쓰기 중단)
50
+ * - holder가 죽었을 때만 stale recovery로 강제 획득
51
+ * 호출자는 락 실패 시 사용자 작업을 망치지 않도록 merge 결과를 버릴 책임을 진다.
52
+ */
37
53
  export function acquireLock() {
38
54
  const maxWaitMs = 3000;
39
55
  const intervalMs = 100;
@@ -54,17 +70,28 @@ export function acquireLock() {
54
70
  // 타임아웃: lock을 잡고 있는 프로세스가 살아있는지 확인
55
71
  const lockPid = readLockPid();
56
72
  if (lockPid !== null && isProcessAlive(lockPid)) {
57
- log.debug(`lockfile 타임아웃 — pid ${lockPid} 프로세스가 아직 활성 상태, 대기 중 강제 획득 보류`);
58
- // 프로세스가 살아있으면 그래도 강제 획득 (데드락 방지)
59
- }
60
- else {
61
- log.debug(`lockfile 타임아웃 — stale lock 감지 (pid: ${lockPid ?? 'unknown'}, 프로세스 종료됨)`);
73
+ log.warn(`lockfile 타임아웃 — pid ${lockPid} 프로세스가 활성 상태, 쓰기 중단`);
74
+ throw new SettingsLockError(`Could not acquire settings.json lock: another forgen process (pid ${lockPid}) is actively writing`);
62
75
  }
76
+ log.debug(`lockfile stale lock 감지 — pid ${lockPid ?? 'unknown'} 종료됨, 회수`);
63
77
  fs.writeFileSync(SETTINGS_LOCK_PATH, String(process.pid));
64
78
  }
65
- /** lockfile 해제 */
79
+ /**
80
+ * lockfile 해제.
81
+ *
82
+ * Audit fix #1 (2026-04-21): 이전에는 ownership 확인 없이 `rmSync`로
83
+ * 다른 프로세스의 lock도 지울 수 있었다 (cascade lock loss). 이제 lock
84
+ * 파일의 PID가 내 PID와 일치할 때만 삭제한다. 불일치 시 조용히 no-op —
85
+ * 정상 케이스에서는 내 PID만 존재하므로 영향 없음, 비정상 경합 시에는
86
+ * 다른 프로세스의 lock을 존중한다.
87
+ */
66
88
  export function releaseLock() {
67
89
  try {
90
+ const ownerPid = readLockPid();
91
+ if (ownerPid !== null && ownerPid !== process.pid) {
92
+ log.debug(`releaseLock: pid ${ownerPid} owns the lock, not me (${process.pid}) — no-op`);
93
+ return;
94
+ }
68
95
  fs.rmSync(SETTINGS_LOCK_PATH, { force: true });
69
96
  }
70
97
  catch { /* 이미 없으면 무시 */ }
@@ -86,6 +113,37 @@ export function readSettings() {
86
113
  const raw = fs.readFileSync(SETTINGS_PATH, 'utf-8');
87
114
  return JSON.parse(raw); // 파싱 실패 시 throw → 호출자가 처리
88
115
  }
116
+ /**
117
+ * settings.json 안전 읽기 + 손상본 보존.
118
+ *
119
+ * 2026-04-21 audit (finding #2, #10): `readSettingsWithBackup`가 parse
120
+ * 실패 시 silent `{}` 반환했고, 이후 merged write가 사용자 원본을 덮어써서
121
+ * 데이터 손실 경로가 됐다. 이제 파싱 실패 시 원본을 `.corrupt-<ts>` 로
122
+ * 별도 보존 후 예외를 던진다 — 호출자가 덮어쓰기를 중단할 수 있도록.
123
+ *
124
+ * Fallthrough: 파일 없음 → `{}`. IO 실패 → throw. Parse 실패 → 손상본
125
+ * 보존 후 throw.
126
+ */
127
+ export function readSettingsSafely() {
128
+ fs.mkdirSync(CLAUDE_DIR, { recursive: true });
129
+ if (!fs.existsSync(SETTINGS_PATH))
130
+ return {};
131
+ const raw = fs.readFileSync(SETTINGS_PATH, 'utf-8');
132
+ try {
133
+ return JSON.parse(raw);
134
+ }
135
+ catch (e) {
136
+ const corruptPath = `${SETTINGS_PATH}.corrupt-${Date.now()}`;
137
+ try {
138
+ fs.copyFileSync(SETTINGS_PATH, corruptPath);
139
+ log.warn(`settings.json parse 실패 — 손상본을 ${corruptPath}로 보존 후 쓰기 중단`);
140
+ }
141
+ catch (copyErr) {
142
+ log.warn(`settings.json parse 실패 + 손상본 보존 실패 — 쓰기 중단`, copyErr);
143
+ }
144
+ throw e instanceof Error ? e : new Error(String(e));
145
+ }
146
+ }
89
147
  /** settings.json 안전 쓰기. backup 생성 + lock + atomic write */
90
148
  export function writeSettings(settings) {
91
149
  fs.mkdirSync(CLAUDE_DIR, { recursive: true });
@@ -15,21 +15,96 @@ function findClaude() {
15
15
  function findRuntimeLauncher(runtime) {
16
16
  return runtime === 'codex' ? 'codex' : findClaude();
17
17
  }
18
- /**
19
- * 가장 최근 transcript 파일을 찾는다.
20
- * Claude Code는 세션 대화를 ~/.claude/projects/{sanitized-cwd}/{uuid}.jsonl에 저장.
21
- */
22
- function findLatestTranscript(cwd) {
18
+ function transcriptProjectDir(cwd) {
23
19
  // Claude Code는 cwd의 /를 -로 치환하고 선행 -를 유지
24
20
  const sanitized = cwd.replace(/\//g, '-');
25
- const projectDir = path.join(os.homedir(), '.claude', 'projects', sanitized);
26
- if (!fs.existsSync(projectDir))
21
+ return path.join(os.homedir(), '.claude', 'projects', sanitized);
22
+ }
23
+ /** 스냅샷용 — 세션 시작 전 존재하는 transcript basename 집합. */
24
+ function snapshotExistingTranscripts(cwd) {
25
+ const dir = transcriptProjectDir(cwd);
26
+ if (!fs.existsSync(dir))
27
+ return new Set();
28
+ try {
29
+ return new Set(fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')));
30
+ }
31
+ catch {
32
+ return new Set();
33
+ }
34
+ }
35
+ /**
36
+ * 세션 시작 후 새로 생성된 transcript 파일을 고른다.
37
+ *
38
+ * Audit fix #8 (2026-04-21): 이전 findLatestTranscript는 mtime 최신 파일을
39
+ * 선택했기에, 같은 cwd에서 동시에 두 세션이 돌면 더 늦게 시작된 세션의
40
+ * transcript가 두 세션의 exit 핸들러 모두에서 선택되어 transcript
41
+ * attribution이 섞였다. 이제는
42
+ * 1) 세션 시작 시점의 "이미 존재하던" 파일 스냅샷을 preSnapshot으로 전달받고
43
+ * 2) exit 시점에 스냅샷에 없던 새 파일만 후보로 보고
44
+ * 3) mtime이 세션 시작 시각 이후인 것 중 최신을 선택한다.
45
+ * 여전히 후보가 여러 개이면 (rare: 훅이 추가 파일을 쓴 경우) 가장 최근 수정본
46
+ * 을 고르되 debug 로그를 남긴다.
47
+ */
48
+ function findSessionTranscript(cwd, sessionStartMs, preSnapshot) {
49
+ const dir = transcriptProjectDir(cwd);
50
+ if (!fs.existsSync(dir))
27
51
  return null;
28
- const jsonlFiles = fs.readdirSync(projectDir)
29
- .filter(f => f.endsWith('.jsonl'))
30
- .map(f => ({ name: f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
31
- .sort((a, b) => b.mtime - a.mtime);
32
- return jsonlFiles.length > 0 ? path.join(projectDir, jsonlFiles[0].name) : null;
52
+ let candidates;
53
+ try {
54
+ candidates = fs
55
+ .readdirSync(dir)
56
+ .filter((f) => f.endsWith('.jsonl') && !preSnapshot.has(f))
57
+ .map((f) => {
58
+ try {
59
+ return { name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ })
65
+ .filter((x) => x !== null && x.mtime >= sessionStartMs);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ if (candidates.length === 0)
71
+ return null;
72
+ candidates.sort((a, b) => b.mtime - a.mtime);
73
+ if (candidates.length > 1) {
74
+ log.debug(`multiple new transcripts after session start — picking ${candidates[0].name} ` +
75
+ `(others: ${candidates.slice(1).map((c) => c.name).join(', ')})`);
76
+ }
77
+ return path.join(dir, candidates[0].name);
78
+ }
79
+ /**
80
+ * 사용자 메시지 수 카운트 (streaming).
81
+ *
82
+ * Audit fix #8 (2026-04-21): 이전에는 `fs.readFileSync(transcript, 'utf-8')`로
83
+ * 파일 전체를 메모리에 올렸다. 수백 MB 규모 transcript에서는 heap spike가
84
+ * 발생했고, 카운트 외엔 내용이 필요 없으니 streaming line-by-line로 충분하다.
85
+ */
86
+ async function countUserMessages(transcriptPath) {
87
+ const { createInterface } = await import('node:readline');
88
+ const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
89
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
90
+ let count = 0;
91
+ try {
92
+ for await (const line of rl) {
93
+ if (!line)
94
+ continue;
95
+ try {
96
+ const t = JSON.parse(line).type;
97
+ if (t === 'user' || t === 'queue-operation')
98
+ count++;
99
+ }
100
+ catch { /* skip malformed */ }
101
+ }
102
+ }
103
+ finally {
104
+ rl.close();
105
+ stream.close();
106
+ }
107
+ return count;
33
108
  }
34
109
  /**
35
110
  * 세션 종료 후 자동 compound 추출 + USER.md 업데이트.
@@ -74,8 +149,10 @@ export async function spawnClaude(args, context, runtime = 'claude') {
74
149
  !cleanArgs.includes('--dangerously-skip-permissions')) {
75
150
  cleanArgs.unshift('--dangerously-skip-permissions');
76
151
  }
77
- // 세션 시작 전 timestamp 기록 (종료 후 transcript 찾기 위해)
152
+ // 세션 시작 전 timestamp + 기존 transcript 스냅샷 기록 (종료 후 finder ).
153
+ // Audit fix #8 (2026-04-21): 스냅샷으로 동시 세션 transcript 오선택을 차단.
78
154
  const sessionStartTime = Date.now();
155
+ const preSnapshot = snapshotExistingTranscripts(context.cwd);
79
156
  return new Promise((resolve, reject) => {
80
157
  const child = spawn(launcher, cleanArgs, {
81
158
  stdio: 'inherit',
@@ -102,37 +179,21 @@ export async function spawnClaude(args, context, runtime = 'claude') {
102
179
  }
103
180
  // 세션 종료 후 하네스 작업
104
181
  try {
105
- const transcript = findLatestTranscript(context.cwd);
182
+ const transcript = findSessionTranscript(context.cwd, sessionStartTime, preSnapshot);
106
183
  if (!transcript) {
107
- log.debug('transcript 파일을 찾을 수 없음');
184
+ log.debug('이 세션에서 생성된 transcript 찾을 수 없음 (snapshot diff)');
108
185
  }
109
186
  else {
110
- const stat = fs.statSync(transcript);
111
- // 세션에서 생성/수정된 transcript만
112
- if (stat.mtimeMs <= sessionStartTime) {
113
- log.debug(`transcript mtime(${stat.mtimeMs}) <= sessionStart(${sessionStartTime}), 건너뜀`);
187
+ const sessionId = path.basename(transcript, '.jsonl');
188
+ // 1. FTS5 인덱싱
189
+ await indexTranscriptToFTS(context.cwd, transcript, sessionId);
190
+ // 2. 자동 compound (10+ user 메시지인 경우만) — streaming line count
191
+ const userMsgCount = await countUserMessages(transcript);
192
+ if (userMsgCount >= 10) {
193
+ await runAutoCompound(context.cwd, transcript, sessionId);
114
194
  }
115
195
  else {
116
- const sessionId = path.basename(transcript, '.jsonl');
117
- // 1. FTS5 인덱싱
118
- await indexTranscriptToFTS(context.cwd, transcript, sessionId);
119
- // 2. 자동 compound (10+ user 메시지인 경우만)
120
- const content = fs.readFileSync(transcript, 'utf-8');
121
- const userMsgCount = content.split('\n')
122
- .filter(l => { try {
123
- const t = JSON.parse(l).type;
124
- return t === 'user' || t === 'queue-operation';
125
- }
126
- catch {
127
- return false;
128
- } })
129
- .length;
130
- if (userMsgCount >= 10) {
131
- await runAutoCompound(context.cwd, transcript, sessionId);
132
- }
133
- else {
134
- console.log(`[forgen] 세션이 짧아 auto-compound 생략 (${userMsgCount} messages)`);
135
- }
196
+ console.log(`[forgen] 세션이 짧아 auto-compound 생략 (${userMsgCount} messages)`);
136
197
  }
137
198
  }
138
199
  }
@@ -0,0 +1,30 @@
1
+ export interface PruneReport {
2
+ scanned: number;
3
+ pruned: number;
4
+ bytesFreed: number;
5
+ retentionDays: number;
6
+ dryRun: boolean;
7
+ /** First 20 pruned file basenames for user confirmation */
8
+ sample: string[];
9
+ }
10
+ export interface PruneOptions {
11
+ retentionMs?: number;
12
+ dryRun?: boolean;
13
+ /** Override the state directory. Used by tests. */
14
+ stateDir?: string;
15
+ /** Override the outcomes directory. Used by tests. */
16
+ outcomesDir?: string;
17
+ /** Current time for deterministic tests. Defaults to Date.now(). */
18
+ now?: number;
19
+ }
20
+ /**
21
+ * Prune session-scoped files older than `retentionMs` from the state and
22
+ * outcomes directories. Defaults to a dry-run so callers must opt-in to
23
+ * deletion via `dryRun: false`.
24
+ */
25
+ export declare function pruneState(opts?: PruneOptions): PruneReport;
26
+ /**
27
+ * Count session-scoped files in STATE_DIR without deleting. Used by doctor
28
+ * to surface a warning when the directory is bloated.
29
+ */
30
+ export declare function countSessionScopedFiles(stateDir?: string): number;