@kaydash9999/banker-plugins 0.18.0 → 0.19.1
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 +103 -0
- package/README.md +18 -7
- package/bin/lib/telemetry-config.mjs +25 -0
- 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 +61 -32
- package/hooks/progress-plan.mjs +14 -8
- package/hooks/progress.mjs +187 -48
- package/hooks/register.mjs +15 -7
- package/hooks/telemetry-count-skill.mjs +4 -6
- package/hooks/telemetry-count.mjs +4 -6
- package/hooks/update-checkin.mjs +46 -43
- package/hooks/update-fetch.mjs +8 -7
- package/package.json +1 -1
- package/skills/anti-drm/SKILL.md +46 -15
- package/skills/anti-drm/scripts/anti-drm.mjs +213 -61
- package/skills/anti-drm/scripts/instruction-toggle.mjs +280 -44
- package/skills/lineage/SKILL.md +4 -1
- package/skills/lineage/lineage.py +196 -73
- package/skills/omc-patch/SKILL.md +37 -11
- package/skills/omc-patch/scripts/omc-patch.mjs +635 -103
- 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 +14 -5
- package/skills/tone-compact/scripts/instruction-toggle.mjs +280 -44
- package/skills/tone-compact/scripts/tone-compact.mjs +4 -4
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -39,7 +39,7 @@ argument-hint: "on | off | status"
|
|
|
39
39
|
| 코드 | 뜻 | 처리 |
|
|
40
40
|
|---|---|---|
|
|
41
41
|
| 0 | 완료. 이미 그 상태였던 경우 포함 | 결과 전달 |
|
|
42
|
-
| 1 | 거부 또는 사용법 오류. 아무것도 바꾸지
|
|
42
|
+
| 1 | 거부 또는 사용법 오류. 아무것도 바꾸지 않음. 단 메시지에 `앞서 바꾼 파일:` 이 있으면 그 파일은 이미 바뀜 | 메시지 그대로 전달 |
|
|
43
43
|
| 2 | 예기치 못한 오류 | 메시지 전달 후 `status` 로 확인 |
|
|
44
44
|
|
|
45
45
|
- 출력이 비어 있으면 실행 실패. 성공으로 보고하지 않음
|
|
@@ -184,20 +184,29 @@ argument-hint: "on | off | status"
|
|
|
184
184
|
- 블록이 남음: `omx setup --merge-agents`, 기본 `omx setup`(team 모드 켜짐, 모델 표만 갱신)
|
|
185
185
|
- 블록이 사라짐: `omx setup --force`, 덮어쓰기 확인, team 모드를 끈 `omx setup`. 그 뒤 `on` 다시 실행
|
|
186
186
|
- UTF-8 이 아닌 지침 파일(CP949, UTF-16): 손대지 않고 거부
|
|
187
|
-
- Codex 파일 변경 전 내용: `<파일>.tone-compact.bak` (이 계정만 읽기 가능)
|
|
187
|
+
- Codex 파일 변경 전 내용: `<파일>.tone-compact.bak` (이 계정만 읽기 가능). `off` 결과에 그 경로를 알림
|
|
188
188
|
- 같은 파일에 있는 다른 도구의 블록은 건드리지 않음
|
|
189
189
|
- `status`: 두 런타임 상태를 모두 표시
|
|
190
190
|
- 한 런타임에서 켜도 다른 런타임은 그대로. 필요하면 그쪽에서도 실행
|
|
191
191
|
|
|
192
192
|
## 문제 해결
|
|
193
193
|
|
|
194
|
+
- 아래 표에서 파일을 보인다는 것은 `ls -l` 로 그 이름의 종류와 링크 대상 경로를 보이는 것이다. 링크가 아닌 일반 파일임을 본 뒤에만 내용을 연다. 심어 둔 링크가 가리키는 파일(키 등)이 세션에 오르지 않게 한다.
|
|
195
|
+
- `<파일>.tone-compact.bak` 은 내용을 열지 않고 경로만 알린다. 지침 파일이 링크였으면 가리키던 파일(키 등)의 사본일 수 있다.
|
|
196
|
+
|
|
194
197
|
| 메시지 | 조치 |
|
|
195
198
|
|---|---|
|
|
196
|
-
| 같은 이름의 다른 파일이 있음 | `rules/banker-tone-compact.md` 가 banker 파일이 아님.
|
|
197
|
-
| 표시의 짝이 맞지 않음 | 지침 파일에 tone-compact 표시 줄이 한쪽만 있거나 문장 속에 들어 있음. 또는 블록 안에 다른 표시 줄이 있음. 짝을 새로 맞추지
|
|
199
|
+
| 같은 이름의 다른 파일이 있음 | `rules/banker-tone-compact.md` 가 banker 파일이 아님. 지우거나 이름을 바꾸지 않음. 내용을 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
|
|
200
|
+
| 표시의 짝이 맞지 않음 | 지침 파일에 tone-compact 표시 줄이 한쪽만 있거나 문장 속에 들어 있음. 또는 블록 안에 다른 표시 줄이 있음. 짝을 새로 맞추지 않음. 남은 표시 줄을 보이고 지울지 물은 뒤 다시 실행 |
|
|
198
201
|
| 설정 폴더가 없음 | 해당 런타임 미설치. 또는 설정 폴더 환경 변수 값 확인 |
|
|
199
202
|
| 규칙이 이 버전과 다름 | `on` 한 번 실행 |
|
|
200
203
|
| 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 | `AGENTS.override.md` 가 새로 생김. `on` 실행 시 옮김 |
|
|
201
204
|
| AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 | override 의 다른 내용이 지워짐. `on` 실행 시 AGENTS.md 로 옮기고 빈 override 삭제 |
|
|
202
|
-
|
|
|
205
|
+
| 다른 banker 블록만 있어 Codex 가 AGENTS.md 를 읽지 않음 | 메시지가 말한 스킬에서 `on` 실행 |
|
|
206
|
+
| UTF-8 이 아닌 파일 | 지침 파일을 바꾸지 않음. 인코딩을 보이고, 백업을 만든 뒤 UTF-8 로 바꿀지 물은 뒤 다시 실행 |
|
|
207
|
+
| 끊어진 링크라 그대로 두었습니다 | AGENTS.md 가 가리키는 파일이 없음. 링크를 지우거나 바꾸지 않음. 링크와 대상을 보이고 어떻게 할지 물은 뒤 다시 실행 |
|
|
208
|
+
| AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 | AGENTS.override.md 에 블록만 남았고 AGENTS.md 가 가리키는 파일이 없음. 링크를 지우거나 바꾸지 않음. 링크와 대상을 보이고 어떻게 할지 물은 뒤 `on` 실행 |
|
|
209
|
+
| 쓰는 사이에 같은 이름이 생겨 그대로 두었습니다 | 다른 프로그램이 그 사이에 같은 이름을 만들었음(링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
|
|
210
|
+
| 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
|
|
211
|
+
| 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
|
|
203
212
|
| 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
|
|
@@ -33,17 +33,30 @@
|
|
|
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.
|
|
37
40
|
*
|
|
38
|
-
*
|
|
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, or,
|
|
46
|
+
* when a change of two files was refused at the second, with the first changed
|
|
47
|
+
* and named in the message ("앞서 바꾼 파일").
|
|
39
48
|
* Anything else that goes wrong throws.
|
|
40
49
|
*/
|
|
41
50
|
import {
|
|
42
51
|
accessSync,
|
|
52
|
+
closeSync,
|
|
43
53
|
constants,
|
|
54
|
+
fstatSync,
|
|
44
55
|
lstatSync,
|
|
56
|
+
openSync,
|
|
45
57
|
mkdirSync,
|
|
46
58
|
readFileSync,
|
|
59
|
+
realpathSync,
|
|
47
60
|
renameSync,
|
|
48
61
|
rmSync,
|
|
49
62
|
statSync,
|
|
@@ -60,8 +73,12 @@ export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
|
|
|
60
73
|
|
|
61
74
|
// The byte order mark an editor may put in front of a file.
|
|
62
75
|
const BOM = String.fromCharCode(0xfeff);
|
|
63
|
-
|
|
64
|
-
const
|
|
76
|
+
/** The text without the byte order mark an editor may put in front of it. */
|
|
77
|
+
export const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
|
|
78
|
+
/** The text with CRLF line ends turned into LF. */
|
|
79
|
+
export const lf = (text) => text.replace(/\r\n/g, "\n");
|
|
80
|
+
/** How many times needle occurs in text. */
|
|
81
|
+
export const count = (text, needle) => text.split(needle).length - 1;
|
|
65
82
|
const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
|
|
66
83
|
const isDir = (path) => {
|
|
67
84
|
try {
|
|
@@ -80,27 +97,61 @@ const isLink = (path) => {
|
|
|
80
97
|
|
|
81
98
|
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
82
99
|
|
|
100
|
+
// What sits at a name that is not a regular file: a folder, a FIFO, a socket, a device or a link loop.
|
|
101
|
+
// It is never read; reading a FIFO waits for a writer.
|
|
102
|
+
const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
|
|
103
|
+
// What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
|
|
104
|
+
const DANGLING = Object.freeze({ bytes: null, text: null, dangling: true });
|
|
105
|
+
// What opening a name fails with when no regular file can be there: links that loop (ELOOP) or a socket (ENXIO,
|
|
106
|
+
// EOPNOTSUPP). macOS answers a socket with errno 102, which Node 22 does not name ("Unknown system error -102"), so
|
|
107
|
+
// the kind of what is there decides too.
|
|
108
|
+
const NOT_A_FILE = new Set(["ELOOP", "ENXIO", "EOPNOTSUPP"]);
|
|
109
|
+
// Whether path leads to something that is there and is no regular file: a folder, a socket, a FIFO or a device.
|
|
110
|
+
const notAFile = (path) => {
|
|
111
|
+
try {
|
|
112
|
+
return !statSync(path).isFile();
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// What a failed open of path means: null for nothing there, DANGLING for a link to nothing, SPECIAL for a folder,
|
|
119
|
+
// a socket or a link loop. Any other failure (permissions) is not "missing" and propagates.
|
|
120
|
+
function openFailure(path, e) {
|
|
121
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") return isLink(path) ? DANGLING : null;
|
|
122
|
+
if (e.code === "EISDIR" || NOT_A_FILE.has(e.code) || notAFile(path)) return SPECIAL;
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
|
|
83
126
|
// A file's bytes and text, null when there is no such file. text is null when
|
|
84
127
|
// the bytes are not UTF-8, which callers refuse: decoding them would replace
|
|
85
|
-
// them with U+FFFD for good.
|
|
86
|
-
//
|
|
128
|
+
// them with U+FFFD for good. A folder, a socket, a link loop or anything but a
|
|
129
|
+
// regular file is SPECIAL, and a link to nothing is DANGLING: the name is
|
|
130
|
+
// opened without blocking and not read.
|
|
87
131
|
function readFile(path) {
|
|
88
|
-
let
|
|
132
|
+
let fd;
|
|
89
133
|
try {
|
|
90
|
-
|
|
134
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
91
135
|
} catch (e) {
|
|
92
|
-
|
|
93
|
-
throw e;
|
|
136
|
+
return openFailure(path, e);
|
|
94
137
|
}
|
|
95
138
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
139
|
+
if (!fstatSync(fd).isFile()) return SPECIAL;
|
|
140
|
+
const bytes = readFileSync(fd);
|
|
141
|
+
try {
|
|
142
|
+
return { bytes, text: UTF8.decode(bytes) };
|
|
143
|
+
} catch {
|
|
144
|
+
return { bytes, text: null };
|
|
145
|
+
}
|
|
146
|
+
} finally {
|
|
147
|
+
closeSync(fd);
|
|
99
148
|
}
|
|
100
149
|
}
|
|
101
150
|
|
|
102
|
-
|
|
103
|
-
const
|
|
151
|
+
/** A result that did, or found already done, what was asked. */
|
|
152
|
+
export const done = (message) => ({ ok: true, message });
|
|
153
|
+
/** A result that refused and changed nothing, unless its message names a file changed before (applied). */
|
|
154
|
+
export const refuse = (message) => ({ ok: false, message });
|
|
104
155
|
|
|
105
156
|
/** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
|
|
106
157
|
export function markersOf(slug) {
|
|
@@ -144,9 +195,14 @@ export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code
|
|
|
144
195
|
* paragraph separator, private-use, unassigned and variation selector
|
|
145
196
|
* characters become "?": a path from the environment cannot start a line of
|
|
146
197
|
* its own, reorder the text around it, or hide text after a visible letter.
|
|
198
|
+
* So do letters that print as nothing (default-ignorable ones such as U+3164),
|
|
199
|
+
* the braille blank and spaces other than the ASCII one, which hide text as blanks.
|
|
147
200
|
*/
|
|
148
201
|
export const where = (value) =>
|
|
149
|
-
String(value).replace(
|
|
202
|
+
String(value).replace(
|
|
203
|
+
/[\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,
|
|
204
|
+
"?",
|
|
205
|
+
);
|
|
150
206
|
|
|
151
207
|
// ---- Claude Code: one rules file that is banker's alone ----
|
|
152
208
|
|
|
@@ -156,7 +212,7 @@ const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
|
|
|
156
212
|
// "off", "foreign" (not banker's file), or, when rules are given, "on" or
|
|
157
213
|
// "stale" (other rules); without rules a banker file is "ours".
|
|
158
214
|
function claudeState(t, file, rules) {
|
|
159
|
-
if (file === null) return "off";
|
|
215
|
+
if (file === null || file.dangling) return "off";
|
|
160
216
|
if (file.text === null) return "foreign";
|
|
161
217
|
const body = lf(noBom(file.text));
|
|
162
218
|
if (!body.startsWith(t.ownerMark)) return "foreign";
|
|
@@ -172,6 +228,7 @@ function claudeOn(t, o, getRules) {
|
|
|
172
228
|
const state = claudeState(t, readFile(file), rules);
|
|
173
229
|
if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${shown}`);
|
|
174
230
|
if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${shown}`);
|
|
231
|
+
const wasLink = isLink(file);
|
|
175
232
|
try {
|
|
176
233
|
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
|
|
177
234
|
writeFresh(file, claudeText(t, rules));
|
|
@@ -179,13 +236,13 @@ function claudeOn(t, o, getRules) {
|
|
|
179
236
|
if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${shown}`);
|
|
180
237
|
throw e;
|
|
181
238
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
? `Claude Code: ${t.updatedText}. ${shown}`
|
|
185
|
-
: `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`,
|
|
186
|
-
);
|
|
239
|
+
const words = state === "stale" ? `Claude Code: ${t.updatedText}. ${shown}` : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`;
|
|
240
|
+
return done(wasLink ? `${words}\n${LINK_REPLACED}` : words);
|
|
187
241
|
}
|
|
188
242
|
|
|
243
|
+
/** The note for a name that was a link (to nothing, or to an older copy) and is now a regular file. */
|
|
244
|
+
export const LINK_REPLACED = "참고: 링크였던 자리를 일반 파일로 바꿨습니다. 링크가 가리키던 곳은 건드리지 않았습니다.";
|
|
245
|
+
|
|
189
246
|
function claudeOff(t, o) {
|
|
190
247
|
const file = claudeFile(t, o);
|
|
191
248
|
const state = claudeState(t, readFile(file));
|
|
@@ -317,25 +374,48 @@ const otherBlocks = (t, text) =>
|
|
|
317
374
|
return spans !== null && spans.length > 0;
|
|
318
375
|
});
|
|
319
376
|
|
|
377
|
+
const special = (f) => f.special === true;
|
|
320
378
|
const binary = (f) => f.bytes !== null && f.text === null;
|
|
321
379
|
const unpaired = (f) => f.spans === null;
|
|
322
380
|
const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
|
|
323
381
|
const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
|
|
324
382
|
|
|
383
|
+
/** Whether two bigint stats name one file: one device and one inode, where the file system gives inodes at all. */
|
|
384
|
+
export const sameInode = (x, y) => x.ino !== 0n && x.dev === y.dev && x.ino === y.ino;
|
|
385
|
+
|
|
386
|
+
// Whether two paths name one file: a link (both resolve to one real path) or a hard link (one inode). A file system
|
|
387
|
+
// that gives no inode numbers (0) cannot say so for hard links, so those count as two files.
|
|
388
|
+
function sameFile(a, b) {
|
|
389
|
+
try {
|
|
390
|
+
if (realpathSync(a) === realpathSync(b)) return true;
|
|
391
|
+
return sameInode(statSync(a, { bigint: true }), statSync(b, { bigint: true }));
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
325
397
|
// Both global instruction files with their bytes and text (null when absent;
|
|
326
398
|
// text null when not UTF-8) and blocks (null when the markers do not pair up).
|
|
327
399
|
// The block's place (target) is the override when the override has text of its
|
|
328
400
|
// own, AGENTS.md otherwise. shadowers names the other banker skills whose
|
|
329
401
|
// blocks alone fill the override: Codex then reads the override, not AGENTS.md.
|
|
402
|
+
// An override that is a link or a hard link to AGENTS.md is one file with it.
|
|
330
403
|
function codexFiles(t, o) {
|
|
331
404
|
const read = (name) => {
|
|
332
405
|
const path = join(o.codexDir, name);
|
|
333
406
|
const file = readFile(path);
|
|
334
|
-
if (file === null
|
|
407
|
+
if (file === null || file.special || file.dangling) {
|
|
408
|
+
return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true };
|
|
409
|
+
}
|
|
335
410
|
return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
|
|
336
411
|
};
|
|
337
412
|
const override = read("AGENTS.override.md");
|
|
338
413
|
const main = read("AGENTS.md");
|
|
414
|
+
if (override.bytes !== null && main.bytes !== null && sameFile(override.path, main.path)) {
|
|
415
|
+
// One file under both names: Codex reads it either way, so it is AGENTS.md alone.
|
|
416
|
+
const none = { path: override.path, bytes: null, text: null, spans: [] };
|
|
417
|
+
return { target: main, other: none, all: [none, main], shadowers: [] };
|
|
418
|
+
}
|
|
339
419
|
const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
|
|
340
420
|
const target = ownContent ? override : main;
|
|
341
421
|
const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
|
|
@@ -357,18 +437,30 @@ function withBlock(t, text, rules) {
|
|
|
357
437
|
return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
|
|
358
438
|
}
|
|
359
439
|
|
|
440
|
+
// The state when the block is not in its place: "hidden" (only in an override with no text of its own),
|
|
441
|
+
// "hiddenbroken" (the same, with AGENTS.md a link to nothing, so on cannot move it), "misplaced" (only in AGENTS.md
|
|
442
|
+
// while Codex reads the override), "dangling" (nowhere, and AGENTS.md is a link to nothing that on must not write
|
|
443
|
+
// through) or "off".
|
|
444
|
+
function placeState(files) {
|
|
445
|
+
if (hasBlock(files.other)) {
|
|
446
|
+
if (files.other !== files.all[0]) return "misplaced";
|
|
447
|
+
return files.target.dangling ? "hiddenbroken" : "hidden";
|
|
448
|
+
}
|
|
449
|
+
return files.target.dangling ? "dangling" : "off";
|
|
450
|
+
}
|
|
451
|
+
|
|
360
452
|
// "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
|
|
361
453
|
// override, so not applied), "hidden" (in the override with no text of its own,
|
|
362
|
-
// applied but hiding AGENTS.md), "
|
|
363
|
-
//
|
|
454
|
+
// applied but hiding AGENTS.md), "dangling" (nowhere, and AGENTS.md is a link to
|
|
455
|
+
// nothing), "unpaired", "notutf8" or "notregular" (a folder, FIFO, socket or
|
|
456
|
+
// link loop at a file's name). Without rules any block in its place counts as "on".
|
|
364
457
|
function codexState(t, files, rules) {
|
|
458
|
+
if (files.all.some(special)) return "notregular";
|
|
365
459
|
if (files.all.some(binary)) return "notutf8";
|
|
366
460
|
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";
|
|
461
|
+
if (!hasBlock(files.target)) return placeState(files);
|
|
370
462
|
if (rules === undefined) return "on";
|
|
371
|
-
return blockCurrent(t, files.target, rules) && !
|
|
463
|
+
return blockCurrent(t, files.target, rules) && !hasBlock(files.other) ? "on" : "stale";
|
|
372
464
|
}
|
|
373
465
|
|
|
374
466
|
// Why these files cannot be written, or "" when they can. The folder counts
|
|
@@ -390,12 +482,120 @@ function writeBlocked(changes) {
|
|
|
390
482
|
// target changes, after keeping the old bytes in a .bak only this account can
|
|
391
483
|
// read (the file may be a link into a private folder). A new file gets 0644
|
|
392
484
|
// whatever the umask allows more. A file left empty is removed, unless it is a
|
|
393
|
-
// link
|
|
485
|
+
// link or has other names (a hard link), whose other names would keep the old
|
|
486
|
+
// text: it is emptied instead. Codex skips an empty file anyway.
|
|
487
|
+
// A file that was not there when it was read is created only if it still is not ("wx"): a name that appeared in
|
|
488
|
+
// between, a link planted there among them, is not written through.
|
|
394
489
|
function apply(t, changes) {
|
|
395
490
|
for (const { file, after } of changes) {
|
|
396
491
|
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 });
|
|
492
|
+
if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
|
|
493
|
+
else writeFileSync(file.path, after, { mode: 0o644, flag: file.bytes === null ? "wx" : "w" });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Applies the changes, or the refusal for a write that failed with nothing of the file changed: a name that
|
|
498
|
+
// appeared in between (EEXIST), or a write a sandbox refused after access() allowed it. The refusal names the place
|
|
499
|
+
// the system refused (a rename's target, as the backup, or the file it was about), and a file changed before the
|
|
500
|
+
// failing one. Other errors propagate.
|
|
501
|
+
function applied(t, changes) {
|
|
502
|
+
let i = 0;
|
|
503
|
+
try {
|
|
504
|
+
for (; i < changes.length; i++) apply(t, [changes[i]]);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
if (e.code !== "EEXIST" && !denied(e)) throw e;
|
|
507
|
+
const why = e.code === "EEXIST" ? "쓰는 사이에 같은 이름이 생겨 그대로 두었습니다" : `쓸 권한이 없어 그대로 두었습니다 (${where(e.code)})`;
|
|
508
|
+
const at = [e.dest, e.path].find((p) => typeof p === "string") ?? changes[i].file.path;
|
|
509
|
+
const before = i === 0 ? "" : `\n앞서 바꾼 파일: ${changes.slice(0, i).map((c) => where(c.file.path)).join(", ")}`;
|
|
510
|
+
return refuse(`${why}: ${where(at)}${before}`);
|
|
511
|
+
}
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Whether the file at path is also reachable under another name: a hard link.
|
|
516
|
+
function hasOtherNames(path) {
|
|
517
|
+
try {
|
|
518
|
+
return lstatSync(path).nlink > 1;
|
|
519
|
+
} catch {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// The lock both banker skills take while they read and write Codex's instructions files: two on's at once would
|
|
525
|
+
// each write the file back with only their own block. A lock is held for milliseconds; one older than a minute was
|
|
526
|
+
// left by a run that died.
|
|
527
|
+
const LOCK = ".banker-agents.lock";
|
|
528
|
+
const LOCK_STALE_MS = 60_000;
|
|
529
|
+
const LOCK_STEP_MS = 50;
|
|
530
|
+
const SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
531
|
+
|
|
532
|
+
// Makes the lock file; false when one is there already. Anything else (permissions) throws.
|
|
533
|
+
function tryLock(path, token) {
|
|
534
|
+
try {
|
|
535
|
+
writeFileSync(path, token, { flag: "wx", mode: 0o600 });
|
|
536
|
+
return true;
|
|
537
|
+
} catch (e) {
|
|
538
|
+
if (e.code === "EEXIST") return false;
|
|
539
|
+
throw e;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Removes a lock a dead run left; true when there is none now, so the next try may take it. The name itself is
|
|
544
|
+
// 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
|
|
545
|
+
// than a minute away either way (a clock set back) is stale too. A folder there is none of this module's: it stays,
|
|
546
|
+
// and the wait ends in a refusal.
|
|
547
|
+
function dropStale(path) {
|
|
548
|
+
let st;
|
|
549
|
+
try {
|
|
550
|
+
st = lstatSync(path);
|
|
551
|
+
} catch (e) {
|
|
552
|
+
if (e.code === "ENOENT" || e.code === "ESTALE") return true; /* gone in between (ESTALE: a network share's word for it) */
|
|
553
|
+
throw e;
|
|
554
|
+
}
|
|
555
|
+
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
|
|
556
|
+
rmSync(path, { force: true });
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Takes the lock: { path, token }, or null when another run held it for waitMs. The deadline is looked at every
|
|
561
|
+
// round, so no lock spot, whatever is there, keeps the wait going past it.
|
|
562
|
+
function takeLock(dir, waitMs) {
|
|
563
|
+
const path = join(dir, LOCK);
|
|
564
|
+
const token = `${process.pid} ${Date.now()} ${Math.random()}`;
|
|
565
|
+
const until = Date.now() + waitMs;
|
|
566
|
+
for (;;) {
|
|
567
|
+
if (tryLock(path, token)) return { path, token };
|
|
568
|
+
if (Date.now() >= until) return null;
|
|
569
|
+
if (!dropStale(path)) Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
|
|
574
|
+
// this run's lock and stays.
|
|
575
|
+
function dropLock(lock) {
|
|
576
|
+
try {
|
|
577
|
+
if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
|
|
578
|
+
} catch {
|
|
579
|
+
/* already gone */
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Runs fn holding the lock; a lock another run keeps past the wait is a refusal. A folder this account cannot write
|
|
584
|
+
// takes no lock, and nothing in it can be written either: fn then only reads, and writeBlocked refuses any change,
|
|
585
|
+
// so "already on" and "already off" answer as before.
|
|
586
|
+
function locked(o, fn) {
|
|
587
|
+
let lock;
|
|
588
|
+
try {
|
|
589
|
+
lock = takeLock(o.codexDir, o.lockWaitMs ?? 5000);
|
|
590
|
+
} catch (e) {
|
|
591
|
+
if (denied(e)) return fn();
|
|
592
|
+
throw e;
|
|
593
|
+
}
|
|
594
|
+
if (!lock) return refuse(`다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(join(o.codexDir, LOCK))}`);
|
|
595
|
+
try {
|
|
596
|
+
return fn();
|
|
597
|
+
} finally {
|
|
598
|
+
dropLock(lock);
|
|
399
599
|
}
|
|
400
600
|
}
|
|
401
601
|
|
|
@@ -408,35 +608,65 @@ const shadowNote = (files) =>
|
|
|
408
608
|
? ""
|
|
409
609
|
: `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
|
|
410
610
|
|
|
611
|
+
// The file a refused state is about: the folder or FIFO, the file that is not UTF-8, or the one whose markers do
|
|
612
|
+
// not pair up; null in any other state.
|
|
613
|
+
function problemFile(files, state) {
|
|
614
|
+
const which = { notregular: special, notutf8: binary, unpaired }[state];
|
|
615
|
+
return which ? files.all.find(which) : null;
|
|
616
|
+
}
|
|
617
|
+
|
|
411
618
|
// The refusal for files on and off must not touch, or null.
|
|
412
619
|
function codexRefusal(t, files, state) {
|
|
413
|
-
|
|
414
|
-
if (
|
|
415
|
-
|
|
620
|
+
const file = problemFile(files, state);
|
|
621
|
+
if (!file) return null;
|
|
622
|
+
const why = {
|
|
623
|
+
notregular: "일반 파일이 아니라 그대로 두었습니다",
|
|
624
|
+
notutf8: "UTF-8 이 아닌 파일이라 그대로 두었습니다",
|
|
625
|
+
unpaired: `${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다`,
|
|
626
|
+
}[state];
|
|
627
|
+
return refuse(`${why}: ${where(file.path)}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// What codexOn says it did. A block Codex does not read, because other banker
|
|
631
|
+
// blocks alone fill the override, says so in its first words.
|
|
632
|
+
function onWords(t, state, unread) {
|
|
633
|
+
if (state === "on") return unread ? "이미 AGENTS.md 에 있지만 지금은 적용되지 않습니다 (변경 없음)" : "이미 켜져 있습니다 (변경 없음)";
|
|
634
|
+
if (unread) return "AGENTS.md 에 넣었지만 지금은 적용되지 않습니다";
|
|
635
|
+
const moved = "블록을 맞는 파일로 옮겼습니다";
|
|
636
|
+
return { stale: t.updatedText, misplaced: moved, hidden: moved }[state] ?? "켰습니다. 다음 세션부터 자동 적용됩니다";
|
|
416
637
|
}
|
|
417
638
|
|
|
418
639
|
function codexOn(t, o, getRules) {
|
|
419
640
|
if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${where(o.codexDir)}`);
|
|
641
|
+
return locked(o, () => codexOnHeld(t, o, getRules));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function codexOnHeld(t, o, getRules) {
|
|
420
645
|
const rules = getRules();
|
|
421
646
|
const files = codexFiles(t, o);
|
|
422
647
|
const state = codexState(t, files, rules);
|
|
423
648
|
const refusal = codexRefusal(t, files, state);
|
|
424
649
|
if (refusal) return refusal;
|
|
650
|
+
if (files.target.dangling) return refuse(`끊어진 링크라 그대로 두었습니다: ${where(files.target.path)}`);
|
|
651
|
+
const words = onWords(t, state, files.shadowers.length > 0);
|
|
425
652
|
const tail = `${where(files.target.path)}${shadowNote(files)}`;
|
|
426
|
-
if (state === "on") return done(`Codex:
|
|
653
|
+
if (state === "on") return done(`Codex: ${words}. ${tail}`);
|
|
427
654
|
const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
|
|
428
655
|
if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
|
|
429
656
|
const why = writeBlocked(changes);
|
|
430
657
|
if (why) return refuse(why);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
const what = { stale: t.updatedText, misplaced: moved, hidden: moved }[state];
|
|
658
|
+
const failed = applied(t, changes);
|
|
659
|
+
if (failed) return failed;
|
|
434
660
|
const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
|
|
435
|
-
return done(`Codex: ${
|
|
661
|
+
return done(`Codex: ${words}. ${tail}${note}`);
|
|
436
662
|
}
|
|
437
663
|
|
|
438
664
|
function codexOff(t, o) {
|
|
439
665
|
if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${where(o.codexDir)}`);
|
|
666
|
+
return locked(o, () => codexOffHeld(t, o));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function codexOffHeld(t, o) {
|
|
440
670
|
const files = codexFiles(t, o);
|
|
441
671
|
const refusal = codexRefusal(t, files, codexState(t, files));
|
|
442
672
|
if (refusal) return refusal;
|
|
@@ -444,8 +674,11 @@ function codexOff(t, o) {
|
|
|
444
674
|
if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
|
|
445
675
|
const why = writeBlocked(changes);
|
|
446
676
|
if (why) return refuse(why);
|
|
447
|
-
|
|
448
|
-
|
|
677
|
+
const failed = applied(t, changes);
|
|
678
|
+
if (failed) return failed;
|
|
679
|
+
const paths = changes.map((c) => where(c.file.path)).join(", ");
|
|
680
|
+
const baks = changes.map((c) => where(`${c.file.path}.${t.slug}.bak`)).join(", ");
|
|
681
|
+
return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${paths}\n지우기 전 파일: ${baks}`);
|
|
449
682
|
}
|
|
450
683
|
|
|
451
684
|
// ---- status ----
|
|
@@ -457,8 +690,11 @@ const STATE_TEXT = {
|
|
|
457
690
|
foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
|
|
458
691
|
misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
|
|
459
692
|
hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
|
|
693
|
+
hiddenbroken: "켜짐, 단 AGENTS.override.md 에 블록만 있고 AGENTS.md 가 끊어진 링크라 on 이 블록을 옮기지 못함 (링크 확인 필요)",
|
|
460
694
|
notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
|
|
695
|
+
notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
|
|
461
696
|
unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
|
|
697
|
+
dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
|
|
462
698
|
};
|
|
463
699
|
|
|
464
700
|
// A state as the status line says it.
|
|
@@ -486,7 +722,7 @@ function codexLook(t, o, rules) {
|
|
|
486
722
|
const files = codexFiles(t, o);
|
|
487
723
|
const state = codexState(t, files, rules);
|
|
488
724
|
const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
|
|
489
|
-
return { state: shadowed ? "shadowed" : state, path: files.target.path, who: files.shadowers };
|
|
725
|
+
return { state: shadowed ? "shadowed" : state, path: (problemFile(files, state) ?? files.target).path, who: files.shadowers };
|
|
490
726
|
}
|
|
491
727
|
|
|
492
728
|
/** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
|
|
@@ -507,7 +743,7 @@ export function stateLines(t, o, rules) {
|
|
|
507
743
|
*/
|
|
508
744
|
export function present(t, runtime, o) {
|
|
509
745
|
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)));
|
|
746
|
+
return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
|
|
511
747
|
}
|
|
512
748
|
|
|
513
749
|
const knownRuntime = (runtime) => {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* `off` never needs the rules, so it works even when SKILL.md cannot be read.
|
|
17
17
|
* Exit status 0 means done or already so; 1 means refused or a usage error, with
|
|
18
|
-
* nothing changed
|
|
18
|
+
* nothing changed (a refusal at the second of two files names the first, which
|
|
19
|
+
* it changed); 2 means an unexpected error stopped the command partway, so
|
|
19
20
|
* check `status`.
|
|
20
21
|
*/
|
|
21
22
|
import { readFileSync, realpathSync } from "node:fs";
|
|
@@ -27,7 +28,9 @@ import {
|
|
|
27
28
|
KEEP_OPEN,
|
|
28
29
|
RUNTIMES,
|
|
29
30
|
blockSpansOf,
|
|
31
|
+
count,
|
|
30
32
|
hasMarker,
|
|
33
|
+
lf,
|
|
31
34
|
stateLines,
|
|
32
35
|
toggleSpec,
|
|
33
36
|
turnOff as toggleOff,
|
|
@@ -50,9 +53,6 @@ const DEFAULTS = {
|
|
|
50
53
|
skillFile: fileURLToPath(new URL("../SKILL.md", import.meta.url)),
|
|
51
54
|
};
|
|
52
55
|
|
|
53
|
-
const count = (text, needle) => text.split(needle).length - 1;
|
|
54
|
-
const lf = (text) => text.replace(/\r\n/g, "\n");
|
|
55
|
-
|
|
56
56
|
/** The rules: the text between the markers in SKILL.md, without the line breaks around it. */
|
|
57
57
|
export function rulesOf(skillText) {
|
|
58
58
|
const text = lf(skillText);
|
|
@@ -61,7 +61,7 @@ banker 는 서로 다른 메커니즘의 채널 3개로 설치되고, 그 채널
|
|
|
61
61
|
- 반대 방향의 거짓말도 하지 마라: banker 자체는 dependencies 0 이라 컴파일이 없다. **빌드 도구·툴체인 준비를 요구하는 절차를 넣으면 그건 거짓이다.**
|
|
62
62
|
|
|
63
63
|
**4. `~/.claude/plugins/cache/` 를 `rm -rf` 하지 마라.**
|
|
64
|
-
- 캐시에 구버전이 공존하는 것은 **관측 대상이지 조치 대상이 아니다.** 갱신 의미론
|
|
64
|
+
- 캐시에 구버전이 공존하는 것은 **관측 대상이지 조치 대상이 아니다.** 갱신 의미론 밖이다. Claude Code 는 교체된 판 폴더에 `.orphaned_at` 을 쓰고, 14일이 지나고 그 판을 쓰는 세션이 없으면 그 폴더를 스스로 지운다(2.1.299 에서 확인). 링크로 된 판 폴더(OMC 의 옛 판 경로 등)는 표시하지도 지우지도 않는다.
|
|
65
65
|
- 발견하면 보고만 한다. 손대지 않는다.
|
|
66
66
|
|
|
67
67
|
## 워크플로
|