@wooojin/forgen 0.5.0 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +167 -0
- package/README.ja.md +12 -8
- package/README.ko.md +12 -8
- package/README.md +1 -0
- package/README.zh.md +12 -8
- package/assets/shared/hook-registry.json +210 -22
- package/dist/core/auto-compound-runner.js +73 -14
- package/dist/core/changelog-cli.d.ts +4 -1
- package/dist/core/changelog-cli.js +8 -7
- package/dist/core/compound-sweep-cli.d.ts +4 -1
- package/dist/core/compound-sweep-cli.js +25 -5
- package/dist/core/doctor.js +29 -0
- package/dist/core/spawn.d.ts +26 -0
- package/dist/core/spawn.js +38 -3
- package/dist/engine/compound-loop.js +20 -1
- package/dist/hooks/context-guard.js +7 -3
- package/dist/hooks/hook-config.js +5 -0
- package/dist/hooks/hook-registry.d.ts +6 -1
- package/dist/hooks/hooks-generator.js +3 -0
- package/dist/hooks/session-end.d.ts +34 -0
- package/dist/hooks/session-end.js +100 -0
- package/dist/hooks/session-recovery.js +18 -1
- package/dist/hooks/shared/hook-timing.js +6 -1
- package/dist/host/codex-rules-context.d.ts +25 -0
- package/dist/host/codex-rules-context.js +62 -0
- package/dist/host/exec-host.d.ts +10 -0
- package/dist/host/exec-host.js +13 -2
- package/dist/host/install-codex.d.ts +58 -0
- package/dist/host/install-codex.js +291 -23
- package/dist/host/install-orchestrator.js +10 -0
- package/dist/host/invoke-agent.js +1 -0
- package/dist/host/parity-harness.js +7 -3
- package/dist/host/projection.d.ts +37 -3
- package/dist/host/projection.js +93 -42
- package/dist/store/evidence-store.js +121 -57
- package/dist/store/rule-store.d.ts +41 -0
- package/dist/store/rule-store.js +77 -2
- package/hooks/hooks.json +13 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/architecture-decision/SKILL.md +18 -0
- package/skills/calibrate/SKILL.md +18 -0
- package/skills/code-review/SKILL.md +17 -0
- package/skills/compound/SKILL.md +17 -0
- package/skills/deep-interview/SKILL.md +7 -0
- package/skills/docker/SKILL.md +18 -0
- package/skills/forge-loop/SKILL.md +23 -0
- package/skills/learn/SKILL.md +15 -0
- package/skills/retro/SKILL.md +16 -0
- package/skills/ship/SKILL.md +18 -0
|
@@ -153,13 +153,28 @@ async function main() {
|
|
|
153
153
|
// 이전엔 prepareHarness (fgx/forgen wrapper) 만 호출 → 직접 claude/codex 호출 시
|
|
154
154
|
// ~/.forgen/state/sessions/<id>.json 미생성. SessionStart hook 에서도 호출하여
|
|
155
155
|
// 양쪽 진입 경로 모두에서 session state 박제.
|
|
156
|
+
let v1RenderedRules = null;
|
|
156
157
|
try {
|
|
157
158
|
const { bootstrapV1Session } = await import('../core/v1-bootstrap.js');
|
|
158
|
-
bootstrapV1Session();
|
|
159
|
+
v1RenderedRules = bootstrapV1Session().renderedRules;
|
|
159
160
|
}
|
|
160
161
|
catch (e) {
|
|
161
162
|
log.debug('v1-bootstrap SessionStart 호출 실패 (fail-open)', e);
|
|
162
163
|
}
|
|
164
|
+
// ADR-014 D1 — Codex 에는 .claude/rules/ 로드 표면이 없으므로 같은 룰을 SessionStart
|
|
165
|
+
// additionalContext 로 주입한다 (Claude 세션은 파일 경로로 이미 로드되므로 skip).
|
|
166
|
+
const codexRulesBlock = await (async () => {
|
|
167
|
+
try {
|
|
168
|
+
const { isCodexRuntime, buildCodexRulesContext } = await import('../host/codex-rules-context.js');
|
|
169
|
+
if (!isCodexRuntime())
|
|
170
|
+
return null;
|
|
171
|
+
return await buildCodexRulesContext(sessionContext.cwd, v1RenderedRules);
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
log.debug('codex rules inject 실패 (fail-open)', e);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
163
178
|
if (!fs.existsSync(STATE_DIR)) {
|
|
164
179
|
console.log(approve());
|
|
165
180
|
return;
|
|
@@ -435,6 +450,8 @@ async function main() {
|
|
|
435
450
|
catch (e) {
|
|
436
451
|
log.debug('lifecycle check 실패', e);
|
|
437
452
|
}
|
|
453
|
+
if (codexRulesBlock)
|
|
454
|
+
recoveryMessages.unshift(codexRulesBlock);
|
|
438
455
|
if (recoveryMessages.length > 0) {
|
|
439
456
|
console.log(approveWithContext(recoveryMessages.join('\n\n'), 'SessionStart'));
|
|
440
457
|
}
|
|
@@ -16,9 +16,14 @@ const MAX_LINES = 500;
|
|
|
16
16
|
// MAX_LINES × 1.5 여유를 둠.
|
|
17
17
|
const ROTATE_SIZE_BYTES = MAX_LINES * 80 * 2; // ~80KB
|
|
18
18
|
export function recordHookTiming(hookName, durationMs, event) {
|
|
19
|
+
// ADR-015 C-G1 (critic #2): 중첩 추출 run 의 빠른 early-approve 타이밍이 p95 를 왜곡하지 않도록 기록 생략.
|
|
20
|
+
if (process.env.FORGEN_NESTED_RUN === '1')
|
|
21
|
+
return;
|
|
19
22
|
try {
|
|
20
23
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
21
|
-
|
|
24
|
+
// ADR-014 D5 — host 별 발화 구분 (codex-adapter 가 FORGEN_RUNTIME=codex 주입).
|
|
25
|
+
const rt = process.env.FORGEN_RUNTIME === 'codex' ? 'codex' : 'claude';
|
|
26
|
+
const entry = JSON.stringify({ hook: hookName, ms: durationMs, event, at: Date.now(), rt });
|
|
22
27
|
fs.appendFileSync(TIMING_LOG, `${entry}\n`);
|
|
23
28
|
// Rotate if too large — size-gated (statSync only, skip read/write 대부분의 호출)
|
|
24
29
|
try {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex 개인화 룰 주입 — ADR-014 D1
|
|
3
|
+
*
|
|
4
|
+
* Claude 는 `.claude/rules/*.md` 파일을 Claude Code 가 매 턴 로드하지만 Codex 에는 그 표면이 없다.
|
|
5
|
+
* 본 모듈은 *같은 소스* (generateClaudeRuleFiles) 를 *같은 캡* (RULE_FILE_CAPS) 으로 렌더해
|
|
6
|
+
* SessionStart additionalContext 로 넣을 블록을 만든다. hooks.json 을 바꾸지 않기 위해 새 훅이
|
|
7
|
+
* 아니라 session-recovery 내부의 codex 분기에서 호출된다.
|
|
8
|
+
*
|
|
9
|
+
* 컴팩션 후 재주입은 별도 경로가 없다: Claude Code 와 Codex 모두 compaction 시 SessionStart 를
|
|
10
|
+
* source="compact" 로 다시 발화하므로 (Codex 공식 hooks 문서), 같은 경로가 한 번 더 돈다.
|
|
11
|
+
* (critic 리뷰 2026-10-01: PreCompact 플래그 경로는 2중 주입이라 제거.)
|
|
12
|
+
*/
|
|
13
|
+
export declare const FORGEN_RULES_TAG = "forgen-rules";
|
|
14
|
+
/** codex-adapter 가 delegate hook 에 FORGEN_RUNTIME=codex 를 주입한다 (0.4.6+). */
|
|
15
|
+
export declare function isCodexRuntime(env?: NodeJS.ProcessEnv): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* 룰 파일 맵 → 단일 additionalContext 블록. Claude 의 injectClaudeRuleFiles 와 동일 캡:
|
|
18
|
+
* 파일당 perRuleFile, 총량 totalRuleFiles. 캡 초과 파일은 잘리고, 총량 초과 시 이후 파일은 생략.
|
|
19
|
+
*/
|
|
20
|
+
export declare function renderCodexRulesBlock(ruleFiles: Record<string, string>): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* cwd + 렌더된 v1 룰 → Codex 주입 블록. config-injector 는 무거워서 lazy import.
|
|
23
|
+
* 실패 시 null (fail-open — 룰 주입 실패가 세션 시작을 막으면 안 된다).
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildCodexRulesContext(cwd: string, renderedRules: string | null): Promise<string | null>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex 개인화 룰 주입 — ADR-014 D1
|
|
3
|
+
*
|
|
4
|
+
* Claude 는 `.claude/rules/*.md` 파일을 Claude Code 가 매 턴 로드하지만 Codex 에는 그 표면이 없다.
|
|
5
|
+
* 본 모듈은 *같은 소스* (generateClaudeRuleFiles) 를 *같은 캡* (RULE_FILE_CAPS) 으로 렌더해
|
|
6
|
+
* SessionStart additionalContext 로 넣을 블록을 만든다. hooks.json 을 바꾸지 않기 위해 새 훅이
|
|
7
|
+
* 아니라 session-recovery 내부의 codex 분기에서 호출된다.
|
|
8
|
+
*
|
|
9
|
+
* 컴팩션 후 재주입은 별도 경로가 없다: Claude Code 와 Codex 모두 compaction 시 SessionStart 를
|
|
10
|
+
* source="compact" 로 다시 발화하므로 (Codex 공식 hooks 문서), 같은 경로가 한 번 더 돈다.
|
|
11
|
+
* (critic 리뷰 2026-10-01: PreCompact 플래그 경로는 2중 주입이라 제거.)
|
|
12
|
+
*/
|
|
13
|
+
import { RULE_FILE_CAPS } from '../hooks/shared/injection-caps.js';
|
|
14
|
+
export const FORGEN_RULES_TAG = 'forgen-rules';
|
|
15
|
+
/** codex-adapter 가 delegate hook 에 FORGEN_RUNTIME=codex 를 주입한다 (0.4.6+). */
|
|
16
|
+
export function isCodexRuntime(env = process.env) {
|
|
17
|
+
return env.FORGEN_RUNTIME === 'codex';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 룰 파일 맵 → 단일 additionalContext 블록. Claude 의 injectClaudeRuleFiles 와 동일 캡:
|
|
21
|
+
* 파일당 perRuleFile, 총량 totalRuleFiles. 캡 초과 파일은 잘리고, 총량 초과 시 이후 파일은 생략.
|
|
22
|
+
*/
|
|
23
|
+
export function renderCodexRulesBlock(ruleFiles) {
|
|
24
|
+
const entries = Object.entries(ruleFiles).filter(([, c]) => typeof c === 'string' && c.trim().length > 0);
|
|
25
|
+
if (entries.length === 0)
|
|
26
|
+
return null;
|
|
27
|
+
const PER = RULE_FILE_CAPS.perRuleFile;
|
|
28
|
+
const TOTAL = RULE_FILE_CAPS.totalRuleFiles;
|
|
29
|
+
const sections = [];
|
|
30
|
+
let total = 0;
|
|
31
|
+
for (const [filename, content] of entries) {
|
|
32
|
+
const capped = content.length > PER
|
|
33
|
+
? `${content.slice(0, PER)}\n... (capped at rule file limit)\n`
|
|
34
|
+
: content;
|
|
35
|
+
if (total + capped.length > TOTAL)
|
|
36
|
+
break;
|
|
37
|
+
total += capped.length;
|
|
38
|
+
sections.push(`<!-- ${filename} -->\n${capped.trim()}`);
|
|
39
|
+
}
|
|
40
|
+
if (sections.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
return [
|
|
43
|
+
`<${FORGEN_RULES_TAG} host="codex">`,
|
|
44
|
+
'These are the same forgen rules Claude Code loads from .claude/rules/. They apply to this Codex session. Follow them.',
|
|
45
|
+
'',
|
|
46
|
+
sections.join('\n\n---\n\n'),
|
|
47
|
+
`</${FORGEN_RULES_TAG}>`,
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* cwd + 렌더된 v1 룰 → Codex 주입 블록. config-injector 는 무거워서 lazy import.
|
|
52
|
+
* 실패 시 null (fail-open — 룰 주입 실패가 세션 시작을 막으면 안 된다).
|
|
53
|
+
*/
|
|
54
|
+
export async function buildCodexRulesContext(cwd, renderedRules) {
|
|
55
|
+
try {
|
|
56
|
+
const { generateClaudeRuleFiles } = await import('../core/config-injector.js');
|
|
57
|
+
return renderCodexRulesBlock(generateClaudeRuleFiles(cwd, renderedRules));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/host/exec-host.d.ts
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { HostId } from '../core/trust-layer-intent.js';
|
|
13
13
|
export interface ExecHostOptions {
|
|
14
|
+
/**
|
|
15
|
+
* ADR-015 C-G1: forgen 훅을 끄고(FORGEN_NESTED_RUN=1) 세션을 남기지 않는 "추출용 중첩 실행" 모드.
|
|
16
|
+
* 기본 true. 위임 에이전트(invoke-agent)처럼 훅이 살아 있어야 하는 호출은 false.
|
|
17
|
+
*/
|
|
18
|
+
nestedRun?: boolean;
|
|
14
19
|
/** prompt — `-p`/`exec` 의 본문 */
|
|
15
20
|
prompt: string;
|
|
16
21
|
/** model 힌트 (claude: --model haiku, codex: 무시 — codex CLI 가 default 사용) */
|
|
@@ -50,6 +55,11 @@ export interface ExecHostResult {
|
|
|
50
55
|
* 보장 (사용자 환경 미오염). compound-extractor / auto-compound-runner 같은
|
|
51
56
|
* 백그라운드 학습 호출에 적합.
|
|
52
57
|
*/
|
|
58
|
+
/** ADR-015 C-G1 — forgen 이 띄우는 중첩 claude 실행의 공통 표식. hook-config.isHookEnabled 가 읽는다. */
|
|
59
|
+
export declare const NESTED_RUN_ENV: Readonly<Record<string, string>>;
|
|
60
|
+
/** `--no-session-persistence` 는 --print 전용 — 추출 run 의 transcript 를 디스크에 남기지 않는다. */
|
|
61
|
+
export declare const NESTED_RUN_CLAUDE_ARGS: ReadonlyArray<string>;
|
|
62
|
+
export declare function withNestedRunClaudeArgs(args: string[]): string[];
|
|
53
63
|
export declare function execHost(opts: ExecHostOptions): ExecHostResult;
|
|
54
64
|
/** 1회 retry — transient 에러(ETIMEDOUT 등) 대응. */
|
|
55
65
|
export declare function execHostRetry(opts: ExecHostOptions): ExecHostResult;
|
package/dist/host/exec-host.js
CHANGED
|
@@ -34,6 +34,13 @@ export const DEFAULT_TIMEOUT_BY_HOST = {
|
|
|
34
34
|
* 보장 (사용자 환경 미오염). compound-extractor / auto-compound-runner 같은
|
|
35
35
|
* 백그라운드 학습 호출에 적합.
|
|
36
36
|
*/
|
|
37
|
+
/** ADR-015 C-G1 — forgen 이 띄우는 중첩 claude 실행의 공통 표식. hook-config.isHookEnabled 가 읽는다. */
|
|
38
|
+
export const NESTED_RUN_ENV = { FORGEN_NESTED_RUN: '1' };
|
|
39
|
+
/** `--no-session-persistence` 는 --print 전용 — 추출 run 의 transcript 를 디스크에 남기지 않는다. */
|
|
40
|
+
export const NESTED_RUN_CLAUDE_ARGS = ['--no-session-persistence'];
|
|
41
|
+
export function withNestedRunClaudeArgs(args) {
|
|
42
|
+
return args.includes('--no-session-persistence') ? args : [...args, ...NESTED_RUN_CLAUDE_ARGS];
|
|
43
|
+
}
|
|
37
44
|
export function execHost(opts) {
|
|
38
45
|
const resolved = resolveDefaultHost(opts.host);
|
|
39
46
|
// 'ask' 는 자동 호출 컨텍스트라 명시 fallback. 그러나 Codex-only 사용자가 'ask'
|
|
@@ -53,10 +60,14 @@ export function execHost(opts) {
|
|
|
53
60
|
env: { ...process.env, ...(opts.env ?? {}) },
|
|
54
61
|
};
|
|
55
62
|
if (host === 'claude') {
|
|
56
|
-
|
|
63
|
+
// ADR-015 C-G1: 중첩 실행 표식 + 세션 비영속 (추출 run 의 transcript 가 다음 SessionStart 의
|
|
64
|
+
// "이전 세션 auto-compound" 후보로 잡히거나 forgen 훅이 재귀 발화하지 않도록).
|
|
65
|
+
const nested = opts.nestedRun ?? true;
|
|
66
|
+
const args = ['-p', opts.prompt, ...(nested ? NESTED_RUN_CLAUDE_ARGS : [])];
|
|
57
67
|
if (opts.model)
|
|
58
68
|
args.push('--model', opts.model);
|
|
59
|
-
const
|
|
69
|
+
const env = nested ? { ...(baseOpts.env ?? {}), ...NESTED_RUN_ENV } : baseOpts.env;
|
|
70
|
+
const stdout = execFileSync('claude', args, { ...baseOpts, env });
|
|
60
71
|
return { message: stdout.toString().trim(), host: 'claude', usage: null };
|
|
61
72
|
}
|
|
62
73
|
// host === 'codex'
|
|
@@ -44,8 +44,65 @@ export interface CodexInstallResult {
|
|
|
44
44
|
devGuideSkillsPath: string;
|
|
45
45
|
devGuideSkillsInstalled: number;
|
|
46
46
|
devGuideSkillsRemoved: number;
|
|
47
|
+
/** ADR-014 D2: ~/.codex/agents/ch-*.toml 커스텀 에이전트 설치 결과 */
|
|
48
|
+
agentsPath: string;
|
|
49
|
+
agentsInstalled: number;
|
|
50
|
+
agentsRemoved: number;
|
|
51
|
+
/** ADR-014 D4: hooks.json forgen 엔트리 중 config.toml hooks.state 에 신뢰 기록이 있는 수 */
|
|
52
|
+
hookTrust: CodexHookTrustAudit;
|
|
53
|
+
/** ADR-014 D2: config.toml `[features] multi_agent = true` 여부 (false 면 ch-* 에이전트 spawn 불가 → 안내) */
|
|
54
|
+
multiAgentEnabled: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface CodexHookTrustAudit {
|
|
57
|
+
/** hooks.json 의 forgen hook 명령 수 (Codex 가 지원하는 이벤트만) */
|
|
58
|
+
total: number;
|
|
59
|
+
/** Codex 가 모르는 이벤트라 조용히 무시되는 forgen 엔트리 (`<event>:<i>:<j>`) — trust 대상이 아님 */
|
|
60
|
+
ignoredByCodex: string[];
|
|
61
|
+
/** config.toml `[hooks.state."<hooks.json>:<event>:<i>:<j>"]` 에 trusted_hash 가 있는 수 */
|
|
62
|
+
trusted: number;
|
|
63
|
+
/** 신뢰 기록이 없는 hook 키 (`<event>:<i>:<j>`) */
|
|
64
|
+
untrusted: string[];
|
|
65
|
+
/** config.toml 자체가 없거나 hooks.state 가 전혀 없으면 true (Codex 가 아직 한 번도 훅을 review 안 함) */
|
|
66
|
+
noStateRecorded: boolean;
|
|
67
|
+
}
|
|
68
|
+
interface HooksFile {
|
|
69
|
+
description?: string;
|
|
70
|
+
hooks: Record<string, Array<unknown>>;
|
|
47
71
|
}
|
|
48
72
|
export declare function planCodexInstall(opts: CodexInstallOptions): CodexInstallResult;
|
|
73
|
+
/** `[features]` 섹션 안에 `multi_agent = true` 가 있는지 (TOML 라이브러리 없이 섹션 범위만 본다). */
|
|
74
|
+
export declare function isCodexMultiAgentEnabled(configToml: string): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Codex 0.153 hooks 공식 이벤트 12종 (learn.chatgpt.com/docs/hooks + binary 문자열). 이 밖의 이벤트
|
|
77
|
+
* (예: Claude 전용 PostToolUseFailure) 는 Codex 가 조용히 무시하므로 trust 대상이 아니다.
|
|
78
|
+
*/
|
|
79
|
+
export declare const CODEX_SUPPORTED_HOOK_EVENTS: ReadonlySet<string>;
|
|
80
|
+
/** Codex 는 hooks.state 키에 이벤트명을 snake_case 로 쓴다 (PreToolUse → pre_tool_use). */
|
|
81
|
+
export declare function codexHookEventKey(event: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* hooks.json 의 forgen 엔트리 각각에 대해 config.toml 의
|
|
84
|
+
* `[hooks.state."<hooksPath>:<event>:<groupIdx>:<hookIdx>"]` 섹션(trusted_hash) 존재를 대조한다.
|
|
85
|
+
* trusted_hash 자체는 검증하지 않는다 — Codex 가 변경된 훅을 review 전까지 skip 하는 정책을
|
|
86
|
+
* forgen 이 우회하지 않기 위해, *기록 유무* 만 가시화한다 (ADR-014 D4).
|
|
87
|
+
*/
|
|
88
|
+
export declare function auditCodexHookTrust(opts: {
|
|
89
|
+
hooksPath: string;
|
|
90
|
+
configTomlPath: string;
|
|
91
|
+
pkgRoot: string;
|
|
92
|
+
hooksFile?: HooksFile | null;
|
|
93
|
+
configToml?: string;
|
|
94
|
+
}): CodexHookTrustAudit;
|
|
95
|
+
/**
|
|
96
|
+
* Claude agent .md → Codex agent role TOML.
|
|
97
|
+
* 공식 스키마(필수 name/description/developer_instructions, 선택 model_reasoning_effort/sandbox_mode)
|
|
98
|
+
* 외 필드는 쓰지 않는다 — Codex 가 unknown field 를 거부 (ADR-014 D2).
|
|
99
|
+
*/
|
|
100
|
+
export declare function renderCodexAgentToml(file: string, raw: string): {
|
|
101
|
+
name: string;
|
|
102
|
+
toml: string;
|
|
103
|
+
} | null;
|
|
104
|
+
/** ADR-014 D3 — Codex 스킬에는 `$ARGUMENTS` 치환 변수가 없다. */
|
|
105
|
+
export declare function adaptSkillBodyForCodex(body: string): string;
|
|
49
106
|
export declare function resolveAgentsMdPath(_pkgRoot: string): string;
|
|
50
107
|
export declare function upsertForgenRulesInAgentsMd(opts: {
|
|
51
108
|
agentsMdPath: string;
|
|
@@ -54,3 +111,4 @@ export declare function upsertForgenRulesInAgentsMd(opts: {
|
|
|
54
111
|
}): {
|
|
55
112
|
injected: boolean;
|
|
56
113
|
};
|
|
114
|
+
export {};
|
|
@@ -85,29 +85,39 @@ export function planCodexInstall(opts) {
|
|
|
85
85
|
releaseMode,
|
|
86
86
|
});
|
|
87
87
|
const generatedHooks = generated.hooks;
|
|
88
|
-
// 2) 기존 hooks.json 읽기
|
|
88
|
+
// 2) 기존 hooks.json 읽기 — forgen 그룹은 *제자리에서* 교체, 사용자 그룹은 위치 그대로 보존.
|
|
89
|
+
// (0.5.3 critic/실머신: 이전엔 사용자 그룹을 앞으로 모으고 forgen 을 뒤에 붙여 그룹 인덱스가
|
|
90
|
+
// 바뀌었고, Codex 의 trust 키 `<event>:<groupIdx>:<hookIdx>` 가 어긋나 20/21 → 12/21 로
|
|
91
|
+
// 훅 신뢰가 깨졌다. 바이트 동일성이 곧 신뢰 보존이다.)
|
|
89
92
|
const existing = readJsonFile(hooksPath);
|
|
90
93
|
const existingHooksByEvent = (existing?.hooks ?? {});
|
|
91
|
-
const
|
|
94
|
+
const eventOrder = [...new Set([...Object.keys(existingHooksByEvent), ...Object.keys(generatedHooks)])];
|
|
95
|
+
const merged = {};
|
|
92
96
|
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
97
|
let forgenCount = 0;
|
|
106
|
-
for (const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
98
|
+
for (const event of eventOrder) {
|
|
99
|
+
const existingGroups = Array.isArray(existingHooksByEvent[event]) ? existingHooksByEvent[event] : [];
|
|
100
|
+
const generatedGroups = generatedHooks[event] ?? [];
|
|
101
|
+
const out = [];
|
|
102
|
+
let inserted = false;
|
|
103
|
+
for (const group of existingGroups) {
|
|
104
|
+
if (isForgenManagedHook(group, opts.pkgRoot)) {
|
|
105
|
+
if (!inserted) {
|
|
106
|
+
out.push(...generatedGroups);
|
|
107
|
+
inserted = true;
|
|
108
|
+
}
|
|
109
|
+
// 이후 중복 forgen 그룹은 드롭 (stale 누적 방지)
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
out.push(group);
|
|
113
|
+
preservedCount += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!inserted && generatedGroups.length > 0)
|
|
117
|
+
out.push(...generatedGroups);
|
|
118
|
+
if (out.length > 0)
|
|
119
|
+
merged[event] = out;
|
|
120
|
+
for (const group of generatedGroups) {
|
|
111
121
|
const g = group;
|
|
112
122
|
if (Array.isArray(g.hooks))
|
|
113
123
|
forgenCount += g.hooks.length;
|
|
@@ -156,6 +166,21 @@ export function planCodexInstall(opts) {
|
|
|
156
166
|
codexHome,
|
|
157
167
|
dryRun: opts.dryRun ?? false,
|
|
158
168
|
});
|
|
169
|
+
// 9) ADR-014 D2: assets/claude/agents/*.md → ~/.codex/agents/ch-<name>.toml
|
|
170
|
+
const codexAgents = installCodexAgents({
|
|
171
|
+
sourceDir: path.join(opts.pkgRoot, 'assets', 'claude', 'agents'),
|
|
172
|
+
targetDir: path.join(codexHome, 'agents'),
|
|
173
|
+
dryRun: opts.dryRun ?? false,
|
|
174
|
+
});
|
|
175
|
+
// 10) ADR-014 D4: 훅 신뢰 감사 (dryRun 이면 현재 디스크 상태 기준)
|
|
176
|
+
const hookTrust = auditCodexHookTrust({
|
|
177
|
+
hooksPath,
|
|
178
|
+
configTomlPath,
|
|
179
|
+
pkgRoot: opts.pkgRoot,
|
|
180
|
+
hooksFile: opts.dryRun ? (existing ?? finalHooksFile) : finalHooksFile,
|
|
181
|
+
configToml: mcpContentToWrite ?? (fs.existsSync(configTomlPath) ? fs.readFileSync(configTomlPath, 'utf-8') : ''),
|
|
182
|
+
});
|
|
183
|
+
const multiAgentEnabled = isCodexMultiAgentEnabled(mcpContentToWrite ?? (fs.existsSync(configTomlPath) ? fs.readFileSync(configTomlPath, 'utf-8') : ''));
|
|
159
184
|
return {
|
|
160
185
|
codexHome,
|
|
161
186
|
hooksPath,
|
|
@@ -172,8 +197,230 @@ export function planCodexInstall(opts) {
|
|
|
172
197
|
devGuideSkillsPath: devGuideResult.devGuideSkillsPath,
|
|
173
198
|
devGuideSkillsInstalled: devGuideResult.devGuideSkillsInstalled,
|
|
174
199
|
devGuideSkillsRemoved: devGuideResult.devGuideSkillsRemoved,
|
|
200
|
+
agentsPath: codexAgents.agentsPath,
|
|
201
|
+
agentsInstalled: codexAgents.installed,
|
|
202
|
+
agentsRemoved: codexAgents.removed,
|
|
203
|
+
hookTrust,
|
|
204
|
+
multiAgentEnabled,
|
|
175
205
|
};
|
|
176
206
|
}
|
|
207
|
+
/** `[features]` 섹션 안에 `multi_agent = true` 가 있는지 (TOML 라이브러리 없이 섹션 범위만 본다). */
|
|
208
|
+
export function isCodexMultiAgentEnabled(configToml) {
|
|
209
|
+
const m = configToml.match(/^\[features\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m);
|
|
210
|
+
if (!m)
|
|
211
|
+
return false;
|
|
212
|
+
return /^\s*multi_agent\s*=\s*true\s*$/m.test(m[1]);
|
|
213
|
+
}
|
|
214
|
+
// ── ADR-014 D4: Codex hook trust audit ────────────────────────────────
|
|
215
|
+
/**
|
|
216
|
+
* Codex 0.153 hooks 공식 이벤트 12종 (learn.chatgpt.com/docs/hooks + binary 문자열). 이 밖의 이벤트
|
|
217
|
+
* (예: Claude 전용 PostToolUseFailure) 는 Codex 가 조용히 무시하므로 trust 대상이 아니다.
|
|
218
|
+
*/
|
|
219
|
+
export const CODEX_SUPPORTED_HOOK_EVENTS = new Set([
|
|
220
|
+
'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'UserPromptSubmit',
|
|
221
|
+
'SubagentStart', 'SubagentStop', 'Stop', 'Interrupt', 'SessionStart', 'SessionEnd',
|
|
222
|
+
]);
|
|
223
|
+
/** Codex 는 hooks.state 키에 이벤트명을 snake_case 로 쓴다 (PreToolUse → pre_tool_use). */
|
|
224
|
+
export function codexHookEventKey(event) {
|
|
225
|
+
return event.replace(/(?<!^)([A-Z])/g, '_$1').toLowerCase();
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* hooks.json 의 forgen 엔트리 각각에 대해 config.toml 의
|
|
229
|
+
* `[hooks.state."<hooksPath>:<event>:<groupIdx>:<hookIdx>"]` 섹션(trusted_hash) 존재를 대조한다.
|
|
230
|
+
* trusted_hash 자체는 검증하지 않는다 — Codex 가 변경된 훅을 review 전까지 skip 하는 정책을
|
|
231
|
+
* forgen 이 우회하지 않기 위해, *기록 유무* 만 가시화한다 (ADR-014 D4).
|
|
232
|
+
*/
|
|
233
|
+
export function auditCodexHookTrust(opts) {
|
|
234
|
+
const hooksFile = opts.hooksFile ?? readJsonFile(opts.hooksPath);
|
|
235
|
+
const toml = opts.configToml ?? (fs.existsSync(opts.configTomlPath) ? fs.readFileSync(opts.configTomlPath, 'utf-8') : '');
|
|
236
|
+
const stateKeys = new Set();
|
|
237
|
+
const re = /^\[hooks\.state\."([^"]+)"\]\s*$/gm;
|
|
238
|
+
let m = re.exec(toml);
|
|
239
|
+
while (m !== null) {
|
|
240
|
+
stateKeys.add(m[1]);
|
|
241
|
+
m = re.exec(toml);
|
|
242
|
+
}
|
|
243
|
+
let total = 0;
|
|
244
|
+
let trusted = 0;
|
|
245
|
+
const untrusted = [];
|
|
246
|
+
const ignoredByCodex = [];
|
|
247
|
+
const events = (hooksFile?.hooks ?? {});
|
|
248
|
+
for (const [event, groups] of Object.entries(events)) {
|
|
249
|
+
if (!Array.isArray(groups))
|
|
250
|
+
continue;
|
|
251
|
+
groups.forEach((group, gi) => {
|
|
252
|
+
const g = group;
|
|
253
|
+
if (!Array.isArray(g.hooks))
|
|
254
|
+
return;
|
|
255
|
+
g.hooks.forEach((h, hi) => {
|
|
256
|
+
const isForgen = typeof h.command === 'string' &&
|
|
257
|
+
(h.command.includes(opts.pkgRoot) || FORGEN_HOOK_SCRIPT_MARKER.test(h.command));
|
|
258
|
+
if (!isForgen)
|
|
259
|
+
return;
|
|
260
|
+
const key = `${codexHookEventKey(event)}:${gi}:${hi}`;
|
|
261
|
+
if (!CODEX_SUPPORTED_HOOK_EVENTS.has(event)) {
|
|
262
|
+
ignoredByCodex.push(key);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
total += 1;
|
|
266
|
+
if (stateKeys.has(`${opts.hooksPath}:${key}`))
|
|
267
|
+
trusted += 1;
|
|
268
|
+
else
|
|
269
|
+
untrusted.push(key);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return { total, trusted, untrusted, ignoredByCodex, noStateRecorded: stateKeys.size === 0 };
|
|
274
|
+
}
|
|
275
|
+
// ── ADR-014 D2: Codex custom agents (~/.codex/agents/ch-*.toml) ──────
|
|
276
|
+
const AGENT_TOML_MARKER = '# forgen-managed';
|
|
277
|
+
const AGENT_NAME_PREFIX = 'ch-';
|
|
278
|
+
function parseAgentMarkdown(raw) {
|
|
279
|
+
const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
280
|
+
if (!fm)
|
|
281
|
+
return null;
|
|
282
|
+
const meta = {};
|
|
283
|
+
const tools = [];
|
|
284
|
+
const disallowedTools = [];
|
|
285
|
+
let currentList = null;
|
|
286
|
+
for (const line of fm[1].split('\n')) {
|
|
287
|
+
const listItem = line.match(/^\s+-\s+(.+)$/);
|
|
288
|
+
if (currentList && listItem) {
|
|
289
|
+
currentList.push(listItem[1].trim());
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
currentList = null;
|
|
293
|
+
const kv = line.match(/^([A-Za-z_]+):\s*(.*)$/);
|
|
294
|
+
if (!kv)
|
|
295
|
+
continue;
|
|
296
|
+
const [, k, v] = kv;
|
|
297
|
+
if (k === 'tools' || k === 'disallowedTools') {
|
|
298
|
+
const target = k === 'tools' ? tools : disallowedTools;
|
|
299
|
+
// 인라인 배열 `tools: [Read, Bash]` 도 허용
|
|
300
|
+
const inline = v.trim().match(/^\[(.*)\]$/);
|
|
301
|
+
if (inline)
|
|
302
|
+
target.push(...inline[1].split(',').map((t) => t.trim()).filter(Boolean));
|
|
303
|
+
else
|
|
304
|
+
currentList = target;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
meta[k] = v.trim();
|
|
308
|
+
}
|
|
309
|
+
return { meta, tools, disallowedTools, body: fm[2].trim() };
|
|
310
|
+
}
|
|
311
|
+
/** TOML 문자열에 들어갈 수 없는 제어문자 제거 (탭/개행/CR 은 유지). */
|
|
312
|
+
function stripTomlControlChars(v) {
|
|
313
|
+
let out = '';
|
|
314
|
+
for (const ch of v) {
|
|
315
|
+
const code = ch.charCodeAt(0);
|
|
316
|
+
const isAllowed = (code >= 0x20 && code !== 0x7f) || code === 0x09 || code === 0x0a || code === 0x0d;
|
|
317
|
+
if (isAllowed)
|
|
318
|
+
out += ch;
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
/** TOML basic string (한 줄). JSON 문자열 문법은 TOML basic string 의 부분집합. */
|
|
323
|
+
function tomlString(v) {
|
|
324
|
+
return JSON.stringify(stripTomlControlChars(v));
|
|
325
|
+
}
|
|
326
|
+
/** TOML multi-line basic string. 백슬래시와 삼중따옴표만 이스케이프하면 된다. */
|
|
327
|
+
function tomlMultiline(v) {
|
|
328
|
+
const cleaned = stripTomlControlChars(v)
|
|
329
|
+
.split('\\').join('\\\\')
|
|
330
|
+
.split('"""').join('\\"\\"\\"');
|
|
331
|
+
return `"""\n${cleaned}\n"""`;
|
|
332
|
+
}
|
|
333
|
+
const REASONING_BY_CLAUDE_MODEL = { opus: 'high', sonnet: 'medium', haiku: 'low' };
|
|
334
|
+
/**
|
|
335
|
+
* Claude agent .md → Codex agent role TOML.
|
|
336
|
+
* 공식 스키마(필수 name/description/developer_instructions, 선택 model_reasoning_effort/sandbox_mode)
|
|
337
|
+
* 외 필드는 쓰지 않는다 — Codex 가 unknown field 를 거부 (ADR-014 D2).
|
|
338
|
+
*/
|
|
339
|
+
export function renderCodexAgentToml(file, raw) {
|
|
340
|
+
const parsed = parseAgentMarkdown(raw);
|
|
341
|
+
if (!parsed)
|
|
342
|
+
return null;
|
|
343
|
+
const base = file.replace(/\.md$/, '');
|
|
344
|
+
const name = base.startsWith(AGENT_NAME_PREFIX) ? base : `${AGENT_NAME_PREFIX}${base}`;
|
|
345
|
+
if (!/^[A-Za-z0-9 _-]+$/.test(name))
|
|
346
|
+
return null;
|
|
347
|
+
const description = parsed.meta.description || name;
|
|
348
|
+
// critic 2026-10-01: 7/14 에이전트는 `tools:` 대신 `disallowedTools: [Write, Edit]` 로 읽기전용을 선언.
|
|
349
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
350
|
+
const canWrite = parsed.tools.length > 0
|
|
351
|
+
? parsed.tools.some((t) => WRITE_TOOLS.has(t))
|
|
352
|
+
: !parsed.disallowedTools.some((t) => WRITE_TOOLS.has(t));
|
|
353
|
+
const sandbox = canWrite ? 'workspace-write' : 'read-only';
|
|
354
|
+
const effort = REASONING_BY_CLAUDE_MODEL[parsed.meta.model ?? ''] ?? 'medium';
|
|
355
|
+
const body = parsed.body.length > 0 ? parsed.body : description;
|
|
356
|
+
const toml = [
|
|
357
|
+
AGENT_TOML_MARKER,
|
|
358
|
+
`# generated by \`forgen install codex\` from assets/claude/agents/${file} — do not edit; re-generated on install`,
|
|
359
|
+
`name = ${tomlString(name)}`,
|
|
360
|
+
`description = ${tomlString(description)}`,
|
|
361
|
+
`model_reasoning_effort = ${tomlString(effort)}`,
|
|
362
|
+
`sandbox_mode = ${tomlString(sandbox)}`,
|
|
363
|
+
`developer_instructions = ${tomlMultiline(body)}`,
|
|
364
|
+
'',
|
|
365
|
+
].join('\n');
|
|
366
|
+
return { name, toml };
|
|
367
|
+
}
|
|
368
|
+
function installCodexAgents(opts) {
|
|
369
|
+
const { sourceDir, targetDir, dryRun } = opts;
|
|
370
|
+
if (!fs.existsSync(sourceDir))
|
|
371
|
+
return { agentsPath: targetDir, installed: 0, removed: 0 };
|
|
372
|
+
const files = fs.readdirSync(sourceDir).filter((f) => f.endsWith('.md'));
|
|
373
|
+
const isUserOwned = (p) => {
|
|
374
|
+
try {
|
|
375
|
+
const st = fs.lstatSync(p);
|
|
376
|
+
if (st.isSymbolicLink())
|
|
377
|
+
return true; // 사용자 심링크 (dangling 포함) — 건드리지 않음
|
|
378
|
+
return !fs.readFileSync(p, 'utf-8').slice(0, 64).startsWith(AGENT_TOML_MARKER);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false; // 없음
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
if (dryRun) {
|
|
385
|
+
const wouldInstall = files.filter((f) => {
|
|
386
|
+
const r = renderCodexAgentToml(f, fs.readFileSync(path.join(sourceDir, f), 'utf-8'));
|
|
387
|
+
return r !== null && !isUserOwned(path.join(targetDir, `${r.name}.toml`));
|
|
388
|
+
}).length;
|
|
389
|
+
return { agentsPath: targetDir, installed: wouldInstall, removed: 0 };
|
|
390
|
+
}
|
|
391
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
392
|
+
// stale 정리: forgen-managed 마커가 있는 ch-*.toml 만. 사용자 파일은 보존.
|
|
393
|
+
let removed = 0;
|
|
394
|
+
for (const entry of fs.readdirSync(targetDir)) {
|
|
395
|
+
if (!entry.startsWith(AGENT_NAME_PREFIX) || !entry.endsWith('.toml'))
|
|
396
|
+
continue;
|
|
397
|
+
const p = path.join(targetDir, entry);
|
|
398
|
+
try {
|
|
399
|
+
if (fs.lstatSync(p).isSymbolicLink())
|
|
400
|
+
continue;
|
|
401
|
+
const head = fs.readFileSync(p, 'utf-8').slice(0, 64);
|
|
402
|
+
if (!head.startsWith(AGENT_TOML_MARKER))
|
|
403
|
+
continue;
|
|
404
|
+
fs.unlinkSync(p);
|
|
405
|
+
removed += 1;
|
|
406
|
+
}
|
|
407
|
+
catch { /* best-effort */ }
|
|
408
|
+
}
|
|
409
|
+
let installed = 0;
|
|
410
|
+
for (const file of files) {
|
|
411
|
+
const rendered = renderCodexAgentToml(file, fs.readFileSync(path.join(sourceDir, file), 'utf-8'));
|
|
412
|
+
if (!rendered)
|
|
413
|
+
continue;
|
|
414
|
+
const dst = path.join(targetDir, `${rendered.name}.toml`);
|
|
415
|
+
if (isUserOwned(dst)) {
|
|
416
|
+
// 방금 stale 정리에서 살아남은 = 사용자 작성 (마커 없음) 또는 심링크 → 보존
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
fs.writeFileSync(dst, rendered.toml, 'utf-8');
|
|
420
|
+
installed += 1;
|
|
421
|
+
}
|
|
422
|
+
return { agentsPath: targetDir, installed, removed };
|
|
423
|
+
}
|
|
177
424
|
// ── v0.4.9: dev-guide skills → ~/.codex/skills ────────────────────────
|
|
178
425
|
// dev-guide prefix pattern: forgen-<stack>-<skill> (e.g. forgen-react-fe-build)
|
|
179
426
|
// 반드시 stack 이 react|vue|node|go 인 것만 매칭 — forgen 자체 commands 보존
|
|
@@ -237,6 +484,27 @@ function installDevGuideSkillsToCodex(opts) {
|
|
|
237
484
|
return { devGuideSkillsPath: codexSkillsDir, devGuideSkillsInstalled: installed, devGuideSkillsRemoved: removed };
|
|
238
485
|
}
|
|
239
486
|
// ── P3-3: Codex skills install ────────────────────────────────────────
|
|
487
|
+
/** ADR-014 D3 — Codex 스킬에는 `$ARGUMENTS` 치환 변수가 없다. */
|
|
488
|
+
export function adaptSkillBodyForCodex(body) {
|
|
489
|
+
return body
|
|
490
|
+
.replace(/\{\$ARGUMENTS\}/g, '{the user\'s request text}')
|
|
491
|
+
.replace(/`\$ARGUMENTS`/g, 'the user\'s request text (what follows the skill name)')
|
|
492
|
+
.replace(/\$ARGUMENTS/g, 'the user\'s request text (what follows the skill name)');
|
|
493
|
+
}
|
|
494
|
+
/** ADR-014 D2/D3 — 스킬 본문의 ch-* 에이전트 참조가 Codex 에서 어떻게 해석되는지 명시. */
|
|
495
|
+
const CODEX_SKILL_HOST_NOTE = `
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## Codex host note (forgen-managed)
|
|
499
|
+
|
|
500
|
+
- Sub-agents named \`ch-*\` (ch-planner, ch-executor, ch-verifier, ch-critic, ...) are installed as Codex
|
|
501
|
+
custom agents under \`$CODEX_HOME/agents/ch-*.toml\`. Spawn them with Codex's multi-agent tools when
|
|
502
|
+
available (\`[features] multi_agent = true\` in config.toml).
|
|
503
|
+
- If spawning is unavailable, call the \`invoke-agent\` tool on the \`forgen-compound\` MCP server
|
|
504
|
+
(agent_name + task) or perform that stage inline yourself. Do not skip the stage.
|
|
505
|
+
- The forgen personalized rules for this session arrive as a \`<forgen-rules host="codex">\` block at
|
|
506
|
+
session start. Treat them exactly like Claude Code's .claude/rules/.
|
|
507
|
+
`;
|
|
240
508
|
function installCodexSkills(opts) {
|
|
241
509
|
const { sourceDir, targetDir, dryRun } = opts;
|
|
242
510
|
if (!fs.existsSync(sourceDir))
|
|
@@ -264,8 +532,8 @@ function installCodexSkills(opts) {
|
|
|
264
532
|
const descMatch = raw.match(/description:\s*(.+)/);
|
|
265
533
|
const desc = descMatch?.[1]?.trim() ?? skillName;
|
|
266
534
|
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`;
|
|
535
|
+
const body = adaptSkillBodyForCodex(bodyMatch?.[1]?.trim() ?? raw);
|
|
536
|
+
const out = `---\nname: ${skillName}\ndescription: ${desc}\n---\n\n${FORGEN_SKILL_MARKER}\n\n${body}\n${CODEX_SKILL_HOST_NOTE}`;
|
|
269
537
|
fs.mkdirSync(skillDir, { recursive: true });
|
|
270
538
|
fs.writeFileSync(skillFile, out);
|
|
271
539
|
count += 1;
|
|
@@ -297,7 +565,7 @@ export function resolveAgentsMdPath(_pkgRoot) {
|
|
|
297
565
|
return path.join(cwd, 'AGENTS.md');
|
|
298
566
|
}
|
|
299
567
|
function buildForgenRulesBlock(pkgRoot) {
|
|
300
|
-
// forgen 의 핵심 규칙 + 사용자 profile 안내 (가벼운 헤더만 — 실 rule
|
|
568
|
+
// forgen 의 핵심 규칙 + 사용자 profile 안내 (가벼운 헤더만 — 실 rule 본문은 ADR-014 D1 의 SessionStart hook 주입)
|
|
301
569
|
const lines = [
|
|
302
570
|
AGENTS_MD_BEGIN,
|
|
303
571
|
'## forgen managed rules',
|
|
@@ -307,7 +575,7 @@ function buildForgenRulesBlock(pkgRoot) {
|
|
|
307
575
|
'- forgen-compound MCP 가 ~/.codex/config.toml 에 등록됨. 학습된 솔루션을 `compound-search` 로 조회 가능.',
|
|
308
576
|
'- 사용자 교정은 `correction-record` MCP 도구로 즉시 박제 (kind: fix-now / prefer-from-now / avoid-this).',
|
|
309
577
|
'- forgen 의 4축 profile (quality_safety / autonomy / judgment_philosophy / communication_style) 이 응답 톤 + 검증 깊이를 가이드.',
|
|
310
|
-
'-
|
|
578
|
+
'- 개인화 룰 본문은 세션 시작 시 SessionStart hook 이 `<forgen-rules host="codex">` 블록으로 주입 (ADR-014). 서브에이전트는 ~/.codex/agents/ch-*.toml.',
|
|
311
579
|
`- pkgRoot: ${pkgRoot}`,
|
|
312
580
|
AGENTS_MD_END,
|
|
313
581
|
];
|
|
@@ -127,6 +127,16 @@ export function renderResult(result, dryRun) {
|
|
|
127
127
|
lines.push(` CODEX_HOME: ${result.codex.codexHome}`);
|
|
128
128
|
lines.push(` hooks.json: ${result.codex.hooksCount} forgen hooks (preserved user: ${result.codex.preservedUserHookCount})`);
|
|
129
129
|
lines.push(` MCP: ${result.codex.mcpAlreadyPresent ? 'already present' : (result.codex.mcpRegistered ? 'registered' : 'skipped')}`);
|
|
130
|
+
lines.push(` skills: ${result.codex.skillsInstalled} commands + ${result.codex.devGuideSkillsInstalled} dev-guide → ${result.codex.skillsPath}`);
|
|
131
|
+
lines.push(` agents: ${result.codex.agentsInstalled} ch-*.toml → ${result.codex.agentsPath}${result.codex.multiAgentEnabled ? '' : ' (⚠ config.toml 에 [features] multi_agent = true 가 없어 Codex 가 spawn 하지 못함 — 추가 권장)'}`);
|
|
132
|
+
lines.push(` rules: injected per session via SessionStart hook (<forgen-rules host="codex">)`);
|
|
133
|
+
const t = result.codex.hookTrust;
|
|
134
|
+
if (t.total > 0 && t.trusted === t.total) {
|
|
135
|
+
lines.push(` hook trust: ${t.trusted}/${t.total} trusted${t.ignoredByCodex.length ? ` (+${t.ignoredByCodex.length} Claude-only event ignored by Codex)` : ''}`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
lines.push(` hook trust: ${t.trusted}/${t.total} trusted — ${t.untrusted.length} hook(s) need review. Codex skips untrusted hooks: run \`/hooks\` inside codex and trust the forgen entries.`);
|
|
139
|
+
}
|
|
130
140
|
}
|
|
131
141
|
if (result.opencode) {
|
|
132
142
|
lines.push('');
|