# Recoil Scripting System

> **▶ START HERE (fresh session): [`docs/PIPELINE_SSOT.md`](docs/PIPELINE_SSOT.md)** — the one-screen
> orientation SSOT (topography · tools · protocols · canonical-source index). `/engine` loads it automatically.

Vertical microdrama creation engine. Two-phase workflow: **Development** → **Scripting**.

> **Visual pipeline scope:** see `recoil/pipeline/CLAUDE.md` for the visual production pipeline (Workspace UI, generation pipeline, refs). This file (`recoil/CLAUDE.md`) covers the narrative engine.
>
> **Current visual topology (updated 2026-06-22):** Console v2 is CANCELLED. The **Recoil Workspace (8450, `recoil/workspace/`) is the sole worksurface** (review/select/re-roll) and is being EXTENDED — NOT deprecated (06-10 narrowing reversed the earlier "Flora is the worksurface" framing; reaffirmed 2026-06-19 "extend the Workspace, NOT Flora"). **Flora is the commodity execution/models/hosting layer ONLY — not the worksurface.** Production Console 8430 and Pre-Pro Console 8420 are deprecated.

**Git worktrees: OK if placed OUTSIDE the repo** (`~/Code/` or `/tmp`), never inside it. `RECOIL_ROOT` is `__file__`-derived, so a sibling worktree resolves correctly. The old blanket ban was an in-repo-placement artifact (2026-05-19 incident) and was lifted 2026-06-02.

**Parallel + merge operating rule:** Work on `main` in the primary checkout. For any mutating build, `recoil/pipeline/tools/session_workspace.sh create` (worktree outside the repo). Builds auto-land their PR when clean + in-scope; otherwise `/land` drains the queue. Never sit on a merged feature branch — SessionStart fast-forwards it.

---

## CONTEXT LOADING

**Run `/load-context [project] [mode]` ONLY for narrative work** (generation, treatment, development). Do NOT run it for engine code, engine-check, compile, or visual pipeline work.

After context compaction during generation: re-run `/load-context [project] generate`.

---

## V12 CONSTRAINTS

> **Source of truth:** `skills/format_v12/SKILL.md` + `CONSTANTS.md`

| Rule | Value |
|------|-------|
| Word count | 450-500 per episode |
| Dialogue | ≤40% |
| Exchanges | 8 max |
| Hooks | 70-85% silent / 15-30% dialogue |
| Cliffhangers | 70-85% mid-action / 15-30% aftermath |
| Pattern limit | Max 3 consecutive same type |

---

## PIPELINE

```
── Recoil (Narrative) ──────────────────────────────────────
1. /develop [project]     → 34-point checklist
2. /validate [project]    → Pre-promotion gate
3. /promote [project]     → Creates scripting folder + episode_arc.md
4. /treatment [project]   → Creates treatment.md (MASTER input)
5. /generate-script [project]    → Episode script generation (60 episodes)
6. /finish [project]      → Post-gen: script-doctor → revise → verify → compile
   (Or individually: /script-doctor, apply_annotations.py, /compile)

── Visual Production (pipeline/) ───────────────────────────
7-17. Camera Test → Global Bible → Plan Pass → Casting →
      Location Refs → Previz → Keyframes → Dailies → Video → Export
```

Each step has a gate. **Orientation SSOT (read first): `docs/PIPELINE_SSOT.md`** (topography · tools · protocols). Legacy phase detail: `docs/WORKFLOW_SPEC.md` (stale — see its banner).

---

## CINEMA MODE FRAMEWORK

- **Cinematography preset layer:** `recoil/config/CINEMA_MODES.yaml` is the
  SSOT for camera-body / lens / filtration / stock / grain / grade /
  aperture / shutter catalogs and the modes that compose them.
  Loader: `recoil/pipeline/_lib/cinema_loader.py`.
  Per-model emission: `model_profiles.json` -> `cinema_token_map` + `supports_negative_prompt`.
  Per-shot lens-type defaults: `PROMPT_BIBLE.yaml::lens_per_shot_size`.
  Constraint dictionary: `PROMPT_BIBLE.yaml::constraint_dictionary`.
  Activation: `projects/<proj>/project_config.json::cinema_mode`.
  Before designing any prompt-look feature, read `recoil/docs/cinema-mode-recon.md`
  and search mempalace for "cinema mode" -- the framework already exists.

---

## EPISODE WRITING RULE (MANDATORY)

**After EVERY episode write, validate before proceeding:**

```bash
python3 tools/episode_metrics.py [file] --json
```

- If `is_valid: true` → proceed
- If `is_valid: false` → run with `--prompt`, apply fixes, re-validate (max 3 attempts)
- **Never trust header word count estimates** — only the validator is accurate
- **Never skip validation** — not even for "just one episode"

---

## KEY COMMANDS

| Command | Purpose |
|---------|---------|
| `/load-context [project] [mode]` | Load narrative context (generate/develop/treatment) |
| `/generate-script-orchestrated [project]` | **Recommended** — fresh sub-agent per batch |
| `/autogenerate-scripts [project]` | Fully autonomous generation |
| `/finish [project]` | Post-gen pipeline (doctor → revise → verify → compile) |
| `/script-doctor [project]` | Multi-pass series diagnostic via Gemini |
| `/compile [project]` | Merge episodes to Fountain |
| `/engine-check` | 53-check engine audit |
| `/analyze-production [project]` | Post-production engine memory population (run after /finish) |

**Full command reference:** `docs/COMMAND_REFERENCE.md`
**Full tool reference:** `docs/TOOL_REFERENCE.md`

---

## CROSS-ENGINE PATHS

The visual pipeline (formerly Starsend) is now internal at `pipeline/`. Cross-engine path resolution uses `core/paths.py` which provides `RECOIL_ROOT` and `PIPELINE_ROOT`. Legacy references to a sibling `starsend/` directory are no longer valid — all visual pipeline code lives under `recoil/pipeline/`.

---

## PROMPT ENGINE (post-CP-3, 2026-04-26)

The visual pipeline has ONE prompt engine for video / keyframe builders: `recoil/pipeline/lib/prompt_engine.py`. Every prompt builder is reached via:

```python
from lib.prompt_engine import get_builder
prompt = get_builder(model_id, modality)(shot, bible)
```

`tools/prompt_engine.py` and `visual/prompt_engine.py` were deleted in CP-3 — do not search for them. Their canonical bodies were either already in `pipeline/lib/prompt_engine.py` (visual/ duplicates) or migrated to `recoil/lib/prompt_compiler.py` (tools/ — `PromptEngine` class).

The `BUILDERS` dispatch table at the end of `pipeline/lib/prompt_engine.py` is the single source of truth for which builder runs for which `(model_id, modality)`. Adding a model = one line: `BUILDERS[("new-model-v1", "i2v")] = build_new_model_i2v_prompt`.

`PROMPT_BIBLE.yaml` at `recoil/config/PROMPT_BIBLE.yaml` is the rule source for length, word ranges, and i2v aspect-ratio behavior — read by `lib.bible_loader` and enforced by `_enforce_prompt_length` / `validate_start_frame_ar`.

For full audit + migration log, see `recoil/docs/prompt-engine-audit.md`. Rollback tag: `pre-prompt-engine-deletion`.

---

## MODALITY REGISTRY (CP-4, post-2026-04-28)

Generation dispatch routes through a modality registry at
`recoil/pipeline/core/registry.py`:

```python
from pipeline.core.registry import get_runner
from pipeline.core.dispatch import register_default_runners

# bootstrap (canonical home post-CP-5; the lapsed pipeline.core.runners
# re-export was removed in engine-fix Phase D Phase 8)
import pipeline.core.runners  # ensures stub modalities (audio_t2a, lipsync_post) register
register_default_runners(step_runner)

# dispatch
result = get_runner("image_t2i").run(payload)    # keyframe
result = get_runner("video_i2v").run(payload)    # video i2v / t2v
result = get_runner("audio_t2a").run(payload)    # NotImplementedError until CP-8
result = get_runner("lipsync_post").run(payload) # NotImplementedError until CP-8
```

`RunResult` (in `pipeline/core/registry.py`) is the typed result. CP-5 will wrap
it in `GenerationReceipt`. Adding a new modality is a one-line
`register_runner(modality_string, runner_instance)` call.

`StepRunner` is unchanged from pre-CP-4 — runners wrap it. Full audit:
`recoil/docs/modality-registry-audit.md`. Rollback tag: `pre-cp4-modality-registry`.

---

## DISPATCH UNIFICATION (CP-5, post-2026-04-28)

Every generation call goes through one entry point:

```python
from pipeline.core import dispatch, DispatchContext, GenerationReceipt

ctx = DispatchContext(
    caller_id="production_loop",
    step_runner=my_step_runner,
    project="tartarus", episode=1,
)
receipt = dispatch("image_t2i", payload, context=ctx)
```

`dispatch()` lazily bootstraps runners, routes through the CP-4 registry, wraps `RunResult` in `GenerationReceipt`, emits a JSONL audit log, and stamps `StepRunner._dispatch_path` for sidecar provenance. **Direct calls to `StepRunner.execute_*` from production code are deprecated** — only tests that exercise the StepRunner contract still call them directly.

`pipeline/lib/api_client.py` (the 7-line proxy) was deleted; use `from execution.api_client import ...` directly.

`pipeline/tools/test_via_steprunner.py` was renamed to `dispatch_cli.py` in CP-5 and deleted in Phase 16 — use `dispatch_cli.py` exclusively.

Full audit: `recoil/docs/dispatch-unification-audit.md`. Rollback tag: `pre-cp5-entry-point-unification`. CP-6 hand-off: `consultations/recoil/cp5-entry-point-spec/CP6_HANDOFF.md`.

---

## FOLDER LAYOUT

```
recoil/           ← Unified engine (narrative + visual)
├── core/         ← Foundational: paths, config, model profiles
├── execution/    ← How to run: API clients, StepRunner, store, feedback
├── visual/       ← What to build: prompts, elements, grammar
├── pipeline/     ← Visual production pipeline (absorbed from legacy starsend/ folder 2026-03-30)
│   ├── lib/      ← Pipeline modules (validation, previz, context builders)
│   ├── orchestrator/ ← pipeline.py, scene_planner.py
│   ├── editors/  ← legacy Production Console source (8430, deprecated)
│   ├── api/      ← FastAPI routes
│   ├── tools/    ← Pipeline utility scripts
│   └── config/   ← Pipeline-specific config
├── workspace/    ← Recoil Workspace server (127.0.0.1:8450) — ACTIVE visual UI
├── tools/        ← Narrative engine tools
├── editors/      ← legacy Pre-Production Console source (8420, deprecated)
└── config/       ← pipeline_config.json (shared)

projects/         ← All shows (sibling to recoil/)
├── [project]/    ← treatment.md, ORCHESTRATION.md, bible/, episodes/, state/
└── _archive/     ← Archived projects
```

---

## TARGET DEMOGRAPHIC

Men 18-35. Competence porn, high-stakes economics, "Sigma Flip" (underdog outsmarts system), action over romance.

---

## THEMATIC REQUIREMENTS

Every premise needs: (1) surprising conceit, (2) "this is really about...", (3) mythological DNA, (4) debatable question.

---

## WHEN UNCERTAIN

1. Run `/load-context` if you haven't
2. Read the relevant skill in `skills/`
3. Check `CONSTANTS.md` for numeric values
4. Read `docs/PIPELINE_SSOT.md` for pipeline topography / tools / protocols (orientation SSOT)
5. Read the relevant skill file for command/tool detail (the skill is the per-command SSOT)

*Authoritative workflow SSOT: `docs/PIPELINE_SSOT.md`. Legacy phase spec (stale, engine-check target): `docs/WORKFLOW_SPEC.md`.*

---

## Engine Memory

Persistent production knowledge lives at `engine-memory/` — tracked as regular files in this repo's git substrate (absorbed in the C6 substrate split; no longer a nested git repo).

**Always load at session start:**
- `engine-memory/ENGINE_MEMORY.md`
- `engine-memory/ANTI_PATTERNS.md`

**Load when investigating a specific topic or when LEARNINGS.md is referenced in the summary:**
- `engine-memory/LEARNINGS.md`

**Write discipline:** Before appending to any engine memory file, apply this test:
"Would a new Claude Code session, starting fresh on a new episode, make a better
decision by knowing this?" If yes, write it. If no, it's a log, not memory.
Put logs in this project's `logs/` directory.

**After any write:** the change is committed with the main substrate's normal git workflow — there is no separate engine-memory repo to commit.

---

## WORKFLOW OBJECT MODEL (CP-6, post-2026-04-28)

Generation calls can be composed into declarative workflows:

```python
from pipeline.core import Workflow, WorkflowStep, DispatchContext

ctx = DispatchContext(caller_id="production_loop", step_runner=sr,
                     project="tartarus", episode=1)
wf = Workflow(
    workflow_id="tartarus_ep001_sh02_full",
    steps=[
        WorkflowStep(step_id="keyframe", modality="image_t2i",
                     payload=kf_payload),
        WorkflowStep(step_id="video", modality="video_i2v",
                     payload=vid_payload, depends_on=["keyframe"]),
    ],
    global_provenance={"shot_id": "EP001_SH02", "scene_id": "scene_3"},
)
wf.run(context=ctx)
kf_receipt = wf.get_step("keyframe").receipt
vid_receipt = wf.get_step("video").receipt
```

`Workflow.run` walks the steps in declared order, calls `dispatch()` per step,
attaches the resulting `GenerationReceipt` to `step.receipt`, and stamps
`provenance["workflow_id"]` + `provenance["workflow_step_id"]` on each receipt.
Failed steps short-circuit dependent steps (`status="skipped"`); independent
branches continue. Hooks `pre_step` / `post_step` / `on_failure` give CP-9 a
place to hang eval calls without touching the executor.

CP-6 ships **linear execution semantics on a DAG-shaped data model** —
today every node has at most one downstream; future Flora/worksurface graph
work can add branching with zero data migration. CP-6 does NOT ship:
persistence, DIRECTOR step subtypes, or eval primitives. CP-7 wraps
`WorkflowStep.receipt` in `Take`. CP-8 adds audio steps. CP-9 fills
`eval_scores` via the hooks.

JSON round-trip: `Workflow.from_dict(wf.to_dict()) == wf`. Useful for tests,
debugging, and CP-7's persistence layer.

Full audit: `recoil/docs/workflow-object-model-audit.md`. Rollback tag:
`pre-cp6-workflow-object-model`. CP-7 hand-off:
`consultations/recoil/cp6-workflow-spec/CP7_HANDOFF.md`.

## TAKE MODEL (CP-7, post-2026-04-28)

Generation attempts can be organized into editorial Takes inside Beats inside Scenes:

```python
from pipeline.core import (
    Take, Beat, Scene,
    Workflow, WorkflowStep, DispatchContext,
)

ctx = DispatchContext(caller_id="production_loop", step_runner=sr,
                     project="tartarus", episode=1)

beat = Beat(beat_id="EP001_SH02",
            beat_metadata={"scene_id": "ep001_sc02"})

# First attempt
take_0 = beat.new_take(workflow=Workflow(
    workflow_id="EP001_SH02_wf0",
    steps=[
        WorkflowStep(step_id="keyframe", modality="image_t2i", payload=kf_payload),
        WorkflowStep(step_id="video", modality="video_i2v",
                     payload=vid_payload, depends_on=["keyframe"]),
    ],
))
take_0.execute(context=ctx)

# Re-attempt = NEW Take, not mutate
if take_0.status != "succeeded":
    take_1 = beat.new_take(workflow=Workflow(workflow_id="EP001_SH02_wf1", steps=[...]))
    take_1.execute(context=ctx)

# Pick the primary (default: first successful)
beat.select_primary()  # strategy="first_success"
primary = beat.primary_take
```

`Take` wraps exactly one `Workflow`. `Take.execute(context, ...)` runs the
workflow and compresses step status into a take-level status:
`succeeded` (all steps succeeded), `failed` (no step succeeded), `partial`
(mixed). Hooks (`pre_step` / `post_step` / `on_failure`) pass through to the
underlying `Workflow.run`.

`Beat` groups multiple Takes for one logical shot. `Beat.new_take` constructs
+ appends a Take with auto-assigned `take_index`. `Beat.select_primary` picks
the primary using a strategy:
- `"first_success"` (CP-7 default) — first take with status="succeeded"
- `"manual"` — caller sets `primary_take_id` directly
- `"score"` — `NotImplementedError` until CP-9 ships eval primitives

`Scene` is a thin grouping of Beats — dataclass + serialization only.

CP-7 is **in-memory only** — no disk persistence. JSON round-trip via
`to_dict` / `from_dict` works for testing, debugging, and a future
persistence CP. CP-7 does NOT ship: persistence, take-level hooks,
DIRECTOR step subtypes, or eval primitives. CP-8 adds audio Takes.
CP-9 fills `eval_scores` on receipts and replaces the primary-selection
default with score-based logic.

Full audit: `recoil/docs/take-model-audit.md`. Rollback tag:
`pre-cp7-take-model`. CP-8 hand-off:
`consultations/recoil/cp7-take-spec/CP8_HANDOFF.md`.

## AUDIO + LIP-SYNC (CP-8, post-2026-04-28)

Audio + lip-sync modalities are now LIVE under the modality registry:

```python
from pipeline.core import dispatch, DispatchContext

ctx = DispatchContext(caller_id="audio_demo", step_runner=sr,
                     project="tartarus", episode=1)

# Text-to-speech via ElevenLabs
audio_receipt = dispatch("audio_t2a", {
    "shot_id": "EP001_VO01",
    "text": "Hold the line — this is not a drill.",
    "voice_id": "Rachel",
    "model": "eleven_multilingual_v2",
    "output_format": "mp3",
}, context=ctx)
# audio_receipt.run_result.output_path → local .mp3

# Lipsync via sync.so (face-video + audio → lipsynced .mp4)
lipsync_receipt = dispatch("lipsync_post", {
    "shot_id": "EP001_VO01",
    "video_path": "/path/to/face_video.mp4",
    "audio_path": audio_receipt.run_result.output_path,
    "model": "lipsync-2.0",
    "output_format": "mp4",
    "sync_mode": "loop",
}, context=ctx)
```

Both modalities produce a `GenerationReceipt` with `RunResult.metadata`
containing cost (`cost_usd`), model id, vendor request/job ids, char count
(audio) or duration (lipsync). Receipts are JSONL-logged at
`$RECOIL_ROOT/_dispatch_logs/receipts.jsonl` like every other modality.

Output files default to `$RECOIL_ROOT/_audio_outputs/` and
`$RECOIL_ROOT/_lipsync_outputs/` (gitignored). Auth via env vars
`ELEVENLABS_API_KEY` and `SYNC_SO_API_KEY`.

Provider adapters: `recoil/execution/providers/elevenlabs.py` and
`recoil/execution/providers/sync_so.py`. They expose `synthesize_speech` /
`lipsync_video` callable functions, raise typed exceptions
(`AudioSynthesisError`, `LipSyncError` subclass tree), and accept a
`transport=` injection point for tests.

Retry policy: 3 retries with exponential backoff (1s, 2s, 4s) for 5xx +
network blips. Fail-fast on 401 / 402 / 422 / 429 (auth, quota, payload,
rate-limit).

CP-8 is the **first production-implementation CP** in the june-refactor
sequence — load-tests the CP-4 modality registry with a brand-new modality
that didn't exist in any pre-existing StepRunner method. CP-8 ships:

- Real `AudioRunner` + `LipSyncPostProcessor` (replacing CP-4 stubs).
- ElevenLabs + sync.so adapters under `execution/providers/`.
- Two new entries each in `model_profiles.json` + `provider_strategy.json`.
- ~85 new tests across 7 test files.
- ZERO new modality strings, ZERO StepRunner additions, ZERO frontend.

CP-9 builds eval primitives on top of this fully-populated 4-modality
surface (image_t2i, video_i2v, audio_t2a, lipsync_post all LIVE).

Full audit: `recoil/docs/audio-lipsync-impl-audit.md`. Rollback tag:
`pre-cp8-audio-lipsync`. CP-9 hand-off:
`consultations/recoil/cp8-audio-lipsync-spec/CP9_HANDOFF.md`.

## EVAL PRIMITIVE (CP-9, post-2026-04-28)

CP-9 ships pluggable artifact eval — multi-judge `PanelOfJudges`,
score-based `Beat.select_primary("score")`, and `Take.aggregate_score`.

```python
from pipeline.core import (
    EvalContext, EvalResult, PanelOfJudges,
    register_eval_node, get_eval_node, attach_eval_hooks,
    Take, Beat, Workflow, WorkflowStep, DispatchContext,
)
from pipeline.core.runners import GeminiVisionEvalNode
from pipeline.core.dispatch import register_default_eval_runners

# Bootstrap eval runners (opt-in; not auto-registered).
register_default_eval_runners()

# Register one or more EvalNode judges per modality. Each EvalNode wraps the
# Gemini Vision adapter for its target artifact_modality ("image"/"video"/"audio").
register_eval_node("eval_image_v1",
                   GeminiVisionEvalNode(artifact_modality="image",
                                        judge_id="eval_image_v1"))

# Build a panel.
panel = PanelOfJudges(
    panel_id="visual_quality_v1",
    judges=[get_eval_node("eval_image_v1")],
    aggregation="median",
    cost_cap_usd=0.50,
)

# Attach hooks to a workflow.
beat = Beat(beat_id="EP001_SH02")
take = beat.new_take(workflow=Workflow(
    workflow_id="EP001_SH02_wf0",
    steps=[WorkflowStep(step_id="kf", modality="image_t2i", payload={...})],
))
ctx = DispatchContext(caller_id="prod", step_runner=sr,
                     project="tartarus", episode=1)
pre, post, on_fail = attach_eval_hooks(take.workflow, panel)
take.execute(context=ctx, pre_step=pre, post_step=post, on_failure=on_fail)

# Compute aggregate + select primary.
take.compute_aggregate_score()
beat.select_primary(strategy="score")
```

Eval scores land in `step.receipt.eval_scores[panel_id]` as a ScoreCard
dict: `{panel_score, panel_warnings, judges: [...], aggregation,
panel_cost_usd}`. Eval cost flows through `receipt.provenance["eval_cost_usd"]`
separately from generation cost.

`Beat.select_primary("score")` is now implementable: highest aggregate
wins; ties broken by `take_index` ASC; score-less takes sort below scored
takes; no-eval-at-all returns None (no exception).

Three new modalities registered: `eval_image_v1`, `eval_video_v1`,
`eval_audio_v1`. All three wrap the same Gemini 3.1 Pro adapter with
modality-specific defaults. Auth via `GEMINI_API_KEY` env var (with
`GOOGLE_API_KEY` fallback per recoil convention).

CP-9 is the **last CP** in the june-refactor sprint. Flora/worksurface
iteration unblocks. Retry-strategy iteration unpauses on the
`PanelOfJudges` substrate (production wire-up gated on JT review).

CP-9 does NOT ship: tournament/elimination, CostGate, pre-generation
prompt eval, scene-continuity critic, multi-panel weighting,
production cutover of legacy `critic.py`, production cutover of
`detect_failure_mode` via `from_score_card`. All deferred to CP-N+.

Provider adapter: `recoil/execution/providers/gemini_vision.py`. Eval
module: `recoil/pipeline/core/eval.py`. Eval runners:
`recoil/pipeline/core/runners/eval_{image,video,audio}_runner.py`.
Legacy critic adapter: appended at the BOTTOM of
`recoil/core/critic.py` (no body modification, no caller modification).
Retry-strategy bridge: `from_score_card` appended at module level in
`recoil/pipeline/orchestrator/strategy_registry.py` (substrate only;
`production_loop.py` byte-untouched).

Full audit: `recoil/docs/eval-primitive-audit.md`. Rollback tag:
`pre-cp9-eval-primitive`. Sprint-complete tag:
`june-refactor-complete` (placed in Phase 9). Post-CP-9 hand-off:
`consultations/recoil/cp9-eval-spec/POST_CP9_HANDOFF.md`.

# Canonical capability map: recoil/architecture/ssot_manifest.yaml
