@litfamily/lithermes 1.0.32 → 1.0.34

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 (56) hide show
  1. package/README.md +7 -7
  2. package/README_Ko-KR.md +7 -7
  3. package/assets/lithermes-plugin/README.md +0 -12
  4. package/assets/lithermes-plugin/adhd_stack.py +43 -1
  5. package/assets/lithermes-plugin/auto_handoff.py +13 -2
  6. package/assets/lithermes-plugin/bounded_work.py +33 -3
  7. package/assets/lithermes-plugin/core.py +59 -18
  8. package/assets/lithermes-plugin/core_review.py +2 -2
  9. package/assets/lithermes-plugin/lit_mark.py +2 -7
  10. package/assets/lithermes-plugin/payload-version.json +49 -53
  11. package/assets/lithermes-plugin/plugin.yaml +1 -1
  12. package/assets/lithermes-plugin/rules/bundled-rules/baseline-discipline.md +7 -13
  13. package/assets/lithermes-plugin/rules/globmatch.py +1 -1
  14. package/assets/lithermes-plugin/skills/debugging/SKILL.md +1 -1
  15. package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +1 -1
  16. package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +2 -2
  17. package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +1 -1
  18. package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +1 -1
  19. package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +4 -4
  20. package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +1 -1
  21. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/corpus/ui-ux-db/data/stacks/nuxtjs.csv +1 -1
  22. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/_canonical-corpus/manifest.json +2 -2
  23. package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
  24. package/assets/lithermes-plugin/skills/lit-adhd/SKILL.md +10 -10
  25. package/assets/lithermes-plugin/skills/lit-adhd/references/brief-contract.md +12 -7
  26. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/collect.mjs +81 -27
  27. package/assets/lithermes-plugin/skills/lit-adhd/scripts/lib/next-step.mjs +34 -6
  28. package/assets/lithermes-plugin/skills/lit-burnoff/SKILL.md +1 -1
  29. package/assets/lithermes-plugin/skills/lit-code/SKILL.md +4 -4
  30. package/assets/lithermes-plugin/skills/lit-code/references/rust/README.md +2 -2
  31. package/assets/lithermes-plugin/skills/lit-code/references/rust/async-tokio.md +4 -4
  32. package/assets/lithermes-plugin/skills/lit-code/references/rust/axum-stack.md +1 -1
  33. package/assets/lithermes-plugin/skills/lit-code/references/rust/clap-stack.md +1 -1
  34. package/assets/lithermes-plugin/skills/lit-code/references/rust/libraries.md +3 -3
  35. package/assets/lithermes-plugin/skills/lit-code/references/rust/proptest-insta.md +10 -10
  36. package/assets/lithermes-plugin/skills/lit-code/references/rust/zero-cost-safety.md +1 -1
  37. package/assets/lithermes-plugin/skills/lit-code/references/typescript/README.md +2 -2
  38. package/assets/lithermes-plugin/skills/lit-code/references/typescript/backend-hono.md +1 -1
  39. package/assets/lithermes-plugin/skills/lit-code/references/typescript/tsconfig-strict.md +1 -1
  40. package/assets/lithermes-plugin/skills/lit-code/scripts/typescript/check-no-excuse-rules.ts +2 -2
  41. package/assets/lithermes-plugin/skills/lit-typographic-motion/SKILL.md +1 -1
  42. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/cover.mjs +1 -1
  43. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-gate.mjs +1 -0
  44. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage-kit.js +42 -3
  45. package/assets/lithermes-plugin/skills/lit-typographic-motion/engine/stage.mjs +133 -4
  46. package/assets/lithermes-plugin/skills/lit-typographic-motion/references/stage.md +55 -1
  47. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package-lock.json +3 -3
  48. package/assets/lithermes-plugin/skills/readme-studio/templates/remotion/package.json +2 -1
  49. package/assets/lithermes-plugin/skills/rules/SKILL.md +1 -1
  50. package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
  51. package/assets/{lithermes-plugin → npm-channel}/auto_update.py +43 -9
  52. package/package.json +1 -1
  53. package/readme-assets/badge-version.svg +1 -1
  54. package/src/lib/canonicalCorpus.js +1 -1
  55. package/src/lib/install.js +22 -0
  56. package/src/lib/skillPayload.js +10 -5
@@ -31,7 +31,7 @@ These are deliberate project choices. Violations are always wrong, not "style pr
31
31
  3. **Exhaustive switch** — every `switch` on a discriminated union ends with `default: assertNever(x)`. No fall-through.
32
32
  4. **No any** — `any` is banned in annotations, returns, and parameters. Use `unknown` and narrow.
33
33
  5. **No type assertions** — `as any`, `as unknown` banned. `as const` and `satisfies` are fine.
34
- 6. **No non-null assertion** — `x!` is banned. Use narrowing or optional chaining (`x?.y`).
34
+ 6. **No non-null assertion** — `x!.y` is banned. Use narrowing or optional chaining (`x?.y`).
35
35
  7. **No @ts-ignore / @ts-expect-error** — fix the type.
36
36
  8. **No enum** — use `as const` objects + literal union types.
37
37
  9. **Zod at boundaries** — external input (API, user, file) → Zod schema. Internal → plain types.
@@ -163,7 +163,7 @@ Violations caught by `../../scripts/typescript/check-no-excuse-rules.ts`. Run af
163
163
  | `no-ts-ignore` | `@ts-ignore` | None — fix the type |
164
164
  | `no-ts-expect-error` | `@ts-expect-error` | None — fix the type |
165
165
  | `no-enum` | `enum` declarations | None — use `as const` |
166
- | `no-non-null-assertion` | `x!` postfix | None — narrow or `?.` |
166
+ | `no-non-null-assertion` | `x!.y` postfix | None — narrow or `?.` |
167
167
  | `no-throw-literal` | `throw "string"` / `throw 123` | None — throw Error subclass |
168
168
  | `no-mutable-export` | `export let` / `export var` | None — use `export const` |
169
169
  | `no-any-annotation` | `: any` in parameter/return/variable types | `// no-excuse-ok: any` |
@@ -100,7 +100,7 @@ app.get(
100
100
  validator('query', QuerySchema),
101
101
  (c) => {
102
102
  const query = c.req.valid('query')
103
- return c.json({ message: `Hello ${query.name ?? 'Hono'}!` })
103
+ return c.json({ message: `Hello ${query.name ?? 'Hono'}.` })
104
104
  },
105
105
  )
106
106
 
@@ -128,7 +128,7 @@ For Bun projects, add to `compilerOptions`:
128
128
  | Rule | What |
129
129
  |---|---|
130
130
  | `noExplicitAny` | `any` in annotations is an error |
131
- | `noNonNullAssertion` | `x!` is an error |
131
+ | `noNonNullAssertion` | `x!.y` is an error |
132
132
  | `noDefaultExport` | Forces named exports |
133
133
  | `useImportType` | Forces `import type` for type-only imports |
134
134
  | `noParameterAssign` | No mutation of function parameters |
@@ -8,7 +8,7 @@
8
8
  * no-ts-ignore - `@ts-ignore` comments
9
9
  * no-ts-expect-error - `@ts-expect-error` comments
10
10
  * no-enum - `enum` declarations
11
- * no-non-null-assertion - `x!` postfix operator
11
+ * no-non-null-assertion - `x!.y` postfix operator
12
12
  * no-throw-literal - `throw "string"` / `throw 123`
13
13
  * no-mutable-export - `export let` / `export var`
14
14
  * no-any-annotation - `: any` in annotations (opt out: `// no-excuse-ok: any`)
@@ -140,7 +140,7 @@ function analyzeFile(filePath: string): Violation[] {
140
140
  // ── x! non-null assertion ──
141
141
  if (ts.isNonNullExpression(node)) {
142
142
  const p = pos(node)
143
- violations.push({ ruleId: "no-non-null-assertion", filePath, ...p, message: "`x!` — use narrowing or optional chaining" })
143
+ violations.push({ ruleId: "no-non-null-assertion", filePath, ...p, message: "`x!.y` — use narrowing or optional chaining" })
144
144
  }
145
145
 
146
146
  // ── throw "literal" ──
@@ -64,7 +64,7 @@ Done only when `$M complete --out DIR` exits 0: valid treatment, passing gate (a
64
64
 
65
65
  ## #contract.hard_stops
66
66
 
67
- Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 the treatment field, 17 a stage 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.
67
+ Exits 10, 11, 12, 14, 15: name the state and its fix (`references/runtime.md`). 16 the treatment field, 17 a stage or clip 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.
68
68
 
69
69
  ## #contract.anti_patterns
70
70
 
@@ -102,7 +102,7 @@ function prepare(out, env) {
102
102
  recordFirstTreatment(out, plan.treatment);
103
103
  const coverDir = join(out, COVER_DIR);
104
104
  if (!existsSync(join(coverDir, 'index.html'))) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: no ${COVER_DIR}/index.html in ${out}; write the cover page first (references/cover.md)`);
105
- const scan = scanStage(coverDir);
105
+ const scan = scanStage(coverDir, { clips: false });
106
106
  if (scan.contract.length) throw fail(EXIT.STAGE_CONTRACT_ERROR, `STAGE_CONTRACT_ERROR: ${scan.contract.join('; ')}`);
107
107
  if (scan.network.length) throw fail(EXIT.STAGE_NETWORK_REQUEST, `STAGE_NETWORK_REQUEST: ${scan.network.join('; ')}; a cover is served offline`);
108
108
  const assets = checkAssets({ out, recipe: plan.treatment.recipe, rasters: scan.rasters.paths, coverDir: COVER_DIR });
@@ -85,6 +85,7 @@ export function renderStageReport(manifest, gate, { out, previewName }) {
85
85
  lines.push(`path: stage; format ${manifest.format} (${manifest.resolution.join('x')}); ${manifest.durationSec.toFixed(2)} s at ${manifest.fps} fps (${manifest.totalFrames} frames); treatment ${manifest.targetDurationSec} s`);
86
86
  lines.push(`renderer: ${manifest.renderer}; flags: ${manifest.chromeFlags.join(' ')}`);
87
87
  lines.push(`frame time: p50 ${manifest.frameTimeMs.p50} ms, p95 ${manifest.frameTimeMs.p95} ms over ${manifest.frameTimeMs.captured} captures; master ${manifest.masterSec?.toFixed(1)} s`);
88
+ if (manifest.scan?.clips?.length) lines.push(`clips: ${manifest.scan.clips.map((c) => `${c.id} ${c.count} frames at ${c.fps} fps`).join(', ')} (keyed overlays, chosen from film time)`);
88
89
  if (manifest.sound) lines.push(`sound: ${manifest.sound.label}`);
89
90
  lines.push(`QA gate: ${gate.failed.length ? 'FAIL' : 'PASS'}`);
90
91
  for (const rule of gate.rules) lines.push(`${rule.id}: ${rule.status} — ${rule.detail}`);
@@ -1,7 +1,7 @@
1
1
  // LitHermes stage kit, served to stage pages at /lit/stage-kit.js.
2
2
  // Motion primitives only: easing, springs, keyframes, timing, seeded
3
- // randomness, text splitting, path drawing and morphing, masks, colour mixing
4
- // and text registration. It ships no scene, object, layout or copy; the page
3
+ // randomness, text splitting, path drawing and morphing, masks, colour mixing,
4
+ // text registration and declared keyed clips. It ships no scene, object, layout or copy; the page
5
5
  // draws whatever its treatment calls for. Every function is a pure function of
6
6
  // its arguments (and of t), so a frame depends only on the virtual clock.
7
7
  (() => {
@@ -374,6 +374,45 @@
374
374
  return target;
375
375
  }
376
376
 
377
+ // A declared keyed clip (stage/clips/clips.json, served as window.LitClips by /lit/clips.js). One absolutely
378
+ // placed wrapper at `box` holds the clip's frame for t; the frame index is floor((t - start) * fps) + 1 counted
379
+ // from `from`, a pure function of film time, so no <video> and no real clock is involved. Outside from..to the
380
+ // wrapper is hidden (null returned) unless `hold` keeps the last frame or `loop` wraps by frame index. The page
381
+ // scales or fades the returned wrapper; the <img> inside is replaced on a frame change and the renderer waits
382
+ // for its decode before it captures.
383
+ function clip({ parent, id, box } = {}) {
384
+ const all = window.LitClips;
385
+ if (!all) throw new Error('LitStage.clip: load <script src="/lit/clips.js"></script> after the kit; the stage declares no clips (clips/clips.json)');
386
+ const c = all[id];
387
+ if (!c) throw new Error(`LitStage.clip: no clip "${id}" in clips/clips.json (declared: ${Object.keys(all).join(', ')})`);
388
+ if (!parent || !box || !(box.w > 0) || !(box.h > 0)) throw new Error('LitStage.clip: needs { parent, id, box: { x, y, w, h } }');
389
+ const wrap = document.createElement('div');
390
+ wrap.setAttribute('data-lit-clip', id);
391
+ Object.assign(wrap.style, { position: 'absolute', left: `${box.x}px`, top: `${box.y}px`, width: `${box.w}px`, height: `${box.h}px`, display: 'none', pointerEvents: 'none' });
392
+ parent.append(wrap);
393
+ let shown = 0;
394
+ let img = null;
395
+ return (t, { start = 0, from = 1, to = c.count, loop = false, hold = false } = {}) => {
396
+ if (!(from >= 1 && to <= c.count && from <= to)) throw new Error(`LitStage.clip: from..to must lie within 1..${c.count} (got ${from}..${to})`);
397
+ const n = to - from + 1;
398
+ const k = Math.floor((t - start) * c.fps + 1e-6);
399
+ const i = k < 0 ? 0 : k < n ? from + k : loop ? from + (k % n) : hold ? to : 0;
400
+ wrap.style.display = i ? 'block' : 'none';
401
+ if (!i) return null;
402
+ if (i !== shown) {
403
+ const next = document.createElement('img');
404
+ next.src = `${c.src}/frame_${String(i).padStart(4, '0')}.png`;
405
+ next.alt = '';
406
+ Object.assign(next.style, { display: 'block', width: '100%', height: '100%', objectFit: 'contain' });
407
+ if (img) img.replaceWith(next);
408
+ else wrap.append(next);
409
+ img = next;
410
+ shown = i;
411
+ }
412
+ return wrap;
413
+ };
414
+ }
415
+
377
416
  function define(film) {
378
417
  if (!film || typeof film !== 'object') throw new Error('LitStage.define needs { width, height, fps, duration, render }');
379
418
  for (const key of ['width', 'height', 'duration']) if (!Number.isFinite(film[key])) throw new Error(`LitStage.define: ${key} must be a number`);
@@ -384,6 +423,6 @@
384
423
 
385
424
  window.LitStage = Object.freeze({
386
425
  ease, bezier, spring, kf, at, stagger, seq, rand, splitText, drawPath, morph,
387
- clipInset, clipCircle, maskWipe, mix, text, define,
426
+ clipInset, clipCircle, maskWipe, mix, text, define, clip,
388
427
  });
389
428
  })();
@@ -22,6 +22,7 @@ import { runTextQa } from './stage-qa.mjs';
22
22
  import { readStyle, styleCss, styleScript } from './styles.mjs';
23
23
  import { addToStills, writeBoard } from './board.mjs';
24
24
  import { FONT_FILES } from './frame.mjs';
25
+ import { decodePng } from './cover-png.mjs';
25
26
 
26
27
  import { findCache, fontFile, fontState, hangulPath, runtimeRequire, WARM } from '../bin/runtime.mjs';
27
28
 
@@ -54,6 +55,12 @@ export const CONTENT_TYPES = Object.freeze({
54
55
  const RASTER = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
55
56
  const TEXTUAL = new Set(['.html', '.js', '.mjs', '.css', '.svg', '.json']);
56
57
  export const RASTER_LIMITS = Object.freeze({ count: 24, bytes: 8 * 1024 * 1024, sameSize: 10 });
58
+ // Declared keyed clips (references/stage.md, Clips) answer to these caps instead of RASTER_LIMITS. A clip
59
+ // is a sticker on the film, never the film: 8-bit RGBA PNG frames, at most `side` px on the long side.
60
+ export const CLIP_LIMITS = Object.freeze({
61
+ clips: 12, framesPerClip: 360, framesTotal: 1500, bytesPerClip: 40 * 1024 * 1024, bytesTotal: 128 * 1024 * 1024, side: 720, fps: 30,
62
+ });
63
+ export const CLIPS_DIR = 'clips';
57
64
 
58
65
  // The software rung only (SwiftShader, CPU raster): a mismatch between two
59
66
  // captures can then only come from the page. Plus the stage set and the two
@@ -171,14 +178,130 @@ function walk(dir, root, out = []) {
171
178
  return out;
172
179
  }
173
180
 
174
- export function scanStage(stageDir) {
181
+ // ---------------------------------------------------------------------------
182
+ // Declared clips: stage/clips/clips.json and the frame folders it names.
183
+
184
+ const CLIP_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/u;
185
+ const CLIP_DIR = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/u;
186
+ const clipFrame = (i) => `frame_${String(i).padStart(4, '0')}.png`;
187
+ const mb = (bytes) => (bytes / 1048576).toFixed(1);
188
+
189
+ // The clips clips.json declares ([] without the file), checked for shape. Throws a plain Error naming the break.
190
+ function declaredClips(root, limits = CLIP_LIMITS) {
191
+ const file = join(root, CLIPS_DIR, 'clips.json');
192
+ if (!existsSync(file)) return [];
193
+ let doc;
194
+ try { doc = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { throw new Error(`${CLIPS_DIR}/clips.json is not valid JSON (${error.message})`); }
195
+ if (!doc || !Array.isArray(doc.clips)) throw new Error(`${CLIPS_DIR}/clips.json needs { "clips": [ { "id", "dir", "count", "fps" } ] }`);
196
+ if (doc.clips.length > limits.clips) throw new Error(`${doc.clips.length} clips declared (at most ${limits.clips} clips)`);
197
+ const ids = new Set(), dirs = new Set();
198
+ return doc.clips.map((c, n) => {
199
+ const at = `${CLIPS_DIR}/clips.json clip #${n + 1}`;
200
+ if (!c || typeof c !== 'object') throw new Error(`${at} is not an object`);
201
+ if (typeof c.id !== 'string' || !CLIP_ID.test(c.id)) throw new Error(`${at}: id must be a plain token of letters, digits, - and _ (got ${JSON.stringify(c.id)})`);
202
+ if (ids.has(c.id)) throw new Error(`${at}: duplicate id ${c.id}`);
203
+ ids.add(c.id);
204
+ if (typeof c.dir !== 'string' || !CLIP_DIR.test(c.dir) || c.dir.includes('..')) throw new Error(`clip "${c.id}": dir must be a folder name directly under ${CLIPS_DIR}/ (got ${JSON.stringify(c.dir)})`);
205
+ if (dirs.has(c.dir)) throw new Error(`clip "${c.id}": dir ${c.dir} is already declared by another clip`);
206
+ dirs.add(c.dir);
207
+ if (!Number.isInteger(c.count) || c.count < 1) throw new Error(`clip "${c.id}": count must be a whole number of frames (got ${JSON.stringify(c.count)})`);
208
+ if (!Number.isInteger(c.fps) || c.fps < 1 || c.fps > limits.fps) throw new Error(`clip "${c.id}": fps must be a whole number from 1 to ${limits.fps} (got ${JSON.stringify(c.fps)})`);
209
+ return { id: c.id, dir: c.dir, count: c.count, fps: c.fps };
210
+ });
211
+ }
212
+
213
+ // One declared clip against its bytes: exactly frame_0001.png..frame_<count>.png in clips/<dir>/, each an 8-bit
214
+ // non-animated RGBA PNG of one size within the overlay cap, real transparency in the first, middle and last frame.
215
+ function checkClip(d, byRel, limits) {
216
+ const fail = (message) => new Error(`clip "${d.id}": ${message}`);
217
+ if (d.count > limits.framesPerClip) throw fail(`${d.count} frames (at most ${limits.framesPerClip} per clip)`);
218
+ const names = [];
219
+ for (let i = 1; i <= d.count; i++) {
220
+ const rel = join(CLIPS_DIR, d.dir, clipFrame(i));
221
+ if (!byRel.has(rel)) throw fail(`${CLIPS_DIR}/${d.dir}/${clipFrame(i)} is missing (declared ${d.count} frames, named frame_0001.png onward)`);
222
+ names.push(rel);
223
+ }
224
+ const prefix = join(CLIPS_DIR, d.dir) + sep;
225
+ const stray = [...byRel.keys()].find((rel) => rel.startsWith(prefix) && !names.includes(rel));
226
+ if (stray) throw fail(`${stray.split(sep).join('/')} is not one of the ${d.count} declared frames; raise count or remove it`);
227
+ const bytes = names.reduce((sum, rel) => sum + statSync(byRel.get(rel)).size, 0);
228
+ if (bytes > limits.bytesPerClip) throw fail(`${mb(bytes)} MB (at most ${(limits.bytesPerClip / 1048576).toFixed(0)} MB per clip)`);
229
+ let size = null;
230
+ for (const rel of names) {
231
+ const data = readFileSync(byRel.get(rel));
232
+ const shown = rel.split(sep).join('/');
233
+ if (data.length < 33 || data.readUInt32BE(12) !== 0x49484452 || data.toString('latin1', 1, 4) !== 'PNG') throw fail(`${shown} is not a readable PNG`);
234
+ if (isAnimatedRaster(data, '.png')) throw fail(`${shown} is an animated image; a clip is a folder of still frames`);
235
+ if (data[25] !== 6 || data[24] !== 8) throw fail(`${shown} must be an 8-bit RGBA PNG with an alpha channel (a keyed frame), not an opaque image`);
236
+ const [width, height] = imageSize(data, '.png');
237
+ if (Math.max(width, height) > limits.side) throw fail(`${shown} is ${width}x${height}; a clip is an overlay of at most ${limits.side} px on the long side`);
238
+ if (size && (size[0] !== width || size[1] !== height)) throw fail(`${shown} is ${width}x${height} but frame_0001.png is ${size[0]}x${size[1]}; one size per clip`);
239
+ size = [width, height];
240
+ }
241
+ for (const rel of [names[0], names[Math.floor((names.length - 1) / 2)], names[names.length - 1]]) {
242
+ let rgba;
243
+ try { ({ rgba } = decodePng(readFileSync(byRel.get(rel)))); } catch (error) { throw fail(`${rel.split(sep).join('/')} cannot be decoded (${error.message}); use a non-interlaced 8-bit RGBA PNG`); }
244
+ let transparent = false;
245
+ for (let i = 3; i < rgba.length && !transparent; i += 4) transparent = rgba[i] < 255;
246
+ if (!transparent) throw fail(`${rel.split(sep).join('/')} has no transparent pixel; a clip is keyed, not a full-frame video`);
247
+ }
248
+ return { ...d, width: size[0], height: size[1], bytes, frames: names };
249
+ }
250
+
251
+ /**
252
+ * Check every declared clip. Never throws: returns { clips, problems, claimed }, where `clips` are the clips that
253
+ * passed ({ id, dir, count, fps, width, height, bytes, frames }), `problems` are the contract findings, and
254
+ * `claimed` is the set of stage-relative raster paths inside declared clip folders, which are not page rasters.
255
+ */
256
+ export function scanClips(stageDir, { limits = CLIP_LIMITS, files = null } = {}) {
257
+ const root = realpathSync(stageDir);
258
+ const problems = [];
259
+ let decls;
260
+ try { decls = declaredClips(root, limits); } catch (error) { return { clips: [], problems: [error.message], claimed: new Set() }; }
261
+ const byRel = new Map();
262
+ for (const f of files || walk(root, root)) if (!f.escape && RASTER.has(extname(f.path).toLowerCase())) byRel.set(relative(root, f.path), f.real);
263
+ const claimed = new Set();
264
+ const clips = [];
265
+ let frames = 0, bytes = 0;
266
+ for (const d of decls) {
267
+ const prefix = join(CLIPS_DIR, d.dir) + sep;
268
+ const mine = new Map([...byRel].filter(([rel]) => rel.startsWith(prefix)));
269
+ for (const rel of mine.keys()) claimed.add(rel);
270
+ try { clips.push(checkClip(d, mine, limits)); } catch (error) { problems.push(error.message); }
271
+ }
272
+ for (const c of clips) { frames += c.count; bytes += c.bytes; }
273
+ if (frames > limits.framesTotal) problems.push(`${frames} clip frames in all (at most ${limits.framesTotal})`);
274
+ if (bytes > limits.bytesTotal) problems.push(`${mb(bytes)} MB of clip frames in all (at most ${(limits.bytesTotal / 1048576).toFixed(0)} MB)`);
275
+ return { clips, problems, claimed };
276
+ }
277
+
278
+ /**
279
+ * window.LitClips for LitStage.clip: id -> { src, count, fps, width, height }, from clips.json and the first frame's
280
+ * header. null when the stage declares no clips, or declares ones the scan would refuse.
281
+ */
282
+ export function clipsScript(stageDir) {
283
+ try {
284
+ const root = realpathSync(stageDir);
285
+ const table = Object.fromEntries(declaredClips(root).map((c) => {
286
+ const [width, height] = imageSize(readFileSync(join(root, CLIPS_DIR, c.dir, clipFrame(1))), '.png');
287
+ return [c.id, { src: `${CLIPS_DIR}/${c.dir}`, count: c.count, fps: c.fps, width, height }];
288
+ }));
289
+ return Object.keys(table).length ? `window.LitClips = ${JSON.stringify(table)};\n` : null;
290
+ } catch { return null; }
291
+ }
292
+
293
+ // `clips: false` (cover mode) ignores clips.json, so a clip folder is held to the raster limits.
294
+ export function scanStage(stageDir, { clips: allowClips = true } = {}) {
175
295
  const root = realpathSync(stageDir);
176
296
  const contract = [], network = [];
177
297
  const files = walk(root, root);
298
+ const clipScan = allowClips ? scanClips(root, { files }) : { clips: [], problems: [], claimed: new Set() };
299
+ contract.push(...clipScan.problems);
178
300
  const rasters = [];
179
301
  for (const file of files) {
180
302
  const rel = relative(root, file.path);
181
303
  if (file.escape) { contract.push(`${rel} is a symlink that leaves the stage dir`); continue; }
304
+ if (clipScan.claimed.has(rel)) continue;
182
305
  const ext = extname(file.path).toLowerCase();
183
306
  const bytes = readFileSync(file.real);
184
307
  if (RASTER.has(ext)) {
@@ -199,7 +322,8 @@ export function scanStage(stageDir) {
199
322
  const bySize = new Map();
200
323
  for (const r of rasters) if (r.size) { const key = r.size.join('x'); bySize.set(key, (bySize.get(key) || 0) + 1); }
201
324
  for (const [key, count] of bySize) if (count >= RASTER_LIMITS.sameSize) contract.push(`${count} rasters of the same size ${key} read as a flipbook; draw motion, do not play frames`);
202
- return { contract: [...new Set(contract)], network: [...new Set(network)], files: files.length, rasters: { count: rasters.length, bytes: total, paths: rasters.map((r) => r.rel) } };
325
+ const clips = clipScan.clips.map(({ id, count, fps, width, height, bytes }) => ({ id, count, fps, width, height, bytes }));
326
+ return { contract: [...new Set(contract)], network: [...new Set(network)], files: files.length, rasters: { count: rasters.length, bytes: total, paths: rasters.map((r) => r.rel) }, clips };
203
327
  }
204
328
 
205
329
  // ---------------------------------------------------------------------------
@@ -312,6 +436,7 @@ export async function openStage({ runtime, stageDir, profileRoot, width, height,
312
436
  const cdp = await context.newCDPSession(page);
313
437
  const root = realpathSync(stageDir);
314
438
  const kit = readFileSync(join(here, 'stage-kit.js'));
439
+ const clipsJs = clipsScript(root);
315
440
  const blocked = [], contract = [];
316
441
  const serve = async (event) => {
317
442
  const { requestId, request } = event;
@@ -329,6 +454,7 @@ export async function openStage({ runtime, stageDir, profileRoot, width, height,
329
454
  const path = decodeURIComponent(url.pathname);
330
455
  if (path === '/lit/stage-kit.js') return reply(200, CONTENT_TYPES['.js'], kit);
331
456
  if (path === '/lit/fonts.css') return reply(200, CONTENT_TYPES['.css'], fontCss);
457
+ if (path === '/lit/clips.js' && clipsJs) return reply(200, CONTENT_TYPES['.js'], clipsJs);
332
458
  if (path === '/lit/style.css' && style) return reply(200, CONTENT_TYPES['.css'], style.css);
333
459
  if (path === '/lit/style.js' && style) return reply(200, CONTENT_TYPES['.js'], style.js);
334
460
  if (path.startsWith('/lit/three/')) {
@@ -448,9 +574,12 @@ function beatFramesOf(treatment, fps, totalFrames) {
448
574
  });
449
575
  }
450
576
 
451
- function imageUrlsIn(stageDir) {
577
+ // The rasters decoded before frame 0. Declared clip frames are left out: a page decodes each one when it shows it.
578
+ export function imageUrlsIn(stageDir) {
452
579
  const root = realpathSync(stageDir);
453
- return walk(root, root).filter((f) => !f.escape && RASTER.has(extname(f.path).toLowerCase()))
580
+ let prefixes = [];
581
+ try { prefixes = declaredClips(root).map((c) => join(CLIPS_DIR, c.dir) + sep); } catch { /* the scan already refused a bad declaration */ }
582
+ return walk(root, root).filter((f) => !f.escape && RASTER.has(extname(f.path).toLowerCase()) && !prefixes.some((p) => relative(root, f.path).startsWith(p)))
454
583
  .map((f) => `${ORIGIN}/${relative(root, f.path).split(sep).map(encodeURIComponent).join('/')}`);
455
584
  }
456
585
 
@@ -17,7 +17,8 @@ deliverable.
17
17
  Never install, download or copy a third-party library into the stage dir. Use
18
18
  the kit below and your own code. Rasters are textures and stills, never a frame
19
19
  sequence: at most 24 raster images and 8 MB in total; 10 or more rasters of the
20
- same size (a flipbook), or any animated GIF, APNG or WebP, is exit 17.
20
+ same size (a flipbook), or any animated GIF, APNG or WebP, is exit 17. The one
21
+ frame sequence a page may carry is a declared keyed clip (Clips).
21
22
 
22
23
  Put all on-screen copy in one `COPY` object at the top of `stage/index.html`,
23
24
  so the copy is easy to find and edit:
@@ -43,6 +44,8 @@ declares the film:
43
44
  1080×1920 for 9:16. `duration` is the treatment's `durationSec`; the render
44
45
  must land within ±10 % of it.
45
46
  - `fps` is 60. Use 30 only when the treatment says so.
47
+ - A page that shows clips also loads `<script src="/lit/clips.js"></script>`
48
+ after the kit (Clips).
46
49
  - `render(t)` draws the frame at time `t` in seconds. It may be omitted when
47
50
  the page animates only with CSS or the Web Animations API.
48
51
  - Setting `window.litStage` to the same object is equivalent to calling
@@ -168,6 +171,57 @@ scenes, no objects, no layouts, no copy. `window.LitStage` holds:
168
171
  - **Text registration.** `LitStage.text(el, { decor: true })` and
169
172
  `LitStage.text({ content, x, y, w, h })`, above.
170
173
 
174
+ - **Clips.** `LitStage.clip({ parent, id, box })` registers a declared keyed
175
+ clip once and returns `show(t, opts)`, which `render(t)` calls every frame
176
+ (Clips).
177
+
178
+ ```text
179
+ const boom = LitStage.clip({ parent: layer, id: "boom", box: { x: 1200, y: 300, w: 520, h: 511 } });
180
+ boom(t, { start: 2.1 });
181
+ ```
182
+
183
+ ## Clips
184
+
185
+ Real keyed footage (a reaction clip, a spinning loop shot on a green screen) joins the
186
+ film as a layer of the page. The footage is keyed outside the renderer into one 8-bit RGBA
187
+ PNG per frame, the frames are declared, and the page picks the frame from film time. Nothing
188
+ is encoded by hand: the renderer captures the page, so the flash audit, the determinism
189
+ replay and the text checks see the final pixels like any other film.
190
+
191
+ 1. Put each clip's frames in the run's stage folder as `stage/clips/<dir>/frame_0001.png`,
192
+ `frame_0002.png` and on, with no gap and no other image in that folder.
193
+ 2. Declare the clips in `stage/clips/clips.json`:
194
+
195
+ ```json
196
+ { "clips": [ { "id": "boom", "dir": "01-boom", "count": 122, "fps": 30 } ] }
197
+ ```
198
+
199
+ `id` is the name the page uses, `dir` a folder directly under `clips/`, `count` the number
200
+ of frames and `fps` the clip's own rate, 1 to 30. Declare a clip once even if it plays twice.
201
+ 3. The page loads `<script src="/lit/clips.js"></script>` after the kit. For each place a clip
202
+ appears it calls `LitStage.clip({ parent, id, box })` once, outside `render`, and calls the
203
+ returned `show(t, opts)` from `render(t)`. `parent` holds the clip, `box` is its place in
204
+ pixels and the frame is fitted inside it. `show` returns the clip's wrapper element, or
205
+ `null` while the clip is hidden; scale, fade or move that wrapper from `t` like any layer.
206
+
207
+ The frame is `floor((t - start) * fps) + 1`, counted from `from`, so a cue is plain
208
+ arithmetic: `start = cue - (N - 1) / fps` puts frame N on the cue. A clip whose own sound
209
+ sits on frame 85 and has to land on a cue at 0.12 s takes `start = 0.12 - 84 / 30`. Options
210
+ are `start` (film seconds, default 0), `from` and `to` (the frame range, default the whole
211
+ clip), `loop` (wrap by frame index) and `hold` (keep the last frame). Before `start`, and after
212
+ `to` without `loop` or `hold`, the clip is hidden.
213
+
214
+ The pre-flight scan refuses with exit 17, naming the clip: a stage holds at most 12 clips; a
215
+ clip holds at most 360 frames and the stage 1500 in all; more than 40 MB in a clip or 128 MB in
216
+ all; a frame whose long side is above 720 px (a clip is an overlay, not the film); a frame rate
217
+ above 30; frames of mixed sizes; a missing or extra frame; a frame that is not an 8-bit RGBA
218
+ PNG, or has no transparent pixel in the first, middle and last frame (an opaque video is not
219
+ keyed); an animated image; a duplicate `id`; and a `dir` that leaves `clips/`. A folder that is
220
+ not declared, and any frame sequence beside a declared clip, is a flipbook as before. Cover mode takes no clips,
221
+ so a declaration there changes nothing and the folder meets the raster limits.
222
+ The text checks read DOM text, so keep clips clear of copy; a clip laid over a line is yours to
223
+ catch on the stills. The manifest and the gate report list each clip.
224
+
171
225
  ## How the capture works
172
226
 
173
227
  The renderer serves the page to a software-rendered Chrome (SwiftShader, CPU
@@ -3771,9 +3771,9 @@
3771
3771
  "license": "ISC"
3772
3772
  },
3773
3773
  "node_modules/zod": {
3774
- "version": "4.5.4",
3775
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
3776
- "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
3774
+ "version": "4.6.6",
3775
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.6.tgz",
3776
+ "integrity": "sha512-4UmzQUF5fxvfcYHlbjBmejU+w+hbDnWFx4aS8kBx7nPBHcO2SqPbpAG8snbdg+Xse4pRefR98hgjFgIMCI/UhA==",
3777
3777
  "license": "MIT",
3778
3778
  "funding": {
3779
3779
  "url": "https://github.com/sponsors/colinhacks"
@@ -3,5 +3,6 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "scripts": {"render": "remotion render", "still": "remotion still"},
6
- "dependencies": {"@remotion/cli":"4.0.526", "remotion":"4.0.526", "react":"19.3.0", "react-dom":"19.3.0"}
6
+ "dependencies": {"@remotion/cli":"4.0.526", "remotion":"4.0.526", "react":"19.3.0", "react-dom":"19.3.0"},
7
+ "overrides": {"zod": "4.6.6"}
7
8
  }
@@ -169,7 +169,7 @@ priority rules.
169
169
  `paths` and Copilot `applyTo` aliases. A Cursor-style comma-separated scalar
170
170
  (`globs: *.ts,*.tsx`) is split into separate patterns.
171
171
  - **Glob support is a documented SUBSET**, not picomatch. `**`, `*`, `?`,
172
- `[seq]`, `{a,b}` and a leading `!` work. Extglob (`@(a|b)`) and POSIX classes
172
+ `[seq]`, `{a,b}` and a leading (!) work. Extglob (`@(a|b)`) and POSIX classes
173
173
  (`[[:alpha:]]`) do NOT — a rule using them silently matches nothing. The full
174
174
  divergence list lives in `rules/globmatch.py` `DIVERGENCES`; read it before
175
175
  claiming a glob "should have" matched.
@@ -485,7 +485,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
485
485
 
486
486
  ```text
487
487
  $ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
488
- Installed LitHermes 1.0.32
488
+ Installed LitHermes 1.0.34
489
489
  plugin: $ISOLATED/plugins/lithermes
490
490
  model config: updated
491
491
 
@@ -502,7 +502,7 @@ installed payload: PASS
502
502
  enabled config: PASS
503
503
 
504
504
  $ hermes lithermes doctor
505
- [OK] plugin.yaml readable (version 1.0.32)
505
+ [OK] plugin.yaml readable (version 1.0.34)
506
506
  [OK] skills bundled: 40
507
507
  [OK] litgoal durable runtime importable
508
508
  ```
@@ -42,6 +42,8 @@ INSTALL_TIMEOUT_SECONDS = 90.0
42
42
  MAX_RESPONSE_BYTES = 64 * 1024
43
43
  AUTO_UPDATE_GUARD_ENV = "LITHERMES_AUTO_UPDATE_RUNNING"
44
44
  INSTALLER_DISTRIBUTION = "npm"
45
+ NPM_CHANNEL_MARKER_NAME = ".lithermes-npm-channel"
46
+ NPM_CHANNEL_MARKER_TEXT = "lithermes npm channel\n"
45
47
  ATTEMPT_RECORD_NAME = "auto-update-attempt.json"
46
48
  # A broken npm fetch blocked each new session's first reply for about 70 s, so
47
49
  # after a failed install the bridge waits a day before it tries the same
@@ -244,19 +246,51 @@ def _read_install_receipt(home: Path) -> dict[str, Any] | None:
244
246
  return _read_state_json(home, "auto-update-receipt.json")
245
247
 
246
248
 
249
+ def _plugin_dir(home: Path, manifest: dict[str, Any] | None) -> Path:
250
+ """Prefer the receipt's plugin path when it is a real directory, not a link."""
251
+
252
+ raw = manifest.get("pluginPath") if isinstance(manifest, dict) else None
253
+ if isinstance(raw, str) and raw.strip():
254
+ candidate = Path(raw).expanduser()
255
+ try:
256
+ info = candidate.lstat()
257
+ except OSError:
258
+ info = None
259
+ if info is not None and stat.S_ISDIR(info.st_mode) and not stat.S_ISLNK(info.st_mode):
260
+ return candidate
261
+ return home / "plugins" / "lithermes"
262
+
263
+
264
+ def _channel_marker_matches(root: Path) -> bool:
265
+ marker = root / NPM_CHANNEL_MARKER_NAME
266
+ expected = NPM_CHANNEL_MARKER_TEXT.encode("utf-8")
267
+ try:
268
+ info = marker.lstat()
269
+ if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
270
+ return False
271
+ fd = os.open(marker, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
272
+ try:
273
+ data = os.read(fd, len(expected) + 1)
274
+ finally:
275
+ os.close(fd)
276
+ except OSError:
277
+ return False
278
+ return data == expected
279
+
280
+
247
281
  def _is_npm_channel(home: Path) -> bool:
248
- """Return true only for a manifest written by the npm installer.
249
-
250
- Hermes catalog/git installs copy the plugin tree directly and do not create the
251
- installer's ownership receipt. The explicit distribution marker makes a stale
252
- receipt fail closed too: a catalog payload must never use it as permission to
253
- fetch and replace itself. Older receipts without the marker are intentionally
254
- treated as non-npm until the user runs the installer once, which preserves the
255
- catalog safety boundary over an ambiguous channel.
282
+ """Return true only for an npm receipt that also carries the channel marker.
283
+
284
+ The receipt lives outside the plugin directory and survives a catalog replace,
285
+ so ``distribution: npm`` alone is not permission to fetch and replace the
286
+ tree. The marker is written only by the npm installer, into the plugin
287
+ directory, and a catalog copy never contains it. A symlink is not the marker.
256
288
  """
257
289
 
258
290
  manifest = _read_state_json(home, "install-manifest.json")
259
- return isinstance(manifest, dict) and manifest.get("distribution") == INSTALLER_DISTRIBUTION
291
+ if not (isinstance(manifest, dict) and manifest.get("distribution") == INSTALLER_DISTRIBUTION):
292
+ return False
293
+ return _channel_marker_matches(_plugin_dir(home, manifest))
260
294
 
261
295
 
262
296
  def _utc_stamp(seconds: float | None = None) -> str:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/lithermes",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
4
4
  "description": "Hermes Agent plugin that plans, builds, checks and hands off your work when you add one word, lit, to a request.",
5
5
  "keywords": [
6
6
  "hermes-agent",
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.32"><title>release: 1.0.32</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.32</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.34"><title>release: 1.0.34</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.34</text></g></svg>
@@ -14,7 +14,7 @@ const EXPECTED = Object.freeze({
14
14
  tree: "9188410be0af35f2421ba300d91a0d7a7341caf0",
15
15
  fileCount: 167,
16
16
  totalBytes: 2596360,
17
- digest: "b0d1a085de8856e7edeba24127d26875fe5473b80c0d95f6578addfdc801e445",
17
+ digest: "c921ed87f41b5aaef14ef480c791f1c36bbd3ec616eaccc32f5e1246fcc547e6",
18
18
  directories: ["design", "designpowers", "perfection", "ui-ux-db"],
19
19
  attributes: "corpus/** -text\nlegal/** -text\n",
20
20
  legal: [
@@ -32,6 +32,9 @@ const { installSkins } = require("./skins");
32
32
 
33
33
  const packageRoot = path.resolve(__dirname, "..", "..");
34
34
  const assetRoot = path.join(packageRoot, "assets", "lithermes-plugin");
35
+ const channelRoot = path.join(packageRoot, "assets", "npm-channel");
36
+ const NPM_CHANNEL_MARKER_NAME = ".lithermes-npm-channel";
37
+ const NPM_CHANNEL_MARKER_TEXT = "lithermes npm channel\n";
35
38
  const LEGACY_VENDOR_DIRS = new Map([
36
39
  ["vendor/022_handoff", "vendor/handoff"],
37
40
  ["vendor/045_scientific-visualization", "vendor/scientific-visualization"],
@@ -547,6 +550,24 @@ function previewModelRoute(flags = {}) {
547
550
  };
548
551
  }
549
552
 
553
+ function stageNpmChannel(dest) {
554
+ const source = path.join(channelRoot, "auto_update.py");
555
+ const sourceStat = fs.lstatSync(source);
556
+ if (!sourceStat.isFile()) {
557
+ throw new LitHermesError("npm channel updater is missing.", 5);
558
+ }
559
+ const target = path.join(dest, "auto_update.py");
560
+ fs.copyFileSync(source, target);
561
+ fs.chmodSync(target, 0o644);
562
+ const marker = path.join(dest, NPM_CHANNEL_MARKER_NAME);
563
+ fs.writeFileSync(marker, NPM_CHANNEL_MARKER_TEXT, { encoding: "utf8", mode: 0o644 });
564
+ fs.chmodSync(marker, 0o644);
565
+ return [
566
+ { path: "auto_update.py", sha256: sha256(target) },
567
+ { path: NPM_CHANNEL_MARKER_NAME, sha256: sha256(marker) },
568
+ ];
569
+ }
570
+
550
571
  function installLitHermes(flags = {}) {
551
572
  const dryRun = Boolean(flags["dry-run"]);
552
573
  const homeInfo = dryRun
@@ -655,6 +676,7 @@ function installLitHermes(flags = {}) {
655
676
  assertPluginParent(hermesHome);
656
677
  fs.rmSync(dest, { recursive: true, force: true });
657
678
  const files = copyTree(assetRoot, dest);
679
+ files.push(...stageNpmChannel(dest));
658
680
  onProgress("Writing Hermes config");
659
681
  checkState();
660
682
  let afterConfig = beforeConfig;