@officexapp/vidfarm-devcli 0.21.34 → 0.21.35

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 (25) hide show
  1. package/.agents/skills/editor-capabilities/SKILL.md +13 -2
  2. package/.agents/skills/vidfarm/SKILL.md +59 -30
  3. package/.agents/skills/vidfarm/harnesses/README.md +112 -0
  4. package/.agents/skills/vidfarm/{regimes/explainer.QA_REGIME.md → harnesses/explainer.HARNESS.md} +3 -2
  5. package/.agents/skills/vidfarm/{regimes/hooks.QA_REGIME.md → harnesses/hooks.HARNESS.md} +3 -3
  6. package/.agents/skills/vidfarm/{regimes/product-demo.QA_REGIME.md → harnesses/product-demo.HARNESS.md} +1 -1
  7. package/.agents/skills/vidfarm/{regimes/short-form.QA_REGIME.md → harnesses/short-form.HARNESS.md} +36 -7
  8. package/.agents/skills/vidfarm/{regimes/ugc-testimonial.QA_REGIME.md → harnesses/ugc-testimonial.HARNESS.md} +3 -3
  9. package/.agents/skills/vidfarm/recipes/{bulk-scripting-with-a-regime.md → bulk-scripting-with-a-harness.md} +20 -12
  10. package/.agents/skills/vidfarm/recipes/cutout-graphics-for-explainers.md +1 -1
  11. package/.agents/skills/vidfarm/recipes/local-edit-render-approve.md +1 -1
  12. package/.agents/skills/vidfarm/references/automation-and-local-dev.md +73 -22
  13. package/.agents/skills/vidfarm/references/editor-workflows.md +18 -5
  14. package/.agents/skills/vidfarm/references/hooks-and-virality.md +62 -5
  15. package/.agents/skills/vidfarm/references/reviewing-renders.md +2 -1
  16. package/.agents/skills/vidfarm-media/SKILL.md +2 -2
  17. package/.agents/skills/vidfarm-media/references/tts.md +26 -4
  18. package/SKILL.director.md +236 -77
  19. package/SKILL.md +32 -14
  20. package/dist/src/cli.js +772 -89
  21. package/dist/src/devcli/{qa-regime.js → harness.js} +132 -55
  22. package/dist/src/devcli/qa-check.js +209 -4
  23. package/dist/src/devcli/skill-docs.js +136 -0
  24. package/package.json +4 -3
  25. package/.agents/skills/vidfarm/regimes/README.md +0 -79
@@ -25,6 +25,33 @@
25
25
  // Pure DOM (linkedom) — no ffmpeg, no network, no Chrome. Local-only: this is a
26
26
  // devcli capability with no cloud/REST equivalent.
27
27
  import { parseHTML } from "linkedom";
28
+ /**
29
+ * Build the "now go watch it" directive. `dir` is substituted into the commands
30
+ * so the agent can paste them; the JSON default is the generic placeholder.
31
+ */
32
+ export function watchTheVideoDirective(dir = "<dir>", renderPath) {
33
+ const video = renderPath || "<render>.mp4";
34
+ return {
35
+ required: true,
36
+ why: "`vidfarm qa` is a static DOM check. It did NOT render this composition, and it has never seen a single pixel of the video — " +
37
+ "so it cannot tell you whether the video is any good. A clean QA run means the markup is clean; it says nothing about drift " +
38
+ "across scenes, a frozen render, a caption sitting on top of the subject, a join that lands like a slap, or audio nobody can " +
39
+ "hear. Those are the defects that actually ship. Watching the video is a required step, not a nicety — and 'it looks fine' " +
40
+ "from an agent that never opened a frame was wrong on every video of a 32-video batch.",
41
+ steps: [
42
+ renderPath
43
+ ? `Re-render if you have edited since: \`vidfarm render ${dir} --target local\` (newest render: ${renderPath})`
44
+ : `Render it: \`vidfarm render ${dir} --target local\``,
45
+ `Look at the WHOLE thing: \`vidfarm stills ${dir} --sheet\` — then actually OPEN ${dir}/stills/contact-sheet.png and read it as an image. Reading the filenames is not looking at the video.`,
46
+ "Judge it as one sequence, not scene by scene: one type scale, steady margins, one palette, deliberate pacing, clean joins, no dead bands.",
47
+ "Judge every caption AGAINST ITS PICTURE: is the line sitting in the emptiest part of that frame, or on top of the subject? Is it small enough not to run edge-to-edge? Does it even need its plate? qa cannot see pixels — you can.",
48
+ "Compare frames from two DIFFERENT scenes — a frozen render passes duration, frame-count and audio-hash checks and still ships a still image.",
49
+ "Time it against your own thumb: name the seconds you would cut. Any beat you can delete without losing the payoff IS fluff — cut it and ripple the hole closed. Assume your first cut is 30–50% too long, and say out loud which beat you cut, or why nothing could go.",
50
+ `Measure the audio instead of vibing it: \`ffmpeg -i ${video} -af volumedetect -f null -\` (peak < 0 dBFS, speech ~12–15 dB over the bed).`,
51
+ "Report what you MEASURED separately from what you JUDGED, and say plainly if you did not watch it."
52
+ ]
53
+ };
54
+ }
28
55
  // ── The font regime ───────────────────────────────────────────────────────────
29
56
  // Mirrors CAPTION_FONT_REGIME in composition-edit.ts / COMPOSITION_FONT_IMPORT
30
57
  // in services/studio-project-adapter.ts. A family outside this set is not even
@@ -78,6 +105,45 @@ function coversTime(node, t) {
78
105
  function textOf(node) {
79
106
  return String(node?.textContent ?? "").replace(/\s+/g, " ").trim();
80
107
  }
108
+ function wordsIn(text) {
109
+ return text.split(/\s+/).filter(Boolean).length;
110
+ }
111
+ /** Merge [start,end) spans into non-overlapping, ascending cover intervals. */
112
+ function mergeSpans(spans) {
113
+ const sorted = spans
114
+ .filter((s) => Number.isFinite(s.start) && Number.isFinite(s.end) && s.end > s.start)
115
+ .sort((a, b) => a.start - b.start);
116
+ const merged = [];
117
+ for (const span of sorted) {
118
+ const last = merged[merged.length - 1];
119
+ if (last && span.start <= last.end + 0.001)
120
+ last.end = Math.max(last.end, span.end);
121
+ else
122
+ merged.push({ start: span.start, end: span.end });
123
+ }
124
+ return merged;
125
+ }
126
+ /**
127
+ * The widest stretch INSIDE the words — first cue to last — where nothing is on
128
+ * screen to read. Leading silence is the hook's problem (own rule) and trailing
129
+ * silence is the tail's, so both are excluded here.
130
+ */
131
+ function widestGap(cover) {
132
+ let widest = null;
133
+ for (let i = 1; i < cover.length; i += 1) {
134
+ const gap = { start: cover[i - 1].end, end: cover[i].start };
135
+ if (!widest || gap.end - gap.start > widest.end - widest.start)
136
+ widest = gap;
137
+ }
138
+ return widest;
139
+ }
140
+ function median(values) {
141
+ if (!values.length)
142
+ return 0;
143
+ const sorted = [...values].sort((a, b) => a - b);
144
+ const mid = Math.floor(sorted.length / 2);
145
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
146
+ }
81
147
  function classTokens(node) {
82
148
  return String(node?.getAttribute?.("class") ?? "").split(/\s+/).filter(Boolean);
83
149
  }
@@ -167,11 +233,19 @@ export function qaCompositionHtml(html) {
167
233
  verdict: "clean",
168
234
  errors: [],
169
235
  warnings: [],
170
- checked: { layers: 0, text_layers: 0, canvas: null }
236
+ checked: { layers: 0, text_layers: 0, canvas: null },
237
+ watch_the_video: watchTheVideoDirective()
171
238
  };
172
239
  }
173
240
  if (!root) {
174
- return { ok: true, verdict: "clean", errors: [], warnings: [], checked: { layers: 0, text_layers: 0, canvas: null } };
241
+ return {
242
+ ok: true,
243
+ verdict: "clean",
244
+ errors: [],
245
+ warnings: [],
246
+ checked: { layers: 0, text_layers: 0, canvas: null },
247
+ watch_the_video: watchTheVideoDirective()
248
+ };
175
249
  }
176
250
  const push = (f) => findings.push(f);
177
251
  const all = Array.from(root.querySelectorAll("*"));
@@ -457,8 +531,41 @@ export function qaCompositionHtml(html) {
457
531
  fix: `Aim for ~${Math.round(canvasW * 0.033)}–${Math.round(canvasW * 0.06)}px.`
458
532
  });
459
533
  }
534
+ else if (canvasW && sizePx > canvasW * 0.075 && wordsIn(textOf(node)) >= 5) {
535
+ // Display type is for one to three hook words. On a whole sentence it
536
+ // runs frame-edge to frame-edge, wraps to three lines, and eats a third
537
+ // of the picture — which is also what forces a full-width plate under
538
+ // it. TWO signals required (size AND length), so a huge two-word hook
539
+ // card never trips this.
540
+ push({
541
+ rule: "caption-oversize",
542
+ severity: "warn",
543
+ message: `font-size ${Math.round(sizePx)}px on a ${wordsIn(textOf(node))}-word line (canvas ${canvasW}px wide) — display type on a full sentence runs edge-to-edge, wraps, and covers the frame.`,
544
+ where: label(node, "text layer"),
545
+ fix: `Shrink the type to ~${Math.round(canvasW * 0.033)}–${Math.round(canvasW * 0.06)}px, or keep this size and cut the cue to 1–3 words. If a line reaches the frame edges the fix is smaller text or fewer words — never a wider box. Then place it where the frame is empty (\`vidfarm stills <dir> --at <t>\` first): text with room around it usually needs no plate at all.`
546
+ });
547
+ }
460
548
  }
461
549
  }
550
+ // ── Rule: wall of text ─────────────────────────────────────────────────────
551
+ // A caption layer is a PAGE, not a transcript. Past ~14 words in one static
552
+ // run the viewer has to read a paragraph while watching the video, and does
553
+ // neither. Layers that are already part of an animated caption run are
554
+ // exempt — those ARE the paged form.
555
+ for (const node of textLayers) {
556
+ const words = wordsIn(textOf(node));
557
+ if (words < 14 || isAnimatedCaptionPart(node))
558
+ continue;
559
+ const duration = numAttrOf(node, "data-duration");
560
+ const held = Number.isFinite(duration) && duration > 0 ? ` held for ${duration.toFixed(1)}s` : "";
561
+ push({
562
+ rule: "wall-of-text",
563
+ severity: "warn",
564
+ message: `One static text layer carries ${words} words${held} — a paragraph, not a caption. A scrolling viewer reads none of it.`,
565
+ where: label(node, "text layer"),
566
+ fix: "Page it into kinetic cues: `vidfarm captions generate <dir> --style word-pop` (or `spotlight`/`karaoke`) splits narration into ~3–5-word cues on real word timings; in the web editor, the `/primitives/audio/captions` job → `set_captions`. Short cues also read at a smaller size, which frees frame space and usually removes the need for a plate. Static text is for hook lines, payoff numbers, and title cards."
567
+ });
568
+ }
462
569
  // ── Rule: caption safe zone ────────────────────────────────────────────────
463
570
  // Local renders auto-normalize this, but the editor preview and cloud render
464
571
  // do not — so flag it as a warning rather than silently relying on the fixer.
@@ -539,6 +646,72 @@ export function qaCompositionHtml(html) {
539
646
  fix: "Start the hook line at 0 so the poster frame states the promise. Ignore this if the video deliberately opens on a clean face/product shot."
540
647
  });
541
648
  }
649
+ // ── Rules: density — every second has to earn its place ────────────────────
650
+ // A second that carries nothing is a free exit: the viewer re-decides to stay
651
+ // continuously, so dead screen time is the cheapest retention loss there is.
652
+ // All three are warnings, never errors — a held beat can be the joke, and a
653
+ // long take can be the whole point. They point at seconds worth defending.
654
+ const compDuration = Number.parseFloat(String(root.getAttribute?.("data-duration") ?? ""));
655
+ const textSpans = textLayers
656
+ .map((node) => ({ start: numAttrOf(node, "data-start"), end: numAttrOf(node, "data-start") + numAttrOf(node, "data-duration") }))
657
+ .filter((span) => Number.isFinite(span.start) && Number.isFinite(span.end) && span.end > span.start);
658
+ const textCover = mergeSpans(textSpans);
659
+ // Dead air: a hole BETWEEN cues. Needs 3+ cues so a two-card title sequence
660
+ // (where the space between cards is the design) never trips it. Counted on
661
+ // the RUNS, not the merged cover — back-to-back cues collapse into one span.
662
+ if (textSpans.length >= 3 && textCover.length >= 2) {
663
+ const gap = widestGap(textCover);
664
+ const seconds = gap ? gap.end - gap.start : 0;
665
+ if (gap && seconds >= 2.5) {
666
+ const gapsOver = textCover.reduce((count, span, i) => (i > 0 && span.start - textCover[i - 1].end >= 2.5 ? count + 1 : count), 0);
667
+ const others = gapsOver - 1;
668
+ push({
669
+ rule: "dead-air",
670
+ severity: "warn",
671
+ message: `${seconds.toFixed(1)}s with nothing to read (${gap.start.toFixed(1)}s–${gap.end.toFixed(1)}s)${others > 0 ? `, and ${others} more gap(s) ≥2.5s` : ""} — dead screen time is a free exit.`,
672
+ where: `timeline ${gap.start.toFixed(1)}s–${gap.end.toFixed(1)}s`,
673
+ fix: "Apply the deletion test to that stretch: if it carries no charge (hook/loop/payoff/bait), cut it and CLOSE the hole — `vidfarm ripple <dir> --at <sec> --delta -<sec>` — don't leave the gap. If the beat earns its seconds (a held comedic pause, the payoff playing out), keep it and ignore this."
674
+ });
675
+ }
676
+ }
677
+ // The tail: footage still rolling after the last word. Almost always an outro,
678
+ // an end card, or a clip nobody trimmed.
679
+ if (textSpans.length >= 2 && textCover.length) {
680
+ const lastWord = textCover[textCover.length - 1].end;
681
+ const visualEnd = layers
682
+ .filter(isVisualClip)
683
+ .reduce((acc, node) => Math.max(acc, numAttrOf(node, "data-start") + numAttrOf(node, "data-duration")), 0);
684
+ const contentEnd = Math.max(Number.isFinite(compDuration) ? compDuration : 0, Number.isFinite(visualEnd) ? visualEnd : 0);
685
+ const tail = contentEnd - lastWord;
686
+ if (tail > 1.5) {
687
+ push({
688
+ rule: "dead-tail",
689
+ severity: "warn",
690
+ message: `The video keeps running ${tail.toFixed(1)}s after the last word (${lastWord.toFixed(1)}s → ${contentEnd.toFixed(1)}s) — an outro nobody watches.`,
691
+ where: `timeline ${lastWord.toFixed(1)}s–${contentEnd.toFixed(1)}s`,
692
+ fix: "End on the bait. Trim the trailing clip (`vidfarm trim <dir> --layer <key> --edge end --to-time <sec>`) and pull the composition duration in (`vidfarm set-composition <dir> --duration <sec>`) so the last frame is the one that loops best."
693
+ });
694
+ }
695
+ }
696
+ // One scene that drags. Judged RELATIVE to the video's own rhythm, so a
697
+ // deliberately slow piece passes and only the outlier in a fast cut is named.
698
+ const sceneDurations = layers
699
+ .filter(isVisualClip)
700
+ .map((node) => numAttrOf(node, "data-duration"))
701
+ .filter((value) => Number.isFinite(value) && value > 0);
702
+ if (sceneDurations.length >= 3) {
703
+ const mid = median(sceneDurations);
704
+ const worst = Math.max(...sceneDurations);
705
+ if (mid > 0 && worst > 6 && worst > mid * 2.5) {
706
+ push({
707
+ rule: "slow-scene",
708
+ severity: "warn",
709
+ message: `One clip runs ${worst.toFixed(1)}s while the rest of the video cuts every ~${mid.toFixed(1)}s — that scene is where viewers leave.`,
710
+ where: "timeline — longest visual clip",
711
+ fix: "Split it and cut away (`vidfarm split <dir> --layer <key> --at <sec>`), speed-ramp the middle, or trim it toward the video's own rhythm. Keep it only if something is genuinely changing on screen for the whole take — the payoff playing out is a legitimate long hold."
712
+ });
713
+ }
714
+ }
542
715
  const errors = findings.filter((f) => f.severity === "error");
543
716
  const warnings = findings.filter((f) => f.severity === "warn");
544
717
  return {
@@ -550,7 +723,8 @@ export function qaCompositionHtml(html) {
550
723
  layers: layers.length,
551
724
  text_layers: textLayers.length,
552
725
  canvas: canvasW && canvasH ? `${canvasW}×${canvasH}` : null
553
- }
726
+ },
727
+ watch_the_video: watchTheVideoDirective()
554
728
  };
555
729
  }
556
730
  function aspectLabel(width, height) {
@@ -561,7 +735,7 @@ function aspectLabel(width, height) {
561
735
  return `${width / divisor}:${height / divisor}`;
562
736
  }
563
737
  /**
564
- * Derive the facts a QA_REGIME.md can assert against. Pure, tolerant of
738
+ * Derive the facts a HARNESS.md can assert against. Pure, tolerant of
565
739
  * malformed input (returns empty facts rather than throwing) — same contract as
566
740
  * qaCompositionHtml, because a regime run must never break a render either.
567
741
  */
@@ -578,6 +752,9 @@ export function extractCompositionFacts(html) {
578
752
  first_frame_text: null,
579
753
  first_text_at_sec: null,
580
754
  max_simultaneous_text: 0,
755
+ max_words_in_run: 0,
756
+ max_dead_air_sec: 0,
757
+ tail_silence_sec: 0,
581
758
  off_regime_fonts: [],
582
759
  all_text: ""
583
760
  };
@@ -627,6 +804,12 @@ export function extractCompositionFacts(html) {
627
804
  if (overlapping > maxSimultaneous)
628
805
  maxSimultaneous = overlapping;
629
806
  }
807
+ // Pacing facts: the hole between cues, and the run-on after the last word.
808
+ const textCover = mergeSpans(textRuns.map((run) => ({ start: run.start, end: run.start + run.duration })));
809
+ const gap = widestGap(textCover);
810
+ const lastWordAt = textCover.length ? textCover[textCover.length - 1].end : 0;
811
+ const visualEnd = visualClips.reduce((acc, clip) => Math.max(acc, clip.start + clip.duration), 0);
812
+ const contentEnd = Math.max(Number.isFinite(duration) ? duration : 0, visualEnd);
630
813
  const openingText = textRuns.filter((run) => run.start <= 0.001 && run.start + run.duration > 0.001);
631
814
  const sortedStarts = textRuns.map((run) => run.start).sort((a, b) => a - b);
632
815
  return {
@@ -641,10 +824,32 @@ export function extractCompositionFacts(html) {
641
824
  first_frame_text: openingText.length ? openingText.map((run) => run.text).join(" ").trim() : null,
642
825
  first_text_at_sec: sortedStarts.length ? sortedStarts[0] : null,
643
826
  max_simultaneous_text: maxSimultaneous,
827
+ max_words_in_run: textRuns.reduce((acc, run) => Math.max(acc, wordsIn(run.text)), 0),
828
+ max_dead_air_sec: gap ? Number((gap.end - gap.start).toFixed(2)) : 0,
829
+ tail_silence_sec: textCover.length ? Number(Math.max(0, contentEnd - lastWordAt).toFixed(2)) : 0,
644
830
  off_regime_fonts: [...new Set(textRuns.map((run) => run.font).filter((font) => Boolean(font) && !FONT_REGIME.includes(font)))],
645
831
  all_text: textRuns.map((run) => run.text).join(" \n ")
646
832
  };
647
833
  }
834
+ /**
835
+ * The closing block, printed on EVERY run including a clean one. Deliberately
836
+ * the last thing on screen and phrased as an instruction to the agent, because
837
+ * the failure mode this exists to stop is an agent pasting "✓ no HTML slop
838
+ * found" to the user as if it were a review of the video.
839
+ */
840
+ export function formatWatchTheVideoNotice(directive, colors) {
841
+ const { yellow, dim, reset } = colors;
842
+ const bold = colors.bold ?? "";
843
+ const lines = [
844
+ "",
845
+ `${yellow}${bold}▶ NOW WATCH THE VIDEO — this check never did.${reset}`,
846
+ `${dim}${directive.why}${reset}`
847
+ ];
848
+ directive.steps.forEach((step, index) => {
849
+ lines.push(` ${dim}${index + 1}.${reset} ${step}`);
850
+ });
851
+ return lines.join("\n");
852
+ }
648
853
  /** Human-readable report body (no trailing summary line — the CLI adds that). */
649
854
  export function formatQaReport(report, colors) {
650
855
  const { red, yellow, dim, reset } = colors;
@@ -0,0 +1,136 @@
1
+ // Local, offline access to the skill pack that ships INSIDE the devcli tarball.
2
+ //
3
+ // WHY THIS EXISTS: `.agents/skills/**` is in package.json `files`, so every
4
+ // `npm i -g @officexapp/vidfarm-devcli` already puts the whole director pack on
5
+ // disk. Until now nothing could READ it without either installing it into a
6
+ // project (`vidfarm skills add`) or fetching it over the network — so an agent
7
+ // with the CLI in front of it still had to go online to learn how to use the
8
+ // CLI. That is backwards for a local-first tool.
9
+ //
10
+ // THE CONTRACT, and its honest limit:
11
+ // • The bundled pack is a SNAPSHOT pinned to this devcli version. It is the
12
+ // right pairing — skill and CLI move together, and a mismatched pair is the
13
+ // usual cause of "the skill says to do X but it fails".
14
+ // • It costs nothing, needs no account, and works on a plane.
15
+ // • It is DOCUMENTATION, not entitlement. The capabilities it describes split
16
+ // into free-local (clipping, hyperframes, local render, qa, harnesses,
17
+ // dedupe, Kokoro TTS) and paid-cloud (AI generation, hosted render, social
18
+ // recycle, media download, marketplace). Reading about a paid primitive
19
+ // offline does not make it run offline — those still need `vidfarm login`
20
+ // and a network call. Nothing here should imply otherwise.
21
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
22
+ import path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ export const DEFAULT_PACK = "vidfarm";
25
+ /** Walk up from this module to the packaged `.agents/skills/<name>` directory. */
26
+ export function bundledPackDir(name = DEFAULT_PACK) {
27
+ let dir = path.dirname(fileURLToPath(import.meta.url));
28
+ for (let i = 0; i < 6; i += 1) {
29
+ const candidate = path.join(dir, ".agents", "skills", name);
30
+ if (existsSync(candidate) && statSync(candidate).isDirectory())
31
+ return candidate;
32
+ const parent = path.dirname(dir);
33
+ if (parent === dir)
34
+ break;
35
+ dir = parent;
36
+ }
37
+ return null;
38
+ }
39
+ export function listPackDocs(name = DEFAULT_PACK) {
40
+ const root = bundledPackDir(name);
41
+ if (!root)
42
+ return [];
43
+ const out = [];
44
+ const walk = (dir, prefix) => {
45
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
46
+ const abs = path.join(dir, entry.name);
47
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
48
+ if (entry.isDirectory())
49
+ walk(abs, rel);
50
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
51
+ const raw = readFileSync(abs, "utf8");
52
+ out.push({ rel, abs, bytes: Buffer.byteLength(raw), lines: raw.split("\n").length });
53
+ }
54
+ }
55
+ };
56
+ walk(root, "");
57
+ // SKILL.md first — it is the entry point and carries the File Index.
58
+ return out.sort((a, b) => (a.rel === "SKILL.md" ? -1 : b.rel === "SKILL.md" ? 1 : a.rel < b.rel ? -1 : 1));
59
+ }
60
+ export class AmbiguousDocRef extends Error {
61
+ ref;
62
+ candidates;
63
+ constructor(ref, candidates) {
64
+ super(`"${ref}" matches ${candidates.length} files: ${candidates.join(", ")}. Be more specific.`);
65
+ this.ref = ref;
66
+ this.candidates = candidates;
67
+ }
68
+ }
69
+ /**
70
+ * Resolve a user-typed reference to one doc. Agents type `hooks`, humans type
71
+ * `references/hooks-and-virality.md`, and both should work — an exact-path-only
72
+ * lookup would make the local copy annoying enough that people go back online.
73
+ * Traversal is impossible by construction: we only ever match against the
74
+ * enumerated file list, never join user input onto a path.
75
+ */
76
+ export function resolvePackDoc(ref, name = DEFAULT_PACK) {
77
+ const docs = listPackDocs(name);
78
+ if (!docs.length) {
79
+ throw new Error(`No bundled "${name}" skill pack found next to this devcli install. Fetch it with \`vidfarm skills add ${name}\`.`);
80
+ }
81
+ const needle = ref.replace(/\\/g, "/").replace(/^\.?\//, "").toLowerCase();
82
+ const exact = docs.find((doc) => doc.rel.toLowerCase() === needle)
83
+ ?? docs.find((doc) => doc.rel.toLowerCase() === `${needle}.md`);
84
+ if (exact)
85
+ return exact;
86
+ const base = (doc) => path.basename(doc.rel).toLowerCase();
87
+ const byBasename = docs.filter((doc) => base(doc) === needle || base(doc) === `${needle}.md`);
88
+ if (byBasename.length === 1)
89
+ return byBasename[0];
90
+ if (byBasename.length > 1)
91
+ throw new AmbiguousDocRef(ref, byBasename.map((doc) => doc.rel));
92
+ // `hooks` → references/hooks-and-virality.md; `harness` → harnesses/README.md
93
+ const fuzzy = docs.filter((doc) => doc.rel.toLowerCase().includes(needle));
94
+ if (fuzzy.length === 1)
95
+ return fuzzy[0];
96
+ if (fuzzy.length > 1) {
97
+ // Prefer a README when the ref names a directory ("harnesses", "recipes").
98
+ const readme = fuzzy.find((doc) => path.basename(doc.rel).toLowerCase() === "readme.md" && path.dirname(doc.rel).toLowerCase().includes(needle));
99
+ if (readme)
100
+ return readme;
101
+ throw new AmbiguousDocRef(ref, fuzzy.map((doc) => doc.rel));
102
+ }
103
+ throw new Error(`No file in the bundled "${name}" pack matches "${ref}". Run \`vidfarm skill ls\` to see all ${docs.length}.`);
104
+ }
105
+ export function readPackDoc(ref, name = DEFAULT_PACK) {
106
+ const doc = resolvePackDoc(ref, name);
107
+ return { doc, contents: readFileSync(doc.abs, "utf8") };
108
+ }
109
+ /**
110
+ * Grep the pack. This is the affordance that makes a local copy genuinely
111
+ * better than the network one: "where does it say anything about greenscreen"
112
+ * is answerable in one call, across 22 files, without loading any of them into
113
+ * context.
114
+ */
115
+ export function searchPackDocs(term, options = {}) {
116
+ const limit = options.limit ?? 40;
117
+ const needle = term.toLowerCase();
118
+ const matches = [];
119
+ for (const doc of listPackDocs(options.name ?? DEFAULT_PACK)) {
120
+ const lines = readFileSync(doc.abs, "utf8").split("\n");
121
+ for (let i = 0; i < lines.length; i += 1) {
122
+ if (!lines[i].toLowerCase().includes(needle))
123
+ continue;
124
+ // Long skill lines are paragraphs; show the window around the hit rather
125
+ // than 900 characters of surrounding prose.
126
+ const at = lines[i].toLowerCase().indexOf(needle);
127
+ const start = Math.max(0, at - 60);
128
+ const snippet = `${start > 0 ? "…" : ""}${lines[i].slice(start, at + needle.length + 100).trim()}${at + needle.length + 100 < lines[i].length ? "…" : ""}`;
129
+ matches.push({ rel: doc.rel, line: i + 1, text: snippet });
130
+ if (matches.length >= limit)
131
+ return matches;
132
+ }
133
+ }
134
+ return matches;
135
+ }
136
+ //# sourceMappingURL=skill-docs.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@officexapp/vidfarm-devcli",
3
- "version": "0.21.34",
3
+ "version": "0.21.35",
4
4
  "description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,8 +28,9 @@
28
28
  "dist/src/devcli/port-utils.js",
29
29
  "dist/src/devcli/process-scan.js",
30
30
  "dist/src/devcli/qa-check.js",
31
- "dist/src/devcli/qa-regime.js",
31
+ "dist/src/devcli/harness.js",
32
32
  "dist/src/devcli/sequence.js",
33
+ "dist/src/devcli/skill-docs.js",
33
34
  "dist/src/devcli/skills.js",
34
35
  "dist/src/devcli/speech.js",
35
36
  "dist/src/devcli/sticker-pack.js",
@@ -99,7 +100,7 @@
99
100
  "test:stickers": "node --import tsx --test test/sticker-pack.test.ts",
100
101
  "test:social-recycle": "node --import tsx --test test/social-recycle.test.ts",
101
102
  "test:dedupe": "node --import tsx --test test/dedupe-recipe.test.ts",
102
- "check:skills": "node scripts/build-director-skill-rollup.mjs --check && node scripts/check-skill-routes.mjs",
103
+ "check:skills": "node scripts/build-director-skill-rollup.mjs --check && node scripts/check-skill-routes.mjs && node scripts/check-skill-nav.mjs",
103
104
  "benchmark:editor-chat": "node --import tsx scripts/benchmark-editor-chat-harness.mjs",
104
105
  "cdk:deploy:prod-serverless": "npm run build && dotenv -e .env.production -- npx aws-cdk deploy --app 'node dist/infra/cdk/bin/vidfarm-prod.js'",
105
106
  "cdk:synth:staging-serverless": "npm run build && dotenv -e .env.staging -- npx aws-cdk synth --app 'node dist/infra/cdk/bin/vidfarm-serverless-staging.js'",
@@ -1,79 +0,0 @@
1
- # QA_REGIME.md — the per-style quality contract
2
-
3
- `vidfarm qa`'s built-in rules are **universal**: no HTML slop, the caption font regime, the thumbnail frame. They're the same for every video anyone makes, so they live in code.
4
-
5
- A **QA_REGIME.md is the opposite**: it's what makes *your* format good — your audience, your hook shape, your banned vocabulary, your pacing, your compliance line. That changes per account, per offer, per campaign, so it can't be hard-coded. It lives next to the work as Markdown you own, edit, and version.
6
-
7
- **It matters most in scripting mode.** One video gets human eyes on every frame; fifty variants generated in a loop do not. The regime is what the batch is graded against — the thing that keeps variant #37 as good as variant #1.
8
-
9
- ## Using one
10
-
11
- ```bash
12
- vidfarm regime list # what ships with the CLI
13
- vidfarm regime show hooks # read one
14
- vidfarm regime init short-form --out ./work/QA_REGIME.md # copy it next to your work, then EDIT it
15
-
16
- vidfarm qa ./work # auto-uses ./work/QA_REGIME.md if present
17
- vidfarm qa ./work --regime hooks # a built-in by name
18
- vidfarm qa ./work --regime ./brand/HOUSE_RULES.md # any file, anywhere
19
- vidfarm qa ./work --regime short-form --regime ./work/QA_REGIME.md # they STACK
20
- vidfarm qa ./work --json # checks + review items, for a scripted batch
21
- ```
22
-
23
- Regimes compose: a shared house regime plus a per-campaign one is the intended shape. `--no-regime` skips auto-discovery; `VIDFARM_QA_REGIME` sets a default for a whole scripting run.
24
-
25
- ## The format
26
-
27
- Plain Markdown, with two machine-readable affordances:
28
-
29
- **1. Optional front matter with a `checks:` block** — the assertions the CLI settles deterministically from the composition, instantly, with no AI and no network:
30
-
31
- ```markdown
32
- ---
33
- name: my-house-style
34
- video_type: what this regime is for
35
- checks:
36
- duration_sec: 8-34 # also "<=34", ">=8", or "30"
37
- aspect: 9:16 # "9:16|1:1" to allow several
38
- first_frame_visual: required
39
- first_frame_text: required | forbidden
40
- hook_words_max: 7
41
- text_by_sec: 1.0
42
- audio: required | forbidden
43
- captions: required
44
- font_regime: required
45
- safe_zone: required
46
- scenes: 3-12
47
- max_scene_sec: 8
48
- max_text_cards: 3
49
- max_simultaneous_text: 2
50
- forbid_text: ["link in bio", "comment below"]
51
- require_text: []
52
- ---
53
- ```
54
-
55
- Unknown keys are reported and ignored, never silently dropped.
56
-
57
- **2. Any `- [ ]` checkbox line** in the body becomes a **review item** — a question handed back for the agent or the human to answer. "Is the withheld answer one the viewer can't supply themselves?" is a judgment call; pretending a linter settles it would be a lie.
58
-
59
- Everything else is prose the agent reads for context. That split is the whole design: the CLI is honest about which half it can enforce, and it never passes a video on the strength of the half it can't.
60
-
61
- ## Writing your own
62
-
63
- Start from the closest built-in (`vidfarm regime init <name>`), then **delete what doesn't apply and add what makes your format yours**. A regime you didn't edit isn't about your videos.
64
-
65
- Good regimes tend to have: a **Part 0** naming the viewer in one line (the thing that decides everything else), an **anatomy** section for the beats your format needs, **rules** with the reason attached — a rule whose "why" is missing gets argued away by the next agent that reads it — and a **pre-flight checklist** of `- [ ]` items, which is the part the CLI hands back on every run.
66
-
67
- Keep the checklist short enough that answering it honestly is cheaper than skipping it.
68
-
69
- **Give every regime a "whole-video review" block, and put it last.** The bundled ones all have one. Front-matter `checks:` grade the composition's structure and `vidfarm qa` grades its DOM — neither can see the finished video, and the defects that actually ship are sequence-level: margins that shift scene to scene, three type sizes, an accent colour that wanders, N identically-long beats, a jarring join, a dead band under top-anchored content. Those come from how the video was built (one scene at a time, each correct in isolation), so they are invisible to every per-scene check *and* to the agent that built it — across a 32-video batch, every first-pass video had a real defect its own author had already called "verified, looks good." The review block is what forces the contact-sheet pass that catches them. Method: `references/reviewing-renders.md`.
70
-
71
- ## Built-ins
72
-
73
- | Name | For |
74
- |---|---|
75
- | `short-form` | The general default: the four charges (hook / loop / payoff / bait) + the standalone rule. Start here |
76
- | `hooks` | Hook-variant batches — chunk-1 legibility, the unguessable test, the anti-patterns that only appear at volume |
77
- | `ugc-testimonial` | A person vouching for a product. Mostly rules about what NOT to add |
78
- | `explainer` | Faceless educational video: one claim, invented visuals |
79
- | `product-demo` | Real product doing a real thing — the highest slop-risk format in the catalog |