@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.
@@ -8,62 +8,41 @@
8
8
  *
9
9
  * The rules are the part of ../SKILL.md between the tone-compact:rules markers,
10
10
  * so the text the skill applies in the session and the text later sessions load
11
- * are the same.
12
- *
13
- * Claude Code loads every rules file under <config>/rules/ (<config> is
14
- * $CLAUDE_CONFIG_DIR or ~/.claude) at session start and again after compaction.
15
- * `on` writes banker-tone-compact.md there and `off` deletes it. A file at that
16
- * name that does not start with banker's mark belongs to someone else and is
17
- * left alone.
18
- *
19
- * Codex reads $CODEX_HOME/AGENTS.override.md when it holds any non-whitespace
20
- * text and $CODEX_HOME/AGENTS.md otherwise (CODEX_HOME defaults to ~/.codex).
21
- * `on` adds one marked block at the end of AGENTS.md, or of the override when
22
- * the override has text of its own (a block alone there would hide AGENTS.md);
23
- * `off` takes it out and gives back the bytes from before. The block sits inside
24
- * OMX's USER:OMX:POLICY markers, the only part of a generated AGENTS.md that
25
- * `omx setup --merge-agents` carries over; a plain `omx setup` with team mode on
26
- * only refreshes the model table. `omx setup --force`, confirming its overwrite
27
- * prompt, or a plain `omx setup` with team mode off replaces the file block and
28
- * all; `on` then has to run again. Text other tools keep in these files is left
29
- * alone, and the bytes from before each change are kept in
30
- * <file>.tone-compact.bak. Markers count only as whole lines that open and close
31
- * in turn; a marker quoted inside a sentence, or an end before a start, makes on
32
- * and off refuse rather than guess where the block ends. So does a file that is
33
- * not UTF-8 (CP949, UTF-16): written back as text it would lose its bytes.
11
+ * are the same. Where they go, and how they come out again byte for byte, is
12
+ * instruction-toggle.mjs beside this file, shared with anti-drm: a rules file
13
+ * under Claude Code's <config>/rules/, and one marked block in the instructions
14
+ * file Codex reads.
34
15
  *
35
16
  * `off` never needs the rules, so it works even when SKILL.md cannot be read.
36
17
  * Exit status 0 means done or already so; 1 means refused or a usage error, with
37
18
  * nothing changed; 2 means an unexpected error stopped the command partway, so
38
19
  * check `status`.
39
20
  */
40
- import {
41
- accessSync,
42
- constants,
43
- lstatSync,
44
- mkdirSync,
45
- readFileSync,
46
- realpathSync,
47
- renameSync,
48
- rmSync,
49
- statSync,
50
- writeFileSync,
51
- } from "node:fs";
21
+ import { readFileSync, realpathSync } from "node:fs";
52
22
  import { homedir } from "node:os";
53
- import { dirname, join } from "node:path";
23
+ import { join } from "node:path";
54
24
  import { fileURLToPath } from "node:url";
25
+ import {
26
+ KEEP_CLOSE,
27
+ KEEP_OPEN,
28
+ RUNTIMES,
29
+ blockSpansOf,
30
+ hasMarker,
31
+ stateLines,
32
+ toggleSpec,
33
+ turnOff as toggleOff,
34
+ turnOn as toggleOn,
35
+ where,
36
+ } from "./instruction-toggle.mjs";
55
37
 
56
38
  const RULES_START = "<!-- tone-compact:rules:start -->";
57
39
  const RULES_END = "<!-- tone-compact:rules:end -->";
58
- // The Claude Code rules file starts with this mark; it is how off and on know the file is banker's.
59
- const OWNER_MARK = "<!-- banker tone-compact";
60
- const HEADER = `${OWNER_MARK}: banker 가 관리하는 파일입니다. 끄려면 tone-compact 에 off 를 실행하세요. -->`;
61
- const OPEN = "<!-- banker:tone-compact:start -->";
62
- const CLOSE = "<!-- banker:tone-compact:end -->";
63
- // OMX keeps only these regions when `omx setup` rewrites a generated AGENTS.md.
64
- const KEEP_OPEN = "<!-- USER:OMX:POLICY:START -->";
65
- const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
66
- const MARKERS = [OPEN, CLOSE, KEEP_OPEN, KEEP_CLOSE, RULES_START, RULES_END];
40
+ const TOGGLE = toggleSpec({
41
+ slug: "tone-compact",
42
+ staleText: "켜짐, 단 규칙이 이 버전과 다름 (on 으로 갱신)",
43
+ updatedText: "규칙을 이 버전으로 바꿨습니다",
44
+ });
45
+ const MARKERS = [TOGGLE.open, TOGGLE.close, KEEP_OPEN, KEEP_CLOSE, RULES_START, RULES_END];
67
46
 
68
47
  const DEFAULTS = {
69
48
  claudeDir: process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
@@ -73,45 +52,6 @@ const DEFAULTS = {
73
52
 
74
53
  const count = (text, needle) => text.split(needle).length - 1;
75
54
  const lf = (text) => text.replace(/\r\n/g, "\n");
76
- const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
77
- const isDir = (path) => {
78
- try {
79
- return statSync(path).isDirectory();
80
- } catch {
81
- return false;
82
- }
83
- };
84
- const isLink = (path) => {
85
- try {
86
- return lstatSync(path).isSymbolicLink();
87
- } catch {
88
- return false;
89
- }
90
- };
91
-
92
- const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
93
-
94
- // A file's bytes and text, null when there is no such file. text is null when
95
- // the bytes are not UTF-8, which callers refuse: decoding them would replace
96
- // them with U+FFFD for good. Any other read failure (permissions, a folder at
97
- // that name) is not "missing" and propagates.
98
- function readFile(path) {
99
- let bytes;
100
- try {
101
- bytes = readFileSync(path);
102
- } catch (e) {
103
- if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
104
- throw e;
105
- }
106
- try {
107
- return { bytes, text: UTF8.decode(bytes) };
108
- } catch {
109
- return { bytes, text: null };
110
- }
111
- }
112
-
113
- const done = (message) => ({ ok: true, message });
114
- const refuse = (message) => ({ ok: false, message });
115
55
 
116
56
  /** The rules: the text between the markers in SKILL.md, without the line breaks around it. */
117
57
  export function rulesOf(skillText) {
@@ -122,7 +62,7 @@ export function rulesOf(skillText) {
122
62
  throw new Error("SKILL.md 에서 규칙 구간(tone-compact:rules 표시)을 찾지 못했습니다");
123
63
  }
124
64
  const rules = text.slice(start + RULES_START.length, end).replace(/^\n+|\n+$/g, "");
125
- if (rules === "" || MARKERS.some((m) => rules.includes(m))) {
65
+ if (rules === "" || MARKERS.some((m) => rules.includes(m)) || hasMarker(rules)) {
126
66
  throw new Error("SKILL.md 의 규칙 구간이 비었거나 관리 표시를 담고 있습니다");
127
67
  }
128
68
  return rules;
@@ -130,291 +70,8 @@ export function rulesOf(skillText) {
130
70
 
131
71
  const loadRules = (o) => rulesOf(readFileSync(o.skillFile, "utf8"));
132
72
 
133
- // Replaces file through a fresh temp file ("wx") and a rename: a link planted at
134
- // either name is replaced rather than written through, and a write that fails
135
- // leaves the old file as it was.
136
- function writeFresh(file, data, mode = 0o644) {
137
- const tmp = `${file}.${process.pid}.tmp`;
138
- rmSync(tmp, { force: true });
139
- try {
140
- writeFileSync(tmp, data, { flag: "wx", mode });
141
- renameSync(tmp, file);
142
- } catch (e) {
143
- rmSync(tmp, { force: true });
144
- throw e;
145
- }
146
- }
147
-
148
- const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
149
-
150
- // ---- Claude Code: one rules file that is banker's alone ----
151
-
152
- const claudeFile = (o) => join(o.claudeDir, "rules", "banker-tone-compact.md");
153
- const claudeText = (rules) => `${HEADER}\n${rules}\n`;
154
-
155
- // "off", "foreign" (not banker's file), or, when rules are given, "on" or
156
- // "stale" (an earlier version's rules); without rules a banker file is "ours".
157
- function claudeState(file, rules) {
158
- if (file === null) return "off";
159
- if (file.text === null) return "foreign";
160
- const body = lf(file.text.replace(/^\uFEFF/, ""));
161
- if (!body.startsWith(OWNER_MARK)) return "foreign";
162
- if (rules === undefined) return "ours";
163
- return body === claudeText(rules) ? "on" : "stale";
164
- }
165
-
166
- function claudeOn(o) {
167
- const file = claudeFile(o);
168
- if (!isDir(o.claudeDir)) return refuse(`Claude Code 설정 폴더가 없습니다: ${o.claudeDir}`);
169
- const rules = loadRules(o);
170
- const state = claudeState(readFile(file), rules);
171
- if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${file}`);
172
- if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${file}`);
173
- try {
174
- mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
175
- writeFresh(file, claudeText(rules));
176
- } catch (e) {
177
- if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${file}`);
178
- throw e;
179
- }
180
- return done(
181
- state === "stale"
182
- ? `Claude Code: 규칙을 이 버전으로 바꿨습니다. ${file}`
183
- : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${file}`,
184
- );
185
- }
186
-
187
- function claudeOff(o) {
188
- const file = claudeFile(o);
189
- const state = claudeState(readFile(file));
190
- if (state === "off") return done("Claude Code: 이미 꺼져 있습니다 (변경 없음).");
191
- if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${file}`);
192
- try {
193
- rmSync(file);
194
- } catch (e) {
195
- if (denied(e)) return refuse(`지울 권한이 없어 끄지 못했습니다 (${e.code}): ${file}`);
196
- throw e;
197
- }
198
- return done(`Claude Code: 껐습니다. 다음 세션부터 적용되지 않습니다. ${file}`);
199
- }
200
-
201
- // ---- Codex: one block in the global instructions file Codex reads ----
202
-
203
- const lineBare = (line) => line.replace(/\r?\n$/, "");
204
- // A line without its line break, and the first one without the byte order mark
205
- // an editor may put in front of it.
206
- const bareAt = (lines, i) => (i === 0 ? lineBare(lines[i]).replace(/^\uFEFF/, "") : lineBare(lines[i]));
207
-
208
- // One block's place in the text: [from, to) runs from its first line (the keep
209
- // marker when one wraps it directly) through the end of its last line; cut is
210
- // from minus the line break `on` puts before the block, and blankBefore says
211
- // whether that break makes a blank line of its own.
212
- function spanOf(lines, offsets, first, last) {
213
- const wrapped =
214
- first > 0 &&
215
- last + 1 < lines.length &&
216
- bareAt(lines, first - 1) === KEEP_OPEN &&
217
- bareAt(lines, last + 1) === KEEP_CLOSE;
218
- const top = wrapped ? first - 1 : first;
219
- const bottom = wrapped ? last + 1 : last;
220
- const before = top > 0 ? lines[top - 1] : "";
221
- const joint = before.endsWith("\r\n") ? 2 : before.endsWith("\n") ? 1 : 0;
222
- const blankBefore = before !== "" && lineBare(before) === "";
223
- return { cut: offsets[top] - joint, from: offsets[top], to: offsets[bottom] + lines[bottom].length, blankBefore };
224
- }
225
-
226
- /**
227
- * Where the tone-compact blocks sit in text, or null when the markers are not
228
- * whole lines that open and close in turn. on and off refuse null, so a marker
229
- * quoted inside a sentence can never make them cut the user's own text.
230
- */
231
- export function blockSpans(text) {
232
- const lines = text.split(/(?<=\n)/);
233
- const offsets = [];
234
- let at = 0;
235
- for (const line of lines) {
236
- offsets.push(at);
237
- at += line.length;
238
- }
239
- const spans = [];
240
- let open = -1;
241
- for (let i = 0; i < lines.length; i++) {
242
- const bare = bareAt(lines, i);
243
- if (bare === OPEN && open === -1) open = i;
244
- else if (bare === CLOSE && open !== -1) {
245
- spans.push(spanOf(lines, offsets, open, i));
246
- open = -1;
247
- } else if (lines[i].includes(OPEN) || lines[i].includes(CLOSE)) return null;
248
- }
249
- return open === -1 ? spans : null;
250
- }
251
-
252
- // The text without its blocks: exactly what it was before `on` added them. The
253
- // line break `on` put in front goes with the block when the block ends the file
254
- // or has a blank line of its own in front; with other text after it, the break
255
- // stays, so the lines on either side never run together.
256
- function strip(text, spans) {
257
- let out = "";
258
- let at = 0;
259
- for (const s of spans) {
260
- const keepBreak = s.to < text.length && !s.blankBefore;
261
- out += text.slice(at, keepBreak ? s.from : s.cut);
262
- at = s.to;
263
- }
264
- return out + text.slice(at);
265
- }
266
-
267
- // Whether text holds anything but whitespace, the way Codex judges a file empty
268
- // (Rust's trim, which unlike JavaScript's keeps a byte order mark).
269
- const hasContent = (text) => /[^\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/.test(text);
270
-
271
- // Both global instruction files with their bytes and text (null when absent;
272
- // text null when not UTF-8) and blocks (null when the markers do not pair up).
273
- // The block's place (target) is the override when the override has text of its
274
- // own, AGENTS.md otherwise: an override holding only the block would hide
275
- // AGENTS.md from Codex, which reads any override with non-whitespace text.
276
- function codexFiles(o) {
277
- const read = (name) => {
278
- const path = join(o.codexDir, name);
279
- const file = readFile(path);
280
- if (file === null) return { path, bytes: null, text: null, spans: [] };
281
- return { path, ...file, spans: file.text === null ? null : blockSpans(file.text) };
282
- };
283
- const override = read("AGENTS.override.md");
284
- const main = read("AGENTS.md");
285
- const ownContent =
286
- binary(override) ||
287
- (override.text !== null && hasContent(override.spans === null ? override.text : strip(override.text, override.spans)));
288
- const target = ownContent ? override : main;
289
- return { target, other: target === override ? main : override, all: [override, main] };
290
- }
291
-
292
- const blockFor = (rules, eol) => [KEEP_OPEN, OPEN, ...rules.split("\n"), CLOSE, KEEP_CLOSE, ""].join(eol);
293
- const binary = (f) => f.bytes !== null && f.text === null;
294
- const unpaired = (f) => f.spans === null;
295
- const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
296
- const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
297
- // A block's text compared without line-break style, a leading byte order mark
298
- // or the final line break, which editors add and drop.
299
- const normBlock = (text) => lf(text).replace(/^\uFEFF/, "").replace(/\n$/, "");
300
- // The block exactly as `on` writes it, and only once.
301
- const blockCurrent = (f, rules) =>
302
- f.spans.length === 1 && normBlock(f.text.slice(f.spans[0].from, f.spans[0].to)) === normBlock(blockFor(rules, "\n"));
303
-
304
- // The text with the block at the end, one line break between them; an empty
305
- // file gets the block alone. strip() undoes exactly this.
306
- function withBlock(text, rules) {
307
- const eol = eolOf(text);
308
- return text === "" ? blockFor(rules, eol) : text + eol + blockFor(rules, eol);
309
- }
310
-
311
- // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
312
- // override, so not applied), "hidden" (alone in the override, applied but hiding
313
- // AGENTS.md), "unpaired" or "notutf8". Without rules any block in its place
314
- // counts as "on".
315
- function codexState(files, rules) {
316
- if (files.all.some(binary)) return "notutf8";
317
- if (files.all.some(unpaired)) return "unpaired";
318
- const inTarget = hasBlock(files.target);
319
- const inOther = hasBlock(files.other);
320
- if (!inTarget) return inOther ? (files.other === files.all[0] ? "hidden" : "misplaced") : "off";
321
- if (rules === undefined) return "on";
322
- return blockCurrent(files.target, rules) && !inOther ? "on" : "stale";
323
- }
324
-
325
- // Why these files cannot be written, or "" when they can. The folder counts
326
- // too: the .bak is written through a temp file beside the file.
327
- function writeBlocked(changes) {
328
- for (const { file } of changes) {
329
- for (const path of file.bytes === null ? [dirname(file.path)] : [file.path, dirname(file.path)]) {
330
- try {
331
- accessSync(path, constants.W_OK);
332
- } catch (e) {
333
- return `쓸 권한이 없어 그대로 두었습니다 (${e.code || e.message}): ${path}`;
334
- }
335
- }
336
- }
337
- return "";
338
- }
339
-
340
- // Writes each file in place, so a symlinked AGENTS.md stays a link and its
341
- // target changes, after keeping the old bytes in a .bak only this account can
342
- // read (the file may be a link into a private folder). A new file gets 0644
343
- // whatever the umask allows more. A file left empty is removed, unless it is a
344
- // link: Codex skips an empty file anyway.
345
- function apply(changes) {
346
- for (const { file, after } of changes) {
347
- if (file.bytes !== null) writeFresh(`${file.path}.tone-compact.bak`, file.bytes, 0o600);
348
- if (after === "" && !isLink(file.path)) rmSync(file.path, { force: true });
349
- else writeFileSync(file.path, after, { mode: 0o644 });
350
- }
351
- }
352
-
353
- const OMX_NOTE =
354
- "참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
355
-
356
- const notUtf8Refusal = (files) => refuse(`UTF-8 이 아닌 파일이라 그대로 두었습니다: ${files.all.find(binary).path}`);
357
-
358
- const unpairedRefusal = (files) =>
359
- refuse(`tone-compact 표시의 짝이 맞지 않아 그대로 두었습니다: ${files.all.find(unpaired).path}`);
360
-
361
- function codexOn(o) {
362
- if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${o.codexDir}`);
363
- const rules = loadRules(o);
364
- const files = codexFiles(o);
365
- const state = codexState(files, rules);
366
- if (state === "notutf8") return notUtf8Refusal(files);
367
- if (state === "unpaired") return unpairedRefusal(files);
368
- if (state === "on") return done(`Codex: 이미 켜져 있습니다 (변경 없음). ${files.target.path}`);
369
- const changes = [{ file: files.target, after: withBlock(stripped(files.target), rules) }];
370
- if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
371
- const why = writeBlocked(changes);
372
- if (why) return refuse(why);
373
- apply(changes);
374
- const moved = "블록을 맞는 파일로 옮겼습니다";
375
- const what = { stale: "규칙을 이 버전으로 바꿨습니다", misplaced: moved, hidden: moved }[state];
376
- const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
377
- return done(`Codex: ${what ?? "켰습니다. 다음 세션부터 자동 적용됩니다"}. ${files.target.path}${note}`);
378
- }
379
-
380
- function codexOff(o) {
381
- if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${o.codexDir}`);
382
- const files = codexFiles(o);
383
- const state = codexState(files);
384
- if (state === "notutf8") return notUtf8Refusal(files);
385
- if (state === "unpaired") return unpairedRefusal(files);
386
- const changes = files.all.filter(hasBlock).map((f) => ({ file: f, after: stripped(f) }));
387
- if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
388
- const why = writeBlocked(changes);
389
- if (why) return refuse(why);
390
- apply(changes);
391
- return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${changes.map((c) => c.file.path).join(", ")}`);
392
- }
393
-
394
- // ---- status ----
395
-
396
- const STATE_TEXT = {
397
- on: "켜짐",
398
- ours: "켜짐 (규칙 버전 확인 불가)",
399
- stale: "켜짐, 단 규칙이 이 버전과 다름 (on 으로 갱신)",
400
- off: "꺼짐",
401
- foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
402
- misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
403
- hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
404
- notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
405
- unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
406
- };
407
-
408
- // One status line per runtime. Read failures are reported, not thrown: status
409
- // is where an exit 2 sends the user to look.
410
- function describe(name, look) {
411
- try {
412
- const { state, path } = look();
413
- return `- ${name}: ${STATE_TEXT[state] ?? state} (${path})`;
414
- } catch (e) {
415
- return `- ${name}: 읽을 수 없음 (${e.code || e.message})`;
416
- }
417
- }
73
+ /** Where the tone-compact blocks sit in text, or null when the markers do not pair up. */
74
+ export const blockSpans = (text) => blockSpansOf(text, TOGGLE.open, TOGGLE.close);
418
75
 
419
76
  export function status(opts = {}) {
420
77
  const o = { ...DEFAULTS, ...opts };
@@ -423,29 +80,17 @@ export function status(opts = {}) {
423
80
  try {
424
81
  rules = loadRules(o);
425
82
  } catch (e) {
426
- lines.push(`- 규칙 원문을 읽지 못해 버전은 비교하지 않음 (${e.code || e.message})`);
83
+ lines.push(`- 규칙 원문을 읽지 못해 버전은 비교하지 않음 (${where(e.code || e.message)})`);
427
84
  }
428
- lines.push(
429
- describe("Claude Code", () => {
430
- if (!isDir(o.claudeDir)) return { state: "설정 폴더 없음", path: o.claudeDir };
431
- return { state: claudeState(readFile(claudeFile(o)), rules), path: claudeFile(o) };
432
- }),
433
- describe("Codex", () => {
434
- if (!isDir(o.codexDir)) return { state: "설정 폴더 없음", path: o.codexDir };
435
- const files = codexFiles(o);
436
- return { state: codexState(files, rules), path: files.target.path };
437
- }),
438
- );
439
- return done(lines.join("\n"));
85
+ lines.push(...stateLines(TOGGLE, o, rules));
86
+ return { ok: true, message: lines.join("\n") };
440
87
  }
441
88
 
442
- const ACTIONS = new Map([
443
- ["claude", { on: claudeOn, off: claudeOff }],
444
- ["codex", { on: codexOn, off: codexOff }],
445
- ]);
446
-
447
- export const turnOn = (runtime, opts = {}) => ACTIONS.get(runtime).on({ ...DEFAULTS, ...opts });
448
- export const turnOff = (runtime, opts = {}) => ACTIONS.get(runtime).off({ ...DEFAULTS, ...opts });
89
+ export const turnOn = (runtime, opts = {}) => {
90
+ const o = { ...DEFAULTS, ...opts };
91
+ return toggleOn(TOGGLE, runtime, o, () => loadRules(o));
92
+ };
93
+ export const turnOff = (runtime, opts = {}) => toggleOff(TOGGLE, runtime, { ...DEFAULTS, ...opts });
449
94
 
450
95
  // { command, runtime, extra } from the command line; --runtime takes "x" or "=x".
451
96
  export function parseArgs(argv) {
@@ -465,14 +110,21 @@ const USAGE = "사용법: tone-compact on | off | status [--runtime claude|codex
465
110
  // The runtime on and off act on: the flag, else Claude Code when it runs this.
466
111
  const runtimeOf = (flag, env) => flag ?? (env.CLAUDECODE === "1" ? "claude" : null);
467
112
 
113
+ // Why the command line cannot run as given, or "" when it can.
114
+ function usageError(command, flag, extra) {
115
+ if (extra.length > 0 || !["on", "off", "status"].includes(command)) return USAGE;
116
+ return flag !== null && !RUNTIMES.includes(flag) ? USAGE : "";
117
+ }
118
+
468
119
  function main(argv, env = process.env) {
469
120
  const { command, runtime: flag, extra } = parseArgs(argv);
470
- if (extra.length > 0 || !["on", "off", "status"].includes(command) || (flag !== null && !ACTIONS.has(flag))) {
471
- process.stdout.write(`${USAGE}\n`);
121
+ const why = usageError(command, flag, extra);
122
+ if (why) {
123
+ process.stdout.write(`${why}\n`);
472
124
  return 1;
473
125
  }
474
126
  const runtime = runtimeOf(flag, env);
475
- if (command !== "status" && !ACTIONS.has(runtime)) {
127
+ if (command !== "status" && !RUNTIMES.includes(runtime)) {
476
128
  process.stdout.write(`런타임을 정할 수 없습니다. --runtime claude 또는 --runtime codex 를 붙이세요.\n${USAGE}\n`);
477
129
  return 1;
478
130
  }
@@ -481,7 +133,7 @@ function main(argv, env = process.env) {
481
133
  process.stdout.write(`${message}\n`);
482
134
  return ok ? 0 : 1;
483
135
  } catch (e) {
484
- process.stdout.write(`예기치 못한 오류로 중단했습니다: ${e.message}\nstatus 로 상태를 확인하세요.\n`);
136
+ process.stdout.write(`예기치 못한 오류로 중단했습니다: ${where(e.message)}\nstatus 로 상태를 확인하세요.\n`);
485
137
  return 2;
486
138
  }
487
139
  }
@@ -283,7 +283,7 @@ ralplan 은 계획 작성자 외에 Architect 와 Critic 두 검토자로 합의
283
283
 
284
284
  ## 5. 구현
285
285
 
286
- 1. 기준선: 바꾸기 전에 시험 명령을 한 번 돌려 이미 실패하는 항목을 `<run>/05-baseline.txt` 에 남긴다. 0단계에서 기존 변경을 두고 진행했으면 지금 트리를 기준선으로 쓴다.
286
+ 1. 기준선: 바꾸기 전에 시험 명령을 한 번 돌려 이미 실패하는 항목을 `<run>/05-baseline.md` 에 남긴다. 0단계에서 기존 변경을 두고 진행했으면 지금 트리를 기준선으로 쓴다.
287
287
  - 깨끗한 사본이 필요하면 저장소 밖 임시 폴더에 `git worktree add --detach <임시> HEAD` 로 만든다. 의존성을 링크하거나 설치한 뒤 돌리고, 명령이 실제로 돌았는지 확인한다. "명령 없음" 오류뿐인 기록은 기준선이 아니다. 끝나면 `git worktree remove --force <임시>` 로 지운다.
288
288
  - `git stash`, `git reset` 은 쓰지 않는다.
289
289
  2. Claude Code: ralph 를 `--critic=critic` 으로 부른다. 인자에 아래를 넘긴다.
@@ -322,7 +322,7 @@ ralph-qa 의 검토자는 시험을 직접 돌리지 않는다. 연쇄가 이
322
322
  1. 이번 변경의 새 파일을 스테이징한다(`git add -- <파일>...`, 커밋은 하지 않음). 박스로 보내는 트리는 추적 파일만 담는다. 스테이징할 목록을 먼저 보인다.
323
323
  - `.omc/`, `.omx/` 아래, `.env` 류, 키와 자격 증명 파일은 넣지 않는다.
324
324
  - 넣을 텍스트 파일은 `<scan>` 으로 검사한다. 걸린 파일은 넣지 않고 사용자에게 알린다. 그 파일이 필요한 시험은 박스에서 미검증이다.
325
- 2. 이 머신에서 시험, 빌드, 린트를 돌려 원문 출력을 `<run>/qa/local-r<k>.txt` 에 남긴다. 결과를 `tests` 에 기록한다(`where: "local"`, `os`, `status`, 셀 수 있으면 `tests`, `pass`, `fail`, `skipped`, `log: "qa/local-r<k>.txt"`).
325
+ 2. 이 머신에서 시험, 빌드, 린트를 돌려 원문 출력을 `<run>/qa/local-r<k>.md` 에 남긴다. 결과를 `tests` 에 기록한다(`where: "local"`, `os`, `status`, 셀 수 있으면 `tests`, `pass`, `fail`, `skipped`, `log: "qa/local-r<k>.md"`).
326
326
  3. 박스에서 같은 시험을 돌린다. 고른 박스마다 `--box <이름>` 을 붙인다. 오래 도는 명령으로 돌리고, 끝난 뒤에 기록한다.
327
327
 
328
328
  ```bash
@@ -341,7 +341,7 @@ ralph-qa 의 검토자는 시험을 직접 돌리지 않는다. 연쇄가 이
341
341
  - 한 OS 에서만 실패하면 원인을 나눈다. 제품 결함은 고치고, 시험이 실행 환경을 잘못 가정한 것도 고친다.
342
342
  - `--boxes=none` 이면 이 3을 건너뛴다.
343
343
  4. ralph-qa 를 `--lens=both --agents=<agents> --max=<max>` 로 부른다. 외부 좌석은 3단계에서 고른 모델을 값으로 넘긴다.
344
- - 입력: 요청 원문, `02-spec.md` 의 수용 기준과 사용자 답변 원문, `03-plan.md`, 승인된 목업과 기대 출력, 2의 원문 출력, 3의 박스 JSON 과 실패한 박스의 로그 발췌, `05-baseline.txt`. diff 의 기준은 `<base>` 다
344
+ - 입력: 요청 원문, `02-spec.md` 의 수용 기준과 사용자 답변 원문, `03-plan.md`, 승인된 목업과 기대 출력, 2의 원문 출력, 3의 박스 JSON 과 실패한 박스의 로그 발췌, `05-baseline.md`. diff 의 기준은 `<base>` 다
345
345
  - 3단계와 같이 모든 좌석의 지시에 적대적 검토 문장을 넣는다
346
346
  - 회차마다 `note` 로 기록하고, 끝나면 보고 전문을 `<run>/qa/ralph-qa-r<k>.md` 로 저장한다.
347
347
  - `ITERATE` 면 고친 뒤 1부터 3까지 다시 하고(새 `<k>`), 같은 실행에서 다시 판정받는다. 끈끈한 반대는 그 검토자의 재판정으로만 풀린다.
@@ -355,7 +355,7 @@ ralph-qa 의 검토자는 시험을 직접 돌리지 않는다. 연쇄가 이
355
355
  - 임시 데이터 폴더나 임시 HOME 에서 만든다. 사용자의 실제 데이터에 쓰지 않는다.
356
356
  - 색을 끈다(`NO_COLOR=1`). 종료 코드도 대조 대상이다.
357
357
  - `mock/expected/` 와 대조한다. JSON 은 파싱해 깊게 비교하고, 텍스트는 바이트로 비교한다(줄 끝 포함). `.ignore.json` 에 적힌 칸은 뺀다.
358
- - 결과를 보기마다 `tests` 하나로 `where: "compare:<보기>"` 에 기록한다. `tests` 는 그 보기에서 대조한 파일 수, `pass` 는 같은 파일 수, `fail` 은 다른 파일 수다. `log` 는 파일마다 `same <이름>` 이나 `DIFF <이름>` 한 줄을 남긴 대조 출력(`visual/compare-r<k>.txt`, `<k>` 는 마지막 `qa` 회차)이다. 로그만 보고도 수를 다시 셀 수 있어야 한다.
358
+ - 결과를 보기마다 `tests` 하나로 `where: "compare:<보기>"` 에 기록한다. `tests` 는 그 보기에서 대조한 파일 수, `pass` 는 같은 파일 수, `fail` 은 다른 파일 수다. `log` 는 파일마다 `same <이름>` 이나 `DIFF <이름>` 한 줄을 남긴 대조 출력(`visual/compare-r<k>.md`, `<k>` 는 마지막 `qa` 회차)이다. 로그만 보고도 수를 다시 셀 수 있어야 한다.
359
359
  2. 캡처: 목업 보기와 실제 결과를 같은 크기로 찍는다. CLI 와 라이브러리는 실제 출력을 escape 해 목업과 같은 HTML 틀에 담아 `<run>/actual/<view>.html` 로 만든 뒤 찍는다. 앱 서버가 필요하면 시험 데이터와 임시 HOME 으로(실제 `.env`, 실제 DB 없이) 오래 도는 명령으로 띄우고, 캡처가 끝나면 그 작업을 멈춘다.
360
360
 
361
361
  ```bash
@@ -429,9 +429,10 @@ node "<S>/references/chain-report.mjs" "<run>"
429
429
  5. 회차는 `maxRound` 다음 번호로 이어 센다. 앞 회차 파일을 덮어쓰지 않는다. `--max` 는 남은 횟수만 쓴다.
430
430
  6. 판정이 없는 회차(그 번호의 시험이나 박스 기록은 있는데 같은 번호의 판정 기록이 없음)는 새 번호로, 지금 트리로 시험부터 다시 한다.
431
431
  7. 5단계에서 이을 때
432
- - `05-baseline.txt` 는 다시 쓰지 않는다. 이미 바뀐 트리로 쓰면 이번 변경이 만든 실패가 기존 실패로 바뀐다.
432
+ - `05-baseline.md` 는 다시 쓰지 않는다. 이미 바뀐 트리로 쓰면 이번 변경이 만든 실패가 기존 실패로 바뀐다.
433
+ - 이 판보다 먼저 시작한 실행 폴더에는 기준선이 `05-baseline.txt` 로 있다. `05-baseline.md` 가 없으면 그 파일을 기준선으로 쓴다.
433
434
  - 같은 세션에서 ralph 가 아직 켜져 있으면 다시 부르지 않고 끝나기를 기다린다.
434
- - 새 세션에서 ralph 를 다시 부르면 ralph 는 지금 트리로 기준선을 다시 기록한다. 이 사실을 `build` 의 `note` 에 적고, 새 실패의 판정은 `05-baseline.txt` 기준으로 한다.
435
+ - 새 세션에서 ralph 를 다시 부르면 ralph 는 지금 트리로 기준선을 다시 기록한다. 이 사실을 `build` 의 `note` 에 적고, 새 실패의 판정은 `05-baseline.md` 기준으로 한다.
435
436
 
436
437
  ## 플래그
437
438
 
@@ -116,7 +116,8 @@ PATH 의 banker : 0.2.0 (구버전)
116
116
  claude plugin update banker@banker-plugins
117
117
  ```
118
118
 
119
- - 옵션은 `-s/--scope`(`user`|`project`|`local`|`managed`, 기본 `user`)와 `-h/--help` 뿐이다. help 문구는 "Update a plugin to the latest version (restart required to apply)".
119
+ - Claude Code 2.1.296 의 옵션은 `-s/--scope`(`user`|`project`|`local`|`managed`, 기본 auto-detect), `--json`(사람용 문구 대신 기계용 결과 한 줄), `-y/--yes`, `--accept-command <sha256>`, `-h/--help` 다. help 문구는 "Update a plugin to the latest version (restart required to apply)".
120
+ - `-y` 와 `--accept-command` 는 마켓플레이스가 선언한 설치 명령(command 원천이나 headersHelper)을 승인할 때만 쓴다. banker 마켓플레이스는 원천이 저장소 폴더(`"source": "."`)라 선언한 명령이 없어 둘 다 필요 없다.
120
121
  - **옵션은 문서가 아니라 live `--help` 가 정본이다.** 공식문서의 옵션표는 실제 CLI 보다 뒤처져 있다(`plugin install` 의 `--config` 누락 확인). 옵션을 쓰기 전에 `claude plugin update --help` 로 확인하라.
121
122
  - `<plugin>` 은 bare name(`banker`) 또는 `plugin@marketplace`(`banker@banker-plugins`) 둘 다 받는다. 마켓플레이스가 여럿이면 후자로 특정한다.
122
123
  - **반영에는 재시작이 필요하다**(help 가 명시). 재시작 전 관측이 옛 값인 것은 실패가 아니다.