@wooojin/forgen 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +132 -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/agents/solution-evolver.md +115 -0
  8. package/dist/cli.js +11 -3
  9. package/dist/core/auto-compound-runner.js +6 -3
  10. package/dist/core/dashboard.js +57 -4
  11. package/dist/core/doctor.d.ts +6 -1
  12. package/dist/core/doctor.js +21 -1
  13. package/dist/core/global-config.d.ts +2 -2
  14. package/dist/core/global-config.js +6 -14
  15. package/dist/core/harness.d.ts +3 -5
  16. package/dist/core/harness.js +34 -338
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +25 -34
  20. package/dist/core/paths.js +25 -35
  21. package/dist/core/settings-injector.d.ts +13 -0
  22. package/dist/core/settings-injector.js +167 -0
  23. package/dist/core/settings-lock.d.ts +35 -2
  24. package/dist/core/settings-lock.js +65 -7
  25. package/dist/core/spawn.js +100 -39
  26. package/dist/core/state-gc.d.ts +30 -0
  27. package/dist/core/state-gc.js +119 -0
  28. package/dist/core/uninstall.js +12 -4
  29. package/dist/core/v1-bootstrap.js +2 -2
  30. package/dist/engine/compound-cli.d.ts +27 -2
  31. package/dist/engine/compound-cli.js +69 -16
  32. package/dist/engine/compound-export.d.ts +15 -0
  33. package/dist/engine/compound-export.js +32 -5
  34. package/dist/engine/compound-loop.js +3 -2
  35. package/dist/engine/learn-cli.d.ts +1 -0
  36. package/dist/engine/learn-cli.js +234 -0
  37. package/dist/engine/match-eval-log.js +45 -0
  38. package/dist/engine/solution-candidate.d.ts +30 -0
  39. package/dist/engine/solution-candidate.js +124 -0
  40. package/dist/engine/solution-fitness.d.ts +52 -0
  41. package/dist/engine/solution-fitness.js +95 -0
  42. package/dist/engine/solution-fixup.d.ts +30 -0
  43. package/dist/engine/solution-fixup.js +116 -0
  44. package/dist/engine/solution-format.d.ts +8 -2
  45. package/dist/engine/solution-format.js +38 -27
  46. package/dist/engine/solution-index.js +10 -0
  47. package/dist/engine/solution-matcher.d.ts +8 -0
  48. package/dist/engine/solution-matcher.js +27 -1
  49. package/dist/engine/solution-outcomes.d.ts +74 -0
  50. package/dist/engine/solution-outcomes.js +319 -0
  51. package/dist/engine/solution-quarantine.d.ts +36 -0
  52. package/dist/engine/solution-quarantine.js +172 -0
  53. package/dist/engine/solution-weakness.d.ts +45 -0
  54. package/dist/engine/solution-weakness.js +225 -0
  55. package/dist/engine/solution-writer.d.ts +9 -1
  56. package/dist/engine/solution-writer.js +44 -2
  57. package/dist/fgx.js +9 -2
  58. package/dist/forge/cli.js +7 -7
  59. package/dist/hooks/context-guard.js +15 -1
  60. package/dist/hooks/hook-config.d.ts +9 -1
  61. package/dist/hooks/hook-config.js +25 -3
  62. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  63. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  64. package/dist/hooks/notepad-injector.js +6 -3
  65. package/dist/hooks/permission-handler.d.ts +10 -2
  66. package/dist/hooks/permission-handler.js +31 -12
  67. package/dist/hooks/post-tool-failure.js +7 -0
  68. package/dist/hooks/pre-tool-use.js +10 -4
  69. package/dist/hooks/secret-filter.js +6 -0
  70. package/dist/hooks/session-recovery.js +15 -7
  71. package/dist/hooks/shared/hook-response.d.ts +0 -2
  72. package/dist/hooks/shared/hook-response.js +3 -8
  73. package/dist/hooks/shared/hook-timing.js +10 -1
  74. package/dist/hooks/solution-injector.d.ts +21 -0
  75. package/dist/hooks/solution-injector.js +80 -1
  76. package/dist/mcp/solution-reader.d.ts +2 -0
  77. package/dist/mcp/solution-reader.js +28 -1
  78. package/dist/mcp/tools.js +13 -2
  79. package/dist/preset/preset-manager.js +12 -2
  80. package/dist/store/evidence-store.js +5 -5
  81. package/dist/store/profile-store.d.ts +9 -0
  82. package/dist/store/profile-store.js +25 -4
  83. package/dist/store/rule-store.js +8 -8
  84. package/package.json +1 -1
  85. package/plugin.json +7 -2
  86. package/scripts/postinstall.js +52 -5
@@ -1,23 +1,7 @@
1
1
  /** ~/.claude/ — Claude Code 설정 디렉토리 */
2
2
  export declare const CLAUDE_DIR: string;
3
- export declare const CODEX_DIR: string;
4
3
  /** ~/.claude/settings.json — Claude Code 설정 파일 */
5
4
  export declare const SETTINGS_PATH: string;
6
- /**
7
- * ~/.compound/ — LEGACY harness home (pre-v5).
8
- *
9
- * @deprecated A5 (2026-04-09): this path must NEVER be used for WRITES.
10
- * It exists solely as the source path for `migrateToForgen()`.
11
- * All new writes must target `FORGEN_HOME`-based paths. Consumers that
12
- * read from this path should prefer the FORGEN_HOME equivalent first
13
- * and fall back here only during migration.
14
- *
15
- * Pre-A5, `harness.ts:ensureDirectories` and several hooks actively
16
- * created directories under this path, causing a dual-reality where
17
- * state could diverge between `~/.compound/` and `~/.forgen/` when the
18
- * migration symlink was broken (sudo install, SIP, CI, manual delete).
19
- */
20
- export declare const COMPOUND_HOME: string;
21
5
  /** ~/.forgen/ — v1 하네스 홈 디렉토리 */
22
6
  export declare const FORGEN_HOME: string;
23
7
  /** ~/.forgen/me/ — 개인 공간 (v5.1: ~/.compound/ → ~/.forgen/ 통합) */
@@ -44,40 +28,47 @@ export declare const STATE_DIR: string;
44
28
  * `src/engine/match-eval-log.ts`; never on the hook critical path.
45
29
  */
46
30
  export declare const MATCH_EVAL_LOG_PATH: string;
31
+ /**
32
+ * ~/.forgen/state/solution-quarantine.jsonl — JSONL log of solution files
33
+ * dropped during index build due to malformed frontmatter. Append-only,
34
+ * dedupe-by-path. Used by `forgen doctor` to surface dead solutions that
35
+ * would otherwise vanish silently (see `diagnoseFrontmatter`).
36
+ */
37
+ export declare const SOLUTION_QUARANTINE_PATH: string;
38
+ /**
39
+ * ~/.forgen/state/outcomes/ — per-session JSONL logs of solution inject →
40
+ * outcome events (accept / correct / error / unknown). Written by the
41
+ * solution-outcome-tracker hook. One file per session for write-safety
42
+ * under concurrent sessions. Consumers aggregate across files to compute
43
+ * fitness (see `solution-fitness.ts`).
44
+ */
45
+ export declare const OUTCOMES_DIR: string;
46
+ /**
47
+ * ~/.forgen/lab/candidates/ — Phase 4 quarantine zone for evolver-agent
48
+ * proposals before they enter the live solution index. The evolver writes
49
+ * here; promotion and rollback commands move files out (to ME_SOLUTIONS
50
+ * or to `lab/archived-{ts}/`). Keeping candidates isolated means a
51
+ * runaway agent cannot silently poison the match pool.
52
+ */
53
+ export declare const CANDIDATES_DIR: string;
54
+ /** ~/.forgen/lab/archived/ — rollback destination for evolved solutions. */
55
+ export declare const ARCHIVED_DIR: string;
47
56
  /** ~/.forgen/sessions/ — 세션 로그 */
48
57
  export declare const SESSIONS_DIR: string;
49
58
  /** ~/.forgen/config.json — 글로벌 설정 */
50
59
  export declare const GLOBAL_CONFIG: string;
51
- /** ~/.forgen/state/session-quality/ — 세션 품질 점수 */
52
- export declare const SESSION_QUALITY_DIR: string;
53
60
  /** ~/.forgen/state/meta-learning/ — 메타학습 상태 파일 */
54
61
  export declare const META_LEARNING_DIR: string;
55
62
  /** ~/.forgen/lab/ — Lab 적응형 최적화 엔진 데이터 */
56
63
  export declare const LAB_DIR: string;
57
- /** ~/.forgen/lab/events.jsonl — Lab 이벤트 로그 (JSONL) */
58
- export declare const LAB_EVENTS: string;
59
64
  /** ~/.forgen/me/forge-profile.json — 글로벌 Forge 프로필 */
60
65
  export declare const FORGE_PROFILE: string;
61
- /** @deprecated use ME_DIR */
62
- export declare const V1_ME_DIR: string;
63
- /** @deprecated use FORGE_PROFILE */
64
- export declare const V1_PROFILE: string;
65
- /** @deprecated use ME_RULES */
66
- export declare const V1_RULES_DIR: string;
67
- /** @deprecated use ME_BEHAVIOR */
68
- export declare const V1_EVIDENCE_DIR: string;
69
66
  /** ~/.forgen/me/recommendations/ — Pack Recommendation */
70
67
  export declare const V1_RECOMMENDATIONS_DIR: string;
71
- /** @deprecated use ME_SOLUTIONS */
72
- export declare const V1_SOLUTIONS_DIR: string;
73
- /** @deprecated use STATE_DIR */
74
- export declare const V1_STATE_DIR: string;
75
68
  /** ~/.forgen/state/sessions/ — Session Effective State */
76
69
  export declare const V1_SESSIONS_DIR: string;
77
70
  /** ~/.forgen/state/raw-logs/ — Raw Log */
78
71
  export declare const V1_RAW_LOGS_DIR: string;
79
- /** @deprecated use GLOBAL_CONFIG */
80
- export declare const V1_GLOBAL_CONFIG: string;
81
72
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
82
73
  export declare const ALL_MODES: readonly ["ralph", "autopilot", "ultrawork", "team", "pipeline", "ccg", "ralplan", "deep-interview", "forge-loop", "ship", "retro", "learn", "calibrate"];
83
74
  /** {repo}/.compound/ — 프로젝트 로컬 디렉토리 */
@@ -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/ 통합) */
@@ -47,41 +31,47 @@ export const STATE_DIR = path.join(FORGEN_HOME, 'state');
47
31
  * `src/engine/match-eval-log.ts`; never on the hook critical path.
48
32
  */
49
33
  export const MATCH_EVAL_LOG_PATH = path.join(STATE_DIR, 'match-eval-log.jsonl');
34
+ /**
35
+ * ~/.forgen/state/solution-quarantine.jsonl — JSONL log of solution files
36
+ * dropped during index build due to malformed frontmatter. Append-only,
37
+ * dedupe-by-path. Used by `forgen doctor` to surface dead solutions that
38
+ * would otherwise vanish silently (see `diagnoseFrontmatter`).
39
+ */
40
+ export const SOLUTION_QUARANTINE_PATH = path.join(STATE_DIR, 'solution-quarantine.jsonl');
41
+ /**
42
+ * ~/.forgen/state/outcomes/ — per-session JSONL logs of solution inject →
43
+ * outcome events (accept / correct / error / unknown). Written by the
44
+ * solution-outcome-tracker hook. One file per session for write-safety
45
+ * under concurrent sessions. Consumers aggregate across files to compute
46
+ * fitness (see `solution-fitness.ts`).
47
+ */
48
+ export const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
49
+ /**
50
+ * ~/.forgen/lab/candidates/ — Phase 4 quarantine zone for evolver-agent
51
+ * proposals before they enter the live solution index. The evolver writes
52
+ * here; promotion and rollback commands move files out (to ME_SOLUTIONS
53
+ * or to `lab/archived-{ts}/`). Keeping candidates isolated means a
54
+ * runaway agent cannot silently poison the match pool.
55
+ */
56
+ export const CANDIDATES_DIR = path.join(FORGEN_HOME, 'lab', 'candidates');
57
+ /** ~/.forgen/lab/archived/ — rollback destination for evolved solutions. */
58
+ export const ARCHIVED_DIR = path.join(FORGEN_HOME, 'lab', 'archived');
50
59
  /** ~/.forgen/sessions/ — 세션 로그 */
51
60
  export const SESSIONS_DIR = path.join(FORGEN_HOME, 'sessions');
52
61
  /** ~/.forgen/config.json — 글로벌 설정 */
53
62
  export const GLOBAL_CONFIG = path.join(FORGEN_HOME, 'config.json');
54
- /** ~/.forgen/state/session-quality/ — 세션 품질 점수 */
55
- export const SESSION_QUALITY_DIR = path.join(STATE_DIR, 'session-quality');
56
63
  /** ~/.forgen/state/meta-learning/ — 메타학습 상태 파일 */
57
64
  export const META_LEARNING_DIR = path.join(STATE_DIR, 'meta-learning');
58
65
  /** ~/.forgen/lab/ — Lab 적응형 최적화 엔진 데이터 */
59
66
  export const LAB_DIR = path.join(FORGEN_HOME, 'lab');
60
- /** ~/.forgen/lab/events.jsonl — Lab 이벤트 로그 (JSONL) */
61
- export const LAB_EVENTS = path.join(LAB_DIR, 'events.jsonl');
62
67
  /** ~/.forgen/me/forge-profile.json — 글로벌 Forge 프로필 */
63
68
  export const FORGE_PROFILE = path.join(ME_DIR, 'forge-profile.json');
64
- // ── v1 호환 경로 (ME_*와 동일 — 점진 제거 예정) ──
65
- /** @deprecated use ME_DIR */
66
- export const V1_ME_DIR = ME_DIR;
67
- /** @deprecated use FORGE_PROFILE */
68
- export const V1_PROFILE = FORGE_PROFILE;
69
- /** @deprecated use ME_RULES */
70
- export const V1_RULES_DIR = ME_RULES;
71
- /** @deprecated use ME_BEHAVIOR */
72
- export const V1_EVIDENCE_DIR = ME_BEHAVIOR;
73
69
  /** ~/.forgen/me/recommendations/ — Pack Recommendation */
74
70
  export const V1_RECOMMENDATIONS_DIR = path.join(ME_DIR, 'recommendations');
75
- /** @deprecated use ME_SOLUTIONS */
76
- export const V1_SOLUTIONS_DIR = ME_SOLUTIONS;
77
- /** @deprecated use STATE_DIR */
78
- export const V1_STATE_DIR = STATE_DIR;
79
71
  /** ~/.forgen/state/sessions/ — Session Effective State */
80
72
  export const V1_SESSIONS_DIR = path.join(STATE_DIR, 'sessions');
81
73
  /** ~/.forgen/state/raw-logs/ — Raw Log */
82
74
  export const V1_RAW_LOGS_DIR = path.join(STATE_DIR, 'raw-logs');
83
- /** @deprecated use GLOBAL_CONFIG */
84
- export const V1_GLOBAL_CONFIG = GLOBAL_CONFIG;
85
75
  // ── 레거시 ──
86
76
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
87
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 });