@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +41 -0
- package/PRIVACY.md +1 -0
- package/README.md +39 -13
- package/bin/banker.js +118 -22
- package/bin/lib/codex-hooks.mjs +116 -18
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +93 -1
- package/bin/lib/command-skill.cjs +37 -6
- package/codex/command-skills/front-qa/SKILL.md +1 -1
- package/codex/command-skills/setup/SKILL.md +4 -4
- package/codex/hooks.json +5 -5
- package/codex/manifest.json +18 -15
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +137 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
- package/codex/transform-matrix.md +5 -3
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +238 -57
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +82 -20
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +277 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +1 -1
- package/skills/play-qa/SKILL.md +6 -4
- package/skills/progress-mon/README.md +162 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +526 -0
- package/skills/progress-mon/scripts/pm-config.mjs +341 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +735 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +104 -15
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +159 -155
- package/skills/remains/SKILL.md +1 -0
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/ultra-chain/SKILL.md +32 -14
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +196 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- 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
|
|
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
|
-
|
|
94
|
-
|
|
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 =
|
|
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 (!
|
|
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}">`));
|