@kaydash9999/banker-plugins 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +41 -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 +137 -0
  17. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -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 +277 -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 +162 -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 +526 -0
  77. package/skills/progress-mon/scripts/pm-config.mjs +341 -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 +308 -0
  81. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  82. package/skills/progress-mon/scripts/pm-server.mjs +735 -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 +104 -15
  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 +159 -155
  91. package/skills/remains/SKILL.md +1 -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 +196 -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
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * run-folder: the private run folders of ralph-qa and ultraqa, and the work tree snapshot their gate evidence is judged
4
+ * against. The source of truth is skills/ralph-qa/references/run-folder.mjs; skills/ultraqa/references holds a
5
+ * byte-identical copy (scripts/sync-adapter.js).
6
+ *
7
+ * bases(ctx) { base, known }: this run's base, and every base a run of any version used
8
+ * sharedAbove(p) the nearest folder above p every account may write in, or null (POSIX)
9
+ * readyBase(ctx, base) makes the base, private (POSIX), or stops
10
+ * makeRunFolder(ctx, kind) <base>/<kind>.<six letters and digits>, private, with its marker .<kind>
11
+ * runFolder(dir, ctx, kinds) { d, kind } for such a folder right under a known private base, else a stop
12
+ * removeMarked(p, marker) the folder's contents, then its marker, then the folder
13
+ * removeRunFolder(dir, ctx, kinds) one run folder, checked as runFolder checks it; nothing outside it
14
+ * snapshot(repo, env) (async) HEAD, the status, the untracked files' content hashes, one fingerprint
15
+ * requireGitTree(repo, env, flag) the work tree's root, or the stop not-a-git-tree naming the folder it checked
16
+ * spawnFiles(exe, args, opts) spawnSync with files, not pipes, for the child's input and output
17
+ * hashFile(createHash, p) a file's sha256 read in pieces (a file past 2 GiB is no Buffer)
18
+ * lstatOr, isFile, listDir, norm(ctx, p), realOr small fs helpers seat-run.mjs shares (null, [], p on an error)
19
+ *
20
+ * ctx is { env, platform }. A base is never /tmp: another account may put files there (gemini, a ralph-qa seat, reads
21
+ * a .env it finds walking up from its folder). No shell and no network: git runs as a program with its arguments.
22
+ */
23
+ import { spawnSync } from "node:child_process";
24
+ import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
25
+ import { homedir, tmpdir } from "node:os";
26
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
27
+
28
+ // A stop: `code` names it, the message is a Korean line (the caller puts its name before it), `detail` the paths or
29
+ // lines it concerns.
30
+ export class Stop extends Error {
31
+ constructor(code, message, detail = []) {
32
+ super(message);
33
+ this.code = code;
34
+ this.detail = detail;
35
+ }
36
+ }
37
+
38
+ export const lstatOr = (p) => {
39
+ try {
40
+ return lstatSync(p);
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+ export const isFile = (p) => Boolean(lstatOr(p)?.isFile());
46
+ export const listDir = (dir) => {
47
+ try {
48
+ return readdirSync(dir);
49
+ } catch {
50
+ return [];
51
+ }
52
+ };
53
+
54
+ const homeOf = (ctx) => (ctx.platform === "win32" ? ctx.env.USERPROFILE : ctx.env.HOME) || homedir();
55
+ export const norm = (ctx, p) => (ctx.platform === "win32" ? p.toLowerCase() : p);
56
+ export const realOr = (p) => {
57
+ try {
58
+ return realpathSync.native(p);
59
+ } catch {
60
+ return p;
61
+ }
62
+ };
63
+
64
+ // A folder only this account may write in (POSIX: its own, no group or other write bit).
65
+ export function privateDir(p) {
66
+ try {
67
+ const st = statSync(p);
68
+ return st.isDirectory() && st.uid === process.getuid() && (st.mode & 0o022) === 0;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+ const insideHome = (ctx, p) => norm(ctx, realOr(p)).startsWith(norm(ctx, realOr(homeOf(ctx))) + sep);
74
+
75
+ // The base for this run's folders, and every base a run of any version may have used, for the day rule. Linux: an
76
+ // XDG_RUNTIME_DIR, macOS: a $TMPDIR (its own per account by default), of this account's own and not below a shared
77
+ // folder (/tmp) the home is not below, else ~/.cache; Windows: %TEMP% when it is inside the home, else ~/.cache (the
78
+ // old Git Bash runs used ~/.cache).
79
+ const OWN_TEMP = { linux: "XDG_RUNTIME_DIR", darwin: "TMPDIR" };
80
+ export function bases(ctx) {
81
+ const cache = join(homeOf(ctx), ".cache");
82
+ const base = baseOn(ctx, cache);
83
+ const own = ctx.env[OWN_TEMP[ctx.platform]];
84
+ return { base, known: [...new Set([base, cache, ...(own ? [resolve(own)] : [])])] };
85
+ }
86
+
87
+ function baseOn(ctx, cache) {
88
+ const { env } = ctx;
89
+ if (ctx.platform === "win32") return env.TEMP && insideHome(ctx, env.TEMP) ? resolve(env.TEMP) : cache;
90
+ const own = env[OWN_TEMP[ctx.platform]];
91
+ return own && isAbsolute(own) && privateDir(own) && !belowSharedAlone(ctx, own) ? resolve(own) : cache;
92
+ }
93
+
94
+ const belowSharedAlone = (ctx, p) => Boolean(sharedAbove(p)) && !sharedAbove(homeOf(ctx));
95
+
96
+ // The nearest folder above p, by its real path, that every account may write in (/tmp, /var/tmp, /dev/shm), or null.
97
+ // A private folder there is still a folder whose parents another account fills: gemini reads the .env it finds
98
+ // walking up to the root. An XDG_RUNTIME_DIR or $TMPDIR below one is refused unless the home is below one too (a test
99
+ // box's account under /tmp): then ~/.cache is no better. POSIX only.
100
+ export function sharedAbove(p) {
101
+ for (let d = dirname(realOr(resolve(p))); ; d = dirname(d)) {
102
+ try {
103
+ if ((statSync(d).mode & 0o002) !== 0) return d;
104
+ } catch {
105
+ /* not there or not readable: the next one up */
106
+ }
107
+ if (dirname(d) === d) return null;
108
+ }
109
+ }
110
+
111
+ export function readyBase(ctx, base) {
112
+ try {
113
+ mkdirSync(base, { recursive: true, mode: 0o700 });
114
+ } catch (e) {
115
+ throw new Stop("base-not-writable", `기준 폴더를 만들 수 없다(${e.code}): ${base}`);
116
+ }
117
+ if (ctx.platform !== "win32" && !privateDir(base)) throw new Stop("base-not-private", `기준 폴더를 다른 계정도 쓸 수 있다: ${base}`);
118
+ }
119
+
120
+ // One folder by mkdtemp under the base, with its marker.
121
+ export function makeRunFolder(ctx, kind) {
122
+ const { base } = bases(ctx);
123
+ readyBase(ctx, base);
124
+ let d;
125
+ try {
126
+ d = mkdtempSync(join(base, `${kind}.`));
127
+ writeFileSync(join(d, `.${kind}`), "", { mode: 0o600 });
128
+ } catch (e) {
129
+ if (d) rmSync(d, { recursive: true, force: true });
130
+ throw new Stop("base-not-writable", `기준 폴더에 쓸 수 없다(${e.code}): ${base}`);
131
+ }
132
+ return { dir: d, base };
133
+ }
134
+
135
+ // The folder's parent, by its real path, is one of the known bases by theirs (macOS's /var is a link to /private/var:
136
+ // either form names the same base), and only this account may write in it (POSIX).
137
+ export function underBase(ctx, d) {
138
+ const parent = norm(ctx, realOr(dirname(d)));
139
+ const known = bases(ctx).known.some((b) => norm(ctx, realOr(b)) === parent);
140
+ return known && (ctx.platform === "win32" || privateDir(dirname(d)));
141
+ }
142
+
143
+ const escaped = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
144
+
145
+ // A folder made as makeRunFolder makes one, of one of `kinds`: named <kind>.<letters and digits>, a folder and no link,
146
+ // with its marker, right under a known base that only this account may write in.
147
+ export function runFolder(dir, ctx, kinds) {
148
+ const d = resolve(String(dir));
149
+ const kind = kinds.find((k) => new RegExp(`^${escaped(k)}\\.[A-Za-z0-9]+$`).test(basename(d)));
150
+ if (!kind || !lstatOr(d)?.isDirectory() || !isFile(join(d, `.${kind}`))) {
151
+ throw new Stop("not-a-run-folder", `init 이 만든 폴더가 아니다: ${d}`);
152
+ }
153
+ if (!underBase(ctx, d)) throw new Stop("not-a-run-folder", `알려진 기준 폴더 바로 아래의 실행 폴더가 아니다: ${d}`);
154
+ return { d, kind };
155
+ }
156
+
157
+ // A marked folder removed, its marker last: a removal that stops part way leaves a folder the next cleanup still knows.
158
+ // One that cannot be removed gets back its marker (if the removal reached it) and its times, so it stays a marked folder.
159
+ export function removeMarked(p, marker) {
160
+ const was = lstatOr(p);
161
+ try {
162
+ for (const name of listDir(p).filter((n) => n !== marker)) rmSync(join(p, name), { recursive: true, force: true, maxRetries: 3 });
163
+ rmSync(p, { recursive: true, force: true, maxRetries: 3 });
164
+ } catch (e) {
165
+ keepMarked(p, marker, was);
166
+ throw e;
167
+ }
168
+ }
169
+
170
+ function keepMarked(p, marker, was) {
171
+ try {
172
+ if (!lstatOr(p)?.isDirectory()) return;
173
+ if (!isFile(join(p, marker))) writeFileSync(join(p, marker), "", { flag: "wx", mode: 0o600 });
174
+ if (was) utimesSync(p, was.atime, was.mtime);
175
+ } catch {
176
+ /* the folder takes no marker or times either */
177
+ }
178
+ }
179
+
180
+ // One run folder and nothing else: a path a record names outside it is the caller's to report, never to remove.
181
+ export function removeRunFolder(dir, ctx, kinds) {
182
+ const { d, kind } = runFolder(dir, ctx, kinds);
183
+ try {
184
+ removeMarked(d, `.${kind}`);
185
+ } catch (e) {
186
+ throw new Stop("cleanup-left", "실행 폴더를 다 지우지 못했다. 사용자에게 경로를 알린다", [`${d} (${e.code || e.message})`]);
187
+ }
188
+ return { removed: d };
189
+ }
190
+
191
+ // ---- a program with files for its input and output
192
+
193
+ // spawnSync with the child's stdin, stdout and stderr on files in a private temporary folder, read back into
194
+ // r.stdout and r.stderr (Buffers) and removed. Node's own pipes are AF_UNIX socket pairs, and the Codex Linux sandbox
195
+ // refuses their setsockopt: a pipe child's output can be lost and spawnSync reports EPERM though the program ran.
196
+ // A file descriptor works there. Without a temporary folder it falls back to the pipes. With `digest` (a function
197
+ // of the stdout file's path, or of the Buffer on the pipe fallback) r.stdout is what it gives, and the output, which
198
+ // may pass 2 GiB, is never read whole.
199
+ export function spawnFiles(exe, args, { input, digest, ...opts } = {}) {
200
+ let t;
201
+ try {
202
+ t = mkdtempSync(join(tmpdir(), "rq-io-"));
203
+ } catch {
204
+ const r = spawnSync(exe, args, { ...opts, input });
205
+ return digest && r.stdout ? { ...r, stdout: digest(r.stdout) } : r;
206
+ }
207
+ const fds = [];
208
+ const open = (name, flags) => (fds.push(openSync(join(t, name), flags, 0o600)), fds[fds.length - 1]);
209
+ try {
210
+ if (input !== undefined) writeFileSync(join(t, "in"), input, { mode: 0o600 });
211
+ const stdio = [input === undefined ? "ignore" : open("in", "r"), open("out", "wx"), open("err", "wx")];
212
+ const r = spawnSync(exe, args, { ...opts, stdio });
213
+ return { ...r, stdout: (digest || readFileSync)(join(t, "out")), stderr: readFileSync(join(t, "err")) };
214
+ } finally {
215
+ for (const fd of fds) closeSync(fd);
216
+ rmSync(t, { recursive: true, force: true });
217
+ }
218
+ }
219
+
220
+ // A file's sha256 in pieces of 1 MiB: readFileSync stops at 2 GiB (ERR_FS_FILE_TOO_LARGE). `createHash` comes from
221
+ // the caller (this module loads node:crypto only inside snapshot).
222
+ export function hashFile(createHash, p) {
223
+ const h = createHash("sha256");
224
+ const fd = openSync(p, "r");
225
+ try {
226
+ const buf = Buffer.alloc(1 << 20);
227
+ for (let n = readSync(fd, buf, 0, buf.length, null); n > 0; n = readSync(fd, buf, 0, buf.length, null)) h.update(buf.subarray(0, n));
228
+ } finally {
229
+ closeSync(fd);
230
+ }
231
+ return h.digest("hex");
232
+ }
233
+
234
+ // ---- the work tree snapshot
235
+
236
+ // git as a program, from the home folder (Windows looks in the working folder first for a bare name; a home that is
237
+ // not there, as in a test's env, gives the temporary folder), with no fsmonitor program and no external diff or text
238
+ // conversion.
239
+ function git(repo, env, args, digest) {
240
+ const cwd = existsSync(homedir()) ? homedir() : tmpdir();
241
+ const r = spawnFiles("git", ["-c", "core.fsmonitor=false", "-C", repo, ...args], { cwd, env, maxBuffer: 1 << 30, windowsHide: true, digest });
242
+ return { ok: r.status === 0, out: r.stdout || Buffer.alloc(0), err: String(r.stderr || r.error?.code || "") };
243
+ }
244
+
245
+ // The untracked entries of `git status --porcelain=v1 -z` (a rename or copy entry carries its old name next).
246
+ function untrackedOf(raw) {
247
+ const parts = String(raw).split("\0");
248
+ const out = [];
249
+ for (let i = 0; i < parts.length; i += 1) {
250
+ const e = parts[i];
251
+ if (e.startsWith("?? ")) out.push(e.slice(3));
252
+ else if (/^[RC]/.test(e)) i += 1;
253
+ }
254
+ return out.sort();
255
+ }
256
+
257
+ // A gate's evidence needs a git work tree: in a folder without one the fingerprint sees no change, so no record of
258
+ // it could ever hold (the conjunct's `unverifiable`). Stopped before anything runs, with the absolute folder it checked
259
+ // (`checked`, also in detail) and what to do: a call from outside the repo without `flag` checked the wrong folder.
260
+ export function requireGitTree(repo, env = process.env, flag = "--cwd") {
261
+ const checked = resolve(repo);
262
+ const top = git(checked, env, ["rev-parse", "--show-toplevel"]);
263
+ if (top.ok) return String(top.out).trim();
264
+ const e = new Stop("not-a-git-tree", `git 저장소가 아니다(검사한 폴더: ${checked}). 이 폴더가 대상 저장소가 아니면 검사할 저장소를 ${flag} 로 주십시오. 대상 저장소가 맞으면 작업 트리 지문이 변경을 보지 못해 게이트 증거를 만들 수 없다: 사용자가 git init 과 첫 커밋을 하거나, 게이트 없이 돈다`, [checked]);
265
+ e.checked = checked;
266
+ throw e;
267
+ }
268
+
269
+ // An untracked entry's content hash: a link's target, a file's bytes (in pieces), or what it is when it is neither.
270
+ function contentHash(root, rel, hash, createHash) {
271
+ const p = join(root, rel);
272
+ const st = lstatOr(p);
273
+ if (st?.isSymbolicLink()) return hash(Buffer.from(`link\0${readlinkSync(p)}`));
274
+ if (st?.isFile()) return hashFile(createHash, p);
275
+ return hash(Buffer.from(st ? "other" : "gone"));
276
+ }
277
+
278
+ // What a gate's result was judged on: HEAD, the status, the diff against HEAD (the index and the work tree) and every
279
+ // untracked file's content, hashed together; the diffs and the files are hashed in pieces, never read whole. A tree
280
+ // with no git has one fingerprint per folder: changes go unseen (`git: false`, which the gate conjunct does not take).
281
+ // Files git ignores are outside it: a build or a test run makes them anew every time.
282
+ export async function snapshot(repo, env = process.env) {
283
+ const { createHash } = await import("node:crypto");
284
+ const hash = (b) => createHash("sha256").update(b).digest("hex");
285
+ const digest = (src) => (Buffer.isBuffer(src) ? hash(src) : hashFile(createHash, src));
286
+ const top = git(resolve(repo), env, ["rev-parse", "--show-toplevel"]);
287
+ if (!top.ok) return { git: false, head: null, untracked: [], fingerprint: hash(`no-git\0${resolve(repo)}`) };
288
+ const root = String(top.out).trim();
289
+ const head = git(root, env, ["rev-parse", "--verify", "-q", "HEAD"]);
290
+ const status = git(root, env, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
291
+ const diffArgs = ["--binary", "--no-ext-diff", "--no-textconv"];
292
+ const diffs = (head.ok ? [["diff", "HEAD"]] : [["diff", "--cached"], ["diff"]]).map((a) => git(root, env, [...a, ...diffArgs], digest));
293
+ const failed = [status, ...diffs].find((r) => !r.ok);
294
+ if (failed) throw new Stop("git-failed", `작업 트리를 읽지 못했다: ${failed.err.split("\n")[0]}`);
295
+ const untracked = untrackedOf(status.out).map((path) => ({ path, sha256: contentHash(root, path, hash, createHash) }));
296
+ const headId = head.ok ? String(head.out).trim() : null;
297
+ const parts = [`head\0${headId}`, `status\0${hash(status.out)}`, ...diffs.map((r) => `diff\0${r.out}`), ...untracked.map((u) => `new\0${u.path}\0${u.sha256}`)];
298
+ return { git: true, root, head: headId, untracked, fingerprint: hash(parts.join("\n")) };
299
+ }
@@ -29,12 +29,12 @@
29
29
  * stop is a Stop with the 0.20.4 wording. The secret scan and the size are reported, not stopped
30
30
  * on: the author masks prompt.md and runs check.
31
31
  */
32
- import { spawnSync } from "node:child_process";
33
32
  import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
34
33
  import { homedir } from "node:os";
35
34
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
36
35
  import { fileURLToPath } from "node:url";
37
36
  import { scan } from "./payload-scan.mjs";
37
+ import { Stop as RunStop, spawnFiles } from "./run-folder.mjs";
38
38
  import { which } from "./seat-spawn.mjs";
39
39
 
40
40
  export const MAX_BYTES = 524288;
@@ -44,11 +44,9 @@ const FOLD_PAST = 100;
44
44
  const WIN = process.platform === "win32";
45
45
  const OUTPUTS = ["prompt.md", "raw.md", "unsent.md", "run.md"];
46
46
 
47
- export class Stop extends Error {
47
+ export class Stop extends RunStop {
48
48
  constructor(code, message, detail = []) {
49
- super(`ralph-qa: ${message}`);
50
- this.code = code;
51
- this.detail = detail;
49
+ super(code, `ralph-qa: ${message}`, detail);
52
50
  }
53
51
  }
54
52
 
@@ -90,8 +88,10 @@ export function markerCount(buf, id) {
90
88
 
91
89
  function readShort(dir, id) {
92
90
  const short = withNewline(readAuthor(dir, "short.md"));
93
- if (markerCount(short, id) !== 1 || !short.includes(Buffer.from(`<review-data id="${id}">`))) {
94
- throw new Stop("markers", `short.md 에 여는 표지 <review-data id="${id}"> 가 한 줄에 한 번만 있어야 한다`);
91
+ const count = markerCount(short, id);
92
+ if (count !== 1 || !short.includes(Buffer.from(`<review-data id="${id}">`))) {
93
+ const fix = count > 1 ? "닫는 표지와 원문은 실행기가 붙이므로 short.md 에는 여는 표지 한 줄만 둔다" : "고정 앞머리 다음에 여는 표지 한 줄, 그 아래 사용자 요청 문장과 수용 기준을 둔다";
94
+ throw new Stop("markers", `short.md 에 여는 표지 <review-data id="${id}"> 가 한 줄에 한 번만 있어야 한다(지금 표지 ${count}줄). ${fix}`);
95
95
  }
96
96
  return short;
97
97
  }
@@ -107,7 +107,7 @@ function repoPath(raw, n) {
107
107
 
108
108
  function specLine(line, n) {
109
109
  const m = /^(new|range|file|gate|review) (.+)$/.exec(line);
110
- if (!m) throw new Stop("spec", `spec ${n}: new, range, file, gate, review 로 시작하는 줄이 아니다: ${line.slice(0, 200)}`);
110
+ if (!m) throw new Stop("spec", `spec ${n}: new, range, file, gate, review 로 시작하는 줄이 아니다: ${line.slice(0, 200)}. 요청 문장과 수용 기준은 spec.md 가 아니라 short.md 에 쓴다`);
111
111
  if (m[1] === "new") return { kind: "new", path: repoPath(m[2], n), line: n };
112
112
  if (m[1] !== "range") {
113
113
  if (m[2].includes("\0")) throw new Stop("spec", `spec ${n}: 경로에 NUL 이 있다`);
@@ -155,7 +155,7 @@ export function gitAt(repo, env = process.env, cwd = homedir(), extra = []) {
155
155
  const onCopy = (index) => ({ ...withoutKeys(base, /^GIT_INDEX_FILE$/i), GIT_INDEX_FILE: index });
156
156
  return (args, { index, input, ok = [0] } = {}) => {
157
157
  const pre = ["-C", repo, ...SAFE, ...extra, ...(index ? ["-c", "core.splitIndex=false"] : [])];
158
- const r = spawnSync(exe, [...pre, ...args], {
158
+ const r = spawnFiles(exe, [...pre, ...args], {
159
159
  cwd, env: index ? onCopy(index) : base, input, maxBuffer: 1 << 30, timeout: GIT_TIMEOUT_MS, windowsHide: true,
160
160
  });
161
161
  const what = `git ${args.filter((a) => a.startsWith("-") || /^[a-z-]+$/.test(a)).slice(0, 4).join(" ")}`;
@@ -253,7 +253,9 @@ function ownLayout(realTop, { gitDir, common, tree }) {
253
253
  return norm(common) === norm(gitDir) && Boolean(tree) && norm(realOr(resolve(gitDir, tree))) === norm(realTop);
254
254
  }
255
255
 
256
- // The first link at or below `root`: git would read another repo's objects or refs through it.
256
+ // The first link at or below `root`: git would read another repo's objects or refs through it. A whole walk of a
257
+ // git folder with 100,000 loose objects and 10,000 refs (objects and refs, 21 runs, 2026-10-29) took p95 206 ms on
258
+ // Linux (Rocky 8, Node 24.13) and 76 ms on the Windows test box (Node 24.19): well under the 1 s that would call for streaming.
257
259
  function linkUnder(root) {
258
260
  const folders = [root];
259
261
  while (folders.length) {
@@ -568,7 +570,7 @@ export function buildPayload({ dir, repo, base, id, env = process.env }) {
568
570
  const prompt = Buffer.concat([short, raw, Buffer.from(`</review-data id="${id}">\n`)]);
569
571
  const markers = markerCount(prompt, id);
570
572
  if (markers !== 2) throw new Stop("markers", `표지가 두 줄이 아니다(${markers}줄). id 를 바꿔 short.md 를 다시 쓴다`);
571
- writeOutputs(dir, { prompt, raw, unsent, run: `id ${id}\nbase ${base}\ntree ${tree}\nrepo ${top}\nshort ${short.length}\n` });
573
+ writeOutputs(dir, { prompt, raw, unsent, run: `id ${id}\nbase ${base}\ntree ${tree}\nrepo ${top}\nshort ${short.length}\nshortsha ${sha256(short)}\n` });
572
574
  return { bytes: prompt.length, markers, scan: scan(prompt.toString("utf8")), unsent: String(unsent).split("\n").filter(Boolean).length };
573
575
  } finally {
574
576
  rmSync(ctx.idx, { force: true });
@@ -623,14 +625,59 @@ function balanced(units, n) {
623
625
  return groups;
624
626
  }
625
627
 
628
+ // SHA-256 (FIPS 180-4) in plain JavaScript: the runner loads no crypto module (a Node built without it still runs the
629
+ // send). Only the short part goes through it, a few KiB.
630
+ const K256 = Array.from({ length: 64 }, (_, i) => {
631
+ const primes = [];
632
+ for (let n = 2; primes.length < 64; n += 1) if (primes.every((q) => n % q)) primes.push(n);
633
+ return (Math.cbrt(primes[i]) % 1) * 2 ** 32 >>> 0;
634
+ });
635
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
636
+ function sha256Block(h, w) {
637
+ for (let t = 16; t < 64; t += 1) {
638
+ const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ (w[t - 15] >>> 3);
639
+ const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ (w[t - 2] >>> 10);
640
+ w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
641
+ }
642
+ let [a, b, c, d, e, f, g, k] = h;
643
+ for (let t = 0; t < 64; t += 1) {
644
+ const t1 = (k + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K256[t] + w[t]) >>> 0;
645
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
646
+ [k, g, f, e, d, c, b, a] = [g, f, e, (d + t1) >>> 0, c, b, a, (t1 + t2) >>> 0];
647
+ }
648
+ return h.map((x, i) => (x + [a, b, c, d, e, f, g, k][i]) >>> 0);
649
+ }
650
+ export function sha256(data) {
651
+ const msg = Buffer.from(data);
652
+ const padded = Buffer.alloc(Math.ceil((msg.length + 9) / 64) * 64);
653
+ msg.copy(padded);
654
+ padded[msg.length] = 0x80;
655
+ padded.writeUInt32BE(Math.floor((msg.length * 8) / 2 ** 32), padded.length - 8);
656
+ padded.writeUInt32BE((msg.length * 8) >>> 0, padded.length - 4);
657
+ let h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
658
+ for (let off = 0; off < padded.length; off += 64) {
659
+ const w = new Array(64).fill(0);
660
+ for (let t = 0; t < 16; t += 1) w[t] = padded.readUInt32BE(off + t * 4);
661
+ h = sha256Block(h, w);
662
+ }
663
+ return h.map((x) => x.toString(16).padStart(8, "0")).join("");
664
+ }
665
+
666
+ // The short part as prepare wrote it: its length is a line start and, when run.md holds its sha256, the same bytes (a
667
+ // change of the same length inside it, a masked word, moved no line start). A run.md of an older prepare has no hash.
668
+ function shortIntact(buf, at, short, shortSha) {
669
+ if (!Number.isInteger(short) || !at.includes(short)) return false;
670
+ return !shortSha || sha256(buf.subarray(0, short)) === shortSha;
671
+ }
672
+
626
673
  // The payload cut into `n` parts where a part starts (a file's diff, a `=== ` line), between the short part (its
627
674
  // first `short` bytes, as prepare wrote it: a `=== ` line of the author's is no cut) and the closing marker. Each part
628
675
  // holds the short part, a `부분 <k>/<n>` line and the closing marker; the parts in order hold every part of the payload
629
676
  // once. A line inside a file that starts like a part may become a cut: the parts are still whole together.
630
- export function splitPayload(buf, n, id, short) {
677
+ export function splitPayload(buf, n, id, short, shortSha = null) {
631
678
  if (!Number.isInteger(n) || n < 2) throw new Stop("split", `나눌 수는 2 이상의 정수다: ${n}`);
632
679
  const at = lineStartsOf(buf);
633
- if (!Number.isInteger(short) || !at.includes(short)) throw new Stop("split", "prompt.md 의 짧은 부분이 prepare 때와 다르다. 가림은 짧은 부분 밖에서 하거나 prepare 를 다시 한다");
680
+ if (!shortIntact(buf, at, short, shortSha)) throw new Stop("split", "prompt.md 의 짧은 부분이 prepare 때와 다르다. 가림은 짧은 부분 밖에서 하거나 prepare 를 다시 한다");
634
681
  const lineAt = (k) => buf.slice(at[k], k + 1 < at.length ? at[k + 1] : buf.length).toString("utf8").replace(/\r?\n$/, "");
635
682
  const lines = at.map((_, k) => lineAt(k));
636
683
  const open = lines.findIndex((l) => l.includes(`<review-data id="${id}">`));