@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,622 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ralph-state: the record of one banker ralph run (Codex), and the check that decides when it may end. The record is
4
+ * <g>/ralph.md in the ralph-qa gate folder <g> the run's gates use (gate-evidence.mjs init). It is not kept in the
5
+ * repo: an untracked file there would change the work tree fingerprint every gate record is judged by.
6
+ *
7
+ * start --dir <g> --cwd <repo> --task <text> [--plan <path>] (--gates <a,b> | --no-gates <reason>) [--max <n>]
8
+ * [--no-deslop] a new run at iteration 0; baseline "run" when the tree has no change
9
+ * next --dir <g> the next iteration (1, 2, ...), up to --max
10
+ * deslop --dir <g> --cwd <repo> --note <text> this iteration's slop pass over the changed files, on the tree now
11
+ * accept-baseline --dir <g> --cwd <repo> --id <gate> --reason <text>
12
+ * a gate that fails as it did before the change, said not to be this
13
+ * task's; counts only with a reviewer's APPROVE recorded after it
14
+ * evidence --dir <g> this iteration's gate records and accepted baselines, for a reviewer
15
+ * verdict --dir <g> --cwd <repo> --by <reviewer-N> --verdict APPROVE|REJECT --fingerprint <fp> --note <text>
16
+ * [--blockers <n>] a fresh reviewer's word on the tree it was shown (<fp> from check)
17
+ * check --dir <g> --cwd <repo> exit 0 when this iteration may end the run, 1 with what is missing
18
+ * done --dir <g> --cwd <repo> check, then status complete; exit 1 and no change when check fails
19
+ * stop --dir <g> --reason <text> status stopped: a blocker the user must see
20
+ * adopt --dir <g> this Codex session (CODEX_THREAD_ID) takes over the run (a resume)
21
+ * status --dir <g> [--cwd <repo>] the record, and the check when --cwd is given
22
+ * find --cwd <repo> this repo's runs under the known bases, newest first
23
+ *
24
+ * continueFor() is what the Codex Stop hook (hooks/ralph-continue-codex.mjs) asks: while the session that started a
25
+ * run (CODEX_THREAD_ID at start) still has it active, the turn may not end. Five stops in a row with no change to the
26
+ * record or the gate evidence let it end, so a run that cannot move on never loops for ever.
27
+ *
28
+ * Every change to the record is made under <g>/.ralph.lock, so two calls at once never lose a verdict. Out: one JSON
29
+ * object on stdout; exit 2 for a usage or internal error, 3 for a stop. The gate commands themselves run through
30
+ * gate-evidence.mjs (a byte-identical copy of ralph-qa's), never through this program. No network, no shell.
31
+ */
32
+ import { createHash, randomBytes } from "node:crypto";
33
+ import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
34
+ import { homedir, tmpdir } from "node:os";
35
+ import { join, resolve } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { gateConjunct, readEvidence, repeatedFailures } from "./gate-evidence.mjs";
38
+ import { Stop, bases, hashFile, listDir, norm, realOr, requireGitTree, runFolder, snapshot, spawnFiles } from "./run-folder.mjs";
39
+
40
+ const GATES_KIND = "ralph-qa-gates";
41
+ const STATE = "ralph.md";
42
+ const LOCK = ".ralph.lock";
43
+ const TEXT_MAX = 4000;
44
+ const REVIEWER = /^reviewer-[1-9][0-9]?$/;
45
+ const STATUSES = new Set(["active", "complete", "stopped"]);
46
+ const LOCK_WAIT_MS = 10000;
47
+ const LOCK_STALE_MS = 60000;
48
+ const CONTINUE = ".ralph-continue.md";
49
+ const CONTINUE_LIMIT = 5;
50
+ const CONTINUE_TOTAL = 100;
51
+ const SAME_STOP_MS = 3000;
52
+ const HOOK_LOCK_WAIT_MS = 200;
53
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
54
+
55
+ class Usage extends Error {}
56
+
57
+ const FLAGS = {
58
+ start: ["dir", "cwd", "task", "plan", "gates", "no-gates", "max", "no-deslop"], next: ["dir"],
59
+ verdict: ["dir", "cwd", "by", "verdict", "fingerprint", "note", "blockers"], deslop: ["dir", "cwd", "note"],
60
+ "accept-baseline": ["dir", "cwd", "id", "reason"], evidence: ["dir"],
61
+ check: ["dir", "cwd"], done: ["dir", "cwd"], stop: ["dir", "reason"], status: ["dir", "cwd"], find: ["cwd"], adopt: ["dir"],
62
+ };
63
+ const REQUIRED = {
64
+ start: ["dir", "cwd", "task"], next: ["dir"], verdict: ["dir", "cwd", "by", "verdict", "fingerprint", "note"], deslop: ["dir", "cwd", "note"],
65
+ "accept-baseline": ["dir", "cwd", "id", "reason"], evidence: ["dir"],
66
+ check: ["dir", "cwd"], done: ["dir", "cwd"], stop: ["dir", "reason"], status: ["dir"], find: ["cwd"], adopt: ["dir"],
67
+ };
68
+ const BOOLEAN = new Set(["no-deslop"]);
69
+
70
+ export function parseArgs(argv) {
71
+ const [command, ...rest] = argv;
72
+ if (!Object.prototype.hasOwnProperty.call(FLAGS, command)) throw new Usage(`모르는 명령이다: ${command ?? "(없음)"}`);
73
+ const o = {};
74
+ for (let i = 0; i < rest.length; ) i = readFlag(FLAGS[command], rest, i, o);
75
+ for (const r of REQUIRED[command]) if (!(r in o)) throw new Usage(`--${r} 가 필요하다`);
76
+ return { command, o };
77
+ }
78
+
79
+ // One `--name value`, `--name=value` or boolean `--name` at rest[i] into o; gives the index of the next one. A value
80
+ // that starts with -- is taken only in the `--name=value` form: `--task --gates` is a missing value, not a task.
81
+ function readFlag(spec, rest, i, o) {
82
+ const m = /^--([a-z][a-z-]*)(?:=([\s\S]*))?$/.exec(rest[i]);
83
+ if (!m || !spec.includes(m[1])) throw new Usage(`모르는 인자다: ${String(rest[i]).slice(0, 80)}`);
84
+ if (m[1] in o) throw new Usage(`--${m[1]} 를 두 번 줬다`);
85
+ if (BOOLEAN.has(m[1])) {
86
+ if (m[2] !== undefined) throw new Usage(`--${m[1]} 는 값을 받지 않는다`);
87
+ o[m[1]] = true;
88
+ return i + 1;
89
+ }
90
+ const inline = m[2] !== undefined;
91
+ const value = inline ? m[2] : nextValue(rest[i + 1]);
92
+ if (value === undefined) throw new Usage(`--${m[1]} 에 값이 없다`);
93
+ o[m[1]] = value;
94
+ return inline ? i + 1 : i + 2;
95
+ }
96
+ const nextValue = (v) => (v === undefined || v.startsWith("--") ? undefined : v);
97
+
98
+ const whole = (v, name, min, max) => {
99
+ const n = Number(v);
100
+ if (!Number.isInteger(n) || n < min || n > max) throw new Usage(`--${name} 는 ${min}~${max} 정수다: ${String(v).slice(0, 20)}`);
101
+ return n;
102
+ };
103
+ const text = (v, name) => {
104
+ const s = String(v).trim();
105
+ if (!s) throw new Usage(`--${name} 가 비었다`);
106
+ return s.slice(0, TEXT_MAX);
107
+ };
108
+ const GATE_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
109
+ const gateIds = (v) => {
110
+ const ids = String(v).split(",").map((g) => g.trim()).filter(Boolean);
111
+ const bad = ids.find((g) => !GATE_ID.test(g));
112
+ if (bad !== undefined) throw new Usage(`게이트 id 는 영소문자, 숫자, - 로 48자까지다: ${bad.slice(0, 60)}`);
113
+ if (!ids.length) throw new Usage("--gates 가 비었다");
114
+ return [...new Set(ids)];
115
+ };
116
+
117
+ // ---- the record and its lock
118
+
119
+ const statePath = (d) => join(d, STATE);
120
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
121
+
122
+ // The shape every command relies on; anything else is a record this program did not write.
123
+ function validState(s) {
124
+ const arrays = ["gates", "verdicts", "deslops", "accepts"].every((k) => Array.isArray(s?.[k]));
125
+ const strings = ["repo", "task", "startFingerprint", "baseline"].every((k) => typeof s?.[k] === "string");
126
+ const numbers = ["iter", "max", "seq"].every((k) => Number.isInteger(s?.[k]));
127
+ return s?.kind === "banker-ralph" && s.v === 1 && STATUSES.has(s.status) && arrays && strings && numbers;
128
+ }
129
+
130
+ function readState(d) {
131
+ let raw;
132
+ try {
133
+ raw = readFileSync(statePath(d), "utf8");
134
+ } catch {
135
+ throw new Stop("not-started", `이 게이트 폴더에 ralph 기록이 없다(start 를 먼저): ${d}`);
136
+ }
137
+ let s = null;
138
+ try {
139
+ s = JSON.parse(raw);
140
+ } catch {
141
+ /* below */
142
+ }
143
+ if (!validState(s)) throw new Stop("bad-state", `ralph 기록을 읽을 수 없다: ${statePath(d)}`);
144
+ return s;
145
+ }
146
+
147
+ // Written whole to a new file, then moved over the old one: a stop part way leaves the old record. Windows may refuse
148
+ // the move for a moment while a scanner holds the file: a few short retries.
149
+ function writeState(d, s) {
150
+ const tmp = join(d, `.${STATE}.${randomBytes(6).toString("hex")}.tmp`);
151
+ writeFileSync(tmp, `${JSON.stringify(s, null, 2)}\n`, { mode: 0o600, flag: "wx" });
152
+ for (let i = 0; ; i += 1) {
153
+ try {
154
+ renameSync(tmp, statePath(d));
155
+ return;
156
+ } catch (e) {
157
+ if (i >= 4 || !["EPERM", "EBUSY", "EACCES"].includes(e.code)) throw e;
158
+ sleep(100);
159
+ }
160
+ }
161
+ }
162
+
163
+ // The lock's content names its holder: only the holder removes it, never a lock someone else took after a stale one.
164
+ function takeLock(p) {
165
+ const token = `${process.pid} ${new Date().toISOString()} ${Math.random().toString(36).slice(2)}\n`;
166
+ const fd = openSync(p, "wx", 0o600);
167
+ writeSync(fd, token);
168
+ closeSync(fd);
169
+ return token;
170
+ }
171
+
172
+ function dropLock(p, token) {
173
+ try {
174
+ if (readFileSync(p, "utf8") === token) unlinkSync(p);
175
+ } catch {
176
+ /* gone already */
177
+ }
178
+ }
179
+
180
+ const unlinkGone = (p) => {
181
+ try {
182
+ unlinkSync(p);
183
+ } catch (e) {
184
+ if (e.code !== "ENOENT") throw e;
185
+ }
186
+ };
187
+
188
+ // A lock left by a process that died is old: removed after a minute. A live call holds it for milliseconds.
189
+ function staleLock(p) {
190
+ try {
191
+ return Date.now() - statSync(p).mtimeMs > LOCK_STALE_MS;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ // fn runs with <g>/.ralph.lock held: the read, the change and the write of the record as one step.
198
+ function locked(d, fn, waitMs = LOCK_WAIT_MS) {
199
+ const p = join(d, LOCK);
200
+ let token;
201
+ for (const until = Date.now() + waitMs; !token; ) {
202
+ try {
203
+ token = takeLock(p);
204
+ } catch (e) {
205
+ if (e.code !== "EEXIST") throw e;
206
+ if (staleLock(p)) unlinkGone(p);
207
+ else if (Date.now() > until) throw new Stop("busy", `다른 ralph-state 호출이 기록을 쥐고 있다: ${p}`);
208
+ else sleep(50);
209
+ }
210
+ }
211
+ try {
212
+ return fn();
213
+ } finally {
214
+ dropLock(p, token);
215
+ }
216
+ }
217
+
218
+ // read, change, write under the lock; `change` returns the command's result.
219
+ const update = (d, change) => locked(d, () => {
220
+ const s = readState(d);
221
+ const result = change(s);
222
+ writeState(d, s);
223
+ return result;
224
+ });
225
+
226
+ const gateFolder = (o, ctx) => runFolder(o.dir, ctx, [GATES_KIND]).d;
227
+
228
+ // The repo the command names must be the run's: a check against another tree would judge the wrong work.
229
+ async function treeOf(o, ctx, repo) {
230
+ const snap = await snapshot(resolve(o.cwd), ctx.env);
231
+ if (!snap.git) throw new Stop("not-a-git-tree", `git 저장소가 아니다: ${resolve(o.cwd)}`);
232
+ if (repo && norm(ctx, realOr(snap.root)) !== norm(ctx, realOr(repo))) throw new Stop("other-repo", `이 실행의 저장소가 아니다: ${snap.root} (실행은 ${repo})`);
233
+ return snap;
234
+ }
235
+
236
+ const active = (s) => {
237
+ if (s.status !== "active") throw new Stop("not-active", `이 실행은 이미 ${s.status} 다`);
238
+ };
239
+ const iterating = (s, what) => {
240
+ active(s);
241
+ if (s.iter < 1) throw new Stop("no-iteration", `반복 1 전에는 ${what}을 적지 않는다(next 를 먼저)`);
242
+ };
243
+ const stamp = (s) => ({ seq: (s.seq += 1), at: new Date().toISOString() });
244
+
245
+ // No change at all: `git status` prints nothing. Its output goes to a file, not a Node pipe (the Codex Linux sandbox
246
+ // can lose a pipe's output, and an empty output would read as clean); any error reads as not clean.
247
+ function cleanTree(root, env) {
248
+ const cwd = existsSync(homedir()) ? homedir() : tmpdir();
249
+ const r = spawnFiles("git", ["-c", "core.fsmonitor=false", "-C", root, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd, env, windowsHide: true });
250
+ return r.status === 0 && !r.error && Buffer.isBuffer(r.stdout) && r.stdout.length === 0;
251
+ }
252
+
253
+ // `git diff --quiet HEAD` by its exit code alone (1: a change), which no lost output can turn into "clean".
254
+ function diffQuiet(root, env) {
255
+ const cwd = existsSync(homedir()) ? homedir() : tmpdir();
256
+ const r = spawnFiles("git", ["-c", "core.fsmonitor=false", "-C", root, "diff", "--quiet", "--no-ext-diff", "HEAD"], { cwd, env, windowsHide: true });
257
+ return r.status === 0 && !r.error;
258
+ }
259
+
260
+ // ---- the check
261
+
262
+ // Iteration 0 is the baseline only when it ran on the tree the run started from, and that tree had no change.
263
+ const usable = (s, evidence) => evidence.filter((r) => r?.kind !== "observed" || r.iter !== 0 || (s.baseline === "run" && r.fingerprint === s.startFingerprint));
264
+
265
+ // Each observed output of this iteration still has the sha256 its record names.
266
+ function changedOutputs(evidence, iter) {
267
+ return evidence.filter((r) => r.kind === "observed" && r.iter === iter).filter((r) => {
268
+ try {
269
+ return hashFile(createHash, r.output) !== r.outputSha256;
270
+ } catch {
271
+ return true;
272
+ }
273
+ }).map((r) => r.seq);
274
+ }
275
+
276
+ const acceptedFor = (s, g, fingerprint) => s.accepts.find((a) => a.id === g.id && a.signature === g.signature && a.iter === s.iter && a.fingerprint === fingerprint);
277
+
278
+ function gateLine(s, g, fingerprint) {
279
+ if (g.state === "pass" || g.state === "misleading") return null;
280
+ if (g.state === "baseline") {
281
+ return acceptedFor(s, g, fingerprint) ? null
282
+ : `게이트 ${g.id}: 시작 전부터 같은 실패(baseline). 이 작업이 고칠 실패면 고치고, 상관없는 실패면 accept-baseline 으로 까닭을 적은 뒤 검토받음`;
283
+ }
284
+ return `게이트 ${g.id}: ${g.state}${g.need ? `(통과 ${g.need}회 더)` : ""}`;
285
+ }
286
+
287
+ function gateMissing(s, evidence, fingerprint) {
288
+ if (!s.gates.length) return { missing: [], warnings: [`게이트 없음: ${s.noGates}`], baseline: 0, misleading: 0 };
289
+ const conj = gateConjunct(evidence, s.gates, fingerprint, s.iter);
290
+ const missing = conj.gates.map((g) => gateLine(s, g, fingerprint)).filter(Boolean);
291
+ const misleading = conj.gates.filter((g) => g.state === "misleading");
292
+ const baseline = conj.gates.filter((g) => g.state === "baseline" && acceptedFor(s, g, fingerprint));
293
+ const warnings = [...misleading.map((g) => `게이트 ${g.id}: 종료 0 인데 출력에 실패나 건너뜀(${g.why}). 검토자가 판단`),
294
+ ...baseline.map((g) => `게이트 ${g.id}: 시작 전부터 같은 실패를 받아들임(${acceptedFor(s, g, fingerprint).reason})`)];
295
+ const changed = changedOutputs(evidence, s.iter);
296
+ if (changed.length) missing.push(`출력 파일이 기록과 다름: seq ${changed.join(", ")}`);
297
+ return { missing, warnings, baseline: baseline.length, misleading: misleading.length };
298
+ }
299
+
300
+ // A REJECT of this tree stands in any iteration. An APPROVE counts for this iteration and tree, and only when it came
301
+ // after every baseline acceptance of that tree (the reviewer must have seen it).
302
+ function reviewMissing(s, fingerprint) {
303
+ const rejects = s.verdicts.filter((v) => v.verdict === "REJECT" && v.fingerprint === fingerprint);
304
+ if (rejects.length) return [`이 작업 트리에 검토자 REJECT: ${[...new Set(rejects.map((v) => v.by))].join(", ")}`];
305
+ const lastAccept = Math.max(0, ...s.accepts.filter((a) => a.iter === s.iter && a.fingerprint === fingerprint).map((a) => a.seq));
306
+ const approved = s.verdicts.some((v) => v.verdict === "APPROVE" && v.iter === s.iter && v.fingerprint === fingerprint && v.seq > lastAccept);
307
+ return approved ? [] : [lastAccept ? "accept-baseline 뒤에 받은 검토자 APPROVE 없음" : "지금 작업 트리에 대한 검토자 APPROVE 없음"];
308
+ }
309
+
310
+ // What keeps this iteration from ending the run: the gates on the tree as it is now (a baseline failure only when
311
+ // accepted), a fresh reviewer's APPROVE of that tree with no REJECT of it, and this iteration's slop pass on that tree.
312
+ export function checkRun(s, rawEvidence, fingerprint) {
313
+ const evidence = usable(s, rawEvidence);
314
+ const missing = [];
315
+ if (s.status !== "active") missing.push(`실행 상태 ${s.status}`);
316
+ if (s.iter < 1) missing.push("반복 1 전(구현 전)");
317
+ const gates = gateMissing(s, evidence, fingerprint);
318
+ missing.push(...gates.missing, ...reviewMissing(s, fingerprint));
319
+ if (s.deslop && !s.deslops.some((x) => x.iter === s.iter && x.fingerprint === fingerprint)) missing.push("지금 작업 트리에 대한 이번 반복의 슬롭 점검 기록 없음");
320
+ return { ok: missing.length === 0, iter: s.iter, missing, warnings: gates.warnings, baseline: gates.baseline, misleading: gates.misleading,
321
+ repeatedFailures: repeatedFailures(evidence) };
322
+ }
323
+
324
+ // ---- commands
325
+
326
+ // The gates the run must pass, or the reason it has none: one of the two, never both.
327
+ function gatesOf(o) {
328
+ const given = o.gates !== undefined;
329
+ const none = o["no-gates"] !== undefined;
330
+ if (given === none) throw new Usage("--gates <a,b> 나 --no-gates <까닭> 중 하나만 준다");
331
+ return given ? { gates: gateIds(o.gates), noGates: null } : { gates: [], noGates: text(o["no-gates"], "no-gates") };
332
+ }
333
+
334
+ async function start(o, ctx) {
335
+ const d = gateFolder(o, ctx);
336
+ const gates = gatesOf(o);
337
+ const fields = { task: text(o.task, "task"), max: whole(o.max ?? 10, "max", 1, 100) };
338
+ const root = requireGitTree(o.cwd, ctx.env);
339
+ const snap = await snapshot(root, ctx.env);
340
+ const clean = cleanTree(snap.root, ctx.env) && snap.untracked.length === 0 && diffQuiet(snap.root, ctx.env);
341
+ const s = {
342
+ v: 1, kind: "banker-ralph", status: "active", repo: snap.root, ...fields, plan: o.plan ? resolve(o.plan) : null, ...gates,
343
+ deslop: !o["no-deslop"], iter: 0, seq: 0, head: snap.head, startFingerprint: snap.fingerprint, baseline: clean ? "run" : "none-dirty",
344
+ session: UUID.test(String(ctx.env.CODEX_THREAD_ID || "")) ? ctx.env.CODEX_THREAD_ID : null,
345
+ startedAt: new Date().toISOString(), verdicts: [], deslops: [], accepts: [], ended: null,
346
+ };
347
+ locked(d, () => {
348
+ if (existsSync(statePath(d))) throw new Stop("started", `이 게이트 폴더에는 이미 ralph 기록이 있다(status 로 이어 감): ${d}`);
349
+ writeState(d, s);
350
+ });
351
+ const warnings = s.session ? [] : ["CODEX_THREAD_ID 가 없어 Stop 훅이 이 실행을 잇지 않는다(Codex 세션 안에서 adopt 하면 이음)"];
352
+ return [{ ok: true, dir: d, head: s.head, session: s.session, warnings, iter: 0, baseline: s.baseline, startFingerprint: s.startFingerprint, gates: s.gates, max: s.max, deslop: s.deslop }, 0];
353
+ }
354
+
355
+ function next(o, ctx) {
356
+ const d = gateFolder(o, ctx);
357
+ const s = update(d, (st) => {
358
+ active(st);
359
+ if (st.iter >= st.max) throw new Stop("max-spent", `반복 한도 ${st.max} 를 다 썼다. stop 으로 끝내고 남은 문제를 보고한다`);
360
+ st.iter += 1;
361
+ return st;
362
+ });
363
+ return [{ ok: true, iter: s.iter, max: s.max, repeatedFailures: repeatedFailures(usable(s, readEvidence(d))) }, 0];
364
+ }
365
+
366
+ async function verdict(o, ctx) {
367
+ const d = gateFolder(o, ctx);
368
+ if (!REVIEWER.test(String(o.by))) throw new Usage(`--by 는 reviewer-<번호> 다(저자는 판정하지 않는다): ${String(o.by).slice(0, 40)}`);
369
+ const word = String(o.verdict).toUpperCase();
370
+ if (word !== "APPROVE" && word !== "REJECT") throw new Usage("--verdict 는 APPROVE 나 REJECT 다");
371
+ const blockers = whole(o.blockers ?? 0, "blockers", 0, 999);
372
+ if (word === "APPROVE" && blockers > 0) throw new Usage("blocker 가 있는 APPROVE 는 없다(REJECT 로 적는다)");
373
+ const snap = await treeOf(o, ctx, readState(d).repo);
374
+ if (o.fingerprint !== snap.fingerprint) throw new Stop("stale-review", "검토자가 본 작업 트리(--fingerprint)가 지금과 다르다. 지금 트리로 다시 검토받는다");
375
+ const record = update(d, (s) => {
376
+ iterating(s, "판정");
377
+ const r = { by: o.by, verdict: word, blockers, note: text(o.note, "note"), iter: s.iter, fingerprint: snap.fingerprint, ...stamp(s) };
378
+ s.verdicts.push(r);
379
+ return r;
380
+ });
381
+ return [{ ok: true, ...record }, 0];
382
+ }
383
+
384
+ async function deslop(o, ctx) {
385
+ const d = gateFolder(o, ctx);
386
+ const snap = await treeOf(o, ctx, readState(d).repo);
387
+ const record = update(d, (s) => {
388
+ iterating(s, "슬롭 점검");
389
+ const r = { iter: s.iter, fingerprint: snap.fingerprint, note: text(o.note, "note"), ...stamp(s) };
390
+ s.deslops.push(r);
391
+ return r;
392
+ });
393
+ return [{ ok: true, ...record }, 0];
394
+ }
395
+
396
+ async function acceptBaseline(o, ctx) {
397
+ const d = gateFolder(o, ctx);
398
+ if (!GATE_ID.test(String(o.id))) throw new Usage(`게이트 id 가 아니다: ${String(o.id).slice(0, 60)}`);
399
+ const snap = await treeOf(o, ctx, readState(d).repo);
400
+ const record = update(d, (s) => {
401
+ iterating(s, "기준선 수용");
402
+ const g = gateConjunct(usable(s, readEvidence(d)), s.gates, snap.fingerprint, s.iter).gates.find((x) => x.id === o.id);
403
+ if (!g || g.state !== "baseline") throw new Stop("not-baseline", `게이트 ${o.id} 는 지금 트리에서 시작 전과 같은 실패가 아니다(${g ? g.state : "선언 안 됨"})`);
404
+ const r = { id: o.id, signature: g.signature, reason: text(o.reason, "reason"), iter: s.iter, fingerprint: snap.fingerprint, ...stamp(s) };
405
+ s.accepts.push(r);
406
+ return r;
407
+ });
408
+ return [{ ok: true, ...record }, 0];
409
+ }
410
+
411
+ // What a reviewer is shown of the gates: this iteration's observed records and the accepted baselines.
412
+ function evidence(o, ctx) {
413
+ const d = gateFolder(o, ctx);
414
+ const s = readState(d);
415
+ const pick = (r) => ({ id: r.id, iter: r.iter, seq: r.seq, cmd: r.cmd, verdict: r.verdict, why: r.why, exit: r.exit, timedOut: r.timedOut, output: r.output, outputSha256: r.outputSha256, fingerprint: r.fingerprint });
416
+ const records = usable(s, readEvidence(d)).filter((r) => r.kind === "observed" && (r.iter === s.iter || r.iter === 0)).map(pick);
417
+ return [{ ok: true, iter: s.iter, baseline: s.baseline, records, accepts: s.accepts.filter((a) => a.iter === s.iter) }, 0];
418
+ }
419
+
420
+ async function check(o, ctx) {
421
+ const d = gateFolder(o, ctx);
422
+ const s = readState(d);
423
+ const snap = await treeOf(o, ctx, s.repo);
424
+ const r = checkRun(s, readEvidence(d), snap.fingerprint);
425
+ return [{ ...r, fingerprint: snap.fingerprint }, r.ok ? 0 : 1];
426
+ }
427
+
428
+ const completeLine = (s, r, fingerprint) => {
429
+ const approvals = s.verdicts.filter((v) => v.iter === s.iter && v.fingerprint === fingerprint && v.verdict === "APPROVE").length;
430
+ return `RALPH COMPLETE iter=${s.iter} gates=${s.gates.join(",") || "없음"} approvals=${approvals} deslop=${s.deslop ? "done" : "skipped"} baseline=${r.baseline} misleading=${r.misleading}`;
431
+ };
432
+
433
+ async function done(o, ctx) {
434
+ const d = gateFolder(o, ctx);
435
+ const snap = await treeOf(o, ctx, readState(d).repo);
436
+ return locked(d, () => {
437
+ const s = readState(d);
438
+ active(s);
439
+ const r = checkRun(s, readEvidence(d), snap.fingerprint);
440
+ if (!r.ok) return [{ ...r, status: s.status, fingerprint: snap.fingerprint }, 1];
441
+ s.status = "complete";
442
+ s.ended = { at: new Date().toISOString(), iter: s.iter, fingerprint: snap.fingerprint, head: snap.head };
443
+ writeState(d, s);
444
+ return [{ ...r, status: "complete", line: completeLine(s, r, snap.fingerprint) }, 0];
445
+ });
446
+ }
447
+
448
+ function stop(o, ctx) {
449
+ const d = gateFolder(o, ctx);
450
+ const s = update(d, (st) => {
451
+ active(st);
452
+ st.status = "stopped";
453
+ st.ended = { at: new Date().toISOString(), iter: st.iter, reason: text(o.reason, "reason") };
454
+ return st;
455
+ });
456
+ return [{ ok: true, status: "stopped", iter: s.iter, reason: s.ended.reason }, 0];
457
+ }
458
+
459
+ // A resume in another Codex session: the Stop hook keeps going only the session the record names.
460
+ function adopt(o, ctx) {
461
+ const d = gateFolder(o, ctx);
462
+ const id = String(ctx.env.CODEX_THREAD_ID || "");
463
+ if (!UUID.test(id)) throw new Stop("no-session", "CODEX_THREAD_ID 가 없다. Codex 세션 안에서 부른다");
464
+ const s = update(d, (st) => {
465
+ active(st);
466
+ st.session = id;
467
+ return st;
468
+ });
469
+ return [{ ok: true, session: s.session, iter: s.iter }, 0];
470
+ }
471
+
472
+ async function status(o, ctx) {
473
+ const d = gateFolder(o, ctx);
474
+ const s = readState(d);
475
+ if (o.cwd === undefined) return [{ ok: true, dir: d, state: s }, 0];
476
+ const snap = await treeOf(o, ctx, s.repo);
477
+ return [{ ok: true, dir: d, state: s, fingerprint: snap.fingerprint, check: checkRun(s, readEvidence(d), snap.fingerprint) }, 0];
478
+ }
479
+
480
+ // This repo's runs: gate folders right under a known base whose record names this repo. A folder whose record cannot
481
+ // be read is listed apart, never a stop for the rest.
482
+ function runsOf(root, ctx) {
483
+ const real = norm(ctx, realOr(root));
484
+ const runs = [];
485
+ const unreadable = [];
486
+ for (const base of bases(ctx).known) {
487
+ for (const name of listDir(base).filter((n) => new RegExp(`^${GATES_KIND}\\.[A-Za-z0-9]+$`).test(n))) {
488
+ const d = join(base, name);
489
+ try {
490
+ runFolder(d, ctx, [GATES_KIND]);
491
+ if (!existsSync(statePath(d))) continue;
492
+ const s = readState(d);
493
+ if (norm(ctx, realOr(s.repo)) === real) runs.push({ dir: d, state: s });
494
+ } catch (e) {
495
+ if (e.code === "bad-state") unreadable.push(d);
496
+ }
497
+ }
498
+ }
499
+ runs.sort((a, b) => String(b.state.startedAt).localeCompare(String(a.state.startedAt)));
500
+ return { runs, unreadable };
501
+ }
502
+
503
+ function find(o, ctx) {
504
+ const root = requireGitTree(o.cwd, ctx.env);
505
+ const { runs, unreadable } = runsOf(root, ctx);
506
+ const brief = ({ dir, state: s }) => ({ dir, status: s.status, iter: s.iter, max: s.max, task: s.task.slice(0, 120), startedAt: s.startedAt });
507
+ return [{ ok: true, repo: root, runs: runs.map(brief), unreadable }, 0];
508
+ }
509
+
510
+ // ---- the Codex Stop hook's question
511
+
512
+ const evidenceSize = (d) => {
513
+ try {
514
+ return statSync(join(d, "evidence.md")).size;
515
+ } catch {
516
+ return 0;
517
+ }
518
+ };
519
+
520
+ const readJsonOr = (p, fallback) => {
521
+ try {
522
+ return JSON.parse(readFileSync(p, "utf8")) || fallback;
523
+ } catch {
524
+ return fallback;
525
+ }
526
+ };
527
+
528
+ // How many stops in a row this run has seen with nothing new (reset when the record or the gate evidence grew), and
529
+ // how many it blocked in all. A continued turn keeps its turn_id, so each stop counts; only a second banker hook at
530
+ // the same stop (the npm and the plugin route both installed: same turn, within a few seconds) does not count again.
531
+ function stopsWithoutChange(d, s, turnId) {
532
+ const key = `${s.iter}:${s.seq}:${s.status}:${evidenceSize(d)}`;
533
+ const p = join(d, CONTINUE);
534
+ const last = readJsonOr(p, {});
535
+ const now = Date.now();
536
+ if (turnId && last.turn === turnId && now - (last.at || 0) < SAME_STOP_MS) return last;
537
+ const next = { key, count: last.key === key ? (last.count || 0) + 1 : 1, total: (last.total || 0) + 1, turn: turnId || null, at: now };
538
+ const tmp = `${p}.${randomBytes(6).toString("hex")}.tmp`;
539
+ writeFileSync(tmp, `${JSON.stringify(next)}\n`, { mode: 0o600, flag: "wx" });
540
+ renameSync(tmp, p);
541
+ return next;
542
+ }
543
+
544
+ // The session that started the run: the hook's session_id; only when the input has none, the rollout file it names
545
+ // (rollout-<time>-<thread id>.jsonl). A sub-agent's own session_id is never matched through a rollout path.
546
+ const sameSession = (s, sessionId, transcript) => Boolean(s.session)
547
+ && (UUID.test(String(sessionId || "")) ? s.session === sessionId : new RegExp(`${s.session}\\.jsonl$`, "i").test(String(transcript || "")));
548
+
549
+ // The active run of this session in the repo at cwd, or null.
550
+ function activeRun({ cwd, sessionId, transcript }, ctx) {
551
+ if (!cwd || (!UUID.test(String(sessionId || "")) && !transcript)) return null;
552
+ let root;
553
+ try {
554
+ root = requireGitTree(cwd, ctx.env);
555
+ } catch {
556
+ return null;
557
+ }
558
+ const run = runsOf(root, ctx).runs.find(({ state: s }) => s.status === "active" && sameSession(s, sessionId, transcript));
559
+ return run ? { ...run, root } : null;
560
+ }
561
+
562
+ // This stop counted under the lock; a lock it cannot take at once (the hook has 5 seconds) is null: the turn may end.
563
+ function countStop(run, turnId) {
564
+ try {
565
+ return locked(run.dir, () => stopsWithoutChange(run.dir, run.state, turnId), HOOK_LOCK_WAIT_MS);
566
+ } catch (e) {
567
+ if (e.code === "busy") return null;
568
+ throw e;
569
+ }
570
+ }
571
+
572
+ // The reason to keep the turn going, or null: an active run this session started in this repo, not yet let go.
573
+ export function continueFor(input, ctx = { env: process.env, platform: process.platform }) {
574
+ const run = activeRun(input, ctx);
575
+ const seen = run && countStop(run, input.turnId);
576
+ if (!seen || seen.count > CONTINUE_LIMIT || seen.total > CONTINUE_TOTAL) return null;
577
+ const s = run.state;
578
+ return [`banker ralph 실행이 아직 active 다(게이트 폴더 ${run.dir}, 반복 ${s.iter}/${s.max}). 끝내지 말고 이어 간다.`,
579
+ `node '${fileURLToPath(import.meta.url)}' check --dir '${run.dir}' --cwd '${run.root}' 로 빠진 것을 보고 banker ralph SKILL.md 절차를 계속한다.`,
580
+ "모두 맞으면 done 으로 끝낸다. 사용자 결정이 필요하거나 같은 실패가 반복되면 stop --reason 으로 멈추고 보고한다.",
581
+ `(기록 변화 없이 멈추려 한 횟수 ${seen.count}/${CONTINUE_LIMIT})`].join(" ");
582
+ }
583
+
584
+ const COMMANDS = { start, next, verdict, deslop, "accept-baseline": acceptBaseline, evidence, check, done, stop, adopt, status, find };
585
+
586
+ const stopJson = (e) => ({ ok: false, stop: e.code, message: `ralph-state: ${e.message}`, detail: e.detail, ...(e.checked ? { checked: e.checked } : {}) });
587
+
588
+ function failure(e, out, err) {
589
+ if (e instanceof Stop) {
590
+ out.write(`${JSON.stringify(stopJson(e), null, 2)}\n`);
591
+ return 3;
592
+ }
593
+ err.write(e instanceof Usage ? `ralph-state: ${e.message}\n` : `ralph-state: 내부 오류(${e?.code || e?.name || "error"}): ${String(e?.message || e).split("\n")[0]}\n`);
594
+ return 2;
595
+ }
596
+
597
+ export async function main(argv, io = {}) {
598
+ const ctx = { env: io.env || process.env, platform: io.platform || process.platform };
599
+ const out = io.stdout || process.stdout;
600
+ try {
601
+ const { command, o } = parseArgs(argv);
602
+ const [result, code] = await COMMANDS[command](o, ctx);
603
+ out.write(`${JSON.stringify({ command, ...result }, null, 2)}\n`);
604
+ return code;
605
+ } catch (e) {
606
+ return failure(e, out, io.stderr || process.stderr);
607
+ }
608
+ }
609
+
610
+ const invokedDirectly = () => {
611
+ try {
612
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
613
+ } catch {
614
+ return false;
615
+ }
616
+ };
617
+
618
+ if (invokedDirectly()) {
619
+ main(process.argv.slice(2)).then((code) => {
620
+ process.exitCode = code;
621
+ });
622
+ }