@kaydash9999/banker-plugins 0.22.0 → 0.23.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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +68 -0
  4. package/PRIVACY.md +1 -0
  5. package/README.md +39 -13
  6. package/bin/banker.js +118 -22
  7. package/bin/lib/codex-hooks.mjs +116 -18
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +93 -1
  10. package/bin/lib/command-skill.cjs +37 -6
  11. package/codex/command-skills/front-qa/SKILL.md +1 -1
  12. package/codex/command-skills/setup/SKILL.md +4 -4
  13. package/codex/hooks.json +5 -5
  14. package/codex/manifest.json +18 -15
  15. package/codex/skills/progress/scripts/progress.mjs +44 -14
  16. package/codex/skills/teamwork/SKILL.md +143 -0
  17. package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
  18. package/codex/transform-matrix.md +5 -3
  19. package/hooks/obsidize-hook.mjs +75 -32
  20. package/hooks/telemetry-count-codex.mjs +238 -57
  21. package/hooks/update-fetch.mjs +2 -1
  22. package/hooks/update-notify.mjs +82 -20
  23. package/package.json +3 -1
  24. package/skills/3d-intro-build/SKILL.md +101 -20
  25. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  26. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  27. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  28. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  29. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  30. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  31. package/skills/3d-intro-build/references/prompts.md +51 -4
  32. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  33. package/skills/3d-intro-build/references/step-nav.css +29 -0
  34. package/skills/3d-intro-build/references/step-nav.js +284 -106
  35. package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
  36. package/skills/3d-intro-build/references/verify.md +6 -0
  37. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  38. package/skills/3d-intro-setup/SKILL.md +20 -7
  39. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  40. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  41. package/skills/all-in-one/SKILL.md +125 -86
  42. package/skills/omc-patch/SKILL.md +1 -1
  43. package/skills/payload-mon/SKILL.md +1 -1
  44. package/skills/play-qa/SKILL.md +6 -4
  45. package/skills/progress-mon/README.md +164 -0
  46. package/skills/progress-mon/SKILL.md +165 -0
  47. package/skills/progress-mon/assets/fixture/README.md +1 -0
  48. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  49. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  50. package/skills/progress-mon/assets/fixture/config.json +30 -0
  51. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  52. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  53. package/skills/progress-mon/assets/fixture/items.json +14 -0
  54. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  55. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  56. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  57. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  58. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  59. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  60. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  61. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  62. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  63. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  64. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  65. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  66. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  67. package/skills/progress-mon/references/arch.md +280 -0
  68. package/skills/progress-mon/references/authoring.md +199 -0
  69. package/skills/progress-mon/references/config.md +115 -0
  70. package/skills/progress-mon/references/flow.md +75 -0
  71. package/skills/progress-mon/references/inventory.md +58 -0
  72. package/skills/progress-mon/references/items.md +74 -0
  73. package/skills/progress-mon/references/req-spec.md +74 -0
  74. package/skills/progress-mon/references/reqs.md +30 -0
  75. package/skills/progress-mon/scripts/index.js +31 -0
  76. package/skills/progress-mon/scripts/pm-check.mjs +549 -0
  77. package/skills/progress-mon/scripts/pm-config.mjs +343 -0
  78. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  79. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  80. package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
  81. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  82. package/skills/progress-mon/scripts/pm-server.mjs +781 -0
  83. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  84. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  85. package/skills/ralph-qa/SKILL.md +107 -17
  86. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  87. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  88. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  89. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  90. package/skills/ralph-qa/references/seat-run.mjs +200 -159
  91. package/skills/remains/SKILL.md +2 -0
  92. package/skills/remains/scripts/boxes.mjs +6 -2
  93. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  94. package/skills/setup-mcp/SKILL.md +1 -1
  95. package/skills/setup-omc-hud/SKILL.md +2 -1
  96. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  97. package/skills/ultra-chain/SKILL.md +32 -14
  98. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  99. package/skills/ultra-init/SKILL.md +30 -22
  100. package/skills/ultraqa/NOTICE.md +34 -0
  101. package/skills/ultraqa/SKILL.md +197 -0
  102. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  103. package/skills/ultraqa/references/run-folder.mjs +299 -0
  104. package/skills/ultraqa/references/tmux-drive.md +52 -0
  105. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  106. package/skills/update-banker/SKILL.md +1 -1
@@ -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";
@@ -31,7 +32,7 @@ import { CLI_DEFAULT, probe as localProbe, safeModel } from "./verifier-probe.mj
31
32
 
32
33
  // The stops a command may end with, as `stop` in its JSON; the skill's table of what to do holds the same set.
33
34
  export const STOP_CODES = ["index-missing", "no-diff", "list", "binary-diff", "range-source", "range-link", "range-outside", "file", "markers",
34
- "spec", "not-a-repo", "base-invalid", "git-failed", "git-timeout", "scan", "too-large", "sent", "sandboxed", "base-not-private",
35
+ "spec", "not-a-repo", "base-invalid", "git-failed", "git-timeout", "scan", "too-large", "sent", "sent-mark", "sandboxed", "base-not-private",
35
36
  "base-not-writable", "inside-repo", "busy", "not-prepared", "not-a-run-folder", "split", "cleanup-left"];
36
37
  const EXIT_OF = { scan: 1, "too-large": 4, busy: 5 };
37
38
 
@@ -42,6 +43,12 @@ 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
+ // The "sent" mark, written once under the lock before any seat work (send's markOrStop). It stands apart from
48
+ // result-send.md (written after every seat ends): a send that holds the lock in the A2 window finds this mark and
49
+ // does not send the payload again, even if that first send died before it wrote result-send.md.
50
+ const SENT_NAME = ".sent.md";
51
+ const STALE_TAKING_MS = 60 * 1000;
45
52
 
46
53
  class Usage extends Error {}
47
54
  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 +100,30 @@ export function fromMsys(p, platform) {
93
100
  return m ? `${m[1].toUpperCase()}:\\${(m[2] || "").replace(/\//g, "\\")}` : p;
94
101
  }
95
102
 
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
- }
103
+ // A folder init made (run-folder.mjs: its name, its marker, right under a known private base) and its answer folder,
104
+ // the sibling ralph-qa-out.<the same>.
105
+ function runPair(dir, ctx) {
106
+ const { d } = runFolder(dir, ctx, ["ralph-qa"]);
119
107
  const o = join(dirname(d), basename(d).replace(/^ralph-qa\./, "ralph-qa-out."));
120
108
  const answers = lstatOr(o);
121
109
  return { d, o: answers?.isDirectory() && isFile(join(o, ".ralph-qa")) ? o : null };
122
110
  }
123
111
 
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) {
112
+ // Two folders with the same six characters: the payload one by run-folder.mjs, its answer folder beside it.
113
+ function makeFolders(ctx) {
175
114
  for (let tries = 0; tries < 20; tries += 1) {
176
- const d = mkdtempSync(join(base, "ralph-qa."));
115
+ const { dir: d, base } = makeRunFolder(ctx, "ralph-qa");
177
116
  const o = join(base, basename(d).replace(/^ralph-qa\./, "ralph-qa-out."));
178
117
  try {
179
118
  mkdirSync(o, { mode: 0o700 });
180
- for (const p of [d, o]) writeFileSync(join(p, ".ralph-qa"), "", { mode: 0o600 });
181
- return { d, o };
119
+ writeFileSync(join(o, ".ralph-qa"), "", { mode: 0o600 });
120
+ return { d, o, base };
182
121
  } catch (e) {
183
122
  rmSync(d, { recursive: true, force: true });
184
- if (e.code !== "EEXIST") throw e;
123
+ if (e.code !== "EEXIST") throw new Stop("base-not-writable", `기준 폴더에 쓸 수 없다(${e.code}): ${base}`);
185
124
  }
186
125
  }
187
- throw new Stop("base-not-writable", `기준 폴더에 새 폴더 이름을 정하지 못했다: ${base}`);
126
+ throw new Stop("base-not-writable", `기준 폴더에 새 폴더 이름을 정하지 못했다: ${bases(ctx).base}`);
188
127
  }
189
128
 
190
129
  // Whether `dir` is inside a git work tree: a seat would then read the repo through its tools.
@@ -197,31 +136,51 @@ function insideRepo(ctx, dir) {
197
136
  }
198
137
 
199
138
  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
- }
139
+ const made = makeFolders(ctx);
209
140
  if (insideRepo(ctx, made.d)) {
210
141
  for (const p of [made.d, made.o]) rmSync(p, { recursive: true, force: true });
211
- throw new Stop("inside-repo", `기준 폴더가 git 저장소 안이다: ${base}`);
142
+ throw new Stop("inside-repo", `기준 폴더가 git 저장소 안이다: ${made.base}`);
212
143
  }
213
- return [{ ok: true, dir: made.d, out: made.o, base }, 0, made.d];
144
+ return [{ ok: true, dir: made.d, out: made.o, base: made.base }, 0, made.d];
214
145
  }
215
146
 
216
147
  function listUntracked(o, ctx) {
217
148
  return [{ ok: true, ...untracked({ repo: o.repo, env: ctx.env, folder: o.folder || null }) }, 0, null];
218
149
  }
219
150
 
220
- const sent = (d) => existsSync(join(d, "result-send.md"));
151
+ // A folder is sent once either mark is there: result-send.md (a send ran to the end) or .sent.md (a send took the
152
+ // folder to send it, even if it then stopped or died before result-send.md).
153
+ const sent = (d) => existsSync(join(d, "result-send.md")) || existsSync(join(d, SENT_NAME));
221
154
  const refuseSent = (d) => {
222
- if (sent(d)) throw new Stop("sent", `이미 보낸 폴더다. 반복마다 init 으로 새 폴더를 만든다: ${d}`);
155
+ if (sent(d)) throw new Stop("sent", `이미 보낸 폴더다. 좌석의 답이 있으면 답 폴더(ralph-qa-out.*)에 있다. 다시 보내지 말고, 다시 보낼지는 사용자에게 묻는다: ${d}`);
223
156
  };
224
157
 
158
+ // Writes .sent.md once (the memo answers again with the first result). EEXIST means another send (the A2 window)
159
+ // already marked it; any other write error is `err`, and a mark left empty by a failed write is taken away, so the
160
+ // next send is not refused for a folder nothing went out of.
161
+ export function makeMarkSent(d, now) {
162
+ let state = null;
163
+ return () => {
164
+ if (state) return state;
165
+ const file = join(d, SENT_NAME);
166
+ try {
167
+ writeFileSync(file, `${process.pid} ${new Date(now()).toISOString()}\n`, { flag: "wx", mode: 0o600 });
168
+ state = { ok: true };
169
+ } catch (e) {
170
+ if (e.code !== "EEXIST") rmSync(file, { force: true });
171
+ state = e.code === "EEXIST" ? { already: true } : { err: e };
172
+ }
173
+ return state;
174
+ };
175
+ }
176
+
177
+ // Marks the folder sent under the lock, before any seat work, or stops the send (the lock goes back in send's finally).
178
+ function markOrStop(d, now) {
179
+ const mark = makeMarkSent(d, now)();
180
+ if (mark.already) throw new RunStop("sent", `다른 send 가 이미 이 폴더를 보냈다: ${d}`);
181
+ if (mark.err) throw new RunStop("sent-mark", `보냈음 표지(.sent.md)를 쓰지 못했다(${mark.err.code || mark.err.message}). 좌석은 아무것도 보내지 않았다: ${d}`);
182
+ }
183
+
225
184
  // prepare and check end with 1 when a line looks like a secret and 4 past 512 KiB; the JSON carries both.
226
185
  function verdict(result) {
227
186
  if (result.scan.count) {
@@ -235,15 +194,15 @@ function verdict(result) {
235
194
  }
236
195
 
237
196
  function prepare(o, ctx) {
238
- const { d } = runFolder(o.dir);
197
+ const { d } = runPair(o.dir, ctx);
239
198
  refuseSent(d);
240
199
  const built = buildPayload({ dir: d, repo: o.repo, base: o.base, id: o.id, env: ctx.env });
241
200
  const [result, code] = verdict({ dir: d, prompt: join(d, "prompt.md"), raw: join(d, "raw.md"), unsentFile: join(d, "unsent.md"), ...built });
242
201
  return [result, code, d];
243
202
  }
244
203
 
245
- function check(o) {
246
- const { d } = runFolder(o.dir);
204
+ function check(o, ctx) {
205
+ const { d } = runPair(o.dir, ctx);
247
206
  const id = readRun(d).id;
248
207
  const c = checkPayload({ dir: d, id });
249
208
  if (c.markers !== 2) throw new Stop("markers", `표지가 두 줄이 아니다(${c.markers}줄). 가리다 표지를 지웠으면 prepare 를 다시 한다`);
@@ -262,7 +221,7 @@ const readStdin = (ctx) => {
262
221
 
263
222
  function put(o, ctx) {
264
223
  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);
224
+ const { d } = runPair(o.dir, ctx);
266
225
  refuseSent(d);
267
226
  const data = readStdin(ctx);
268
227
  writeFileSync(join(d, o.name), data, { mode: 0o600 });
@@ -294,22 +253,71 @@ export function liveLock(d, now = Date.now()) {
294
253
  return st && st.isFile() && pid !== process.pid && now - st.mtimeMs < STALE_LOCK_MS && alive(pid) ? pid : null;
295
254
  }
296
255
 
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) {
256
+ // A lock with no number to read (empty: another send made it and has not written its number yet; or broken) touched
257
+ // in the last 15 minutes: taken over, it would let two sends hold the folder.
258
+ function freshUnread(d, now) {
259
+ const st = lockPid(d) ? null : lstatOr(join(d, LOCK_NAME));
260
+ return Boolean(st && st.isFile() && now - st.mtimeMs < STALE_LOCK_MS);
261
+ }
262
+
263
+ const justStarted = () => new Stop("busy", "이 폴더의 send 가 방금 시작했다");
264
+
265
+ // Windows makes "wx" fail EPERM (or EACCES, EBUSY), not EEXIST, where another process is deleting the file (delete
266
+ // pending: a lock given back that moment). Such a code is tried again for up to a second; then it is the lock there
267
+ // when the file is seen, else this send's error. Any other error fails at once.
268
+ const PENDING = new Set(["EPERM", "EACCES", "EBUSY"]);
269
+ const PENDING_TRIES = 40;
270
+ const PENDING_WAIT_MS = 25;
271
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
272
+
273
+ // The lock (or the take-over mark) made with "wx": one of two sends that make it together has it.
274
+ function makeLock(file, now) {
275
+ for (let tries = 1; ; tries += 1) {
302
276
  try {
303
- writeFileSync(lock, `${process.pid} ${new Date(now).toISOString()}\n`, { flag: "wx", mode: 0o600 });
304
- return lock;
277
+ writeFileSync(file, `${process.pid} ${new Date(now).toISOString()}\n`, { flag: "wx", mode: 0o600 });
278
+ return true;
305
279
  } catch (e) {
306
- if (e.code !== "EEXIST") throw e;
280
+ if (e.code === "EEXIST") return false;
281
+ if (!PENDING.has(e.code)) throw e;
282
+ if (tries >= PENDING_TRIES) {
283
+ if (existsSync(file)) return false;
284
+ throw e;
285
+ }
307
286
  }
308
- const pid = liveLock(d, now);
309
- if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid})`);
287
+ pause(PENDING_WAIT_MS);
288
+ }
289
+ }
290
+
291
+ // A stale lock is taken over only under the take-over mark .taking.md, and judged again under it: of two sends that
292
+ // both found it stale, the second finds the first's new lock live (or with no number yet) and is busy; neither removes
293
+ // 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
294
+ // is busy; the next one goes on), so it holds the folder no longer than that.
295
+ export function takeOver(d, lock, now = Date.now()) {
296
+ const taking = join(d, TAKING_NAME);
297
+ if (!makeLock(taking, now)) {
298
+ const st = lstatOr(taking);
299
+ if (st?.isFile() && now - st.mtimeMs >= STALE_TAKING_MS) rmSync(taking, { force: true });
300
+ throw new Stop("busy", "다른 send 가 이 폴더의 낡은 잠금을 넘겨받는 중이다");
301
+ }
302
+ try {
303
+ if (liveLock(d, now) || freshUnread(d, now)) throw justStarted();
310
304
  rmSync(lock, { force: true });
305
+ if (!makeLock(lock, now)) throw justStarted();
306
+ return lock;
307
+ } finally {
308
+ rmSync(taking, { force: true });
311
309
  }
312
- throw new Stop("busy", "이 폴더의 send 가 방금 시작했다");
310
+ }
311
+
312
+ // Takes the folder's lock: "wx" lets one of two sends that start together have it; a stale one is taken over under
313
+ // the take-over mark. Gives back the lock's path.
314
+ export function takeLock(d, now = Date.now()) {
315
+ const lock = join(d, LOCK_NAME);
316
+ if (makeLock(lock, now)) return lock;
317
+ const pid = liveLock(d, now);
318
+ if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid})`);
319
+ if (freshUnread(d, now)) throw justStarted();
320
+ return takeOver(d, lock, now);
313
321
  }
314
322
 
315
323
  // Touches the lock every `every` ms while a send runs: a send past 15 minutes (split parts) stays live to cleanup.
@@ -330,31 +338,22 @@ function giveBack(lock) {
330
338
  if (lockPid(dirname(lock)) === process.pid) rmSync(lock, { force: true });
331
339
  }
332
340
 
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);
341
+ // The lock, and only then the last look for result-send.md: a send that checked before another gave the lock back
342
+ // finds the folder sent here, not after it sent again. A stop here gives the lock back.
343
+ export function holdFolder(d, now = Date.now()) {
344
+ const lock = takeLock(d, now);
339
345
  try {
340
- rmSync(p, { recursive: true, force: true, maxRetries: 3 });
346
+ refuseSent(d);
341
347
  } catch (e) {
342
- keepMarked(p, was);
348
+ giveBack(lock);
343
349
  throw e;
344
350
  }
351
+ return lock;
345
352
  }
346
353
 
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.
354
+ // The day rule: folders of an earlier run, marked, past a day, never a link or a folder without the marker (removed
355
+ // by run-folder.mjs, the marker last). One that cannot be removed (a held handle, a folder this account cannot
356
+ // empty) goes to `left` with why; the rest go on.
358
357
  function dayRule(base, now, left) {
359
358
  const removed = [];
360
359
  for (const name of listDir(base)) {
@@ -362,7 +361,7 @@ function dayRule(base, now, left) {
362
361
  const st = ANY_RUN.test(name) ? lstatOr(p) : null;
363
362
  if (!st?.isDirectory() || now - st.mtimeMs <= DAY_MS || !isFile(join(p, ".ralph-qa"))) continue;
364
363
  try {
365
- removeMarked(p);
364
+ removeMarked(p, ".ralph-qa");
366
365
  removed.push(p);
367
366
  } catch (e) {
368
367
  left.push({ path: p, why: e.code || e.message });
@@ -376,7 +375,7 @@ function removeOwn(folders) {
376
375
  const stayed = [];
377
376
  for (const p of folders.filter(Boolean)) {
378
377
  try {
379
- removeMarked(p);
378
+ removeMarked(p, ".ralph-qa");
380
379
  } catch (e) {
381
380
  stayed.push(`${p} (${e.code || e.message})`);
382
381
  }
@@ -430,7 +429,7 @@ const readIdOr = (d) => {
430
429
  };
431
430
 
432
431
  async function cleanup(o, ctx) {
433
- const { d, o: out } = runFolder(o.dir);
432
+ const { d, o: out } = runPair(o.dir, ctx);
434
433
  const pid = o.force ? null : liveLock(d, ctx.now());
435
434
  if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid}). 끝난 뒤 다시 한다`);
436
435
  const { known } = bases(ctx);
@@ -586,8 +585,8 @@ async function cliChecks(ctx, seat, plan, exe) {
586
585
  }
587
586
 
588
587
  // 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];
588
+ function partsOf(payload, n, { id, short, shortSha }) {
589
+ const parts = n ? splitPayload(payload, n, id, short, shortSha) : [payload];
591
590
  parts.forEach((part, k) => {
592
591
  if (markerCount(part, id) !== 2 || scan(part.toString("utf8")).count) throw new Stop("split", `부분 ${k + 1}/${parts.length} 의 표지나 검사가 맞지 않는다`);
593
592
  });
@@ -699,7 +698,7 @@ async function seatWork(ctx, run, seat, g, payload, base) {
699
698
  if (why) return { ...base, why };
700
699
  let parts;
701
700
  try {
702
- parts = partsOf(payload, run.split[seat.cli], run.id, run.short);
701
+ parts = partsOf(payload, run.split[seat.cli], run);
703
702
  } catch (e) {
704
703
  if (e instanceof Stop) return { ...base, why: "split", notes: [e.message] };
705
704
  throw e;
@@ -708,10 +707,37 @@ async function seatWork(ctx, run, seat, g, payload, base) {
708
707
  return runParts(ctx, run, seat, g, parts, base);
709
708
  }
710
709
 
710
+ // The seat CLI's version, read without starting gemini or opencode: codex's from the probe's `codex --version`, the
711
+ // others' from the package.json of their npm package (beside the entry point, above its real path, or under the
712
+ // prefix's lib/node_modules), opencode's own installer folder holding the plugin package of its version.
713
+ const PACKAGES = { codex: "@openai/codex", gemini: "@google/gemini-cli", opencode: "opencode-ai" };
714
+ const readJson = (p) => {
715
+ try {
716
+ return JSON.parse(readFileSync(p, "utf8"));
717
+ } catch {
718
+ return null;
719
+ }
720
+ };
721
+ function installedVersion(cli, exe) {
722
+ const real = realOr(exe);
723
+ const pkg = PACKAGES[cli].split("/");
724
+ const tries = [join(dirname(exe), "node_modules", ...pkg, "package.json"), join(dirname(dirname(exe)), "lib", "node_modules", ...pkg, "package.json")];
725
+ for (let dir = dirname(real), k = 0; k < 5; k += 1, dir = dirname(dir)) tries.push(join(dir, "package.json"));
726
+ const found = tries.map(readJson).find((j) => j?.name === PACKAGES[cli] && typeof j.version === "string");
727
+ if (found) return found.version;
728
+ const own = cli === "opencode" ? readJson(join(dirname(dirname(real)), "package.json"))?.dependencies?.["@opencode-ai/plugin"] : null;
729
+ return typeof own === "string" ? `${own} (설치 폴더의 @opencode-ai/plugin)` : null;
730
+ }
731
+ export function cliVersion(cli, exe, probed) {
732
+ if (cli === "codex" && probed?.version) return String(probed.version).slice(0, 80);
733
+ return installedVersion(cli, exe) || "알 수 없음";
734
+ }
735
+
711
736
  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: [] };
737
+ 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
738
  const g = guards(ctx, seat, run);
714
- if (g.why) return { ...base, why: g.why, notes: g.notes || [] };
739
+ if (g.why) return { ...none, why: g.why, notes: g.notes || [] };
740
+ const base = { ...none, cliVersion: cliVersion(seat.cli, g.exe, g.probed) };
715
741
  let result;
716
742
  try {
717
743
  mkdirSync(join(run.d, seat.cli), { recursive: true, mode: 0o700 });
@@ -719,7 +745,7 @@ async function sendSeat(ctx, run, seat, payload) {
719
745
  } catch (e) {
720
746
  // the runner's own error in this seat's work (its copy cannot be written: ENOSPC, EACCES, a Windows lock) is this
721
747
  // 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;
748
+ if (e instanceof RunStop) throw e;
723
749
  result = { ...base, status: "failed", why: "spawn", notes: [`좌석을 준비하지 못했다(${e.code || e.message})`] };
724
750
  } finally {
725
751
  const notes = await afterSeat(ctx, run, seat);
@@ -775,29 +801,45 @@ function declared(run, bytes, seats, d) {
775
801
  }
776
802
 
777
803
  function sendStart(o, ctx, a) {
778
- const { d, o: out } = runFolder(o.dir);
804
+ const { d, o: out } = runPair(o.dir, ctx);
779
805
  if (!out) throw new Stop("not-a-run-folder", `답 폴더(ralph-qa-out.*)가 없다: ${d}`);
780
806
  refuseSent(d);
781
807
  if (ctx.env.CODEX_SANDBOX_NETWORK_DISABLED === "1" && !a.dryRun) throw new Stop("sandboxed", "Codex sandbox 가 네트워크를 막는다. 승인을 받아 sandbox 밖에서 send 를 다시 한다");
782
- const { id, short } = readRun(d);
808
+ const { id, short, shortsha } = readRun(d);
783
809
  const payload = payloadToSend(d, id, Boolean(o["allow-large"]));
784
- return { d, out, id, payload, short: Number(short) };
810
+ return { d, out, id, payload, short: Number(short), shortSha: shortsha || null };
785
811
  }
786
812
 
813
+ const resultJson = (command, result) => `${JSON.stringify({ command, ...result }, null, 2)}\n`;
814
+ const writeResult = (dir, name, json) => {
815
+ try {
816
+ writeFileSync(join(dir, `result-${name}.md`), json, { mode: 0o600 });
817
+ } catch {
818
+ /* the JSON on stdout is the answer */
819
+ }
820
+ };
821
+
822
+ // A send that went out writes result-send.md before the lock goes back, so the next send to take the lock finds it
823
+ // (report writes it again with the same JSON).
787
824
  async function send(o, ctx) {
788
825
  const a = sendArgs(o);
789
- const { d, out, id, payload, short } = sendStart(o, ctx, a);
790
- const lock = takeLock(d, ctx.now());
826
+ const { d, out, id, payload, short, shortSha } = sendStart(o, ctx, a);
827
+ const lock = holdFolder(d, ctx.now());
791
828
  const touch = keepFresh(lock);
792
829
  const signals = watchSignals();
793
- const run = { ...a, d, o: out, id, short, signalled: () => signals.code() !== 0 };
830
+ const run = { ...a, d, o: out, id, short, shortSha, signalled: () => signals.code() !== 0 };
794
831
  try {
832
+ // The folder is marked sent under the lock, before any seat writes into it: a send that lost the A2 window stops
833
+ // here, before it could overwrite the winner's prompt copies or clean the winner's seat folders.
834
+ if (!a.dryRun) markOrStop(d, ctx.now);
795
835
  // every seat settles before the lock goes back and the stop handlers go away
796
836
  const settled = await Promise.allSettled(a.seats.map((x) => sendSeat(ctx, run, x, payload)));
797
837
  const thrown = settled.find((x) => x.status === "rejected");
798
838
  if (thrown) throw thrown.reason;
799
839
  const seats = settled.map((x) => x.value);
840
+ 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
841
  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) };
842
+ if (!a.dryRun) writeResult(d, "send", resultJson("send", result));
801
843
  return [result, signals.code(), a.dryRun ? null : d];
802
844
  } finally {
803
845
  clearInterval(touch);
@@ -810,42 +852,41 @@ const COMMANDS = { init, untracked: listUntracked, prepare, check, send, cleanup
810
852
 
811
853
  // The JSON on stdout and in <d>/result-<command>.md (not for cleanup: the folder is gone).
812
854
  function report(ctx, command, dir, result, code, name = command) {
813
- const json = `${JSON.stringify({ command, ...result }, null, 2)}\n`;
855
+ const json = resultJson(command, result);
814
856
  ctx.stdout.write(json);
815
857
  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
- }
858
+ if (dir) writeResult(dir, name, json);
823
859
  return code;
824
860
  }
825
861
 
826
862
  // A stop in the folder it concerns, when that is a run folder.
827
- const folderOf = (o) => {
863
+ const folderOf = (o, ctx) => {
828
864
  try {
829
- return o.dir ? runFolder(o.dir).d : null;
865
+ return o.dir ? runPair(o.dir, ctx).d : null;
830
866
  } catch {
831
867
  return null;
832
868
  }
833
869
  };
834
870
 
835
- // A send that stopped goes to result-send-stop.md: result-send.md says the folder was sent, and a stopped send sent nothing.
871
+ // A send that stopped goes to result-send-stop.md, not result-send.md (that one says every seat ended). A stopped
872
+ // send may still have carried the payload to some seats: .sent.md, written under the lock before any seat work, is what
873
+ // keeps the next send from sending it again.
836
874
  const stopName = (command) => (command === "send" ? "send-stop" : command);
837
875
 
876
+ // A stop of run-folder.mjs carries no name: this runner's goes before it.
877
+ const named = (message) => (message.startsWith("ralph-qa: ") ? message : `ralph-qa: ${message}`);
878
+
838
879
  function failed(ctx, { command, o }, e) {
839
880
  if (e instanceof Usage) {
840
881
  ctx.stderr.write(`ralph-qa: ${e.message}\n${USAGE}`);
841
882
  return 2;
842
883
  }
843
- if (!(e instanceof Stop)) {
884
+ if (!(e instanceof RunStop)) {
844
885
  ctx.stderr.write(`ralph-qa: 실행기 내부 오류(${e?.code || e?.name || "error"}): ${String(e?.message || e).split("\n")[0]}\n`);
845
886
  return 2;
846
887
  }
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));
888
+ const result = { ok: false, stop: e.code, message: named(e.message), detail: e.detail };
889
+ return report(ctx, command, folderOf(o, ctx), result, EXIT_OF[e.code] || 3, stopName(command));
849
890
  }
850
891
 
851
892
  // ---- the send's own node ----
@@ -54,6 +54,8 @@ 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 ...` 으로 쓴다.
58
+ - `for` 반복으로 시험을 여러 번 돌릴 때 종료 코드는 마지막 반복 것이라 앞 반복의 실패가 가려진다(POSIX `run.sh` 도 같다). 각 반복에 `|| exit /b 1`(POSIX `|| exit 1`)을 붙여 한 번이라도 실패하면 비정상 종료로 끝낸다.
57
59
  - 오래 걸리면 `--timeout <분>`(기본 20)을 늘린다.
58
60
  4. 출력 JSON 의 박스별 `summary`(tests, pass, fail, skipped, failures)와 `log` 파일을 근거로 쓴다. 종료 코드는 다음과 같다.
59
61
  - 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`] : [];
@@ -166,16 +166,27 @@ export function setKey(text, table, key, rawValue, comment = '') {
166
166
  return ls.join(nl);
167
167
  }
168
168
  const range = tableRange(scan(text), table);
169
- if (!range) {
170
- while (ls.length && ls[ls.length - 1] === '') ls.pop();
171
- return [...ls, ...(ls.length ? [''] : []), `[${table}]`, ...line, ''].join(nl);
172
- }
173
- // 다음 표 머리에 붙은 주석(`# MCP servers` 처럼 머리 바로 위의 주석 줄)과 그 앞 빈 줄은 건너뛰고 넣는다.
169
+ if (!range) return withNewTable(ls, table, line).join(nl);
170
+ const [at, after] = placeIn(ls, range);
171
+ ls.splice(at, 0, ...line, ...after);
172
+ return ls.join(nl);
173
+ }
174
+
175
+ function withNewTable(ls, table, line) {
176
+ while (ls.length && ls[ls.length - 1] === '') ls.pop();
177
+ return [...ls, ...(ls.length ? [''] : []), `[${table}]`, ...line, ''];
178
+ }
179
+
180
+ // 표 안에서 새 키를 넣을 줄 번호와 키 뒤에 더할 줄. 다음 표 머리에 붙은 주석(`# MCP servers` 처럼 머리 바로 위의 주석
181
+ // 줄)과 그 앞 빈 줄은 건너뛴다. 건너뛴 주석이 파일 첫 줄까지 닿으면 파일 머리 주석으로 보고 그 뒤에 넣고, 키와 표 머리
182
+ // 사이에 빈 줄 하나를 둔다(파일 첫 줄부터 표 설명 주석인 파일도 이렇게 되지만 값의 뜻은 같다).
183
+ function placeIn(ls, range) {
174
184
  let at = range[1];
175
- if (range[1] < ls.length) while (at > range[0] && ls[at - 1].trim().startsWith('#')) at--;
185
+ const beforeHead = range[1] < ls.length;
186
+ if (beforeHead) while (at > range[0] && ls[at - 1].trim().startsWith('#')) at--;
187
+ if (beforeHead && at === 0 && range[1] > 0) return [range[1], ['']];
176
188
  while (at > range[0] && ls[at - 1].trim() === '') at--;
177
- ls.splice(at, 0, ...line);
178
- return ls.join(nl);
189
+ return [at, []];
179
190
  }
180
191
 
181
192
  // 키를 뺀다. comment 와 같은 주석 줄이 바로 위에 있으면 그것도 뺀다.
@@ -6,7 +6,7 @@ description: "범용 MCP 서버(context7, sequential-thinking, filesystem, git,
6
6
  # setup-mcp: 범용 MCP 서버 설치
7
7
 
8
8
  context7·sequential-thinking·filesystem·git·fetch - 특정 도구에 종속되지 않는 5종 표준 MCP 서버를
9
- Claude Code·Codex 양쪽에 배선한다. **`setup-omc`(OMC/OMX 프레임워크 자체 설치, 자기 도구를 따로 들고 옴)와도,
9
+ Claude Code·Codex 양쪽에 배선한다. **`setup-omc`(Codex 는 `setup-omx`. OMC/OMX 프레임워크 자체 설치, 자기 도구를 따로 들고 옴)와도,
10
10
  `setup-lsp`(`lsp_bridge` 전용)와도 다르다** - 이 스킬은 그 외 범용 standalone 서버만 다룬다. Serena 는
11
11
  프로젝트 스코프(`--project` 가 홈 디렉터리면 인덱싱 과부하)라 여기서 제외한다. 설치는 **멱등**(있으면
12
12
  remove 후 재-add). 답변은 한글(기술 토큰 영문).