@litfamily/lithermes 1.0.26 → 1.0.28

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 (96) hide show
  1. package/README.md +10 -9
  2. package/README_Ko-KR.md +10 -9
  3. package/assets/lithermes-plugin/README.md +28 -9
  4. package/assets/lithermes-plugin/__init__.py +8 -3
  5. package/assets/lithermes-plugin/auto_update.py +17 -3
  6. package/assets/lithermes-plugin/core.py +41 -2
  7. package/assets/lithermes-plugin/core_contexts.py +27 -9
  8. package/assets/lithermes-plugin/core_contract.py +7 -2
  9. package/assets/lithermes-plugin/core_review.py +144 -3
  10. package/assets/lithermes-plugin/core_routing.py +10 -0
  11. package/assets/lithermes-plugin/core_workflow_commands.py +3 -2
  12. package/assets/lithermes-plugin/deliverable_hedge_guard.py +171 -48
  13. package/assets/lithermes-plugin/diagnostics.py +5 -3
  14. package/assets/lithermes-plugin/humanizer_detector.py +117 -5
  15. package/assets/lithermes-plugin/lit_reentry.py +233 -0
  16. package/assets/lithermes-plugin/payload-version.json +184 -52
  17. package/assets/lithermes-plugin/plugin.yaml +1 -1
  18. package/assets/lithermes-plugin/rules/__init__.py +2 -0
  19. package/assets/lithermes-plugin/rules/bundled-rules/lit-humanizer.md +2 -0
  20. package/assets/lithermes-plugin/rules/engine.py +26 -11
  21. package/assets/lithermes-plugin/skills/deep-interview/SKILL.md +2 -1
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +3 -3
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/hostexec.mjs +118 -0
  24. package/assets/lithermes-plugin/skills/frontend-ui-ux/scripts/probe.mjs +6 -5
  25. package/assets/lithermes-plugin/skills/lit-brainstorm/SKILL.md +408 -0
  26. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/doctor.mjs +3 -3
  27. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/export.mjs +5 -5
  28. package/assets/lithermes-plugin/skills/lit-diagram-drawer/scripts/hostexec.mjs +118 -0
  29. package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_pdf.py +38 -4
  30. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/acs/template.tex +3 -0
  31. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/elsevier/template.tex +3 -0
  32. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/ieee/template.tex +3 -0
  33. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/korean-generic/template.tex +3 -0
  34. package/assets/lithermes-plugin/skills/lit-docx/templates/latex/nature/template.tex +3 -0
  35. package/assets/lithermes-plugin/skills/lit-humanizer/SKILL.md +2 -0
  36. package/assets/lithermes-plugin/skills/lit-pptx/bin/hostexec.mjs +118 -0
  37. package/assets/lithermes-plugin/skills/lit-pptx/bin/office.mjs +16 -9
  38. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +13 -12
  39. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/motion.mjs +19 -6
  40. package/assets/lithermes-plugin/skills/lit-typographic-motion/bin/runtime.mjs +25 -19
  41. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +2 -0
  42. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/browser.mjs +11 -4
  43. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/constants.mjs +29 -0
  44. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-assets.mjs +87 -0
  45. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-capture.mjs +189 -0
  46. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-gate.mjs +263 -0
  47. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-image.mjs +103 -0
  48. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-init.js +36 -0
  49. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-png.mjs +77 -0
  50. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-sheet.mjs +70 -0
  51. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-treatment.mjs +104 -0
  52. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-webp.mjs +112 -0
  53. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover-worker.mjs +19 -0
  54. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +338 -0
  55. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/encode.mjs +1 -1
  56. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/hostexec.mjs +118 -0
  57. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/look.mjs +103 -7
  58. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/loop-kit.js +80 -0
  59. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/sound.mjs +1 -1
  60. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +8 -2
  61. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/treatment.mjs +1 -0
  62. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/expected.json +49 -0
  63. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/card-scenes/index.html +37 -0
  64. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/expected.json +49 -0
  65. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/reset-sweep/index.html +27 -0
  66. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/expected.json +51 -0
  67. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/still-glow/index.html +29 -0
  68. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/expected.json +51 -0
  69. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/negatives/title-slam/index.html +37 -0
  70. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +68 -0
  71. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/orbit-loop/index.html +112 -0
  72. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/expected.json +96 -0
  73. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/painted-light/index.html +208 -0
  74. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/expected.json +68 -0
  75. package/assets/lithermes-plugin/skills/lit-typographic-motion/recipes/weight-tide/index.html +81 -0
  76. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/cover.md +458 -0
  77. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/craft-loop.md +4 -1
  78. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/runtime.md +13 -5
  79. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +4 -2
  80. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/treatment.md +6 -0
  81. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/fonts.json +11 -0
  82. package/assets/lithermes-plugin/skills/lit-typographic-motion/runtime/requirements.lock +72 -0
  83. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +19 -1
  84. package/assets/lithermes-plugin/skills/lsp-setup/scripts/verify-lsp.ts +16 -3
  85. package/assets/lithermes-plugin/skills/review-work/SKILL.md +38 -16
  86. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +3 -3
  87. package/package.json +2 -1
  88. package/readme-assets/badge-version.svg +1 -1
  89. package/readme-assets/cover-motion-still.webp +0 -0
  90. package/readme-assets/cover-motion.webp +0 -0
  91. package/src/cli.js +2 -2
  92. package/src/lib/check.js +9 -6
  93. package/src/lib/hermesDiscovery.js +2 -2
  94. package/src/lib/hostExec.js +81 -0
  95. package/src/lib/skillPayload.js +1 -1
  96. package/src/lib/updateNotifier.js +14 -4
@@ -0,0 +1,118 @@
1
+ // Child processes that survive Windows. Since the CVE-2024-27980 fix, Node refuses to spawn a
2
+ // .cmd or .bat file without a shell (spawnSync npm.cmd EINVAL), and PATH lookups there depend on
3
+ // PATHEXT. Platform, env and fs probes are arguments so tests can simulate win32 without
4
+ // touching globals. Every skill that spawns tools carries a byte-identical copy of this file.
5
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { posix, win32 } from 'node:path';
8
+
9
+ const SHELL_SCRIPT = /\.(?:cmd|bat)$/i;
10
+ const SAFE_ARG = /^[\w@+=:,./\\-]+$/;
11
+
12
+ export function whichSync(name, { platform = process.platform, env = process.env, exists = existsSync } = {}) {
13
+ const win = platform === 'win32';
14
+ const p = win ? win32 : posix;
15
+ const exts = win ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.trim().toLowerCase()).filter(Boolean) : [''];
16
+ const hasExt = win && exts.some((e) => name.toLowerCase().endsWith(e.toLowerCase()));
17
+ // A bare name never matches on Windows: npm leaves an extensionless sh script beside npm.cmd.
18
+ const names = win ? (hasExt ? [name] : exts.map((e) => name + e)) : [name];
19
+ const dirs = p.isAbsolute(name) || /[\\/]/.test(name) ? [''] : String(env.PATH ?? env.Path ?? '').split(win ? ';' : ':').map((d) => d.trim().replace(/^"|"$/g, '')).filter(Boolean);
20
+ for (const dir of dirs) {
21
+ for (const candidate of names) {
22
+ const full = dir ? p.join(dir, candidate) : candidate;
23
+ if (exists(full)) return full;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // Quoted for cmd.exe, which still expands %VAR% inside quotes, so those are refused, never escaped.
30
+ export function quoteCmdArg(arg) {
31
+ const text = String(arg);
32
+ if (/["%\r\n\0]/.test(text)) throw new Error(`refusing an argument with a quote, percent sign or line break on a Windows shell: ${JSON.stringify(text.slice(0, 40))}`);
33
+ if (text !== '' && SAFE_ARG.test(text)) return text;
34
+ return `"${text.replace(/\\+$/, (run) => run + run)}"`;
35
+ }
36
+
37
+ // Where an npm or npx shim's JavaScript entry lives, so Windows can run it with node directly.
38
+ function npmCli(name, resolved, { env, execPath, exists }) {
39
+ const cli = `${name}-cli.js`;
40
+ const dirs = [];
41
+ if (typeof env.npm_execpath === 'string' && /\.js$/i.test(env.npm_execpath)) dirs.push(win32.dirname(env.npm_execpath));
42
+ dirs.push(win32.join(win32.dirname(resolved), 'node_modules', 'npm', 'bin'));
43
+ dirs.push(win32.join(win32.dirname(execPath), 'node_modules', 'npm', 'bin'));
44
+ return dirs.map((dir) => win32.join(dir, cli)).find((file) => exists(file)) || null;
45
+ }
46
+
47
+ export function prepareSpawn(command, args = [], options = {}, ctx = {}) {
48
+ const { platform = process.platform, execPath = process.execPath, exists = existsSync } = ctx;
49
+ if (platform !== 'win32') return { command, args, options };
50
+ const env = ctx.env || options.env || process.env;
51
+ const resolved = whichSync(command, { platform, env, exists });
52
+ if (!resolved || !SHELL_SCRIPT.test(resolved)) return { command: resolved || command, args, options };
53
+ const base = win32.basename(resolved).replace(SHELL_SCRIPT, '').toLowerCase();
54
+ if (base === 'npm' || base === 'npx') {
55
+ const cli = npmCli(base, resolved, { env, execPath, exists });
56
+ if (cli) return { command: execPath, args: [cli, ...args], options };
57
+ }
58
+ const line = [resolved, ...args].map(quoteCmdArg).join(' ');
59
+ return { command: line, args: [], options: { ...options, shell: true } };
60
+ }
61
+
62
+ export function spawnTool(command, args, options, ctx = {}) {
63
+ const run = ctx.spawnSync || nodeSpawnSync;
64
+ const prepared = prepareSpawn(command, args, options, ctx);
65
+ return run(prepared.command, prepared.args, prepared.options);
66
+ }
67
+
68
+ // One line naming the real cause: the spawn error, the exit status and the last stderr line.
69
+ export function describeSpawnFailure(label, result, { limit = 200 } = {}) {
70
+ if (!result) return `${label} returned no result`;
71
+ const tail = String(result.stderr || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1) || '';
72
+ const clean = tail.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, limit);
73
+ if (result.error) return `${label} could not run: ${result.error.code || 'error'} ${result.error.message}${clean ? ` (${clean})` : ''}`;
74
+ if (result.signal) return `${label} stopped by ${result.signal}${clean ? ` (${clean})` : ''}`;
75
+ return `${label} exited ${result.status}${clean ? `: ${clean}` : ''}`;
76
+ }
77
+
78
+ // Interpreter inside a venv: Scripts\python.exe on Windows, bin/python elsewhere.
79
+ export function venvPython(dir, platform = process.platform) {
80
+ return platform === 'win32' ? win32.join(dir, 'Scripts', 'python.exe') : posix.join(dir, 'bin', 'python');
81
+ }
82
+
83
+ // The newest CPython the Office wheels ship for (pip runs --only-binary); 3.15 has no PyYAML wheel,
84
+ // so a python3 that is 3.15 is passed over. A caller whose lock is narrower passes its own max.
85
+ export const PYTHON_MAX = [3, 14];
86
+
87
+ export function probePython(command, env = process.env, spawn = spawnTool) {
88
+ const result = spawn(command, ['-c', 'import sys; print("%d.%d.%d" % sys.version_info[:3])'], { encoding: 'utf8', env, timeout: 10000 });
89
+ if (!result || result.error || result.status !== 0) return null;
90
+ const version = String(result.stdout || '').trim();
91
+ const [major, minor] = version.split('.').map(Number);
92
+ return Number.isInteger(major) && Number.isInteger(minor) ? { version, major, minor } : null;
93
+ }
94
+
95
+ // The host Python a pinned venv is built from. An override variable, when set, is the only
96
+ // candidate and is not range-checked. Otherwise the newest python3.X from `max` down to `min`,
97
+ // then python3 and python, each kept only when it runs and falls inside min..max.
98
+ // Returns { python, version } or { python: null, detail } naming what was tried.
99
+ export function pickPython({ env = process.env, override, min = [3, 10], max = PYTHON_MAX, probe = probePython } = {}) {
100
+ if (override && env[override]) {
101
+ const found = probe(env[override], env);
102
+ return found ? { python: env[override], version: found.version } : { python: null, detail: `${override} is set to ${env[override]}, which does not run` };
103
+ }
104
+ const candidates = [];
105
+ for (let minor = max[1]; minor >= min[1]; minor -= 1) candidates.push(`python3.${minor}`);
106
+ candidates.push('python3', 'python');
107
+ const tried = [];
108
+ for (const command of candidates) {
109
+ const found = probe(command, env);
110
+ if (!found) continue;
111
+ const inRange = (found.major > min[0] || (found.major === min[0] && found.minor >= min[1]))
112
+ && (found.major < max[0] || (found.major === max[0] && found.minor <= max[1]));
113
+ if (inRange) return { python: command, version: found.version };
114
+ tried.push(`${command} ${found.version}`);
115
+ }
116
+ const range = `${min.join('.')} to ${max.join('.')}`;
117
+ return { python: null, detail: `no supported Python (${range}) on PATH${tried.length ? `; found only ${tried.join(', ')}` : ''}` };
118
+ }
@@ -106,10 +106,17 @@ def short_author_list(frontmatter: dict) -> str:
106
106
  return f"{first_name} et al."
107
107
 
108
108
 
109
+ def latex_fonts(publisher: dict) -> tuple[str, str]:
110
+ """The Latin and CJK faces the LaTeX template asks fontspec for."""
111
+
112
+ cjk_cfg = publisher["latex"].get("cjk") or {}
113
+ body_font = ((publisher.get("docx") or {}).get("font") or {}).get("body") or {}
114
+ return str(body_font.get("family") or "Times New Roman"), str(cjk_cfg.get("main_font") or "Pretendard")
115
+
116
+
109
117
  def template_variables(frontmatter: dict, publisher_name: str, publisher: dict, detected_locale: str, mode: str) -> dict[str, str]:
110
118
  latex_cfg = publisher["latex"]
111
- cjk_cfg = latex_cfg.get("cjk") or {}
112
- body_font = ((publisher.get("docx") or {}).get("font") or {}).get("body") or {}
119
+ mainfont, cjk_main_font = latex_fonts(publisher)
113
120
  title = str(frontmatter.get("title") or "")
114
121
  return {
115
122
  "title": title,
@@ -122,8 +129,8 @@ def template_variables(frontmatter: dict, publisher_name: str, publisher: dict,
122
129
  "author-list-short": short_author_list(frontmatter),
123
130
  "documentclass": str(latex_cfg.get("documentclass") or "article"),
124
131
  "classoptions": ",".join(str(item) for item in latex_cfg.get("classoptions") or []),
125
- "mainfont": str(body_font.get("family") or "Times New Roman"),
126
- "cjk-main-font": str(cjk_cfg.get("main_font") or "Pretendard"),
132
+ "mainfont": mainfont,
133
+ "cjk-main-font": cjk_main_font,
127
134
  "publisher-name": publisher_name,
128
135
  "detected-locale": detected_locale,
129
136
  "mode": mode,
@@ -183,6 +190,26 @@ def kpsewhich_has(target: str) -> bool:
183
190
  return result.returncode == 0 and bool(result.stdout.strip())
184
191
 
185
192
 
193
+ def installed_font_families() -> set[str] | None:
194
+ """Lower-cased family names fontconfig knows, or None when there is no fc-list to ask."""
195
+
196
+ fc_list = command_path("fc-list")
197
+ if not fc_list:
198
+ return None
199
+ result = run_checked([fc_list, ":", "family"])
200
+ if result.returncode != 0:
201
+ return None
202
+ return {name.strip().lower() for line in result.stdout.splitlines() for name in line.split(",") if name.strip()}
203
+
204
+
205
+ def missing_pdf_fonts(publisher: dict, detected_locale: str, families: set[str] | None) -> list[str]:
206
+ if families is None:
207
+ return []
208
+ mainfont, cjk_main_font = latex_fonts(publisher)
209
+ wanted = [mainfont] + ([cjk_main_font] if detected_locale in {"ko", "mixed"} else [])
210
+ return [name for name in dict.fromkeys(wanted) if name.lower() not in families]
211
+
212
+
186
213
  def preflight_pdf_compile(publisher_name: str, publisher: dict, detected_locale: str) -> None:
187
214
  ensure_pandoc()
188
215
  if not command_path("xelatex"):
@@ -204,6 +231,13 @@ def preflight_pdf_compile(publisher_name: str, publisher: dict, detected_locale:
204
231
  raise SystemExit(
205
232
  "Error: xeCJK.sty not found. Korean/CJK PDF output requires xeCJK from TeX Live."
206
233
  )
234
+ missing = missing_pdf_fonts(publisher, detected_locale, installed_font_families())
235
+ if missing:
236
+ raise SystemExit(
237
+ f"BLOCKED: PDF font not installed: {', '.join(missing)} (publisher '{publisher_name}', fontspec would stop XeLaTeX). "
238
+ "Install the font system-wide (Pretendard ships in skills/lit-pptx/pretendard-font/public/static) "
239
+ "or change latex.cjk.main_font / the docx body font in templates/registry.yaml to one you have."
240
+ )
207
241
 
208
242
 
209
243
  def compile_latex_to_pdf(tex_path: Path, pdf_path: Path) -> str:
@@ -3,6 +3,9 @@
3
3
  \usepackage{graphicx}
4
4
  \usepackage{hyperref}
5
5
  \usepackage{booktabs}
6
+ $if(tables)$
7
+ \usepackage{longtable,array,calc}
8
+ $endif$
6
9
  \usepackage{microtype}
7
10
  \usepackage{siunitx}
8
11
  \usepackage{csquotes}
@@ -4,6 +4,9 @@
4
4
  \usepackage{hyperref}
5
5
  \usepackage{natbib}
6
6
  \usepackage{booktabs}
7
+ $if(tables)$
8
+ \usepackage{longtable,array,calc}
9
+ $endif$
7
10
  \usepackage{microtype}
8
11
  \usepackage{siunitx}
9
12
  \usepackage{csquotes}
@@ -3,6 +3,9 @@
3
3
  \usepackage{graphicx}
4
4
  \usepackage{hyperref}
5
5
  \usepackage{booktabs}
6
+ $if(tables)$
7
+ \usepackage{longtable,array,calc}
8
+ $endif$
6
9
  \usepackage{microtype}
7
10
  \usepackage{siunitx}
8
11
  \usepackage{csquotes}
@@ -3,6 +3,9 @@
3
3
  \usepackage{graphicx}
4
4
  \usepackage{hyperref}
5
5
  \usepackage{booktabs}
6
+ $if(tables)$
7
+ \usepackage{longtable,array,calc}
8
+ $endif$
6
9
  \usepackage{microtype}
7
10
  \usepackage{siunitx}
8
11
  \usepackage{csquotes}
@@ -3,6 +3,9 @@
3
3
  \usepackage{graphicx}
4
4
  \usepackage{hyperref}
5
5
  \usepackage{booktabs}
6
+ $if(tables)$
7
+ \usepackage{longtable,array,calc}
8
+ $endif$
6
9
  \usepackage{microtype}
7
10
  \usepackage{siunitx}
8
11
  \usepackage{csquotes}
@@ -60,6 +60,8 @@ after_payload_edit: "npm --prefix packages/lithermes-installer run sync-plugin -
60
60
 
61
61
  For a file edit, the Hermes `pre_tool_call` guard blocks block-tier findings in added text passed to `write_file` or `patch` for Markdown, HTML, text, TeX, CSV, SVG, reStructuredText, and AsciiDoc files. Warn-tier findings arrive as advisory context. Fenced code, inline code, exact quoted user spans, and internal `plans/`, `evidence/`, `HANDOFF*`, ledger, `.lit*/`, and `.hermes/` paths are excluded. The guard never scans unchanged file text. Use `write_file` or `patch` for prose edits when pre-save checking is required.
62
62
 
63
+ Lead with what a number or thing is. A run of definitions by negation leaves the reader to assemble the meaning from what was ruled out. Write "18,420회는 같은 사람의 반복 방문을 포함한 방문 횟수 합계입니다" in place of `18,420회는 주민 수가 아님. 고유 방문자 수도 아님.`, and give a needed caveat once; an honest limit such as "고유 이용자 지표는 자료에 없습니다" stays. The detector's `negation-cluster` check counts, in each paragraph, list block or Markdown table column (cells of 12 characters or fewer are ignored), after removing fenced code, inline code and `>` quote lines: the Korean forms `아닙니다`, `아님`, `아니다`, `아니며`, `아니라`, `아닌` and their rewordings `~로 읽을 수 없음`, `~로 볼 수 없음`, `~로 보지 않음`, `~을 뜻하지 않음`, `~와는 다름`, `~로 읽으면 안 됨`; the English forms `is not`, `are not`, `isn't`, `aren't`, `not a`, `rather than` and the rewordings `should not be read as`, `does not mean`. Two Korean hits, or three in any language, make it block-tier: the `pre_tool_call` guard denies the `write_file` or `patch`, and the denial text Hermes returns as the tool result carries the rewrite advice and this example. Exactly two hits that are not both Korean stay a warn-tier advisory and the write goes through. Rewording `아님` into `~로 읽을 수 없음` counts the same. Put a bad example you need to show in inline code or a `>` quote so the guard skips it.
64
+
63
65
  DOCX/PPTX/PDF checks happen after a supported file-write event or when a script-like tool reports an explicit output path in its result. Hermes' `post_tool_call` is observer-only, so a finding cannot undo a completed export: revise the source and rebuild the file. DOCX/PPTX extraction uses Python's standard-library ZIP/XML support. PDF extraction uses host `pdftotext` when present; if it is unavailable or fails, the scan fails open and reports that boundary. A DOCX, PPTX or Markdown file that a `terminal` or `execute_code` call builds is checked when that call returns: Hermes' `transform_tool_result` hook finds files modified while the call ran (the reported or session working directory, `workdir`, and folders of paths named in the command, code or output; bounded depth, file count and size, internal paths skipped) and, on a block-tier finding, puts `LIT_HUMANIZER_SCRIPT_DELIVERABLE_BLOCK` with the rule ids in that tool result's `error` field. The command's exit code stays as it ran, and the file stays on disk: edit the build source and rebuild before presenting it. A file written outside those folders, deeper than the scan, or by a background process after the call returned is not seen; run the detector on it explicitly.
64
66
 
65
67
  ## #contract.language_and_scope
@@ -0,0 +1,118 @@
1
+ // Child processes that survive Windows. Since the CVE-2024-27980 fix, Node refuses to spawn a
2
+ // .cmd or .bat file without a shell (spawnSync npm.cmd EINVAL), and PATH lookups there depend on
3
+ // PATHEXT. Platform, env and fs probes are arguments so tests can simulate win32 without
4
+ // touching globals. Every skill that spawns tools carries a byte-identical copy of this file.
5
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { posix, win32 } from 'node:path';
8
+
9
+ const SHELL_SCRIPT = /\.(?:cmd|bat)$/i;
10
+ const SAFE_ARG = /^[\w@+=:,./\\-]+$/;
11
+
12
+ export function whichSync(name, { platform = process.platform, env = process.env, exists = existsSync } = {}) {
13
+ const win = platform === 'win32';
14
+ const p = win ? win32 : posix;
15
+ const exts = win ? String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.trim().toLowerCase()).filter(Boolean) : [''];
16
+ const hasExt = win && exts.some((e) => name.toLowerCase().endsWith(e.toLowerCase()));
17
+ // A bare name never matches on Windows: npm leaves an extensionless sh script beside npm.cmd.
18
+ const names = win ? (hasExt ? [name] : exts.map((e) => name + e)) : [name];
19
+ const dirs = p.isAbsolute(name) || /[\\/]/.test(name) ? [''] : String(env.PATH ?? env.Path ?? '').split(win ? ';' : ':').map((d) => d.trim().replace(/^"|"$/g, '')).filter(Boolean);
20
+ for (const dir of dirs) {
21
+ for (const candidate of names) {
22
+ const full = dir ? p.join(dir, candidate) : candidate;
23
+ if (exists(full)) return full;
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // Quoted for cmd.exe, which still expands %VAR% inside quotes, so those are refused, never escaped.
30
+ export function quoteCmdArg(arg) {
31
+ const text = String(arg);
32
+ if (/["%\r\n\0]/.test(text)) throw new Error(`refusing an argument with a quote, percent sign or line break on a Windows shell: ${JSON.stringify(text.slice(0, 40))}`);
33
+ if (text !== '' && SAFE_ARG.test(text)) return text;
34
+ return `"${text.replace(/\\+$/, (run) => run + run)}"`;
35
+ }
36
+
37
+ // Where an npm or npx shim's JavaScript entry lives, so Windows can run it with node directly.
38
+ function npmCli(name, resolved, { env, execPath, exists }) {
39
+ const cli = `${name}-cli.js`;
40
+ const dirs = [];
41
+ if (typeof env.npm_execpath === 'string' && /\.js$/i.test(env.npm_execpath)) dirs.push(win32.dirname(env.npm_execpath));
42
+ dirs.push(win32.join(win32.dirname(resolved), 'node_modules', 'npm', 'bin'));
43
+ dirs.push(win32.join(win32.dirname(execPath), 'node_modules', 'npm', 'bin'));
44
+ return dirs.map((dir) => win32.join(dir, cli)).find((file) => exists(file)) || null;
45
+ }
46
+
47
+ export function prepareSpawn(command, args = [], options = {}, ctx = {}) {
48
+ const { platform = process.platform, execPath = process.execPath, exists = existsSync } = ctx;
49
+ if (platform !== 'win32') return { command, args, options };
50
+ const env = ctx.env || options.env || process.env;
51
+ const resolved = whichSync(command, { platform, env, exists });
52
+ if (!resolved || !SHELL_SCRIPT.test(resolved)) return { command: resolved || command, args, options };
53
+ const base = win32.basename(resolved).replace(SHELL_SCRIPT, '').toLowerCase();
54
+ if (base === 'npm' || base === 'npx') {
55
+ const cli = npmCli(base, resolved, { env, execPath, exists });
56
+ if (cli) return { command: execPath, args: [cli, ...args], options };
57
+ }
58
+ const line = [resolved, ...args].map(quoteCmdArg).join(' ');
59
+ return { command: line, args: [], options: { ...options, shell: true } };
60
+ }
61
+
62
+ export function spawnTool(command, args, options, ctx = {}) {
63
+ const run = ctx.spawnSync || nodeSpawnSync;
64
+ const prepared = prepareSpawn(command, args, options, ctx);
65
+ return run(prepared.command, prepared.args, prepared.options);
66
+ }
67
+
68
+ // One line naming the real cause: the spawn error, the exit status and the last stderr line.
69
+ export function describeSpawnFailure(label, result, { limit = 200 } = {}) {
70
+ if (!result) return `${label} returned no result`;
71
+ const tail = String(result.stderr || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1) || '';
72
+ const clean = tail.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, limit);
73
+ if (result.error) return `${label} could not run: ${result.error.code || 'error'} ${result.error.message}${clean ? ` (${clean})` : ''}`;
74
+ if (result.signal) return `${label} stopped by ${result.signal}${clean ? ` (${clean})` : ''}`;
75
+ return `${label} exited ${result.status}${clean ? `: ${clean}` : ''}`;
76
+ }
77
+
78
+ // Interpreter inside a venv: Scripts\python.exe on Windows, bin/python elsewhere.
79
+ export function venvPython(dir, platform = process.platform) {
80
+ return platform === 'win32' ? win32.join(dir, 'Scripts', 'python.exe') : posix.join(dir, 'bin', 'python');
81
+ }
82
+
83
+ // The newest CPython the Office wheels ship for (pip runs --only-binary); 3.15 has no PyYAML wheel,
84
+ // so a python3 that is 3.15 is passed over. A caller whose lock is narrower passes its own max.
85
+ export const PYTHON_MAX = [3, 14];
86
+
87
+ export function probePython(command, env = process.env, spawn = spawnTool) {
88
+ const result = spawn(command, ['-c', 'import sys; print("%d.%d.%d" % sys.version_info[:3])'], { encoding: 'utf8', env, timeout: 10000 });
89
+ if (!result || result.error || result.status !== 0) return null;
90
+ const version = String(result.stdout || '').trim();
91
+ const [major, minor] = version.split('.').map(Number);
92
+ return Number.isInteger(major) && Number.isInteger(minor) ? { version, major, minor } : null;
93
+ }
94
+
95
+ // The host Python a pinned venv is built from. An override variable, when set, is the only
96
+ // candidate and is not range-checked. Otherwise the newest python3.X from `max` down to `min`,
97
+ // then python3 and python, each kept only when it runs and falls inside min..max.
98
+ // Returns { python, version } or { python: null, detail } naming what was tried.
99
+ export function pickPython({ env = process.env, override, min = [3, 10], max = PYTHON_MAX, probe = probePython } = {}) {
100
+ if (override && env[override]) {
101
+ const found = probe(env[override], env);
102
+ return found ? { python: env[override], version: found.version } : { python: null, detail: `${override} is set to ${env[override]}, which does not run` };
103
+ }
104
+ const candidates = [];
105
+ for (let minor = max[1]; minor >= min[1]; minor -= 1) candidates.push(`python3.${minor}`);
106
+ candidates.push('python3', 'python');
107
+ const tried = [];
108
+ for (const command of candidates) {
109
+ const found = probe(command, env);
110
+ if (!found) continue;
111
+ const inRange = (found.major > min[0] || (found.major === min[0] && found.minor >= min[1]))
112
+ && (found.major < max[0] || (found.major === max[0] && found.minor <= max[1]));
113
+ if (inRange) return { python: command, version: found.version };
114
+ tried.push(`${command} ${found.version}`);
115
+ }
116
+ const range = `${min.join('.')} to ${max.join('.')}`;
117
+ return { python: null, detail: `no supported Python (${range}) on PATH${tried.length ? `; found only ${tried.join(', ')}` : ''}` };
118
+ }
@@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readFileSync, cpSync, renameSync, rmSync, writeF
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { spawnSync } from 'node:child_process';
8
+ import { describeSpawnFailure, pickPython, spawnTool, venvPython, whichSync } from './hostexec.mjs';
9
9
 
10
10
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
11
11
  const plugin = resolve(root, '..', '..');
@@ -15,18 +15,19 @@ const cacheBase = process.env.XDG_CACHE_HOME || join(process.env.HERMES_HOME ||
15
15
  const cache = join(cacheBase, 'lithermes', 'office', digest);
16
16
  const nodeCache = join(cache, 'node');
17
17
  const pythonCache = join(cache, 'python');
18
- const python = join(pythonCache, 'bin', 'python');
18
+ const python = venvPython(pythonCache);
19
19
  const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(Number);
20
20
  const nodeSupported = nodeMajor > 20 || (nodeMajor === 20 && nodeMinor >= 9);
21
21
  const ready = () => existsSync(join(cache, 'READY')) && existsSync(join(nodeCache, 'node_modules', 'pptxgenjs')) && existsSync(join(nodeCache, 'node_modules', 'sharp')) && existsSync(python);
22
22
  const run = (command, args, options = {}) => {
23
- const result = spawnSync(command, args, { stdio: 'inherit', ...options });
24
- if (result.error) throw result.error;
25
- if (result.status !== 0) throw new Error(`${command} exited ${result.status ?? result.signal}`);
23
+ const result = spawnTool(command, args, { stdio: 'inherit', ...options });
24
+ if (result.error || result.status !== 0) throw new Error(describeSpawnFailure(`${command} ${args[0] ?? ''}`.trim(), result));
26
25
  };
27
26
 
28
27
  function install() {
29
28
  if (ready()) return;
29
+ const host = pickPython({ env: process.env, override: 'LITHERMES_OFFICE_BOOTSTRAP_PYTHON', min: [3, 10] });
30
+ if (!host.python) throw new Error(`OFFICE_RUNTIME_PYTHON_UNAVAILABLE: ${host.detail}. Fix: install Python 3.14 (any 3.10 to 3.14 works) or set LITHERMES_OFFICE_BOOTSTRAP_PYTHON to one.`);
30
31
  mkdirSync(cache, { recursive: true });
31
32
  const staging = join(cache, `staging-${process.pid}`);
32
33
  rmSync(staging, { recursive: true, force: true });
@@ -38,8 +39,8 @@ function install() {
38
39
  for (const name of ['package.json', 'package-lock.json']) cpSync(join(runtime, name), join(nodeStage, name));
39
40
  run('npm', ['ci', '--prefix', nodeStage, '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { env: { ...process.env, npm_config_cache: join(cache, 'npm-cache') } });
40
41
  const pythonStage = join(staging, 'python');
41
- run(process.env.LITHERMES_OFFICE_BOOTSTRAP_PYTHON || 'python3', ['-m', 'venv', pythonStage]);
42
- run(join(pythonStage, 'bin', 'python'), ['-m', 'pip', 'install', '--disable-pip-version-check', '--require-hashes', '-r', join(runtime, 'requirements.lock')]);
42
+ run(host.python, ['-m', 'venv', pythonStage]);
43
+ run(venvPython(pythonStage), ['-m', 'pip', 'install', '--disable-pip-version-check', '--require-hashes', '-r', join(runtime, 'requirements.lock')]);
43
44
  rmSync(nodeCache, { recursive: true, force: true });
44
45
  rmSync(pythonCache, { recursive: true, force: true });
45
46
  renameSync(nodeStage, nodeCache);
@@ -52,7 +53,7 @@ function install() {
52
53
 
53
54
  const [action, ...args] = process.argv.slice(2);
54
55
  if (action === 'doctor') {
55
- const tools = ['soffice', 'pandoc', 'xelatex'].map(name => `${name}=${spawnSync('sh', ['-c', `command -v ${name}`], { stdio: 'ignore' }).status === 0 ? 'available' : 'missing'}`);
56
+ const tools = ['soffice', 'pandoc', 'xelatex'].map(name => `${name}=${whichSync(name) ? 'available' : 'missing'}`);
56
57
  console.log(`office-runtime=${ready() ? 'READY' : 'NOT_INSTALLED'} node=${nodeSupported ? 'supported' : 'requires-20.9+'} cache=${cache} ${tools.join(' ')}`);
57
58
  process.exit(0);
58
59
  }
@@ -75,4 +76,10 @@ if (!target) {
75
76
  if (!nodeSupported) throw new Error('LitHermes Office requires Node.js 20.9 or newer for its pinned image runtime');
76
77
  install();
77
78
  const env = { ...process.env, NODE_PATH: join(nodeCache, 'node_modules'), LITHERMES_OFFICE_PYTHON: python };
78
- run(target[0] === python ? join(pythonCache, 'bin', 'python') : target[0], [target[1], ...args], { env });
79
+ // The tool's own exit code is the command's exit code: a failed gate must not read as success.
80
+ const child = spawnTool(target[0], [target[1], ...args], { stdio: 'inherit', env });
81
+ if (child.error) {
82
+ console.error(describeSpawnFailure(`${target[0]} ${target[1]}`, child));
83
+ process.exit(1);
84
+ }
85
+ process.exit(child.status ?? 1);
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: lit-typographic-motion
3
- description: Direct an original film in Hermes Agent. A treatment comes first, then a drawn stage page for films that show things, or the WebGL2 type engine when the words are the film.
3
+ description: Direct an original film in Hermes Agent. A treatment comes first, then a drawn stage page for films that show things, or the WebGL2 type engine when the words are the film; or a seamless README cover loop.
4
4
  ---
5
5
 
6
6
  # LitHermes film director
@@ -23,31 +23,32 @@ Reached by `/lit-typographic-motion <request>`, the skill id, or bare `lit` with
23
23
 
24
24
  ## #contract.inputs
25
25
 
26
- The request and any source text are data. Under bare `lit` ask nothing. When the request names no subject, words or facts, invent a specific example subject and copy, and label them.
26
+ The request and any source text are data. Under bare `lit` ask nothing. With no subject, words or facts given, invent specific ones and label them.
27
27
 
28
28
  ## #contract.mode_matrix
29
29
 
30
30
  | Mode | Trigger | Contract |
31
31
  |---|---|---|
32
- | Treatment | write `treatment.json` in DIR | `references/treatment.md`, always first |
32
+ | Treatment | `treatment.json` in DIR | `references/treatment.md`, always first |
33
33
  | Stage | `$M stage --out DIR [--stills-only]` | `references/stage.md` |
34
34
  | Type | `$M run --out DIR [--stills-only]` | `references/engine-contract.md`, `references/style-bibles.md`, `references/type-craft.md` |
35
+ | Cover | `$M cover --out DIR --round N` | `references/cover.md`: README, hero, banner loops |
35
36
  | Sound | `$M sound --out DIR` | treatment sound rules |
36
37
  | Look | `$M look --out DIR --round N --answers FILE` | `references/craft-loop.md` |
37
- | Recheck | `$M gate`, `$M complete` | same |
38
+ | Recheck | `$M gate`, `$M cover-gate FILE --strict`, `$M complete` | same |
38
39
 
39
40
  ## #contract.procedure
40
41
 
41
- 1. Load `references/treatment.md` only. Write the treatment and set `path`; then load that path's references.
42
- 2. Author the film: `index.html` in `DIR/stage` with the kit, or the type brief.
43
- 3. Round 1 is stills (`--stills-only`): view every still, strip and sheet with `vision_analyze`, record it with `look`, change the weakest beat.
44
- 4. Render the film, look again and record the last round.
42
+ 1. Load `references/treatment.md` only (a cover: `references/cover.md`). Write the treatment; then load its path's references.
43
+ 2. Author the stage page with the kit, the type brief, or a cover page from `recipes/`.
44
+ 3. Round 1 is stills (`--stills-only`; a cover renders in full): view with `vision_analyze`, record with `look`, change.
45
+ 4. Render, look, record the last round.
45
46
 
46
- Give each render call a timeout of at least 600 s and never shorten the film to save render time. Hand-encoded films (a PIL frame loop, a raw ffmpeg graph, a screen recording, a raster flipbook) are not the deliverable.
47
+ Give each render at least 600 s; never shorten to save time. Hand-encoded films (PIL, raw ffmpeg, screen recordings, flipbooks) never count.
47
48
 
48
49
  ## #contract.outputs
49
50
 
50
- `film.mp4` (60 fps; 1920×1080, or 1080×1920 on the stage path), poster, reduced-motion still, `sound/`, `stills/`, `sheet/`, `look.json`, `manifest.json`, `gate-report.txt`. A flash failure leaves exports only in `withheld/`.
51
+ `film.mp4` (60 fps; 1920×1080, or 1080×1920 on the stage path), poster, reduced-motion still, `sound/`, `stills/`, `sheet/`, `look.json`, `manifest.json`, `gate-report.txt`; a cover: `cover.webp`, poster, decoded `stills/`. A flash failure leaves exports only in `withheld/`.
51
52
 
52
53
  ## #contract.output_channels
53
54
 
@@ -58,11 +59,11 @@ limitations_channel: reply
58
59
 
59
60
  ## #contract.evidence
60
61
 
61
- Done only when `$M complete --out DIR` exits 0: a valid treatment, a passing gate and two look rounds, the last on the final render. Reply in plain words: one line of defaults, labels on every invention and on a generated sound bed, one line on what was checked, where the copy lives and the command that re-renders, and any downgrade or open look item.
62
+ Done only when `$M complete --out DIR` exits 0: a valid treatment, a passing gate (a cover: TARGET) and two look rounds, the last on the final render. Reply plainly: defaults, a label on each invention and generated sound or asset, what was checked, where the copy lives, the re-render command, open items.
62
63
 
63
64
  ## #contract.hard_stops
64
65
 
65
- Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 names the treatment field, 17 a stage contract break, 18 a nondeterministic frame, 19 a network request, 20 a sound fault. Never install or fetch during a render; `lithermes motion-runtime install` runs outside the session. Never deliver a flash-failing film. Never downgrade to pass.
66
+ Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 names the treatment field, 17 a stage contract break, 18 a nondeterministic frame, 19 a network request, 20 a sound fault, 21 no cover encoder, 22 a missing cover asset. Never install during a render, deliver a flash-failing film, or downgrade to pass.
66
67
 
67
68
  ## #contract.anti_patterns
68
69
 
@@ -23,6 +23,7 @@ import { WARM, audioState, findCache, fontFile, fontState, hangulPath, runtimeRe
23
23
  import { loadTreatment } from '../engine/treatment.mjs';
24
24
  import { recordFirstTreatment } from '../engine/director.mjs';
25
25
  import { stageCommand, stageRegate } from '../engine/stage.mjs';
26
+ import { coverCommand, coverGateCommand } from '../engine/cover.mjs';
26
27
  import { completeCommand, lookCommand } from '../engine/look.mjs';
27
28
  import { stampStills, stillsPlan, writeStillsSet } from '../engine/stills.mjs';
28
29
  import { produceSound, soundCommand, soundGate } from '../engine/sound.mjs';
@@ -31,6 +32,11 @@ import { viewedFiles } from '../engine/director.mjs';
31
32
  const USAGE = `LitHermes lit-typographic-motion
32
33
  usage: node motion.mjs <subcommand> --out DIR [options] (write DIR/treatment.json first)
33
34
  stage --out DIR [--stills-only] [--round 1-3] stage path: capture DIR/stage/index.html frame by frame; stills, film, sound, gate
35
+ cover --out DIR [--round 1-3] [--window t0,t1] [--detach] cover mode (treatment mode: cover): the silent README loop DIR/cover/index.html,
36
+ captured at 2x, encoded to cover.webp and gated on the decoded file;
37
+ --window re-renders only t0-0.5 s .. t1+0.5 s and sheets it (never done)
38
+ cover-gate FILE [--frames D --frames2 D --masks D --title x,y,w,h] [--strict] [--json]
39
+ the cover gate on any cover file or PNG frame folder
34
40
  run --out DIR [--brief FILE] [--round 1-3] [--stills-only] type path: the WebGL2 type engine; stills, cut sheet, film, preview, poster, gate
35
41
  sound --out DIR build the generated sound bed and sound-cues.json from the treatment
36
42
  look --out DIR --round 1-3 --answers FILE record one look round against the latest stills set
@@ -39,7 +45,8 @@ usage: node motion.mjs <subcommand> --out DIR [options] (write DIR/treatment.j
39
45
  type-path tools: stills, sheet, video, perf (--brief FILE --out DIR)
40
46
  options: --scale 1-4 --word-timing --force-software
41
47
  exit: 0 ok, 10 no Chrome, 11 no WebGL2, 12 no ffmpeg for video, 13 QA gate failed, 14 not pre-warmed, 15 font missing or corrupt,
42
- 16 treatment invalid, 17 stage contract, 18 stage not deterministic, 19 stage network request, 20 sound invalid
48
+ 16 treatment invalid, 17 stage contract, 18 stage not deterministic, 19 stage network request, 20 sound invalid,
49
+ 21 no cover encoder (img2webp and dwebp, or gifski), 22 a listed cover asset is missing
43
50
  ${CREDIT_LINE}`;
44
51
 
45
52
  const sha = (data) => createHash('sha256').update(data).digest('hex');
@@ -53,7 +60,7 @@ function parseArgs(argv) {
53
60
  const arg = argv[i];
54
61
  if (!arg.startsWith('--')) { flags._.push(arg); continue; }
55
62
  const key = arg.slice(2);
56
- if (['stills-only', 'cuts', 'word-timing', 'force-software', 'help', 'no-rerender', 'aids', 'detach'].includes(key)) flags[key] = true;
63
+ if (['stills-only', 'cuts', 'word-timing', 'force-software', 'help', 'no-rerender', 'aids', 'detach', 'strict', 'json'].includes(key)) flags[key] = true;
57
64
  else if (key === 'viewed') flags.viewed = String(argv[++i] || '').split(',').map((v) => Number(v.replace(/^f/, ''))).filter(Number.isInteger);
58
65
  else flags[key] = argv[++i];
59
66
  }
@@ -154,7 +161,7 @@ class Session {
154
161
  const grid = join(this.out, 'audio-grid.json');
155
162
  const result = spawnSync(state.python, [join(skillRoot, 'bin', 'audio.py'), audioPath, grid], { encoding: 'utf8', timeout: 300000 });
156
163
  if (result.status !== 0 || !existsSync(grid)) {
157
- this.warnings.push(`audio analysis failed (${(result.stderr || result.error?.message || '').trim().split('\n').at(-1)}); Tier 1 text timing used`);
164
+ this.warnings.push(`audio analysis failed (${(result.stderr || result.error?.message || '').trim().split(/\r?\n/).at(-1)}); Tier 1 text timing used`);
158
165
  return null;
159
166
  }
160
167
  const parsed = JSON.parse(readFileSync(grid, 'utf8'));
@@ -385,7 +392,7 @@ class Session {
385
392
  if (f % 60 === 59) process.stderr.write(`motion: ${f + 1}/${this.totalFrames} frames\n`);
386
393
  }
387
394
  const encoded = await encoder.finish();
388
- if (encoded.code !== 0) throw fail(1, `ffmpeg master encode failed: ${encoded.stderr.split('\n').filter(Boolean).at(-1) || encoded.code}`);
395
+ if (encoded.code !== 0) throw fail(1, `ffmpeg master encode failed: ${encoded.stderr.split(/\r?\n/).filter(Boolean).at(-1) || encoded.code}`);
389
396
  // Every planned track is muxed here, whatever the --audio tier's state:
390
397
  // the beat grid is optional, the soundtrack is not.
391
398
  const supplied = this.brief.audio ? (isAbsolute(this.brief.audio) ? this.brief.audio : resolve(dirname(this.briefPath), this.brief.audio)) : null;
@@ -430,8 +437,8 @@ class Session {
430
437
  if (this.flags['force-software']) args.push('--force-software');
431
438
  const res = spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 600000 });
432
439
  rmSync(dir, { recursive: true, force: true });
433
- const line = (res.stdout || '').split('\n').find((l) => l.startsWith('MOTION_FRAME '));
434
- if (!line) throw new Error(`determinism child for frame ${frame} failed: ${(res.stderr || '').trim().split('\n').at(-1) || res.status}`);
440
+ const line = (res.stdout || '').split(/\r?\n/).find((l) => l.startsWith('MOTION_FRAME '));
441
+ if (!line) throw new Error(`determinism child for frame ${frame} failed: ${(res.stderr || '').trim().split(/\r?\n/).at(-1) || res.status}`);
435
442
  return JSON.parse(line.slice('MOTION_FRAME '.length));
436
443
  };
437
444
  const checks = [];
@@ -542,6 +549,8 @@ async function main(argv) {
542
549
  if (!mode || flags.help || mode === 'help') { console.log(USAGE); return 0; }
543
550
  if (mode === 'complete') return completeCommand(resolve(flags.out || 'motion-output'));
544
551
  if (mode === 'stage') return stageCommand(flags);
552
+ if (mode === 'cover') return coverCommand(flags);
553
+ if (mode === 'cover-gate') return coverGateCommand(flags);
545
554
  if (mode === 'sound') return soundCommand(flags);
546
555
  if (mode === 'look') return lookCommand(flags);
547
556
  if (!['run', 'stills', 'sheet', 'video', 'perf', 'gate', 'hash-frame'].includes(mode)) { console.error(USAGE); return 2; }
@@ -630,6 +639,10 @@ async function main(argv) {
630
639
  async function regate(flags) {
631
640
  const out = resolve(flags.out || 'motion-output');
632
641
  const manifestPath = join(out, 'manifest.json');
642
+ if (existsSync(manifestPath) && JSON.parse(readFileSync(manifestPath, 'utf8')).path === 'cover') {
643
+ console.error(`${out} holds a cover: re-gate it with \`cover-gate ${join(out, 'cover.webp')} --strict\`, or render it again with \`cover --out ${out}\``);
644
+ return 2;
645
+ }
633
646
  if (!existsSync(manifestPath) || !existsSync(join(out, 'render.jsonl'))) { console.error(`no finished render in ${out}`); return 2; }
634
647
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
635
648
  if (manifest.path === 'stage') return stageRegate(out);