@kaydash9999/banker-plugins 0.17.1 → 0.19.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 (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +72 -0
  4. package/README.md +26 -8
  5. package/codex/manifest.json +3 -1
  6. package/codex/transform-matrix.md +5 -2
  7. package/commands/front-qa.md +3 -3
  8. package/hooks/graceful-pause.mjs +4 -2
  9. package/hooks/obsidize-hook.mjs +38 -28
  10. package/hooks/progress-plan.mjs +14 -8
  11. package/hooks/progress.mjs +174 -47
  12. package/hooks/register.mjs +14 -7
  13. package/hooks/update-checkin.mjs +29 -27
  14. package/package.json +1 -1
  15. package/skills/3d-intro-build/SKILL.md +1 -1
  16. package/skills/3d-intro-build/references/assemble.mjs +3 -3
  17. package/skills/3d-intro-setup/SKILL.md +3 -3
  18. package/skills/anti-drm/SKILL.md +254 -0
  19. package/skills/anti-drm/scripts/anti-drm.mjs +643 -0
  20. package/skills/anti-drm/scripts/instruction-toggle.mjs +733 -0
  21. package/skills/lineage/SKILL.md +3 -3
  22. package/skills/lineage/lineage.py +43 -7
  23. package/skills/omc-patch/SKILL.md +18 -6
  24. package/skills/omc-patch/scripts/omc-patch.mjs +400 -87
  25. package/skills/omc-reference/SKILL.md +1 -1
  26. package/skills/ralph-qa/SKILL.md +19 -19
  27. package/skills/theme-selection/SKILL.md +165 -0
  28. package/skills/theme-selection/assets/LICENSE +21 -0
  29. package/skills/theme-selection/assets/NOTICE.md +30 -0
  30. package/skills/theme-selection/assets/palettes.json +81 -0
  31. package/skills/theme-selection/references/contrast.md +138 -0
  32. package/skills/theme-selection/references/integrate.md +161 -0
  33. package/skills/theme-selection/scripts/themes.mjs +1067 -0
  34. package/skills/tone-compact/SKILL.md +8 -4
  35. package/skills/tone-compact/scripts/instruction-toggle.mjs +733 -0
  36. package/skills/tone-compact/scripts/tone-compact.mjs +48 -397
  37. package/skills/ultra-chain/SKILL.md +7 -6
  38. package/skills/update-banker/SKILL.md +1 -1
@@ -0,0 +1,733 @@
1
+ /**
2
+ * instruction-toggle: puts one set of agent instructions where Claude Code and
3
+ * Codex load them at every session start, and takes it out again.
4
+ *
5
+ * banker's tone-compact and anti-drm share this module. Each skill folder holds
6
+ * a byte-identical copy, since Codex installs every skill folder on its own and
7
+ * one skill cannot import from another. tone-compact's copy is the source:
8
+ * `node scripts/sync-adapter.js` mirrors it, and the smoke test and anti-drm's
9
+ * tests compare the two.
10
+ *
11
+ * Claude Code loads every rules file under <config>/rules/ (<config> is
12
+ * $CLAUDE_CONFIG_DIR or ~/.claude) at session start and again after compaction.
13
+ * turnOn writes banker-<slug>.md there and turnOff deletes it. A file at that
14
+ * name that does not start with banker's mark belongs to someone else and is
15
+ * left alone.
16
+ *
17
+ * Codex reads $CODEX_HOME/AGENTS.override.md when it holds any non-whitespace
18
+ * text and $CODEX_HOME/AGENTS.md otherwise (CODEX_HOME defaults to ~/.codex).
19
+ * turnOn adds one marked block at the end of AGENTS.md, or of the override when
20
+ * the override has text of its own. No banker block counts as the override's
21
+ * own text: banker's blocks alone there would hide AGENTS.md. When other
22
+ * banker blocks alone fill the override, Codex reads only them; turnOn and the
23
+ * status line say so, and those skills' turnOn moves them out. turnOff takes
24
+ * the block out and gives back the bytes from before.
25
+ *
26
+ * The block sits inside OMX's USER:OMX:POLICY markers, the only part of a
27
+ * generated AGENTS.md that `omx setup --merge-agents` carries over; a plain
28
+ * `omx setup` with team mode on only refreshes the model table. `omx setup
29
+ * --force`, confirming its overwrite prompt, or a plain `omx setup` with team
30
+ * mode off replaces the file block and all; turnOn then has to run again. Text
31
+ * other tools keep in these files is left alone, and the bytes from before each
32
+ * change are kept in <file>.<slug>.bak. Markers count only as whole lines that
33
+ * open and close in turn; a marker quoted inside a sentence, or an end before a
34
+ * start, makes turnOn and turnOff refuse rather than guess where the block ends.
35
+ * So does a file that is not UTF-8 (CP949, UTF-16): written back as text it
36
+ * would lose its bytes. A folder or FIFO at a file's name is never read: a
37
+ * FIFO would make the read wait forever. Nor is a socket or a link loop; and an
38
+ * AGENTS.md that is a link to nothing is refused by turnOn, which would
39
+ * otherwise create the file the link points at.
40
+ *
41
+ * Codex's files are read and written under a lock file (.banker-agents.lock in
42
+ * the Codex folder), so both skills turned on at once keep both blocks; a lock
43
+ * older than a minute is taken over.
44
+ *
45
+ * Results are { ok, message }; ok false means refused with nothing changed.
46
+ * Anything else that goes wrong throws.
47
+ */
48
+ import {
49
+ accessSync,
50
+ closeSync,
51
+ constants,
52
+ fstatSync,
53
+ lstatSync,
54
+ openSync,
55
+ mkdirSync,
56
+ readFileSync,
57
+ realpathSync,
58
+ renameSync,
59
+ rmSync,
60
+ statSync,
61
+ writeFileSync,
62
+ } from "node:fs";
63
+ import { dirname, join } from "node:path";
64
+
65
+ /** The banker skills that keep a block in Codex's instructions files. */
66
+ export const BANKER_SLUGS = ["tone-compact", "anti-drm"];
67
+ export const RUNTIMES = ["claude", "codex"];
68
+ // OMX keeps only these regions when `omx setup` rewrites a generated AGENTS.md.
69
+ export const KEEP_OPEN = "<!-- USER:OMX:POLICY:START -->";
70
+ export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
71
+
72
+ // The byte order mark an editor may put in front of a file.
73
+ const BOM = String.fromCharCode(0xfeff);
74
+ /** The text without the byte order mark an editor may put in front of it. */
75
+ export const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
76
+ /** The text with CRLF line ends turned into LF. */
77
+ export const lf = (text) => text.replace(/\r\n/g, "\n");
78
+ /** How many times needle occurs in text. */
79
+ export const count = (text, needle) => text.split(needle).length - 1;
80
+ const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
81
+ const isDir = (path) => {
82
+ try {
83
+ return statSync(path).isDirectory();
84
+ } catch {
85
+ return false;
86
+ }
87
+ };
88
+ const isLink = (path) => {
89
+ try {
90
+ return lstatSync(path).isSymbolicLink();
91
+ } catch {
92
+ return false;
93
+ }
94
+ };
95
+
96
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
97
+
98
+ // What sits at a name that is not a regular file: a folder, a FIFO or a device.
99
+ // It is never read; reading a FIFO waits for a writer.
100
+ const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
101
+ // What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
102
+ const DANGLING = Object.freeze({ bytes: null, text: null, dangling: true });
103
+ // What opening a name fails with when no regular file can be there: links that loop (ELOOP) or a socket (ENXIO,
104
+ // EOPNOTSUPP). macOS answers a socket with errno 102, which Node 22 does not name ("Unknown system error -102"), so
105
+ // the kind of what is there decides too.
106
+ const NOT_A_FILE = new Set(["ELOOP", "ENXIO", "EOPNOTSUPP"]);
107
+ // Whether path leads to something that is there and is no regular file: a folder, a socket, a FIFO or a device.
108
+ const notAFile = (path) => {
109
+ try {
110
+ return !statSync(path).isFile();
111
+ } catch {
112
+ return false;
113
+ }
114
+ };
115
+
116
+ // What a failed open of path means: null for nothing there, DANGLING for a link to nothing, SPECIAL for a folder,
117
+ // a socket or a link loop. Any other failure (permissions) is not "missing" and propagates.
118
+ function openFailure(path, e) {
119
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return isLink(path) ? DANGLING : null;
120
+ if (e.code === "EISDIR" || NOT_A_FILE.has(e.code) || notAFile(path)) return SPECIAL;
121
+ throw e;
122
+ }
123
+
124
+ // A file's bytes and text, null when there is no such file. text is null when
125
+ // the bytes are not UTF-8, which callers refuse: decoding them would replace
126
+ // them with U+FFFD for good. A folder, a socket, a link loop or anything but a
127
+ // regular file is SPECIAL, and a link to nothing is DANGLING: the name is
128
+ // opened without blocking and not read.
129
+ function readFile(path) {
130
+ let fd;
131
+ try {
132
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
133
+ } catch (e) {
134
+ return openFailure(path, e);
135
+ }
136
+ try {
137
+ if (!fstatSync(fd).isFile()) return SPECIAL;
138
+ const bytes = readFileSync(fd);
139
+ try {
140
+ return { bytes, text: UTF8.decode(bytes) };
141
+ } catch {
142
+ return { bytes, text: null };
143
+ }
144
+ } finally {
145
+ closeSync(fd);
146
+ }
147
+ }
148
+
149
+ /** A result that did, or found already done, what was asked. */
150
+ export const done = (message) => ({ ok: true, message });
151
+ /** A result that refused and changed nothing. */
152
+ export const refuse = (message) => ({ ok: false, message });
153
+
154
+ /** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
155
+ export function markersOf(slug) {
156
+ return { ownerMark: `<!-- banker ${slug}`, open: `<!-- banker:${slug}:start -->`, close: `<!-- banker:${slug}:end -->` };
157
+ }
158
+
159
+ /**
160
+ * What one skill's toggle needs: its slug and marks, the first line of its
161
+ * rules file, and two texts it words its own way: the status of rules that
162
+ * differ from the ones given (staleText), and what turnOn says when it replaced
163
+ * them (updatedText).
164
+ */
165
+ export function toggleSpec({ slug, staleText, updatedText }) {
166
+ const marks = markersOf(slug);
167
+ const header = `${marks.ownerMark}: banker 가 관리하는 파일입니다. 끄려면 ${slug} 에 off 를 실행하세요. -->`;
168
+ return { slug, ...marks, header, staleText, updatedText };
169
+ }
170
+
171
+ /**
172
+ * Replaces file through a fresh temp file ("wx") and a rename: a link planted
173
+ * at either name is replaced rather than written through, and a write that
174
+ * fails leaves the old file as it was.
175
+ */
176
+ export function writeFresh(file, data, mode = 0o644) {
177
+ const tmp = `${file}.${process.pid}.tmp`;
178
+ rmSync(tmp, { force: true });
179
+ try {
180
+ writeFileSync(tmp, data, { flag: "wx", mode });
181
+ renameSync(tmp, file);
182
+ } catch (e) {
183
+ rmSync(tmp, { force: true });
184
+ throw e;
185
+ }
186
+ }
187
+
188
+ /** Whether an error is about permissions or a read-only file system. */
189
+ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
190
+
191
+ /**
192
+ * A path or an error text as a message shows it. Control, format, line and
193
+ * paragraph separator, private-use, unassigned and variation selector
194
+ * characters become "?": a path from the environment cannot start a line of
195
+ * its own, reorder the text around it, or hide text after a visible letter.
196
+ * So do letters that print as nothing (default-ignorable ones such as U+3164),
197
+ * the braille blank and spaces other than the ASCII one, which hide text as blanks.
198
+ */
199
+ export const where = (value) =>
200
+ String(value).replace(
201
+ /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}\p{Default_Ignorable_Code_Point}\u{2800}\u{a0}\u{1680}\u{2000}-\u{200a}\u{202f}\u{205f}\u{3000}]/gu,
202
+ "?",
203
+ );
204
+
205
+ // ---- Claude Code: one rules file that is banker's alone ----
206
+
207
+ const claudeFile = (t, o) => join(o.claudeDir, "rules", `banker-${t.slug}.md`);
208
+ const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
209
+
210
+ // "off", "foreign" (not banker's file), or, when rules are given, "on" or
211
+ // "stale" (other rules); without rules a banker file is "ours".
212
+ function claudeState(t, file, rules) {
213
+ if (file === null || file.dangling) return "off";
214
+ if (file.text === null) return "foreign";
215
+ const body = lf(noBom(file.text));
216
+ if (!body.startsWith(t.ownerMark)) return "foreign";
217
+ if (rules === undefined) return "ours";
218
+ return body === claudeText(t, rules) ? "on" : "stale";
219
+ }
220
+
221
+ function claudeOn(t, o, getRules) {
222
+ const file = claudeFile(t, o);
223
+ const shown = where(file);
224
+ if (!isDir(o.claudeDir)) return refuse(`Claude Code 설정 폴더가 없습니다: ${where(o.claudeDir)}`);
225
+ const rules = getRules();
226
+ const state = claudeState(t, readFile(file), rules);
227
+ if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${shown}`);
228
+ if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${shown}`);
229
+ try {
230
+ mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
231
+ writeFresh(file, claudeText(t, rules));
232
+ } catch (e) {
233
+ if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${shown}`);
234
+ throw e;
235
+ }
236
+ return done(
237
+ state === "stale"
238
+ ? `Claude Code: ${t.updatedText}. ${shown}`
239
+ : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`,
240
+ );
241
+ }
242
+
243
+ function claudeOff(t, o) {
244
+ const file = claudeFile(t, o);
245
+ const state = claudeState(t, readFile(file));
246
+ if (state === "off") return done("Claude Code: 이미 꺼져 있습니다 (변경 없음).");
247
+ if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${where(file)}`);
248
+ try {
249
+ rmSync(file);
250
+ } catch (e) {
251
+ if (denied(e)) return refuse(`지울 권한이 없어 끄지 못했습니다 (${e.code}): ${where(file)}`);
252
+ throw e;
253
+ }
254
+ return done(`Claude Code: 껐습니다. 다음 세션부터 적용되지 않습니다. ${where(file)}`);
255
+ }
256
+
257
+ // ---- Codex: one block in the global instructions file Codex reads ----
258
+
259
+ const lineBare = (line) => line.replace(/\r?\n$/, "");
260
+ // A line without its line break, and the first one without the byte order mark
261
+ // an editor may put in front of it.
262
+ const bareAt = (lines, i) => (i === 0 ? noBom(lineBare(lines[i])) : lineBare(lines[i]));
263
+
264
+ // One block's place in the text: [from, to) runs from its first line (the keep
265
+ // marker when one wraps it directly) through the end of its last line; cut is
266
+ // from minus the line break `on` puts before the block, and blankBefore says
267
+ // whether that break makes a blank line of its own.
268
+ function spanOf(lines, offsets, first, last) {
269
+ const wrapped =
270
+ first > 0 &&
271
+ last + 1 < lines.length &&
272
+ bareAt(lines, first - 1) === KEEP_OPEN &&
273
+ bareAt(lines, last + 1) === KEEP_CLOSE;
274
+ const top = wrapped ? first - 1 : first;
275
+ const bottom = wrapped ? last + 1 : last;
276
+ const before = top > 0 ? lines[top - 1] : "";
277
+ const joint = before.endsWith("\r\n") ? 2 : before.endsWith("\n") ? 1 : 0;
278
+ const blankBefore = before !== "" && lineBare(before) === "";
279
+ return { cut: offsets[top] - joint, from: offsets[top], to: offsets[bottom] + lines[bottom].length, blankBefore };
280
+ }
281
+
282
+ // Where each line starts in the text.
283
+ function lineOffsets(lines) {
284
+ const offsets = [];
285
+ let at = 0;
286
+ for (const line of lines) {
287
+ offsets.push(at);
288
+ at += line.length;
289
+ }
290
+ return offsets;
291
+ }
292
+
293
+ // Any management marker, a banker block's or OMX's, anywhere on a line.
294
+ const ANY_MARKER = /<!-- (?:banker:[a-z0-9-]+:(?:start|end)|USER:OMX:POLICY:(?:START|END)) -->/;
295
+
296
+ /**
297
+ * Whether text quotes any management marker. Rules must not: a block holding
298
+ * one would be refused as soon as it is written, and could never come out.
299
+ */
300
+ export const hasMarker = (text) => ANY_MARKER.test(text);
301
+ // Whether a marker sits between a block's first and last line: then its end was
302
+ // moved, by hand or by an editor, and cutting to it would take other text along.
303
+ const markerInside = (lines, first, last) => lines.slice(first + 1, last).some((line) => ANY_MARKER.test(line));
304
+
305
+ /**
306
+ * Where the blocks between whole lines `open` and `close` sit in text, or null
307
+ * when those markers are not whole lines that open and close in turn, or when
308
+ * another management marker sits inside a block. turnOn and turnOff refuse
309
+ * null, so a marker quoted inside a sentence, or an end put back by hand around
310
+ * other text, can never make them cut the user's own text.
311
+ */
312
+ export function blockSpansOf(text, open, close) {
313
+ const lines = text.split(/(?<=\n)/);
314
+ const offsets = lineOffsets(lines);
315
+ const spans = [];
316
+ let first = -1;
317
+ for (let i = 0; i < lines.length; i++) {
318
+ const bare = bareAt(lines, i);
319
+ if (bare === open && first === -1) first = i;
320
+ else if (bare === close && first !== -1) {
321
+ if (markerInside(lines, first, i)) return null;
322
+ spans.push(spanOf(lines, offsets, first, i));
323
+ first = -1;
324
+ } else if (lines[i].includes(open) || lines[i].includes(close)) return null;
325
+ }
326
+ return first === -1 ? spans : null;
327
+ }
328
+
329
+ // A block, banker's or any USER:OMX:POLICY one, right after a line break.
330
+ const BLOCK_NEXT = /^\r?\n(?:<!-- USER:OMX:POLICY:START -->|<!-- banker:)/;
331
+
332
+ // The text without its blocks: exactly what it was before `on` added them. The
333
+ // line break `on` put in front goes with the block when the block ends the file
334
+ // or has a blank line of its own in front; with other text after it, the break
335
+ // stays, so the lines on either side never run together. When another block
336
+ // follows, its own leading break keeps the lines apart, so this one goes too:
337
+ // two blocks taken out in the order they went in then leave a text that had no
338
+ // final line break as it was.
339
+ function strip(text, spans) {
340
+ let out = "";
341
+ let at = 0;
342
+ for (const s of spans) {
343
+ const keepBreak = s.to < text.length && !s.blankBefore && !BLOCK_NEXT.test(text.slice(s.to));
344
+ out += text.slice(at, keepBreak ? s.from : s.cut);
345
+ at = s.to;
346
+ }
347
+ return out + text.slice(at);
348
+ }
349
+
350
+ // Whether text holds anything but whitespace, the way Codex judges a file empty:
351
+ // Rust's trim, which strips the Unicode White_Space set and, unlike
352
+ // JavaScript's trim, keeps a byte order mark.
353
+ const hasContent = (text) => /\P{White_Space}/u.test(text);
354
+
355
+ // The text without any banker skill's blocks. A skill whose markers do not pair
356
+ // up keeps its text, which then counts as content: nothing is cut on a guess.
357
+ function withoutBankerBlocks(text) {
358
+ return BANKER_SLUGS.reduce((out, slug) => {
359
+ const { open, close } = markersOf(slug);
360
+ const spans = blockSpansOf(out, open, close);
361
+ return spans === null ? out : strip(out, spans);
362
+ }, text);
363
+ }
364
+
365
+ // The banker skills other than t with a block in text.
366
+ const otherBlocks = (t, text) =>
367
+ BANKER_SLUGS.filter((slug) => {
368
+ if (slug === t.slug) return false;
369
+ const { open, close } = markersOf(slug);
370
+ const spans = blockSpansOf(text, open, close);
371
+ return spans !== null && spans.length > 0;
372
+ });
373
+
374
+ const special = (f) => f.special === true;
375
+ const binary = (f) => f.bytes !== null && f.text === null;
376
+ const unpaired = (f) => f.spans === null;
377
+ const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
378
+ const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
379
+
380
+ /** Whether two bigint stats name one file: one device and one inode, where the file system gives inodes at all. */
381
+ export const sameInode = (x, y) => x.ino !== 0n && x.dev === y.dev && x.ino === y.ino;
382
+
383
+ // Whether two paths name one file: a link (both resolve to one real path) or a hard link (one inode). A file system
384
+ // that gives no inode numbers (0) cannot say so for hard links, so those count as two files.
385
+ function sameFile(a, b) {
386
+ try {
387
+ if (realpathSync(a) === realpathSync(b)) return true;
388
+ return sameInode(statSync(a, { bigint: true }), statSync(b, { bigint: true }));
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ // Both global instruction files with their bytes and text (null when absent;
395
+ // text null when not UTF-8) and blocks (null when the markers do not pair up).
396
+ // The block's place (target) is the override when the override has text of its
397
+ // own, AGENTS.md otherwise. shadowers names the other banker skills whose
398
+ // blocks alone fill the override: Codex then reads the override, not AGENTS.md.
399
+ // An override that is a link or a hard link to AGENTS.md is one file with it.
400
+ function codexFiles(t, o) {
401
+ const read = (name) => {
402
+ const path = join(o.codexDir, name);
403
+ const file = readFile(path);
404
+ if (file === null || file.special || file.dangling) {
405
+ return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true };
406
+ }
407
+ return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
408
+ };
409
+ const override = read("AGENTS.override.md");
410
+ const main = read("AGENTS.md");
411
+ if (override.bytes !== null && main.bytes !== null && sameFile(override.path, main.path)) {
412
+ // One file under both names: Codex reads it either way, so it is AGENTS.md alone.
413
+ const none = { path: override.path, bytes: null, text: null, spans: [] };
414
+ return { target: main, other: none, all: [none, main], shadowers: [] };
415
+ }
416
+ const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
417
+ const target = ownContent ? override : main;
418
+ const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
419
+ return { target, other: target === override ? main : override, all: [override, main], shadowers };
420
+ }
421
+
422
+ const blockFor = (t, rules, eol) => [KEEP_OPEN, t.open, ...rules.split("\n"), t.close, KEEP_CLOSE, ""].join(eol);
423
+ // A block's text compared without line-break style, a leading byte order mark
424
+ // or the final line break, which editors add and drop.
425
+ const normBlock = (text) => noBom(lf(text)).replace(/\n$/, "");
426
+ // The block exactly as `on` writes it, and only once.
427
+ const blockCurrent = (t, f, rules) =>
428
+ f.spans.length === 1 && normBlock(f.text.slice(f.spans[0].from, f.spans[0].to)) === normBlock(blockFor(t, rules, "\n"));
429
+
430
+ // The text with the block at the end, one line break between them; an empty
431
+ // file gets the block alone. strip() undoes exactly this.
432
+ function withBlock(t, text, rules) {
433
+ const eol = eolOf(text);
434
+ return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
435
+ }
436
+
437
+ // The state when the block is not in its place: "hidden" (only in an override with no text of its own),
438
+ // "misplaced" (only in AGENTS.md while Codex reads the override), "dangling" (nowhere, and AGENTS.md is a link to
439
+ // nothing that on must not write through) or "off".
440
+ function placeState(files) {
441
+ if (hasBlock(files.other)) return files.other === files.all[0] ? "hidden" : "misplaced";
442
+ return files.target.dangling ? "dangling" : "off";
443
+ }
444
+
445
+ // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
446
+ // override, so not applied), "hidden" (in the override with no text of its own,
447
+ // applied but hiding AGENTS.md), "dangling" (nowhere, and AGENTS.md is a link to
448
+ // nothing), "unpaired", "notutf8" or "notregular" (a folder, FIFO, socket or
449
+ // link loop at a file's name). Without rules any block in its place counts as "on".
450
+ function codexState(t, files, rules) {
451
+ if (files.all.some(special)) return "notregular";
452
+ if (files.all.some(binary)) return "notutf8";
453
+ if (files.all.some(unpaired)) return "unpaired";
454
+ if (!hasBlock(files.target)) return placeState(files);
455
+ if (rules === undefined) return "on";
456
+ return blockCurrent(t, files.target, rules) && !hasBlock(files.other) ? "on" : "stale";
457
+ }
458
+
459
+ // Why these files cannot be written, or "" when they can. The folder counts
460
+ // too: the .bak is written through a temp file beside the file.
461
+ function writeBlocked(changes) {
462
+ for (const { file } of changes) {
463
+ for (const path of file.bytes === null ? [dirname(file.path)] : [file.path, dirname(file.path)]) {
464
+ try {
465
+ accessSync(path, constants.W_OK);
466
+ } catch (e) {
467
+ return `쓸 권한이 없어 그대로 두었습니다 (${where(e.code || e.message)}): ${where(path)}`;
468
+ }
469
+ }
470
+ }
471
+ return "";
472
+ }
473
+
474
+ // Writes each file in place, so a symlinked AGENTS.md stays a link and its
475
+ // target changes, after keeping the old bytes in a .bak only this account can
476
+ // read (the file may be a link into a private folder). A new file gets 0644
477
+ // whatever the umask allows more. A file left empty is removed, unless it is a
478
+ // link or has other names (a hard link), whose other names would keep the old
479
+ // text: it is emptied instead. Codex skips an empty file anyway.
480
+ function apply(t, changes) {
481
+ for (const { file, after } of changes) {
482
+ if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
483
+ if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
484
+ else writeFileSync(file.path, after, { mode: 0o644 });
485
+ }
486
+ }
487
+
488
+ // Whether the file at path is also reachable under another name: a hard link.
489
+ function hasOtherNames(path) {
490
+ try {
491
+ return lstatSync(path).nlink > 1;
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ // The lock both banker skills take while they read and write Codex's instructions files: two on's at once would
498
+ // each write the file back with only their own block. A lock is held for milliseconds; one older than a minute was
499
+ // left by a run that died.
500
+ const LOCK = ".banker-agents.lock";
501
+ const LOCK_STALE_MS = 60_000;
502
+ const LOCK_STEP_MS = 50;
503
+ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
504
+
505
+ // Makes the lock file; false when one is there already. Anything else (permissions) throws.
506
+ function tryLock(path, token) {
507
+ try {
508
+ writeFileSync(path, token, { flag: "wx", mode: 0o600 });
509
+ return true;
510
+ } catch (e) {
511
+ if (e.code === "EEXIST") return false;
512
+ throw e;
513
+ }
514
+ }
515
+
516
+ // Removes a lock a dead run left; true when there is none now, so the next try may take it. The name itself is
517
+ // looked at, not where it leads: a link to nothing or a link loop there is a lock like any other. A lock dated more
518
+ // than a minute away either way (a clock set back) is stale too. A folder there is none of this module's: it stays,
519
+ // and the wait ends in a refusal.
520
+ function dropStale(path) {
521
+ let st;
522
+ try {
523
+ st = lstatSync(path);
524
+ } catch (e) {
525
+ if (e.code === "ENOENT") return true; /* gone in between */
526
+ throw e;
527
+ }
528
+ if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
529
+ rmSync(path, { force: true });
530
+ return true;
531
+ }
532
+
533
+ // Takes the lock: { path, token }, or null when another run held it for waitMs. The deadline is looked at every
534
+ // round, so no lock spot, whatever is there, keeps the wait going past it.
535
+ function takeLock(dir, waitMs) {
536
+ const path = join(dir, LOCK);
537
+ const token = `${process.pid} ${Date.now()} ${Math.random()}`;
538
+ const until = Date.now() + waitMs;
539
+ for (;;) {
540
+ if (tryLock(path, token)) return { path, token };
541
+ if (Date.now() >= until) return null;
542
+ if (!dropStale(path)) Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
543
+ }
544
+ }
545
+
546
+ // Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
547
+ // this run's lock and stays.
548
+ function dropLock(lock) {
549
+ try {
550
+ if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
551
+ } catch {
552
+ /* already gone */
553
+ }
554
+ }
555
+
556
+ // Runs fn holding the lock; a lock another run keeps past the wait is a refusal. A folder this account cannot write
557
+ // takes no lock, and nothing in it can be written either: fn then only reads, and writeBlocked refuses any change,
558
+ // so "already on" and "already off" answer as before.
559
+ function locked(o, fn) {
560
+ let lock;
561
+ try {
562
+ lock = takeLock(o.codexDir, o.lockWaitMs ?? 5000);
563
+ } catch (e) {
564
+ if (denied(e)) return fn();
565
+ throw e;
566
+ }
567
+ if (!lock) return refuse(`다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(join(o.codexDir, LOCK))}`);
568
+ try {
569
+ return fn();
570
+ } finally {
571
+ dropLock(lock);
572
+ }
573
+ }
574
+
575
+ const OMX_NOTE =
576
+ "참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
577
+
578
+ // The note for a block other banker blocks keep from being read.
579
+ const shadowNote = (files) =>
580
+ files.shadowers.length === 0
581
+ ? ""
582
+ : `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
583
+
584
+ // The file a refused state is about: the folder or FIFO, the file that is not UTF-8, or the one whose markers do
585
+ // not pair up; null in any other state.
586
+ function problemFile(files, state) {
587
+ const which = { notregular: special, notutf8: binary, unpaired }[state];
588
+ return which ? files.all.find(which) : null;
589
+ }
590
+
591
+ // The refusal for files on and off must not touch, or null.
592
+ function codexRefusal(t, files, state) {
593
+ const file = problemFile(files, state);
594
+ if (!file) return null;
595
+ const why = {
596
+ notregular: "일반 파일이 아니라 그대로 두었습니다",
597
+ notutf8: "UTF-8 이 아닌 파일이라 그대로 두었습니다",
598
+ unpaired: `${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다`,
599
+ }[state];
600
+ return refuse(`${why}: ${where(file.path)}`);
601
+ }
602
+
603
+ // What codexOn says it did. A block Codex does not read, because other banker
604
+ // blocks alone fill the override, says so in its first words.
605
+ function onWords(t, state, unread) {
606
+ if (state === "on") return unread ? "이미 AGENTS.md 에 있지만 지금은 적용되지 않습니다 (변경 없음)" : "이미 켜져 있습니다 (변경 없음)";
607
+ if (unread) return "AGENTS.md 에 넣었지만 지금은 적용되지 않습니다";
608
+ const moved = "블록을 맞는 파일로 옮겼습니다";
609
+ return { stale: t.updatedText, misplaced: moved, hidden: moved }[state] ?? "켰습니다. 다음 세션부터 자동 적용됩니다";
610
+ }
611
+
612
+ function codexOn(t, o, getRules) {
613
+ if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${where(o.codexDir)}`);
614
+ return locked(o, () => codexOnHeld(t, o, getRules));
615
+ }
616
+
617
+ function codexOnHeld(t, o, getRules) {
618
+ const rules = getRules();
619
+ const files = codexFiles(t, o);
620
+ const state = codexState(t, files, rules);
621
+ const refusal = codexRefusal(t, files, state);
622
+ if (refusal) return refusal;
623
+ if (files.target.dangling) return refuse(`끊어진 링크라 그대로 두었습니다: ${where(files.target.path)}`);
624
+ const words = onWords(t, state, files.shadowers.length > 0);
625
+ const tail = `${where(files.target.path)}${shadowNote(files)}`;
626
+ if (state === "on") return done(`Codex: ${words}. ${tail}`);
627
+ const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
628
+ if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
629
+ const why = writeBlocked(changes);
630
+ if (why) return refuse(why);
631
+ apply(t, changes);
632
+ const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
633
+ return done(`Codex: ${words}. ${tail}${note}`);
634
+ }
635
+
636
+ function codexOff(t, o) {
637
+ if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${where(o.codexDir)}`);
638
+ return locked(o, () => codexOffHeld(t, o));
639
+ }
640
+
641
+ function codexOffHeld(t, o) {
642
+ const files = codexFiles(t, o);
643
+ const refusal = codexRefusal(t, files, codexState(t, files));
644
+ if (refusal) return refusal;
645
+ const changes = files.all.filter(hasBlock).map((f) => ({ file: f, after: stripped(f) }));
646
+ if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
647
+ const why = writeBlocked(changes);
648
+ if (why) return refuse(why);
649
+ apply(t, changes);
650
+ const paths = changes.map((c) => where(c.file.path)).join(", ");
651
+ const baks = changes.map((c) => where(`${c.file.path}.${t.slug}.bak`)).join(", ");
652
+ return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${paths}\n지우기 전 파일: ${baks}`);
653
+ }
654
+
655
+ // ---- status ----
656
+
657
+ const STATE_TEXT = {
658
+ on: "켜짐",
659
+ ours: "켜짐 (규칙 버전 확인 불가)",
660
+ off: "꺼짐",
661
+ foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
662
+ misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
663
+ hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
664
+ notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
665
+ notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
666
+ unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
667
+ dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
668
+ };
669
+
670
+ // A state as the status line says it.
671
+ function stateText(t, { state, who }) {
672
+ if (state === "stale") return t.staleText;
673
+ if (state === "shadowed") {
674
+ return `꺼짐, 단 AGENTS.override.md 에 다른 banker 블록(${who.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않음 (그 스킬에서 on 으로 옮김)`;
675
+ }
676
+ return STATE_TEXT[state] ?? state;
677
+ }
678
+
679
+ // One status line. Read failures are reported, not thrown: status is where an
680
+ // exit 2 sends the user to look.
681
+ function describe(t, name, look) {
682
+ try {
683
+ const found = look();
684
+ return `- ${name}: ${stateText(t, found)} (${where(found.path)})`;
685
+ } catch (e) {
686
+ return `- ${name}: 읽을 수 없음 (${where(e.code || e.message)})`;
687
+ }
688
+ }
689
+
690
+ function codexLook(t, o, rules) {
691
+ if (!isDir(o.codexDir)) return { state: "설정 폴더 없음", path: o.codexDir };
692
+ const files = codexFiles(t, o);
693
+ const state = codexState(t, files, rules);
694
+ const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
695
+ return { state: shadowed ? "shadowed" : state, path: (problemFile(files, state) ?? files.target).path, who: files.shadowers };
696
+ }
697
+
698
+ /** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
699
+ export function stateLines(t, o, rules) {
700
+ return [
701
+ describe(t, "Claude Code", () => {
702
+ if (!isDir(o.claudeDir)) return { state: "설정 폴더 없음", path: o.claudeDir };
703
+ return { state: claudeState(t, readFile(claudeFile(t, o)), rules), path: claudeFile(t, o) };
704
+ }),
705
+ describe(t, "Codex", () => codexLook(t, o, rules)),
706
+ ];
707
+ }
708
+
709
+ /**
710
+ * Whether a runtime holds the skill's rules in some form (current, older, in
711
+ * the wrong file or with broken markers), so that changed rules should reach
712
+ * it. Throws when a file cannot be read.
713
+ */
714
+ export function present(t, runtime, o) {
715
+ if (runtime === "claude") return isDir(o.claudeDir) && claudeState(t, readFile(claudeFile(t, o))) === "ours";
716
+ return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
717
+ }
718
+
719
+ const knownRuntime = (runtime) => {
720
+ if (!RUNTIMES.includes(runtime)) throw new Error(`알 수 없는 런타임입니다: ${runtime}`);
721
+ };
722
+
723
+ /** Puts the rules where one runtime loads them. getRules runs after the config folder check. */
724
+ export function turnOn(t, runtime, o, getRules) {
725
+ knownRuntime(runtime);
726
+ return runtime === "claude" ? claudeOn(t, o, getRules) : codexOn(t, o, getRules);
727
+ }
728
+
729
+ /** Takes the rules out for one runtime; it never needs them. */
730
+ export function turnOff(t, runtime, o) {
731
+ knownRuntime(runtime);
732
+ return runtime === "claude" ? claudeOff(t, o) : codexOff(t, o);
733
+ }