@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.
- package/.claude-plugin/plugin.json +7 -2
- package/CHANGELOG.md +100 -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/dist/cli.js +3 -3
- package/dist/core/auto-compound-runner.js +6 -3
- package/dist/core/dashboard.js +11 -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 +0 -34
- package/dist/core/paths.js +0 -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.js +52 -0
- package/dist/engine/match-eval-log.js +45 -0
- package/dist/engine/solution-format.d.ts +0 -2
- package/dist/engine/solution-format.js +0 -4
- package/dist/engine/solution-matcher.d.ts +8 -0
- package/dist/engine/solution-matcher.js +7 -4
- package/dist/engine/solution-outcomes.d.ts +4 -0
- package/dist/engine/solution-outcomes.js +174 -97
- package/dist/engine/solution-writer.d.ts +8 -5
- package/dist/engine/solution-writer.js +43 -19
- 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/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 +60 -1
- package/dist/mcp/solution-reader.d.ts +2 -0
- package/dist/mcp/solution-reader.js +28 -1
- package/dist/mcp/tools.js +5 -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
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
|
+
}
|
package/dist/core/paths.d.ts
CHANGED
|
@@ -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/ 통합) */
|
|
@@ -73,36 +57,18 @@ export declare const ARCHIVED_DIR: string;
|
|
|
73
57
|
export declare const SESSIONS_DIR: string;
|
|
74
58
|
/** ~/.forgen/config.json — 글로벌 설정 */
|
|
75
59
|
export declare const GLOBAL_CONFIG: string;
|
|
76
|
-
/** ~/.forgen/state/session-quality/ — 세션 품질 점수 */
|
|
77
|
-
export declare const SESSION_QUALITY_DIR: string;
|
|
78
60
|
/** ~/.forgen/state/meta-learning/ — 메타학습 상태 파일 */
|
|
79
61
|
export declare const META_LEARNING_DIR: string;
|
|
80
62
|
/** ~/.forgen/lab/ — Lab 적응형 최적화 엔진 데이터 */
|
|
81
63
|
export declare const LAB_DIR: string;
|
|
82
|
-
/** ~/.forgen/lab/events.jsonl — Lab 이벤트 로그 (JSONL) */
|
|
83
|
-
export declare const LAB_EVENTS: string;
|
|
84
64
|
/** ~/.forgen/me/forge-profile.json — 글로벌 Forge 프로필 */
|
|
85
65
|
export declare const FORGE_PROFILE: string;
|
|
86
|
-
/** @deprecated use ME_DIR */
|
|
87
|
-
export declare const V1_ME_DIR: string;
|
|
88
|
-
/** @deprecated use FORGE_PROFILE */
|
|
89
|
-
export declare const V1_PROFILE: string;
|
|
90
|
-
/** @deprecated use ME_RULES */
|
|
91
|
-
export declare const V1_RULES_DIR: string;
|
|
92
|
-
/** @deprecated use ME_BEHAVIOR */
|
|
93
|
-
export declare const V1_EVIDENCE_DIR: string;
|
|
94
66
|
/** ~/.forgen/me/recommendations/ — Pack Recommendation */
|
|
95
67
|
export declare const V1_RECOMMENDATIONS_DIR: string;
|
|
96
|
-
/** @deprecated use ME_SOLUTIONS */
|
|
97
|
-
export declare const V1_SOLUTIONS_DIR: string;
|
|
98
|
-
/** @deprecated use STATE_DIR */
|
|
99
|
-
export declare const V1_STATE_DIR: string;
|
|
100
68
|
/** ~/.forgen/state/sessions/ — Session Effective State */
|
|
101
69
|
export declare const V1_SESSIONS_DIR: string;
|
|
102
70
|
/** ~/.forgen/state/raw-logs/ — Raw Log */
|
|
103
71
|
export declare const V1_RAW_LOGS_DIR: string;
|
|
104
|
-
/** @deprecated use GLOBAL_CONFIG */
|
|
105
|
-
export declare const V1_GLOBAL_CONFIG: string;
|
|
106
72
|
/** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
|
|
107
73
|
export declare const ALL_MODES: readonly ["ralph", "autopilot", "ultrawork", "team", "pipeline", "ccg", "ralplan", "deep-interview", "forge-loop", "ship", "retro", "learn", "calibrate"];
|
|
108
74
|
/** {repo}/.compound/ — 프로젝트 로컬 디렉토리 */
|