@kaydash9999/banker-plugins 0.19.0 → 0.19.2

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.
@@ -47,7 +47,6 @@ import {
47
47
  readFileSync,
48
48
  readSync,
49
49
  realpathSync,
50
- statSync,
51
50
  } from "node:fs";
52
51
  import { homedir } from "node:os";
53
52
  import { dirname, join } from "node:path";
@@ -61,6 +60,7 @@ import {
61
60
  done,
62
61
  hasMarker,
63
62
  lf,
63
+ LINK_REPLACED,
64
64
  noBom,
65
65
  present,
66
66
  refuse,
@@ -204,9 +204,9 @@ export function readConfig(file) {
204
204
  return checkConfig({ deny: raw.deny, alt: raw.alt, tsvDelimiter: raw.tsvDelimiter ?? "tab" });
205
205
  }
206
206
 
207
- // What sits at the settings path: "none", "file" (a regular file, or a link to
208
- // one), or "other" (a folder, a FIFO). A link that points nowhere or loops is
209
- // "none": the save replaces the link.
207
+ // What sits at the settings path: "none", "file" (a regular file with this one name), "link" (a link, wherever it
208
+ // leads), "hardlink" (a regular file with another name too) or "other" (a folder, a FIFO). The save replaces a link
209
+ // or a hard link by this name only: what it led to stays, so nothing of it is read or kept.
210
210
  function pathKind(file) {
211
211
  let st;
212
212
  try {
@@ -215,31 +215,64 @@ function pathKind(file) {
215
215
  if (e.code === "ENOENT" || e.code === "ENOTDIR") return "none";
216
216
  throw e;
217
217
  }
218
- if (st.isFile()) return "file";
219
- if (!st.isSymbolicLink()) return "other";
218
+ if (st.isSymbolicLink()) return "link";
219
+ if (!st.isFile()) return "other";
220
+ return st.nlink > 1 ? "hardlink" : "file";
221
+ }
222
+
223
+ // Opened without blocking. O_NOFOLLOW, where the system has it, keeps the open from following a link put at the name
224
+ // after the first look; Windows has none, so there the name is looked at again after the open.
225
+ const PRIOR_OPEN = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
226
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
227
+
228
+ /**
229
+ * What the settings name holds after the open, against the opened file, from their bigint stats: "same", "link" (a
230
+ * link now: the open followed it), "hardlink" (the opened file has another name now) or "changed" (gone, not a regular
231
+ * file, or another file: the file IDs differ; an ID of 0, which some file systems give, proves nothing).
232
+ */
233
+ export function nameNow(opened, now) {
234
+ if (now?.isSymbolicLink()) return "link";
235
+ if (!now?.isFile()) return "changed";
236
+ if (opened.ino !== 0n && (now.ino !== opened.ino || now.dev !== opened.dev)) return "changed";
237
+ return now.nlink > 1n ? "hardlink" : "same";
238
+ }
239
+
240
+ // The name's own stats in bigint, or null when it cannot be looked at.
241
+ function lstatOrNull(file) {
220
242
  try {
221
- return statSync(file).isFile() ? "file" : "none";
243
+ return lstatSync(file, { bigint: true });
222
244
  } catch {
223
- return "none";
245
+ return null;
224
246
  }
225
247
  }
226
248
 
227
- // The bytes of the file at the settings path, opened without blocking: { bytes }
228
- // (null when it turned out not to be a regular file) or { refusal }.
229
- export function priorBytes(file) {
249
+ // What the file opened at the settings path gives, as priorBytes says. Without O_NOFOLLOW the name must still hold it.
250
+ function openedBytes(file, fd, noFollow) {
251
+ const st = fstatSync(fd);
252
+ if (!st.isFile() || st.nlink > 1) return { bytes: null };
253
+ const now = noFollow ? "same" : nameNow(fstatSync(fd, { bigint: true }), lstatOrNull(file));
254
+ if (now === "changed") return { refusal: "확인하는 사이에 바뀐 자리라 설정을 저장하지 않았습니다" };
255
+ if (now !== "same") return { bytes: null };
256
+ if (st.size > MAX_BYTES) return { refusal: "64KiB 보다 큰 파일이라 설정을 저장하지 않았습니다" };
257
+ return { bytes: readFileSync(fd) };
258
+ }
259
+
260
+ /**
261
+ * The bytes of the regular file with this one name at the settings path: { bytes } (null when it turned out to be
262
+ * something else: gone, a link, not a regular file, or a file with another name too, which the save loses nothing
263
+ * of) or { refusal }. noFollow is O_NOFOLLOW where the system has it; 0 takes the Windows way.
264
+ */
265
+ export function priorBytes(file, noFollow = NO_FOLLOW) {
230
266
  let fd;
231
267
  try {
232
- fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
268
+ fd = openSync(file, PRIOR_OPEN | noFollow);
233
269
  } catch (e) {
234
- if (e.code === "ENOENT" || e.code === "ENOTDIR") return { bytes: null };
270
+ if (e.code === "ENOENT" || e.code === "ENOTDIR" || e.code === "ELOOP") return { bytes: null };
235
271
  if (denied(e)) return { refusal: "읽을 수 없는 파일이라 설정을 저장하지 않았습니다" };
236
272
  throw e;
237
273
  }
238
274
  try {
239
- const st = fstatSync(fd);
240
- if (!st.isFile()) return { bytes: null };
241
- if (st.size > MAX_BYTES) return { refusal: "64KiB 보다 큰 파일이라 설정을 저장하지 않았습니다" };
242
- return { bytes: readFileSync(fd) };
275
+ return openedBytes(file, fd, noFollow);
243
276
  } finally {
244
277
  closeSync(fd);
245
278
  }
@@ -260,22 +293,27 @@ function ownSettings(bytes) {
260
293
  }
261
294
  }
262
295
 
263
- // Whether anything, a dangling link included, already sits at path.
264
- function taken(path) {
296
+ // Keeps a file anti-drm did not write at bak, once, and says what it did: "kept", "earlier" (a regular file is there
297
+ // already and stays) or "other" (something else is there: a link, a dangling one too, or a folder; nothing is written).
298
+ // The note comes from the same look as the write.
299
+ function keepOnce(bak, bytes) {
300
+ let st;
265
301
  try {
266
- lstatSync(path);
267
- return true;
268
- } catch {
269
- return false;
302
+ st = lstatSync(bak);
303
+ } catch (e) {
304
+ if (e.code !== "ENOENT") throw e;
305
+ writeFresh(bak, bytes, 0o600);
306
+ return "kept";
270
307
  }
308
+ return st.isFile() ? "earlier" : "other";
271
309
  }
272
310
 
273
- // What a save finds at the settings path: { keep } (the bytes to put in .bak,
274
- // null for none: nothing there, or anti-drm's own settings) or { refusal }.
311
+ // What a save finds at the settings path: { keep } (the bytes to put in .bak, null for none: nothing there, a link
312
+ // or a hard link, whose file stays, or anti-drm's own settings) or { refusal }.
275
313
  function priorSettings(file) {
276
314
  const kind = pathKind(file);
277
- if (kind === "none") return { keep: null };
278
315
  if (kind === "other") return { refusal: "일반 파일이 아니라 설정을 저장하지 않았습니다" };
316
+ if (kind !== "file") return { keep: null };
279
317
  const got = priorBytes(file);
280
318
  if (got.refusal) return got;
281
319
  return { keep: got.bytes === null || ownSettings(got.bytes) ? null : got.bytes };
@@ -285,12 +323,14 @@ function priorSettings(file) {
285
323
  * Saves the settings: the folder 0755, the file 0644 through a temp file and a
286
324
  * rename. keep, when given, is a file anti-drm did not write; its bytes go to
287
325
  * <file>.bak, readable by this account only, once: a .bak already there is the
288
- * first such file and stays.
326
+ * first such file and stays. Gives back what became of keep: null when none was
327
+ * given, else "kept", "earlier" or "other" (see keepOnce).
289
328
  */
290
329
  export function writeConfig(file, config, keep = null) {
291
330
  mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
292
- if (keep !== null && !taken(`${file}.bak`)) writeFresh(`${file}.bak`, keep, 0o600);
331
+ const kept = keep === null ? null : keepOnce(`${file}.bak`, keep);
293
332
  writeFresh(file, `${JSON.stringify(config, null, 2)}\n`, 0o644);
333
+ return kept;
294
334
  }
295
335
 
296
336
  // ---- rules ----
@@ -396,17 +436,38 @@ export function status(opts = {}) {
396
436
  return done(lines.join("\n"));
397
437
  }
398
438
 
399
- // Saves the settings, as a result to report.
439
+ // What a save does beyond writing the settings, as lines to report: a link or a hard link at the settings path becomes
440
+ // a regular file (what it led to stays, and nothing of it goes to .bak), and a file anti-drm did not write goes to
441
+ // .bak, or is not kept when an earlier one, or something that is not a regular file, is there already. kept is what
442
+ // writeConfig gave back.
443
+ const KEPT_NOTES = {
444
+ kept: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 ${bak} 에 남겼습니다.`,
445
+ earlier: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 남기지 않았습니다. 먼저 남긴 파일이 있습니다: ${bak}`,
446
+ other: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 남기지 않았습니다. 그 자리에 일반 파일이 아닌 것이 있습니다: ${bak}`,
447
+ };
448
+ const REPLACED_NOTES = {
449
+ link: LINK_REPLACED,
450
+ hardlink: "참고: 하드 링크였던 자리를 일반 파일로 바꿨습니다. 같은 파일의 다른 이름은 건드리지 않았습니다.",
451
+ };
452
+ function saveNotes(file, replaced, kept) {
453
+ const notes = REPLACED_NOTES[replaced] ? [REPLACED_NOTES[replaced]] : [];
454
+ if (kept) notes.push(KEPT_NOTES[kept](where(`${file}.bak`)));
455
+ return notes;
456
+ }
457
+
458
+ // Saves the settings, as a result to report. The look just before the write names what the rename replaces.
400
459
  function save(o, config) {
460
+ let notes;
401
461
  try {
402
462
  const prior = priorSettings(o.configFile);
403
463
  if (prior.refusal) return refuse(`${prior.refusal}: ${where(o.configFile)}`);
404
- writeConfig(o.configFile, config, prior.keep);
464
+ const replaced = pathKind(o.configFile);
465
+ notes = saveNotes(o.configFile, replaced, writeConfig(o.configFile, config, prior.keep));
405
466
  } catch (e) {
406
467
  if (denied(e)) return refuse(`쓸 권한이 없어 설정을 저장하지 못했습니다 (${e.code}): ${where(o.configFile)}`);
407
468
  throw e;
408
469
  }
409
- return done(`설정을 저장했습니다: ${where(o.configFile)}`);
470
+ return done([`설정을 저장했습니다: ${where(o.configFile)}`, ...notes].join("\n"));
410
471
  }
411
472
 
412
473
  // New rules in each runtime that holds the rules, but `skip`. A runtime that
@@ -42,7 +42,9 @@
42
42
  * the Codex folder), so both skills turned on at once keep both blocks; a lock
43
43
  * older than a minute is taken over.
44
44
  *
45
- * Results are { ok, message }; ok false means refused with nothing changed.
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 ("앞서 바꾼 파일").
46
48
  * Anything else that goes wrong throws.
47
49
  */
48
50
  import {
@@ -95,7 +97,7 @@ const isLink = (path) => {
95
97
 
96
98
  const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
97
99
 
98
- // What sits at a name that is not a regular file: a folder, a FIFO or a device.
100
+ // What sits at a name that is not a regular file: a folder, a FIFO, a socket, a device or a link loop.
99
101
  // It is never read; reading a FIFO waits for a writer.
100
102
  const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
101
103
  // What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
@@ -148,7 +150,7 @@ function readFile(path) {
148
150
 
149
151
  /** A result that did, or found already done, what was asked. */
150
152
  export const done = (message) => ({ ok: true, message });
151
- /** A result that refused and changed nothing. */
153
+ /** A result that refused and changed nothing, unless its message names a file changed before (applied). */
152
154
  export const refuse = (message) => ({ ok: false, message });
153
155
 
154
156
  /** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
@@ -226,6 +228,7 @@ function claudeOn(t, o, getRules) {
226
228
  const state = claudeState(t, readFile(file), rules);
227
229
  if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${shown}`);
228
230
  if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${shown}`);
231
+ const wasLink = isLink(file);
229
232
  try {
230
233
  mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
231
234
  writeFresh(file, claudeText(t, rules));
@@ -233,13 +236,13 @@ function claudeOn(t, o, getRules) {
233
236
  if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${shown}`);
234
237
  throw e;
235
238
  }
236
- return done(
237
- state === "stale"
238
- ? `Claude Code: ${t.updatedText}. ${shown}`
239
- : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`,
240
- );
239
+ const words = state === "stale" ? `Claude Code: ${t.updatedText}. ${shown}` : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`;
240
+ return done(wasLink ? `${words}\n${LINK_REPLACED}` : words);
241
241
  }
242
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
+
243
246
  function claudeOff(t, o) {
244
247
  const file = claudeFile(t, o);
245
248
  const state = claudeState(t, readFile(file));
@@ -435,10 +438,14 @@ function withBlock(t, text, rules) {
435
438
  }
436
439
 
437
440
  // 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".
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".
440
444
  function placeState(files) {
441
- if (hasBlock(files.other)) return files.other === files.all[0] ? "hidden" : "misplaced";
445
+ if (hasBlock(files.other)) {
446
+ if (files.other !== files.all[0]) return "misplaced";
447
+ return files.target.dangling ? "hiddenbroken" : "hidden";
448
+ }
442
449
  return files.target.dangling ? "dangling" : "off";
443
450
  }
444
451
 
@@ -477,12 +484,32 @@ function writeBlocked(changes) {
477
484
  // whatever the umask allows more. A file left empty is removed, unless it is a
478
485
  // link or has other names (a hard link), whose other names would keep the old
479
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.
480
489
  function apply(t, changes) {
481
490
  for (const { file, after } of changes) {
482
491
  if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
483
492
  if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
484
- else writeFileSync(file.path, after, { mode: 0o644 });
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}`);
485
511
  }
512
+ return null;
486
513
  }
487
514
 
488
515
  // Whether the file at path is also reachable under another name: a hard link.
@@ -523,6 +550,7 @@ function dropStale(path) {
523
550
  st = lstatSync(path);
524
551
  } catch (e) {
525
552
  if (e.code === "ENOENT") return true; /* gone in between */
553
+ if (e.code === "ESTALE") return false; /* a network share's stale handle: rest and look again, not spin */
526
554
  throw e;
527
555
  }
528
556
  if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
@@ -628,7 +656,8 @@ function codexOnHeld(t, o, getRules) {
628
656
  if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
629
657
  const why = writeBlocked(changes);
630
658
  if (why) return refuse(why);
631
- apply(t, changes);
659
+ const failed = applied(t, changes);
660
+ if (failed) return failed;
632
661
  const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
633
662
  return done(`Codex: ${words}. ${tail}${note}`);
634
663
  }
@@ -646,7 +675,8 @@ function codexOffHeld(t, o) {
646
675
  if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
647
676
  const why = writeBlocked(changes);
648
677
  if (why) return refuse(why);
649
- apply(t, changes);
678
+ const failed = applied(t, changes);
679
+ if (failed) return failed;
650
680
  const paths = changes.map((c) => where(c.file.path)).join(", ");
651
681
  const baks = changes.map((c) => where(`${c.file.path}.${t.slug}.bak`)).join(", ");
652
682
  return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${paths}\n지우기 전 파일: ${baks}`);
@@ -661,6 +691,7 @@ const STATE_TEXT = {
661
691
  foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
662
692
  misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
663
693
  hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
694
+ hiddenbroken: "켜짐, 단 AGENTS.override.md 에 블록만 있고 AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 (링크 확인 필요)",
664
695
  notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
665
696
  notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
666
697
  unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
@@ -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.5
5
+ version: 3.0.7
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