@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
@@ -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) => {
@@ -2,7 +2,7 @@
2
2
  name: lineage
3
3
  description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
4
4
  invocation: /lineage
5
- version: 3.0.3
5
+ version: 3.0.6
6
6
  schema_version: 1
7
7
  ---
8
8
 
@@ -17,6 +17,8 @@ Claude 본문은 **마크다운으로 렌더**(헤딩·리스트·표·코드·
17
17
  노이즈(스킬 본문·compaction 요약·조작 명령·에코 교환)는 **기본으로 걸러진다**.
18
18
  Secret 자동 redaction. 규칙 단계는 결정론(turn uuid·요약기버전 캐시)이고, 세션 모델의 검토 결정은 캐시해 다음 실행에 다시 쓴다.
19
19
 
20
+ > **3.0.6** — 글자 방향을 바꾸는 제어 문자 9자(U+202A~202E, U+2066~2069)는 `--no-markdown` 이어도 `[U+202E]` 처럼 보이는 표지로 넣는다. 화면의 글자 순서가 실제와 달라 보이는 속임수를 막기 위해서다. 방향 표시(LRM, RLM, ALM)는 그대로 둔다.
21
+
20
22
  > **3.0.0 — 기본 흐름이 세션 모델 검토로 바뀌었습니다.**
21
23
  > - 규칙이 1차로 정리한 턴을 세션 모델이 다시 검토한다. 요약 줄을 직접 쓰고, 남길 턴과 뺄 턴을 고른다.
22
24
  > - 2.x 의 한 번 실행(규칙만, 모델 호출 없음)은 `--rulebase` 다.
@@ -214,6 +216,7 @@ Schema-tolerant 파서: 미지 record type → stderr WARN + 다음 line. `v1`/`
214
216
 
215
217
  - 캐시 hit(`~/.cache/lineage/<schema>/<sid>/<uuid>-<digest>.md`, 3.0.2 까지 쓴 `.txt` 도 읽음) → 그대로. digest 는 본문+요약기버전
216
218
  해시라 **요약기를 고치면 자동 무효화**된다.
219
+ - 캐시는 64KiB 이하의 일반 파일만 읽고 링크는 따르지 않는다. 그 자리의 FIFO, 링크, 큰 파일, 깊게 중첩된 결정 캐시는 읽지 못한 것으로 보고 다시 요약한다(3.0.6).
217
220
  - miss → head+tail 추출(도입이 순수 의도문이면 결론절을 끝에서 읽어 승격) + 0600 캐시 쓰기.
218
221
  - 요약 저장은 항상 **redact 적용 후**(평문 secret 이 디스크에 남지 않음).
219
222