@wooojin/forgen 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +7 -2
- package/CHANGELOG.md +132 -0
- package/README.ja.md +29 -0
- package/README.ko.md +29 -0
- package/README.md +36 -3
- package/README.zh.md +29 -0
- package/agents/solution-evolver.md +115 -0
- package/dist/cli.js +11 -3
- package/dist/core/auto-compound-runner.js +6 -3
- package/dist/core/dashboard.js +57 -4
- package/dist/core/doctor.d.ts +6 -1
- package/dist/core/doctor.js +21 -1
- package/dist/core/global-config.d.ts +2 -2
- package/dist/core/global-config.js +6 -14
- package/dist/core/harness.d.ts +3 -5
- package/dist/core/harness.js +34 -338
- package/dist/core/installer.d.ts +10 -0
- package/dist/core/installer.js +185 -0
- package/dist/core/paths.d.ts +25 -34
- package/dist/core/paths.js +25 -35
- package/dist/core/settings-injector.d.ts +13 -0
- package/dist/core/settings-injector.js +167 -0
- package/dist/core/settings-lock.d.ts +35 -2
- package/dist/core/settings-lock.js +65 -7
- package/dist/core/spawn.js +100 -39
- package/dist/core/state-gc.d.ts +30 -0
- package/dist/core/state-gc.js +119 -0
- package/dist/core/uninstall.js +12 -4
- package/dist/core/v1-bootstrap.js +2 -2
- package/dist/engine/compound-cli.d.ts +27 -2
- package/dist/engine/compound-cli.js +69 -16
- package/dist/engine/compound-export.d.ts +15 -0
- package/dist/engine/compound-export.js +32 -5
- package/dist/engine/compound-loop.js +3 -2
- package/dist/engine/learn-cli.d.ts +1 -0
- package/dist/engine/learn-cli.js +234 -0
- package/dist/engine/match-eval-log.js +45 -0
- package/dist/engine/solution-candidate.d.ts +30 -0
- package/dist/engine/solution-candidate.js +124 -0
- package/dist/engine/solution-fitness.d.ts +52 -0
- package/dist/engine/solution-fitness.js +95 -0
- package/dist/engine/solution-fixup.d.ts +30 -0
- package/dist/engine/solution-fixup.js +116 -0
- package/dist/engine/solution-format.d.ts +8 -2
- package/dist/engine/solution-format.js +38 -27
- package/dist/engine/solution-index.js +10 -0
- package/dist/engine/solution-matcher.d.ts +8 -0
- package/dist/engine/solution-matcher.js +27 -1
- package/dist/engine/solution-outcomes.d.ts +74 -0
- package/dist/engine/solution-outcomes.js +319 -0
- package/dist/engine/solution-quarantine.d.ts +36 -0
- package/dist/engine/solution-quarantine.js +172 -0
- package/dist/engine/solution-weakness.d.ts +45 -0
- package/dist/engine/solution-weakness.js +225 -0
- package/dist/engine/solution-writer.d.ts +9 -1
- package/dist/engine/solution-writer.js +44 -2
- package/dist/fgx.js +9 -2
- package/dist/forge/cli.js +7 -7
- package/dist/hooks/context-guard.js +15 -1
- package/dist/hooks/hook-config.d.ts +9 -1
- package/dist/hooks/hook-config.js +25 -3
- package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
- package/dist/hooks/internal/run-lifecycle-check.js +32 -0
- package/dist/hooks/notepad-injector.js +6 -3
- package/dist/hooks/permission-handler.d.ts +10 -2
- package/dist/hooks/permission-handler.js +31 -12
- package/dist/hooks/post-tool-failure.js +7 -0
- package/dist/hooks/pre-tool-use.js +10 -4
- package/dist/hooks/secret-filter.js +6 -0
- package/dist/hooks/session-recovery.js +15 -7
- package/dist/hooks/shared/hook-response.d.ts +0 -2
- package/dist/hooks/shared/hook-response.js +3 -8
- package/dist/hooks/shared/hook-timing.js +10 -1
- package/dist/hooks/solution-injector.d.ts +21 -0
- package/dist/hooks/solution-injector.js +80 -1
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +13 -2
- package/dist/preset/preset-manager.js +12 -2
- package/dist/store/evidence-store.js +5 -5
- package/dist/store/profile-store.d.ts +9 -0
- package/dist/store/profile-store.js +25 -4
- package/dist/store/rule-store.js +8 -8
- package/package.json +1 -1
- package/plugin.json +7 -2
- package/scripts/postinstall.js +52 -5
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { GLOBAL_CONFIG
|
|
4
|
-
/**
|
|
3
|
+
import { GLOBAL_CONFIG } from './paths.js';
|
|
4
|
+
/** 글로벌 config 로드 (~/.forgen/config.json) */
|
|
5
5
|
export function loadGlobalConfig() {
|
|
6
|
-
// v1 경로 우선
|
|
7
|
-
if (fs.existsSync(V1_GLOBAL_CONFIG)) {
|
|
8
|
-
try {
|
|
9
|
-
return JSON.parse(fs.readFileSync(V1_GLOBAL_CONFIG, 'utf-8'));
|
|
10
|
-
}
|
|
11
|
-
catch { /* fall through */ }
|
|
12
|
-
}
|
|
13
|
-
// 레거시 폴백
|
|
14
6
|
if (fs.existsSync(GLOBAL_CONFIG)) {
|
|
15
7
|
try {
|
|
16
8
|
return JSON.parse(fs.readFileSync(GLOBAL_CONFIG, 'utf-8'));
|
|
17
9
|
}
|
|
18
|
-
catch { /*
|
|
10
|
+
catch { /* malformed — use defaults */ }
|
|
19
11
|
}
|
|
20
12
|
return {};
|
|
21
13
|
}
|
|
22
|
-
/**
|
|
14
|
+
/** 글로벌 config 저장 (~/.forgen/config.json) */
|
|
23
15
|
export function saveGlobalConfig(config) {
|
|
24
|
-
fs.mkdirSync(path.dirname(
|
|
25
|
-
fs.writeFileSync(
|
|
16
|
+
fs.mkdirSync(path.dirname(GLOBAL_CONFIG), { recursive: true });
|
|
17
|
+
fs.writeFileSync(GLOBAL_CONFIG, JSON.stringify(config, null, 2));
|
|
26
18
|
}
|
package/dist/core/harness.d.ts
CHANGED
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* philosophy/scope/pack 의존 제거. Profile + Preset Manager + Rule Renderer.
|
|
6
6
|
*
|
|
7
7
|
* Module Structure:
|
|
8
|
-
* - Lines 1-
|
|
9
|
-
* - Lines
|
|
10
|
-
* - Lines
|
|
11
|
-
* - Lines 400-550: Rule file injection, gitignore, compound memory
|
|
12
|
-
* - Lines 550+: prepareHarness — main orchestration
|
|
8
|
+
* - Lines 1-50: Imports, utility helpers
|
|
9
|
+
* - Lines 50-120: Rule file injection, gitignore, compound memory
|
|
10
|
+
* - Lines 120+: prepareHarness — main orchestration
|
|
13
11
|
*/
|
|
14
12
|
import { type RuntimeHost } from './types.js';
|
|
15
13
|
import { rollbackSettings } from './settings-lock.js';
|
package/dist/core/harness.js
CHANGED
|
@@ -5,13 +5,10 @@
|
|
|
5
5
|
* philosophy/scope/pack 의존 제거. Profile + Preset Manager + Rule Renderer.
|
|
6
6
|
*
|
|
7
7
|
* Module Structure:
|
|
8
|
-
* - Lines 1-
|
|
9
|
-
* - Lines
|
|
10
|
-
* - Lines
|
|
11
|
-
* - Lines 400-550: Rule file injection, gitignore, compound memory
|
|
12
|
-
* - Lines 550+: prepareHarness — main orchestration
|
|
8
|
+
* - Lines 1-50: Imports, utility helpers
|
|
9
|
+
* - Lines 50-120: Rule file injection, gitignore, compound memory
|
|
10
|
+
* - Lines 120+: prepareHarness — main orchestration
|
|
13
11
|
*/
|
|
14
|
-
import * as crypto from 'node:crypto';
|
|
15
12
|
import * as fs from 'node:fs';
|
|
16
13
|
import * as os from 'node:os';
|
|
17
14
|
import * as path from 'node:path';
|
|
@@ -20,10 +17,10 @@ import { buildEnv, generateClaudeRuleFiles, registerTmuxBindings } from './confi
|
|
|
20
17
|
import { createLogger } from './logger.js';
|
|
21
18
|
import { HANDOFFS_DIR, ME_BEHAVIOR, ME_DIR, ME_RULES, ME_SKILLS, ME_SOLUTIONS, SESSIONS_DIR, STATE_DIR, FORGEN_HOME } from './paths.js';
|
|
22
19
|
import { RULE_FILE_CAPS } from '../hooks/shared/injection-caps.js';
|
|
23
|
-
import {
|
|
24
|
-
import { acquireLock, atomicWriteFileSync, CLAUDE_DIR, releaseLock, rollbackSettings, SETTINGS_BACKUP_PATH, SETTINGS_PATH, } from './settings-lock.js';
|
|
25
|
-
import { ConfigError } from './errors.js';
|
|
20
|
+
import { rollbackSettings, } from './settings-lock.js';
|
|
26
21
|
import { bootstrapV1Session, ensureV1Directories } from './v1-bootstrap.js';
|
|
22
|
+
import { injectSettings } from './settings-injector.js';
|
|
23
|
+
import { installAgents, installSlashCommands } from './installer.js';
|
|
27
24
|
const log = createLogger('harness');
|
|
28
25
|
/** forgen 패키지 루트 */
|
|
29
26
|
function getPackageRoot() {
|
|
@@ -61,331 +58,6 @@ function ensureDirectories() {
|
|
|
61
58
|
ensureV1Directories();
|
|
62
59
|
}
|
|
63
60
|
export { rollbackSettings };
|
|
64
|
-
// ── Settings Injection ──
|
|
65
|
-
const FORGEN_PERMISSION_RULES = new Set([
|
|
66
|
-
'# forgen-managed',
|
|
67
|
-
'Bash(rm -rf *)',
|
|
68
|
-
'Bash(git push --force*)',
|
|
69
|
-
'Bash(git reset --hard*)',
|
|
70
|
-
]);
|
|
71
|
-
function stripForgenManagedRules(rules) {
|
|
72
|
-
return rules.filter(r => !FORGEN_PERMISSION_RULES.has(r));
|
|
73
|
-
}
|
|
74
|
-
/** Claude Code settings.json에 하네스 환경변수 + 훅 주입 */
|
|
75
|
-
// ── B9: injectSettings sub-phases (extracted from 128-line monolith) ──
|
|
76
|
-
/** Read settings.json with backup, or return empty object on failure. */
|
|
77
|
-
function readSettingsWithBackup() {
|
|
78
|
-
if (!fs.existsSync(SETTINGS_PATH))
|
|
79
|
-
return {};
|
|
80
|
-
try {
|
|
81
|
-
const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
82
|
-
fs.copyFileSync(SETTINGS_PATH, SETTINGS_BACKUP_PATH);
|
|
83
|
-
return settings;
|
|
84
|
-
}
|
|
85
|
-
catch (e) {
|
|
86
|
-
log.debug('settings.json 파싱 실패, 빈 설정으로 시작', new ConfigError('settings.json parse failed', { configPath: SETTINGS_PATH, cause: e }));
|
|
87
|
-
return {};
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
/** Apply forgen statusLine only if user hasn't set a custom one. */
|
|
91
|
-
function applyStatusLine(settings) {
|
|
92
|
-
const existing = settings.statusLine;
|
|
93
|
-
const isForgenOwned = !existing || !existing.command || existing.command.startsWith('forgen');
|
|
94
|
-
if (isForgenOwned) {
|
|
95
|
-
settings.statusLine = { type: 'command', command: 'forgen me' };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
/** Check if a settings.json hook entry was installed by forgen. */
|
|
99
|
-
function isForgenHookEntry(entry, pkgRoot) {
|
|
100
|
-
const distHooksPath = path.join(pkgRoot, 'dist', 'hooks');
|
|
101
|
-
const matchesPath = (cmd) => cmd.includes(distHooksPath) || /[\\/]dist[\\/]hooks[\\/].*\.js/.test(cmd);
|
|
102
|
-
if (typeof entry.command === 'string' && matchesPath(entry.command))
|
|
103
|
-
return true;
|
|
104
|
-
const hooks = entry.hooks;
|
|
105
|
-
return Array.isArray(hooks) && hooks.some(h => typeof h.command === 'string' && matchesPath(h.command));
|
|
106
|
-
}
|
|
107
|
-
/** Strip existing forgen hooks from settings, merge fresh hooks.json. */
|
|
108
|
-
function mergeHooksIntoSettings(settings, runtime, cwd) {
|
|
109
|
-
const pkgRoot = getPackageRoot();
|
|
110
|
-
const hooksConfig = settings.hooks ?? {};
|
|
111
|
-
// Remove existing forgen hooks (clean slate before re-inject)
|
|
112
|
-
for (const [event, entries] of Object.entries(hooksConfig)) {
|
|
113
|
-
if (!Array.isArray(entries))
|
|
114
|
-
continue;
|
|
115
|
-
const filtered = entries.filter(h => !isForgenHookEntry(h, pkgRoot));
|
|
116
|
-
if (filtered.length === 0)
|
|
117
|
-
delete hooksConfig[event];
|
|
118
|
-
else
|
|
119
|
-
hooksConfig[event] = filtered;
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
if (runtime === 'codex') {
|
|
123
|
-
const generated = generateHooksJson({ cwd, runtime, pluginRoot: path.join(pkgRoot, 'dist') });
|
|
124
|
-
for (const [event, handlers] of Object.entries(generated.hooks)) {
|
|
125
|
-
if (!hooksConfig[event])
|
|
126
|
-
hooksConfig[event] = [];
|
|
127
|
-
hooksConfig[event].push(...handlers);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
// Read hooks.json and inject, replacing ${CLAUDE_PLUGIN_ROOT}
|
|
132
|
-
const hooksJsonPath = path.join(pkgRoot, 'hooks', 'hooks.json');
|
|
133
|
-
if (fs.existsSync(hooksJsonPath)) {
|
|
134
|
-
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf-8'));
|
|
135
|
-
const hooksData = hooksJson.hooks;
|
|
136
|
-
if (hooksData) {
|
|
137
|
-
const resolved = JSON.parse(JSON.stringify(hooksData).replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pkgRoot));
|
|
138
|
-
for (const [event, handlers] of Object.entries(resolved)) {
|
|
139
|
-
if (!hooksConfig[event])
|
|
140
|
-
hooksConfig[event] = [];
|
|
141
|
-
hooksConfig[event].push(...handlers);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
catch (e) {
|
|
148
|
-
log.debug('hooks.json 로드 실패', e);
|
|
149
|
-
}
|
|
150
|
-
settings.hooks = Object.keys(hooksConfig).length > 0 ? hooksConfig : undefined;
|
|
151
|
-
if (settings.hooks && Object.keys(settings.hooks).length === 0) {
|
|
152
|
-
delete settings.hooks;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/** Apply v1 trust policy → permissions (deny/ask lists). */
|
|
156
|
-
function applyTrustPolicyPermissions(settings, v1Result) {
|
|
157
|
-
if (!v1Result.session)
|
|
158
|
-
return;
|
|
159
|
-
const trust = v1Result.session.effective_trust_policy;
|
|
160
|
-
const permissions = settings.permissions ?? {};
|
|
161
|
-
const existingDeny = stripForgenManagedRules(permissions.deny ?? []);
|
|
162
|
-
if (trust === '가드레일 우선') {
|
|
163
|
-
permissions.deny = [
|
|
164
|
-
...existingDeny, '# forgen-managed',
|
|
165
|
-
'Bash(rm -rf *)', 'Bash(git push --force*)', 'Bash(git reset --hard*)',
|
|
166
|
-
];
|
|
167
|
-
}
|
|
168
|
-
else if (trust === '승인 완화') {
|
|
169
|
-
const existingAsk = stripForgenManagedRules(permissions.ask ?? []);
|
|
170
|
-
permissions.ask = [
|
|
171
|
-
...existingAsk, '# forgen-managed',
|
|
172
|
-
'Bash(rm -rf *)', 'Bash(git push --force*)',
|
|
173
|
-
];
|
|
174
|
-
permissions.deny = existingDeny.length > 0 ? existingDeny : undefined;
|
|
175
|
-
}
|
|
176
|
-
// '완전 신뢰 실행': 추가 제한 없음
|
|
177
|
-
if (!permissions.deny?.length)
|
|
178
|
-
delete permissions.deny;
|
|
179
|
-
if (!permissions.ask?.length)
|
|
180
|
-
delete permissions.ask;
|
|
181
|
-
if (Object.keys(permissions).length > 0)
|
|
182
|
-
settings.permissions = permissions;
|
|
183
|
-
}
|
|
184
|
-
/**
|
|
185
|
-
* B9: injectSettings — now a ~20-line coordinator calling the extracted
|
|
186
|
-
* sub-phases above. Pre-B9 this was 128 lines with interleaved phases
|
|
187
|
-
* (read/backup, env merge, statusLine, hook strip+inject, trust policy,
|
|
188
|
-
* atomic write). Each phase is now a named function with a single
|
|
189
|
-
* responsibility, testable in isolation if needed.
|
|
190
|
-
*/
|
|
191
|
-
function injectSettings(env, v1Result, runtime, cwd) {
|
|
192
|
-
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
193
|
-
acquireLock();
|
|
194
|
-
const settings = readSettingsWithBackup();
|
|
195
|
-
// Merge env vars
|
|
196
|
-
settings.env = { ...(settings.env ?? {}), ...env };
|
|
197
|
-
applyStatusLine(settings);
|
|
198
|
-
mergeHooksIntoSettings(settings, runtime, cwd);
|
|
199
|
-
applyTrustPolicyPermissions(settings, v1Result);
|
|
200
|
-
try {
|
|
201
|
-
atomicWriteFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
202
|
-
}
|
|
203
|
-
catch (err) {
|
|
204
|
-
rollbackSettings();
|
|
205
|
-
throw err;
|
|
206
|
-
}
|
|
207
|
-
finally {
|
|
208
|
-
releaseLock();
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
// ── Agent Installation ──
|
|
212
|
-
function contentHash(content) {
|
|
213
|
-
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
|
|
214
|
-
}
|
|
215
|
-
const AGENT_HASHES_PATH = path.join(STATE_DIR, 'agent-hashes.json');
|
|
216
|
-
function loadAgentHashes() {
|
|
217
|
-
try {
|
|
218
|
-
if (fs.existsSync(AGENT_HASHES_PATH)) {
|
|
219
|
-
return JSON.parse(fs.readFileSync(AGENT_HASHES_PATH, 'utf-8'));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
catch (e) {
|
|
223
|
-
log.debug('에이전트 해시 맵 로드 실패', e);
|
|
224
|
-
}
|
|
225
|
-
return {};
|
|
226
|
-
}
|
|
227
|
-
function saveAgentHashes(hashes) {
|
|
228
|
-
try {
|
|
229
|
-
fs.mkdirSync(path.dirname(AGENT_HASHES_PATH), { recursive: true });
|
|
230
|
-
fs.writeFileSync(AGENT_HASHES_PATH, JSON.stringify(hashes, null, 2));
|
|
231
|
-
}
|
|
232
|
-
catch (e) {
|
|
233
|
-
log.debug('에이전트 해시 맵 저장 실패', e);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
function installAgentsFromDir(sourceDir, targetDir, prefix, hashes) {
|
|
237
|
-
if (!fs.existsSync(sourceDir))
|
|
238
|
-
return;
|
|
239
|
-
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'));
|
|
240
|
-
for (const file of files) {
|
|
241
|
-
const src = path.join(sourceDir, file);
|
|
242
|
-
const dstName = `${prefix}${file}`;
|
|
243
|
-
const dst = path.join(targetDir, dstName);
|
|
244
|
-
const content = fs.readFileSync(src, 'utf-8');
|
|
245
|
-
const newHash = contentHash(content);
|
|
246
|
-
if (fs.existsSync(dst)) {
|
|
247
|
-
const existing = fs.readFileSync(dst, 'utf-8');
|
|
248
|
-
if (existing === content) {
|
|
249
|
-
hashes[dstName] = newHash;
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
const recordedHash = hashes[dstName];
|
|
253
|
-
if (recordedHash && contentHash(existing) !== recordedHash) {
|
|
254
|
-
log.debug(`에이전트 파일 보호: ${dstName} (사용자 수정 감지)`);
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (!recordedHash && !existing.includes('<!-- forgen-managed -->')) {
|
|
258
|
-
log.debug(`에이전트 파일 보호: ${dstName} (레거시 사용자 수정 감지)`);
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
fs.writeFileSync(dst, content);
|
|
263
|
-
hashes[dstName] = newHash;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* 현재 source에 없는 stale ch-*.md 에이전트 파일을 정리.
|
|
268
|
-
* forgen-managed 마커가 있는 파일만 삭제 (사용자 수정 파일 보호).
|
|
269
|
-
*/
|
|
270
|
-
function cleanupStaleAgents(sourceDir, targetDir, prefix, hashes) {
|
|
271
|
-
if (!fs.existsSync(targetDir))
|
|
272
|
-
return;
|
|
273
|
-
if (!fs.existsSync(sourceDir))
|
|
274
|
-
return;
|
|
275
|
-
// 현재 source의 유효한 파일 목록
|
|
276
|
-
const validFiles = new Set(fs.readdirSync(sourceDir)
|
|
277
|
-
.filter((f) => f.endsWith('.md'))
|
|
278
|
-
.map((f) => `${prefix}${f}`));
|
|
279
|
-
// targetDir에서 prefix로 시작하지만 유효 목록에 없는 파일 삭제
|
|
280
|
-
for (const existing of fs.readdirSync(targetDir)) {
|
|
281
|
-
if (!existing.startsWith(prefix) || !existing.endsWith('.md'))
|
|
282
|
-
continue;
|
|
283
|
-
if (validFiles.has(existing))
|
|
284
|
-
continue;
|
|
285
|
-
const filePath = path.join(targetDir, existing);
|
|
286
|
-
try {
|
|
287
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
288
|
-
// 사용자 수정 보호: forgen-managed 마커가 있고 hash가 기록된 경우만 삭제
|
|
289
|
-
const recordedHash = hashes[existing];
|
|
290
|
-
const hasMarker = content.includes('<!-- forgen-managed -->');
|
|
291
|
-
if (!hasMarker) {
|
|
292
|
-
log.debug(`에이전트 삭제 스킵: ${existing} (forgen-managed 마커 없음)`);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (recordedHash && contentHash(content) !== recordedHash) {
|
|
296
|
-
log.debug(`에이전트 삭제 스킵: ${existing} (사용자 수정 감지)`);
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
fs.unlinkSync(filePath);
|
|
300
|
-
delete hashes[existing];
|
|
301
|
-
log.debug(`stale 에이전트 삭제: ${existing}`);
|
|
302
|
-
}
|
|
303
|
-
catch (e) {
|
|
304
|
-
log.debug(`에이전트 삭제 실패: ${existing}`, e);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
/** 에이전트 정의 파일 설치 (패키지 내장만) */
|
|
309
|
-
function installAgents(cwd) {
|
|
310
|
-
const pkgRoot = getPackageRoot();
|
|
311
|
-
const targetDir = path.join(cwd, '.claude', 'agents');
|
|
312
|
-
fs.mkdirSync(targetDir, { recursive: true });
|
|
313
|
-
const hashes = loadAgentHashes();
|
|
314
|
-
const sourceDir = path.join(pkgRoot, 'agents');
|
|
315
|
-
try {
|
|
316
|
-
installAgentsFromDir(sourceDir, targetDir, 'ch-', hashes);
|
|
317
|
-
cleanupStaleAgents(sourceDir, targetDir, 'ch-', hashes);
|
|
318
|
-
saveAgentHashes(hashes);
|
|
319
|
-
}
|
|
320
|
-
catch (e) {
|
|
321
|
-
log.debug('에이전트 설치 실패', e);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
// ── Slash Commands ──
|
|
325
|
-
function buildCommandContent(skillContent, skillName) {
|
|
326
|
-
const descMatch = skillContent.match(/description:\s*(.+)/);
|
|
327
|
-
const desc = descMatch?.[1]?.trim() ?? skillName;
|
|
328
|
-
return `# ${desc}\n\n<!-- forgen-managed -->\n\nActivate Forgen "${skillName}" mode for the task: $ARGUMENTS\n\n${skillContent}`;
|
|
329
|
-
}
|
|
330
|
-
function safeWriteCommand(cmdPath, content) {
|
|
331
|
-
if (fs.existsSync(cmdPath)) {
|
|
332
|
-
const existing = fs.readFileSync(cmdPath, 'utf-8');
|
|
333
|
-
if (!existing.includes('<!-- forgen-managed -->'))
|
|
334
|
-
return false;
|
|
335
|
-
}
|
|
336
|
-
fs.writeFileSync(cmdPath, content);
|
|
337
|
-
return true;
|
|
338
|
-
}
|
|
339
|
-
function cleanupStaleCommands(commandsDir, validFiles) {
|
|
340
|
-
if (!fs.existsSync(commandsDir))
|
|
341
|
-
return 0;
|
|
342
|
-
let removed = 0;
|
|
343
|
-
for (const file of fs.readdirSync(commandsDir).filter((f) => f.endsWith('.md'))) {
|
|
344
|
-
if (validFiles.has(file))
|
|
345
|
-
continue;
|
|
346
|
-
const filePath = path.join(commandsDir, file);
|
|
347
|
-
try {
|
|
348
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
349
|
-
if (content.includes('<!-- forgen-managed -->')) {
|
|
350
|
-
fs.unlinkSync(filePath);
|
|
351
|
-
removed++;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
catch (e) {
|
|
355
|
-
log.debug(`stale 명령 파일 정리 실패: ${file}`, e);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return removed;
|
|
359
|
-
}
|
|
360
|
-
/** 스킬을 Claude Code 슬래시 명령으로 설치 (패키지 내장만) */
|
|
361
|
-
function installSlashCommands(_cwd) {
|
|
362
|
-
const pkgRoot = getPackageRoot();
|
|
363
|
-
let skillsDir = path.join(pkgRoot, 'commands');
|
|
364
|
-
if (!fs.existsSync(skillsDir)) {
|
|
365
|
-
skillsDir = path.join(pkgRoot, 'skills');
|
|
366
|
-
}
|
|
367
|
-
const homeDir = os.homedir();
|
|
368
|
-
const globalCommandsDir = path.join(homeDir, '.claude', 'commands', 'forgen');
|
|
369
|
-
if (!fs.existsSync(skillsDir))
|
|
370
|
-
return;
|
|
371
|
-
fs.mkdirSync(globalCommandsDir, { recursive: true });
|
|
372
|
-
const skills = fs.readdirSync(skillsDir).filter((f) => f.endsWith('.md'));
|
|
373
|
-
const validGlobalFiles = new Set();
|
|
374
|
-
let installed = 0;
|
|
375
|
-
for (const file of skills) {
|
|
376
|
-
validGlobalFiles.add(file);
|
|
377
|
-
const skillName = file.replace('.md', '');
|
|
378
|
-
const skillContent = fs.readFileSync(path.join(skillsDir, file), 'utf-8');
|
|
379
|
-
const cmdContent = buildCommandContent(skillContent, skillName);
|
|
380
|
-
if (safeWriteCommand(path.join(globalCommandsDir, file), cmdContent)) {
|
|
381
|
-
installed++;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
const removedGlobal = validGlobalFiles.size > 0
|
|
385
|
-
? cleanupStaleCommands(globalCommandsDir, validGlobalFiles)
|
|
386
|
-
: 0;
|
|
387
|
-
log.debug(`슬래시 명령 설치: ${installed}개 설치, ${removedGlobal}개 정리`);
|
|
388
|
-
}
|
|
389
61
|
// ── Rule File Injection ──
|
|
390
62
|
function injectClaudeRuleFiles(cwd, ruleFiles) {
|
|
391
63
|
const PER_RULE_CAP = RULE_FILE_CAPS.perRuleFile;
|
|
@@ -633,6 +305,14 @@ export async function prepareHarness(cwd, options = {}) {
|
|
|
633
305
|
if (v1Result.session) {
|
|
634
306
|
const { session } = v1Result;
|
|
635
307
|
log.debug(`v1 세션 시작: ${session.quality_pack}/${session.autonomy_pack}, trust=${session.effective_trust_policy}`);
|
|
308
|
+
// Audit fix #3 (2026-04-21): trust 에스컬레이션(runtime > desired) 경고를
|
|
309
|
+
// 사용자에게 명시적으로 노출 — 이전엔 session.warnings에만 저장되고
|
|
310
|
+
// 출력되지 않아 silent escalation이 됐다.
|
|
311
|
+
for (const w of session.warnings ?? []) {
|
|
312
|
+
if (w.includes('Trust 상승')) {
|
|
313
|
+
console.warn(` ⚠ ${w}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
636
316
|
for (const w of session.warnings) {
|
|
637
317
|
// mismatch 경고는 사용자에게 직접 표시
|
|
638
318
|
if (w.includes('mismatch')) {
|
|
@@ -646,16 +326,32 @@ export async function prepareHarness(cwd, options = {}) {
|
|
|
646
326
|
}
|
|
647
327
|
// 3. 환경 확인
|
|
648
328
|
const inTmux = !!process.env.TMUX;
|
|
649
|
-
// 4. Claude Code 설정 주입 (환경변수 + trust 기반 permissions)
|
|
329
|
+
// 4. Claude Code 설정 주입 (환경변수 + trust 기반 permissions).
|
|
330
|
+
//
|
|
331
|
+
// Audit fix #1 (2026-04-21): acquireLock에서 live holder 감지 시
|
|
332
|
+
// SettingsLockError가 throw될 수 있다. 사용자 작업 자체를 실패시키지
|
|
333
|
+
// 않도록 warn 후 계속 진행 (이번 실행에서 settings는 기존 값 유지).
|
|
334
|
+
const pkgRoot = getPackageRoot();
|
|
650
335
|
const env = buildEnv(cwd, v1Result.session?.session_id, runtime);
|
|
651
|
-
|
|
336
|
+
try {
|
|
337
|
+
injectSettings(env, v1Result, runtime, cwd, pkgRoot);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
341
|
+
if (msg.includes('settings.json lock') || msg.includes('SettingsLockError')) {
|
|
342
|
+
console.error(`[forgen] ${msg} — settings 갱신 스킵, 이전 값 유지`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
throw e;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
652
348
|
// 5. 에이전트 설치
|
|
653
|
-
installAgents(cwd);
|
|
349
|
+
installAgents(cwd, pkgRoot);
|
|
654
350
|
// 6. 규칙 파일 생성 및 주입 (v1 부트스트랩 결과의 renderedRules를 직접 전달)
|
|
655
351
|
const ruleFiles = generateClaudeRuleFiles(cwd, v1Result.renderedRules);
|
|
656
352
|
injectClaudeRuleFiles(cwd, ruleFiles);
|
|
657
353
|
// 7. 슬래시 명령 설치
|
|
658
|
-
installSlashCommands(cwd);
|
|
354
|
+
installSlashCommands(cwd, pkgRoot);
|
|
659
355
|
// 8. tmux 바인딩 등록
|
|
660
356
|
if (inTmux) {
|
|
661
357
|
await registerTmuxBindings();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer — Agent definitions + slash command installation
|
|
3
|
+
*
|
|
4
|
+
* Extracted from harness.ts (B9 decomposition).
|
|
5
|
+
* Handles copying agent .md files and skill commands into Claude Code directories.
|
|
6
|
+
*/
|
|
7
|
+
/** 에이전트 정의 파일 설치 (패키지 내장만) */
|
|
8
|
+
export declare function installAgents(cwd: string, pkgRoot: string): void;
|
|
9
|
+
/** 스킬을 Claude Code 슬래시 명령으로 설치 (패키지 내장만) */
|
|
10
|
+
export declare function installSlashCommands(_cwd: string, pkgRoot: string): void;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer — Agent definitions + slash command installation
|
|
3
|
+
*
|
|
4
|
+
* Extracted from harness.ts (B9 decomposition).
|
|
5
|
+
* Handles copying agent .md files and skill commands into Claude Code directories.
|
|
6
|
+
*/
|
|
7
|
+
import * as crypto from 'node:crypto';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import { createLogger } from './logger.js';
|
|
12
|
+
import { STATE_DIR } from './paths.js';
|
|
13
|
+
const log = createLogger('installer');
|
|
14
|
+
// ── Agent Installation ──
|
|
15
|
+
function contentHash(content) {
|
|
16
|
+
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 12);
|
|
17
|
+
}
|
|
18
|
+
const AGENT_HASHES_PATH = path.join(STATE_DIR, 'agent-hashes.json');
|
|
19
|
+
function loadAgentHashes() {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(AGENT_HASHES_PATH)) {
|
|
22
|
+
return JSON.parse(fs.readFileSync(AGENT_HASHES_PATH, 'utf-8'));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
log.debug('에이전트 해시 맵 로드 실패', e);
|
|
27
|
+
}
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
function saveAgentHashes(hashes) {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(path.dirname(AGENT_HASHES_PATH), { recursive: true });
|
|
33
|
+
fs.writeFileSync(AGENT_HASHES_PATH, JSON.stringify(hashes, null, 2));
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
log.debug('에이전트 해시 맵 저장 실패', e);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function installAgentsFromDir(sourceDir, targetDir, prefix, hashes) {
|
|
40
|
+
if (!fs.existsSync(sourceDir))
|
|
41
|
+
return;
|
|
42
|
+
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'));
|
|
43
|
+
for (const file of files) {
|
|
44
|
+
const src = path.join(sourceDir, file);
|
|
45
|
+
const dstName = `${prefix}${file}`;
|
|
46
|
+
const dst = path.join(targetDir, dstName);
|
|
47
|
+
const content = fs.readFileSync(src, 'utf-8');
|
|
48
|
+
const newHash = contentHash(content);
|
|
49
|
+
if (fs.existsSync(dst)) {
|
|
50
|
+
const existing = fs.readFileSync(dst, 'utf-8');
|
|
51
|
+
if (existing === content) {
|
|
52
|
+
hashes[dstName] = newHash;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const recordedHash = hashes[dstName];
|
|
56
|
+
if (recordedHash && contentHash(existing) !== recordedHash) {
|
|
57
|
+
log.debug(`에이전트 파일 보호: ${dstName} (사용자 수정 감지)`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!recordedHash && !existing.includes('<!-- forgen-managed -->')) {
|
|
61
|
+
log.debug(`에이전트 파일 보호: ${dstName} (레거시 사용자 수정 감지)`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
fs.writeFileSync(dst, content);
|
|
66
|
+
hashes[dstName] = newHash;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 현재 source에 없는 stale ch-*.md 에이전트 파일을 정리.
|
|
71
|
+
* forgen-managed 마커가 있는 파일만 삭제 (사용자 수정 파일 보호).
|
|
72
|
+
*/
|
|
73
|
+
function cleanupStaleAgents(sourceDir, targetDir, prefix, hashes) {
|
|
74
|
+
if (!fs.existsSync(targetDir))
|
|
75
|
+
return;
|
|
76
|
+
if (!fs.existsSync(sourceDir))
|
|
77
|
+
return;
|
|
78
|
+
const validFiles = new Set(fs
|
|
79
|
+
.readdirSync(sourceDir)
|
|
80
|
+
.filter((f) => f.endsWith('.md'))
|
|
81
|
+
.map((f) => `${prefix}${f}`));
|
|
82
|
+
for (const existing of fs.readdirSync(targetDir)) {
|
|
83
|
+
if (!existing.startsWith(prefix) || !existing.endsWith('.md'))
|
|
84
|
+
continue;
|
|
85
|
+
if (validFiles.has(existing))
|
|
86
|
+
continue;
|
|
87
|
+
const filePath = path.join(targetDir, existing);
|
|
88
|
+
try {
|
|
89
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
90
|
+
const recordedHash = hashes[existing];
|
|
91
|
+
const hasMarker = content.includes('<!-- forgen-managed -->');
|
|
92
|
+
if (!hasMarker) {
|
|
93
|
+
log.debug(`에이전트 삭제 스킵: ${existing} (forgen-managed 마커 없음)`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (recordedHash && contentHash(content) !== recordedHash) {
|
|
97
|
+
log.debug(`에이전트 삭제 스킵: ${existing} (사용자 수정 감지)`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
fs.unlinkSync(filePath);
|
|
101
|
+
delete hashes[existing];
|
|
102
|
+
log.debug(`stale 에이전트 삭제: ${existing}`);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
log.debug(`에이전트 삭제 실패: ${existing}`, e);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** 에이전트 정의 파일 설치 (패키지 내장만) */
|
|
110
|
+
export function installAgents(cwd, pkgRoot) {
|
|
111
|
+
const targetDir = path.join(cwd, '.claude', 'agents');
|
|
112
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
113
|
+
const hashes = loadAgentHashes();
|
|
114
|
+
const sourceDir = path.join(pkgRoot, 'agents');
|
|
115
|
+
try {
|
|
116
|
+
installAgentsFromDir(sourceDir, targetDir, 'ch-', hashes);
|
|
117
|
+
cleanupStaleAgents(sourceDir, targetDir, 'ch-', hashes);
|
|
118
|
+
saveAgentHashes(hashes);
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
log.debug('에이전트 설치 실패', e);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ── Slash Commands ──
|
|
125
|
+
function buildCommandContent(skillContent, skillName) {
|
|
126
|
+
const descMatch = skillContent.match(/description:\s*(.+)/);
|
|
127
|
+
const desc = descMatch?.[1]?.trim() ?? skillName;
|
|
128
|
+
return `# ${desc}\n\n<!-- forgen-managed -->\n\nActivate Forgen "${skillName}" mode for the task: $ARGUMENTS\n\n${skillContent}`;
|
|
129
|
+
}
|
|
130
|
+
function safeWriteCommand(cmdPath, content) {
|
|
131
|
+
if (fs.existsSync(cmdPath)) {
|
|
132
|
+
const existing = fs.readFileSync(cmdPath, 'utf-8');
|
|
133
|
+
if (!existing.includes('<!-- forgen-managed -->'))
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
fs.writeFileSync(cmdPath, content);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
function cleanupStaleCommands(commandsDir, validFiles) {
|
|
140
|
+
if (!fs.existsSync(commandsDir))
|
|
141
|
+
return 0;
|
|
142
|
+
let removed = 0;
|
|
143
|
+
for (const file of fs.readdirSync(commandsDir).filter((f) => f.endsWith('.md'))) {
|
|
144
|
+
if (validFiles.has(file))
|
|
145
|
+
continue;
|
|
146
|
+
const filePath = path.join(commandsDir, file);
|
|
147
|
+
try {
|
|
148
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
149
|
+
if (content.includes('<!-- forgen-managed -->')) {
|
|
150
|
+
fs.unlinkSync(filePath);
|
|
151
|
+
removed++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
log.debug(`stale 명령 파일 정리 실패: ${file}`, e);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return removed;
|
|
159
|
+
}
|
|
160
|
+
/** 스킬을 Claude Code 슬래시 명령으로 설치 (패키지 내장만) */
|
|
161
|
+
export function installSlashCommands(_cwd, pkgRoot) {
|
|
162
|
+
let skillsDir = path.join(pkgRoot, 'commands');
|
|
163
|
+
if (!fs.existsSync(skillsDir)) {
|
|
164
|
+
skillsDir = path.join(pkgRoot, 'skills');
|
|
165
|
+
}
|
|
166
|
+
const homeDir = os.homedir();
|
|
167
|
+
const globalCommandsDir = path.join(homeDir, '.claude', 'commands', 'forgen');
|
|
168
|
+
if (!fs.existsSync(skillsDir))
|
|
169
|
+
return;
|
|
170
|
+
fs.mkdirSync(globalCommandsDir, { recursive: true });
|
|
171
|
+
const skills = fs.readdirSync(skillsDir).filter((f) => f.endsWith('.md'));
|
|
172
|
+
const validGlobalFiles = new Set();
|
|
173
|
+
let installed = 0;
|
|
174
|
+
for (const file of skills) {
|
|
175
|
+
validGlobalFiles.add(file);
|
|
176
|
+
const skillName = file.replace('.md', '');
|
|
177
|
+
const skillContent = fs.readFileSync(path.join(skillsDir, file), 'utf-8');
|
|
178
|
+
const cmdContent = buildCommandContent(skillContent, skillName);
|
|
179
|
+
if (safeWriteCommand(path.join(globalCommandsDir, file), cmdContent)) {
|
|
180
|
+
installed++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const removedGlobal = validGlobalFiles.size > 0 ? cleanupStaleCommands(globalCommandsDir, validGlobalFiles) : 0;
|
|
184
|
+
log.debug(`슬래시 명령 설치: ${installed}개 설치, ${removedGlobal}개 정리`);
|
|
185
|
+
}
|