@kaydash9999/banker-plugins 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +49 -0
  4. package/README.md +5 -4
  5. package/bin/lib/codex-hooks.mjs +5 -1
  6. package/codex/hooks.json +4 -4
  7. package/codex/manifest.json +2 -1
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +21 -11
  9. package/codex/transform-matrix.md +1 -0
  10. package/hooks/telemetry-count-codex.mjs +3 -2
  11. package/package.json +1 -1
  12. package/skills/anti-drm/SKILL.md +2 -2
  13. package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
  14. package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
  15. package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
  16. package/skills/build-testbox/scripts/containers.mjs +8 -3
  17. package/skills/build-testbox/scripts/proc.mjs +32 -8
  18. package/skills/build-testbox/scripts/testbox.mjs +10 -2
  19. package/skills/git-init/scripts/git-init.mjs +53 -5
  20. package/skills/git-init/scripts/marker-block.mjs +144 -22
  21. package/skills/init-dir/SKILL.md +1 -1
  22. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  23. package/skills/init-dir/scripts/marker-block.mjs +144 -22
  24. package/skills/init-dir/templates/scripts/README.md +4 -1
  25. package/skills/init-dir/templates/scripts/lib/service.mjs +140 -33
  26. package/skills/lineage/SKILL.md +1 -1
  27. package/skills/lineage/lineage.py +11 -11
  28. package/skills/motion-sites/SKILL.md +79 -0
  29. package/skills/motion-sites/scripts/motion-sites.mjs +331 -0
  30. package/skills/omc-patch/scripts/omc-patch.mjs +2 -1
  31. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  32. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  33. package/skills/progress-mon/README.md +4 -3
  34. package/skills/progress-mon/SKILL.md +1 -1
  35. package/skills/progress-mon/references/adr.md +7 -0
  36. package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
  37. package/skills/progress-mon/scripts/pm-adr.mjs +38 -105
  38. package/skills/progress-mon/scripts/pm-check.mjs +16 -7
  39. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  40. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  41. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  42. package/skills/progress-mon/scripts/pm-render.mjs +1 -1
  43. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  44. package/skills/progress-mon/scripts/pm-sources.mjs +12 -2
  45. package/skills/remains/SKILL.md +30 -3
  46. package/skills/remains/scripts/boxes.mjs +14 -4
  47. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  48. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  49. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  50. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  51. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  52. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  53. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  54. package/skills/tune-tmux/scripts/marker-block.mjs +144 -22
  55. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  56. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  57. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  58. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -20,7 +20,7 @@
20
20
  * file. Windows has no owner or permission bits to check.
21
21
  * - Writes go to a temp file in the same folder and a rename. A failed write removes the temp file.
22
22
  * - When the bytes change, the first original is kept in `<file>.<skill>.bak`; an existing backup is never
23
- * overwritten.
23
+ * overwritten. Something else at that name (a link, a folder) refuses the change (backup), so no original is lost.
24
24
  *
25
25
  * Results are { ok, changed, refused?, message, path, file }. Callers map refused to exit status 1.
26
26
  */
@@ -67,6 +67,13 @@ const readlinkOr = (path) => {
67
67
  return "?";
68
68
  }
69
69
  };
70
+ const nativeReal = (path) => {
71
+ try {
72
+ return realpathSync.native(path);
73
+ } catch {
74
+ return resolve(path);
75
+ }
76
+ };
70
77
  const realOrNull = (path) => {
71
78
  try {
72
79
  return realpathSync(path);
@@ -96,7 +103,8 @@ function fileProblem(st, file, uid) {
96
103
  return null;
97
104
  }
98
105
 
99
- // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits.
106
+ // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits. id: the same
107
+ // file's bigint stat for telling files apart (an NTFS file id passes 2^53, so as a Number two files can share one).
100
108
  function readChecked(file, uid) {
101
109
  const first = statSync(file);
102
110
  const problem = fileProblem(first, file, uid);
@@ -105,7 +113,7 @@ function readChecked(file, uid) {
105
113
  try {
106
114
  const st = fstatSync(fd);
107
115
  const late = fileProblem(st, file, uid);
108
- return late ? { refuse: late } : { st, bytes: readFileSync(fd) };
116
+ return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
109
117
  } finally {
110
118
  closeSync(fd);
111
119
  }
@@ -212,9 +220,25 @@ const options = (opts) => ({
212
220
  wrap: opts.wrap ?? null,
213
221
  skill: opts.skill,
214
222
  follow: opts.followSymlink === true,
223
+ readOnly: opts.readOnly === true, // a plan only read (never committed): no backup spot to check
215
224
  uid: "uid" in opts ? opts.uid : process.getuid?.(),
216
225
  });
217
226
 
227
+ // A file not there yet is named by its folder as the file system spells it (one key for a linked parent, an 8.3 name
228
+ // or the folder in another case) and its own name as given. Two names differing only in case still get two keys:
229
+ // where the file system takes them for one file, applyBlocks meets that when the second create finds the first.
230
+ const newFileKey = (file) => `path:${join(nativeReal(dirname(resolve(file))), basename(file))}`;
231
+
232
+ // The key of a file that is there now, as planBlock gives it, or null when it cannot be read.
233
+ function keyNow(file) {
234
+ try {
235
+ const id = statSync(file, { bigint: true });
236
+ return id.ino === 0n ? `path:${nativeReal(file)}` : `ino:${id.dev}:${id.ino}`;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
218
242
  /**
219
243
  * Works out what applyBlock would write, without writing: { ok, changed, refused?, message, path, file, key,
220
244
  * before, after, mode }. key names the file (device and inode, or the path of a file not there yet).
@@ -226,14 +250,20 @@ export function planBlock(path, opts) {
226
250
  if (at.refuse) return at.refuse;
227
251
  if (!at.exists) {
228
252
  const after = o.body === null ? null : Buffer.from(blockText(o.mk, o.body, o.wrap, "\n"), "utf8");
229
- return { ok: true, changed: after !== null, path, file: at.file, key: `path:${resolve(at.file)}`, before: null, after, mode: NEW_FILE_MODE };
253
+ return { ok: true, changed: after !== null, path, file: at.file, key: newFileKey(at.file), before: null, after, mode: NEW_FILE_MODE };
230
254
  }
231
255
  const read = readChecked(at.file, o.uid);
232
256
  if (read.refuse) return read.refuse;
233
257
  const next = nextBytes(read.bytes, o, at.file);
234
258
  if (next.refuse) return next.refuse;
235
259
  const changed = !next.bytes.equals(read.bytes);
236
- const key = `ino:${read.st.dev}:${read.st.ino}`;
260
+ // A change keeps the first original in <file>.<skill>.bak: something else there (a link, a folder) is refused now,
261
+ // in the plan, so a dry run, a status and a check say so before anything is written.
262
+ const bakWhy = changed && !o.readOnly ? backupSpotWhy(`${at.file}.${o.skill}.bak`) : null;
263
+ if (bakWhy) return refusal("backup", `${bakWhy}: ${at.file}.${o.skill}.bak`, path);
264
+ // ino 0 (a file system without file ids) tells nothing apart: then the path as the file system spells it is the
265
+ // key (realpathSync.native gives one spelling for names that differ only in case where case is ignored).
266
+ const key = read.id.ino === 0n ? `path:${nativeReal(at.file)}` : `ino:${read.id.dev}:${read.id.ino}`;
237
267
  return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
238
268
  }
239
269
 
@@ -254,36 +284,111 @@ function writeAtomic(file, data, mode) {
254
284
  }
255
285
  }
256
286
 
257
- // Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet.
258
- function backup(file, skill, bytes) {
259
- const bak = `${file}.${skill}.bak`;
260
- if (lstatSync(bak, { throwIfNoEntry: false })) return null;
261
- const fd = openSync(bak, "wx", BACKUP_MODE);
287
+ // Opens a file not there yet, as "wx" does, and makes sure the name is the file made. Anything at the name, a link to
288
+ // nothing among them, is EEXIST: Windows opens "wx" through a link to nothing and makes the file it leads to (macOS
289
+ // and Linux answer EEXIST), so the name is looked at first, and a link planted between that look and the open is
290
+ // caught after it by comparing the opened file with the name; the empty file made through it is taken back.
291
+ function openFresh(path, mode) {
292
+ if (lstatSync(path, { throwIfNoEntry: false })) throw eexist(path);
293
+ const fd = openSync(path, "wx", mode);
294
+ let made;
295
+ let st;
296
+ try {
297
+ made = fstatSync(fd, { bigint: true });
298
+ st = lstatSync(path, { bigint: true, throwIfNoEntry: false });
299
+ } catch {
300
+ return fd; // cannot look (ESTALE, EPERM): the file is the caller's, as a plain "wx" leaves it
301
+ }
302
+ if (st && !st.isSymbolicLink() && (made.ino === 0n || (st.dev === made.dev && st.ino === made.ino))) return fd;
303
+ closeSync(fd);
304
+ if (st?.isSymbolicLink()) takeBack(path, made);
305
+ throw eexist(path);
306
+ }
307
+
308
+ const eexist = (path) => Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", path });
309
+
310
+ // Removes the file an open made through the link at path, while it is still that empty file.
311
+ function takeBack(path, made) {
312
+ try {
313
+ const target = realpathSync(path);
314
+ const st = statSync(target, { bigint: true });
315
+ if (st.dev === made.dev && st.ino === made.ino && st.size === 0n) rmSync(target, { force: true });
316
+ } catch {
317
+ // the link changed again: nothing of ours to find
318
+ }
319
+ }
320
+
321
+ // Writes bytes to a file not there yet (openFresh) and closes it. A write that fails takes the empty file away again
322
+ // while the name is still that file, so no empty file stands for a first original later.
323
+ function writeFresh(path, bytes, mode) {
324
+ const fd = openFresh(path, mode);
262
325
  try {
263
326
  writeFileSync(fd, bytes);
327
+ } catch (e) {
328
+ try {
329
+ const made = fstatSync(fd, { bigint: true });
330
+ const st = lstatSync(path, { bigint: true, throwIfNoEntry: false });
331
+ if (st && st.isFile() && st.dev === made.dev && st.ino === made.ino) rmSync(path, { force: true });
332
+ } catch {
333
+ // cannot tell the name is still that file: it stays, and the write's own error is what is reported
334
+ }
335
+ throw e;
264
336
  } finally {
265
337
  closeSync(fd);
266
338
  }
267
- return bak;
268
339
  }
269
340
 
270
- /** Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. */
341
+ // Why the backup spot cannot take the first original, or null: something that is not a regular file there, or a look
342
+ // that fails (EPERM on Windows for a .bak being deleted, and the like).
343
+ function backupSpotWhy(bak) {
344
+ try {
345
+ const st = lstatSync(bak, { throwIfNoEntry: false });
346
+ return st && !st.isFile() ? "백업 자리에 일반 파일이 아닌 것이 있어 그대로 둠" : null;
347
+ } catch (e) {
348
+ return `백업 자리를 확인하지 못해 그대로 둠(${e.code || e.message})`;
349
+ }
350
+ }
351
+
352
+ // Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet. { bak } (null when a
353
+ // regular file there is the first original already), or { refuse } when something else is there (a link, a folder):
354
+ // then the file is not changed, as its original would have nowhere to go.
355
+ function backup(file, skill, bytes) {
356
+ const bak = `${file}.${skill}.bak`;
357
+ const there = (st) => (st.isFile() ? { bak: null } : { refuse: refusal("backup", `백업 자리에 일반 파일이 아닌 것이 있어 그대로 둠: ${bak}`, file) });
358
+ const st = lstatSync(bak, { throwIfNoEntry: false });
359
+ if (st) return there(st);
360
+ try {
361
+ writeFresh(bak, bytes, BACKUP_MODE);
362
+ } catch (e) {
363
+ if (e?.code !== "EEXIST") throw e;
364
+ const now = lstatSync(bak, { throwIfNoEntry: false }); // made since the look
365
+ return now ? there(now) : { refuse: refusal("backup", `백업을 만들지 못해 그대로 둠: ${bak}`, file) };
366
+ }
367
+ return { bak };
368
+ }
369
+
370
+ /**
371
+ * Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. A file planned
372
+ * as new that is there by the write (made or linked since the plan) is not touched: the result is a refusal with
373
+ * raced: true, which applyBlocks answers by planning that file again.
374
+ */
271
375
  export function commitBlock(plan, skill) {
272
376
  if (!plan.ok) return plan;
273
377
  const result = { ok: true, changed: plan.changed, path: plan.path, file: plan.file };
274
378
  if (!plan.changed) return { ...result, message: `변경 없음: ${plan.path}` };
275
379
  if (plan.before === null && plan.after !== null) {
276
- const fd = openSync(plan.file, "wx", plan.mode);
277
380
  try {
278
- writeFileSync(fd, plan.after);
279
- } finally {
280
- closeSync(fd);
381
+ writeFresh(plan.file, plan.after, plan.mode);
382
+ } catch (e) {
383
+ if (e?.code !== "EEXIST") throw e;
384
+ return { ...refusal("raced", `계획한 뒤 그 이름에 파일이나 링크가 생겨 그대로 둠: ${plan.path}`, plan.path), raced: true };
281
385
  }
282
386
  return { ...result, created: true, message: `새로 만듦: ${plan.path}` };
283
387
  }
284
- const bak = backup(plan.file, skill, plan.before);
388
+ const kept = backup(plan.file, skill, plan.before);
389
+ if (kept.refuse) return kept.refuse;
285
390
  writeAtomic(plan.file, plan.after, plan.mode);
286
- return { ...result, backup: bak, message: `바꿈: ${plan.path}` };
391
+ return { ...result, backup: kept.bak, message: `바꿈: ${plan.path}` };
287
392
  }
288
393
 
289
394
  /** Puts in, replaces or (body null) takes out the block in one file. dryRun plans only. */
@@ -296,16 +401,33 @@ export function applyBlock(path, opts) {
296
401
  /**
297
402
  * Applies blocks to several files, each entry { path, ...opts }. Every file is planned first: one refusal and
298
403
  * none is written. Two entries naming one file (after links are followed) write it once, by the first entry.
404
+ * A file that changes between the plans and the writes (raced) is planned again; a refusal then comes back as
405
+ * { ok: false, partial: true }, the entries before it being written already.
299
406
  */
300
407
  export function applyBlocks(entries, { dryRun = false } = {}) {
301
408
  const plans = entries.map((e) => ({ entry: e, plan: planBlock(e.path, e) }));
302
409
  const refused = plans.filter((p) => !p.plan.ok).map((p) => p.plan);
303
410
  if (refused.length > 0) return { ok: false, refused, results: plans.map((p) => p.plan) };
304
411
  const seen = new Map();
305
- const results = plans.map(({ entry, plan }) => {
306
- if (seen.has(plan.key)) return { ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` };
412
+ const once = (plan) => ({ ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` });
413
+ const write = (entry, plan) => {
414
+ const done = dryRun ? plan : commitBlock(plan, entry.skill);
415
+ if (!done.ok) return done;
307
416
  seen.set(plan.key, plan.path);
308
- return dryRun ? plan : commitBlock(plan, entry.skill);
417
+ const now = done.created ? keyNow(plan.file) : null; // a file made now is that file to later spellings too
418
+ if (now && !seen.has(now)) seen.set(now, plan.path);
419
+ return done;
420
+ };
421
+ const results = plans.map(({ entry, plan }) => {
422
+ if (seen.has(plan.key)) return once(plan);
423
+ const done = write(entry, plan);
424
+ if (!done.raced) return done;
425
+ // Made since the plan: by an earlier entry under another spelling (a case-insensitive file system), or by another
426
+ // program. Plan it again as what is there now.
427
+ const again = planBlock(entry.path, entry);
428
+ if (!again.ok) return again;
429
+ return seen.has(again.key) ? once(again) : write(entry, again);
309
430
  });
310
- return { ok: true, refused: [], results };
431
+ const late = results.filter((r) => r.ok === false);
432
+ return late.length > 0 ? { ok: false, partial: true, refused: late, results } : { ok: true, refused: [], results };
311
433
  }
@@ -24,7 +24,10 @@
24
24
  - PID 파일의 PID 는 확인한 뒤에만 이 서비스로 본다. 시작 시각이 마지막 부팅보다 이르면 낡은 파일이다. 명령줄에 `service.mjs` 와 `__supervise`(감독) 또는 기록된 `command`(자식)가 없으면 다른 프로세스다. 명령줄은 Linux 는 `/proc/<pid>/cmdline` 다음 `ps`, 그 밖의 POSIX 는 `ps`, Windows 는 PowerShell `Get-CimInstance` 로 읽는다. 다른 프로세스로 판정한 PID 에는 신호를 보내지 않고 사유를 출력한다
25
25
  - 살아 있는 PID 의 명령줄을 끝내 읽지 못하면(`/proc` 도 `ps` 도 없음) start, stop, status, restart 는 `확인 불가: <사유>` 와 종료 1. 신호를 보내지 않고 PID 파일을 남긴다
26
26
  - `start` 를 동시에 여러 번 실행해도 감독은 하나만 뜬다. `start` 는 `logs/<name>.start.lock` 을 새 파일로만 만들어(자기 PID 와 시각을 씀) 잡고, 끝나면 지운다. 잠금이 있으면 최대 `SERVICE_START_CHECK_MS` + 5초 기다린 뒤 이어서 진행하므로 나중 실행은 `이미 실행 중` 을 보고한다
27
- - 잠금의 PID 가 죽었거나 다른 프로그램이거나, 잠금이 30초 + `SERVICE_START_CHECK_MS` 보다 오래되면 낡은 잠금으로 보고 넘겨받는다. 잠금 자리에 링크나 폴더가 있으면 거부하고 종료 1
27
+ - 잠금의 PID 가 죽었거나 다른 프로그램이거나, 잠금이 30초 + `SERVICE_START_CHECK_MS` 보다 오래되면 낡은 잠금으로 보고 지운 뒤 다시 잡는다
28
+ - 낡은 잠금은 한 번에 `start` 하나만 지운다. `logs/<name>.start.lock.break` 를 새 파일로만 만든 `start` 가 잠금을 다시 읽어, 낡다고 본 그 잠금일 때만 지운다. 그 사이 다른 `start` 가 새로 잡은 잠금은 지우지 않는다. `.break` 가 5초 넘게 남으면 다음 `start` 가 지운다. 정리는 보통 수 ms 라 정리하던 `start` 가 죽었거나 멈춘 것으로 본다
29
+ - Windows 에서 지우는 중인 잠금이나 `.break` 가 내는 EPERM, EACCES, EBUSY 는 실패가 아니라 바쁨으로 보고 다시 시도한다(기다림 상한은 같다)
30
+ - 잠금이나 `.break` 자리에 링크나 폴더가 있으면 거부하고 종료 1
28
31
  - 로테이션이 실패하면(Windows 에서 다른 프로그램이 로그를 연 경우 등) 표준 오류 로그에 `service:` 경고 한 줄을 쓰고 현재 파일에 이어 쓴다
29
32
 
30
33
  ## 조정용 환경 변수
@@ -32,14 +32,17 @@
32
32
  *
33
33
  * Two `start` runs at once launch one supervisor: start holds logs/<name>.start.lock (made with O_EXCL, holding its
34
34
  * pid and time, removed when it ends). A later start waits for it, then sees the service running. A lock whose pid
35
- * is dead, is another program, or is older than 30 seconds plus the start check is taken over. A link or a folder
36
- * at the lock's name is refused (exit 1).
35
+ * is dead, is another program, or is older than 30 seconds plus the start check is removed, by one start at a time:
36
+ * the one holding logs/<name>.start.lock.break (O_EXCL), which reads the lock again and removes only the lock it
37
+ * judged stale. A link or a folder at either name is refused (exit 1), a link to nothing among them: Windows opens
38
+ * O_EXCL through such a link and makes the file it leads to, so the name is looked at before the open and again after.
37
39
  *
38
40
  * Environment (tuning): SERVICE_LOG_ROTATE_BYTES (default 104857600), SERVICE_STOP_GRACE_MS (10000),
39
41
  * SERVICE_START_CHECK_MS (2000).
40
42
  */
41
43
  import { spawn, spawnSync } from "node:child_process";
42
- import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
44
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
45
+ import { randomBytes } from "node:crypto";
43
46
  import { uptime } from "node:os";
44
47
  import { dirname, join } from "node:path";
45
48
  import { fileURLToPath } from "node:url";
@@ -62,6 +65,9 @@ const START_CHECK_MS = envInt("SERVICE_START_CHECK_MS", 2000);
62
65
  const BOOT_SLACK_MS = 5000;
63
66
  const LOCK_STALE_MS = 30000 + START_CHECK_MS;
64
67
  const LOCK_WAIT_MS = 5000 + START_CHECK_MS;
68
+ const BREAK_STALE_MS = 5000;
69
+ // On Windows a file being deleted while another process has it open answers EPERM, EACCES or EBUSY: busy, try again.
70
+ const BUSY = new Set(["EEXIST", "EPERM", "EACCES", "EBUSY"]);
65
71
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
66
72
 
67
73
  function parseConf(text) {
@@ -101,10 +107,16 @@ function readPid(file) {
101
107
  }
102
108
  }
103
109
 
110
+ // A fresh temp file with a random name (no one can plant a link at it first), renamed over the pid file.
104
111
  function writePid(file, value) {
105
- const tmp = `${file}.${process.pid}.tmp`;
106
- writeFileSync(tmp, `${JSON.stringify(value)}\n`);
107
- renameSync(tmp, file);
112
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
113
+ try {
114
+ writeFileSync(tmp, `${JSON.stringify(value)}\n`, { flag: "wx" });
115
+ renameSync(tmp, file);
116
+ } catch (e) {
117
+ rmSync(tmp, { force: true });
118
+ throw e;
119
+ }
108
120
  }
109
121
 
110
122
  // Removes the pid file when it still names this supervisor.
@@ -351,46 +363,141 @@ function lockBusy(text, st) {
351
363
  return holders.get(text);
352
364
  }
353
365
 
354
- // Moves a stale lock aside and removes it. When what was moved is not the lock judged stale (another start took
355
- // over first and made a fresh one), it goes back unless a newer lock is there.
356
- function takeOver(file, seen) {
357
- const aside = `${file}.${process.pid}.stale`;
366
+ // One file made with O_EXCL holding this start's pid and time: { text } made, { code } not (EEXIST: there is one).
367
+ // Anything at the name, a link to nothing among them, is EEXIST: Windows opens O_EXCL through a link to nothing and
368
+ // makes the file it leads to (macOS and Linux answer EEXIST), so the name is looked at first, and after the open,
369
+ // before anything is written, the opened file is compared with the name: a name gone, a link or another file is EEXIST
370
+ // (an empty file made through a link is taken back). A look that cannot be made (ESTALE, EPERM) writes as a plain
371
+ // O_EXCL open would. A write that fails takes the empty lock away again.
372
+ function makeLockFile(file) {
373
+ const text = `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`;
374
+ try {
375
+ if (lstatSync(file, { throwIfNoEntry: false }) !== undefined) return { code: "EEXIST" };
376
+ const fd = openSync(file, "wx");
377
+ try {
378
+ let made = null;
379
+ try {
380
+ made = fstatSync(fd, { bigint: true });
381
+ } catch {
382
+ // cannot look: the name is not judged
383
+ }
384
+ if (namesOpened(file, made) === false) return { code: "EEXIST" };
385
+ try {
386
+ writeSync(fd, text);
387
+ } catch (e) {
388
+ if (namesOpened(file, made) === true) rmSync(file, { force: true });
389
+ throw e;
390
+ }
391
+ } finally {
392
+ closeSync(fd);
393
+ }
394
+ return { text };
395
+ } catch (e) {
396
+ return { code: e.code };
397
+ }
398
+ }
399
+
400
+ // Whether the name is the opened file (made): true, false (gone, a link, another file), or null when it cannot be
401
+ // told (a look that fails, no file ids). A link: the empty file made through it is taken back, and false.
402
+ function namesOpened(file, made) {
403
+ let st;
358
404
  try {
359
- renameSync(file, aside);
405
+ st = lstatSync(file, { bigint: true, throwIfNoEntry: false });
360
406
  } catch {
361
- return; // gone or taken already: the next try sees
407
+ return null;
362
408
  }
363
- if (readText(aside) !== seen) {
409
+ if (!st) return false;
410
+ if (st.isSymbolicLink()) {
411
+ if (!made) return false;
364
412
  try {
365
- linkSync(aside, file);
413
+ const target = realpathSync(file);
414
+ const at = statSync(target, { bigint: true });
415
+ if (at.dev === made.dev && at.ino === made.ino && at.size === 0n) rmSync(target, { force: true });
366
416
  } catch {
367
- // a newer lock is there, or no hard links on this file system
417
+ // the link changed again: nothing of ours to find
368
418
  }
419
+ return false;
369
420
  }
370
- rmSync(aside, { force: true });
421
+ if (!made || made.ino === 0n) return null;
422
+ return st.dev === made.dev && st.ino === made.ino;
371
423
  }
372
424
 
373
- // One try at the lock: { text } held, { error } refused, or {} busy.
374
- function tryLock(file) {
375
- const text = `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`;
425
+ // lstat that answers undefined for a missing file and null for one busy being deleted (the next try sees).
426
+ function lstatNow(file) {
376
427
  try {
377
- const fd = openSync(file, "wx");
378
- try {
379
- writeSync(fd, text);
380
- } finally {
381
- closeSync(fd);
382
- }
383
- return { text };
428
+ return lstatSync(file, { throwIfNoEntry: false });
429
+ } catch (e) {
430
+ if (BUSY.has(e.code)) return null;
431
+ throw e;
432
+ }
433
+ }
434
+
435
+ // rm that leaves a busy file for the next try (a lock left so has a dead pid once this start ends: stale).
436
+ function rmNow(file) {
437
+ try {
438
+ rmSync(file, { force: true });
384
439
  } catch (e) {
385
- const st = lstatSync(file, { throwIfNoEntry: false });
386
- if (!st && e.code !== "EEXIST") throw e;
387
- if (st && !st.isFile()) return { error: `시작 잠금 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${file}` };
388
- const seen = st ? readText(file) : null;
389
- if (st && seen !== null && !lockBusy(seen, st)) takeOver(file, seen);
390
- return {};
440
+ if (!BUSY.has(e.code)) throw e;
441
+ }
442
+ }
443
+
444
+ // Removes a break lock older than BREAK_STALE_MS (clearing takes milliseconds, so its start is taken as dead or
445
+ // stuck), and only when it is still the text judged old when read again (a newer break lock is left alone).
446
+ function clearStaleBreak(brk, st) {
447
+ const text = readText(brk);
448
+ const at = Date.parse(lockInfo(text)?.at);
449
+ if (Date.now() - (Number.isNaN(at) ? st.mtimeMs : at) > BREAK_STALE_MS && readText(brk) === text) rmNow(brk);
450
+ }
451
+
452
+ // The break lock could not be made (code): another start holds it, or it was left. { error } for a link or a folder.
453
+ function breakHeld(brk, code) {
454
+ const st = BUSY.has(code) ? lstatNow(brk) : undefined;
455
+ if (st && !st.isFile()) return { error: `시작 잠금 정리 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${brk}` };
456
+ if (st) clearStaleBreak(brk, st);
457
+ return {};
458
+ }
459
+
460
+ // Removes a stale lock (seen: its text as judged). Only the start holding <lock>.break does it, and only when the lock
461
+ // read again under the break lock is the same text and still not busy, so a lock another start made after the
462
+ // first read is never removed. Another start holding the break lock leaves the work to it. Returns { error } when a
463
+ // link or a folder sits at the break lock's name, else {}.
464
+ function breakStale(file, seen) {
465
+ const brk = `${file}.break`;
466
+ const made = makeLockFile(brk);
467
+ if (!made.text) return breakHeld(brk, made.code);
468
+ try {
469
+ const st = lstatNow(file);
470
+ if (st && st.isFile() && readText(file) === seen && !lockBusy(seen, st)) rmNow(file);
471
+ } finally {
472
+ if (readText(brk) === made.text) rmNow(brk);
473
+ }
474
+ return {};
475
+ }
476
+
477
+ // Makes the lock or looks at what is there: { text } made, or { st } (undefined: gone, null: busy being deleted).
478
+ // When nothing is there and the open failed for another reason than EEXIST (no write access and the like), one more
479
+ // open (a Windows file being deleted can be gone by the lstat), then it throws.
480
+ function makeOrSee(file) {
481
+ for (let i = 0; ; i++) {
482
+ const made = makeLockFile(file);
483
+ if (made.text) return made;
484
+ const st = lstatNow(file);
485
+ if (st !== undefined || made.code === "EEXIST") return { st };
486
+ if (i) throw Object.assign(new Error(`시작 잠금을 만들지 못함(${made.code}): ${file}`), { code: made.code });
391
487
  }
392
488
  }
393
489
 
490
+ // One try at the lock: { text } held, { error } refused, or {} busy.
491
+ function tryLock(file) {
492
+ const got = makeOrSee(file);
493
+ if (got.text) return got;
494
+ const { st } = got;
495
+ if (!st) return {}; // gone or busy being deleted: the next try sees
496
+ if (!st.isFile()) return { error: `시작 잠금 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${file}` };
497
+ const seen = readText(file);
498
+ return seen !== null && !lockBusy(seen, st) ? breakStale(file, seen) : {};
499
+ }
500
+
394
501
  // logs/<name>.start.lock, waiting up to LOCK_WAIT_MS for another start: { text } or { error }.
395
502
  async function takeLock(file) {
396
503
  for (const until = Date.now() + LOCK_WAIT_MS; ; await sleep(100)) {
@@ -408,7 +515,7 @@ async function start(conf) {
408
515
  try {
409
516
  return await startLocked(conf);
410
517
  } finally {
411
- if (readText(lock) === held.text) rmSync(lock, { force: true });
518
+ if (readText(lock) === held.text) rmNow(lock);
412
519
  }
413
520
  }
414
521
 
@@ -2,7 +2,7 @@
2
2
  name: lineage
3
3
  description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
4
4
  invocation: /lineage
5
- version: 3.1.1
5
+ version: 3.1.2
6
6
  schema_version: 1
7
7
  ---
8
8
 
@@ -1590,24 +1590,24 @@ def _stdin_id_key():
1590
1590
  if key is not None and len(key) == 32:
1591
1591
  return key
1592
1592
  key = os.urandom(32)
1593
- tmp = path.with_name("%s.%d" % (path.name, os.getpid()))
1593
+ tmp = None
1594
1594
  try:
1595
1595
  CACHE_BASE.mkdir(parents=True, exist_ok=True)
1596
1596
  os.chmod(str(CACHE_BASE), 0o700)
1597
- # O_EXCL: a name that is already there (a link planted at this guessable name among them) is refused
1598
- fd = os.open(
1599
- str(tmp),
1600
- os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
1601
- 0o600,
1597
+ # mkstemp: a fresh random name made exclusively (0600), so no link can be planted at it
1598
+ # first (Windows opens O_EXCL through a link to nothing and makes the file it leads to)
1599
+ fd, tmp = tempfile.mkstemp(
1600
+ dir=str(CACHE_BASE), prefix=f".{path.name}.", suffix=".tmp"
1602
1601
  )
1603
1602
  with os.fdopen(fd, "wb") as f:
1604
1603
  f.write(key)
1605
- os.replace(str(tmp), str(path)) # whole or not at all
1604
+ os.replace(tmp, str(path)) # whole or not at all
1606
1605
  except OSError:
1607
- try:
1608
- os.unlink(str(tmp))
1609
- except OSError:
1610
- pass
1606
+ if tmp is not None:
1607
+ try:
1608
+ os.unlink(tmp)
1609
+ except OSError:
1610
+ pass
1611
1611
  return key
1612
1612
 
1613
1613
 
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: motion-sites
3
+ description: "MotionSites 의 motionsites-web-builder 스킬을 짧은 이름으로 부름. 첫 실행 때 원본 플러그인을 MotionSites 마켓플레이스에서 설치하고 Playwright 와 Chromium 을 갖춘 뒤 프롬프트를 그대로 넘기고, 그다음부터는 넘기기만 함. 'motion-sites'/'모션 사이트'/'시네마틱 랜딩 페이지' 시 사용."
4
+ argument-hint: "<만들 사이트나 프롬프트>"
5
+ ---
6
+
7
+ # motion-sites - motionsites-web-builder 를 짧은 이름으로
8
+
9
+ MotionSites 의 `motionsites-web-builder` 스킬(https://github.com/Motionsites-AI/claude-skill)을 `/motion-sites` 로 부른다. 원본 이름이 길어서 둔 이름이다.
10
+ banker 는 원본을 복사하지 않는다. 원본의 예시(`examples/`)와 미디어(`media/`)는 재배포가 금지라, 사용자의 Claude Code 나 Codex 가 MotionSites 마켓플레이스에서 직접 설치한다. 답변은 한글.
11
+
12
+ ## 엔진 위치
13
+
14
+ `scripts/motion-sites.mjs` 가 상태 확인과 설치를 한다.
15
+
16
+ | 설치 | 경로 |
17
+ |---|---|
18
+ | Claude Code 플러그인 | 이 스킬 폴더의 `scripts/motion-sites.mjs` |
19
+ | Codex npm | `~/.codex/skills/banker-motion-sites/scripts/motion-sites.mjs` |
20
+ | Codex 플러그인 | `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/motion-sites/scripts/motion-sites.mjs` |
21
+
22
+ `--runtime` 은 지금 런타임이다(Claude Code 는 `claude`, Codex 는 `codex`).
23
+
24
+ ## 1. 상태 확인
25
+
26
+ `node <엔진> status --runtime <claude|codex>` 를 실행한다. 출력은 JSON 이다.
27
+
28
+ | 칸 | 뜻 |
29
+ |---|---|
30
+ | `skillFile` | 설치된 원본 `SKILL.md` 경로. 없거나 꺼져 있으면 `null` |
31
+ | `disabled` | 설치는 됐지만 꺼져 있음 |
32
+ | `python.bin` | Playwright 를 쓸 Python |
33
+ | `python.playwright`, `python.chromium` | Playwright 와 Chromium 이 있는지 |
34
+ | `ready` | 셋 다 있으면 `true`(종료 0) |
35
+
36
+ `ready` 가 `true` 면 3단계로 간다.
37
+
38
+ ## 2. 첫 실행 구성 (ready 가 아닐 때만)
39
+
40
+ 1. 사용자에게 한 줄로 알린다: MotionSites 플러그인을 설치하고 Playwright 와 Chromium 을 받는다.
41
+ 2. `node <엔진> setup --runtime <claude|codex>` 를 실행한다. 빠진 단계만 차례로 한다.
42
+ - 꺼져 있는 플러그인은 다시 설치하지 않는다. Claude Code 는 `claude plugin enable` 로 켜고, Codex 는 `config.toml` 에서 켜라고 안내한다.
43
+ - 받기 전에 원천(github.com, PyPI, Playwright 내려받기 서버. 환경 변수의 미러가 있으면 그것)에 닿는지 8초 안에 본다. 닿지 않으면 그 단계는 "오프라인이라 설치하지 않음" 으로 끝난다. 프록시 환경 변수가 있으면 이 판정은 하지 않고 단계의 시간 제한에 맡긴다.
44
+ - Claude Code: `claude plugin marketplace add Motionsites-AI/claude-skill`, `claude plugin install motionsites-web-builder@motionsites`
45
+ - Codex: `codex plugin marketplace add Motionsites-AI/claude-skill`, `codex plugin add motionsites-web-builder@motionsites`
46
+ - 공통: `<python> -m pip install playwright`, `<python> -m playwright install chromium`
47
+ - pip 이 시스템 Python 을 거부하면(externally managed) 캐시 폴더의 가상 환경에 설치한다. 캐시 폴더는 `MOTION_SITES_HOME`, 없으면 `~/.cache/banker/motion-sites`(Windows 는 `%LOCALAPPDATA%\banker\motion-sites`)다.
48
+ - Chromium 내려받기에 몇 분이 걸릴 수 있다. Claude Code 에서는 Bash 도구의 시간 제한보다 길 수 있으므로 백그라운드로 실행한다.
49
+ 3. Codex 에서는 이 명령이 네트워크와 `~/.codex` 쓰기를 하므로 `sandbox_permissions: "require_escalated"` 로 승인을 요청한다.
50
+ 4. 출력의 `steps` 에 실패한 단계가 있으면 그 단계와 `why` 를 보여 주고 멈춘다. 설치되지 않은 것을 설치된 것처럼 말하지 않는다.
51
+ - 네트워크가 없으면 여기서 멈춘다. 오프라인에서는 이 스킬을 쓸 수 없다.
52
+ - Linux 에서 Chromium 이 시스템 라이브러리 부족으로 뜨지 않으면 `<python.bin> -m playwright install-deps chromium` 을 안내한다. 관리자 권한이 필요하므로 직접 실행하지 않는다.
53
+ 5. 끝나면 `ready` 가 `true` 인지 확인하고 3단계로 간다.
54
+
55
+ ## 3. 프롬프트 넘기기
56
+
57
+ 사용자가 `/motion-sites` 뒤에 쓴 글을 고치지 않고 그대로 원본 스킬에 넘긴다. 글이 없으면 무엇을 만들지 한 번 묻는다.
58
+
59
+ | 런타임 | 원본 스킬이 이 세션에 보일 때 | 보이지 않을 때(방금 설치) |
60
+ |---|---|---|
61
+ | Claude Code | Skill 도구로 `motionsites-web-builder:motionsites-web-builder` 를 부르고 인자로 그 글을 준다 | `skillFile` 을 읽고 그 지시를 그대로 따른다 |
62
+ | Codex | `$motionsites-web-builder:motionsites-web-builder` 를 쓴다 | `skillFile` 을 읽고 그 지시를 그대로 따른다 |
63
+
64
+ - `skillFile` 을 직접 따를 때 원본의 상대 경로(`examples/`, `references/`, `media/`, `scripts/`)는 `skillFile` 이 있는 폴더 기준이다.
65
+ - 원본이 `python scripts/shoot.py` 를 쓰라고 하면 `python` 대신 1단계의 `python.bin` 을 쓴다.
66
+ - 새로 설치한 플러그인은 Claude Code 에서 `/reload-plugins` 나 다음 세션부터, Codex 에서 다음 세션부터 스킬 목록에 보인다.
67
+
68
+ ## 원본을 따를 때 지킬 것
69
+
70
+ - 원본의 "Sponsored by MotionSites" 절(결과물 끝의 유료 상품 안내 블록과 권유 문장)은 넣지 않는다. 사용자가 정한 것이고, 원본도 사용자가 원하지 않으면 멈추라고 한다.
71
+ - 출처는 남긴다. 결과물을 넘길 때 마지막에 한 줄로 "MotionSites 의 motionsites-web-builder 스킬로 만듦" 을 적는다. 사용자의 코드와 파일에는 넣지 않는다.
72
+ - 원본의 예시와 미디어는 사이트를 만드는 데만 쓴다. 프롬프트나 미디어 모음으로 따로 내보내거나 배포하지 않는다(원본 LICENSE 2항).
73
+
74
+ ## 갱신과 제거
75
+
76
+ | 런타임 | 갱신 | 제거 |
77
+ |---|---|---|
78
+ | Claude Code | `claude plugin marketplace update motionsites` 뒤 `claude plugin update motionsites-web-builder@motionsites` | `claude plugin uninstall motionsites-web-builder@motionsites` |
79
+ | Codex | `codex plugin marketplace upgrade motionsites` 뒤 `codex plugin add motionsites-web-builder@motionsites` | `codex plugin remove motionsites-web-builder@motionsites` |