@kaydash9999/banker-plugins 0.16.3 → 0.17.1

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.
Files changed (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +57 -0
  4. package/README.md +14 -11
  5. package/bin/banker.js +2 -2
  6. package/codex/manifest.json +2 -1
  7. package/codex/transform-matrix.md +2 -1
  8. package/commands/setup.md +2 -2
  9. package/hooks/progress-plan.mjs +408 -20
  10. package/hooks/progress.mjs +202 -50
  11. package/hooks/register.mjs +7 -1
  12. package/package.json +1 -1
  13. package/skills/3d-intro-build/references/assemble.mjs +10 -2
  14. package/skills/3d-intro-build/references/curate.mjs +10 -2
  15. package/skills/3d-intro-build/references/preview-lib.mjs +2 -0
  16. package/skills/3d-intro-build/references/serve.mjs +10 -2
  17. package/skills/3d-intro-build/references/verify-intro.mjs +10 -2
  18. package/skills/motion-graphic-make/references/curate.mjs +10 -2
  19. package/skills/motion-graphic-make/references/preview-lib.mjs +2 -0
  20. package/skills/motion-graphic-make/references/serve.mjs +10 -2
  21. package/skills/obsidizer/obsidize.mjs +10 -2
  22. package/skills/omc-patch/SKILL.md +105 -22
  23. package/skills/omc-patch/scripts/omc-patch.mjs +609 -255
  24. package/skills/omc-patch/scripts/probe-pm.mjs +192 -0
  25. package/skills/ultra-chain/SKILL.md +475 -0
  26. package/skills/ultra-chain/references/capture.mjs +232 -0
  27. package/skills/ultra-chain/references/chain-ledger.mjs +491 -0
  28. package/skills/ultra-chain/references/chain-report.mjs +257 -0
  29. package/skills/ultra-chain/references/preview-lib.mjs +258 -0
  30. package/skills/ultra-chain/references/serve.mjs +139 -0
  31. package/skills/update-banker/SKILL.md +2 -1
  32. package/skills/vertical-pptx/verify_a4p.mjs +10 -2
@@ -2,27 +2,35 @@
2
2
  /**
3
3
  * OMC 패치 — 버전 고정 + 훅 결함 수정 + 자동 업데이트 차단.
4
4
  *
5
- * node omc-patch.mjs 업데이트(5.3.0) + 패치 + 고정
5
+ * node omc-patch.mjs 업데이트(5.7.0) + 패치 + 고정
6
6
  * node omc-patch.mjs --check 진단만 (아무것도 바꾸지 않음)
7
7
  * node omc-patch.mjs --revert 패치·고정 되돌리기
8
8
  * node omc-patch.mjs --no-update 버전은 그대로 두고 패치만
9
+ * node omc-patch.mjs --root <플러그인 폴더> [--check | --revert]
10
+ * 그 폴더의 hooks/hooks.json 과 scripts/ 만 다룬다(번들 사본, 활성 캐시 폴더 하나).
11
+ * installed_plugins.json, 마켓플레이스 origin, 판은 건드리지 않는다.
12
+ * 이름표(.claude-plugin/plugin.json 의 name, 없으면 package.json 의 name)가
13
+ * OMC 가 아니면 멈춘다.
14
+ * node omc-patch.mjs --scan <파일>...
15
+ * 헬퍼 선언의 블록 깊이와 래핑 자리를 JSON 으로 낸다(읽기 전용).
9
16
  *
10
17
  * 활성 버전은 installed_plugins.json 의 oh-my-claudecode@omc 항목(version, installPath)에서
11
- * 읽는다. 캐시에 5.3.0 이 있다고 5.3.0 을 활성으로 보지 않는다(--no-update 는 활성 버전을 패치).
18
+ * 읽는다. 캐시에 TARGET_VERSION 이 있다고 그것을 활성으로 보지 않는다(--no-update 는 활성 버전을 패치).
12
19
  * OMC_PATCH_INSTALLED=<json 경로> 로 읽을 파일을 바꿀 수 있다(시험용, --check 와 함께).
13
20
  *
14
- * OS: Windows / macOS / Linux 공통. 런처는 apply.cmd(Windows), apply.sh(그 외).
15
- * 배경과 근거는 같은 폴더의 README.md.
21
+ * OS: Windows / macOS / Linux 공통. 런처(apply.cmd, apply.sh)와 배경 문서(README.md)는
22
+ * 킷 배포 폴더에만 있다. 이 파일만 옮겨 쓰는 곳(예: 스킬 폴더)에는 없다.
16
23
  */
17
24
  import {
18
- readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync,
19
- unlinkSync, mkdirSync, rmSync, symlinkSync,
25
+ readFileSync, writeFileSync, writeSync, existsSync, copyFileSync, readdirSync,
26
+ unlinkSync, mkdirSync, rmSync, symlinkSync, lstatSync, realpathSync,
20
27
  } from "node:fs";
21
- import { join, dirname } from "node:path";
28
+ import { join, dirname, resolve, sep as PATH_SEP, delimiter as PATH_LIST_SEP } from "node:path";
22
29
  import { homedir, hostname } from "node:os";
23
30
  import { execFileSync } from "node:child_process";
24
31
 
25
- const TARGET_VERSION = "5.3.0";
32
+ // 인자 없는 apply 가 맞춰 놓는 판. banker 의 omc-patch 스킬은 늘 --no-update 로 돌려 이 값으로 판을 바꾸지 않는다.
33
+ const TARGET_VERSION = "5.7.0";
26
34
  // 3초는 좁았다. 이 예산은 «정상이지만 느린» 로드까지 잘라내는데, 파일 스캔이 8.2배 느려진
27
35
  // 이 PC 가 정확히 그 조건이다(실측: 4초 지연에서 session-end 가 조용히 건너뛰어졌다).
28
36
  // 대안이 «무한 정지» 인 자리에만 걸리므로 넉넉히 잡는다.
@@ -36,9 +44,17 @@ const MARKETPLACE = "omc";
36
44
  const PLUGIN_KEY = "oh-my-claudecode@omc";
37
45
  const REPO_URL = "https://github.com/Yeachan-Heo/oh-my-claudecode.git";
38
46
 
39
- const argv = new Set(process.argv.slice(2));
47
+ const args = process.argv.slice(2);
48
+ const argv = new Set(args);
40
49
  const MODE = argv.has("--revert") ? "revert" : argv.has("--check") ? "check" : "apply";
41
50
  const NO_UPDATE = argv.has("--no-update");
51
+ // --root <플러그인 폴더>: 이 PC 의 설치 상태와 상관없이 그 폴더 하나만 고친다. 번들 사본처럼
52
+ // 판 폴더도 installed_plugins.json 도 없는 트리나, 활성 캐시 폴더 하나만 고칠 때 쓴다.
53
+ // 값이 없거나 다음 플래그면 멈춘다.
54
+ const ROOT_AT = args.indexOf("--root");
55
+ const ROOT_ARG = ROOT_AT >= 0 ? args[ROOT_AT + 1] : null;
56
+ const ROOT = ROOT_ARG && !ROOT_ARG.startsWith("--") ? resolve(ROOT_ARG) : null;
57
+ const SCAN_AT = args.indexOf("--scan");
42
58
 
43
59
  const CFG = join(homedir(), ".claude");
44
60
  const PLUGINS = join(CFG, "plugins");
@@ -100,19 +116,44 @@ const installedVersions = () =>
100
116
  // installPath 마지막 구간 → 옛 규칙(TARGET 이 캐시에 있으면 TARGET, 아니면 최신) 순으로 본다.
101
117
  function activeVersion() {
102
118
  const versions = installedVersions();
103
- try {
104
- const e = JSON.parse(readFileSync(INSTALLED, "utf8")).plugins?.[PLUGIN_KEY]?.[0];
105
- if (e) {
106
- if (typeof e.version === "string" && /^\d+\.\d+\.\d+$/.test(e.version) && versions.includes(e.version)) return { v: e.version, src: "installed_plugins.json" };
107
- const last = typeof e.installPath === "string" ? e.installPath.split(/[\\/]+/).filter(Boolean).pop() : null;
108
- if (last && versions.includes(last)) return { v: last, src: "installed_plugins.json installPath" };
109
- }
110
- } catch { /* 읽기·파싱 실패는 옛 규칙으로 */ }
119
+ const found = entryVersion(installedEntry(), versions);
120
+ if (found) return found;
111
121
  return { v: versions.includes(TARGET_VERSION) ? TARGET_VERSION : versions[0] || TARGET_VERSION, src: "추정: installed_plugins.json 에서 확인 못 함" };
112
122
  }
113
123
 
124
+ // installed_plugins.json 의 omc 항목. 읽기·파싱 실패는 null(옛 규칙으로).
125
+ function installedEntry() {
126
+ try { return JSON.parse(readFileSync(INSTALLED, "utf8")).plugins?.[PLUGIN_KEY]?.[0] || null; }
127
+ catch { return null; }
128
+ }
129
+
130
+ // 항목의 version 필드, 없으면 installPath 마지막 구간. 캐시에 있는 판이어야 한다.
131
+ function entryVersion(e, versions) {
132
+ if (!e) return null;
133
+ if (typeof e.version === "string" && /^\d+\.\d+\.\d+$/.test(e.version) && versions.includes(e.version)) return { v: e.version, src: "installed_plugins.json" };
134
+ const last = typeof e.installPath === "string" ? e.installPath.split(/[\\/]+/).filter(Boolean).pop() : null;
135
+ if (last && versions.includes(last)) return { v: last, src: "installed_plugins.json installPath" };
136
+ return null;
137
+ }
138
+
139
+ // git 이 일할 저장소를 cwd 와 상관없이 정하는 변수. git 훅처럼 GIT_DIR 을 내보낸 셸에서 이 도구를 부르면
140
+ // 마켓플레이스가 아니라 그 저장소의 origin 을 고정했다(/tmp 에서 재현). git 이 다른 저장소로 옮겨 갈 때
141
+ // 지우는 목록(`git rev-parse --local-env-vars`, git 2.43)과 같다. Windows 는 변수 이름의 대소문자를 가리지 않는다.
142
+ const GIT_REPO_VARS = new Set(["GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS",
143
+ "GIT_CONFIG_COUNT", "GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
144
+ "GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR"]);
145
+ // 마켓플레이스의 .git 이 깨졌으면(중단된 clone) git 은 위로 올라가 다음 저장소(dotfiles 로 관리하는 ~/.claude 등)를
146
+ // 고른다. 바로 위 폴더를 천장(GIT_CEILING_DIRECTORIES)으로 두어 마켓플레이스 밖으로 나가지 못하게 한다.
147
+ // 마켓플레이스가 링크면 실제 경로 쪽 위 폴더도 막는다. 셸에 있던 천장 값은 대소문자와 상관없이 이 값으로 바꾼다.
148
+ const gitEnv = () => {
149
+ const drop = (k) => GIT_REPO_VARS.has(k.toUpperCase()) || k.toUpperCase() === "GIT_CEILING_DIRECTORIES";
150
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !drop(k)));
151
+ env.GIT_CEILING_DIRECTORIES = [...new Set([dirname(MARKET), dirname(realOr(MARKET))])].join(PATH_LIST_SEP);
152
+ return env;
153
+ };
154
+
114
155
  function git(args, cwd = MARKET) {
115
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
156
+ return execFileSync("git", args, { cwd, env: gitEnv(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
116
157
  }
117
158
 
118
159
  // ── 1. 업데이트 ──────────────────────────────────────────────────────────────
@@ -120,6 +161,19 @@ function ensureVersion() {
120
161
  const have = installedVersions();
121
162
  if (have.includes(TARGET_VERSION)) { ok(`${TARGET_VERSION} 설치되어 있음`); return true; }
122
163
  if (MODE === "check") { bad(`${TARGET_VERSION} 미설치 (설치된 버전: ${have.join(", ") || "없음"})`); return false; }
164
+ if (!canInstallTarget()) return false;
165
+
166
+ log(` ${TARGET_VERSION} 설치 중...`);
167
+ const sha = targetSha();
168
+ if (sha === null) return false;
169
+ const dest = join(CACHE, TARGET_VERSION);
170
+ unpackTarget(dest);
171
+ linkNodeModules(dest, have);
172
+ return switchActive(sha, have);
173
+ }
174
+
175
+ // 마켓플레이스가 제 git 저장소이고 git 과 tar 가 있어야 TARGET_VERSION 을 꺼낼 수 있다.
176
+ function canInstallTarget() {
123
177
  if (!existsSync(MARKET)) { bad(`마켓플레이스 없음: ${MARKET}\n Claude Code 에서 marketplace 를 먼저 추가하십시오: ${REPO_URL}`); return false; }
124
178
  if (!existsSync(join(MARKET, ".git"))) { bad(`마켓플레이스 폴더가 git 저장소가 아님: ${MARKET}`); return false; }
125
179
 
@@ -130,23 +184,28 @@ function ensureVersion() {
130
184
  try { execFileSync(cmd, ["--version"], { stdio: "ignore" }); }
131
185
  catch { bad(`${cmd} 를 찾을 수 없습니다 (${why} 필요). 설치한 뒤 다시 실행하거나, --no-update 로 패치만 적용하십시오.`); return false; }
132
186
  }
187
+ return true;
188
+ }
133
189
 
134
- log(` ${TARGET_VERSION} 설치 중...`);
190
+ // 태그 v<TARGET_VERSION> 의 커밋. 못 찾으면 null.
191
+ function targetSha() {
135
192
  try { git(["fetch", "--tags", "--quiet", "origin"]); } catch { /* 오프라인이어도 로컬 태그가 있으면 진행 */ }
136
- let sha;
137
- try { sha = git(["rev-parse", `v${TARGET_VERSION}^{}`]); }
138
- catch { bad(`태그 v${TARGET_VERSION} 를 마켓플레이스에서 찾을 수 없음`); return false; }
193
+ try { return git(["rev-parse", `v${TARGET_VERSION}^{}`]); }
194
+ catch { bad(`태그 v${TARGET_VERSION} 를 마켓플레이스에서 찾을 수 없음`); return null; }
195
+ }
139
196
 
140
- const dest = join(CACHE, TARGET_VERSION);
197
+ // git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
198
+ function unpackTarget(dest) {
141
199
  if (existsSync(dest)) rmSync(dest, { recursive: true, force: true });
142
200
  mkdirSync(dest, { recursive: true });
143
- // git archive | tar 로 소스를 편다. 저장소에 dist/ 가 포함되어 빌드는 불필요하다.
144
- execFileSync("git", ["-C", MARKET, "archive", `v${TARGET_VERSION}`, "-o", join(dest, "_src.tar")], { stdio: "ignore" });
201
+ git(["archive", `v${TARGET_VERSION}`, "-o", join(dest, "_src.tar")]);
145
202
  execFileSync("tar", ["-xf", join(dest, "_src.tar"), "-C", dest], { stdio: "ignore" });
146
203
  unlinkSync(join(dest, "_src.tar"));
204
+ }
147
205
 
148
- // node_modules 는 복사하지 않고 직전 버전 것을 링크한다. Windows 는 junction
149
- // (관리자 권한 불필요), POSIX 는 type 인자가 무시되고 디렉터리 symlink 가 된다.
206
+ // node_modules 는 복사하지 않고 직전 버전 것을 링크한다. Windows 는 junction
207
+ // (관리자 권한 불필요), POSIX 는 type 인자가 무시되고 디렉터리 symlink 가 된다.
208
+ function linkNodeModules(dest, have) {
150
209
  const prev = have.find((v) => v !== TARGET_VERSION);
151
210
  const nm = join(dest, "node_modules");
152
211
  if (!existsSync(nm) && prev && existsSync(join(CACHE, prev, "node_modules"))) {
@@ -155,8 +214,10 @@ function ensureVersion() {
155
214
  } else if (!existsSync(nm)) {
156
215
  bad(`node_modules 없음. ${dest} 에서 \`npm install --omit=dev\` 를 실행하십시오`);
157
216
  }
217
+ }
158
218
 
159
- // 활성 버전 전환
219
+ // 활성 버전 전환
220
+ function switchActive(sha, have) {
160
221
  const j = JSON.parse(readFileSync(INSTALLED, "utf8"));
161
222
  const e = j.plugins?.[PLUGIN_KEY]?.[0];
162
223
  if (!e) { bad(`installed_plugins.json 에 ${PLUGIN_KEY} 항목이 없음 — 수동 확인 필요`); return false; }
@@ -194,105 +255,164 @@ const REGEX_PREV_CH = new Set("(,=:[!&|?{};+-*%~^".split(""));
194
255
  const REGEX_PREV_KW = new Set(["return", "typeof", "instanceof", "in", "of", "new", "delete",
195
256
  "void", "case", "do", "else", "yield", "await"]);
196
257
 
197
- function scanTopLevelAwaitImports(src) {
198
- const n = src.length;
199
- const fnStack = [];
200
- const hits = [];
201
- let i = 0, prev = "", prevIdx = -1;
202
-
203
- const wordEndingAt = (j) => {
204
- let e = j;
205
- while (j >= 0 && ID_CH.test(src[j])) j--;
206
- return src.slice(j + 1, e + 1);
207
- };
208
- const backSkip = (j) => {
209
- for (;;) {
210
- while (j >= 0 && /\s/.test(src[j])) j--;
211
- if (j >= 1 && src[j] === "/" && src[j - 1] === "*") { // */ 로 끝나는 주석
212
- const s = src.lastIndexOf("/*", j - 1);
213
- if (s < 0) return -1;
214
- j = s - 1; continue;
215
- }
216
- return j;
217
- }
218
- };
219
- const skipString = (q) => {
220
- i++;
221
- while (i < n) {
222
- const c = src[i];
223
- if (c === "\\") { i += 2; continue; }
224
- if (c === q) { i++; return; }
225
- if (q === "`" && c === "$" && src[i + 1] === "{") {
226
- i += 2;
227
- let d = 1;
228
- while (i < n && d > 0) {
229
- const k = src[i];
230
- if (k === "\\") { i += 2; continue; }
231
- if (k === "'" || k === '"' || k === "`") { skipString(k); continue; }
232
- if (k === "{") d++;
233
- else if (k === "}") d--;
234
- i++;
235
- }
236
- continue;
237
- }
238
- i++;
239
- }
240
- };
241
- // 이 중괄호가 «함수 몸통» 을 여는가
242
- const isFunctionBrace = (at) => {
243
- let j = backSkip(at - 1);
244
- if (j < 0) return false;
245
- if (src[j] === ">" && src[j - 1] === "=") return true; // () => {
246
- if (src[j] !== ")") return false; // class/객체/블록
247
- let d = 0;
248
- for (; j >= 0; j--) { // 짝 맞는 ( 찾기
249
- if (src[j] === ")") d++;
250
- else if (src[j] === "(") { if (--d === 0) break; }
251
- }
252
- if (j < 0) return false;
253
- const k = backSkip(j - 1);
254
- if (k < 0 || !ID_CH.test(src[k])) return false;
255
- return !BLOCK_KW.has(wordEndingAt(k)); // if/for/while/catch… 은 블록
256
- };
258
+ // probe(i, fnStack) 를 주면 코드 글자(문자열·주석·정규식·중괄호가 아닌 자리)마다 부른다.
259
+ // --scan 이 같은 토크나이저로 헬퍼 선언의 깊이를 재는 데 쓴다. 패치 경로는 probe 없이 부른다.
260
+ function scanTopLevelAwaitImports(src, probe) {
261
+ const s = { src, n: src.length, i: 0, prev: "", prevIdx: -1, fnStack: [], hits: [] };
262
+ while (s.i < s.n) {
263
+ if (!skipNonCode(s)) stepCode(s, probe);
264
+ }
265
+ return s.hits;
266
+ }
257
267
 
258
- while (i < n) {
259
- const c = src[i];
260
- if (c === "/" && src[i + 1] === "/") { while (i < n && src[i] !== "\n") i++; continue; }
261
- if (c === "/" && src[i + 1] === "*") { const e = src.indexOf("*/", i + 2); i = e < 0 ? n : e + 2; continue; }
262
- if (c === "'" || c === '"' || c === "`") { skipString(c); prev = c; continue; }
263
- if (/\s/.test(c)) { i++; continue; }
264
- if (c === "/") {
265
- const asRegex = prev === "" || REGEX_PREV_CH.has(prev)
266
- || (prevIdx >= 0 && ID_CH.test(prev) && REGEX_PREV_KW.has(wordEndingAt(prevIdx)));
267
- if (asRegex) {
268
- i++;
269
- let inClass = false;
270
- while (i < n) {
271
- const k = src[i];
272
- if (k === "\\") { i += 2; continue; }
273
- if (k === "[") inClass = true;
274
- else if (k === "]") inClass = false;
275
- else if (k === "/" && !inClass) { i++; break; }
276
- else if (k === "\n") break;
277
- i++;
278
- }
279
- while (i < n && ID_CH.test(src[i])) i++; // 플래그
280
- prev = "/"; prevIdx = i - 1; continue;
281
- }
282
- }
283
- if (c === "{") { fnStack.push(isFunctionBrace(i)); i++; prev = "{"; prevIdx = i - 1; continue; }
284
- if (c === "}") { fnStack.pop(); i++; prev = "}"; prevIdx = i - 1; continue; }
285
- if (c === "a" && !(i > 0 && ID_CH.test(src[i - 1])) && /^await\s+import\s*\(/.test(src.slice(i, i + 40))) {
286
- if (!fnStack.some(Boolean)) hits.push(i);
287
- }
288
- prev = c; prevIdx = i; i++;
268
+ const wordEndingAt = (src, j) => {
269
+ const e = j;
270
+ while (j >= 0 && ID_CH.test(src[j])) j--;
271
+ return src.slice(j + 1, e + 1);
272
+ };
273
+
274
+ // j 에서 거꾸로 공백과 /* */ 주석을 건너뛴 자리. 주석의 시작이 없으면 -1.
275
+ function backSkip(src, j) {
276
+ for (;;) {
277
+ while (j >= 0 && /\s/.test(src[j])) j--;
278
+ if (!(j >= 1 && src[j] === "/" && src[j - 1] === "*")) return j; // */ 로 끝나는 주석이 아님
279
+ const s = src.lastIndexOf("/*", j - 1);
280
+ if (s < 0) return -1;
281
+ j = s - 1;
282
+ }
283
+ }
284
+
285
+ // 아래 함수들은 토크나이저 상태 s 를 함께 쓴다. src 와 n(길이), i(지금 자리), prev 와
286
+ // prevIdx(직전 코드 글자와 그 자리), fnStack(열린 중괄호마다 함수 몸통이면 true), hits.
287
+
288
+ // 주석, 문자열, 공백, 정규식이면 건너뛰고 true. 코드 글자면 그대로 두고 false.
289
+ function skipNonCode(s) {
290
+ const c = s.src[s.i];
291
+ if (skipComment(s)) return true;
292
+ if (c === "'" || c === '"' || c === "`") { skipString(s, c); s.prev = c; return true; }
293
+ if (/\s/.test(c)) { s.i++; return true; }
294
+ if (c === "/" && startsRegex(s)) { skipRegex(s); return true; }
295
+ return false;
296
+ }
297
+
298
+ function skipComment(s) {
299
+ const { src, n } = s;
300
+ if (src[s.i] !== "/") return false;
301
+ if (src[s.i + 1] === "/") {
302
+ while (s.i < n && src[s.i] !== "\n") s.i++;
303
+ return true;
304
+ }
305
+ if (src[s.i + 1] !== "*") return false;
306
+ const e = src.indexOf("*/", s.i + 2);
307
+ s.i = e < 0 ? n : e + 2;
308
+ return true;
309
+ }
310
+
311
+ // s.i 는 여는 따옴표. 닫는 따옴표 다음으로 옮긴다. 템플릿의 ${ } 는 skipTemplateExpr 가 맡는다.
312
+ function skipString(s, q) {
313
+ const { src, n } = s;
314
+ s.i++;
315
+ while (s.i < n) {
316
+ const c = src[s.i];
317
+ if (c === "\\") { s.i += 2; continue; }
318
+ if (c === q) { s.i++; return; }
319
+ if (q === "`" && c === "$" && src[s.i + 1] === "{") { skipTemplateExpr(s); continue; }
320
+ s.i++;
321
+ }
322
+ }
323
+
324
+ // s.i 는 ${ 의 $. 짝 맞는 } 다음으로 옮긴다. 그 안의 문자열은 다시 skipString 으로 건너뛴다.
325
+ function skipTemplateExpr(s) {
326
+ const { src, n } = s;
327
+ s.i += 2;
328
+ let d = 1;
329
+ while (s.i < n && d > 0) {
330
+ const k = src[s.i];
331
+ if (k === "\\") { s.i += 2; continue; }
332
+ if (k === "'" || k === '"' || k === "`") { skipString(s, k); continue; }
333
+ if (k === "{") d++;
334
+ else if (k === "}") d--;
335
+ s.i++;
336
+ }
337
+ }
338
+
339
+ // 여기의 / 가 나눗셈이 아니라 정규식을 여는가. 직전 코드 글자나 직전 낱말로 정한다.
340
+ function startsRegex(s) {
341
+ const { src, prev, prevIdx } = s;
342
+ return prev === "" || REGEX_PREV_CH.has(prev)
343
+ || (prevIdx >= 0 && ID_CH.test(prev) && REGEX_PREV_KW.has(wordEndingAt(src, prevIdx)));
344
+ }
345
+
346
+ // s.i 는 정규식을 여는 /. 닫는 / 와 플래그 다음으로 옮긴다. 줄이 끝나면 거기서 멈춘다.
347
+ function skipRegex(s) {
348
+ const { src, n } = s;
349
+ s.i++;
350
+ let inClass = false;
351
+ while (s.i < n) {
352
+ const k = src[s.i];
353
+ if (k === "\\") { s.i += 2; continue; }
354
+ if (k === "[") inClass = true;
355
+ else if (k === "]") inClass = false;
356
+ else if (k === "/" && !inClass) { s.i++; break; }
357
+ else if (k === "\n") break;
358
+ s.i++;
289
359
  }
290
- return hits;
360
+ skipWord(s); // 플래그
361
+ s.prev = "/"; s.prevIdx = s.i - 1;
362
+ }
363
+
364
+ function skipWord(s) {
365
+ while (s.i < s.n && ID_CH.test(s.src[s.i])) s.i++;
366
+ }
367
+
368
+ // 코드 글자 하나. 중괄호면 함수 스택을 바꾸고, 함수 밖의 `await import(` 면 hits 에 넣는다.
369
+ function stepCode(s, probe) {
370
+ const { src } = s;
371
+ const c = src[s.i];
372
+ if (c === "{" || c === "}") {
373
+ if (c === "{") s.fnStack.push(isFunctionBrace(src, s.i));
374
+ else s.fnStack.pop();
375
+ s.i++; s.prev = c; s.prevIdx = s.i - 1;
376
+ return;
377
+ }
378
+ if (c === "a" && isAwaitImportAt(src, s.i) && !s.fnStack.some(Boolean)) s.hits.push(s.i);
379
+ if (probe) probe(s.i, s.fnStack);
380
+ s.prev = c; s.prevIdx = s.i; s.i++;
381
+ }
382
+
383
+ const isAwaitImportAt = (src, i) => !(i > 0 && ID_CH.test(src[i - 1])) && /^await\s+import\s*\(/.test(src.slice(i, i + 40));
384
+
385
+ // 이 중괄호가 «함수 몸통» 을 여는가
386
+ function isFunctionBrace(src, at) {
387
+ let j = backSkip(src, at - 1);
388
+ if (j < 0) return false;
389
+ if (src[j] === ">" && src[j - 1] === "=") return true; // () => {
390
+ if (src[j] !== ")") return false; // class/객체/블록
391
+ j = openParenOf(src, j); // 짝 맞는 ( 찾기
392
+ if (j < 0) return false;
393
+ const k = backSkip(src, j - 1);
394
+ if (k < 0 || !ID_CH.test(src[k])) return false;
395
+ return !BLOCK_KW.has(wordEndingAt(src, k)); // if/for/while/catch… 은 블록
396
+ }
397
+
398
+ // j 의 ) 와 짝인 ( 의 자리. 없으면 -1.
399
+ function openParenOf(src, j) {
400
+ let d = 0;
401
+ for (; j >= 0; j--) {
402
+ if (src[j] === ")") d++;
403
+ else if (src[j] === "(" && --d === 0) return j;
404
+ }
405
+ return -1;
291
406
  }
292
407
 
293
408
  // 자리별 상태. 이미 손으로 Promise.race 를 두른 자리는 건드리지 않는다(4.15.7 에 그런 것이 둘 있다).
409
+ // 이미 감싼 파일이라도 헬퍼가 쓰일 수 없는 자리면(옛 판의 자리) «패치됨» 이 아니라 «misplaced» 다.
410
+ // 그 상태를 «패치됨» 으로 넘기면 --check 가 결함 트리에 «모두 적용» 을 낸다(2026-10-12 실측).
294
411
  function siteInfo(src) {
295
- if (src.includes("__omcRaceImport")) return { state: "patched", sites: [] };
412
+ if (src.includes("__omcRaceImport")) {
413
+ const why = helperDefect(scanFacts(src));
414
+ return why ? { state: "misplaced", sites: [], why } : { state: "patched", sites: [] };
415
+ }
296
416
  const all = scanTopLevelAwaitImports(src);
297
417
  if (!all.length) return { state: "n/a", sites: [] };
298
418
  const open = all.filter((p) => !/Promise\.race/.test(src.slice(Math.max(0, p - 300), p)));
@@ -300,22 +420,26 @@ function siteInfo(src) {
300
420
  return { state: "needs", sites: open };
301
421
  }
302
422
 
303
- const needsPatch = (src) => siteInfo(src).state;
423
+ // 헬퍼가 쓰일 수 없는 자리면 그 이유를, 제자리면 null 을 낸다.
424
+ // - 선언이 블록 안(깊이 1 이상): 다른 블록의 자리에서 ReferenceError(옛 킷이 남긴 자리)
425
+ // - 선언을 못 찾음: 이름만으로는 «패치됨» 이라 할 근거가 없다. 앞쪽 글을 토크나이저가 잘못
426
+ // 읽어 선언이 문자열 안으로 보였을 수도 있다
427
+ // - 최상위 사용이 선언보다 앞: 그 자리가 먼저 돌아 const, let 은 TDZ 의 ReferenceError, var 는
428
+ // undefined 호출. function 선언은 끌어올려져 앞에서 불러도 된다. 순서는 줄이 아니라 글자 자리로 본다
429
+ function helperDefect({ helperDecls, wrapped }) {
430
+ const inBlock = helperDecls.find((d) => d.depth !== 0);
431
+ if (inBlock) return `헬퍼 선언 :${inBlock.line} 이 블록 안, 깊이 ${inBlock.depth}`;
432
+ if (!helperDecls.length) return "헬퍼 선언을 찾지 못함";
433
+ const decl = helperDecls[0];
434
+ const first = wrapped.find((w) => !w.inFunction);
435
+ if (first && first.at < decl.at && decl.kind !== "function") return `헬퍼 선언 :${decl.line} 이 첫 사용 :${first.line} 보다 뒤`;
436
+ return null;
437
+ }
304
438
 
305
439
  function transform(src) {
306
440
  const info = siteInfo(src);
307
441
  if (info.state !== "needs") return null;
308
- const edits = [];
309
- for (const start of info.sites) {
310
- const open = src.indexOf("(", src.indexOf("import", start));
311
- let depth = 0, end = -1;
312
- for (let i = open; i < src.length; i++) {
313
- const c = src[i];
314
- if (c === "(") depth++;
315
- else if (c === ")" && --depth === 0) { end = i; break; }
316
- }
317
- if (end !== -1) edits.push({ start, end });
318
- }
442
+ const edits = importSpans(src, info.sites);
319
443
  if (!edits.length) return null;
320
444
  let out = src;
321
445
  for (const e of edits.reverse()) {
@@ -332,6 +456,57 @@ function transform(src) {
332
456
  return out.slice(0, ls) + HELPER + out.slice(ls);
333
457
  }
334
458
 
459
+ // 감쌀 자리마다 `await` 에서 import( 의 짝 맞는 ) 까지.
460
+ function importSpans(src, sites) {
461
+ const edits = [];
462
+ for (const start of sites) {
463
+ const end = closingParenOf(src, src.indexOf("(", src.indexOf("import", start)));
464
+ if (end !== -1) edits.push({ start, end });
465
+ }
466
+ return edits;
467
+ }
468
+
469
+ // open 의 ( 와 짝인 ) 의 자리. 없으면 -1.
470
+ function closingParenOf(src, open) {
471
+ let depth = 0;
472
+ for (let i = open; i < src.length; i++) {
473
+ const c = src[i];
474
+ if (c === "(") depth++;
475
+ else if (c === ")" && --depth === 0) return i;
476
+ }
477
+ return -1;
478
+ }
479
+
480
+ // ── 읽기 전용 진단 (--scan) ──────────────────────────────────────────────────
481
+ // 패치가 «제자리» 에 앉았는지를 위 토크나이저 하나로 잰다. 시험이 토크나이저를 따로 가지면
482
+ // 패치가 고르는 자리와 시험이 재는 자리가 서로 다른 규칙을 따르게 된다.
483
+ // helperDecls 헬퍼 선언마다 { line, depth }. depth 는 둘러싼 중괄호 수(함수 몸통과 블록).
484
+ // 0 이어야 최상위 문장이다. 블록 안 const 는 블록 범위라 다른 블록의 자리에서
485
+ // ReferenceError 가 나고, 훅의 catch 가 그것을 삼킨다(옛 자리 결함).
486
+ // wrapped `await __omcRaceImport(` 자리마다 { line, inFunction }. 패치는 최상위 자리만 감싼다.
487
+ // unwrapped 아직 감싸지지 않은 최상위 `await import(` 의 줄.
488
+ function scanFacts(src) {
489
+ const lineOf = (at) => src.slice(0, at).split("\n").length;
490
+ const helperDecls = [], wrapped = [];
491
+ const unwrapped = scanTopLevelAwaitImports(src, (i, fnStack) => {
492
+ const ch = src[i];
493
+ if (ch !== "c" && ch !== "l" && ch !== "v" && ch !== "f" && ch !== "a") return;
494
+ if (i > 0 && ID_CH.test(src[i - 1])) return;
495
+ const head = src.slice(i, i + 48);
496
+ const decl = /^(const|let|var|function)\s+__omcRaceImport\b/.exec(head);
497
+ if (decl) helperDecls.push({ line: lineOf(i), depth: fnStack.length, at: i, kind: decl[1] });
498
+ else if (/^await\s+__omcRaceImport\s*\(/.test(head)) wrapped.push({ line: lineOf(i), inFunction: fnStack.some(Boolean), at: i });
499
+ }).map(lineOf);
500
+ return { helperDecls, wrapped, unwrapped };
501
+ }
502
+
503
+ // --scan 이 내는 칸. at 과 kind 는 판정용이라 내보내지 않는다(출력 형식은 그대로 둔다).
504
+ const publicFacts = ({ helperDecls, wrapped, unwrapped }) => ({
505
+ helperDecls: helperDecls.map(({ line, depth }) => ({ line, depth })),
506
+ wrapped: wrapped.map(({ line, inFunction }) => ({ line, inFunction })),
507
+ unwrapped,
508
+ });
509
+
335
510
  function syntaxError(file) {
336
511
  try { execFileSync(process.execPath, ["--check", file], { stdio: ["ignore", "ignore", "pipe"] }); return null; }
337
512
  catch (e) {
@@ -346,8 +521,7 @@ const looksHandPatched = (src) =>
346
521
  !src.includes("__omcRaceImport") && /Promise\.race\s*\(\s*\[?[\s\S]{0,120}?\bimport\s*\(/.test(src);
347
522
 
348
523
  function patchHooks(root) {
349
- let done = 0, skip = 0, fail = 0, need = 0;
350
- const foreign = [];
524
+ const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0, foreign: [], orphan: [], failed: [] };
351
525
  // revert 는 scripts/ 전체를 훑는다. hooks.json 기반 목록은 두 번 새는데, 실측으로
352
526
  // 4.15.7 이 둘 다 겪었다 — PostToolUse 를 지우면 그 스크립트가 목록에서 빠지고(그래서
353
527
  // .omcbak 이 있어도 안 돌아온다), 손으로 고친 파일은 .omcbak 조차 없어 아예 안 보인다.
@@ -356,58 +530,138 @@ function patchHooks(root) {
356
530
  : hookNames(root);
357
531
  for (const n of names) {
358
532
  const f = join(root, "scripts", `${n}.mjs`);
359
- const bak = `${f}.omcbak`;
360
533
  if (!existsSync(f)) continue;
534
+ if (MODE === "revert") revertScript(root, n, f, r);
535
+ else checkOrPatchScript(root, n, f, r);
536
+ }
537
+ return r;
538
+ }
361
539
 
362
- if (MODE === "revert") {
363
- if (existsSync(bak)) { copyFileSync(bak, f); unlinkSync(bak); log(` 되돌림 ${n}`); done++; continue; }
364
- // 백업이 없는데 이미 감싸여 있다면 «이 도구가 아닌 손» 이 고친 것이다. 되돌릴 원본이
365
- // 디스크에 없으니 조용히 넘기지 않고 이름을 댄다(실측: 4.15.7 의 두 파일이 그랬다).
366
- // 주의: 손 패치는 `await import(` 를 `await Promise.race([import(` 로 «바꿔치기» 하므로
367
- // await import 패턴 자체가 사라진다. siteInfo 로는 잡히지 않아 원문에서 직접 찾는다.
368
- if (looksHandPatched(readFileSync(f, "utf8"))) foreign.push(n);
369
- continue;
370
- }
371
- const st = needsPatch(readFileSync(f, "utf8"));
372
- if (st !== "needs") { skip++; continue; }
373
- if (MODE === "check") { bad(`패치 필요: ${n}`); need++; continue; }
374
-
375
- const out = transform(readFileSync(f, "utf8"));
376
- if (!out) { bad(`변환 실패: ${n}`); fail++; continue; }
377
- if (!existsSync(bak)) copyFileSync(f, bak);
378
- writeFileSync(f, out, "utf8");
379
- const err = syntaxError(f);
380
- if (err) { copyFileSync(bak, f); bad(`문법오류로 롤백: ${n}\n ${err}`); fail++; continue; }
381
- done++;
540
+ function revertScript(root, n, f, r) {
541
+ const bak = `${f}.omcbak`;
542
+ if (existsSync(bak)) {
543
+ const why = whyNotWrite(root, f, bak);
544
+ if (why) { bad(`되돌리지 않음: ${shown(n)} (${why})`); r.fail++; r.failed.push(f); return; }
545
+ copyFileSync(bak, f); unlinkSync(bak); log(` 되돌림 ${shown(n)}`); r.done++; return;
382
546
  }
383
- return { done, skip, fail, need, foreign };
547
+ // 백업이 없는데 이미 감싸여 있으면 되돌릴 원본이 디스크에 없다. 조용히 넘기지 않고 이름을 댄다.
548
+ // - 우리 헬퍼가 있으면: 이 도구나 옛 킷이 고쳤는데 백업이 사라진 파일(복사해 온 폴더 등)
549
+ // - 손 패치: `await import(` 를 `await Promise.race([import(` 로 «바꿔치기» 해서 await import
550
+ // 패턴 자체가 사라진다. siteInfo 로는 잡히지 않아 원문에서 직접 찾는다(실측: 4.15.7 의 두 파일)
551
+ const src = readFileSync(f, "utf8");
552
+ if (src.includes("__omcRaceImport")) r.orphan.push(n);
553
+ else if (looksHandPatched(src)) r.foreign.push(n);
384
554
  }
385
555
 
556
+ function checkOrPatchScript(root, n, f, r) {
557
+ const info = siteInfo(readFileSync(f, "utf8"));
558
+ // 헬퍼 자리 결함은 check 와 apply 모두 이름을 댄다. 이미 감싼 파일이라 transform 으로는
559
+ // 고칠 수 없으므로 apply 도 건드리지 않고, 되돌린 뒤 다시 적용하라고 안내한다(misplacedHint).
560
+ if (info.state === "misplaced") { bad(`헬퍼 자리 결함: ${n} (${info.why})`); r.misplaced++; return; }
561
+ if (info.state !== "needs") { r.skip++; return; }
562
+ if (MODE === "check") { bad(`패치 필요: ${n}`); r.need++; return; }
563
+
564
+ const bak = `${f}.omcbak`;
565
+ const why = whyNotWrite(root, f, bak);
566
+ if (why) { bad(`쓰지 않음: ${n} (${why})`); r.fail++; return; }
567
+ const out = transform(readFileSync(f, "utf8"));
568
+ if (!out) { bad(`변환 실패: ${n}`); r.fail++; return; }
569
+ if (!existsSync(bak)) copyFileSync(f, bak);
570
+ writeFileSync(f, out, "utf8");
571
+ const err = syntaxError(f);
572
+ if (err) { copyFileSync(bak, f); bad(`문법오류로 롤백: ${n}\n ${err}`); r.fail++; return; }
573
+ r.done++;
574
+ }
575
+
576
+ // 쓰기 전에 본다. 쓸 파일이 (링크를 따라가서) 플러그인 폴더 밖이거나 백업 자리가 링크면 그 이유를,
577
+ // 아니면 null. 백업 자리가 깨진 링크면 existsSync 는 false 라, copyFileSync 가 링크가 가리키는 곳에 쓴다.
578
+ function whyNotWrite(root, file, bak) {
579
+ if (isLink(bak)) return "백업 자리가 링크";
580
+ if (!insideRoot(root, file)) return "파일이 플러그인 폴더 밖에 있음";
581
+ return null;
582
+ }
583
+
584
+ const isLink = (p) => {
585
+ try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
586
+ };
587
+
588
+ // 실제 경로로 p 가 root 안인가. 버전 폴더 자체가 링크(옛 판이 새 판을 가리킴)여도 root 를 같이
589
+ // 풀므로 맞다. Windows 는 경로의 대소문자를 구분하지 않아 소문자로 비교한다.
590
+ function insideRoot(root, p) {
591
+ const fold = (x) => (process.platform === "win32" ? x.toLowerCase() : x);
592
+ try { return fold(realpathSync(p)).startsWith(fold(realpathSync(root)) + PATH_SEP); }
593
+ catch { return false; }
594
+ }
595
+
596
+ // 실제 경로. 풀 수 없으면 받은 그대로.
597
+ const realOr = (p) => {
598
+ try { return realpathSync(p); } catch { return p; }
599
+ };
600
+
601
+ // 같은 파일인지 셀 때 쓰는 열쇠. Windows 는 경로의 대소문자를 가리지 않아 소문자로 접는다. junction 이 대상을
602
+ // 캐시 경로와 다른 글자로 적어 두면 같은 파일이 다른 철자로 풀리기 때문이다.
603
+ const fileKey = (p) => (process.platform === "win32" ? realOr(p).toLowerCase() : realOr(p));
604
+
386
605
  // ── 3. PostToolUse 훅 제거 ───────────────────────────────────────────────────
387
606
  function patchHooksJson(root) {
388
607
  const hj = join(root, "hooks", "hooks.json");
389
- const bak = `${hj}.omcbak`;
390
608
  if (!existsSync(hj)) return "no-file";
391
- if (MODE === "revert") {
392
- if (existsSync(bak)) { copyFileSync(bak, hj); unlinkSync(bak); log(" 되돌림 hooks.json"); return "reverted"; }
393
- // 이 도구가 만든 백업이 없어도, 손으로 만든 백업이 남아 있을 수 있다(실측: 4.15.7 의
394
- // hooks.json.bak-20260906-postoolremove). PostToolUse 를 담고 있는 유효한 JSON 이
395
- // 딱 하나면 그것이 제거 직전 상태다 — 무엇을 썼는지 밝히고 되돌린다.
396
- const dir = join(root, "hooks");
397
- const cands = readdirSync(dir)
398
- .filter((f) => f.startsWith("hooks.json.") && f !== "hooks.json.omcbak")
399
- .filter((f) => {
400
- try { return !!JSON.parse(readFileSync(join(dir, f), "utf8"))?.hooks?.PostToolUse; }
401
- catch { return false; }
402
- });
403
- if (cands.length === 1) {
404
- copyFileSync(join(dir, cands[0]), hj);
405
- log(` 되돌림 hooks.json (수동 백업 사용: ${cands[0]})`);
406
- return "reverted";
407
- }
408
- if (cands.length > 1) { bad(`hooks.json 수동 백업이 여러 개라 자동 복구하지 않음: ${cands.join(", ")}`); return "ambiguous"; }
409
- return "no-backup";
609
+ if (MODE !== "check") {
610
+ const why = whyNotWrite(root, hj, `${hj}.omcbak`);
611
+ if (why) { bad(`hooks.json 을 건드리지 않음 (${why})`); return "unsafe"; }
612
+ }
613
+ return MODE === "revert" ? revertHooksJson(root, hj) : removePostToolUse(hj);
614
+ }
615
+
616
+ // 되돌린 결과. ambiguous 와 linked 는 hooks.json 을 되돌리지 못하고 남긴 것이라 끝 줄이 센다.
617
+ function revertHooksJson(root, hj) {
618
+ const bak = `${hj}.omcbak`;
619
+ if (existsSync(bak)) { copyFileSync(bak, hj); unlinkSync(bak); log(" 되돌림 hooks.json"); return "reverted"; }
620
+ // 이 도구의 백업이 없을 때 PostToolUse 가 그대로면 되돌릴 것이 없다. 옆의 수동 백업으로 덮어쓰면
621
+ // 지금 내용을 잃는다.
622
+ if (hasPostToolUse(hj)) return "no-backup";
623
+ // 손으로 만든 백업이 남아 있을 수 있다(실측: 4.15.7 의 hooks.json.bak-20260906-postoolremove).
624
+ // PostToolUse 를 담고 있는 유효한 JSON 이 딱 하나면 그것이 제거 직전 상태다 — 무엇을 썼는지 밝히고 되돌린다.
625
+ const dir = dirname(hj);
626
+ const { usable, unsafe } = manualBackups(root, dir);
627
+ if (unsafe.length) bad(`hooks.json 수동 백업 후보가 링크이거나 플러그인 폴더 밖이라 쓰지 않음: ${unsafe.map(shown).join(", ")}`);
628
+ if (usable.length === 1) {
629
+ copyFileSync(join(dir, usable[0]), hj);
630
+ log(` 되돌림 hooks.json (수동 백업 사용: ${shown(usable[0])})`);
631
+ return "reverted";
632
+ }
633
+ if (usable.length > 1) { bad(`hooks.json 수동 백업이 여러 개라 자동 복구하지 않음: ${usable.map(shown).join(", ")}`); return "ambiguous"; }
634
+ return unsafe.length ? "linked" : "no-backup";
635
+ }
636
+
637
+ // 파일에서 읽은 값(파일 이름, PINNED 의 host)을 출력에 넣을 때 쓴다. 줄바꿈과 제어 문자를 ? 로 바꾸고 길이를 자른다.
638
+ // SKILL 은 [!] 줄을 그대로 보고하라고 하므로, 값이 제 줄을 새로 시작하면 도구가 낸 줄처럼 읽힌다.
639
+ // 줄을 바꾸거나 글자 순서를 바꾸는 문자: C0 과 C1 제어 문자, 폭 없는 공백과 방향 표시, 줄과 문단 구분자,
640
+ // 방향 덮어쓰기와 격리, BOM. oneLine 은 길이를 두고(URL), shown 은 120자로 자른다(이름, host).
641
+ const UNSAFE_CH = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/g;
642
+ const oneLine = (s) => String(s).replace(UNSAFE_CH, "?");
643
+ const shown = (s) => oneLine(s).slice(0, 120);
644
+
645
+ // hooks.json 옆의 수동 백업 후보. usable 은 PostToolUse 를 담은 유효한 JSON 이다. 링크로 심은 후보는
646
+ // unsafe 로 따로 두고 읽지도 않는다. 폴더 밖 파일(PostToolUse 키가 있는 settings.json 등)이 hooks.json 이 된다.
647
+ function manualBackups(root, dir) {
648
+ const usable = [], unsafe = [];
649
+ for (const f of readdirSync(dir)) {
650
+ if (!f.startsWith("hooks.json.") || f === "hooks.json.omcbak") continue;
651
+ const p = join(dir, f);
652
+ if (isLink(p) || !insideRoot(root, p)) unsafe.push(f);
653
+ else if (hasPostToolUse(p)) usable.push(f);
410
654
  }
655
+ return { usable, unsafe };
656
+ }
657
+
658
+ const hasPostToolUse = (p) => {
659
+ try { return !!JSON.parse(readFileSync(p, "utf8"))?.hooks?.PostToolUse; }
660
+ catch { return false; }
661
+ };
662
+
663
+ function removePostToolUse(hj) {
664
+ const bak = `${hj}.omcbak`;
411
665
  const j = JSON.parse(readFileSync(hj, "utf8"));
412
666
  if (!j.hooks?.PostToolUse) return "already";
413
667
  const list = j.hooks.PostToolUse.flatMap((g) => (g.hooks || []).map((h) => (h.command || "").match(/([\w-]+)\.mjs/)?.[1] || "?"));
@@ -425,38 +679,20 @@ function patchHooksJson(root) {
425
679
  // 올릴 수 있다. 그러면 위 패치가 통째로 덮어써진다. 로컬에서 막을 수 있는
426
680
  // 지점은 "마켓플레이스 저장소가 새 커밋을 가져오지 못하게" 하는 것뿐이다.
427
681
  // origin remote 의 URL 을 무효 주소로 바꿔 fetch 를 실패시킨다(원래 URL 은
428
- // PINNED 파일에 적어 두고 --revert 로 복구한다). 저장소 자체는 그대로라
429
- // 이미 받은 태그로 재설치·검증이 가능하다.
682
+ // `omc-pinned://` 뒤에 그대로 남기고 --revert 가 거기서 복구한다. PINNED 파일은 기록이다).
683
+ // 저장소 자체는 그대로라 이미 받은 태그로 재설치·검증이 가능하다.
430
684
  function pinMarketplace() {
431
685
  if (!existsSync(MARKET)) return "no-marketplace";
432
686
  // 마켓플레이스 폴더가 제 git 저장소가 아니면 git 이 위쪽 저장소(예: dotfiles 로 관리하는
433
- // ~/.claude)를 찾아 그 origin 을 바꾼다. 제 .git 이 있을 때만 고정하고 되돌린다.
687
+ // ~/.claude)를 찾아 그 origin 을 바꾼다. 제 .git 이 있을 때만 고정하고 되돌린다. .git 이 있어도
688
+ // 깨졌으면 git 이 위로 올라가는데, 그것은 gitEnv 의 천장이 막는다(origin 을 못 읽어 no-remote).
434
689
  if (!existsSync(join(MARKET, ".git"))) return "no-git";
435
690
  let url;
436
691
  try { url = git(["remote", "get-url", "origin"]); } catch { return "no-remote"; }
437
692
 
438
- if (MODE === "revert") {
439
- // 원본 URL 은 현재 origin 안에 그대로 들어 있다(`omc-pinned://` + 원본). 파일이 아니라
440
- // 거기서 복원한다. PINNED 는 배포 폴더에 같이 딸려 오므로 다른 PC 의 값일 수 있고,
441
- // PINFILE 은 스크립트 위치가 아니라 homedir() 기준이라 그 사실이 눈에 띄지도 않는다.
442
- const PIN_PREFIX = "omc-pinned://";
443
- let restored = null;
444
- if (url.startsWith(PIN_PREFIX)) restored = url.slice(PIN_PREFIX.length);
445
- if (existsSync(PINFILE)) {
446
- let saved = {};
447
- try { saved = JSON.parse(readFileSync(PINFILE, "utf8")); } catch { /* 깨졌으면 무시 */ }
448
- if (saved.host && saved.host !== hostname()) {
449
- bad(`PINNED 는 다른 기기(${saved.host})의 기록입니다 — 그 값은 쓰지 않고 현재 origin 에서 복원합니다.`);
450
- }
451
- if (!restored && saved.originUrl) restored = saved.originUrl;
452
- unlinkSync(PINFILE);
453
- }
454
- if (!restored) return "not-pinned";
455
- try { git(["remote", "set-url", "origin", restored]); } catch { /* noop */ }
456
- return "unpinned";
457
- }
693
+ if (MODE === "revert") return unpinMarketplace(url);
458
694
  if (url.startsWith("omc-pinned://")) return "already";
459
- if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${url})`); return "needs"; }
695
+ if (MODE === "check") { bad(`마켓플레이스 미고정 (origin=${oneLine(url)})`); return "needs"; }
460
696
 
461
697
  mkdirSync(dirname(PINFILE), { recursive: true });
462
698
  // host 를 남긴다. 이 폴더는 통째로 복사해 배포하므로 PINNED 도 같이 딸려 가는데,
@@ -466,112 +702,230 @@ function pinMarketplace() {
466
702
  return "pinned";
467
703
  }
468
704
 
705
+ // 원본 URL 은 현재 origin 안에 그대로 들어 있다(`omc-pinned://` + 원본). 거기서만 복원한다.
706
+ // PINNED 의 값으로 origin 을 쓰지 않는다. PINNED 는 배포 폴더에 같이 딸려 오므로 다른 PC 의 값일 수 있고,
707
+ // host 가 없는 기록은 어느 기기의 것인지 모른다. origin 이 고정 상태가 아니면 되돌릴 것도 없다.
708
+ function unpinMarketplace(url) {
709
+ const PIN_PREFIX = "omc-pinned://";
710
+ if (existsSync(PINFILE)) dropPinRecord();
711
+ const restored = url.startsWith(PIN_PREFIX) ? url.slice(PIN_PREFIX.length) : "";
712
+ if (!restored) return "not-pinned";
713
+ try { git(["remote", "set-url", "origin", restored]); } catch { /* noop */ }
714
+ return "unpinned";
715
+ }
716
+
717
+ // PINNED 를 지운다. 다른 기기의 기록이면 알린다. 깨진 기록(JSON 이 아니거나, null 처럼 객체가 아닌 값)은 말없이 지운다.
718
+ function dropPinRecord() {
719
+ let saved = null;
720
+ try { saved = JSON.parse(readFileSync(PINFILE, "utf8")); } catch { /* 깨졌으면 무시 */ }
721
+ unlinkSync(PINFILE);
722
+ if (saved && typeof saved === "object" && saved.host && saved.host !== hostname()) {
723
+ bad(`PINNED 는 다른 기기(${shown(saved.host)})의 기록입니다 — 그 값은 쓰지 않습니다.`);
724
+ }
725
+ }
726
+
727
+ // --root 폴더의 이름표. .claude-plugin/plugin.json 의 name 이 있으면 그것으로, 없으면 package.json 의 name
728
+ // 으로 정한다. OMC 가 아니면 그 설명을, 아니면 null. 둘 다 없거나 읽을 수 없으면 확인할 길이 없어 null 이다
729
+ // (번들 사본에는 없을 수 있다). 다른 폴더를 잘못 준 실수를 막는 확인이다. 폴더 밖 쓰기를 막는 것은 whyNotWrite.
730
+ const OMC_NAMES = [[".claude-plugin/plugin.json", "oh-my-claudecode"], ["package.json", "oh-my-claude-sisyphus"]];
731
+ function otherPluginName(root) {
732
+ for (const [rel, want] of OMC_NAMES) {
733
+ let name;
734
+ try { name = JSON.parse(readFileSync(join(root, rel), "utf8")).name; } catch { continue; }
735
+ if (name === undefined) continue;
736
+ if (name === want) return null;
737
+ const quoted = JSON.stringify(name);
738
+ return `${rel} 의 name 이 ${quoted.length > 80 ? `${quoted.slice(0, 80)}...` : quoted}`;
739
+ }
740
+ return null;
741
+ }
742
+
743
+ // ── --scan: 사실만 내고 끝낸다 ───────────────────────────────────────────────
744
+ // 아래 main 보다 먼저 끝나야 한다. 이 모드는 installed_plugins.json 도 플러그인 캐시도 읽지 않는다.
745
+ // 결과는 입력 순서대로 JSON 배열 한 줄이다. 동기 쓰기로 내보내야 process.exit 가 잘라먹지 않는다.
746
+ if (SCAN_AT >= 0) {
747
+ const files = args.slice(SCAN_AT + 1);
748
+ if (!files.length) { console.error("--scan 뒤에 파일 경로가 필요합니다."); process.exit(1); }
749
+ const out = files.map((file) => {
750
+ try { return { file, ...publicFacts(scanFacts(readFileSync(file, "utf8"))) }; }
751
+ catch (e) { return { file, error: String(e.code || e.message) }; }
752
+ });
753
+ writeSync(1, JSON.stringify(out) + "\n");
754
+ process.exit(out.some((x) => x.error) ? 1 : 0);
755
+ }
756
+
469
757
  // ── main ─────────────────────────────────────────────────────────────────────
470
- log(`OMC 패치 (대상 ${TARGET_VERSION}) 모드: ${MODE}${NO_UPDATE ? " --no-update" : ""}`);
758
+ if (ROOT_AT >= 0 && !ROOT) { bad("--root 뒤에 플러그인 폴더 경로가 필요합니다."); process.exit(1); }
759
+ // --check 는 읽기 전용이다. --revert 와 함께 받으면 예전에는 되돌리기가 이겨 파일을 썼다. 무엇도 하지 않고 멈춘다.
760
+ if (argv.has("--check") && argv.has("--revert")) {
761
+ bad("--check 와 --revert 는 함께 쓸 수 없습니다. 진단만 하려면 --check 만 주십시오.");
762
+ process.exit(1);
763
+ }
764
+ log(ROOT
765
+ ? `OMC 패치 (대상 폴더 ${ROOT}) 모드: ${MODE}`
766
+ : `OMC 패치 (대상 ${TARGET_VERSION}) 모드: ${MODE}${NO_UPDATE ? " --no-update" : ""}`);
471
767
  log(`플랫폼: ${process.platform} node ${process.version}`);
472
768
  log();
473
769
 
474
- if (!existsSync(INSTALLED)) { bad(`Claude Code 플러그인 설정을 찾을 수 없음: ${INSTALLED}`); process.exit(1); }
770
+ // targets 는 «고칠 플러그인 폴더» 의 이름표 목록이다. 기본은 캐시의 판 번호이고, --root 는
771
+ // 그 폴더 경로 하나다. rootOf 가 이름표를 폴더로 바꾼다.
772
+ let active, targets;
773
+ const rootOf = (v) => (ROOT ? v : join(CACHE, v));
774
+ // 헬퍼 자리 결함은 이 판의 apply 로 고칠 수 없다(이미 감싼 파일). 원본으로 되돌린 뒤 다시 적용한다.
775
+ // 되돌리기는 그 폴더 하나만 한다. 인자 없는 --revert 는 설치된 모든 판을 되돌리고 고정까지 푼다.
776
+ // active 는 아래에서 정해지므로 쓰는 자리에서 부른다.
777
+ const misplacedHint = () => (ROOT
778
+ ? "--revert --root 로 되돌린 뒤 같은 --root 로 다시 적용하십시오."
779
+ : `--revert --root "${join(CACHE, active)}" 로 활성 폴더만 되돌린 뒤 다시 적용하십시오.`);
780
+ if (ROOT) {
781
+ // 그 폴더 하나만 고친다(판 폴더도 installed_plugins.json 도 없는 번들 사본, 또는 활성 캐시 폴더).
782
+ // 판 바꾸기([1/4])와 마켓플레이스 고정([4/4])은 하지 않는다 — 그 둘은 이 PC 의 설정이고 이 폴더의 것이 아니다.
783
+ if (!existsSync(join(ROOT, "hooks", "hooks.json")) || !existsSync(join(ROOT, "scripts"))) {
784
+ bad(`플러그인 폴더가 아님 (hooks/hooks.json 과 scripts/ 가 있어야 함): ${ROOT}`);
785
+ process.exit(1);
786
+ }
787
+ const other = otherPluginName(ROOT);
788
+ if (other) { bad(`OMC 플러그인 폴더가 아님 (${other}): ${ROOT}`); process.exit(1); }
789
+ log("[1/4] 버전");
790
+ log(" 건너뜀 (--root 는 판을 바꾸지 않음)");
791
+ active = ROOT;
792
+ targets = [ROOT];
793
+ } else {
794
+ if (!existsSync(INSTALLED)) { bad(`Claude Code 플러그인 설정을 찾을 수 없음: ${INSTALLED}`); process.exit(1); }
475
795
 
476
- log("[1/4] 버전");
477
- if (NO_UPDATE) log(" 건너뜀 (--no-update)");
478
- else if (MODE === "revert") log(" 건너뜀 (revert 는 버전을 되돌리지 않음 — README 의 롤백 절차 참고)");
479
- else if (!ensureVersion() && MODE !== "check") { bad("버전 확보 실패 — 중단"); process.exit(1); }
796
+ log("[1/4] 버전");
797
+ if (NO_UPDATE) log(" 건너뜀 (--no-update)");
798
+ else if (MODE === "revert") log(" 건너뜀 (revert 는 버전을 되돌리지 않음 — 킷 배포 폴더 README 의 롤백 절차 참고)");
799
+ else if (!ensureVersion() && MODE !== "check") { bad("버전 확보 실패 — 중단"); process.exit(1); }
480
800
 
481
- const versions = installedVersions();
482
- // [1/4] 뒤에 계산해야 일반 apply 가 TARGET_VERSION 으로 전환한 결과가 반영된다.
483
- const { v: active, src: activeSrc } = activeVersion();
484
- if (MODE !== "revert" && (MODE === "check" || NO_UPDATE)) {
485
- log(` 활성 버전: ${active} (${activeSrc})`);
486
- if (active !== TARGET_VERSION) {
487
- log(` 활성 버전이 대상(${TARGET_VERSION})과 다름 — 기본 apply 는 ${TARGET_VERSION} 로 전환하고, --no-update 는 활성 버전(${active})을 그 자리에서 패치함`);
801
+ const versions = installedVersions();
802
+ // [1/4] 뒤에 계산해야 일반 apply 가 TARGET_VERSION 으로 전환한 결과가 반영된다.
803
+ const { v, src: activeSrc } = activeVersion();
804
+ active = v;
805
+ if (MODE !== "revert" && (MODE === "check" || NO_UPDATE)) {
806
+ log(` 활성 버전: ${active} (${activeSrc})`);
807
+ if (active !== TARGET_VERSION) {
808
+ log(` 활성 버전이 대상(${TARGET_VERSION})과 다름 — 기본 apply 는 ${TARGET_VERSION} 로 전환하고, --no-update 는 활성 버전(${active})을 그 자리에서 패치함`);
809
+ }
488
810
  }
811
+ // apply 는 활성 버전만 손댄다. revert 와 check 는 설치된 모든 버전을 훑는다 —
812
+ // 여러 버전이 나란히 있을 때 이전 버전에 수정이 남으면, 롤백했을 때 예상과 다른
813
+ // 상태가 된다(실제로 4.15.7 의 hooks.json 수정이 그렇게 남았다).
814
+ targets = MODE === "apply" ? [active] : (versions.length ? versions : [active]);
815
+ if (!existsSync(join(CACHE, active))) { bad(`설치 경로 없음: ${join(CACHE, active)}`); process.exit(1); }
489
816
  }
490
- // apply 는 활성 버전만 손댄다. revert 와 check 는 설치된 모든 버전을 훑는다 —
491
- // 여러 버전이 나란히 있을 때 이전 버전에 수정이 남으면, 롤백했을 때 예상과 다른
492
- // 상태가 된다(실제로 4.15.7 의 hooks.json 수정이 그렇게 남았다).
493
- const targets = MODE === "apply" ? [active] : (versions.length ? versions : [active]);
494
- if (!existsSync(join(CACHE, active))) { bad(`설치 경로 없음: ${join(CACHE, active)}`); process.exit(1); }
495
817
 
496
818
  log();
497
- log(`[2/4] 훅 패치${MODE === "apply" ? ` (${active})` : ` (대상 ${targets.length}개 버전)`}`);
498
- const r = { done: 0, skip: 0, fail: 0, need: 0 };
819
+ log(`[2/4] 훅 패치${MODE === "apply" || ROOT ? ` (${active})` : ` (대상 ${targets.length}개 버전)`}`);
820
+ const r = { done: 0, skip: 0, fail: 0, need: 0, misplaced: 0 };
499
821
  const needBy = {};
822
+ const misplacedBy = {};
500
823
  const foreignBy = {};
824
+ const orphanBy = {};
825
+ // 되돌리기 끝 줄이 세는 파일. 쓰지 못한 것(failed)과 백업이 없어 남은 것(left)이다. 링크로 이어진 판 폴더는
826
+ // 같은 파일을 여러 이름으로 보이므로 fileKey 로 한 번만 센다. 위 [2/4] 줄의 건수는 판 폴더마다 센다.
827
+ const failed = new Set();
828
+ const left = new Set();
501
829
  for (const v of targets) {
502
- const root = join(CACHE, v);
830
+ const root = rootOf(v);
503
831
  if (!existsSync(root)) continue;
504
832
  if (targets.length > 1) log(` — ${v}`);
505
833
  const x = patchHooks(root);
506
834
  needBy[v] = x.need;
507
- for (const k of ["done", "skip", "fail", "need"]) r[k] += x[k];
835
+ misplacedBy[v] = x.misplaced;
836
+ for (const k of ["done", "skip", "fail", "need", "misplaced"]) r[k] += x[k];
508
837
  if (x.foreign?.length) foreignBy[v] = x.foreign;
838
+ if (x.orphan?.length) orphanBy[v] = x.orphan;
839
+ for (const n of [...x.foreign, ...x.orphan]) left.add(fileKey(join(root, "scripts", `${n}.mjs`)));
840
+ for (const p of x.failed) failed.add(fileKey(p));
509
841
  }
510
- log(` ${MODE === "check" ? `패치 필요 ${r.need}건` : MODE === "revert" ? `되돌림 ${r.done}건` : `패치 ${r.done}건 / 실패 ${r.fail}건`} / 대상 아님 ${r.skip}건`);
511
- // 이 도구가 만들지 않은 수정은 되돌릴 원본이 디스크에 없다. 조용히 넘기면 «완전히
842
+ log(` ${MODE === "check" ? `패치 필요 ${r.need}건` : MODE === "revert" ? `되돌림 ${r.done}건${r.fail ? ` / 실패 ${r.fail}건` : ""}` : `패치 ${r.done}건 / 실패 ${r.fail}건`}${r.misplaced ? ` / 헬퍼 자리 결함 ${r.misplaced}건` : ""} / 대상 아님 ${r.skip}건`);
843
+ // 백업이 없어 되돌리지 못한 파일은 되돌릴 원본이 디스크에 없다. 조용히 넘기면 «완전히
512
844
  // 되돌렸다» 는 잘못된 인상을 준다 — 무엇이 남았고 어떻게 되찾는지 정확히 말한다.
513
- for (const [v, list] of Object.entries(foreignBy)) {
514
- bad(`${v}: 이 도구가 만들지 않은 수정이 남아 있습니다 — ${list.join(", ")}`);
845
+ function reportNoBackup(v, list, what) {
846
+ bad(`${v}: ${what} — ${list.map(shown).join(", ")}`);
515
847
  log(` 백업(.omcbak)이 없어 되돌릴 원본이 디스크에 없습니다. 원본이 필요하면:`);
516
- log(` git -C "${MARKET}" archive v${v} scripts/<이름>.mjs | tar -x -C "${join(CACHE, v)}"`);
848
+ if (ROOT) log(` 같은 판의 상류 원본에서 scripts/<이름>.mjs 를 다시 가져오십시오.`);
849
+ else log(` git -C "${MARKET}" archive v${v} scripts/<이름>.mjs | tar -x -C "${join(CACHE, v)}"`);
517
850
  }
851
+ for (const [v, list] of Object.entries(foreignBy)) reportNoBackup(v, list, "이 도구가 만들지 않은 수정이 남아 있습니다");
852
+ for (const [v, list] of Object.entries(orphanBy)) reportNoBackup(v, list, "패치는 있는데 백업이 없어 되돌리지 못했습니다");
518
853
 
519
854
  log();
520
855
  log("[3/4] PostToolUse 훅 제거");
521
856
  const hjNeedBy = {};
522
857
  for (const v of targets) {
523
- const root = join(CACHE, v);
858
+ const root = rootOf(v);
524
859
  if (!existsSync(root)) continue;
525
860
  const hj = patchHooksJson(root);
526
861
  const tag = targets.length > 1 ? `${v}: ` : "";
527
862
  if (Array.isArray(hj)) ok(`${tag}제거: ${hj.join(", ")}`);
528
863
  else if (hj === "already") ok(`${tag}이미 제거됨`);
529
864
  else if (hj === "needs") hjNeedBy[v] = true;
530
- else if (hj === "reverted" || hj === "ambiguous") { /* patchHooksJson 이 출력함 */ }
865
+ else if (hj === "reverted" || hj === "ambiguous" || hj === "linked") { /* patchHooksJson 이 출력함 */ }
531
866
  else if (hj === "no-backup") ok(`${tag}되돌릴 백업 없음 (이 도구가 지운 적 없음)`);
532
867
  else if (hj === "no-file") bad(`${tag}hooks.json 파일 자체가 없음`);
868
+ else if (hj === "unsafe") { r.fail++; failed.add(fileKey(join(root, "hooks", "hooks.json"))); }
869
+ if (hj === "ambiguous" || hj === "linked") left.add(fileKey(join(root, "hooks", "hooks.json")));
533
870
  }
534
871
 
535
872
  log();
536
873
  log("[4/4] 자동 업데이트 차단");
537
- // Claude Code 는 세션 시작 후 최대 10분 지연으로 마켓플레이스를 자동 fetch 한다.
538
- // 공식 차단 수단은 두 가지뿐이고 각각 한계가 있어, 현재 상태를 같이 보여준다.
539
- // - DISABLE_AUTOUPDATER=1 : 확실하지만 전역(다른 마켓플레이스도 멈춤)
540
- // - /plugin UI 의 마켓플레이스별 토글 : omc 만 끄지만 UI 상태라 파일로 확인 불가
541
- try {
542
- const st = JSON.parse(readFileSync(join(CFG, "settings.json"), "utf8"));
543
- const off = st?.env?.DISABLE_AUTOUPDATER;
544
- log(` DISABLE_AUTOUPDATER: ${off ? `${off} (전역 차단 중)` : "미설정 (다른 마켓플레이스는 계속 자동 업데이트)"}`);
545
- } catch { /* settings.json 이 없거나 읽을 수 없으면 표시만 생략 */ }
546
- const pin = pinMarketplace();
547
- ({
548
- pinned: () => ok("마켓플레이스 고정 (origin fetch 차단)"),
549
- already: () => ok("이미 고정됨"),
550
- unpinned: () => ok("고정 해제 (origin 복구)"),
551
- "not-pinned": () => ok("고정된 적 없음"),
552
- needs: () => {},
553
- "no-marketplace": () => bad("마켓플레이스 디렉터리 없음 — 고정 불필요"),
554
- "no-git": () => bad("마켓플레이스 폴더가 git 저장소가 아님 — 고정하지 않음"),
555
- "no-remote": () => ok("origin remote 없음 — 이미 fetch 불가"),
556
- }[pin] || (() => {}))();
874
+ let pin = "skipped";
875
+ if (ROOT) {
876
+ log(" 건너뜀 (--root 는 마켓플레이스와 settings.json 을 건드리지 않음)");
877
+ } else {
878
+ // Claude Code 는 세션 시작 후 최대 10분 지연으로 마켓플레이스를 자동 fetch 한다.
879
+ // 공식 차단 수단은 두 가지뿐이고 각각 한계가 있어, 현재 상태를 같이 보여준다.
880
+ // - DISABLE_AUTOUPDATER=1 : 확실하지만 전역(다른 마켓플레이스도 멈춤)
881
+ // - /plugin UI 의 마켓플레이스별 토글 : omc 만 끄지만 UI 상태라 파일로 확인 불가
882
+ try {
883
+ const st = JSON.parse(readFileSync(join(CFG, "settings.json"), "utf8"));
884
+ const off = st?.env?.DISABLE_AUTOUPDATER;
885
+ log(` DISABLE_AUTOUPDATER: ${off ? `${off} (전역 차단 중)` : "미설정 (다른 마켓플레이스는 계속 자동 업데이트)"}`);
886
+ } catch { /* settings.json 이 없거나 읽을 수 없으면 표시만 생략 */ }
887
+ pin = pinMarketplace();
888
+ ({
889
+ pinned: () => ok("마켓플레이스 고정 (origin fetch 차단)"),
890
+ already: () => ok("이미 고정됨"),
891
+ unpinned: () => ok("고정 해제 (origin 복구)"),
892
+ "not-pinned": () => ok("고정된 적 없음"),
893
+ needs: () => {},
894
+ "no-marketplace": () => bad("마켓플레이스 디렉터리 없음 — 고정 불필요"),
895
+ "no-git": () => bad("마켓플레이스 폴더가 git 저장소가 아님 — 고정하지 않음"),
896
+ "no-remote": () => ok("origin remote 없음 — 이미 fetch 불가"),
897
+ }[pin] || (() => {}))();
898
+ }
557
899
 
558
900
  log();
559
901
  if (MODE === "check") {
560
902
  // 판정은 «활성 버전» 기준이다. apply 는 활성 버전만 손대므로, 판정만 모든 버전을 보면
561
903
  // 캐시에 옛 버전이 남아 있는 한 apply 를 몇 번 돌려도 영원히 «미적용» 으로 나온다
562
904
  // (3-OS 에서 동일하게 재현). 비활성 버전은 정보로만 알린다.
563
- const clean = (needBy[active] || 0) === 0 && !hjNeedBy[active] && pin !== "needs";
564
- const others = targets.filter((v) => v !== active && ((needBy[v] || 0) > 0 || hjNeedBy[v]));
565
- log(clean ? "결과: 모두 적용된 상태입니다." : "결과: 적용되지 않은 항목이 있습니다. 인자 없이 다시 실행하십시오.");
905
+ // 헬퍼 자리 결함이 하나라도 있으면 «모두 적용» 이 아니다. 다시 적용만으로는 안 고쳐지므로
906
+ // 그 안내를 먼저 낸다(되돌린 뒤 적용하면 «패치 필요» 항목도 함께 처리된다).
907
+ const misplacedActive = misplacedBy[active] || 0;
908
+ const clean = (needBy[active] || 0) === 0 && !hjNeedBy[active] && pin !== "needs" && misplacedActive === 0;
909
+ const others = targets.filter((v) => v !== active && ((needBy[v] || 0) > 0 || hjNeedBy[v] || (misplacedBy[v] || 0) > 0));
910
+ log(clean ? "결과: 모두 적용된 상태입니다."
911
+ : misplacedActive ? `결과: 헬퍼 자리 결함 ${misplacedActive}건이 있습니다. ${misplacedHint()}`
912
+ : ROOT ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 같은 --root 로 다시 실행하십시오."
913
+ : NO_UPDATE ? "결과: 적용되지 않은 항목이 있습니다. --check 없이 --no-update 로 다시 실행하십시오."
914
+ : "결과: 적용되지 않은 항목이 있습니다. 인자 없이 다시 실행하십시오.");
566
915
  if (others.length) {
567
916
  log(` 참고: 비활성 버전 ${others.join(", ")} 에 미적용분이 있습니다.`);
568
917
  log(" 지금 쓰이지 않는 버전이라 판정에 넣지 않았습니다. 그 버전으로 되돌릴 계획이면 함께 적용하십시오.");
569
918
  }
570
919
  } else if (MODE === "revert") {
571
- log("되돌리기 완료. Claude Code 를 재시작해야 반영됩니다.");
920
+ // 백업이 없어 남은 파일과 hooks.json 도 «끝내지 못함» 이다. 완료라고 하면 남은 것을 보지 못하고 넘어간다.
921
+ const why = [failed.size && `실패 ${failed.size}건`, left.size && `되돌리지 못한 것 ${left.size}건`].filter(Boolean);
922
+ log(why.length
923
+ ? `되돌리기를 끝내지 못했습니다. ${why.join(", ")}. 위의 [!] 줄을 확인하십시오.`
924
+ : "되돌리기 완료. Claude Code 를 재시작해야 반영됩니다.");
572
925
  } else {
573
926
  log(`완료. 실패 ${r.fail}건.`);
927
+ if (r.misplaced) log(`[!] 헬퍼 자리 결함 ${r.misplaced}건은 고치지 않았습니다. ${misplacedHint()}`);
574
928
  log(" 훅 스크립트 패치는 즉시 적용됩니다 (훅이 호출될 때마다 파일을 새로 읽습니다).");
575
929
  log("[!] PostToolUse 훅 제거는 hooks.json 변경이라 각 세션이 다시 시작될 때부터 적용됩니다.");
576
930
  }
577
- process.exit(r.fail > 0 ? 1 : 0);
931
+ process.exit(r.fail > 0 || left.size > 0 ? 1 : 0);