@kaydash9999/banker-plugins 0.20.2 → 0.21.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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +203 -1
  4. package/PRIVACY.md +34 -4
  5. package/README.md +46 -15
  6. package/bin/banker.js +228 -51
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +33 -16
  9. package/codex/manifest.json +14 -11
  10. package/codex/skills/graceful-pause/SKILL.md +30 -0
  11. package/codex/skills/progress/SKILL.md +42 -0
  12. package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
  13. package/codex/skills/progress/scripts/progress.mjs +295 -0
  14. package/codex/transform-matrix.md +19 -14
  15. package/commands/setup.md +23 -0
  16. package/hooks/obsidize-hook.mjs +116 -4
  17. package/hooks/progress-plan.mjs +1 -1
  18. package/hooks/progress.mjs +143 -38
  19. package/hooks/register.mjs +13 -3
  20. package/hooks/telemetry-count-codex.mjs +222 -0
  21. package/hooks/update-checkin.mjs +15 -7
  22. package/hooks/update-fetch.mjs +15 -9
  23. package/hooks/update-notify.mjs +30 -17
  24. package/package.json +1 -1
  25. package/skills/3d-intro-build/SKILL.md +11 -5
  26. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-build/references/curate.mjs +48 -14
  28. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  29. package/skills/3d-intro-build/references/prompts.md +4 -4
  30. package/skills/3d-intro-build/references/serve.mjs +6 -1
  31. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  32. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  33. package/skills/3d-intro-setup/SKILL.md +41 -10
  34. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  35. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  36. package/skills/anti-drm/SKILL.md +6 -3
  37. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  38. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  39. package/skills/append_wiki/SKILL.md +34 -8
  40. package/skills/audit-mock/SKILL.md +1 -1
  41. package/skills/audit-security/SKILL.md +64 -2
  42. package/skills/compact-wiki/SKILL.md +37 -6
  43. package/skills/deep-research/SKILL.md +11 -0
  44. package/skills/docs-setup/SKILL.md +37 -7
  45. package/skills/lfm/SKILL.md +148 -0
  46. package/skills/lineage/SKILL.md +25 -15
  47. package/skills/lineage/lineage.py +564 -301
  48. package/skills/motion-graphic-make/SKILL.md +1 -1
  49. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  50. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  51. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  52. package/skills/motion-graphic-setup/SKILL.md +58 -13
  53. package/skills/obsidizer/SKILL.md +30 -11
  54. package/skills/obsidizer/obsidize.mjs +50 -12
  55. package/skills/omc-patch/SKILL.md +86 -23
  56. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  57. package/skills/payload-mon/SKILL.md +6 -2
  58. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  59. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  60. package/skills/pdf-vision-extract/SKILL.md +11 -6
  61. package/skills/ralph-qa/SKILL.md +125 -151
  62. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  63. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  64. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  65. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  66. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  67. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  68. package/skills/rfp-author/SKILL.md +5 -3
  69. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  70. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  71. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  72. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  73. package/skills/setup-harness-factory/SKILL.md +17 -1
  74. package/skills/setup-insane-search/SKILL.md +16 -0
  75. package/skills/setup-java/SKILL.md +20 -1
  76. package/skills/setup-lsp/SKILL.md +56 -13
  77. package/skills/setup-mcp/SKILL.md +73 -4
  78. package/skills/setup-node/SKILL.md +31 -6
  79. package/skills/setup-omc/SKILL.md +24 -0
  80. package/skills/setup-omc-hud/SKILL.md +42 -9
  81. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  82. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  83. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  84. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  85. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  86. package/skills/setup-playwright/SKILL.md +45 -2
  87. package/skills/setup-pwsh/SKILL.md +19 -0
  88. package/skills/setup-python/SKILL.md +45 -7
  89. package/skills/setup-sandbox/SKILL.md +30 -5
  90. package/skills/setup-stitch/SKILL.md +22 -0
  91. package/skills/setup-tmux/SKILL.md +24 -4
  92. package/skills/smart-compact/SKILL.md +58 -12
  93. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  94. package/skills/tone-compact/SKILL.md +2 -1
  95. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  96. package/skills/ultra-chain/SKILL.md +12 -7
  97. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  98. package/skills/ultra-chain/references/serve.mjs +6 -1
  99. package/skills/ultra-interview/SKILL.md +14 -2
  100. package/skills/update-banker/SKILL.md +19 -3
  101. package/skills/vertical-pptx/SKILL.md +6 -3
  102. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -60,6 +60,7 @@ import {
60
60
  renameSync,
61
61
  rmSync,
62
62
  statSync,
63
+ unlinkSync,
63
64
  writeFileSync,
64
65
  } from "node:fs";
65
66
  import { dirname, join } from "node:path";
@@ -102,6 +103,10 @@ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
102
103
  const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
103
104
  // What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
104
105
  const DANGLING = Object.freeze({ bytes: null, text: null, dangling: true });
106
+ // An instructions file past this is no file of hand-written instructions: it is not read, so no .bak doubles it and a
107
+ // file past what Node reads at once (2 GiB) is a refusal, not a crash.
108
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
109
+ const TOO_BIG = Object.freeze({ bytes: null, text: null, tooBig: true });
105
110
  // What opening a name fails with when no regular file can be there: links that loop (ELOOP) or a socket (ENXIO,
106
111
  // EOPNOTSUPP). macOS answers a socket with errno 102, which Node 22 does not name ("Unknown system error -102"), so
107
112
  // the kind of what is there decides too.
@@ -136,7 +141,9 @@ function readFile(path) {
136
141
  return openFailure(path, e);
137
142
  }
138
143
  try {
139
- if (!fstatSync(fd).isFile()) return SPECIAL;
144
+ const st = fstatSync(fd);
145
+ if (!st.isFile()) return SPECIAL;
146
+ if (st.size > MAX_FILE_BYTES) return TOO_BIG;
140
147
  const bytes = readFileSync(fd);
141
148
  try {
142
149
  return { bytes, text: UTF8.decode(bytes) };
@@ -180,13 +187,25 @@ export function writeFresh(file, data, mode = 0o644) {
180
187
  rmSync(tmp, { force: true });
181
188
  try {
182
189
  writeFileSync(tmp, data, { flag: "wx", mode });
183
- renameSync(tmp, file);
190
+ renameOver(tmp, file);
184
191
  } catch (e) {
185
192
  rmSync(tmp, { force: true });
186
193
  throw e;
187
194
  }
188
195
  }
189
196
 
197
+ // Windows does not rename a file over a link to a folder: there the link goes first, and the folder it
198
+ // leads to stays.
199
+ function renameOver(tmp, file) {
200
+ try {
201
+ renameSync(tmp, file);
202
+ } catch (e) {
203
+ if (process.platform !== "win32" || !lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) throw e;
204
+ unlinkSync(file);
205
+ renameSync(tmp, file);
206
+ }
207
+ }
208
+
190
209
  /** Whether an error is about permissions or a read-only file system. */
191
210
  export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
192
211
 
@@ -198,8 +217,12 @@ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code
198
217
  * So do letters that print as nothing (default-ignorable ones such as U+3164),
199
218
  * the braille blank and spaces other than the ASCII one, which hide text as blanks.
200
219
  */
220
+ // Marks drawn over or around the letter before them (a stroke, a slash, a ring), which make a name look like another.
221
+ // U+0338 stays after a sign it makes another of in a decomposed name (= and U+0338 are ≠ in NFD, as macOS may keep it).
222
+ const OVERLAY = /[\p{Me}\u{0334}-\u{0337}\u{20d2}\u{20d3}\u{20d8}-\u{20da}\u{20e5}\u{20e6}\u{20ea}\u{20eb}]|(?<![<=>\u{2190}\u{2192}\u{2194}\u{21d0}\u{21d2}\u{21d4}\u{2203}\u{2208}\u{220b}\u{2223}\u{2225}\u{223c}\u{2243}\u{2245}\u{2248}\u{224d}\u{2261}\u{2264}\u{2265}\u{2272}\u{2273}\u{2276}\u{2277}\u{227a}-\u{227d}\u{2282}\u{2283}\u{2286}\u{2287}\u{2291}\u{2292}\u{22a2}\u{22a8}\u{22a9}\u{22ab}\u{22b2}-\u{22b5}\u{2add}])\u{0338}/gu;
223
+
201
224
  export const where = (value) =>
202
- String(value).replace(
225
+ String(value).replace(OVERLAY, "?").replace(
203
226
  /[\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
227
  "?",
205
228
  );
@@ -375,6 +398,7 @@ const otherBlocks = (t, text) =>
375
398
  });
376
399
 
377
400
  const special = (f) => f.special === true;
401
+ const tooBig = (f) => f.tooBig === true;
378
402
  const binary = (f) => f.bytes !== null && f.text === null;
379
403
  const unpaired = (f) => f.spans === null;
380
404
  const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
@@ -404,8 +428,8 @@ function codexFiles(t, o) {
404
428
  const read = (name) => {
405
429
  const path = join(o.codexDir, name);
406
430
  const file = readFile(path);
407
- if (file === null || file.special || file.dangling) {
408
- return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true };
431
+ if (file === null || file.special || file.dangling || file.tooBig) {
432
+ return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true, tooBig: file?.tooBig === true };
409
433
  }
410
434
  return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
411
435
  };
@@ -452,10 +476,12 @@ function placeState(files) {
452
476
  // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
453
477
  // override, so not applied), "hidden" (in the override with no text of its own,
454
478
  // 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".
479
+ // nothing), "unpaired", "notutf8", "toobig" (a file past 32 MiB, left unread) or
480
+ // "notregular" (a folder, FIFO, socket or link loop at a file's name). Without
481
+ // rules any block in its place counts as "on".
457
482
  function codexState(t, files, rules) {
458
483
  if (files.all.some(special)) return "notregular";
484
+ if (files.all.some(tooBig)) return "toobig";
459
485
  if (files.all.some(binary)) return "notutf8";
460
486
  if (files.all.some(unpaired)) return "unpaired";
461
487
  if (!hasBlock(files.target)) return placeState(files);
@@ -495,23 +521,30 @@ function apply(t, changes) {
495
521
  }
496
522
 
497
523
  // 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.
524
+ // appeared in between (EEXIST), a folder where the write goes (EISDIR; Windows answers a rename onto one with EPERM),
525
+ // or a write a sandbox refused after access() allowed it. The refusal names the place the system refused (a rename's
526
+ // target, as the backup, or the file it was about), and a file changed before the failing one. Other errors propagate.
501
527
  function applied(t, changes) {
502
528
  let i = 0;
503
529
  try {
504
530
  for (; i < changes.length; i++) apply(t, [changes[i]]);
505
531
  } catch (e) {
506
- if (e.code !== "EEXIST" && !denied(e)) throw e;
507
- const why = e.code === "EEXIST" ? "쓰는 사이에 같은 이름이 생겨 그대로 두었습니다" : `쓸 권한이 없어 그대로 두었습니다 (${where(e.code)})`;
532
+ if (e.code !== "EEXIST" && e.code !== "EISDIR" && !denied(e)) throw e;
508
533
  const at = [e.dest, e.path].find((p) => typeof p === "string") ?? changes[i].file.path;
509
534
  const before = i === 0 ? "" : `\n앞서 바꾼 파일: ${changes.slice(0, i).map((c) => where(c.file.path)).join(", ")}`;
510
- return refuse(`${why}: ${where(at)}${before}`);
535
+ return refuse(`${refusedWhy(e, at)}: ${where(at)}${before}`);
511
536
  }
512
537
  return null;
513
538
  }
514
539
 
540
+ // Why a write applied() refuses failed: a name that appeared in between, something that is no regular file at the
541
+ // place it went (a folder), or permissions.
542
+ function refusedWhy(e, at) {
543
+ if (e.code === "EEXIST") return "쓰는 사이에 같은 이름이 생겨 그대로 두었습니다";
544
+ if (notAFile(at)) return "일반 파일이 아니라 그대로 두었습니다";
545
+ return `쓸 권한이 없어 그대로 두었습니다 (${where(e.code)})`;
546
+ }
547
+
515
548
  // Whether the file at path is also reachable under another name: a hard link.
516
549
  function hasOtherNames(path) {
517
550
  try {
@@ -530,16 +563,63 @@ const LOCK_STEP_MS = 50;
530
563
  const SLEEP = new Int32Array(new SharedArrayBuffer(4));
531
564
 
532
565
  // Makes the lock file: "taken", "held" when one is there already, or "stale" when a network share answered with a
533
- // stale file handle (ESTALE), which the next try may not. Anything else (permissions) throws.
566
+ // stale file handle (ESTALE), which the next try may not. A lock file this try made but could not write is taken
567
+ // away again while the name still holds that very file (one device and one inode), so it keeps no one waiting; "left"
568
+ // when it may still be there (no stats or no inode number to prove it is that file, or the share could not say).
569
+ // Any other error (permissions, EIO) throws, after the same taking away.
534
570
  function tryLock(path, token) {
571
+ let fd;
535
572
  try {
536
- writeFileSync(path, token, { flag: "wx", mode: 0o600 });
537
- return "taken";
573
+ fd = openSync(path, "wx", 0o600);
538
574
  } catch (e) {
539
575
  if (e.code === "EEXIST") return "held";
540
576
  if (e.code === "ESTALE") return "stale";
541
577
  throw e;
542
578
  }
579
+ const failed = writeToken(fd, token);
580
+ if (failed === null) return "taken";
581
+ const gone = dropMade(path, failed.made, token);
582
+ if (failed.error.code !== "ESTALE") throw failed.error;
583
+ return gone ? "stale" : "left";
584
+ }
585
+
586
+ // Writes the token into the lock file just made and closes it: null when both went well, else the first error and
587
+ // the file's bigint stats (null when they could not be had). A share often reports a failed write at the close.
588
+ function writeToken(fd, token) {
589
+ let made = null;
590
+ let error = null;
591
+ try {
592
+ made = fstatSync(fd, { bigint: true });
593
+ writeFileSync(fd, token);
594
+ } catch (e) {
595
+ error = e;
596
+ }
597
+ try {
598
+ closeSync(fd);
599
+ } catch (e) {
600
+ error ??= e;
601
+ }
602
+ return error === null ? null : { made, error };
603
+ }
604
+
605
+ // Removes the lock file a failed try made, only while the name still holds it (one device and one inode): true when
606
+ // that file is not there any more, false when it may be (the share could not say, or gives no inode number; such a
607
+ // lock goes stale in a minute).
608
+ function dropMade(path, made, token) {
609
+ if (made === null || made.ino === 0n) return false;
610
+ try {
611
+ if (sameInode(made, lstatSync(path, { bigint: true })) && heldWords(path, token)) rmSync(path, { force: true });
612
+ return true;
613
+ } catch (e) {
614
+ return e.code === "ENOENT";
615
+ }
616
+ }
617
+
618
+ // Whether the lock file holds no words but a start of this run's token (none, or a write cut short): a lock another
619
+ // run made at the name in between can get the very inode number, but it holds that run's own token, so it stays.
620
+ function heldWords(path, token) {
621
+ const text = readFile(path)?.text;
622
+ return typeof text === "string" && token.startsWith(text);
543
623
  }
544
624
 
545
625
  // Removes a lock a dead run left. The name itself is looked at, not where it leads: a link to nothing or a link loop
@@ -563,16 +643,19 @@ function dropStale(path) {
563
643
  }
564
644
 
565
645
  // Takes the lock: { path, token }, or { path, why } once the wait ends without it: why is "stale" when the last try
566
- // met a stale file handle instead of a lock, else "held". Every round that does not take the lock rests, so no answer
646
+ // met a stale file handle instead of a lock, or a lock this run made could not be taken away again, else "held". Every round that does not take the lock rests, so no answer
567
647
  // of the file system makes the wait spin; the deadline is looked at every round, so no lock spot keeps it going past.
568
648
  function takeLock(dir, waitMs) {
569
649
  const path = join(dir, LOCK);
570
650
  const token = `${process.pid} ${Date.now()} ${Math.random()}`;
571
651
  const until = Date.now() + waitMs;
652
+ // a made lock this run could not take away again names ESTALE as the cause, not another banker run
653
+ let left = false;
572
654
  for (;;) {
573
655
  const got = tryLock(path, token);
574
656
  if (got === "taken") return { path, token };
575
- if (Date.now() >= until) return { path, why: got };
657
+ left ||= got === "left";
658
+ if (Date.now() >= until) return { path, why: left ? "stale" : got };
576
659
  if (got === "held") dropStale(path);
577
660
  Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
578
661
  }
@@ -628,7 +711,7 @@ const shadowNote = (files) =>
628
711
  // The file a refused state is about: the folder or FIFO, the file that is not UTF-8, or the one whose markers do
629
712
  // not pair up; null in any other state.
630
713
  function problemFile(files, state) {
631
- const which = { notregular: special, notutf8: binary, unpaired }[state];
714
+ const which = { notregular: special, toobig: tooBig, notutf8: binary, unpaired }[state];
632
715
  return which ? files.all.find(which) : null;
633
716
  }
634
717
 
@@ -638,6 +721,7 @@ function codexRefusal(t, files, state) {
638
721
  if (!file) return null;
639
722
  const why = {
640
723
  notregular: "일반 파일이 아니라 그대로 두었습니다",
724
+ toobig: "32MiB 보다 큰 파일이라 그대로 두었습니다",
641
725
  notutf8: "UTF-8 이 아닌 파일이라 그대로 두었습니다",
642
726
  unpaired: `${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다`,
643
727
  }[state];
@@ -710,6 +794,7 @@ const STATE_TEXT = {
710
794
  hiddenbroken: "켜짐, 단 AGENTS.override.md 에 블록만 있고 AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 (링크 확인 필요)",
711
795
  notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
712
796
  notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
797
+ toobig: "32MiB 보다 큰 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
713
798
  unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
714
799
  dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
715
800
  };
@@ -760,7 +845,7 @@ export function stateLines(t, o, rules) {
760
845
  */
761
846
  export function present(t, runtime, o) {
762
847
  if (runtime === "claude") return isDir(o.claudeDir) && claudeState(t, readFile(claudeFile(t, o))) === "ours";
763
- return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
848
+ return isDir(o.codexDir) && !["off", "notutf8", "notregular", "toobig", "dangling"].includes(codexState(t, codexFiles(t, o)));
764
849
  }
765
850
 
766
851
  const knownRuntime = (runtime) => {
@@ -53,6 +53,7 @@ ultra-chain 이 직접 하는 일은 여섯 가지다.
53
53
  | 항목 | Claude Code (OMC) | Codex (OMX) |
54
54
  |---|---|---|
55
55
  | 1 자료 조사 | `Skill("banker:deep-research")` | `$banker-deep-research` |
56
+ | 1 전문가 작업 방식 | `Skill("banker:lfm")` | `$banker-lfm` |
56
57
  | 2 모호성 제거 | `Skill("banker:ultra-interview")` | `$banker-ultra-interview` |
57
58
  | 3 합의 계획 | `Skill("oh-my-claudecode:ralplan")` `--deliberate`, 이어서 `Skill("banker:ralph-qa")` | `$ralplan --deliberate`, 이어서 `$banker-ralph-qa` |
58
59
  | 5 구현 | `Skill("oh-my-claudecode:ralph")` `--critic=critic` | `$ralph` 뒤 이 스킬의 critic 루프(OMX `ralph` 에는 `--critic` 이 없다) |
@@ -64,7 +65,7 @@ ultra-chain 이 직접 하는 일은 여섯 가지다.
64
65
  | 이어 하기 메모 | OMC 작업 노트에 덧붙이기(`notepad_write_working`). 우선 노트는 통째로 바뀌므로 쓰지 않음 | `.omx/notepad.md` 에 한 줄 덧붙이기 |
65
66
  | 실행 기록 기본 폴더 | `.omc/ultra-chain/` | `.omx/ultra-chain/` |
66
67
 
67
- - Codex 에는 Skill 도구가 없다. 위 표의 `$이름` 은 그 스킬의 `SKILL.md` 를 읽고 그대로 따른다는 뜻이다. banker 스킬은 `<S>/../banker-<이름>/SKILL.md`, OMX 스킬은 `~/.codex/skills/<이름>/SKILL.md` 다. 그 스킬이 상태 기록을 요구하면 그 지시대로 직접 기록한다. 큰 `SKILL.md`(ralph-qa 는 약 78KB)는 셸 출력 한도에서 잘리니 나눠 읽는다.
68
+ - Codex 에는 Skill 도구가 없다. 위 표의 `$이름` 은 그 스킬의 `SKILL.md` 를 읽고 그대로 따른다는 뜻이다. banker 스킬은 `<S>/../banker-<이름>/SKILL.md`, OMX 스킬은 `~/.codex/skills/<이름>/SKILL.md` 다. 그 스킬이 상태 기록을 요구하면 그 지시대로 직접 기록한다. 큰 `SKILL.md`(ralph-qa 는 약 71KB)는 셸 출력 한도에서 잘리니 나눠 읽는다.
68
69
  - Codex 의 하위 에이전트(critic, 시각 판정)는 저자 대화를 넘기지 않는다. multi-agent v2 는 `fork_turns="none"` 을 주고, v1 은 `fork_context` 를 주지 않는다. 판정을 받으면 `close_agent` 로 닫는다.
69
70
  - Codex 환경 지시가 역할(`agent_type`)을 요구하면, 고정 모델이 세션 모델과 같은 역할을 쓰고 지시 앞머리에 파일 쓰기 금지를 적는다. 그런 역할이 없으면 역할 없이 띄운다. 쓴 역할과 모델, 강도를 판정 기록의 `note` 에 적는다.
70
71
  - Codex 의 오래 도는 명령은 기다리는 시간을 박스 `--timeout` 보다 길게 둔다. 기다리다 끊기면 박스 정리와 결과 JSON 이 남지 않는다.
@@ -84,7 +85,7 @@ ultra-chain 이 직접 하는 일은 여섯 가지다.
84
85
 
85
86
  - 모든 박스가 같은 권한 오류로 실패하면 접속 실패로 판정하지 않는다. 승인을 받아 다시 돌린 뒤 판정한다.
86
87
  - 스테이징을 못 하면 새 시험 파일은 박스로 가지 않는다. 그 시험은 박스에서 미검증으로 적는다.
87
- - 승인은 명령 하나씩 요청한다. 넓은 접두 규칙(`node`, `git`, `npm` 전체)은 요청하지 않는다. `&&` 로 묶은 명령도 나눠 요청하되, 하위 스킬이 실패하면 멈추는 장치로 쓰는 사슬(ralph-qa 의 외부 좌석 전송 2단계 등)은 나누지 않고 그대로 요청한다. 나눠 돌린 명령은 단계마다 종료 코드를 보고, 실패하면 멈춘다.
88
+ - 승인은 명령 하나씩 요청한다. 넓은 접두 규칙(`node`, `git`, `npm` 전체)은 요청하지 않는다. `&&` 로 묶은 명령도 나눠 요청하되, 하위 스킬이 실패하면 멈추는 장치로 쓰는 사슬은 나누지 않고 그대로 요청한다. ralph-qa 의 외부 좌석 전송은 실행기 명령 하나씩(`seat-run.mjs` 의 init, prepare, send 등)이라 사슬이 없다. 나눠 돌린 명령은 단계마다 종료 코드를 보고, 실패하면 멈춘다.
88
89
 
89
90
  ## 자리 표시
90
91
 
@@ -190,15 +191,18 @@ node "<S>/references/chain-ledger.mjs" event "<run>" --stage research --status d
190
191
 
191
192
  ## 1. 자료 조사
192
193
 
193
- - deep-research 를 부른다. 넘기는 질문은 미리 좁히고, 인자에 "추가 질문 금지" 를 적는다. 범위 질문은 2단계가 맡는다. 질문은 공개 웹 검색어가 되므로 저장소 코드, 내부 이름, 비밀값을 넣지 않는다.
194
+ - deep-research 를 부른다. 넘기는 질문은 미리 좁히고, 인자에 "추가 질문 금지" 와 "전문가 작업 방식은 lfm 이 맡음" 을 적는다. 범위 질문은 2단계가 맡는다. 질문은 공개 웹 검색어가 되므로 저장소 코드, 내부 이름, 비밀값을 넣지 않는다.
194
195
  - 저장소 사실(구조, 시험 명령과 시험 도구, 빌드 방법, 관련 코드 위치, CI)은 읽기 전용 탐색으로 모은다.
195
196
  - 둘을 `<run>/01-research.md` 하나로 저장한다. 조사 보고, 그다음 `## 저장소 사실` 절이다. 조사 스킬은 보고를 대화에만 내므로 연쇄가 파일로 남긴다. 수동 경로였다면 충실도 한계를 그대로 옮긴다.
196
- - 게이트: 살아남은 주장마다 출처가 있고, 폐기된 주장과 한계가 적혀 있다.
197
+ - 같은 단계에서 `lfm` 을 요청의 분야로 부른다. 인자는 `--no-questions --out <run>/01-masters.md` 다. 그 분야 최고 전문가 그룹이 이 일을 어떻게 하는지와 계획 적용 항목(`M-1` …)이 남는다. 검색어 규칙은 deep-research 와 같다(저장소 코드, 내부 이름, 비밀값을 넣지 않는다).
198
+ - `lfm` 이 "웹 조사 불가" 로 끝나면 `01-masters.md` 에 그 사실만 적고 진행한다. 3단계는 "전문가 작업 방식 없음" 으로 계획한다.
199
+ - 게이트: 살아남은 주장마다 출처가 있고, 폐기된 주장과 한계가 적혀 있다. 주장이 0개면 게이트를 지나지 않은 것이다.
200
+ - deep-research 가 "웹 조사 불가" 로 끝나면(네트워크 없음) 이 단계를 `skipped` 와 이유 "네트워크 없음" 으로 기록하고, 저장소 사실만 `01-research.md` 에 남긴 채 2단계로 간다. 사용자에게는 웹 조사를 하지 못했다고 알린다.
197
201
  - `--skip=research` 면 이 단계를 `skipped` 로 기록한다. 2단계의 ultra-interview 가 자기 조사를 한다.
198
202
 
199
203
  ## 2. 모호성 제거
200
204
 
201
- - ultra-interview 를 부른다. `01-research.md` 를 근거로 넘기고, 거기서 답하지 못한 항목만 더 조사하게 한다.
205
+ - ultra-interview 를 부른다. `01-research.md` 와 `01-masters.md` 를 근거로 넘기고, 거기서 답하지 못한 항목만 더 조사하게 한다. `01-masters.md` 가 있으면 `lfm` 을 다시 부르지 말라고 적는다.
202
206
  - `--auto` 면 인자에 "사람에게 묻지 않는다. P 항목은 보수적 가정으로 답하고 가정임을 표시한다" 를 넣는다. 이 지시는 ultra-interview 의 "가정을 사람이 훑기 전에는 판정하지 않는다" 규칙보다 앞선다. 사용자가 `--auto` 로 묻지 말라고 명시했기 때문이다. 그래서 이 결과는 통과가 아니라 "사람 확인 없음" 으로 기록한다. 보수적인 쪽이 없는 핵심 P(기능의 뜻 자체를 정하는 항목)가 남으면 멈추고 보고한다.
203
207
  - 결과를 `<run>/02-spec.md` 로 저장한다.
204
208
  - 번호 붙은 항목과 E, I, P 분류, 근거(URL, 파일 경로), 답, 가정
@@ -212,7 +216,8 @@ node "<S>/references/chain-ledger.mjs" event "<run>" --stage research --status d
212
216
  ralplan 은 계획 작성자 외에 Architect 와 Critic 두 검토자로 합의하고, 검토자를 늘리는 옵션이 없다. 그래서 ralplan 합의 뒤 ralph-qa 의 독립 검토자 3명 이상이 같은 계획을 다시 판정한다. Architect 와 Critic 은 이 3명에 세지 않는다.
213
217
 
214
218
  1. ralplan 을 `--deliberate` 로 부른다. `--interactive` 는 주지 않는다. 인자에 아래를 함께 넘긴다.
215
- - 요청 원문, `02-spec.md` 경로와 수용 기준, `01-research.md` 경로
219
+ - 요청 원문, `02-spec.md` 경로와 수용 기준, `01-research.md` 와 `01-masters.md` 경로
220
+ - 계획에 `## 전문가 작업 방식 적용` 절을 두라는 지시. `01-masters.md` 의 `M-n` 마다 판정(적용, 조정, 제외)과 계획의 어느 단계에 넣었는지, 조정과 제외는 그 이유를 적는다. 판정을 바꾸면(예: `lfm` 이 적용인데 계획은 제외) 이유를 적는다
216
221
  - 계획 저장 경로: Claude Code 는 `.omc/plans/ultra-chain-<run id>.md`. Codex 는 OMX 규칙대로 `.omx/plans/` 에 쓴다
217
222
  - Codex: 인터뷰가 끝났으니 `$deep-interview` 를 돌리지 말라는 말
218
223
  - 계획에 넣을 것
@@ -232,7 +237,7 @@ ralplan 은 계획 작성자 외에 Architect 와 Critic 두 검토자로 합의
232
237
  - Claude Code: 1 에서 지정한 경로, 또는 ralplan 이 보고한 계획 경로다. 둘 다 없으면 이 단계가 시작된 뒤 `.omc/plans/` 에 쓰였고 `pending approval` 표지가 있는 파일을 고른다. 검토 문서(이름이 `-critic.md`, `-architect.md`, `-review.md` 로 끝남)와 `open-questions.md` 는 고르지 않는다. 원본 경로는 `summary` 에 적는다.
233
238
  - Codex: 이번에 쓰인 `.omx/plans/prd-*.md` 와 `test-spec-*.md` 를 차례로 붙이고, 원본 경로를 맨 위에 적는다.
234
239
  4. ralph-qa 를 `--lens=plan --agents=<agents> --max=<max>` 로 부른다. 외부 좌석 플래그(`--codex`, `--gemini`, `--opencode`)는 사용자가 준 것만 넘긴다.
235
- - 검증 대상: `03-plan.md`(구현 전이라 `<base>` 대비 diff 가 없으니 원문으로 붙인다). 수용 기준: `02-spec.md` 의 AC 와 deliberate 필수 항목(사전 부검, 확장 시험 계획).
240
+ - 검증 대상: `03-plan.md`(구현 전이라 `<base>` 대비 diff 가 없으니 원문으로 붙인다). 수용 기준: `02-spec.md` 의 AC 와 deliberate 필수 항목(사전 부검, 확장 시험 계획), 그리고 `01-masters.md` 의 `M-n` 이 모두 `## 전문가 작업 방식 적용` 절에 판정과 함께 있는지(조정과 제외에 이유가 있는지).
236
241
  - 모든 좌석의 지시에 "저자의 주장이 틀렸다는 증거를 먼저 찾는 적대적 검토자로 판정한다" 를 넣는다. 렌즈 배정은 ralph-qa 그대로다. ralph-qa 의 기본 배정에서는 적대적 반증 렌즈가 한 좌석뿐이라, 이 문장이 있어야 검토자 모두가 적대적이다.
237
242
  - 출처-독립 검토자에게 줄 "사용자 원 요청" 은 `task.md` 와 `02-spec.md` 의 사용자 답변 원문이다.
238
243
  - ralph-qa 가 외부 모델을 물으면 여기서 한 번만 고르게 하고, 고른 모델을 기록한다. 6단계에는 `--gemini=<그 모델>` 처럼 값을 박아 넘긴다.
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * Runtime: Node >=18 builtins only (node:fs / node:http / node:net / node:path). No shell.
18
18
  */
19
+ import crypto from 'node:crypto';
19
20
  import fs from 'node:fs';
20
21
  import http from 'node:http';
21
22
  import net from 'node:net';
23
+ import os from 'node:os';
22
24
  import path from 'node:path';
23
- import { pipeline } from 'node:stream';
25
+ import { Transform, pipeline } from 'node:stream';
24
26
 
25
27
  export const LOOPBACK = '127.0.0.1';
26
28
  // Answers { pid } so a stop can tell this server from another process on the same port.
@@ -80,39 +82,57 @@ export function send(res, status, body, headers = {}) {
80
82
  export const READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0);
81
83
 
82
84
  // An open regular file: { fd, size }, or { status } when there is none (404) or it cannot be read (403).
83
- function openRegular(fp) {
85
+ // With `expect` ({ dev, ino } of the file a check passed, as BigInt), another file at the path is none:
86
+ // the path changed between that check and this open. BigInt, since an NTFS file id is 64 bits and a
87
+ // Number keeps only 53: two files could read as one.
88
+ function openRegular(fp, expect) {
84
89
  let fd;
85
90
  try { fd = fs.openSync(fp, READ_FLAGS); } catch (e) { return { status: ['ENOENT', 'ENOTDIR', 'EISDIR'].includes(e.code) ? 404 : 403 }; }
86
91
  let st = null;
87
- try { st = fs.fstatSync(fd); } catch { /* a status the system cannot give (EIO on a network drive) */ }
88
- if (st && st.isFile()) return { fd, size: st.size };
92
+ try { st = fs.fstatSync(fd, { bigint: true }); } catch { /* a status the system cannot give (EIO on a network drive) */ }
93
+ if (st && st.isFile() && sameFile(st, expect)) return { fd, size: Number(st.size) };
89
94
  fs.closeSync(fd);
90
95
  return { status: st ? 404 : 403 };
91
96
  }
92
97
 
98
+ const sameFile = (st, expect) => !expect || (st.dev === expect.dev && st.ino === expect.ino);
99
+
93
100
  // Sends the bytes `start`..`end` of an open file (what the headers declared, even if the file grows
94
101
  // meanwhile) and closes it, also when the browser drops the download midway (a pipe would keep the
95
102
  // file open). Nothing to send closes the file and ends the response.
96
- function stream(res, fp, { fd, start, end }) {
97
- if (end < start) {
103
+ // A HEAD request gets the headers only: the file is closed unread.
104
+ function stream(res, fp, { fd, start, end, head }) {
105
+ if (head || end < start) {
98
106
  fs.closeSync(fd);
99
107
  return void res.end();
100
108
  }
101
- pipeline(fs.createReadStream(fp, { fd, start, end }), res, () => {});
109
+ pipeline(fs.createReadStream(fp, { fd, start, end }), declaredLength(end - start + 1), res, () => {});
110
+ }
111
+
112
+ // Passes the bytes on and fails at the end when they were fewer than the headers declared (the file
113
+ // shrank since): the pipeline then destroys the response, which closes the connection. Ended as
114
+ // usual, it would leave the browser waiting on a kept-alive connection for bytes that never come.
115
+ function declaredLength(want) {
116
+ let sent = 0;
117
+ return new Transform({
118
+ transform(chunk, _enc, done) { sent += chunk.length; done(null, chunk); },
119
+ flush(done) { done(sent < want ? new Error('the file is shorter than its declared length') : null); },
120
+ });
102
121
  }
103
122
 
104
123
  // A file, with byte ranges for video (smoother scrubbing). Never cached: a rebuilt page is fresh.
105
124
  // The file is opened before any header goes out, so one that cannot be read gets 403 instead of
106
- // ending the server.
107
- export function sendFile(req, res, fp, headers = {}) {
108
- const f = openRegular(fp);
125
+ // ending the server. `expect` ({ dev, ino }) names the only file that may go out (404 otherwise).
126
+ export function sendFile(req, res, fp, headers = {}, { expect } = {}) {
127
+ const f = openRegular(fp, expect);
109
128
  if (f.status) return send(res, f.status, String(f.status), headers);
129
+ f.head = req.method === 'HEAD';
110
130
  const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
111
131
  const base = { ...headers, 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
112
132
  const range = type.startsWith('video/') ? /bytes=(\d+)-(\d*)/.exec(req.headers.range || '') : null;
113
133
  if (range) return sendRange(res, fp, f, range, base, headers);
114
134
  res.writeHead(200, { ...base, 'Content-Length': f.size });
115
- stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1 });
135
+ stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1, head: f.head });
116
136
  }
117
137
 
118
138
  function sendRange(res, fp, f, range, base, headers) {
@@ -124,7 +144,7 @@ function sendRange(res, fp, f, range, base, headers) {
124
144
  return void res.end();
125
145
  }
126
146
  res.writeHead(206, { ...base, 'Content-Range': `bytes ${start}-${end}/${f.size}`, 'Content-Length': end - start + 1 });
127
- stream(res, fp, { fd: f.fd, start, end });
147
+ stream(res, fp, { fd: f.fd, start, end, head: f.head });
128
148
  }
129
149
 
130
150
  // True when the request was the PID question, which this answers.
@@ -169,8 +189,12 @@ export const portMatches = (st, port) => !port || st.port === port || st.request
169
189
  // The state file of a server for `dir`: beside the folder, so a deployed folder never carries it.
170
190
  export const siblingState = (dir) => path.join(path.dirname(dir), `${path.basename(dir)}.server.json`);
171
191
 
192
+ // Through a fresh random name opened exclusively, then renamed over the old file: a link at the state
193
+ // file's name (one a repository can bring) is replaced, never written through.
172
194
  export function writeState(file, info) {
173
- fs.writeFileSync(file, JSON.stringify(info, null, 2) + '\n');
195
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
196
+ fs.writeFileSync(tmp, JSON.stringify(info, null, 2) + '\n', { flag: 'wx' });
197
+ try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
174
198
  }
175
199
 
176
200
  export function readState(file) {
@@ -220,10 +244,22 @@ async function waitClosed(port, timeoutMs, host) {
220
244
  // A PID a state file may name: a whole number above 1 (never a process group, never init).
221
245
  const validPid = (pid) => Number.isInteger(pid) && pid > 1;
222
246
 
247
+ // A host a state file may name: this machine (loopback, a wildcard, an address of its own). A state file
248
+ // can come with a repository: its host must not send a request elsewhere, nor pick the PID a signal goes to.
249
+ // A record a stop or a start may act on: a PID that is a process id, on this machine.
250
+ const recordedHere = (st) => validPid(st.pid) && thisMachine(st.host);
251
+
252
+ // No host is the default loopback one. A loopback address counts only as an IPv4 address of 127/8: `127.evil.test`
253
+ // or `127.256.0.1` is a DNS name, resolved anywhere (a resolver's search domain may answer it).
254
+ export function thisMachine(host) {
255
+ if (!host || ['0.0.0.0', '::', '::1', 'localhost'].includes(host) || (net.isIPv4(String(host)) && String(host).startsWith('127.'))) return true;
256
+ return Object.values(os.networkInterfaces()).flat().some((a) => a && a.address === host);
257
+ }
258
+
223
259
  /** True when the server a state file records still answers on its port with its PID. */
224
260
  export async function isRunning(stateFile) {
225
261
  const st = readState(stateFile);
226
- return Boolean(st && st.port) && validPid(st.pid) && (await askPid(st.port, 1500, st.host)) === st.pid;
262
+ return Boolean(st && st.port) && recordedHere(st) && (await askPid(st.port, 1500, st.host)) === st.pid;
227
263
  }
228
264
 
229
265
  function signal(pid, sig) {
@@ -236,7 +272,7 @@ function signal(pid, sig) {
236
272
  export async function stopServer(stateFile, { timeoutMs = 3000 } = {}) {
237
273
  const st = readState(stateFile);
238
274
  if (!st || !st.port) return { stopped: false, reason: 'no-state' };
239
- if (!validPid(st.pid)) return { stopped: false, reason: 'invalid-state' };
275
+ if (!recordedHere(st)) return { stopped: false, reason: 'invalid-state' };
240
276
  const who = await askPid(st.port, Math.min(timeoutMs, 1500), st.host);
241
277
  if (who === NONE) {
242
278
  dropState(stateFile);
@@ -95,6 +95,9 @@ async function stopMain(o, dir) {
95
95
  const sameSettings = (st, o, dir) => path.resolve(String(st.dir || '')) === dir && (st.csp || null) === cspFrom(o.csp)
96
96
  && (st.host || LOOPBACK) === (process.env.PREVIEW_HOST || LOOPBACK) && portMatches(st, o.port);
97
97
 
98
+ // A value in one quoted piece, with "?" for what a reader takes for a line end or what changes how text shows.
99
+ const oneLine = (v) => JSON.stringify(String(v)).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
100
+
98
101
  // A server already running for this state file: the same settings only report its address; other
99
102
  // settings (folder, CSP, host, port) never pass for it, so a check meant to run under a CSP cannot
100
103
  // run without one, and a page meant for one folder or port is not checked on another.
@@ -105,7 +108,9 @@ async function reportRunning(recorded, o, dir) {
105
108
  console.log(`PREVIEW ${st.url} (already running, pid ${st.pid})`);
106
109
  return true;
107
110
  }
108
- console.error(`serve: the state file records a server running with different settings (folder: ${st.dir}, `
111
+ // the folder in one quoted piece, and as "?" what a reader takes for a line end or what changes how text shows (a
112
+ // line or paragraph separator, NEL, a bidi override, a tag character): none of it adds a line to what an agent reads
113
+ console.error(`serve: the state file records a server running with different settings (folder: ${oneLine(st.dir)}, `
109
114
  + `csp: ${st.csp ? 'on' : 'off'}, host: ${st.host}, port: ${st.port}); stop it with --stop and start again`);
110
115
  process.exit(1);
111
116
  }
@@ -99,6 +99,16 @@ description: "리서치를 선행해 사람에게 묻는 질문을 최소화하
99
99
  - **공식 문서** (해당 기술·제품의 1차 출처)
100
100
  - **우수사례 / best practice**
101
101
  - **요청에 걸리는 공개 규제 정보** (규정·법령·시행세칙·규칙·기록)
102
+ - **그 분야 최고 전문가 그룹의 작업 방식** (`lfm`)
103
+
104
+ **전문가 작업 방식 (`lfm`):**
105
+ - 요청의 분야로 `lfm` 을 부른다. 인자에 `--no-questions` 를 준다. 이 스킬처럼 질문을 늘리지 않기 위해서다.
106
+ - 결과의 계획 적용 항목(`M-1` …)을 이 인터뷰에 쓴다.
107
+ - "적용" 이나 "조정" 판정의 관행이 답하는 항목은 E 로 답한다. 근거는 `lfm` 결과의 1차 출처 URL 이다. 이 작업에 맞는지는 사람이 가정을 훑으며 확인한다.
108
+ - 품질 기준 후보는 수용 기준 후보로 가정에 넣는다.
109
+ - 관행이 이 작업과 맞는지 사람의 판단이 필요하면(비용, 팀 구성, 일정) P 로 둔다.
110
+ - 결과에 `## 전문가 작업 방식` 절을 두고, `M-n` 마다 판정과 이 인터뷰의 어느 항목에 반영했는지 적는다. 다음 단계의 계획이 이 절을 그대로 받는다.
111
+ - `lfm` 이 "웹 조사 불가" 로 끝나면 그 사실을 이 절에 적고 진행한다.
102
112
 
103
113
  **규제 정보 접근 규칙:**
104
114
  - **엔드포인트·조회 파라미터·API 키를 하드코딩하지 마라.** 관할·기관·시점마다 다르고 이 스킬은 그것을 검증한 적이 없다. 하드코딩된 경로는 조용히 죽거나, 더 나쁘게는 엉뚱한 문서를 준다.
@@ -108,7 +118,7 @@ description: "리서치를 선행해 사람에게 묻는 질문을 최소화하
108
118
  - **규제 해석은 이 스킬의 산출물이 아니다.** 조문을 근거로 항목을 잠글 수는 있어도, 법적 판단이 필요한 자리는 P 로 두고 사람에게 넘긴다.
109
119
 
110
120
  **`deep-research` 위임:**
111
- - 넘길 때 **"추가 질문 금지" 를 인자에 명시한다.**
121
+ - 넘길 때 **"추가 질문 금지" 를 인자에 명시한다.** `lfm` 을 따로 부르므로 "전문가 작업 방식은 lfm 이 맡음" 도 적는다.
112
122
  - 이유: 명시하지 않으면 사용자는 리서치 단계에서 한 번, 인터뷰 단계에서 또 한 번, 총 두 번 질문받는다. **질문을 줄이려고 부른 리서치가 질문을 늘린다.** 그러면 이 스킬은 존재 이유를 훼손당한다.
113
123
  - 넘기는 질문은 **이쪽에서 이미 좁혀서** 넘긴다. 되물을 여지를 안 남기는 것이 인자에 적는 금지보다 확실하다.
114
124
 
@@ -143,6 +153,8 @@ description: "리서치를 선행해 사람에게 묻는 질문을 최소화하
143
153
  E 후보에 리서치를 돌린다. **질문 전이다.**
144
154
  - 항목마다 근거 URL 을 붙인다. URL 없는 답은 채택하지 않는다.
145
155
  - 검색 수단이 런타임에 없으면 **없다고 보고**하고, E 후보를 전부 P 로 내린 뒤 그 사실을 명시한다.
156
+ - 수단이 있어도 호출이 모두 실패하면(네트워크 없음) 같다. "조사하지 못함: <오류>" 로 보고하고 E 후보를 전부 P 로 내린다.
157
+ - 같은 단계에서 `lfm` 으로 그 분야 전문가 그룹의 작업 방식을 조사한다(위 "전문가 작업 방식" 참조).
146
158
 
147
159
  ### 2. E/I/P 판정
148
160
  전 항목을 분류한다. 애매하면 P.
@@ -229,4 +241,4 @@ E 후보에 리서치를 돌린다. **질문 전이다.**
229
241
 
230
242
  ARGUMENTS: [--threshold <퍼센트>] [--no-research] [요청/맥락] (없으면 현재 작업의 요청에 적용)
231
243
  - `--threshold`: 기본 3%. 올리면 게이트가 무르다. 내리는 것은 항목 33개 이하 구간에서 무의미하다(이미 미해결 0개가 조건이다). 바꿨으면 **바꾼 값을 보고에 적는다.**
232
- - `--no-research`: 리서치를 끈다. E 축이 죽어 전 항목이 P 로 떨어지고 **질문이 늘어난다.** 이 스킬의 델타 하나가 사라지므로, 켜서 부를 이유가 없으면 쓰지 마라.
244
+ - `--no-research`: 리서치를 끈다(`lfm` 도 부르지 않는다). E 축이 죽어 전 항목이 P 로 떨어지고 **질문이 늘어난다.** 이 스킬의 델타 하나가 사라지므로, 켜서 부를 이유가 없으면 쓰지 마라.
@@ -70,10 +70,25 @@ banker 는 서로 다른 메커니즘의 채널 3개로 설치되고, 그 채널
70
70
  여기서 찍은 값이 4단계 검증의 before 다. 먼저 어떤 채널이 이 머신에 실재하는지 판정한다.
71
71
 
72
72
  ```bash
73
+ in15() { "$@" & p=$!; ( sleep 15 && kill "$p" ) > /dev/null 2>&1 & k=$!; wait "$p" 2>/dev/null; r=$?; kill "$k" 2>/dev/null; wait "$k" 2>/dev/null; [ "$r" = 143 ] && echo "15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)"; return "$r"; }
74
+ in15 sh -c 'cd ~ && exec npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier'; P=$? # 레지스트리 도달 확인(이름 풀이가 실패하면 1초 안에, 패킷을 버리는 망이면 15초 감시에서 멈춤)
73
75
  command -v claude >/dev/null && echo "claude: present" || echo "claude: absent (Claude 채널 스킵)"
74
76
  command -v codex >/dev/null && echo "codex: present" || echo "codex: absent (Codex 채널 스킵)"
75
77
  command -v banker >/dev/null && banker --version || echo "banker: absent"
76
- npm view @kaydash9999/banker-plugins version
78
+ [ "$P" = 0 ] && (cd ~ && npm view @kaydash9999/banker-plugins version --fetch-retries=0 --fetch-timeout=10000)
79
+ ```
80
+
81
+ 레지스트리에 닿지 못하면(`npm ping` 의 종료 코드가 0 이 아님) "레지스트리에 닿지 못함. 최신 판을 알 수 없어 어느 채널도 바꾸지 않음" 으로 보고하고, 어느 채널도 바꾸지 않고 멈춘다. `--check` 도 드리프트를 판정하지 않고 같은 보고로 끝낸다.
82
+
83
+ - `npm` 이 없으면(명령 없음, 종료 코드 127) 레지스트리 문제가 아니다. "npm 없음. 최신 판을 알 수 없어 어느 채널도 바꾸지 않음" 으로 보고하고 같은 이유로 멈춘다.
84
+ - `npm ping` 과 `npm view` 는 홈 폴더에서 부른다. 지금 폴더에 있는 프로젝트의 `.npmrc` 가 정한 레지스트리와 토큰을 따르지 않게 하려는 것이다. Windows PowerShell 에서는 `Push-Location ~` 뒤에 부르고 `Pop-Location` 으로 돌아온다.
85
+ - `npm ping` 은 15초 감시(`in15`) 안에서 부르고, `npm view` 는 ping 이 0 으로 끝났을 때만 부른다. 패킷을 버리는 망에서 npm 은 연결에 시간 제한을 두지 않아(`--fetch-timeout` 은 받는 중의 쉼만 잰다) OS 의 연결 재시도가 끝날 때까지 기다린다(Rocky 8.10 실측 130초). 15초 안에 끝나지 않으면 `15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)` 과 종료 143 이다. PowerShell 에는 감시를 두지 않는다(Windows 의 연결 시간 제한은 약 21초로 알려져 있다. 미실측).
86
+
87
+ - **`npm view` 는 이 확인을 대신하지 못한다.** npm 캐시에 옛 값이 있으면 레지스트리에 닿지 못해도 그 값을 종료 0 으로, 경고 없이 낸다(`--prefer-online`, `--offline` 도 같다). `--fetch-retries=0` 은 실패까지의 약 70초 대기만 없앤다.
88
+ - 사내 미러가 `npm ping` 에 답하지 않으면, 빈 임시 캐시로 한 번 더 묻고 그 값으로 판정한다. 캐시가 비어 있어 옛 값이 나올 수 없다. 이것도 실패하면 위처럼 멈춘다. ping 이 15초 감시에 걸려 멈췄으면(종료 143) 이 확인은 하지 않는다. 같은 레지스트리라 같은 시간을 또 기다린다.
89
+
90
+ ```bash
91
+ (cd ~ && T=$(mktemp -d) && { npm view @kaydash9999/banker-plugins version --fetch-retries=0 --fetch-timeout=10000 --cache "$T" --no-update-notifier; r=$?; rm -rf "$T"; exit $r; })
77
92
  ```
78
93
 
79
94
  부재한 채널은 그 자리에서 "스킵" 으로 확정한다. 없는 채널을 되살리려 들지 마라(그건 설치지 갱신이 아니다).
@@ -84,7 +99,7 @@ npm view @kaydash9999/banker-plugins version
84
99
  ```bash
85
100
  npm i -g @kaydash9999/banker-plugins@latest
86
101
  banker --version
87
- npm view @kaydash9999/banker-plugins version
102
+ (cd ~ && npm view @kaydash9999/banker-plugins version --fetch-retries=0 --fetch-timeout=10000)
88
103
  ```
89
104
 
90
105
  두 값이 **일치할 때만** 다음 단계로 간다. 불일치면 **즉시 중단**하고 보고한다. 우회하지 마라.
@@ -166,7 +181,7 @@ console.log(e&&e[0]?e[0].version:'absent (Claude 채널 미설치 -> 스킵)')"
166
181
  프로브 2, npm(두 값이 같아야 한다):
167
182
  ```bash
168
183
  banker --version
169
- npm view @kaydash9999/banker-plugins version
184
+ (cd ~ && npm view @kaydash9999/banker-plugins version --fetch-retries=0 --fetch-timeout=10000)
170
185
  ```
171
186
 
172
187
  프로브 3, Codex. **축이 둘이다**(skills, prompts). 각각 설치분과 매니페스트를 대조한다. 스킬만 세면 프롬프트 소실을 못 본다:
@@ -209,6 +224,7 @@ Codex prompts : 2 -> 2 (매니페스트 2) 유지
209
224
  ## 함정
210
225
  - **3채널을 하나로 뭉개는 것.** "banker 버전" 이라는 단일 값은 없다.
211
226
  - **exit 0 을 성공으로 읽는 것.** 이 도메인에서 종료 코드는 신호가 아니다. 관측만이 신호다.
227
+ - **낡은 npm 캐시 값을 최신으로 읽는 것.** 레지스트리에 닿지 못해도 `npm view` 는 캐시의 옛 값을 종료 0 으로 낸다. 0단계의 `npm ping` 이 실패하면 멈춘다.
212
228
  - **순서를 바꾸는 것.** Codex 를 npm 보다 먼저 돌리면 sweep 이 현재 설치분을 지우고 구버전 개수만 복원한다. `~/.codex/skills` 와 `~/.codex/prompts` **둘 다** 다. 이것이 이 스킬에서 데이터가 사라지는 유일하고 실재하는 경로다.
213
229
  - **스킬만 세는 것.** 프롬프트 축을 빼면 구버전 매니페스트의 **복원 대상 command 가 0** 일 때 생기는 **프롬프트 100% 소실**을 구조적으로 볼 수 없다. `banker doctor` 도 프롬프트를 세지 않으므로 대체가 안 된다.
214
230
  - **sweep 차액을 "배포본에서 빠진 것" 으로 단정하는 것.** sweep 은 `banker-` 접두사만 본다. 사용자가 손수 만든 것도 지운다. 오귀속하지 말고 이름을 제시하라.
@@ -360,13 +360,16 @@ pptxgenjs 는 `fontFace` 하나면 되고, python-pptx 는 `a:ea`·`a:cs` 를
360
360
  런타임 준비 절차는 `docs-setup` 스킬(`/banker:docs-setup`)에 있다.
361
361
 
362
362
  일회성으로 붙여 볼 때는 프로젝트 트리를 건드리지 않는 스크래치 설치를 쓴다.
363
+ 네트워크가 필요한 설치다. 먼저 `vertical-pptx-setup` 의 네트워크 확인을 돌리고, 닿지 않으면 설치하지 않고 "오프라인이라 설치하지 않음(필요한 것: pptxgenjs 또는 python-pptx)" 으로 알린다.
363
364
 
364
365
  ```bash
365
- npm i --no-save --no-audit --no-fund pptxgenjs@4
366
- python -m pip install --target <dir> python-pptx fonttools
366
+ S=$(mktemp -d) # 스크래치 폴더: 프로젝트 트리와 그 .npmrc 밖
367
+ (cd "$S" && npm i --no-save --no-audit --no-fund pptxgenjs@4) && echo "NODE_PATH=$S/node_modules"
368
+ (cd ~ && python -m pip install --target <절대 경로> python-pptx fonttools) # 홈에서: -c, -m, 표준 입력 스크립트는 지금 폴더의 모듈을 먼저 불러온다
367
369
  ```
368
370
 
369
- `--target` 으로 받았으면 그 경로를 `PYTHONPATH` 에, npm 쪽은 `NODE_PATH` 에 넣어 부른다.
371
+ `--target` 으로 받았으면 그 경로를 `PYTHONPATH` 에, npm 쪽은 찍힌 `NODE_PATH` 에 넣어 부른다. 다 쓴 스크래치 폴더는 `rm -rf "$S"` 로 지운다.
372
+ npm 을 지금 폴더에서 돌리면 그 폴더에 `node_modules` 를 만들고, 그 폴더의 `.npmrc` 가 정한 레지스트리와 토큰을 따른다. 그래서 스크래치 폴더에서 돌린다.
370
373
 
371
374
  ## 수리와 변환
372
375