@wooojin/forgen 0.5.0 → 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 +236 -0
- package/README.ja.md +16 -8
- package/README.ko.md +17 -9
- package/README.md +6 -1
- package/README.zh.md +16 -8
- package/assets/claude/skills/verify/SKILL.md +66 -0
- package/assets/shared/hook-registry.json +217 -22
- package/dist/cli.js +7 -3
- 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 +37 -0
- package/dist/core/spawn.d.ts +26 -0
- package/dist/core/spawn.js +38 -3
- package/dist/core/uninstall.d.ts +7 -0
- package/dist/core/uninstall.js +46 -1
- package/dist/engine/compound-loop.js +20 -1
- package/dist/hooks/context-guard.d.ts +3 -0
- package/dist/hooks/context-guard.js +16 -8
- package/dist/hooks/hook-config.js +5 -0
- package/dist/hooks/hook-registry.d.ts +17 -1
- package/dist/hooks/hooks-generator.d.ts +2 -0
- package/dist/hooks/hooks-generator.js +8 -1
- package/dist/hooks/session-end.d.ts +41 -0
- package/dist/hooks/session-end.js +113 -0
- package/dist/hooks/session-recovery.js +18 -1
- package/dist/hooks/shared/hook-timing.js +6 -1
- 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/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 +18 -3
- package/dist/host/install-claude.d.ts +14 -0
- package/dist/host/install-claude.js +53 -0
- package/dist/host/install-codex.d.ts +120 -0
- package/dist/host/install-codex.js +560 -52
- package/dist/host/install-orchestrator.d.ts +4 -0
- package/dist/host/install-orchestrator.js +32 -2
- 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
|
@@ -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);
|
|
@@ -85,29 +207,39 @@ export function planCodexInstall(opts) {
|
|
|
85
207
|
releaseMode,
|
|
86
208
|
});
|
|
87
209
|
const generatedHooks = generated.hooks;
|
|
88
|
-
// 2) 기존 hooks.json 읽기
|
|
210
|
+
// 2) 기존 hooks.json 읽기 — forgen 그룹은 *제자리에서* 교체, 사용자 그룹은 위치 그대로 보존.
|
|
211
|
+
// (0.5.3 critic/실머신: 이전엔 사용자 그룹을 앞으로 모으고 forgen 을 뒤에 붙여 그룹 인덱스가
|
|
212
|
+
// 바뀌었고, Codex 의 trust 키 `<event>:<groupIdx>:<hookIdx>` 가 어긋나 20/21 → 12/21 로
|
|
213
|
+
// 훅 신뢰가 깨졌다. 바이트 동일성이 곧 신뢰 보존이다.)
|
|
89
214
|
const existing = readJsonFile(hooksPath);
|
|
90
215
|
const existingHooksByEvent = (existing?.hooks ?? {});
|
|
91
|
-
const
|
|
216
|
+
const eventOrder = [...new Set([...Object.keys(existingHooksByEvent), ...Object.keys(generatedHooks)])];
|
|
217
|
+
const merged = {};
|
|
92
218
|
let preservedCount = 0;
|
|
93
|
-
for (const [event, entries] of Object.entries(existingHooksByEvent)) {
|
|
94
|
-
if (!Array.isArray(entries))
|
|
95
|
-
continue;
|
|
96
|
-
const userEntries = entries.filter((e) => !isForgenManagedHook(e, opts.pkgRoot));
|
|
97
|
-
if (userEntries.length > 0) {
|
|
98
|
-
preserved[event] = userEntries;
|
|
99
|
-
preservedCount += userEntries.length;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
// 3) merge: user 보존 + forgen fresh.
|
|
103
|
-
// `forgenCount` 는 실제 hook 명령 개수 (matcher group 내부 hooks[] 길이의 합) 로 집계한다.
|
|
104
|
-
const merged = { ...preserved };
|
|
105
219
|
let forgenCount = 0;
|
|
106
|
-
for (const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
220
|
+
for (const event of eventOrder) {
|
|
221
|
+
const existingGroups = Array.isArray(existingHooksByEvent[event]) ? existingHooksByEvent[event] : [];
|
|
222
|
+
const generatedGroups = generatedHooks[event] ?? [];
|
|
223
|
+
const out = [];
|
|
224
|
+
let inserted = false;
|
|
225
|
+
for (const group of existingGroups) {
|
|
226
|
+
if (isForgenManagedHook(group, opts.pkgRoot)) {
|
|
227
|
+
if (!inserted) {
|
|
228
|
+
out.push(...generatedGroups);
|
|
229
|
+
inserted = true;
|
|
230
|
+
}
|
|
231
|
+
// 이후 중복 forgen 그룹은 드롭 (stale 누적 방지)
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
out.push(group);
|
|
235
|
+
preservedCount += 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!inserted && generatedGroups.length > 0)
|
|
239
|
+
out.push(...generatedGroups);
|
|
240
|
+
if (out.length > 0)
|
|
241
|
+
merged[event] = out;
|
|
242
|
+
for (const group of generatedGroups) {
|
|
111
243
|
const g = group;
|
|
112
244
|
if (Array.isArray(g.hooks))
|
|
113
245
|
forgenCount += g.hooks.length;
|
|
@@ -121,22 +253,36 @@ export function planCodexInstall(opts) {
|
|
|
121
253
|
const registerMcp = opts.registerMcp ?? true;
|
|
122
254
|
let mcpAlreadyPresent = false;
|
|
123
255
|
let mcpRegistered = false;
|
|
124
|
-
|
|
256
|
+
const currentToml = fs.existsSync(configTomlPath) ? fs.readFileSync(configTomlPath, 'utf-8') : '';
|
|
257
|
+
let configToml = currentToml;
|
|
125
258
|
if (registerMcp) {
|
|
126
|
-
const
|
|
127
|
-
? fs.readFileSync(configTomlPath, 'utf-8')
|
|
128
|
-
: '';
|
|
129
|
-
const { content, alreadyPresent } = upsertMcpBlock(currentToml, opts.pkgRoot);
|
|
259
|
+
const { content, alreadyPresent } = upsertMcpBlock(configToml, opts.pkgRoot);
|
|
130
260
|
mcpAlreadyPresent = alreadyPresent;
|
|
131
261
|
mcpRegistered = !alreadyPresent;
|
|
132
|
-
|
|
262
|
+
configToml = content;
|
|
133
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
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const configTomlToWrite = configToml !== currentToml ? configToml : null;
|
|
134
280
|
// 5) 실제 쓰기 (dryRun 이면 skip) — hooks.json + config.toml
|
|
135
281
|
if (!opts.dryRun) {
|
|
136
282
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
137
283
|
fs.writeFileSync(hooksPath, `${JSON.stringify(finalHooksFile, null, 2)}\n`, 'utf-8');
|
|
138
|
-
if (
|
|
139
|
-
fs.writeFileSync(configTomlPath,
|
|
284
|
+
if (configTomlToWrite !== null) {
|
|
285
|
+
fs.writeFileSync(configTomlPath, configTomlToWrite, 'utf-8');
|
|
140
286
|
}
|
|
141
287
|
}
|
|
142
288
|
// 6) P3-3 (US-013): Codex skills/ 에 forgen 10 commands install
|
|
@@ -156,6 +302,21 @@ export function planCodexInstall(opts) {
|
|
|
156
302
|
codexHome,
|
|
157
303
|
dryRun: opts.dryRun ?? false,
|
|
158
304
|
});
|
|
305
|
+
// 9) ADR-014 D2: assets/claude/agents/*.md → ~/.codex/agents/ch-<name>.toml
|
|
306
|
+
const codexAgents = installCodexAgents({
|
|
307
|
+
sourceDir: path.join(opts.pkgRoot, 'assets', 'claude', 'agents'),
|
|
308
|
+
targetDir: path.join(codexHome, 'agents'),
|
|
309
|
+
dryRun: opts.dryRun ?? false,
|
|
310
|
+
});
|
|
311
|
+
// 10) ADR-014 D4: 훅 신뢰 감사 (dryRun 이면 현재 디스크 상태 기준)
|
|
312
|
+
const hookTrust = auditCodexHookTrust({
|
|
313
|
+
hooksPath,
|
|
314
|
+
configTomlPath,
|
|
315
|
+
pkgRoot: opts.pkgRoot,
|
|
316
|
+
hooksFile: opts.dryRun ? (existing ?? finalHooksFile) : finalHooksFile,
|
|
317
|
+
configToml: opts.dryRun ? currentToml : configToml,
|
|
318
|
+
});
|
|
319
|
+
const multiAgentEnabled = isCodexMultiAgentEnabled(configToml);
|
|
159
320
|
return {
|
|
160
321
|
codexHome,
|
|
161
322
|
hooksPath,
|
|
@@ -172,7 +333,333 @@ export function planCodexInstall(opts) {
|
|
|
172
333
|
devGuideSkillsPath: devGuideResult.devGuideSkillsPath,
|
|
173
334
|
devGuideSkillsInstalled: devGuideResult.devGuideSkillsInstalled,
|
|
174
335
|
devGuideSkillsRemoved: devGuideResult.devGuideSkillsRemoved,
|
|
336
|
+
agentsPath: codexAgents.agentsPath,
|
|
337
|
+
agentsInstalled: codexAgents.installed,
|
|
338
|
+
agentsRemoved: codexAgents.removed,
|
|
339
|
+
hookTrust,
|
|
340
|
+
multiAgentEnabled,
|
|
341
|
+
notify,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
/** `[features]` 섹션 안에 `multi_agent = true` 가 있는지 (TOML 라이브러리 없이 섹션 범위만 본다). */
|
|
345
|
+
export function isCodexMultiAgentEnabled(configToml) {
|
|
346
|
+
const m = configToml.match(/^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m);
|
|
347
|
+
if (!m)
|
|
348
|
+
return false;
|
|
349
|
+
return /^\s*multi_agent\s*=\s*true\s*$/m.test(m[1]);
|
|
350
|
+
}
|
|
351
|
+
// ── ADR-014 D4: Codex hook trust audit ────────────────────────────────
|
|
352
|
+
/**
|
|
353
|
+
* Codex 0.153 hooks 공식 이벤트 12종 (learn.chatgpt.com/docs/hooks + binary 문자열). 이 밖의 이벤트
|
|
354
|
+
* (예: Claude 전용 PostToolUseFailure) 는 Codex 가 조용히 무시하므로 trust 대상이 아니다.
|
|
355
|
+
*/
|
|
356
|
+
export const CODEX_SUPPORTED_HOOK_EVENTS = new Set([
|
|
357
|
+
'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'UserPromptSubmit',
|
|
358
|
+
'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt', 'SessionStart', 'SessionEnd',
|
|
359
|
+
]);
|
|
360
|
+
/** Codex 는 hooks.state 키에 이벤트명을 snake_case 로 쓴다 (PreToolUse → pre_tool_use). */
|
|
361
|
+
export function codexHookEventKey(event) {
|
|
362
|
+
return event.replace(/(?<!^)([A-Z])/g, '_$1').toLowerCase();
|
|
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
|
+
}
|
|
381
|
+
/**
|
|
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,
|
|
175
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 정책을 우회하지 않는다.
|
|
450
|
+
*/
|
|
451
|
+
export function auditCodexHookTrust(opts) {
|
|
452
|
+
const hooksFile = opts.hooksFile ?? readJsonFile(opts.hooksPath);
|
|
453
|
+
const toml = opts.configToml ?? (fs.existsSync(opts.configTomlPath) ? fs.readFileSync(opts.configTomlPath, 'utf-8') : '');
|
|
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);
|
|
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
|
+
};
|
|
471
|
+
let total = 0;
|
|
472
|
+
let trusted = 0;
|
|
473
|
+
const untrusted = [];
|
|
474
|
+
const modified = [];
|
|
475
|
+
const disabled = [];
|
|
476
|
+
const ignoredByCodex = [];
|
|
477
|
+
const events = (hooksFile?.hooks ?? {});
|
|
478
|
+
for (const [event, groups] of Object.entries(events)) {
|
|
479
|
+
if (!Array.isArray(groups))
|
|
480
|
+
continue;
|
|
481
|
+
groups.forEach((group, gi) => {
|
|
482
|
+
const g = group;
|
|
483
|
+
if (!Array.isArray(g.hooks))
|
|
484
|
+
return;
|
|
485
|
+
g.hooks.forEach((h, hi) => {
|
|
486
|
+
const isForgen = typeof h.command === 'string' &&
|
|
487
|
+
(h.command.includes(opts.pkgRoot) || FORGEN_HOOK_SCRIPT_MARKER.test(h.command));
|
|
488
|
+
if (!isForgen)
|
|
489
|
+
return;
|
|
490
|
+
const key = `${codexHookEventKey(event)}:${gi}:${hi}`;
|
|
491
|
+
if (!CODEX_SUPPORTED_HOOK_EVENTS.has(event)) {
|
|
492
|
+
ignoredByCodex.push(key);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
total += 1;
|
|
496
|
+
const recorded = lookup(key);
|
|
497
|
+
if (!recorded || recorded.hash === null) {
|
|
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;
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
return { total, trusted, untrusted, modified, disabled, ignoredByCodex, noStateRecorded: state.size === 0 };
|
|
514
|
+
}
|
|
515
|
+
// ── ADR-014 D2: Codex custom agents (~/.codex/agents/ch-*.toml) ──────
|
|
516
|
+
const AGENT_TOML_MARKER = '# forgen-managed';
|
|
517
|
+
const AGENT_NAME_PREFIX = 'ch-';
|
|
518
|
+
function parseAgentMarkdown(raw) {
|
|
519
|
+
const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
520
|
+
if (!fm)
|
|
521
|
+
return null;
|
|
522
|
+
const meta = {};
|
|
523
|
+
const tools = [];
|
|
524
|
+
const disallowedTools = [];
|
|
525
|
+
let currentList = null;
|
|
526
|
+
for (const line of fm[1].split('\n')) {
|
|
527
|
+
const listItem = line.match(/^\s+-\s+(.+)$/);
|
|
528
|
+
if (currentList && listItem) {
|
|
529
|
+
currentList.push(listItem[1].trim());
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
currentList = null;
|
|
533
|
+
const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
|
|
534
|
+
if (!kv)
|
|
535
|
+
continue;
|
|
536
|
+
const [, k, v] = kv;
|
|
537
|
+
if (k === 'tools' || k === 'disallowedTools') {
|
|
538
|
+
const target = k === 'tools' ? tools : disallowedTools;
|
|
539
|
+
// 인라인 배열 `tools: [Read, Bash]` 도 허용
|
|
540
|
+
const inline = v.trim().match(/^\[(.*)\]$/);
|
|
541
|
+
if (inline)
|
|
542
|
+
target.push(...inline[1].split(',').map((t) => t.trim()).filter(Boolean));
|
|
543
|
+
else
|
|
544
|
+
currentList = target;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
meta[k] = v.trim();
|
|
548
|
+
}
|
|
549
|
+
return { meta, tools, disallowedTools, body: fm[2].trim() };
|
|
550
|
+
}
|
|
551
|
+
/** TOML 문자열에 들어갈 수 없는 제어문자 제거 (탭/개행/CR 은 유지). */
|
|
552
|
+
function stripTomlControlChars(v) {
|
|
553
|
+
let out = '';
|
|
554
|
+
for (const ch of v) {
|
|
555
|
+
const code = ch.charCodeAt(0);
|
|
556
|
+
const isAllowed = (code >= 0x20 && code !== 0x7f) || code === 0x09 || code === 0x0a || code === 0x0d;
|
|
557
|
+
if (isAllowed)
|
|
558
|
+
out += ch;
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
/** TOML basic string (한 줄). JSON 문자열 문법은 TOML basic string 의 부분집합. */
|
|
563
|
+
function tomlString(v) {
|
|
564
|
+
return JSON.stringify(stripTomlControlChars(v));
|
|
565
|
+
}
|
|
566
|
+
/** TOML multi-line basic string. 백슬래시와 삼중따옴표만 이스케이프하면 된다. */
|
|
567
|
+
function tomlMultiline(v) {
|
|
568
|
+
const cleaned = stripTomlControlChars(v)
|
|
569
|
+
.split('\\').join('\\\\')
|
|
570
|
+
.split('"""').join('\\"\\"\\"');
|
|
571
|
+
return `"""\n${cleaned}\n"""`;
|
|
572
|
+
}
|
|
573
|
+
const REASONING_BY_CLAUDE_MODEL = { opus: 'high', sonnet: 'medium', haiku: 'low' };
|
|
574
|
+
/**
|
|
575
|
+
* Claude agent .md → Codex agent role TOML.
|
|
576
|
+
* 공식 스키마(필수 name/description/developer_instructions, 선택 model_reasoning_effort/sandbox_mode)
|
|
577
|
+
* 외 필드는 쓰지 않는다 — Codex 가 unknown field 를 거부 (ADR-014 D2).
|
|
578
|
+
*/
|
|
579
|
+
export function renderCodexAgentToml(file, raw) {
|
|
580
|
+
const parsed = parseAgentMarkdown(raw);
|
|
581
|
+
if (!parsed)
|
|
582
|
+
return null;
|
|
583
|
+
const base = file.replace(/\.md$/, '');
|
|
584
|
+
const name = base.startsWith(AGENT_NAME_PREFIX) ? base : `${AGENT_NAME_PREFIX}${base}`;
|
|
585
|
+
if (!/^[A-Za-z0-9 _-]+$/.test(name))
|
|
586
|
+
return null;
|
|
587
|
+
const description = parsed.meta.description || name;
|
|
588
|
+
// critic 2026-10-01: 7/14 에이전트는 `tools:` 대신 `disallowedTools: [Write, Edit]` 로 읽기전용을 선언.
|
|
589
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
590
|
+
const canWrite = parsed.tools.length > 0
|
|
591
|
+
? parsed.tools.some((t) => WRITE_TOOLS.has(t))
|
|
592
|
+
: !parsed.disallowedTools.some((t) => WRITE_TOOLS.has(t));
|
|
593
|
+
const sandbox = canWrite ? 'workspace-write' : 'read-only';
|
|
594
|
+
const effort = REASONING_BY_CLAUDE_MODEL[parsed.meta.model ?? ''] ?? 'medium';
|
|
595
|
+
const body = parsed.body.length > 0 ? parsed.body : description;
|
|
596
|
+
const toml = [
|
|
597
|
+
AGENT_TOML_MARKER,
|
|
598
|
+
`# generated by \`forgen install codex\` from assets/claude/agents/${file} — do not edit; re-generated on install`,
|
|
599
|
+
`name = ${tomlString(name)}`,
|
|
600
|
+
`description = ${tomlString(description)}`,
|
|
601
|
+
`model_reasoning_effort = ${tomlString(effort)}`,
|
|
602
|
+
`sandbox_mode = ${tomlString(sandbox)}`,
|
|
603
|
+
`developer_instructions = ${tomlMultiline(body)}`,
|
|
604
|
+
'',
|
|
605
|
+
].join('\n');
|
|
606
|
+
return { name, toml };
|
|
607
|
+
}
|
|
608
|
+
function installCodexAgents(opts) {
|
|
609
|
+
const { sourceDir, targetDir, dryRun } = opts;
|
|
610
|
+
if (!fs.existsSync(sourceDir))
|
|
611
|
+
return { agentsPath: targetDir, installed: 0, removed: 0 };
|
|
612
|
+
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'));
|
|
613
|
+
const isUserOwned = (p) => {
|
|
614
|
+
try {
|
|
615
|
+
const st = fs.lstatSync(p);
|
|
616
|
+
if (st.isSymbolicLink())
|
|
617
|
+
return true; // 사용자 심링크 (dangling 포함) — 건드리지 않음
|
|
618
|
+
return !fs.readFileSync(p, 'utf-8').slice(0, 64).startsWith(AGENT_TOML_MARKER);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
return false; // 없음
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
if (dryRun) {
|
|
625
|
+
const wouldInstall = files.filter((f) => {
|
|
626
|
+
const r = renderCodexAgentToml(f, fs.readFileSync(path.join(sourceDir, f), 'utf-8'));
|
|
627
|
+
return r !== null && !isUserOwned(path.join(targetDir, `${r.name}.toml`));
|
|
628
|
+
}).length;
|
|
629
|
+
return { agentsPath: targetDir, installed: wouldInstall, removed: 0 };
|
|
630
|
+
}
|
|
631
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
632
|
+
// stale 정리: forgen-managed 마커가 있는 ch-*.toml 만. 사용자 파일은 보존.
|
|
633
|
+
let removed = 0;
|
|
634
|
+
for (const entry of fs.readdirSync(targetDir)) {
|
|
635
|
+
if (!entry.startsWith(AGENT_NAME_PREFIX) || !entry.endsWith('.toml'))
|
|
636
|
+
continue;
|
|
637
|
+
const p = path.join(targetDir, entry);
|
|
638
|
+
try {
|
|
639
|
+
if (fs.lstatSync(p).isSymbolicLink())
|
|
640
|
+
continue;
|
|
641
|
+
const head = fs.readFileSync(p, 'utf-8').slice(0, 64);
|
|
642
|
+
if (!head.startsWith(AGENT_TOML_MARKER))
|
|
643
|
+
continue;
|
|
644
|
+
fs.unlinkSync(p);
|
|
645
|
+
removed += 1;
|
|
646
|
+
}
|
|
647
|
+
catch { /* best-effort */ }
|
|
648
|
+
}
|
|
649
|
+
let installed = 0;
|
|
650
|
+
for (const file of files) {
|
|
651
|
+
const rendered = renderCodexAgentToml(file, fs.readFileSync(path.join(sourceDir, file), 'utf-8'));
|
|
652
|
+
if (!rendered)
|
|
653
|
+
continue;
|
|
654
|
+
const dst = path.join(targetDir, `${rendered.name}.toml`);
|
|
655
|
+
if (isUserOwned(dst)) {
|
|
656
|
+
// 방금 stale 정리에서 살아남은 = 사용자 작성 (마커 없음) 또는 심링크 → 보존
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
fs.writeFileSync(dst, rendered.toml, 'utf-8');
|
|
660
|
+
installed += 1;
|
|
661
|
+
}
|
|
662
|
+
return { agentsPath: targetDir, installed, removed };
|
|
176
663
|
}
|
|
177
664
|
// ── v0.4.9: dev-guide skills → ~/.codex/skills ────────────────────────
|
|
178
665
|
// dev-guide prefix pattern: forgen-<stack>-<skill> (e.g. forgen-react-fe-build)
|
|
@@ -237,6 +724,27 @@ function installDevGuideSkillsToCodex(opts) {
|
|
|
237
724
|
return { devGuideSkillsPath: codexSkillsDir, devGuideSkillsInstalled: installed, devGuideSkillsRemoved: removed };
|
|
238
725
|
}
|
|
239
726
|
// ── P3-3: Codex skills install ────────────────────────────────────────
|
|
727
|
+
/** ADR-014 D3 — Codex 스킬에는 `$ARGUMENTS` 치환 변수가 없다. */
|
|
728
|
+
export function adaptSkillBodyForCodex(body) {
|
|
729
|
+
return body
|
|
730
|
+
.replace(/\{\$ARGUMENTS\}/g, '{the user\'s request text}')
|
|
731
|
+
.replace(/`\$ARGUMENTS`/g, 'the user\'s request text (what follows the skill name)')
|
|
732
|
+
.replace(/\$ARGUMENTS/g, 'the user\'s request text (what follows the skill name)');
|
|
733
|
+
}
|
|
734
|
+
/** ADR-014 D2/D3 — 스킬 본문의 ch-* 에이전트 참조가 Codex 에서 어떻게 해석되는지 명시. */
|
|
735
|
+
const CODEX_SKILL_HOST_NOTE = `
|
|
736
|
+
---
|
|
737
|
+
|
|
738
|
+
## Codex host note (forgen-managed)
|
|
739
|
+
|
|
740
|
+
- Sub-agents named \`ch-*\` (ch-planner, ch-executor, ch-verifier, ch-critic, ...) are installed as Codex
|
|
741
|
+
custom agents under \`$CODEX_HOME/agents/ch-*.toml\`. Spawn them with Codex's multi-agent tools when
|
|
742
|
+
available (\`[features] multi_agent = true\` in config.toml).
|
|
743
|
+
- If spawning is unavailable, call the \`invoke-agent\` tool on the \`forgen-compound\` MCP server
|
|
744
|
+
(agent_name + task) or perform that stage inline yourself. Do not skip the stage.
|
|
745
|
+
- The forgen personalized rules for this session arrive as a \`<forgen-rules host="codex">\` block at
|
|
746
|
+
session start. Treat them exactly like Claude Code's .claude/rules/.
|
|
747
|
+
`;
|
|
240
748
|
function installCodexSkills(opts) {
|
|
241
749
|
const { sourceDir, targetDir, dryRun } = opts;
|
|
242
750
|
if (!fs.existsSync(sourceDir))
|
|
@@ -264,8 +772,8 @@ function installCodexSkills(opts) {
|
|
|
264
772
|
const descMatch = raw.match(/description:\s*(.+)/);
|
|
265
773
|
const desc = descMatch?.[1]?.trim() ?? skillName;
|
|
266
774
|
const bodyMatch = raw.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
|
|
267
|
-
const body = bodyMatch?.[1]?.trim() ?? raw;
|
|
268
|
-
const out = `---\nname: ${skillName}\ndescription: ${desc}\n---\n\n${FORGEN_SKILL_MARKER}\n\n${body}\n`;
|
|
775
|
+
const body = adaptSkillBodyForCodex(bodyMatch?.[1]?.trim() ?? raw);
|
|
776
|
+
const out = `---\nname: ${skillName}\ndescription: ${desc}\n---\n\n${FORGEN_SKILL_MARKER}\n\n${body}\n${CODEX_SKILL_HOST_NOTE}`;
|
|
269
777
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
270
778
|
fs.writeFileSync(skillFile, out);
|
|
271
779
|
count += 1;
|
|
@@ -297,7 +805,7 @@ export function resolveAgentsMdPath(_pkgRoot) {
|
|
|
297
805
|
return path.join(cwd, 'AGENTS.md');
|
|
298
806
|
}
|
|
299
807
|
function buildForgenRulesBlock(pkgRoot) {
|
|
300
|
-
// forgen 의 핵심 규칙 + 사용자 profile 안내 (가벼운 헤더만 — 실 rule
|
|
808
|
+
// forgen 의 핵심 규칙 + 사용자 profile 안내 (가벼운 헤더만 — 실 rule 본문은 ADR-014 D1 의 SessionStart hook 주입)
|
|
301
809
|
const lines = [
|
|
302
810
|
AGENTS_MD_BEGIN,
|
|
303
811
|
'## forgen managed rules',
|
|
@@ -307,7 +815,7 @@ function buildForgenRulesBlock(pkgRoot) {
|
|
|
307
815
|
'- forgen-compound MCP 가 ~/.codex/config.toml 에 등록됨. 학습된 솔루션을 `compound-search` 로 조회 가능.',
|
|
308
816
|
'- 사용자 교정은 `correction-record` MCP 도구로 즉시 박제 (kind: fix-now / prefer-from-now / avoid-this).',
|
|
309
817
|
'- forgen 의 4축 profile (quality_safety / autonomy / judgment_philosophy / communication_style) 이 응답 톤 + 검증 깊이를 가이드.',
|
|
310
|
-
'-
|
|
818
|
+
'- 개인화 룰 본문은 세션 시작 시 SessionStart hook 이 `<forgen-rules host="codex">` 블록으로 주입 (ADR-014). 서브에이전트는 ~/.codex/agents/ch-*.toml.',
|
|
311
819
|
`- pkgRoot: ${pkgRoot}`,
|
|
312
820
|
AGENTS_MD_END,
|
|
313
821
|
];
|