@litfamily/litopencode 1.0.28 → 1.0.30

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 (156) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README-Ko-KR.md +7 -7
  3. package/README.md +7 -7
  4. package/dist/activation-primary-prompts.js +15 -6
  5. package/dist/activation-prompt-utils.d.ts +2 -2
  6. package/dist/activation-prompt-utils.js +9 -3
  7. package/dist/activation-reentry.d.ts +29 -0
  8. package/dist/activation-reentry.js +71 -0
  9. package/dist/activation-routing.d.ts +1 -1
  10. package/dist/activation-routing.js +40 -7
  11. package/dist/activation-workflow-prompts.d.ts +2 -0
  12. package/dist/activation-workflow-prompts.js +32 -7
  13. package/dist/activation.d.ts +6 -1
  14. package/dist/activation.js +34 -7
  15. package/dist/cli/args.js +14 -0
  16. package/dist/cli/auto-update.js +2 -4
  17. package/dist/cli/command-plan.d.ts +40 -0
  18. package/dist/cli/command-plan.js +62 -0
  19. package/dist/cli/doctor.d.ts +4 -1
  20. package/dist/cli/doctor.js +65 -4
  21. package/dist/cli/host-detect.d.ts +58 -0
  22. package/dist/cli/host-detect.js +199 -0
  23. package/dist/cli/install-report.js +3 -1
  24. package/dist/cli/install.d.ts +9 -1
  25. package/dist/cli/install.js +134 -31
  26. package/dist/cli/managed-skill-assets.d.ts +1 -1
  27. package/dist/cli/managed-skill-assets.js +5 -3
  28. package/dist/cli/office-dependencies.d.ts +2 -0
  29. package/dist/cli/office-dependencies.js +7 -3
  30. package/dist/cli/scientific-visualization-dependencies.js +1 -1
  31. package/dist/cli/tui-config.d.ts +27 -2
  32. package/dist/cli/tui-config.js +77 -2
  33. package/dist/cli/types.d.ts +17 -0
  34. package/dist/cli.js +9 -6
  35. package/dist/commands.d.ts +10 -1
  36. package/dist/commands.js +17 -3
  37. package/dist/deliverable-hedge-guard.js +21 -9
  38. package/dist/features.d.ts +27 -1
  39. package/dist/features.js +40 -8
  40. package/dist/host/server.d.ts +7 -0
  41. package/dist/host/server.js +12 -0
  42. package/dist/host/tui.d.ts +7 -0
  43. package/dist/host/tui.js +12 -0
  44. package/dist/server.js +11 -1
  45. package/dist/skill-renames.d.ts +1 -1
  46. package/dist/skills.d.ts +9 -2
  47. package/dist/skills.js +22 -8
  48. package/dist/tui/mission-source-v2.d.ts +51 -0
  49. package/dist/tui/mission-source-v2.js +52 -0
  50. package/dist/v2/agents.d.ts +105 -0
  51. package/dist/v2/agents.js +185 -0
  52. package/dist/v2/commands.d.ts +44 -0
  53. package/dist/v2/commands.js +65 -0
  54. package/dist/v2/context.d.ts +67 -0
  55. package/dist/v2/context.js +17 -0
  56. package/dist/v2/events.d.ts +55 -0
  57. package/dist/v2/events.js +193 -0
  58. package/dist/v2/hooks.d.ts +39 -0
  59. package/dist/v2/hooks.js +61 -0
  60. package/dist/v2/server.d.ts +2 -0
  61. package/dist/v2/server.js +68 -0
  62. package/dist/v2/session-hooks.d.ts +64 -0
  63. package/dist/v2/session-hooks.js +230 -0
  64. package/dist/v2/tools.d.ts +26 -0
  65. package/dist/v2/tools.js +49 -0
  66. package/dist/v2/tui.d.ts +63 -0
  67. package/dist/v2/tui.js +259 -0
  68. package/docs/assets/cover-motion-still.webp +0 -0
  69. package/docs/assets/cover-motion.webp +0 -0
  70. package/docs/assets/cover.webp +0 -0
  71. package/docs/assets/readme/badge-version.svg +1 -1
  72. package/docs/assets/readme/litfamily-machines.png +0 -0
  73. package/docs/migration.md +4 -3
  74. package/docs/reference-Ko-KR.md +109 -7
  75. package/docs/reference.md +122 -9
  76. package/package.json +10 -5
  77. package/rules/bundled-rules/lit-humanizer.md +2 -0
  78. package/skills/deep-interview/SKILL.md +1 -1
  79. package/skills/lit-brainstorm/SKILL.md +299 -0
  80. package/skills/lit-brainstorm/references/handoff-briefs.md +72 -0
  81. package/skills/lit-brainstorm/references/lenses.md +106 -0
  82. package/skills/lit-brainstorm/references/scoring-and-convergence.md +93 -0
  83. package/skills/lit-brainstorm/references/session-notes-and-lanes.md +122 -0
  84. package/skills/lit-docx/SKILL.md +1 -1
  85. package/skills/lit-docx/run.py +42 -4
  86. package/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -0
  87. package/skills/lit-docx/templates/latex/acs/template.tex +2 -0
  88. package/skills/lit-docx/templates/latex/elsevier/template.tex +2 -0
  89. package/skills/lit-docx/templates/latex/ieee/template.tex +2 -0
  90. package/skills/lit-docx/templates/latex/korean-generic/template.tex +2 -0
  91. package/skills/lit-docx/templates/latex/nature/template.tex +2 -0
  92. package/skills/lit-humanizer/SKILL.md +4 -0
  93. package/skills/lit-humanizer/scripts/core.mjs +81 -1
  94. package/skills/lit-humanizer/scripts/detect.mjs +4 -3
  95. package/skills/lit-humanizer/scripts/guard-scan.mjs +12 -6
  96. package/skills/lit-pptx/SKILL.md +1 -1
  97. package/skills/lit-pptx/command.mjs +94 -0
  98. package/skills/lit-pptx/run.mjs +16 -6
  99. package/skills/lit-pptx/scripts/compile-deck.js +2 -2
  100. package/skills/lit-typographic-motion/SKILL.md +5 -2
  101. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +3 -0
  102. package/skills/lit-typographic-motion/engine/browser.mjs +3 -9
  103. package/skills/lit-typographic-motion/engine/command.mjs +94 -0
  104. package/skills/lit-typographic-motion/engine/constants.mjs +29 -1
  105. package/skills/lit-typographic-motion/engine/cover/assets.mjs +91 -0
  106. package/skills/lit-typographic-motion/engine/cover/capture.mjs +173 -0
  107. package/skills/lit-typographic-motion/engine/cover/decode.mjs +173 -0
  108. package/skills/lit-typographic-motion/engine/cover/frame-worker.mjs +19 -0
  109. package/skills/lit-typographic-motion/engine/cover/gate.mjs +261 -0
  110. package/skills/lit-typographic-motion/engine/cover/image.mjs +138 -0
  111. package/skills/lit-typographic-motion/engine/cover/render.mjs +323 -0
  112. package/skills/lit-typographic-motion/engine/cover/sheet.mjs +65 -0
  113. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +110 -0
  114. package/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  115. package/skills/lit-typographic-motion/engine/look.mjs +113 -10
  116. package/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  117. package/skills/lit-typographic-motion/engine/stage/capture.mjs +26 -18
  118. package/skills/lit-typographic-motion/engine/stage/cover-init.js +38 -0
  119. package/skills/lit-typographic-motion/engine/stage/loop-kit.js +102 -0
  120. package/skills/lit-typographic-motion/engine/stage/render.mjs +4 -4
  121. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  122. package/skills/lit-typographic-motion/fonts/JetBrainsMono-OFL.txt +93 -0
  123. package/skills/lit-typographic-motion/fonts/JetBrainsMono-wght-latin.woff2 +0 -0
  124. package/skills/lit-typographic-motion/fonts/pins.json +3 -1
  125. package/skills/lit-typographic-motion/gate.mjs +34 -1
  126. package/skills/lit-typographic-motion/probe.mjs +8 -3
  127. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +16 -0
  128. package/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +35 -0
  129. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +16 -0
  130. package/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +29 -0
  131. package/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +17 -0
  132. package/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  133. package/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +17 -0
  134. package/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +35 -0
  135. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +62 -0
  136. package/skills/lit-typographic-motion/recipes/orbit-loop/index.html +100 -0
  137. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +95 -0
  138. package/skills/lit-typographic-motion/recipes/painted-light/index.html +232 -0
  139. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +62 -0
  140. package/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  141. package/skills/lit-typographic-motion/references/cover.md +423 -0
  142. package/skills/lit-typographic-motion/references/craft-loop.md +2 -0
  143. package/skills/lit-typographic-motion/references/runtime.md +17 -2
  144. package/skills/lit-typographic-motion/references/stage.md +3 -0
  145. package/skills/lit-typographic-motion/references/treatment.md +4 -0
  146. package/skills/lit-typographic-motion/render.mjs +30 -6
  147. package/skills/lit-typographic-motion/runtime.mjs +12 -13
  148. package/skills/{durable-litgoal → litgoal}/SKILL.md +31 -12
  149. package/skills/managed-skill-manifest.json +175 -31
  150. package/skills/review-work/SKILL.md +27 -18
  151. package/skills/skill-rename-aliases.json +2 -1
  152. package/tools/check-pack-payload.mjs +4 -1
  153. package/tools/payload-substance-allowlist.json +1 -1
  154. package/tools/payload-substance-parity.json +6 -4
  155. package/tools/qa-cover-recipes.mjs +167 -0
  156. package/tools/version-manifests.json +6 -0
@@ -2,10 +2,12 @@
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { parseRules, scanText } from "./core.mjs";
5
+ import { negationClusterFindings, parseRules, scanText } from "./core.mjs";
6
6
 
7
7
  const here = dirname(fileURLToPath(import.meta.url));
8
8
  const maxInputBytes = 8 * 1024 * 1024;
9
+ // The host refuses worker output over its byte cap and then allows the write, so blocks go first and the list is bounded.
10
+ const maxFindings = 1000;
9
11
 
10
12
  try {
11
13
  const input = readFileSync(0);
@@ -15,21 +17,25 @@ try {
15
17
  throw new Error("guard input must contain file, oldText, and newText strings");
16
18
  }
17
19
  const rules = parseRules(readFileSync(join(here, "../rules.json"), "utf8"));
18
- const oldFindings = scanText(request.oldText, rules, request.file);
20
+ const scan = (text) => [...scanText(text, rules, request.file), ...negationClusterFindings(text, request.file)];
21
+ // A document-scope finding is keyed by its occurrence lines, so an edit between two old occurrences does not re-raise it.
22
+ const keyOf = (finding) => JSON.stringify([finding.rule, finding.severity, finding.occurrences ?? finding.excerpt]);
23
+ const oldFindings = scan(request.oldText);
19
24
  const remaining = new Map();
20
25
  for (const finding of oldFindings) {
21
- const key = JSON.stringify([finding.rule, finding.severity, finding.excerpt]);
26
+ const key = keyOf(finding);
22
27
  remaining.set(key, (remaining.get(key) ?? 0) + 1);
23
28
  }
24
- const findings = scanText(request.newText, rules, request.file).filter((finding) => {
25
- const key = JSON.stringify([finding.rule, finding.severity, finding.excerpt]);
29
+ const findings = scan(request.newText).filter((finding) => {
30
+ const key = keyOf(finding);
26
31
  const count = remaining.get(key) ?? 0;
27
32
  if (count === 0) return true;
28
33
  if (count === 1) remaining.delete(key);
29
34
  else remaining.set(key, count - 1);
30
35
  return false;
31
36
  });
32
- process.stdout.write(`${JSON.stringify({ findings })}\n`);
37
+ const ordered = [...findings.filter((finding) => finding.severity === "block"), ...findings.filter((finding) => finding.severity !== "block")];
38
+ process.stdout.write(`${JSON.stringify({ findings: ordered.slice(0, maxFindings) })}\n`);
33
39
  } catch (error) {
34
40
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
35
41
  process.exitCode = 2;
@@ -43,7 +43,7 @@ Under `lit`, create a `.pptx` by default. Do not ask a theme, colour or font que
43
43
 
44
44
  ## #contract.procedure
45
45
 
46
- 1. **Runtime.** Nothing to install by hand. The first `node <skill>/run.mjs compile …` installs the pinned Node lockfile into the LitOpenCode cache and prints one notice line; the first Python helper call does the same for its venv. `node <skill>/run.mjs doctor` reports readiness without installing. A failed first-use install (no network, no `npm`, no Python 3) is a capability failure: quote the error line and stop; never install anything globally or inside the skill.
46
+ 1. **Runtime.** Nothing to install by hand. The first `node <skill>/run.mjs compile …` installs the pinned Node lockfile into the LitOpenCode cache and prints one notice line; the first Python helper call does the same for its venv. `node <skill>/run.mjs doctor` reports readiness without installing. A failed first-use install (no network, no `npm`, no Python 3.10 to 3.14; the venv comes from the newest `python3.X` in that range, a newer `python3` such as 3.15 is passed over, and `LITOPENCODE_OFFICE_PYTHON` names one interpreter instead) is a capability failure: quote the error line and stop; never install anything globally or inside the skill.
47
47
 
48
48
  2. **Choose the direction first, and write the direction card.** Before the first slide is written, decide the look and record it. Follow `references/direction-step.md`:
49
49
  - Settle four facts and write any inference down: deck type, reader, delivery (presented or read) and language.
@@ -0,0 +1,94 @@
1
+ // Finding and starting commands on every platform. Windows keeps npm and most shims as `.cmd` files:
2
+ // Node refuses to spawn one without a shell (CVE-2024-27980, `spawn EINVAL`), a bare name never
3
+ // matches `ffmpeg.exe`, and a child's lines end in CRLF. Each rule here is injectable, so a test
4
+ // proves the Windows answer on any machine.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ /** Lines of a child's output, whichever line ending it printed. */
10
+ export const splitLines = (text) => String(text ?? "").split(/\r?\n/u);
11
+
12
+ /** The path of a command on PATH. On Windows the PATHEXT extensions are tried and a bare name never matches. */
13
+ export function which(name, env = process.env, platform = process.platform) {
14
+ const windows = platform === "win32";
15
+ const extensions = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((ext) => ext.toLowerCase());
16
+ const names = !windows ? [name]
17
+ : [...(extensions.some((ext) => name.toLowerCase().endsWith(ext)) ? [name] : []), ...extensions.map((ext) => name + ext)];
18
+ for (const dir of (env.PATH ?? env.Path ?? "").split(windows ? ";" : path.delimiter)) {
19
+ if (!dir) continue;
20
+ for (const candidate of names) {
21
+ const file = path.join(dir, candidate);
22
+ if (existsSync(file)) return file;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ const shim = /\.(?:cmd|bat)$/iu;
29
+
30
+ /** One cmd.exe argument in double quotes; what quotes cannot protect is refused, so a value never becomes shell syntax. */
31
+ export function quoteForCmd(value) {
32
+ if (/["%\r\n\0]/u.test(value)) throw new Error("A Windows command argument may not contain a quote, a percent sign or a line break.");
33
+ return `"${value.replace(/(\\+)$/u, "$1$1")}"`;
34
+ }
35
+
36
+ /** { command, args, shell } for spawnSync: a `.cmd` or `.bat` shim runs through a shell with quoted arguments. */
37
+ export function commandPlan(command, args, { platform = process.platform, env = process.env, find = (name) => which(name, env, "win32") } = {}) {
38
+ if (platform !== "win32") return { command, args, shell: false };
39
+ const resolved = command.includes("/") || command.includes("\\") ? command : find(command) ?? command;
40
+ return shim.test(resolved) ? { command: quoteForCmd(resolved), args: args.map(quoteForCmd), shell: true } : { command: resolved, args, shell: false };
41
+ }
42
+
43
+ /** npm through Node and its own CLI script when one is at hand, else through the quoted shim. */
44
+ export function npmPlan(args, { platform = process.platform, env = process.env, execPath = process.execPath, exists = existsSync, find = (name) => which(name, env, "win32") } = {}) {
45
+ if (platform !== "win32") return { command: "npm", args, shell: false };
46
+ if (env.npm_execpath && /\.[cm]?js$/iu.test(env.npm_execpath) && /^node(?:\.exe)?$/iu.test(path.win32.basename(execPath))) {
47
+ return { command: execPath, args: [env.npm_execpath, ...args], shell: false };
48
+ }
49
+ const found = find("npm");
50
+ if (found && shim.test(found)) {
51
+ const directory = path.win32.dirname(found);
52
+ const cli = path.win32.join(directory, "node_modules", "npm", "bin", "npm-cli.js");
53
+ if (exists(cli)) {
54
+ const sibling = path.win32.join(directory, "node.exe");
55
+ return { command: exists(sibling) ? sibling : find("node") ?? "node", args: [cli, ...args], shell: false };
56
+ }
57
+ }
58
+ return commandPlan("npm", args, { platform, env, find });
59
+ }
60
+
61
+ /** Runs `command` once to read its Python version: { ok, command, version } when it starts, else ok false. */
62
+ export function probePython(command, env = process.env) {
63
+ const launch = commandPlan(command, ["-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"], { env });
64
+ const result = spawnSync(launch.command, launch.args, { encoding: "utf8", env, shell: launch.shell, windowsHide: true, timeout: 15000 });
65
+ if (result.error?.code === "ENOENT") return { ok: false, command, detail: "not found" };
66
+ if (result.error || result.status !== 0) return { ok: false, command, detail: (result.stderr || result.error?.message || "did not start").trim() };
67
+ return { ok: true, command, version: result.stdout.trim() };
68
+ }
69
+
70
+ /**
71
+ * The Python a venv of hash-pinned wheels is built from. The pins stop at a minor version, so a newer
72
+ * python3 (such as 3.15) fails pip on the first package without a wheel. `override` is used alone when
73
+ * set; otherwise the newest python3.<max> down to python3.<min> on PATH, then python3 and python.
74
+ * Returns { ok, command, version } or { ok: false, detail } naming each interpreter it passed over.
75
+ */
76
+ export function supportedPython({ min, max, override, env = process.env, probe = probePython }) {
77
+ const range = `3.${min} to 3.${max}`;
78
+ const check = (command) => {
79
+ const found = probe(command, env);
80
+ if (!found.ok) return found;
81
+ const [major, minor] = found.version.split(".").map(Number);
82
+ if (major !== 3 || minor < min) return { ok: false, command, detail: `${found.version} is older than 3.${min}` };
83
+ if (minor > max) return { ok: false, command, detail: `${found.version} is newer than 3.${max}` };
84
+ return found;
85
+ };
86
+ const candidates = override ? [override] : [...Array.from({ length: max - min + 1 }, (_, index) => `python3.${max - index}`), "python3", "python"];
87
+ const passed = [];
88
+ for (const command of candidates) {
89
+ const found = check(command);
90
+ if (found.ok) return found;
91
+ if (override || found.detail !== "not found") passed.push(`${command}: ${found.detail}`);
92
+ }
93
+ return { ok: false, detail: `no supported Python (${range})${passed.length > 0 ? `; ${passed.join(", ")}` : " on PATH"}` };
94
+ }
@@ -6,6 +6,7 @@ import { homedir, tmpdir } from "node:os";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { spawnSync } from "node:child_process";
9
+ import { commandPlan, npmPlan, supportedPython } from "./command.mjs";
9
10
 
10
11
  const root = path.dirname(fileURLToPath(import.meta.url));
11
12
  const cache = path.join(process.env.XDG_CACHE_HOME || path.join(homedir(), ".cache"), "litopencode", "office");
@@ -20,12 +21,19 @@ const commands = {
20
21
  validate: ["python", "scripts/validate_pptx.py"],
21
22
  };
22
23
 
24
+ // Windows: Node refuses to spawn a .cmd shim without a shell (spawn EINVAL) and a bare name never
25
+ // matches npm.cmd or python.exe; command.mjs plans the launch (npm through Node and its own CLI).
26
+ const isWindows = process.platform === "win32";
27
+
23
28
  function run(binary, args, options = {}) {
24
- const result = spawnSync(binary, args, { stdio: "inherit", cwd: root, ...options });
25
- if (result.error) throw result.error;
29
+ const launch = binary === "npm" ? npmPlan(args) : commandPlan(binary, args);
30
+ const result = spawnSync(launch.command, launch.args, { stdio: "inherit", cwd: root, shell: launch.shell, windowsHide: true, ...options });
31
+ if (result.error) throw new Error(`${binary} could not start: ${result.error.message}`);
26
32
  if (result.status !== 0) process.exit(result.status || 1);
27
33
  }
28
34
 
35
+ const venvPython = (venv) => path.join(venv, ...(isWindows ? ["Scripts", "python.exe"] : ["bin", "python"]));
36
+
29
37
  function prepareNode() {
30
38
  if (existsSync(path.join(nodeRoot, "node_modules", "pptxgenjs"))) return;
31
39
  mkdirSync(cache, { recursive: true });
@@ -42,14 +50,16 @@ function prepareNode() {
42
50
  }
43
51
 
44
52
  function preparePython() {
45
- const binary = path.join(pythonRoot, "bin", "python");
53
+ const binary = venvPython(pythonRoot);
46
54
  if (existsSync(binary)) return binary;
47
55
  mkdirSync(cache, { recursive: true });
48
56
  const temporary = mkdtempSync(path.join(cache, "python-install-"));
49
57
  try {
50
58
  process.stderr.write("Installing LitOpenCode office Python dependencies in its cache.\n");
51
- run("python3", ["-m", "venv", temporary]);
52
- run(path.join(temporary, "bin", "python"), ["-m", "pip", "install", "--disable-pip-version-check", "--require-hashes", "-r", path.join(root, "requirements.lock")]);
59
+ const python = supportedPython({ min: 10, max: 14, override: process.env.LITOPENCODE_OFFICE_PYTHON });
60
+ if (!python.ok) throw new Error(`${python.detail}. Install Python 3.10 to 3.14, or set LITOPENCODE_OFFICE_PYTHON to one.`);
61
+ run(python.command, ["-m", "venv", temporary]);
62
+ run(venvPython(temporary), ["-m", "pip", "install", "--disable-pip-version-check", "--require-hashes", "-r", path.join(root, "requirements.lock")]);
53
63
  renameSync(temporary, pythonRoot);
54
64
  } finally {
55
65
  if (existsSync(temporary)) rmSync(temporary, { recursive: true, force: true });
@@ -59,7 +69,7 @@ function preparePython() {
59
69
 
60
70
  const [command, ...args] = process.argv.slice(2);
61
71
  if (command === "doctor") {
62
- console.log(JSON.stringify({ nodeReady: existsSync(path.join(nodeRoot, "node_modules", "pptxgenjs")), pythonReady: existsSync(path.join(pythonRoot, "bin", "python")) }));
72
+ console.log(JSON.stringify({ nodeReady: existsSync(path.join(nodeRoot, "node_modules", "pptxgenjs")), pythonReady: existsSync(venvPython(pythonRoot)) }));
63
73
  } else if (Object.hasOwn(commands, command)) {
64
74
  const [kind, script] = commands[command];
65
75
  const binary = kind === "node" ? (prepareNode(), "node") : preparePython();
@@ -451,7 +451,7 @@ if (require.main === module) {
451
451
  // Run Python boilerplate engine for PPTX
452
452
  await new Promise((resolve, reject) => {
453
453
  const pyArgs = [pyScript, tmpAstPath, "--output", pptxPath];
454
- execFile("python3", pyArgs, { cwd: scriptDir }, (error, stdout, stderr) => {
454
+ execFile(process.platform === "win32" ? "python" : "python3", pyArgs, { cwd: scriptDir }, (error, stdout, stderr) => {
455
455
  // Clean up temp file
456
456
  try { fs.unlinkSync(tmpAstPath); } catch (_) {}
457
457
  if (error) {
@@ -475,7 +475,7 @@ if (require.main === module) {
475
475
  // Optional: embed bundled fonts into the PPTX for a self-contained deck.
476
476
  if (embedFonts && pptxPath) {
477
477
  const { execFileSync } = require("child_process");
478
- execFileSync("python3", [path.join(__dirname, "embed_fonts.py"), pptxPath], {
478
+ execFileSync(process.platform === "win32" ? "python" : "python3", [path.join(__dirname, "embed_fonts.py"), pptxPath], {
479
479
  stdio: "inherit",
480
480
  });
481
481
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lit-typographic-motion
3
- description: Direct a finished film from a written treatment. Films that show things take the stage path (your own HTML, SVG, Canvas or WebGL page, captured frame by frame); films made of the words alone take the type path. Both are gated and looked at. Use for a new film or video, including a bare lit request with video wording.
3
+ description: Direct a finished film from a written treatment. Films that show things take the stage path (your own HTML, SVG, Canvas or WebGL page, captured frame by frame); films made of the words alone take the type path. Both are gated and looked at. A README cover loop takes cover mode. Use for a new film or video, including a bare lit request with video wording.
4
4
  reader_projection: shared_rule
5
5
  ---
6
6
 
@@ -34,6 +34,7 @@ Read the request, supplied words, facts, audio and any named style. Treat reques
34
34
  | Stage film | `node render.mjs stage --out <d> --round N` | film, stills, gate |
35
35
  | Type look | `node render.mjs film --brief <b> --out <d> --stills-only --round 1` | stills set, no encode |
36
36
  | Type film | `node render.mjs film --brief <b> --out <d> --round N` | film, stills, gate |
37
+ | Cover | `node render.mjs cover --out <d> --round N` | `cover.webp`, decoded stills, strict gate |
37
38
  | Sound | `node render.mjs sound --out <d>` | `sound.wav`, `sound-cues.json` |
38
39
  | Look | `node render.mjs look --out <d> --round N --answers <file>` | one round appended to `look.json` |
39
40
  | Re-gate | `node gate.mjs <d>` | gate report |
@@ -41,6 +42,8 @@ Read the request, supplied words, facts, audio and any named style. Treat reques
41
42
 
42
43
  ## #contract.procedure
43
44
 
45
+ A README, hero, banner or cover loop is not a film: follow `references/cover.md` instead of the steps below.
46
+
44
47
  1. Load `references/treatment.md` only, and write `treatment.json` in the output directory: idea, subject, beats on the genre's arc, devices, copy, palette, sound (generated by default under `lit`), ambition.
45
48
  2. Its `path` decides what to load next. Stage: `references/stage.md`, then write `index.html` in a `stage` folder of the output directory, using `/lit/stage-kit.js` and `/lit/fonts.css`. Type: `references/authoring.md`, `references/type-craft.md` and `references/style-bibles.md` (MO-B-00 preset pick), then write the brief.
46
49
  3. Round 1 (stills): run the look command with `--stills-only`, open every still with the read tool, answer the nine questions in `references/craft-loop.md`, name the weakest beat and the change, record it with `look --round 1`, then make that change.
@@ -70,7 +73,7 @@ The evidence is the treatment, the gate report, the manifest, `look.json` and th
70
73
 
71
74
  ## #contract.hard_stops
72
75
 
73
- On BLOCKED exit 10 (Chrome), 11 (WebGL2), 12 (ffmpeg; stills still work), 14 (runtime not pre-warmed) or 15 (font missing or hash mismatch), stop, name the state and its fix command; never install inside the session. Exit 16 (treatment), 17 (stage contract), 18 (stage not deterministic), 19 (network request) and 20 (sound) name the cause to fix. A flash-audit failure withholds the exports in every round.
76
+ On BLOCKED exit 10 (Chrome), 11 (WebGL2), 12 (ffmpeg; stills still work), 14 (runtime not pre-warmed) or 15 (font missing or hash mismatch), stop, name the state and its fix command; never install inside the session. Exit 21 (no cover encoder) and 22 (a listed cover asset is missing; never draw a stand-in) stop too. Exit 16 (treatment), 17 (stage contract), 18 (stage not deterministic), 19 (network request) and 20 (sound) name the cause to fix. A flash-audit failure withholds the exports in every round.
74
77
 
75
78
  ## #contract.anti_patterns
76
79
 
@@ -8,6 +8,9 @@ Fonts bundled in this skill (paths relative to this file):
8
8
  - VT323, fonts/VT323-Regular.ttf: SIL OFL 1.1, licence at fonts/VT323-OFL.txt
9
9
  - Silkscreen, fonts/Silkscreen-Regular.ttf and fonts/Silkscreen-Bold.ttf: SIL OFL 1.1, licence at fonts/Silkscreen-OFL.txt
10
10
  - EMS single-stroke SVG fonts (EMSAllure, EMSFelix, EMSOsmotron, EMSReadability, EMSTech), fonts/stroke/*.svg: SIL OFL 1.1, licence at fonts/stroke/OFL.txt, credits at fonts/stroke/CREDITS
11
+ - JetBrains Mono, variable wght 100-800, Latin subset, fonts/JetBrainsMono-wght-latin.woff2 (unmodified file
12
+ files/jetbrains-mono-latin-wght-normal.woff2 of @fontsource-variable/jetbrains-mono 5.3.0; served to cover pages
13
+ only): SIL OFL 1.1, no Reserved Font Name, licence at fonts/JetBrainsMono-OFL.txt
11
14
 
12
15
  Fonts reused by path from the sibling lit-pptx skill:
13
16
  - Pretendard Regular and Bold, ../lit-pptx/pretendard-font/public/static/Pretendard-{Regular,Bold}.otf: SIL OFL 1.1, licence at ../lit-pptx/pretendard-font/LICENSE.txt
@@ -5,17 +5,11 @@ import { existsSync, mkdirSync, rmSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { chromeFlagRungs } from "./constants.mjs";
8
+ import { splitLines, which } from "./command.mjs";
8
9
 
9
10
  const macChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
10
11
 
11
- export function which(name, env = process.env) {
12
- for (const dir of (env.PATH ?? "").split(path.delimiter)) {
13
- if (!dir) continue;
14
- const candidate = path.join(dir, name);
15
- if (existsSync(candidate)) return candidate;
16
- }
17
- return undefined;
18
- }
12
+ export { which };
19
13
 
20
14
  export function findChrome(env = process.env) {
21
15
  if (env.CHROME_PATH) return existsSync(env.CHROME_PATH) ? { path: env.CHROME_PATH } : { missing: `CHROME_PATH does not exist: ${env.CHROME_PATH}` };
@@ -29,7 +23,7 @@ export function findChrome(env = process.env) {
29
23
 
30
24
  export function chromeVersion(executable) {
31
25
  const result = spawnSync(executable, ["--version"], { encoding: "utf8", timeout: 10000 });
32
- return result.status === 0 ? result.stdout.trim() : `version unavailable (${(result.stderr || result.error?.message || "").split("\n")[0]})`;
26
+ return result.status === 0 ? result.stdout.trim() : `version unavailable (${splitLines(result.stderr || result.error?.message || "")[0]})`;
33
27
  }
34
28
 
35
29
  export async function loadPlaywright(cacheDir) {
@@ -0,0 +1,94 @@
1
+ // Finding and starting commands on every platform. Windows keeps npm and most shims as `.cmd` files:
2
+ // Node refuses to spawn one without a shell (CVE-2024-27980, `spawn EINVAL`), a bare name never
3
+ // matches `ffmpeg.exe`, and a child's lines end in CRLF. Each rule here is injectable, so a test
4
+ // proves the Windows answer on any machine.
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ /** Lines of a child's output, whichever line ending it printed. */
10
+ export const splitLines = (text) => String(text ?? "").split(/\r?\n/u);
11
+
12
+ /** The path of a command on PATH. On Windows the PATHEXT extensions are tried and a bare name never matches. */
13
+ export function which(name, env = process.env, platform = process.platform) {
14
+ const windows = platform === "win32";
15
+ const extensions = (env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean).map((ext) => ext.toLowerCase());
16
+ const names = !windows ? [name]
17
+ : [...(extensions.some((ext) => name.toLowerCase().endsWith(ext)) ? [name] : []), ...extensions.map((ext) => name + ext)];
18
+ for (const dir of (env.PATH ?? env.Path ?? "").split(windows ? ";" : path.delimiter)) {
19
+ if (!dir) continue;
20
+ for (const candidate of names) {
21
+ const file = path.join(dir, candidate);
22
+ if (existsSync(file)) return file;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ const shim = /\.(?:cmd|bat)$/iu;
29
+
30
+ /** One cmd.exe argument in double quotes; what quotes cannot protect is refused, so a value never becomes shell syntax. */
31
+ export function quoteForCmd(value) {
32
+ if (/["%\r\n\0]/u.test(value)) throw new Error("A Windows command argument may not contain a quote, a percent sign or a line break.");
33
+ return `"${value.replace(/(\\+)$/u, "$1$1")}"`;
34
+ }
35
+
36
+ /** { command, args, shell } for spawnSync: a `.cmd` or `.bat` shim runs through a shell with quoted arguments. */
37
+ export function commandPlan(command, args, { platform = process.platform, env = process.env, find = (name) => which(name, env, "win32") } = {}) {
38
+ if (platform !== "win32") return { command, args, shell: false };
39
+ const resolved = command.includes("/") || command.includes("\\") ? command : find(command) ?? command;
40
+ return shim.test(resolved) ? { command: quoteForCmd(resolved), args: args.map(quoteForCmd), shell: true } : { command: resolved, args, shell: false };
41
+ }
42
+
43
+ /** npm through Node and its own CLI script when one is at hand, else through the quoted shim. */
44
+ export function npmPlan(args, { platform = process.platform, env = process.env, execPath = process.execPath, exists = existsSync, find = (name) => which(name, env, "win32") } = {}) {
45
+ if (platform !== "win32") return { command: "npm", args, shell: false };
46
+ if (env.npm_execpath && /\.[cm]?js$/iu.test(env.npm_execpath) && /^node(?:\.exe)?$/iu.test(path.win32.basename(execPath))) {
47
+ return { command: execPath, args: [env.npm_execpath, ...args], shell: false };
48
+ }
49
+ const found = find("npm");
50
+ if (found && shim.test(found)) {
51
+ const directory = path.win32.dirname(found);
52
+ const cli = path.win32.join(directory, "node_modules", "npm", "bin", "npm-cli.js");
53
+ if (exists(cli)) {
54
+ const sibling = path.win32.join(directory, "node.exe");
55
+ return { command: exists(sibling) ? sibling : find("node") ?? "node", args: [cli, ...args], shell: false };
56
+ }
57
+ }
58
+ return commandPlan("npm", args, { platform, env, find });
59
+ }
60
+
61
+ /** Runs `command` once to read its Python version: { ok, command, version } when it starts, else ok false. */
62
+ export function probePython(command, env = process.env) {
63
+ const launch = commandPlan(command, ["-c", "import sys; print('%d.%d.%d' % sys.version_info[:3])"], { env });
64
+ const result = spawnSync(launch.command, launch.args, { encoding: "utf8", env, shell: launch.shell, windowsHide: true, timeout: 15000 });
65
+ if (result.error?.code === "ENOENT") return { ok: false, command, detail: "not found" };
66
+ if (result.error || result.status !== 0) return { ok: false, command, detail: (result.stderr || result.error?.message || "did not start").trim() };
67
+ return { ok: true, command, version: result.stdout.trim() };
68
+ }
69
+
70
+ /**
71
+ * The Python a venv of hash-pinned wheels is built from. The pins stop at a minor version, so a newer
72
+ * python3 (such as 3.15) fails pip on the first package without a wheel. `override` is used alone when
73
+ * set; otherwise the newest python3.<max> down to python3.<min> on PATH, then python3 and python.
74
+ * Returns { ok, command, version } or { ok: false, detail } naming each interpreter it passed over.
75
+ */
76
+ export function supportedPython({ min, max, override, env = process.env, probe = probePython }) {
77
+ const range = `3.${min} to 3.${max}`;
78
+ const check = (command) => {
79
+ const found = probe(command, env);
80
+ if (!found.ok) return found;
81
+ const [major, minor] = found.version.split(".").map(Number);
82
+ if (major !== 3 || minor < min) return { ok: false, command, detail: `${found.version} is older than 3.${min}` };
83
+ if (minor > max) return { ok: false, command, detail: `${found.version} is newer than 3.${max}` };
84
+ return found;
85
+ };
86
+ const candidates = override ? [override] : [...Array.from({ length: max - min + 1 }, (_, index) => `python3.${max - index}`), "python3", "python"];
87
+ const passed = [];
88
+ for (const command of candidates) {
89
+ const found = check(command);
90
+ if (found.ok) return found;
91
+ if (override || found.detail !== "not found") passed.push(`${command}: ${found.detail}`);
92
+ }
93
+ return { ok: false, detail: `no supported Python (${range})${passed.length > 0 ? `; ${passed.join(", ")}` : " on PATH"}` };
94
+ }
@@ -14,7 +14,9 @@ export const EXIT = Object.freeze({
14
14
  STAGE_CONTRACT_ERROR: 17,
15
15
  STAGE_NONDETERMINISTIC: 18,
16
16
  STAGE_NETWORK_REQUEST: 19,
17
- SOUND_INVALID: 20
17
+ SOUND_INVALID: 20,
18
+ BLOCKED_NO_COVER_ENCODER: 21, // cover mode: neither img2webp + dwebp nor gifski on PATH
19
+ BLOCKED_ASSET_MISSING: 22 // cover mode: assets.json lists a file that is not there
18
20
  });
19
21
 
20
22
  export const FRAME = Object.freeze({ width: 1920, height: 1080, fps: 60 });
@@ -175,3 +177,29 @@ export const easeTokens = Object.freeze({
175
177
 
176
178
  export const engineCredit = "mexicat/pdoom-video ca251e3dddda422b364385eb484b5a3593a0990d (MIT)";
177
179
  export const engineCreditLine = "Typographic-motion engine adapted from mexicat/pdoom-video (MIT, Giacomo Magnanini), commit `ca251e3`.";
180
+
181
+ // Cover mode (references/cover.md): the README cover loop and its gate on the decoded file. The
182
+ // thresholds were calibrated on 2026-10-20 against eight earlier painted covers (all pass FLOOR),
183
+ // four slide-like negatives (all fail FLOOR) and three pilot loops (all reach TARGET). A number
184
+ // moves only with a new calibration table, never to let a cover through.
185
+ export const COVER = Object.freeze({
186
+ maxBytes: 3 * 1024 * 1024, // G1 FLOOR
187
+ targetBytes: 2 * 1024 * 1024, // G1 TARGET
188
+ minSec: 6, maxSec: 12,
189
+ gridMs: 50, // metrics run on a grid of max(gridMs, shortest decoded frame)
190
+ stillEps: 0.0005, // mean |dY| per grid step (luma 0..1 at about 300 px wide) below this is still
191
+ floor: Object.freeze({ medianD: 0.0004, movingFrac: 0.45, stillSec: 2.0, seamLocal: 8 }),
192
+ target: Object.freeze({ medianD: 0.0015, movingFrac: 0.95, stillSec: 0.5, spike: 6, seamLocal: 2.0, seamLocalMin: 0.25 }),
193
+ minContrast: 4.5, // G5, WCAG ratio of the title against its own box
194
+ minCapPx: 24, displayWidth: 830, // G6: cap height at a full-width README column
195
+ artSsim: 0.9, artMinShare: 0.1, // frame 0 shows the base art
196
+ seekFrames: 16, seekLevels: 1, // frames re-rendered out of order, and the rasterizer's rounding
197
+ windowPadSec: 0.5, maxDefects: 3,
198
+ captureScale: 2,
199
+ fps: Object.freeze([16, 20, 25]),
200
+ aspects: Object.freeze({
201
+ "2:1": Object.freeze({ page: Object.freeze([1200, 600]), size: Object.freeze([1000, 500]) }),
202
+ "3:2": Object.freeze({ page: Object.freeze([1200, 800]), size: Object.freeze([960, 640]) })
203
+ }),
204
+ encode: Object.freeze({ q: 86, edgeQ: 97, edgeFrames: 15, method: 4 })
205
+ });
@@ -0,0 +1,91 @@
1
+ // The cover's asset list, `<out>/assets.json`. Every raster the cover page can load (base art, logo,
2
+ // wordmark, texture) is listed with its path, role, source (user: supplied; generated: made for this
3
+ // cover and labelled so in the reply) and SHA-256, and the renderer checks the list before the first
4
+ // capture. The art is supplied or generated, never replaced by a drawn stand-in: a listed file that
5
+ // is missing stops the run as BLOCKED_ASSET_MISSING (exit 22), and so does the painted-light recipe
6
+ // with no base art listed. After the render, decoded frame 0 must show the base art (artCheck).
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { COVER, EXIT } from "../constants.mjs";
11
+ import { crop, lanczosResize, ssim } from "./image.mjs";
12
+
13
+ export const ASSET_ROLES = Object.freeze(["base-art", "logo", "wordmark", "texture"]);
14
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
15
+ const stop = (code, name, detail) => Object.assign(new Error(`${name} (${code}): ${detail}`), { exitCode: code });
16
+ const badEntry = (field, detail) => stop(EXIT.BLOCKED_TREATMENT_INVALID, "BLOCKED_TREATMENT_INVALID", `assets.json ${field}: ${detail}`);
17
+ const isBox = (b) => Array.isArray(b) && b.length === 4 && b.every((v) => typeof v === "number" && v >= 0 && v <= 1) && b[2] > 0 && b[3] > 0 && b[0] + b[2] <= 1 + 1e-9 && b[1] + b[3] <= 1 + 1e-9;
18
+ const inside = (inner, outer) => inner[0] >= outer[0] - 1e-9 && inner[1] >= outer[1] - 1e-9 && inner[0] + inner[2] <= outer[0] + outer[2] + 1e-9 && inner[1] + inner[3] <= outer[1] + outer[3] + 1e-9;
19
+
20
+ /**
21
+ * Check `<out>/assets.json` against the rasters the static scan found in the cover folder (paths
22
+ * relative to that folder). Returns the listed assets. Exit 22: a listed file is missing, or the
23
+ * painted-light recipe has no base art. Exit 17: a raster the list does not name, or a file whose
24
+ * hash changed since it was listed. Exit 16: a malformed entry.
25
+ */
26
+ export function checkAssets({ out, treatment, rasters, coverDir = "cover" }) {
27
+ const file = path.join(out, "assets.json");
28
+ let assets = [];
29
+ if (existsSync(file)) {
30
+ let doc;
31
+ try { doc = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw badEntry("file", `not JSON (${error.message})`); }
32
+ if (!Array.isArray(doc?.assets)) throw badEntry("assets", "must be an array of { path, role, source, sha256 }");
33
+ assets = doc.assets.map((entry, i) => {
34
+ const at = `assets[${i}]`;
35
+ if (!entry || typeof entry !== "object") throw badEntry(at, "must be an object");
36
+ if (typeof entry.path !== "string" || !entry.path.startsWith(`${coverDir}/`) || entry.path.split("/").includes("..")) throw badEntry(`${at}.path`, `must be a path inside ${coverDir}/, relative to the output directory`);
37
+ if (!ASSET_ROLES.includes(entry.role)) throw badEntry(`${at}.role`, `must be one of ${ASSET_ROLES.join(", ")}`);
38
+ if (!["user", "generated"].includes(entry.source)) throw badEntry(`${at}.source`, "must be user (supplied) or generated");
39
+ if (!/^[0-9a-f]{64}$/u.test(entry.sha256 ?? "")) throw badEntry(`${at}.sha256`, "must be the file's SHA-256 (64 hex digits)");
40
+ if ("box" in entry && !isBox(entry.box)) throw badEntry(`${at}.box`, "must be [x, y, w, h] as fractions of the cover, inside it");
41
+ if (entry.role === "base-art" && !entry.box) throw badEntry(`${at}.box`, "base art needs the box it fills on the cover");
42
+ if (entry.role === "base-art" && !/\.png$/iu.test(entry.path)) throw badEntry(`${at}.path`, "base art is a PNG, so frame 0 can be compared with it");
43
+ if ("checkBox" in entry) {
44
+ if (!isBox(entry.checkBox) || !entry.box || !inside(entry.checkBox, entry.box)) throw badEntry(`${at}.checkBox`, "must be [x, y, w, h] inside the art's box");
45
+ if (entry.checkBox[2] * entry.checkBox[3] < COVER.artMinShare * entry.box[2] * entry.box[3]) throw badEntry(`${at}.checkBox`, `must cover at least ${COVER.artMinShare * 100} % of the art's box`);
46
+ }
47
+ return { path: entry.path, role: entry.role, source: entry.source, sha256: entry.sha256, box: entry.box ?? null, checkBox: entry.checkBox ?? null, title: entry.title === true };
48
+ });
49
+ for (const asset of assets) {
50
+ const real = path.join(out, asset.path);
51
+ if (!existsSync(real)) throw stop(EXIT.BLOCKED_ASSET_MISSING, "BLOCKED_ASSET_MISSING", `${asset.path} is listed in assets.json and is not there; supply it or generate it, never draw a stand-in`);
52
+ if (sha256(readFileSync(real)) !== asset.sha256) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${asset.path} no longer matches its sha256 in assets.json; list it again after changing it`);
53
+ }
54
+ }
55
+ if (treatment.recipe === "painted-light" && !assets.some((asset) => asset.role === "base-art")) {
56
+ throw stop(EXIT.BLOCKED_ASSET_MISSING, "BLOCKED_ASSET_MISSING", "the painted-light recipe relights a painting, and assets.json lists no base-art; supply the painting or generate it and list it, never draw a stand-in");
57
+ }
58
+ const listed = new Set(assets.map((asset) => asset.path));
59
+ for (const raster of rasters) {
60
+ const rel = `${coverDir}/${raster.rel.split(path.sep).join("/")}`;
61
+ if (!listed.has(rel)) throw stop(EXIT.STAGE_CONTRACT_ERROR, "STAGE_CONTRACT_ERROR", `${rel} is a raster the page can load and assets.json does not list it; list it with its role, source and sha256`);
62
+ }
63
+ return assets;
64
+ }
65
+
66
+ /**
67
+ * Frame 0 is the poster and shows the base art: crop the check box (by default the whole art box)
68
+ * out of decoded frame 0 and compare it with the same region of the source scaled into the art box.
69
+ * `art` is { rgba, width, height }; boxes are fractions of the cover.
70
+ */
71
+ export function artCheck({ frame0, width, height, art, box, checkBox = null }) {
72
+ const px = (b) => b.map((v, k) => Math.round(v * (k % 2 ? height : width)));
73
+ const [bx, by, bw, bh] = px(box);
74
+ const [cx, cy, cw, ch] = px(checkBox ?? box);
75
+ const scaled = lanczosResize(art.rgba, art.width, art.height, bw, bh);
76
+ const value = ssim(crop(frame0, width, [cx, cy, cw, ch]), crop(scaled, bw, [cx - bx, cy - by, cw, ch]), cw, ch);
77
+ return { ssim: value, pass: value >= COVER.artSsim, checked: [cx, cy, cw, ch] };
78
+ }
79
+
80
+ /** The reply labels a finished cover owes: one per asset the list marks as generated. */
81
+ export function generatedAssetLabels(out) {
82
+ const file = path.join(out, "assets.json");
83
+ if (!existsSync(file)) return [];
84
+ try {
85
+ return (JSON.parse(readFileSync(file, "utf8")).assets ?? [])
86
+ .filter((asset) => asset?.source === "generated" && typeof asset.path === "string")
87
+ .map((asset) => `generated: ${asset.path} (${String(asset.role ?? "asset").replace("-", " ")}) was made for this cover, not supplied; label it as generated in the reply`);
88
+ } catch {
89
+ return [];
90
+ }
91
+ }