@kaydash9999/banker-plugins 0.20.2 → 0.20.5

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +179 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +25 -2
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +4 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +9 -6
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -0,0 +1,956 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * seat-run: the external seat send of ralph-qa (the skill's "외부 좌석 전송") as one program: the folders, the
4
+ * payload and its checks, the seats with their read-only guards and time limit, and the cleanup. The skill gives it
5
+ * paths and choices; no seat command or git chain is put together by hand.
6
+ *
7
+ * node seat-run.mjs init the payload folder <d> and the answer folder <o>
8
+ * node seat-run.mjs untracked --repo <repo> [--folder <folder>] the new files git does not track, by exact name
9
+ * node seat-run.mjs prepare --dir <d> --repo <repo> --base <base|auto> --id <id> short.md + spec.md -> prompt.md
10
+ * node seat-run.mjs check --dir <d> prompt.md again, after the author masked it
11
+ * node seat-run.mjs send --dir <d> --runtime claude|codex --seat <cli>=<model|@cli-default> ...
12
+ * [--effort <v>] [--allow-large] [--split <cli>=<n>] [--limit <s>] [--dry-run]
13
+ * node seat-run.mjs cleanup --dir <d> [--force] the seat records, old runs' folders, <d> and <o>
14
+ * node seat-run.mjs put --dir <d> --name short.md|spec.md writes the file from standard input
15
+ *
16
+ * Out: one JSON object on stdout, the same in <d>/result-<command>.md (a PowerShell 5.1 console may garble it), and
17
+ * `ralph-qa: ` lines on stderr. Exit 0 done, 1 secret-like lines, 2 a usage or internal error, 3 a stop (`stop` in
18
+ * the JSON says which), 4 a payload past 512 KiB, 5 a send of the folder still runs. Every file name ends in .md
19
+ * (the DRM of a closed network encrypts files by their extension, and .md is one it leaves readable).
20
+ */
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";
25
+ import { fileURLToPath } from "node:url";
26
+ import { POLICY, cleanSeat, policyIntact, strayEnvFiles, sweepBase } from "./gemini-seat.mjs";
27
+ import { scan } from "./payload-scan.mjs";
28
+ import { MAX_BYTES, Stop, buildPayload, checkPayload, gitAt, markerCount, readRun, splitPayload, untracked } from "./seat-payload.mjs";
29
+ import { cmdLine, runLimited, stopAll, which } from "./seat-spawn.mjs";
30
+ import { CLI_DEFAULT, probe as localProbe, safeModel } from "./verifier-probe.mjs";
31
+
32
+ // 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
+ 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
+ "base-not-writable", "inside-repo", "busy", "not-prepared", "not-a-run-folder", "split", "cleanup-left"];
36
+ const EXIT_OF = { scan: 1, "too-large": 4, busy: 5 };
37
+
38
+ const DAY_MS = 86400 * 1000;
39
+ const STALE_LOCK_MS = 15 * 60 * 1000;
40
+ const RUN_NAME = /^ralph-qa\.[A-Za-z0-9]+$/;
41
+ // A run folder of any version: bash's six characters, PowerShell's 32 of a guid, this runner's six.
42
+ const ANY_RUN = /^ralph-qa(-out)?\.[A-Za-z0-9]+$/;
43
+ // The lock of a send that runs: one name, made with "wx", holding the pid and the time.
44
+ const LOCK_NAME = ".running.md";
45
+
46
+ class Usage extends Error {}
47
+ const USAGE = "usage: node seat-run.mjs init | untracked --repo <repo> [--folder <f>] | prepare --dir <d> --repo <repo> --base <base|auto> --id <id>"
48
+ + " | check --dir <d> | send --dir <d> --runtime claude|codex --seat <cli>=<model> ... | cleanup --dir <d> [--force] | put --dir <d> --name short.md|spec.md\n";
49
+
50
+ // The flags of each command: a plain name takes one value, `*` takes many, `!` takes none.
51
+ const FLAGS = {
52
+ init: [], untracked: ["repo", "folder"], prepare: ["dir", "repo", "base", "id"], check: ["dir"],
53
+ send: ["dir", "runtime", "seat*", "effort", "allow-large!", "split*", "limit", "dry-run!"], cleanup: ["dir", "force!"], put: ["dir", "name"],
54
+ };
55
+ const REQUIRED = { untracked: ["repo"], prepare: ["dir", "repo", "base", "id"], check: ["dir"], send: ["dir", "runtime", "seat"], cleanup: ["dir"], put: ["dir", "name"] };
56
+
57
+ function takeFlag(o, flag, name, value) {
58
+ if (flag.endsWith("*")) (o[name] = o[name] || []).push(value);
59
+ else if (name in o) throw new Usage(`--${name} 를 두 번 줬다`);
60
+ else o[name] = value;
61
+ }
62
+
63
+ // One flag at rest[i] into o; gives the index of the next one.
64
+ function readFlag(spec, rest, i, o) {
65
+ const m = /^--([a-z-]+)(?:=([\s\S]*))?$/.exec(rest[i]);
66
+ const flag = m && spec.find((f) => f.replace(/[*!]$/, "") === m[1]);
67
+ if (!flag) throw new Usage(`모르는 인자다: ${String(rest[i]).slice(0, 80)}`);
68
+ if (flag.endsWith("!")) {
69
+ o[m[1]] = true;
70
+ return i + 1;
71
+ }
72
+ const value = m[2] !== undefined ? m[2] : rest[i + 1];
73
+ if (value === undefined) throw new Usage(`--${m[1]} 에 값이 없다`);
74
+ takeFlag(o, flag, m[1], value);
75
+ return m[2] !== undefined ? i + 1 : i + 2;
76
+ }
77
+
78
+ export function parseArgs(argv) {
79
+ const [command, ...rest] = argv;
80
+ const spec = Object.prototype.hasOwnProperty.call(FLAGS, command) ? FLAGS[command] : null;
81
+ if (!spec) throw new Usage(`모르는 명령이다: ${command ?? "(없음)"}`);
82
+ const o = {};
83
+ for (let i = 0; i < rest.length; ) i = readFlag(spec, rest, i, o);
84
+ for (const r of REQUIRED[command] || []) if (!(r in o)) throw new Usage(`--${r} 가 필요하다`);
85
+ return { command, o };
86
+ }
87
+
88
+ // A path as Git Bash writes it on Windows (/c/Users/...) as Windows reads it (C:\Users\...). Git Bash turns such a
89
+ // word into C:\... for a Windows program only some of the time (not one with Korean and brackets), and git cannot go
90
+ // to /c/...: the runner does it for --repo and --dir. Any other path, and every path off Windows, stays.
91
+ export function fromMsys(p, platform) {
92
+ const m = platform === "win32" ? /^\/([A-Za-z])(?:\/([\s\S]*))?$/.exec(String(p)) : null;
93
+ return m ? `${m[1].toUpperCase()}:\\${(m[2] || "").replace(/\//g, "\\")}` : p;
94
+ }
95
+
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
+ }
119
+ const o = join(dirname(d), basename(d).replace(/^ralph-qa\./, "ralph-qa-out."));
120
+ const answers = lstatOr(o);
121
+ return { d, o: answers?.isDirectory() && isFile(join(o, ".ralph-qa")) ? o : null };
122
+ }
123
+
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) {
175
+ for (let tries = 0; tries < 20; tries += 1) {
176
+ const d = mkdtempSync(join(base, "ralph-qa."));
177
+ const o = join(base, basename(d).replace(/^ralph-qa\./, "ralph-qa-out."));
178
+ try {
179
+ mkdirSync(o, { mode: 0o700 });
180
+ for (const p of [d, o]) writeFileSync(join(p, ".ralph-qa"), "", { mode: 0o600 });
181
+ return { d, o };
182
+ } catch (e) {
183
+ rmSync(d, { recursive: true, force: true });
184
+ if (e.code !== "EEXIST") throw e;
185
+ }
186
+ }
187
+ throw new Stop("base-not-writable", `기준 폴더에 새 폴더 이름을 정하지 못했다: ${base}`);
188
+ }
189
+
190
+ // Whether `dir` is inside a git work tree: a seat would then read the repo through its tools.
191
+ function insideRepo(ctx, dir) {
192
+ try {
193
+ return gitAt(dir, ctx.env, dir)(["rev-parse", "--git-dir"], { ok: [0, 128] }).status === 0;
194
+ } catch {
195
+ return false; // no git: no repo either
196
+ }
197
+ }
198
+
199
+ 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
+ }
209
+ if (insideRepo(ctx, made.d)) {
210
+ for (const p of [made.d, made.o]) rmSync(p, { recursive: true, force: true });
211
+ throw new Stop("inside-repo", `기준 폴더가 git 저장소 안이다: ${base}`);
212
+ }
213
+ return [{ ok: true, dir: made.d, out: made.o, base }, 0, made.d];
214
+ }
215
+
216
+ function listUntracked(o, ctx) {
217
+ return [{ ok: true, ...untracked({ repo: o.repo, env: ctx.env, folder: o.folder || null }) }, 0, null];
218
+ }
219
+
220
+ const sent = (d) => existsSync(join(d, "result-send.md"));
221
+ const refuseSent = (d) => {
222
+ if (sent(d)) throw new Stop("sent", `이미 보낸 폴더다. 반복마다 init 으로 새 폴더를 만든다: ${d}`);
223
+ };
224
+
225
+ // prepare and check end with 1 when a line looks like a secret and 4 past 512 KiB; the JSON carries both.
226
+ function verdict(result) {
227
+ if (result.scan.count) {
228
+ const lines = result.scan.hits.map((h) => `${h.line}(${h.kind}${h.end === null ? ", 끝 줄 없음" : ""})`);
229
+ return [{ ...result, ok: false, stop: "scan", message: `ralph-qa: 비밀처럼 보이는 줄이 있다: ${lines.join(", ")}. 값을 [REDACTED] 로 가린 뒤 check 를 다시 한다` }, 1];
230
+ }
231
+ if (result.bytes > MAX_BYTES) {
232
+ return [{ ...result, ok: false, stop: "too-large", message: `ralph-qa: 페이로드가 512 KiB 를 넘는다(${result.bytes}바이트). 보내기 전에 사용자에게 묻는다` }, 4];
233
+ }
234
+ return [{ ...result, ok: true }, 0];
235
+ }
236
+
237
+ function prepare(o, ctx) {
238
+ const { d } = runFolder(o.dir);
239
+ refuseSent(d);
240
+ const built = buildPayload({ dir: d, repo: o.repo, base: o.base, id: o.id, env: ctx.env });
241
+ const [result, code] = verdict({ dir: d, prompt: join(d, "prompt.md"), raw: join(d, "raw.md"), unsentFile: join(d, "unsent.md"), ...built });
242
+ return [result, code, d];
243
+ }
244
+
245
+ function check(o) {
246
+ const { d } = runFolder(o.dir);
247
+ const id = readRun(d).id;
248
+ const c = checkPayload({ dir: d, id });
249
+ if (c.markers !== 2) throw new Stop("markers", `표지가 두 줄이 아니다(${c.markers}줄). 가리다 표지를 지웠으면 prepare 를 다시 한다`);
250
+ const [result, code] = verdict({ dir: d, prompt: join(d, "prompt.md"), bytes: c.bytes, markers: c.markers, scan: c.scan });
251
+ return [result, code, d];
252
+ }
253
+
254
+ const readStdin = (ctx) => {
255
+ if (ctx.stdin !== undefined) return Buffer.from(ctx.stdin);
256
+ try {
257
+ return readFileSync(0);
258
+ } catch {
259
+ return Buffer.alloc(0);
260
+ }
261
+ };
262
+
263
+ function put(o, ctx) {
264
+ 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);
266
+ refuseSent(d);
267
+ const data = readStdin(ctx);
268
+ writeFileSync(join(d, o.name), data, { mode: 0o600 });
269
+ return [{ ok: true, written: join(d, o.name), bytes: data.length }, 0, d];
270
+ }
271
+
272
+ const alive = (pid) => {
273
+ try {
274
+ process.kill(pid, 0);
275
+ return true;
276
+ } catch (e) {
277
+ return e.code === "EPERM";
278
+ }
279
+ };
280
+
281
+ const lockPid = (d) => {
282
+ try {
283
+ return Number(readFileSync(join(d, LOCK_NAME), "utf8").split(" ")[0]) || null;
284
+ } catch {
285
+ return null;
286
+ }
287
+ };
288
+
289
+ // A send of the folder that still runs: the lock of a live process, touched in the last 15 minutes (a send touches
290
+ // it every minute; a lock left by a send that was killed, or one whose number another process now has, goes stale).
291
+ export function liveLock(d, now = Date.now()) {
292
+ const pid = lockPid(d);
293
+ const st = pid && lstatOr(join(d, LOCK_NAME));
294
+ return st && st.isFile() && pid !== process.pid && now - st.mtimeMs < STALE_LOCK_MS && alive(pid) ? pid : null;
295
+ }
296
+
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) {
302
+ try {
303
+ writeFileSync(lock, `${process.pid} ${new Date(now).toISOString()}\n`, { flag: "wx", mode: 0o600 });
304
+ return lock;
305
+ } catch (e) {
306
+ if (e.code !== "EEXIST") throw e;
307
+ }
308
+ const pid = liveLock(d, now);
309
+ if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid})`);
310
+ rmSync(lock, { force: true });
311
+ }
312
+ throw new Stop("busy", "이 폴더의 send 가 방금 시작했다");
313
+ }
314
+
315
+ // Touches the lock every `every` ms while a send runs: a send past 15 minutes (split parts) stays live to cleanup.
316
+ export function keepFresh(lock, every = 60000) {
317
+ const timer = setInterval(() => {
318
+ try {
319
+ utimesSync(lock, new Date(), new Date());
320
+ } catch {
321
+ /* the lock is gone: nothing to keep */
322
+ }
323
+ }, every);
324
+ timer.unref();
325
+ return timer;
326
+ }
327
+
328
+ // The lock goes back only while it is this process's (a send past 15 minutes without its touches may have lost it).
329
+ function giveBack(lock) {
330
+ if (lockPid(dirname(lock)) === process.pid) rmSync(lock, { force: true });
331
+ }
332
+
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);
339
+ try {
340
+ rmSync(p, { recursive: true, force: true, maxRetries: 3 });
341
+ } catch (e) {
342
+ keepMarked(p, was);
343
+ throw e;
344
+ }
345
+ }
346
+
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.
358
+ function dayRule(base, now, left) {
359
+ const removed = [];
360
+ for (const name of listDir(base)) {
361
+ const p = join(base, name);
362
+ const st = ANY_RUN.test(name) ? lstatOr(p) : null;
363
+ if (!st?.isDirectory() || now - st.mtimeMs <= DAY_MS || !isFile(join(p, ".ralph-qa"))) continue;
364
+ try {
365
+ removeMarked(p);
366
+ removed.push(p);
367
+ } catch (e) {
368
+ left.push({ path: p, why: e.code || e.message });
369
+ }
370
+ }
371
+ return removed;
372
+ }
373
+
374
+ // This run's folders; any that stays is a stop (the payload copy would stay on disk), named with why.
375
+ function removeOwn(folders) {
376
+ const stayed = [];
377
+ for (const p of folders.filter(Boolean)) {
378
+ try {
379
+ removeMarked(p);
380
+ } catch (e) {
381
+ stayed.push(`${p} (${e.code || e.message})`);
382
+ }
383
+ }
384
+ if (stayed.length) throw new Stop("cleanup-left", "이 실행의 폴더를 다 지우지 못했다. 사용자에게 경로를 알린다", stayed);
385
+ }
386
+
387
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
388
+ // An opencode session of this run, or of a seat folder under one of the bases that is gone (a run cut short).
389
+ // opencode records the folder resolved through links (macOS's /var is a link to /private/var).
390
+ function staleSession(ctx, s, forms) {
391
+ if (typeof s?.title !== "string" || !SESSION_ID.test(String(s.id))) return false;
392
+ return (Boolean(ctx.id) && s.title === `ralph-qa-review-${ctx.id}`) || goneSeat(ctx, s, forms);
393
+ }
394
+ function goneSeat(ctx, s, forms) {
395
+ const dir = String(s.directory || "");
396
+ if (!s.title.startsWith("ralph-qa-review-") || basename(dir) !== "opencode" || !RUN_NAME.test(basename(dirname(dir)))) return false;
397
+ return forms.has(norm(ctx, dirname(dirname(dir)))) && !existsSync(dir);
398
+ }
399
+
400
+ async function opencodeCall(ctx, exe, args, out) {
401
+ const env = { ...ctx.env, OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_AUTOUPDATE: "1" };
402
+ return runLimited({ exe, args, cwd: ctx.d, env, stdin: "ignore", stdout: out, stderr: "ignore", limitMs: 60000 });
403
+ }
404
+
405
+ async function opencodeSessions(ctx, known) {
406
+ const exe = which("opencode", { env: ctx.env, platform: ctx.platform });
407
+ if (!exe) return { deleted: 0, note: "opencode 가 없다" };
408
+ const out = join(ctx.d, ".opencode-sessions.md");
409
+ const r = await opencodeCall(ctx, exe, ["session", "list", "--pure", "--format", "json"], out);
410
+ let sessions = [];
411
+ try {
412
+ sessions = r.exit === 0 ? JSON.parse(readFileSync(out, "utf8")) : [];
413
+ } catch {
414
+ return { deleted: 0, note: "opencode 의 세션 목록을 읽지 못했다" };
415
+ }
416
+ const forms = new Set(known.flatMap((b) => [norm(ctx, b), norm(ctx, realOr(b))]));
417
+ let deleted = 0;
418
+ for (const s of Array.isArray(sessions) ? sessions.filter((x) => staleSession(ctx, x, forms)) : []) {
419
+ if ((await opencodeCall(ctx, exe, ["session", "delete", String(s.id), "--pure"], "ignore")).exit === 0) deleted += 1;
420
+ }
421
+ return { deleted };
422
+ }
423
+
424
+ const readIdOr = (d) => {
425
+ try {
426
+ return readRun(d).id;
427
+ } catch {
428
+ return null;
429
+ }
430
+ };
431
+
432
+ async function cleanup(o, ctx) {
433
+ const { d, o: out } = runFolder(o.dir);
434
+ const pid = o.force ? null : liveLock(d, ctx.now());
435
+ if (pid) throw new Stop("busy", `이 폴더의 send 가 아직 돈다(pid ${pid}). 끝난 뒤 다시 한다`);
436
+ const { known } = bases(ctx);
437
+ const all = [...new Set([dirname(d), ...known])];
438
+ const gemini = cleanSeat(join(d, "gemini"), { env: ctx.env, platform: ctx.platform });
439
+ const left = [];
440
+ const swept = all.flatMap((b) => dayRule(b, ctx.now(), left));
441
+ for (const b of all) gemini.skipped.push(...sweepBase(b, { env: ctx.env, platform: ctx.platform }).skipped);
442
+ for (const p of gemini.skipped) ctx.stderr.write(`ralph-qa: gemini 기록 폴더를 남겼다(두 프로젝트가 그 이름을 씀): ${p}\n`);
443
+ const opencode = await opencodeSessions({ ...ctx, d, id: readIdOr(d) }, all);
444
+ removeOwn([d, out]);
445
+ return [{ ok: true, removed: [d, out].filter(Boolean), swept, left, gemini, opencode }, 0, null];
446
+ }
447
+
448
+ // ---- send
449
+
450
+ // Why a seat was not sent (`not-sent`) or its run counts for nothing (`failed`); `probe:<reason>` carries the probe's.
451
+ export const SEAT_WHY = ["rejected", "timeout", "exit", "signal", "spawn", "empty", "verdict", "policy-file-error", "fallback-agent",
452
+ "model-mismatch", "default-model-family", "probe", "unsafe-model", "cli-absent", "unsafe-word", "gemini-policy", "gemini-env-file",
453
+ "opencode-agent", "split"];
454
+ const LIMIT_S = 600;
455
+ const CHECK_LIMIT_MS = 120000;
456
+ // Lines of a seat's errors that say the provider refused the model (the author may then seat another, before a first verdict).
457
+ const REJECT_HINT = /\b40[34]\b|model_not_found|model not found|does not exist|PERMISSION_DENIED|permission denied/i;
458
+ const VERDICT_LINE = /^VERDICT: (APPROVE|ITERATE|REJECT|ERROR)\s*$/;
459
+ const RANK = { APPROVE: 0, ITERATE: 1, REJECT: 2, ERROR: 3 };
460
+
461
+ function seatArg(v) {
462
+ const m = /^(codex|gemini|opencode)=([\s\S]+)$/.exec(String(v));
463
+ if (!m) throw new Usage(`--seat 은 <cli>=<모델> 이다(cli 는 codex, gemini, opencode): ${String(v).slice(0, 80)}`);
464
+ return { cli: m[1], model: m[2] };
465
+ }
466
+ function splitArg(v) {
467
+ const m = /^(codex|gemini|opencode)=(\d{1,2})$/.exec(String(v));
468
+ if (!m || Number(m[2]) < 2) throw new Usage(`--split 은 <cli>=<2 이상의 수> 다: ${String(v).slice(0, 80)}`);
469
+ return [m[1], Number(m[2])];
470
+ }
471
+ function limitOf(o) {
472
+ const limit = o.limit === undefined ? LIMIT_S : Number(o.limit);
473
+ if (!Number.isInteger(limit) || limit < 1 || limit > LIMIT_S) throw new Usage(`--limit 은 1~600 초다: ${String(o.limit).slice(0, 40)}`);
474
+ return limit * 1000;
475
+ }
476
+ function sendArgs(o) {
477
+ if (!["claude", "codex"].includes(o.runtime)) throw new Usage(`--runtime 은 claude 나 codex 다: ${String(o.runtime).slice(0, 40)}`);
478
+ const seats = o.seat.map(seatArg);
479
+ if (seats.length > 3 || new Set(seats.map((x) => x.cli)).size !== seats.length) throw new Usage("--seat 는 CLI 마다 한 번, 셋까지다");
480
+ const split = Object.fromEntries((o.split || []).map(splitArg));
481
+ for (const cli of Object.keys(split)) if (!seats.some((x) => x.cli === cli)) throw new Usage(`--split 의 ${cli} 는 보내는 좌석이 아니다`);
482
+ if (o.effort !== undefined && !/^[a-z]{1,16}$/.test(o.effort)) throw new Usage(`--effort 값이 아니다: ${String(o.effort).slice(0, 40)}`);
483
+ return { seats, split, limitMs: limitOf(o), effort: o.effort, runtime: o.runtime, dryRun: Boolean(o["dry-run"]) };
484
+ }
485
+
486
+ // What goes out, read once: the same bytes are checked here and copied for every seat.
487
+ function payloadToSend(d, id, allowLarge) {
488
+ let buf;
489
+ try {
490
+ buf = readFileSync(join(d, "prompt.md"));
491
+ } catch {
492
+ throw new Stop("not-prepared", `prompt.md 가 없다. prepare 를 먼저 한다: ${d}`);
493
+ }
494
+ const markers = markerCount(buf, id);
495
+ if (markers !== 2) throw new Stop("markers", `표지가 두 줄이 아니다(${markers}줄). 가리다 표지를 지웠으면 prepare 를 다시 한다`);
496
+ let found;
497
+ try {
498
+ found = scan(buf.toString("utf8"));
499
+ } catch (e) {
500
+ throw new Stop("scan", `비밀 검사를 끝내지 못해 보내지 않는다(${String(e?.message || e).split("\n")[0]})`);
501
+ }
502
+ if (found.count) throw new Stop("scan", `비밀처럼 보이는 줄이 있어 보내지 않는다: ${found.hits.map((h) => h.line).join(", ")}. 가린 뒤 check 를 다시 한다`);
503
+ if (buf.length > MAX_BYTES && !allowLarge) throw new Stop("too-large", `페이로드가 512 KiB 를 넘는다(${buf.length}바이트). 사용자가 보내기로 하면 --allow-large 로 다시 한다`);
504
+ return buf;
505
+ }
506
+
507
+ const modelFlag = (model) => (model === CLI_DEFAULT ? [] : ["-m", model]);
508
+ const OPENCODE_DENY = '{"*":"deny"}';
509
+ // The commands of the 0.20.4 skill's bash block, word for word, as plans: arguments, what is set and unset in the
510
+ // env, and the check run first (opencode). `part` names the files of a split part.
511
+ const PLANS = {
512
+ codex: (p, f) => ({
513
+ args: ["exec", "-C", f.cwd, "--ephemeral", "--ignore-rules", ...["multi_agent", "hooks", "plugins", "apps", "shell_tool"].flatMap((x) => ["--disable", x]),
514
+ "-c", "notify=[]", "-c", "analytics.enabled=false", "-c", "web_search=disabled", ...(p.mcpServers || []).flatMap((n) => ["-c", `mcp_servers.${n}.enabled=false`]),
515
+ "-c", 'otel.exporter="none"', "-c", 'otel.trace_exporter="none"', "-c", 'otel.metrics_exporter="none"',
516
+ ...(p.effort ? ["-c", `model_reasoning_effort=${p.effort}`] : []), ...modelFlag(p.model), "-s", "read-only", "--skip-git-repo-check", "-o", f.answer, "-"],
517
+ stdout: join(p.o, `stdout-codex${f.sfx}.md`), set: {}, unset: [], check: null,
518
+ }),
519
+ gemini: (p, f) => ({
520
+ args: ["--skip-trust", "--approval-mode", "default", "--admin-policy", p.policy, "--allowed-mcp-server-names", "ralph-qa-none", "-e", "none",
521
+ ...modelFlag(p.model), "-p", "첨부한 검토 지시를 따르라"],
522
+ stdout: f.answer, set: { TMPDIR: p.o, TEMP: p.o, TMP: p.o, GEMINI_SANDBOX: "false", GEMINI_TELEMETRY_LOG_PROMPTS: "false" }, unset: ["GEMINI_CLI_IDE_WORKSPACE_PATH"], check: null,
523
+ }),
524
+ opencode: (p, f) => {
525
+ const agent = `ralph-qa-review-${p.id}`;
526
+ const config = { share: "disabled", agent: { compaction: { disable: true }, [agent]: { mode: "primary", description: "ralph-qa reviewer", permission: { "*": "deny" } } } };
527
+ return {
528
+ args: ["run", "--pure", "--agent", agent, "--title", agent, ...modelFlag(p.model), "아래 검토 지시를 따르라"], stdout: f.answer, unset: [],
529
+ set: { OPENCODE_PERMISSION: OPENCODE_DENY, OPENCODE_CONFIG_CONTENT: JSON.stringify(config), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_DISABLE_MODELS_FETCH: "1",
530
+ OPENCODE_DISABLE_AUTOUPDATE: "1", OPENCODE_DISABLE_SHARE: "1", OPENCODE_DISABLE_CLAUDE_CODE: "1" },
531
+ check: ["debug", "agent", agent, "--pure"],
532
+ };
533
+ },
534
+ };
535
+
536
+ export function seatPlan(p) {
537
+ const sfx = p.part ? `-${p.part}` : "";
538
+ const cwd = join(p.d, p.cli);
539
+ const f = { cwd, sfx, answer: join(p.o, `out-${p.cli}${sfx}.md`) };
540
+ return { cwd, stdin: join(cwd, `prompt${sfx}.md`), stderr: join(p.o, `err-${p.cli}${sfx}.md`), answer: f.answer, ...PLANS[p.cli](p, f) };
541
+ }
542
+
543
+ // The seat's env: the runner's, without what the plan unsets (on Windows whatever the case of a name), with what it sets.
544
+ function seatEnv(ctx, plan) {
545
+ const win = ctx.platform === "win32";
546
+ const drop = new Set([...plan.unset, ...(win ? Object.keys(plan.set) : [])].map((k) => (win ? k.toUpperCase() : k)));
547
+ const kept = Object.entries(ctx.env).filter(([k]) => !drop.has(win ? k.toUpperCase() : k));
548
+ return { ...Object.fromEntries(kept), ...plan.set };
549
+ }
550
+
551
+ // The probe called again just before the send, with the model chosen: the guards as the config is now, the MCP
552
+ // servers to turn off by name. The model is not chosen again.
553
+ function reprobe(ctx, seat, runtime) {
554
+ const run = ctx.probe || localProbe;
555
+ const out = run({ requested: { [seat.cli]: seat.model === CLI_DEFAULT ? null : seat.model }, runtime });
556
+ return (out.seats || []).find((x) => x.cli === seat.cli) || { decision: "unseated", reason: "cli-call-failed", notes: out.notes };
557
+ }
558
+
559
+ function guards(ctx, seat, a) {
560
+ if (seat.model !== CLI_DEFAULT && !safeModel(seat.model)) return { why: "unsafe-model" };
561
+ const exe = which(seat.cli, { env: ctx.env, platform: ctx.platform });
562
+ if (!exe) return { why: "cli-absent" };
563
+ const probed = reprobe(ctx, seat, a.runtime);
564
+ if (probed.decision === "unseated") return { why: `probe:${probed.reason}`, notes: probed.notes || [] };
565
+ return { exe, probed };
566
+ }
567
+
568
+ const policyText = () => {
569
+ try {
570
+ return readFileSync(POLICY, "utf8");
571
+ } catch {
572
+ return null;
573
+ }
574
+ };
575
+
576
+ // The checks a seat must pass in its own folder before anything goes: cmd.exe's words, gemini's policy and a .env
577
+ // above the folder, opencode's per-run agent.
578
+ async function cliChecks(ctx, seat, plan, exe) {
579
+ const viaCmd = ctx.platform === "win32" && /\.(cmd|bat)$/i.test(exe);
580
+ if (viaCmd && [plan.check, plan.args].some((args) => args && cmdLine(exe, args) === null)) return "unsafe-word";
581
+ if (seat.cli === "gemini" && !policyIntact(policyText())) return "gemini-policy";
582
+ if (seat.cli === "gemini" && strayEnvFiles(plan.cwd, { env: ctx.env, platform: ctx.platform }).length) return "gemini-env-file";
583
+ if (!plan.check) return null;
584
+ const r = await runLimited({ exe, args: plan.check, cwd: plan.cwd, env: seatEnv(ctx, plan), stdin: "ignore", stdout: "ignore", stderr: "ignore", limitMs: CHECK_LIMIT_MS });
585
+ return r.exit === 0 ? null : "opencode-agent";
586
+ }
587
+
588
+ // 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];
591
+ parts.forEach((part, k) => {
592
+ if (markerCount(part, id) !== 2 || scan(part.toString("utf8")).count) throw new Stop("split", `부분 ${k + 1}/${parts.length} 의 표지나 검사가 맞지 않는다`);
593
+ });
594
+ return parts;
595
+ }
596
+
597
+ const readText = (file) => {
598
+ try {
599
+ return readFileSync(file, "utf8");
600
+ } catch {
601
+ return "";
602
+ }
603
+ };
604
+
605
+ // What a seat's answer and errors say.
606
+ function factsOf(cli, plan) {
607
+ const out = readText(plan.answer);
608
+ const err = readText(plan.stderr);
609
+ const facts = { verdicts: out.split(/\r?\n/).map((l) => VERDICT_LINE.exec(l)).filter(Boolean).map((m) => m[1]), empty: !out.trim() };
610
+ if (cli === "codex") facts.ranModel = (/^model: (\S+)/m.exec(err.split("\n").slice(0, 40).join("\n")) || [])[1] || null;
611
+ if (cli === "gemini") Object.assign(facts, { policyError: err.includes("Policy file error"), apiError: err.includes("Full report") });
612
+ if (cli === "opencode") facts.fallback = err.includes("Falling back to default agent");
613
+ const hint = err.split("\n").find((l) => REJECT_HINT.test(l));
614
+ if (hint) facts.rejectHint = hint.trim().slice(0, 200);
615
+ return facts;
616
+ }
617
+
618
+ // A run that ended without an answer to read: why, or null.
619
+ function processWhy(r) {
620
+ if (r.error) return "spawn";
621
+ if (r.timedOut) return "timeout";
622
+ if (r.stopped || r.signal) return "signal";
623
+ return null;
624
+ }
625
+ // An answer that cannot count: why, or null.
626
+ function answerWhy(r, f, seat) {
627
+ if (f.policyError) return "policy-file-error";
628
+ if (f.fallback) return "fallback-agent";
629
+ if (r.exit !== 0) return f.rejectHint ? "rejected" : "exit";
630
+ if (f.empty) return "empty";
631
+ if (f.verdicts.length !== 1) return "verdict";
632
+ if (seat.cli === "codex" && seat.model !== CLI_DEFAULT && f.ranModel !== seat.model) return "model-mismatch";
633
+ return null;
634
+ }
635
+
636
+ // Run with @cli-default, codex ran what its config names: the author's own family, or one the probe cannot tell, does not count.
637
+ function defaultFamilyWhy(ctx, seat, a, f) {
638
+ if (seat.cli !== "codex" || seat.model !== CLI_DEFAULT) return null;
639
+ if (!f.ranModel || !safeModel(f.ranModel)) return "default-model-family";
640
+ const again = reprobe(ctx, { cli: "codex", model: f.ranModel }, a.runtime);
641
+ return again.decision === "unseated" || again.familyKnown === false ? "default-model-family" : null;
642
+ }
643
+
644
+ export const gathered = (verdicts) => verdicts.reduce((worst, v) => (RANK[v] > RANK[worst] ? v : worst), "APPROVE");
645
+
646
+ async function runPart(ctx, run, seat, g, part, k, count) {
647
+ const plan = seatPlan({ cli: seat.cli, model: seat.model, effort: run.effort, mcpServers: g.probed.mcpServers || [], d: run.d, o: run.o, id: run.id, policy: POLICY, part: count > 1 ? k + 1 : 0 });
648
+ writeFileSync(plan.stdin, part, { mode: 0o600 });
649
+ if (run.signalled()) return { why: "signal", r: { exit: null, signal: null, secs: 0 }, f: {}, plan };
650
+ const r = await runLimited({ exe: g.exe, args: plan.args, cwd: plan.cwd, env: seatEnv(ctx, plan), stdin: plan.stdin, stdout: plan.stdout, stderr: plan.stderr, limitMs: run.limitMs });
651
+ const f = processWhy(r) ? {} : factsOf(seat.cli, plan);
652
+ const why = processWhy(r) || answerWhy(r, f, seat) || defaultFamilyWhy(ctx, seat, run, f);
653
+ return { why, r, f, plan };
654
+ }
655
+
656
+ const factsShown = (f) => Object.fromEntries(Object.entries({ verdict: f.verdicts?.length === 1 ? f.verdicts[0] : undefined, ranModel: f.ranModel,
657
+ apiError: f.apiError || undefined, rejectHint: f.rejectHint }).filter(([, v]) => v !== undefined && v !== null));
658
+
659
+ // Sends the parts in order; the first that fails ends the seat's run (the seat is ERROR whatever the rest say).
660
+ async function runParts(ctx, run, seat, g, parts, base) {
661
+ const done = [];
662
+ for (const [k, part] of parts.entries()) {
663
+ const one = await runPart(ctx, run, seat, g, part, k, parts.length);
664
+ done.push(one);
665
+ if (one.why) break;
666
+ }
667
+ const last = done[done.length - 1];
668
+ const secs = Math.round(done.reduce((t, x) => t + (x.r.secs || 0), 0) * 10) / 10;
669
+ const result = { ...base, status: last.why ? "failed" : "answered", why: last.why, exit: last.r.exit, signal: last.r.signal, secs,
670
+ answer: last.plan.answer, errors: last.plan.stderr, facts: factsShown(last.f) };
671
+ if (!last.why && parts.length > 1) result.facts.verdict = gathered(done.map((x) => x.f.verdicts[0]));
672
+ if (parts.length > 1) result.parts = done.map((x) => ({ answer: x.plan.answer, errors: x.plan.stderr, why: x.why, facts: factsShown(x.f) }));
673
+ return result;
674
+ }
675
+
676
+ // What a seat's run leaves is taken away when it returns: gemini's record of its folder, this run's opencode session,
677
+ // the seat's copy of the payload. What cannot be taken away (a held handle, a virus scanner on Windows) is a note of
678
+ // the seat's: the other seats' results still come back, and the lock stays until every seat has ended.
679
+ async function afterSeat(ctx, run, seat) {
680
+ const notes = [];
681
+ const copy = join(run.d, seat.cli);
682
+ try {
683
+ if (seat.cli === "gemini") notes.push(...cleanSeat(copy, { env: ctx.env, platform: ctx.platform }).skipped.map((p) => `gemini 기록 폴더를 남김: ${p}`));
684
+ if (seat.cli === "opencode") await opencodeSessions({ ...ctx, d: run.d, id: run.id }, []);
685
+ } catch (e) {
686
+ notes.push(`좌석 기록을 지우지 못했다(${e.code || e.message})`);
687
+ }
688
+ try {
689
+ rmSync(copy, { recursive: true, force: true, maxRetries: 3 });
690
+ } catch (e) {
691
+ notes.push(`사본을 지우지 못했다: ${copy} (${e.code || e.message})`);
692
+ }
693
+ return notes;
694
+ }
695
+
696
+ async function seatWork(ctx, run, seat, g, payload, base) {
697
+ const plan = seatPlan({ cli: seat.cli, model: seat.model, effort: run.effort, mcpServers: g.probed.mcpServers || [], d: run.d, o: run.o, id: run.id, policy: POLICY });
698
+ const why = await cliChecks(ctx, seat, plan, g.exe);
699
+ if (why) return { ...base, why };
700
+ let parts;
701
+ try {
702
+ parts = partsOf(payload, run.split[seat.cli], run.id, run.short);
703
+ } catch (e) {
704
+ if (e instanceof Stop) return { ...base, why: "split", notes: [e.message] };
705
+ throw e;
706
+ }
707
+ if (run.dryRun) return { ...base, status: "planned", plan: { exe: g.exe, cwd: plan.cwd, args: plan.args, set: plan.set, unset: plan.unset, check: plan.check, parts: parts.length } };
708
+ return runParts(ctx, run, seat, g, parts, base);
709
+ }
710
+
711
+ 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: [] };
713
+ const g = guards(ctx, seat, run);
714
+ if (g.why) return { ...base, why: g.why, notes: g.notes || [] };
715
+ let result;
716
+ try {
717
+ mkdirSync(join(run.d, seat.cli), { recursive: true, mode: 0o700 });
718
+ result = await seatWork(ctx, run, seat, g, payload, base);
719
+ } catch (e) {
720
+ // the runner's own error in this seat's work (its copy cannot be written: ENOSPC, EACCES, a Windows lock) is this
721
+ // 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;
723
+ result = { ...base, status: "failed", why: "spawn", notes: [`좌석을 준비하지 못했다(${e.code || e.message})`] };
724
+ } finally {
725
+ const notes = await afterSeat(ctx, run, seat);
726
+ if (result) result.notes = [...(result.notes || []), ...notes];
727
+ }
728
+ return result;
729
+ }
730
+
731
+ const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
732
+ // A stop the runner asks of the send's node over their channel, or the channel closing (the runner is gone): it stops
733
+ // the send as that signal would, also when it comes before the send watches for signals.
734
+ const asked = { sig: null, on: null };
735
+ function askStop(sig) {
736
+ if (asked.sig) return;
737
+ asked.sig = sig;
738
+ if (asked.on) asked.on(sig);
739
+ }
740
+
741
+ // SIGINT, SIGTERM and SIGHUP end every seat (TERM to its group, KILL 2 s later), the seats' records are still cleaned,
742
+ // and send ends with 128 + the signal's number; past 30 s it ends anyway.
743
+ function watchSignals() {
744
+ let code = 0;
745
+ const stop = (sig) => {
746
+ if (code) return;
747
+ code = 128 + (osConstants.signals[sig] || 0);
748
+ stopAll();
749
+ setTimeout(() => process.exit(code), 30000).unref();
750
+ };
751
+ const handlers = STOP_SIGNALS.map((sig) => [sig, () => stop(sig)]);
752
+ for (const [sig, h] of handlers) process.on(sig, h);
753
+ asked.on = stop;
754
+ if (asked.sig) stop(asked.sig);
755
+ return {
756
+ code: () => code,
757
+ off: () => {
758
+ asked.on = null;
759
+ handlers.forEach(([sig, h]) => process.off(sig, h));
760
+ },
761
+ };
762
+ }
763
+
764
+ const DEST = {
765
+ codex: () => "config.toml 이 정한 프로바이더",
766
+ gemini: () => "사용자 설정의 인증",
767
+ opencode: (x) => (x.model === CLI_DEFAULT ? "opencode 설정의 프로바이더" : `${x.model.split("/")[0]}(-m 의 프로바이더)`),
768
+ };
769
+ const unsentCount = (d) => readText(join(d, "unsent.md")).split("\n").filter(Boolean).length;
770
+ // The line the report carries: where the payload went, its size, the scan, the new files left out.
771
+ function declared(run, bytes, seats, d) {
772
+ const went = seats.filter((x) => x.status !== "not-sent").map((x) => `${x.seat} 는 ${DEST[x.seat](x)}`);
773
+ const head = run.dryRun ? "외부 전송(선언, dry-run 이라 보내지 않음)" : "외부 전송(선언)";
774
+ return `${head}: ${went.join(", ") || "보낸 좌석 없음"}, 페이로드 ${bytes}B, 비밀 검사 0건, 보내지 않은 새 파일 ${unsentCount(d)}`;
775
+ }
776
+
777
+ function sendStart(o, ctx, a) {
778
+ const { d, o: out } = runFolder(o.dir);
779
+ if (!out) throw new Stop("not-a-run-folder", `답 폴더(ralph-qa-out.*)가 없다: ${d}`);
780
+ refuseSent(d);
781
+ if (ctx.env.CODEX_SANDBOX_NETWORK_DISABLED === "1" && !a.dryRun) throw new Stop("sandboxed", "Codex sandbox 가 네트워크를 막는다. 승인을 받아 sandbox 밖에서 send 를 다시 한다");
782
+ const { id, short } = readRun(d);
783
+ const payload = payloadToSend(d, id, Boolean(o["allow-large"]));
784
+ return { d, out, id, payload, short: Number(short) };
785
+ }
786
+
787
+ async function send(o, ctx) {
788
+ const a = sendArgs(o);
789
+ const { d, out, id, payload, short } = sendStart(o, ctx, a);
790
+ const lock = takeLock(d, ctx.now());
791
+ const touch = keepFresh(lock);
792
+ const signals = watchSignals();
793
+ const run = { ...a, d, o: out, id, short, signalled: () => signals.code() !== 0 };
794
+ try {
795
+ // every seat settles before the lock goes back and the stop handlers go away
796
+ const settled = await Promise.allSettled(a.seats.map((x) => sendSeat(ctx, run, x, payload)));
797
+ const thrown = settled.find((x) => x.status === "rejected");
798
+ if (thrown) throw thrown.reason;
799
+ const seats = settled.map((x) => x.value);
800
+ 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) };
801
+ return [result, signals.code(), a.dryRun ? null : d];
802
+ } finally {
803
+ clearInterval(touch);
804
+ signals.off();
805
+ giveBack(lock);
806
+ }
807
+ }
808
+
809
+ const COMMANDS = { init, untracked: listUntracked, prepare, check, send, cleanup, put };
810
+
811
+ // The JSON on stdout and in <d>/result-<command>.md (not for cleanup: the folder is gone).
812
+ function report(ctx, command, dir, result, code, name = command) {
813
+ const json = `${JSON.stringify({ command, ...result }, null, 2)}\n`;
814
+ ctx.stdout.write(json);
815
+ 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
+ return code;
824
+ }
825
+
826
+ // A stop in the folder it concerns, when that is a run folder.
827
+ const folderOf = (o) => {
828
+ try {
829
+ return o.dir ? runFolder(o.dir).d : null;
830
+ } catch {
831
+ return null;
832
+ }
833
+ };
834
+
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.
836
+ const stopName = (command) => (command === "send" ? "send-stop" : command);
837
+
838
+ function failed(ctx, { command, o }, e) {
839
+ if (e instanceof Usage) {
840
+ ctx.stderr.write(`ralph-qa: ${e.message}\n${USAGE}`);
841
+ return 2;
842
+ }
843
+ if (!(e instanceof Stop)) {
844
+ ctx.stderr.write(`ralph-qa: 실행기 내부 오류(${e?.code || e?.name || "error"}): ${String(e?.message || e).split("\n")[0]}\n`);
845
+ return 2;
846
+ }
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));
849
+ }
850
+
851
+ // ---- the send's own node ----
852
+
853
+ // send runs in a second node, started here with a channel to it: a kill of this process alone (kill -9, Task Manager)
854
+ // runs none of its handlers and would leave the seats running past their limit, but closes the channel, and the
855
+ // second node then stops the send itself. It is detached: Windows ends the children a node started undetached when
856
+ // that node dies (libuv's job object), and a kill of this process's group does not reach it. It has no output of its
857
+ // own (a detached Windows process has no console): what it prints comes over the channel and is printed here. A stop
858
+ // signal here goes to it over the channel too. Its exit code is the send's.
859
+ function sendViaWorker(argv, ctx) {
860
+ return new Promise((done) => {
861
+ let reported = null;
862
+ let settled = false;
863
+ const settle = (code) => {
864
+ if (settled) return;
865
+ settled = true;
866
+ forward.forEach(([sig, h]) => process.off(sig, h));
867
+ done(code);
868
+ };
869
+ const child = fork(fileURLToPath(import.meta.url), ["send-worker", ...argv.slice(1)],
870
+ { env: ctx.env, stdio: ["ignore", "ignore", "ignore", "ipc"], detached: true, windowsHide: true });
871
+ const forward = STOP_SIGNALS.map((sig) => [sig, () => child.connected && child.send({ stop: sig }, () => {})]);
872
+ for (const [sig, h] of forward) process.on(sig, h);
873
+ child.on("message", (m) => {
874
+ if (m && typeof m.out === "string") ctx.stdout.write(m.out);
875
+ if (m && typeof m.err === "string") ctx.stderr.write(m.err);
876
+ if (m && Number.isInteger(m.done)) reported = m.done;
877
+ });
878
+ child.on("error", (e) => {
879
+ ctx.stderr.write(`ralph-qa: send 를 맡을 node 를 띄우지 못했다(${e.code || e.message})\n`);
880
+ settle(2);
881
+ });
882
+ // close, not exit: it comes after the channel is closed, so the node's last message is in
883
+ child.on("close", (code, signal) => {
884
+ if (settled) return;
885
+ if (reported === null) ctx.stderr.write(`ralph-qa: send 를 맡은 node 가 결과 없이 끝났다(${signal || `종료 ${code}`}). 좌석이 아직 돌 수 있다. 남은 좌석 기록과 사본은 다음 cleanup 이 지운다\n`);
886
+ settle(reported === null ? 2 : reported);
887
+ });
888
+ });
889
+ }
890
+
891
+ // What the send's node prints goes to the runner over the channel; with the runner gone it is dropped (the result
892
+ // files in the folder stay).
893
+ const relay = (key) => ({ write: (text) => process.connected && process.send({ [key]: String(text) }, () => {}) });
894
+
895
+ // The send's node: a message from the runner is a signal the runner got, the channel closing means the runner is gone.
896
+ async function workerMain(argv, ctx) {
897
+ if (typeof process.send !== "function") {
898
+ ctx.stderr.write(`ralph-qa: send-worker 는 send 가 띄우는 내부 명령이다\n${USAGE}`);
899
+ return 2;
900
+ }
901
+ process.on("message", (m) => {
902
+ if (m && STOP_SIGNALS.includes(m.stop)) askStop(m.stop);
903
+ });
904
+ process.on("disconnect", () => askStop("SIGTERM"));
905
+ if (!process.connected) askStop("SIGTERM");
906
+ const code = await runCommand(["send", ...argv.slice(1)], { ...ctx, stdout: relay("out"), stderr: relay("err") });
907
+ if (process.connected) process.send({ done: code }, () => process.connected && process.disconnect());
908
+ return code;
909
+ }
910
+
911
+ async function runCommand(argv, ctx) {
912
+ let parsed;
913
+ try {
914
+ parsed = parseArgs(argv);
915
+ } catch (e) {
916
+ return failed(ctx, { command: argv[0], o: {} }, e);
917
+ }
918
+ for (const k of ["repo", "dir"]) if (k in parsed.o) parsed.o[k] = fromMsys(parsed.o[k], ctx.platform);
919
+ try {
920
+ const [result, code, dir] = await COMMANDS[parsed.command](parsed.o, ctx);
921
+ return report(ctx, parsed.command, dir, result, code);
922
+ } catch (e) {
923
+ return failed(ctx, parsed, e);
924
+ }
925
+ }
926
+
927
+ export async function main(argv, io = {}) {
928
+ const ctx = {
929
+ env: io.env || process.env, platform: io.platform || process.platform, stdout: io.stdout || process.stdout, stderr: io.stderr || process.stderr,
930
+ stdin: io.stdin, probe: io.probe, now: io.now || Date.now,
931
+ };
932
+ if (argv[0] === "send-worker") return workerMain(argv, ctx);
933
+ if (argv[0] === "send") {
934
+ try {
935
+ parseArgs(argv);
936
+ } catch (e) {
937
+ return failed(ctx, { command: "send", o: {} }, e);
938
+ }
939
+ return sendViaWorker(argv, ctx);
940
+ }
941
+ return runCommand(argv, ctx);
942
+ }
943
+
944
+ const invokedDirectly = () => {
945
+ try {
946
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
947
+ } catch {
948
+ return false;
949
+ }
950
+ };
951
+
952
+ if (invokedDirectly()) {
953
+ main(process.argv.slice(2)).then((code) => {
954
+ process.exitCode = code;
955
+ });
956
+ }