@wooojin/forgen 0.5.5 → 0.5.6
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 +69 -0
- package/README.ja.md +4 -0
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.zh.md +4 -0
- package/assets/claude/skills/verify/SKILL.md +66 -0
- package/assets/shared/hook-registry.json +10 -3
- package/dist/cli.js +7 -3
- package/dist/core/doctor.js +9 -1
- package/dist/core/uninstall.d.ts +7 -0
- package/dist/core/uninstall.js +46 -1
- package/dist/hooks/context-guard.d.ts +3 -0
- package/dist/hooks/context-guard.js +9 -5
- package/dist/hooks/hook-registry.d.ts +11 -0
- package/dist/hooks/hooks-generator.d.ts +2 -0
- package/dist/hooks/hooks-generator.js +5 -1
- package/dist/hooks/session-end.d.ts +10 -3
- package/dist/hooks/session-end.js +17 -4
- package/dist/host/codex-adapter.js +5 -0
- package/dist/host/codex-hook-alive.d.ts +23 -0
- package/dist/host/codex-hook-alive.js +51 -0
- package/dist/host/codex-notify.d.ts +55 -0
- package/dist/host/codex-notify.js +153 -0
- package/dist/host/codex-rollout.d.ts +21 -0
- package/dist/host/codex-rollout.js +82 -0
- package/dist/host/exec-host.js +5 -1
- package/dist/host/install-claude.d.ts +14 -0
- package/dist/host/install-claude.js +53 -0
- package/dist/host/install-codex.d.ts +67 -5
- package/dist/host/install-codex.js +285 -45
- package/dist/host/install-orchestrator.d.ts +4 -0
- package/dist/host/install-orchestrator.js +23 -3
- package/package.json +1 -1
- package/plugin.json +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex 훅 생존 마커 — ADR-016 D1
|
|
3
|
+
*
|
|
4
|
+
* Codex 는 미승인(untrusted)/변경된(modified) 훅을 조용히 skip 한다. "forgen 훅이 실제로 실행되고 있는가"
|
|
5
|
+
* 를 관측하는 유일한 choke point 는 codex-adapter 다 (Codex 가 forgen 훅을 실행하면 반드시 거친다).
|
|
6
|
+
* 어댑터가 턴 경계 이벤트에서 전역 마커를 갱신하고, notify 폴백(codex-notify)이 그 신선도를 본다.
|
|
7
|
+
*
|
|
8
|
+
* 훅 신뢰 상태는 hooks.json 단위로 모든 세션이 공유하므로 마커는 세션별이 아니라 전역 1개다.
|
|
9
|
+
* 어댑터가 매 훅마다 import 하므로 의존성은 node 내장 + paths 만 둔다.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
14
|
+
export const CODEX_HOOK_ALIVE_PATH = path.join(STATE_DIR, 'codex-hook-alive.json');
|
|
15
|
+
/** notify 는 Stop 훅 직후 발화한다. Stop 훅 타임아웃(10s) 대비 넉넉한 창. */
|
|
16
|
+
export const CODEX_HOOK_ALIVE_WINDOW_MS = 120_000;
|
|
17
|
+
/** 턴 경계 이벤트만 기록 — PreToolUse/PostToolUse 마다 쓰지 않는다. */
|
|
18
|
+
const ALIVE_EVENTS = new Set(['Stop', 'SubagentStop', 'UserPromptSubmit']);
|
|
19
|
+
/** 어댑터 입력(stdin JSON)으로 마커 갱신. 대상 이벤트가 아니면 no-op. 실패는 삼킨다 (fail-open). */
|
|
20
|
+
export function markCodexHookAlive(input, now = Date.now()) {
|
|
21
|
+
try {
|
|
22
|
+
const i = (input ?? {});
|
|
23
|
+
const event = typeof i.hook_event_name === 'string' ? i.hook_event_name
|
|
24
|
+
: typeof i.hookEventName === 'string' ? i.hookEventName : '';
|
|
25
|
+
if (!ALIVE_EVENTS.has(event))
|
|
26
|
+
return false;
|
|
27
|
+
const marker = { at: now, event };
|
|
28
|
+
if (typeof i.session_id === 'string')
|
|
29
|
+
marker.sessionId = i.session_id;
|
|
30
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
31
|
+
fs.writeFileSync(CODEX_HOOK_ALIVE_PATH, JSON.stringify(marker));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function readCodexHookAlive() {
|
|
39
|
+
try {
|
|
40
|
+
const m = JSON.parse(fs.readFileSync(CODEX_HOOK_ALIVE_PATH, 'utf-8'));
|
|
41
|
+
return typeof m.at === 'number' && Number.isFinite(m.at) ? m : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** 최근 window 안에 forgen 훅이 Codex 에서 실행됐는가. */
|
|
48
|
+
export function isCodexHookAlive(now = Date.now(), windowMs = CODEX_HOOK_ALIVE_WINDOW_MS) {
|
|
49
|
+
const m = readCodexHookAlive();
|
|
50
|
+
return m !== null && now - m.at >= 0 && now - m.at < windowMs;
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Codex `notify` 폴백 — ADR-016 D1
|
|
4
|
+
*
|
|
5
|
+
* Codex 는 config.toml 의 top-level `notify = [argv…]` 프로그램을 **턴 완료마다**(Stop 훅 뒤) detached 로
|
|
6
|
+
* 실행하고, JSON 페이로드를 마지막 argv 로 넘긴다. 훅과 달리 **신뢰(trust) 승인과 무관**하게 돈다.
|
|
7
|
+
* forgen 은 이것을 "훅이 조용히 skip 되는 동안의 안전망" 으로만 쓴다:
|
|
8
|
+
*
|
|
9
|
+
* 1. forgen 훅이 살아 있으면(codex-adapter 가 방금 alive 마커를 갱신) 아무것도 하지 않는다 —
|
|
10
|
+
* auto-compound 는 Stop 훅(context-guard) 소관이다.
|
|
11
|
+
* 2. 훅이 돌지 않았으면 silent 플래그를 남기고(`forgen doctor` 가 노출), 세션이 충분히 길면 Stop 훅과
|
|
12
|
+
* 같은 디바운스 경로로 auto-compound 를 띄운다.
|
|
13
|
+
*
|
|
14
|
+
* 페이로드 (codex-rs/hooks/src/legacy_notify.rs, 0.153.4):
|
|
15
|
+
* { "type":"agent-turn-complete", "thread-id", "turn-id", "cwd", "client"?, "input-messages":[…],
|
|
16
|
+
* "last-assistant-message" } — transcript 경로는 없다. `thread-id` 로 rollout 파일을 찾는다.
|
|
17
|
+
*
|
|
18
|
+
* argv: `codex-notify.js [-- <chain program> <args…>] <payload JSON>`
|
|
19
|
+
* `--` 뒤는 사용자가 수동으로 체인한 자기 notifier (단일 argv 제약 우회). 페이로드를 붙여 먼저 전달한다.
|
|
20
|
+
*
|
|
21
|
+
* 실패 정책: 모든 단계 fail-open. stdout/stderr 는 Codex 가 /dev/null 로 버린다.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CODEX_HOOKS_SILENT_PATH: string;
|
|
24
|
+
export interface CodexNotifyPayload {
|
|
25
|
+
type?: string;
|
|
26
|
+
'thread-id'?: string;
|
|
27
|
+
'turn-id'?: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
client?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface CodexHooksSilent {
|
|
32
|
+
detectedAt: string;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
/** 연속으로 관측된 silent 턴 수 */
|
|
36
|
+
count: number;
|
|
37
|
+
}
|
|
38
|
+
export type NotifyOutcome = 'nested-run' | 'no-payload' | 'ignored-event' | 'hooks-alive' | 'silent-recorded' | 'silent-compound-spawned';
|
|
39
|
+
/** argv(스크립트 뒤) → 체인 프로그램 + 페이로드. 페이로드는 항상 마지막 argv. */
|
|
40
|
+
export declare function parseNotifyArgv(argv: string[]): {
|
|
41
|
+
chain: string[];
|
|
42
|
+
payloadRaw: string | null;
|
|
43
|
+
payload: CodexNotifyPayload | null;
|
|
44
|
+
};
|
|
45
|
+
/** doctor 용 — 유효(24h 이내)한 silent 관측만 반환. */
|
|
46
|
+
export declare function readCodexHooksSilent(now?: number): CodexHooksSilent | null;
|
|
47
|
+
export interface NotifyDeps {
|
|
48
|
+
now?: number;
|
|
49
|
+
env?: NodeJS.ProcessEnv;
|
|
50
|
+
/** 테스트 주입: auto-compound 디바운스 트리거 */
|
|
51
|
+
spawnCompound?: (sessionId: string, transcriptPath: string, promptCount: number, cwd: string) => Promise<boolean>;
|
|
52
|
+
/** 테스트 주입: forgen hook-config 조회 */
|
|
53
|
+
isHookEnabled?: (name: string) => boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare function handleNotify(argv: string[], deps?: NotifyDeps): Promise<NotifyOutcome>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Codex `notify` 폴백 — ADR-016 D1
|
|
4
|
+
*
|
|
5
|
+
* Codex 는 config.toml 의 top-level `notify = [argv…]` 프로그램을 **턴 완료마다**(Stop 훅 뒤) detached 로
|
|
6
|
+
* 실행하고, JSON 페이로드를 마지막 argv 로 넘긴다. 훅과 달리 **신뢰(trust) 승인과 무관**하게 돈다.
|
|
7
|
+
* forgen 은 이것을 "훅이 조용히 skip 되는 동안의 안전망" 으로만 쓴다:
|
|
8
|
+
*
|
|
9
|
+
* 1. forgen 훅이 살아 있으면(codex-adapter 가 방금 alive 마커를 갱신) 아무것도 하지 않는다 —
|
|
10
|
+
* auto-compound 는 Stop 훅(context-guard) 소관이다.
|
|
11
|
+
* 2. 훅이 돌지 않았으면 silent 플래그를 남기고(`forgen doctor` 가 노출), 세션이 충분히 길면 Stop 훅과
|
|
12
|
+
* 같은 디바운스 경로로 auto-compound 를 띄운다.
|
|
13
|
+
*
|
|
14
|
+
* 페이로드 (codex-rs/hooks/src/legacy_notify.rs, 0.153.4):
|
|
15
|
+
* { "type":"agent-turn-complete", "thread-id", "turn-id", "cwd", "client"?, "input-messages":[…],
|
|
16
|
+
* "last-assistant-message" } — transcript 경로는 없다. `thread-id` 로 rollout 파일을 찾는다.
|
|
17
|
+
*
|
|
18
|
+
* argv: `codex-notify.js [-- <chain program> <args…>] <payload JSON>`
|
|
19
|
+
* `--` 뒤는 사용자가 수동으로 체인한 자기 notifier (단일 argv 제약 우회). 페이로드를 붙여 먼저 전달한다.
|
|
20
|
+
*
|
|
21
|
+
* 실패 정책: 모든 단계 fail-open. stdout/stderr 는 Codex 가 /dev/null 로 버린다.
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import * as fs from 'node:fs';
|
|
25
|
+
import * as os from 'node:os';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
29
|
+
import { isCodexHookAlive } from './codex-hook-alive.js';
|
|
30
|
+
import { countCodexUserPrompts, findCodexRollout } from './codex-rollout.js';
|
|
31
|
+
export const CODEX_HOOKS_SILENT_PATH = path.join(STATE_DIR, 'codex-hooks-silent.json');
|
|
32
|
+
/** silent 플래그 유효기간 — 이보다 오래된 관측은 doctor 가 무시한다. */
|
|
33
|
+
const SILENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
/** Stop 훅 경로와 같은 "의미 있는 세션" 임계. */
|
|
35
|
+
const MIN_USER_PROMPTS = 10;
|
|
36
|
+
/** argv(스크립트 뒤) → 체인 프로그램 + 페이로드. 페이로드는 항상 마지막 argv. */
|
|
37
|
+
export function parseNotifyArgv(argv) {
|
|
38
|
+
if (argv.length === 0)
|
|
39
|
+
return { chain: [], payloadRaw: null, payload: null };
|
|
40
|
+
const payloadRaw = argv[argv.length - 1];
|
|
41
|
+
const chain = argv[0] === '--' ? argv.slice(1, -1) : [];
|
|
42
|
+
let payload = null;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(payloadRaw);
|
|
45
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
46
|
+
payload = parsed;
|
|
47
|
+
}
|
|
48
|
+
catch { /* 페이로드 아님 */ }
|
|
49
|
+
return { chain, payloadRaw, payload };
|
|
50
|
+
}
|
|
51
|
+
/** doctor 용 — 유효(24h 이내)한 silent 관측만 반환. */
|
|
52
|
+
export function readCodexHooksSilent(now = Date.now()) {
|
|
53
|
+
try {
|
|
54
|
+
const s = JSON.parse(fs.readFileSync(CODEX_HOOKS_SILENT_PATH, 'utf-8'));
|
|
55
|
+
const at = Date.parse(s.detectedAt);
|
|
56
|
+
if (!Number.isFinite(at) || now - at > SILENT_TTL_MS)
|
|
57
|
+
return null;
|
|
58
|
+
if (typeof s.sessionId !== 'string' || typeof s.count !== 'number')
|
|
59
|
+
return null;
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function recordSilent(sessionId, cwd, now) {
|
|
67
|
+
const prev = readCodexHooksSilent(now);
|
|
68
|
+
const next = {
|
|
69
|
+
detectedAt: new Date(now).toISOString(),
|
|
70
|
+
sessionId,
|
|
71
|
+
cwd,
|
|
72
|
+
count: (prev?.count ?? 0) + 1,
|
|
73
|
+
};
|
|
74
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
75
|
+
fs.writeFileSync(CODEX_HOOKS_SILENT_PATH, JSON.stringify(next));
|
|
76
|
+
}
|
|
77
|
+
function clearSilent() {
|
|
78
|
+
try {
|
|
79
|
+
fs.rmSync(CODEX_HOOKS_SILENT_PATH, { force: true });
|
|
80
|
+
}
|
|
81
|
+
catch { /* noop */ }
|
|
82
|
+
}
|
|
83
|
+
function runChain(chain, payloadRaw) {
|
|
84
|
+
if (chain.length === 0)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
const child = spawn(chain[0], [...chain.slice(1), payloadRaw], { detached: true, stdio: 'ignore' });
|
|
88
|
+
child.on('error', () => { });
|
|
89
|
+
child.unref();
|
|
90
|
+
}
|
|
91
|
+
catch { /* fail-open */ }
|
|
92
|
+
}
|
|
93
|
+
/** Stop 경로는 context-guard 훅이 auto-compound 를 띄운다 — 사용자가 그 훅을 껐으면 폴백도 띄우지 않는다. */
|
|
94
|
+
async function contextGuardEnabled(deps) {
|
|
95
|
+
try {
|
|
96
|
+
const isEnabled = deps.isHookEnabled ?? (await import('../hooks/hook-config.js')).isHookEnabled;
|
|
97
|
+
return isEnabled('context-guard');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return true; // 설정을 못 읽으면 기본값(활성)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function handleNotify(argv, deps = {}) {
|
|
104
|
+
const env = deps.env ?? process.env;
|
|
105
|
+
const now = deps.now ?? Date.now();
|
|
106
|
+
// forgen 자신의 추출용 `codex exec` — 재귀/오탐 방지 (env 는 Codex 세션 시작 시점 스냅샷으로 상속된다).
|
|
107
|
+
if (env.FORGEN_NESTED_RUN === '1')
|
|
108
|
+
return 'nested-run';
|
|
109
|
+
const { chain, payloadRaw, payload } = parseNotifyArgv(argv);
|
|
110
|
+
if (payloadRaw !== null)
|
|
111
|
+
runChain(chain, payloadRaw);
|
|
112
|
+
if (!payload)
|
|
113
|
+
return 'no-payload';
|
|
114
|
+
if (payload.type !== 'agent-turn-complete')
|
|
115
|
+
return 'ignored-event';
|
|
116
|
+
if (isCodexHookAlive(now)) {
|
|
117
|
+
clearSilent();
|
|
118
|
+
return 'hooks-alive';
|
|
119
|
+
}
|
|
120
|
+
const sessionId = typeof payload['thread-id'] === 'string' ? payload['thread-id'] : 'unknown';
|
|
121
|
+
const cwd = typeof payload.cwd === 'string' && payload.cwd.length > 0 ? payload.cwd : process.cwd();
|
|
122
|
+
try {
|
|
123
|
+
recordSilent(sessionId, cwd, now);
|
|
124
|
+
}
|
|
125
|
+
catch { /* fail-open */ }
|
|
126
|
+
// 훅이 돌지 않으므로 Stop 트리거 auto-compound 도 없다 → 같은 디바운스 경로로 대신 띄운다.
|
|
127
|
+
// (프롬프트 수는 rollout 전체 기준이라 resume 된 세션에서는 Stop 경로의 훅 카운터보다 클 수 있다.)
|
|
128
|
+
try {
|
|
129
|
+
if (!(await contextGuardEnabled(deps)))
|
|
130
|
+
return 'silent-recorded';
|
|
131
|
+
const codexHome = env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
|
|
132
|
+
const rollout = findCodexRollout(codexHome, sessionId);
|
|
133
|
+
if (!rollout)
|
|
134
|
+
return 'silent-recorded';
|
|
135
|
+
const prompts = countCodexUserPrompts(rollout);
|
|
136
|
+
if (prompts < MIN_USER_PROMPTS)
|
|
137
|
+
return 'silent-recorded';
|
|
138
|
+
const spawnCompound = deps.spawnCompound ?? (async (sid, transcript, count, dir) => {
|
|
139
|
+
// 훅 경로에선 codex-adapter 가 주입하는 값 — 러너의 evidence/timing 이 rt:"codex" 로 태깅된다.
|
|
140
|
+
// (추출에 쓸 LLM host 는 이것과 무관하게 profile.default_host 로 정해진다 — Stop 경로와 동일.)
|
|
141
|
+
process.env.FORGEN_RUNTIME = 'codex';
|
|
142
|
+
const { maybeSpawnAutoCompound } = await import('../hooks/context-guard.js');
|
|
143
|
+
return maybeSpawnAutoCompound(sid, transcript, count, dir);
|
|
144
|
+
});
|
|
145
|
+
return (await spawnCompound(sessionId, rollout, prompts, cwd)) ? 'silent-compound-spawned' : 'silent-recorded';
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return 'silent-recorded';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
152
|
+
handleNotify(process.argv.slice(2)).catch(() => { });
|
|
153
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex rollout 파일 헬퍼 — ADR-016 (SessionEnd 훅 + notify 폴백 공용)
|
|
3
|
+
*
|
|
4
|
+
* rollout: `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<local ts>-<thread id>.jsonl` (compact JSONL).
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* thread id(= 훅의 session_id = notify 의 `thread-id`) 로 rollout 파일을 찾는다 (최근 날짜부터).
|
|
8
|
+
* id 는 UUID 형태만 허용한다.
|
|
9
|
+
*/
|
|
10
|
+
export declare function findCodexRollout(codexHome: string, threadId: string): string | null;
|
|
11
|
+
/** 스캔 상한. SessionEnd 훅은 3s 예산이라 전체 스트리밍(239MB ≈ 3s) 대신 raw 바이트 스캔 + 상한을 쓴다. */
|
|
12
|
+
export declare const CODEX_PROMPT_SCAN_MAX_BYTES: number;
|
|
13
|
+
/**
|
|
14
|
+
* Codex rollout 의 *실제 사용자 프롬프트* 수 — `event_msg` / `payload.type:"user_message"` 레코드.
|
|
15
|
+
*
|
|
16
|
+
* `response_item` role=user 는 AGENTS.md·환경 컨텍스트 주입까지 포함해 실측 ~1.8배 과대계수이고
|
|
17
|
+
* (51 vs 91), 줄당 수십 KB 인 tool 출력 때문에 "앞 200KB 만 JSON.parse" 로는 프롬프트 1~2개밖에 못 본다.
|
|
18
|
+
* 그래서 JSON 을 파싱하지 않고 구조 수준의 바이트 패턴을 센다 — 문자열 값 안의 같은 텍스트는 따옴표가
|
|
19
|
+
* `\"` 로 이스케이프되므로 매치되지 않는다. maxBytes 까지만 읽는다 (하한 추정).
|
|
20
|
+
*/
|
|
21
|
+
export declare function countCodexUserPrompts(rolloutPath: string, maxBytes?: number): number;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex rollout 파일 헬퍼 — ADR-016 (SessionEnd 훅 + notify 폴백 공용)
|
|
3
|
+
*
|
|
4
|
+
* rollout: `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<local ts>-<thread id>.jsonl` (compact JSONL).
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
/** rollout 탐색 시 최근 일자 디렉토리만 본다 (resume 된 오래된 세션 대비 여유). */
|
|
9
|
+
const MAX_DAY_DIRS = 45;
|
|
10
|
+
function sortedDesc(dir) {
|
|
11
|
+
try {
|
|
12
|
+
return fs.readdirSync(dir).sort().reverse();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* thread id(= 훅의 session_id = notify 의 `thread-id`) 로 rollout 파일을 찾는다 (최근 날짜부터).
|
|
20
|
+
* id 는 UUID 형태만 허용한다.
|
|
21
|
+
*/
|
|
22
|
+
export function findCodexRollout(codexHome, threadId) {
|
|
23
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(threadId))
|
|
24
|
+
return null;
|
|
25
|
+
const root = path.join(codexHome, 'sessions');
|
|
26
|
+
let scanned = 0;
|
|
27
|
+
for (const y of sortedDesc(root)) {
|
|
28
|
+
for (const m of sortedDesc(path.join(root, y))) {
|
|
29
|
+
for (const d of sortedDesc(path.join(root, y, m))) {
|
|
30
|
+
if (scanned >= MAX_DAY_DIRS)
|
|
31
|
+
return null;
|
|
32
|
+
scanned += 1;
|
|
33
|
+
const dayDir = path.join(root, y, m, d);
|
|
34
|
+
const hit = sortedDesc(dayDir).find((f) => f.startsWith('rollout-') && f.endsWith('.jsonl') && f.includes(`-${threadId}`));
|
|
35
|
+
if (hit)
|
|
36
|
+
return path.join(dayDir, hit);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const USER_PROMPT_NEEDLE = Buffer.from('"type":"user_message"');
|
|
43
|
+
const CHUNK_BYTES = 1024 * 1024;
|
|
44
|
+
/** 스캔 상한. SessionEnd 훅은 3s 예산이라 전체 스트리밍(239MB ≈ 3s) 대신 raw 바이트 스캔 + 상한을 쓴다. */
|
|
45
|
+
export const CODEX_PROMPT_SCAN_MAX_BYTES = 64 * 1024 * 1024;
|
|
46
|
+
/**
|
|
47
|
+
* Codex rollout 의 *실제 사용자 프롬프트* 수 — `event_msg` / `payload.type:"user_message"` 레코드.
|
|
48
|
+
*
|
|
49
|
+
* `response_item` role=user 는 AGENTS.md·환경 컨텍스트 주입까지 포함해 실측 ~1.8배 과대계수이고
|
|
50
|
+
* (51 vs 91), 줄당 수십 KB 인 tool 출력 때문에 "앞 200KB 만 JSON.parse" 로는 프롬프트 1~2개밖에 못 본다.
|
|
51
|
+
* 그래서 JSON 을 파싱하지 않고 구조 수준의 바이트 패턴을 센다 — 문자열 값 안의 같은 텍스트는 따옴표가
|
|
52
|
+
* `\"` 로 이스케이프되므로 매치되지 않는다. maxBytes 까지만 읽는다 (하한 추정).
|
|
53
|
+
*/
|
|
54
|
+
export function countCodexUserPrompts(rolloutPath, maxBytes = CODEX_PROMPT_SCAN_MAX_BYTES) {
|
|
55
|
+
const fd = fs.openSync(rolloutPath, 'r');
|
|
56
|
+
try {
|
|
57
|
+
const overlap = USER_PROMPT_NEEDLE.length - 1;
|
|
58
|
+
const buf = Buffer.alloc(CHUNK_BYTES + overlap);
|
|
59
|
+
let carry = 0;
|
|
60
|
+
let pos = 0;
|
|
61
|
+
let count = 0;
|
|
62
|
+
while (pos < maxBytes) {
|
|
63
|
+
const read = fs.readSync(fd, buf, carry, Math.min(CHUNK_BYTES, maxBytes - pos), pos);
|
|
64
|
+
if (read <= 0)
|
|
65
|
+
break;
|
|
66
|
+
const view = buf.subarray(0, carry + read);
|
|
67
|
+
let idx = view.indexOf(USER_PROMPT_NEEDLE);
|
|
68
|
+
while (idx !== -1) {
|
|
69
|
+
count += 1;
|
|
70
|
+
idx = view.indexOf(USER_PROMPT_NEEDLE, idx + USER_PROMPT_NEEDLE.length);
|
|
71
|
+
}
|
|
72
|
+
// 청크 경계에 걸친 패턴 대비: 끝 overlap 바이트를 다음 청크 앞에 붙인다 (needle 보다 짧아 이중계수 없음).
|
|
73
|
+
carry = Math.min(overlap, view.length);
|
|
74
|
+
view.copy(buf, 0, view.length - carry, view.length);
|
|
75
|
+
pos += read;
|
|
76
|
+
}
|
|
77
|
+
return count;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
fs.closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/dist/host/exec-host.js
CHANGED
|
@@ -82,7 +82,11 @@ export function execHost(opts) {
|
|
|
82
82
|
'--skip-git-repo-check',
|
|
83
83
|
opts.prompt,
|
|
84
84
|
];
|
|
85
|
-
|
|
85
|
+
// ADR-016 (critic m2): Codex 추출 run 에도 중첩 표식을 준다. `--ephemeral` 은 rollout 만 안 남길 뿐
|
|
86
|
+
// 사용자 config(훅·notify)는 그대로 로드한다 — 표식이 없으면 forgen 훅이 추출 세션에서 발화하고
|
|
87
|
+
// notify 폴백이 그 세션을 "훅 미발화" 로 오기록한다.
|
|
88
|
+
const codexEnv = (opts.nestedRun ?? true) ? { ...(baseOpts.env ?? {}), ...NESTED_RUN_ENV } : baseOpts.env;
|
|
89
|
+
const stdout = execFileSync('codex', args, { ...baseOpts, env: codexEnv });
|
|
86
90
|
const parsed = parseCodexJsonlOutput(stdout.toString());
|
|
87
91
|
return {
|
|
88
92
|
message: parsed.message,
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
|
|
11
11
|
* 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
|
|
12
12
|
* 5. Dev-guide skills: ~/.claude/skills/forgen-<stack>-<skill>/ 설치 (forgen-managed only)
|
|
13
|
+
* 6. verify skill: ~/.claude/skills/verify/ 설치 (ADR-016 D3 — 사용자 소유면 보존)
|
|
13
14
|
*
|
|
14
15
|
* 사용자 비-forgen 자산 보존 + 재실행 idempotent.
|
|
15
16
|
*/
|
|
@@ -21,7 +22,17 @@ export interface ClaudeInstallOptions {
|
|
|
21
22
|
dryRun?: boolean;
|
|
22
23
|
/** MCP forgen-compound 등록 여부 (default true). */
|
|
23
24
|
registerMcp?: boolean;
|
|
25
|
+
/** ADR-016 D3: user-level `verify` 스킬 설치 여부 (default true). */
|
|
26
|
+
installVerifySkill?: boolean;
|
|
24
27
|
}
|
|
28
|
+
/** ADR-016 D3 — `~/.claude/skills/verify` 설치 결과. */
|
|
29
|
+
export type VerifySkillStatus =
|
|
30
|
+
/** forgen-managed 스킬을 새로 썼거나 갱신함 */
|
|
31
|
+
'installed'
|
|
32
|
+
/** 사용자가 직접 만든 `verify` 스킬이 있어 건드리지 않음 */
|
|
33
|
+
| 'user-owned'
|
|
34
|
+
/** --no-verify-skill 또는 자산 부재 */
|
|
35
|
+
| 'skipped';
|
|
25
36
|
export interface ClaudeInstallResult {
|
|
26
37
|
homeDir: string;
|
|
27
38
|
pluginCachePath: string;
|
|
@@ -35,5 +46,8 @@ export interface ClaudeInstallResult {
|
|
|
35
46
|
skillsPath: string;
|
|
36
47
|
skillsInstalled: number;
|
|
37
48
|
skillsRemoved: number;
|
|
49
|
+
verifySkill: VerifySkillStatus;
|
|
38
50
|
}
|
|
51
|
+
/** `<skillsDir>/verify` 가 사용자 소유인가 (심링크 / 마커 없는 SKILL.md / SKILL.md 없이 다른 파일만 있음). */
|
|
52
|
+
export declare function isUserOwnedVerifySkill(skillsDir: string): boolean;
|
|
39
53
|
export declare function planClaudeInstall(opts: ClaudeInstallOptions): ClaudeInstallResult;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 3. Settings hooks injection: ~/.claude/settings.json 의 hooks 머지 (forgen entry idempotent)
|
|
11
11
|
* 4. MCP register: ~/.claude.json 에 mcpServers.forgen-compound 추가
|
|
12
12
|
* 5. Dev-guide skills: ~/.claude/skills/forgen-<stack>-<skill>/ 설치 (forgen-managed only)
|
|
13
|
+
* 6. verify skill: ~/.claude/skills/verify/ 설치 (ADR-016 D3 — 사용자 소유면 보존)
|
|
13
14
|
*
|
|
14
15
|
* 사용자 비-forgen 자산 보존 + 재실행 idempotent.
|
|
15
16
|
*/
|
|
@@ -301,6 +302,54 @@ function installDevGuideSkills(opts) {
|
|
|
301
302
|
}
|
|
302
303
|
return { skillsPath: skillsDir, skillsInstalled: installed, skillsRemoved: removed };
|
|
303
304
|
}
|
|
305
|
+
// ── 6. verify skill (ADR-016 D3) ───────────────────────────────────────
|
|
306
|
+
/** frontmatter 직후에 forgen-managed 마커가 있는 SKILL.md 만 forgen 소유로 본다. */
|
|
307
|
+
const MANAGED_SKILL_RE = /^---\n[\s\S]*?\n---\n\s*<!-- forgen-managed -->/;
|
|
308
|
+
/** `<skillsDir>/verify` 가 사용자 소유인가 (심링크 / 마커 없는 SKILL.md / SKILL.md 없이 다른 파일만 있음). */
|
|
309
|
+
export function isUserOwnedVerifySkill(skillsDir) {
|
|
310
|
+
const dir = path.join(skillsDir, 'verify');
|
|
311
|
+
try {
|
|
312
|
+
if (fs.lstatSync(dir).isSymbolicLink())
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return false; // 없음
|
|
317
|
+
}
|
|
318
|
+
const file = path.join(dir, 'SKILL.md');
|
|
319
|
+
try {
|
|
320
|
+
if (fs.lstatSync(file).isSymbolicLink())
|
|
321
|
+
return true;
|
|
322
|
+
return !MANAGED_SKILL_RE.test(fs.readFileSync(file, 'utf-8'));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// SKILL.md 없음 — 디렉토리에 다른 것이 있으면 사용자가 만들던 것
|
|
326
|
+
try {
|
|
327
|
+
return fs.readdirSync(dir).length > 0;
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Claude Code 2.1.286+ 는 project/user 스킬에 `verify` 가 있으면 커밋 직전에 실행하라고 모델에 안내한다
|
|
336
|
+
* (플러그인 스킬 `forgen:verify` 는 해당 없음). 그래서 user 레벨에 un-namespaced 로 설치한다.
|
|
337
|
+
* 사용자 소유 스킬은 절대 덮어쓰지 않는다.
|
|
338
|
+
*/
|
|
339
|
+
function installVerifySkill(opts) {
|
|
340
|
+
const src = path.join(opts.pkgRoot, 'assets', 'claude', 'skills', 'verify', 'SKILL.md');
|
|
341
|
+
if (!fs.existsSync(src))
|
|
342
|
+
return 'skipped';
|
|
343
|
+
if (isUserOwnedVerifySkill(opts.skillsDir))
|
|
344
|
+
return 'user-owned';
|
|
345
|
+
if (opts.dryRun)
|
|
346
|
+
return 'installed';
|
|
347
|
+
const dir = path.join(opts.skillsDir, 'verify');
|
|
348
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
349
|
+
// 심링크가 아니라 복사 — 마커로 소유를 판정하고, npm 경로가 바뀌어도 깨지지 않게.
|
|
350
|
+
fs.copyFileSync(src, path.join(dir, 'SKILL.md'));
|
|
351
|
+
return 'installed';
|
|
352
|
+
}
|
|
304
353
|
// ── public ─────────────────────────────────────────────────────────────
|
|
305
354
|
export function planClaudeInstall(opts) {
|
|
306
355
|
if (!opts.pkgRoot || !fs.existsSync(opts.pkgRoot)) {
|
|
@@ -324,6 +373,9 @@ export function planClaudeInstall(opts) {
|
|
|
324
373
|
? registerMcpInClaudeJson({ pkgRoot: opts.pkgRoot, claudeJsonPath, dryRun })
|
|
325
374
|
: { registered: false, alreadyPresent: false };
|
|
326
375
|
const skills = installDevGuideSkills({ pkgRoot: opts.pkgRoot, skillsDir, dryRun });
|
|
376
|
+
const verifySkill = (opts.installVerifySkill ?? true)
|
|
377
|
+
? installVerifySkill({ pkgRoot: opts.pkgRoot, skillsDir, dryRun })
|
|
378
|
+
: 'skipped';
|
|
327
379
|
return {
|
|
328
380
|
homeDir,
|
|
329
381
|
pluginCachePath: cacheDir,
|
|
@@ -337,5 +389,6 @@ export function planClaudeInstall(opts) {
|
|
|
337
389
|
skillsPath: skills.skillsPath,
|
|
338
390
|
skillsInstalled: skills.skillsInstalled,
|
|
339
391
|
skillsRemoved: skills.skillsRemoved,
|
|
392
|
+
verifySkill,
|
|
340
393
|
};
|
|
341
394
|
}
|
|
@@ -24,7 +24,23 @@ export interface CodexInstallOptions {
|
|
|
24
24
|
releaseMode?: boolean;
|
|
25
25
|
/** AGENTS.md 위치 override (default: pkgRoot 기준 자동 resolve). 격리 테스트용. */
|
|
26
26
|
agentsMdPath?: string;
|
|
27
|
+
/** ADR-016 D1: config.toml 에 forgen notify 폴백 등록 여부 (default true). */
|
|
28
|
+
registerNotify?: boolean;
|
|
27
29
|
}
|
|
30
|
+
/** ADR-016 D1 — config.toml `notify` 등록 결과. */
|
|
31
|
+
export type CodexNotifyStatus =
|
|
32
|
+
/** forgen 블록을 새로 썼거나 갱신함 */
|
|
33
|
+
'installed'
|
|
34
|
+
/** 이미 동일한 forgen 블록이 있음 */
|
|
35
|
+
| 'already-present'
|
|
36
|
+
/** 사용자가 직접 정의한 `notify` 가 있어 건드리지 않음 (단일 argv 라 병합 불가) */
|
|
37
|
+
| 'user-defined'
|
|
38
|
+
/** forgen 블록의 notify 줄이 손으로 고쳐져(여러 줄 배열 등) 안전하게 다시 쓸 수 없어 그대로 둠 */
|
|
39
|
+
| 'custom-block'
|
|
40
|
+
/** --no-notify: 블록이 없었음 */
|
|
41
|
+
| 'skipped'
|
|
42
|
+
/** --no-notify: 기존 forgen 블록을 제거함 */
|
|
43
|
+
| 'removed';
|
|
28
44
|
export interface CodexInstallResult {
|
|
29
45
|
codexHome: string;
|
|
30
46
|
hooksPath: string;
|
|
@@ -52,19 +68,47 @@ export interface CodexInstallResult {
|
|
|
52
68
|
hookTrust: CodexHookTrustAudit;
|
|
53
69
|
/** ADR-014 D2: config.toml `[features] multi_agent = true` 여부 (false 면 ch-* 에이전트 spawn 불가 → 안내) */
|
|
54
70
|
multiAgentEnabled: boolean;
|
|
71
|
+
/** ADR-016 D1: config.toml `notify` 폴백 등록 결과 */
|
|
72
|
+
notify: CodexNotifyStatus;
|
|
55
73
|
}
|
|
56
74
|
export interface CodexHookTrustAudit {
|
|
57
75
|
/** hooks.json 의 forgen hook 명령 수 (Codex 가 지원하는 이벤트만) */
|
|
58
76
|
total: number;
|
|
59
77
|
/** Codex 가 모르는 이벤트라 조용히 무시되는 forgen 엔트리 (`<event>:<i>:<j>`) — trust 대상이 아님 */
|
|
60
78
|
ignoredByCodex: string[];
|
|
61
|
-
/**
|
|
79
|
+
/** trusted_hash 가 현재 핸들러의 해시와 일치하고 꺼져 있지 않은 수 (Codex 가 실제로 실행하는 훅) */
|
|
62
80
|
trusted: number;
|
|
63
81
|
/** 신뢰 기록이 없는 hook 키 (`<event>:<i>:<j>`) */
|
|
64
82
|
untrusted: string[];
|
|
83
|
+
/**
|
|
84
|
+
* ADR-016 D2: 신뢰 기록은 있으나 핸들러가 바뀌어 해시가 어긋난 hook 키. Codex 는 `/hooks` 재승인
|
|
85
|
+
* 전까지 이 훅도 skip 한다 (이전엔 키 존재만 봐서 "trusted" 로 오표시).
|
|
86
|
+
*/
|
|
87
|
+
modified: string[];
|
|
88
|
+
/** 승인돼 있고 해시도 맞지만 사용자가 `/hooks` 에서 끈 훅 (`enabled = false`) — Codex 가 실행하지 않는다 */
|
|
89
|
+
disabled: string[];
|
|
65
90
|
/** config.toml 자체가 없거나 hooks.state 가 전혀 없으면 true (Codex 가 아직 한 번도 훅을 review 안 함) */
|
|
66
91
|
noStateRecorded: boolean;
|
|
67
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* config.toml 에 forgen notify 블록을 upsert.
|
|
95
|
+
*
|
|
96
|
+
* - Codex 의 `notify` 는 top-level 단일 argv 다. 사용자가 이미 정의했으면 **건드리지 않는다** — 그리고
|
|
97
|
+
* forgen 블록이 남아 있으면 제거한다 (중복 키 = config.toml 파싱 실패 → Codex 기동 불가).
|
|
98
|
+
* - top-level 키는 첫 테이블 헤더 앞에 와야 하므로 블록은 항상 파일 최상단(BOM 뒤)에 둔다.
|
|
99
|
+
* - 사용자가 forgen 블록의 argv 뒤에 `"--", "<prog>", …` 로 자기 notifier 를 체인해 뒀으면 그 꼬리를 보존.
|
|
100
|
+
* 블록의 notify 줄을 한 줄 JSON 으로 읽을 수 없으면(여러 줄 배열 등 손편집) 아무것도 바꾸지 않는다.
|
|
101
|
+
* - 블록 사이에 Codex 가 끼워 넣은 줄(root 키)은 블록 바로 뒤로 옮겨 보존한다.
|
|
102
|
+
*/
|
|
103
|
+
export declare function upsertNotifyBlock(currentToml: string, pkgRoot: string): {
|
|
104
|
+
content: string;
|
|
105
|
+
status: CodexNotifyStatus;
|
|
106
|
+
};
|
|
107
|
+
/** forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존. */
|
|
108
|
+
export declare function removeNotifyBlock(currentToml: string): {
|
|
109
|
+
content: string;
|
|
110
|
+
removed: boolean;
|
|
111
|
+
};
|
|
68
112
|
interface HooksFile {
|
|
69
113
|
description?: string;
|
|
70
114
|
hooks: Record<string, Array<unknown>>;
|
|
@@ -80,10 +124,28 @@ export declare const CODEX_SUPPORTED_HOOK_EVENTS: ReadonlySet<string>;
|
|
|
80
124
|
/** Codex 는 hooks.state 키에 이벤트명을 snake_case 로 쓴다 (PreToolUse → pre_tool_use). */
|
|
81
125
|
export declare function codexHookEventKey(event: string): string;
|
|
82
126
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
127
|
+
* ADR-016 D2 — Codex 0.153.4 의 hook trust 해시 재현 (`hook_hash`, discovery.rs).
|
|
128
|
+
*
|
|
129
|
+
* 해시는 *핸들러 단위*: `{event_name, matcher?, hooks:[정규화된 핸들러 1개]}` 의 canonical JSON sha256.
|
|
130
|
+
* 정규화: timeout 은 기본값/clamp 적용 후 항상 포함, async 항상 포함, statusMessage 는 있을 때만,
|
|
131
|
+
* additionalContextLimit 은 허용 이벤트에서 기본값(2500)이 아닐 때만. 파일 경로·인덱스·미지 필드는 불포함.
|
|
132
|
+
* `type:"command"` 가 아니면 null (forgen 은 command 훅만 쓴다).
|
|
133
|
+
*/
|
|
134
|
+
export declare function codexHookTrustHash(event: string, matcher: unknown, handler: {
|
|
135
|
+
type?: unknown;
|
|
136
|
+
command?: unknown;
|
|
137
|
+
timeout?: unknown;
|
|
138
|
+
async?: unknown;
|
|
139
|
+
statusMessage?: unknown;
|
|
140
|
+
additionalContextLimit?: unknown;
|
|
141
|
+
}): string | null;
|
|
142
|
+
/**
|
|
143
|
+
* hooks.json 의 forgen 엔트리 각각을 config.toml 의
|
|
144
|
+
* `[hooks.state."<hooksPath>:<event>:<groupIdx>:<hookIdx>"] trusted_hash` 와 대조한다 (ADR-014 D4).
|
|
145
|
+
*
|
|
146
|
+
* ADR-016 D2: 이전엔 *기록 유무* 만 봤다 — 그래서 핸들러가 바뀌어 Codex 가 `modified` 로 skip 하는 훅을
|
|
147
|
+
* "trusted" 로 오표시했다. 이제 Codex 와 같은 해시를 계산해 trusted / modified / untrusted 를 구분한다.
|
|
148
|
+
* 읽기 전용 대조이며 trusted_hash 를 쓰지 않는다 — Codex 의 review 정책을 우회하지 않는다.
|
|
87
149
|
*/
|
|
88
150
|
export declare function auditCodexHookTrust(opts: {
|
|
89
151
|
hooksPath: string;
|