overlay-factory-worker 0.2.0 → 0.2.2

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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # overlay-factory-worker
2
2
 
3
3
  The Overlay Factory's worker: goosetools.com queues overlay jobs, this machine
4
- writes the overlays with Claude, renders them with Remotion, and burns them onto
4
+ writes the overlays with the agent the user picked in Setup (Claude by default; episode research always uses Claude, for web search), renders them with Remotion, and burns them onto
5
5
  the uploaded clip with ffmpeg. Published to npm on its own; lives in the
6
6
  goosetools repo at `workers/overlay` (it was the standalone `reel-factory` repo
7
7
  until 2026-09-26 — the What Prints series that lived there is at the
@@ -33,7 +33,12 @@ is: **add references, it tries, you adjust in words, it learns.**
33
33
  the rewrite against the note and retries once with whatever it missed; then
34
34
  writes the result as the series. The note is appended to `notes.md`, so it
35
35
  keeps applying — to later revisions and to a full rebuild.
36
- 4. The previous 5 versions of each series are kept in `series/.history/<slug>/`,
36
+ 4. **It checks its own render.** After a build or a revision, the series is
37
+ rendered and put next to the references with the shared method's compare
38
+ phase (`skills/reference-analysis.md`, the same method the Carousel Maker
39
+ uses); anything that doesn't match is fixed before you see it. Field keys
40
+ can't change in that step.
41
+ 5. The previous 5 versions of each series are kept in `series/.history/<slug>/`,
37
42
  so a bad revision can be put back by hand.
38
43
 
39
44
  `series/` is gitignored and per-machine (in an npm install it lives in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "overlay-factory-worker",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "The Goose Tools Overlay Factory worker — your computer renders reel overlays for goosetools.com with Remotion and ffmpeg.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,8 @@
40
40
  "overlay:install": "node worker/cli.mjs install",
41
41
  "overlay:uninstall": "node worker/cli.mjs uninstall",
42
42
  "overlay:status": "node worker/cli.mjs status",
43
- "overlay:doctor": "node worker/cli.mjs doctor"
43
+ "overlay:doctor": "node worker/cli.mjs doctor",
44
+ "prepublishOnly": "node ../sync-shared.mjs --check"
44
45
  },
45
46
  "sideEffects": [
46
47
  "*.css"
@@ -58,6 +59,7 @@
58
59
  "src",
59
60
  "remotion.config.ts",
60
61
  "tsconfig.json",
61
- "README.md"
62
+ "README.md",
63
+ "skills"
62
64
  ]
63
65
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The overlay worker's two shared dependencies with the other workers:
3
+ *
4
+ * - Which coding-agent CLI to run. The user picks one in Setup on
5
+ * goosetools.com and every claim carries it (`agentCli`); prompts go
6
+ * through worker/agent-cli.mjs, the same adapter the other workers use,
7
+ * synced from workers/shared/agent-cli.js.
8
+ * - How to read references. skills/reference-analysis.md is the Carousel
9
+ * Maker's method, shared so both tools study references, and check their
10
+ * own renders against them, the same way.
11
+ *
12
+ * The worker runs one job at a time, so the agent is set per job with
13
+ * useAgent() rather than threaded through every generation helper.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { pathToFileURL } from "node:url";
18
+
19
+ const ROOT = join(__dirname, "..");
20
+
21
+ type RunAgent = (opts: {
22
+ agent?: string;
23
+ prompt: string;
24
+ cwd?: string;
25
+ mode?: "text" | "oneshot" | "read" | "session";
26
+ timeoutMs?: number;
27
+ }) => Promise<{ text: string }>;
28
+
29
+ let current = "claude-code";
30
+
31
+ /** Called at the start of each job with the agent that job's owner picked. */
32
+ export const useAgent = (agentCli?: string | null) => {
33
+ current = agentCli || "claude-code";
34
+ };
35
+
36
+ let adapter: Promise<{ runAgent: RunAgent }> | undefined;
37
+
38
+ /**
39
+ * Run one prompt through the current agent and return its text.
40
+ *
41
+ * "read" lets it open the reference and render images named in the prompt;
42
+ * "oneshot" is for judgements where everything it needs is inlined.
43
+ */
44
+ export const askAgent = async (
45
+ prompt: string,
46
+ { mode = "read", timeoutMs = 15 * 60 * 1000 }: { mode?: "read" | "oneshot"; timeoutMs?: number } = {},
47
+ ): Promise<string> => {
48
+ // A file URL rather than a bare specifier: the adapter is an ES module in a
49
+ // CommonJS package, and this keeps it a real dynamic import.
50
+ adapter ??= import(pathToFileURL(join(ROOT, "worker", "agent-cli.mjs")).href) as Promise<{
51
+ runAgent: RunAgent;
52
+ }>;
53
+ const { runAgent } = await adapter;
54
+ const { text } = await runAgent({ agent: current, prompt, mode, cwd: ROOT, timeoutMs });
55
+ return text;
56
+ };
57
+
58
+ let method: string | undefined;
59
+
60
+ /** One section of the shared reference method, by its SECTION marker. */
61
+ export const skillSection = (name: string): string => {
62
+ method ??= readFileSync(join(ROOT, "skills", "reference-analysis.md"), "utf8");
63
+ const m = method.match(new RegExp(`<!-- SECTION: ${name} -->\\n([\\s\\S]*?)(?=<!-- SECTION: |$)`));
64
+ if (!m) throw new Error(`reference method section "${name}" not found`);
65
+ return m[1].trim();
66
+ };
@@ -46,6 +46,7 @@ import {
46
46
  import { resolveCustomFonts } from "./custom-fonts";
47
47
  import { resolveFieldImages } from "./field-images";
48
48
  import { ensureStateLinks } from "./state-dir";
49
+ import { useAgent } from "./agent";
49
50
  import { acquireWorkerLock } from "./worker-lock";
50
51
 
51
52
  // Worker-side upload, same signature as the old `upload` from
@@ -149,6 +150,8 @@ type NewSeries = {
149
150
  type Job = {
150
151
  id: string;
151
152
  kind: "overlay" | "series" | "restock" | "preview" | "single";
153
+ /** The coding-agent CLI this job's owner picked in Setup. */
154
+ agentCli?: string | null;
152
155
  /** kind='single' and one-off previews: what it should say, and what to copy. */
153
156
  single?: { say: string; refUrls: string[] } | null;
154
157
  /** The preview this job confirms, so it reuses that overlay rather than writing a new one. */
@@ -177,8 +180,17 @@ type Job = {
177
180
  const ROOT = join(__dirname, "..");
178
181
  const PUBLIC = join(ROOT, "public");
179
182
 
180
- /** A one-off's directory name — the same for its preview and its burn. */
181
- const singleSlug = (id: string) => `single-${id.slice(0, 8)}`;
183
+ /**
184
+ * A one-off's directory name — the same for its preview and its burn.
185
+ *
186
+ * The id comes from the server, and the result is a folder the worker later
187
+ * deletes, so anything that isn't a plain job id is refused rather than
188
+ * trusted to stay inside series/.
189
+ */
190
+ const singleSlug = (id: string) => {
191
+ if (!/^[0-9a-f]{8}-[0-9a-f-]{4,}$/i.test(id)) throw new Error("Bad one-off id");
192
+ return `single-${id.slice(0, 8).toLowerCase()}`;
193
+ };
182
194
 
183
195
  /**
184
196
  * Run a script, and on failure raise the part a human wrote.
@@ -678,6 +690,7 @@ const watchOwnSource = () => {
678
690
  const files = [
679
691
  "scripts/overlay-worker.ts",
680
692
  "scripts/series.ts",
693
+ "scripts/agent.ts",
681
694
  "scripts/stock.ts",
682
695
  "scripts/render-overlay.ts",
683
696
  "scripts/custom-fonts.ts",
@@ -708,6 +721,8 @@ const main = async () => {
708
721
  try {
709
722
  const job = (await api("claim")) as Job | null;
710
723
  if (job) {
724
+ // One job at a time, so the owner's agent choice is set per job.
725
+ useAgent(job.agentCli);
711
726
  if (job.kind === "series") await runSeriesJob(job);
712
727
  else if (job.kind === "restock") await runRestockJob(job);
713
728
  else if (job.kind === "preview") await runPreviewJob(job);
package/scripts/series.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * freeze on whatever the first frame painted. Each layer instead declares an
21
21
  * `enter`, which the composition drives with Remotion's own spring.
22
22
  */
23
- import { execFile, execFileSync } from "node:child_process";
23
+ import { execFileSync } from "node:child_process";
24
24
  import {
25
25
  cpSync,
26
26
  existsSync,
@@ -34,9 +34,8 @@ import {
34
34
  } from "node:fs";
35
35
  import { tmpdir } from "node:os";
36
36
  import { join } from "node:path";
37
- import { promisify } from "node:util";
37
+ import { askAgent, skillSection } from "./agent";
38
38
 
39
- const execFileAsync = promisify(execFile);
40
39
  const ROOT = join(__dirname, "..");
41
40
  const SERIES_DIR = join(ROOT, "series");
42
41
 
@@ -216,6 +215,10 @@ ${
216
215
  Read each of these image files closely (in order — the first matters most):
217
216
  ${opts.refPaths.map((p) => `- ${p}`).join("\n")}
218
217
 
218
+ ${skillSection("principles")}
219
+
220
+ ${skillSection("overlay-analyze")}
221
+
219
222
  Sample the ACTUAL colors from them. Match their typography treatment (weight,
220
223
  case, letter-spacing, serif vs sans), their shapes (radius, borders, bars),
221
224
  their spacing and their composition. A finished frame should look like it
@@ -432,11 +435,7 @@ const draftFiles = async (
432
435
  let p = initialPrompt;
433
436
  // Two attempts: the second is told exactly what was wrong with the first.
434
437
  for (let attempt = 1; attempt <= 2; attempt++) {
435
- const { stdout } = await execFileAsync(
436
- "claude",
437
- ["-p", p, "--output-format", "text", "--allowedTools", "Read"],
438
- { maxBuffer: 20 * 1024 * 1024, timeout: 15 * 60 * 1000 },
439
- );
438
+ const stdout = await askAgent(p, { mode: "read" });
440
439
  try {
441
440
  const files = extractFiles(stdout);
442
441
  return { files, spec: validate(files) };
@@ -451,6 +450,8 @@ const draftFiles = async (
451
450
  };
452
451
 
453
452
  const NOTES_FILE = "notes.md";
453
+ /** Marks a one-off's folder, so the sweep never touches a real series. */
454
+ const ONE_OFF_MARKER = ".one-off";
454
455
  const HISTORY_DIR = join(SERIES_DIR, ".history");
455
456
  const KEEP_VERSIONS = 5;
456
457
 
@@ -475,7 +476,7 @@ const writeSeries = (
475
476
  const out = join(SERIES_DIR, slug);
476
477
  const notes = readNotes(slug);
477
478
 
478
- if (existsSync(join(out, "series.json")) && !slug.startsWith("single-")) {
479
+ if (existsSync(join(out, "series.json")) && !existsSync(join(out, ONE_OFF_MARKER))) {
479
480
  const hist = join(HISTORY_DIR, slug);
480
481
  mkdirSync(hist, { recursive: true });
481
482
  cpSync(out, join(hist, new Date().toISOString().replace(/[:.]/g, "-")), {
@@ -544,12 +545,103 @@ export const generateSeries = async (input: {
544
545
  }),
545
546
  "Series generation",
546
547
  );
547
- return spec.fields;
548
+ await selfCheck({
549
+ slug: input.slug,
550
+ refPaths,
551
+ brand: input.brand ?? null,
552
+ workDir: dir,
553
+ });
554
+ return readSeriesSpec(input.slug)?.fields ?? spec.fields;
548
555
  } finally {
549
556
  rmSync(dir, { recursive: true, force: true });
550
557
  }
551
558
  };
552
559
 
560
+ // ── Self-check ────────────────────────────────────────────────────────────
561
+
562
+ /**
563
+ * Look at the rendered series next to the references and fix what's off.
564
+ *
565
+ * The Carousel Maker's self-check, for overlays: judging a design by
566
+ * re-reading its markup misses exactly the things that make it look wrong —
567
+ * a label twice the size the references use, a box that reads as a slab. So
568
+ * this renders a frame and puts the picture beside the references, with the
569
+ * shared method's compare phase. One round, and it never fails the job: a
570
+ * check that can't run leaves the series as it was.
571
+ *
572
+ * Returns a sentence when it changed something, or null.
573
+ */
574
+ const selfCheck = async (input: {
575
+ slug: string;
576
+ refPaths: string[];
577
+ brand: Brand | null;
578
+ /** When the check follows a revision, the rewrite must keep doing this. */
579
+ note?: string;
580
+ workDir: string;
581
+ }): Promise<string | null> => {
582
+ if (input.refPaths.length === 0) return null;
583
+ const spec = readSeriesSpec(input.slug);
584
+ if (!spec) return null;
585
+ try {
586
+ const frame = join(input.workDir, "self-check.png");
587
+ renderSeriesPreview(input.slug, spec.fields, input.brand, frame);
588
+ const files = currentFiles(input.slug);
589
+
590
+ const out = await askAgent(
591
+ `You designed this overlay series for vertical video; now inspect your own output.
592
+
593
+ Read the RENDERED frame (sample text in every field, over neutral grey — the real
594
+ thing sits over footage):
595
+ - ${frame}
596
+
597
+ Then read the REFERENCES this series must look like it belongs with (in order;
598
+ the first matters most):
599
+ ${input.refPaths.map((p) => `- ${p}`).join("\n")}
600
+
601
+ ${skillSection("principles")}
602
+
603
+ ${skillSection("overlay-compare")}
604
+ ${
605
+ input.note
606
+ ? `
607
+ The user's latest note on this series must still be true after any fix:
608
+ "${input.note}"
609
+ `
610
+ : ""
611
+ }
612
+ The series files as they are now:
613
+ ${asFileBlocks(files)}
614
+
615
+ If it passes every test, reply with exactly: PASS
616
+ Otherwise reply with the corrected files — EVERY file, in full — in exactly
617
+ this format, changing only what fails, and nothing before or after:
618
+ === FILE: series.json ===
619
+ ...
620
+ === FILE: <template>.html ===
621
+ ...`,
622
+ { mode: "read" },
623
+ );
624
+ if (/^\s*PASS\b/.test(out) && !out.includes("=== FILE:")) {
625
+ console.log(" self-check: matches the references");
626
+ return null;
627
+ }
628
+ const fixed = extractFiles(out);
629
+ const fixedSpec = validate(fixed);
630
+ // A fix must not change what the form asks for — episodes use those keys.
631
+ const keys = (f: SeriesField[]) => f.map((x) => x.key).sort().join(",");
632
+ if (keys(fixedSpec.fields) !== keys(spec.fields)) {
633
+ console.warn(" self-check: its fix changed the fields, so it was not applied");
634
+ return null;
635
+ }
636
+ writeSeries(input.slug, fixed, { keepEpisodes: true });
637
+ console.log(" self-check: adjusted to match the references");
638
+ return "Checked against your references and adjusted to match.";
639
+ } catch (e) {
640
+ console.error(` self-check skipped (${(e as Error).message.split("\n")[0]})`);
641
+ return null;
642
+ }
643
+ };
644
+
553
645
  // ── Revisions ─────────────────────────────────────────────────────────────
554
646
 
555
647
  /** The files that make up a series' look, as they are now. */
@@ -593,11 +685,7 @@ the AFTER files actually do it (compare against BEFORE — an unchanged value
593
685
  means it was not done). Reply with ONLY this JSON, nothing else:
594
686
  {"applied": "<one short sentence, in plain words, saying what changed>",
595
687
  "missed": ["<each request that was NOT done, in the user's words>"]}`;
596
- const { stdout } = await execFileAsync(
597
- "claude",
598
- ["-p", p, "--output-format", "text"],
599
- { maxBuffer: 5 * 1024 * 1024, timeout: 5 * 60 * 1000 },
600
- );
688
+ const stdout = await askAgent(p, { mode: "oneshot", timeoutMs: 5 * 60 * 1000 });
601
689
  const json = stdout.slice(stdout.indexOf("{"), stdout.lastIndexOf("}") + 1);
602
690
  const parsed = JSON.parse(json) as { applied?: unknown; missed?: unknown };
603
691
  return {
@@ -697,7 +785,17 @@ ${missed.map((m) => `- ${m}`).join("\n")}
697
785
  join(SERIES_DIR, input.slug, NOTES_FILE),
698
786
  `${readNotes(input.slug)}- ${date} · ${input.feedback.replace(/\s+/g, " ").trim()}\n`,
699
787
  );
700
- return { fields: draft.spec.fields, note: summary };
788
+ const checked = await selfCheck({
789
+ slug: input.slug,
790
+ refPaths,
791
+ brand,
792
+ note: input.feedback,
793
+ workDir: dir,
794
+ });
795
+ return {
796
+ fields: readSeriesSpec(input.slug)?.fields ?? draft.spec.fields,
797
+ note: checked ? `${summary} ${checked}` : summary,
798
+ };
701
799
  } finally {
702
800
  rmSync(dir, { recursive: true, force: true });
703
801
  }
@@ -829,6 +927,10 @@ export const sweepSingles = (maxAgeMs = 24 * 60 * 60 * 1000) => {
829
927
  for (const d of readdirSync(SERIES_DIR, { withFileTypes: true })) {
830
928
  if (!d.isDirectory() || !d.name.startsWith("single-")) continue;
831
929
  const p = join(SERIES_DIR, d.name);
930
+ // Only folders generateSingle marked. A real series can have a single-
931
+ // slug (the site now refuses new ones, but older ones may exist), and
932
+ // this deletes without a backup.
933
+ if (!existsSync(join(p, ONE_OFF_MARKER))) continue;
832
934
  try {
833
935
  if (Date.now() - statSync(p).mtimeMs > maxAgeMs) {
834
936
  rmSync(p, { recursive: true, force: true });
@@ -878,6 +980,7 @@ export const generateSingle = async (input: {
878
980
  }),
879
981
  "Overlay generation",
880
982
  );
983
+ writeFileSync(join(SERIES_DIR, input.slug, ONE_OFF_MARKER), "");
881
984
  return {
882
985
  layers: seriesLayers(input.slug, {}, input.durationSec, input.brand ?? null),
883
986
  cleanup,
package/scripts/stock.ts CHANGED
@@ -169,6 +169,8 @@ and stops being readable. No markdown, no quotes around the whole value.
169
169
 
170
170
  Return at most ${opts.count}.`;
171
171
 
172
+ // Always Claude, whatever agent the user picked: research needs WebSearch
173
+ // and WebFetch, which the shared agent adapter doesn't offer for the others.
172
174
  const { stdout } = await execFileAsync(
173
175
  "claude",
174
176
  [
@@ -0,0 +1,149 @@
1
+ # Reference Analysis Methodology
2
+
3
+ How to study a set of reference images, map them onto something new, and check
4
+ the finished render against them. Shared by the Carousel Maker (slides) and the
5
+ Overlay Factory (overlays on reels) — the reference images are the source of
6
+ truth for EVERY behavior described here; there are no hardcoded rules. Worker
7
+ prompts splice individual sections by the SECTION markers below.
8
+
9
+ SHARED FILE: the source of truth is workers/shared/reference-analysis.md in the
10
+ goosetools repo, copied into each worker's skills/ by `npm run workers:sync`.
11
+
12
+ <!-- SECTION: principles -->
13
+ ## Principles — both tools
14
+
15
+ - **Purpose first, paint second.** Work out what each reference is FOR — the
16
+ recurring format a follower would recognize — before its colours and fonts.
17
+ A copy that nails the palette but misses the format is wrong.
18
+ - **Variance vs constants.** Across the set, note what varies and what never
19
+ changes. Freedom lives exactly where the references vary; fidelity is owed
20
+ everywhere they don't.
21
+ - **Same-account test.** The finished thing should read as the same account's
22
+ work if posted next to the references — same format, same typographic
23
+ voice, same palette logic. Pixel-matching any one reference is NOT the goal.
24
+ - **Look, don't assume.** Read every image before deciding anything, and judge
25
+ your own output by looking at the render, not by re-reading your markup.
26
+
27
+ <!-- SECTION: analyze -->
28
+ ## Phase A — understand what each reference is DOING
29
+
30
+ Read every reference image first. For each one, work out:
31
+
32
+ - **Role**: is this the cover (the hook slide), a middle content slide, or
33
+ the closer/CTA? The first reference is usually the cover; use layout and
34
+ copy cues (page counters, "swipe", "follow") to confirm.
35
+ - **Content job**: what is the slide FOR? Presenting a color palette with
36
+ labeled swatches? Comparing a before vs an after? Listing tips? Showcasing
37
+ a product photo? The recurring format a follower would recognize is the
38
+ thing to reproduce — purpose first, paint second.
39
+ - **Photo and subject**: where does the photo's main subject sit in the
40
+ frame? How tight is the crop?
41
+ - **Overlays**: where do overlays (swatch stacks, badges, labels, text
42
+ blocks) sit relative to the subject? Record which behavior this set uses:
43
+ some styles keep overlays OFF the subject (overlays live in the empty
44
+ side of the frame); others deliberately lay elements OVER the photo as
45
+ part of the look. Both are legitimate — copy whichever these references do.
46
+ - **Variance vs constants**: across the whole set, what varies (number of
47
+ swatches, which side the overlay sits on, crop tightness) and what never
48
+ changes (the format, the typography, the palette treatment, the border
49
+ style)? Freedom lives exactly where the references vary; fidelity is owed
50
+ everywhere they don't.
51
+
52
+ <!-- SECTION: map -->
53
+ ## Phase B — map references onto the new carousel, role to role
54
+
55
+ Match by ROLE and FORMAT, never image-to-image: your cover should do what
56
+ the reference cover does, your closer what their closer does, and every
57
+ middle slide should follow the references' middle-slide format. Your slide 3
58
+ does not need to mirror reference slide 3.
59
+
60
+ - **Colors**: if the user's photos share a coherent palette (e.g. they are
61
+ all sunsets, all one product line), sample the carousel's colors from THE
62
+ USER'S PHOTOS so the deck feels made from their images. If the photos are
63
+ visually unrelated, keep the references' original colors. Never mix in
64
+ colors from anywhere else.
65
+ - **Variance**: whatever varied across the references may vary across your
66
+ slides the same way — swatch counts, overlay side, crop tightness. Do not
67
+ make every slide identical when the references weren't.
68
+ - **Overlay placement**: follow the coverage behavior recorded in Phase A.
69
+ If the references keep overlays clear of the subject, look at each of the
70
+ user's photos (and what the user's prompt says the photo is about) and
71
+ place overlays on the empty side — use `variant: flip` on a slide to
72
+ mirror a template's overlay to the other side. If the references overlay
73
+ the photo deliberately, do the same.
74
+ - **Photo count adaptation** — the references decide the slide mix:
75
+ - More photos than slides: choose the photos that best serve each slide's
76
+ message, but if the user's prompt asks to show something specific, the
77
+ photo showing it MUST be used.
78
+ - Fewer photos than the format wants: if the references put a photo on
79
+ every slide, reuse a photo with a DIFFERENT focus/zoom crop so it reads
80
+ as a new frame; if the references include photo-free formats (statement
81
+ slides, text slides), use those instead. Never pad with invented filler
82
+ content.
83
+
84
+ <!-- SECTION: compare -->
85
+ ## Phase C — compare each rendered slide against the references
86
+
87
+ For each rendered slide, find the reference(s) with the same ROLE (slide 1 =
88
+ cover, last slide = CTA/closer, everything else = middle) and ask:
89
+
90
+ - **Same-account test**: if this slide were posted next to the references,
91
+ would it read as the same account's work — same format, same typographic
92
+ voice, same palette logic? Exact alignment with any single reference is
93
+ NOT required; matching the format is.
94
+ - **Overlay-behavior test**: does the slide follow the references' coverage
95
+ behavior? If they keep overlays off the subject, is this slide's subject
96
+ (or a face) hidden under a swatch stack, badge, or caption? If so it
97
+ fails — fix with `variant: flip` (mirror the overlay side) or a focus/zoom
98
+ change that moves the subject clear.
99
+ - **Framing test**: is the thing the slide is about actually prominent and
100
+ centered by the crop, exactly as the reference format frames its subjects?
101
+
102
+ Fix vocabulary (per slide): `focus` ("X% Y%" on the original photo),
103
+ `zoom` (1–3), `variant: flip` (mirror the overlay side), `swatches`
104
+ (recount/recolor a swatch list — only on slides that already have one).
105
+ Never rewrite a slide's text or switch its template during comparison.
106
+
107
+ <!-- SECTION: overlay-analyze -->
108
+ ## Overlay Phase A — understand what the references are DOING
109
+
110
+ These are frames from reels (or screenshots of them) with text or graphics laid
111
+ over footage. For the set, work out:
112
+
113
+ - **Format**: what one episode of this is — a question, a hot take, a stat, a
114
+ quote, a before/after label? That is what the fields should capture.
115
+ - **Hierarchy**: what the eye reads first, second, third, and how that is
116
+ achieved (size, weight, colour, a box, a bar, a label above the main line).
117
+ - **Type**: weight, case, letter-spacing, serif vs sans vs mono, line length,
118
+ how many lines the main text runs to.
119
+ - **Containers**: solid box, translucent scrim, outline, shadow only, or text
120
+ straight on footage — and corner radius, border, padding.
121
+ - **Placement**: where on the 9:16 frame each element sits, and how much of
122
+ the frame the overlay takes. Note anything placed where Instagram's UI would
123
+ cover it — keep the format but move it into the safe band.
124
+ - **Colour logic**: which colours are fixed accents and which come from the
125
+ footage; how contrast is kept over busy or bright video.
126
+ - **Variance vs constants** across the set (see Principles).
127
+
128
+ <!-- SECTION: overlay-compare -->
129
+ ## Overlay Phase C — compare the rendered frame against the references
130
+
131
+ Look at the rendered frame (sample text in every field, over grey) next to the
132
+ references and ask:
133
+
134
+ - **Same-account test**: would this sit next to the references as the same
135
+ series? Same format, hierarchy, type treatment, container style, colour
136
+ logic.
137
+ - **Hierarchy test**: does the eye land where it lands in the references, in
138
+ the same order?
139
+ - **Proportion test**: is the overlay roughly the size the references use — not
140
+ a tiny label where they fill the band, not a slab where they are a caption?
141
+ - **Legibility test**: would it read over ANY footage, on a phone at arm's
142
+ length? Thin text straight on video fails.
143
+ - **Safe-area test**: is anything in Instagram's top bar, right action rail or
144
+ bottom caption zone?
145
+
146
+ Fix by editing the templates (sizes, weights, spacing, colours, containers,
147
+ widthPct, anchor). Never change field keys, the content brief, or wording that
148
+ the user typed or asked for.
149
+
@@ -0,0 +1,277 @@
1
+ // Agent CLI adapter — runs a job's prompt through whichever coding-agent CLI
2
+ // the user picked on goosetools.com (claim payloads carry `agentCli`).
3
+ //
4
+ // SHARED FILE: the source of truth is workers/shared/agent-cli.js in the
5
+ // goosetools repo. Each worker ships its own copy (worker/agent-cli.js) so the
6
+ // npm packages stay self-contained. Edit the shared one, then run
7
+ // `npm run workers:sync` from the repo root; `npm run workers:check` fails if
8
+ // a copy has drifted.
9
+ //
10
+ // Modes, chosen for speed-vs-capability (see brand-manager commit c0c1b33 —
11
+ // tool-looping turned 36-second drafts into 10-minute ones):
12
+ // "text" — plain prompt-in/text-out, no tool flags at all (captions).
13
+ // "oneshot" — tools explicitly OFF, all context inlined by the caller.
14
+ // "read" — file-reading allowed (carousel drafts look at photos).
15
+ // "session" — tools ON and resumable where the CLI supports it.
16
+ //
17
+ // Honest capability notes per CLI:
18
+ // claude — full support: fine tool control, JSON envelope, --resume.
19
+ // codex — sandbox-level tool control only; resumable threads; JSONL out.
20
+ // gemini — no headless resume: sessionId is always null, callers replay
21
+ // history (they already do this whenever sessionId is absent).
22
+ // opencode — plain text out; session continuation is version-dependent, so
23
+ // we don't claim it: sessionId is always null.
24
+ //
25
+ // Session ids are namespaced ("claude:<uuid>", "codex:<thread-id>") because
26
+ // the server stores them per conversation and the user can switch agents
27
+ // between turns — a Claude uuid means nothing to Codex. resolveSession()
28
+ // drops a mismatched id so the caller falls back to history replay. Bare
29
+ // un-prefixed ids predate namespacing and are treated as Claude's.
30
+
31
+ import { spawn } from "node:child_process";
32
+ import { spawnSync } from "node:child_process";
33
+
34
+ export const AGENT_IDS = ["claude-code", "codex", "gemini", "opencode"];
35
+
36
+ const INSTALL_HINTS = {
37
+ "claude-code": "npm install -g @anthropic-ai/claude-code",
38
+ codex: "npm install -g @openai/codex, then run: codex login",
39
+ gemini: "npm install -g @google/gemini-cli, then run: gemini (to sign in)",
40
+ opencode: "npm install -g opencode-ai, then run: opencode auth login",
41
+ };
42
+
43
+ const LABELS = {
44
+ "claude-code": "Claude Code",
45
+ codex: "Codex CLI",
46
+ gemini: "Gemini CLI",
47
+ opencode: "OpenCode",
48
+ };
49
+
50
+ // Session-id namespace per agent. claude-code writes "claude:" for continuity
51
+ // with the bare ids already stored server-side.
52
+ const SESSION_PREFIX = {
53
+ "claude-code": "claude",
54
+ codex: "codex",
55
+ gemini: "gemini",
56
+ opencode: "opencode",
57
+ };
58
+
59
+ function bin(agent) {
60
+ if (agent === "claude-code") return process.env.CLAUDE_BIN ?? "claude";
61
+ return agent; // codex / gemini / opencode binaries share their id
62
+ }
63
+
64
+ /** "claude" (legacy claim payloads) → "claude-code"; unknown → null. */
65
+ export function normalizeAgent(v) {
66
+ if (v == null || v === "" ) return "claude-code";
67
+ if (v === "claude") return "claude-code";
68
+ return AGENT_IDS.includes(v) ? v : null;
69
+ }
70
+
71
+ /**
72
+ * Turn a stored (possibly namespaced, possibly another agent's) session id
73
+ * into one usable by `agent`, or null — null tells the caller to inline the
74
+ * conversation history instead of resuming.
75
+ */
76
+ export function resolveSession(agent, sessionId) {
77
+ if (!sessionId) return null;
78
+ const idx = sessionId.indexOf(":");
79
+ if (idx === -1) {
80
+ // Legacy bare id — those were always Claude Code session uuids.
81
+ return agent === "claude-code" ? sessionId : null;
82
+ }
83
+ const prefix = sessionId.slice(0, idx);
84
+ return prefix === SESSION_PREFIX[agent] ? sessionId.slice(idx + 1) : null;
85
+ }
86
+
87
+ function namespaced(agent, rawId) {
88
+ return rawId ? `${SESSION_PREFIX[agent]}:${rawId}` : null;
89
+ }
90
+
91
+ // Strip ANSI escapes and other terminal noise some CLIs print around answers.
92
+ export function cleanText(text) {
93
+ // eslint-disable-next-line no-control-regex
94
+ return String(text).replace(/\[[0-9;]*[A-Za-z]/g, "").trim();
95
+ }
96
+
97
+ // stdio[0] MUST be "ignore". Inherited stdin makes invocations hang forever
98
+ // under launchd — brand-manager hit this exact bug (fixed in 3b1f955) and it
99
+ // presents as the worker silently doing nothing after a reboot.
100
+ function run(agent, args, { timeoutMs, cwd }) {
101
+ return new Promise((resolve, reject) => {
102
+ const child = spawn(bin(agent), args, {
103
+ cwd,
104
+ stdio: ["ignore", "pipe", "pipe"],
105
+ });
106
+ let stdout = "";
107
+ let stderr = "";
108
+ child.stdout.on("data", (d) => (stdout += d));
109
+ child.stderr.on("data", (d) => (stderr += d));
110
+ const timer = setTimeout(() => {
111
+ child.kill("SIGKILL");
112
+ reject(new Error(`${LABELS[agent]} timed out after ${timeoutMs}ms`));
113
+ }, timeoutMs);
114
+ child.on("error", (err) => {
115
+ clearTimeout(timer);
116
+ reject(
117
+ err.code === "ENOENT"
118
+ ? new Error(
119
+ `${LABELS[agent]} isn't installed on this computer. Install: ${INSTALL_HINTS[agent]} — or switch your agent back to Claude Code in Setup on goosetools.com.`,
120
+ )
121
+ : err,
122
+ );
123
+ });
124
+ child.on("close", (code) => {
125
+ clearTimeout(timer);
126
+ if (code === 0) resolve(stdout);
127
+ else
128
+ reject(
129
+ new Error(`${LABELS[agent]} exited ${code}: ${stderr.slice(0, 600)}`),
130
+ );
131
+ });
132
+ });
133
+ }
134
+
135
+ /** Is the CLI on PATH? { installed, hint } — cheap, for startup diagnostics. */
136
+ export function detect(agent) {
137
+ const res = spawnSync(bin(agent), ["--version"], {
138
+ stdio: ["ignore", "pipe", "pipe"],
139
+ timeout: 10_000,
140
+ });
141
+ return {
142
+ installed: res.status === 0,
143
+ hint: INSTALL_HINTS[agent],
144
+ label: LABELS[agent],
145
+ };
146
+ }
147
+
148
+ // Tools Claude must NOT touch in oneshot mode — everything is inlined.
149
+ const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch";
150
+
151
+ /**
152
+ * Run a prompt through the chosen agent CLI.
153
+ * Returns { text, sessionId } — sessionId is namespaced and null whenever
154
+ * the CLI can't resume (callers then replay history on the next turn).
155
+ */
156
+ export async function runAgent({
157
+ agent = "claude-code",
158
+ prompt,
159
+ cwd,
160
+ mode = "text",
161
+ sessionId = null,
162
+ tools = "Read,Edit,Write,Glob,Grep",
163
+ timeoutMs = 5 * 60 * 1000,
164
+ }) {
165
+ const normalized = normalizeAgent(agent);
166
+ if (!normalized) {
167
+ throw new Error(
168
+ `Unknown agent "${agent}" — update this worker: npx --yes <package>@latest install`,
169
+ );
170
+ }
171
+ const resume = resolveSession(normalized, sessionId);
172
+
173
+ if (normalized === "claude-code") {
174
+ if (mode === "session") {
175
+ const args = [
176
+ "-p",
177
+ prompt,
178
+ "--output-format",
179
+ "json",
180
+ "--permission-mode",
181
+ "acceptEdits",
182
+ "--allowedTools",
183
+ tools,
184
+ ];
185
+ if (resume) args.push("--resume", resume);
186
+ const raw = await run(normalized, args, { timeoutMs, cwd });
187
+ // The json envelope carries the session id. Be forgiving: a shape
188
+ // change shouldn't lose the user's answer.
189
+ try {
190
+ const parsed = JSON.parse(raw);
191
+ return {
192
+ text: parsed.result ?? parsed.text ?? raw,
193
+ sessionId: namespaced(
194
+ normalized,
195
+ parsed.session_id ?? parsed.sessionId ?? resume ?? null,
196
+ ),
197
+ };
198
+ } catch {
199
+ return { text: raw, sessionId: namespaced(normalized, resume) };
200
+ }
201
+ }
202
+ const args = ["-p", prompt, "--output-format", "text"];
203
+ if (mode === "oneshot") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
204
+ if (mode === "read") args.push("--allowedTools", "Read");
205
+ const text = await run(normalized, args, { timeoutMs, cwd });
206
+ return { text, sessionId: null };
207
+ }
208
+
209
+ if (normalized === "codex") {
210
+ // Sandbox is the only tool control codex offers; read-only + the prompt's
211
+ // own "don't use tools" language is the closest match to oneshot.
212
+ const sandbox = mode === "session" ? "workspace-write" : "read-only";
213
+ const args = ["exec"];
214
+ if (resume) args.push("resume", resume);
215
+ args.push("--skip-git-repo-check", "--sandbox", sandbox, "--json");
216
+ if (cwd) args.push("--cd", cwd);
217
+ args.push(prompt);
218
+ const raw = await run(normalized, args, { timeoutMs, cwd });
219
+ // --json emits JSONL events; harvest the thread id and the last agent
220
+ // message, falling back to the raw output if the shape ever changes.
221
+ let threadId = resume ?? null;
222
+ let last = null;
223
+ for (const line of raw.split("\n")) {
224
+ const trimmed = line.trim();
225
+ if (!trimmed.startsWith("{")) continue;
226
+ try {
227
+ const evt = JSON.parse(trimmed);
228
+ threadId =
229
+ evt.thread_id ?? evt.session_id ?? evt.thread?.id ?? threadId;
230
+ const item = evt.item ?? evt;
231
+ const type = item.item_type ?? item.type;
232
+ if (
233
+ (type === "agent_message" || type === "assistant_message") &&
234
+ typeof (item.text ?? item.message) === "string"
235
+ ) {
236
+ last = item.text ?? item.message;
237
+ }
238
+ } catch {
239
+ // not an event line — ignore
240
+ }
241
+ }
242
+ return {
243
+ text: cleanText(last ?? raw),
244
+ sessionId: mode === "session" ? namespaced(normalized, threadId) : null,
245
+ };
246
+ }
247
+
248
+ if (normalized === "gemini") {
249
+ // No reliable headless resume — never claim one. Session mode gets yolo
250
+ // approvals so its tools can run unattended.
251
+ const args = ["-p", prompt, "--output-format", "json"];
252
+ if (mode === "session") args.push("--yolo");
253
+ let raw;
254
+ try {
255
+ raw = await run(normalized, args, { timeoutMs, cwd });
256
+ } catch (err) {
257
+ // Older gemini builds lack --output-format; retry plain.
258
+ if (!/output-format|unknown option/i.test(String(err.message))) throw err;
259
+ raw = await run(normalized, ["-p", prompt], { timeoutMs, cwd });
260
+ return { text: cleanText(raw), sessionId: null };
261
+ }
262
+ try {
263
+ const parsed = JSON.parse(raw);
264
+ return {
265
+ text: cleanText(parsed.response ?? parsed.result ?? raw),
266
+ sessionId: null,
267
+ };
268
+ } catch {
269
+ return { text: cleanText(raw), sessionId: null };
270
+ }
271
+ }
272
+
273
+ // opencode — plain text out; we don't claim session continuation (it's
274
+ // version-dependent), so every turn replays history.
275
+ const raw = await run(normalized, ["run", prompt], { timeoutMs, cwd });
276
+ return { text: cleanText(raw), sessionId: null };
277
+ }