@kaydash9999/banker-plugins 0.18.0 → 0.19.0

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.
@@ -184,7 +184,7 @@ 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
  - 한 런타임에서 켜도 다른 런타임은 그대로. 필요하면 그쪽에서도 실행
@@ -193,11 +193,15 @@ argument-hint: "on | off | status"
193
193
 
194
194
  | 메시지 | 조치 |
195
195
  |---|---|
196
- | 같은 이름의 다른 파일이 있음 | `rules/banker-tone-compact.md` 가 banker 파일이 아님. 이름 변경 또는 삭제 뒤 다시 실행 |
197
- | 표시의 짝이 맞지 않음 | 지침 파일에 tone-compact 표시 줄이 한쪽만 있거나 문장 속에 들어 있음. 또는 블록 안에 다른 표시 줄이 있음. 짝을 새로 맞추지 말고 남은 표시 줄을 지운 뒤 다시 실행 |
196
+ | 같은 이름의 다른 파일이 있음 | `rules/banker-tone-compact.md` 가 banker 파일이 아님. 지우거나 이름을 바꾸지 않음. 내용을 보이고 어떻게 할지 물은 뒤 다시 실행 |
197
+ | 표시의 짝이 맞지 않음 | 지침 파일에 tone-compact 표시 줄이 한쪽만 있거나 문장 속에 들어 있음. 또는 블록 안에 다른 표시 줄이 있음. 짝을 새로 맞추지 않음. 남은 표시 줄을 보이고 지울지 물은 뒤 다시 실행 |
198
198
  | 설정 폴더가 없음 | 해당 런타임 미설치. 또는 설정 폴더 환경 변수 값 확인 |
199
199
  | 규칙이 이 버전과 다름 | `on` 한 번 실행 |
200
200
  | 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 | `AGENTS.override.md` 가 새로 생김. `on` 실행 시 옮김 |
201
201
  | AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 | override 의 다른 내용이 지워짐. `on` 실행 시 AGENTS.md 로 옮기고 빈 override 삭제 |
202
- | UTF-8 이 아닌 파일 | 지침 파일을 UTF-8 로 바꾼 뒤 다시 실행 |
202
+ | 다른 banker 블록만 있어 Codex 가 AGENTS.md 를 읽지 않음 | 메시지가 말한 스킬에서 `on` 실행 |
203
+ | UTF-8 이 아닌 파일 | 지침 파일을 바꾸지 않음. 인코딩을 보이고, 백업을 만든 뒤 UTF-8 로 바꿀지 물은 뒤 다시 실행 |
204
+ | 끊어진 링크라 그대로 두었습니다 | AGENTS.md 가 가리키는 파일이 없음. 링크를 지우거나 바꾸지 않음. 링크와 대상을 보이고 어떻게 할지 물은 뒤 다시 실행 |
205
+ | 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
206
+ | 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
203
207
  | 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
@@ -33,17 +33,28 @@
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.
40
+ *
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.
37
44
  *
38
45
  * Results are { ok, message }; ok false means refused with nothing changed.
39
46
  * Anything else that goes wrong throws.
40
47
  */
41
48
  import {
42
49
  accessSync,
50
+ closeSync,
43
51
  constants,
52
+ fstatSync,
44
53
  lstatSync,
54
+ openSync,
45
55
  mkdirSync,
46
56
  readFileSync,
57
+ realpathSync,
47
58
  renameSync,
48
59
  rmSync,
49
60
  statSync,
@@ -60,8 +71,12 @@ export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
60
71
 
61
72
  // The byte order mark an editor may put in front of a file.
62
73
  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");
74
+ /** The text without the byte order mark an editor may put in front of it. */
75
+ export const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
76
+ /** The text with CRLF line ends turned into LF. */
77
+ export const lf = (text) => text.replace(/\r\n/g, "\n");
78
+ /** How many times needle occurs in text. */
79
+ export const count = (text, needle) => text.split(needle).length - 1;
65
80
  const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
66
81
  const isDir = (path) => {
67
82
  try {
@@ -80,27 +95,61 @@ const isLink = (path) => {
80
95
 
81
96
  const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
82
97
 
98
+ // What sits at a name that is not a regular file: a folder, a FIFO or a device.
99
+ // It is never read; reading a FIFO waits for a writer.
100
+ const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
101
+ // What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
102
+ const DANGLING = Object.freeze({ bytes: null, text: null, dangling: true });
103
+ // What opening a name fails with when no regular file can be there: links that loop (ELOOP) or a socket (ENXIO,
104
+ // EOPNOTSUPP). macOS answers a socket with errno 102, which Node 22 does not name ("Unknown system error -102"), so
105
+ // the kind of what is there decides too.
106
+ const NOT_A_FILE = new Set(["ELOOP", "ENXIO", "EOPNOTSUPP"]);
107
+ // Whether path leads to something that is there and is no regular file: a folder, a socket, a FIFO or a device.
108
+ const notAFile = (path) => {
109
+ try {
110
+ return !statSync(path).isFile();
111
+ } catch {
112
+ return false;
113
+ }
114
+ };
115
+
116
+ // What a failed open of path means: null for nothing there, DANGLING for a link to nothing, SPECIAL for a folder,
117
+ // a socket or a link loop. Any other failure (permissions) is not "missing" and propagates.
118
+ function openFailure(path, e) {
119
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return isLink(path) ? DANGLING : null;
120
+ if (e.code === "EISDIR" || NOT_A_FILE.has(e.code) || notAFile(path)) return SPECIAL;
121
+ throw e;
122
+ }
123
+
83
124
  // A file's bytes and text, null when there is no such file. text is null when
84
125
  // 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.
126
+ // them with U+FFFD for good. A folder, a socket, a link loop or anything but a
127
+ // regular file is SPECIAL, and a link to nothing is DANGLING: the name is
128
+ // opened without blocking and not read.
87
129
  function readFile(path) {
88
- let bytes;
130
+ let fd;
89
131
  try {
90
- bytes = readFileSync(path);
132
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
91
133
  } catch (e) {
92
- if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
93
- throw e;
134
+ return openFailure(path, e);
94
135
  }
95
136
  try {
96
- return { bytes, text: UTF8.decode(bytes) };
97
- } catch {
98
- return { bytes, text: null };
137
+ if (!fstatSync(fd).isFile()) return SPECIAL;
138
+ const bytes = readFileSync(fd);
139
+ try {
140
+ return { bytes, text: UTF8.decode(bytes) };
141
+ } catch {
142
+ return { bytes, text: null };
143
+ }
144
+ } finally {
145
+ closeSync(fd);
99
146
  }
100
147
  }
101
148
 
102
- const done = (message) => ({ ok: true, message });
103
- const refuse = (message) => ({ ok: false, message });
149
+ /** A result that did, or found already done, what was asked. */
150
+ export const done = (message) => ({ ok: true, message });
151
+ /** A result that refused and changed nothing. */
152
+ export const refuse = (message) => ({ ok: false, message });
104
153
 
105
154
  /** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
106
155
  export function markersOf(slug) {
@@ -144,9 +193,14 @@ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code
144
193
  * paragraph separator, private-use, unassigned and variation selector
145
194
  * characters become "?": a path from the environment cannot start a line of
146
195
  * its own, reorder the text around it, or hide text after a visible letter.
196
+ * So do letters that print as nothing (default-ignorable ones such as U+3164),
197
+ * the braille blank and spaces other than the ASCII one, which hide text as blanks.
147
198
  */
148
199
  export const where = (value) =>
149
- String(value).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}]/gu, "?");
200
+ String(value).replace(
201
+ /[\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,
202
+ "?",
203
+ );
150
204
 
151
205
  // ---- Claude Code: one rules file that is banker's alone ----
152
206
 
@@ -156,7 +210,7 @@ const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
156
210
  // "off", "foreign" (not banker's file), or, when rules are given, "on" or
157
211
  // "stale" (other rules); without rules a banker file is "ours".
158
212
  function claudeState(t, file, rules) {
159
- if (file === null) return "off";
213
+ if (file === null || file.dangling) return "off";
160
214
  if (file.text === null) return "foreign";
161
215
  const body = lf(noBom(file.text));
162
216
  if (!body.startsWith(t.ownerMark)) return "foreign";
@@ -317,25 +371,48 @@ const otherBlocks = (t, text) =>
317
371
  return spans !== null && spans.length > 0;
318
372
  });
319
373
 
374
+ const special = (f) => f.special === true;
320
375
  const binary = (f) => f.bytes !== null && f.text === null;
321
376
  const unpaired = (f) => f.spans === null;
322
377
  const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
323
378
  const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
324
379
 
380
+ /** Whether two bigint stats name one file: one device and one inode, where the file system gives inodes at all. */
381
+ export const sameInode = (x, y) => x.ino !== 0n && x.dev === y.dev && x.ino === y.ino;
382
+
383
+ // Whether two paths name one file: a link (both resolve to one real path) or a hard link (one inode). A file system
384
+ // that gives no inode numbers (0) cannot say so for hard links, so those count as two files.
385
+ function sameFile(a, b) {
386
+ try {
387
+ if (realpathSync(a) === realpathSync(b)) return true;
388
+ return sameInode(statSync(a, { bigint: true }), statSync(b, { bigint: true }));
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
325
394
  // Both global instruction files with their bytes and text (null when absent;
326
395
  // text null when not UTF-8) and blocks (null when the markers do not pair up).
327
396
  // The block's place (target) is the override when the override has text of its
328
397
  // own, AGENTS.md otherwise. shadowers names the other banker skills whose
329
398
  // blocks alone fill the override: Codex then reads the override, not AGENTS.md.
399
+ // An override that is a link or a hard link to AGENTS.md is one file with it.
330
400
  function codexFiles(t, o) {
331
401
  const read = (name) => {
332
402
  const path = join(o.codexDir, name);
333
403
  const file = readFile(path);
334
- if (file === null) return { path, bytes: null, text: null, spans: [] };
404
+ if (file === null || file.special || file.dangling) {
405
+ return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true };
406
+ }
335
407
  return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
336
408
  };
337
409
  const override = read("AGENTS.override.md");
338
410
  const main = read("AGENTS.md");
411
+ if (override.bytes !== null && main.bytes !== null && sameFile(override.path, main.path)) {
412
+ // One file under both names: Codex reads it either way, so it is AGENTS.md alone.
413
+ const none = { path: override.path, bytes: null, text: null, spans: [] };
414
+ return { target: main, other: none, all: [none, main], shadowers: [] };
415
+ }
339
416
  const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
340
417
  const target = ownContent ? override : main;
341
418
  const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
@@ -357,18 +434,26 @@ function withBlock(t, text, rules) {
357
434
  return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
358
435
  }
359
436
 
437
+ // The state when the block is not in its place: "hidden" (only in an override with no text of its own),
438
+ // "misplaced" (only in AGENTS.md while Codex reads the override), "dangling" (nowhere, and AGENTS.md is a link to
439
+ // nothing that on must not write through) or "off".
440
+ function placeState(files) {
441
+ if (hasBlock(files.other)) return files.other === files.all[0] ? "hidden" : "misplaced";
442
+ return files.target.dangling ? "dangling" : "off";
443
+ }
444
+
360
445
  // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
361
446
  // 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".
447
+ // applied but hiding AGENTS.md), "dangling" (nowhere, and AGENTS.md is a link to
448
+ // nothing), "unpaired", "notutf8" or "notregular" (a folder, FIFO, socket or
449
+ // link loop at a file's name). Without rules any block in its place counts as "on".
364
450
  function codexState(t, files, rules) {
451
+ if (files.all.some(special)) return "notregular";
365
452
  if (files.all.some(binary)) return "notutf8";
366
453
  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";
454
+ if (!hasBlock(files.target)) return placeState(files);
370
455
  if (rules === undefined) return "on";
371
- return blockCurrent(t, files.target, rules) && !inOther ? "on" : "stale";
456
+ return blockCurrent(t, files.target, rules) && !hasBlock(files.other) ? "on" : "stale";
372
457
  }
373
458
 
374
459
  // Why these files cannot be written, or "" when they can. The folder counts
@@ -390,15 +475,103 @@ function writeBlocked(changes) {
390
475
  // target changes, after keeping the old bytes in a .bak only this account can
391
476
  // read (the file may be a link into a private folder). A new file gets 0644
392
477
  // whatever the umask allows more. A file left empty is removed, unless it is a
393
- // link: Codex skips an empty file anyway.
478
+ // link or has other names (a hard link), whose other names would keep the old
479
+ // text: it is emptied instead. Codex skips an empty file anyway.
394
480
  function apply(t, changes) {
395
481
  for (const { file, after } of changes) {
396
482
  if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
397
- if (after === "" && !isLink(file.path)) rmSync(file.path, { force: true });
483
+ if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
398
484
  else writeFileSync(file.path, after, { mode: 0o644 });
399
485
  }
400
486
  }
401
487
 
488
+ // Whether the file at path is also reachable under another name: a hard link.
489
+ function hasOtherNames(path) {
490
+ try {
491
+ return lstatSync(path).nlink > 1;
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ // The lock both banker skills take while they read and write Codex's instructions files: two on's at once would
498
+ // each write the file back with only their own block. A lock is held for milliseconds; one older than a minute was
499
+ // left by a run that died.
500
+ const LOCK = ".banker-agents.lock";
501
+ const LOCK_STALE_MS = 60_000;
502
+ const LOCK_STEP_MS = 50;
503
+ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
504
+
505
+ // Makes the lock file; false when one is there already. Anything else (permissions) throws.
506
+ function tryLock(path, token) {
507
+ try {
508
+ writeFileSync(path, token, { flag: "wx", mode: 0o600 });
509
+ return true;
510
+ } catch (e) {
511
+ if (e.code === "EEXIST") return false;
512
+ throw e;
513
+ }
514
+ }
515
+
516
+ // Removes a lock a dead run left; true when there is none now, so the next try may take it. The name itself is
517
+ // 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
518
+ // than a minute away either way (a clock set back) is stale too. A folder there is none of this module's: it stays,
519
+ // and the wait ends in a refusal.
520
+ function dropStale(path) {
521
+ let st;
522
+ try {
523
+ st = lstatSync(path);
524
+ } catch (e) {
525
+ if (e.code === "ENOENT") return true; /* gone in between */
526
+ throw e;
527
+ }
528
+ if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
529
+ rmSync(path, { force: true });
530
+ return true;
531
+ }
532
+
533
+ // Takes the lock: { path, token }, or null when another run held it for waitMs. The deadline is looked at every
534
+ // round, so no lock spot, whatever is there, keeps the wait going past it.
535
+ function takeLock(dir, waitMs) {
536
+ const path = join(dir, LOCK);
537
+ const token = `${process.pid} ${Date.now()} ${Math.random()}`;
538
+ const until = Date.now() + waitMs;
539
+ for (;;) {
540
+ if (tryLock(path, token)) return { path, token };
541
+ if (Date.now() >= until) return null;
542
+ if (!dropStale(path)) Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
543
+ }
544
+ }
545
+
546
+ // Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
547
+ // this run's lock and stays.
548
+ function dropLock(lock) {
549
+ try {
550
+ if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
551
+ } catch {
552
+ /* already gone */
553
+ }
554
+ }
555
+
556
+ // Runs fn holding the lock; a lock another run keeps past the wait is a refusal. A folder this account cannot write
557
+ // takes no lock, and nothing in it can be written either: fn then only reads, and writeBlocked refuses any change,
558
+ // so "already on" and "already off" answer as before.
559
+ function locked(o, fn) {
560
+ let lock;
561
+ try {
562
+ lock = takeLock(o.codexDir, o.lockWaitMs ?? 5000);
563
+ } catch (e) {
564
+ if (denied(e)) return fn();
565
+ throw e;
566
+ }
567
+ if (!lock) return refuse(`다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(join(o.codexDir, LOCK))}`);
568
+ try {
569
+ return fn();
570
+ } finally {
571
+ dropLock(lock);
572
+ }
573
+ }
574
+
402
575
  const OMX_NOTE =
403
576
  "참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
404
577
 
@@ -408,35 +581,64 @@ const shadowNote = (files) =>
408
581
  ? ""
409
582
  : `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
410
583
 
584
+ // The file a refused state is about: the folder or FIFO, the file that is not UTF-8, or the one whose markers do
585
+ // not pair up; null in any other state.
586
+ function problemFile(files, state) {
587
+ const which = { notregular: special, notutf8: binary, unpaired }[state];
588
+ return which ? files.all.find(which) : null;
589
+ }
590
+
411
591
  // The refusal for files on and off must not touch, or null.
412
592
  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;
593
+ const file = problemFile(files, state);
594
+ if (!file) return null;
595
+ const why = {
596
+ notregular: "일반 파일이 아니라 그대로 두었습니다",
597
+ notutf8: "UTF-8 이 아닌 파일이라 그대로 두었습니다",
598
+ unpaired: `${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다`,
599
+ }[state];
600
+ return refuse(`${why}: ${where(file.path)}`);
601
+ }
602
+
603
+ // What codexOn says it did. A block Codex does not read, because other banker
604
+ // blocks alone fill the override, says so in its first words.
605
+ function onWords(t, state, unread) {
606
+ if (state === "on") return unread ? "이미 AGENTS.md 에 있지만 지금은 적용되지 않습니다 (변경 없음)" : "이미 켜져 있습니다 (변경 없음)";
607
+ if (unread) return "AGENTS.md 에 넣었지만 지금은 적용되지 않습니다";
608
+ const moved = "블록을 맞는 파일로 옮겼습니다";
609
+ return { stale: t.updatedText, misplaced: moved, hidden: moved }[state] ?? "켰습니다. 다음 세션부터 자동 적용됩니다";
416
610
  }
417
611
 
418
612
  function codexOn(t, o, getRules) {
419
613
  if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${where(o.codexDir)}`);
614
+ return locked(o, () => codexOnHeld(t, o, getRules));
615
+ }
616
+
617
+ function codexOnHeld(t, o, getRules) {
420
618
  const rules = getRules();
421
619
  const files = codexFiles(t, o);
422
620
  const state = codexState(t, files, rules);
423
621
  const refusal = codexRefusal(t, files, state);
424
622
  if (refusal) return refusal;
623
+ if (files.target.dangling) return refuse(`끊어진 링크라 그대로 두었습니다: ${where(files.target.path)}`);
624
+ const words = onWords(t, state, files.shadowers.length > 0);
425
625
  const tail = `${where(files.target.path)}${shadowNote(files)}`;
426
- if (state === "on") return done(`Codex: 이미 켜져 있습니다 (변경 없음). ${tail}`);
626
+ if (state === "on") return done(`Codex: ${words}. ${tail}`);
427
627
  const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
428
628
  if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
429
629
  const why = writeBlocked(changes);
430
630
  if (why) return refuse(why);
431
631
  apply(t, changes);
432
- const moved = "블록을 맞는 파일로 옮겼습니다";
433
- const what = { stale: t.updatedText, misplaced: moved, hidden: moved }[state];
434
632
  const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
435
- return done(`Codex: ${what ?? "켰습니다. 다음 세션부터 자동 적용됩니다"}. ${tail}${note}`);
633
+ return done(`Codex: ${words}. ${tail}${note}`);
436
634
  }
437
635
 
438
636
  function codexOff(t, o) {
439
637
  if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${where(o.codexDir)}`);
638
+ return locked(o, () => codexOffHeld(t, o));
639
+ }
640
+
641
+ function codexOffHeld(t, o) {
440
642
  const files = codexFiles(t, o);
441
643
  const refusal = codexRefusal(t, files, codexState(t, files));
442
644
  if (refusal) return refusal;
@@ -445,7 +647,9 @@ function codexOff(t, o) {
445
647
  const why = writeBlocked(changes);
446
648
  if (why) return refuse(why);
447
649
  apply(t, changes);
448
- return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${changes.map((c) => where(c.file.path)).join(", ")}`);
650
+ const paths = changes.map((c) => where(c.file.path)).join(", ");
651
+ const baks = changes.map((c) => where(`${c.file.path}.${t.slug}.bak`)).join(", ");
652
+ return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${paths}\n지우기 전 파일: ${baks}`);
449
653
  }
450
654
 
451
655
  // ---- status ----
@@ -458,7 +662,9 @@ const STATE_TEXT = {
458
662
  misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
459
663
  hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
460
664
  notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
665
+ notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
461
666
  unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
667
+ dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
462
668
  };
463
669
 
464
670
  // A state as the status line says it.
@@ -486,7 +692,7 @@ function codexLook(t, o, rules) {
486
692
  const files = codexFiles(t, o);
487
693
  const state = codexState(t, files, rules);
488
694
  const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
489
- return { state: shadowed ? "shadowed" : state, path: files.target.path, who: files.shadowers };
695
+ return { state: shadowed ? "shadowed" : state, path: (problemFile(files, state) ?? files.target).path, who: files.shadowers };
490
696
  }
491
697
 
492
698
  /** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
@@ -507,7 +713,7 @@ export function stateLines(t, o, rules) {
507
713
  */
508
714
  export function present(t, runtime, o) {
509
715
  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)));
716
+ return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
511
717
  }
512
718
 
513
719
  const knownRuntime = (runtime) => {
@@ -27,7 +27,9 @@ import {
27
27
  KEEP_OPEN,
28
28
  RUNTIMES,
29
29
  blockSpansOf,
30
+ count,
30
31
  hasMarker,
32
+ lf,
31
33
  stateLines,
32
34
  toggleSpec,
33
35
  turnOff as toggleOff,
@@ -50,9 +52,6 @@ const DEFAULTS = {
50
52
  skillFile: fileURLToPath(new URL("../SKILL.md", import.meta.url)),
51
53
  };
52
54
 
53
- const count = (text, needle) => text.split(needle).length - 1;
54
- const lf = (text) => text.replace(/\r\n/g, "\n");
55
-
56
55
  /** The rules: the text between the markers in SKILL.md, without the line breaks around it. */
57
56
  export function rulesOf(skillText) {
58
57
  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
  ## 워크플로