@kaydash9999/banker-plugins 0.26.0 → 0.27.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 (32) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +30 -0
  4. package/README.md +10 -7
  5. package/bin/lib/codex-hooks.mjs +7 -1
  6. package/codex/command-skills/front-qa/SKILL.md +3 -1
  7. package/codex/hooks.json +15 -5
  8. package/codex/manifest.json +2 -1
  9. package/codex/skills/ralph/SKILL.md +272 -0
  10. package/codex/skills/ralph/scripts/gate-evidence.mjs +457 -0
  11. package/codex/skills/ralph/scripts/ralph-state.mjs +622 -0
  12. package/codex/skills/ralph/scripts/run-folder.mjs +299 -0
  13. package/codex/transform-matrix.md +6 -5
  14. package/commands/front-qa.md +3 -1
  15. package/hooks/ralph-continue-codex.mjs +59 -0
  16. package/package.json +1 -1
  17. package/skills/all-in-one/SKILL.md +22 -8
  18. package/skills/make-evidence/SKILL.md +207 -131
  19. package/skills/make-evidence/scripts/evidence.mjs +162 -0
  20. package/skills/make-evidence/scripts/me-check.mjs +125 -0
  21. package/skills/make-evidence/scripts/me-commit.mjs +48 -0
  22. package/skills/make-evidence/scripts/me-git.mjs +63 -0
  23. package/skills/make-evidence/scripts/me-oracle.mjs +244 -0
  24. package/skills/make-evidence/scripts/me-precheck.mjs +122 -0
  25. package/skills/make-evidence/scripts/me-run.mjs +214 -0
  26. package/skills/ralph-qa/references/gate-evidence.mjs +3 -3
  27. package/skills/ralph-qa/references/run-folder.mjs +3 -3
  28. package/skills/ultra-chain/SKILL.md +11 -4
  29. package/skills/ultra-init/SKILL.md +9 -7
  30. package/skills/ultraqa/references/gate-evidence.mjs +3 -3
  31. package/skills/ultraqa/references/run-folder.mjs +3 -3
  32. package/skills/visual-ralph/SKILL.md +9 -4
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gate-evidence: a gate command run by this program, not by the model, and what it saw, as the evidence ralph-qa
4
+ * (--gates), ultraqa, the Codex ralph, all-in-one, ultra-init and ultra-chain judge by. The source of truth is
5
+ * skills/ralph-qa/references/gate-evidence.mjs; skills/ultraqa/references and codex/skills/ralph/scripts hold
6
+ * byte-identical copies (scripts/sync-adapter.js).
7
+ *
8
+ * node gate-evidence.mjs init [--cwd <repo>] the gate folder <g> of one ralph-qa run (ralph-qa-gates.<letters and digits>): made
9
+ * once before the baseline, kept across the iterations, apart from the payload folder seat-run.mjs makes anew
10
+ * each iteration; seat-run's day rule never takes it (another name, another marker). init and run stop with
11
+ * not-a-git-tree when the repo (--cwd) is no git work tree: its fingerprint would see no change.
12
+ * node gate-evidence.mjs cleanup --dir <g> that gate folder alone, once the run has its end word
13
+ * node gate-evidence.mjs run --dir <d> --id <id> --cmd <command> --timeout <s> [--iter <n>] [--cwd <repo>]
14
+ * one command (one gate, never several joined), POSIX `sh -c`, Windows PowerShell 7 (pwsh) when on PATH,
15
+ * else 5.1, with an exit that keeps the command's code; its output on the descriptor of a new file
16
+ * <d>/gate-<id>-<iter>-<seq>.md (no Node pipes); past the limit its whole tree ends (POSIX: its process group, TERM then
17
+ * KILL; Windows: taskkill /T /F); after it ends on its own the group is sent KILL too (POSIX). One
18
+ * `observed` line goes to <d>/evidence.md: the command, folder, times, exit or signal, timedOut, the output
19
+ * file and its sha256, the work tree fingerprint after it, the verdict and the failure signature. Each seq is
20
+ * claimed with a file of its own (<d>/.seq-<n>.md, made with "wx"), so two runs at once never share one.
21
+ * node gate-evidence.mjs note --dir <d> --id <id> --text <what> [--iter <n>] [--exit <code>]
22
+ * a `reported` line: what someone said (a model, an OMX report). Never a verdict's evidence.
23
+ * node gate-evidence.mjs gate --dir <d> --gates <a,b,...> --iter <n> [--cwd <repo>]
24
+ * gateConjunct over the evidence and the fingerprint now; exit 0 when it holds, 1 when not.
25
+ * node gate-evidence.mjs verify --dir <d> each observed output file against its recorded sha256; 1 on a mismatch
26
+ * node gate-evidence.mjs fingerprint [--cwd <repo>]
27
+ *
28
+ * <d> is a gate folder (init), an ultraqa run folder, or a ralph-qa folder an older version made with seat-run.mjs init
29
+ * (run-folder.mjs). Iteration 0 is the baseline, before any fix. Out: one
30
+ * JSON object on stdout; exit 2 for a usage or internal error, 3 for a stop. Every file name ends in .md (a closed
31
+ * network's DRM encrypts files by their extension and leaves .md readable).
32
+ */
33
+ import { spawn, spawnSync } from "node:child_process";
34
+ import { createHash } from "node:crypto";
35
+ import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
36
+ import { delimiter, dirname, join, resolve, win32 } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+ import { Stop, hashFile, makeRunFolder, removeRunFolder, requireGitTree, runFolder, snapshot } from "./run-folder.mjs";
39
+
40
+ const GATES_KIND = "ralph-qa-gates";
41
+ const KINDS = [GATES_KIND, "ralph-qa", "ultraqa"];
42
+ const EVIDENCE = "evidence.md";
43
+ const GRACE_MS = 2000;
44
+ // The output text the verdict and the signature read: whole up to 64 MiB, past that its first and last 32 MiB.
45
+ const TEXT_MAX = 64 << 20;
46
+ const sha256 = (b) => createHash("sha256").update(b).digest("hex");
47
+
48
+ // The nine adversarial classes (OMX ultraqa's), the lens of ralph-qa's adversarial seat and the rows of ultraqa's matrix.
49
+ export const CLASSES = [
50
+ ["malformed-input", "잘못된 입력"], ["repeated-interruption", "반복 중단"], ["prompt-injection", "프롬프트 주입"],
51
+ ["cancel-resume", "취소와 재개"], ["stale-state", "낡은 상태"], ["dirty-worktree", "더러운 작업 트리"],
52
+ ["hung-command", "멈춘 명령"], ["flaky-test", "흔들리는 시험"], ["misleading-output", "오도하는 성공 출력"],
53
+ ];
54
+
55
+ // ---- verdict and signature (pure)
56
+
57
+ // Exit 0 whose output still says a test failed or was skipped: node:test, TAP, Jest, Vitest, pytest, Go test.
58
+ const MISLEADING = [
59
+ ["node:test 실패", /^ℹ (?:fail|cancelled) [1-9]/m], ["node:test 건너뜀", /^ℹ (?:skipped|todo) [1-9]/m],
60
+ ["TAP 실패나 건너뜀", /^# (?:fail|skip|todo|cancelled) [1-9]/m],
61
+ ["Jest/Vitest 실패", /^\s*Tests?:?\s+.*?\b[1-9]\d* failed\b/m], ["Jest/Vitest 건너뜀", /^\s*Tests?:?\s+.*?\b[1-9]\d* (?:skipped|todo|pending)\b/m],
62
+ ["pytest 실패", /^=+ .*?\b[1-9]\d* (?:failed|errors?)\b.*=+\s*$/m], ["pytest 건너뜀", /^=+ .*?\b[1-9]\d* skipped\b.*=+\s*$/m],
63
+ ["Go test 실패", /^(?:--- FAIL: |FAIL\s)/m], ["Go test 건너뜀", /^\s*--- SKIP: /m],
64
+ ];
65
+
66
+ export function judge({ exit, signal, timedOut, text }) {
67
+ if (timedOut) return { verdict: "timeout", why: "시간 제한" };
68
+ if (exit !== 0) return { verdict: "fail", why: exit === null ? `신호 ${signal}` : `종료 ${exit}` };
69
+ const hit = MISLEADING.find(([, re]) => re.test(text));
70
+ return hit ? { verdict: "misleading", why: hit[0] } : { verdict: "pass", why: null };
71
+ }
72
+
73
+ const FAILURE_LINE = /(?:^|[\s(])(?:not ok|✖|FAIL(?:ED)?|Error|ERROR|error|[Ff]ailed|AssertionError|panic:|Traceback)\b/;
74
+ // What does not tell two failures apart: colours, times, numbers, long hex, temporary folders.
75
+ const normalized = (line) => line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/(?:\/tmp|\/var\/folders|\\Temp|\/private\/var)[^\s'"]*/gi, "<tmp>")
76
+ .replace(/\b[0-9a-f]{8,}\b/gi, "<hex>").replace(/\d+(?:\.\d+)?/g, "#").replace(/\s+/g, " ").trim();
77
+
78
+ // Where the command ran, as <repo>: the same failure in the repo and in a clean worktree copy of it (the baseline of a
79
+ // tree with changes) names two folders. Each root (never a file system root) in its own form and with / (a Windows
80
+ // file URL), longest first, as one pattern.
81
+ function rootsOut(roots) {
82
+ const forms = [...new Set(roots.filter((r) => r && dirname(r) !== r).flatMap((r) => [r, r.replace(/\\/g, "/")]))].sort((x, y) => y.length - x.length);
83
+ if (!forms.length) return (line) => line;
84
+ const re = new RegExp(forms.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gi");
85
+ return (line) => line.replace(re, "<repo>");
86
+ }
87
+
88
+ // The id with the sha256 (16 hex) of the normalized failure lines, or of the last 20 lines when none reads as one.
89
+ // `roots` are the folders the command ran in (its cwd, the repo root): written as <repo>.
90
+ export function signatureOf(id, verdict, text, roots = []) {
91
+ const lines = String(text).split(/\r?\n/);
92
+ const failing = lines.filter((l) => FAILURE_LINE.test(l));
93
+ const out = rootsOut(roots);
94
+ const picked = (failing.length ? failing : lines.filter((l) => l.trim()).slice(-20)).map((l) => normalized(out(l)));
95
+ return `${id}:${sha256(`${verdict}\n${[...new Set(picked)].slice(0, 200).join("\n")}`).slice(0, 16)}`;
96
+ }
97
+
98
+ // ---- the gate conjunct (pure)
99
+
100
+ const OK_STATES = new Set(["pass", "baseline", "misleading"]);
101
+ const PASSES_AFTER_FAIL = 3;
102
+
103
+ // `scenarios` stands for every scenario-* gate the evidence holds.
104
+ function expand(gates, observed) {
105
+ const scenarioIds = [...new Set(observed.map((r) => r.id).filter((id) => id.startsWith("scenario-")))];
106
+ return gates.flatMap((g) => (g !== "scenarios" ? [g] : scenarioIds.length ? scenarioIds : ["scenarios"]));
107
+ }
108
+
109
+ // A pass right after a failure counts once it has two more passes on the same tree.
110
+ function afterFailure(recs, last, fingerprint) {
111
+ const lastBad = recs.reduce((at, r, i) => (r.verdict !== "pass" ? i : at), -1);
112
+ if (lastBad === -1) return { state: "pass", seq: last.seq };
113
+ const passes = recs.slice(lastBad + 1).filter((r) => r.fingerprint === fingerprint).length;
114
+ return passes >= PASSES_AFTER_FAIL ? { state: "pass", seq: last.seq } : { state: "flaky", seq: last.seq, need: PASSES_AFTER_FAIL - passes };
115
+ }
116
+
117
+ // The baseline record (iteration 0 of the gate) failed the same way: the same command and failure signature.
118
+ const sameAsBaseline = (base, r) => Boolean(base) && base.verdict !== "pass" && base.cmd === r.cmd && base.signature === r.signature;
119
+
120
+ // A failure the baseline (iteration 0, the same command) had with the same signature is not this change's.
121
+ function failed(recs, last) {
122
+ const same = sameAsBaseline(recs.find((r) => r.iter === 0), last);
123
+ return same ? { state: "baseline", seq: last.seq, signature: last.signature } : { state: last.verdict, seq: last.seq, signature: last.signature };
124
+ }
125
+
126
+ function gateState(recs, id, fingerprint, iter) {
127
+ const now = recs.filter((r) => r.iter === iter);
128
+ if (!now.length) return { id, state: "missing" };
129
+ const last = now[now.length - 1];
130
+ // A tree with no git has one fingerprint whatever changes: no record of it shows the tree it was judged on.
131
+ if (last.git === false) return { id, state: "unverifiable", seq: last.seq, why: "git 저장소가 아니라 작업 트리 변경을 볼 수 없다" };
132
+ if (last.fingerprint !== fingerprint) return { id, state: "stale", seq: last.seq };
133
+ if (last.verdict === "misleading") return { id, state: "misleading", seq: last.seq, why: last.why };
134
+ return { id, ...(last.verdict === "pass" ? afterFailure(recs, last, fingerprint) : failed(recs, last)) };
135
+ }
136
+
137
+ // The quorum's gate conjunct: every declared gate has an observed record of this iteration on the tree as it is now,
138
+ // and passes (a pass after a failure: two more), or fails as the baseline did. `reported` lines never count.
139
+ export function gateConjunct(evidence, gates, fingerprint, iter) {
140
+ const observed = evidence.filter((r) => r && r.kind === "observed");
141
+ const results = expand(gates, observed).map((g) => gateState(observed.filter((r) => r.id === g), g, fingerprint, iter));
142
+ return { ok: results.length > 0 && results.every((x) => OK_STATES.has(x.state)), gates: results };
143
+ }
144
+
145
+ // Keys seen in `n` or more different iterations: the same failure (a gate's signature) or the same issue.
146
+ export function repeated(items, n = 3) {
147
+ const iters = new Map();
148
+ for (const { key, iter } of items) iters.set(key, (iters.get(key) || new Set()).add(iter));
149
+ return [...iters].filter(([, s]) => s.size >= n).map(([key]) => key);
150
+ }
151
+ // A gate's same failure, from iteration 1 on: the baseline (iteration 0) is no review iteration, and a failure the
152
+ // baseline had (same command, same signature; `baseline` in the conjunct, never this change's to fix) is not counted.
153
+ export function repeatedFailures(evidence, n = 3) {
154
+ const observed = evidence.filter((r) => r?.kind === "observed");
155
+ const own = observed.filter((r) => r.signature && r.iter !== 0 && !sameAsBaseline(observed.find((b) => b.id === r.id && b.iter === 0), r));
156
+ return repeated(own.map((r) => ({ key: r.signature, iter: r.iter })), n);
157
+ }
158
+
159
+ // ralph-qa's same issue: (seat id, the path or criterion ID it cites, its title normalized).
160
+ export const issueKey = (seat, cite, title) => [seat, cite, String(title).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim()].join("\u0000");
161
+
162
+ // How a run that spent --max ends: blockers left are INCONCLUSIVE; a run that only the gates kept from APPROVE says so.
163
+ export function exhausted({ blockers, quorumWithoutGate, gateOk }) {
164
+ if (blockers > 0) return "INCONCLUSIVE";
165
+ return quorumWithoutGate && gateOk === false ? "ITERATE(한도 소진, 원인 gate)" : "ITERATE(한도 소진)";
166
+ }
167
+
168
+ // ---- running one command
169
+
170
+ function onPath(name, env) {
171
+ const key = Object.keys(env).find((k) => k.toUpperCase() === "PATH");
172
+ for (const dir of String(key ? env[key] : "").split(delimiter).filter((x) => win32.isAbsolute(x))) {
173
+ const p = win32.join(dir, name);
174
+ if (existsSync(p) && statSync(p).isFile()) return p;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ // The shell: POSIX sh, or PowerShell with an exit that keeps a native command's code (5.1 makes any failure 1) and
180
+ // turns a failed cmdlet into 1. A newline before it, so a trailing comment in the command cannot swallow it.
181
+ export function shellFor(cmd, ctx) {
182
+ if (ctx.platform !== "win32") return { name: "sh", exe: "/bin/sh", args: ["-c", cmd] };
183
+ const pwsh = onPath("pwsh.exe", ctx.env);
184
+ const exe = pwsh || win32.join(ctx.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
185
+ const tail = "\n$rqOk = $?; if ($null -ne $LASTEXITCODE -and $LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; if (-not $rqOk) { exit 1 }; exit 0";
186
+ return { name: pwsh ? "pwsh" : "powershell", exe, args: ["-NoProfile", "-NonInteractive", "-Command", `${cmd}${tail}`] };
187
+ }
188
+
189
+ const killGroup = (pid, sig) => {
190
+ try {
191
+ process.kill(-pid, sig);
192
+ } catch {
193
+ /* the group is gone */
194
+ }
195
+ };
196
+ const taskkill = (ctx, pid) => spawnSync(win32.join(ctx.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true });
197
+
198
+ function endTree(ctx, child) {
199
+ if (ctx.platform === "win32") return void taskkill(ctx, child.pid);
200
+ killGroup(child.pid, "SIGTERM");
201
+ setTimeout(() => killGroup(child.pid, "SIGKILL"), GRACE_MS).unref();
202
+ }
203
+
204
+ // Runs `cmd` with its output on `fd` (a new output file, closed here); resolves { exit, signal, timedOut, secs, shell }.
205
+ // The child writes to the output file's descriptor itself, not through Node's pipes: those are AF_UNIX socket pairs,
206
+ // and in the Codex Linux sandbox their output can be lost (zero bytes recorded for a command that printed).
207
+ export function execute({ cmd, cwd, limitMs, fd }, ctx) {
208
+ const sh = shellFor(cmd, ctx);
209
+ const t0 = Date.now();
210
+ return new Promise((done) => {
211
+ const child = spawn(sh.exe, sh.args, { cwd, env: ctx.env, stdio: ["ignore", fd, fd], detached: ctx.platform !== "win32", windowsHide: true });
212
+ let timedOut = false;
213
+ let settled = false;
214
+ const finish = (status, error) => {
215
+ if (settled) return;
216
+ settled = true;
217
+ clearTimeout(limit);
218
+ closeSync(fd);
219
+ const { code = null, signal = null } = status || {};
220
+ done({ exit: error ? null : code, signal, timedOut, secs: Math.round((Date.now() - t0) / 100) / 10, shell: sh.name, error: error || null });
221
+ };
222
+ const limit = setTimeout(() => {
223
+ timedOut = true;
224
+ endTree(ctx, child);
225
+ }, limitMs);
226
+ child.on("error", (e) => (child.pid === undefined ? finish(null, e.code || "spawn") : null));
227
+ // After the shell ends, what it left in its group ends too (POSIX).
228
+ child.on("exit", (code, signal) => {
229
+ if (ctx.platform !== "win32") killGroup(child.pid, "SIGKILL");
230
+ finish({ code, signal });
231
+ });
232
+ });
233
+ }
234
+
235
+ // A new file (never one already there): its descriptor, or null when one is there; a folder the sandbox does not let
236
+ // it write is a stop with what to do.
237
+ function openNew(p) {
238
+ try {
239
+ return openSync(p, "wx", 0o600);
240
+ } catch (e) {
241
+ if (e.code === "EEXIST") return null;
242
+ if (!["EACCES", "EPERM", "EROFS"].includes(e.code)) throw e;
243
+ throw new Stop("no-write", `게이트 기록 파일을 만들지 못했다(${e.code}): ${p}. 실행 폴더가 샌드박스 쓰기 범위 밖이면 이 명령을 승인으로 다시 실행한다`, [p]);
244
+ }
245
+ }
246
+
247
+ // The next seq: past every recorded one (a record cut short leaves no line) and claimed by its own file made with
248
+ // "wx", so two runs in one folder at once never share one.
249
+ function claimSeq(d) {
250
+ const recorded = readEvidence(d).map((r) => (Number.isInteger(r?.seq) ? r.seq : 0));
251
+ for (let seq = Math.max(recorded.length, ...recorded) + 1; ; seq += 1) {
252
+ const fd = openNew(join(d, `.seq-${seq}.md`));
253
+ if (fd !== null) return (closeSync(fd), seq);
254
+ }
255
+ }
256
+
257
+ // A claimed seq whose output file is new (one a run of an older version left behind takes the next seq).
258
+ function newOutput(d, id, iter) {
259
+ for (;;) {
260
+ const seq = claimSeq(d);
261
+ const output = join(d, `gate-${id}-${iter}-${seq}.md`);
262
+ const fd = openNew(output);
263
+ if (fd !== null) return { seq, output, fd };
264
+ }
265
+ }
266
+
267
+ // What the verdict and the signature read: the whole output up to TEXT_MAX, past it the first and last half (a
268
+ // test runner's counts are at its end) with `cut`.
269
+ export function outputText(p, max = TEXT_MAX) {
270
+ const fd = openSync(p, "r");
271
+ try {
272
+ const size = fstatSync(fd).size;
273
+ if (size <= max) return { text: readFileSync(fd).toString("utf8"), size, cut: false };
274
+ const part = (at, n) => {
275
+ const b = Buffer.alloc(n);
276
+ return b.subarray(0, readSync(fd, b, 0, n, at));
277
+ };
278
+ return { text: Buffer.concat([part(0, max / 2), Buffer.from("\n"), part(size - max / 2, max / 2)]).toString("utf8"), size, cut: true };
279
+ } finally {
280
+ closeSync(fd);
281
+ }
282
+ }
283
+
284
+ // The verdict of one run; a pass whose output was too large to read whole is misleading (the seats judge it).
285
+ function verdictOf(r, out) {
286
+ if (r.error) return { verdict: "fail", why: `시작 실패(${r.error})` };
287
+ const v = judge({ ...r, text: out.text });
288
+ return v.verdict === "pass" && out.cut ? { verdict: "misleading", why: `출력 ${out.size} 바이트가 ${TEXT_MAX} 바이트를 넘어 앞뒤만 봄` } : v;
289
+ }
290
+
291
+ // ---- commands
292
+
293
+ class Usage extends Error {}
294
+ const FLAGS = {
295
+ init: ["cwd"], cleanup: ["dir"], run: ["dir", "id", "cmd", "timeout", "iter", "cwd"], note: ["dir", "id", "text", "iter", "exit"],
296
+ gate: ["dir", "gates", "iter", "cwd"], verify: ["dir"], fingerprint: ["cwd"],
297
+ };
298
+ const REQUIRED = { init: [], cleanup: ["dir"], run: ["dir", "id", "cmd", "timeout"], note: ["dir", "id", "text"], gate: ["dir", "gates", "iter"], verify: ["dir"], fingerprint: [] };
299
+
300
+ export function parseArgs(argv) {
301
+ const [command, ...rest] = argv;
302
+ if (!Object.prototype.hasOwnProperty.call(FLAGS, command)) throw new Usage(`모르는 명령이다: ${command ?? "(없음)"}`);
303
+ const o = {};
304
+ for (let i = 0; i < rest.length; ) i = readFlag(FLAGS[command], rest, i, o);
305
+ for (const r of REQUIRED[command]) if (!(r in o)) throw new Usage(`--${r} 가 필요하다`);
306
+ return { command, o };
307
+ }
308
+
309
+ // One `--name value` or `--name=value` at rest[i] into o; gives the index of the next one.
310
+ function readFlag(spec, rest, i, o) {
311
+ const m = /^--([a-z]+)(?:=([\s\S]*))?$/.exec(rest[i]);
312
+ if (!m || !spec.includes(m[1])) throw new Usage(`모르는 인자다: ${String(rest[i]).slice(0, 80)}`);
313
+ if (m[1] in o) throw new Usage(`--${m[1]} 를 두 번 줬다`);
314
+ const value = m[2] !== undefined ? m[2] : rest[i + 1];
315
+ if (value === undefined) throw new Usage(`--${m[1]} 에 값이 없다`);
316
+ o[m[1]] = value;
317
+ return m[2] !== undefined ? i + 1 : i + 2;
318
+ }
319
+
320
+ const gateId = (id) => {
321
+ if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(String(id))) throw new Usage(`게이트 id 는 영소문자, 숫자, - 로 48자까지다: ${String(id).slice(0, 60)}`);
322
+ return id;
323
+ };
324
+ const whole = (v, name, min, max) => {
325
+ const n = Number(v);
326
+ if (!Number.isInteger(n) || n < min || n > max) throw new Usage(`--${name} 는 ${min}~${max} 정수다: ${String(v).slice(0, 20)}`);
327
+ return n;
328
+ };
329
+
330
+ export function readEvidence(d) {
331
+ let text = "";
332
+ try {
333
+ text = readFileSync(join(d, EVIDENCE), "utf8");
334
+ } catch {
335
+ return [];
336
+ }
337
+ return text.split("\n").filter(Boolean).map((l) => {
338
+ try {
339
+ return JSON.parse(l);
340
+ } catch {
341
+ return { kind: "unreadable" };
342
+ }
343
+ });
344
+ }
345
+ const append = (d, record) => appendFileSync(join(d, EVIDENCE), `${JSON.stringify(record)}\n`, { mode: 0o600 });
346
+
347
+ function init(o, ctx) {
348
+ requireGitTree(o.cwd || process.cwd(), ctx.env);
349
+ const { dir, base } = makeRunFolder(ctx, GATES_KIND);
350
+ return [{ ok: true, dir, base }, 0];
351
+ }
352
+
353
+ // The gate folder alone (an older ralph-qa.* folder goes with seat-run.mjs cleanup, which knows its answer folder).
354
+ function cleanup(o, ctx) {
355
+ return [{ ok: true, ...removeRunFolder(o.dir, ctx, [GATES_KIND]) }, 0];
356
+ }
357
+
358
+ async function run(o, ctx) {
359
+ const { d } = runFolder(o.dir, ctx, KINDS);
360
+ const [id, iter, limit] = [gateId(o.id), whole(o.iter ?? 0, "iter", 0, 999), whole(o.timeout, "timeout", 1, 86400)];
361
+ const cwd = resolve(o.cwd || process.cwd());
362
+ if (!existsSync(cwd)) throw new Stop("no-cwd", `--cwd 폴더가 없다: ${cwd}`);
363
+ requireGitTree(cwd, ctx.env);
364
+ const { seq, output, fd } = newOutput(d, id, iter);
365
+ const startedAt = new Date().toISOString();
366
+ const r = await execute({ cmd: o.cmd, cwd, limitMs: limit * 1000, fd }, ctx);
367
+ const out = outputText(output);
368
+ const snap = await snapshot(cwd, ctx.env);
369
+ const v = verdictOf(r, out);
370
+ const record = {
371
+ kind: "observed", id, iter, seq, cmd: o.cmd, cwd, shell: r.shell, startedAt, endedAt: new Date().toISOString(), secs: r.secs,
372
+ exit: r.exit, signal: r.signal, timedOut: r.timedOut, timeout: limit, output, outputSha256: hashFile(createHash, output), bytes: out.size,
373
+ git: snap.git, fingerprint: snap.fingerprint, verdict: v.verdict, why: v.why,
374
+ signature: v.verdict === "pass" ? null : signatureOf(id, v.verdict, out.text, [cwd, snap.root]),
375
+ };
376
+ append(d, record);
377
+ return [record, 0];
378
+ }
379
+
380
+ function note(o, ctx) {
381
+ const { d } = runFolder(o.dir, ctx, KINDS);
382
+ const record = { kind: "reported", id: gateId(o.id), iter: whole(o.iter ?? 0, "iter", 0, 999), exit: o.exit === undefined ? null : Number(o.exit),
383
+ text: String(o.text).slice(0, 4000), at: new Date().toISOString(), seq: claimSeq(d) };
384
+ append(d, record);
385
+ return [record, 0];
386
+ }
387
+
388
+ async function gate(o, ctx) {
389
+ const { d } = runFolder(o.dir, ctx, KINDS);
390
+ const gates = String(o.gates).split(",").map((g) => g.trim()).filter(Boolean).map((g) => gateId(g.replace(/^custom:.*$/s, "custom")));
391
+ const evidence = readEvidence(d);
392
+ const snap = await snapshot(resolve(o.cwd || process.cwd()), ctx.env);
393
+ const result = gateConjunct(evidence, gates, snap.fingerprint, whole(o.iter, "iter", 0, 999));
394
+ return [{ ...result, fingerprint: snap.fingerprint, repeatedFailures: repeatedFailures(evidence) }, result.ok ? 0 : 1];
395
+ }
396
+
397
+ function verify(o, ctx) {
398
+ const { d } = runFolder(o.dir, ctx, KINDS);
399
+ const checked = readEvidence(d).filter((r) => r.kind === "observed").map((r) => {
400
+ let now = null;
401
+ try {
402
+ now = hashFile(createHash, r.output);
403
+ } catch {
404
+ /* gone */
405
+ }
406
+ return { seq: r.seq, id: r.id, output: r.output, state: now === null ? "missing" : now === r.outputSha256 ? "same" : "changed" };
407
+ });
408
+ const bad = checked.filter((c) => c.state !== "same");
409
+ return [{ ok: bad.length === 0, checked: checked.length, bad }, bad.length ? 1 : 0];
410
+ }
411
+
412
+ async function fingerprint(o, ctx) {
413
+ const snap = await snapshot(resolve(o.cwd || process.cwd()), ctx.env);
414
+ return [snap, 0];
415
+ }
416
+
417
+ const COMMANDS = { init, cleanup, run, note, gate, verify, fingerprint };
418
+
419
+ // A stop as JSON: what it is, its line, its paths, and for not-a-git-tree the folder it checked.
420
+ const stopJson = (e) => ({ ok: false, stop: e.code, message: `gate-evidence: ${e.message}`, detail: e.detail, ...(e.checked ? { checked: e.checked } : {}) });
421
+
422
+ // A usage error and an internal one on stderr (2), a stop as JSON on stdout (3).
423
+ function failure(e, out, err) {
424
+ if (e instanceof Stop) {
425
+ out.write(`${JSON.stringify(stopJson(e), null, 2)}\n`);
426
+ return 3;
427
+ }
428
+ err.write(e instanceof Usage ? `gate-evidence: ${e.message}\n` : `gate-evidence: 내부 오류(${e?.code || e?.name || "error"}): ${String(e?.message || e).split("\n")[0]}\n`);
429
+ return 2;
430
+ }
431
+
432
+ export async function main(argv, io = {}) {
433
+ const ctx = { env: io.env || process.env, platform: io.platform || process.platform };
434
+ const out = io.stdout || process.stdout;
435
+ try {
436
+ const { command, o } = parseArgs(argv);
437
+ const [result, code] = await COMMANDS[command](o, ctx);
438
+ out.write(`${JSON.stringify({ command, ...result }, null, 2)}\n`);
439
+ return code;
440
+ } catch (e) {
441
+ return failure(e, out, io.stderr || process.stderr);
442
+ }
443
+ }
444
+
445
+ const invokedDirectly = () => {
446
+ try {
447
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
448
+ } catch {
449
+ return false;
450
+ }
451
+ };
452
+
453
+ if (invokedDirectly()) {
454
+ main(process.argv.slice(2)).then((code) => {
455
+ process.exitCode = code;
456
+ });
457
+ }