@kaydash9999/banker-plugins 0.20.2 → 0.21.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 (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +203 -1
  4. package/PRIVACY.md +34 -4
  5. package/README.md +46 -15
  6. package/bin/banker.js +228 -51
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +33 -16
  9. package/codex/manifest.json +14 -11
  10. package/codex/skills/graceful-pause/SKILL.md +30 -0
  11. package/codex/skills/progress/SKILL.md +42 -0
  12. package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
  13. package/codex/skills/progress/scripts/progress.mjs +295 -0
  14. package/codex/transform-matrix.md +19 -14
  15. package/commands/setup.md +23 -0
  16. package/hooks/obsidize-hook.mjs +116 -4
  17. package/hooks/progress-plan.mjs +1 -1
  18. package/hooks/progress.mjs +143 -38
  19. package/hooks/register.mjs +13 -3
  20. package/hooks/telemetry-count-codex.mjs +222 -0
  21. package/hooks/update-checkin.mjs +15 -7
  22. package/hooks/update-fetch.mjs +15 -9
  23. package/hooks/update-notify.mjs +30 -17
  24. package/package.json +1 -1
  25. package/skills/3d-intro-build/SKILL.md +11 -5
  26. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-build/references/curate.mjs +48 -14
  28. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  29. package/skills/3d-intro-build/references/prompts.md +4 -4
  30. package/skills/3d-intro-build/references/serve.mjs +6 -1
  31. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  32. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  33. package/skills/3d-intro-setup/SKILL.md +41 -10
  34. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  35. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  36. package/skills/anti-drm/SKILL.md +6 -3
  37. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  38. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  39. package/skills/append_wiki/SKILL.md +34 -8
  40. package/skills/audit-mock/SKILL.md +1 -1
  41. package/skills/audit-security/SKILL.md +64 -2
  42. package/skills/compact-wiki/SKILL.md +37 -6
  43. package/skills/deep-research/SKILL.md +11 -0
  44. package/skills/docs-setup/SKILL.md +37 -7
  45. package/skills/lfm/SKILL.md +148 -0
  46. package/skills/lineage/SKILL.md +25 -15
  47. package/skills/lineage/lineage.py +564 -301
  48. package/skills/motion-graphic-make/SKILL.md +1 -1
  49. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  50. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  51. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  52. package/skills/motion-graphic-setup/SKILL.md +58 -13
  53. package/skills/obsidizer/SKILL.md +30 -11
  54. package/skills/obsidizer/obsidize.mjs +50 -12
  55. package/skills/omc-patch/SKILL.md +86 -23
  56. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  57. package/skills/payload-mon/SKILL.md +6 -2
  58. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  59. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  60. package/skills/pdf-vision-extract/SKILL.md +11 -6
  61. package/skills/ralph-qa/SKILL.md +125 -151
  62. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  63. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  64. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  65. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  66. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  67. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  68. package/skills/rfp-author/SKILL.md +5 -3
  69. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  70. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  71. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  72. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  73. package/skills/setup-harness-factory/SKILL.md +17 -1
  74. package/skills/setup-insane-search/SKILL.md +16 -0
  75. package/skills/setup-java/SKILL.md +20 -1
  76. package/skills/setup-lsp/SKILL.md +56 -13
  77. package/skills/setup-mcp/SKILL.md +73 -4
  78. package/skills/setup-node/SKILL.md +31 -6
  79. package/skills/setup-omc/SKILL.md +24 -0
  80. package/skills/setup-omc-hud/SKILL.md +42 -9
  81. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  82. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  83. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  84. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  85. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  86. package/skills/setup-playwright/SKILL.md +45 -2
  87. package/skills/setup-pwsh/SKILL.md +19 -0
  88. package/skills/setup-python/SKILL.md +45 -7
  89. package/skills/setup-sandbox/SKILL.md +30 -5
  90. package/skills/setup-stitch/SKILL.md +22 -0
  91. package/skills/setup-tmux/SKILL.md +24 -4
  92. package/skills/smart-compact/SKILL.md +58 -12
  93. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  94. package/skills/tone-compact/SKILL.md +2 -1
  95. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  96. package/skills/ultra-chain/SKILL.md +12 -7
  97. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  98. package/skills/ultra-chain/references/serve.mjs +6 -1
  99. package/skills/ultra-interview/SKILL.md +14 -2
  100. package/skills/update-banker/SKILL.md +19 -3
  101. package/skills/vertical-pptx/SKILL.md +6 -3
  102. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -64,10 +64,28 @@ const MEDIA = /\.(png|jpe?g|webp|gif|mp4|webm|mov)$/i;
64
64
  function linkRefusal(dir, fp) {
65
65
  let real;
66
66
  try { real = fs.realpathSync(fp); } catch { return null; }
67
+ return realRefusal(dir, real);
68
+ }
69
+
70
+ function realRefusal(dir, real) {
67
71
  if (!real.startsWith(fs.realpathSync(dir) + path.sep)) return '링크가 프로젝트 폴더 밖을 가리켜 보이지 않습니다.';
68
72
  return MEDIA.test(real) ? null : '링크가 이미지나 영상이 아닌 파일을 가리켜 보이지 않습니다.';
69
73
  }
70
74
 
75
+ // What a listed path may be served as: { dev, ino } (BigInt) of the file its links lead to, taken without
76
+ // following a link, so a link put in its place after this check is not that file. null when there is
77
+ // no such file, or its links leave the project or end at a file that is not an image or a video.
78
+ function servedFile(dir, fp) {
79
+ try {
80
+ const real = fs.realpathSync(fp);
81
+ if (realRefusal(dir, real)) return null;
82
+ const st = fs.lstatSync(real, { bigint: true });
83
+ return { dev: st.dev, ino: st.ino };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
71
89
  // Why a listed file is neither shown nor served nor hashed, or null when it may be: it must be an
72
90
  // image or a video inside the project, by its path and by where its links lead, so a key file or
73
91
  // a record listed by mistake never leaves the folder.
@@ -103,9 +121,11 @@ export function readDecisions(dir) {
103
121
  }
104
122
 
105
123
  // Written whole or not at all: a crash mid-write leaves the previous file.
124
+ // The temp name is random and opened exclusively: a link a repository put at a guessable name is never written through.
106
125
  function writeJson(file, value) {
107
- fs.writeFileSync(`${file}.tmp`, JSON.stringify(value, null, 2) + '\n');
108
- fs.renameSync(`${file}.tmp`, file);
126
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
127
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' });
128
+ try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
109
129
  }
110
130
 
111
131
  const writeDecisions = (dir, all) => writeJson(path.join(dir, DECISIONS), all);
@@ -247,20 +267,26 @@ export function archiveRejected(dir, { now = new Date() } = {}) {
247
267
  // path turns into one after the first check).
248
268
  const hashes = new Map();
249
269
  const notRegular = () => new Error('일반 파일 아님');
270
+ // A file changed within this long of a hash may change again in the same timestamp step (2 s on FAT):
271
+ // its hash is not kept, so the next view reads it again.
272
+ const RACY_MS = 3000n;
273
+ const keyOf = (fp, st) => `${fp}:${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`;
274
+ const racy = (st, startedNs) => startedNs - (st.ctimeNs > st.mtimeNs ? st.ctimeNs : st.mtimeNs) < RACY_MS * 1_000_000n;
250
275
  export async function hashFile(fp) {
251
276
  if (!(await fs.promises.stat(fp)).isFile()) throw notRegular();
252
277
  const fh = await fs.promises.open(fp, READ_FLAGS);
253
278
  let done;
254
279
  try {
255
- const st = await fh.stat();
280
+ const startedNs = BigInt(Date.now()) * 1_000_000n;
281
+ const st = await fh.stat({ bigint: true });
256
282
  if (!st.isFile()) throw notRegular();
257
- const key = `${fp}:${st.ino}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`;
283
+ const key = keyOf(fp, st);
258
284
  done = hashes.get(key);
259
285
  if (done) return done;
260
- done = sha256Of(fh, st.size);
261
- done.catch(() => hashes.delete(key));
286
+ done = sha256Of(fh, Number(st.size));
262
287
  hashes.set(key, done);
263
288
  await done.catch(() => {});
289
+ if (racy(st, startedNs) || !(await done.then(() => true, () => false))) hashes.delete(key);
264
290
  } finally {
265
291
  await fh.close();
266
292
  }
@@ -287,7 +313,10 @@ async function hashText(dir, file) {
287
313
 
288
314
  // The hash text of every listed take by file, four files at a time per call (a long list must not
289
315
  // run out of file handles), before the page is drawn.
290
- async function takeHashes(input, dir) {
316
+ // A take not hashed within waitMs shows that its read goes on; the next view shows its hash.
317
+ const HASH_WAIT_MS = 5000;
318
+ const STILL_READING = '읽는 중(새로 고치면 보임)';
319
+ async function takeHashes(input, dir, waitMs = HASH_WAIT_MS) {
291
320
  const files = [...new Set(input.scenes.flatMap((s) => s.takes.map((t) => t.file)))];
292
321
  const out = new Map();
293
322
  let next = 0;
@@ -297,8 +326,12 @@ async function takeHashes(input, dir) {
297
326
  out.set(f, await hashText(dir, f));
298
327
  }
299
328
  };
300
- await Promise.all(Array.from({ length: Math.min(4, files.length) }, worker));
301
- return out;
329
+ const all = Promise.all(Array.from({ length: Math.min(4, files.length) }, worker));
330
+ let timer;
331
+ const late = new Promise((resolve) => { timer = setTimeout(resolve, waitMs); });
332
+ await Promise.race([all, late]);
333
+ clearTimeout(timer);
334
+ return new Map(files.map((f) => [f, out.has(f) ? out.get(f) : STILL_READING]));
302
335
  }
303
336
 
304
337
  function media(t, dir) {
@@ -366,11 +399,11 @@ function summary(input, entries) {
366
399
  }
367
400
 
368
401
  /** The review page for `input`. Resolves once every listed take is hashed. */
369
- export async function renderPage(input, decisions, { dir }) {
402
+ export async function renderPage(input, decisions, { dir, hashWaitMs }) {
370
403
  const stage = decisions[input.stage] || { scenes: [] };
371
404
  const entries = new Map(stage.scenes.map((e) => [e.id, { ...e, rejected: e.rejected || [] }]));
372
405
  const promptLabel = input.promptLabel || (INSTRUCTION_STAGES.has(input.stage) ? '구성 지시문' : '프롬프트');
373
- const ctx = { dir, promptLabel, entries, hashes: await takeHashes(input, dir) };
406
+ const ctx = { dir, promptLabel, entries, hashes: await takeHashes(input, dir, hashWaitMs) };
374
407
  const body = input.scenes.map((s, i) => sceneBlock(s, i, ctx)).join('\n') || '<p class="empty">curate-input.json 에 장면이 없습니다.</p>';
375
408
  return `<!doctype html>
376
409
  <html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -505,8 +538,9 @@ async function page(dir, res) {
505
538
  function serveListed(dir, req, res, urlPath) {
506
539
  const fp = resolveInside(dir, urlPath);
507
540
  if (!fp) return send(res, 403, '403');
508
- if (!listedFiles(dir).has(fp) || linkRefusal(dir, fp)) return send(res, 404, '404');
509
- sendFile(req, res, fp);
541
+ const expect = listedFiles(dir).has(fp) ? servedFile(dir, fp) : null;
542
+ if (!expect) return send(res, 404, '404');
543
+ sendFile(req, res, fp, {}, { expect });
510
544
  }
511
545
 
512
546
  function route(dir, req, res, urlPath) {
@@ -546,7 +580,7 @@ export async function startCurate(dir, { port, host = process.env.PREVIEW_HOST |
546
580
  // Starts hashing the listed takes, so the first view of the page does not wait for a large render.
547
581
  // An input that cannot be read hashes nothing here; the page reports it.
548
582
  function warmHashes(dir) {
549
- try { return takeHashes(readInput(dir), dir).catch(() => new Map()); } catch { return Promise.resolve(new Map()); }
583
+ try { return takeHashes(readInput(dir), dir, 2 ** 31 - 1).catch(() => new Map()); } catch { return Promise.resolve(new Map()); }
550
584
  }
551
585
 
552
586
  // ---- CLI ------------------------------------------------------------------------
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * Runtime: Node >=18 builtins only (node:fs / node:http / node:net / node:path). No shell.
18
18
  */
19
+ import crypto from 'node:crypto';
19
20
  import fs from 'node:fs';
20
21
  import http from 'node:http';
21
22
  import net from 'node:net';
23
+ import os from 'node:os';
22
24
  import path from 'node:path';
23
- import { pipeline } from 'node:stream';
25
+ import { Transform, pipeline } from 'node:stream';
24
26
 
25
27
  export const LOOPBACK = '127.0.0.1';
26
28
  // Answers { pid } so a stop can tell this server from another process on the same port.
@@ -80,39 +82,57 @@ export function send(res, status, body, headers = {}) {
80
82
  export const READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0);
81
83
 
82
84
  // An open regular file: { fd, size }, or { status } when there is none (404) or it cannot be read (403).
83
- function openRegular(fp) {
85
+ // With `expect` ({ dev, ino } of the file a check passed, as BigInt), another file at the path is none:
86
+ // the path changed between that check and this open. BigInt, since an NTFS file id is 64 bits and a
87
+ // Number keeps only 53: two files could read as one.
88
+ function openRegular(fp, expect) {
84
89
  let fd;
85
90
  try { fd = fs.openSync(fp, READ_FLAGS); } catch (e) { return { status: ['ENOENT', 'ENOTDIR', 'EISDIR'].includes(e.code) ? 404 : 403 }; }
86
91
  let st = null;
87
- try { st = fs.fstatSync(fd); } catch { /* a status the system cannot give (EIO on a network drive) */ }
88
- if (st && st.isFile()) return { fd, size: st.size };
92
+ try { st = fs.fstatSync(fd, { bigint: true }); } catch { /* a status the system cannot give (EIO on a network drive) */ }
93
+ if (st && st.isFile() && sameFile(st, expect)) return { fd, size: Number(st.size) };
89
94
  fs.closeSync(fd);
90
95
  return { status: st ? 404 : 403 };
91
96
  }
92
97
 
98
+ const sameFile = (st, expect) => !expect || (st.dev === expect.dev && st.ino === expect.ino);
99
+
93
100
  // Sends the bytes `start`..`end` of an open file (what the headers declared, even if the file grows
94
101
  // meanwhile) and closes it, also when the browser drops the download midway (a pipe would keep the
95
102
  // file open). Nothing to send closes the file and ends the response.
96
- function stream(res, fp, { fd, start, end }) {
97
- if (end < start) {
103
+ // A HEAD request gets the headers only: the file is closed unread.
104
+ function stream(res, fp, { fd, start, end, head }) {
105
+ if (head || end < start) {
98
106
  fs.closeSync(fd);
99
107
  return void res.end();
100
108
  }
101
- pipeline(fs.createReadStream(fp, { fd, start, end }), res, () => {});
109
+ pipeline(fs.createReadStream(fp, { fd, start, end }), declaredLength(end - start + 1), res, () => {});
110
+ }
111
+
112
+ // Passes the bytes on and fails at the end when they were fewer than the headers declared (the file
113
+ // shrank since): the pipeline then destroys the response, which closes the connection. Ended as
114
+ // usual, it would leave the browser waiting on a kept-alive connection for bytes that never come.
115
+ function declaredLength(want) {
116
+ let sent = 0;
117
+ return new Transform({
118
+ transform(chunk, _enc, done) { sent += chunk.length; done(null, chunk); },
119
+ flush(done) { done(sent < want ? new Error('the file is shorter than its declared length') : null); },
120
+ });
102
121
  }
103
122
 
104
123
  // A file, with byte ranges for video (smoother scrubbing). Never cached: a rebuilt page is fresh.
105
124
  // The file is opened before any header goes out, so one that cannot be read gets 403 instead of
106
- // ending the server.
107
- export function sendFile(req, res, fp, headers = {}) {
108
- const f = openRegular(fp);
125
+ // ending the server. `expect` ({ dev, ino }) names the only file that may go out (404 otherwise).
126
+ export function sendFile(req, res, fp, headers = {}, { expect } = {}) {
127
+ const f = openRegular(fp, expect);
109
128
  if (f.status) return send(res, f.status, String(f.status), headers);
129
+ f.head = req.method === 'HEAD';
110
130
  const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
111
131
  const base = { ...headers, 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
112
132
  const range = type.startsWith('video/') ? /bytes=(\d+)-(\d*)/.exec(req.headers.range || '') : null;
113
133
  if (range) return sendRange(res, fp, f, range, base, headers);
114
134
  res.writeHead(200, { ...base, 'Content-Length': f.size });
115
- stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1 });
135
+ stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1, head: f.head });
116
136
  }
117
137
 
118
138
  function sendRange(res, fp, f, range, base, headers) {
@@ -124,7 +144,7 @@ function sendRange(res, fp, f, range, base, headers) {
124
144
  return void res.end();
125
145
  }
126
146
  res.writeHead(206, { ...base, 'Content-Range': `bytes ${start}-${end}/${f.size}`, 'Content-Length': end - start + 1 });
127
- stream(res, fp, { fd: f.fd, start, end });
147
+ stream(res, fp, { fd: f.fd, start, end, head: f.head });
128
148
  }
129
149
 
130
150
  // True when the request was the PID question, which this answers.
@@ -169,8 +189,12 @@ export const portMatches = (st, port) => !port || st.port === port || st.request
169
189
  // The state file of a server for `dir`: beside the folder, so a deployed folder never carries it.
170
190
  export const siblingState = (dir) => path.join(path.dirname(dir), `${path.basename(dir)}.server.json`);
171
191
 
192
+ // Through a fresh random name opened exclusively, then renamed over the old file: a link at the state
193
+ // file's name (one a repository can bring) is replaced, never written through.
172
194
  export function writeState(file, info) {
173
- fs.writeFileSync(file, JSON.stringify(info, null, 2) + '\n');
195
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`;
196
+ fs.writeFileSync(tmp, JSON.stringify(info, null, 2) + '\n', { flag: 'wx' });
197
+ try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
174
198
  }
175
199
 
176
200
  export function readState(file) {
@@ -220,10 +244,22 @@ async function waitClosed(port, timeoutMs, host) {
220
244
  // A PID a state file may name: a whole number above 1 (never a process group, never init).
221
245
  const validPid = (pid) => Number.isInteger(pid) && pid > 1;
222
246
 
247
+ // A host a state file may name: this machine (loopback, a wildcard, an address of its own). A state file
248
+ // can come with a repository: its host must not send a request elsewhere, nor pick the PID a signal goes to.
249
+ // A record a stop or a start may act on: a PID that is a process id, on this machine.
250
+ const recordedHere = (st) => validPid(st.pid) && thisMachine(st.host);
251
+
252
+ // No host is the default loopback one. A loopback address counts only as an IPv4 address of 127/8: `127.evil.test`
253
+ // or `127.256.0.1` is a DNS name, resolved anywhere (a resolver's search domain may answer it).
254
+ export function thisMachine(host) {
255
+ if (!host || ['0.0.0.0', '::', '::1', 'localhost'].includes(host) || (net.isIPv4(String(host)) && String(host).startsWith('127.'))) return true;
256
+ return Object.values(os.networkInterfaces()).flat().some((a) => a && a.address === host);
257
+ }
258
+
223
259
  /** True when the server a state file records still answers on its port with its PID. */
224
260
  export async function isRunning(stateFile) {
225
261
  const st = readState(stateFile);
226
- return Boolean(st && st.port) && validPid(st.pid) && (await askPid(st.port, 1500, st.host)) === st.pid;
262
+ return Boolean(st && st.port) && recordedHere(st) && (await askPid(st.port, 1500, st.host)) === st.pid;
227
263
  }
228
264
 
229
265
  function signal(pid, sig) {
@@ -236,7 +272,7 @@ function signal(pid, sig) {
236
272
  export async function stopServer(stateFile, { timeoutMs = 3000 } = {}) {
237
273
  const st = readState(stateFile);
238
274
  if (!st || !st.port) return { stopped: false, reason: 'no-state' };
239
- if (!validPid(st.pid)) return { stopped: false, reason: 'invalid-state' };
275
+ if (!recordedHere(st)) return { stopped: false, reason: 'invalid-state' };
240
276
  const who = await askPid(st.port, Math.min(timeoutMs, 1500), st.host);
241
277
  if (who === NONE) {
242
278
  dropState(stateFile);
@@ -80,7 +80,7 @@ Subject: [SCENE.subject — 미니어처 장면을 묘사: 건물/공간, 일하
80
80
 
81
81
  - `size` = `ORIENTATION` (`720x1280` 세로 / `1280x720` 가로). Sora 크기와 반드시 일치시킨다.
82
82
  - `quality` = `low`(미리보기·저렴) 또는 `high`(최종).
83
- - **N3 주의:** v1 경로가 실패해 classic 폴백이 뜨면 반환 크기가 `1024x1536`(2:3)라 `720x1280` 이 아니다. `createVideo` 에 넣기 전에 Sora 크기로 리사이즈(ffmpeg `scale`)하거나 재생성한다. v1 경로(`720x1280`)가 검증된 happy path 다.
83
+ - **N3 주의:** v1 경로가 실패해 classic 폴백이 뜨면 반환 크기가 `1024x1536`(2:3)라 `720x1280` 이 아니다. 영상 씨앗(`generateClip` 의 `firstFramePng`)으로 쓰기 전에 ORIENTATION 크기로 리사이즈(ffmpeg `scale`)하거나 재생성한다. v1 경로(`720x1280`)가 검증된 happy path 다.
84
84
 
85
85
  ### FLUX 코히전 옵트인 (`generateImageFlux` / FLUX.2-pro)
86
86
 
@@ -90,14 +90,14 @@ Subject: [SCENE.subject — 미니어처 장면을 묘사: 건물/공간, 일하
90
90
 
91
91
  ---
92
92
 
93
- ## 4. Fly-through — forward-chaining leg 프롬프트 (`Sora-2`, `createVideo`)
93
+ ## 4. Fly-through — forward-chaining leg 프롬프트 (`generateClip`: WAN 키 풀이 먼저, 쓸 수 있는 키가 없으면 `Sora-2`)
94
94
 
95
95
  Azure `/videos` 는 `input_reference` 를 **딱 하나만** 받는다(두-이미지는 400).
96
96
  그래서 연속성은 **forward-chaining** 으로 만든다: 이전 클립의 **실제 마지막 프레임**을 다음 클립의 `input_reference` 로 넣는다.
97
97
  아래 굵은 절이 **모션 핸드오프 계약**이다 — 글자 그대로 유지하고, 씬별 표현은 가운데 MOVE 슬롯에 넣는다.
98
98
 
99
99
  **Leg 0 (씨앗) — 씬 1 스틸에서 dive-in:**
100
- `input_reference = 씬 1 스틸`.
100
+ `firstFramePng = 씬 1 스틸`.
101
101
 
102
102
  ```
103
103
  Single continuous cinematic camera move, no cuts. Begin high and far, looking down at the
@@ -142,7 +142,7 @@ tilt. It only travels straight and level, the world sliding past beneath the sam
142
142
  잔잔한 전진 활공의 한 컷으로 읽혀야 한다(옆으로 흐르는 모션블러·완결 안 된 오빗 금지).
143
143
  아니면 그 leg 를 재생성한다 — 나쁜 핸드오프 프레임 하나가 뒤의 모든 leg 를 오염시킨다.
144
144
 
145
- 어댑터 인자: `model: 'sora-2'`, `size: ORIENTATION`, `seconds`(기본 4; 예산·길이에 따라 조정).
145
+ `generateClip` 인자: `size: ORIENTATION`, `seconds`(기본 5. WAN 은 정수 2~30초, Sora 로 폴백되면 4, 8, 12초 중 가장 가까운 값), `firstFramePng`. 모델과 provider 순서는 키 파일(`WAN_MODEL`, `AZURE_SORA_DEPLOYMENT`, `VIDEO_PROVIDER_ORDER`)이 정한다. `createVideo` 를 직접 부르지 않는다. 키 풀, 폴백, 이중 과금 방지를 건너뛴다(SKILL.md Step 4).
146
146
 
147
147
  ---
148
148
 
@@ -95,6 +95,9 @@ async function stopMain(o, dir) {
95
95
  const sameSettings = (st, o, dir) => path.resolve(String(st.dir || '')) === dir && (st.csp || null) === cspFrom(o.csp)
96
96
  && (st.host || LOOPBACK) === (process.env.PREVIEW_HOST || LOOPBACK) && portMatches(st, o.port);
97
97
 
98
+ // A value in one quoted piece, with "?" for what a reader takes for a line end or what changes how text shows.
99
+ const oneLine = (v) => JSON.stringify(String(v)).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
100
+
98
101
  // A server already running for this state file: the same settings only report its address; other
99
102
  // settings (folder, CSP, host, port) never pass for it, so a check meant to run under a CSP cannot
100
103
  // run without one, and a page meant for one folder or port is not checked on another.
@@ -105,7 +108,9 @@ async function reportRunning(recorded, o, dir) {
105
108
  console.log(`PREVIEW ${st.url} (already running, pid ${st.pid})`);
106
109
  return true;
107
110
  }
108
- console.error(`serve: the state file records a server running with different settings (folder: ${st.dir}, `
111
+ // the folder in one quoted piece, and as "?" what a reader takes for a line end or what changes how text shows (a
112
+ // line or paragraph separator, NEL, a bidi override, a tag character): none of it adds a line to what an agent reads
113
+ console.error(`serve: the state file records a server running with different settings (folder: ${oneLine(st.dir)}, `
109
114
  + `csp: ${st.csp ? 'on' : 'off'}, host: ${st.host}, port: ${st.port}); stop it with --stop and start again`);
110
115
  process.exit(1);
111
116
  }
@@ -18,7 +18,7 @@
18
18
  * [--viewports 1280x800,390x844t] [--no-input] [--no-contrast] [--playwright-from-project]
19
19
  * --viewports a trailing t marks a touch screen, r reduced motion (default: the six below)
20
20
  * --header the site's fixed header (default: the engine's .sw-topbar)
21
- * --playwright-from-project also look in the current folder's node_modules, first (your own project only)
21
+ * --playwright-from-project also look in the current folder's node_modules, before the global npm root (your own project only)
22
22
  * Exit 0 when nothing failed, 1 otherwise; the report (JSON) goes to --out or stdout.
23
23
  *
24
24
  * Needs Playwright with Chromium (setup-playwright skill: npx playwright install chromium), found
@@ -138,17 +138,24 @@ function globalRoot() {
138
138
  return String(r.stdout || '').trim();
139
139
  }
140
140
 
141
+ // The playwright entry outside this script's folder: the project's when asked, else the global root's.
142
+ function outsideEntry(fromProject) {
143
+ const fromHere = fromProject ? resolveFrom(process.cwd()) : null;
144
+ if (fromHere) return fromHere;
145
+ const root = globalRoot();
146
+ return root ? resolveFrom(root) : null;
147
+ }
148
+
141
149
  /**
142
- * The playwright package: beside this script, else in the global npm root. The project's own
143
- * node_modules (the current folder) is read only with `fromProject` (--playwright-from-project), and
144
- * then first: a repository can ship a node_modules of its own, and loading it runs its code.
150
+ * The playwright package: beside this script (or in a folder above it), else in the global npm root.
151
+ * The project's own node_modules (the current folder) is read only with `fromProject`
152
+ * (--playwright-from-project), and then before the global root: a repository can ship a node_modules
153
+ * of its own, and loading it runs its code. `importBeside` replaces the first import (tests).
145
154
  * `npx playwright install chromium` alone fetches the browser, not this package.
146
155
  */
147
- export async function loadPlaywright({ fromProject = false } = {}) {
148
- try { return await import('playwright'); } catch { /* not beside this script */ }
149
- const fromHere = fromProject ? resolveFrom(process.cwd()) : null;
150
- const root = fromHere ? null : globalRoot();
151
- const entry = fromHere || (root && resolveFrom(root));
156
+ export async function loadPlaywright({ fromProject = false, importBeside = () => import('playwright') } = {}) {
157
+ try { return await importBeside(); } catch { /* not beside this script */ }
158
+ const entry = outsideEntry(fromProject);
152
159
  if (entry) {
153
160
  const mod = await import(pathToFileURL(entry).href);
154
161
  return mod.chromium ? mod : mod.default;