@wooojin/forgen 0.5.6 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://claude.ai/schemas/claude-plugin.json",
3
3
  "name": "forgen",
4
- "version": "0.5.6",
4
+ "version": "0.5.7",
5
5
  "description": "Claude Code harness — the more you use Claude, the better it gets",
6
6
  "author": {
7
7
  "name": "jang-ujin",
package/CHANGELOG.md CHANGED
@@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.7] — 2026-10-02 — `forgen uninstall` 의 Codex 정리 · 의존성 메이저 업그레이드
11
+
12
+ ### Added
13
+ - **`forgen uninstall` 이 Codex 등록분도 되돌린다 (ADR-016 D4).** hooks.json 의 forgen 훅, config.toml 의 MCP/notify 블록,
14
+ forgen-managed `skills/` · `agents/ch-*.toml`, cwd 의 AGENTS.md 블록을 제거한다. 이전엔 Codex 쪽이 그대로 남아
15
+ 패키지를 지우면 Codex 가 매 훅 이벤트마다 사라진 스크립트를 실행하려 했다.
16
+ - **다른 도구의 훅 신뢰를 지킨다.** Codex 의 trust 키는 그룹 인덱스 기반이라 forgen 그룹을 지워 인덱스가 당겨지면
17
+ 뒤따르는 훅이 재승인 전까지 조용히 skip 된다. 그런 위치에는 빈 그룹(`{"hooks": []}`)을 남겨 인덱스를 유지한다
18
+ (Codex 0.153.4 `hooks/list` 로 확인: 경고 없음, 뒤 그룹 trusted 유지). forgen 은 `[hooks.state]` 를 고쳐 쓰지 않는다.
19
+ - 사용자가 만든 스킬/에이전트/마커 없는 MCP 테이블/자기 `notify`, Codex 가 블록 사이에 써 넣은 설정은 보존.
20
+ forgen notify 뒤에 체인해 둔 사용자 notifier 는 그 argv 만으로 `notify` 를 되돌려 놓는다.
21
+ - uninstall 후 재설치하면 빈 그룹 자리를 다시 채워 forgen 훅이 원래 인덱스(= 이미 승인된 trust 키)로 돌아간다.
22
+ - 각 단계는 독립 — 하나가 실패(읽기 전용 파일, 깨진 hooks.json)해도 나머지는 진행하고 실패를 출력한다.
23
+ - 한계: 다른 프로젝트의 AGENTS.md 블록(실행한 cwd 것만 정리), `forgen install opencode` 산출물, 낡은 `[hooks.state]`
24
+ 항목, 빈 디렉토리/빈 config.toml 은 남는다.
25
+ - `forgen uninstall` 의 Claude 쪽 누락 보강: `~/.claude.json` 의 `forgen-compound` MCP 등록(install 은 여기에 쓰는데 uninstall 은
26
+ settings.json 만 정리했다), 패키지가 제공한 dev-guide 스킬(`~/.claude/skills/forgen-<stack>-<skill>`).
27
+
28
+ ### Fixed
29
+ - **손으로 여러 줄로 고친 forgen notify 블록을 제거(`--no-notify`, uninstall)하면 config.toml 이 깨지던 결함 (0.5.6).**
30
+ 배열의 첫 줄만 지우고 나머지를 남겨 Codex 가 기동하지 못했다. 이제 그런 블록은 건드리지 않고 알린다.
31
+ - **훅 소유 판정이 너무 넓던 것.** command 에 `dist/hooks/<아무이름>.js` 가 있거나 forgen 설치 경로를 *부분문자열* 로
32
+ 포함하면 forgen 훅으로 봤다 — 다른 프로젝트의 `…/dist/hooks/pre-commit.js` 나 `<pkgRoot>-fork/…` 가 재설치 시 교체
33
+ 대상이 될 수 있었다. 이제 forgen 설치 경로의 `dist/` 아래, codex-adapter 경유, registry 에 있는 forgen 훅 이름일 때만.
34
+ - 소유 마커 판정 강화: SKILL.md 는 frontmatter *바로 뒤* 의 마커만 인정(본문의 `---` 뒤 마커 인용을 오인하던 것),
35
+ agent TOML 은 첫 줄이 정확히 `# forgen-managed` 일 때만.
36
+ - `~/.claude.json` / settings.json 을 다시 쓸 때 기존 파일 권한(0600)과 심링크를 보존 (이전엔 0644 로 넓어지고 심링크가
37
+ 일반 파일로 바뀌었다).
38
+ - `forgen status` 점수: 총점을 표시되는 항목 점수의 합으로 계산 (반올림 순서 때문에 항목 합과 총점이 1 어긋날 수 있었다).
39
+
40
+ ### Changed — dependencies
41
+ - **js-yaml 4 → 5** (런타임 의존성). v5 ESM 빌드는 default export 가 없어 namespace import 로 전환, dump 옵션
42
+ `quotingType` → `quoteStyle`, 타입 내장으로 `@types/js-yaml` 제거. **스키마를 `JSON_SCHEMA` → `CORE_SCHEMA` 로 변경**:
43
+ v5 의 `JSON_SCHEMA` 는 strict JSON 이라 v4 가 읽던 `confidence: .5`, `supersedes: ~`, `True` 등을 문자열로 읽어
44
+ 솔루션이 조용히 탈락한다. `CORE_SCHEMA` 가 v4 와 같은 해석을 준다 (회귀 테스트 추가). 실 솔루션/룰 frontmatter 87개에서
45
+ v4 와 파싱·직렬화 결과 바이트 동일. 남는 차이: `1_000`, `0b11` 형태는 v5 에서 문자열로 읽히고 따옴표 없이 직렬화된다
46
+ (forgen 이 쓰는 값에는 나타나지 않는 형태).
47
+ - **TypeScript 5.9 → 7.0** (dev). TS 6+ 는 `@types/*` 를 자동 포함하지 않아 tsconfig 에 `"types": ["node"]` 명시.
48
+ TS 7 의 `tsc` 는 Node 20.0 에서 실행되지 않으므로 CI 의 훅 포터빌리티 잡을 "Node 22 로 빌드 → 대상 Node 로 로드" 로 분리
49
+ (배포물은 빌드된 dist 이므로 사용자 영향 없음).
50
+ - **vitest 4 → 5** + `@vitest/coverage-v8` 5 (dev, 함께 올려야 함).
51
+ - `@modelcontextprotocol/sdk` 1.31.0, `zod` 4.6.5, `@types/node` 26.6.4, `@biomejs/biome` 2.5.15, `npm audit fix`
52
+ (advisory 13 → 0), `actions/setup-node` v7.
53
+
54
+ ### Docs
55
+ - `packages/forgen-eval/reports/persistence/` 원시 리포트 6건 커밋 — `docs/release/v0.5.0-persistence-delta.md` 가 재현
56
+ 근거로 인용하던 파일이 저장소에 없었다.
57
+
58
+ ### Verified
59
+ - vitest 3204 통과 (신규: Codex uninstall 왕복·신뢰 인덱스 보존·config 보존·소유 판정 오탐·손편집 notify 블록,
60
+ Claude 쪽 MCP/dev-guide 정리·권한/심링크 보존, js-yaml 스칼라 해석).
61
+ - 격리 `HOME` 에서 CLI `install both` → `uninstall --force` 왕복: forgen 이 등록한 훅/블록/스킬/에이전트가 제거되고, 다른
62
+ 도구 훅은 실 Codex `hooks/list` 에서 uninstall 후에도 `trusted`, `codex mcp list` 가 config 를 정상 파싱 (손편집된
63
+ 여러 줄 notify 블록이 있는 경우 포함).
64
+ - fresh-context critic 리뷰: MAJOR 2 · MINOR 12 → 위 Fixed/Changed 에 반영 또는 한계로 명시. 리뷰어가 확인한 것: TS 7 빌드의
65
+ `.js` 227개가 TS 5.9 빌드와 바이트 동일, 실 `~/.codex` 사본에서 uninstall 후 다른 도구 훅 8개 trusted 유지.
66
+ - CI: ubuntu/macOS/arm 테스트 + Node 20.0.0~22 훅 로드 14개 검사 통과.
67
+ - 0.5.6 을 이 머신의 실 `~/.codex` 에 설치한 뒤 실 Codex 세션 1회: Stop 훅 발화·alive 마커 갱신, silent 플래그 없음.
68
+
69
+
10
70
  ## [0.5.6] — 2026-10-02 — Codex notify 폴백 · 훅 번들(재승인 2건) · Claude verify 스킬 (ADR-016)
11
71
 
12
72
  > **Codex 사용자 — 재승인 필요**: 업그레이드 후 `forgen install codex` 를 다시 실행하면 훅 2개
package/README.ja.md CHANGED
@@ -564,7 +564,7 @@ forgen mcp list # インストール済み MCP サーバーを
564
564
  forgen mcp add <名前> # テンプレートから MCP サーバーを追加
565
565
  forgen mcp templates # 利用可能なテンプレートを表示
566
566
  forgen notepad show # セッションノートパッドを表示
567
- forgen uninstall # forgen をきれいに削除
567
+ forgen uninstall # forgen をきれいに削除 (Claude Code + Codex の登録分)
568
568
  ```
569
569
 
570
570
  ### MCP ツール(セッション中に Claude が使用可能)
package/README.ko.md CHANGED
@@ -549,7 +549,7 @@ forgen mcp list # 설치된 MCP 서버 목록
549
549
  forgen mcp add <이름> # 템플릿에서 MCP 서버 추가
550
550
  forgen mcp templates # 사용 가능한 템플릿 목록
551
551
  forgen notepad show # 세션 노트패드 보기
552
- forgen uninstall # forgen 깔끔하게 제거
552
+ forgen uninstall # forgen 깔끔하게 제거 (Claude Code + Codex 등록분)
553
553
  ```
554
554
 
555
555
  ### MCP 도구 (세션 중 Claude가 사용)
package/README.md CHANGED
@@ -750,7 +750,7 @@ forgen mcp list # List installed MCP servers
750
750
  forgen mcp add <name> # Add MCP server from template
751
751
  forgen mcp templates # Show available templates
752
752
  forgen notepad show # View session notepad
753
- forgen uninstall # Remove forgen cleanly
753
+ forgen uninstall # Remove forgen cleanly (Claude Code + Codex registrations)
754
754
  ```
755
755
 
756
756
  ### Rule lifecycle (v0.4.0, ADR-001/002)
package/README.zh.md CHANGED
@@ -533,7 +533,7 @@ forgen mcp list # 列出已安装的 MCP 服务器
533
533
  forgen mcp add <名称> # 从模板添加 MCP 服务器
534
534
  forgen mcp templates # 显示可用模板
535
535
  forgen notepad show # 查看会话记事本
536
- forgen uninstall # 干净地卸载 forgen
536
+ forgen uninstall # 干净地卸载 forgen (Claude Code + Codex 的注册项)
537
537
  ```
538
538
 
539
539
  ### MCP 工具(会话中 Claude 可使用)
@@ -44,19 +44,18 @@ export function computeHealth() {
44
44
  if (Object.keys(s.philosophy.axisScores).length >= 4)
45
45
  profile += 3;
46
46
  }
47
- const total = Math.round(utilization + effectiveness + growth + coverage + profile);
48
- const grade = total >= 80 ? 'A' : total >= 60 ? 'B' : total >= 40 ? 'C' : total >= 20 ? 'D' : 'F';
49
- return {
50
- total,
51
- components: {
52
- utilization: Math.round(utilization),
53
- effectiveness: Math.round(effectiveness),
54
- growth: Math.round(growth),
55
- coverage: Math.round(coverage),
56
- profile: Math.round(profile),
57
- },
58
- grade,
47
+ // 총점은 *표시되는* 항목 점수의 합이어야 한다. 이전엔 합을 반올림(round(Σ))하고 항목은 따로 반올림해서
48
+ // 실 상태에 따라 "항목 합 60 ≠ 총점 61" 이 나왔다 (tests/health-cli 가 머신 상태에 따라 간헐 실패).
49
+ const components = {
50
+ utilization: Math.round(utilization),
51
+ effectiveness: Math.round(effectiveness),
52
+ growth: Math.round(growth),
53
+ coverage: Math.round(coverage),
54
+ profile: Math.round(profile),
59
55
  };
56
+ const total = components.utilization + components.effectiveness + components.growth + components.coverage + components.profile;
57
+ const grade = total >= 80 ? 'A' : total >= 60 ? 'B' : total >= 40 ? 'C' : total >= 20 ? 'D' : 'F';
58
+ return { total, components, grade };
60
59
  }
61
60
  function gradeColor(grade) {
62
61
  if (grade === 'A')
@@ -91,7 +91,7 @@ export declare const V1_SESSIONS_DIR: string;
91
91
  /** ~/.forgen/state/raw-logs/ — Raw Log */
92
92
  export declare const V1_RAW_LOGS_DIR: string;
93
93
  /** 모든 실행 모드 이름 (cancel/recovery 시 사용) */
94
- export declare const ALL_MODES: readonly ["ralph", "autopilot", "ultrawork", "team", "pipeline", "ccg", "ralplan", "deep-interview", "forge-loop", "ship", "retro", "learn", "calibrate"];
94
+ export declare const ALL_MODES: readonly ['ralph', 'autopilot', 'ultrawork', 'team', 'pipeline', 'ccg', 'ralplan', 'deep-interview', 'forge-loop', 'ship', 'retro', 'learn', 'calibrate'];
95
95
  /** {repo}/.compound/ — 프로젝트 로컬 디렉토리 */
96
96
  export declare function projectDir(cwd: string): string;
97
97
  /** {repo}/.compound/pack.link — 팀 팩 연결 파일 */
@@ -26,7 +26,13 @@ export declare function acquireLock(): void;
26
26
  * 다른 프로세스의 lock을 존중한다.
27
27
  */
28
28
  export declare function releaseLock(): void;
29
- /** 임시파일에 쓴 후 rename으로 원자적 교체 */
29
+ /**
30
+ * 임시파일에 쓴 후 rename으로 원자적 교체.
31
+ *
32
+ * 0.5.7 (critic): 기존 파일의 권한과 심링크를 보존한다. 이전엔 새 파일을 기본 mode(0644)로 만들어 rename 해서
33
+ * 0600 이던 `~/.claude.json`(MCP env 에 비밀값이 들어갈 수 있다)이 0644 로 넓어졌고, 심링크였으면 링크가
34
+ * 일반 파일로 바뀌고 원본은 갱신되지 않았다.
35
+ */
30
36
  export declare function atomicWriteFileSync(targetPath: string, data: string): void;
31
37
  /**
32
38
  * settings.json 안전 읽기.
@@ -96,11 +96,26 @@ export function releaseLock() {
96
96
  }
97
97
  catch { /* 이미 없으면 무시 */ }
98
98
  }
99
- /** 임시파일에 쓴 후 rename으로 원자적 교체 */
99
+ /**
100
+ * 임시파일에 쓴 후 rename으로 원자적 교체.
101
+ *
102
+ * 0.5.7 (critic): 기존 파일의 권한과 심링크를 보존한다. 이전엔 새 파일을 기본 mode(0644)로 만들어 rename 해서
103
+ * 0600 이던 `~/.claude.json`(MCP env 에 비밀값이 들어갈 수 있다)이 0644 로 넓어졌고, 심링크였으면 링크가
104
+ * 일반 파일로 바뀌고 원본은 갱신되지 않았다.
105
+ */
100
106
  export function atomicWriteFileSync(targetPath, data) {
101
- const tmpPath = `${targetPath}.tmp.${process.pid}`;
102
- fs.writeFileSync(tmpPath, data);
103
- fs.renameSync(tmpPath, targetPath);
107
+ let realTarget = targetPath;
108
+ let mode;
109
+ try {
110
+ realTarget = fs.realpathSync(targetPath);
111
+ mode = fs.statSync(realTarget).mode & 0o777;
112
+ }
113
+ catch { /* 새 파일 — 기본 mode */ }
114
+ const tmpPath = `${realTarget}.tmp.${process.pid}`;
115
+ fs.writeFileSync(tmpPath, data, mode !== undefined ? { mode } : undefined);
116
+ if (mode !== undefined)
117
+ fs.chmodSync(tmpPath, mode); // umask 로 깎였을 수 있어 명시
118
+ fs.renameSync(tmpPath, realTarget);
104
119
  }
105
120
  /**
106
121
  * settings.json 안전 읽기.
@@ -13,7 +13,7 @@
13
13
  * forgen status --blocks [N] 최근 차단 N건 (rule·사유·해결)
14
14
  * forgen status --live 실시간 훅 이벤트 스트림
15
15
  */
16
- declare const VIEWS: readonly ["--compound", "--profile", "--rules", "--blocks", "--live", "--overview"];
16
+ declare const VIEWS: readonly ['--compound', '--profile', '--rules', '--blocks', '--live', '--overview'];
17
17
  type View = (typeof VIEWS)[number];
18
18
  export declare function resolveView(args: string[]): View | null;
19
19
  export declare function handleStatus(args: string[]): Promise<void>;
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * 1원칙: Claude semantics 가 reference. 본 enum 의 의미는 Claude Hook schema 의 행동을 그대로 사용한다.
9
9
  */
10
- export declare const TRUST_LAYER_INTENTS: readonly ["block-completion", "block-tool-use", "inject-context", "observe-only", "secret-filter", "forge-loop-state-inject", "self-evidence-record"];
10
+ export declare const TRUST_LAYER_INTENTS: readonly ['block-completion', 'block-tool-use', 'inject-context', 'observe-only', 'secret-filter', 'forge-loop-state-inject', 'self-evidence-record'];
11
11
  export type TrustLayerIntent = (typeof TRUST_LAYER_INTENTS)[number];
12
12
  export type CapabilityStatus = 'supported' | 'partial' | 'unsupported';
13
13
  export interface CapabilityDeclaration {
@@ -25,7 +25,7 @@ export interface CapabilityDeclaration {
25
25
  * `(HOST_IDS as readonly string[]).includes(host)` 로 "유효 host 인지" 를 판정하라
26
26
  * (W3-3 리뷰 SEV-3 #5: Record<HostId> 는 Record 리터럴만 강제, 자유비교는 미포착).
27
27
  */
28
- export declare const HOST_IDS: readonly ["claude", "codex", "opencode"];
28
+ export declare const HOST_IDS: readonly ['claude', 'codex', 'opencode'];
29
29
  export type HostId = (typeof HOST_IDS)[number];
30
30
  /**
31
31
  * 능력 선언의 검증 수준 (W3-3 리뷰 SEV-3 #1).
@@ -1,10 +1,15 @@
1
1
  /** ADR-016 D3 — ~/.claude/skills/verify 제거 (forgen-managed 마커가 있는 것만; 사용자 스킬은 보존) */
2
2
  export declare function cleanVerifySkill(homeDir?: string): boolean;
3
3
  /**
4
- * ADR-016 D1 — Codex config.toml 의 forgen notify 블록 제거. 남겨 두면 Codex 가 매 턴 사라진 스크립트를
5
- * spawn 한다. (Codex hooks.json / MCP 블록 정리는 아직 uninstall 범위 밖 — 알려진 갭.)
4
+ * ADR-016 D4 — `~/.claude/skills/forgen-<stack>-<skill>/` dev-guide 스킬 제거.
5
+ * 패키지가 실제로 제공하는 이름만 지운다 (사용자가 만든 `forgen-react-mine` 같은 스킬은 보존).
6
6
  */
7
- export declare function cleanCodexNotify(codexHome?: string): Promise<boolean>;
7
+ export declare function cleanDevGuideSkills(pkgRoot: string, homeDir?: string): number;
8
+ /**
9
+ * ADR-016 D4 — `~/.claude.json` 의 `mcpServers["forgen-compound"]` 제거. install 은 settings.json 이 아니라
10
+ * 여기에 등록하는데 uninstall 은 settings.json 만 정리하고 있었다. forgen 서버 경로를 가리킬 때만 지운다.
11
+ */
12
+ export declare function cleanClaudeJsonMcp(homeDir?: string): boolean;
8
13
  /** forgen uninstall 메인 */
9
14
  export declare function handleUninstall(cwd: string, options: {
10
15
  force?: boolean;
@@ -2,6 +2,8 @@ import * as fs from 'node:fs';
2
2
  import * as os from 'node:os';
3
3
  import * as path from 'node:path';
4
4
  import * as readline from 'node:readline';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { hasManagedSkillMarker, listDevGuideSkillNames, removeSkillFile } from '../host/managed-marker.js';
5
7
  import { SETTINGS_PATH, acquireLock, releaseLock, atomicWriteFileSync, } from './settings-lock.js';
6
8
  /** 플러그인 제거 (plugin-installer.ts 삭제 후 인라인) */
7
9
  function uninstallPlugin() {
@@ -103,8 +105,7 @@ export function cleanVerifySkill(homeDir = os.homedir()) {
103
105
  try {
104
106
  if (fs.lstatSync(dir).isSymbolicLink() || fs.lstatSync(file).isSymbolicLink())
105
107
  return false;
106
- const content = fs.readFileSync(file, 'utf-8');
107
- if (!/^---\n[\s\S]*?\n---\n\s*<!-- forgen-managed -->/.test(content))
108
+ if (!hasManagedSkillMarker(fs.readFileSync(file, 'utf-8')))
108
109
  return false;
109
110
  fs.unlinkSync(file);
110
111
  try {
@@ -119,22 +120,41 @@ export function cleanVerifySkill(homeDir = os.homedir()) {
119
120
  }
120
121
  }
121
122
  /**
122
- * ADR-016 D1 — Codex config.toml 의 forgen notify 블록 제거. 남겨 두면 Codex 가 매 턴 사라진 스크립트를
123
- * spawn 한다. (Codex hooks.json / MCP 블록 정리는 아직 uninstall 범위 밖 — 알려진 갭.)
123
+ * ADR-016 D4 — `~/.claude/skills/forgen-<stack>-<skill>/` dev-guide 스킬 제거.
124
+ * 패키지가 실제로 제공하는 이름만 지운다 (사용자가 만든 `forgen-react-mine` 같은 스킬은 보존).
124
125
  */
125
- export async function cleanCodexNotify(codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex')) {
126
- const configPath = path.join(codexHome, 'config.toml');
126
+ export function cleanDevGuideSkills(pkgRoot, homeDir = os.homedir()) {
127
+ const skillsDir = path.join(homeDir, '.claude', 'skills');
128
+ let removed = 0;
129
+ for (const name of listDevGuideSkillNames(pkgRoot)) {
130
+ if (removeSkillFile(skillsDir, name, false))
131
+ removed += 1;
132
+ }
133
+ return removed;
134
+ }
135
+ /**
136
+ * ADR-016 D4 — `~/.claude.json` 의 `mcpServers["forgen-compound"]` 제거. install 은 settings.json 이 아니라
137
+ * 여기에 등록하는데 uninstall 은 settings.json 만 정리하고 있었다. forgen 서버 경로를 가리킬 때만 지운다.
138
+ */
139
+ export function cleanClaudeJsonMcp(homeDir = os.homedir()) {
140
+ const claudeJsonPath = path.join(homeDir, '.claude.json');
127
141
  try {
128
- const current = fs.readFileSync(configPath, 'utf-8');
129
- const { removeNotifyBlock } = await import('../host/install-codex.js');
130
- const r = removeNotifyBlock(current);
131
- if (!r.removed)
142
+ const data = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf-8'));
143
+ const entry = data.mcpServers?.['forgen-compound'];
144
+ if (!entry)
132
145
  return false;
133
- fs.writeFileSync(configPath, r.content, 'utf-8');
146
+ const args = Array.isArray(entry.args) ? entry.args : [];
147
+ const isForgenServer = args.some((a) => typeof a === 'string' && /[\\/]dist[\\/]mcp[\\/]server\.js$/.test(a));
148
+ if (!isForgenServer)
149
+ return false; // 사용자가 같은 이름으로 다른 서버를 등록
150
+ delete data.mcpServers?.['forgen-compound'];
151
+ if (data.mcpServers && Object.keys(data.mcpServers).length === 0)
152
+ delete data.mcpServers;
153
+ atomicWriteFileSync(claudeJsonPath, `${JSON.stringify(data, null, 2)}\n`);
134
154
  return true;
135
155
  }
136
156
  catch {
137
- return false; // Codex 미사용
157
+ return false; // 파일 없음 / 파싱 실패 — 건드리지 않는다
138
158
  }
139
159
  }
140
160
  /** 사용자에게 y/n 확인 */
@@ -343,9 +363,10 @@ export async function handleUninstall(cwd, options) {
343
363
  console.log(' 3. Delete .claude/rules/ rule files (project-context, routing, forge-*)');
344
364
  console.log(' 4. Remove forgen block from CLAUDE.md');
345
365
  console.log(' 5. Remove slash commands (~/.claude/commands/forgen/) and the forgen-managed verify skill (~/.claude/skills/verify/)');
346
- console.log(' 6. Remove plugin artifacts (cache, installed_plugins.json, plugin directory)');
366
+ console.log(' 6. Remove plugin artifacts (cache, installed_plugins.json, plugin directory), dev-guide skills, and the forgen-compound MCP entry in ~/.claude.json');
367
+ console.log(' 7. Codex (if $CODEX_HOME or ~/.codex exists): forgen hooks in hooks.json, the MCP/notify blocks in config.toml, forgen skills, ch-*.toml agents, and the AGENTS.md block — hooks and files from other tools are kept');
347
368
  if (options.purge) {
348
- console.log(' 7. --purge: Delete ~/.forgen/ entirely (rules, me/, state/, solutions/, behavior/)');
369
+ console.log(' 8. --purge: Delete ~/.forgen/ entirely (rules, me/, state/, solutions/, behavior/)');
349
370
  console.log(' WARNING: this erases all accumulated corrections, rules, drift, and lifecycle history.');
350
371
  }
351
372
  else {
@@ -373,8 +394,21 @@ export async function handleUninstall(cwd, options) {
373
394
  cleanSlashCommands();
374
395
  if (cleanVerifySkill())
375
396
  console.log(' ✓ Removed forgen verify skill (~/.claude/skills/verify/)');
376
- if (await cleanCodexNotify())
377
- console.log(' ✓ Removed forgen notify block from Codex config.toml');
397
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
398
+ const devGuide = cleanDevGuideSkills(pkgRoot);
399
+ if (devGuide > 0)
400
+ console.log(` ✓ Removed ${devGuide} dev-guide skill(s) (~/.claude/skills/forgen-*)`);
401
+ if (cleanClaudeJsonMcp())
402
+ console.log(' ✓ Removed forgen-compound MCP server from ~/.claude.json');
403
+ // ADR-016 D4 — Codex 에 등록한 것도 되돌린다 (다른 도구의 훅과 그 신뢰는 보존)
404
+ try {
405
+ const { planCodexUninstall, renderCodexUninstall } = await import('../host/uninstall-codex.js');
406
+ for (const line of renderCodexUninstall(planCodexUninstall({ pkgRoot })))
407
+ console.log(line);
408
+ }
409
+ catch (e) {
410
+ console.error(' ✗ Codex cleanup failed:', e instanceof Error ? e.message : String(e));
411
+ }
378
412
  cleanPluginArtifacts();
379
413
  if (options.purge) {
380
414
  try {
@@ -392,5 +426,5 @@ export async function handleUninstall(cwd, options) {
392
426
  console.log(` ✗ ~/.forgen/ deletion failed: ${e.message}`);
393
427
  }
394
428
  }
395
- console.log('\n[forgen] Uninstall complete. Restart Claude Code for a clean state.\n');
429
+ console.log('\n[forgen] Uninstall complete. Restart Claude Code (and Codex) for a clean state.\n');
396
430
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import yaml from 'js-yaml';
3
+ import * as yaml from 'js-yaml';
4
4
  import { DEFAULT_EVIDENCE } from './solution-format.js';
5
5
  import { diagnoseFromRawContent } from './solution-quarantine.js';
6
6
  import { createLogger } from '../core/logger.js';
@@ -83,7 +83,7 @@ function tryFix(content, initialErrors) {
83
83
  const body = trimmed.slice(endIdx + 3);
84
84
  let fm;
85
85
  try {
86
- const parsed = yaml.load(fmRaw, { schema: yaml.JSON_SCHEMA });
86
+ const parsed = yaml.load(fmRaw, { schema: yaml.CORE_SCHEMA });
87
87
  if (parsed == null || typeof parsed !== 'object') {
88
88
  return { changed: false, added, remaining: initialErrors, content };
89
89
  }
@@ -1,4 +1,4 @@
1
- import yaml from 'js-yaml';
1
+ import * as yaml from 'js-yaml';
2
2
  export const DEFAULT_EVIDENCE = {
3
3
  injected: 0,
4
4
  reflected: 0,
@@ -106,7 +106,7 @@ export function parseFrontmatterOnly(content) {
106
106
  const anchorCount = (raw.match(/(?<=\s|^)&\w+/g) ?? []).length;
107
107
  if (anchorCount > 3)
108
108
  return null;
109
- const parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
109
+ const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
110
110
  if (!validateFrontmatter(parsed))
111
111
  return null;
112
112
  return parsed;
@@ -156,8 +156,8 @@ export function parseSolutionV3(content) {
156
156
  export function serializeSolutionV3(solution) {
157
157
  const yamlStr = yaml.dump(solution.frontmatter, {
158
158
  lineWidth: -1,
159
- quotingType: '"',
160
- schema: yaml.JSON_SCHEMA,
159
+ quoteStyle: 'double',
160
+ schema: yaml.CORE_SCHEMA,
161
161
  });
162
162
  return `---\n${yamlStr}---\n\n## Context\n${solution.context}\n\n## Content\n${solution.content}\n`;
163
163
  }
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import yaml from 'js-yaml';
3
+ import * as yaml from 'js-yaml';
4
4
  import { SOLUTION_QUARANTINE_PATH, STATE_DIR } from '../core/paths.js';
5
5
  import { diagnoseFrontmatter } from './solution-format.js';
6
6
  import { createLogger } from '../core/logger.js';
@@ -24,7 +24,7 @@ export function diagnoseFromRawContent(content) {
24
24
  return ['frontmatter too large (>5000 chars — YAML bomb guard)'];
25
25
  let parsed;
26
26
  try {
27
- parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
27
+ parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
28
28
  }
29
29
  catch (e) {
30
30
  return [`YAML parse error: ${e instanceof Error ? e.message : String(e)}`];
@@ -18,6 +18,7 @@ import * as fs from 'node:fs';
18
18
  import * as os from 'node:os';
19
19
  import * as path from 'node:path';
20
20
  import { generateHooksJson } from '../hooks/hooks-generator.js';
21
+ import { hasManagedSkillMarker } from './managed-marker.js';
21
22
  const PLUGIN_KEY = 'forgen@forgen-local';
22
23
  const FORGEN_MANAGED_MARKER = '<!-- forgen-managed -->';
23
24
  function readPkgVersion(pkgRoot) {
@@ -303,8 +304,6 @@ function installDevGuideSkills(opts) {
303
304
  return { skillsPath: skillsDir, skillsInstalled: installed, skillsRemoved: removed };
304
305
  }
305
306
  // ── 6. verify skill (ADR-016 D3) ───────────────────────────────────────
306
- /** frontmatter 직후에 forgen-managed 마커가 있는 SKILL.md 만 forgen 소유로 본다. */
307
- const MANAGED_SKILL_RE = /^---\n[\s\S]*?\n---\n\s*<!-- forgen-managed -->/;
308
307
  /** `<skillsDir>/verify` 가 사용자 소유인가 (심링크 / 마커 없는 SKILL.md / SKILL.md 없이 다른 파일만 있음). */
309
308
  export function isUserOwnedVerifySkill(skillsDir) {
310
309
  const dir = path.join(skillsDir, 'verify');
@@ -319,7 +318,7 @@ export function isUserOwnedVerifySkill(skillsDir) {
319
318
  try {
320
319
  if (fs.lstatSync(file).isSymbolicLink())
321
320
  return true;
322
- return !MANAGED_SKILL_RE.test(fs.readFileSync(file, 'utf-8'));
321
+ return !hasManagedSkillMarker(fs.readFileSync(file, 'utf-8'));
323
322
  }
324
323
  catch {
325
324
  // SKILL.md 없음 — 디렉토리에 다른 것이 있으면 사용자가 만들던 것
@@ -90,6 +90,19 @@ export interface CodexHookTrustAudit {
90
90
  /** config.toml 자체가 없거나 hooks.state 가 전혀 없으면 true (Codex 가 아직 한 번도 훅을 review 안 함) */
91
91
  noStateRecorded: boolean;
92
92
  }
93
+ export declare const FORGEN_SKILL_MARKER = "<!-- forgen-managed -->";
94
+ /** 훅 command 문자열이 forgen 소유인가. */
95
+ export declare function isForgenHookCommand(command: unknown, pkgRoot: string): boolean;
96
+ export declare function readJsonFile<T>(p: string): T | null;
97
+ /**
98
+ * forgen MCP 블록 제거 (uninstall, ADR-016 D4). forgen 테이블(본문 + `[mcp_servers.forgen-compound.*]` 하위
99
+ * 테이블)과 마커만 걷어내고, 블록 사이에 Codex 가 끼워 넣은 다른 내용은 그 자리에 보존한다.
100
+ * 마커 없는 사용자 관리 테이블은 건드리지 않는다.
101
+ */
102
+ export declare function removeMcpBlock(currentToml: string): {
103
+ content: string;
104
+ removed: boolean;
105
+ };
93
106
  /**
94
107
  * config.toml 에 forgen notify 블록을 upsert.
95
108
  *
@@ -104,12 +117,20 @@ export declare function upsertNotifyBlock(currentToml: string, pkgRoot: string):
104
117
  content: string;
105
118
  status: CodexNotifyStatus;
106
119
  };
107
- /** forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존. */
120
+ /**
121
+ * forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존.
122
+ *
123
+ * - 사용자가 블록의 notify 줄을 여러 줄 배열 등으로 손편집했으면(`custom`) **건드리지 않는다** — 첫 줄만
124
+ * 지우면 남은 줄이 깨진 TOML 이 되어 Codex 가 기동하지 못한다 (critic 2026-10-02).
125
+ * - `"--"` 뒤에 사용자가 체인해 둔 자기 notifier 가 있으면 그 argv 만으로 `notify` 를 되돌려 놓는다.
126
+ */
108
127
  export declare function removeNotifyBlock(currentToml: string): {
109
128
  content: string;
110
129
  removed: boolean;
130
+ custom: boolean;
131
+ restoredChain: string[];
111
132
  };
112
- interface HooksFile {
133
+ export interface HooksFile {
113
134
  description?: string;
114
135
  hooks: Record<string, Array<unknown>>;
115
136
  }
@@ -154,6 +175,8 @@ export declare function auditCodexHookTrust(opts: {
154
175
  hooksFile?: HooksFile | null;
155
176
  configToml?: string;
156
177
  }): CodexHookTrustAudit;
178
+ export declare const AGENT_TOML_MARKER = "# forgen-managed";
179
+ export declare const AGENT_NAME_PREFIX = "ch-";
157
180
  /**
158
181
  * Claude agent .md → Codex agent role TOML.
159
182
  * 공식 스키마(필수 name/description/developer_instructions, 선택 model_reasoning_effort/sandbox_mode)
@@ -163,6 +186,7 @@ export declare function renderCodexAgentToml(file: string, raw: string): {
163
186
  name: string;
164
187
  toml: string;
165
188
  } | null;
189
+ export declare const DEV_GUIDE_SKILL_PATTERN: RegExp;
166
190
  /** ADR-014 D3 — Codex 스킬에는 `$ARGUMENTS` 치환 변수가 없다. */
167
191
  export declare function adaptSkillBodyForCodex(body: string): string;
168
192
  export declare function resolveAgentsMdPath(_pkgRoot: string): string;
@@ -173,4 +197,11 @@ export declare function upsertForgenRulesInAgentsMd(opts: {
173
197
  }): {
174
198
  injected: boolean;
175
199
  };
176
- export {};
200
+ /** AGENTS.md 의 forgen 블록 제거 (uninstall). 블록뿐이던 파일은 삭제한다. */
201
+ export declare function removeForgenRulesFromAgentsMd(opts: {
202
+ agentsMdPath: string;
203
+ dryRun: boolean;
204
+ }): {
205
+ removed: boolean;
206
+ fileDeleted: boolean;
207
+ };
@@ -16,11 +16,13 @@ import * as fs from 'node:fs';
16
16
  import * as os from 'node:os';
17
17
  import * as path from 'node:path';
18
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';
19
21
  const MCP_MARKER_BEGIN = '# >>> forgen-managed-mcp';
20
22
  const MCP_MARKER_END = '# <<< forgen-managed-mcp';
21
23
  const NOTIFY_MARKER_BEGIN = '# >>> forgen-managed-notify';
22
24
  const NOTIFY_MARKER_END = '# <<< forgen-managed-notify';
23
- const FORGEN_SKILL_MARKER = '<!-- forgen-managed -->';
25
+ export const FORGEN_SKILL_MARKER = '<!-- forgen-managed -->';
24
26
  const AGENTS_MD_BEGIN = '<!-- >>> forgen-managed-rules -->';
25
27
  const AGENTS_MD_END = '<!-- <<< forgen-managed-rules -->';
26
28
  function resolveCodexHome(opts) {
@@ -29,18 +31,35 @@ function resolveCodexHome(opts) {
29
31
  // 0.4.6 fix — pkgRoot match 외에 script-marker fallback 추가.
30
32
  // Stale install path (예: 다른 머신에서 install 한 hooks.json 마운트, 또는
31
33
  // node_modules path 변경) 의 forgen entry 를 "user entry" 로 오분류 → 중복 누적
32
- // 하던 버그. forgen hook 의 시그니처는 dist/host/codex-adapter.js 또는
33
- // dist/hooks/<name>.js — 사용자 custom hook 과 충돌 가능성 거의 없음.
34
- const FORGEN_HOOK_SCRIPT_MARKER = /\bdist\/(host\/codex-adapter|hooks\/[a-z][a-z0-9-]+)\.js\b/;
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
+ }
35
54
  function isForgenManagedHook(entry, pkgRoot) {
36
55
  if (!entry || typeof entry !== 'object')
37
56
  return false;
38
57
  const e = entry;
39
58
  if (!Array.isArray(e.hooks))
40
59
  return false;
41
- return e.hooks.some((h) => typeof h.command === 'string' && (h.command.includes(pkgRoot) || FORGEN_HOOK_SCRIPT_MARKER.test(h.command)));
60
+ return e.hooks.some((h) => isForgenHookCommand(h.command, pkgRoot));
42
61
  }
43
- function readJsonFile(p) {
62
+ export function readJsonFile(p) {
44
63
  try {
45
64
  if (!fs.existsSync(p))
46
65
  return null;
@@ -115,6 +134,37 @@ function upsertMcpBlock(currentToml, pkgRoot) {
115
134
  const content = bom + out.join('\n');
116
135
  return { content, alreadyPresent: content === currentToml };
117
136
  }
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
+ }
118
168
  // ── ADR-016 D1: notify 폴백 (config.toml top-level `notify`) ───────────
119
169
  /** forgen notify 바이너리 argv 접두 (`--` 뒤는 사용자가 수동으로 붙인 체인 프로그램). */
120
170
  function forgenNotifyArgv(pkgRoot) {
@@ -181,19 +231,30 @@ export function upsertNotifyBlock(currentToml, pkgRoot) {
181
231
  const content = bom + (tail.length > 0 ? [...block, cr, ...tail] : [...block, '']).join('\n');
182
232
  return { content, status: content === currentToml ? 'already-present' : 'installed' };
183
233
  }
184
- /** forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존. */
234
+ /**
235
+ * forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존.
236
+ *
237
+ * - 사용자가 블록의 notify 줄을 여러 줄 배열 등으로 손편집했으면(`custom`) **건드리지 않는다** — 첫 줄만
238
+ * 지우면 남은 줄이 깨진 TOML 이 되어 Codex 가 기동하지 못한다 (critic 2026-10-02).
239
+ * - `"--"` 뒤에 사용자가 체인해 둔 자기 notifier 가 있으면 그 argv 만으로 `notify` 를 되돌려 놓는다.
240
+ */
185
241
  export function removeNotifyBlock(currentToml) {
186
- const { bom, body } = tomlShape(currentToml);
187
- const { span, foreign } = parseNotifyBlock(body.split('\n'));
242
+ const { bom, body, cr } = tomlShape(currentToml);
243
+ const { span, foreign, argv } = parseNotifyBlock(body.split('\n'));
188
244
  if (!span)
189
- return { content: currentToml, removed: false };
190
- const rest = [...span.before, ...foreign, ...span.after];
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];
191
252
  let start = 0;
192
- if (span.before.every((l) => l.trim() === '') && foreign.length === 0) {
253
+ if (span.before.every((l) => l.trim() === '') && restored.length === 0 && foreign.length === 0) {
193
254
  while (start < rest.length && rest[start].trim() === '')
194
255
  start += 1;
195
256
  }
196
- return { content: bom + rest.slice(start).join('\n'), removed: true };
257
+ return { content: bom + rest.slice(start).join('\n'), removed: true, custom: false, restoredChain };
197
258
  }
198
259
  export function planCodexInstall(opts) {
199
260
  const codexHome = resolveCodexHome(opts);
@@ -223,7 +284,11 @@ export function planCodexInstall(opts) {
223
284
  const out = [];
224
285
  let inserted = false;
225
286
  for (const group of existingGroups) {
226
- if (isForgenManagedHook(group, opts.pkgRoot)) {
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) {
227
292
  if (!inserted) {
228
293
  out.push(...generatedGroups);
229
294
  inserted = true;
@@ -275,6 +340,8 @@ export function planCodexInstall(opts) {
275
340
  notify = 'removed';
276
341
  configToml = r.content;
277
342
  }
343
+ else if (r.custom)
344
+ notify = 'custom-block';
278
345
  }
279
346
  const configTomlToWrite = configToml !== currentToml ? configToml : null;
280
347
  // 5) 실제 쓰기 (dryRun 이면 skip) — hooks.json + config.toml
@@ -483,9 +550,7 @@ export function auditCodexHookTrust(opts) {
483
550
  if (!Array.isArray(g.hooks))
484
551
  return;
485
552
  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)
553
+ if (!isForgenHookCommand(h.command, opts.pkgRoot))
489
554
  return;
490
555
  const key = `${codexHookEventKey(event)}:${gi}:${hi}`;
491
556
  if (!CODEX_SUPPORTED_HOOK_EVENTS.has(event)) {
@@ -513,8 +578,8 @@ export function auditCodexHookTrust(opts) {
513
578
  return { total, trusted, untrusted, modified, disabled, ignoredByCodex, noStateRecorded: state.size === 0 };
514
579
  }
515
580
  // ── ADR-014 D2: Codex custom agents (~/.codex/agents/ch-*.toml) ──────
516
- const AGENT_TOML_MARKER = '# forgen-managed';
517
- const AGENT_NAME_PREFIX = 'ch-';
581
+ export const AGENT_TOML_MARKER = '# forgen-managed';
582
+ export const AGENT_NAME_PREFIX = 'ch-';
518
583
  function parseAgentMarkdown(raw) {
519
584
  const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
520
585
  if (!fm)
@@ -615,7 +680,7 @@ function installCodexAgents(opts) {
615
680
  const st = fs.lstatSync(p);
616
681
  if (st.isSymbolicLink())
617
682
  return true; // 사용자 심링크 (dangling 포함) — 건드리지 않음
618
- return !fs.readFileSync(p, 'utf-8').slice(0, 64).startsWith(AGENT_TOML_MARKER);
683
+ return !isManagedAgentToml(fs.readFileSync(p, 'utf-8'));
619
684
  }
620
685
  catch {
621
686
  return false; // 없음
@@ -638,8 +703,7 @@ function installCodexAgents(opts) {
638
703
  try {
639
704
  if (fs.lstatSync(p).isSymbolicLink())
640
705
  continue;
641
- const head = fs.readFileSync(p, 'utf-8').slice(0, 64);
642
- if (!head.startsWith(AGENT_TOML_MARKER))
706
+ if (!isManagedAgentToml(fs.readFileSync(p, 'utf-8')))
643
707
  continue;
644
708
  fs.unlinkSync(p);
645
709
  removed += 1;
@@ -664,7 +728,7 @@ function installCodexAgents(opts) {
664
728
  // ── v0.4.9: dev-guide skills → ~/.codex/skills ────────────────────────
665
729
  // dev-guide prefix pattern: forgen-<stack>-<skill> (e.g. forgen-react-fe-build)
666
730
  // 반드시 stack 이 react|vue|node|go 인 것만 매칭 — forgen 자체 commands 보존
667
- const DEV_GUIDE_SKILL_PATTERN = /^forgen-(react|vue|node|go)-/;
731
+ export const DEV_GUIDE_SKILL_PATTERN = /^forgen-(react|vue|node|go)-/;
668
732
  function installDevGuideSkillsToCodex(opts) {
669
733
  const devGuideRoot = path.join(opts.pkgRoot, 'assets', 'dev-guide');
670
734
  const codexSkillsDir = path.join(opts.codexHome, 'skills');
@@ -764,8 +828,7 @@ function installCodexSkills(opts) {
764
828
  // 사용자가 forgen 문서를 인용해 본문 안에 marker 가 우연히 포함될 수 있어
765
829
  // includes() 만으론 안전 X. 정규식으로 frontmatter 종결(`---\n`) 다음 빈 줄 다음
766
830
  // 첫 non-blank 줄에 marker 가 있는지 확인.
767
- const fmMarkerRe = /^---\n[\s\S]*?\n---\n\s*<!-- forgen-managed -->/;
768
- if (!fmMarkerRe.test(existing))
831
+ if (!hasManagedSkillMarker(existing))
769
832
  continue; // 사용자 작성 또는 손상 — skip
770
833
  }
771
834
  const raw = fs.readFileSync(path.join(sourceDir, file), 'utf-8');
@@ -856,6 +919,31 @@ export function upsertForgenRulesInAgentsMd(opts) {
856
919
  fs.writeFileSync(agentsMdPath, newContent, 'utf-8');
857
920
  return { injected: newContent !== current };
858
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
+ }
859
947
  function escapeRegex(s) {
860
948
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
861
949
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * forgen-managed 소유 판정 — install/uninstall 공용.
3
+ *
4
+ * "이 파일을 forgen 이 썼는가" 를 잘못 판정하면 사용자 파일을 덮어쓰거나 지운다. 그래서 마커가 *정해진
5
+ * 위치* 에 있을 때만 forgen 소유로 본다 (본문에 마커 문자열을 인용한 사용자 파일과 구분).
6
+ */
7
+ /**
8
+ * SKILL.md: frontmatter(`---` … 첫 번째 닫는 `---`) **바로 뒤** 에 마커가 있어야 한다.
9
+ * (이전의 lazy 정규식은 본문의 `---` 구분선 뒤에 나오는 마커까지 매치했다 — critic 2026-10-02.)
10
+ */
11
+ export declare function hasManagedSkillMarker(content: string): boolean;
12
+ /** Codex agent TOML: 첫 줄이 정확히 마커여야 한다 (`# forgen-managed-by-me` 같은 접두 일치는 사용자 파일). */
13
+ export declare function isManagedAgentToml(content: string): boolean;
14
+ /**
15
+ * 패키지가 실제로 제공하는 dev-guide 스킬 이름 (`forgen-<stack>-<skill>`).
16
+ * uninstall 은 이름 패턴이 아니라 이 목록으로 소유를 판정한다 — 사용자가 만든 `forgen-react-mine` 을 지우지 않게.
17
+ */
18
+ export declare function listDevGuideSkillNames(pkgRoot: string): Set<string>;
19
+ /**
20
+ * `<skillsDir>/<name>/SKILL.md` 한 개를 제거하고 디렉토리가 비면 디렉토리도 지운다.
21
+ * 사용자가 그 디렉토리에 다른 파일을 넣어 뒀으면 디렉토리는 남는다. 실제로 SKILL.md 를 지웠을 때만 true.
22
+ */
23
+ export declare function removeSkillFile(skillsDir: string, name: string, dryRun: boolean): boolean;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * forgen-managed 소유 판정 — install/uninstall 공용.
3
+ *
4
+ * "이 파일을 forgen 이 썼는가" 를 잘못 판정하면 사용자 파일을 덮어쓰거나 지운다. 그래서 마커가 *정해진
5
+ * 위치* 에 있을 때만 forgen 소유로 본다 (본문에 마커 문자열을 인용한 사용자 파일과 구분).
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ const SKILL_MARKER = '<!-- forgen-managed -->';
10
+ const AGENT_TOML_MARKER = '# forgen-managed';
11
+ /**
12
+ * SKILL.md: frontmatter(`---` … 첫 번째 닫는 `---`) **바로 뒤** 에 마커가 있어야 한다.
13
+ * (이전의 lazy 정규식은 본문의 `---` 구분선 뒤에 나오는 마커까지 매치했다 — critic 2026-10-02.)
14
+ */
15
+ export function hasManagedSkillMarker(content) {
16
+ const text = content.replace(/\r\n/g, '\n');
17
+ if (!text.startsWith('---\n'))
18
+ return false;
19
+ const close = text.indexOf('\n---\n', 3);
20
+ if (close === -1)
21
+ return false;
22
+ return text.slice(close + 5).trimStart().startsWith(SKILL_MARKER);
23
+ }
24
+ /** Codex agent TOML: 첫 줄이 정확히 마커여야 한다 (`# forgen-managed-by-me` 같은 접두 일치는 사용자 파일). */
25
+ export function isManagedAgentToml(content) {
26
+ return content.split('\n', 1)[0].replace(/\r$/, '') === AGENT_TOML_MARKER;
27
+ }
28
+ /**
29
+ * 패키지가 실제로 제공하는 dev-guide 스킬 이름 (`forgen-<stack>-<skill>`).
30
+ * uninstall 은 이름 패턴이 아니라 이 목록으로 소유를 판정한다 — 사용자가 만든 `forgen-react-mine` 을 지우지 않게.
31
+ */
32
+ export function listDevGuideSkillNames(pkgRoot) {
33
+ const names = new Set();
34
+ const root = path.join(pkgRoot, 'assets', 'dev-guide');
35
+ try {
36
+ for (const tier of fs.readdirSync(root)) {
37
+ const skillsBase = path.join(root, tier, 'skills');
38
+ if (!fs.existsSync(skillsBase))
39
+ continue;
40
+ for (const stack of fs.readdirSync(skillsBase)) {
41
+ const stackDir = path.join(skillsBase, stack);
42
+ if (!fs.statSync(stackDir).isDirectory())
43
+ continue;
44
+ for (const skill of fs.readdirSync(stackDir)) {
45
+ if (fs.existsSync(path.join(stackDir, skill, 'SKILL.md')))
46
+ names.add(`forgen-${stack}-${skill}`);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ catch { /* 자산 없음 */ }
52
+ return names;
53
+ }
54
+ /**
55
+ * `<skillsDir>/<name>/SKILL.md` 한 개를 제거하고 디렉토리가 비면 디렉토리도 지운다.
56
+ * 사용자가 그 디렉토리에 다른 파일을 넣어 뒀으면 디렉토리는 남는다. 실제로 SKILL.md 를 지웠을 때만 true.
57
+ */
58
+ export function removeSkillFile(skillsDir, name, dryRun) {
59
+ const dir = path.join(skillsDir, name);
60
+ const file = path.join(dir, 'SKILL.md');
61
+ try {
62
+ if (fs.lstatSync(dir).isSymbolicLink() || !fs.statSync(dir).isDirectory())
63
+ return false;
64
+ fs.lstatSync(file); // 없으면 throw
65
+ if (dryRun)
66
+ return true;
67
+ fs.unlinkSync(file);
68
+ try {
69
+ if (fs.readdirSync(dir).length === 0)
70
+ fs.rmdirSync(dir);
71
+ }
72
+ catch { /* ignore */ }
73
+ return true;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { type ClaudePreToolInput, type OpencodeToolDecision } from '../translate.js';
16
16
  /** PreToolUse 가드 순서: pre-tool-use(룰+위험명령 디스패처) → db-guard(rm -rf/위험 SQL). */
17
- export declare const PRE_TOOL_GUARDS: readonly ["pre-tool-use.js", "db-guard.js"];
17
+ export declare const PRE_TOOL_GUARDS: readonly ['pre-tool-use.js', 'db-guard.js'];
18
18
  export interface GuardRunOptions {
19
19
  /** forgen hook 바이너리 디렉터리. guard-cli 가 forgen dist/hooks 를 주입. */
20
20
  hookDir?: string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Codex uninstall — ADR-016 D4
3
+ *
4
+ * `forgen install codex` 가 쓴 것을 되돌린다: hooks.json 의 forgen 훅, config.toml 의 MCP/notify 블록,
5
+ * `skills/`, `agents/ch-*.toml`, cwd 의 AGENTS.md 블록. 사용자 소유물(마커 없는 파일, 심링크, 다른 도구의
6
+ * 훅)은 보존한다.
7
+ *
8
+ * 훅 신뢰 보존: Codex 의 trust 키는 `<event>:<groupIdx>:<hookIdx>` 다. forgen 그룹을 지워 뒤따르는
9
+ * 사용자 그룹의 인덱스가 당겨지면 **다른 도구의 훅이 재승인 전까지 조용히 skip** 된다. 그래서 뒤에 사용자
10
+ * 그룹이 남는 위치의 forgen 그룹은 빈 그룹(`{"hooks": []}`)으로 남겨 인덱스를 유지한다 (Codex 0.153.4 는
11
+ * 빈 그룹을 경고 없이 받아들인다 — `hooks/list` 로 확인). forgen 은 `[hooks.state]` 를 고쳐 쓰지 않는다.
12
+ */
13
+ import { type HooksFile } from './install-codex.js';
14
+ export interface CodexUninstallOptions {
15
+ /** forgen package root — 훅 command 소유 판정에 쓴다 (스크립트 시그니처 fallback 이 있어 정확도 비의존). */
16
+ pkgRoot: string;
17
+ /** codex home (default: $CODEX_HOME ?? ~/.codex). */
18
+ codexHome?: string;
19
+ /** AGENTS.md 위치 override (default: cwd 의 git root). 격리 테스트용. */
20
+ agentsMdPath?: string;
21
+ dryRun?: boolean;
22
+ }
23
+ export interface CodexUninstallResult {
24
+ codexHome: string;
25
+ /** $CODEX_HOME 자체가 없으면 false — 나머지 필드는 전부 0/false */
26
+ present: boolean;
27
+ /** 제거한 forgen 훅 핸들러 수 */
28
+ hooksRemoved: number;
29
+ /** 보존한 사용자(다른 도구) 훅 핸들러 수 */
30
+ userHooksPreserved: number;
31
+ /** 뒤따르는 사용자 그룹의 trust 인덱스를 지키려고 남긴 빈 그룹 수 */
32
+ placeholderGroups: number;
33
+ /** 같은 그룹 안에서 forgen 핸들러가 빠져 인덱스가 바뀐 사용자 훅 (`<event>:<group>:<old>→<new>`) — `/hooks` 재승인 필요 */
34
+ userHooksReindexed: string[];
35
+ hooksFileDeleted: boolean;
36
+ mcpRemoved: boolean;
37
+ notifyRemoved: boolean;
38
+ /** forgen notify 블록이 손편집돼(여러 줄 배열 등) 안전하게 지울 수 없어 그대로 둠 */
39
+ notifyCustomLeft: boolean;
40
+ /** 블록에 체인돼 있던 사용자 notifier argv — 그것만으로 `notify` 를 되돌려 놓았다 */
41
+ notifyChainRestored: string[];
42
+ skillsRemoved: number;
43
+ agentsRemoved: number;
44
+ agentsMdCleaned: boolean;
45
+ /** 단계별 실패 (한 단계가 실패해도 나머지는 진행한다) */
46
+ errors: string[];
47
+ }
48
+ /** hooks.json 에서 forgen 핸들러 제거 (순수 함수 — 파일을 쓰지 않는다). */
49
+ export declare function stripForgenHooks(file: HooksFile, pkgRoot: string): {
50
+ next: HooksFile | null;
51
+ removed: number;
52
+ preserved: number;
53
+ placeholders: number;
54
+ reindexed: string[];
55
+ };
56
+ export declare function planCodexUninstall(opts: CodexUninstallOptions): CodexUninstallResult;
57
+ /** CLI 출력 — 무엇을 지웠고 무엇을 남겼는지. */
58
+ export declare function renderCodexUninstall(r: CodexUninstallResult): string[];
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Codex uninstall — ADR-016 D4
3
+ *
4
+ * `forgen install codex` 가 쓴 것을 되돌린다: hooks.json 의 forgen 훅, config.toml 의 MCP/notify 블록,
5
+ * `skills/`, `agents/ch-*.toml`, cwd 의 AGENTS.md 블록. 사용자 소유물(마커 없는 파일, 심링크, 다른 도구의
6
+ * 훅)은 보존한다.
7
+ *
8
+ * 훅 신뢰 보존: Codex 의 trust 키는 `<event>:<groupIdx>:<hookIdx>` 다. forgen 그룹을 지워 뒤따르는
9
+ * 사용자 그룹의 인덱스가 당겨지면 **다른 도구의 훅이 재승인 전까지 조용히 skip** 된다. 그래서 뒤에 사용자
10
+ * 그룹이 남는 위치의 forgen 그룹은 빈 그룹(`{"hooks": []}`)으로 남겨 인덱스를 유지한다 (Codex 0.153.4 는
11
+ * 빈 그룹을 경고 없이 받아들인다 — `hooks/list` 로 확인). forgen 은 `[hooks.state]` 를 고쳐 쓰지 않는다.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as os from 'node:os';
15
+ import * as path from 'node:path';
16
+ import { AGENT_NAME_PREFIX, codexHookEventKey, isForgenHookCommand, removeForgenRulesFromAgentsMd, removeMcpBlock, removeNotifyBlock, resolveAgentsMdPath, } from './install-codex.js';
17
+ import { hasManagedSkillMarker, isManagedAgentToml, listDevGuideSkillNames, removeSkillFile } from './managed-marker.js';
18
+ const FORGEN_HOOKS_DESCRIPTION_RE = /^forgen Codex hooks \(managed/;
19
+ function isEmptyGroup(g) {
20
+ const hooks = g?.hooks;
21
+ return Array.isArray(hooks) && hooks.length === 0;
22
+ }
23
+ /** hooks.json 에서 forgen 핸들러 제거 (순수 함수 — 파일을 쓰지 않는다). */
24
+ export function stripForgenHooks(file, pkgRoot) {
25
+ let removed = 0;
26
+ let preserved = 0;
27
+ let placeholders = 0;
28
+ const reindexed = [];
29
+ const nextHooks = {};
30
+ for (const [event, rawGroups] of Object.entries(file.hooks ?? {})) {
31
+ if (!Array.isArray(rawGroups)) {
32
+ nextHooks[event] = rawGroups;
33
+ continue;
34
+ }
35
+ const groups = rawGroups.map((group, gi) => {
36
+ const g = group;
37
+ if (!Array.isArray(g?.hooks))
38
+ return group; // 모르는 형태 — 건드리지 않는다
39
+ const kept = [];
40
+ g.hooks.forEach((h, hi) => {
41
+ if (isForgenHookCommand(h?.command, pkgRoot)) {
42
+ removed += 1;
43
+ return;
44
+ }
45
+ if (kept.length !== hi)
46
+ reindexed.push(`${codexHookEventKey(event)}:${gi}:${hi}→${kept.length}`);
47
+ kept.push(h);
48
+ preserved += 1;
49
+ });
50
+ if (kept.length === g.hooks.length)
51
+ return group; // forgen 핸들러 없음 — 원본 그대로
52
+ // forgen 전용 그룹은 자리표시용 빈 그룹으로 (matcher 는 의미가 없어 뺀다)
53
+ return kept.length === 0 ? { hooks: [] } : { ...g, hooks: kept };
54
+ });
55
+ // 뒤에서부터 빈 그룹을 걷어낸다 — 뒤따르는 그룹이 없으면 인덱스를 지킬 이유가 없다.
56
+ while (groups.length > 0 && isEmptyGroup(groups[groups.length - 1]))
57
+ groups.pop();
58
+ if (groups.length === 0)
59
+ continue;
60
+ placeholders += groups.filter(isEmptyGroup).length;
61
+ nextHooks[event] = groups;
62
+ }
63
+ const { description, hooks: _hooks, ...rest } = file;
64
+ const keepDescription = typeof description === 'string' && !FORGEN_HOOKS_DESCRIPTION_RE.test(description);
65
+ const nothingLeft = Object.keys(nextHooks).length === 0 && Object.keys(rest).length === 0 && !keepDescription;
66
+ if (nothingLeft)
67
+ return { next: null, removed, preserved, placeholders, reindexed };
68
+ const next = { ...(keepDescription ? { description } : {}), ...rest, hooks: nextHooks };
69
+ return { next, removed, preserved, placeholders, reindexed };
70
+ }
71
+ function removeManagedSkills(skillsDir, pkgRoot, dryRun) {
72
+ let removed = 0;
73
+ let entries;
74
+ try {
75
+ entries = fs.readdirSync(skillsDir);
76
+ }
77
+ catch {
78
+ return 0;
79
+ }
80
+ // dev-guide 스킬은 이름 패턴이 아니라 패키지가 실제로 제공하는 이름으로 소유를 판정한다.
81
+ const devGuideNames = listDevGuideSkillNames(pkgRoot);
82
+ for (const name of entries) {
83
+ const skillFile = path.join(skillsDir, name, 'SKILL.md');
84
+ let owned = devGuideNames.has(name);
85
+ if (!owned) {
86
+ try {
87
+ owned = !fs.lstatSync(skillFile).isSymbolicLink() && hasManagedSkillMarker(fs.readFileSync(skillFile, 'utf-8'));
88
+ }
89
+ catch {
90
+ owned = false;
91
+ }
92
+ }
93
+ if (owned && removeSkillFile(skillsDir, name, dryRun))
94
+ removed += 1;
95
+ }
96
+ return removed;
97
+ }
98
+ function removeManagedAgents(agentsDir, dryRun) {
99
+ let removed = 0;
100
+ let entries;
101
+ try {
102
+ entries = fs.readdirSync(agentsDir);
103
+ }
104
+ catch {
105
+ return 0;
106
+ }
107
+ for (const entry of entries) {
108
+ if (!entry.startsWith(AGENT_NAME_PREFIX) || !entry.endsWith('.toml'))
109
+ continue;
110
+ const p = path.join(agentsDir, entry);
111
+ try {
112
+ if (fs.lstatSync(p).isSymbolicLink())
113
+ continue;
114
+ if (!isManagedAgentToml(fs.readFileSync(p, 'utf-8')))
115
+ continue;
116
+ if (!dryRun)
117
+ fs.unlinkSync(p);
118
+ removed += 1;
119
+ }
120
+ catch { /* best-effort */ }
121
+ }
122
+ return removed;
123
+ }
124
+ export function planCodexUninstall(opts) {
125
+ const codexHome = opts.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex');
126
+ const dryRun = opts.dryRun ?? false;
127
+ const result = {
128
+ codexHome,
129
+ present: fs.existsSync(codexHome),
130
+ hooksRemoved: 0,
131
+ userHooksPreserved: 0,
132
+ placeholderGroups: 0,
133
+ userHooksReindexed: [],
134
+ hooksFileDeleted: false,
135
+ mcpRemoved: false,
136
+ notifyRemoved: false,
137
+ notifyCustomLeft: false,
138
+ notifyChainRestored: [],
139
+ skillsRemoved: 0,
140
+ agentsRemoved: 0,
141
+ agentsMdCleaned: false,
142
+ errors: [],
143
+ };
144
+ if (!result.present)
145
+ return result;
146
+ // 각 단계는 독립이다 — 하나가 실패(읽기 전용 파일 등)해도 나머지 정리는 계속한다.
147
+ const step = (label, fn) => {
148
+ try {
149
+ fn();
150
+ }
151
+ catch (e) {
152
+ result.errors.push(`${label}: ${e instanceof Error ? e.message : String(e)}`);
153
+ }
154
+ };
155
+ // 1) hooks.json
156
+ step('hooks.json', () => {
157
+ const hooksPath = path.join(codexHome, 'hooks.json');
158
+ if (!fs.existsSync(hooksPath))
159
+ return;
160
+ let hooksFile;
161
+ try {
162
+ hooksFile = JSON.parse(fs.readFileSync(hooksPath, 'utf-8'));
163
+ }
164
+ catch {
165
+ throw new Error('not valid JSON — left untouched (remove forgen entries by hand)');
166
+ }
167
+ const hooks = hooksFile?.hooks;
168
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
169
+ return;
170
+ const stripped = stripForgenHooks(hooksFile, opts.pkgRoot);
171
+ result.hooksRemoved = stripped.removed;
172
+ result.userHooksPreserved = stripped.preserved;
173
+ result.placeholderGroups = stripped.placeholders;
174
+ result.userHooksReindexed = stripped.reindexed;
175
+ if (stripped.removed === 0)
176
+ return;
177
+ result.hooksFileDeleted = stripped.next === null;
178
+ if (dryRun)
179
+ return;
180
+ if (stripped.next === null)
181
+ fs.unlinkSync(hooksPath);
182
+ else
183
+ fs.writeFileSync(hooksPath, `${JSON.stringify(stripped.next, null, 2)}\n`, 'utf-8');
184
+ });
185
+ // 2) config.toml — MCP / notify 블록
186
+ step('config.toml', () => {
187
+ const configTomlPath = path.join(codexHome, 'config.toml');
188
+ if (!fs.existsSync(configTomlPath))
189
+ return;
190
+ const current = fs.readFileSync(configTomlPath, 'utf-8');
191
+ const mcp = removeMcpBlock(current);
192
+ const notify = removeNotifyBlock(mcp.content);
193
+ result.mcpRemoved = mcp.removed;
194
+ result.notifyRemoved = notify.removed;
195
+ result.notifyCustomLeft = notify.custom;
196
+ result.notifyChainRestored = notify.restoredChain;
197
+ if (!dryRun && notify.content !== current)
198
+ fs.writeFileSync(configTomlPath, notify.content, 'utf-8');
199
+ });
200
+ // 3) skills / agents
201
+ step('skills', () => { result.skillsRemoved = removeManagedSkills(path.join(codexHome, 'skills'), opts.pkgRoot, dryRun); });
202
+ step('agents', () => { result.agentsRemoved = removeManagedAgents(path.join(codexHome, 'agents'), dryRun); });
203
+ // 4) AGENTS.md (cwd 의 git root)
204
+ step('AGENTS.md', () => {
205
+ const agentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
206
+ result.agentsMdCleaned = removeForgenRulesFromAgentsMd({ agentsMdPath, dryRun }).removed;
207
+ });
208
+ return result;
209
+ }
210
+ /** CLI 출력 — 무엇을 지웠고 무엇을 남겼는지. */
211
+ export function renderCodexUninstall(r) {
212
+ if (!r.present)
213
+ return [];
214
+ const lines = [];
215
+ if (r.hooksRemoved > 0) {
216
+ lines.push(` ✓ Removed ${r.hooksRemoved} forgen hook(s) from Codex hooks.json${r.hooksFileDeleted ? ' (file deleted — nothing else was in it)' : ` (kept ${r.userHooksPreserved} hook(s) from other tools)`}`);
217
+ if (r.placeholderGroups > 0) {
218
+ lines.push(` ↳ left ${r.placeholderGroups} empty group(s) so the hooks after them keep their Codex trust (deleting them means re-approving those hooks in /hooks)`);
219
+ }
220
+ if (r.userHooksReindexed.length > 0) {
221
+ lines.push(` ⚠ ${r.userHooksReindexed.length} hook(s) shared a group with forgen and moved (${r.userHooksReindexed.slice(0, 3).join(', ')}) — re-approve them in codex /hooks`);
222
+ }
223
+ }
224
+ if (r.mcpRemoved)
225
+ lines.push(' ✓ Removed forgen-compound MCP block from Codex config.toml');
226
+ if (r.notifyRemoved) {
227
+ lines.push(r.notifyChainRestored.length > 0
228
+ ? ` ✓ Removed forgen notify wrapper from Codex config.toml — your chained notifier is kept: notify = ${JSON.stringify(r.notifyChainRestored)}`
229
+ : ' ✓ Removed forgen notify block from Codex config.toml');
230
+ }
231
+ if (r.notifyCustomLeft) {
232
+ lines.push(' ⚠ The forgen notify block in Codex config.toml was hand-edited (multi-line) — left in place. Remove the lines between the forgen-managed-notify markers yourself.');
233
+ }
234
+ if (r.skillsRemoved > 0)
235
+ lines.push(` ✓ Removed ${r.skillsRemoved} forgen skill(s) from ${path.join(r.codexHome, 'skills')}`);
236
+ if (r.agentsRemoved > 0)
237
+ lines.push(` ✓ Removed ${r.agentsRemoved} ch-*.toml agent(s) from ${path.join(r.codexHome, 'agents')}`);
238
+ if (r.agentsMdCleaned)
239
+ lines.push(' ✓ Removed forgen block from AGENTS.md');
240
+ for (const e of r.errors)
241
+ lines.push(` ✗ Codex cleanup — ${e}`);
242
+ if (lines.length > 0) {
243
+ lines.push(' note: AGENTS.md blocks in other projects where you ran `forgen install codex` are not touched — run `forgen uninstall` there or delete the forgen-managed-rules block.');
244
+ }
245
+ return lines;
246
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooojin/forgen",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "preferGlobal": true,
5
5
  "main": "dist/lib.js",
6
6
  "types": "./dist/lib.d.ts",
@@ -78,15 +78,14 @@
78
78
  ],
79
79
  "devDependencies": {
80
80
  "@biomejs/biome": "^2.4.16",
81
- "@types/js-yaml": "^4.0.9",
82
81
  "@types/node": "^26.1.1",
83
- "@vitest/coverage-v8": "^4.1.7",
84
- "typescript": "^5.9.3",
85
- "vitest": "^4.1.7"
82
+ "@vitest/coverage-v8": "^5.0.2",
83
+ "typescript": "^7.0.2",
84
+ "vitest": "^5.0.2"
86
85
  },
87
86
  "dependencies": {
88
87
  "@modelcontextprotocol/sdk": "^1.28.0",
89
- "js-yaml": "^4.1.1",
88
+ "js-yaml": "^5.4.2",
90
89
  "jsonc-parser": "^3.3.1",
91
90
  "zod": "^4.4.3"
92
91
  },
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://claude.ai/schemas/claude-plugin.json",
3
3
  "name": "forgen",
4
- "version": "0.5.6",
4
+ "version": "0.5.7",
5
5
  "description": "Claude Code harness — the more you use Claude, the better it gets",
6
6
  "author": {
7
7
  "name": "jang-ujin",