@wooojin/forgen 0.1.0
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 +20 -0
- package/CHANGELOG.md +353 -0
- package/CONTRIBUTING.md +98 -0
- package/LICENSE +21 -0
- package/README.ja.md +469 -0
- package/README.ko.md +469 -0
- package/README.md +483 -0
- package/README.zh.md +469 -0
- package/agents/analyst.md +98 -0
- package/agents/architect.md +62 -0
- package/agents/code-reviewer.md +120 -0
- package/agents/code-simplifier.md +197 -0
- package/agents/critic.md +70 -0
- package/agents/debugger.md +117 -0
- package/agents/designer.md +131 -0
- package/agents/executor.md +54 -0
- package/agents/explore.md +145 -0
- package/agents/git-master.md +212 -0
- package/agents/performance-reviewer.md +172 -0
- package/agents/planner.md +29 -0
- package/agents/qa-tester.md +158 -0
- package/agents/refactoring-expert.md +168 -0
- package/agents/scientist.md +144 -0
- package/agents/security-reviewer.md +137 -0
- package/agents/test-engineer.md +153 -0
- package/agents/verifier.md +133 -0
- package/agents/writer.md +184 -0
- package/commands/api-design.md +268 -0
- package/commands/architecture-decision.md +314 -0
- package/commands/ci-cd.md +270 -0
- package/commands/code-review.md +233 -0
- package/commands/compound.md +117 -0
- package/commands/database.md +263 -0
- package/commands/debug-detective.md +99 -0
- package/commands/docker.md +274 -0
- package/commands/documentation.md +276 -0
- package/commands/ecomode.md +51 -0
- package/commands/frontend.md +271 -0
- package/commands/git-master.md +90 -0
- package/commands/incident-response.md +292 -0
- package/commands/migrate.md +101 -0
- package/commands/performance.md +288 -0
- package/commands/refactor.md +105 -0
- package/commands/security-review.md +288 -0
- package/commands/tdd.md +183 -0
- package/commands/testing-strategy.md +265 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +295 -0
- package/dist/core/auto-compound-runner.d.ts +12 -0
- package/dist/core/auto-compound-runner.js +460 -0
- package/dist/core/config-hooks.d.ts +10 -0
- package/dist/core/config-hooks.js +112 -0
- package/dist/core/config-injector.d.ts +50 -0
- package/dist/core/config-injector.js +455 -0
- package/dist/core/doctor.d.ts +1 -0
- package/dist/core/doctor.js +163 -0
- package/dist/core/errors.d.ts +81 -0
- package/dist/core/errors.js +133 -0
- package/dist/core/global-config.d.ts +43 -0
- package/dist/core/global-config.js +25 -0
- package/dist/core/harness.d.ts +24 -0
- package/dist/core/harness.js +621 -0
- package/dist/core/init.d.ts +7 -0
- package/dist/core/init.js +37 -0
- package/dist/core/inspect-cli.d.ts +7 -0
- package/dist/core/inspect-cli.js +47 -0
- package/dist/core/legacy-detector.d.ts +33 -0
- package/dist/core/legacy-detector.js +66 -0
- package/dist/core/logger.d.ts +34 -0
- package/dist/core/logger.js +121 -0
- package/dist/core/mcp-config.d.ts +44 -0
- package/dist/core/mcp-config.js +177 -0
- package/dist/core/notepad.d.ts +31 -0
- package/dist/core/notepad.js +88 -0
- package/dist/core/paths.d.ts +85 -0
- package/dist/core/paths.js +101 -0
- package/dist/core/plugin-detector.d.ts +44 -0
- package/dist/core/plugin-detector.js +226 -0
- package/dist/core/runtime-detector.d.ts +8 -0
- package/dist/core/runtime-detector.js +49 -0
- package/dist/core/scope-resolver.d.ts +8 -0
- package/dist/core/scope-resolver.js +45 -0
- package/dist/core/session-logger.d.ts +6 -0
- package/dist/core/session-logger.js +111 -0
- package/dist/core/session-store.d.ts +28 -0
- package/dist/core/session-store.js +218 -0
- package/dist/core/settings-lock.d.ts +18 -0
- package/dist/core/settings-lock.js +125 -0
- package/dist/core/spawn.d.ts +3 -0
- package/dist/core/spawn.js +135 -0
- package/dist/core/types.d.ts +108 -0
- package/dist/core/types.js +1 -0
- package/dist/core/uninstall.d.ts +4 -0
- package/dist/core/uninstall.js +307 -0
- package/dist/core/v1-bootstrap.d.ts +26 -0
- package/dist/core/v1-bootstrap.js +155 -0
- package/dist/engine/compound-cli.d.ts +24 -0
- package/dist/engine/compound-cli.js +250 -0
- package/dist/engine/compound-extractor.d.ts +68 -0
- package/dist/engine/compound-extractor.js +860 -0
- package/dist/engine/compound-lifecycle.d.ts +32 -0
- package/dist/engine/compound-lifecycle.js +305 -0
- package/dist/engine/compound-loop.d.ts +32 -0
- package/dist/engine/compound-loop.js +511 -0
- package/dist/engine/match-eval-log.d.ts +139 -0
- package/dist/engine/match-eval-log.js +270 -0
- package/dist/engine/phrase-blocklist.d.ts +119 -0
- package/dist/engine/phrase-blocklist.js +208 -0
- package/dist/engine/skill-promoter.d.ts +20 -0
- package/dist/engine/skill-promoter.js +115 -0
- package/dist/engine/solution-format.d.ts +160 -0
- package/dist/engine/solution-format.js +432 -0
- package/dist/engine/solution-index.d.ts +13 -0
- package/dist/engine/solution-index.js +252 -0
- package/dist/engine/solution-matcher.d.ts +364 -0
- package/dist/engine/solution-matcher.js +656 -0
- package/dist/engine/solution-writer.d.ts +76 -0
- package/dist/engine/solution-writer.js +157 -0
- package/dist/engine/term-matcher.d.ts +81 -0
- package/dist/engine/term-matcher.js +268 -0
- package/dist/engine/term-normalizer.d.ts +116 -0
- package/dist/engine/term-normalizer.js +171 -0
- package/dist/fgx.d.ts +6 -0
- package/dist/fgx.js +42 -0
- package/dist/forge/cli.d.ts +11 -0
- package/dist/forge/cli.js +100 -0
- package/dist/forge/evidence-processor.d.ts +21 -0
- package/dist/forge/evidence-processor.js +87 -0
- package/dist/forge/mismatch-detector.d.ts +44 -0
- package/dist/forge/mismatch-detector.js +83 -0
- package/dist/forge/onboarding-cli.d.ts +6 -0
- package/dist/forge/onboarding-cli.js +89 -0
- package/dist/forge/onboarding.d.ts +25 -0
- package/dist/forge/onboarding.js +122 -0
- package/dist/hooks/compound-reflection.d.ts +45 -0
- package/dist/hooks/compound-reflection.js +82 -0
- package/dist/hooks/context-guard.d.ts +24 -0
- package/dist/hooks/context-guard.js +156 -0
- package/dist/hooks/dangerous-patterns.json +18 -0
- package/dist/hooks/db-guard.d.ts +17 -0
- package/dist/hooks/db-guard.js +105 -0
- package/dist/hooks/hook-config.d.ts +29 -0
- package/dist/hooks/hook-config.js +92 -0
- package/dist/hooks/hook-registry.d.ts +43 -0
- package/dist/hooks/hook-registry.js +31 -0
- package/dist/hooks/hooks-generator.d.ts +49 -0
- package/dist/hooks/hooks-generator.js +99 -0
- package/dist/hooks/intent-classifier.d.ts +12 -0
- package/dist/hooks/intent-classifier.js +62 -0
- package/dist/hooks/keyword-detector.d.ts +25 -0
- package/dist/hooks/keyword-detector.js +389 -0
- package/dist/hooks/notepad-injector.d.ts +18 -0
- package/dist/hooks/notepad-injector.js +51 -0
- package/dist/hooks/permission-handler.d.ts +14 -0
- package/dist/hooks/permission-handler.js +114 -0
- package/dist/hooks/post-tool-failure.d.ts +11 -0
- package/dist/hooks/post-tool-failure.js +118 -0
- package/dist/hooks/post-tool-handlers.d.ts +17 -0
- package/dist/hooks/post-tool-handlers.js +115 -0
- package/dist/hooks/post-tool-use.d.ts +29 -0
- package/dist/hooks/post-tool-use.js +151 -0
- package/dist/hooks/pre-compact.d.ts +10 -0
- package/dist/hooks/pre-compact.js +165 -0
- package/dist/hooks/pre-tool-use.d.ts +31 -0
- package/dist/hooks/pre-tool-use.js +325 -0
- package/dist/hooks/prompt-injection-filter.d.ts +56 -0
- package/dist/hooks/prompt-injection-filter.js +287 -0
- package/dist/hooks/rate-limiter.d.ts +21 -0
- package/dist/hooks/rate-limiter.js +86 -0
- package/dist/hooks/secret-filter.d.ts +14 -0
- package/dist/hooks/secret-filter.js +65 -0
- package/dist/hooks/session-recovery.d.ts +27 -0
- package/dist/hooks/session-recovery.js +406 -0
- package/dist/hooks/shared/atomic-write.d.ts +41 -0
- package/dist/hooks/shared/atomic-write.js +148 -0
- package/dist/hooks/shared/context-budget.d.ts +37 -0
- package/dist/hooks/shared/context-budget.js +45 -0
- package/dist/hooks/shared/file-lock.d.ts +56 -0
- package/dist/hooks/shared/file-lock.js +253 -0
- package/dist/hooks/shared/hook-response.d.ts +33 -0
- package/dist/hooks/shared/hook-response.js +62 -0
- package/dist/hooks/shared/injection-caps.d.ts +39 -0
- package/dist/hooks/shared/injection-caps.js +52 -0
- package/dist/hooks/shared/plugin-signal.d.ts +23 -0
- package/dist/hooks/shared/plugin-signal.js +104 -0
- package/dist/hooks/shared/read-stdin.d.ts +8 -0
- package/dist/hooks/shared/read-stdin.js +63 -0
- package/dist/hooks/shared/sanitize-id.d.ts +7 -0
- package/dist/hooks/shared/sanitize-id.js +9 -0
- package/dist/hooks/shared/sanitize.d.ts +7 -0
- package/dist/hooks/shared/sanitize.js +22 -0
- package/dist/hooks/skill-injector.d.ts +38 -0
- package/dist/hooks/skill-injector.js +285 -0
- package/dist/hooks/slop-detector.d.ts +18 -0
- package/dist/hooks/slop-detector.js +93 -0
- package/dist/hooks/solution-injector.d.ts +58 -0
- package/dist/hooks/solution-injector.js +436 -0
- package/dist/hooks/subagent-tracker.d.ts +10 -0
- package/dist/hooks/subagent-tracker.js +90 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +224 -0
- package/dist/lib.d.ts +14 -0
- package/dist/lib.js +14 -0
- package/dist/mcp/server.d.ts +8 -0
- package/dist/mcp/server.js +40 -0
- package/dist/mcp/solution-reader.d.ts +90 -0
- package/dist/mcp/solution-reader.js +273 -0
- package/dist/mcp/tools.d.ts +16 -0
- package/dist/mcp/tools.js +302 -0
- package/dist/preset/facet-catalog.d.ts +17 -0
- package/dist/preset/facet-catalog.js +46 -0
- package/dist/preset/preset-manager.d.ts +31 -0
- package/dist/preset/preset-manager.js +111 -0
- package/dist/renderer/inspect-renderer.d.ts +11 -0
- package/dist/renderer/inspect-renderer.js +123 -0
- package/dist/renderer/rule-renderer.d.ts +18 -0
- package/dist/renderer/rule-renderer.js +159 -0
- package/dist/store/evidence-store.d.ts +23 -0
- package/dist/store/evidence-store.js +58 -0
- package/dist/store/profile-store.d.ts +12 -0
- package/dist/store/profile-store.js +53 -0
- package/dist/store/recommendation-store.d.ts +22 -0
- package/dist/store/recommendation-store.js +64 -0
- package/dist/store/rule-store.d.ts +22 -0
- package/dist/store/rule-store.js +62 -0
- package/dist/store/session-state-store.d.ts +11 -0
- package/dist/store/session-state-store.js +44 -0
- package/dist/store/types.d.ts +159 -0
- package/dist/store/types.js +7 -0
- package/hooks/hook-registry.json +21 -0
- package/hooks/hooks.json +185 -0
- package/package.json +89 -0
- package/plugin.json +20 -0
- package/scripts/postinstall.js +826 -0
- package/skills/api-design/SKILL.md +262 -0
- package/skills/architecture-decision/SKILL.md +309 -0
- package/skills/ci-cd/SKILL.md +264 -0
- package/skills/code-review/SKILL.md +228 -0
- package/skills/compound/SKILL.md +101 -0
- package/skills/database/SKILL.md +257 -0
- package/skills/debug-detective/SKILL.md +95 -0
- package/skills/docker/SKILL.md +268 -0
- package/skills/documentation/SKILL.md +270 -0
- package/skills/ecomode/SKILL.md +46 -0
- package/skills/frontend/SKILL.md +265 -0
- package/skills/git-master/SKILL.md +86 -0
- package/skills/incident-response/SKILL.md +286 -0
- package/skills/migrate/SKILL.md +96 -0
- package/skills/performance/SKILL.md +282 -0
- package/skills/refactor/SKILL.md +100 -0
- package/skills/security-review/SKILL.md +282 -0
- package/skills/tdd/SKILL.md +178 -0
- package/skills/testing-strategy/SKILL.md +260 -0
- package/starter-pack/solutions/starter-api-error-responses.md +37 -0
- package/starter-pack/solutions/starter-async-patterns.md +40 -0
- package/starter-pack/solutions/starter-caching-strategy.md +40 -0
- package/starter-pack/solutions/starter-code-review-checklist.md +39 -0
- package/starter-pack/solutions/starter-debugging-systematic.md +40 -0
- package/starter-pack/solutions/starter-dependency-injection.md +40 -0
- package/starter-pack/solutions/starter-error-handling-patterns.md +38 -0
- package/starter-pack/solutions/starter-git-atomic-commits.md +36 -0
- package/starter-pack/solutions/starter-input-validation.md +40 -0
- package/starter-pack/solutions/starter-n-plus-one-queries.md +37 -0
- package/starter-pack/solutions/starter-refactor-safely.md +38 -0
- package/starter-pack/solutions/starter-secret-management.md +37 -0
- package/starter-pack/solutions/starter-separation-of-concerns.md +36 -0
- package/starter-pack/solutions/starter-tdd-red-green-refactor.md +40 -0
- package/starter-pack/solutions/starter-typescript-strict-types.md +39 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — PreToolUse: Rate Limiter Hook
|
|
4
|
+
*
|
|
5
|
+
* MCP 도구 호출 빈도를 제한하여 남용을 방지합니다.
|
|
6
|
+
* 기본 제한: 30회/분
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { readStdinJSON } from './shared/read-stdin.js';
|
|
11
|
+
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
12
|
+
import { isHookEnabled } from './hook-config.js';
|
|
13
|
+
import { approve, deny, failOpen } from './shared/hook-response.js';
|
|
14
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
15
|
+
const RATE_LIMIT_PATH = path.join(STATE_DIR, 'rate-limit.json');
|
|
16
|
+
const DEFAULT_LIMIT = 30; // calls per minute
|
|
17
|
+
const WINDOW_MS = 60_000; // 1 minute
|
|
18
|
+
/** 상태 파일 로드 (스키마 검증 포함) */
|
|
19
|
+
export function loadRateLimitState() {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(RATE_LIMIT_PATH)) {
|
|
22
|
+
const raw = JSON.parse(fs.readFileSync(RATE_LIMIT_PATH, 'utf-8'));
|
|
23
|
+
// 스키마 검증: calls가 number 배열이어야 함
|
|
24
|
+
if (raw && Array.isArray(raw.calls) && raw.calls.every((c) => typeof c === 'number')) {
|
|
25
|
+
return raw;
|
|
26
|
+
}
|
|
27
|
+
// 손상된 상태 → 초기화
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch { /* rate limit state parse failure — starting fresh, window resets (fail-open is safe here) */ }
|
|
31
|
+
return { calls: [] };
|
|
32
|
+
}
|
|
33
|
+
/** 상태 파일 저장 (atomic write로 동시 세션 안전) */
|
|
34
|
+
export function saveRateLimitState(state) {
|
|
35
|
+
atomicWriteJSON(RATE_LIMIT_PATH, state);
|
|
36
|
+
}
|
|
37
|
+
/** 오래된 호출 기록 정리 + 제한 초과 여부 판정 (순수 함수) */
|
|
38
|
+
export function checkRateLimit(state, now = Date.now(), limit = DEFAULT_LIMIT) {
|
|
39
|
+
// 1분 이전 호출 제거
|
|
40
|
+
const cutoff = now - WINDOW_MS;
|
|
41
|
+
const recentCalls = state.calls.filter(t => t > cutoff);
|
|
42
|
+
// 초과 여부 먼저 판정 (현재 호출 추가 전 기준)
|
|
43
|
+
const exceeded = recentCalls.length >= limit;
|
|
44
|
+
// 거부된 호출은 윈도우에 추가하지 않음 — 승인된 호출만 기록
|
|
45
|
+
if (!exceeded) {
|
|
46
|
+
recentCalls.push(now);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
exceeded,
|
|
50
|
+
count: recentCalls.length + (exceeded ? 1 : 0),
|
|
51
|
+
updatedState: { calls: recentCalls },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function main() {
|
|
55
|
+
const data = await readStdinJSON(1500); // Must finish within plugin.json timeout (2000ms)
|
|
56
|
+
if (!data) {
|
|
57
|
+
// stdin 파싱 실패 — 통과 (rate limiter는 fail-open)
|
|
58
|
+
console.log(failOpen());
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (!isHookEnabled('rate-limiter')) {
|
|
62
|
+
console.log(approve());
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
66
|
+
// MCP 도구만 추적 (mcp__ 접두사)
|
|
67
|
+
if (!toolName.startsWith('mcp__')) {
|
|
68
|
+
console.log(approve());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const state = loadRateLimitState();
|
|
72
|
+
const { exceeded, count, updatedState } = checkRateLimit(state);
|
|
73
|
+
// 거부된 호출은 상태를 저장하지 않음 (윈도우 누적 방지)
|
|
74
|
+
if (!exceeded) {
|
|
75
|
+
saveRateLimitState(updatedState);
|
|
76
|
+
}
|
|
77
|
+
if (exceeded) {
|
|
78
|
+
console.log(deny(`[Forgen] Rate limit exceeded (${count}/${DEFAULT_LIMIT}/min). Wait before retrying.`));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
console.log(approve());
|
|
82
|
+
}
|
|
83
|
+
main().catch((e) => {
|
|
84
|
+
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
85
|
+
console.log(failOpen());
|
|
86
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — PostToolUse: Secret Filter Hook
|
|
4
|
+
*
|
|
5
|
+
* 도구 실행 결과에서 API 키, 토큰, 비밀번호 등 민감 정보 노출을 감지합니다.
|
|
6
|
+
* 차단하지 않고 경고 메시지만 출력합니다.
|
|
7
|
+
*/
|
|
8
|
+
export interface SecretPattern {
|
|
9
|
+
name: string;
|
|
10
|
+
pattern: RegExp;
|
|
11
|
+
}
|
|
12
|
+
export declare const SECRET_PATTERNS: SecretPattern[];
|
|
13
|
+
/** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
|
|
14
|
+
export declare function detectSecrets(text: string): SecretPattern[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — PostToolUse: Secret Filter Hook
|
|
4
|
+
*
|
|
5
|
+
* 도구 실행 결과에서 API 키, 토큰, 비밀번호 등 민감 정보 노출을 감지합니다.
|
|
6
|
+
* 차단하지 않고 경고 메시지만 출력합니다.
|
|
7
|
+
*/
|
|
8
|
+
import { HookError } from '../core/errors.js';
|
|
9
|
+
import { readStdinJSON } from './shared/read-stdin.js';
|
|
10
|
+
import { isHookEnabled } from './hook-config.js';
|
|
11
|
+
import { approve, approveWithWarning, failOpen } from './shared/hook-response.js';
|
|
12
|
+
export const SECRET_PATTERNS = [
|
|
13
|
+
{ name: 'API Key', pattern: /(sk|pk|api[_-]?key)[_-][\w\-.]{20,}/i },
|
|
14
|
+
{ name: 'AWS Access Key', pattern: /AKIA[\w]{16}/ },
|
|
15
|
+
{ name: 'Token/Bearer/JWT', pattern: /(token|bearer|jwt)[=:\s]["']?[\w\-.]{20,}/i },
|
|
16
|
+
{ name: 'Password', pattern: /(password|passwd|pwd)\s*[=:]\s*["']?[^\s"']{8,}/i },
|
|
17
|
+
{ name: 'Private Key', pattern: /-----BEGIN (RSA |EC |DSA )?PRIVATE KEY-----/ },
|
|
18
|
+
{ name: 'Connection String', pattern: /(mongodb|postgres|mysql|redis):\/\/\w+:[^@]+@/ },
|
|
19
|
+
];
|
|
20
|
+
/** 텍스트에서 민감 정보 패턴 감지 (순수 함수) */
|
|
21
|
+
export function detectSecrets(text) {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const sp of SECRET_PATTERNS) {
|
|
24
|
+
if (sp.pattern.test(text)) {
|
|
25
|
+
found.push(sp);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
async function main() {
|
|
31
|
+
const data = await readStdinJSON();
|
|
32
|
+
if (!isHookEnabled('secret-filter')) {
|
|
33
|
+
console.log(approve());
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!data) {
|
|
37
|
+
console.log(approve());
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const toolName = data.tool_name ?? data.toolName ?? '';
|
|
41
|
+
const toolResponse = data.tool_response ?? data.toolOutput ?? '';
|
|
42
|
+
const toolInput = data.tool_input ?? data.toolInput ?? {};
|
|
43
|
+
// Write/Edit/Bash 도구만 검사
|
|
44
|
+
if (!['Write', 'Edit', 'Bash'].includes(toolName)) {
|
|
45
|
+
console.log(approve());
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// 도구 입력 + 출력 모두 검사
|
|
49
|
+
const inputStr = typeof toolInput === 'string' ? toolInput : JSON.stringify(toolInput);
|
|
50
|
+
const textToScan = `${inputStr}\n${toolResponse}`;
|
|
51
|
+
const secrets = detectSecrets(textToScan);
|
|
52
|
+
if (secrets.length > 0) {
|
|
53
|
+
const names = secrets.map(s => s.name).join(', ');
|
|
54
|
+
console.log(approveWithWarning(`<compound-security-warning>\n[Forgen] ⚠ Sensitive information exposure detected: ${names}\nThe output may contain secrets. Please review.\n</compound-security-warning>`));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
console.log(approve());
|
|
58
|
+
}
|
|
59
|
+
main().catch((e) => {
|
|
60
|
+
const hookErr = new HookError(e instanceof Error ? e.message : String(e), {
|
|
61
|
+
hookName: 'secret-filter', eventType: 'PostToolUse', cause: e,
|
|
62
|
+
});
|
|
63
|
+
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
64
|
+
console.log(failOpen());
|
|
65
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Session Recovery Hook
|
|
4
|
+
*
|
|
5
|
+
* Claude Code SessionStart 훅으로 등록.
|
|
6
|
+
* 이전 세션에서 활성화된 지속 모드(ralph, autopilot, ultrawork)의
|
|
7
|
+
* 상태를 복구하여 작업을 자동 재개합니다.
|
|
8
|
+
*/
|
|
9
|
+
export interface Checkpoint {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
mode: string;
|
|
12
|
+
modifiedFiles: string[];
|
|
13
|
+
lastToolCall: string;
|
|
14
|
+
toolCallCount: number;
|
|
15
|
+
timestamp: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
}
|
|
18
|
+
/** 체크포인트 저장 */
|
|
19
|
+
export declare function saveCheckpoint(data: Checkpoint): void;
|
|
20
|
+
/** 체크포인트 로드 */
|
|
21
|
+
export declare function loadCheckpoint(sessionId: string): Checkpoint | null;
|
|
22
|
+
/** 오래된 체크포인트 삭제 */
|
|
23
|
+
export declare function cleanStaleCheckpoints(maxAgeMs?: number): number;
|
|
24
|
+
export declare function resolveSessionStartContext(rawInput: string): {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
};
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — Session Recovery Hook
|
|
4
|
+
*
|
|
5
|
+
* Claude Code SessionStart 훅으로 등록.
|
|
6
|
+
* 이전 세션에서 활성화된 지속 모드(ralph, autopilot, ultrawork)의
|
|
7
|
+
* 상태를 복구하여 작업을 자동 재개합니다.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as os from 'node:os';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createLogger } from '../core/logger.js';
|
|
14
|
+
const log = createLogger('session-recovery');
|
|
15
|
+
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
16
|
+
import { sanitizeId } from './shared/sanitize-id.js';
|
|
17
|
+
import { isHookEnabled } from './hook-config.js';
|
|
18
|
+
import { approve, approveWithContext, failOpen } from './shared/hook-response.js';
|
|
19
|
+
import { HANDOFFS_DIR, STATE_DIR } from '../core/paths.js';
|
|
20
|
+
/** 체크포인트 저장 */
|
|
21
|
+
export function saveCheckpoint(data) {
|
|
22
|
+
try {
|
|
23
|
+
const filePath = path.join(STATE_DIR, `checkpoint-${sanitizeId(data.sessionId)}.json`);
|
|
24
|
+
atomicWriteJSON(filePath, data);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
log.debug('체크포인트 저장 실패', e);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Checkpoint 구조 검증 */
|
|
31
|
+
function isValidCheckpoint(data) {
|
|
32
|
+
if (typeof data !== 'object' || data === null)
|
|
33
|
+
return false;
|
|
34
|
+
const d = data;
|
|
35
|
+
return (typeof d.sessionId === 'string' &&
|
|
36
|
+
typeof d.timestamp === 'string' &&
|
|
37
|
+
typeof d.mode === 'string' &&
|
|
38
|
+
typeof d.cwd === 'string' &&
|
|
39
|
+
Array.isArray(d.modifiedFiles) &&
|
|
40
|
+
typeof d.lastToolCall === 'string' &&
|
|
41
|
+
typeof d.toolCallCount === 'number');
|
|
42
|
+
}
|
|
43
|
+
/** ModeState 구조 검증 */
|
|
44
|
+
function isValidModeState(data) {
|
|
45
|
+
if (typeof data !== 'object' || data === null)
|
|
46
|
+
return false;
|
|
47
|
+
const d = data;
|
|
48
|
+
return typeof d.active === 'boolean' && typeof d.startedAt === 'string';
|
|
49
|
+
}
|
|
50
|
+
/** 체크포인트 로드 */
|
|
51
|
+
export function loadCheckpoint(sessionId) {
|
|
52
|
+
try {
|
|
53
|
+
const filePath = path.join(STATE_DIR, `checkpoint-${sanitizeId(sessionId)}.json`);
|
|
54
|
+
if (fs.existsSync(filePath)) {
|
|
55
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
56
|
+
if (!isValidCheckpoint(data)) {
|
|
57
|
+
log.debug('체크포인트 구조 검증 실패', { sessionId });
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return data;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
log.debug('체크포인트 로드 실패', e);
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** 오래된 체크포인트 삭제 */
|
|
69
|
+
export function cleanStaleCheckpoints(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
70
|
+
let cleaned = 0;
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.existsSync(STATE_DIR))
|
|
73
|
+
return 0;
|
|
74
|
+
const files = fs.readdirSync(STATE_DIR).filter(f => f.startsWith('checkpoint-') && f.endsWith('.json'));
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const filePath = path.join(STATE_DIR, file);
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
80
|
+
if (!isValidCheckpoint(parsed)) {
|
|
81
|
+
// 구조 검증 실패한 파일도 정리
|
|
82
|
+
try {
|
|
83
|
+
fs.unlinkSync(filePath);
|
|
84
|
+
cleaned++;
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
log.debug(`invalid checkpoint unlink failed: ${filePath}`, e);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const age = now - new Date(parsed.timestamp).getTime();
|
|
92
|
+
if (age > maxAgeMs) {
|
|
93
|
+
fs.unlinkSync(filePath);
|
|
94
|
+
cleaned++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// 파싱 실패한 파일도 정리
|
|
99
|
+
try {
|
|
100
|
+
fs.unlinkSync(filePath);
|
|
101
|
+
cleaned++;
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
log.debug(`corrupt checkpoint unlink failed: ${filePath}`, e);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
log.debug('스테일 체크포인트 정리 실패', e);
|
|
111
|
+
}
|
|
112
|
+
return cleaned;
|
|
113
|
+
}
|
|
114
|
+
const PERSISTENT_MODES = ['ralph', 'autopilot', 'ultrawork', 'team', 'pipeline'];
|
|
115
|
+
export function resolveSessionStartContext(rawInput) {
|
|
116
|
+
const fallbackCwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(rawInput);
|
|
119
|
+
return {
|
|
120
|
+
sessionId: parsed.session_id?.trim() || `session-${Date.now()}`,
|
|
121
|
+
cwd: parsed.cwd?.trim() || fallbackCwd,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return {
|
|
126
|
+
sessionId: `session-${Date.now()}`,
|
|
127
|
+
cwd: fallbackCwd,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function main() {
|
|
132
|
+
// SessionStart 훅은 stdin으로 세션 정보를 받음 (타임아웃 포함)
|
|
133
|
+
const chunks = [];
|
|
134
|
+
process.stdin.setEncoding('utf-8');
|
|
135
|
+
await new Promise((resolve) => {
|
|
136
|
+
const timeout = setTimeout(() => {
|
|
137
|
+
process.stdin.removeAllListeners('data');
|
|
138
|
+
process.stdin.removeAllListeners('end');
|
|
139
|
+
resolve();
|
|
140
|
+
}, 2000);
|
|
141
|
+
process.stdin.on('data', (chunk) => chunks.push(String(chunk)));
|
|
142
|
+
process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
|
|
143
|
+
});
|
|
144
|
+
const sessionContext = resolveSessionStartContext(chunks.join(''));
|
|
145
|
+
if (!isHookEnabled('session-recovery')) {
|
|
146
|
+
console.log(approve());
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (!fs.existsSync(STATE_DIR)) {
|
|
150
|
+
console.log(approve());
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// 활성 모드 찾기
|
|
154
|
+
const recoveryMessages = [];
|
|
155
|
+
for (const mode of PERSISTENT_MODES) {
|
|
156
|
+
const statePath = path.join(STATE_DIR, `${mode}-state.json`);
|
|
157
|
+
if (!fs.existsSync(statePath))
|
|
158
|
+
continue;
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
161
|
+
if (!isValidModeState(parsed)) {
|
|
162
|
+
log.debug(`상태 파일 구조 검증 실패: ${mode}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const state = parsed;
|
|
166
|
+
if (!state.active)
|
|
167
|
+
continue;
|
|
168
|
+
// 24시간 이상 경과한 상태는 만료
|
|
169
|
+
const startedAt = new Date(state.startedAt).getTime();
|
|
170
|
+
const elapsed = Date.now() - startedAt;
|
|
171
|
+
if (elapsed > 24 * 60 * 60 * 1000) {
|
|
172
|
+
fs.unlinkSync(statePath);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const elapsedMinutes = Math.round(elapsed / 60000);
|
|
176
|
+
// Security: 상태 파일의 사용자 입력을 XML에 삽입하기 전 이스케이프
|
|
177
|
+
const escXml = (s) => s.replace(/[<>&"]/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c] ?? c);
|
|
178
|
+
recoveryMessages.push(`<compound-recovery mode="${mode}">` +
|
|
179
|
+
`\n${mode} mode from previous session has been recovered.` +
|
|
180
|
+
`\nStarted: ${state.startedAt} (${elapsedMinutes} minutes ago)` +
|
|
181
|
+
(state.prompt ? `\nOriginal request: ${escXml(state.prompt)}` : '') +
|
|
182
|
+
(state.stage ? `\nCurrent stage: ${escXml(state.stage)}` : '') +
|
|
183
|
+
(state.completedSteps?.length ? `\nCompleted steps: ${state.completedSteps.map((s) => escXml(s)).join(', ')}` : '') +
|
|
184
|
+
`\n\nContinue the previous work. To stop, type "cancelforgen".` +
|
|
185
|
+
`\n</compound-recovery>`);
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
log.debug(`상태 파일 파싱 실패`, e);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Phase 0: endTime이 없는 이전 세션 backfill (file mtime 기반)
|
|
192
|
+
// 최근 7일 파일만 대상 — 오래된 파일은 무시하여 성능 보장
|
|
193
|
+
try {
|
|
194
|
+
const { SESSIONS_DIR: sessDir } = await import('../core/paths.js');
|
|
195
|
+
if (fs.existsSync(sessDir)) {
|
|
196
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
197
|
+
const sessionFiles = fs.readdirSync(sessDir)
|
|
198
|
+
.filter(f => f.endsWith('.json'))
|
|
199
|
+
.filter(f => {
|
|
200
|
+
try {
|
|
201
|
+
return fs.statSync(path.join(sessDir, f)).mtimeMs > cutoff;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
for (const file of sessionFiles) {
|
|
208
|
+
const fp = path.join(sessDir, file);
|
|
209
|
+
try {
|
|
210
|
+
const raw = JSON.parse(fs.readFileSync(fp, 'utf-8'));
|
|
211
|
+
if (raw.startTime && !raw.endTime) {
|
|
212
|
+
const mtime = fs.statSync(fp).mtime;
|
|
213
|
+
raw.endTime = mtime.toISOString();
|
|
214
|
+
raw.durationMs = mtime.getTime() - new Date(raw.startTime).getTime();
|
|
215
|
+
raw.recoveredEndTime = true;
|
|
216
|
+
atomicWriteJSON(fp, raw);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch { /* individual file recovery failure — skip and continue */ }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
log.debug('세션 endTime backfill 실패', e);
|
|
225
|
+
}
|
|
226
|
+
// 미완료 체크포인트 감지
|
|
227
|
+
try {
|
|
228
|
+
const checkpointFiles = fs.readdirSync(STATE_DIR)
|
|
229
|
+
.filter(f => f.startsWith('checkpoint-') && f.endsWith('.json'));
|
|
230
|
+
for (const file of checkpointFiles) {
|
|
231
|
+
try {
|
|
232
|
+
const parsedCp = JSON.parse(fs.readFileSync(path.join(STATE_DIR, file), 'utf-8'));
|
|
233
|
+
if (!isValidCheckpoint(parsedCp)) {
|
|
234
|
+
log.debug(`체크포인트 파일 구조 검증 실패: ${file}`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const cp = parsedCp;
|
|
238
|
+
const age = Date.now() - new Date(cp.timestamp).getTime();
|
|
239
|
+
if (age > 24 * 60 * 60 * 1000) {
|
|
240
|
+
fs.unlinkSync(path.join(STATE_DIR, file));
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const elapsedMin = Math.round(age / 60000);
|
|
244
|
+
const safeSessionId = String(cp.sessionId).replace(/[&"<>]/g, '_');
|
|
245
|
+
const safeLastTool = String(cp.lastToolCall ?? '').replace(/[<>]/g, '_');
|
|
246
|
+
const safeCwd = String(cp.cwd ?? '').replace(/[<>]/g, '_');
|
|
247
|
+
recoveryMessages.push(`<compound-checkpoint session="${safeSessionId}">` +
|
|
248
|
+
`\nIncomplete checkpoint found (${elapsedMin} minutes ago)` +
|
|
249
|
+
`\n- Modified files: ${cp.modifiedFiles.length}` +
|
|
250
|
+
`\n- Tool calls: ${cp.toolCallCount}` +
|
|
251
|
+
`\n- Last tool: ${safeLastTool}` +
|
|
252
|
+
`\n- Working directory: ${safeCwd}` +
|
|
253
|
+
`\n</compound-checkpoint>`);
|
|
254
|
+
}
|
|
255
|
+
catch { /* 개별 파일 파싱 실패 무시 */ }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
log.debug('체크포인트 스캔 실패', e);
|
|
260
|
+
}
|
|
261
|
+
// pending-compound 마커 확인 (이전 세션에서 compound loop 필요 표시)
|
|
262
|
+
const pendingPath = path.join(STATE_DIR, 'pending-compound.json');
|
|
263
|
+
if (fs.existsSync(pendingPath)) {
|
|
264
|
+
try {
|
|
265
|
+
const pending = JSON.parse(fs.readFileSync(pendingPath, 'utf-8'));
|
|
266
|
+
recoveryMessages.push(`<compound-pending>` +
|
|
267
|
+
`\nCompound loop was scheduled in the previous session (${pending.promptCount ?? '?'} prompts).` +
|
|
268
|
+
`\nRun \`forgen compound\` to preview, then \`forgen compound --save\` to persist patterns/solutions.` +
|
|
269
|
+
`\n</compound-pending>`);
|
|
270
|
+
// 마커 삭제 (한 번만 안내)
|
|
271
|
+
fs.unlinkSync(pendingPath);
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
log.debug('pending-compound 마커 읽기 실패', e);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// 핸드오프 파일 확인
|
|
278
|
+
const handoffDir = HANDOFFS_DIR;
|
|
279
|
+
if (fs.existsSync(handoffDir)) {
|
|
280
|
+
try {
|
|
281
|
+
const handoffs = fs.readdirSync(handoffDir)
|
|
282
|
+
.filter(f => f.endsWith('.md'))
|
|
283
|
+
.sort();
|
|
284
|
+
if (handoffs.length > 0) {
|
|
285
|
+
const latest = handoffs[handoffs.length - 1];
|
|
286
|
+
const latestPath = path.join(handoffDir, latest);
|
|
287
|
+
// Security: symlink 방지 + XML 이스케이프
|
|
288
|
+
if (fs.lstatSync(latestPath).isSymbolicLink())
|
|
289
|
+
throw new Error('symlink rejected');
|
|
290
|
+
const raw = fs.readFileSync(latestPath, 'utf-8');
|
|
291
|
+
const safeName = latest.replace(/[&"<>]/g, '_');
|
|
292
|
+
const escaped = raw.replace(/<\/?[a-zA-Z][\w-]*(?:\s[^>]*)?\/?>/g, m => m.replace(/</g, '<').replace(/>/g, '>'));
|
|
293
|
+
recoveryMessages.push(`<compound-handoff file="${safeName}">\n${escaped}\n</compound-handoff>`);
|
|
294
|
+
// 마커 삭제 (한 번만 안내 — pending-compound.json과 동일 패턴)
|
|
295
|
+
try {
|
|
296
|
+
fs.unlinkSync(latestPath);
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
log.debug('handoff 파일 삭제 실패', e);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch (e) {
|
|
304
|
+
log.debug('handoff 파일 읽기 실패', e);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const sessionId = sessionContext.sessionId;
|
|
308
|
+
// 이전 세션 자동 compound (fire-and-forget)
|
|
309
|
+
// /new로 세션 리셋 시 SessionStart가 다시 호출됨 — 이때 이전 transcript를 compound
|
|
310
|
+
try {
|
|
311
|
+
const cwd = sessionContext.cwd;
|
|
312
|
+
const sanitized = cwd.replace(/\//g, '-');
|
|
313
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', sanitized);
|
|
314
|
+
if (fs.existsSync(projectDir)) {
|
|
315
|
+
const transcripts = fs.readdirSync(projectDir)
|
|
316
|
+
.filter(f => f.endsWith('.jsonl') && f !== `${sessionId}.jsonl`) // 현재 세션 제외
|
|
317
|
+
.map(f => ({ name: f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
|
|
318
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
319
|
+
if (transcripts.length > 0) {
|
|
320
|
+
const prevTranscript = path.join(projectDir, transcripts[0].name);
|
|
321
|
+
const lastCompoundPath = path.join(STATE_DIR, 'last-auto-compound.json');
|
|
322
|
+
let lastCompoundedSession = '';
|
|
323
|
+
try {
|
|
324
|
+
lastCompoundedSession = JSON.parse(fs.readFileSync(lastCompoundPath, 'utf-8')).sessionId ?? '';
|
|
325
|
+
}
|
|
326
|
+
catch { /* first time */ }
|
|
327
|
+
const prevSessionId = transcripts[0].name.replace('.jsonl', '');
|
|
328
|
+
if (prevSessionId !== lastCompoundedSession) {
|
|
329
|
+
// 이전 세션이 compound 안 된 상태 — 메시지 수 확인
|
|
330
|
+
// W-D2: 대용량 transcript 보호 — 앞 200KB만 읽어 메시지 수 추정
|
|
331
|
+
const fd = fs.openSync(prevTranscript, 'r');
|
|
332
|
+
const buf = Buffer.alloc(200 * 1024);
|
|
333
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
334
|
+
fs.closeSync(fd);
|
|
335
|
+
const content = buf.toString('utf-8', 0, bytesRead);
|
|
336
|
+
const userMsgCount = content.split('\n')
|
|
337
|
+
.filter(l => { try {
|
|
338
|
+
const t = JSON.parse(l).type;
|
|
339
|
+
return t === 'user' || t === 'queue-operation';
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return false;
|
|
343
|
+
} })
|
|
344
|
+
.length;
|
|
345
|
+
if (userMsgCount >= 10) {
|
|
346
|
+
// background로 auto-compound 실행 (hook timeout과 무관)
|
|
347
|
+
const { spawn: spawnProcess } = await import('node:child_process');
|
|
348
|
+
const autoCompound = spawnProcess('node', [
|
|
349
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js'),
|
|
350
|
+
cwd, prevTranscript, prevSessionId,
|
|
351
|
+
], { detached: true, stdio: 'ignore' });
|
|
352
|
+
autoCompound.unref();
|
|
353
|
+
log.debug(`이전 세션 auto-compound 시작: ${prevSessionId} (${userMsgCount} messages)`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
log.debug('이전 세션 auto-compound 체크 실패', e);
|
|
361
|
+
}
|
|
362
|
+
// v1: regex 기반 패턴 학습(prompt-learner) 제거. Evidence 기반으로 전환됨.
|
|
363
|
+
// Compound v3: Run lifecycle check once per day
|
|
364
|
+
try {
|
|
365
|
+
const lifecycleModulePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'engine', 'compound-lifecycle.js');
|
|
366
|
+
const lastLifecyclePath = path.join(STATE_DIR, 'last-lifecycle.json');
|
|
367
|
+
let shouldRun = true;
|
|
368
|
+
try {
|
|
369
|
+
if (fs.existsSync(lastLifecyclePath)) {
|
|
370
|
+
const data = JSON.parse(fs.readFileSync(lastLifecyclePath, 'utf-8'));
|
|
371
|
+
const last = new Date(data.lastRun).getTime();
|
|
372
|
+
shouldRun = Date.now() - last > 24 * 60 * 60 * 1000;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch { /* last-lifecycle.json parse failure — run lifecycle check anyway */ }
|
|
376
|
+
if (shouldRun) {
|
|
377
|
+
// B-4: detached background spawn으로 분리 — hook timeout 초과 방지
|
|
378
|
+
const { spawn: spawnLifecycle } = await import('node:child_process');
|
|
379
|
+
const lifecycleRunner = spawnLifecycle('node', [
|
|
380
|
+
'--input-type=module',
|
|
381
|
+
'-e',
|
|
382
|
+
`import('${lifecycleModulePath.replace(/\\/g, '/')}').then(m => m.runLifecycleCheck('${sessionId}'))`,
|
|
383
|
+
], { detached: true, stdio: 'ignore' });
|
|
384
|
+
lifecycleRunner.unref();
|
|
385
|
+
const { atomicWriteJSON: writeJSON } = await import('./shared/atomic-write.js');
|
|
386
|
+
writeJSON(lastLifecyclePath, { lastRun: new Date().toISOString() });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
log.debug('lifecycle check 실패', e);
|
|
391
|
+
}
|
|
392
|
+
if (recoveryMessages.length > 0) {
|
|
393
|
+
console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
console.log(approve());
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// ESM main guard: 다른 모듈에서 import 시 main() 실행 방지
|
|
400
|
+
// realpathSync로 symlink 해석 (플러그인 캐시가 symlink일 때 경로 불일치 방지)
|
|
401
|
+
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
402
|
+
main().catch((e) => {
|
|
403
|
+
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
404
|
+
console.log(failOpen());
|
|
405
|
+
});
|
|
406
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 훅 공유 유틸: 원자적 파일 쓰기
|
|
3
|
+
*
|
|
4
|
+
* write → rename 패턴으로 동시 세션에서의 상태 파일 손상을 방지합니다.
|
|
5
|
+
*
|
|
6
|
+
* 보안 모델:
|
|
7
|
+
* - mode 옵션이 0o600인 파일은 같은 호스트의 다른 user로부터 보호.
|
|
8
|
+
* - tmp 파일은 PID + randomBytes(6) suffix → 같은 process가 동시 atomic write를
|
|
9
|
+
* 하더라도 tmp 충돌 없음. symlink TOCTOU도 방지.
|
|
10
|
+
*
|
|
11
|
+
* Windows 한계:
|
|
12
|
+
* - fchmodSync/chmodSync는 Windows에서 read-only 비트만 영향. 0o600 같은
|
|
13
|
+
* POSIX 권한은 의미가 없으며, 보안은 OS-level ACL과 사용자 home 격리에 의존.
|
|
14
|
+
* - 민감 데이터는 Windows에서 추가 보호가 필요할 수 있다.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* JSON 데이터를 원자적으로 파일에 기록 (tmp → rename)
|
|
18
|
+
*
|
|
19
|
+
* @param options.pretty 들여쓴 포맷으로 직렬화
|
|
20
|
+
* @param options.mode 생성될 파일의 권한 (예: 0o600). 기본값은 umask 의존(보통 0o644).
|
|
21
|
+
* 민감한 캐시(컨텍스트 식별자/태그 포함)에 대해서는 0o600 권장.
|
|
22
|
+
* @param options.dirMode 부모 디렉터리 mode. cache는 0o700 권장.
|
|
23
|
+
*
|
|
24
|
+
* 권한 보장 (M6+M16+M17 fix):
|
|
25
|
+
* 1. tmp 파일이 random suffix → 다른 fd 충돌 없음 (Promise.all 안전).
|
|
26
|
+
* 2. fd 단위 fchmodSync로 새 inode 권한 강제.
|
|
27
|
+
* 3. post-rename chmodSync 실패는 throw — 느슨한 권한 침묵 방지.
|
|
28
|
+
* 4. Windows에서는 위 mode가 무효일 수 있으나, OS-level ACL이 일반적으로 user 격리.
|
|
29
|
+
*/
|
|
30
|
+
export declare function atomicWriteJSON(filePath: string, data: unknown, options?: {
|
|
31
|
+
pretty?: boolean;
|
|
32
|
+
mode?: number;
|
|
33
|
+
dirMode?: number;
|
|
34
|
+
}): void;
|
|
35
|
+
/** 텍스트를 원자적으로 파일에 기록 (tmp → rename) */
|
|
36
|
+
export declare function atomicWriteText(filePath: string, content: string, options?: {
|
|
37
|
+
mode?: number;
|
|
38
|
+
dirMode?: number;
|
|
39
|
+
}): void;
|
|
40
|
+
/** JSON 파일을 안전하게 읽기 (파싱 실패 시 fallback 반환) */
|
|
41
|
+
export declare function safeReadJSON<T>(filePath: string, fallback: T): T;
|