@kaydash9999/banker-plugins 0.18.0 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +52 -0
- package/README.md +18 -7
- package/codex/manifest.json +1 -0
- package/codex/transform-matrix.md +1 -0
- package/hooks/graceful-pause.mjs +4 -2
- package/hooks/obsidize-hook.mjs +38 -28
- package/hooks/progress-plan.mjs +14 -8
- package/hooks/progress.mjs +174 -47
- package/hooks/register.mjs +14 -7
- package/hooks/update-checkin.mjs +29 -27
- package/package.json +1 -1
- package/skills/anti-drm/SKILL.md +36 -12
- package/skills/anti-drm/scripts/anti-drm.mjs +121 -30
- package/skills/anti-drm/scripts/instruction-toggle.mjs +241 -35
- package/skills/lineage/SKILL.md +1 -1
- package/skills/lineage/lineage.py +29 -2
- package/skills/omc-patch/SKILL.md +18 -6
- package/skills/omc-patch/scripts/omc-patch.mjs +400 -87
- package/skills/theme-selection/SKILL.md +165 -0
- package/skills/theme-selection/assets/LICENSE +21 -0
- package/skills/theme-selection/assets/NOTICE.md +30 -0
- package/skills/theme-selection/assets/palettes.json +81 -0
- package/skills/theme-selection/references/contrast.md +138 -0
- package/skills/theme-selection/references/integrate.md +161 -0
- package/skills/theme-selection/scripts/themes.mjs +1067 -0
- package/skills/tone-compact/SKILL.md +8 -4
- package/skills/tone-compact/scripts/instruction-toggle.mjs +241 -35
- package/skills/tone-compact/scripts/tone-compact.mjs +2 -3
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -33,17 +33,28 @@
|
|
|
33
33
|
* open and close in turn; a marker quoted inside a sentence, or an end before a
|
|
34
34
|
* start, makes turnOn and turnOff refuse rather than guess where the block ends.
|
|
35
35
|
* So does a file that is not UTF-8 (CP949, UTF-16): written back as text it
|
|
36
|
-
* would lose its bytes.
|
|
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.
|
|
37
44
|
*
|
|
38
45
|
* Results are { ok, message }; ok false means refused with nothing changed.
|
|
39
46
|
* Anything else that goes wrong throws.
|
|
40
47
|
*/
|
|
41
48
|
import {
|
|
42
49
|
accessSync,
|
|
50
|
+
closeSync,
|
|
43
51
|
constants,
|
|
52
|
+
fstatSync,
|
|
44
53
|
lstatSync,
|
|
54
|
+
openSync,
|
|
45
55
|
mkdirSync,
|
|
46
56
|
readFileSync,
|
|
57
|
+
realpathSync,
|
|
47
58
|
renameSync,
|
|
48
59
|
rmSync,
|
|
49
60
|
statSync,
|
|
@@ -60,8 +71,12 @@ export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
|
|
|
60
71
|
|
|
61
72
|
// The byte order mark an editor may put in front of a file.
|
|
62
73
|
const BOM = String.fromCharCode(0xfeff);
|
|
63
|
-
|
|
64
|
-
const
|
|
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;
|
|
65
80
|
const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
|
|
66
81
|
const isDir = (path) => {
|
|
67
82
|
try {
|
|
@@ -80,27 +95,61 @@ const isLink = (path) => {
|
|
|
80
95
|
|
|
81
96
|
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
82
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
|
+
|
|
83
124
|
// A file's bytes and text, null when there is no such file. text is null when
|
|
84
125
|
// the bytes are not UTF-8, which callers refuse: decoding them would replace
|
|
85
|
-
// them with U+FFFD for good.
|
|
86
|
-
//
|
|
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.
|
|
87
129
|
function readFile(path) {
|
|
88
|
-
let
|
|
130
|
+
let fd;
|
|
89
131
|
try {
|
|
90
|
-
|
|
132
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
91
133
|
} catch (e) {
|
|
92
|
-
|
|
93
|
-
throw e;
|
|
134
|
+
return openFailure(path, e);
|
|
94
135
|
}
|
|
95
136
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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);
|
|
99
146
|
}
|
|
100
147
|
}
|
|
101
148
|
|
|
102
|
-
|
|
103
|
-
const
|
|
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 });
|
|
104
153
|
|
|
105
154
|
/** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
|
|
106
155
|
export function markersOf(slug) {
|
|
@@ -144,9 +193,14 @@ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code
|
|
|
144
193
|
* paragraph separator, private-use, unassigned and variation selector
|
|
145
194
|
* characters become "?": a path from the environment cannot start a line of
|
|
146
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.
|
|
147
198
|
*/
|
|
148
199
|
export const where = (value) =>
|
|
149
|
-
String(value).replace(
|
|
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
|
+
);
|
|
150
204
|
|
|
151
205
|
// ---- Claude Code: one rules file that is banker's alone ----
|
|
152
206
|
|
|
@@ -156,7 +210,7 @@ const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
|
|
|
156
210
|
// "off", "foreign" (not banker's file), or, when rules are given, "on" or
|
|
157
211
|
// "stale" (other rules); without rules a banker file is "ours".
|
|
158
212
|
function claudeState(t, file, rules) {
|
|
159
|
-
if (file === null) return "off";
|
|
213
|
+
if (file === null || file.dangling) return "off";
|
|
160
214
|
if (file.text === null) return "foreign";
|
|
161
215
|
const body = lf(noBom(file.text));
|
|
162
216
|
if (!body.startsWith(t.ownerMark)) return "foreign";
|
|
@@ -317,25 +371,48 @@ const otherBlocks = (t, text) =>
|
|
|
317
371
|
return spans !== null && spans.length > 0;
|
|
318
372
|
});
|
|
319
373
|
|
|
374
|
+
const special = (f) => f.special === true;
|
|
320
375
|
const binary = (f) => f.bytes !== null && f.text === null;
|
|
321
376
|
const unpaired = (f) => f.spans === null;
|
|
322
377
|
const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
|
|
323
378
|
const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
|
|
324
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
|
+
|
|
325
394
|
// Both global instruction files with their bytes and text (null when absent;
|
|
326
395
|
// text null when not UTF-8) and blocks (null when the markers do not pair up).
|
|
327
396
|
// The block's place (target) is the override when the override has text of its
|
|
328
397
|
// own, AGENTS.md otherwise. shadowers names the other banker skills whose
|
|
329
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.
|
|
330
400
|
function codexFiles(t, o) {
|
|
331
401
|
const read = (name) => {
|
|
332
402
|
const path = join(o.codexDir, name);
|
|
333
403
|
const file = readFile(path);
|
|
334
|
-
if (file === null
|
|
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
|
+
}
|
|
335
407
|
return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
|
|
336
408
|
};
|
|
337
409
|
const override = read("AGENTS.override.md");
|
|
338
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
|
+
}
|
|
339
416
|
const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
|
|
340
417
|
const target = ownContent ? override : main;
|
|
341
418
|
const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
|
|
@@ -357,18 +434,26 @@ function withBlock(t, text, rules) {
|
|
|
357
434
|
return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
|
|
358
435
|
}
|
|
359
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
|
+
|
|
360
445
|
// "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
|
|
361
446
|
// override, so not applied), "hidden" (in the override with no text of its own,
|
|
362
|
-
// applied but hiding AGENTS.md), "
|
|
363
|
-
//
|
|
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".
|
|
364
450
|
function codexState(t, files, rules) {
|
|
451
|
+
if (files.all.some(special)) return "notregular";
|
|
365
452
|
if (files.all.some(binary)) return "notutf8";
|
|
366
453
|
if (files.all.some(unpaired)) return "unpaired";
|
|
367
|
-
|
|
368
|
-
const inOther = hasBlock(files.other);
|
|
369
|
-
if (!inTarget) return inOther ? (files.other === files.all[0] ? "hidden" : "misplaced") : "off";
|
|
454
|
+
if (!hasBlock(files.target)) return placeState(files);
|
|
370
455
|
if (rules === undefined) return "on";
|
|
371
|
-
return blockCurrent(t, files.target, rules) && !
|
|
456
|
+
return blockCurrent(t, files.target, rules) && !hasBlock(files.other) ? "on" : "stale";
|
|
372
457
|
}
|
|
373
458
|
|
|
374
459
|
// Why these files cannot be written, or "" when they can. The folder counts
|
|
@@ -390,15 +475,103 @@ function writeBlocked(changes) {
|
|
|
390
475
|
// target changes, after keeping the old bytes in a .bak only this account can
|
|
391
476
|
// read (the file may be a link into a private folder). A new file gets 0644
|
|
392
477
|
// whatever the umask allows more. A file left empty is removed, unless it is a
|
|
393
|
-
// link
|
|
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.
|
|
394
480
|
function apply(t, changes) {
|
|
395
481
|
for (const { file, after } of changes) {
|
|
396
482
|
if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
|
|
397
|
-
if (after === "" && !isLink(file.path)) rmSync(file.path, { force: true });
|
|
483
|
+
if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
|
|
398
484
|
else writeFileSync(file.path, after, { mode: 0o644 });
|
|
399
485
|
}
|
|
400
486
|
}
|
|
401
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
|
+
|
|
402
575
|
const OMX_NOTE =
|
|
403
576
|
"참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
|
|
404
577
|
|
|
@@ -408,35 +581,64 @@ const shadowNote = (files) =>
|
|
|
408
581
|
? ""
|
|
409
582
|
: `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
|
|
410
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
|
+
|
|
411
591
|
// The refusal for files on and off must not touch, or null.
|
|
412
592
|
function codexRefusal(t, files, state) {
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
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] ?? "켰습니다. 다음 세션부터 자동 적용됩니다";
|
|
416
610
|
}
|
|
417
611
|
|
|
418
612
|
function codexOn(t, o, getRules) {
|
|
419
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) {
|
|
420
618
|
const rules = getRules();
|
|
421
619
|
const files = codexFiles(t, o);
|
|
422
620
|
const state = codexState(t, files, rules);
|
|
423
621
|
const refusal = codexRefusal(t, files, state);
|
|
424
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);
|
|
425
625
|
const tail = `${where(files.target.path)}${shadowNote(files)}`;
|
|
426
|
-
if (state === "on") return done(`Codex:
|
|
626
|
+
if (state === "on") return done(`Codex: ${words}. ${tail}`);
|
|
427
627
|
const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
|
|
428
628
|
if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
|
|
429
629
|
const why = writeBlocked(changes);
|
|
430
630
|
if (why) return refuse(why);
|
|
431
631
|
apply(t, changes);
|
|
432
|
-
const moved = "블록을 맞는 파일로 옮겼습니다";
|
|
433
|
-
const what = { stale: t.updatedText, misplaced: moved, hidden: moved }[state];
|
|
434
632
|
const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
|
|
435
|
-
return done(`Codex: ${
|
|
633
|
+
return done(`Codex: ${words}. ${tail}${note}`);
|
|
436
634
|
}
|
|
437
635
|
|
|
438
636
|
function codexOff(t, o) {
|
|
439
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) {
|
|
440
642
|
const files = codexFiles(t, o);
|
|
441
643
|
const refusal = codexRefusal(t, files, codexState(t, files));
|
|
442
644
|
if (refusal) return refusal;
|
|
@@ -445,7 +647,9 @@ function codexOff(t, o) {
|
|
|
445
647
|
const why = writeBlocked(changes);
|
|
446
648
|
if (why) return refuse(why);
|
|
447
649
|
apply(t, changes);
|
|
448
|
-
|
|
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}`);
|
|
449
653
|
}
|
|
450
654
|
|
|
451
655
|
// ---- status ----
|
|
@@ -458,7 +662,9 @@ const STATE_TEXT = {
|
|
|
458
662
|
misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
|
|
459
663
|
hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
|
|
460
664
|
notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
|
|
665
|
+
notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
|
|
461
666
|
unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
|
|
667
|
+
dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
|
|
462
668
|
};
|
|
463
669
|
|
|
464
670
|
// A state as the status line says it.
|
|
@@ -486,7 +692,7 @@ function codexLook(t, o, rules) {
|
|
|
486
692
|
const files = codexFiles(t, o);
|
|
487
693
|
const state = codexState(t, files, rules);
|
|
488
694
|
const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
|
|
489
|
-
return { state: shadowed ? "shadowed" : state, path: files.target.path, who: files.shadowers };
|
|
695
|
+
return { state: shadowed ? "shadowed" : state, path: (problemFile(files, state) ?? files.target).path, who: files.shadowers };
|
|
490
696
|
}
|
|
491
697
|
|
|
492
698
|
/** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
|
|
@@ -507,7 +713,7 @@ export function stateLines(t, o, rules) {
|
|
|
507
713
|
*/
|
|
508
714
|
export function present(t, runtime, o) {
|
|
509
715
|
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)));
|
|
716
|
+
return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
|
|
511
717
|
}
|
|
512
718
|
|
|
513
719
|
const knownRuntime = (runtime) => {
|
package/skills/lineage/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: lineage
|
|
3
3
|
description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
|
|
4
4
|
invocation: /lineage
|
|
5
|
-
version: 3.0.
|
|
5
|
+
version: 3.0.5
|
|
6
6
|
schema_version: 1
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -20,6 +20,7 @@ import pathlib
|
|
|
20
20
|
import re
|
|
21
21
|
import sys
|
|
22
22
|
import tempfile
|
|
23
|
+
import unicodedata
|
|
23
24
|
from html.parser import HTMLParser
|
|
24
25
|
|
|
25
26
|
if sys.version_info < (3, 7):
|
|
@@ -734,14 +735,40 @@ def _page_clean_turn(t, extra, mode):
|
|
|
734
735
|
return dict(t, text=clean(t["text"]), parts=[clean(p) for p in t.get("parts") or []])
|
|
735
736
|
|
|
736
737
|
|
|
738
|
+
# Letters that print as nothing: the Hangul fillers, the braille blank, and the combining marks Unicode ignores by
|
|
739
|
+
# default (the grapheme joiner, the Khmer inherent vowels, the Mongolian free variation selectors). With the control,
|
|
740
|
+
# format, private-use, unassigned and separator categories, a cache made only of them holds no summary a person can
|
|
741
|
+
# read (3.0.5).
|
|
742
|
+
_BLANK_LETTERS = frozenset(map(chr, (0x115F, 0x1160, 0x3164, 0xFFA0, 0x2800,
|
|
743
|
+
0x034F, 0x17B4, 0x17B5, 0x180B, 0x180C, 0x180D, 0x180F)))
|
|
744
|
+
_BLANK_CATEGORIES = frozenset(("Cc", "Cf", "Co", "Cn", "Zs", "Zl", "Zp"))
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _blank(ch):
|
|
748
|
+
"""Whether ch prints as nothing: whitespace, a blank-looking letter, or a variation selector."""
|
|
749
|
+
if ch.isspace() or ch in _BLANK_LETTERS or unicodedata.category(ch) in _BLANK_CATEGORIES:
|
|
750
|
+
return True
|
|
751
|
+
o = ord(ch)
|
|
752
|
+
return 0xFE00 <= o <= 0xFE0F or 0xE0100 <= o <= 0xE01EF
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _has_visible(text):
|
|
756
|
+
"""Whether text holds a letter a person can see."""
|
|
757
|
+
return not all(_blank(ch) for ch in text)
|
|
758
|
+
|
|
759
|
+
|
|
737
760
|
def _read_cached(p):
|
|
738
761
|
"""The summary cached at p, else at the same name with .txt, which 3.0.2 and earlier wrote.
|
|
739
|
-
None when neither
|
|
762
|
+
None when neither holds one: missing, bytes that are not UTF-8 (a DRM encrypted them), or
|
|
763
|
+
nothing a person can see (a write that stopped early, or only blank-looking letters); the
|
|
764
|
+
turn is then summarized again (3.0.4, 3.0.5)."""
|
|
740
765
|
for cached in (p, p.with_name(p.stem + ".txt")):
|
|
741
766
|
try:
|
|
742
|
-
|
|
767
|
+
text = cached.read_text(encoding="utf-8").strip()
|
|
743
768
|
except (OSError, UnicodeDecodeError):
|
|
744
769
|
continue
|
|
770
|
+
if _has_visible(text):
|
|
771
|
+
return text
|
|
745
772
|
return None
|
|
746
773
|
|
|
747
774
|
|
|
@@ -41,11 +41,11 @@ OMC(oh-my-claudecode)가 새 버전으로 자동 업데이트되면 로컬 훅
|
|
|
41
41
|
- `--root` 는 폴더의 이름표가 OMC 가 아니면 `OMC 플러그인 폴더가 아님` 으로 멈춘다. `.claude-plugin/plugin.json` 의 name 이 있으면 그것으로, 없으면 `package.json` 의 name 으로 정한다. 다른 폴더를 잘못 준 실수를 막는 확인이고, 이름표가 없는 폴더는 통과한다.
|
|
42
42
|
- 쓸 파일이 링크로 플러그인 폴더 밖을 가리키거나 백업 자리(`.omcbak`)가 링크면 도구는 쓰지 않고 실패로 센다. 문구는 적용 때 `쓰지 않음`, 되돌리기 때 `되돌리지 않음`, hooks.json 은 `hooks.json 을 건드리지 않음` 이다.
|
|
43
43
|
- 패치 규칙은 OMC 훅 코드의 모양에 맞춰져 있다. 처음 보는 OMC 버전이면 반드시 `check` 를 먼저 실행한다.
|
|
44
|
-
- `실패 N건` 이 1건 이상이면 멈추고 출력을 그대로 보고한다. 문법 오류가 난 파일은 도구가 원본으로 자동 롤백한다.
|
|
44
|
+
- `실패 N건` 이 1건 이상이면 멈추고 출력을 그대로 보고한다. 문법 오류가 난 파일은 도구가 원본으로 자동 롤백한다. 고친 hooks.json 이 다시 읽어 JSON 이 아니어도 원본으로 되돌리고(`hooks.json 손상으로 롤백`) 실패로 센다.
|
|
45
45
|
- 판정은 출력 문구로 한다. 종료 코드 0 만으로 완료라고 보고하지 않는다.
|
|
46
46
|
- 캐시에는 정션이나 심볼릭 링크로 이어진 버전 폴더가 있을 수 있다(예: 옛 버전 폴더가 새 버전을 가리킴). 이때 한 번의 변경이 두 버전 폴더에 함께 보인다. 정상이다.
|
|
47
47
|
- 도구는 `os.homedir()/.claude` 를 쓴다. `CLAUDE_CONFIG_DIR` 로 설정 폴더를 옮겼다면 이 도구는 그 폴더를 보지 않는다.
|
|
48
|
-
- 출력의 origin 에 `https://사용자:토큰@...` 처럼 자격증명이 들어 있으면, 보고할 때 `//***@` 로 가린다. 출력을 그대로 보고하라는 단계에서도 이 가림은 지킨다. 그 URL
|
|
48
|
+
- 출력의 origin 에 `https://사용자:토큰@...` 처럼 자격증명이 들어 있으면, 보고할 때 `//***@` 로 가린다. 출력을 그대로 보고하라는 단계에서도 이 가림은 지킨다. 옛 판의 도구가 만든 `PINNED` 에는 그 URL 이 남아 있을 수 있다. `--check` 는 그 칸을 `[!] 고정 기록(PINNED)에 옛 URL 칸이 남아 있음 (적용하면 지움)` 으로 알리고 모두 적용된 상태로 보지 않는다. 적용(`--check` 가 아닌 실행)은 그 칸을 지우고 권한을 0600 으로 맞춘다. 이 도구가 지우지 못하면(기록이 링크, JSON 이 아님, 쓰기 실패, 읽지 못함) `[!] 고정 기록(PINNED)의 옛 URL 칸을 ...` 줄로 알리고, 적용과 되돌리기는 실패로 센다(종료 1). 링크는 따라가 쓰지 않는다. 이때는 그 줄의 경로를 사용자에게 보이고 그 파일을 지울지 묻는다. `PINNED` 는 기록일 뿐이라 지워도 고정과 되돌리기에 영향이 없다. 링크면 URL 칸은 링크가 가리키는 파일에 있으니 그 파일도 보이고 함께 묻는다. 지운 뒤 다시 실행한다. 기록 파일의 내용은 읽어 보이지 않는다(인증 정보가 들 수 있음).
|
|
49
49
|
|
|
50
50
|
## 절차
|
|
51
51
|
|
|
@@ -77,6 +77,8 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no
|
|
|
77
77
|
| `모두 적용된 상태입니다.` | 4단계 검증으로 건너뜀 |
|
|
78
78
|
| `헬퍼 자리 결함 N건이 있습니다.` | 2단계 확인 뒤 3단계에서 활성 폴더부터 되돌림 |
|
|
79
79
|
| `적용되지 않은 항목이 있습니다.` | 2단계 확인 뒤 3단계 적용 |
|
|
80
|
+
| `hooks.json 을 확인하지 못했습니다.` | 적용하지 않고 위의 `[!]` 줄을 그대로 보고 |
|
|
81
|
+
| `고정 기록(PINNED)의 옛 URL 칸은 이 도구로 지울 수 없습니다.` | 적용하지 않고, 위 `규칙` 절의 `PINNED` 처리대로 사용자에게 묻는다. 지운 뒤 1단계부터 다시 |
|
|
80
82
|
|
|
81
83
|
- `참고: 비활성 버전 ...` 은 지금 쓰지 않는 버전이라 판정에 들어가지 않는다.
|
|
82
84
|
- 인자가 `check` 이면 여기서 결과만 보고하고 끝낸다.
|
|
@@ -184,13 +186,17 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no
|
|
|
184
186
|
| 그 밖의 `probe-pm:` 줄(종료 코드 2) | 명령 오류다. 경로를 다시 구해 한 번만 다시 실행한다. 같으면 출력을 보고하고 멈춘다 |
|
|
185
187
|
| 판정 줄 뒤의 `probe-pm: 임시 폴더를 지우지 못함` | 판정은 그대로 쓴다. 남은 임시 폴더 경로를 보고에 적어 사용자가 지우게 한다 |
|
|
186
188
|
|
|
187
|
-
5. 마켓플레이스 고정을 확인한다. `omc-pinned://` 로 시작하면 고정된 것이다.
|
|
189
|
+
5. 마켓플레이스 고정을 확인한다. `omc-pinned://` 로 시작하면 고정된 것이다. 명령은 origin 의 사용자 정보를 `//***@` 로 가려서 낸다.
|
|
188
190
|
|
|
189
191
|
```bash
|
|
190
|
-
git -C "$HOME/.claude/plugins/marketplaces/omc"
|
|
192
|
+
git -C "$HOME/.claude/plugins/marketplaces/omc" config --get remote.origin.url | sed -E 's#(//)[^/]*@#\1***@#'
|
|
191
193
|
```
|
|
192
194
|
|
|
193
|
-
PowerShell
|
|
195
|
+
PowerShell:
|
|
196
|
+
|
|
197
|
+
```powershell
|
|
198
|
+
(git -C "$env:USERPROFILE/.claude/plugins/marketplaces/omc" config --get remote.origin.url) -replace '(//)[^/]*@', '$1***@'
|
|
199
|
+
```
|
|
194
200
|
|
|
195
201
|
### 5. 보고
|
|
196
202
|
|
|
@@ -209,6 +215,7 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --revert
|
|
|
209
215
|
- `.omcbak` 백업으로 설치된 모든 버전의 훅 스크립트와 `hooks.json` 을 되돌리고 마켓플레이스 `origin` 을 원래 URL 로 복구한다.
|
|
210
216
|
- 되돌린 뒤 Claude Code 를 다시 시작해야 반영된다.
|
|
211
217
|
- `되돌리기를 끝내지 못했습니다.` 가 나오면 위의 `[!]` 줄을 그대로 보고한다. 종료 코드는 1 이다. 백업이 없어 남은 파일과 hooks.json 도 이 끝 줄로 끝난다.
|
|
218
|
+
- 고정 기록(`PINNED`)을 지우지 못하면(읽기 전용 폴더, 폴더인 `PINNED`) `[!] 고정 기록(PINNED)을 지우지 못함` 으로 알리고 실패로 센다. 링크인 `PINNED` 너머에 옛 URL 칸이 있으면 링크를 두고 `[!]` 로 알린다. 둘 다 origin 은 그대로 되돌린다. 위 `규칙` 절의 `PINNED` 처리대로 사용자에게 묻는다.
|
|
212
219
|
- `이 도구가 만들지 않은 수정이 남아 있습니다` 가 나오면 백업이 없는 손 수정이다. 출력에 적힌 복구 명령을 그대로 보고하고, 사용자 승인 없이 실행하지 않는다.
|
|
213
220
|
|
|
214
221
|
## 함정
|
|
@@ -218,9 +225,14 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --revert
|
|
|
218
225
|
- 헬퍼 자리 결함이 남은 채 `revert` 없이 적용을 다시 돌려도 바뀌는 것이 없다. 이미 감싼 파일은 건너뛰기 때문이다.
|
|
219
226
|
- `--root` 와 `--revert --root` 는 그 폴더만 다룬다. 고정 해제까지 하려면 인자 없는 `--revert` 를 쓴다.
|
|
220
227
|
- 고정을 풀지 않고 OMC 를 올리려면 먼저 `revert` 한다. 그렇지 않으면 마켓플레이스 fetch 가 계속 실패한다.
|
|
221
|
-
- 고정 기록(`PINNED`)은 `~/.claude/omc-local-patches/PINNED` 에 생긴다. 기록일 뿐이라 지워도 고정은 풀리지 않고, 이미 고정된 상태에서는 다시 만들어지지 않는다. `revert` 는 origin 값에서만 원래 URL 을
|
|
228
|
+
- 고정 기록(`PINNED`)은 `~/.claude/omc-local-patches/PINNED` 에 생긴다. 고정한 시각, 판, 기기 이름만 담고 URL 은 담지 않는다. 옛 판의 기록에 URL 칸이 있으면 `--check` 가 알리고 다음 적용 때 지운다. 지우지 못하면 `[!]` 로 알리고 실패로 센다(링크는 따라가 쓰지 않음). 이 계정만 읽을 수 있다(0600). 기록일 뿐이라 지워도 고정은 풀리지 않고, 이미 고정된 상태에서는 다시 만들어지지 않는다. `revert` 는 origin 값에서만 원래 URL 을 되찾는다. 다른 기기의 기록이면 알리기만 한다.
|
|
222
229
|
- `--check` 와 `--revert` 를 함께 주면 도구는 아무것도 하지 않고 종료 코드 1 로 멈춘다. 진단만 하려면 `--check` 만 준다.
|
|
223
230
|
- 도구는 git 을 부를 때 저장소를 고르는 변수(`GIT_DIR`, `GIT_WORK_TREE` 등 `git rev-parse --local-env-vars` 목록)를 지운다. git 훅 안에서 불러도 마켓플레이스 저장소만 다룬다.
|
|
224
231
|
- 마켓플레이스의 `.git` 이 깨졌으면(중단된 clone 등) git 이 위쪽 저장소로 올라가지 못하게 막는다. 그때 `[4/4]` 는 `origin remote 없음` 으로 끝나고 고정하지 않는다.
|
|
225
232
|
- `--scan` 은 읽기만 한다. 플러그인 캐시도 `installed_plugins.json` 도 읽지 않고 준 파일만 본다.
|
|
233
|
+
- 도구는 모르는 인자를 받지 않는다. `--check`, `--revert`, `--no-update`, `--root <폴더>`, `--scan <파일>...`, `--help` 밖의 인자나 `--root=<폴더>` 꼴을 주면 아무것도 하지 않고 사용법과 함께 종료 1 로 멈춘다.
|
|
234
|
+
- 출력의 origin URL 에 사용자 이름과 비밀번호가 있으면 `//***@` 로 가린다. 도구는 origin 을 설정 값 그대로 읽어 고정하고 되돌린다(전역 `insteadOf` 를 펼치지 않음).
|
|
235
|
+
- origin 을 바꾸지 못하면(`.git` 쓰기 권한 등) `[!] 고정 실패` 나 `[!] 고정 해제 실패` 와 종료 1 로 끝난다. 그 줄을 그대로 보고한다. 고정 해제에 실패해도 원래 URL 은 `git config --get remote.origin.url` 결과의 `omc-pinned://` 뒤에 남는다.
|
|
236
|
+
- `--check` 는 쓸 수 없는 파일을 `패치 필요, 쓸 수 없음` 으로 미리 알린다. 폴더 밖 링크 때문에 되돌리지 못한 파일은 손 복구 방법을 함께 낸다. 사용자 승인 없이 그 방법을 실행하지 않는다.
|
|
237
|
+
- 파일은 새 임시 파일과 rename 으로 바꾼다. 하드 링크로 이어진 폴더 밖 파일은 바뀌지 않는다. 쓰지 못한 파일은 `쓰지 못함` 으로 이름을 대고 실패로 센다.
|
|
226
238
|
- 도구의 주석과 출력 문구는 한글이다.
|