@wooojin/forgen 0.5.5 → 0.5.7
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 +129 -0
- package/README.ja.md +5 -1
- package/README.ko.md +6 -2
- package/README.md +6 -2
- package/README.zh.md +5 -1
- 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/health-cli.js +11 -12
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/settings-lock.d.ts +7 -1
- package/dist/core/settings-lock.js +19 -4
- package/dist/core/status-cli.d.ts +1 -1
- package/dist/core/trust-layer-intent.d.ts +2 -2
- package/dist/core/uninstall.d.ts +12 -0
- package/dist/core/uninstall.js +83 -4
- package/dist/engine/solution-fixup.js +2 -2
- package/dist/engine/solution-format.js +4 -4
- package/dist/engine/solution-quarantine.js +2 -2
- 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 +52 -0
- package/dist/host/install-codex.d.ts +100 -7
- package/dist/host/install-codex.js +391 -63
- package/dist/host/install-orchestrator.d.ts +4 -0
- package/dist/host/install-orchestrator.js +23 -3
- package/dist/host/managed-marker.d.ts +23 -0
- package/dist/host/managed-marker.js +78 -0
- package/dist/host/opencode/plugin/forgen.d.ts +1 -1
- package/dist/host/uninstall-codex.d.ts +58 -0
- package/dist/host/uninstall-codex.js +246 -0
- package/package.json +5 -6
- package/plugin.json +1 -1
|
@@ -11,13 +11,18 @@
|
|
|
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';
|
|
19
|
+
import { HOOK_REGISTRY } from '../hooks/hook-registry.js';
|
|
20
|
+
import { hasManagedSkillMarker, isManagedAgentToml } from './managed-marker.js';
|
|
18
21
|
const MCP_MARKER_BEGIN = '# >>> forgen-managed-mcp';
|
|
19
22
|
const MCP_MARKER_END = '# <<< forgen-managed-mcp';
|
|
20
|
-
const
|
|
23
|
+
const NOTIFY_MARKER_BEGIN = '# >>> forgen-managed-notify';
|
|
24
|
+
const NOTIFY_MARKER_END = '# <<< forgen-managed-notify';
|
|
25
|
+
export const FORGEN_SKILL_MARKER = '<!-- forgen-managed -->';
|
|
21
26
|
const AGENTS_MD_BEGIN = '<!-- >>> forgen-managed-rules -->';
|
|
22
27
|
const AGENTS_MD_END = '<!-- <<< forgen-managed-rules -->';
|
|
23
28
|
function resolveCodexHome(opts) {
|
|
@@ -26,18 +31,35 @@ function resolveCodexHome(opts) {
|
|
|
26
31
|
// 0.4.6 fix — pkgRoot match 외에 script-marker fallback 추가.
|
|
27
32
|
// Stale install path (예: 다른 머신에서 install 한 hooks.json 마운트, 또는
|
|
28
33
|
// node_modules path 변경) 의 forgen entry 를 "user entry" 로 오분류 → 중복 누적
|
|
29
|
-
// 하던 버그.
|
|
30
|
-
//
|
|
31
|
-
|
|
34
|
+
// 하던 버그.
|
|
35
|
+
//
|
|
36
|
+
// 0.5.7 (critic): fallback 이 `dist/hooks/<아무이름>.js` 전부와 pkgRoot *부분문자열* 을 forgen 으로 봐서
|
|
37
|
+
// 다른 프로젝트의 `…/dist/hooks/pre-commit.js` 나 `<pkgRoot>-fork/hook.sh` 까지 forgen 소유로 분류했다
|
|
38
|
+
// (uninstall 이 그것을 지운다). 이제: (1) pkgRoot 의 dist/ 아래, (2) codex-adapter 경유, (3) registry 에
|
|
39
|
+
// 있는 forgen 훅 스크립트 이름 — 셋 중 하나일 때만.
|
|
40
|
+
const FORGEN_ADAPTER_RE = /[\\/]dist[\\/]host[\\/]codex-adapter\.js(?![\w-])/;
|
|
41
|
+
const FORGEN_HOOK_SCRIPT_RE = /[\\/]dist[\\/]hooks[\\/]([a-z][a-z0-9-]*)\.js(?![\w-])/;
|
|
42
|
+
const FORGEN_HOOK_SCRIPT_NAMES = new Set(HOOK_REGISTRY.map((h) => h.script.split(' ')[0].replace(/^hooks\//, '').replace(/\.js$/, '')));
|
|
43
|
+
/** 훅 command 문자열이 forgen 소유인가. */
|
|
44
|
+
export function isForgenHookCommand(command, pkgRoot) {
|
|
45
|
+
if (typeof command !== 'string')
|
|
46
|
+
return false;
|
|
47
|
+
if (command.includes(`${pkgRoot}/dist/`) || command.includes(`${pkgRoot}\\dist\\`))
|
|
48
|
+
return true;
|
|
49
|
+
if (FORGEN_ADAPTER_RE.test(command))
|
|
50
|
+
return true;
|
|
51
|
+
const script = command.match(FORGEN_HOOK_SCRIPT_RE)?.[1];
|
|
52
|
+
return script !== undefined && FORGEN_HOOK_SCRIPT_NAMES.has(script);
|
|
53
|
+
}
|
|
32
54
|
function isForgenManagedHook(entry, pkgRoot) {
|
|
33
55
|
if (!entry || typeof entry !== 'object')
|
|
34
56
|
return false;
|
|
35
57
|
const e = entry;
|
|
36
58
|
if (!Array.isArray(e.hooks))
|
|
37
59
|
return false;
|
|
38
|
-
return e.hooks.some((h) =>
|
|
60
|
+
return e.hooks.some((h) => isForgenHookCommand(h.command, pkgRoot));
|
|
39
61
|
}
|
|
40
|
-
function readJsonFile(p) {
|
|
62
|
+
export function readJsonFile(p) {
|
|
41
63
|
try {
|
|
42
64
|
if (!fs.existsSync(p))
|
|
43
65
|
return null;
|
|
@@ -47,31 +69,192 @@ function readJsonFile(p) {
|
|
|
47
69
|
return null;
|
|
48
70
|
}
|
|
49
71
|
}
|
|
50
|
-
function
|
|
72
|
+
function splitManagedSpan(lines, begin, end) {
|
|
73
|
+
const b = lines.findIndex((l) => l.trim() === begin);
|
|
74
|
+
if (b === -1)
|
|
75
|
+
return null;
|
|
76
|
+
const rel = lines.slice(b + 1).findIndex((l) => l.trim() === end);
|
|
77
|
+
if (rel === -1)
|
|
78
|
+
return null;
|
|
79
|
+
const e = b + 1 + rel;
|
|
80
|
+
return { before: lines.slice(0, b), inner: lines.slice(b + 1, e), after: lines.slice(e + 1) };
|
|
81
|
+
}
|
|
82
|
+
function trimBlankEdges(lines) {
|
|
83
|
+
let a = 0;
|
|
84
|
+
let z = lines.length;
|
|
85
|
+
while (a < z && lines[a].trim() === '')
|
|
86
|
+
a += 1;
|
|
87
|
+
while (z > a && lines[z - 1].trim() === '')
|
|
88
|
+
z -= 1;
|
|
89
|
+
return lines.slice(a, z);
|
|
90
|
+
}
|
|
91
|
+
/** BOM 은 파일 맨 앞에 있어야 한다 — 떼어 두었다가 결과 맨 앞에 다시 붙인다. 줄 끝(CRLF)도 보존. */
|
|
92
|
+
function tomlShape(toml) {
|
|
93
|
+
const bom = toml.startsWith('') ? '' : '';
|
|
94
|
+
return { bom, body: toml.slice(bom.length), cr: /\r\n/.test(toml) ? '\r' : '' };
|
|
95
|
+
}
|
|
96
|
+
const MCP_TABLE_HEADER = '[mcp_servers.forgen-compound]';
|
|
97
|
+
const MCP_OWN_KEY_RE = /^\s*(command|args)\s*=/;
|
|
98
|
+
function upsertMcpBlock(currentToml, pkgRoot) {
|
|
99
|
+
const { bom, body, cr } = tomlShape(currentToml);
|
|
100
|
+
const serverPath = path.join(pkgRoot, 'dist', 'mcp', 'server.js');
|
|
51
101
|
// forgen-mcp 는 dist/mcp/server.js. node 경로는 PATH 기반.
|
|
52
102
|
// `--host=codex` 인자는 server.ts 가 process.env.FORGEN_HOST 로 set 하여
|
|
53
103
|
// correction-record evidence 박제 시 host:"codex" 로 정확히 태깅되게 한다 (spec §10-5).
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
104
|
+
const ownKeys = ['command = "node"', `args = [${JSON.stringify(serverPath)}, "--host=codex"]`];
|
|
105
|
+
const lines = body.split('\n');
|
|
106
|
+
const span = splitManagedSpan(lines, MCP_MARKER_BEGIN, MCP_MARKER_END);
|
|
107
|
+
if (!span) {
|
|
108
|
+
// 마커 없이 같은 테이블이 있으면(사용자 직접 작성 / 마커 손상) append 하지 않는다 — 중복 테이블 = 파싱 실패.
|
|
109
|
+
if (lines.some((l) => l.trim() === MCP_TABLE_HEADER))
|
|
110
|
+
return { content: currentToml, alreadyPresent: true };
|
|
111
|
+
const block = [MCP_MARKER_BEGIN, MCP_TABLE_HEADER, ...ownKeys, MCP_MARKER_END].map((l) => l + cr).join('\n');
|
|
112
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
113
|
+
const sep = trimmed.length > 0 ? `${cr}\n${cr}\n` : '';
|
|
114
|
+
return { content: `${bom}${trimmed}${sep}${block}\n`, alreadyPresent: false };
|
|
115
|
+
}
|
|
116
|
+
const h = span.inner.findIndex((l) => l.trim() === MCP_TABLE_HEADER);
|
|
117
|
+
let extraKeys = [];
|
|
118
|
+
let foreign = span.inner;
|
|
119
|
+
if (h !== -1) {
|
|
120
|
+
const afterHeader = span.inner.slice(h + 1);
|
|
121
|
+
const next = afterHeader.findIndex((l) => /^\s*\[/.test(l));
|
|
122
|
+
const tableBody = next === -1 ? afterHeader : afterHeader.slice(0, next);
|
|
123
|
+
// 손으로 고쳐 여러 줄이 된 command/args 는 안전하게 다시 쓸 수 없다 — 블록을 그대로 둔다.
|
|
124
|
+
const own = tableBody.filter((l) => MCP_OWN_KEY_RE.test(l));
|
|
125
|
+
if (own.some((l) => !/(["'\]])\s*(#.*)?$/.test(l.trim())))
|
|
126
|
+
return { content: currentToml, alreadyPresent: true };
|
|
127
|
+
// 사용자가 Codex 로 이 서버에 붙인 설정(enabled, startup_timeout_sec …)은 테이블 안에 유지.
|
|
128
|
+
extraKeys = tableBody.filter((l) => l.trim() !== '' && !MCP_OWN_KEY_RE.test(l)).map((l) => l.replace(/\r$/, ''));
|
|
129
|
+
foreign = [...span.inner.slice(0, h), ...(next === -1 ? [] : afterHeader.slice(next))];
|
|
130
|
+
}
|
|
131
|
+
const block = [MCP_MARKER_BEGIN, MCP_TABLE_HEADER, ...ownKeys, ...extraKeys, MCP_MARKER_END].map((l) => l + cr);
|
|
132
|
+
const moved = trimBlankEdges(foreign);
|
|
133
|
+
const out = [...span.before, ...block, ...(moved.length > 0 ? [cr, ...moved] : []), ...span.after];
|
|
134
|
+
const content = bom + out.join('\n');
|
|
135
|
+
return { content, alreadyPresent: content === currentToml };
|
|
62
136
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
137
|
+
/**
|
|
138
|
+
* forgen MCP 블록 제거 (uninstall, ADR-016 D4). forgen 테이블(본문 + `[mcp_servers.forgen-compound.*]` 하위
|
|
139
|
+
* 테이블)과 마커만 걷어내고, 블록 사이에 Codex 가 끼워 넣은 다른 내용은 그 자리에 보존한다.
|
|
140
|
+
* 마커 없는 사용자 관리 테이블은 건드리지 않는다.
|
|
141
|
+
*/
|
|
142
|
+
export function removeMcpBlock(currentToml) {
|
|
143
|
+
const { bom, body } = tomlShape(currentToml);
|
|
144
|
+
const span = splitManagedSpan(body.split('\n'), MCP_MARKER_BEGIN, MCP_MARKER_END);
|
|
145
|
+
if (!span)
|
|
146
|
+
return { content: currentToml, removed: false };
|
|
147
|
+
const kept = [];
|
|
148
|
+
let dropping = false;
|
|
149
|
+
for (const line of span.inner) {
|
|
150
|
+
const t = line.trim();
|
|
151
|
+
// 헤더 줄에서만 상태를 바꾼다: forgen 테이블 본체, 그 하위 테이블/배열 테이블은 버리고 나머지는 보존.
|
|
152
|
+
if (/^\[/.test(t))
|
|
153
|
+
dropping = /^\[{1,2}mcp_servers\.forgen-compound(\]{1,2}|\.)/.test(t);
|
|
154
|
+
if (!dropping)
|
|
155
|
+
kept.push(line);
|
|
156
|
+
}
|
|
157
|
+
const foreign = trimBlankEdges(kept);
|
|
158
|
+
const before = [...span.before];
|
|
159
|
+
// 블록 앞의 구분용 빈 줄은 블록과 함께 정리 (재설치/제거를 반복해도 빈 줄이 쌓이지 않게)
|
|
160
|
+
if (foreign.length === 0)
|
|
161
|
+
while (before.length > 0 && before[before.length - 1].trim() === '')
|
|
162
|
+
before.pop();
|
|
163
|
+
const out = [...before, ...foreign, ...span.after];
|
|
164
|
+
while (out.length > 1 && out[0].trim() === '')
|
|
165
|
+
out.shift(); // 파일 맨 앞 빈 줄
|
|
166
|
+
return { content: bom + out.join('\n'), removed: true };
|
|
167
|
+
}
|
|
168
|
+
// ── ADR-016 D1: notify 폴백 (config.toml top-level `notify`) ───────────
|
|
169
|
+
/** forgen notify 바이너리 argv 접두 (`--` 뒤는 사용자가 수동으로 붙인 체인 프로그램). */
|
|
170
|
+
function forgenNotifyArgv(pkgRoot) {
|
|
171
|
+
return ['node', path.join(pkgRoot, 'dist', 'host', 'codex-notify.js')];
|
|
172
|
+
}
|
|
173
|
+
const NOTIFY_OWN_COMMENTS = [
|
|
174
|
+
'# forgen turn-complete fallback (ADR-016): works even while forgen hooks are untrusted.',
|
|
175
|
+
'# To chain your own notifier, append: "--", "<program>", "<args…>" (kept across re-install).',
|
|
176
|
+
];
|
|
177
|
+
/** `notify`, `"notify"`, `'notify'` 키 (dotted `notify.x` 포함) — 어느 것이든 forgen 의 notify 와 충돌한다. */
|
|
178
|
+
const NOTIFY_KEY_RE = /^[ \t]*(?:notify|"notify"|'notify')[ \t]*[.=]/;
|
|
179
|
+
function parseNotifyBlock(lines) {
|
|
180
|
+
const span = splitManagedSpan(lines, NOTIFY_MARKER_BEGIN, NOTIFY_MARKER_END);
|
|
181
|
+
if (!span)
|
|
182
|
+
return { span: null, foreign: [], argv: null };
|
|
183
|
+
const idx = span.inner.findIndex((l) => NOTIFY_KEY_RE.test(l));
|
|
184
|
+
let argv = null;
|
|
185
|
+
if (idx !== -1) {
|
|
186
|
+
argv = 'unparseable';
|
|
187
|
+
const value = span.inner[idx].trim().match(/^notify[ \t]*=[ \t]*(\[.*\])$/)?.[1];
|
|
188
|
+
try {
|
|
189
|
+
const parsed = value ? JSON.parse(value) : null;
|
|
190
|
+
if (Array.isArray(parsed) && parsed.every((a) => typeof a === 'string'))
|
|
191
|
+
argv = parsed;
|
|
192
|
+
}
|
|
193
|
+
catch { /* multi-line / single-quoted / trailing comment — 아래에서 블록을 그대로 둔다 */ }
|
|
194
|
+
}
|
|
195
|
+
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'));
|
|
196
|
+
return { span, foreign, argv };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* config.toml 에 forgen notify 블록을 upsert.
|
|
200
|
+
*
|
|
201
|
+
* - Codex 의 `notify` 는 top-level 단일 argv 다. 사용자가 이미 정의했으면 **건드리지 않는다** — 그리고
|
|
202
|
+
* forgen 블록이 남아 있으면 제거한다 (중복 키 = config.toml 파싱 실패 → Codex 기동 불가).
|
|
203
|
+
* - top-level 키는 첫 테이블 헤더 앞에 와야 하므로 블록은 항상 파일 최상단(BOM 뒤)에 둔다.
|
|
204
|
+
* - 사용자가 forgen 블록의 argv 뒤에 `"--", "<prog>", …` 로 자기 notifier 를 체인해 뒀으면 그 꼬리를 보존.
|
|
205
|
+
* 블록의 notify 줄을 한 줄 JSON 으로 읽을 수 없으면(여러 줄 배열 등 손편집) 아무것도 바꾸지 않는다.
|
|
206
|
+
* - 블록 사이에 Codex 가 끼워 넣은 줄(root 키)은 블록 바로 뒤로 옮겨 보존한다.
|
|
207
|
+
*/
|
|
208
|
+
export function upsertNotifyBlock(currentToml, pkgRoot) {
|
|
209
|
+
const { bom, body, cr } = tomlShape(currentToml);
|
|
210
|
+
const lines = body.split('\n');
|
|
211
|
+
const { span, foreign, argv: existingArgv } = parseNotifyBlock(lines);
|
|
212
|
+
const outside = span ? [...span.before, ...span.after] : lines;
|
|
213
|
+
// 보수적 판정: 블록 밖 어디든 `notify =` 줄이 있으면 사용자 정의로 본다 (프로필 테이블 안이어도 skip —
|
|
214
|
+
// 폴백을 못 넣는 쪽이 config 를 깨뜨리는 쪽보다 낫다).
|
|
215
|
+
if (outside.some((l) => NOTIFY_KEY_RE.test(l))) {
|
|
216
|
+
if (!span)
|
|
217
|
+
return { content: currentToml, status: 'user-defined' };
|
|
218
|
+
return { content: bom + [...span.before, ...foreign, ...span.after].join('\n'), status: 'user-defined' };
|
|
219
|
+
}
|
|
220
|
+
if (existingArgv === 'unparseable')
|
|
221
|
+
return { content: currentToml, status: 'custom-block' };
|
|
222
|
+
const sep = existingArgv ? existingArgv.indexOf('--') : -1;
|
|
223
|
+
const chainTail = existingArgv && sep !== -1 ? existingArgv.slice(sep) : [];
|
|
224
|
+
const argv = [...forgenNotifyArgv(pkgRoot), ...chainTail];
|
|
225
|
+
const block = [NOTIFY_MARKER_BEGIN, ...NOTIFY_OWN_COMMENTS, `notify = ${JSON.stringify(argv)}`, NOTIFY_MARKER_END].map((l) => l + cr);
|
|
226
|
+
const rest = span ? [...span.before, ...foreign, ...span.after] : lines;
|
|
227
|
+
let start = 0;
|
|
228
|
+
while (start < rest.length && rest[start].trim() === '')
|
|
229
|
+
start += 1;
|
|
230
|
+
const tail = rest.slice(start);
|
|
231
|
+
const content = bom + (tail.length > 0 ? [...block, cr, ...tail] : [...block, '']).join('\n');
|
|
232
|
+
return { content, status: content === currentToml ? 'already-present' : 'installed' };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존.
|
|
236
|
+
*
|
|
237
|
+
* - 사용자가 블록의 notify 줄을 여러 줄 배열 등으로 손편집했으면(`custom`) **건드리지 않는다** — 첫 줄만
|
|
238
|
+
* 지우면 남은 줄이 깨진 TOML 이 되어 Codex 가 기동하지 못한다 (critic 2026-10-02).
|
|
239
|
+
* - `"--"` 뒤에 사용자가 체인해 둔 자기 notifier 가 있으면 그 argv 만으로 `notify` 를 되돌려 놓는다.
|
|
240
|
+
*/
|
|
241
|
+
export function removeNotifyBlock(currentToml) {
|
|
242
|
+
const { bom, body, cr } = tomlShape(currentToml);
|
|
243
|
+
const { span, foreign, argv } = parseNotifyBlock(body.split('\n'));
|
|
244
|
+
if (!span)
|
|
245
|
+
return { content: currentToml, removed: false, custom: false, restoredChain: [] };
|
|
246
|
+
if (argv === 'unparseable')
|
|
247
|
+
return { content: currentToml, removed: false, custom: true, restoredChain: [] };
|
|
248
|
+
const sep = argv ? argv.indexOf('--') : -1;
|
|
249
|
+
const restoredChain = argv && sep !== -1 ? argv.slice(sep + 1) : [];
|
|
250
|
+
const restored = restoredChain.length > 0 ? [`notify = ${JSON.stringify(restoredChain)}${cr}`] : [];
|
|
251
|
+
const rest = [...span.before, ...restored, ...foreign, ...span.after];
|
|
252
|
+
let start = 0;
|
|
253
|
+
if (span.before.every((l) => l.trim() === '') && restored.length === 0 && foreign.length === 0) {
|
|
254
|
+
while (start < rest.length && rest[start].trim() === '')
|
|
255
|
+
start += 1;
|
|
256
|
+
}
|
|
257
|
+
return { content: bom + rest.slice(start).join('\n'), removed: true, custom: false, restoredChain };
|
|
75
258
|
}
|
|
76
259
|
export function planCodexInstall(opts) {
|
|
77
260
|
const codexHome = resolveCodexHome(opts);
|
|
@@ -101,7 +284,11 @@ export function planCodexInstall(opts) {
|
|
|
101
284
|
const out = [];
|
|
102
285
|
let inserted = false;
|
|
103
286
|
for (const group of existingGroups) {
|
|
104
|
-
|
|
287
|
+
// 빈 그룹(`{"hooks": []}`)은 uninstall 이 다른 도구 훅의 trust 인덱스를 지키려고 남긴 자리표시다 —
|
|
288
|
+
// 재설치 시 그 자리를 다시 채워 forgen 훅이 원래 인덱스(= 이미 승인된 trust 키)로 돌아가게 한다.
|
|
289
|
+
const isPlaceholder = Array.isArray(group?.hooks)
|
|
290
|
+
&& (group.hooks.length === 0);
|
|
291
|
+
if (isForgenManagedHook(group, opts.pkgRoot) || isPlaceholder) {
|
|
105
292
|
if (!inserted) {
|
|
106
293
|
out.push(...generatedGroups);
|
|
107
294
|
inserted = true;
|
|
@@ -131,22 +318,38 @@ export function planCodexInstall(opts) {
|
|
|
131
318
|
const registerMcp = opts.registerMcp ?? true;
|
|
132
319
|
let mcpAlreadyPresent = false;
|
|
133
320
|
let mcpRegistered = false;
|
|
134
|
-
|
|
321
|
+
const currentToml = fs.existsSync(configTomlPath) ? fs.readFileSync(configTomlPath, 'utf-8') : '';
|
|
322
|
+
let configToml = currentToml;
|
|
135
323
|
if (registerMcp) {
|
|
136
|
-
const
|
|
137
|
-
? fs.readFileSync(configTomlPath, 'utf-8')
|
|
138
|
-
: '';
|
|
139
|
-
const { content, alreadyPresent } = upsertMcpBlock(currentToml, opts.pkgRoot);
|
|
324
|
+
const { content, alreadyPresent } = upsertMcpBlock(configToml, opts.pkgRoot);
|
|
140
325
|
mcpAlreadyPresent = alreadyPresent;
|
|
141
326
|
mcpRegistered = !alreadyPresent;
|
|
142
|
-
|
|
327
|
+
configToml = content;
|
|
328
|
+
}
|
|
329
|
+
// 4b) ADR-016 D1: notify 폴백 (사용자 notify 가 있으면 보존)
|
|
330
|
+
let notify = 'skipped';
|
|
331
|
+
if (opts.registerNotify ?? true) {
|
|
332
|
+
const r = upsertNotifyBlock(configToml, opts.pkgRoot);
|
|
333
|
+
notify = r.status;
|
|
334
|
+
configToml = r.content;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
// opt-out 은 "더 이상 등록하지 않음" 이 아니라 "없앰" 이어야 한다 (이전 설치의 블록이 남지 않게).
|
|
338
|
+
const r = removeNotifyBlock(configToml);
|
|
339
|
+
if (r.removed) {
|
|
340
|
+
notify = 'removed';
|
|
341
|
+
configToml = r.content;
|
|
342
|
+
}
|
|
343
|
+
else if (r.custom)
|
|
344
|
+
notify = 'custom-block';
|
|
143
345
|
}
|
|
346
|
+
const configTomlToWrite = configToml !== currentToml ? configToml : null;
|
|
144
347
|
// 5) 실제 쓰기 (dryRun 이면 skip) — hooks.json + config.toml
|
|
145
348
|
if (!opts.dryRun) {
|
|
146
349
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
147
350
|
fs.writeFileSync(hooksPath, `${JSON.stringify(finalHooksFile, null, 2)}\n`, 'utf-8');
|
|
148
|
-
if (
|
|
149
|
-
fs.writeFileSync(configTomlPath,
|
|
351
|
+
if (configTomlToWrite !== null) {
|
|
352
|
+
fs.writeFileSync(configTomlPath, configTomlToWrite, 'utf-8');
|
|
150
353
|
}
|
|
151
354
|
}
|
|
152
355
|
// 6) P3-3 (US-013): Codex skills/ 에 forgen 10 commands install
|
|
@@ -178,9 +381,9 @@ export function planCodexInstall(opts) {
|
|
|
178
381
|
configTomlPath,
|
|
179
382
|
pkgRoot: opts.pkgRoot,
|
|
180
383
|
hooksFile: opts.dryRun ? (existing ?? finalHooksFile) : finalHooksFile,
|
|
181
|
-
configToml:
|
|
384
|
+
configToml: opts.dryRun ? currentToml : configToml,
|
|
182
385
|
});
|
|
183
|
-
const multiAgentEnabled = isCodexMultiAgentEnabled(
|
|
386
|
+
const multiAgentEnabled = isCodexMultiAgentEnabled(configToml);
|
|
184
387
|
return {
|
|
185
388
|
codexHome,
|
|
186
389
|
hooksPath,
|
|
@@ -202,6 +405,7 @@ export function planCodexInstall(opts) {
|
|
|
202
405
|
agentsRemoved: codexAgents.removed,
|
|
203
406
|
hookTrust,
|
|
204
407
|
multiAgentEnabled,
|
|
408
|
+
notify,
|
|
205
409
|
};
|
|
206
410
|
}
|
|
207
411
|
/** `[features]` 섹션 안에 `multi_agent = true` 가 있는지 (TOML 라이브러리 없이 섹션 범위만 본다). */
|
|
@@ -224,25 +428,118 @@ export const CODEX_SUPPORTED_HOOK_EVENTS = new Set([
|
|
|
224
428
|
export function codexHookEventKey(event) {
|
|
225
429
|
return event.replace(/(?<!^)([A-Z])/g, '_$1').toLowerCase();
|
|
226
430
|
}
|
|
431
|
+
/** `additionalContextLimit` 을 인정하는 이벤트 (codex-rs/hooks/src/engine/discovery.rs). */
|
|
432
|
+
const CODEX_CONTEXT_LIMIT_EVENTS = new Set([
|
|
433
|
+
'PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'SubagentStart',
|
|
434
|
+
]);
|
|
435
|
+
/** matcher 를 해시에서 제외하는 이벤트 (Codex 가 matcher 를 무시). */
|
|
436
|
+
const CODEX_NO_MATCHER_EVENTS = new Set(['UserPromptSubmit', 'Stop', 'Interrupt']);
|
|
437
|
+
const CODEX_DEFAULT_CONTEXT_LIMIT = 2500;
|
|
438
|
+
/** 키 정렬 + compact JSON (codex-rs/config/src/fingerprint.rs `canonical_json`). */
|
|
439
|
+
function canonicalJson(value) {
|
|
440
|
+
if (Array.isArray(value))
|
|
441
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
442
|
+
if (value && typeof value === 'object') {
|
|
443
|
+
const obj = value;
|
|
444
|
+
return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(',')}}`;
|
|
445
|
+
}
|
|
446
|
+
return JSON.stringify(value);
|
|
447
|
+
}
|
|
227
448
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
449
|
+
* ADR-016 D2 — Codex 0.153.4 의 hook trust 해시 재현 (`hook_hash`, discovery.rs).
|
|
450
|
+
*
|
|
451
|
+
* 해시는 *핸들러 단위*: `{event_name, matcher?, hooks:[정규화된 핸들러 1개]}` 의 canonical JSON sha256.
|
|
452
|
+
* 정규화: timeout 은 기본값/clamp 적용 후 항상 포함, async 항상 포함, statusMessage 는 있을 때만,
|
|
453
|
+
* additionalContextLimit 은 허용 이벤트에서 기본값(2500)이 아닐 때만. 파일 경로·인덱스·미지 필드는 불포함.
|
|
454
|
+
* `type:"command"` 가 아니면 null (forgen 은 command 훅만 쓴다).
|
|
455
|
+
*/
|
|
456
|
+
export function codexHookTrustHash(event, matcher, handler) {
|
|
457
|
+
if (handler.type !== 'command' || typeof handler.command !== 'string')
|
|
458
|
+
return null;
|
|
459
|
+
const rawTimeout = typeof handler.timeout === 'number' ? handler.timeout : undefined;
|
|
460
|
+
const timeout = event === 'SessionEnd' || event === 'Interrupt'
|
|
461
|
+
? Math.min(3, Math.max(1, rawTimeout ?? 1))
|
|
462
|
+
: Math.max(1, rawTimeout ?? 600);
|
|
463
|
+
const normalized = {
|
|
464
|
+
type: 'command',
|
|
465
|
+
command: handler.command,
|
|
466
|
+
timeout,
|
|
467
|
+
// upstream 은 raw 플래그를 해시한다 (SessionEnd 의 "동기로 실행" 강등은 실행 방식에만 반영).
|
|
468
|
+
async: handler.async === true,
|
|
469
|
+
};
|
|
470
|
+
if (typeof handler.statusMessage === 'string')
|
|
471
|
+
normalized.statusMessage = handler.statusMessage;
|
|
472
|
+
if (typeof handler.additionalContextLimit === 'number' &&
|
|
473
|
+
CODEX_CONTEXT_LIMIT_EVENTS.has(event) &&
|
|
474
|
+
handler.additionalContextLimit !== CODEX_DEFAULT_CONTEXT_LIMIT) {
|
|
475
|
+
normalized.additionalContextLimit = handler.additionalContextLimit;
|
|
476
|
+
}
|
|
477
|
+
const identity = { event_name: codexHookEventKey(event), hooks: [normalized] };
|
|
478
|
+
if (!CODEX_NO_MATCHER_EVENTS.has(event) && typeof matcher === 'string')
|
|
479
|
+
identity.matcher = matcher;
|
|
480
|
+
return `sha256:${crypto.createHash('sha256').update(canonicalJson(identity)).digest('hex')}`;
|
|
481
|
+
}
|
|
482
|
+
/** config.toml 의 `[hooks.state."<key>"]` (또는 literal `'<key>'`) 섹션 → trusted_hash / enabled. */
|
|
483
|
+
function parseCodexHookState(toml) {
|
|
484
|
+
const state = new Map();
|
|
485
|
+
let current = null;
|
|
486
|
+
for (const raw of toml.split('\n')) {
|
|
487
|
+
const line = raw.replace(/\r$/, '');
|
|
488
|
+
const header = line.match(/^\[hooks\.state\.(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\]\s*(#.*)?$/);
|
|
489
|
+
if (header) {
|
|
490
|
+
// basic string 은 이스케이프(\\, \") 를 풀고, literal string 은 그대로.
|
|
491
|
+
const key = header[1] !== undefined ? header[1].replace(/\\(["\\])/g, '$1') : header[2];
|
|
492
|
+
current = { hash: null, enabled: true };
|
|
493
|
+
state.set(key, current);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (/^\s*\[/.test(line)) {
|
|
497
|
+
current = null;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (current === null)
|
|
501
|
+
continue;
|
|
502
|
+
const hash = line.match(/^\s*trusted_hash\s*=\s*(?:"([^"]*)"|'([^']*)')/);
|
|
503
|
+
if (hash)
|
|
504
|
+
current.hash = hash[1] ?? hash[2];
|
|
505
|
+
if (/^\s*enabled\s*=\s*false\b/.test(line))
|
|
506
|
+
current.enabled = false;
|
|
507
|
+
}
|
|
508
|
+
return state;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* hooks.json 의 forgen 엔트리 각각을 config.toml 의
|
|
512
|
+
* `[hooks.state."<hooksPath>:<event>:<groupIdx>:<hookIdx>"] trusted_hash` 와 대조한다 (ADR-014 D4).
|
|
513
|
+
*
|
|
514
|
+
* ADR-016 D2: 이전엔 *기록 유무* 만 봤다 — 그래서 핸들러가 바뀌어 Codex 가 `modified` 로 skip 하는 훅을
|
|
515
|
+
* "trusted" 로 오표시했다. 이제 Codex 와 같은 해시를 계산해 trusted / modified / untrusted 를 구분한다.
|
|
516
|
+
* 읽기 전용 대조이며 trusted_hash 를 쓰지 않는다 — Codex 의 review 정책을 우회하지 않는다.
|
|
232
517
|
*/
|
|
233
518
|
export function auditCodexHookTrust(opts) {
|
|
234
519
|
const hooksFile = opts.hooksFile ?? readJsonFile(opts.hooksPath);
|
|
235
520
|
const toml = opts.configToml ?? (fs.existsSync(opts.configTomlPath) ? fs.readFileSync(opts.configTomlPath, 'utf-8') : '');
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
521
|
+
const state = parseCodexHookState(toml);
|
|
522
|
+
// Codex 는 $CODEX_HOME 을 canonicalize 한 경로로 키를 쓴다 — 심링크된 홈에서는 raw 경로와 다르다.
|
|
523
|
+
const keyPrefixes = [opts.hooksPath];
|
|
524
|
+
try {
|
|
525
|
+
const real = fs.realpathSync(opts.hooksPath);
|
|
526
|
+
if (real !== opts.hooksPath)
|
|
527
|
+
keyPrefixes.push(real);
|
|
242
528
|
}
|
|
529
|
+
catch { /* hooks.json 없음 (dry-run) */ }
|
|
530
|
+
const lookup = (key) => {
|
|
531
|
+
for (const prefix of keyPrefixes) {
|
|
532
|
+
const hit = state.get(`${prefix}:${key}`);
|
|
533
|
+
if (hit)
|
|
534
|
+
return hit;
|
|
535
|
+
}
|
|
536
|
+
return undefined;
|
|
537
|
+
};
|
|
243
538
|
let total = 0;
|
|
244
539
|
let trusted = 0;
|
|
245
540
|
const untrusted = [];
|
|
541
|
+
const modified = [];
|
|
542
|
+
const disabled = [];
|
|
246
543
|
const ignoredByCodex = [];
|
|
247
544
|
const events = (hooksFile?.hooks ?? {});
|
|
248
545
|
for (const [event, groups] of Object.entries(events)) {
|
|
@@ -253,9 +550,7 @@ export function auditCodexHookTrust(opts) {
|
|
|
253
550
|
if (!Array.isArray(g.hooks))
|
|
254
551
|
return;
|
|
255
552
|
g.hooks.forEach((h, hi) => {
|
|
256
|
-
|
|
257
|
-
(h.command.includes(opts.pkgRoot) || FORGEN_HOOK_SCRIPT_MARKER.test(h.command));
|
|
258
|
-
if (!isForgen)
|
|
553
|
+
if (!isForgenHookCommand(h.command, opts.pkgRoot))
|
|
259
554
|
return;
|
|
260
555
|
const key = `${codexHookEventKey(event)}:${gi}:${hi}`;
|
|
261
556
|
if (!CODEX_SUPPORTED_HOOK_EVENTS.has(event)) {
|
|
@@ -263,18 +558,28 @@ export function auditCodexHookTrust(opts) {
|
|
|
263
558
|
return;
|
|
264
559
|
}
|
|
265
560
|
total += 1;
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
else
|
|
561
|
+
const recorded = lookup(key);
|
|
562
|
+
if (!recorded || recorded.hash === null) {
|
|
269
563
|
untrusted.push(key);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (recorded.hash !== codexHookTrustHash(event, g.matcher, h)) {
|
|
567
|
+
modified.push(key);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (!recorded.enabled) {
|
|
571
|
+
disabled.push(key);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
trusted += 1;
|
|
270
575
|
});
|
|
271
576
|
});
|
|
272
577
|
}
|
|
273
|
-
return { total, trusted, untrusted, ignoredByCodex, noStateRecorded:
|
|
578
|
+
return { total, trusted, untrusted, modified, disabled, ignoredByCodex, noStateRecorded: state.size === 0 };
|
|
274
579
|
}
|
|
275
580
|
// ── ADR-014 D2: Codex custom agents (~/.codex/agents/ch-*.toml) ──────
|
|
276
|
-
const AGENT_TOML_MARKER = '# forgen-managed';
|
|
277
|
-
const AGENT_NAME_PREFIX = 'ch-';
|
|
581
|
+
export const AGENT_TOML_MARKER = '# forgen-managed';
|
|
582
|
+
export const AGENT_NAME_PREFIX = 'ch-';
|
|
278
583
|
function parseAgentMarkdown(raw) {
|
|
279
584
|
const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
280
585
|
if (!fm)
|
|
@@ -375,7 +680,7 @@ function installCodexAgents(opts) {
|
|
|
375
680
|
const st = fs.lstatSync(p);
|
|
376
681
|
if (st.isSymbolicLink())
|
|
377
682
|
return true; // 사용자 심링크 (dangling 포함) — 건드리지 않음
|
|
378
|
-
return !fs.readFileSync(p, 'utf-8')
|
|
683
|
+
return !isManagedAgentToml(fs.readFileSync(p, 'utf-8'));
|
|
379
684
|
}
|
|
380
685
|
catch {
|
|
381
686
|
return false; // 없음
|
|
@@ -398,8 +703,7 @@ function installCodexAgents(opts) {
|
|
|
398
703
|
try {
|
|
399
704
|
if (fs.lstatSync(p).isSymbolicLink())
|
|
400
705
|
continue;
|
|
401
|
-
|
|
402
|
-
if (!head.startsWith(AGENT_TOML_MARKER))
|
|
706
|
+
if (!isManagedAgentToml(fs.readFileSync(p, 'utf-8')))
|
|
403
707
|
continue;
|
|
404
708
|
fs.unlinkSync(p);
|
|
405
709
|
removed += 1;
|
|
@@ -424,7 +728,7 @@ function installCodexAgents(opts) {
|
|
|
424
728
|
// ── v0.4.9: dev-guide skills → ~/.codex/skills ────────────────────────
|
|
425
729
|
// dev-guide prefix pattern: forgen-<stack>-<skill> (e.g. forgen-react-fe-build)
|
|
426
730
|
// 반드시 stack 이 react|vue|node|go 인 것만 매칭 — forgen 자체 commands 보존
|
|
427
|
-
const DEV_GUIDE_SKILL_PATTERN = /^forgen-(react|vue|node|go)-/;
|
|
731
|
+
export const DEV_GUIDE_SKILL_PATTERN = /^forgen-(react|vue|node|go)-/;
|
|
428
732
|
function installDevGuideSkillsToCodex(opts) {
|
|
429
733
|
const devGuideRoot = path.join(opts.pkgRoot, 'assets', 'dev-guide');
|
|
430
734
|
const codexSkillsDir = path.join(opts.codexHome, 'skills');
|
|
@@ -524,8 +828,7 @@ function installCodexSkills(opts) {
|
|
|
524
828
|
// 사용자가 forgen 문서를 인용해 본문 안에 marker 가 우연히 포함될 수 있어
|
|
525
829
|
// includes() 만으론 안전 X. 정규식으로 frontmatter 종결(`---\n`) 다음 빈 줄 다음
|
|
526
830
|
// 첫 non-blank 줄에 marker 가 있는지 확인.
|
|
527
|
-
|
|
528
|
-
if (!fmMarkerRe.test(existing))
|
|
831
|
+
if (!hasManagedSkillMarker(existing))
|
|
529
832
|
continue; // 사용자 작성 또는 손상 — skip
|
|
530
833
|
}
|
|
531
834
|
const raw = fs.readFileSync(path.join(sourceDir, file), 'utf-8');
|
|
@@ -616,6 +919,31 @@ export function upsertForgenRulesInAgentsMd(opts) {
|
|
|
616
919
|
fs.writeFileSync(agentsMdPath, newContent, 'utf-8');
|
|
617
920
|
return { injected: newContent !== current };
|
|
618
921
|
}
|
|
922
|
+
/** AGENTS.md 의 forgen 블록 제거 (uninstall). 블록뿐이던 파일은 삭제한다. */
|
|
923
|
+
export function removeForgenRulesFromAgentsMd(opts) {
|
|
924
|
+
let current;
|
|
925
|
+
try {
|
|
926
|
+
current = fs.readFileSync(opts.agentsMdPath, 'utf-8');
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
return { removed: false, fileDeleted: false };
|
|
930
|
+
}
|
|
931
|
+
const re = new RegExp(`\\n*${escapeRegex(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRegex(AGENTS_MD_END)}\\n?`);
|
|
932
|
+
if (!re.test(current))
|
|
933
|
+
return { removed: false, fileDeleted: false };
|
|
934
|
+
const rest = current.replace(re, '\n').replace(/^\n+/, '');
|
|
935
|
+
const empty = rest.trim().length === 0;
|
|
936
|
+
// 심링크면 링크를 지우지 않고 대상 파일에 써 넣는다 (링크만 지우면 대상에 블록이 남는다).
|
|
937
|
+
const isLink = fs.lstatSync(opts.agentsMdPath).isSymbolicLink();
|
|
938
|
+
const deleteFile = empty && !isLink;
|
|
939
|
+
if (!opts.dryRun) {
|
|
940
|
+
if (deleteFile)
|
|
941
|
+
fs.unlinkSync(opts.agentsMdPath);
|
|
942
|
+
else
|
|
943
|
+
fs.writeFileSync(opts.agentsMdPath, empty ? '' : (rest.endsWith('\n') ? rest : `${rest}\n`), 'utf-8');
|
|
944
|
+
}
|
|
945
|
+
return { removed: true, fileDeleted: deleteFile };
|
|
946
|
+
}
|
|
619
947
|
function escapeRegex(s) {
|
|
620
948
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
621
949
|
}
|
|
@@ -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;
|