Eleven skills for conference speakers covering rhetoric analysis, talk authoring, deck building and export, CFP submissions, publishing, illustrations, shownotes, and screencast production, informed by a 113-entry Presentation Patterns taxonomy (83 observable: 64 patterns + 19 antipatterns; 30 unobservable: 21 patterns + 9 antipatterns).
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
#!/usr/bin/env python3
"""Outline schema — the source-of-truth document for a talk.
`outline.yaml` is the single hand-edited (well, agent-edited) artifact per talk.
`narrative.md`, `rhetorical-review.md`, `script.md`, and `slides.md` are
generated from it by separate extractor scripts.
Pattern IDs are validated against the closed enum discovered from
`references/patterns/{prepare,build,deliver}/*.md` at import time. Antipatterns
(`_anti_*.md`) are deliberately excluded — antipatterns surface as detections
in `rhetorical-review.md`, never as authored declarations. The observable
vs unobservable distinction within the patterns taxonomy belongs to the
presentation-publisher delivery checklist, not the schema; the schema accepts
any non-antipattern.
"""
from __future__ import annotations
import json
import copy
import os
import re
import stat
import sys
import tempfile
from enum import Enum
from pathlib import Path
from typing import Literal
import yaml
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationError,
field_validator,
model_validator,
)
_PROFILE_SCRIPTS = Path(__file__).resolve().parents[2] / "vault-profile" / "scripts"
if str(_PROFILE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_PROFILE_SCRIPTS))
# The installed sibling skill owns speech-rate schemas; readers do not migrate them.
from speech_rates import assumed_narration, validate_rate # noqa: E402
class _StrictModel(BaseModel):
"""Base for every outline model — rejects unknown fields.
`extra='forbid'` catches misspelled or unsupported YAML keys at load
time instead of silently dropping them, which is the right call for
a source-of-truth schema downstream extractors depend on.
"""
model_config = ConfigDict(extra="forbid")
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
# ── Pattern enum: discovered from the patterns directory ──────────────
_PATTERNS_DIR = Path(__file__).resolve().parent.parent / "references" / "patterns"
def _discover_pattern_ids() -> tuple[frozenset[str], frozenset[str]]:
"""Return (allowed_in_outline, antipattern_ids).
Walks all three phase directories. Any non-antipattern, non-index file
is allowed; antipatterns (file prefix `_anti_`) are rejected.
"""
allowed: set[str] = set()
antipatterns: set[str] = set()
for phase in ("prepare", "build", "deliver"):
for md in (_PATTERNS_DIR / phase).glob("*.md"):
stem = md.stem
if stem.startswith("_anti_"):
antipatterns.add(stem.removeprefix("_anti_"))
elif stem.startswith("_"):
continue
else:
allowed.add(stem)
return frozenset(allowed), frozenset(antipatterns)
PATTERN_IDS, ANTIPATTERN_IDS = _discover_pattern_ids()
ARCHITECTURE_IDS: frozenset[str] = frozenset(
{
"narrative-arc",
"sparkline",
"fourthought",
"triad",
"talklet",
"expansion-joints",
"lightning-talk",
"takahashi",
"cave-painting",
}
)
# ── Closed enums for instance metadata ───────────────────────────────
class AudienceSpread(str, Enum):
"""Whether the room is mixed in what it accepts as proof.
Drives `walk-around`'s cover-or-match decision. Homogeneity is a claim
about what persuades the room, never about job titles.
"""
heterogeneous = "heterogeneous"
homogeneous = "homogeneous"
class Register(str, Enum):
"""The four question archetypes a `walk-around` answers.
Herrmann's quadrant letters, kept as a recognizable handle. These name
kinds of question, never kinds of person — see
`references/patterns/prepare/walk-around.md`.
"""
a_precision = "A" # what exactly, and how do you know?
b_process = "B" # how does it work, in what order?
c_impact = "C" # who does this affect, and how will it land?
d_implication = "D" # where does this lead, what does it connect to?
class PunchFlavor(str, Enum):
personal = "personal"
unexpected = "unexpected"
novel = "novel"
challenging = "challenging"
humorous = "humorous"
class StarSubtype(str, Enum):
memorable_dramatization = "memorable-dramatization"
repeatable_sound_bite = "repeatable-sound-bite"
evocative_visual = "evocative-visual"
emotive_storytelling = "emotive-storytelling"
shocking_statistic = "shocking-statistic"
class ResistanceVector(str, Enum):
comfort_zone = "comfort-zone"
fear = "fear"
vulnerabilities = "vulnerabilities"
misunderstanding = "misunderstanding"
obstacles = "obstacles"
politics = "politics"
class MasterStoryBeat(str, Enum):
introduce = "introduce"
recall_1 = "recall-1"
recall_2 = "recall-2"
recall_3 = "recall-3"
class SlideFormat(str, Enum):
full = "FULL"
img_txt = "IMG+TXT"
exception = "EXCEPTION"
demo = "DEMO"
# Enum members shadow inherited str methods by design — `SlideFormat.title`
# resolves to the member, never to `str.title`. The assignment-type finding
# is a false positive on every str-Enum member named after a str method.
title = "TITLE" # pyright: ignore[reportAssignmentType]
class Composition(str, Enum):
"""Deck-wide illustration composition (see rules/title-overlay-rules.md).
`standard` overlays titles/footers at apply time over a reserved safe zone;
`poster-theatrical` bakes the title, and the footer unless `footer_style`
stamps it, into the image as part of the scene.
Absence of the field means `standard`.
"""
standard = "standard"
poster_theatrical = "poster-theatrical"
class SafeZoneName(str, Enum):
upper_third = "upper_third"
middle_third = "middle_third"
lower_third = "lower_third"
left_half = "left_half"
right_half = "right_half"
class Renderer(str, Enum):
pptx = "pptx"
presenterm = "presenterm"
class AudienceTemperament(str, Enum):
doer = "doer"
supplier = "supplier"
influencer = "influencer"
innovator = "innovator"
# ── Pattern application: pattern id + per-pattern instance metadata ──
# Maps pattern id → set of instance-metadata fields it accepts.
# Empty set means the pattern carries no instance metadata.
_PATTERN_INSTANCE_FIELDS: dict[str, frozenset[str]] = {
"opening-punch": frozenset({"flavors"}),
"star-moment": frozenset({"subtype"}),
"inoculation": frozenset({"resistance_vector"}),
"master-story": frozenset({"story_id", "beat"}),
"foreshadowing": frozenset({"plant_id"}),
"call-to-adventure": frozenset({"big_idea_text"}),
"call-to-action": frozenset({"asks"}),
"walk-around": frozenset({"registers"}),
}
_ALL_INSTANCE_FIELDS: frozenset[str] = frozenset(
{
"flavors",
"subtype",
"resistance_vector",
"story_id",
"beat",
"plant_id",
"big_idea_text",
"asks",
"registers",
}
)
class AppliedPattern(_StrictModel):
"""One pattern application — either at talk level or slide level."""
id: str
flavors: list[PunchFlavor] | None = None
subtype: StarSubtype | None = None
resistance_vector: ResistanceVector | None = None
story_id: str | None = None
beat: MasterStoryBeat | None = None
plant_id: str | None = None
big_idea_text: str | None = None
asks: dict[AudienceTemperament, str] | None = None
# Which of the four question archetypes this walk-around application
# answers. The agent judges which registers a claim actually lands;
# check-rhetorical.py checks the union across the talk.
registers: list[Register] | None = Field(default=None, min_length=1)
@field_validator("id")
@classmethod
def _id_in_closed_set(cls, v: str) -> str:
if v in PATTERN_IDS:
return v
if v in ANTIPATTERN_IDS:
raise ValueError(
f"pattern id '{v}' is an antipattern — antipatterns are not "
f"declared in outline.yaml; they surface in rhetorical-review",
)
raise ValueError(
f"pattern id '{v}' not found in references/patterns/",
)
@model_validator(mode="after")
def _instance_data_matches_pattern(self) -> "AppliedPattern":
allowed = _PATTERN_INSTANCE_FIELDS.get(self.id, frozenset())
for field_name in _ALL_INSTANCE_FIELDS:
if getattr(self, field_name) is not None and field_name not in allowed:
raise ValueError(
f"pattern '{self.id}' does not accept '{field_name}' "
f"(accepted: {sorted(allowed) or 'none'})",
)
return self
# ── Slide-level structural ledger entries ────────────────────────────
class Callback(_StrictModel):
kind: Literal["plant", "pay"]
id: str
variation: str | None = None
class ProgressiveListEntry(_StrictModel):
id: str
item_index: int = Field(ge=1)
class RunningGagEntry(_StrictModel):
id: str
appearance_index: int = Field(ge=1)
class SafeZone(_StrictModel):
"""Per-slide title safe zone for standard-composition illustrations.
Reserves negative space in the generated image for an overlaid title;
`surface` optionally overrides the default backdrop description for the
zone. See rules/title-overlay-rules.md.
"""
zone: SafeZoneName
surface: str | None = None
class Build(_StrictModel):
step: int = Field(ge=0)
desc: str
# Backwards-erase prompt for `generate-illustrations.py --build`. `desc` is
# the additive, human-facing reveal ("Add the second pillar"); `erase` is the
# instruction that turns step N+1 into this step by removing what N+1 added,
# with explicit "Keep ..." clauses for everything that must persist. The
# final (full-image) step needs no erase prompt — it is copied from the base
# slide image. Optional so slides whose builds are only deck-budget
# placeholders validate; --build refuses any non-final step missing it.
erase: str | None = None
# Optional erase region as a normalized bounding box [x0, y0, x1, y1] in
# 0..1 image coords (origin top-left). When set, --build confines the edit to
# this box: the static background outside it is preserved pixel-for-pixel
# (OpenAI gets a real edit mask; Gemini's full regeneration is composited back
# over the prior frame outside the box). Without it, the whole frame is
# regenerated and a static background can drift (#90). Belongs only on
# non-final steps — the final step is a verbatim copy, never edited.
erase_region: tuple[float, float, float, float] | None = None
@model_validator(mode="after")
def _check_erase_region(self) -> "Build":
if self.erase_region is None:
return self
x0, y0, x1, y1 = self.erase_region
if not all(0.0 <= v <= 1.0 for v in self.erase_region):
raise ValueError(
f"erase_region values must be in 0..1 (normalized), got {self.erase_region}"
)
if x0 >= x1 or y0 >= y1:
raise ValueError(
"erase_region must be [x0, y0, x1, y1] with x0 < x1 and y0 < y1, "
f"got {self.erase_region}"
)
return self
# ── Screenplay-form script ───────────────────────────────────────────
class ScriptItem(_StrictModel):
"""One unit in a slide's screenplay block.
Exactly one of {cue, parenthetical, line} is set. `speaker` is required
iff the talk has more than one speaker (enforced at the Outline level).
"""
cue: str | None = None
parenthetical: str | None = None
line: str | None = None
speaker: str | None = None
@model_validator(mode="after")
def _exactly_one_content(self) -> "ScriptItem":
present = [
x for x in (self.cue, self.parenthetical, self.line) if x is not None
]
if len(present) != 1:
raise ValueError(
"script item must have exactly one of {cue, parenthetical, line}",
)
if self.speaker is not None and self.cue is not None:
raise ValueError(
"speaker attribution does not apply to cue items "
"(cues are scene-level, not speaker-attributed)",
)
return self
# ── Narrative-side: chapters + argument beats ────────────────────────
class ArgumentBeat(_StrictModel):
text: str
slide_refs: list[int] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
class Chapter(_StrictModel):
id: str
title: str
target_min: float = Field(gt=0)
accent: str | None = None
cuttable: bool = False
argument_beats: list[ArgumentBeat] = Field(default_factory=list)
# ── Illustration style anchor (optional, per talk) ───────────────────
class FooterStyle(_StrictModel):
"""Geometry and type for a footer stamped onto poster slides (#474).
Present, it takes the footer out of the image model's hands: generation
reserves a quiet strip and renders no footer, and the apply step stamps
`embedded_footer` onto every poster slide with this exact type and geometry,
so the footer is pixel-identical across the deck and a regenerated slide
cannot change it. The stamping and its defaults are
`skills/illustrations/scripts/footer_stamp.py`'s.
"""
# A TrueType/OpenType file (or collection, with `font_index`); null uses the
# renderer's bundled sans-serif.
font_path: str | None = None
font_index: int = Field(default=0, ge=0)
# The rendered text spans this share of the slide width.
target_width_pct: float = Field(default=82.0, gt=0, le=100)
# The text baseline sits this share of the slide height above the bottom edge.
baseline_from_bottom_pct: float = Field(default=3.8, gt=0, lt=50)
color: str = Field(default="#F2EFE6", pattern=r"^#[0-9A-Fa-f]{6}$")
class StyleAnchor(_StrictModel):
model: str
full: str
imgtxt: str
conventions: str
# Deck-wide composition; null == standard overlay. poster-theatrical bakes
# the title (+ embedded_footer) into every image. See rules/title-overlay-rules.md.
composition: Composition | None = None
embedded_footer: str | None = None
# How the baked title is rendered in poster-theatrical mode (e.g. "glowing
# hand-script neon on an in-scene surface"). Lives on the anchor so every
# slide's title is styled identically; a model-rendered footer has its own
# fixed style and a stamped one takes footer_style; the per-slide image_prompt carries
# only the scene and text_overlay carries only the literal title string. Only
# consumed in poster-theatrical mode; null falls back to a generic treatment.
text_treatment: str | None = None
# Stamp the footer deterministically instead of letting the model render it.
footer_style: FooterStyle | None = None
@model_validator(mode="after")
def _footer_style_needs_a_poster_footer(self) -> "StyleAnchor":
if self.footer_style is not None and (
self.composition != Composition.poster_theatrical
or not (self.embedded_footer or "").strip()
):
raise ValueError(
"footer_style stamps embedded_footer onto poster slides; set "
"composition: poster-theatrical and a non-empty embedded_footer, "
"or remove footer_style"
)
if self.footer_style is not None and any(
mark in (self.embedded_footer or "") for mark in "\r\n"
):
raise ValueError(
"footer_style stamps embedded_footer as one line; remove its line "
"breaks (spaces separate its parts), or remove footer_style to let "
"the model render it"
)
return self
# ── Talk metadata ────────────────────────────────────────────────────
class TalkMetadata(_StrictModel):
title: str
slug: str
speakers: list[str] = Field(min_length=1)
duration_min: float = Field(gt=0)
audience: str
# Whether the room is mixed in what it accepts as proof, or uniform.
# Drives the `walk-around` cover-or-match decision: a heterogeneous room
# gets all four registers, a homogeneous one gets its own. Declared at
# intake so the choice is made deliberately rather than defaulted into.
audience_spread: AudienceSpread
# Which register a homogeneous room speaks. Required iff the room is
# homogeneous; forbidden otherwise (there is no single register to name
# for a mixed room).
dominant_register: Register | None = None
mode: str
venue: str
slide_budget: int = Field(gt=0)
# Legacy read-only compatibility: unverified narration planning assumption,
# never a measured rate. New outlines select one typed pacing_rate instead.
pacing_wpm: tuple[int, int] | None = None
pacing_rate: dict | None = None
architecture: str
applied_patterns: list[AppliedPattern] = Field(default_factory=list)
@field_validator("pacing_wpm", mode="before")
@classmethod
def _legacy_pacing_is_an_ordered_integer_range(cls, value):
if value is None:
return value
if (
not isinstance(value, (list, tuple))
or len(value) != 2
or any(type(n) is not int for n in value)
or not 0 < value[0] <= value[1]
):
raise ValueError(
"pacing_wpm requires an ordered positive integer range; use pacing_rate for measured narration"
)
return value
@field_validator("pacing_rate")
@classmethod
def _typed_pacing_is_narration(cls, value):
if value is None:
return value
validate_rate(value)
if value["metric"] != "narration":
raise ValueError("pacing_rate must name narration for long-form planning")
return value
@model_validator(mode="after")
def _one_pacing_source(self) -> "TalkMetadata":
if (self.pacing_wpm is None) == (self.pacing_rate is None):
raise ValueError(
"select exactly one pacing_rate or legacy pacing_wpm assumption"
)
return self
@property
def narration_rate(self) -> dict:
"""Read a typed rate without rewriting the source outline."""
if self.pacing_rate is not None:
return copy.deepcopy(self.pacing_rate)
assert self.pacing_wpm is not None # Required by _one_pacing_source.
return assumed_narration(
*self.pacing_wpm,
reason="legacy pacing_wpm; unverified narration assumption",
)
@model_validator(mode="after")
def _walk_around_is_per_claim(self) -> "TalkMetadata":
"""`walk-around` audits a claim, and a claim has a location.
Slides and interludes both carry claims and are individually
checkable — a live demo answering "how does it work in what order"
is a B answer no slide can match. Talk level carries no claim: it
asserts that the talk answers a register somewhere, with nothing to
check it against, which is the unfalsifiable shape the audit exists
to prevent.
"""
if any(p.id == "walk-around" for p in self.applied_patterns):
raise ValueError(
"`walk-around` is a per-claim audit and cannot be declared at "
"talk level — attach it to the slides or interludes carrying "
"the load-bearing claims, with `registers:` naming what each "
"one answers",
)
return self
@model_validator(mode="after")
def _register_matches_spread(self) -> "TalkMetadata":
if self.audience_spread == AudienceSpread.homogeneous:
if self.dominant_register is None:
raise ValueError(
"audience_spread 'homogeneous' requires 'dominant_register' "
"(A=precision/evidence, B=process/sequence, C=human impact, "
"D=implication) — name the register the room speaks, or set "
"audience_spread to 'heterogeneous' and cover all four",
)
elif self.dominant_register is not None:
raise ValueError(
"audience_spread 'heterogeneous' does not accept "
"'dominant_register' — a mixed room has no single register; "
"cover all four via walk-around instead",
)
return self
# Spec metadata (collapsed from the legacy presentation-spec.md).
# All optional — older outlines without these fields still validate.
thesis: str | None = None
# A reader-facing summary of `thesis` — a couple of paragraphs or a short
# bulleted list. Authored as a distillation of the elaborated thesis;
# narrative.md renders it verbatim as the TL;DR (it never reprints `thesis`).
tldr: str | None = None
shownotes_url_base: str | None = None
commercial_intent: str | None = None
profanity_register: str | None = None
must_include: list[str] = Field(default_factory=list)
must_avoid: list[str] = Field(default_factory=list)
catalog_reference: str | None = None
delivery_count: int | None = Field(default=None, ge=1)
delivery_date: str | None = None # ISO YYYY-MM-DD
# Deck tooling + theme, sourced in Phase 2 Decision #2. All optional —
# older outlines without these fields still validate.
engine: Renderer | None = None # pptx | presenterm; null = legacy/inferred
deck_theme: str | None = None # free-string theme/template pointer (provenance)
engine_source: str | None = None # how the engine was sourced (provenance)
@field_validator("architecture")
@classmethod
def _architecture_in_closed_set(cls, v: str) -> str:
if v not in ARCHITECTURE_IDS:
raise ValueError(
f"architecture '{v}' not in closed set: {sorted(ARCHITECTURE_IDS)}",
)
return v
@field_validator("slug")
@classmethod
def _slug_is_kebab(cls, v: str) -> str:
if not _SLUG_RE.match(v):
raise ValueError(
f"slug '{v}' must be kebab-case (lowercase alphanumeric + "
f"single hyphens, no leading/trailing/double dashes)",
)
return v
@field_validator("delivery_date")
@classmethod
def _delivery_date_iso(cls, v: str | None) -> str | None:
if v is None:
return v
if not re.match(r"^\d{4}-\d{2}-\d{2}$", v):
raise ValueError(
f"delivery_date '{v}' must be ISO YYYY-MM-DD",
)
return v
# ── Slide ────────────────────────────────────────────────────────────
class Interlude(_StrictModel):
"""A production interlude between slides — typically a live demo.
The speaker switches off the deck for the duration. The interlude is
anchored by `after_slide`: it plays between slide N and slide N+1.
Audience never sees a numbered slide for it; the script extractor
inlines it in sequence.
"""
id: str
after_slide: int = Field(ge=0)
title: str
chapter: str
cuttable: bool = False
script: list[ScriptItem] = Field(default_factory=list)
callbacks: list[Callback] = Field(default_factory=list)
applied_patterns: list[AppliedPattern] = Field(default_factory=list)
class Slide(_StrictModel):
n: int = Field(ge=0)
cuttable: bool = False
chapter: str
title: str
format: SlideFormat
format_justification: str | None = None
visual: str | None = None
text_overlay: str | None = None
image_prompt: str | None = None
safe_zone: SafeZone | None = None
builds: list[Build] = Field(default_factory=list)
script: list[ScriptItem] = Field(default_factory=list)
applied_patterns: list[AppliedPattern] = Field(default_factory=list)
callbacks: list[Callback] = Field(default_factory=list)
progressive_lists: list[ProgressiveListEntry] = Field(default_factory=list)
running_gags: list[RunningGagEntry] = Field(default_factory=list)
placeholders: list[str] = Field(default_factory=list)
big_idea: bool = False
thesis: Literal["preview", "payoff"] | None = None
@model_validator(mode="after")
def _exception_needs_justification(self) -> "Slide":
if self.format == SlideFormat.exception and not self.format_justification:
raise ValueError(
f"slide {self.n}: format=EXCEPTION requires format_justification",
)
return self
@model_validator(mode="after")
def _build_steps_contiguous_from_zero(self) -> "Slide":
"""Builds start at 0 and are contiguous (0, 1, 2, …, N-1).
Per phase3-content.md: `step: 0` is the empty frame and each
subsequent step adds one element. Holes or wrong starting steps
produce mislabeled build-NN images and break slide-budget
arithmetic.
"""
if not self.builds:
return self
steps = [b.step for b in self.builds]
expected = list(range(len(steps)))
if sorted(steps) != expected:
raise ValueError(
f"slide {self.n}: build steps must be contiguous starting "
f"at 0; got {steps}, expected {expected}",
)
if steps != expected:
raise ValueError(
f"slide {self.n}: build steps not in ascending order: {steps}",
)
return self
# ── Shared cross-field checks ────────────────────────────────────────
def _check_beat_slide_refs(
chapters: list["Chapter"],
slides: list["Slide"],
) -> None:
"""Reject argument_beat slide_refs that point at a non-existent slide.
Shared by `Outline` (full) and `PartialOutline` (narrative phase). When no
slide is authored yet, the slide set is empty, so any ref is invalid — beats
must leave slide_refs empty until Phase 3 wires them to real slides.
"""
slide_ns = {s.n for s in slides}
for c in chapters:
for beat in c.argument_beats:
for ref in beat.slide_refs:
if ref not in slide_ns:
hint = (
" — no slides are authored yet; leave slide_refs "
"empty until Phase 3"
if not slide_ns
else ""
)
raise ValueError(
f"chapter '{c.id}' argument_beat slide_ref {ref} "
f"does not match any slide in slides[]{hint}",
)
# ── Top-level outline ────────────────────────────────────────────────
def deck_positions(slides: list["Slide"]) -> dict[int, int]:
"""Each outline slide's 1-based position in the structurally built deck.
The build lays down one deck slide per outline slide, in outline order, so a
slide's position is its rank by ``n``, not ``n`` itself: numbering may start
at 0 or skip values. Every by-index deck pass keys on this, never on ``n``.
"""
return {
slide.n: position
for position, slide in enumerate(sorted(slides, key=lambda s: s.n), start=1)
}
# Outline root schema. v2 adds the optional `style_anchor.footer_style`. Every
# outline carries its root stamp; a v1 outline loads read-only, and only the
# owner's migration (`migrate_outline`) rewrites it.
OUTLINE_SCHEMA_VERSION = 2
FOOTER_STYLE_OUTLINE_SCHEMA_VERSION = 2
_MISSING_STAMP_FIX = (
f"a new outline starts with `schema_version: {OUTLINE_SCHEMA_VERSION}`, and "
"one written before versioning has the schema 1 shape, so add "
"`schema_version: 1` as its first root key and run presentation-creator's "
"`outline_schema.py outline.yaml` to migrate it"
)
def _require_root_stamp(data: object) -> object:
"""Refuse a root mapping without ``schema_version``; there is no default."""
if isinstance(data, dict) and "schema_version" not in data:
raise ValueError(
f"the outline has no root schema_version; {_MISSING_STAMP_FIX}"
)
return data
def _check_outline_version_and_poster(
schema_version: int, style_anchor: "StyleAnchor | None", slides: list["Slide"]
) -> None:
"""Root checks shared by the full and partial views."""
if (
style_anchor is not None
and style_anchor.footer_style is not None
and schema_version < FOOTER_STYLE_OUTLINE_SCHEMA_VERSION
):
raise ValueError(
"style_anchor.footer_style belongs to outline schema "
f"{FOOTER_STYLE_OUTLINE_SCHEMA_VERSION}; set the root schema_version: "
f"{FOOTER_STYLE_OUTLINE_SCHEMA_VERSION}"
)
if (
style_anchor is not None
and style_anchor.composition == Composition.poster_theatrical
):
offenders = [s.n for s in slides if s.image_prompt and s.format.value != "FULL"]
if offenders:
raise ValueError(
"poster-theatrical decks illustrate FULL slides only; slide(s) "
f"{', '.join(map(str, offenders))} carry an image_prompt with "
"another format — set them to FULL, or drop the image_prompt"
)
class Outline(_StrictModel):
# The owner's validation CLI migrates a v1 outline before reading it; the
# loader reads one read-only and never writes.
schema_version: int = Field(strict=True, ge=1, le=OUTLINE_SCHEMA_VERSION)
talk: TalkMetadata
style_anchor: StyleAnchor | None = None
chapters: list[Chapter] = Field(min_length=1)
slides: list[Slide] = Field(min_length=1)
interludes: list[Interlude] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def _root_stamp(cls, data: object) -> object:
return _require_root_stamp(data)
@model_validator(mode="after")
def _version_and_poster_slides(self) -> "Outline":
_check_outline_version_and_poster(
self.schema_version, self.style_anchor, self.slides
)
return self
@model_validator(mode="after")
def _chapter_ids_unique(self) -> "Outline":
ids = [c.id for c in self.chapters]
if len(set(ids)) != len(ids):
raise ValueError(f"duplicate chapter ids: {ids}")
return self
@model_validator(mode="after")
def _chapter_refs_valid(self) -> "Outline":
chapter_ids = {c.id for c in self.chapters}
for s in self.slides:
if s.chapter not in chapter_ids:
raise ValueError(
f"slide {s.n} references unknown chapter '{s.chapter}' "
f"(known: {sorted(chapter_ids)})",
)
for il in self.interludes:
if il.chapter not in chapter_ids:
raise ValueError(
f"interlude '{il.id}' references unknown chapter "
f"'{il.chapter}' (known: {sorted(chapter_ids)})",
)
return self
@model_validator(mode="after")
def _argument_beat_slide_refs_valid(self) -> "Outline":
_check_beat_slide_refs(self.chapters, self.slides)
return self
@model_validator(mode="after")
def _interlude_anchors_valid(self) -> "Outline":
slide_ns = {s.n for s in self.slides}
for il in self.interludes:
if il.after_slide not in slide_ns:
raise ValueError(
f"interlude '{il.id}' anchored after_slide={il.after_slide} "
f"but no slide has that number",
)
ids = [il.id for il in self.interludes]
if len(set(ids)) != len(ids):
raise ValueError(f"duplicate interlude ids: {ids}")
return self
@model_validator(mode="after")
def _slide_numbers_unique_and_ordered(self) -> "Outline":
nums = [s.n for s in self.slides]
if len(set(nums)) != len(nums):
raise ValueError(f"duplicate slide numbers: {nums}")
if nums != sorted(nums):
raise ValueError(f"slide numbers not in ascending order: {nums}")
return self
@model_validator(mode="after")
def _exactly_one_big_idea(self) -> "Outline":
marked = [s.n for s in self.slides if s.big_idea]
if len(marked) != 1:
raise ValueError(
f"expected exactly 1 slide with big_idea=true, found {len(marked)} "
f"(slides: {marked})",
)
return self
@model_validator(mode="after")
def _speaker_attribution_matches_talk_speakers(self) -> "Outline":
speakers = set(self.talk.speakers)
multi = len(speakers) > 1
def _check(label: str, script: list[ScriptItem]) -> None:
for i, item in enumerate(script):
# `line:` items have stricter rules than `parenthetical:` items.
# Cues never carry `speaker:` (already enforced at ScriptItem level).
if item.line is not None:
if multi and item.speaker is None:
raise ValueError(
f"{label} script[{i}]: multi-speaker talk requires "
f"`speaker:` on every line item",
)
if not multi and item.speaker is not None:
raise ValueError(
f"{label} script[{i}]: single-speaker talk must not "
f"attribute lines",
)
elif item.parenthetical is not None:
# speaker on parenthetical is OPTIONAL in multi-speaker
# mode (floating stage directions describe the scene) and
# FORBIDDEN in single-speaker mode (redundant).
if not multi and item.speaker is not None:
raise ValueError(
f"{label} script[{i}]: single-speaker talk must not "
f"attribute parentheticals",
)
if item.speaker is not None and item.speaker not in speakers:
raise ValueError(
f"{label} script[{i}]: speaker '{item.speaker}' "
f"not in talk.speakers={sorted(speakers)}",
)
for s in self.slides:
_check(f"slide {s.n}", s.script)
for il in self.interludes:
_check(f"interlude '{il.id}'", il.script)
return self
@model_validator(mode="after")
def _slide_budget_respected(self) -> "Outline":
total = sum(max(len(s.builds), 1) for s in self.slides)
if total > self.talk.slide_budget:
raise ValueError(
f"slide budget exceeded: {total} slides (builds expanded) > "
f"{self.talk.slide_budget} budget",
)
return self
@model_validator(mode="after")
def _callback_plants_paid(self) -> "Outline":
plants: dict[str, int] = {}
pays: dict[str, int] = {}
for s in self.slides:
for cb in s.callbacks:
bucket = plants if cb.kind == "plant" else pays
bucket[cb.id] = bucket.get(cb.id, 0) + 1
for il in self.interludes:
for cb in il.callbacks:
bucket = plants if cb.kind == "plant" else pays
bucket[cb.id] = bucket.get(cb.id, 0) + 1
unpaid = sorted(set(plants) - set(pays))
orphan_pays = sorted(set(pays) - set(plants))
problems: list[str] = []
if unpaid:
problems.append(f"unpaid plants: {unpaid}")
if orphan_pays:
problems.append(f"pays without plants: {orphan_pays}")
if problems:
raise ValueError("; ".join(problems))
return self
# ── Narrative-phase partial view ─────────────────────────────────────
class PartialOutline(_StrictModel):
"""Talk metadata plus an optional, slide-less narrative scaffold.
Before any slide exists, `narrative.md` renders `talk.tldr` as the TL;DR
plus the `chapters[].argument_beats` arc (fully authored by the end of
Phase 2); once `slides[]` are authored, the full view switches to a
per-slide walk. `extract-narrative.py` renders from this view so the human
can review and approve the narrative during Phases 1–2.
Every present section is still validated by its own field- and model-level
validators (a `slides[]` entry, if present, still runs its build-step and
EXCEPTION checks), and slide_refs are still checked for integrity. What this
view *skips* are the `Outline`-level cross-field validators that presuppose a
complete deck: `chapters`/`slides` `min_length`, the `big_idea` singleton,
slide-budget arithmetic, callback pairing, slide ordering/uniqueness,
chapter/interlude anchoring, and speaker attribution. The full `Outline`
stays the Phase 3+ source-of-truth contract.
"""
schema_version: int = Field(strict=True, ge=1, le=OUTLINE_SCHEMA_VERSION)
talk: TalkMetadata
style_anchor: StyleAnchor | None = None
chapters: list[Chapter] = Field(default_factory=list)
slides: list[Slide] = Field(default_factory=list)
interludes: list[Interlude] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def _root_stamp(cls, data: object) -> object:
return _require_root_stamp(data)
@model_validator(mode="after")
def _version_and_poster_slides(self) -> "PartialOutline":
_check_outline_version_and_poster(
self.schema_version, self.style_anchor, self.slides
)
return self
@model_validator(mode="after")
def _argument_beat_slide_refs_valid(self) -> "PartialOutline":
_check_beat_slide_refs(self.chapters, self.slides)
return self
# ── Loader + CLI guard ───────────────────────────────────────────────
def load_outline(path: Path | str) -> Outline:
text = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(text)
return Outline.model_validate(data)
def load_outline_partial(path: Path | str) -> PartialOutline:
text = Path(path).read_text(encoding="utf-8")
data = yaml.safe_load(text)
return PartialOutline.model_validate(data)
class OutlineVersionError(ValueError):
"""An outline the gate or the migration cannot take as it stands.
Raised for an older or newer schema, a missing or non-integer root stamp,
text that is not UTF-8, and a stamp layout the migration cannot rewrite;
the message names the fix.
"""
# The root `schema_version` key at column 0, bare or quoted; only its value is
# replaced, so an inline comment and the line ending survive.
_ROOT_VERSION_LINE = re.compile(
r"""^(?P<key>schema_version|"schema_version"|'schema_version')"""
r"""(?P<sep>[ \t]*:[ \t]*)(?P<value>[^\s#]+)""",
re.MULTILINE,
)
# A non-owner reader never edits outline.yaml, so every refusal of the file
# itself sends it to the owner instead of naming an edit.
_HANDOFF = (
"this reader never edits outline.yaml. Hand off to presentation-creator, "
"which owns it and migrates or repairs it when it validates the outline, "
"then re-run this command"
)
def _refuse(problem: str, fix: str, *, handoff: bool) -> OutlineVersionError:
return OutlineVersionError(f"{problem}; {_HANDOFF if handoff else fix}")
def _decode(path: Path, raw: bytes, *, handoff: bool) -> str:
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise _refuse(
f"{path} is not UTF-8 text (undecodable byte at offset {exc.start})",
"re-save it as UTF-8 and re-run",
handoff=handoff,
) from exc
def _read(path: Path, *, handoff: bool) -> str:
"""The outline's text; a failed read names the path fix, never an errno."""
try:
raw = path.read_bytes()
except FileNotFoundError as exc:
raise OutlineVersionError(
f"no outline at {path}; pass the path to the talk's existing "
"outline.yaml, then re-run"
) from exc
except IsADirectoryError as exc:
raise OutlineVersionError(
f"{path} is a directory; pass the outline.yaml inside it, then re-run"
) from exc
except PermissionError as exc:
raise OutlineVersionError(
f"cannot read {path}: permission denied; make it readable, then re-run"
) from exc
except OSError as exc:
raise OutlineVersionError(
f"cannot read {path} ({exc.strerror or exc}); check the path and the "
"disk, then re-run"
) from exc
return _decode(path, raw, handoff=handoff)
def _replace_in_place(target: Path, text: str) -> None:
"""Swap ``text`` in for ``target`` atomically, keeping its file mode.
The staging file is unique to this call, so concurrent migrations never
share one, and a failure removes it and leaves the outline unchanged.
"""
try:
mode = stat.S_IMODE(target.stat().st_mode)
handle, name = tempfile.mkstemp(
dir=target.parent, prefix=f".{target.name}.", suffix=".migrating"
)
except OSError as exc:
raise OutlineVersionError(
f"cannot stage the migration beside {target} ({exc.strerror or exc}); "
f"make {target.parent} writable, then re-run. The outline is unchanged"
) from exc
staged = Path(name)
try:
with os.fdopen(handle, "wb") as out:
out.write(text.encode("utf-8"))
os.chmod(staged, mode)
os.replace(staged, target)
except OSError as exc:
staged.unlink(missing_ok=True)
raise OutlineVersionError(
f"cannot replace {target} ({exc.strerror or exc}); make "
f"{target.parent} writable and check free space, then re-run. The "
"outline is unchanged"
) from exc
def _root_version(path: Path, text: str, *, handoff: bool) -> int:
"""The exact integer root stamp; anything else is refused with its fix."""
try:
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise _refuse(
f"{path} is not valid YAML ({exc})",
"fix the YAML and re-run",
handoff=handoff,
) from exc
if not isinstance(data, dict):
raise _refuse(
f"{path} is not an outline: its root is not a YAML mapping",
"restore the outline's root mapping and re-run",
handoff=handoff,
)
if "schema_version" not in data:
raise _refuse(
f"{path} has no root schema_version", _MISSING_STAMP_FIX, handoff=handoff
)
version = data["schema_version"]
# Exact type: YAML 2.0 equals 2 and true equals 1.
if type(version) is not int:
raise _refuse(
f"{path} has root schema_version {version!r}, not an integer",
f"set it to {OUTLINE_SCHEMA_VERSION}",
handoff=handoff,
)
return version
def require_current_outline(path: Path | str) -> None:
"""Refuse an older outline for a non-owner reader; the owner migrates it.
presentation-creator owns outline.yaml. Every other skill reads only the
current schema and treats an older one as no usable prior state: it never
runs the migration or edits the file, and every refusal of the file itself
hands it to presentation-creator.
"""
target = Path(path)
text = _read(target, handoff=True)
version = _root_version(target, text, handoff=True)
if version == OUTLINE_SCHEMA_VERSION:
return
if version > OUTLINE_SCHEMA_VERSION:
raise OutlineVersionError(
f"{path} is outline schema {version}, newer than this speaker-toolkit "
f"reads ({OUTLINE_SCHEMA_VERSION}); update speaker-toolkit"
)
raise OutlineVersionError(
f"{path} is outline schema {version} and this reader needs schema "
f"{OUTLINE_SCHEMA_VERSION}; {_HANDOFF}"
)
def migrate_outline(path: Path | str) -> dict[str, object]:
"""Restamp an older outline at the current schema, as its owner, in place.
v2 only adds an optional field, so the migration is the root stamp: only an
exact integer 1 migrates, its value is rewritten, and nothing else changes,
comments, quoting and line endings included. The result must load at the
current version before it replaces the file atomically; a missing stamp or
a layout this cannot restamp is refused.
"""
target = Path(path)
# Bytes in, bytes out: universal-newline reads would rewrite CRLF files.
text = _read(target, handoff=False)
version = _root_version(target, text, handoff=False)
if version == OUTLINE_SCHEMA_VERSION:
return {"ok": True, "path": str(target), "migrated": False, "to": version}
if version != 1:
raise OutlineVersionError(
f"{target} has root schema_version {version!r}; only schema 1 "
f"migrates to {OUTLINE_SCHEMA_VERSION}"
)
existing = _ROOT_VERSION_LINE.search(text)
migrated = (
text[: existing.start("value")]
+ str(OUTLINE_SCHEMA_VERSION)
+ text[existing.end("value") :]
if existing
else text
)
loaded = yaml.safe_load(migrated)
if not existing or loaded.get("schema_version") != OUTLINE_SCHEMA_VERSION:
raise OutlineVersionError(
f"{target} keeps its root schema_version in a layout the migration "
f"cannot restamp; set it to {OUTLINE_SCHEMA_VERSION} by hand"
)
PartialOutline.model_validate(loaded)
_replace_in_place(target, migrated)
return {"ok": True, "path": str(target), "migrated": True, "from": 1, "to": 2}
def _os_failure(path: str, exc: OSError) -> str:
return (
f"FAIL: cannot access {path} ({exc.strerror or exc}); check that the path "
"names the talk's outline.yaml and that it and its directory are readable "
"and writable, then re-run"
)
def main(argv: list[str]) -> int:
args = argv[1:]
if args[:1] == ["--migrate"]:
if len(args) != 2:
print("usage: outline_schema.py --migrate <outline.yaml>", file=sys.stderr)
return 2
try:
print(json.dumps(migrate_outline(args[1])))
except (yaml.YAMLError, ValidationError, OutlineVersionError) as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 1
except OSError as exc:
print(_os_failure(args[1], exc), file=sys.stderr)
return 1
return 0
emit_json = "--emit-json" in args
args = [a for a in args if a != "--emit-json"]
if len(args) != 1:
print(
"usage: outline_schema.py [--emit-json] <outline.yaml> | "
"--migrate <outline.yaml>",
file=sys.stderr,
)
return 2
try:
# Plain validation is the owner's read, so an older outline is migrated
# before it is consumed; --emit-json serves other skills, which refuse
# an older outline and never write.
if emit_json:
require_current_outline(args[0])
else:
migration = migrate_outline(args[0])
if migration["migrated"]:
print(
f"migrated {args[0]} from outline schema {migration['from']} "
f"to {migration['to']}",
file=sys.stderr,
)
outline = load_outline(args[0])
except (yaml.YAMLError, ValidationError, OutlineVersionError) as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 1
except OSError as exc:
print(_os_failure(args[0], exc), file=sys.stderr)
return 1
if emit_json:
json.dump(outline.model_dump(mode="json"), sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(
f"OK: {len(outline.slides)} slides across {len(outline.chapters)} chapters",
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv)).tessl-plugin
rules
skills
cfp-writer
references
illustrations
presentation-builder
presentation-creator
references
patterns
build
deliver
prepare
scripts
presentation-publisher
screencast-editor
screencast-recorder
shownotes-publisher
vault-clarification
vault-ingress
references
scripts
vault-profile