@kaydash9999/banker-plugins 0.18.0 → 0.19.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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +103 -0
  4. package/README.md +18 -7
  5. package/bin/lib/telemetry-config.mjs +25 -0
  6. package/codex/manifest.json +1 -0
  7. package/codex/transform-matrix.md +1 -0
  8. package/hooks/graceful-pause.mjs +4 -2
  9. package/hooks/obsidize-hook.mjs +61 -32
  10. package/hooks/progress-plan.mjs +14 -8
  11. package/hooks/progress.mjs +187 -48
  12. package/hooks/register.mjs +15 -7
  13. package/hooks/telemetry-count-skill.mjs +4 -6
  14. package/hooks/telemetry-count.mjs +4 -6
  15. package/hooks/update-checkin.mjs +46 -43
  16. package/hooks/update-fetch.mjs +8 -7
  17. package/package.json +1 -1
  18. package/skills/anti-drm/SKILL.md +46 -15
  19. package/skills/anti-drm/scripts/anti-drm.mjs +213 -61
  20. package/skills/anti-drm/scripts/instruction-toggle.mjs +280 -44
  21. package/skills/lineage/SKILL.md +4 -1
  22. package/skills/lineage/lineage.py +196 -73
  23. package/skills/omc-patch/SKILL.md +37 -11
  24. package/skills/omc-patch/scripts/omc-patch.mjs +635 -103
  25. package/skills/theme-selection/SKILL.md +165 -0
  26. package/skills/theme-selection/assets/LICENSE +21 -0
  27. package/skills/theme-selection/assets/NOTICE.md +30 -0
  28. package/skills/theme-selection/assets/palettes.json +81 -0
  29. package/skills/theme-selection/references/contrast.md +138 -0
  30. package/skills/theme-selection/references/integrate.md +161 -0
  31. package/skills/theme-selection/scripts/themes.mjs +1067 -0
  32. package/skills/tone-compact/SKILL.md +14 -5
  33. package/skills/tone-compact/scripts/instruction-toggle.mjs +280 -44
  34. package/skills/tone-compact/scripts/tone-compact.mjs +4 -4
  35. package/skills/update-banker/SKILL.md +1 -1
@@ -39,7 +39,7 @@ argument-hint: "on | off | status"
39
39
  | 코드 | 뜻 | 처리 |
40
40
  |---|---|---|
41
41
  | 0 | 완료. 이미 그 상태였던 경우 포함 | 결과 전달 |
42
- | 1 | 거부 또는 사용법 오류. 아무것도 바꾸지 않음 | 메시지 그대로 전달 |
42
+ | 1 | 거부 또는 사용법 오류. 아무것도 바꾸지 않음. 단 메시지에 `앞서 바꾼 파일:` 이 있으면 그 파일은 이미 바뀜 | 메시지 그대로 전달 |
43
43
  | 2 | 예기치 못한 오류 | 메시지 전달 후 `status` 로 확인 |
44
44
 
45
45
  - 출력이 비어 있으면 실행 실패. 성공으로 보고하지 않음
@@ -184,20 +184,29 @@ argument-hint: "on | off | status"
184
184
  - 블록이 남음: `omx setup --merge-agents`, 기본 `omx setup`(team 모드 켜짐, 모델 표만 갱신)
185
185
  - 블록이 사라짐: `omx setup --force`, 덮어쓰기 확인, team 모드를 끈 `omx setup`. 그 뒤 `on` 다시 실행
186
186
  - UTF-8 이 아닌 지침 파일(CP949, UTF-16): 손대지 않고 거부
187
- - Codex 파일 변경 전 내용: `<파일>.tone-compact.bak` (이 계정만 읽기 가능)
187
+ - Codex 파일 변경 전 내용: `<파일>.tone-compact.bak` (이 계정만 읽기 가능). `off` 결과에 그 경로를 알림
188
188
  - 같은 파일에 있는 다른 도구의 블록은 건드리지 않음
189
189
  - `status`: 두 런타임 상태를 모두 표시
190
190
  - 한 런타임에서 켜도 다른 런타임은 그대로. 필요하면 그쪽에서도 실행
191
191
 
192
192
  ## 문제 해결
193
193
 
194
+ - 아래 표에서 파일을 보인다는 것은 `ls -l` 로 그 이름의 종류와 링크 대상 경로를 보이는 것이다. 링크가 아닌 일반 파일임을 본 뒤에만 내용을 연다. 심어 둔 링크가 가리키는 파일(키 등)이 세션에 오르지 않게 한다.
195
+ - `<파일>.tone-compact.bak` 은 내용을 열지 않고 경로만 알린다. 지침 파일이 링크였으면 가리키던 파일(키 등)의 사본일 수 있다.
196
+
194
197
  | 메시지 | 조치 |
195
198
  |---|---|
196
- | 같은 이름의 다른 파일이 있음 | `rules/banker-tone-compact.md` 가 banker 파일이 아님. 이름 변경 또는 삭제 뒤 다시 실행 |
197
- | 표시의 짝이 맞지 않음 | 지침 파일에 tone-compact 표시 줄이 한쪽만 있거나 문장 속에 들어 있음. 또는 블록 안에 다른 표시 줄이 있음. 짝을 새로 맞추지 말고 남은 표시 줄을 지운 뒤 다시 실행 |
199
+ | 같은 이름의 다른 파일이 있음 | `rules/banker-tone-compact.md` 가 banker 파일이 아님. 지우거나 이름을 바꾸지 않음. 내용을 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
200
+ | 표시의 짝이 맞지 않음 | 지침 파일에 tone-compact 표시 줄이 한쪽만 있거나 문장 속에 들어 있음. 또는 블록 안에 다른 표시 줄이 있음. 짝을 새로 맞추지 않음. 남은 표시 줄을 보이고 지울지 물은 뒤 다시 실행 |
198
201
  | 설정 폴더가 없음 | 해당 런타임 미설치. 또는 설정 폴더 환경 변수 값 확인 |
199
202
  | 규칙이 이 버전과 다름 | `on` 한 번 실행 |
200
203
  | 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 | `AGENTS.override.md` 가 새로 생김. `on` 실행 시 옮김 |
201
204
  | AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 | override 의 다른 내용이 지워짐. `on` 실행 시 AGENTS.md 로 옮기고 빈 override 삭제 |
202
- | UTF-8 이 아닌 파일 | 지침 파일을 UTF-8 로 바꾼 뒤 다시 실행 |
205
+ | 다른 banker 블록만 있어 Codex 가 AGENTS.md 를 읽지 않음 | 메시지가 말한 스킬에서 `on` 실행 |
206
+ | UTF-8 이 아닌 파일 | 지침 파일을 바꾸지 않음. 인코딩을 보이고, 백업을 만든 뒤 UTF-8 로 바꿀지 물은 뒤 다시 실행 |
207
+ | 끊어진 링크라 그대로 두었습니다 | AGENTS.md 가 가리키는 파일이 없음. 링크를 지우거나 바꾸지 않음. 링크와 대상을 보이고 어떻게 할지 물은 뒤 다시 실행 |
208
+ | AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 | AGENTS.override.md 에 블록만 남았고 AGENTS.md 가 가리키는 파일이 없음. 링크를 지우거나 바꾸지 않음. 링크와 대상을 보이고 어떻게 할지 물은 뒤 `on` 실행 |
209
+ | 쓰는 사이에 같은 이름이 생겨 그대로 두었습니다 | 다른 프로그램이 그 사이에 같은 이름을 만들었음(링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
210
+ | 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
211
+ | 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
203
212
  | 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
@@ -33,17 +33,30 @@
33
33
  * open and close in turn; a marker quoted inside a sentence, or an end before a
34
34
  * start, makes turnOn and turnOff refuse rather than guess where the block ends.
35
35
  * So does a file that is not UTF-8 (CP949, UTF-16): written back as text it
36
- * would lose its bytes.
36
+ * would lose its bytes. A folder or FIFO at a file's name is never read: a
37
+ * FIFO would make the read wait forever. Nor is a socket or a link loop; and an
38
+ * AGENTS.md that is a link to nothing is refused by turnOn, which would
39
+ * otherwise create the file the link points at.
37
40
  *
38
- * Results are { ok, message }; ok false means refused with nothing changed.
41
+ * Codex's files are read and written under a lock file (.banker-agents.lock in
42
+ * the Codex folder), so both skills turned on at once keep both blocks; a lock
43
+ * older than a minute is taken over.
44
+ *
45
+ * Results are { ok, message }; ok false means refused with nothing changed, or,
46
+ * when a change of two files was refused at the second, with the first changed
47
+ * and named in the message ("앞서 바꾼 파일").
39
48
  * Anything else that goes wrong throws.
40
49
  */
41
50
  import {
42
51
  accessSync,
52
+ closeSync,
43
53
  constants,
54
+ fstatSync,
44
55
  lstatSync,
56
+ openSync,
45
57
  mkdirSync,
46
58
  readFileSync,
59
+ realpathSync,
47
60
  renameSync,
48
61
  rmSync,
49
62
  statSync,
@@ -60,8 +73,12 @@ export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
60
73
 
61
74
  // The byte order mark an editor may put in front of a file.
62
75
  const BOM = String.fromCharCode(0xfeff);
63
- const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
64
- const lf = (text) => text.replace(/\r\n/g, "\n");
76
+ /** The text without the byte order mark an editor may put in front of it. */
77
+ export const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
78
+ /** The text with CRLF line ends turned into LF. */
79
+ export const lf = (text) => text.replace(/\r\n/g, "\n");
80
+ /** How many times needle occurs in text. */
81
+ export const count = (text, needle) => text.split(needle).length - 1;
65
82
  const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
66
83
  const isDir = (path) => {
67
84
  try {
@@ -80,27 +97,61 @@ const isLink = (path) => {
80
97
 
81
98
  const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
82
99
 
100
+ // What sits at a name that is not a regular file: a folder, a FIFO, a socket, a device or a link loop.
101
+ // It is never read; reading a FIFO waits for a writer.
102
+ const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
103
+ // What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
104
+ const DANGLING = Object.freeze({ bytes: null, text: null, dangling: true });
105
+ // What opening a name fails with when no regular file can be there: links that loop (ELOOP) or a socket (ENXIO,
106
+ // EOPNOTSUPP). macOS answers a socket with errno 102, which Node 22 does not name ("Unknown system error -102"), so
107
+ // the kind of what is there decides too.
108
+ const NOT_A_FILE = new Set(["ELOOP", "ENXIO", "EOPNOTSUPP"]);
109
+ // Whether path leads to something that is there and is no regular file: a folder, a socket, a FIFO or a device.
110
+ const notAFile = (path) => {
111
+ try {
112
+ return !statSync(path).isFile();
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+
118
+ // What a failed open of path means: null for nothing there, DANGLING for a link to nothing, SPECIAL for a folder,
119
+ // a socket or a link loop. Any other failure (permissions) is not "missing" and propagates.
120
+ function openFailure(path, e) {
121
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return isLink(path) ? DANGLING : null;
122
+ if (e.code === "EISDIR" || NOT_A_FILE.has(e.code) || notAFile(path)) return SPECIAL;
123
+ throw e;
124
+ }
125
+
83
126
  // A file's bytes and text, null when there is no such file. text is null when
84
127
  // the bytes are not UTF-8, which callers refuse: decoding them would replace
85
- // them with U+FFFD for good. Any other read failure (permissions, a folder at
86
- // that name) is not "missing" and propagates.
128
+ // them with U+FFFD for good. A folder, a socket, a link loop or anything but a
129
+ // regular file is SPECIAL, and a link to nothing is DANGLING: the name is
130
+ // opened without blocking and not read.
87
131
  function readFile(path) {
88
- let bytes;
132
+ let fd;
89
133
  try {
90
- bytes = readFileSync(path);
134
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
91
135
  } catch (e) {
92
- if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
93
- throw e;
136
+ return openFailure(path, e);
94
137
  }
95
138
  try {
96
- return { bytes, text: UTF8.decode(bytes) };
97
- } catch {
98
- return { bytes, text: null };
139
+ if (!fstatSync(fd).isFile()) return SPECIAL;
140
+ const bytes = readFileSync(fd);
141
+ try {
142
+ return { bytes, text: UTF8.decode(bytes) };
143
+ } catch {
144
+ return { bytes, text: null };
145
+ }
146
+ } finally {
147
+ closeSync(fd);
99
148
  }
100
149
  }
101
150
 
102
- const done = (message) => ({ ok: true, message });
103
- const refuse = (message) => ({ ok: false, message });
151
+ /** A result that did, or found already done, what was asked. */
152
+ export const done = (message) => ({ ok: true, message });
153
+ /** A result that refused and changed nothing, unless its message names a file changed before (applied). */
154
+ export const refuse = (message) => ({ ok: false, message });
104
155
 
105
156
  /** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
106
157
  export function markersOf(slug) {
@@ -144,9 +195,14 @@ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code
144
195
  * paragraph separator, private-use, unassigned and variation selector
145
196
  * characters become "?": a path from the environment cannot start a line of
146
197
  * its own, reorder the text around it, or hide text after a visible letter.
198
+ * So do letters that print as nothing (default-ignorable ones such as U+3164),
199
+ * the braille blank and spaces other than the ASCII one, which hide text as blanks.
147
200
  */
148
201
  export const where = (value) =>
149
- String(value).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}]/gu, "?");
202
+ String(value).replace(
203
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}\p{Default_Ignorable_Code_Point}\u{2800}\u{a0}\u{1680}\u{2000}-\u{200a}\u{202f}\u{205f}\u{3000}]/gu,
204
+ "?",
205
+ );
150
206
 
151
207
  // ---- Claude Code: one rules file that is banker's alone ----
152
208
 
@@ -156,7 +212,7 @@ const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
156
212
  // "off", "foreign" (not banker's file), or, when rules are given, "on" or
157
213
  // "stale" (other rules); without rules a banker file is "ours".
158
214
  function claudeState(t, file, rules) {
159
- if (file === null) return "off";
215
+ if (file === null || file.dangling) return "off";
160
216
  if (file.text === null) return "foreign";
161
217
  const body = lf(noBom(file.text));
162
218
  if (!body.startsWith(t.ownerMark)) return "foreign";
@@ -172,6 +228,7 @@ function claudeOn(t, o, getRules) {
172
228
  const state = claudeState(t, readFile(file), rules);
173
229
  if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${shown}`);
174
230
  if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${shown}`);
231
+ const wasLink = isLink(file);
175
232
  try {
176
233
  mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
177
234
  writeFresh(file, claudeText(t, rules));
@@ -179,13 +236,13 @@ function claudeOn(t, o, getRules) {
179
236
  if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${shown}`);
180
237
  throw e;
181
238
  }
182
- return done(
183
- state === "stale"
184
- ? `Claude Code: ${t.updatedText}. ${shown}`
185
- : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`,
186
- );
239
+ const words = state === "stale" ? `Claude Code: ${t.updatedText}. ${shown}` : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`;
240
+ return done(wasLink ? `${words}\n${LINK_REPLACED}` : words);
187
241
  }
188
242
 
243
+ /** The note for a name that was a link (to nothing, or to an older copy) and is now a regular file. */
244
+ export const LINK_REPLACED = "참고: 링크였던 자리를 일반 파일로 바꿨습니다. 링크가 가리키던 곳은 건드리지 않았습니다.";
245
+
189
246
  function claudeOff(t, o) {
190
247
  const file = claudeFile(t, o);
191
248
  const state = claudeState(t, readFile(file));
@@ -317,25 +374,48 @@ const otherBlocks = (t, text) =>
317
374
  return spans !== null && spans.length > 0;
318
375
  });
319
376
 
377
+ const special = (f) => f.special === true;
320
378
  const binary = (f) => f.bytes !== null && f.text === null;
321
379
  const unpaired = (f) => f.spans === null;
322
380
  const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
323
381
  const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
324
382
 
383
+ /** Whether two bigint stats name one file: one device and one inode, where the file system gives inodes at all. */
384
+ export const sameInode = (x, y) => x.ino !== 0n && x.dev === y.dev && x.ino === y.ino;
385
+
386
+ // Whether two paths name one file: a link (both resolve to one real path) or a hard link (one inode). A file system
387
+ // that gives no inode numbers (0) cannot say so for hard links, so those count as two files.
388
+ function sameFile(a, b) {
389
+ try {
390
+ if (realpathSync(a) === realpathSync(b)) return true;
391
+ return sameInode(statSync(a, { bigint: true }), statSync(b, { bigint: true }));
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+
325
397
  // Both global instruction files with their bytes and text (null when absent;
326
398
  // text null when not UTF-8) and blocks (null when the markers do not pair up).
327
399
  // The block's place (target) is the override when the override has text of its
328
400
  // own, AGENTS.md otherwise. shadowers names the other banker skills whose
329
401
  // blocks alone fill the override: Codex then reads the override, not AGENTS.md.
402
+ // An override that is a link or a hard link to AGENTS.md is one file with it.
330
403
  function codexFiles(t, o) {
331
404
  const read = (name) => {
332
405
  const path = join(o.codexDir, name);
333
406
  const file = readFile(path);
334
- if (file === null) return { path, bytes: null, text: null, spans: [] };
407
+ if (file === null || file.special || file.dangling) {
408
+ return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true };
409
+ }
335
410
  return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
336
411
  };
337
412
  const override = read("AGENTS.override.md");
338
413
  const main = read("AGENTS.md");
414
+ if (override.bytes !== null && main.bytes !== null && sameFile(override.path, main.path)) {
415
+ // One file under both names: Codex reads it either way, so it is AGENTS.md alone.
416
+ const none = { path: override.path, bytes: null, text: null, spans: [] };
417
+ return { target: main, other: none, all: [none, main], shadowers: [] };
418
+ }
339
419
  const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
340
420
  const target = ownContent ? override : main;
341
421
  const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
@@ -357,18 +437,30 @@ function withBlock(t, text, rules) {
357
437
  return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
358
438
  }
359
439
 
440
+ // The state when the block is not in its place: "hidden" (only in an override with no text of its own),
441
+ // "hiddenbroken" (the same, with AGENTS.md a link to nothing, so on cannot move it), "misplaced" (only in AGENTS.md
442
+ // while Codex reads the override), "dangling" (nowhere, and AGENTS.md is a link to nothing that on must not write
443
+ // through) or "off".
444
+ function placeState(files) {
445
+ if (hasBlock(files.other)) {
446
+ if (files.other !== files.all[0]) return "misplaced";
447
+ return files.target.dangling ? "hiddenbroken" : "hidden";
448
+ }
449
+ return files.target.dangling ? "dangling" : "off";
450
+ }
451
+
360
452
  // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
361
453
  // override, so not applied), "hidden" (in the override with no text of its own,
362
- // applied but hiding AGENTS.md), "unpaired" or "notutf8". Without rules any
363
- // block in its place counts as "on".
454
+ // applied but hiding AGENTS.md), "dangling" (nowhere, and AGENTS.md is a link to
455
+ // nothing), "unpaired", "notutf8" or "notregular" (a folder, FIFO, socket or
456
+ // link loop at a file's name). Without rules any block in its place counts as "on".
364
457
  function codexState(t, files, rules) {
458
+ if (files.all.some(special)) return "notregular";
365
459
  if (files.all.some(binary)) return "notutf8";
366
460
  if (files.all.some(unpaired)) return "unpaired";
367
- const inTarget = hasBlock(files.target);
368
- const inOther = hasBlock(files.other);
369
- if (!inTarget) return inOther ? (files.other === files.all[0] ? "hidden" : "misplaced") : "off";
461
+ if (!hasBlock(files.target)) return placeState(files);
370
462
  if (rules === undefined) return "on";
371
- return blockCurrent(t, files.target, rules) && !inOther ? "on" : "stale";
463
+ return blockCurrent(t, files.target, rules) && !hasBlock(files.other) ? "on" : "stale";
372
464
  }
373
465
 
374
466
  // Why these files cannot be written, or "" when they can. The folder counts
@@ -390,12 +482,120 @@ function writeBlocked(changes) {
390
482
  // target changes, after keeping the old bytes in a .bak only this account can
391
483
  // read (the file may be a link into a private folder). A new file gets 0644
392
484
  // whatever the umask allows more. A file left empty is removed, unless it is a
393
- // link: Codex skips an empty file anyway.
485
+ // link or has other names (a hard link), whose other names would keep the old
486
+ // text: it is emptied instead. Codex skips an empty file anyway.
487
+ // A file that was not there when it was read is created only if it still is not ("wx"): a name that appeared in
488
+ // between, a link planted there among them, is not written through.
394
489
  function apply(t, changes) {
395
490
  for (const { file, after } of changes) {
396
491
  if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
397
- if (after === "" && !isLink(file.path)) rmSync(file.path, { force: true });
398
- else writeFileSync(file.path, after, { mode: 0o644 });
492
+ if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
493
+ else writeFileSync(file.path, after, { mode: 0o644, flag: file.bytes === null ? "wx" : "w" });
494
+ }
495
+ }
496
+
497
+ // Applies the changes, or the refusal for a write that failed with nothing of the file changed: a name that
498
+ // appeared in between (EEXIST), or a write a sandbox refused after access() allowed it. The refusal names the place
499
+ // the system refused (a rename's target, as the backup, or the file it was about), and a file changed before the
500
+ // failing one. Other errors propagate.
501
+ function applied(t, changes) {
502
+ let i = 0;
503
+ try {
504
+ for (; i < changes.length; i++) apply(t, [changes[i]]);
505
+ } catch (e) {
506
+ if (e.code !== "EEXIST" && !denied(e)) throw e;
507
+ const why = e.code === "EEXIST" ? "쓰는 사이에 같은 이름이 생겨 그대로 두었습니다" : `쓸 권한이 없어 그대로 두었습니다 (${where(e.code)})`;
508
+ const at = [e.dest, e.path].find((p) => typeof p === "string") ?? changes[i].file.path;
509
+ const before = i === 0 ? "" : `\n앞서 바꾼 파일: ${changes.slice(0, i).map((c) => where(c.file.path)).join(", ")}`;
510
+ return refuse(`${why}: ${where(at)}${before}`);
511
+ }
512
+ return null;
513
+ }
514
+
515
+ // Whether the file at path is also reachable under another name: a hard link.
516
+ function hasOtherNames(path) {
517
+ try {
518
+ return lstatSync(path).nlink > 1;
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+
524
+ // The lock both banker skills take while they read and write Codex's instructions files: two on's at once would
525
+ // each write the file back with only their own block. A lock is held for milliseconds; one older than a minute was
526
+ // left by a run that died.
527
+ const LOCK = ".banker-agents.lock";
528
+ const LOCK_STALE_MS = 60_000;
529
+ const LOCK_STEP_MS = 50;
530
+ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
531
+
532
+ // Makes the lock file; false when one is there already. Anything else (permissions) throws.
533
+ function tryLock(path, token) {
534
+ try {
535
+ writeFileSync(path, token, { flag: "wx", mode: 0o600 });
536
+ return true;
537
+ } catch (e) {
538
+ if (e.code === "EEXIST") return false;
539
+ throw e;
540
+ }
541
+ }
542
+
543
+ // Removes a lock a dead run left; true when there is none now, so the next try may take it. The name itself is
544
+ // looked at, not where it leads: a link to nothing or a link loop there is a lock like any other. A lock dated more
545
+ // than a minute away either way (a clock set back) is stale too. A folder there is none of this module's: it stays,
546
+ // and the wait ends in a refusal.
547
+ function dropStale(path) {
548
+ let st;
549
+ try {
550
+ st = lstatSync(path);
551
+ } catch (e) {
552
+ if (e.code === "ENOENT" || e.code === "ESTALE") return true; /* gone in between (ESTALE: a network share's word for it) */
553
+ throw e;
554
+ }
555
+ if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
556
+ rmSync(path, { force: true });
557
+ return true;
558
+ }
559
+
560
+ // Takes the lock: { path, token }, or null when another run held it for waitMs. The deadline is looked at every
561
+ // round, so no lock spot, whatever is there, keeps the wait going past it.
562
+ function takeLock(dir, waitMs) {
563
+ const path = join(dir, LOCK);
564
+ const token = `${process.pid} ${Date.now()} ${Math.random()}`;
565
+ const until = Date.now() + waitMs;
566
+ for (;;) {
567
+ if (tryLock(path, token)) return { path, token };
568
+ if (Date.now() >= until) return null;
569
+ if (!dropStale(path)) Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
570
+ }
571
+ }
572
+
573
+ // Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
574
+ // this run's lock and stays.
575
+ function dropLock(lock) {
576
+ try {
577
+ if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
578
+ } catch {
579
+ /* already gone */
580
+ }
581
+ }
582
+
583
+ // Runs fn holding the lock; a lock another run keeps past the wait is a refusal. A folder this account cannot write
584
+ // takes no lock, and nothing in it can be written either: fn then only reads, and writeBlocked refuses any change,
585
+ // so "already on" and "already off" answer as before.
586
+ function locked(o, fn) {
587
+ let lock;
588
+ try {
589
+ lock = takeLock(o.codexDir, o.lockWaitMs ?? 5000);
590
+ } catch (e) {
591
+ if (denied(e)) return fn();
592
+ throw e;
593
+ }
594
+ if (!lock) return refuse(`다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(join(o.codexDir, LOCK))}`);
595
+ try {
596
+ return fn();
597
+ } finally {
598
+ dropLock(lock);
399
599
  }
400
600
  }
401
601
 
@@ -408,35 +608,65 @@ const shadowNote = (files) =>
408
608
  ? ""
409
609
  : `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
410
610
 
611
+ // The file a refused state is about: the folder or FIFO, the file that is not UTF-8, or the one whose markers do
612
+ // not pair up; null in any other state.
613
+ function problemFile(files, state) {
614
+ const which = { notregular: special, notutf8: binary, unpaired }[state];
615
+ return which ? files.all.find(which) : null;
616
+ }
617
+
411
618
  // The refusal for files on and off must not touch, or null.
412
619
  function codexRefusal(t, files, state) {
413
- if (state === "notutf8") return refuse(`UTF-8 이 아닌 파일이라 그대로 두었습니다: ${where(files.all.find(binary).path)}`);
414
- if (state === "unpaired") return refuse(`${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다: ${where(files.all.find(unpaired).path)}`);
415
- return null;
620
+ const file = problemFile(files, state);
621
+ if (!file) return null;
622
+ const why = {
623
+ notregular: "일반 파일이 아니라 그대로 두었습니다",
624
+ notutf8: "UTF-8 이 아닌 파일이라 그대로 두었습니다",
625
+ unpaired: `${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다`,
626
+ }[state];
627
+ return refuse(`${why}: ${where(file.path)}`);
628
+ }
629
+
630
+ // What codexOn says it did. A block Codex does not read, because other banker
631
+ // blocks alone fill the override, says so in its first words.
632
+ function onWords(t, state, unread) {
633
+ if (state === "on") return unread ? "이미 AGENTS.md 에 있지만 지금은 적용되지 않습니다 (변경 없음)" : "이미 켜져 있습니다 (변경 없음)";
634
+ if (unread) return "AGENTS.md 에 넣었지만 지금은 적용되지 않습니다";
635
+ const moved = "블록을 맞는 파일로 옮겼습니다";
636
+ return { stale: t.updatedText, misplaced: moved, hidden: moved }[state] ?? "켰습니다. 다음 세션부터 자동 적용됩니다";
416
637
  }
417
638
 
418
639
  function codexOn(t, o, getRules) {
419
640
  if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${where(o.codexDir)}`);
641
+ return locked(o, () => codexOnHeld(t, o, getRules));
642
+ }
643
+
644
+ function codexOnHeld(t, o, getRules) {
420
645
  const rules = getRules();
421
646
  const files = codexFiles(t, o);
422
647
  const state = codexState(t, files, rules);
423
648
  const refusal = codexRefusal(t, files, state);
424
649
  if (refusal) return refusal;
650
+ if (files.target.dangling) return refuse(`끊어진 링크라 그대로 두었습니다: ${where(files.target.path)}`);
651
+ const words = onWords(t, state, files.shadowers.length > 0);
425
652
  const tail = `${where(files.target.path)}${shadowNote(files)}`;
426
- if (state === "on") return done(`Codex: 이미 켜져 있습니다 (변경 없음). ${tail}`);
653
+ if (state === "on") return done(`Codex: ${words}. ${tail}`);
427
654
  const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
428
655
  if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
429
656
  const why = writeBlocked(changes);
430
657
  if (why) return refuse(why);
431
- apply(t, changes);
432
- const moved = "블록을 맞는 파일로 옮겼습니다";
433
- const what = { stale: t.updatedText, misplaced: moved, hidden: moved }[state];
658
+ const failed = applied(t, changes);
659
+ if (failed) return failed;
434
660
  const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
435
- return done(`Codex: ${what ?? "켰습니다. 다음 세션부터 자동 적용됩니다"}. ${tail}${note}`);
661
+ return done(`Codex: ${words}. ${tail}${note}`);
436
662
  }
437
663
 
438
664
  function codexOff(t, o) {
439
665
  if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${where(o.codexDir)}`);
666
+ return locked(o, () => codexOffHeld(t, o));
667
+ }
668
+
669
+ function codexOffHeld(t, o) {
440
670
  const files = codexFiles(t, o);
441
671
  const refusal = codexRefusal(t, files, codexState(t, files));
442
672
  if (refusal) return refusal;
@@ -444,8 +674,11 @@ function codexOff(t, o) {
444
674
  if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
445
675
  const why = writeBlocked(changes);
446
676
  if (why) return refuse(why);
447
- apply(t, changes);
448
- return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${changes.map((c) => where(c.file.path)).join(", ")}`);
677
+ const failed = applied(t, changes);
678
+ if (failed) return failed;
679
+ const paths = changes.map((c) => where(c.file.path)).join(", ");
680
+ const baks = changes.map((c) => where(`${c.file.path}.${t.slug}.bak`)).join(", ");
681
+ return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${paths}\n지우기 전 파일: ${baks}`);
449
682
  }
450
683
 
451
684
  // ---- status ----
@@ -457,8 +690,11 @@ const STATE_TEXT = {
457
690
  foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
458
691
  misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
459
692
  hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
693
+ hiddenbroken: "켜짐, 단 AGENTS.override.md 에 블록만 있고 AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 (링크 확인 필요)",
460
694
  notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
695
+ notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
461
696
  unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
697
+ dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
462
698
  };
463
699
 
464
700
  // A state as the status line says it.
@@ -486,7 +722,7 @@ function codexLook(t, o, rules) {
486
722
  const files = codexFiles(t, o);
487
723
  const state = codexState(t, files, rules);
488
724
  const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
489
- return { state: shadowed ? "shadowed" : state, path: files.target.path, who: files.shadowers };
725
+ return { state: shadowed ? "shadowed" : state, path: (problemFile(files, state) ?? files.target).path, who: files.shadowers };
490
726
  }
491
727
 
492
728
  /** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
@@ -507,7 +743,7 @@ export function stateLines(t, o, rules) {
507
743
  */
508
744
  export function present(t, runtime, o) {
509
745
  if (runtime === "claude") return isDir(o.claudeDir) && claudeState(t, readFile(claudeFile(t, o))) === "ours";
510
- return isDir(o.codexDir) && !["off", "notutf8"].includes(codexState(t, codexFiles(t, o)));
746
+ return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
511
747
  }
512
748
 
513
749
  const knownRuntime = (runtime) => {
@@ -15,7 +15,8 @@
15
15
  *
16
16
  * `off` never needs the rules, so it works even when SKILL.md cannot be read.
17
17
  * Exit status 0 means done or already so; 1 means refused or a usage error, with
18
- * nothing changed; 2 means an unexpected error stopped the command partway, so
18
+ * nothing changed (a refusal at the second of two files names the first, which
19
+ * it changed); 2 means an unexpected error stopped the command partway, so
19
20
  * check `status`.
20
21
  */
21
22
  import { readFileSync, realpathSync } from "node:fs";
@@ -27,7 +28,9 @@ import {
27
28
  KEEP_OPEN,
28
29
  RUNTIMES,
29
30
  blockSpansOf,
31
+ count,
30
32
  hasMarker,
33
+ lf,
31
34
  stateLines,
32
35
  toggleSpec,
33
36
  turnOff as toggleOff,
@@ -50,9 +53,6 @@ const DEFAULTS = {
50
53
  skillFile: fileURLToPath(new URL("../SKILL.md", import.meta.url)),
51
54
  };
52
55
 
53
- const count = (text, needle) => text.split(needle).length - 1;
54
- const lf = (text) => text.replace(/\r\n/g, "\n");
55
-
56
56
  /** The rules: the text between the markers in SKILL.md, without the line breaks around it. */
57
57
  export function rulesOf(skillText) {
58
58
  const text = lf(skillText);
@@ -61,7 +61,7 @@ banker 는 서로 다른 메커니즘의 채널 3개로 설치되고, 그 채널
61
61
  - 반대 방향의 거짓말도 하지 마라: banker 자체는 dependencies 0 이라 컴파일이 없다. **빌드 도구·툴체인 준비를 요구하는 절차를 넣으면 그건 거짓이다.**
62
62
 
63
63
  **4. `~/.claude/plugins/cache/` 를 `rm -rf` 하지 마라.**
64
- - 캐시에 구버전이 공존하는 것은 **관측 대상이지 조치 대상이 아니다.** 갱신 의미론 밖이고, 자동 정리 주기에 관한 증거는 서로 어긋난다.
64
+ - 캐시에 구버전이 공존하는 것은 **관측 대상이지 조치 대상이 아니다.** 갱신 의미론 밖이다. Claude Code 는 교체된 판 폴더에 `.orphaned_at` 을 쓰고, 14일이 지나고 그 판을 쓰는 세션이 없으면 그 폴더를 스스로 지운다(2.1.299 에서 확인). 링크로 된 판 폴더(OMC 의 옛 판 경로 등)는 표시하지도 지우지도 않는다.
65
65
  - 발견하면 보고만 한다. 손대지 않는다.
66
66
 
67
67
  ## 워크플로