@kaydash9999/banker-plugins 0.21.0 → 0.23.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 (129) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +69 -0
  4. package/PRIVACY.md +8 -3
  5. package/README.md +78 -15
  6. package/bin/banker.js +154 -32
  7. package/bin/lib/codex-hooks.mjs +123 -20
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +204 -0
  10. package/bin/lib/command-skill.cjs +50 -0
  11. package/bin/lib/telemetry-config.mjs +2 -1
  12. package/codex/command-skills/front-qa/SKILL.md +56 -0
  13. package/codex/command-skills/setup/SKILL.md +87 -0
  14. package/codex/hooks.json +29 -0
  15. package/codex/manifest.json +18 -15
  16. package/codex/skills/graceful-pause/SKILL.md +2 -2
  17. package/codex/skills/progress/scripts/progress.mjs +44 -14
  18. package/codex/skills/teamwork/SKILL.md +137 -0
  19. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
  20. package/codex/transform-matrix.md +5 -3
  21. package/commands/setup.md +2 -2
  22. package/hooks/obsidize-hook.mjs +75 -32
  23. package/hooks/telemetry-count-codex.mjs +303 -35
  24. package/hooks/update-fetch.mjs +2 -1
  25. package/hooks/update-notify.mjs +146 -13
  26. package/package.json +3 -1
  27. package/skills/3d-intro-build/SKILL.md +101 -20
  28. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  29. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  30. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  31. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  32. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  33. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  34. package/skills/3d-intro-build/references/prompts.md +51 -4
  35. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  36. package/skills/3d-intro-build/references/step-nav.css +29 -0
  37. package/skills/3d-intro-build/references/step-nav.js +277 -106
  38. package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
  39. package/skills/3d-intro-build/references/verify.md +6 -0
  40. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  41. package/skills/3d-intro-setup/SKILL.md +20 -7
  42. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  43. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  44. package/skills/all-in-one/SKILL.md +125 -86
  45. package/skills/anti-drm/SKILL.md +1 -1
  46. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  47. package/skills/arch-diagram/build_pptx_template.py +47 -28
  48. package/skills/audit-web-page/SKILL.md +1 -1
  49. package/skills/audit-web-page/harness.py +0 -1
  50. package/skills/lineage/lineage.py +1435 -533
  51. package/skills/motion-graphic-make/SKILL.md +1 -1
  52. package/skills/obsidizer/SKILL.md +20 -5
  53. package/skills/omc-patch/SKILL.md +1 -1
  54. package/skills/payload-mon/SKILL.md +2 -2
  55. package/skills/play-qa/SKILL.md +7 -5
  56. package/skills/progress-mon/README.md +162 -0
  57. package/skills/progress-mon/SKILL.md +165 -0
  58. package/skills/progress-mon/assets/fixture/README.md +1 -0
  59. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  60. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  61. package/skills/progress-mon/assets/fixture/config.json +30 -0
  62. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  63. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  64. package/skills/progress-mon/assets/fixture/items.json +14 -0
  65. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  66. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  67. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  68. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  69. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  70. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  71. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  72. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  73. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  74. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  75. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  76. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  77. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  78. package/skills/progress-mon/references/arch.md +280 -0
  79. package/skills/progress-mon/references/authoring.md +199 -0
  80. package/skills/progress-mon/references/config.md +115 -0
  81. package/skills/progress-mon/references/flow.md +75 -0
  82. package/skills/progress-mon/references/inventory.md +58 -0
  83. package/skills/progress-mon/references/items.md +74 -0
  84. package/skills/progress-mon/references/req-spec.md +74 -0
  85. package/skills/progress-mon/references/reqs.md +30 -0
  86. package/skills/progress-mon/scripts/index.js +31 -0
  87. package/skills/progress-mon/scripts/pm-check.mjs +526 -0
  88. package/skills/progress-mon/scripts/pm-config.mjs +341 -0
  89. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  90. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  91. package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
  92. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  93. package/skills/progress-mon/scripts/pm-server.mjs +735 -0
  94. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  95. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  96. package/skills/ralph-qa/SKILL.md +105 -16
  97. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  98. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  99. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  100. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  101. package/skills/ralph-qa/references/seat-run.mjs +159 -155
  102. package/skills/remains/SKILL.md +2 -1
  103. package/skills/remains/scripts/boxes.mjs +6 -2
  104. package/skills/rfp-author/scripts/md2docx.py +63 -34
  105. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  106. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  107. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  108. package/skills/setup-mcp/SKILL.md +1 -1
  109. package/skills/setup-omc-hud/SKILL.md +2 -1
  110. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  111. package/skills/smart-compact/SKILL.md +1 -1
  112. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  113. package/skills/theme-selection/SKILL.md +1 -1
  114. package/skills/tone-compact/SKILL.md +1 -1
  115. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  116. package/skills/ultra-chain/SKILL.md +34 -16
  117. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  118. package/skills/ultra-init/SKILL.md +30 -22
  119. package/skills/ultra-ui-qa/SKILL.md +1 -1
  120. package/skills/ultraqa/NOTICE.md +34 -0
  121. package/skills/ultraqa/SKILL.md +196 -0
  122. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  123. package/skills/ultraqa/references/run-folder.mjs +299 -0
  124. package/skills/ultraqa/references/tmux-drive.md +52 -0
  125. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  126. package/skills/update-banker/SKILL.md +32 -7
  127. package/skills/vertical-pptx/SKILL.md +1 -1
  128. package/skills/vertical-pptx/build_a4p.js +20 -2
  129. package/skills/vertical-pptx/build_a4p.py +560 -359
@@ -19,10 +19,11 @@
19
19
  * (the DRM of a closed network encrypts files by their extension, and .md is one it leaves readable).
20
20
  */
21
21
  import { fork } from "node:child_process";
22
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
23
- import { constants as osConstants, homedir } from "node:os";
24
- import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
22
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, utimesSync, writeFileSync } from "node:fs";
23
+ import { constants as osConstants } from "node:os";
24
+ import { basename, dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
+ import { Stop as RunStop, bases, isFile, listDir, lstatOr, makeRunFolder, norm, realOr, removeMarked, runFolder } from "./run-folder.mjs";
26
27
  import { POLICY, cleanSeat, policyIntact, strayEnvFiles, sweepBase } from "./gemini-seat.mjs";
27
28
  import { scan } from "./payload-scan.mjs";
28
29
  import { MAX_BYTES, Stop, buildPayload, checkPayload, gitAt, markerCount, readRun, splitPayload, untracked } from "./seat-payload.mjs";
@@ -42,6 +43,8 @@ const RUN_NAME = /^ralph-qa\.[A-Za-z0-9]+$/;
42
43
  const ANY_RUN = /^ralph-qa(-out)?\.[A-Za-z0-9]+$/;
43
44
  // The lock of a send that runs: one name, made with "wx", holding the pid and the time.
44
45
  const LOCK_NAME = ".running.md";
46
+ const TAKING_NAME = ".taking.md";
47
+ const STALE_TAKING_MS = 60 * 1000;
45
48
 
46
49
  class Usage extends Error {}
47
50
  const USAGE = "usage: node seat-run.mjs init | untracked --repo <repo> [--folder <f>] | prepare --dir <d> --repo <repo> --base <base|auto> --id <id>"
@@ -93,98 +96,30 @@ export function fromMsys(p, platform) {
93
96
  return m ? `${m[1].toUpperCase()}:\\${(m[2] || "").replace(/\//g, "\\")}` : p;
94
97
  }
95
98
 
96
- const lstatOr = (p) => {
97
- try {
98
- return lstatSync(p);
99
- } catch {
100
- return null;
101
- }
102
- };
103
- const isFile = (p) => Boolean(lstatOr(p)?.isFile());
104
- const listDir = (dir) => {
105
- try {
106
- return readdirSync(dir);
107
- } catch {
108
- return [];
109
- }
110
- };
111
-
112
- // A folder init made: named ralph-qa.<letters and digits>, a folder and no link, with its marker. Its answer folder
113
- // is the sibling ralph-qa-out.<the same>.
114
- export function runFolder(dir) {
115
- const d = resolve(String(dir));
116
- if (!RUN_NAME.test(basename(d)) || !lstatOr(d)?.isDirectory() || !isFile(join(d, ".ralph-qa"))) {
117
- throw new Stop("not-a-run-folder", `init 이 만든 폴더가 아니다: ${d}`);
118
- }
99
+ // A folder init made (run-folder.mjs: its name, its marker, right under a known private base) and its answer folder,
100
+ // the sibling ralph-qa-out.<the same>.
101
+ function runPair(dir, ctx) {
102
+ const { d } = runFolder(dir, ctx, ["ralph-qa"]);
119
103
  const o = join(dirname(d), basename(d).replace(/^ralph-qa\./, "ralph-qa-out."));
120
104
  const answers = lstatOr(o);
121
105
  return { d, o: answers?.isDirectory() && isFile(join(o, ".ralph-qa")) ? o : null };
122
106
  }
123
107
 
124
- const homeOf = (ctx) => (ctx.platform === "win32" ? ctx.env.USERPROFILE : ctx.env.HOME) || homedir();
125
- const norm = (ctx, p) => (ctx.platform === "win32" ? p.toLowerCase() : p);
126
- const realOr = (p) => {
127
- try {
128
- return realpathSync.native(p);
129
- } catch {
130
- return p;
131
- }
132
- };
133
-
134
- // A folder only this account may write in (POSIX: its own, no group or other write bit).
135
- function privateDir(p) {
136
- try {
137
- const st = statSync(p);
138
- return st.isDirectory() && st.uid === process.getuid() && (st.mode & 0o022) === 0;
139
- } catch {
140
- return false;
141
- }
142
- }
143
- const insideHome = (ctx, p) => norm(ctx, realOr(p)).startsWith(norm(ctx, realOr(homeOf(ctx))) + sep);
144
-
145
- // The base for this run's folders, and every base a run of any version may have used, for the day rule. Linux: an
146
- // XDG_RUNTIME_DIR of this account's own, else ~/.cache; macOS: $TMPDIR (its own per account); Windows: %TEMP% when it
147
- // is inside the home, else ~/.cache (the old Git Bash runs used ~/.cache). Never /tmp: gemini reads a .env it finds
148
- // walking up from its folder, and another account may put one there.
149
- export function bases(ctx) {
150
- const cache = join(homeOf(ctx), ".cache");
151
- const base = baseOn(ctx, cache);
152
- const xdg = ctx.platform === "linux" && ctx.env.XDG_RUNTIME_DIR ? [resolve(ctx.env.XDG_RUNTIME_DIR)] : [];
153
- return { base, known: [...new Set([base, cache, ...xdg])] };
154
- }
155
-
156
- function baseOn(ctx, cache) {
157
- const { env } = ctx;
158
- if (ctx.platform === "win32") return env.TEMP && insideHome(ctx, env.TEMP) ? resolve(env.TEMP) : cache;
159
- if (ctx.platform === "darwin") return env.TMPDIR ? resolve(env.TMPDIR) : cache;
160
- const xdg = env.XDG_RUNTIME_DIR;
161
- return xdg && isAbsolute(xdg) && privateDir(xdg) ? resolve(xdg) : cache;
162
- }
163
-
164
- function readyBase(ctx, base) {
165
- try {
166
- mkdirSync(base, { recursive: true, mode: 0o700 });
167
- } catch (e) {
168
- throw new Stop("base-not-writable", `기준 폴더를 만들 수 없다(${e.code}): ${base}`);
169
- }
170
- if (ctx.platform !== "win32" && !privateDir(base)) throw new Stop("base-not-private", `기준 폴더를 다른 계정도 쓸 수 있다: ${base}`);
171
- }
172
-
173
- // Two folders with the same six characters: the payload one by mkdtemp, its answer folder beside it.
174
- function makeFolders(base) {
108
+ // Two folders with the same six characters: the payload one by run-folder.mjs, its answer folder beside it.
109
+ function makeFolders(ctx) {
175
110
  for (let tries = 0; tries < 20; tries += 1) {
176
- const d = mkdtempSync(join(base, "ralph-qa."));
111
+ const { dir: d, base } = makeRunFolder(ctx, "ralph-qa");
177
112
  const o = join(base, basename(d).replace(/^ralph-qa\./, "ralph-qa-out."));
178
113
  try {
179
114
  mkdirSync(o, { mode: 0o700 });
180
- for (const p of [d, o]) writeFileSync(join(p, ".ralph-qa"), "", { mode: 0o600 });
181
- return { d, o };
115
+ writeFileSync(join(o, ".ralph-qa"), "", { mode: 0o600 });
116
+ return { d, o, base };
182
117
  } catch (e) {
183
118
  rmSync(d, { recursive: true, force: true });
184
- if (e.code !== "EEXIST") throw e;
119
+ if (e.code !== "EEXIST") throw new Stop("base-not-writable", `기준 폴더에 쓸 수 없다(${e.code}): ${base}`);
185
120
  }
186
121
  }
187
- throw new Stop("base-not-writable", `기준 폴더에 새 폴더 이름을 정하지 못했다: ${base}`);
122
+ throw new Stop("base-not-writable", `기준 폴더에 새 폴더 이름을 정하지 못했다: ${bases(ctx).base}`);
188
123
  }
189
124
 
190
125
  // Whether `dir` is inside a git work tree: a seat would then read the repo through its tools.
@@ -197,20 +132,12 @@ function insideRepo(ctx, dir) {
197
132
  }
198
133
 
199
134
  function init(_o, ctx) {
200
- const { base } = bases(ctx);
201
- readyBase(ctx, base);
202
- let made;
203
- try {
204
- made = makeFolders(base);
205
- } catch (e) {
206
- if (e instanceof Stop) throw e;
207
- throw new Stop("base-not-writable", `기준 폴더에 쓸 수 없다(${e.code}): ${base}`);
208
- }
135
+ const made = makeFolders(ctx);
209
136
  if (insideRepo(ctx, made.d)) {
210
137
  for (const p of [made.d, made.o]) rmSync(p, { recursive: true, force: true });
211
- throw new Stop("inside-repo", `기준 폴더가 git 저장소 안이다: ${base}`);
138
+ throw new Stop("inside-repo", `기준 폴더가 git 저장소 안이다: ${made.base}`);
212
139
  }
213
- return [{ ok: true, dir: made.d, out: made.o, base }, 0, made.d];
140
+ return [{ ok: true, dir: made.d, out: made.o, base: made.base }, 0, made.d];
214
141
  }
215
142
 
216
143
  function listUntracked(o, ctx) {
@@ -235,15 +162,15 @@ function verdict(result) {
235
162
  }
236
163
 
237
164
  function prepare(o, ctx) {
238
- const { d } = runFolder(o.dir);
165
+ const { d } = runPair(o.dir, ctx);
239
166
  refuseSent(d);
240
167
  const built = buildPayload({ dir: d, repo: o.repo, base: o.base, id: o.id, env: ctx.env });
241
168
  const [result, code] = verdict({ dir: d, prompt: join(d, "prompt.md"), raw: join(d, "raw.md"), unsentFile: join(d, "unsent.md"), ...built });
242
169
  return [result, code, d];
243
170
  }
244
171
 
245
- function check(o) {
246
- const { d } = runFolder(o.dir);
172
+ function check(o, ctx) {
173
+ const { d } = runPair(o.dir, ctx);
247
174
  const id = readRun(d).id;
248
175
  const c = checkPayload({ dir: d, id });
249
176
  if (c.markers !== 2) throw new Stop("markers", `표지가 두 줄이 아니다(${c.markers}줄). 가리다 표지를 지웠으면 prepare 를 다시 한다`);
@@ -262,7 +189,7 @@ const readStdin = (ctx) => {
262
189
 
263
190
  function put(o, ctx) {
264
191
  if (!["short.md", "spec.md"].includes(o.name)) throw new Usage(`--name 은 short.md 나 spec.md 다: ${String(o.name).slice(0, 80)}`);
265
- const { d } = runFolder(o.dir);
192
+ const { d } = runPair(o.dir, ctx);
266
193
  refuseSent(d);
267
194
  const data = readStdin(ctx);
268
195
  writeFileSync(join(d, o.name), data, { mode: 0o600 });
@@ -294,22 +221,71 @@ export function liveLock(d, now = Date.now()) {
294
221
  return st && st.isFile() && pid !== process.pid && now - st.mtimeMs < STALE_LOCK_MS && alive(pid) ? pid : null;
295
222
  }
296
223
 
297
- // Takes the folder's lock: "wx" lets one of two sends that start together have it. A stale lock is taken over once
298
- // (two sends that both find the same stale lock is the one gap left). Gives back the lock's path.
299
- export function takeLock(d, now = Date.now()) {
300
- const lock = join(d, LOCK_NAME);
301
- for (let tries = 0; tries < 2; tries += 1) {
224
+ // A lock with no number to read (empty: another send made it and has not written its number yet; or broken) touched
225
+ // in the last 15 minutes: taken over, it would let two sends hold the folder.
226
+ function freshUnread(d, now) {
227
+ const st = lockPid(d) ? null : lstatOr(join(d, LOCK_NAME));
228
+ return Boolean(st && st.isFile() && now - st.mtimeMs < STALE_LOCK_MS);
229
+ }
230
+
231
+ const justStarted = () => new Stop("busy", "이 폴더의 send 가 방금 시작했다");
232
+
233
+ // Windows makes "wx" fail EPERM (or EACCES, EBUSY), not EEXIST, where another process is deleting the file (delete
234
+ // pending: a lock given back that moment). Such a code is tried again for up to a second; then it is the lock there
235
+ // when the file is seen, else this send's error. Any other error fails at once.
236
+ const PENDING = new Set(["EPERM", "EACCES", "EBUSY"]);
237
+ const PENDING_TRIES = 40;
238
+ const PENDING_WAIT_MS = 25;
239
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
240
+
241
+ // The lock (or the take-over mark) made with "wx": one of two sends that make it together has it.
242
+ function makeLock(file, now) {
243
+ for (let tries = 1; ; tries += 1) {
302
244
  try {
303
- writeFileSync(lock, `${process.pid} ${new Date(now).toISOString()}\n`, { flag: "wx", mode: 0o600 });
304
- return lock;
245
+ writeFileSync(file, `${process.pid} ${new Date(now).toISOString()}\n`, { flag: "wx", mode: 0o600 });
246
+ return true;
305
247
  } catch (e) {
306
- if (e.code !== "EEXIST") throw e;
248
+ if (e.code === "EEXIST") return false;
249
+ if (!PENDING.has(e.code)) throw e;
250
+ if (tries >= PENDING_TRIES) {
251
+ if (existsSync(file)) return false;
252
+ throw e;
253
+ }
307
254
  }
308
- const pid = liveLock(d, now);
309
- if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid})`);
255
+ pause(PENDING_WAIT_MS);
256
+ }
257
+ }
258
+
259
+ // A stale lock is taken over only under the take-over mark .taking.md, and judged again under it: of two sends that
260
+ // both found it stale, the second finds the first's new lock live (or with no number yet) and is busy; neither removes
261
+ // a lock that is not stale. A mark left by a send that died taking over is cleared once it is a minute old (that send
262
+ // is busy; the next one goes on), so it holds the folder no longer than that.
263
+ export function takeOver(d, lock, now = Date.now()) {
264
+ const taking = join(d, TAKING_NAME);
265
+ if (!makeLock(taking, now)) {
266
+ const st = lstatOr(taking);
267
+ if (st?.isFile() && now - st.mtimeMs >= STALE_TAKING_MS) rmSync(taking, { force: true });
268
+ throw new Stop("busy", "다른 send 가 이 폴더의 낡은 잠금을 넘겨받는 중이다");
269
+ }
270
+ try {
271
+ if (liveLock(d, now) || freshUnread(d, now)) throw justStarted();
310
272
  rmSync(lock, { force: true });
273
+ if (!makeLock(lock, now)) throw justStarted();
274
+ return lock;
275
+ } finally {
276
+ rmSync(taking, { force: true });
311
277
  }
312
- throw new Stop("busy", "이 폴더의 send 가 방금 시작했다");
278
+ }
279
+
280
+ // Takes the folder's lock: "wx" lets one of two sends that start together have it; a stale one is taken over under
281
+ // the take-over mark. Gives back the lock's path.
282
+ export function takeLock(d, now = Date.now()) {
283
+ const lock = join(d, LOCK_NAME);
284
+ if (makeLock(lock, now)) return lock;
285
+ const pid = liveLock(d, now);
286
+ if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid})`);
287
+ if (freshUnread(d, now)) throw justStarted();
288
+ return takeOver(d, lock, now);
313
289
  }
314
290
 
315
291
  // Touches the lock every `every` ms while a send runs: a send past 15 minutes (split parts) stays live to cleanup.
@@ -330,31 +306,22 @@ function giveBack(lock) {
330
306
  if (lockPid(dirname(lock)) === process.pid) rmSync(lock, { force: true });
331
307
  }
332
308
 
333
- // The day rule: folders of an earlier run, marked, past a day, never a link or a folder without the marker.
334
- // A marked folder removed. One that cannot be removed gets back its marker (if the removal took it) and its times
335
- // (what the removal took from it changed them), so it stays a marked folder past a day: a later cleanup sweeps it
336
- // again, and cleanup --dir on it still finds a run folder.
337
- function removeMarked(p) {
338
- const was = lstatOr(p);
309
+ // The lock, and only then the last look for result-send.md: a send that checked before another gave the lock back
310
+ // finds the folder sent here, not after it sent again. A stop here gives the lock back.
311
+ export function holdFolder(d, now = Date.now()) {
312
+ const lock = takeLock(d, now);
339
313
  try {
340
- rmSync(p, { recursive: true, force: true, maxRetries: 3 });
314
+ refuseSent(d);
341
315
  } catch (e) {
342
- keepMarked(p, was);
316
+ giveBack(lock);
343
317
  throw e;
344
318
  }
319
+ return lock;
345
320
  }
346
321
 
347
- function keepMarked(p, was) {
348
- try {
349
- if (!lstatOr(p)?.isDirectory()) return;
350
- if (!isFile(join(p, ".ralph-qa"))) writeFileSync(join(p, ".ralph-qa"), "", { flag: "wx", mode: 0o600 });
351
- if (was) utimesSync(p, was.atime, was.mtime);
352
- } catch {
353
- /* the folder takes no marker or times either */
354
- }
355
- }
356
-
357
- // One that cannot be removed (a held handle, a folder this account cannot empty) goes to `left` with why; the rest go on.
322
+ // The day rule: folders of an earlier run, marked, past a day, never a link or a folder without the marker (removed
323
+ // by run-folder.mjs, the marker last). One that cannot be removed (a held handle, a folder this account cannot
324
+ // empty) goes to `left` with why; the rest go on.
358
325
  function dayRule(base, now, left) {
359
326
  const removed = [];
360
327
  for (const name of listDir(base)) {
@@ -362,7 +329,7 @@ function dayRule(base, now, left) {
362
329
  const st = ANY_RUN.test(name) ? lstatOr(p) : null;
363
330
  if (!st?.isDirectory() || now - st.mtimeMs <= DAY_MS || !isFile(join(p, ".ralph-qa"))) continue;
364
331
  try {
365
- removeMarked(p);
332
+ removeMarked(p, ".ralph-qa");
366
333
  removed.push(p);
367
334
  } catch (e) {
368
335
  left.push({ path: p, why: e.code || e.message });
@@ -376,7 +343,7 @@ function removeOwn(folders) {
376
343
  const stayed = [];
377
344
  for (const p of folders.filter(Boolean)) {
378
345
  try {
379
- removeMarked(p);
346
+ removeMarked(p, ".ralph-qa");
380
347
  } catch (e) {
381
348
  stayed.push(`${p} (${e.code || e.message})`);
382
349
  }
@@ -430,7 +397,7 @@ const readIdOr = (d) => {
430
397
  };
431
398
 
432
399
  async function cleanup(o, ctx) {
433
- const { d, o: out } = runFolder(o.dir);
400
+ const { d, o: out } = runPair(o.dir, ctx);
434
401
  const pid = o.force ? null : liveLock(d, ctx.now());
435
402
  if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid}). 끝난 뒤 다시 한다`);
436
403
  const { known } = bases(ctx);
@@ -586,8 +553,8 @@ async function cliChecks(ctx, seat, plan, exe) {
586
553
  }
587
554
 
588
555
  // The parts a seat gets: the payload, or its split, each with its two markers and no secret-like line.
589
- function partsOf(payload, n, id, short) {
590
- const parts = n ? splitPayload(payload, n, id, short) : [payload];
556
+ function partsOf(payload, n, { id, short, shortSha }) {
557
+ const parts = n ? splitPayload(payload, n, id, short, shortSha) : [payload];
591
558
  parts.forEach((part, k) => {
592
559
  if (markerCount(part, id) !== 2 || scan(part.toString("utf8")).count) throw new Stop("split", `부분 ${k + 1}/${parts.length} 의 표지나 검사가 맞지 않는다`);
593
560
  });
@@ -699,7 +666,7 @@ async function seatWork(ctx, run, seat, g, payload, base) {
699
666
  if (why) return { ...base, why };
700
667
  let parts;
701
668
  try {
702
- parts = partsOf(payload, run.split[seat.cli], run.id, run.short);
669
+ parts = partsOf(payload, run.split[seat.cli], run);
703
670
  } catch (e) {
704
671
  if (e instanceof Stop) return { ...base, why: "split", notes: [e.message] };
705
672
  throw e;
@@ -708,10 +675,37 @@ async function seatWork(ctx, run, seat, g, payload, base) {
708
675
  return runParts(ctx, run, seat, g, parts, base);
709
676
  }
710
677
 
678
+ // The seat CLI's version, read without starting gemini or opencode: codex's from the probe's `codex --version`, the
679
+ // others' from the package.json of their npm package (beside the entry point, above its real path, or under the
680
+ // prefix's lib/node_modules), opencode's own installer folder holding the plugin package of its version.
681
+ const PACKAGES = { codex: "@openai/codex", gemini: "@google/gemini-cli", opencode: "opencode-ai" };
682
+ const readJson = (p) => {
683
+ try {
684
+ return JSON.parse(readFileSync(p, "utf8"));
685
+ } catch {
686
+ return null;
687
+ }
688
+ };
689
+ function installedVersion(cli, exe) {
690
+ const real = realOr(exe);
691
+ const pkg = PACKAGES[cli].split("/");
692
+ const tries = [join(dirname(exe), "node_modules", ...pkg, "package.json"), join(dirname(dirname(exe)), "lib", "node_modules", ...pkg, "package.json")];
693
+ for (let dir = dirname(real), k = 0; k < 5; k += 1, dir = dirname(dir)) tries.push(join(dir, "package.json"));
694
+ const found = tries.map(readJson).find((j) => j?.name === PACKAGES[cli] && typeof j.version === "string");
695
+ if (found) return found.version;
696
+ const own = cli === "opencode" ? readJson(join(dirname(dirname(real)), "package.json"))?.dependencies?.["@opencode-ai/plugin"] : null;
697
+ return typeof own === "string" ? `${own} (설치 폴더의 @opencode-ai/plugin)` : null;
698
+ }
699
+ export function cliVersion(cli, exe, probed) {
700
+ if (cli === "codex" && probed?.version) return String(probed.version).slice(0, 80);
701
+ return installedVersion(cli, exe) || "알 수 없음";
702
+ }
703
+
711
704
  async function sendSeat(ctx, run, seat, payload) {
712
- const base = { seat: seat.cli, model: seat.model, status: "not-sent", why: null, exit: null, signal: null, secs: 0, answer: null, errors: null, facts: {}, notes: [] };
705
+ const none = { seat: seat.cli, model: seat.model, status: "not-sent", why: null, exit: null, signal: null, secs: 0, answer: null, errors: null, facts: {}, notes: [] };
713
706
  const g = guards(ctx, seat, run);
714
- if (g.why) return { ...base, why: g.why, notes: g.notes || [] };
707
+ if (g.why) return { ...none, why: g.why, notes: g.notes || [] };
708
+ const base = { ...none, cliVersion: cliVersion(seat.cli, g.exe, g.probed) };
715
709
  let result;
716
710
  try {
717
711
  mkdirSync(join(run.d, seat.cli), { recursive: true, mode: 0o700 });
@@ -719,7 +713,7 @@ async function sendSeat(ctx, run, seat, payload) {
719
713
  } catch (e) {
720
714
  // the runner's own error in this seat's work (its copy cannot be written: ENOSPC, EACCES, a Windows lock) is this
721
715
  // seat's failure: the other seats go on, and the lock and the stop handlers hold until they end
722
- if (e instanceof Stop) throw e;
716
+ if (e instanceof RunStop) throw e;
723
717
  result = { ...base, status: "failed", why: "spawn", notes: [`좌석을 준비하지 못했다(${e.code || e.message})`] };
724
718
  } finally {
725
719
  const notes = await afterSeat(ctx, run, seat);
@@ -775,29 +769,42 @@ function declared(run, bytes, seats, d) {
775
769
  }
776
770
 
777
771
  function sendStart(o, ctx, a) {
778
- const { d, o: out } = runFolder(o.dir);
772
+ const { d, o: out } = runPair(o.dir, ctx);
779
773
  if (!out) throw new Stop("not-a-run-folder", `답 폴더(ralph-qa-out.*)가 없다: ${d}`);
780
774
  refuseSent(d);
781
775
  if (ctx.env.CODEX_SANDBOX_NETWORK_DISABLED === "1" && !a.dryRun) throw new Stop("sandboxed", "Codex sandbox 가 네트워크를 막는다. 승인을 받아 sandbox 밖에서 send 를 다시 한다");
782
- const { id, short } = readRun(d);
776
+ const { id, short, shortsha } = readRun(d);
783
777
  const payload = payloadToSend(d, id, Boolean(o["allow-large"]));
784
- return { d, out, id, payload, short: Number(short) };
778
+ return { d, out, id, payload, short: Number(short), shortSha: shortsha || null };
785
779
  }
786
780
 
781
+ const resultJson = (command, result) => `${JSON.stringify({ command, ...result }, null, 2)}\n`;
782
+ const writeResult = (dir, name, json) => {
783
+ try {
784
+ writeFileSync(join(dir, `result-${name}.md`), json, { mode: 0o600 });
785
+ } catch {
786
+ /* the JSON on stdout is the answer */
787
+ }
788
+ };
789
+
790
+ // A send that went out writes result-send.md before the lock goes back, so the next send to take the lock finds it
791
+ // (report writes it again with the same JSON).
787
792
  async function send(o, ctx) {
788
793
  const a = sendArgs(o);
789
- const { d, out, id, payload, short } = sendStart(o, ctx, a);
790
- const lock = takeLock(d, ctx.now());
794
+ const { d, out, id, payload, short, shortSha } = sendStart(o, ctx, a);
795
+ const lock = holdFolder(d, ctx.now());
791
796
  const touch = keepFresh(lock);
792
797
  const signals = watchSignals();
793
- const run = { ...a, d, o: out, id, short, signalled: () => signals.code() !== 0 };
798
+ const run = { ...a, d, o: out, id, short, shortSha, signalled: () => signals.code() !== 0 };
794
799
  try {
795
800
  // every seat settles before the lock goes back and the stop handlers go away
796
801
  const settled = await Promise.allSettled(a.seats.map((x) => sendSeat(ctx, run, x, payload)));
797
802
  const thrown = settled.find((x) => x.status === "rejected");
798
803
  if (thrown) throw thrown.reason;
799
804
  const seats = settled.map((x) => x.value);
805
+ if (!a.dryRun) appendFileSync(join(d, "run.md"), seats.filter((x) => x.cliVersion).map((x) => `cli-${x.seat} ${x.cliVersion}\n`).join(""), { mode: 0o600 });
800
806
  const result = { ok: true, ...(a.dryRun ? { dryRun: true } : {}), dir: d, out, bytes: payload.length, scan: 0, unsent: unsentCount(d), seats, declared: declared(run, payload.length, seats, d) };
807
+ if (!a.dryRun) writeResult(d, "send", resultJson("send", result));
801
808
  return [result, signals.code(), a.dryRun ? null : d];
802
809
  } finally {
803
810
  clearInterval(touch);
@@ -810,23 +817,17 @@ const COMMANDS = { init, untracked: listUntracked, prepare, check, send, cleanup
810
817
 
811
818
  // The JSON on stdout and in <d>/result-<command>.md (not for cleanup: the folder is gone).
812
819
  function report(ctx, command, dir, result, code, name = command) {
813
- const json = `${JSON.stringify({ command, ...result }, null, 2)}\n`;
820
+ const json = resultJson(command, result);
814
821
  ctx.stdout.write(json);
815
822
  if (result.message) ctx.stderr.write(`${result.message}\n`);
816
- if (dir) {
817
- try {
818
- writeFileSync(join(dir, `result-${name}.md`), json, { mode: 0o600 });
819
- } catch {
820
- /* the JSON on stdout is the answer */
821
- }
822
- }
823
+ if (dir) writeResult(dir, name, json);
823
824
  return code;
824
825
  }
825
826
 
826
827
  // A stop in the folder it concerns, when that is a run folder.
827
- const folderOf = (o) => {
828
+ const folderOf = (o, ctx) => {
828
829
  try {
829
- return o.dir ? runFolder(o.dir).d : null;
830
+ return o.dir ? runPair(o.dir, ctx).d : null;
830
831
  } catch {
831
832
  return null;
832
833
  }
@@ -835,17 +836,20 @@ const folderOf = (o) => {
835
836
  // A send that stopped goes to result-send-stop.md: result-send.md says the folder was sent, and a stopped send sent nothing.
836
837
  const stopName = (command) => (command === "send" ? "send-stop" : command);
837
838
 
839
+ // A stop of run-folder.mjs carries no name: this runner's goes before it.
840
+ const named = (message) => (message.startsWith("ralph-qa: ") ? message : `ralph-qa: ${message}`);
841
+
838
842
  function failed(ctx, { command, o }, e) {
839
843
  if (e instanceof Usage) {
840
844
  ctx.stderr.write(`ralph-qa: ${e.message}\n${USAGE}`);
841
845
  return 2;
842
846
  }
843
- if (!(e instanceof Stop)) {
847
+ if (!(e instanceof RunStop)) {
844
848
  ctx.stderr.write(`ralph-qa: 실행기 내부 오류(${e?.code || e?.name || "error"}): ${String(e?.message || e).split("\n")[0]}\n`);
845
849
  return 2;
846
850
  }
847
- const result = { ok: false, stop: e.code, message: e.message, detail: e.detail };
848
- return report(ctx, command, folderOf(o), result, EXIT_OF[e.code] || 3, stopName(command));
851
+ const result = { ok: false, stop: e.code, message: named(e.message), detail: e.detail };
852
+ return report(ctx, command, folderOf(o, ctx), result, EXIT_OF[e.code] || 3, stopName(command));
849
853
  }
850
854
 
851
855
  // ---- the send's own node ----
@@ -41,7 +41,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
41
41
 
42
42
  ## 4. 테스트박스 (등록돼 있으면)
43
43
 
44
- 박스 작업은 이 스킬 폴더의 `scripts/boxes.mjs` 가 한다(Codex 에서는 `~/.codex/skills/banker-remains/scripts/boxes.mjs`). Node 만 쓰고, 이 머신에 `ssh`, `scp`, `git`, 박스에 `tar` 가 있어야 한다. `--no-boxes` 를 받으면 이 단계를 건너뛰고 확인 범위에 그렇게 적는다.
44
+ 박스 작업은 이 스킬 폴더의 `scripts/boxes.mjs` 가 한다(Codex 의 npm 설치는 `~/.codex/skills/banker-remains/scripts/boxes.mjs`, 플러그인 설치는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/remains/scripts/boxes.mjs`). Node 만 쓰고, 이 머신에 `ssh`, `scp`, `git`, 박스에 `tar` 가 있어야 한다. `--no-boxes` 를 받으면 이 단계를 건너뛰고 확인 범위에 그렇게 적는다.
45
45
 
46
46
  1. `node <스킬 폴더>/scripts/boxes.mjs list` 로 등록을 본다. 등록 파일이 없으면 확인 범위에 "테스트박스 미등록" 이라 적고 넘어간다.
47
47
  2. `node <스킬 폴더>/scripts/boxes.mjs probe` 로 접속을 확인한다.
@@ -54,6 +54,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
54
54
  - `--node-test` 는 `*.test.mjs` 만 돌린다. 다른 종류의 시험(Python, `.test.ts` 등)은 `--cmd` 로 따로 돌리거나 그 OS 에서 미검증으로 적는다.
55
55
  - 보내는 것은 추적하는 파일의 커밋된 내용 그대로다(줄 끝 변환 없음). `--ref worktree` 는 커밋하지 않은 추적 파일의 변경까지 보낸다. 추적하지 않는 새 파일은 보내지 않는다(출력의 `untracked`). 그 파일이 시험에 필요하면 미검증으로 적는다.
56
56
  - Windows 박스에서는 명령이 `.cmd` 파일 안에서 돈다. 명령이 OS 마다 달라야 하면 `--box <이름>` 으로 나눠 돌린다.
57
+ - 그 `.cmd` 파일은 UTF-8 코드 페이지로 돌아 한글 인자도 그대로 간다. `for` 의 변수는 배치 파일 규칙대로 `%%i` 로 쓰고, `for` 나 `if` 안에서 npm 같은 배치 명령을 부르면 `call npm ...` 으로 쓴다.
57
58
  - 오래 걸리면 `--timeout <분>`(기본 20)을 늘린다.
58
59
  4. 출력 JSON 의 박스별 `summary`(tests, pass, fail, skipped, failures)와 `log` 파일을 근거로 쓴다. 종료 코드는 다음과 같다.
59
60
  - 0: 모든 박스가 통과
@@ -117,10 +117,14 @@ export const nodeTestCommand = (os, files) =>
117
117
 
118
118
  // The script that runs on the box from the top of the sent tree. On Linux and macOS it first leaves its process
119
119
  // id beside itself, so a stopped run can end everything it started. On Windows it is a .cmd file: `call` returns
120
- // from a batch command such as npm, and the command stays out of the ssh and cmd /c quoting.
120
+ // from a batch command such as npm, and the command stays out of the ssh and cmd /c quoting. The file is UTF-8, so
121
+ // it first sets that code page: cmd reads each line in the console's (949 on a Korean Windows), and a Korean
122
+ // argument came to node broken. `call` cannot run for, if or a ( block, so those go as they are.
123
+ const NO_CALL = /^\s*(?:(?:for|if)(?=[\s/]|$)|\()/i;
121
124
  export function runScript(b, prefix, command) {
122
125
  if (b.os === "win32") {
123
- const lines = ["@echo off", `cd /d "%~dp0${prefix}" || exit /b 97`, `call ${command}`, "exit /b %ERRORLEVEL%"];
126
+ const run = NO_CALL.test(command) ? command : `call ${command}`;
127
+ const lines = ["@echo off", "chcp 65001 >nul 2>&1", `cd /d "%~dp0${prefix}" || exit /b 97`, run, "exit /b %ERRORLEVEL%"];
124
128
  return { ext: ".cmd", text: lines.map((l) => `${l}\r\n`).join("") };
125
129
  }
126
130
  const path = b.path?.length ? [`${pathPrefix(b)}export PATH`] : [];