@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
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
* - MCP 등록은 TOML 라이브러리 없이 marker block 으로 idempotent 관리.
|
|
12
12
|
* - dryRun 시 파일을 쓰지 않고 결과만 반환 (테스트 + preview 용).
|
|
13
13
|
*/
|
|
14
|
+
import * as crypto from 'node:crypto';
|
|
14
15
|
import * as fs from 'node:fs';
|
|
15
16
|
import * as os from 'node:os';
|
|
16
17
|
import * as path from 'node:path';
|
|
17
18
|
import { generateHooksJson } from '../hooks/hooks-generator.js';
|
|
18
19
|
const MCP_MARKER_BEGIN = '# >>> forgen-managed-mcp';
|
|
19
20
|
const MCP_MARKER_END = '# <<< forgen-managed-mcp';
|
|
21
|
+
const NOTIFY_MARKER_BEGIN = '# >>> forgen-managed-notify';
|
|
22
|
+
const NOTIFY_MARKER_END = '# <<< forgen-managed-notify';
|
|
20
23
|
const FORGEN_SKILL_MARKER = '<!-- forgen-managed -->';
|
|
21
24
|
const AGENTS_MD_BEGIN = '<!-- >>> forgen-managed-rules -->';
|
|
22
25
|
const AGENTS_MD_END = '<!-- <<< forgen-managed-rules -->';
|
|
@@ -47,31 +50,150 @@ function readJsonFile(p) {
|
|
|
47
50
|
return null;
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
|
-
function
|
|
53
|
+
function splitManagedSpan(lines, begin, end) {
|
|
54
|
+
const b = lines.findIndex((l) => l.trim() === begin);
|
|
55
|
+
if (b === -1)
|
|
56
|
+
return null;
|
|
57
|
+
const rel = lines.slice(b + 1).findIndex((l) => l.trim() === end);
|
|
58
|
+
if (rel === -1)
|
|
59
|
+
return null;
|
|
60
|
+
const e = b + 1 + rel;
|
|
61
|
+
return { before: lines.slice(0, b), inner: lines.slice(b + 1, e), after: lines.slice(e + 1) };
|
|
62
|
+
}
|
|
63
|
+
function trimBlankEdges(lines) {
|
|
64
|
+
let a = 0;
|
|
65
|
+
let z = lines.length;
|
|
66
|
+
while (a < z && lines[a].trim() === '')
|
|
67
|
+
a += 1;
|
|
68
|
+
while (z > a && lines[z - 1].trim() === '')
|
|
69
|
+
z -= 1;
|
|
70
|
+
return lines.slice(a, z);
|
|
71
|
+
}
|
|
72
|
+
/** BOM 은 파일 맨 앞에 있어야 한다 — 떼어 두었다가 결과 맨 앞에 다시 붙인다. 줄 끝(CRLF)도 보존. */
|
|
73
|
+
function tomlShape(toml) {
|
|
74
|
+
const bom = toml.startsWith('') ? '' : '';
|
|
75
|
+
return { bom, body: toml.slice(bom.length), cr: /\r\n/.test(toml) ? '\r' : '' };
|
|
76
|
+
}
|
|
77
|
+
const MCP_TABLE_HEADER = '[mcp_servers.forgen-compound]';
|
|
78
|
+
const MCP_OWN_KEY_RE = /^\s*(command|args)\s*=/;
|
|
79
|
+
function upsertMcpBlock(currentToml, pkgRoot) {
|
|
80
|
+
const { bom, body, cr } = tomlShape(currentToml);
|
|
81
|
+
const serverPath = path.join(pkgRoot, 'dist', 'mcp', 'server.js');
|
|
51
82
|
// forgen-mcp 는 dist/mcp/server.js. node 경로는 PATH 기반.
|
|
52
83
|
// `--host=codex` 인자는 server.ts 가 process.env.FORGEN_HOST 로 set 하여
|
|
53
84
|
// correction-record evidence 박제 시 host:"codex" 로 정확히 태깅되게 한다 (spec §10-5).
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
85
|
+
const ownKeys = ['command = "node"', `args = [${JSON.stringify(serverPath)}, "--host=codex"]`];
|
|
86
|
+
const lines = body.split('\n');
|
|
87
|
+
const span = splitManagedSpan(lines, MCP_MARKER_BEGIN, MCP_MARKER_END);
|
|
88
|
+
if (!span) {
|
|
89
|
+
// 마커 없이 같은 테이블이 있으면(사용자 직접 작성 / 마커 손상) append 하지 않는다 — 중복 테이블 = 파싱 실패.
|
|
90
|
+
if (lines.some((l) => l.trim() === MCP_TABLE_HEADER))
|
|
91
|
+
return { content: currentToml, alreadyPresent: true };
|
|
92
|
+
const block = [MCP_MARKER_BEGIN, MCP_TABLE_HEADER, ...ownKeys, MCP_MARKER_END].map((l) => l + cr).join('\n');
|
|
93
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
94
|
+
const sep = trimmed.length > 0 ? `${cr}\n${cr}\n` : '';
|
|
95
|
+
return { content: `${bom}${trimmed}${sep}${block}\n`, alreadyPresent: false };
|
|
96
|
+
}
|
|
97
|
+
const h = span.inner.findIndex((l) => l.trim() === MCP_TABLE_HEADER);
|
|
98
|
+
let extraKeys = [];
|
|
99
|
+
let foreign = span.inner;
|
|
100
|
+
if (h !== -1) {
|
|
101
|
+
const afterHeader = span.inner.slice(h + 1);
|
|
102
|
+
const next = afterHeader.findIndex((l) => /^\s*\[/.test(l));
|
|
103
|
+
const tableBody = next === -1 ? afterHeader : afterHeader.slice(0, next);
|
|
104
|
+
// 손으로 고쳐 여러 줄이 된 command/args 는 안전하게 다시 쓸 수 없다 — 블록을 그대로 둔다.
|
|
105
|
+
const own = tableBody.filter((l) => MCP_OWN_KEY_RE.test(l));
|
|
106
|
+
if (own.some((l) => !/(["'\]])\s*(#.*)?$/.test(l.trim())))
|
|
107
|
+
return { content: currentToml, alreadyPresent: true };
|
|
108
|
+
// 사용자가 Codex 로 이 서버에 붙인 설정(enabled, startup_timeout_sec …)은 테이블 안에 유지.
|
|
109
|
+
extraKeys = tableBody.filter((l) => l.trim() !== '' && !MCP_OWN_KEY_RE.test(l)).map((l) => l.replace(/\r$/, ''));
|
|
110
|
+
foreign = [...span.inner.slice(0, h), ...(next === -1 ? [] : afterHeader.slice(next))];
|
|
111
|
+
}
|
|
112
|
+
const block = [MCP_MARKER_BEGIN, MCP_TABLE_HEADER, ...ownKeys, ...extraKeys, MCP_MARKER_END].map((l) => l + cr);
|
|
113
|
+
const moved = trimBlankEdges(foreign);
|
|
114
|
+
const out = [...span.before, ...block, ...(moved.length > 0 ? [cr, ...moved] : []), ...span.after];
|
|
115
|
+
const content = bom + out.join('\n');
|
|
116
|
+
return { content, alreadyPresent: content === currentToml };
|
|
62
117
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
118
|
+
// ── ADR-016 D1: notify 폴백 (config.toml top-level `notify`) ───────────
|
|
119
|
+
/** forgen notify 바이너리 argv 접두 (`--` 뒤는 사용자가 수동으로 붙인 체인 프로그램). */
|
|
120
|
+
function forgenNotifyArgv(pkgRoot) {
|
|
121
|
+
return ['node', path.join(pkgRoot, 'dist', 'host', 'codex-notify.js')];
|
|
122
|
+
}
|
|
123
|
+
const NOTIFY_OWN_COMMENTS = [
|
|
124
|
+
'# forgen turn-complete fallback (ADR-016): works even while forgen hooks are untrusted.',
|
|
125
|
+
'# To chain your own notifier, append: "--", "<program>", "<args…>" (kept across re-install).',
|
|
126
|
+
];
|
|
127
|
+
/** `notify`, `"notify"`, `'notify'` 키 (dotted `notify.x` 포함) — 어느 것이든 forgen 의 notify 와 충돌한다. */
|
|
128
|
+
const NOTIFY_KEY_RE = /^[ \t]*(?:notify|"notify"|'notify')[ \t]*[.=]/;
|
|
129
|
+
function parseNotifyBlock(lines) {
|
|
130
|
+
const span = splitManagedSpan(lines, NOTIFY_MARKER_BEGIN, NOTIFY_MARKER_END);
|
|
131
|
+
if (!span)
|
|
132
|
+
return { span: null, foreign: [], argv: null };
|
|
133
|
+
const idx = span.inner.findIndex((l) => NOTIFY_KEY_RE.test(l));
|
|
134
|
+
let argv = null;
|
|
135
|
+
if (idx !== -1) {
|
|
136
|
+
argv = 'unparseable';
|
|
137
|
+
const value = span.inner[idx].trim().match(/^notify[ \t]*=[ \t]*(\[.*\])$/)?.[1];
|
|
138
|
+
try {
|
|
139
|
+
const parsed = value ? JSON.parse(value) : null;
|
|
140
|
+
if (Array.isArray(parsed) && parsed.every((a) => typeof a === 'string'))
|
|
141
|
+
argv = parsed;
|
|
142
|
+
}
|
|
143
|
+
catch { /* multi-line / single-quoted / trailing comment — 아래에서 블록을 그대로 둔다 */ }
|
|
144
|
+
}
|
|
145
|
+
const foreign = span.inner.filter((l, i) => i !== idx && l.trim() !== '' && !l.trim().startsWith('# forgen turn-complete fallback') && !l.trim().startsWith('# To chain your own notifier'));
|
|
146
|
+
return { span, foreign, argv };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* config.toml 에 forgen notify 블록을 upsert.
|
|
150
|
+
*
|
|
151
|
+
* - Codex 의 `notify` 는 top-level 단일 argv 다. 사용자가 이미 정의했으면 **건드리지 않는다** — 그리고
|
|
152
|
+
* forgen 블록이 남아 있으면 제거한다 (중복 키 = config.toml 파싱 실패 → Codex 기동 불가).
|
|
153
|
+
* - top-level 키는 첫 테이블 헤더 앞에 와야 하므로 블록은 항상 파일 최상단(BOM 뒤)에 둔다.
|
|
154
|
+
* - 사용자가 forgen 블록의 argv 뒤에 `"--", "<prog>", …` 로 자기 notifier 를 체인해 뒀으면 그 꼬리를 보존.
|
|
155
|
+
* 블록의 notify 줄을 한 줄 JSON 으로 읽을 수 없으면(여러 줄 배열 등 손편집) 아무것도 바꾸지 않는다.
|
|
156
|
+
* - 블록 사이에 Codex 가 끼워 넣은 줄(root 키)은 블록 바로 뒤로 옮겨 보존한다.
|
|
157
|
+
*/
|
|
158
|
+
export function upsertNotifyBlock(currentToml, pkgRoot) {
|
|
159
|
+
const { bom, body, cr } = tomlShape(currentToml);
|
|
160
|
+
const lines = body.split('\n');
|
|
161
|
+
const { span, foreign, argv: existingArgv } = parseNotifyBlock(lines);
|
|
162
|
+
const outside = span ? [...span.before, ...span.after] : lines;
|
|
163
|
+
// 보수적 판정: 블록 밖 어디든 `notify =` 줄이 있으면 사용자 정의로 본다 (프로필 테이블 안이어도 skip —
|
|
164
|
+
// 폴백을 못 넣는 쪽이 config 를 깨뜨리는 쪽보다 낫다).
|
|
165
|
+
if (outside.some((l) => NOTIFY_KEY_RE.test(l))) {
|
|
166
|
+
if (!span)
|
|
167
|
+
return { content: currentToml, status: 'user-defined' };
|
|
168
|
+
return { content: bom + [...span.before, ...foreign, ...span.after].join('\n'), status: 'user-defined' };
|
|
169
|
+
}
|
|
170
|
+
if (existingArgv === 'unparseable')
|
|
171
|
+
return { content: currentToml, status: 'custom-block' };
|
|
172
|
+
const sep = existingArgv ? existingArgv.indexOf('--') : -1;
|
|
173
|
+
const chainTail = existingArgv && sep !== -1 ? existingArgv.slice(sep) : [];
|
|
174
|
+
const argv = [...forgenNotifyArgv(pkgRoot), ...chainTail];
|
|
175
|
+
const block = [NOTIFY_MARKER_BEGIN, ...NOTIFY_OWN_COMMENTS, `notify = ${JSON.stringify(argv)}`, NOTIFY_MARKER_END].map((l) => l + cr);
|
|
176
|
+
const rest = span ? [...span.before, ...foreign, ...span.after] : lines;
|
|
177
|
+
let start = 0;
|
|
178
|
+
while (start < rest.length && rest[start].trim() === '')
|
|
179
|
+
start += 1;
|
|
180
|
+
const tail = rest.slice(start);
|
|
181
|
+
const content = bom + (tail.length > 0 ? [...block, cr, ...tail] : [...block, '']).join('\n');
|
|
182
|
+
return { content, status: content === currentToml ? 'already-present' : 'installed' };
|
|
183
|
+
}
|
|
184
|
+
/** forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존. */
|
|
185
|
+
export function removeNotifyBlock(currentToml) {
|
|
186
|
+
const { bom, body } = tomlShape(currentToml);
|
|
187
|
+
const { span, foreign } = parseNotifyBlock(body.split('\n'));
|
|
188
|
+
if (!span)
|
|
189
|
+
return { content: currentToml, removed: false };
|
|
190
|
+
const rest = [...span.before, ...foreign, ...span.after];
|
|
191
|
+
let start = 0;
|
|
192
|
+
if (span.before.every((l) => l.trim() === '') && foreign.length === 0) {
|
|
193
|
+
while (start < rest.length && rest[start].trim() === '')
|
|
194
|
+
start += 1;
|
|
195
|
+
}
|
|
196
|
+
return { content: bom + rest.slice(start).join('\n'), removed: true };
|
|
75
197
|
}
|
|
76
198
|
export function planCodexInstall(opts) {
|
|
77
199
|
const codexHome = resolveCodexHome(opts);
|
|
@@ -131,22 +253,36 @@ export function planCodexInstall(opts) {
|
|
|
131
253
|
const registerMcp = opts.registerMcp ?? true;
|
|
132
254
|
let mcpAlreadyPresent = false;
|
|
133
255
|
let mcpRegistered = false;
|
|
134
|
-
|
|
256
|
+
const currentToml = fs.existsSync(configTomlPath) ? fs.readFileSync(configTomlPath, 'utf-8') : '';
|
|
257
|
+
let configToml = currentToml;
|
|
135
258
|
if (registerMcp) {
|
|
136
|
-
const
|
|
137
|
-
? fs.readFileSync(configTomlPath, 'utf-8')
|
|
138
|
-
: '';
|
|
139
|
-
const { content, alreadyPresent } = upsertMcpBlock(currentToml, opts.pkgRoot);
|
|
259
|
+
const { content, alreadyPresent } = upsertMcpBlock(configToml, opts.pkgRoot);
|
|
140
260
|
mcpAlreadyPresent = alreadyPresent;
|
|
141
261
|
mcpRegistered = !alreadyPresent;
|
|
142
|
-
|
|
262
|
+
configToml = content;
|
|
263
|
+
}
|
|
264
|
+
// 4b) ADR-016 D1: notify 폴백 (사용자 notify 가 있으면 보존)
|
|
265
|
+
let notify = 'skipped';
|
|
266
|
+
if (opts.registerNotify ?? true) {
|
|
267
|
+
const r = upsertNotifyBlock(configToml, opts.pkgRoot);
|
|
268
|
+
notify = r.status;
|
|
269
|
+
configToml = r.content;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
// opt-out 은 "더 이상 등록하지 않음" 이 아니라 "없앰" 이어야 한다 (이전 설치의 블록이 남지 않게).
|
|
273
|
+
const r = removeNotifyBlock(configToml);
|
|
274
|
+
if (r.removed) {
|
|
275
|
+
notify = 'removed';
|
|
276
|
+
configToml = r.content;
|
|
277
|
+
}
|
|
143
278
|
}
|
|
279
|
+
const configTomlToWrite = configToml !== currentToml ? configToml : null;
|
|
144
280
|
// 5) 실제 쓰기 (dryRun 이면 skip) — hooks.json + config.toml
|
|
145
281
|
if (!opts.dryRun) {
|
|
146
282
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
147
283
|
fs.writeFileSync(hooksPath, `${JSON.stringify(finalHooksFile, null, 2)}\n`, 'utf-8');
|
|
148
|
-
if (
|
|
149
|
-
fs.writeFileSync(configTomlPath,
|
|
284
|
+
if (configTomlToWrite !== null) {
|
|
285
|
+
fs.writeFileSync(configTomlPath, configTomlToWrite, 'utf-8');
|
|
150
286
|
}
|
|
151
287
|
}
|
|
152
288
|
// 6) P3-3 (US-013): Codex skills/ 에 forgen 10 commands install
|
|
@@ -178,9 +314,9 @@ export function planCodexInstall(opts) {
|
|
|
178
314
|
configTomlPath,
|
|
179
315
|
pkgRoot: opts.pkgRoot,
|
|
180
316
|
hooksFile: opts.dryRun ? (existing ?? finalHooksFile) : finalHooksFile,
|
|
181
|
-
configToml:
|
|
317
|
+
configToml: opts.dryRun ? currentToml : configToml,
|
|
182
318
|
});
|
|
183
|
-
const multiAgentEnabled = isCodexMultiAgentEnabled(
|
|
319
|
+
const multiAgentEnabled = isCodexMultiAgentEnabled(configToml);
|
|
184
320
|
return {
|
|
185
321
|
codexHome,
|
|
186
322
|
hooksPath,
|
|
@@ -202,6 +338,7 @@ export function planCodexInstall(opts) {
|
|
|
202
338
|
agentsRemoved: codexAgents.removed,
|
|
203
339
|
hookTrust,
|
|
204
340
|
multiAgentEnabled,
|
|
341
|
+
notify,
|
|
205
342
|
};
|
|
206
343
|
}
|
|
207
344
|
/** `[features]` 섹션 안에 `multi_agent = true` 가 있는지 (TOML 라이브러리 없이 섹션 범위만 본다). */
|
|
@@ -224,25 +361,118 @@ export const CODEX_SUPPORTED_HOOK_EVENTS = new Set([
|
|
|
224
361
|
export function codexHookEventKey(event) {
|
|
225
362
|
return event.replace(/(?<!^)([A-Z])/g, '_$1').toLowerCase();
|
|
226
363
|
}
|
|
364
|
+
/** `additionalContextLimit` 을 인정하는 이벤트 (codex-rs/hooks/src/engine/discovery.rs). */
|
|
365
|
+
const CODEX_CONTEXT_LIMIT_EVENTS = new Set([
|
|
366
|
+
'PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'SubagentStart',
|
|
367
|
+
]);
|
|
368
|
+
/** matcher 를 해시에서 제외하는 이벤트 (Codex 가 matcher 를 무시). */
|
|
369
|
+
const CODEX_NO_MATCHER_EVENTS = new Set(['UserPromptSubmit', 'Stop', 'Interrupt']);
|
|
370
|
+
const CODEX_DEFAULT_CONTEXT_LIMIT = 2500;
|
|
371
|
+
/** 키 정렬 + compact JSON (codex-rs/config/src/fingerprint.rs `canonical_json`). */
|
|
372
|
+
function canonicalJson(value) {
|
|
373
|
+
if (Array.isArray(value))
|
|
374
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
375
|
+
if (value && typeof value === 'object') {
|
|
376
|
+
const obj = value;
|
|
377
|
+
return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(',')}}`;
|
|
378
|
+
}
|
|
379
|
+
return JSON.stringify(value);
|
|
380
|
+
}
|
|
227
381
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
382
|
+
* ADR-016 D2 — Codex 0.153.4 의 hook trust 해시 재현 (`hook_hash`, discovery.rs).
|
|
383
|
+
*
|
|
384
|
+
* 해시는 *핸들러 단위*: `{event_name, matcher?, hooks:[정규화된 핸들러 1개]}` 의 canonical JSON sha256.
|
|
385
|
+
* 정규화: timeout 은 기본값/clamp 적용 후 항상 포함, async 항상 포함, statusMessage 는 있을 때만,
|
|
386
|
+
* additionalContextLimit 은 허용 이벤트에서 기본값(2500)이 아닐 때만. 파일 경로·인덱스·미지 필드는 불포함.
|
|
387
|
+
* `type:"command"` 가 아니면 null (forgen 은 command 훅만 쓴다).
|
|
388
|
+
*/
|
|
389
|
+
export function codexHookTrustHash(event, matcher, handler) {
|
|
390
|
+
if (handler.type !== 'command' || typeof handler.command !== 'string')
|
|
391
|
+
return null;
|
|
392
|
+
const rawTimeout = typeof handler.timeout === 'number' ? handler.timeout : undefined;
|
|
393
|
+
const timeout = event === 'SessionEnd' || event === 'Interrupt'
|
|
394
|
+
? Math.min(3, Math.max(1, rawTimeout ?? 1))
|
|
395
|
+
: Math.max(1, rawTimeout ?? 600);
|
|
396
|
+
const normalized = {
|
|
397
|
+
type: 'command',
|
|
398
|
+
command: handler.command,
|
|
399
|
+
timeout,
|
|
400
|
+
// upstream 은 raw 플래그를 해시한다 (SessionEnd 의 "동기로 실행" 강등은 실행 방식에만 반영).
|
|
401
|
+
async: handler.async === true,
|
|
402
|
+
};
|
|
403
|
+
if (typeof handler.statusMessage === 'string')
|
|
404
|
+
normalized.statusMessage = handler.statusMessage;
|
|
405
|
+
if (typeof handler.additionalContextLimit === 'number' &&
|
|
406
|
+
CODEX_CONTEXT_LIMIT_EVENTS.has(event) &&
|
|
407
|
+
handler.additionalContextLimit !== CODEX_DEFAULT_CONTEXT_LIMIT) {
|
|
408
|
+
normalized.additionalContextLimit = handler.additionalContextLimit;
|
|
409
|
+
}
|
|
410
|
+
const identity = { event_name: codexHookEventKey(event), hooks: [normalized] };
|
|
411
|
+
if (!CODEX_NO_MATCHER_EVENTS.has(event) && typeof matcher === 'string')
|
|
412
|
+
identity.matcher = matcher;
|
|
413
|
+
return `sha256:${crypto.createHash('sha256').update(canonicalJson(identity)).digest('hex')}`;
|
|
414
|
+
}
|
|
415
|
+
/** config.toml 의 `[hooks.state."<key>"]` (또는 literal `'<key>'`) 섹션 → trusted_hash / enabled. */
|
|
416
|
+
function parseCodexHookState(toml) {
|
|
417
|
+
const state = new Map();
|
|
418
|
+
let current = null;
|
|
419
|
+
for (const raw of toml.split('\n')) {
|
|
420
|
+
const line = raw.replace(/\r$/, '');
|
|
421
|
+
const header = line.match(/^\[hooks\.state\.(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\]\s*(#.*)?$/);
|
|
422
|
+
if (header) {
|
|
423
|
+
// basic string 은 이스케이프(\\, \") 를 풀고, literal string 은 그대로.
|
|
424
|
+
const key = header[1] !== undefined ? header[1].replace(/\\(["\\])/g, '$1') : header[2];
|
|
425
|
+
current = { hash: null, enabled: true };
|
|
426
|
+
state.set(key, current);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (/^\s*\[/.test(line)) {
|
|
430
|
+
current = null;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (current === null)
|
|
434
|
+
continue;
|
|
435
|
+
const hash = line.match(/^\s*trusted_hash\s*=\s*(?:"([^"]*)"|'([^']*)')/);
|
|
436
|
+
if (hash)
|
|
437
|
+
current.hash = hash[1] ?? hash[2];
|
|
438
|
+
if (/^\s*enabled\s*=\s*false\b/.test(line))
|
|
439
|
+
current.enabled = false;
|
|
440
|
+
}
|
|
441
|
+
return state;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* hooks.json 의 forgen 엔트리 각각을 config.toml 의
|
|
445
|
+
* `[hooks.state."<hooksPath>:<event>:<groupIdx>:<hookIdx>"] trusted_hash` 와 대조한다 (ADR-014 D4).
|
|
446
|
+
*
|
|
447
|
+
* ADR-016 D2: 이전엔 *기록 유무* 만 봤다 — 그래서 핸들러가 바뀌어 Codex 가 `modified` 로 skip 하는 훅을
|
|
448
|
+
* "trusted" 로 오표시했다. 이제 Codex 와 같은 해시를 계산해 trusted / modified / untrusted 를 구분한다.
|
|
449
|
+
* 읽기 전용 대조이며 trusted_hash 를 쓰지 않는다 — Codex 의 review 정책을 우회하지 않는다.
|
|
232
450
|
*/
|
|
233
451
|
export function auditCodexHookTrust(opts) {
|
|
234
452
|
const hooksFile = opts.hooksFile ?? readJsonFile(opts.hooksPath);
|
|
235
453
|
const toml = opts.configToml ?? (fs.existsSync(opts.configTomlPath) ? fs.readFileSync(opts.configTomlPath, 'utf-8') : '');
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
454
|
+
const state = parseCodexHookState(toml);
|
|
455
|
+
// Codex 는 $CODEX_HOME 을 canonicalize 한 경로로 키를 쓴다 — 심링크된 홈에서는 raw 경로와 다르다.
|
|
456
|
+
const keyPrefixes = [opts.hooksPath];
|
|
457
|
+
try {
|
|
458
|
+
const real = fs.realpathSync(opts.hooksPath);
|
|
459
|
+
if (real !== opts.hooksPath)
|
|
460
|
+
keyPrefixes.push(real);
|
|
242
461
|
}
|
|
462
|
+
catch { /* hooks.json 없음 (dry-run) */ }
|
|
463
|
+
const lookup = (key) => {
|
|
464
|
+
for (const prefix of keyPrefixes) {
|
|
465
|
+
const hit = state.get(`${prefix}:${key}`);
|
|
466
|
+
if (hit)
|
|
467
|
+
return hit;
|
|
468
|
+
}
|
|
469
|
+
return undefined;
|
|
470
|
+
};
|
|
243
471
|
let total = 0;
|
|
244
472
|
let trusted = 0;
|
|
245
473
|
const untrusted = [];
|
|
474
|
+
const modified = [];
|
|
475
|
+
const disabled = [];
|
|
246
476
|
const ignoredByCodex = [];
|
|
247
477
|
const events = (hooksFile?.hooks ?? {});
|
|
248
478
|
for (const [event, groups] of Object.entries(events)) {
|
|
@@ -263,14 +493,24 @@ export function auditCodexHookTrust(opts) {
|
|
|
263
493
|
return;
|
|
264
494
|
}
|
|
265
495
|
total += 1;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
else
|
|
496
|
+
const recorded = lookup(key);
|
|
497
|
+
if (!recorded || recorded.hash === null) {
|
|
269
498
|
untrusted.push(key);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (recorded.hash !== codexHookTrustHash(event, g.matcher, h)) {
|
|
502
|
+
modified.push(key);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (!recorded.enabled) {
|
|
506
|
+
disabled.push(key);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
trusted += 1;
|
|
270
510
|
});
|
|
271
511
|
});
|
|
272
512
|
}
|
|
273
|
-
return { total, trusted, untrusted, ignoredByCodex, noStateRecorded:
|
|
513
|
+
return { total, trusted, untrusted, modified, disabled, ignoredByCodex, noStateRecorded: state.size === 0 };
|
|
274
514
|
}
|
|
275
515
|
// ── ADR-014 D2: Codex custom agents (~/.codex/agents/ch-*.toml) ──────
|
|
276
516
|
const AGENT_TOML_MARKER = '# forgen-managed';
|
|
@@ -22,6 +22,10 @@ export interface OrchestratorOptions {
|
|
|
22
22
|
pkgRoot: string;
|
|
23
23
|
dryRun?: boolean;
|
|
24
24
|
registerMcp?: boolean;
|
|
25
|
+
/** ADR-016 D1: Codex config.toml notify 폴백 등록 (default true). */
|
|
26
|
+
registerNotify?: boolean;
|
|
27
|
+
/** ADR-016 D3: Claude user-level `verify` 스킬 설치 (default true). */
|
|
28
|
+
installVerifySkill?: boolean;
|
|
25
29
|
}
|
|
26
30
|
export interface OrchestratorResult {
|
|
27
31
|
target: InstallTarget;
|
|
@@ -97,10 +97,10 @@ export async function runInstall(opts) {
|
|
|
97
97
|
const dryRun = opts.dryRun ?? false;
|
|
98
98
|
const registerMcp = opts.registerMcp ?? true;
|
|
99
99
|
if (target === 'claude' || target === 'both') {
|
|
100
|
-
result.claude = planClaudeInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
|
|
100
|
+
result.claude = planClaudeInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp, installVerifySkill: opts.installVerifySkill });
|
|
101
101
|
}
|
|
102
102
|
if (target === 'codex' || target === 'both') {
|
|
103
|
-
result.codex = planCodexInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
|
|
103
|
+
result.codex = planCodexInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp, registerNotify: opts.registerNotify });
|
|
104
104
|
}
|
|
105
105
|
// W3-3: opencode 는 명시 타겟일 때만 설치('both' 는 claude+codex primary-pair 유지).
|
|
106
106
|
if (target === 'opencode') {
|
|
@@ -120,6 +120,12 @@ export function renderResult(result, dryRun) {
|
|
|
120
120
|
lines.push(` settings.json hooks: ${result.claude.hooksInjected}`);
|
|
121
121
|
lines.push(` MCP: ${result.claude.mcpAlreadyPresent ? 'already present' : (result.claude.mcpRegistered ? 'registered' : 'skipped')}`);
|
|
122
122
|
lines.push(` skills: ${result.claude.skillsInstalled ?? 0} installed → ${result.claude.skillsPath ?? ''}`);
|
|
123
|
+
const verifyLine = {
|
|
124
|
+
installed: 'installed → ~/.claude/skills/verify (Claude runs it right before non-docs commits)',
|
|
125
|
+
'user-owned': 'skipped — you already have your own `verify` skill (kept as is)',
|
|
126
|
+
skipped: 'skipped (--no-verify-skill)',
|
|
127
|
+
};
|
|
128
|
+
lines.push(` verify skill: ${verifyLine[result.claude.verifySkill]}`);
|
|
123
129
|
}
|
|
124
130
|
if (result.codex) {
|
|
125
131
|
lines.push('');
|
|
@@ -135,8 +141,22 @@ export function renderResult(result, dryRun) {
|
|
|
135
141
|
lines.push(` hook trust: ${t.trusted}/${t.total} trusted${t.ignoredByCodex.length ? ` (+${t.ignoredByCodex.length} Claude-only event ignored by Codex)` : ''}`);
|
|
136
142
|
}
|
|
137
143
|
else {
|
|
138
|
-
|
|
144
|
+
const pending = [...t.modified.map((k) => `${k} (modified)`), ...t.untrusted.map((k) => `${k} (new)`), ...t.disabled.map((k) => `${k} (disabled)`)];
|
|
145
|
+
lines.push(` hook trust: ${t.trusted}/${t.total} trusted — ${pending.length} hook(s) need review: ${pending.slice(0, 6).join(', ')}${pending.length > 6 ? ', …' : ''}`);
|
|
146
|
+
lines.push(' Codex skips these until approved/enabled: run `/hooks` inside codex and trust the forgen entries.');
|
|
147
|
+
if (t.modified.some((k) => k.startsWith('session_start:'))) {
|
|
148
|
+
lines.push(' ⚠ session_start is pending — the <forgen-rules> block is NOT injected into Codex sessions until you approve it.');
|
|
149
|
+
}
|
|
139
150
|
}
|
|
151
|
+
const notifyLine = {
|
|
152
|
+
installed: 'registered (turn-complete fallback — runs even while hooks are untrusted)',
|
|
153
|
+
'already-present': 'already present',
|
|
154
|
+
'user-defined': 'skipped — your config.toml already defines `notify` (kept as is). To chain forgen: notify = ["node", "<pkgRoot>/dist/host/codex-notify.js", "--", <your argv…>]',
|
|
155
|
+
'custom-block': 'left as is — the forgen notify block in config.toml was hand-edited into a form forgen will not rewrite (keep it on one line as a JSON array to let forgen manage it)',
|
|
156
|
+
skipped: 'skipped (--no-notify)',
|
|
157
|
+
removed: 'removed (--no-notify)',
|
|
158
|
+
};
|
|
159
|
+
lines.push(` notify: ${notifyLine[result.codex.notify]}`);
|
|
140
160
|
}
|
|
141
161
|
if (result.opencode) {
|
|
142
162
|
lines.push('');
|
package/package.json
CHANGED
package/plugin.json
CHANGED