ossclip 0.1.27 → 0.1.29

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/src/produce.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  createProvider,
53
53
  createTieredProvider,
54
54
  defaultProviderName,
55
+ fallbackProviderName,
55
56
  defaultTheme,
56
57
  detectSilences,
57
58
  dropHiddenCues,
@@ -91,6 +92,8 @@ import {
91
92
  portraitMimeType,
92
93
  thumbnailDecision,
93
94
  thumbnailImageCacheName,
95
+ applyCleanupChoices,
96
+ vetoedRemovals,
94
97
  applyUserCuts,
95
98
  pruneHidesInsideCuts,
96
99
  loadConfig,
@@ -118,6 +121,8 @@ import {
118
121
  measureLevels,
119
122
  probe,
120
123
  produceScenes,
124
+ PRODUCER_PROMPT_VERSION,
125
+ type FramingContext,
121
126
  reclampPinnedTiming,
122
127
  reconcileCopy,
123
128
  repairTranscript,
@@ -144,7 +149,10 @@ import {
144
149
  type CleanupLevel,
145
150
  type ClipWindow,
146
151
  type Layout,
152
+ type LlmEffort,
147
153
  type LlmProvider,
154
+ type LlmUsage,
155
+ AGY_PRINT_TIMEOUT,
148
156
  type Production,
149
157
  ossclipOutputPathFor,
150
158
  type ProviderName,
@@ -154,7 +162,7 @@ import {
154
162
  type Transcript,
155
163
  } from "@ossclip/core";
156
164
  import { recordRecentProject } from "./edit";
157
- import { binOnPath, detectionLine } from "./llm-detect";
165
+ import { binOnPath, detectionLine, fallbackLine } from "./llm-detect";
158
166
  import {
159
167
  modelImpliedLanguage,
160
168
  modelUrl,
@@ -272,11 +280,165 @@ export function transcriptCacheReusable(
272
280
  };
273
281
  }
274
282
 
283
+ /**
284
+ * The beat-sheet/scenes cache key: everything that changes the plan — which
285
+ * prompt asked, who was asked, with what editorial steer, about which words,
286
+ * in what frame. Pure and exported so the §78 posture ("a change that changes
287
+ * the answer must change the key") is testable without a workdir.
288
+ *
289
+ * Two of these are new, and only one of them was a live bug:
290
+ * - `promptVersion` (the caller passes PRODUCER_PROMPT_VERSION) is the §78
291
+ * fix proper — before it, a warm workdir kept serving a sheet the OLD
292
+ * prompt wrote, exactly the failure YOUTUBE_PROMPT_VERSION exists for.
293
+ * - `aspect` is LATENT rather than active: it changes the user prompt (the
294
+ * LANDSCAPE block, R21 §101), but `--aspect 16:9` also derives its own
295
+ * `-16x9` workdir and this cache is a file inside it, so a portrait and a
296
+ * landscape plan of the same source cannot meet today. Keyed anyway —
297
+ * the collision is one workdir-naming change away, and the key should not
298
+ * depend on a different module's directory scheme to stay correct.
299
+ */
300
+ export function beatSheetCacheKey(parts: {
301
+ promptVersion: string;
302
+ /** The primary on reads; on a §143 fallback WRITE, the provider that
303
+ * actually answered — a plain name from the usage records. */
304
+ providerName: string;
305
+ llmModel?: string;
306
+ /** The §143 effort knob — it steers the editorial call, so it changes the plan. */
307
+ llmEffort?: LlmEffort;
308
+ intent?: string;
309
+ cleanup: CleanupLevel;
310
+ forceComponent?: SceneComponentId;
311
+ /** Framing constraints steer layout choice, so a re-measure must replan. */
312
+ framing?: FramingContext;
313
+ clipTargetSec?: number;
314
+ clipWindow?: ClipWindow | null;
315
+ /** The repaired transcript's TEXT — see the call site on why not the count. */
316
+ words: readonly string[];
317
+ aspect: "9:16" | "16:9";
318
+ }): string {
319
+ return createHash("sha1")
320
+ .update(
321
+ JSON.stringify([
322
+ parts.promptVersion,
323
+ parts.providerName,
324
+ parts.llmModel,
325
+ parts.intent,
326
+ parts.cleanup,
327
+ parts.forceComponent ?? null,
328
+ parts.framing ?? null,
329
+ // §93f: the clip target and the RESOLVED window key the plan too —
330
+ // without them a clip run and a full run of the same source would
331
+ // collide and answer from each other's cache (the §78 failure
332
+ // mode). Keyed POST-resolution so a replay that derives the same
333
+ // window hits the same entries.
334
+ parts.clipTargetSec ?? null,
335
+ parts.clipWindow ? `${parts.clipWindow.startWord}:${parts.clipWindow.endWord}` : null,
336
+ parts.words,
337
+ parts.aspect,
338
+ // The §143 effort knob — appended at the END, and only when SET: an
339
+ // unconditional `?? null` would change the serialization of every
340
+ // existing key and silently re-plan every warm workdir for users who
341
+ // never touched the knob. §78 only demands that a DIFFERENT effort
342
+ // miss; an unset one must keep hitting what it always hit.
343
+ ...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
344
+ ]),
345
+ )
346
+ .digest("hex")
347
+ .slice(0, 8);
348
+ }
349
+
350
+ /**
351
+ * The clip-window cache key (`clipwindow-<hash>.json`), which answers a
352
+ * different question — WHICH ~Ns of the take to keep (R19 §93) — but asks it
353
+ * with the SAME producer prompt, via `produceScenes(…, clip: {…})`. So it
354
+ * carries the same two fields `beatSheetCacheKey` gained, for the same
355
+ * reasons:
356
+ * - `promptVersion`: without it, editing the producer prompt leaves every
357
+ * warm workdir serving a window that was resolved under the OLD prompt —
358
+ * the §78 failure mode, one call above where it was just fixed. The
359
+ * tradeoff is accepted deliberately: a version bump DOES throw away an
360
+ * already-resolved window and costs one LLM call to re-select it. That is
361
+ * the correct price. Planning a whole video against a window the current
362
+ * prompt would not have chosen is worse than an LLM call.
363
+ * - `aspect`: LATENT today, exactly as it is one function down — `--aspect
364
+ * 16:9` derives its own `-16x9` workdir and this cache is a file inside
365
+ * it, so a portrait and a landscape selection of the same source cannot
366
+ * meet. Keyed anyway: the aspect reaches the prompt (both halves of it
367
+ * since the producerSystem change), and the key should not depend on a
368
+ * different module's directory scheme to stay correct.
369
+ *
370
+ * Deliberately NOT keyed, matching what the call site passes to the selection
371
+ * call: cleanup, forced component and the clip window itself. The first two
372
+ * do not reach this prompt, and the third is what it returns.
373
+ */
374
+ export function clipWindowCacheKey(parts: {
375
+ promptVersion: string;
376
+ /** Same read/write split as `beatSheetCacheKey` (§143). */
377
+ providerName: string;
378
+ llmModel?: string;
379
+ /** The §143 effort knob — the selection rides the same editorial call. */
380
+ llmEffort?: LlmEffort;
381
+ intent?: string;
382
+ clipTargetSec: number;
383
+ /** Framing constraints steer the selection call the same way (see above). */
384
+ framing?: FramingContext;
385
+ /** The repaired transcript's TEXT — the window is word-indexed into it. */
386
+ words: readonly string[];
387
+ aspect: "9:16" | "16:9";
388
+ }): string {
389
+ return createHash("sha1")
390
+ .update(
391
+ JSON.stringify([
392
+ parts.promptVersion,
393
+ parts.providerName,
394
+ parts.llmModel,
395
+ parts.intent,
396
+ parts.clipTargetSec,
397
+ parts.framing ?? null,
398
+ parts.words,
399
+ parts.aspect,
400
+ // Appended at the END, only when SET — beatSheetCacheKey's rule: an
401
+ // unset effort must keep every existing key byte-identical.
402
+ ...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
403
+ ]),
404
+ )
405
+ .digest("hex")
406
+ .slice(0, 8);
407
+ }
408
+
409
+ /**
410
+ * The provider that actually answered the call whose output is being cached
411
+ * (2026-08-22, FINDINGS §143): after a timeout fallback the resolved
412
+ * `providerName` names the provider that FAILED, and keying a cache write on
413
+ * it would attribute the fallback's plan to a provider that never produced
414
+ * it. Last matching record wins — retries and the fallback both append to the
415
+ * usage log, so the last writer is the one whose answer survived. Pure and
416
+ * exported so the attribution is testable without a workdir.
417
+ */
418
+ export function actualProvider(
419
+ usage: readonly LlmUsage[],
420
+ schemaName: string,
421
+ defaultName: string,
422
+ ): string {
423
+ for (let i = usage.length - 1; i >= 0; i--) {
424
+ if (usage[i]!.schemaName === schemaName) return usage[i]!.provider;
425
+ }
426
+ return defaultName;
427
+ }
428
+
275
429
  export interface ProduceOptions {
276
430
  out?: string;
277
431
  cleanup: CleanupLevel;
278
432
  transcript?: string;
279
433
  render: boolean;
434
+ /**
435
+ * This run is a `--review` (cut review step 1/3): `render` is already
436
+ * false (reviewFlag resolved that in the action) and the editor opens on
437
+ * the workdir afterwards. Produce only reads it to phrase the no-render
438
+ * exit — "the editor is opening" instead of the `--no-render` skip line
439
+ * plus an `ossclip edit` hint for an editor that is about to open itself.
440
+ */
441
+ review?: boolean;
280
442
  mezzanine: boolean;
281
443
  workdir?: string;
282
444
  inspect?: boolean;
@@ -291,6 +453,12 @@ export interface ProduceOptions {
291
453
  llmModel?: string;
292
454
  /** Model for mechanical calls; "same" sends everything to the main model. */
293
455
  llmFastModel?: string;
456
+ /**
457
+ * `agy --effort` for the antigravity provider (§143). Already zod-parsed to
458
+ * the union by program.ts — the CONFIG's `llmEffort` arrives separately, as
459
+ * an unvalidated string, and `resolveLlmEffort` arbitrates.
460
+ */
461
+ llmEffort?: LlmEffort;
294
462
  /** Who is on camera — steers repair and exempts their name from grounding. */
295
463
  speaker?: string;
296
464
  /** Repair ASR mishearings before captions/producer/grounding (default on). */
@@ -483,6 +651,32 @@ export function resolveYoutube(
483
651
  return flag ?? configValue === true;
484
652
  }
485
653
 
654
+ /**
655
+ * The effective `--llm-effort` — reasoning effort for the antigravity
656
+ * provider's `agy --effort` flag (§143: exposed after the hang incident;
657
+ * untested at real scale whether it moves the hang, but the knob existed and
658
+ * we passed nothing). A TYPED flag always wins, and it arrives already
659
+ * zod-parsed by program.ts; only the config value is checked here — the
660
+ * `dictionary` posture, since it comes from hand-editable JSON loadConfig
661
+ * doesn't zod-parse: exactly low|medium|high, or one warning and agy's
662
+ * default, never a coerced effort level. Pure so the flag × config matrix is
663
+ * testable without a config file on disk.
664
+ */
665
+ export function resolveLlmEffort(
666
+ flag: LlmEffort | undefined,
667
+ configValue: unknown,
668
+ ): { effort?: LlmEffort; warning?: string } {
669
+ // Typed-beats-config, and typed also beats a MALFORMED config: the user
670
+ // asking for an effort on the command line gets it, not a warning about a
671
+ // config key they did not touch this run.
672
+ if (flag !== undefined) return { effort: flag };
673
+ if (configValue === undefined) return {};
674
+ if (configValue === "low" || configValue === "medium" || configValue === "high") {
675
+ return { effort: configValue };
676
+ }
677
+ return { warning: "⚠ config llmEffort ignored — expected low|medium|high" };
678
+ }
679
+
486
680
  /**
487
681
  * How many browser tabs the render runs in parallel (2026-08-17 render-speed
488
682
  * pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
@@ -965,7 +1159,23 @@ export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailS
965
1159
  // ceiling). Capped BEFORE caching so the cache and the image key hold
966
1160
  // what is used.
967
1161
  concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
968
- await writeFile(conceptCache, JSON.stringify(concept, null, 2));
1162
+ // §143 read/write split, same as the plan and repair caches: the
1163
+ // concept call rides the editorial provider and can fall back, so the
1164
+ // file goes under whoever actually answered.
1165
+ await writeFile(
1166
+ join(
1167
+ args.work,
1168
+ thumbnailConceptCacheName({
1169
+ ...args,
1170
+ providerName: actualProvider(
1171
+ args.provider.usage,
1172
+ "thumbnail_concept",
1173
+ args.providerName,
1174
+ ),
1175
+ }),
1176
+ ),
1177
+ JSON.stringify(concept, null, 2),
1178
+ );
969
1179
  } catch (err) {
970
1180
  log(
971
1181
  `▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
@@ -1149,6 +1359,54 @@ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
1149
1359
  return "auto";
1150
1360
  }
1151
1361
 
1362
+ /**
1363
+ * Resolves `--review` against the two flags it overlaps: produce without
1364
+ * rendering, then open the editor to review the cut — so the ONE render
1365
+ * happens from the editor's Render button, not before the user could look.
1366
+ *
1367
+ * `--review --no-render` is agreement, not a contradiction — both point the
1368
+ * same way (don't render here), unlike the jump-cuts pair — so it resolves
1369
+ * silently. `--review --no-open-editor` IS the contradiction (reviewing is
1370
+ * opening the editor) and follows jumpCutsFlag's rule: a loud error, never a
1371
+ * precedence the user has to memorize. Without --review everything passes
1372
+ * through untouched, tri-states included. Pure so the whole matrix is
1373
+ * assertable without commander or a TTY.
1374
+ */
1375
+ export function reviewFlag(
1376
+ review: boolean,
1377
+ render: boolean,
1378
+ openEditor: boolean | undefined,
1379
+ ): { render: boolean; openEditor: boolean | undefined } {
1380
+ if (!review) return { render, openEditor };
1381
+ if (openEditor === false) {
1382
+ throw new Error("--review contradicts --no-open-editor — reviewing means opening the editor");
1383
+ }
1384
+ return { render: false, openEditor: true };
1385
+ }
1386
+
1387
+ /**
1388
+ * The one loud line for cleanup vetoes actually changing this run's cut (cut
1389
+ * review step 3) — the `▸ N user cut(s) removed …` voice, inverted: per
1390
+ * declined reason, how many removals came back and how much source time they
1391
+ * restore. Pure so the whole phrasing is assertable without running produce;
1392
+ * callers only print it when `vetoed` is non-empty (a silent no-change run
1393
+ * must stay silent, like the user-cut line's own `cuts.length > 0` gate).
1394
+ */
1395
+ export function cleanupChoicesLine(vetoed: readonly Segment[], outputDuration: number): string {
1396
+ const byReason = new Map<string, { count: number; sec: number }>();
1397
+ for (const seg of vetoed) {
1398
+ const key = seg.reason ?? "unlabeled";
1399
+ const entry = byReason.get(key) ?? { count: 0, sec: 0 };
1400
+ entry.count += 1;
1401
+ entry.sec += seg.srcOut - seg.srcIn;
1402
+ byReason.set(key, entry);
1403
+ }
1404
+ const parts = [...byReason.entries()].map(
1405
+ ([reason, { count, sec }]) => `${count} ${reason} removal(s) (+${sec.toFixed(1)}s)`,
1406
+ );
1407
+ return `▸ cleanup choices kept ${parts.join(", ")} — ${outputDuration.toFixed(1)}s output`;
1408
+ }
1409
+
1152
1410
  /**
1153
1411
  * The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
1154
1412
  * (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
@@ -1156,6 +1414,14 @@ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
1156
1414
  * and even on a talking head a 7% lurch reads as the camera stumbling. Big
1157
1415
  * enough to break up the jump, small enough to pass as sensor noise.
1158
1416
  */
1417
+ /**
1418
+ * How long a silent antigravity call runs before the spinner admits it (§149).
1419
+ * Well clear of the 17-46s a healthy call takes, so a normal run never shows
1420
+ * the notice, and well short of AGY_PRINT_TIMEOUT, so it lands while the wait
1421
+ * still has somewhere to go.
1422
+ */
1423
+ export const AGY_SLOW_NOTICE_MS = 30_000;
1424
+
1159
1425
  export const FACE_PUNCH_SCALE = 1.015;
1160
1426
 
1161
1427
  /**
@@ -1678,6 +1944,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1678
1944
  const dictionary = opts.dictionary ?? configDictionary ?? [];
1679
1945
  if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
1680
1946
 
1947
+ // Resolved ONCE for the whole run (§143): the provider call, both plan
1948
+ // cache keys and the command.json pin must all see the same effort, or a
1949
+ // replay re-plans under a knob the run never used.
1950
+ const { effort: llmEffort, warning: llmEffortWarning } = resolveLlmEffort(
1951
+ opts.llmEffort,
1952
+ cfg.llmEffort,
1953
+ );
1954
+ if (llmEffortWarning) console.log(llmEffortWarning);
1955
+
1681
1956
  // The run's base theme (F6): config theme over defaultTheme, resolved once
1682
1957
  // and used for BOTH resolveTheme's base and props.baseTheme below — the
1683
1958
  // editor's reset must land on the user's global colors, not the factory's.
@@ -2088,31 +2363,62 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2088
2363
  if (!opts.provider) {
2089
2364
  console.log(detectionLine(providerName));
2090
2365
  }
2366
+ // Timeout fallback (2026-08-22, FINDINGS §143): agy hangs persistently on
2367
+ // the real beat-sheet call — 10-minute --print-timeout expiries while
2368
+ // claude-cli planned the same video — and auto-detection picks agy
2369
+ // whenever the CLI is on PATH. When the editorial call times out, ONE
2370
+ // other provider answers it instead of the run dying, announced out loud:
2371
+ // the user must know which model planned their video.
2372
+ const llmFallbackName =
2373
+ providerName === "antigravity"
2374
+ ? fallbackProviderName(providerName, process.env, binOnPath)
2375
+ : undefined;
2091
2376
  provider = createTieredProvider(providerName, {
2092
2377
  model: opts.llmModel,
2093
2378
  fastModel: opts.llmFastModel ?? cfg.fastModel,
2379
+ fallback: llmFallbackName,
2380
+ onFallback: (info) => console.log(fallbackLine(info.from, info.to, info.schemaName)),
2381
+ // §143: rides the editorial antigravity call only — see TieringOptions.
2382
+ effort: llmEffort,
2094
2383
  });
2095
2384
  }
2096
2385
 
2097
2386
  let rawTranscript = transcript;
2098
2387
  let repairs: AppliedRepair[] = [];
2099
2388
  if (provider && opts.repair !== false) {
2100
- const rawKey = createHash("sha1")
2101
- .update(
2102
- JSON.stringify([
2103
- providerName,
2104
- opts.llmModel,
2105
- opts.llmFastModel ?? cfg.fastModel,
2106
- opts.speaker ?? cfg.speaker,
2107
- // The dictionary changes both the prompt and the vouched set (F4),
2108
- // so cached repairs from a different vocabulary must not be reused.
2109
- dictionary,
2110
- rawTranscript.words.map((w) => w.text),
2111
- ]),
2112
- )
2113
- .digest("hex")
2114
- .slice(0, 8);
2115
- const repairCache = join(work, `repairs-${rawKey}.json`);
2389
+ // Parameterized on the provider so the WRITE below can re-key on who
2390
+ // actually answered (§143) — the same read/write split as the beat-sheet
2391
+ // caches, and load-bearing beyond attribution: repairs REWRITE the words,
2392
+ // and the words are an input to every plan cache key downstream. A repair
2393
+ // set cached under the provider that timed out would fork the transcript
2394
+ // lineage, and the fallback-written plan could never be reached by a
2395
+ // later run that names the fallback provider directly (measured
2396
+ // 2026-08-22: two repair caches, 10 vs 11 repairs — one applied
2397
+ // "Llama," → "LLaVA," — put the same take's beat sheets under keys no
2398
+ // other run computed).
2399
+ const repairKeyFor = (p: string): string =>
2400
+ createHash("sha1")
2401
+ .update(
2402
+ JSON.stringify([
2403
+ p,
2404
+ opts.llmModel,
2405
+ opts.llmFastModel ?? cfg.fastModel,
2406
+ opts.speaker ?? cfg.speaker,
2407
+ // The dictionary changes both the prompt and the vouched set (F4),
2408
+ // so cached repairs from a different vocabulary must not be reused.
2409
+ dictionary,
2410
+ rawTranscript.words.map((w) => w.text),
2411
+ // Repair runs on the EDITORIAL tier (repair.ts: deciding what a
2412
+ // person actually said is semantic work), so the §143 effort knob
2413
+ // changes its answers. Appended at the END, only when SET —
2414
+ // beatSheetCacheKey's rule: an unset effort must keep serving the
2415
+ // repairs every existing workdir already cached.
2416
+ ...(llmEffort !== undefined ? [llmEffort] : []),
2417
+ ]),
2418
+ )
2419
+ .digest("hex")
2420
+ .slice(0, 8);
2421
+ const repairCache = join(work, `repairs-${repairKeyFor(providerName)}.json`);
2116
2422
  if (existsSync(repairCache)) {
2117
2423
  const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
2118
2424
  // Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
@@ -2182,7 +2488,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2182
2488
  " (not cached — the next run retries the pass)",
2183
2489
  );
2184
2490
  } else {
2185
- await writeFile(repairCache, JSON.stringify(repairs, null, 2));
2491
+ // Written under the ANSWERING provider's key (§143): after a timeout
2492
+ // fallback these are the fallback's repairs, and the transcript
2493
+ // lineage they start must be reachable by a run that asks that
2494
+ // provider by name. The primary-keyed read above stays deliberate.
2495
+ await writeFile(
2496
+ join(
2497
+ work,
2498
+ `repairs-${repairKeyFor(actualProvider(provider.usage, "transcript_repair", providerName))}.json`,
2499
+ ),
2500
+ JSON.stringify(repairs, null, 2),
2501
+ );
2186
2502
  }
2187
2503
  }
2188
2504
  for (const r of repairs) {
@@ -2298,19 +2614,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2298
2614
  // calls; only a first run selects.
2299
2615
  let clipFresh: Awaited<ReturnType<typeof produceScenes>> | null = null;
2300
2616
  if (clipTargetSec !== undefined) {
2301
- const windowKey = createHash("sha1")
2302
- .update(
2303
- JSON.stringify([
2304
- providerName,
2305
- opts.llmModel,
2306
- opts.intent,
2307
- clipTargetSec,
2308
- framingCtx ?? null,
2309
- transcript.words.map((w) => w.text),
2310
- ]),
2311
- )
2312
- .digest("hex")
2313
- .slice(0, 8);
2617
+ // Parts split from the key so the WRITE below can re-key on the
2618
+ // provider that actually answered (§143) without restating them.
2619
+ const windowKeyParts = {
2620
+ promptVersion: PRODUCER_PROMPT_VERSION,
2621
+ llmModel: opts.llmModel,
2622
+ llmEffort,
2623
+ intent: opts.intent,
2624
+ clipTargetSec,
2625
+ framing: framingCtx,
2626
+ words: transcript.words.map((w) => w.text),
2627
+ aspect: landscape ? ("16:9" as const) : ("9:16" as const),
2628
+ };
2629
+ const windowKey = clipWindowCacheKey({ ...windowKeyParts, providerName });
2314
2630
  const clipWindowCache = join(work, `clipwindow-${windowKey}.json`);
2315
2631
  if (opts.clipWindow) {
2316
2632
  clipWindow = parseClipWindowPin(transcript, opts.clipWindow);
@@ -2337,7 +2653,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2337
2653
  );
2338
2654
  clipWindow = clipFresh.clip!.window;
2339
2655
  for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
2340
- await writeFile(clipWindowCache, JSON.stringify(clipWindow, null, 2));
2656
+ // The WRITE keys on the provider that actually answered; the READS
2657
+ // above stay keyed on the primary — both deliberate (2026-08-22,
2658
+ // FINDINGS §143). After a timeout fallback the window is the
2659
+ // fallback's work: filing it under agy would let an agy-keyed read
2660
+ // claim a window agy never chose, and would hide it from a later
2661
+ // `--llm claude-cli` run that should hit it. The price of the
2662
+ // primary-keyed read is that a repeat agy run re-attempts agy (10
2663
+ // minutes, today) before falling back again — accepted over ever
2664
+ // serving a cache whose label lies.
2665
+ await writeFile(
2666
+ join(
2667
+ work,
2668
+ `clipwindow-${clipWindowCacheKey({
2669
+ ...windowKeyParts,
2670
+ providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
2671
+ })}.json`,
2672
+ ),
2673
+ JSON.stringify(clipWindow, null, 2),
2674
+ );
2341
2675
  }
2342
2676
 
2343
2677
  // Slice the pipeline state to the window (§93.1), then let everything
@@ -2381,29 +2715,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2381
2715
  map = new TimeMap(cutlist);
2382
2716
  }
2383
2717
 
2384
- const cacheKey = createHash("sha1")
2385
- .update(
2386
- JSON.stringify([
2387
- providerName,
2388
- opts.llmModel,
2389
- opts.intent,
2390
- opts.cleanup,
2391
- opts.forceComponent ?? null,
2392
- // The framing constraints steer layout choice, so a change in the
2393
- // measured framing must invalidate the cached plan.
2394
- framingCtx ?? null,
2395
- // §93f: the clip target and the RESOLVED window key the plan too —
2396
- // without them a clip run and a full run of the same source would
2397
- // collide and answer from each other's cache (the §78 failure
2398
- // mode). Keyed POST-resolution so a replay that derives the same
2399
- // window hits the same entries.
2400
- clipTargetSec ?? null,
2401
- clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : null,
2402
- transcript.words.map((w) => w.text),
2403
- ]),
2404
- )
2405
- .digest("hex")
2406
- .slice(0, 8);
2718
+ // Parts split from the key for the same §143 reason as the clip window:
2719
+ // the writes below re-key on the provider that actually answered.
2720
+ const beatKeyParts = {
2721
+ promptVersion: PRODUCER_PROMPT_VERSION,
2722
+ llmModel: opts.llmModel,
2723
+ llmEffort,
2724
+ intent: opts.intent,
2725
+ cleanup: opts.cleanup,
2726
+ forceComponent: opts.forceComponent,
2727
+ framing: framingCtx,
2728
+ clipTargetSec,
2729
+ clipWindow,
2730
+ words: transcript.words.map((w) => w.text),
2731
+ aspect: landscape ? ("16:9" as const) : ("9:16" as const),
2732
+ };
2733
+ const cacheKey = beatSheetCacheKey({ ...beatKeyParts, providerName });
2407
2734
  const sceneCache = join(work, `scenes-${cacheKey}.json`);
2408
2735
  // The cover needs the editorial copy, which is not in the scene list — a
2409
2736
  // cached run must still be able to write one.
@@ -2431,9 +2758,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2431
2758
  clipFresh.graphics.asked,
2432
2759
  transcript,
2433
2760
  );
2434
- await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
2761
+ // Written under the ANSWERING provider's key (§143, same rule as the
2762
+ // clip-window write above): the adopted sheet came from the ONE
2763
+ // clip_beat_sheet call, which may have been the fallback's.
2764
+ const adoptKey = beatSheetCacheKey({
2765
+ ...beatKeyParts,
2766
+ providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
2767
+ });
2768
+ await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
2435
2769
  await writeFile(
2436
- beatCache,
2770
+ join(work, `beatsheet-${adoptKey}.json`),
2437
2771
  JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
2438
2772
  );
2439
2773
  } else if (existsSync(sceneCache)) {
@@ -2462,17 +2796,38 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2462
2796
  : null;
2463
2797
  if (!aiAnim) console.log(`▸ producing scenes (${providerName})…`);
2464
2798
  if (opts.forceComponent) console.log(`▸ forcing every graphic to ${opts.forceComponent}`);
2465
- const result = await phases.time("llm", () =>
2466
- produceScenes(provider!, {
2467
- transcript,
2468
- outputDuration: map.outputDuration,
2469
- intent: opts.intent,
2470
- speaker: opts.speaker ?? cfg.speaker,
2471
- forceComponent: opts.forceComponent,
2472
- framing: framingCtx,
2473
- aspect: landscape ? "16:9" : "9:16",
2474
- }),
2475
- );
2799
+ // A hung agy is indistinguishable from a working one on screen: the
2800
+ // 2026-08-23 field run sat on this spinner for 605.9s with no hint that
2801
+ // a budget existed or that a recovery was coming, which reads as a
2802
+ // freeze rather than as waiting (§149). Only antigravity has a
2803
+ // print-timeout and a fallback, so only it gets the notice — and only
2804
+ // once the call is actually slow, so a healthy run never sees it.
2805
+ const slowNotice =
2806
+ aiAnim && providerName === "antigravity"
2807
+ ? setTimeout(
2808
+ () =>
2809
+ // Short on purpose: StageAnimator clamps the subtitle to the
2810
+ // terminal width and floors that at 40 columns, and the first
2811
+ // draft lost the budget to "falling back to the n...". The
2812
+ // number is the only part that changes what the user does
2813
+ // (wait vs. Ctrl-C), so it has to survive the clamp.
2814
+ aiAnim.update(`agy not replying — falling back at ${AGY_PRINT_TIMEOUT}...`),
2815
+ AGY_SLOW_NOTICE_MS,
2816
+ )
2817
+ : undefined;
2818
+ const result = await phases
2819
+ .time("llm", () =>
2820
+ produceScenes(provider!, {
2821
+ transcript,
2822
+ outputDuration: map.outputDuration,
2823
+ intent: opts.intent,
2824
+ speaker: opts.speaker ?? cfg.speaker,
2825
+ forceComponent: opts.forceComponent,
2826
+ framing: framingCtx,
2827
+ aspect: landscape ? "16:9" : "9:16",
2828
+ }),
2829
+ )
2830
+ .finally(() => clearTimeout(slowNotice));
2476
2831
  if (aiAnim) aiAnim.stop();
2477
2832
  scenes = result.scenes;
2478
2833
  beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
@@ -2493,9 +2848,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2493
2848
  // Cache props only — overrides are user-owned and live in overrides.json,
2494
2849
  // never in production.json (that file is derived and every `produce`
2495
2850
  // run overwrites it, per the merge rule in `overrides.ts`).
2496
- await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
2851
+ // Keyed on who answered the beat_sheet call (§143) — see the clip-window
2852
+ // write above for why reads stay primary-keyed while writes do not.
2853
+ const freshKey = beatSheetCacheKey({
2854
+ ...beatKeyParts,
2855
+ providerName: actualProvider(provider.usage, "beat_sheet", providerName),
2856
+ });
2857
+ await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
2497
2858
  await writeFile(
2498
- beatCache,
2859
+ join(work, `beatsheet-${freshKey}.json`),
2499
2860
  JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
2500
2861
  );
2501
2862
  }
@@ -2531,8 +2892,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2531
2892
  // …and stamp it onto the artefact it explains, so `production.json` says
2532
2893
  // who planned it without a second file to cross-reference.
2533
2894
  const last = log.runs[log.runs.length - 1]!;
2895
+ // `last.provider` derives from records[0] — the FIRST call's provider,
2896
+ // which after a §143 timeout fallback is the primary that failed the
2897
+ // editorial call. The stamp answers "who planned this", so it is built
2898
+ // from the records that ANSWERED (`failed` attempts stay in usage.json
2899
+ // and every report — their cost is real — but a stamp listing the failed
2900
+ // attempt's placeholder model read "planned by claude-cli
2901
+ // (antigravity-default)" after a fallback run, 2026-08-22). Providers in
2902
+ // first-seen order, the same " → " rendering the usage report uses;
2903
+ // single-provider runs stamp exactly as before.
2904
+ const answered = provider.usage.filter((r) => !r.failed);
2905
+ const providersSeen = [...new Set(answered.map((r) => r.provider))];
2534
2906
  producerStamp = {
2535
- provider: last.provider ?? providerName,
2907
+ provider:
2908
+ providersSeen.length > 1
2909
+ ? providersSeen.join(" → ")
2910
+ : providersSeen[0] ?? last.provider ?? providerName,
2911
+ // `last.models` already excludes failed attempts' models (usage.ts,
2912
+ // same §143 rule) — a stamp that listed the timed-out placeholder read
2913
+ // "planned by claude-cli (antigravity-default)" after a fallback run.
2536
2914
  models: last.models,
2537
2915
  cached: last.cached,
2538
2916
  at: last.at,
@@ -2632,6 +3010,28 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2632
3010
  }
2633
3011
  overrideDoc = parsed.data;
2634
3012
  }
3013
+ // ---- Cleanup choices: the user's veto over the AUTOMATIC cutlist (cut
3014
+ // review step 3) ------------------------------------------------------
3015
+ // Applied here — the same load `cuts` rides, before `applyUserCuts` — and
3016
+ // in exactly this order on purpose: cleanup vetoes reshape the PROPOSAL,
3017
+ // then user cuts subtract from the result as they always did, so a user
3018
+ // cut drawn over a vetoed pause still cuts (an explicit user action
3019
+ // outranks a veto). The proposal itself is captured FIRST for
3020
+ // `production.json`'s `cutlistProposed` — the resolution is lossy (a
3021
+ // vetoed removal merges into a plain keep), and the editor's checkboxes
3022
+ // re-derive the veto state from proposal + choices through the same
3023
+ // `applyCleanupChoices` this run used (ProductionSchema has the full why).
3024
+ // Consumers of `map` ABOVE this point (the repair pass's isCut guard, the
3025
+ // producer's outputDuration hint) saw the pre-veto map: both are
3026
+ // conservative uses — a refused word-merge across a span that comes back,
3027
+ // a duration hint a few seconds short — never a wrong cut.
3028
+ const cutlistProposed = cutlist;
3029
+ const cleanupVetoed = vetoedRemovals(cutlist, overrideDoc.cleanup);
3030
+ if (cleanupVetoed.length > 0) {
3031
+ cutlist = applyCleanupChoices(cutlist, overrideDoc.cleanup);
3032
+ map = new TimeMap(cutlist);
3033
+ console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
3034
+ }
2635
3035
  // `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
2636
3036
  // no `src` yet) and any already-re-anchored splits/pins are expressed
2637
3037
  // relative to whatever render-props the user was LAST looking at, not the
@@ -3150,7 +3550,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3150
3550
  transcript: rawTranscript,
3151
3551
  repairs: repairs.length > 0 ? repairs : undefined,
3152
3552
  analysis,
3553
+ // The APPLIED truth vs the PROPOSAL — see ProductionSchema for why both
3554
+ // are recorded: `cutlist` keeps the report/exporters honest about what
3555
+ // happened; `cutlistProposed` keeps the declined categories recoverable
3556
+ // for the editor (a vetoed removal merges into a plain keep, so the
3557
+ // resolved list alone cannot name what was declined).
3153
3558
  cutlist,
3559
+ cutlistProposed,
3154
3560
  ...(clipWindow && clipTargetSec !== undefined
3155
3561
  ? { clip: { targetSec: clipTargetSec, ...clipWindow } }
3156
3562
  : {}),
@@ -3806,12 +4212,147 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3806
4212
  console.log(overridesWriteLine(cutResult.changed));
3807
4213
  }
3808
4214
 
4215
+ // Resolved HERE — above the --no-render exit rather than at the thumbnail
4216
+ // gate / pack section below — so the gate, the post-render consumers AND
4217
+ // the command.json record (which both exits now write) all read one
4218
+ // answer. Typed-beats-config, `typeof` not truthiness (the `portrait`
4219
+ // posture): config.json is hand-edited and unparsed, and a
4220
+ // `"audience": true` typo must resolve to "no audience".
4221
+ const youtube = resolveYoutube(opts.youtube, cfg.youtube);
4222
+ // resolvePortrait (portrait-override.ts) carries the expandHome treatment
4223
+ // of the flag and config paths, and puts the workdir's portrait-override
4224
+ // ABOVE both (editor face swap, 2026-08-17): a per-project expression
4225
+ // chosen in the editor must survive CLI re-renders — the flag/config
4226
+ // portrait is the fallback headshot, and a replay silently reverting the
4227
+ // swapped face would undo the one thing the swap exists for.
4228
+ const portrait = resolvePortrait({
4229
+ overridePath: portraitOverridePath(work),
4230
+ flagPortrait: opts.portrait,
4231
+ cfgPortrait: cfg.portrait,
4232
+ })?.path;
4233
+ const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
4234
+ const thumbnailBrief =
4235
+ opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
4236
+
4237
+ // Record THIS invocation so the editor's Render button can replay it (R11
4238
+ // Task 4). Nothing else can reconstruct it — production.json has the
4239
+ // source path, cleanup and intent, but not --produce, --out or the LLM
4240
+ // flags — and guessing would silently render a different video than the
4241
+ // one on screen. execArgv carries the module loader (tsx in dev), so the
4242
+ // replay works from source and from a compiled build alike.
4243
+ //
4244
+ // Written BEFORE the render/no-render fork, not after the render (cut-review
4245
+ // step 1): a --no-render workdir used to carry NO command.json at all, so
4246
+ // the editor's Render button 412'd with "run `ossclip produce` once from
4247
+ // the terminal" — a dead end that made `--review` (produce without
4248
+ // rendering, review in the editor, render ONCE from its Render button)
4249
+ // impossible. Every pin below is resolved by this point, so the record is
4250
+ // byte-identical to what the post-render write produced; the side effect is
4251
+ // that a crashed render now leaves a record its own Render button can
4252
+ // retry. recordedProduceArgs strips --review/--no-render at record, so the
4253
+ // replay actually renders instead of looping.
4254
+ //
4255
+ // The provider may have been AUTO-DETECTED from this shell's environment
4256
+ // (a GEMINI_/ANTHROPIC_ key exported here). The editor's Render replays
4257
+ // this argv from the EDIT SERVER's environment, which may not have that
4258
+ // key — and the auto-detection would then silently pick a DIFFERENT
4259
+ // provider (R16 §75). Pin the RESOLVED choice into the recorded args —
4260
+ // never the key itself; secrets stay out of the workdir — so a replay
4261
+ // uses the same configuration or fails loudly asking for it.
4262
+ // §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
4263
+ // editor's Render replays this argv; if replay re-asked the model and got a
4264
+ // slightly different window, every saved override — anchored to scene ids
4265
+ // and word indices — would land on the wrong words. The word range, not
4266
+ // just `--clip 60`, is what makes replay deterministic with zero LLM calls.
4267
+ //
4268
+ // §129: NOT process.argv. A wizard or bare-path run re-enters commander
4269
+ // with a BUILT argv while process.argv still holds the original invocation
4270
+ // (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
4271
+ // recording process.argv shipped a command that replays as
4272
+ // `ossclip <path> --llm …` and dies on "unknown option '--llm'".
4273
+ // recordedProduceArgs prefers the argv the re-entry stashed and falls back
4274
+ // to process.argv for a directly typed `ossclip produce …`, which stays
4275
+ // byte-identical to what was always recorded.
4276
+ // Watermark pin, same §75 shape, and in BOTH directions (review,
4277
+ // Important): the effective default comes from THIS machine's
4278
+ // ~/.ossclip/config.json, so an unpinned record replays differently
4279
+ // wherever that config differs — an off-run would silently gain a credit
4280
+ // under a later/foreign config-on, an on-run would silently lose it. The
4281
+ // RESOLVED state is always pinned; a typed flag is already in the argv and
4282
+ // the includes-guard leaves it alone.
4283
+ // Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
4284
+ // the override-inclusive `captionsHidden` — overrides.json travels with
4285
+ // the workdir and is re-read on every replay, so pinning --no-captions
4286
+ // because the EDITOR hid them would freeze an edit the user may later
4287
+ // undo in that same editor. See recordedProduceArgs for why the pin is
4288
+ // unconditional even though captions' default is config-independent today.
4289
+ // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4290
+ // argv — "auto" has no flag spelling and stays unpinned (see
4291
+ // recordedProduceArgs for why that is safe today).
4292
+ // Youtube pin: the watermark's config-dependent-default rationale exactly —
4293
+ // resolved both ways, so a later config edit can't flip what Render
4294
+ // replays. Portrait and dictionary pin the RESOLVED values (a path and
4295
+ // terms, never a secret) for the same reason; recordedProduceArgs owns
4296
+ // the non-empty/includes guards.
4297
+ const recordedArgs = recordedProduceArgs({
4298
+ llm: provider ? providerName : undefined,
4299
+ // The RESOLVED effort (§143), pinned like the dictionary: it may have
4300
+ // come from this machine's config, and it keys the plan caches — an
4301
+ // unpinned record would re-plan on replay after a config edit.
4302
+ llmEffort,
4303
+ clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4304
+ watermark,
4305
+ captions: opts.captions ?? true,
4306
+ jumpCuts: jumpCutsMode,
4307
+ dictionary,
4308
+ youtube,
4309
+ portrait,
4310
+ audience,
4311
+ thumbnailBrief,
4312
+ });
4313
+ // produce is the ONLY command that may write command.json — edit.ts's §129
4314
+ // heal prepends the "produce" literal to any record that doesn't start
4315
+ // with it, so a record made by `transcribe`/`analyze` (which run this same
4316
+ // pipeline with render: false, and used to be kept out by the write
4317
+ // sitting after the early return below) would heal into
4318
+ // `produce transcribe …` and replay garbage. Their argv cannot start with
4319
+ // "produce" (§129: the stash mirrors the parse that ran, and only
4320
+ // produce's re-entries stash), which is the gate.
4321
+ if (recordedArgs[0] === "produce") {
4322
+ await writeFile(
4323
+ join(work, "command.json"),
4324
+ JSON.stringify(
4325
+ {
4326
+ execPath: process.execPath,
4327
+ execArgv: process.execArgv,
4328
+ script: process.argv[1],
4329
+ args: recordedArgs,
4330
+ cwd: process.cwd(),
4331
+ out: outPath,
4332
+ },
4333
+ null,
4334
+ 2,
4335
+ ),
4336
+ );
4337
+ // Every produce run is a project the picker should offer (R17 §83) —
4338
+ // best-effort, so a read-only home dir never fails the render.
4339
+ await recordRecentProject(work);
4340
+ }
4341
+
3809
4342
  if (!opts.render) {
3810
4343
  // §140: the breakdown goes above the closing lines on both exits, so the
3811
4344
  // last thing on screen stays the success line and the edit hint.
3812
4345
  console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
3813
- console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
3814
- console.log(editHint(work));
4346
+ if (opts.review === true) {
4347
+ // --review (step 1's report, fixed in step 3): the editor opens itself
4348
+ // right after this return, so the --no-render skip line plus an
4349
+ // `ossclip edit …` hint would tell the user to do what is already
4350
+ // happening. One line that says what comes next instead.
4351
+ console.log("▸ review: opening the editor — render once from its Render button");
4352
+ } else {
4353
+ console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
4354
+ console.log(editHint(work));
4355
+ }
3815
4356
  return {
3816
4357
  workdir: work,
3817
4358
  rendered: false,
@@ -3833,27 +4374,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3833
4374
  // multi-minute render. Interactive runs approve (or edit, or skip) it
3834
4375
  // here; the file it writes is what thumbnailStep honors after render — and
3835
4376
  // what a non-TTY replay (the editor's Render) reuses, which is the whole
3836
- // persistence story.
3837
- //
3838
- // Resolved HERE rather than at the pack section below so the gate and the
3839
- // post-render consumers read one answer. Typed-beats-config, `typeof` not
3840
- // truthiness (the `portrait` posture): config.json is hand-edited and
3841
- // unparsed, and a `"audience": true` typo must resolve to "no audience".
3842
- const youtube = resolveYoutube(opts.youtube, cfg.youtube);
3843
- // resolvePortrait (portrait-override.ts) carries the expandHome treatment
3844
- // of the flag and config paths, and puts the workdir's portrait-override
3845
- // ABOVE both (editor face swap, 2026-08-17): a per-project expression
3846
- // chosen in the editor must survive CLI re-renders — the flag/config
3847
- // portrait is the fallback headshot, and a replay silently reverting the
3848
- // swapped face would undo the one thing the swap exists for.
3849
- const portrait = resolvePortrait({
3850
- overridePath: portraitOverridePath(work),
3851
- flagPortrait: opts.portrait,
3852
- cfgPortrait: cfg.portrait,
3853
- })?.path;
3854
- const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
3855
- const thumbnailBrief =
3856
- opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
4377
+ // persistence story. The youtube/portrait/audience/brief resolutions this
4378
+ // gate reads moved above the --no-render exit (cut-review step 1) so the
4379
+ // command.json record pins the same answers on both exits.
3857
4380
  const geminiKey = process.env.GEMINI_API_KEY;
3858
4381
  const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
3859
4382
  // The gate reuses thumbnailDecision (plus the mime check) so the prompt
@@ -4279,14 +4802,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4279
4802
  } else {
4280
4803
  // Beat-sheet cache shape: keyed on everything that changes the answer —
4281
4804
  // who is asked, with what editorial steer, about which words.
4282
- const packKey = createHash("sha1")
4805
+ // Parameterized on the provider for the §143 read/write split: the
4806
+ // write below re-keys on who actually answered the pack call.
4807
+ const packKeyFor = (p: string): string => createHash("sha1")
4283
4808
  .update(
4284
4809
  JSON.stringify([
4285
4810
  // Prompt changes change the answer (the §78 posture): the v2
4286
4811
  // rewrite must not serve a pack cached under v1's questions.
4287
4812
  YOUTUBE_PROMPT_VERSION,
4288
- providerName,
4813
+ p,
4289
4814
  opts.llmModel ?? "",
4815
+ // Appended only when set — the plan-cache rule (§78 via §143's
4816
+ // effort knob): an unset effort must keep every warm workdir's
4817
+ // key byte-identical, and a changed effort is a different answer.
4818
+ ...(llmEffort !== undefined ? [llmEffort] : []),
4290
4819
  opts.intent ?? "",
4291
4820
  // Steer, so part of the key — a changed audience is a different
4292
4821
  // pack, not a cache hit.
@@ -4302,7 +4831,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4302
4831
  )
4303
4832
  .digest("hex")
4304
4833
  .slice(0, 8);
4305
- const packCache = join(work, `youtube-${packKey}.json`);
4834
+ const packCache = join(work, `youtube-${packKeyFor(providerName)}.json`);
4306
4835
  if (existsSync(packCache)) {
4307
4836
  pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
4308
4837
  console.log("▸ youtube: metadata cached");
@@ -4322,7 +4851,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4322
4851
  durationSec: map.outputDuration,
4323
4852
  }),
4324
4853
  );
4325
- await writeFile(packCache, JSON.stringify(pack, null, 2));
4854
+ // §143 read/write split, same as the plan and repair caches: the
4855
+ // pack files under whoever wrote it.
4856
+ await writeFile(
4857
+ join(
4858
+ work,
4859
+ `youtube-${packKeyFor(actualProvider(provider!.usage, "youtube_pack", providerName))}.json`,
4860
+ ),
4861
+ JSON.stringify(pack, null, 2),
4862
+ );
4326
4863
  } catch (err) {
4327
4864
  // NEVER cache a failure (§106), and never fail the produce that
4328
4865
  // just rendered over a metadata sidecar: one loud line, the video
@@ -4388,84 +4925,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4388
4925
  });
4389
4926
  }
4390
4927
  }
4391
- // Record THIS invocation so the editor's Render button can replay it (R11
4392
- // Task 4). Nothing else can reconstruct it — production.json has the
4393
- // source path, cleanup and intent, but not --produce, --out or the LLM
4394
- // flags — and guessing would silently render a different video than the
4395
- // one on screen. execArgv carries the module loader (tsx in dev), so the
4396
- // replay works from source and from a compiled build alike.
4397
- // The provider may have been AUTO-DETECTED from this shell's environment
4398
- // (a GEMINI_/ANTHROPIC_ key exported here). The editor's Render replays
4399
- // this argv from the EDIT SERVER's environment, which may not have that
4400
- // key — and the auto-detection would then silently pick a DIFFERENT
4401
- // provider (R16 §75). Pin the RESOLVED choice into the recorded args —
4402
- // never the key itself; secrets stay out of the workdir — so a replay
4403
- // uses the same configuration or fails loudly asking for it.
4404
- // §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
4405
- // editor's Render replays this argv; if replay re-asked the model and got a
4406
- // slightly different window, every saved override — anchored to scene ids
4407
- // and word indices — would land on the wrong words. The word range, not
4408
- // just `--clip 60`, is what makes replay deterministic with zero LLM calls.
4409
- //
4410
- // §129: NOT process.argv. A wizard or bare-path run re-enters commander
4411
- // with a BUILT argv while process.argv still holds the original invocation
4412
- // (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
4413
- // recording process.argv shipped a command that replays as
4414
- // `ossclip <path> --llm …` and dies on "unknown option '--llm'".
4415
- // recordedProduceArgs prefers the argv the re-entry stashed and falls back
4416
- // to process.argv for a directly typed `ossclip produce …`, which stays
4417
- // byte-identical to what was always recorded.
4418
- // Watermark pin, same §75 shape, and in BOTH directions (review,
4419
- // Important): the effective default comes from THIS machine's
4420
- // ~/.ossclip/config.json, so an unpinned record replays differently
4421
- // wherever that config differs — an off-run would silently gain a credit
4422
- // under a later/foreign config-on, an on-run would silently lose it. The
4423
- // RESOLVED state is always pinned; a typed flag is already in the argv and
4424
- // the includes-guard leaves it alone.
4425
- // Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
4426
- // the override-inclusive `captionsHidden` — overrides.json travels with
4427
- // the workdir and is re-read on every replay, so pinning --no-captions
4428
- // because the EDITOR hid them would freeze an edit the user may later
4429
- // undo in that same editor. See recordedProduceArgs for why the pin is
4430
- // unconditional even though captions' default is config-independent today.
4431
- // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4432
- // argv — "auto" has no flag spelling and stays unpinned (see
4433
- // recordedProduceArgs for why that is safe today).
4434
- // Youtube pin: the watermark's config-dependent-default rationale exactly —
4435
- // resolved both ways, so a later config edit can't flip what Render
4436
- // replays. Portrait and dictionary pin the RESOLVED values (a path and
4437
- // terms, never a secret) for the same reason; recordedProduceArgs owns
4438
- // the non-empty/includes guards.
4439
- const recordedArgs = recordedProduceArgs({
4440
- llm: provider ? providerName : undefined,
4441
- clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4442
- watermark,
4443
- captions: opts.captions ?? true,
4444
- jumpCuts: jumpCutsMode,
4445
- dictionary,
4446
- youtube,
4447
- portrait,
4448
- audience,
4449
- thumbnailBrief,
4450
- });
4451
- await writeFile(
4452
- join(work, "command.json"),
4453
- JSON.stringify(
4454
- {
4455
- execPath: process.execPath,
4456
- execArgv: process.execArgv,
4457
- script: process.argv[1],
4458
- args: recordedArgs,
4459
- cwd: process.cwd(),
4460
- out: outPath,
4461
- },
4462
- null,
4463
- 2,
4464
- ),
4465
- );
4466
- // Every produce run is a project the picker should offer (R17 §83) —
4467
- // best-effort, so a read-only home dir never fails the render.
4468
- await recordRecentProject(work);
4928
+ // command.json was recorded above the --no-render exit (cut-review step 1)
4929
+ // — see the block before that fork for the §75/§93g/§129 pinning story.
4469
4930
  console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
4470
4931
  console.log(`✓ done → ${outPath}`);
4471
4932
  if (isInteractive()) {