@kaydash9999/banker-plugins 0.20.1 → 0.20.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +195 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +27 -4
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +5 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +137 -30
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +13 -5
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +381 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -0,0 +1,646 @@
1
+ /**
2
+ * seat-payload: the review payload the ralph-qa runner (seat-run.mjs) sends to an external seat,
3
+ * built from the repository as step 2 of the skill's "외부 좌석 전송" says, and its checks.
4
+ *
5
+ * The author writes two files in the payload folder <d> (with a file tool, or `seat-run put`):
6
+ * short.md the fixed preamble, the opening marker <review-data id="<id>">, the user's request
7
+ * and the acceptance criteria
8
+ * spec.md one line each, in the order the parts go out:
9
+ * new <file> a new file of the change (its exact name, from the repo root)
10
+ * range <start> <end> <file> lines of a tracked or listed file the verdict needs
11
+ * file <path> a base or rule file; gate <path> a saved gate output
12
+ * review <path> what to review outside the diff (an ignored folder, a file
13
+ * outside the repo); with one, an empty diff is no stop
14
+ * A relative path is from the repo root. Blank lines and # lines are skipped.
15
+ * buildPayload writes, removing the last try's first:
16
+ * prompt.md short.md, the diff with the listed new files, each part of spec.md under its own
17
+ * `=== ` line, and the closing marker
18
+ * raw.md the diff and the parts alone (the source-independent seat's input)
19
+ * unsent.md the new files left out, one per line as git lists them
20
+ * run.md the id, base, repo and short part's byte length of this prepare (send refuses a folder without
21
+ * it; split cuts only after the short part)
22
+ * The diff comes from a copy of the real index (its staging, sparse checkout and racy entries as
23
+ * they are, its time kept so racily clean entries stay suspect) with only the listed new files
24
+ * added as intent-to-add. textconv, external drivers, color, submodule contents and dirt, the
25
+ * user's context settings, fsmonitor, the repo's hooks and filter drivers and every transport are
26
+ * off; attributes are followed, and a diff with a NUL byte stops. A git folder that is not this
27
+ * repo's own (another repo's through a .git file or link, commondir, core.worktree, alternates or
28
+ * links in refs and objects) stops. git runs by its absolute path from <d>, never from the repo. A
29
+ * stop is a Stop with the 0.20.4 wording. The secret scan and the size are reported, not stopped
30
+ * on: the author masks prompt.md and runs check.
31
+ */
32
+ import { spawnSync } from "node:child_process";
33
+ import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { scan } from "./payload-scan.mjs";
38
+ import { which } from "./seat-spawn.mjs";
39
+
40
+ export const MAX_BYTES = 524288;
41
+ const GIT_TIMEOUT_MS = 120000;
42
+ const FILE_CAP = 64 * 1024 * 1024;
43
+ const FOLD_PAST = 100;
44
+ const WIN = process.platform === "win32";
45
+ const OUTPUTS = ["prompt.md", "raw.md", "unsent.md", "run.md"];
46
+
47
+ export class Stop extends Error {
48
+ constructor(code, message, detail = []) {
49
+ super(`ralph-qa: ${message}`);
50
+ this.code = code;
51
+ this.detail = detail;
52
+ }
53
+ }
54
+
55
+ const SAFE_ID = /^[A-Za-z0-9]{4,64}$/;
56
+ export function checkId(id) {
57
+ if (!SAFE_ID.test(String(id))) throw new Stop("spec", `id 는 영문자와 숫자 4~64자다: ${String(id).slice(0, 80)}`);
58
+ }
59
+
60
+ // What the author wrote: a UTF-8 BOM dropped, UTF-16 with its BOM read as text.
61
+ function authorBytes(buf) {
62
+ if (buf[0] === 0xff && buf[1] === 0xfe) return Buffer.from(buf.slice(2).toString("utf16le"), "utf8");
63
+ if (buf[0] === 0xfe && buf[1] === 0xff) return Buffer.from(Buffer.from(buf.slice(2)).swap16().toString("utf16le"), "utf8");
64
+ return buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf ? buf.slice(3) : buf;
65
+ }
66
+
67
+ const withNewline = (buf) => (buf.length && buf[buf.length - 1] !== 10 ? Buffer.concat([buf, Buffer.from("\n")]) : buf);
68
+
69
+ function readAuthor(dir, name) {
70
+ try {
71
+ return authorBytes(readFileSync(join(dir, name)));
72
+ } catch (e) {
73
+ throw new Stop(name === "spec.md" ? "spec" : "markers", `${name} 를 읽을 수 없다(${e.code || e.message})`);
74
+ }
75
+ }
76
+
77
+ // Lines of `buf` holding the marker text of `id`, as grep -c counts them.
78
+ export function markerCount(buf, id) {
79
+ const needle = Buffer.from(`review-data id="${id}"`);
80
+ let n = 0;
81
+ for (let start = 0; start <= buf.length; ) {
82
+ const end = buf.indexOf(10, start);
83
+ const stop = end === -1 ? buf.length : end;
84
+ if (buf.slice(start, stop).includes(needle)) n += 1;
85
+ if (end === -1) break;
86
+ start = end + 1;
87
+ }
88
+ return n;
89
+ }
90
+
91
+ function readShort(dir, id) {
92
+ const short = withNewline(readAuthor(dir, "short.md"));
93
+ if (markerCount(short, id) !== 1 || !short.includes(Buffer.from(`<review-data id="${id}">`))) {
94
+ throw new Stop("markers", `short.md 에 여는 표지 <review-data id="${id}"> 가 한 줄에 한 번만 있어야 한다`);
95
+ }
96
+ return short;
97
+ }
98
+
99
+ // A path from the repo root as spec.md gives it: ./ dropped, no absolute path, no .. part.
100
+ function repoPath(raw, n) {
101
+ let p = WIN ? raw.replace(/\\/g, "/") : raw;
102
+ while (p.startsWith("./")) p = p.slice(2);
103
+ const bad = !p || p.includes("\0") || p.includes("\r") || isAbsolute(p) || /^[A-Za-z]:/.test(p) || p.split("/").includes("..");
104
+ if (bad) throw new Stop("spec", `spec ${n}: 저장소 루트 기준 경로가 아니다(절대 경로, .., 빈 경로): ${raw.slice(0, 200)}`);
105
+ return p;
106
+ }
107
+
108
+ function specLine(line, n) {
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)}`);
111
+ if (m[1] === "new") return { kind: "new", path: repoPath(m[2], n), line: n };
112
+ if (m[1] !== "range") {
113
+ if (m[2].includes("\0")) throw new Stop("spec", `spec ${n}: 경로에 NUL 이 있다`);
114
+ return { kind: m[1], path: m[2], line: n };
115
+ }
116
+ const r = /^(\d{1,9}) (\d{1,9}) (.+)$/.exec(m[2]);
117
+ if (!r) throw new Stop("spec", `spec ${n}: range <시작> <끝> <파일> 꼴이 아니다: ${line.slice(0, 200)}`);
118
+ return { kind: "range", from: Number(r[1]), to: Number(r[2]), path: repoPath(r[3], n), line: n };
119
+ }
120
+
121
+ export function readSpec(buf) {
122
+ let text;
123
+ try {
124
+ text = new TextDecoder("utf-8", { fatal: true }).decode(authorBytes(buf));
125
+ } catch {
126
+ throw new Stop("spec", "spec.md 가 UTF-8 이 아니다");
127
+ }
128
+ const out = [];
129
+ text.split("\n").forEach((raw, i) => {
130
+ const line = raw.replace(/\r$/, "");
131
+ if (line.trim() && !line.startsWith("#")) out.push(specLine(line, i + 1));
132
+ });
133
+ return out;
134
+ }
135
+
136
+ const firstLine = (buf) => String(buf || "").split("\n").find((l) => l.trim()) || "";
137
+ const withoutKeys = (env, re) => Object.fromEntries(Object.entries(env).filter(([k]) => !re.test(k)));
138
+
139
+ // A folder under a file: no hook can be there on any system, so core.hooksPath at it leaves the repo's hooks out.
140
+ const NO_HOOKS = join(fileURLToPath(import.meta.url), "no-hooks");
141
+ // The repo may be an archive that came with its .git: git never runs its fsmonitor or hooks and never reaches the
142
+ // network (a partial clone's lazy fetch, a transport's own command such as ext::). The repo's own
143
+ // protocol.<name>.allow outranks -c protocol.allow=never, so the environment also gives git an allow list of no
144
+ // protocol (GIT_ALLOW_PROTOCOL wins over all config) and turns lazy fetching off where git knows the switch.
145
+ const SAFE = ["-c", "core.fsmonitor=false", "-c", `core.hooksPath=${NO_HOOKS}`, "-c", "protocol.allow=never"];
146
+ const NO_FETCH = { GIT_ALLOW_PROTOCOL: "ralph-qa-no-protocol", GIT_NO_LAZY_FETCH: "1" };
147
+
148
+ // git by its absolute path, from `cwd`, on `repo`, with the C locale and no context settings; `extra` are more -c
149
+ // settings (the repo's filter drivers left out). `index` runs it on an index copy, which git then writes whole (no
150
+ // split index in the repo's .git).
151
+ export function gitAt(repo, env = process.env, cwd = homedir(), extra = []) {
152
+ const exe = which("git", { env });
153
+ if (!exe) throw new Stop("git-failed", "git 을 PATH 의 절대 경로에서 찾지 못했다");
154
+ const base = { ...withoutKeys(env, /^(GIT_DIFF_OPTS|LC_ALL|GIT_ALLOW_PROTOCOL|GIT_NO_LAZY_FETCH)$/i), LC_ALL: "C", ...NO_FETCH };
155
+ const onCopy = (index) => ({ ...withoutKeys(base, /^GIT_INDEX_FILE$/i), GIT_INDEX_FILE: index });
156
+ return (args, { index, input, ok = [0] } = {}) => {
157
+ const pre = ["-C", repo, ...SAFE, ...extra, ...(index ? ["-c", "core.splitIndex=false"] : [])];
158
+ const r = spawnSync(exe, [...pre, ...args], {
159
+ cwd, env: index ? onCopy(index) : base, input, maxBuffer: 1 << 30, timeout: GIT_TIMEOUT_MS, windowsHide: true,
160
+ });
161
+ const what = `git ${args.filter((a) => a.startsWith("-") || /^[a-z-]+$/.test(a)).slice(0, 4).join(" ")}`;
162
+ if (r.error && r.error.code === "ETIMEDOUT") throw new Stop("git-timeout", `${what} 가 ${GIT_TIMEOUT_MS / 1000}초 안에 끝나지 않았다`);
163
+ if (r.error) throw new Stop("git-failed", `${what} 를 띄우지 못했다(${r.error.code})`);
164
+ if (!ok.includes(r.status)) throw new Stop("git-failed", `${what} 가 실패했다(종료 ${r.status}): ${firstLine(r.stderr)}`);
165
+ return r;
166
+ };
167
+ }
168
+
169
+ const zList = (buf) => String(buf).split("\0").filter(Boolean);
170
+ // `git config --list -z` entries as [key, value]; a key without a value (a boolean true) has null.
171
+ const zEntries = (buf) => zList(buf).map((e) => (e.includes("\n") ? [e.slice(0, e.indexOf("\n")), e.slice(e.indexOf("\n") + 1)] : [e, null]));
172
+
173
+ // The filter driver keys the repo's own config sets (its config file, its worktree's, what they include).
174
+ function repoFilterKeys(git) {
175
+ const keys = new Set();
176
+ for (const scope of ["--local", "--worktree"]) {
177
+ const r = git(["config", scope, "--includes", "--list", "-z", "--name-only"], { ok: scope === "--local" ? [0] : [0, 1, 128, 129] });
178
+ if (r.status !== 0) continue;
179
+ for (const key of zList(r.stdout)) if (/^filter\.[\s\S]+\.(?:clean|smudge|process|required)$/.test(key)) keys.add(key);
180
+ }
181
+ return [...keys];
182
+ }
183
+
184
+ // The user's and the system's values for filter drivers: what git uses with the repo's own config left out.
185
+ function userFilterValues(git, env) {
186
+ const values = new Map();
187
+ const scopes = /^(1|true|yes|on)$/i.test(env.GIT_CONFIG_NOSYSTEM || "") ? ["--global"] : ["--system", "--global"];
188
+ for (const scope of scopes) {
189
+ const r = git(["config", scope, "--includes", "--list", "-z"], { ok: [0, 1, 128] });
190
+ if (r.status === 0) for (const [k, v] of zEntries(r.stdout)) if (k.startsWith("filter.")) values.set(k, v);
191
+ }
192
+ return values;
193
+ }
194
+
195
+ // -c settings over each filter key the repo's own config sets: the user's value where the user's config gives one,
196
+ // else empty (no command) or false (not required). Only those keys: an empty process would also turn off the user's
197
+ // clean and smudge of the same driver. A key -c cannot carry (= or a line break in the name) stops.
198
+ function filterOverrides(git, env) {
199
+ const keys = repoFilterKeys(git);
200
+ const bad = keys.find((k) => /[=\r\n]/.test(k));
201
+ if (bad !== undefined) throw new Stop("git-failed", `저장소 설정의 filter 이름을 끌 수 없다(= 나 줄바꿈): ${JSON.stringify(bad).slice(0, 80)}`);
202
+ const user = keys.length ? userFilterValues(git, env) : new Map();
203
+ return keys.flatMap((key) => {
204
+ if (user.has(key)) return ["-c", user.get(key) === null ? key : `${key}=${user.get(key)}`];
205
+ return ["-c", `${key}=${key.endsWith(".required") ? "false" : ""}`];
206
+ });
207
+ }
208
+
209
+ const norm = (p) => (WIN ? p.toLowerCase() : p);
210
+ const realOr = (p) => {
211
+ try {
212
+ return realpathSync.native(p);
213
+ } catch {
214
+ return resolve(p);
215
+ }
216
+ };
217
+ function kindAt(p) {
218
+ let st;
219
+ try {
220
+ st = lstatSync(p);
221
+ } catch {
222
+ return null;
223
+ }
224
+ return st.isDirectory() ? "dir" : st.isFile() ? "file" : "other";
225
+ }
226
+
227
+ // Where a linked worktree's git folder says its worktree's .git file is.
228
+ function backPointer(gitDir) {
229
+ try {
230
+ return realOr(resolve(gitDir, readFileSync(join(gitDir, "gitdir"), "utf8").trim()));
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ // The git folders of the repo at `top` as git reads them: its own, the common one (refs, objects) and core.worktree.
237
+ function gitFolders(git, top) {
238
+ return {
239
+ gitDir: realOr(String(git(["rev-parse", "--absolute-git-dir"]).stdout).trim()),
240
+ common: realOr(resolve(top, String(git(["rev-parse", "--git-common-dir"]).stdout).trim())),
241
+ tree: String(git(["config", "--get", "core.worktree"], { ok: [0, 1] }).stdout).trim(),
242
+ };
243
+ }
244
+
245
+ // git's own layouts: <top>/.git, a linked worktree's folder (its gitdir file names <top>/.git) or a submodule's (its
246
+ // core.worktree is <top>).
247
+ function ownLayout(realTop, { gitDir, common, tree }) {
248
+ const dotGit = join(realTop, ".git");
249
+ const kind = kindAt(dotGit);
250
+ if (kind === "dir") return norm(gitDir) === norm(dotGit) && norm(common) === norm(gitDir);
251
+ if (kind !== "file") return false;
252
+ if (norm(dirname(gitDir)) === norm(join(common, "worktrees"))) return norm(backPointer(gitDir) || "") === norm(dotGit);
253
+ return norm(common) === norm(gitDir) && Boolean(tree) && norm(realOr(resolve(gitDir, tree))) === norm(realTop);
254
+ }
255
+
256
+ // The first link at or below `root`: git would read another repo's objects or refs through it.
257
+ function linkUnder(root) {
258
+ const folders = [root];
259
+ while (folders.length) {
260
+ const dir = folders.pop();
261
+ let entries;
262
+ try {
263
+ entries = readdirSync(dir, { withFileTypes: true });
264
+ } catch {
265
+ continue;
266
+ }
267
+ for (const e of entries) {
268
+ if (e.isSymbolicLink()) return join(dir, e.name);
269
+ if (e.isDirectory()) folders.push(join(dir, e.name));
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+
275
+ // What makes git read another repo's HEAD, refs or objects: a link at HEAD, refs (the shared ones and a worktree's
276
+ // own), packed-refs or objects or anywhere below refs and objects, and objects/info/alternates (another object store,
277
+ // whose tip a linked ref or a known sha names).
278
+ function borrowed(f) {
279
+ const tops = [join(f.gitDir, "HEAD"), join(f.gitDir, "refs"), ...["refs", "packed-refs", "objects"].map((n) => join(f.common, n))].filter((p) => kindAt(p) === "other");
280
+ const below = [join(f.common, "refs"), join(f.common, "objects"), join(f.gitDir, "refs")].map(linkUnder).filter(Boolean);
281
+ const alternates = join(f.common, "objects", "info", "alternates");
282
+ return [...tops, ...below, ...(kindAt(alternates) ? [alternates] : [])];
283
+ }
284
+
285
+ // The repo's git folder must be its own (ownLayout) and read its HEAD, refs and objects from itself (borrowed). A .git
286
+ // file or link to another repo, a commondir file, core.worktree set to another folder, a link in refs or objects or
287
+ // an alternates file would make the diff carry another repo's files: it stops.
288
+ function ownGitDir(git, top) {
289
+ const realTop = realOr(top);
290
+ const f = gitFolders(git, top);
291
+ const found = borrowed(f);
292
+ if (!ownLayout(realTop, f) || found.length) {
293
+ throw new Stop("not-a-repo", `git 폴더가 이 저장소의 것이 아니다(.git 파일이나 링크, core.worktree, refs 나 objects 의 링크, alternates 가 다른 곳을 가리킴): ${f.gitDir}`, found);
294
+ }
295
+ }
296
+
297
+ // The repo's top and a git on it. git from the top must find the repo git found from --repo: core.worktree pointing
298
+ // elsewhere would have it find another repo there (or none).
299
+ function topLevel(repo, env, cwd) {
300
+ const first = gitAt(repo, env, cwd);
301
+ const r = first(["rev-parse", "--show-toplevel", "--absolute-git-dir"], { ok: [0, 128] });
302
+ if (r.status !== 0) throw new Stop("not-a-repo", `git 저장소가 아니다: ${repo}`, [firstLine(r.stderr)]);
303
+ const [topLine, dirLine] = String(r.stdout).trim().split("\n");
304
+ const top = resolve(topLine);
305
+ const git = gitAt(top, env, cwd);
306
+ const again = git(["rev-parse", "--absolute-git-dir"], { ok: [0, 128] });
307
+ if (again.status !== 0 || norm(realOr(String(again.stdout).trim())) !== norm(realOr(dirLine || ""))) {
308
+ throw new Stop("not-a-repo", `git 이 저장소의 맨 위(${top})에서 같은 저장소를 찾지 못한다(core.worktree 가 다른 곳을 가리킴)`);
309
+ }
310
+ ownGitDir(git, top);
311
+ return { top, git: gitAt(top, env, cwd, filterOverrides(git, env)) };
312
+ }
313
+
314
+ export function untracked({ repo, env = process.env, folder = null, cwd = homedir() }) {
315
+ const { git } = topLevel(repo, env, cwd);
316
+ const args = ["--literal-pathspecs", "ls-files", "-z", "--others", "--exclude-standard"];
317
+ if (folder) return { files: zList(git([...args, "--", `${folder.replace(/\/+$/, "")}/`]).stdout), folded: false };
318
+ const files = zList(git(args).stdout);
319
+ if (files.length <= FOLD_PAST) return { files, count: files.length, folded: false };
320
+ return { files: zList(git([...args, "--directory"]).stdout), count: files.length, folded: true };
321
+ }
322
+
323
+ // The tree to diff against: `auto` is HEAD, or the empty tree in a repo without a commit.
324
+ function baseTree(git, base) {
325
+ const value = String(base);
326
+ if (value === "auto") {
327
+ if (git(["rev-parse", "-q", "--verify", "HEAD^{commit}"], { ok: [0, 1] }).status === 0) return baseTree(git, "HEAD");
328
+ return String(git(["hash-object", "-t", "tree", "--stdin"], { input: "" }).stdout).trim();
329
+ }
330
+ if (!/^[^\s-][^\s]{0,255}$/.test(value)) throw new Stop("base-invalid", `기준이 아니다: ${value.slice(0, 80)}`);
331
+ const r = git(["rev-parse", "-q", "--verify", `${value}^{tree}`], { ok: [0, 1, 128] });
332
+ if (r.status !== 0) throw new Stop("base-invalid", `기준을 찾지 못했다: ${value}`, [firstLine(r.stderr)]);
333
+ return String(r.stdout).trim();
334
+ }
335
+
336
+ // The real index copied with its times, so entries as new as it stay suspect to git.
337
+ function copyIndex(git, top, idx) {
338
+ const out = String(git(["rev-parse", "--git-path", "index"]).stdout).trim();
339
+ const real = isAbsolute(out) ? out : join(top, out);
340
+ rmSync(idx, { force: true });
341
+ if (existsSync(real)) {
342
+ copyFileSync(real, idx);
343
+ const st = statSync(real);
344
+ utimesSync(idx, st.atime, st.mtime);
345
+ return;
346
+ }
347
+ if (git(["rev-parse", "-q", "--verify", "HEAD"], { ok: [0, 1] }).status === 0) throw new Stop("index-missing", `커밋이 있는데 인덱스 파일이 없다: ${real}`);
348
+ }
349
+
350
+ const isDir = (p) => {
351
+ try {
352
+ return statSync(p).isDirectory();
353
+ } catch {
354
+ return false;
355
+ }
356
+ };
357
+
358
+ // C-style quoting as git lists a name with ", \ or a control character.
359
+ function unquoted(p) {
360
+ if (!/^".*"$/.test(p)) return null;
361
+ try {
362
+ return JSON.parse(p.replace(/\\([0-7]{3})/g, (_, o) => `\\u00${Number.parseInt(o, 8).toString(16).padStart(2, "0")}`).replace(/\\a/g, "\\u0007").replace(/\\v/g, "\\u000b"));
363
+ } catch {
364
+ return null;
365
+ }
366
+ }
367
+
368
+ const UNDER_LIMIT = 5;
369
+ function folderReason(p, all) {
370
+ const prefix = p === "." ? "" : `${p.replace(/\/+$/, "")}/`;
371
+ const under = all.filter((f) => f.startsWith(prefix));
372
+ const shown = under.slice(0, UNDER_LIMIT).join(", ") + (under.length > UNDER_LIMIT ? " 등" : "");
373
+ return `폴더 줄이다. 그 아래 새 파일을 한 줄에 하나씩 적는다(${shown || "새 파일 없음"})`;
374
+ }
375
+
376
+ // The reasons a `new` line is no new file that need no git: a quoted name, a nested repo.
377
+ function quotedOrNested(p, all) {
378
+ const set = new Set(all);
379
+ const plain = unquoted(p);
380
+ if (plain !== null && set.has(plain)) return `따옴표를 풀지 않은 줄이다. new ${plain} 로 적는다`;
381
+ if (set.has(`${p}/`)) return "하위 저장소다. 그 안의 파일은 실리지 않는다";
382
+ if (all.some((f) => f.endsWith("/") && p.startsWith(f))) return "하위 저장소 안이다. 필요하면 file 줄로 붙인다";
383
+ return null;
384
+ }
385
+
386
+ // Why a `new` line names no new file of the repo, in the words the author fixes it by.
387
+ function whyNotNew(p, { git, idx, top, all }) {
388
+ const early = quotedOrNested(p, all);
389
+ if (early) return early;
390
+ if (p === "." || p.endsWith("/") || isDir(join(top, p))) return folderReason(p, all);
391
+ const has = (args) => zList(git(["--literal-pathspecs", "ls-files", "-z", ...args, "--", p], { index: idx }).stdout).includes(p);
392
+ if (has(["--others", "--ignored", "--exclude-standard"])) return "무시되는 파일이다. 검토 대상이면 review 줄로 적는다";
393
+ if (has([])) return "이미 추적하는 파일이다(diff 에 실린다)";
394
+ return existsSync(join(top, p)) ? "추적하지 않는 새 파일이 아니다" : "없다";
395
+ }
396
+
397
+ // The listed new files, each one exactly a new file of the repo; any other line stops with `list`.
398
+ function listedNew(ctx, spec) {
399
+ const wanted = [...new Set(spec.filter((u) => u.kind === "new").map((u) => u.path))];
400
+ const all = zList(ctx.git(["ls-files", "-z", "--others", "--exclude-standard"], { index: ctx.idx }).stdout);
401
+ const set = new Set(all);
402
+ const bad = wanted.filter((p) => !set.has(p)).map((p) => `${p}: ${whyNotNew(p, { ...ctx, all })}`);
403
+ if (bad.length) throw new Stop("list", "목록의 줄이 새 파일 하나가 아니다", bad);
404
+ return { wanted, all };
405
+ }
406
+
407
+ function addIntent(ctx, { wanted, all }) {
408
+ for (let i = 0; i < wanted.length; i += 100) {
409
+ ctx.git(["--literal-pathspecs", "add", "-N", "--", ...wanted.slice(i, i + 100)], { index: ctx.idx });
410
+ }
411
+ const left = zList(ctx.git(["ls-files", "-z", "--others", "--exclude-standard"], { index: ctx.idx }).stdout);
412
+ const [want, still] = [new Set(wanted), new Set(left)];
413
+ const extra = all.filter((f) => !want.has(f) && !still.has(f));
414
+ if (left.length !== all.length - wanted.length || extra.length) {
415
+ throw new Stop("list", "목록에 없는 새 파일이 올라간다", extra.slice(0, 20));
416
+ }
417
+ }
418
+
419
+ function diffOf(ctx, tree) {
420
+ // --ignore-submodules=dirty: a submodule's dirt would take a git status inside it, with its own hooks and filters
421
+ const diff = ctx.git(["-c", "core.quotePath=false", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--submodule=short", "--ignore-submodules=dirty",
422
+ "-U3", "--inter-hunk-context=0", tree, "--"], { index: ctx.idx }).stdout;
423
+ const nul = diff.indexOf(0);
424
+ if (nul === -1) return diff;
425
+ const head = diff.lastIndexOf(Buffer.from("diff --git "), nul);
426
+ const name = head === -1 ? "" : firstLine(diff.slice(head, diff.indexOf(10, head)));
427
+ throw new Stop("binary-diff", "diff 에 바이너리 바이트가 있다(attributes 의 diff 속성이 바이너리 파일을 글로 펼침)", [name]);
428
+ }
429
+
430
+ // A regular file's bytes, opened without following a link and without blocking on a FIFO; null when it is not one.
431
+ function readRegular(full) {
432
+ const fd = openSync(full, constants.O_RDONLY | (constants.O_NONBLOCK || 0) | (constants.O_NOFOLLOW || 0));
433
+ try {
434
+ const st = fstatSync(fd);
435
+ if (!st.isFile()) return null;
436
+ if (st.size > FILE_CAP) throw new Stop("file", `파일이 64 MiB 를 넘는다: ${full}`);
437
+ return readFileSync(fd);
438
+ } finally {
439
+ closeSync(fd);
440
+ }
441
+ }
442
+
443
+ // Whether `rel` (from the repo root) passes through a link: each folder and the file itself.
444
+ function throughLink(top, rel) {
445
+ const parts = rel.split("/");
446
+ for (let i = 1; i <= parts.length; i += 1) {
447
+ try {
448
+ if (lstatSync(join(top, ...parts.slice(0, i))).isSymbolicLink()) return true;
449
+ } catch {
450
+ return false;
451
+ }
452
+ }
453
+ const norm = (p) => (WIN ? p.toLowerCase() : p);
454
+ try {
455
+ return norm(realpathSync.native(join(top, rel))) !== norm(join(realpathSync.native(top), ...parts));
456
+ } catch {
457
+ return false;
458
+ }
459
+ }
460
+
461
+ function lineStarts(buf) {
462
+ const starts = [0];
463
+ for (let i = buf.indexOf(10); i !== -1; i = buf.indexOf(10, i + 1)) starts.push(i + 1);
464
+ const count = starts[starts.length - 1] === buf.length ? starts.length - 1 : starts.length;
465
+ return { starts, count };
466
+ }
467
+
468
+ // The bytes of a part's file; `fail(why)` makes the stop for one that cannot be read (why) or is
469
+ // not a regular file (null).
470
+ function readPart(full, fail) {
471
+ let buf;
472
+ try {
473
+ buf = readRegular(full);
474
+ } catch (e) {
475
+ if (e instanceof Stop) throw e;
476
+ throw fail(e.code || e.message);
477
+ }
478
+ if (buf === null) throw fail(null);
479
+ return buf;
480
+ }
481
+
482
+ function rangeUnit(ctx, u) {
483
+ const tracked = zList(ctx.git(["--literal-pathspecs", "ls-files", "-z", "--", u.path], { index: ctx.idx }).stdout).includes(u.path);
484
+ if (!tracked) throw new Stop("range-source", `관련 범위의 파일이 추적 파일도 목록의 새 파일도 아니다: ${u.path}`);
485
+ const link = (why) => new Stop("range-link", `관련 범위의 파일이 링크이거나 링크 폴더 아래에 있다(저장소 밖을 읽을 수 있다): ${u.path}`, why ? [why] : []);
486
+ if (throughLink(ctx.top, u.path)) throw link(null);
487
+ const buf = readPart(join(ctx.top, u.path), link);
488
+ const { starts, count } = lineStarts(buf);
489
+ if (u.from < 1 || u.to < u.from || u.from > count) throw new Stop("range-outside", `관련 범위가 파일 밖이다: ${u.path}:${u.from}-${u.to}`);
490
+ const last = Math.min(u.to, count);
491
+ const body = buf.slice(starts[u.from - 1], last < count ? starts[last] : buf.length);
492
+ return Buffer.concat([Buffer.from(`=== ${u.path}:${u.from}-${u.to}\n`), withNewline(body)]);
493
+ }
494
+
495
+ // The path of `full` from the repo root, or null outside the repo. A folder above `full` that is the
496
+ // repo by another spelling (a link above the repo, as /var and /private/var on macOS) counts too.
497
+ function repoRel(top, full) {
498
+ const from = (base) => {
499
+ const rel = relative(base, full).split(sep).join("/");
500
+ return rel !== "" && rel !== ".." && !rel.startsWith("../") && !isAbsolute(rel) ? rel : null;
501
+ };
502
+ const direct = from(top);
503
+ if (direct !== null) return direct;
504
+ const norm = (p) => (WIN ? p.toLowerCase() : p);
505
+ let real;
506
+ try {
507
+ real = norm(realpathSync.native(top));
508
+ } catch {
509
+ return null;
510
+ }
511
+ for (let base = dirname(full); base !== dirname(base); base = dirname(base)) {
512
+ try {
513
+ if (norm(realpathSync.native(base)) === real) return from(base);
514
+ } catch {
515
+ // a folder that is not there: look higher
516
+ }
517
+ }
518
+ return null;
519
+ }
520
+
521
+ // A file, gate or review part under its `=== <kind> <name>` line: the path from the repo root
522
+ // when it is inside the repo, else its file name alone.
523
+ function fileUnit(ctx, u) {
524
+ const full = resolve(ctx.top, u.path);
525
+ const rel = repoRel(ctx.top, full);
526
+ const inside = rel !== null;
527
+ if (inside && throughLink(ctx.top, rel)) throw new Stop("file", `${u.kind} 의 파일이 링크이거나 링크 폴더 아래에 있다: ${u.path}`);
528
+ const buf = readPart(full, (why) => new Stop("file", why ? `${u.kind} 의 파일을 읽을 수 없다(${why}): ${u.path}` : `${u.kind} 의 파일이 일반 파일이 아니다: ${u.path}`));
529
+ if (buf.includes(0)) throw new Stop("file", `${u.kind} 의 파일이 바이너리다: ${u.path}`);
530
+ return Buffer.concat([Buffer.from(`=== ${u.kind} ${inside ? rel : basename(full)}\n`), withNewline(buf)]);
531
+ }
532
+
533
+ const unitOf = (ctx, u) => (u.kind === "range" ? rangeUnit(ctx, u) : fileUnit(ctx, u));
534
+
535
+ function writeOutputs(dir, { prompt, raw, unsent, run }) {
536
+ writeFileSync(join(dir, "raw.md"), raw, { mode: 0o600 });
537
+ writeFileSync(join(dir, "unsent.md"), unsent, { mode: 0o600 });
538
+ const tmp = join(dir, `.prompt-${process.pid}.md`);
539
+ writeFileSync(tmp, prompt, { mode: 0o600 });
540
+ renameSync(tmp, join(dir, "prompt.md"));
541
+ writeFileSync(join(dir, "run.md"), run, { mode: 0o600 });
542
+ }
543
+
544
+ // The parts of the payload, from the repo and spec.md: the diff, then each part in spec order.
545
+ function assemble(ctx, spec, tree) {
546
+ const listed = listedNew(ctx, spec);
547
+ if (listed.wanted.length) addIntent(ctx, listed);
548
+ const unsent = ctx.git(["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard"], { index: ctx.idx }).stdout;
549
+ const diff = diffOf(ctx, tree);
550
+ if (!diff.length && !spec.some((u) => u.kind === "review")) {
551
+ throw new Stop("no-diff", "보낼 diff 가 없다", ["무시되는 폴더나 저장소 밖의 검토 대상이면 review 줄을 더한다"]);
552
+ }
553
+ const raw = Buffer.concat([diff, ...spec.filter((u) => u.kind !== "new").map((u) => unitOf(ctx, u))]);
554
+ return { raw, unsent };
555
+ }
556
+
557
+ export function buildPayload({ dir, repo, base, id, env = process.env }) {
558
+ checkId(id);
559
+ for (const f of OUTPUTS) rmSync(join(dir, f), { force: true });
560
+ const short = readShort(dir, id);
561
+ const spec = readSpec(readAuthor(dir, "spec.md"));
562
+ const { top, git } = topLevel(repo, env, dir);
563
+ const tree = baseTree(git, base);
564
+ const ctx = { git, top, idx: join(dir, "idx.md") };
565
+ try {
566
+ copyIndex(git, top, ctx.idx);
567
+ const { raw, unsent } = assemble(ctx, spec, tree);
568
+ const prompt = Buffer.concat([short, raw, Buffer.from(`</review-data id="${id}">\n`)]);
569
+ const markers = markerCount(prompt, id);
570
+ 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` });
572
+ return { bytes: prompt.length, markers, scan: scan(prompt.toString("utf8")), unsent: String(unsent).split("\n").filter(Boolean).length };
573
+ } finally {
574
+ rmSync(ctx.idx, { force: true });
575
+ rmSync(`${ctx.idx}.lock`, { force: true });
576
+ }
577
+ }
578
+ // What run.md says of the last prepare in `dir`: its id, base, tree and repo. A folder without one was never prepared
579
+ // (or its prepare stopped): nothing in it goes out.
580
+ export function readRun(dir) {
581
+ let text;
582
+ try {
583
+ text = readFileSync(join(dir, "run.md"), "utf8");
584
+ } catch {
585
+ throw new Stop("not-prepared", `prepare 가 끝난 폴더가 아니다(run.md 없음): ${dir}`);
586
+ }
587
+ const run = Object.fromEntries(text.split("\n").filter(Boolean).map((l) => [l.slice(0, l.indexOf(" ")), l.slice(l.indexOf(" ") + 1)]));
588
+ checkId(run.id);
589
+ return run;
590
+ }
591
+
592
+ // The payload as it would go out now (after the author masked it): its size, the lines with the marker of the id,
593
+ // the secret scan, and whether it is past 512 KiB.
594
+ export function checkPayload({ dir, id = readRun(dir).id }) {
595
+ let buf;
596
+ try {
597
+ buf = readFileSync(join(dir, "prompt.md"));
598
+ } catch {
599
+ throw new Stop("not-prepared", `prompt.md 가 없다. prepare 를 먼저 한다: ${dir}`);
600
+ }
601
+ return { bytes: buf.length, markers: markerCount(buf, id), scan: scan(buf.toString("utf8")), tooLarge: buf.length > MAX_BYTES };
602
+ }
603
+
604
+ // Where each line of `buf` starts, and the end.
605
+ function lineStartsOf(buf) {
606
+ const starts = [0];
607
+ for (let i = buf.indexOf(10); i !== -1 && i + 1 < buf.length; i = buf.indexOf(10, i + 1)) starts.push(i + 1);
608
+ return starts;
609
+ }
610
+
611
+ // Groups of the parts, in order, as even in size as they can be, `n` groups each holding one part at least.
612
+ function balanced(units, n) {
613
+ const total = units.reduce((a, u) => a + u.length, 0);
614
+ const groups = [[]];
615
+ let acc = 0;
616
+ units.forEach((u, i) => {
617
+ const left = units.length - i;
618
+ const toOpen = n - groups.length;
619
+ if (groups[groups.length - 1].length && (left <= toOpen || acc >= (total * groups.length) / n)) groups.push([]);
620
+ groups[groups.length - 1].push(u);
621
+ acc += u.length;
622
+ });
623
+ return groups;
624
+ }
625
+
626
+ // The payload cut into `n` parts where a part starts (a file's diff, a `=== ` line), between the short part (its
627
+ // first `short` bytes, as prepare wrote it: a `=== ` line of the author's is no cut) and the closing marker. Each part
628
+ // holds the short part, a `부분 <k>/<n>` line and the closing marker; the parts in order hold every part of the payload
629
+ // 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) {
631
+ if (!Number.isInteger(n) || n < 2) throw new Stop("split", `나눌 수는 2 이상의 정수다: ${n}`);
632
+ const at = lineStartsOf(buf);
633
+ if (!Number.isInteger(short) || !at.includes(short)) throw new Stop("split", "prompt.md 의 짧은 부분이 prepare 때와 다르다. 가림은 짧은 부분 밖에서 하거나 prepare 를 다시 한다");
634
+ const lineAt = (k) => buf.slice(at[k], k + 1 < at.length ? at[k + 1] : buf.length).toString("utf8").replace(/\r?\n$/, "");
635
+ const lines = at.map((_, k) => lineAt(k));
636
+ const open = lines.findIndex((l) => l.includes(`<review-data id="${id}">`));
637
+ const close = lines.lastIndexOf(`</review-data id="${id}">`);
638
+ if (open === -1 || close <= open) throw new Stop("split", "여는 표지와 닫는 표지를 찾지 못했다");
639
+ const unitLine = (l, k) => k > open && k < close && at[k] >= short && (l.startsWith("diff --git ") || l.startsWith("=== "));
640
+ const starts = lines.map((l, k) => (unitLine(l, k) ? k : -1)).filter((k) => k !== -1);
641
+ if (starts.length < n) throw new Stop("split", `나눌 수 있는 단위가 ${starts.length}개라 ${n} 부분으로 나눌 수 없다`);
642
+ const units = starts.map((k, j) => buf.slice(at[k], at[j + 1 < starts.length ? starts[j + 1] : close]));
643
+ const prefix = buf.slice(0, at[starts[0]]);
644
+ const closing = buf.slice(at[close]);
645
+ return balanced(units, n).map((g, k) => Buffer.concat([prefix, Buffer.from(`부분 ${k + 1}/${n}\n`), ...g, closing]));
646
+ }