@kaydash9999/banker-plugins 0.17.0 → 0.18.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.
@@ -0,0 +1,527 @@
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.
37
+ *
38
+ * Results are { ok, message }; ok false means refused with nothing changed.
39
+ * Anything else that goes wrong throws.
40
+ */
41
+ import {
42
+ accessSync,
43
+ constants,
44
+ lstatSync,
45
+ mkdirSync,
46
+ readFileSync,
47
+ renameSync,
48
+ rmSync,
49
+ statSync,
50
+ writeFileSync,
51
+ } from "node:fs";
52
+ import { dirname, join } from "node:path";
53
+
54
+ /** The banker skills that keep a block in Codex's instructions files. */
55
+ export const BANKER_SLUGS = ["tone-compact", "anti-drm"];
56
+ export const RUNTIMES = ["claude", "codex"];
57
+ // OMX keeps only these regions when `omx setup` rewrites a generated AGENTS.md.
58
+ export const KEEP_OPEN = "<!-- USER:OMX:POLICY:START -->";
59
+ export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
60
+
61
+ // The byte order mark an editor may put in front of a file.
62
+ const BOM = String.fromCharCode(0xfeff);
63
+ const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
64
+ const lf = (text) => text.replace(/\r\n/g, "\n");
65
+ const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
66
+ const isDir = (path) => {
67
+ try {
68
+ return statSync(path).isDirectory();
69
+ } catch {
70
+ return false;
71
+ }
72
+ };
73
+ const isLink = (path) => {
74
+ try {
75
+ return lstatSync(path).isSymbolicLink();
76
+ } catch {
77
+ return false;
78
+ }
79
+ };
80
+
81
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
82
+
83
+ // A file's bytes and text, null when there is no such file. text is null when
84
+ // the bytes are not UTF-8, which callers refuse: decoding them would replace
85
+ // them with U+FFFD for good. Any other read failure (permissions, a folder at
86
+ // that name) is not "missing" and propagates.
87
+ function readFile(path) {
88
+ let bytes;
89
+ try {
90
+ bytes = readFileSync(path);
91
+ } catch (e) {
92
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
93
+ throw e;
94
+ }
95
+ try {
96
+ return { bytes, text: UTF8.decode(bytes) };
97
+ } catch {
98
+ return { bytes, text: null };
99
+ }
100
+ }
101
+
102
+ const done = (message) => ({ ok: true, message });
103
+ const refuse = (message) => ({ ok: false, message });
104
+
105
+ /** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
106
+ export function markersOf(slug) {
107
+ return { ownerMark: `<!-- banker ${slug}`, open: `<!-- banker:${slug}:start -->`, close: `<!-- banker:${slug}:end -->` };
108
+ }
109
+
110
+ /**
111
+ * What one skill's toggle needs: its slug and marks, the first line of its
112
+ * rules file, and two texts it words its own way: the status of rules that
113
+ * differ from the ones given (staleText), and what turnOn says when it replaced
114
+ * them (updatedText).
115
+ */
116
+ export function toggleSpec({ slug, staleText, updatedText }) {
117
+ const marks = markersOf(slug);
118
+ const header = `${marks.ownerMark}: banker 가 관리하는 파일입니다. 끄려면 ${slug} 에 off 를 실행하세요. -->`;
119
+ return { slug, ...marks, header, staleText, updatedText };
120
+ }
121
+
122
+ /**
123
+ * Replaces file through a fresh temp file ("wx") and a rename: a link planted
124
+ * at either name is replaced rather than written through, and a write that
125
+ * fails leaves the old file as it was.
126
+ */
127
+ export function writeFresh(file, data, mode = 0o644) {
128
+ const tmp = `${file}.${process.pid}.tmp`;
129
+ rmSync(tmp, { force: true });
130
+ try {
131
+ writeFileSync(tmp, data, { flag: "wx", mode });
132
+ renameSync(tmp, file);
133
+ } catch (e) {
134
+ rmSync(tmp, { force: true });
135
+ throw e;
136
+ }
137
+ }
138
+
139
+ /** Whether an error is about permissions or a read-only file system. */
140
+ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
141
+
142
+ /**
143
+ * A path or an error text as a message shows it. Control, format, line and
144
+ * paragraph separator, private-use, unassigned and variation selector
145
+ * characters become "?": a path from the environment cannot start a line of
146
+ * its own, reorder the text around it, or hide text after a visible letter.
147
+ */
148
+ export const where = (value) =>
149
+ String(value).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}]/gu, "?");
150
+
151
+ // ---- Claude Code: one rules file that is banker's alone ----
152
+
153
+ const claudeFile = (t, o) => join(o.claudeDir, "rules", `banker-${t.slug}.md`);
154
+ const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
155
+
156
+ // "off", "foreign" (not banker's file), or, when rules are given, "on" or
157
+ // "stale" (other rules); without rules a banker file is "ours".
158
+ function claudeState(t, file, rules) {
159
+ if (file === null) return "off";
160
+ if (file.text === null) return "foreign";
161
+ const body = lf(noBom(file.text));
162
+ if (!body.startsWith(t.ownerMark)) return "foreign";
163
+ if (rules === undefined) return "ours";
164
+ return body === claudeText(t, rules) ? "on" : "stale";
165
+ }
166
+
167
+ function claudeOn(t, o, getRules) {
168
+ const file = claudeFile(t, o);
169
+ const shown = where(file);
170
+ if (!isDir(o.claudeDir)) return refuse(`Claude Code 설정 폴더가 없습니다: ${where(o.claudeDir)}`);
171
+ const rules = getRules();
172
+ const state = claudeState(t, readFile(file), rules);
173
+ if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${shown}`);
174
+ if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${shown}`);
175
+ try {
176
+ mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
177
+ writeFresh(file, claudeText(t, rules));
178
+ } catch (e) {
179
+ if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${shown}`);
180
+ throw e;
181
+ }
182
+ return done(
183
+ state === "stale"
184
+ ? `Claude Code: ${t.updatedText}. ${shown}`
185
+ : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`,
186
+ );
187
+ }
188
+
189
+ function claudeOff(t, o) {
190
+ const file = claudeFile(t, o);
191
+ const state = claudeState(t, readFile(file));
192
+ if (state === "off") return done("Claude Code: 이미 꺼져 있습니다 (변경 없음).");
193
+ if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${where(file)}`);
194
+ try {
195
+ rmSync(file);
196
+ } catch (e) {
197
+ if (denied(e)) return refuse(`지울 권한이 없어 끄지 못했습니다 (${e.code}): ${where(file)}`);
198
+ throw e;
199
+ }
200
+ return done(`Claude Code: 껐습니다. 다음 세션부터 적용되지 않습니다. ${where(file)}`);
201
+ }
202
+
203
+ // ---- Codex: one block in the global instructions file Codex reads ----
204
+
205
+ const lineBare = (line) => line.replace(/\r?\n$/, "");
206
+ // A line without its line break, and the first one without the byte order mark
207
+ // an editor may put in front of it.
208
+ const bareAt = (lines, i) => (i === 0 ? noBom(lineBare(lines[i])) : lineBare(lines[i]));
209
+
210
+ // One block's place in the text: [from, to) runs from its first line (the keep
211
+ // marker when one wraps it directly) through the end of its last line; cut is
212
+ // from minus the line break `on` puts before the block, and blankBefore says
213
+ // whether that break makes a blank line of its own.
214
+ function spanOf(lines, offsets, first, last) {
215
+ const wrapped =
216
+ first > 0 &&
217
+ last + 1 < lines.length &&
218
+ bareAt(lines, first - 1) === KEEP_OPEN &&
219
+ bareAt(lines, last + 1) === KEEP_CLOSE;
220
+ const top = wrapped ? first - 1 : first;
221
+ const bottom = wrapped ? last + 1 : last;
222
+ const before = top > 0 ? lines[top - 1] : "";
223
+ const joint = before.endsWith("\r\n") ? 2 : before.endsWith("\n") ? 1 : 0;
224
+ const blankBefore = before !== "" && lineBare(before) === "";
225
+ return { cut: offsets[top] - joint, from: offsets[top], to: offsets[bottom] + lines[bottom].length, blankBefore };
226
+ }
227
+
228
+ // Where each line starts in the text.
229
+ function lineOffsets(lines) {
230
+ const offsets = [];
231
+ let at = 0;
232
+ for (const line of lines) {
233
+ offsets.push(at);
234
+ at += line.length;
235
+ }
236
+ return offsets;
237
+ }
238
+
239
+ // Any management marker, a banker block's or OMX's, anywhere on a line.
240
+ const ANY_MARKER = /<!-- (?:banker:[a-z0-9-]+:(?:start|end)|USER:OMX:POLICY:(?:START|END)) -->/;
241
+
242
+ /**
243
+ * Whether text quotes any management marker. Rules must not: a block holding
244
+ * one would be refused as soon as it is written, and could never come out.
245
+ */
246
+ export const hasMarker = (text) => ANY_MARKER.test(text);
247
+ // Whether a marker sits between a block's first and last line: then its end was
248
+ // moved, by hand or by an editor, and cutting to it would take other text along.
249
+ const markerInside = (lines, first, last) => lines.slice(first + 1, last).some((line) => ANY_MARKER.test(line));
250
+
251
+ /**
252
+ * Where the blocks between whole lines `open` and `close` sit in text, or null
253
+ * when those markers are not whole lines that open and close in turn, or when
254
+ * another management marker sits inside a block. turnOn and turnOff refuse
255
+ * null, so a marker quoted inside a sentence, or an end put back by hand around
256
+ * other text, can never make them cut the user's own text.
257
+ */
258
+ export function blockSpansOf(text, open, close) {
259
+ const lines = text.split(/(?<=\n)/);
260
+ const offsets = lineOffsets(lines);
261
+ const spans = [];
262
+ let first = -1;
263
+ for (let i = 0; i < lines.length; i++) {
264
+ const bare = bareAt(lines, i);
265
+ if (bare === open && first === -1) first = i;
266
+ else if (bare === close && first !== -1) {
267
+ if (markerInside(lines, first, i)) return null;
268
+ spans.push(spanOf(lines, offsets, first, i));
269
+ first = -1;
270
+ } else if (lines[i].includes(open) || lines[i].includes(close)) return null;
271
+ }
272
+ return first === -1 ? spans : null;
273
+ }
274
+
275
+ // A block, banker's or any USER:OMX:POLICY one, right after a line break.
276
+ const BLOCK_NEXT = /^\r?\n(?:<!-- USER:OMX:POLICY:START -->|<!-- banker:)/;
277
+
278
+ // The text without its blocks: exactly what it was before `on` added them. The
279
+ // line break `on` put in front goes with the block when the block ends the file
280
+ // or has a blank line of its own in front; with other text after it, the break
281
+ // stays, so the lines on either side never run together. When another block
282
+ // follows, its own leading break keeps the lines apart, so this one goes too:
283
+ // two blocks taken out in the order they went in then leave a text that had no
284
+ // final line break as it was.
285
+ function strip(text, spans) {
286
+ let out = "";
287
+ let at = 0;
288
+ for (const s of spans) {
289
+ const keepBreak = s.to < text.length && !s.blankBefore && !BLOCK_NEXT.test(text.slice(s.to));
290
+ out += text.slice(at, keepBreak ? s.from : s.cut);
291
+ at = s.to;
292
+ }
293
+ return out + text.slice(at);
294
+ }
295
+
296
+ // Whether text holds anything but whitespace, the way Codex judges a file empty:
297
+ // Rust's trim, which strips the Unicode White_Space set and, unlike
298
+ // JavaScript's trim, keeps a byte order mark.
299
+ const hasContent = (text) => /\P{White_Space}/u.test(text);
300
+
301
+ // The text without any banker skill's blocks. A skill whose markers do not pair
302
+ // up keeps its text, which then counts as content: nothing is cut on a guess.
303
+ function withoutBankerBlocks(text) {
304
+ return BANKER_SLUGS.reduce((out, slug) => {
305
+ const { open, close } = markersOf(slug);
306
+ const spans = blockSpansOf(out, open, close);
307
+ return spans === null ? out : strip(out, spans);
308
+ }, text);
309
+ }
310
+
311
+ // The banker skills other than t with a block in text.
312
+ const otherBlocks = (t, text) =>
313
+ BANKER_SLUGS.filter((slug) => {
314
+ if (slug === t.slug) return false;
315
+ const { open, close } = markersOf(slug);
316
+ const spans = blockSpansOf(text, open, close);
317
+ return spans !== null && spans.length > 0;
318
+ });
319
+
320
+ const binary = (f) => f.bytes !== null && f.text === null;
321
+ const unpaired = (f) => f.spans === null;
322
+ const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
323
+ const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
324
+
325
+ // Both global instruction files with their bytes and text (null when absent;
326
+ // text null when not UTF-8) and blocks (null when the markers do not pair up).
327
+ // The block's place (target) is the override when the override has text of its
328
+ // own, AGENTS.md otherwise. shadowers names the other banker skills whose
329
+ // blocks alone fill the override: Codex then reads the override, not AGENTS.md.
330
+ function codexFiles(t, o) {
331
+ const read = (name) => {
332
+ const path = join(o.codexDir, name);
333
+ const file = readFile(path);
334
+ if (file === null) return { path, bytes: null, text: null, spans: [] };
335
+ return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
336
+ };
337
+ const override = read("AGENTS.override.md");
338
+ const main = read("AGENTS.md");
339
+ const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
340
+ const target = ownContent ? override : main;
341
+ const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
342
+ return { target, other: target === override ? main : override, all: [override, main], shadowers };
343
+ }
344
+
345
+ const blockFor = (t, rules, eol) => [KEEP_OPEN, t.open, ...rules.split("\n"), t.close, KEEP_CLOSE, ""].join(eol);
346
+ // A block's text compared without line-break style, a leading byte order mark
347
+ // or the final line break, which editors add and drop.
348
+ const normBlock = (text) => noBom(lf(text)).replace(/\n$/, "");
349
+ // The block exactly as `on` writes it, and only once.
350
+ const blockCurrent = (t, f, rules) =>
351
+ f.spans.length === 1 && normBlock(f.text.slice(f.spans[0].from, f.spans[0].to)) === normBlock(blockFor(t, rules, "\n"));
352
+
353
+ // The text with the block at the end, one line break between them; an empty
354
+ // file gets the block alone. strip() undoes exactly this.
355
+ function withBlock(t, text, rules) {
356
+ const eol = eolOf(text);
357
+ return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
358
+ }
359
+
360
+ // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
361
+ // override, so not applied), "hidden" (in the override with no text of its own,
362
+ // applied but hiding AGENTS.md), "unpaired" or "notutf8". Without rules any
363
+ // block in its place counts as "on".
364
+ function codexState(t, files, rules) {
365
+ if (files.all.some(binary)) return "notutf8";
366
+ if (files.all.some(unpaired)) return "unpaired";
367
+ const inTarget = hasBlock(files.target);
368
+ const inOther = hasBlock(files.other);
369
+ if (!inTarget) return inOther ? (files.other === files.all[0] ? "hidden" : "misplaced") : "off";
370
+ if (rules === undefined) return "on";
371
+ return blockCurrent(t, files.target, rules) && !inOther ? "on" : "stale";
372
+ }
373
+
374
+ // Why these files cannot be written, or "" when they can. The folder counts
375
+ // too: the .bak is written through a temp file beside the file.
376
+ function writeBlocked(changes) {
377
+ for (const { file } of changes) {
378
+ for (const path of file.bytes === null ? [dirname(file.path)] : [file.path, dirname(file.path)]) {
379
+ try {
380
+ accessSync(path, constants.W_OK);
381
+ } catch (e) {
382
+ return `쓸 권한이 없어 그대로 두었습니다 (${where(e.code || e.message)}): ${where(path)}`;
383
+ }
384
+ }
385
+ }
386
+ return "";
387
+ }
388
+
389
+ // Writes each file in place, so a symlinked AGENTS.md stays a link and its
390
+ // target changes, after keeping the old bytes in a .bak only this account can
391
+ // read (the file may be a link into a private folder). A new file gets 0644
392
+ // whatever the umask allows more. A file left empty is removed, unless it is a
393
+ // link: Codex skips an empty file anyway.
394
+ function apply(t, changes) {
395
+ for (const { file, after } of changes) {
396
+ if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
397
+ if (after === "" && !isLink(file.path)) rmSync(file.path, { force: true });
398
+ else writeFileSync(file.path, after, { mode: 0o644 });
399
+ }
400
+ }
401
+
402
+ const OMX_NOTE =
403
+ "참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
404
+
405
+ // The note for a block other banker blocks keep from being read.
406
+ const shadowNote = (files) =>
407
+ files.shadowers.length === 0
408
+ ? ""
409
+ : `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
410
+
411
+ // The refusal for files on and off must not touch, or null.
412
+ function codexRefusal(t, files, state) {
413
+ if (state === "notutf8") return refuse(`UTF-8 이 아닌 파일이라 그대로 두었습니다: ${where(files.all.find(binary).path)}`);
414
+ if (state === "unpaired") return refuse(`${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다: ${where(files.all.find(unpaired).path)}`);
415
+ return null;
416
+ }
417
+
418
+ function codexOn(t, o, getRules) {
419
+ if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${where(o.codexDir)}`);
420
+ const rules = getRules();
421
+ const files = codexFiles(t, o);
422
+ const state = codexState(t, files, rules);
423
+ const refusal = codexRefusal(t, files, state);
424
+ if (refusal) return refusal;
425
+ const tail = `${where(files.target.path)}${shadowNote(files)}`;
426
+ if (state === "on") return done(`Codex: 이미 켜져 있습니다 (변경 없음). ${tail}`);
427
+ const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
428
+ if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
429
+ const why = writeBlocked(changes);
430
+ if (why) return refuse(why);
431
+ apply(t, changes);
432
+ const moved = "블록을 맞는 파일로 옮겼습니다";
433
+ const what = { stale: t.updatedText, misplaced: moved, hidden: moved }[state];
434
+ const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
435
+ return done(`Codex: ${what ?? "켰습니다. 다음 세션부터 자동 적용됩니다"}. ${tail}${note}`);
436
+ }
437
+
438
+ function codexOff(t, o) {
439
+ if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${where(o.codexDir)}`);
440
+ const files = codexFiles(t, o);
441
+ const refusal = codexRefusal(t, files, codexState(t, files));
442
+ if (refusal) return refusal;
443
+ const changes = files.all.filter(hasBlock).map((f) => ({ file: f, after: stripped(f) }));
444
+ if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
445
+ const why = writeBlocked(changes);
446
+ if (why) return refuse(why);
447
+ apply(t, changes);
448
+ return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${changes.map((c) => where(c.file.path)).join(", ")}`);
449
+ }
450
+
451
+ // ---- status ----
452
+
453
+ const STATE_TEXT = {
454
+ on: "켜짐",
455
+ ours: "켜짐 (규칙 버전 확인 불가)",
456
+ off: "꺼짐",
457
+ foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
458
+ misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
459
+ hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
460
+ notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
461
+ unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
462
+ };
463
+
464
+ // A state as the status line says it.
465
+ function stateText(t, { state, who }) {
466
+ if (state === "stale") return t.staleText;
467
+ if (state === "shadowed") {
468
+ return `꺼짐, 단 AGENTS.override.md 에 다른 banker 블록(${who.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않음 (그 스킬에서 on 으로 옮김)`;
469
+ }
470
+ return STATE_TEXT[state] ?? state;
471
+ }
472
+
473
+ // One status line. Read failures are reported, not thrown: status is where an
474
+ // exit 2 sends the user to look.
475
+ function describe(t, name, look) {
476
+ try {
477
+ const found = look();
478
+ return `- ${name}: ${stateText(t, found)} (${where(found.path)})`;
479
+ } catch (e) {
480
+ return `- ${name}: 읽을 수 없음 (${where(e.code || e.message)})`;
481
+ }
482
+ }
483
+
484
+ function codexLook(t, o, rules) {
485
+ if (!isDir(o.codexDir)) return { state: "설정 폴더 없음", path: o.codexDir };
486
+ const files = codexFiles(t, o);
487
+ const state = codexState(t, files, rules);
488
+ const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
489
+ return { state: shadowed ? "shadowed" : state, path: files.target.path, who: files.shadowers };
490
+ }
491
+
492
+ /** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
493
+ export function stateLines(t, o, rules) {
494
+ return [
495
+ describe(t, "Claude Code", () => {
496
+ if (!isDir(o.claudeDir)) return { state: "설정 폴더 없음", path: o.claudeDir };
497
+ return { state: claudeState(t, readFile(claudeFile(t, o)), rules), path: claudeFile(t, o) };
498
+ }),
499
+ describe(t, "Codex", () => codexLook(t, o, rules)),
500
+ ];
501
+ }
502
+
503
+ /**
504
+ * Whether a runtime holds the skill's rules in some form (current, older, in
505
+ * the wrong file or with broken markers), so that changed rules should reach
506
+ * it. Throws when a file cannot be read.
507
+ */
508
+ export function present(t, runtime, o) {
509
+ if (runtime === "claude") return isDir(o.claudeDir) && claudeState(t, readFile(claudeFile(t, o))) === "ours";
510
+ return isDir(o.codexDir) && !["off", "notutf8"].includes(codexState(t, codexFiles(t, o)));
511
+ }
512
+
513
+ const knownRuntime = (runtime) => {
514
+ if (!RUNTIMES.includes(runtime)) throw new Error(`알 수 없는 런타임입니다: ${runtime}`);
515
+ };
516
+
517
+ /** Puts the rules where one runtime loads them. getRules runs after the config folder check. */
518
+ export function turnOn(t, runtime, o, getRules) {
519
+ knownRuntime(runtime);
520
+ return runtime === "claude" ? claudeOn(t, o, getRules) : codexOn(t, o, getRules);
521
+ }
522
+
523
+ /** Takes the rules out for one runtime; it never needs them. */
524
+ export function turnOff(t, runtime, o) {
525
+ knownRuntime(runtime);
526
+ return runtime === "claude" ? claudeOff(t, o) : codexOff(t, o);
527
+ }