ossclip 0.1.27 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,9 @@ import {
144
149
  type CleanupLevel,
145
150
  type ClipWindow,
146
151
  type Layout,
152
+ type LlmEffort,
147
153
  type LlmProvider,
154
+ type LlmUsage,
148
155
  type Production,
149
156
  ossclipOutputPathFor,
150
157
  type ProviderName,
@@ -154,7 +161,7 @@ import {
154
161
  type Transcript,
155
162
  } from "@ossclip/core";
156
163
  import { recordRecentProject } from "./edit";
157
- import { binOnPath, detectionLine } from "./llm-detect";
164
+ import { binOnPath, detectionLine, fallbackLine } from "./llm-detect";
158
165
  import {
159
166
  modelImpliedLanguage,
160
167
  modelUrl,
@@ -272,11 +279,165 @@ export function transcriptCacheReusable(
272
279
  };
273
280
  }
274
281
 
282
+ /**
283
+ * The beat-sheet/scenes cache key: everything that changes the plan — which
284
+ * prompt asked, who was asked, with what editorial steer, about which words,
285
+ * in what frame. Pure and exported so the §78 posture ("a change that changes
286
+ * the answer must change the key") is testable without a workdir.
287
+ *
288
+ * Two of these are new, and only one of them was a live bug:
289
+ * - `promptVersion` (the caller passes PRODUCER_PROMPT_VERSION) is the §78
290
+ * fix proper — before it, a warm workdir kept serving a sheet the OLD
291
+ * prompt wrote, exactly the failure YOUTUBE_PROMPT_VERSION exists for.
292
+ * - `aspect` is LATENT rather than active: it changes the user prompt (the
293
+ * LANDSCAPE block, R21 §101), but `--aspect 16:9` also derives its own
294
+ * `-16x9` workdir and this cache is a file inside it, so a portrait and a
295
+ * landscape plan of the same source cannot meet today. Keyed anyway —
296
+ * the collision is one workdir-naming change away, and the key should not
297
+ * depend on a different module's directory scheme to stay correct.
298
+ */
299
+ export function beatSheetCacheKey(parts: {
300
+ promptVersion: string;
301
+ /** The primary on reads; on a §143 fallback WRITE, the provider that
302
+ * actually answered — a plain name from the usage records. */
303
+ providerName: string;
304
+ llmModel?: string;
305
+ /** The §143 effort knob — it steers the editorial call, so it changes the plan. */
306
+ llmEffort?: LlmEffort;
307
+ intent?: string;
308
+ cleanup: CleanupLevel;
309
+ forceComponent?: SceneComponentId;
310
+ /** Framing constraints steer layout choice, so a re-measure must replan. */
311
+ framing?: FramingContext;
312
+ clipTargetSec?: number;
313
+ clipWindow?: ClipWindow | null;
314
+ /** The repaired transcript's TEXT — see the call site on why not the count. */
315
+ words: readonly string[];
316
+ aspect: "9:16" | "16:9";
317
+ }): string {
318
+ return createHash("sha1")
319
+ .update(
320
+ JSON.stringify([
321
+ parts.promptVersion,
322
+ parts.providerName,
323
+ parts.llmModel,
324
+ parts.intent,
325
+ parts.cleanup,
326
+ parts.forceComponent ?? null,
327
+ parts.framing ?? null,
328
+ // §93f: the clip target and the RESOLVED window key the plan too —
329
+ // without them a clip run and a full run of the same source would
330
+ // collide and answer from each other's cache (the §78 failure
331
+ // mode). Keyed POST-resolution so a replay that derives the same
332
+ // window hits the same entries.
333
+ parts.clipTargetSec ?? null,
334
+ parts.clipWindow ? `${parts.clipWindow.startWord}:${parts.clipWindow.endWord}` : null,
335
+ parts.words,
336
+ parts.aspect,
337
+ // The §143 effort knob — appended at the END, and only when SET: an
338
+ // unconditional `?? null` would change the serialization of every
339
+ // existing key and silently re-plan every warm workdir for users who
340
+ // never touched the knob. §78 only demands that a DIFFERENT effort
341
+ // miss; an unset one must keep hitting what it always hit.
342
+ ...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
343
+ ]),
344
+ )
345
+ .digest("hex")
346
+ .slice(0, 8);
347
+ }
348
+
349
+ /**
350
+ * The clip-window cache key (`clipwindow-<hash>.json`), which answers a
351
+ * different question — WHICH ~Ns of the take to keep (R19 §93) — but asks it
352
+ * with the SAME producer prompt, via `produceScenes(…, clip: {…})`. So it
353
+ * carries the same two fields `beatSheetCacheKey` gained, for the same
354
+ * reasons:
355
+ * - `promptVersion`: without it, editing the producer prompt leaves every
356
+ * warm workdir serving a window that was resolved under the OLD prompt —
357
+ * the §78 failure mode, one call above where it was just fixed. The
358
+ * tradeoff is accepted deliberately: a version bump DOES throw away an
359
+ * already-resolved window and costs one LLM call to re-select it. That is
360
+ * the correct price. Planning a whole video against a window the current
361
+ * prompt would not have chosen is worse than an LLM call.
362
+ * - `aspect`: LATENT today, exactly as it is one function down — `--aspect
363
+ * 16:9` derives its own `-16x9` workdir and this cache is a file inside
364
+ * it, so a portrait and a landscape selection of the same source cannot
365
+ * meet. Keyed anyway: the aspect reaches the prompt (both halves of it
366
+ * since the producerSystem change), and the key should not depend on a
367
+ * different module's directory scheme to stay correct.
368
+ *
369
+ * Deliberately NOT keyed, matching what the call site passes to the selection
370
+ * call: cleanup, forced component and the clip window itself. The first two
371
+ * do not reach this prompt, and the third is what it returns.
372
+ */
373
+ export function clipWindowCacheKey(parts: {
374
+ promptVersion: string;
375
+ /** Same read/write split as `beatSheetCacheKey` (§143). */
376
+ providerName: string;
377
+ llmModel?: string;
378
+ /** The §143 effort knob — the selection rides the same editorial call. */
379
+ llmEffort?: LlmEffort;
380
+ intent?: string;
381
+ clipTargetSec: number;
382
+ /** Framing constraints steer the selection call the same way (see above). */
383
+ framing?: FramingContext;
384
+ /** The repaired transcript's TEXT — the window is word-indexed into it. */
385
+ words: readonly string[];
386
+ aspect: "9:16" | "16:9";
387
+ }): string {
388
+ return createHash("sha1")
389
+ .update(
390
+ JSON.stringify([
391
+ parts.promptVersion,
392
+ parts.providerName,
393
+ parts.llmModel,
394
+ parts.intent,
395
+ parts.clipTargetSec,
396
+ parts.framing ?? null,
397
+ parts.words,
398
+ parts.aspect,
399
+ // Appended at the END, only when SET — beatSheetCacheKey's rule: an
400
+ // unset effort must keep every existing key byte-identical.
401
+ ...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
402
+ ]),
403
+ )
404
+ .digest("hex")
405
+ .slice(0, 8);
406
+ }
407
+
408
+ /**
409
+ * The provider that actually answered the call whose output is being cached
410
+ * (2026-08-22, FINDINGS §143): after a timeout fallback the resolved
411
+ * `providerName` names the provider that FAILED, and keying a cache write on
412
+ * it would attribute the fallback's plan to a provider that never produced
413
+ * it. Last matching record wins — retries and the fallback both append to the
414
+ * usage log, so the last writer is the one whose answer survived. Pure and
415
+ * exported so the attribution is testable without a workdir.
416
+ */
417
+ export function actualProvider(
418
+ usage: readonly LlmUsage[],
419
+ schemaName: string,
420
+ defaultName: string,
421
+ ): string {
422
+ for (let i = usage.length - 1; i >= 0; i--) {
423
+ if (usage[i]!.schemaName === schemaName) return usage[i]!.provider;
424
+ }
425
+ return defaultName;
426
+ }
427
+
275
428
  export interface ProduceOptions {
276
429
  out?: string;
277
430
  cleanup: CleanupLevel;
278
431
  transcript?: string;
279
432
  render: boolean;
433
+ /**
434
+ * This run is a `--review` (cut review step 1/3): `render` is already
435
+ * false (reviewFlag resolved that in the action) and the editor opens on
436
+ * the workdir afterwards. Produce only reads it to phrase the no-render
437
+ * exit — "the editor is opening" instead of the `--no-render` skip line
438
+ * plus an `ossclip edit` hint for an editor that is about to open itself.
439
+ */
440
+ review?: boolean;
280
441
  mezzanine: boolean;
281
442
  workdir?: string;
282
443
  inspect?: boolean;
@@ -291,6 +452,12 @@ export interface ProduceOptions {
291
452
  llmModel?: string;
292
453
  /** Model for mechanical calls; "same" sends everything to the main model. */
293
454
  llmFastModel?: string;
455
+ /**
456
+ * `agy --effort` for the antigravity provider (§143). Already zod-parsed to
457
+ * the union by program.ts — the CONFIG's `llmEffort` arrives separately, as
458
+ * an unvalidated string, and `resolveLlmEffort` arbitrates.
459
+ */
460
+ llmEffort?: LlmEffort;
294
461
  /** Who is on camera — steers repair and exempts their name from grounding. */
295
462
  speaker?: string;
296
463
  /** Repair ASR mishearings before captions/producer/grounding (default on). */
@@ -483,6 +650,32 @@ export function resolveYoutube(
483
650
  return flag ?? configValue === true;
484
651
  }
485
652
 
653
+ /**
654
+ * The effective `--llm-effort` — reasoning effort for the antigravity
655
+ * provider's `agy --effort` flag (§143: exposed after the hang incident;
656
+ * untested at real scale whether it moves the hang, but the knob existed and
657
+ * we passed nothing). A TYPED flag always wins, and it arrives already
658
+ * zod-parsed by program.ts; only the config value is checked here — the
659
+ * `dictionary` posture, since it comes from hand-editable JSON loadConfig
660
+ * doesn't zod-parse: exactly low|medium|high, or one warning and agy's
661
+ * default, never a coerced effort level. Pure so the flag × config matrix is
662
+ * testable without a config file on disk.
663
+ */
664
+ export function resolveLlmEffort(
665
+ flag: LlmEffort | undefined,
666
+ configValue: unknown,
667
+ ): { effort?: LlmEffort; warning?: string } {
668
+ // Typed-beats-config, and typed also beats a MALFORMED config: the user
669
+ // asking for an effort on the command line gets it, not a warning about a
670
+ // config key they did not touch this run.
671
+ if (flag !== undefined) return { effort: flag };
672
+ if (configValue === undefined) return {};
673
+ if (configValue === "low" || configValue === "medium" || configValue === "high") {
674
+ return { effort: configValue };
675
+ }
676
+ return { warning: "⚠ config llmEffort ignored — expected low|medium|high" };
677
+ }
678
+
486
679
  /**
487
680
  * How many browser tabs the render runs in parallel (2026-08-17 render-speed
488
681
  * pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
@@ -965,7 +1158,23 @@ export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailS
965
1158
  // ceiling). Capped BEFORE caching so the cache and the image key hold
966
1159
  // what is used.
967
1160
  concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
968
- await writeFile(conceptCache, JSON.stringify(concept, null, 2));
1161
+ // §143 read/write split, same as the plan and repair caches: the
1162
+ // concept call rides the editorial provider and can fall back, so the
1163
+ // file goes under whoever actually answered.
1164
+ await writeFile(
1165
+ join(
1166
+ args.work,
1167
+ thumbnailConceptCacheName({
1168
+ ...args,
1169
+ providerName: actualProvider(
1170
+ args.provider.usage,
1171
+ "thumbnail_concept",
1172
+ args.providerName,
1173
+ ),
1174
+ }),
1175
+ ),
1176
+ JSON.stringify(concept, null, 2),
1177
+ );
969
1178
  } catch (err) {
970
1179
  log(
971
1180
  `▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
@@ -1149,6 +1358,54 @@ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
1149
1358
  return "auto";
1150
1359
  }
1151
1360
 
1361
+ /**
1362
+ * Resolves `--review` against the two flags it overlaps: produce without
1363
+ * rendering, then open the editor to review the cut — so the ONE render
1364
+ * happens from the editor's Render button, not before the user could look.
1365
+ *
1366
+ * `--review --no-render` is agreement, not a contradiction — both point the
1367
+ * same way (don't render here), unlike the jump-cuts pair — so it resolves
1368
+ * silently. `--review --no-open-editor` IS the contradiction (reviewing is
1369
+ * opening the editor) and follows jumpCutsFlag's rule: a loud error, never a
1370
+ * precedence the user has to memorize. Without --review everything passes
1371
+ * through untouched, tri-states included. Pure so the whole matrix is
1372
+ * assertable without commander or a TTY.
1373
+ */
1374
+ export function reviewFlag(
1375
+ review: boolean,
1376
+ render: boolean,
1377
+ openEditor: boolean | undefined,
1378
+ ): { render: boolean; openEditor: boolean | undefined } {
1379
+ if (!review) return { render, openEditor };
1380
+ if (openEditor === false) {
1381
+ throw new Error("--review contradicts --no-open-editor — reviewing means opening the editor");
1382
+ }
1383
+ return { render: false, openEditor: true };
1384
+ }
1385
+
1386
+ /**
1387
+ * The one loud line for cleanup vetoes actually changing this run's cut (cut
1388
+ * review step 3) — the `▸ N user cut(s) removed …` voice, inverted: per
1389
+ * declined reason, how many removals came back and how much source time they
1390
+ * restore. Pure so the whole phrasing is assertable without running produce;
1391
+ * callers only print it when `vetoed` is non-empty (a silent no-change run
1392
+ * must stay silent, like the user-cut line's own `cuts.length > 0` gate).
1393
+ */
1394
+ export function cleanupChoicesLine(vetoed: readonly Segment[], outputDuration: number): string {
1395
+ const byReason = new Map<string, { count: number; sec: number }>();
1396
+ for (const seg of vetoed) {
1397
+ const key = seg.reason ?? "unlabeled";
1398
+ const entry = byReason.get(key) ?? { count: 0, sec: 0 };
1399
+ entry.count += 1;
1400
+ entry.sec += seg.srcOut - seg.srcIn;
1401
+ byReason.set(key, entry);
1402
+ }
1403
+ const parts = [...byReason.entries()].map(
1404
+ ([reason, { count, sec }]) => `${count} ${reason} removal(s) (+${sec.toFixed(1)}s)`,
1405
+ );
1406
+ return `▸ cleanup choices kept ${parts.join(", ")} — ${outputDuration.toFixed(1)}s output`;
1407
+ }
1408
+
1152
1409
  /**
1153
1410
  * The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
1154
1411
  * (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
@@ -1678,6 +1935,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1678
1935
  const dictionary = opts.dictionary ?? configDictionary ?? [];
1679
1936
  if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
1680
1937
 
1938
+ // Resolved ONCE for the whole run (§143): the provider call, both plan
1939
+ // cache keys and the command.json pin must all see the same effort, or a
1940
+ // replay re-plans under a knob the run never used.
1941
+ const { effort: llmEffort, warning: llmEffortWarning } = resolveLlmEffort(
1942
+ opts.llmEffort,
1943
+ cfg.llmEffort,
1944
+ );
1945
+ if (llmEffortWarning) console.log(llmEffortWarning);
1946
+
1681
1947
  // The run's base theme (F6): config theme over defaultTheme, resolved once
1682
1948
  // and used for BOTH resolveTheme's base and props.baseTheme below — the
1683
1949
  // editor's reset must land on the user's global colors, not the factory's.
@@ -2088,31 +2354,62 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2088
2354
  if (!opts.provider) {
2089
2355
  console.log(detectionLine(providerName));
2090
2356
  }
2357
+ // Timeout fallback (2026-08-22, FINDINGS §143): agy hangs persistently on
2358
+ // the real beat-sheet call — 10-minute --print-timeout expiries while
2359
+ // claude-cli planned the same video — and auto-detection picks agy
2360
+ // whenever the CLI is on PATH. When the editorial call times out, ONE
2361
+ // other provider answers it instead of the run dying, announced out loud:
2362
+ // the user must know which model planned their video.
2363
+ const llmFallbackName =
2364
+ providerName === "antigravity"
2365
+ ? fallbackProviderName(providerName, process.env, binOnPath)
2366
+ : undefined;
2091
2367
  provider = createTieredProvider(providerName, {
2092
2368
  model: opts.llmModel,
2093
2369
  fastModel: opts.llmFastModel ?? cfg.fastModel,
2370
+ fallback: llmFallbackName,
2371
+ onFallback: (info) => console.log(fallbackLine(info.from, info.to, info.schemaName)),
2372
+ // §143: rides the editorial antigravity call only — see TieringOptions.
2373
+ effort: llmEffort,
2094
2374
  });
2095
2375
  }
2096
2376
 
2097
2377
  let rawTranscript = transcript;
2098
2378
  let repairs: AppliedRepair[] = [];
2099
2379
  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`);
2380
+ // Parameterized on the provider so the WRITE below can re-key on who
2381
+ // actually answered (§143) — the same read/write split as the beat-sheet
2382
+ // caches, and load-bearing beyond attribution: repairs REWRITE the words,
2383
+ // and the words are an input to every plan cache key downstream. A repair
2384
+ // set cached under the provider that timed out would fork the transcript
2385
+ // lineage, and the fallback-written plan could never be reached by a
2386
+ // later run that names the fallback provider directly (measured
2387
+ // 2026-08-22: two repair caches, 10 vs 11 repairs — one applied
2388
+ // "Llama," → "LLaVA," — put the same take's beat sheets under keys no
2389
+ // other run computed).
2390
+ const repairKeyFor = (p: string): string =>
2391
+ createHash("sha1")
2392
+ .update(
2393
+ JSON.stringify([
2394
+ p,
2395
+ opts.llmModel,
2396
+ opts.llmFastModel ?? cfg.fastModel,
2397
+ opts.speaker ?? cfg.speaker,
2398
+ // The dictionary changes both the prompt and the vouched set (F4),
2399
+ // so cached repairs from a different vocabulary must not be reused.
2400
+ dictionary,
2401
+ rawTranscript.words.map((w) => w.text),
2402
+ // Repair runs on the EDITORIAL tier (repair.ts: deciding what a
2403
+ // person actually said is semantic work), so the §143 effort knob
2404
+ // changes its answers. Appended at the END, only when SET —
2405
+ // beatSheetCacheKey's rule: an unset effort must keep serving the
2406
+ // repairs every existing workdir already cached.
2407
+ ...(llmEffort !== undefined ? [llmEffort] : []),
2408
+ ]),
2409
+ )
2410
+ .digest("hex")
2411
+ .slice(0, 8);
2412
+ const repairCache = join(work, `repairs-${repairKeyFor(providerName)}.json`);
2116
2413
  if (existsSync(repairCache)) {
2117
2414
  const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
2118
2415
  // Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
@@ -2182,7 +2479,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2182
2479
  " (not cached — the next run retries the pass)",
2183
2480
  );
2184
2481
  } else {
2185
- await writeFile(repairCache, JSON.stringify(repairs, null, 2));
2482
+ // Written under the ANSWERING provider's key (§143): after a timeout
2483
+ // fallback these are the fallback's repairs, and the transcript
2484
+ // lineage they start must be reachable by a run that asks that
2485
+ // provider by name. The primary-keyed read above stays deliberate.
2486
+ await writeFile(
2487
+ join(
2488
+ work,
2489
+ `repairs-${repairKeyFor(actualProvider(provider.usage, "transcript_repair", providerName))}.json`,
2490
+ ),
2491
+ JSON.stringify(repairs, null, 2),
2492
+ );
2186
2493
  }
2187
2494
  }
2188
2495
  for (const r of repairs) {
@@ -2298,19 +2605,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2298
2605
  // calls; only a first run selects.
2299
2606
  let clipFresh: Awaited<ReturnType<typeof produceScenes>> | null = null;
2300
2607
  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);
2608
+ // Parts split from the key so the WRITE below can re-key on the
2609
+ // provider that actually answered (§143) without restating them.
2610
+ const windowKeyParts = {
2611
+ promptVersion: PRODUCER_PROMPT_VERSION,
2612
+ llmModel: opts.llmModel,
2613
+ llmEffort,
2614
+ intent: opts.intent,
2615
+ clipTargetSec,
2616
+ framing: framingCtx,
2617
+ words: transcript.words.map((w) => w.text),
2618
+ aspect: landscape ? ("16:9" as const) : ("9:16" as const),
2619
+ };
2620
+ const windowKey = clipWindowCacheKey({ ...windowKeyParts, providerName });
2314
2621
  const clipWindowCache = join(work, `clipwindow-${windowKey}.json`);
2315
2622
  if (opts.clipWindow) {
2316
2623
  clipWindow = parseClipWindowPin(transcript, opts.clipWindow);
@@ -2337,7 +2644,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2337
2644
  );
2338
2645
  clipWindow = clipFresh.clip!.window;
2339
2646
  for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
2340
- await writeFile(clipWindowCache, JSON.stringify(clipWindow, null, 2));
2647
+ // The WRITE keys on the provider that actually answered; the READS
2648
+ // above stay keyed on the primary — both deliberate (2026-08-22,
2649
+ // FINDINGS §143). After a timeout fallback the window is the
2650
+ // fallback's work: filing it under agy would let an agy-keyed read
2651
+ // claim a window agy never chose, and would hide it from a later
2652
+ // `--llm claude-cli` run that should hit it. The price of the
2653
+ // primary-keyed read is that a repeat agy run re-attempts agy (10
2654
+ // minutes, today) before falling back again — accepted over ever
2655
+ // serving a cache whose label lies.
2656
+ await writeFile(
2657
+ join(
2658
+ work,
2659
+ `clipwindow-${clipWindowCacheKey({
2660
+ ...windowKeyParts,
2661
+ providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
2662
+ })}.json`,
2663
+ ),
2664
+ JSON.stringify(clipWindow, null, 2),
2665
+ );
2341
2666
  }
2342
2667
 
2343
2668
  // Slice the pipeline state to the window (§93.1), then let everything
@@ -2381,29 +2706,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2381
2706
  map = new TimeMap(cutlist);
2382
2707
  }
2383
2708
 
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);
2709
+ // Parts split from the key for the same §143 reason as the clip window:
2710
+ // the writes below re-key on the provider that actually answered.
2711
+ const beatKeyParts = {
2712
+ promptVersion: PRODUCER_PROMPT_VERSION,
2713
+ llmModel: opts.llmModel,
2714
+ llmEffort,
2715
+ intent: opts.intent,
2716
+ cleanup: opts.cleanup,
2717
+ forceComponent: opts.forceComponent,
2718
+ framing: framingCtx,
2719
+ clipTargetSec,
2720
+ clipWindow,
2721
+ words: transcript.words.map((w) => w.text),
2722
+ aspect: landscape ? ("16:9" as const) : ("9:16" as const),
2723
+ };
2724
+ const cacheKey = beatSheetCacheKey({ ...beatKeyParts, providerName });
2407
2725
  const sceneCache = join(work, `scenes-${cacheKey}.json`);
2408
2726
  // The cover needs the editorial copy, which is not in the scene list — a
2409
2727
  // cached run must still be able to write one.
@@ -2431,9 +2749,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2431
2749
  clipFresh.graphics.asked,
2432
2750
  transcript,
2433
2751
  );
2434
- await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
2752
+ // Written under the ANSWERING provider's key (§143, same rule as the
2753
+ // clip-window write above): the adopted sheet came from the ONE
2754
+ // clip_beat_sheet call, which may have been the fallback's.
2755
+ const adoptKey = beatSheetCacheKey({
2756
+ ...beatKeyParts,
2757
+ providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
2758
+ });
2759
+ await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
2435
2760
  await writeFile(
2436
- beatCache,
2761
+ join(work, `beatsheet-${adoptKey}.json`),
2437
2762
  JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
2438
2763
  );
2439
2764
  } else if (existsSync(sceneCache)) {
@@ -2493,9 +2818,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2493
2818
  // Cache props only — overrides are user-owned and live in overrides.json,
2494
2819
  // never in production.json (that file is derived and every `produce`
2495
2820
  // run overwrites it, per the merge rule in `overrides.ts`).
2496
- await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
2821
+ // Keyed on who answered the beat_sheet call (§143) — see the clip-window
2822
+ // write above for why reads stay primary-keyed while writes do not.
2823
+ const freshKey = beatSheetCacheKey({
2824
+ ...beatKeyParts,
2825
+ providerName: actualProvider(provider.usage, "beat_sheet", providerName),
2826
+ });
2827
+ await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
2497
2828
  await writeFile(
2498
- beatCache,
2829
+ join(work, `beatsheet-${freshKey}.json`),
2499
2830
  JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
2500
2831
  );
2501
2832
  }
@@ -2531,8 +2862,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2531
2862
  // …and stamp it onto the artefact it explains, so `production.json` says
2532
2863
  // who planned it without a second file to cross-reference.
2533
2864
  const last = log.runs[log.runs.length - 1]!;
2865
+ // `last.provider` derives from records[0] — the FIRST call's provider,
2866
+ // which after a §143 timeout fallback is the primary that failed the
2867
+ // editorial call. The stamp answers "who planned this", so it is built
2868
+ // from the records that ANSWERED (`failed` attempts stay in usage.json
2869
+ // and every report — their cost is real — but a stamp listing the failed
2870
+ // attempt's placeholder model read "planned by claude-cli
2871
+ // (antigravity-default)" after a fallback run, 2026-08-22). Providers in
2872
+ // first-seen order, the same " → " rendering the usage report uses;
2873
+ // single-provider runs stamp exactly as before.
2874
+ const answered = provider.usage.filter((r) => !r.failed);
2875
+ const providersSeen = [...new Set(answered.map((r) => r.provider))];
2534
2876
  producerStamp = {
2535
- provider: last.provider ?? providerName,
2877
+ provider:
2878
+ providersSeen.length > 1
2879
+ ? providersSeen.join(" → ")
2880
+ : providersSeen[0] ?? last.provider ?? providerName,
2881
+ // `last.models` already excludes failed attempts' models (usage.ts,
2882
+ // same §143 rule) — a stamp that listed the timed-out placeholder read
2883
+ // "planned by claude-cli (antigravity-default)" after a fallback run.
2536
2884
  models: last.models,
2537
2885
  cached: last.cached,
2538
2886
  at: last.at,
@@ -2632,6 +2980,28 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2632
2980
  }
2633
2981
  overrideDoc = parsed.data;
2634
2982
  }
2983
+ // ---- Cleanup choices: the user's veto over the AUTOMATIC cutlist (cut
2984
+ // review step 3) ------------------------------------------------------
2985
+ // Applied here — the same load `cuts` rides, before `applyUserCuts` — and
2986
+ // in exactly this order on purpose: cleanup vetoes reshape the PROPOSAL,
2987
+ // then user cuts subtract from the result as they always did, so a user
2988
+ // cut drawn over a vetoed pause still cuts (an explicit user action
2989
+ // outranks a veto). The proposal itself is captured FIRST for
2990
+ // `production.json`'s `cutlistProposed` — the resolution is lossy (a
2991
+ // vetoed removal merges into a plain keep), and the editor's checkboxes
2992
+ // re-derive the veto state from proposal + choices through the same
2993
+ // `applyCleanupChoices` this run used (ProductionSchema has the full why).
2994
+ // Consumers of `map` ABOVE this point (the repair pass's isCut guard, the
2995
+ // producer's outputDuration hint) saw the pre-veto map: both are
2996
+ // conservative uses — a refused word-merge across a span that comes back,
2997
+ // a duration hint a few seconds short — never a wrong cut.
2998
+ const cutlistProposed = cutlist;
2999
+ const cleanupVetoed = vetoedRemovals(cutlist, overrideDoc.cleanup);
3000
+ if (cleanupVetoed.length > 0) {
3001
+ cutlist = applyCleanupChoices(cutlist, overrideDoc.cleanup);
3002
+ map = new TimeMap(cutlist);
3003
+ console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
3004
+ }
2635
3005
  // `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
2636
3006
  // no `src` yet) and any already-re-anchored splits/pins are expressed
2637
3007
  // relative to whatever render-props the user was LAST looking at, not the
@@ -3150,7 +3520,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3150
3520
  transcript: rawTranscript,
3151
3521
  repairs: repairs.length > 0 ? repairs : undefined,
3152
3522
  analysis,
3523
+ // The APPLIED truth vs the PROPOSAL — see ProductionSchema for why both
3524
+ // are recorded: `cutlist` keeps the report/exporters honest about what
3525
+ // happened; `cutlistProposed` keeps the declined categories recoverable
3526
+ // for the editor (a vetoed removal merges into a plain keep, so the
3527
+ // resolved list alone cannot name what was declined).
3153
3528
  cutlist,
3529
+ cutlistProposed,
3154
3530
  ...(clipWindow && clipTargetSec !== undefined
3155
3531
  ? { clip: { targetSec: clipTargetSec, ...clipWindow } }
3156
3532
  : {}),
@@ -3806,12 +4182,147 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3806
4182
  console.log(overridesWriteLine(cutResult.changed));
3807
4183
  }
3808
4184
 
4185
+ // Resolved HERE — above the --no-render exit rather than at the thumbnail
4186
+ // gate / pack section below — so the gate, the post-render consumers AND
4187
+ // the command.json record (which both exits now write) all read one
4188
+ // answer. Typed-beats-config, `typeof` not truthiness (the `portrait`
4189
+ // posture): config.json is hand-edited and unparsed, and a
4190
+ // `"audience": true` typo must resolve to "no audience".
4191
+ const youtube = resolveYoutube(opts.youtube, cfg.youtube);
4192
+ // resolvePortrait (portrait-override.ts) carries the expandHome treatment
4193
+ // of the flag and config paths, and puts the workdir's portrait-override
4194
+ // ABOVE both (editor face swap, 2026-08-17): a per-project expression
4195
+ // chosen in the editor must survive CLI re-renders — the flag/config
4196
+ // portrait is the fallback headshot, and a replay silently reverting the
4197
+ // swapped face would undo the one thing the swap exists for.
4198
+ const portrait = resolvePortrait({
4199
+ overridePath: portraitOverridePath(work),
4200
+ flagPortrait: opts.portrait,
4201
+ cfgPortrait: cfg.portrait,
4202
+ })?.path;
4203
+ const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
4204
+ const thumbnailBrief =
4205
+ opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
4206
+
4207
+ // Record THIS invocation so the editor's Render button can replay it (R11
4208
+ // Task 4). Nothing else can reconstruct it — production.json has the
4209
+ // source path, cleanup and intent, but not --produce, --out or the LLM
4210
+ // flags — and guessing would silently render a different video than the
4211
+ // one on screen. execArgv carries the module loader (tsx in dev), so the
4212
+ // replay works from source and from a compiled build alike.
4213
+ //
4214
+ // Written BEFORE the render/no-render fork, not after the render (cut-review
4215
+ // step 1): a --no-render workdir used to carry NO command.json at all, so
4216
+ // the editor's Render button 412'd with "run `ossclip produce` once from
4217
+ // the terminal" — a dead end that made `--review` (produce without
4218
+ // rendering, review in the editor, render ONCE from its Render button)
4219
+ // impossible. Every pin below is resolved by this point, so the record is
4220
+ // byte-identical to what the post-render write produced; the side effect is
4221
+ // that a crashed render now leaves a record its own Render button can
4222
+ // retry. recordedProduceArgs strips --review/--no-render at record, so the
4223
+ // replay actually renders instead of looping.
4224
+ //
4225
+ // The provider may have been AUTO-DETECTED from this shell's environment
4226
+ // (a GEMINI_/ANTHROPIC_ key exported here). The editor's Render replays
4227
+ // this argv from the EDIT SERVER's environment, which may not have that
4228
+ // key — and the auto-detection would then silently pick a DIFFERENT
4229
+ // provider (R16 §75). Pin the RESOLVED choice into the recorded args —
4230
+ // never the key itself; secrets stay out of the workdir — so a replay
4231
+ // uses the same configuration or fails loudly asking for it.
4232
+ // §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
4233
+ // editor's Render replays this argv; if replay re-asked the model and got a
4234
+ // slightly different window, every saved override — anchored to scene ids
4235
+ // and word indices — would land on the wrong words. The word range, not
4236
+ // just `--clip 60`, is what makes replay deterministic with zero LLM calls.
4237
+ //
4238
+ // §129: NOT process.argv. A wizard or bare-path run re-enters commander
4239
+ // with a BUILT argv while process.argv still holds the original invocation
4240
+ // (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
4241
+ // recording process.argv shipped a command that replays as
4242
+ // `ossclip <path> --llm …` and dies on "unknown option '--llm'".
4243
+ // recordedProduceArgs prefers the argv the re-entry stashed and falls back
4244
+ // to process.argv for a directly typed `ossclip produce …`, which stays
4245
+ // byte-identical to what was always recorded.
4246
+ // Watermark pin, same §75 shape, and in BOTH directions (review,
4247
+ // Important): the effective default comes from THIS machine's
4248
+ // ~/.ossclip/config.json, so an unpinned record replays differently
4249
+ // wherever that config differs — an off-run would silently gain a credit
4250
+ // under a later/foreign config-on, an on-run would silently lose it. The
4251
+ // RESOLVED state is always pinned; a typed flag is already in the argv and
4252
+ // the includes-guard leaves it alone.
4253
+ // Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
4254
+ // the override-inclusive `captionsHidden` — overrides.json travels with
4255
+ // the workdir and is re-read on every replay, so pinning --no-captions
4256
+ // because the EDITOR hid them would freeze an edit the user may later
4257
+ // undo in that same editor. See recordedProduceArgs for why the pin is
4258
+ // unconditional even though captions' default is config-independent today.
4259
+ // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4260
+ // argv — "auto" has no flag spelling and stays unpinned (see
4261
+ // recordedProduceArgs for why that is safe today).
4262
+ // Youtube pin: the watermark's config-dependent-default rationale exactly —
4263
+ // resolved both ways, so a later config edit can't flip what Render
4264
+ // replays. Portrait and dictionary pin the RESOLVED values (a path and
4265
+ // terms, never a secret) for the same reason; recordedProduceArgs owns
4266
+ // the non-empty/includes guards.
4267
+ const recordedArgs = recordedProduceArgs({
4268
+ llm: provider ? providerName : undefined,
4269
+ // The RESOLVED effort (§143), pinned like the dictionary: it may have
4270
+ // come from this machine's config, and it keys the plan caches — an
4271
+ // unpinned record would re-plan on replay after a config edit.
4272
+ llmEffort,
4273
+ clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4274
+ watermark,
4275
+ captions: opts.captions ?? true,
4276
+ jumpCuts: jumpCutsMode,
4277
+ dictionary,
4278
+ youtube,
4279
+ portrait,
4280
+ audience,
4281
+ thumbnailBrief,
4282
+ });
4283
+ // produce is the ONLY command that may write command.json — edit.ts's §129
4284
+ // heal prepends the "produce" literal to any record that doesn't start
4285
+ // with it, so a record made by `transcribe`/`analyze` (which run this same
4286
+ // pipeline with render: false, and used to be kept out by the write
4287
+ // sitting after the early return below) would heal into
4288
+ // `produce transcribe …` and replay garbage. Their argv cannot start with
4289
+ // "produce" (§129: the stash mirrors the parse that ran, and only
4290
+ // produce's re-entries stash), which is the gate.
4291
+ if (recordedArgs[0] === "produce") {
4292
+ await writeFile(
4293
+ join(work, "command.json"),
4294
+ JSON.stringify(
4295
+ {
4296
+ execPath: process.execPath,
4297
+ execArgv: process.execArgv,
4298
+ script: process.argv[1],
4299
+ args: recordedArgs,
4300
+ cwd: process.cwd(),
4301
+ out: outPath,
4302
+ },
4303
+ null,
4304
+ 2,
4305
+ ),
4306
+ );
4307
+ // Every produce run is a project the picker should offer (R17 §83) —
4308
+ // best-effort, so a read-only home dir never fails the render.
4309
+ await recordRecentProject(work);
4310
+ }
4311
+
3809
4312
  if (!opts.render) {
3810
4313
  // §140: the breakdown goes above the closing lines on both exits, so the
3811
4314
  // last thing on screen stays the success line and the edit hint.
3812
4315
  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));
4316
+ if (opts.review === true) {
4317
+ // --review (step 1's report, fixed in step 3): the editor opens itself
4318
+ // right after this return, so the --no-render skip line plus an
4319
+ // `ossclip edit …` hint would tell the user to do what is already
4320
+ // happening. One line that says what comes next instead.
4321
+ console.log("▸ review: opening the editor — render once from its Render button");
4322
+ } else {
4323
+ console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
4324
+ console.log(editHint(work));
4325
+ }
3815
4326
  return {
3816
4327
  workdir: work,
3817
4328
  rendered: false,
@@ -3833,27 +4344,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3833
4344
  // multi-minute render. Interactive runs approve (or edit, or skip) it
3834
4345
  // here; the file it writes is what thumbnailStep honors after render — and
3835
4346
  // 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);
4347
+ // persistence story. The youtube/portrait/audience/brief resolutions this
4348
+ // gate reads moved above the --no-render exit (cut-review step 1) so the
4349
+ // command.json record pins the same answers on both exits.
3857
4350
  const geminiKey = process.env.GEMINI_API_KEY;
3858
4351
  const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
3859
4352
  // The gate reuses thumbnailDecision (plus the mime check) so the prompt
@@ -4279,14 +4772,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4279
4772
  } else {
4280
4773
  // Beat-sheet cache shape: keyed on everything that changes the answer —
4281
4774
  // who is asked, with what editorial steer, about which words.
4282
- const packKey = createHash("sha1")
4775
+ // Parameterized on the provider for the §143 read/write split: the
4776
+ // write below re-keys on who actually answered the pack call.
4777
+ const packKeyFor = (p: string): string => createHash("sha1")
4283
4778
  .update(
4284
4779
  JSON.stringify([
4285
4780
  // Prompt changes change the answer (the §78 posture): the v2
4286
4781
  // rewrite must not serve a pack cached under v1's questions.
4287
4782
  YOUTUBE_PROMPT_VERSION,
4288
- providerName,
4783
+ p,
4289
4784
  opts.llmModel ?? "",
4785
+ // Appended only when set — the plan-cache rule (§78 via §143's
4786
+ // effort knob): an unset effort must keep every warm workdir's
4787
+ // key byte-identical, and a changed effort is a different answer.
4788
+ ...(llmEffort !== undefined ? [llmEffort] : []),
4290
4789
  opts.intent ?? "",
4291
4790
  // Steer, so part of the key — a changed audience is a different
4292
4791
  // pack, not a cache hit.
@@ -4302,7 +4801,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4302
4801
  )
4303
4802
  .digest("hex")
4304
4803
  .slice(0, 8);
4305
- const packCache = join(work, `youtube-${packKey}.json`);
4804
+ const packCache = join(work, `youtube-${packKeyFor(providerName)}.json`);
4306
4805
  if (existsSync(packCache)) {
4307
4806
  pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
4308
4807
  console.log("▸ youtube: metadata cached");
@@ -4322,7 +4821,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4322
4821
  durationSec: map.outputDuration,
4323
4822
  }),
4324
4823
  );
4325
- await writeFile(packCache, JSON.stringify(pack, null, 2));
4824
+ // §143 read/write split, same as the plan and repair caches: the
4825
+ // pack files under whoever wrote it.
4826
+ await writeFile(
4827
+ join(
4828
+ work,
4829
+ `youtube-${packKeyFor(actualProvider(provider!.usage, "youtube_pack", providerName))}.json`,
4830
+ ),
4831
+ JSON.stringify(pack, null, 2),
4832
+ );
4326
4833
  } catch (err) {
4327
4834
  // NEVER cache a failure (§106), and never fail the produce that
4328
4835
  // just rendered over a metadata sidecar: one loud line, the video
@@ -4388,84 +4895,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4388
4895
  });
4389
4896
  }
4390
4897
  }
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);
4898
+ // command.json was recorded above the --no-render exit (cut-review step 1)
4899
+ // — see the block before that fork for the §75/§93g/§129 pinning story.
4469
4900
  console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
4470
4901
  console.log(`✓ done → ${outPath}`);
4471
4902
  if (isInteractive()) {