Deck Review Skill
Help startup founders strengthen their pitch decks before sending them to investors. Produce a structured, scored review with specific, actionable recommendations grounded in current best practices from Sequoia, DocSend, YC, a16z, and Carta data. The tone is founder-first: a candid coaching session, not a VC evaluation.
Skill Execution Model (READ FIRST)
See founder-skills/references/skill-execution-model.md for the full inline-skill execution model (3 dispatch contexts, Mitigation 1+2, producer contract, Cowork quirks, per-symptom triage).
This skill runs inline in the main thread, not as a sub-agent — see the reference above ("Why Inline (Not Forked Sub-Agent)") for the rationale. Sub-agents are deliberately shell-free, so orchestration (producer scripts, artifact persistence) stays in the main thread.
Two dispatch contexts for the sub-agent:
- Context A — Per-step analytical dispatch (Mitigation 1): Steps 4 and 5 dispatch the deck-review agent via the
Task tool. The agent does deep analysis, WRITES its output JSON to the OUTPUT_PATH given in its prompt (the handoff/ dir), and returns a small receipt. The main thread gates the file with check_handoff.py, then pipes it through the producer script (slide_reviews.py or checklist.py). The sub-agent never writes canonical artifacts — only its hand-off file.
- Context B — Post-compose coaching dispatch: Step 7 dispatches the sub-agent after
compose_report.py writes report.md. The sub-agent Reads the staged coaching_payload.json from the hand-off dir (Mitigation 2) — it does NOT Read the full report.md — composes the coaching commentary, WRITES it to the OUTPUT_PATH hand-off file, and returns a small receipt. The main thread gates the file (check_handoff.py) and inserts it via the shared insert_coaching.py script (idempotency matrix, uuid-marker replacement, run_id-parity verification — all deterministic). See the reference above for the full Context B contract.
Tolerant JSON extraction protocol (Context B returns; also the Context A message-channel fallback): capture the sub-agent's final assistant message. It should be raw JSON, but may be wrapped in ```json ... ``` fences or carry a prose preamble. Extract tolerantly:
- If the message is wrapped in a
```json ... ``` (or plain ``` ... ```) fence, strip the fence first.
- Try to parse the stripped text directly as JSON.
- If that fails, walk through the text looking for the first
{ character and try json.JSONDecoder().raw_decode(text[i:]) — this is brace-aware and handles nested objects correctly (unlike regex, which truncates on the first }).
- If extraction fails entirely, re-prompt the sub-agent with: "Your previous reply could not be parsed as JSON. Return ONLY the JSON object — no markdown fences, no prose preamble."
Context A receipts don't need this protocol by hand — check_handoff.py --receipt-json - applies the same tolerant extraction internally; pass the final message verbatim.
Input Formats
Accept any format: PDF, PowerPoint (PPTX/PPT), markdown, or text descriptions of slides.
PowerPoint is converted to PDF at ingestion (Step 2) so the slides can actually be seen;
without a converter the review degrades to text-only and says so.
Artifact Pipeline
Every review deposits structured JSON artifacts into a working directory. The final step assembles all artifacts into a report and validates consistency. This is not optional.
| Step | Artifact | Producer |
|---|
| 1 | founder context | founder_context.py read/init |
| 2 | deck_inventory.json | deck_inventory.py (agent provides JSON via stdin) |
| 3 | stage_profile.json | stage_profile.py (agent provides JSON via stdin) |
| 3.5 | ledger.json | ledger.py (agent provides JSON via stdin) |
| 3.6 | second_read.json | a ledger-blind re-read of the figure-bearing slide text |
| 3.8 | reconciliation.json | reconcile.py (agent proposes relations via stdin) |
| 4 | slide_reviews.json | slide_reviews.py (agent provides JSON via stdin) |
| 5 | checklist.json | checklist.py |
| 6 | Report | compose_report.py (writes both report.json and report.md) |
Rules:
- Deposit each artifact before proceeding to the next step
- For producer-script artifacts (Steps 2-4, including 3.5 and 3.8), the agent supplies JSON on stdin and the script schema-validates against
references/schemas/<artifact>.schema.json. Never write artifacts directly via Write or Edit — always pipe through the producer script so metadata.run_id is injected and the schema is enforced.
- If a step is not applicable, deposit a stub:
{"skipped": true, "reason": "..."}
Keep the founder informed with brief, plain-language updates at each step. Narrate the founder-visible OUTCOME, never the internal step. That is the test, and it catches more than a word list can: the forbidden thing is not a syntax, it is talking about the machinery. Bad — "Gating and piping the extraction through the producer, then staging the coaching hand-off"; good — "I've checked your numbers and I'm writing up what stood out." Never name an internal artifact, field, or token (a payload key, a marker name, an artifact filename, a hand-off dir) even in plain prose with no backticks — a detector keyed on syntax cannot see "gated", "hand-off" or "canonical artifacts", but the founder still reads them and they mean nothing to them. The between-step progress lines are the primary leak vector, not the final summary. Say the outcome of each transition: "Reading your deck slide by slide", "Your figures line up — moving on to the slide review", "Finishing up and putting the report together". Also excluded: file/script names, paths, *.py, --flags, $vars, exit codes ("Exit N", "not found"), W_/E_ codes, JSON, and step/route labels ("Lane N", "Context A/B", "Phase N", "structure detection", "the grid", any ALL_CAPS_TOKEN). After each analytical step (4–5), share a one-sentence finding before moving on. The task tracker is founder-visible too — the same rule governs its labels. "Gate the slide-review handoff" is a leak even though it names a real step, and even when the prose around it is clean. Label each task by the founder-visible outcome — "Check your inputs", "Score against the review", "Write up what I found" — never by a file, directory, script, or pipeline stage.
Workflow
Step 0: Path Setup
Every Bash tool call runs in a fresh shell — variables do not persist. A stale reference does not error, it silently expands to empty (a path quietly becomes /inputs.json). Run the block below exactly once: it resolves $PLUGIN_ROOT deterministically, and every later block must substitute the printed value as a literal rather than re-running the resolution — repeating the self-heal search can land on a different mount than Step 0 picked when more than one is present (see why in the block's comments). RUN_ID is the run_id the start call below prints, and nothing else. Read the printed values out of each Bash call's output (PLUGIN_ROOT and ARTIFACTS_ROOT here, run_id from start, then review_dir/resume/gate_action after Step 1) and paste them as literals into every subsequent block; do not carry a variable forward and assume it survived.
Optional, best-effort, and via the Read tool (not a shell command): before the block below, Read ${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json and note its version field as EXPECT_VERSION. Passing it to select_plugin_root.py below lets an exact version match win over an arbitrary first hit. If the Read fails, skip it and omit --expect-version — selection is still deterministic without it. Skip it if that path still begins with $.
Run the block below with ${CLAUDE_PLUGIN_ROOT} exactly as it appears.
IFS= read -r TEXT_ROOT_RAW <<'EOF'
${CLAUDE_PLUGIN_ROOT}
EOF
SCRIPTS="${CLAUDE_PLUGIN_ROOT}/skills/deck-review/scripts"
case "$TEXT_ROOT_RAW" in *'$'*|'') SCRIPTS="" ;; esac # text arrived unfilled: ignore any exported value
if [ ! -d "$SCRIPTS" ]; then
# In Cowork, CLAUDE_PLUGIN_ROOT is a host path absent inside the VM. Collect EVERY
# candidate mount (a session can have several) and let select_plugin_root.py pick one
# deterministically — never trust `find`'s first hit, which mixes plugin versions.
ours() { python3 -c 'import json, sys
for c in sys.stdin.read().splitlines():
try: n = json.load(open(c.rsplit("/skills/", 1)[0] + "/.claude-plugin/plugin.json"))["name"]
except Exception: n = "founder-skills"
if n == "founder-skills": print(c)'; }
CANDIDATES="$(find /sessions -type d -path '*/skills/deck-review/scripts' 2>/dev/null | ours)"
[ -n "$CANDIDATES" ] || CANDIDATES="$(find /root/.claude/plugins -type d -path '*/skills/deck-review/scripts' 2>/dev/null | ours)"
[ -n "$CANDIDATES" ] || CANDIDATES="$(find / -type d -path '*/skills/deck-review/scripts' 2>/dev/null | ours)"
PROVISIONAL_ROOT="$(printf '%s\n' "$CANDIDATES" | head -1)"
PROVISIONAL_ROOT="${PROVISIONAL_ROOT%/skills/*}"
# Bootstrap order: $SHARED_SCRIPTS isn't known until a root is chosen, so use the
# provisional root's OWN copy of the selector; an older plugin copy without one
# falls back to the provisional root unchanged.
SELECTOR="$PROVISIONAL_ROOT/scripts/select_plugin_root.py"
if [ -f "$SELECTOR" ]; then
if [ -n "$EXPECT_VERSION" ]; then
PLUGIN_ROOT="$(printf '%s\n' "$CANDIDATES" | python3 "$SELECTOR" --expect-version "$EXPECT_VERSION")"
else
PLUGIN_ROOT="$(printf '%s\n' "$CANDIDATES" | python3 "$SELECTOR")"
fi
else
PLUGIN_ROOT="$PROVISIONAL_ROOT"
fi
SCRIPTS="$PLUGIN_ROOT/skills/deck-review/scripts"
fi
PLUGIN_ROOT="${SCRIPTS%/skills/*}"
echo "PLUGIN_ROOT=$PLUGIN_ROOT" # resolved ONCE, here — paste this literal into every later block; never re-run this resolution. PLUGIN_ROOT is the shell's path: never Read from it or put it in a sub-agent prompt. Exception: a READ_ROOT= printed below goes in Reads and prompts.
SHARED_SCRIPTS="$PLUGIN_ROOT/scripts"
# PREFLIGHT: the run STOPS if this prints (the skill was served without its plugin). Test for
# check_handoff.py, never a /mnt/skills path: a working first-message session shows that path too.
[ -f "$SHARED_SCRIPTS/check_handoff.py" ] || echo "UNSUPPORTED_ENVIRONMENT: the plugin's shared scripts are not reachable from here"
[ -f "$SHARED_SCRIPTS/check_handoff.py" ] && case "$TEXT_ROOT_RAW" in
*'$'*|'') echo "PATH_STATE=literal"; echo "READ_ROOT=$PLUGIN_ROOT" ;;
/sessions/*) echo "PATH_STATE=local" ;;
*) if [ -d "$TEXT_ROOT_RAW" ]; then echo "PATH_STATE=substituted"; else echo "PATH_STATE=local"; fi ;;
esac
# Resolve the artifacts root via the SCRIPT, never inline bash: an inline computation gets
# paraphrased into outputs/ one run and outputs/artifacts/ the next, desyncing find_artifact.py.
python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" # prints ARTIFACTS_ROOT — use the printed path verbatim as ARTIFACTS_ROOT in every later block (a captured var dies in the next fresh shell)
If the preflight line printed UNSUPPORTED_ENVIRONMENT, stop here. This environment serves the
skill without the rest of its plugin: the shared scripts are missing and sub-agents cannot be
dispatched, so the gates, the hand-off checks and the independent review steps below cannot run.
Tell the founder, in one sentence, that this skill needs Claude Cowork or Claude Code and that you
have not run it — then stop. Do not improvise the missing steps: an analysis that grades itself and
reviews itself reads exactly like one that was checked, which is the failure this stop exists to
prevent.
Plugin folder for Reads and prompts (as loaded): ${CLAUDE_PLUGIN_ROOT} — this skill's references are in ${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/. If Step 0 printed READ_ROOT=, use that value instead.
Plugin paths. If Step 0 printed READ_ROOT=, this skill's text arrived without its plugin folder filled in. Use that value in place of ${CLAUDE_PLUGIN_ROOT} in every Read and sub-agent prompt, including where a later step says to leave that path literal. If it did not print READ_ROOT=, use the paths as shown. The folder comes from Step 0's filesystem search, not from a skill file or the "Base directory" line, which can name a folder that does not exist. These are setup details: updates to the founder are about their company, not file locations, printed paths or plugin versions.
Outputs mount is append-only. Everything under the promoted outputs mount (.../mnt/outputs/, not just $REVIEW_DIR) is write-allowed; by this skill's rule, not a platform limit, nothing there is deleted, since a removed file may be one the founder or a later step still needs: never rm, move away, or empty anything under it — including files you created yourself (scripts remove only their own files, e.g. setup_run.py --clean its earlier-run checkpoints). Never create ad-hoc scratch anywhere under the outputs mount (no _src/ copies, no run-state note files); scratch belongs in $STAGING_DIR (a /tmp dir, defined below). Do not "clean up" the outputs folder before delivering — extra working files there are expected and harmless. One exception: Step 2's block may put the deck (its PDF rendering, or a copy) in this run's hand-off folder, the one place the Read tool reaches it; that is working data, never a deliverable.
There is no quick-check lane here, and that is deliberate. The 35 criteria are scored from per-slide sub-agent reviews — those reviews ARE the work, so dropping them leaves only the checklist scaffolding. So when the founder asks a small
conversational question, do not improvise an answer from your own reasoning under this skill's name —
an unproduced score is exactly the output a founder over-trusts. Instead, say up front what the
full run costs and let them choose: "Answering that properly means running the full deck review — it takes
about 15–25 minutes and produces a scored report across all 35 criteria. I can run it now, or if you just want my read without the
scoring, say so and I'll answer outside the deck review." Naming the trade-off is honest; quietly
substituting the cheap version is not.
Then start the run's record, once, copying every FS_HOST_ line of the request between the markers. RUN_ID is the run_id it prints; resume: 1 needs nothing special. Any non-zero exit here or from setup_run/bind below: say in one sentence that the review could not start, and stop. Exit 12 from any script: ask nothing; say what the run is waiting for, and stop. (bind printing RUN_REFUSED is the founder's decline: follow gate_action stop.)
python3 "<printed PLUGIN_ROOT>/scripts/run_status.py" start --skill deck-review --artifacts-root "<printed ARTIFACTS_ROOT>" <<'FS_HOST_EOF'
<each FS_HOST_ line of the request>
FS_HOST_EOF
After Step 1 (when the slug is known) — substitute $SLUG below with the company slug from Step 1's printed JSON, then call setup_run.py to resolve REVIEW_DIR, detect whether this is a resume, and clean stale state in one atomic step. Always call setup_run.py with --clean and --run-id "$RUN_ID"; do not pre-read gate_state.json yourself. setup_run.py decides resume vs. fresh by comparing the answered gate_state.json's run_id against --run-id, and on a fresh (non-resume) run it deletes a stale answered gate_state.json so a prior completed run cannot be misread as a resume:
python3 "$SCRIPTS/setup_run.py" \
--artifacts-root "$ARTIFACTS_ROOT" \
--slug "$SLUG" \
--run-id "$RUN_ID" \
--clean \
--pretty &&
python3 "$SHARED_SCRIPTS/run_status.py" bind --run-id "$RUN_ID" --artifacts-root "$ARTIFACTS_ROOT" \
--run-dir "$ARTIFACTS_ROOT/deck-review-$SLUG" --slug "$SLUG"
Read review_dir, run_id, resume, reuse_checkpoints, gate_id, gate_action, and gate_answer from the JSON printed by the previous Bash command. gate_action is what to do next — branch on it, not on the answer string. It is one of:
gate_action | what it means |
|---|
continue | the founder confirmed; proceed |
continue_if_rebuilt | they said "proceed anyway"; rebuild the profile at low confidence FIRST, then proceed |
rebuild | an intermediate answer (Different stage, or a stage_choice pick): rebuild and re-emit the confirmation gate — this run is not finished asking |
stop | declined (by the request when declined_by: request, else the founder). Stop. Produce nothing. |
reask | no usable answer; emit the gate gate_id names (about gate_stage, when printed) |
Read gate_id too when you act on the answer: "Seed" means one thing on stage_choice and nothing at all on the others, and the answer string alone cannot tell you which gate you are resuming. Substitute REVIEW_DIR with the review_dir value, RUN_ID with the run_id value, and IS_RESUMING with 1 if resume is true, else empty, in every subsequent bash block. Then:
# Context A hand-off dir — PER RUN: sub-agents WRITE their raw output JSON here (audit trail,
# pre-validation; never a canonical artifact). The $RUN_ID segment is load-bearing — it stops a
# stale prior-run file passing the hand-off gate when a dispatch fails to write.
HANDOFF_DIR="$REVIEW_DIR/handoff/$RUN_ID"
mkdir -p "$HANDOFF_DIR"
# FILE-TOOL PATHS (Cowork only; skip when ARTIFACTS_ROOT does not start with /sessions/). File tools
# refuse a RELATIVE path and cannot open /sessions/... paths. FIRST, with the Write tool, write
# "<HOST_OUTPUTS>/artifacts/.host-outputs-probe" containing exactly <HOST_OUTPUTS>: the file-tool path
# your context maps to /sessions/<id>/mnt/outputs/ (the "Paths in bash differ from what file tools
# see" list; no such list -> that /sessions/.../mnt/outputs path itself). Then prove it (quoted; it may
# contain spaces). Exit 0 = proven. 2/4/5 -> fix per its JSON, retry once, then STOP:
python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" --set-host-outputs-dir "<HOST_OUTPUTS>"
# Sub-agents address the SAME dir by a different path (the absolute file-tool path
# proven above). Resolve agent-namespace paths via the script — never hand-splice the printed
# root with a literal skill/slug/run-id string, which is the non-determinism it exists to remove:
python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" --handoff-dir-agent \
--dir-name "deck-review-${SLUG}" --run-id "$RUN_ID" # prints HANDOFF_AGENT verbatim
HANDOFF_AGENT="<printed value>" # use verbatim in OUTPUT_PATH lines
# Sub-agent READ paths for under-outputs artifacts use the SAME agent namespace (absolute once proven;
# a relative or /sessions/... read is refused):
python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" --analysis-dir-agent \
--dir-name "deck-review-${SLUG}" # prints the dir in the agent namespace
REVIEW_DIR_AGENT="<printed value>" # e.g. stage_profile.json, deck_inventory.json reads
# Ad-hoc scratch (NOT hand-off) lives OUTSIDE the outputs/ tree, where it is safe to create and
# reclaim. Use the printed path verbatim in later steps.
# Scratch output (a redirect or a temp file) goes in $STAGING_DIR, never a fixed /tmp/<name>: /tmp is shared across sessions.
STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/deck-review-${SLUG:-deck}.staging.XXXXXX")"
A run resumed through FS_HOST_RUN_ID is already bound, so setup_run.py keeps gate_state.json, deck_inventory.json and stage_profile.json (same-run checkpoints); an answer the request carried is applied when the gate is emitted.
Pass RUN_ID to every producer script via --run-id. Producer scripts inject it into metadata.run_id automatically. compose_report.py enforces that all required artifacts share the same run_id and emits a MISSING_METADATA (high) warning for any artifact without one. Keeping RUN_ID stable across the gate is what prevents a STALE_ARTIFACT mismatch with the pre-gate artifacts.
When reuse_checkpoints is true: --clean kept this run's checkpoints. Skip Steps 2 and 3 if both deck_inventory.json and stage_profile.json exist and their metadata.run_id matches $RUN_ID; otherwise re-run them with the same RUN_ID.
Read reuse_checkpoints, not resume, for this decision — they are different questions and they do come apart. resume says the gate may be skipped; reuse_checkpoints says the artifacts on disk are this run's. A same-run gate answered without a recorded source yields resume: false with reuse_checkpoints: true: ask the founder again, but keep what Steps 2-3 already produced. Apply the same rule to Steps 3.5-3.8: skip them when reconciliation.json exists with a matching metadata.run_id. It is the most expensive stretch of the pipeline — three dispatches, two of which read the deck — and re-running it on a gate round-trip spends that twice for an identical result.
Step 1: Read or Create Founder Context
Take the company's name from the founder's message, or from the deck's title slide when you can already read it, and add --expect-company "<that name>" to this read (omit it when neither names one); a context stored for another company then asks which (Exit 10).
python3 "$SHARED_SCRIPTS/founder_context.py" read --artifacts-root "$ARTIFACTS_ROOT" --run-id "$RUN_ID" --skill deck-review --pretty
Exit 0 (found): Use the company slug and pre-filled fields. Proceed to Step 2.
Exit 1 (not found: no code, or CONTEXT_NOT_FOUND): Open the four questions (the block below) first. Do NOT mention this check or its exit status to the founder; if you narrate anything first, say only "Let me grab a few basics about the company." Then skim the attached deck (title slide, footer, contact block; do NOT write any artifact yet): a PDF with the Read tool at the attachment's file-tool path, never the shell's; a PowerPoint deck from python3 "$SHARED_SCRIPTS/pptx_to_text.py" "<deck path>"'s printed output, since Read refuses PowerPoint to derive candidate values. Then use AskUserQuestion (NOT plain chat) to ask for company name, stage, sector, and geography, pre-filling each question's first option with the deck-derived value (company name from the title slide; sector/geography from deck signals such as customer names, currency, phone country codes), labeled as read from the deck; keep free-form options for correction. If AskUserQuestion is genuinely unavailable in the host, do NOT skip the ask and do NOT assume the answer: ask the same question in plain chat, state the options explicitly, and wait for an answer before continuing. If the deck yields no signal for a field, ask as normal. Deriving some of the four does not license skipping the ask. Treat them independently: a deck that evidences company, stage and sector but says nothing about geography leaves you asking for geography — not filling it in and moving on. Never record a value the materials do not evidence, and in particular never read geography off a currency symbol ($ is also CAD, AUD and SGD, and founders everywhere price in USD) or off where the team used to work (an ex-Stripe engineer is not a US company). Geography is saved for later reviews, and a financial model review grades against it, so a silent guess there is not a small one.
Stage is the exception to deck-derived pre-filling — it has a real fixed label set, not a value to read off a slide.
Options: Pre-seed / Seed / Series A / Series B+
→ pre-seed | seed | series-a | series-b (founder_context.py's VALID_STAGES has 7 values including series-c/series-d/later; on a Series B+ pick, ask a plain-text follow-up rather than defaulting to series-b). This is founder_context.py's company-stage field, distinct from the deck-scope --rebuild-stage enum the later Gate uses (pre_seed/seed/series_a/series_b/growth, at :377 below) — the two do not share a value set. Provide at least 2 options. Stage is re-confirmed later by the Gate, so Step 1's stage answer is a prior, not a commitment.
Each question is a recorded gate: open, ask, answer. Open the four before asking; ask from the printed needs_input (fill a label_template slot such as <name> from the deck); record each reply by running the answer_command it printed. A Series B+ reply opens the follow-up (ctx_stage_detail): ask and record it the same way. <!-- gate: ctx_stage_detail -->
python3 "$SHARED_SCRIPTS/record_gate_answer.py" open --run-id "$RUN_ID" --artifacts-root "$ARTIFACTS_ROOT" --gate ctx_basics.company_name \
--gate ctx_basics.stage --gate ctx_basics.sector --gate ctx_basics.geography
Then create (exit 10 names a question not yet recorded: ask it, record it, run init again):
python3 "$SHARED_SCRIPTS/founder_context.py" init \
--company-name "Acme Corp" --stage seed --sector "B2B SaaS" \
--geography "US" --artifacts-root "$ARTIFACTS_ROOT" \
--run-id "$RUN_ID" --skill deck-review
Exit 10 (which company): ask which from the printed needs_input, record it with its answer_command, then re-read with --slug; A different company → as Exit 1. <!-- gate: ctx_select_company --> Exit 1 with another code: report it and stop.
Execution checkpoint — END OF STEP 1, READ BEFORE CONTINUING
You now have enough to run. Invoking this skill is not the same as running it. From here, every
number that reaches the founder must come out of a producer script. Concretely:
- Never compute a figure in chat. Not TAM, not runway, not a ratio, not a benchmark comparison —
not even one you are confident about. An in-chat number has no provenance, no range, no artifact, and
nothing downstream can contradict it. That is worse than a slow answer and worse than no answer.
- Never benchmark against a figure you recalled. Benchmarks live in the reference files and the
producers read them. If you find yourself writing "typically around X for this stage", stop: either a
producer sourced it or it does not go in front of the founder.
- A what-if, a sensitivity illustration, or "roughly what would X give" is NOT an exemption. This is
the exemption a live run invented: having correctly produced the real figure, it then wrote "using the
current count would shave TAM to roughly €249M rather than €270M" — a second number, computed in chat,
from an input the founder never gave. An illustrative figure is read exactly as confidently as a
computed one, and the founder cannot tell which came from the pipeline. Two ways to answer a what-if:
re-run the producer with the alternate input and quote its output, or give no number and say
which direction it moves. Never arithmetic in prose.
- Never offer the real run as an opt-in after answering. "Here's a rough estimate — I can run the
full analysis if you want" is the failure. The founder cannot tell that what they just read was not
the analysis, so they will not ask for it.
- Two ways to finish, and only two: run the full pipeline to completion, or run the full pipeline after stating its cost up front (there is no quick lane here). Both end
with real artifacts on disk. Anything else is not a finished run.
- If you are blocked, say BLOCKED and say why. A missing input, a failed hand-off, an unreadable
document — name it and stop. Do not substitute your own reasoning for the pipeline and present the
result as its output.
Artifact existence is the proof of execution: if no canonical artifact was written, the skill did not
run, whatever the transcript says.
Step 2: Ingest Deck -> deck_inventory.json
A question in this step is a recorded gate, named at its site: open it first (record_gate_answer.py open --run-id "$RUN_ID" --artifacts-root "$ARTIFACTS_ROOT" --gate <gate>), ask from its needs_input, and record the reply with its answer_command. Stop the review ends the run: stop and say so.
Ingestion pitfalls — common issues that degrade review quality:
- PDF image-only slides: Some PDFs embed slides as images with no extractable text. If Read returns blank or garbled content, note
input_quality: "image_only" in deck_inventory.json and base the review on visual description + OCR-level best effort. Flag reduced confidence in coaching commentary.
- PPTX speaker notes vs. slide content: Speaker notes often contain the real narrative; slide text is abbreviated. Extract both — notes go into
content_summary, slide text into headline. Do not discard notes.
- Multi-file submissions: Founder sends v1 + v2, or deck + appendix as separate files. Ask which is the primary deck before proceeding. <!-- gate: dr_primary_deck --> Do not merge or review both simultaneously.
- Partial decks: Deck has fewer than 5 slides or is clearly a subset. Proceed but set
confidence: "low" in stage_profile and note the limitation. Missing-slides detection still runs normally.
- Wrong file type: File named
.pdf but is actually a Word doc or image. If Read fails, try alternate format before asking the founder for a re-upload. <!-- gate: dr_input_request.wrong_file_type -->
When the deck is image-rendered, deck_inventory IS the canonical text. For a PDF whose
slides are images, Read returns page images and there is no extracted text to inline — so build
the per-slide record here once (headline / content_summary / visuals) and inline THAT, verbatim
and identically, everywhere a dispatch below asks for the deck's text. Do not let each dispatch
re-transcribe: LEDGER_EXTRACTION and SECOND_READ are the two halves of one corroboration, and
two different transcriptions make the second read a second read of a different deck — which
weakens the check silently instead of failing it.
Find the deck before anything else — do not assume it is missing. An attached file is
already on disk under the uploads mount; nothing tells you its name up front, so list it:
python3 "<printed PLUGIN_ROOT>/scripts/resolve_artifacts_root.py" --uploads # prints UPLOADS_DIR, or exits 3
On exit 0, ls -la <printed UPLOADS_DIR>. Measured: on one run the agent never looked, replied "I don't
see a pitch deck attached", and stopped — with the deck sitting in the uploads mount the whole
time. Only ask the founder to upload after that listing actually comes back empty. Set DECK_SRC
to the file you find. On exit 3 nothing was printed, so there is nothing to list: no uploads folder
was found for this session (nothing has been attached yet, or this host keeps uploads elsewhere).
If the request already names the deck's path, use it; otherwise ask the founder to attach the deck
or give its path, rather than reporting it missing. <!-- gate: dr_input_request.no_uploads_mount --> Never
hand-build this path — a relative ./mnt/uploads resolves against the shell's cwd, which has
already moved once underneath us.
A link is not a dead end, and it is not a licence to scrape. When the listing comes back empty
(or the resolver exited 3) and the founder gave a link instead, fetch it ONCE. A public export returns the slides and the
review proceeds normally from there. What comes back is often a login, consent or password page
instead — that is where this stops: say BLOCKED, name the link as gated, and ask for a PDF export or
the slides as text. <!-- gate: dr_input_request.gated_link --> Do not try to authenticate, and do not fetch twice. The condition is what the
fetch RETURNED, never that the input was a link: refusing every link would refuse the public ones
that work.
Convert a PowerPoint deck to PDF before reading it. Design & Readability are scored from
what a reader SEES, and only a rendered page gives you that. The block tries LibreOffice, then
Keynote on macOS, each only where it is installed. Today .pptx/.ppt are binary
and Read refuses them outright, so without this the slides are invisible — but do not treat
that as the reason: if some future Read does open PowerPoint, still convert unless it returns
actual page images, because text and structure without layout cannot support a design score.
Do this FIRST, before reading anything. Substitute the uploaded deck's path for <deck path>:
DECK_SRC="<deck path>"
HANDOFF_DIR="<REVIEW_DIR>/handoff/<RUN_ID>"; HANDOFF_AGENT="<printed HANDOFF_AGENT>"
DECK_READ="$DECK_SRC"; CONVERTER=""; SOFFICE=""; B="$(basename "$DECK_SRC")"
case "$DECK_SRC" in
*.pptx|*.PPTX|*.ppt|*.PPT)
DECK_READ="no-converter"
# Into the hand-off folder, which Read reaches; never a temp folder (refused on a local session).
PDF_OUT="$HANDOFF_DIR/${B%.*}.pdf"
# Two converters, tried in this order and each ONLY when it is installed: LibreOffice
# on any OS, then Keynote on macOS. A host with neither takes the text-only path below
# unchanged.
for c in libreoffice soffice /Applications/LibreOffice.app/Contents/MacOS/soffice \
"/c/Program Files/LibreOffice/program/soffice.exe" "${PROGRAMFILES:-}/LibreOffice/program/soffice.exe"; do
command -v "$c" >/dev/null 2>&1 && { SOFFICE="$c"; CONVERTER="libreoffice"; break; }
done
if [ -z "$CONVERTER" ] && [ -d /Applications/Keynote.app ]; then CONVERTER="keynote"; fi
case "$CONVERTER" in
libreoffice)
# -env:UserInstallation is REQUIRED: $HOME is read-only, so profile creation
# dies (exit 77) having converted nothing. Do not suppress errors — a silent
# failure is indistinguishable from having no converter, and misreports why.
LO_PROFILE="$(mktemp -d "${TMPDIR:-/tmp}/lo-profile.XXXXXX")" # this block's own: a fresh shell
"$SOFFICE" --headless -env:UserInstallation="file://$LO_PROFILE" \
--convert-to pdf --outdir "$HANDOFF_DIR" "$DECK_SRC" 2>&1 | tail -3
;;
keynote)
# Keynote imports PowerPoint and exports PDF. `launch` first: a Keynote left running
# headless by an earlier attempt answers every document command with "doesn't
# understand" (-1708) and only quitting it cures that — measured on a real run.
# So this block QUITS on both exits. Without that, the first conversion leaves the
# exact headless Keynote behind that breaks the second, and the founder is told a
# converter failed.
# Keynote runs only in a Mac's own shell, where Read takes the shell's paths: export to a
# temp folder, so it is never asked to write into a user folder (a macOS access prompt).
PDF_OUT="$(mktemp -d "${TMPDIR:-/tmp}/keynote.XXXXXX")/${B%.*}.pdf"
osascript - "$DECK_SRC" "$PDF_OUT" <<'KEYNOTE_EOF' 2>&1 | tail -3
on run argv
tell application "Keynote"
launch
open (POSIX file (item 1 of argv))
delay 5
if (count documents) is 0 then
quit
error "Keynote opened nothing; retry"
end if
export document 1 to (POSIX file (item 2 of argv)) as PDF
close document 1 saving no
quit
end tell
end run
KEYNOTE_EOF
;;
esac
if [ -s "$PDF_OUT" ]; then
case "$PDF_OUT" in "$HANDOFF_DIR"/*) DECK_READ="$HANDOFF_AGENT/${B%.*}.pdf" ;; *) DECK_READ="$PDF_OUT" ;; esac
elif [ -n "$CONVERTER" ]; then
DECK_READ="convert-failed"
fi
;;
*)
# A local session's Read tool is refused the shell's path to the upload: Read a copy instead.
if [ "$HANDOFF_AGENT" != "$HANDOFF_DIR" ]; then
if cp "$DECK_SRC" "$HANDOFF_DIR/"; then DECK_READ="$HANDOFF_AGENT/$B"; else DECK_READ="copy-failed"; fi
fi
;;
esac
echo "$DECK_READ"
Then branch on what it printed:
- A path — read THAT path with the Read tool's
pages parameter, exactly as for any PDF
(it is the Read tool's path, not the shell's), and for a PowerPoint deck set input_format to "pptx". The slides are now genuinely visible, so the Design &
Readability criteria are scored normally.
copy-failed — the deck could not be put where the Read tool reaches it; the error printed
above. Never Read the shell's path instead: say BLOCKED, name the error, and ask for the deck again. <!-- gate: dr_input_request.copy_failed -->
convert-failed — a converter exists and broke; its error printed just above. Report
that error verbatim when you tell the founder what happened, then take the same fallback
as no-converter below. Do not retry blindly.
no-converter (or convert-failed) — you cannot see the slides, so do not review them as if you could. Run
python3 "$SHARED_SCRIPTS/pptx_to_text.py" "$DECK_SRC" --pretty and read the JSON straight
from the command's output — do NOT write it to $STAGING_DIR and Read it back, because
$STAGING_DIR is a /tmp path outside the session and the Read tool refuses it. Then
build the inventory from that (it carries speaker notes, which often hold the real
narrative), and set input_format to "text" — which gates the 4 visual Design & Readability
criteria to not_applicable. Scoring a deck's layout without having seen it is a confident
review of something you never looked at. Tell the founder you read the content but could
not see the design, mention images_not_read if non-zero, and that a PDF gets the full
review. If the script also fails, ask for a PDF re-export and do not proceed. <!-- gate: dr_input_request.pdf_unreadable -->
Read EVERY page, and record whether you actually saw it. Read takes at most 20 pages
per call, so a deck longer than that needs several calls — read pages 1-20, then 21-40, and
so on until the last page. A deck partially read is the failure this record exists to catch:
design criteria are scored from what a reader SEES, and a slide nobody rendered cannot
support that judgement any more than a PowerPoint nobody converted can.
Two fields carry the record, and both are load-bearing rather than bookkeeping:
input_quality (required): "good" when every page rendered and was legible;
"image_only" when slides are pictures with no extractable text; "partial" when any
page went unread. It is required precisely because its absence used to be
indistinguishable from "good" — a review of a deck nobody could read looked identical to
a review of one that was read. A per-slide visual_evidence_captured: false gates them
too, whatever input_quality claims — a slide nobody rendered cannot support a design
judgement. "image_only" and "partial" gate the 4 visual Design &
Readability criteria to not_applicable automatically, the same way "text" does.
visual_evidence_captured (per slide): true when you rendered and saw that slide,
false when you have only its text.
Read the provided deck. For each slide, extract: headline, content summary, visuals description, word count estimate. Also determine ai_company_status using the two sub-questions below. Then write the inventory through the producer script:
AI company classification (mandatory — field is required): Answer two sub-questions:
- Does the deck make an AI claim? (tagline, "AI-native"/"AI-powered" positioning, or AI in the product description)
- Is there evidence AI is core? Use ALL FOUR signals: ML in value prop / inference-or-training in COGS / foundation-model or fine-tuning mentions / AI-specific retention metrics.
Map to ai_company_status:
- Evidence present (any core-AI signal) →
"ai_core"
- AI claim but no core-AI evidence →
"ai_claimed_unverified"
- No AI claim and not AI →
"not_ai"
Record what evidence or claim was found in ai_evidence (required for ai_core and ai_claimed_unverified; brief for not_ai).
claimed_stage holds the stage token the deck itself states (pre_seed, seed, series_a, series_b, growth); optional prior_rounds lists rounds it says were already closed (stage, optional year). If the deck never states a stage, omit the field or set it to null — never invent a descriptive placeholder (a made-up value misfires the stage cross-checks downstream).
claimed_raise, ai_evidence and slides[].visuals are optional: null and omission mean the same thing — the producer normalises an explicit null away before validating, so either spelling is accepted. Prefer omission. A deck that states no ask is a real and notable finding, so say so in the review rather than treating the empty field as the whole story.
cat <<'INVENTORY_EOF' | python3 "$SCRIPTS/deck_inventory.py" --run-id "$RUN_ID" -o "$REVIEW_DIR/deck_inventory.json" --pretty
{
"company_name": "...",
"review_date": "YYYY-MM-DD",
"input_format": "pdf",
"input_quality": "good",
"total_slides": 12,
"claimed_stage": "seed",
"claimed_raise": "...",
"ai_company_status": "...",
"ai_evidence": "...",
"slides": [
{"number": 1, "headline": "...", "content_summary": "...", "visuals": "...", "word_count_estimate": 15, "visual_evidence_captured": true}
]
}
INVENTORY_EOF
The script validates against references/schemas/deck_inventory.schema.json and injects metadata.run_id. Never write deck_inventory.json directly via heredoc — the schema-validation gate is what keeps the pipeline honest.
Step 3: Detect Stage -> stage_profile.json
Determine pre-seed/seed/series-a from signals in the deck. Read ${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/deck-best-practices.md for stage-specific frameworks. Record: detected stage, confidence, evidence, whether AI company, expected slide framework, stage benchmarks.
Stage signals: Pre-seed: no revenue, LOIs/waitlist, prototype, <$2.5M ask. Seed: early ARR, paying customers, <$6M ask. Series A: $1M+ ARR, cohort data, repeatable GTM, $10M+ ask. Later-stage: set detected_stage to "series_b" or "growth" — use the Gate below. Do not ask outside the gate.
AI company note: ai_company_status was determined in Step 2 (deck inventory) and is already in deck_inventory.json. Set is_ai_company in stage_profile.json to true if ai_company_status is "ai_core" or "ai_claimed_unverified", otherwise false. Record the same evidence in ai_evidence.
Then write the profile through the producer script:
cat <<'PROFILE_EOF' | python3 "$SCRIPTS/stage_profile.py" --run-id "$RUN_ID" -o "$REVIEW_DIR/stage_profile.json" --pretty
{
"detected_stage": "seed",
"confidence": "high",
"evidence": ["Claims $2M ARR", "..."],
"is_ai_company": false,
"ai_evidence": "...",
"expected_framework": ["..."],
"stage_benchmarks": {"round_size_range": "...", "expected_traction": "...", "runway_expectation": "..."},
"reference_file_read": ["deck-best-practices.md", "checklist-criteria.md", "artifact-schemas.md"]
}
PROFILE_EOF
Gate: Confirm Stage and Scope
Gate execution model: this skill runs inline in the main thread, so you ask the founder yourself. The gate is still a checkpoint on disk, so that the answer survives a fresh shell or a resumed session: gate_state.py emit writes gate_state.json and prints a structured needs_input payload (below), which is what you ask from. Call AskUserQuestion if available — offering the needs_input options in the ORDER they appear, first to last, and adding no recommendation of your own — or otherwise ask the founder via plain text — then write the answer back into gate_state.json with gate_state.py answer (use exactly this flag shape):
python3 "$SCRIPTS/gate_state.py" answer \
--file "$REVIEW_DIR/gate_state.json" \
--run-id "$RUN_ID" \
--answer "<the founder's chosen option, verbatim>" \
--source founder
then re-run setup_run.py (Step 0) with --run-id set to the RUN_ID literal printed earlier, never an empty or new one: it reports resume: true and the gate_action to branch on (see the resume note under Step 0). (--file, --run-id, --answer, --source; -o/--output are accepted as aliases for --file, and --run-id is checked for parity against the gate's metadata.run_id.) --source is required and says who produced the answer: founder here, because they were asked and replied. (The plain-text round-trip works correctly even without AskUserQuestion.)
How to detect re-invocation: you already did, in Step 1. setup_run.py printed resume, gate_action and gate_answer. If resume was true, skip the gate-emit and jump to "After the gate" below, branching on gate_action (the answer string is context, not the decision). Do not re-read gate_state.json to decide this — resume detection lives in setup_run.py and nowhere else, because it weighs run_id parity and whether the answer records where it came from.
Auto-satisfy branch — the founder already told you the stage in Step 1. If Step 1's AskUserQuestion
captured a stage and the detected stage MATCHES it, do not ask again: write that answer straight through —
gate_state.py emit the gate exactly as below, then immediately gate_state.py answer it with
--answer "Looks right" --source auto_satisfied — and continue to "After the gate". Re-asking a
question the founder answered two minutes ago reads as not listening, and it is the single most common
reason a founder abandons a gated run.
--source auto_satisfied is what makes this branch auditable afterwards. It is accepted only here —
only on the stage_confirmation gate, only for "Looks right" — because any other gate or option is a
decision the founder has not made, and the script refuses it.
Three conditions, all required, and none is optional:
- It must MATCH. If Step 1 says seed and detection says Series A, that disagreement is exactly what
the gate exists to surface — emit it normally and let the founder adjudicate.
- The DECK must agree too. The match above is two-way — what the founder said, and what you
detected — and the deck's own claim is a third party to it. If
deck_inventory.claimed_stage
names a different stage from the one being confirmed, ask: a founder naming their stage from
memory may not know their own deck contradicts it, and this gate is the moment that matters.
gate_state.py answer refuses --source auto_satisfied here, so this is enforced rather than
requested; answer it --source founder once they have been asked.
- There must BE a Step 1 answer. A form the founder skipped, dismissed, or left on its default is
not an answer, and neither is a stage you inferred while Step 1 ran. In each case this branch is
unavailable — emit the gate normally. The ask need not have been a structured form; a plain-text
question they answered counts. What is required is that a founder actually said it.
- Say that you did it. The founder must see "you told me seed, and the deck agrees — proceeding on
that" rather than the step silently vanishing. That sentence is only true when the deck does agree,
which is why it cannot be reached otherwise. A gate that self-answers invisibly is indistinguishable
from a gate that was skipped.
Anything other than a clean match — no Step 1 answer, a mismatch, or low-confidence detection — takes the
normal path below.
Otherwise, write gate_state.json via the producer script and emit a needs_input payload:
cat <<'GATE_EOF' | python3 "$SCRIPTS/gate_state.py" emit --run-id "$RUN_ID" --stage <detected-stage-token> -o "$REVIEW_DIR/gate_state.json" --pretty
{
"gate_id": "stage_confirmation",
"question": "Does this stage detection look right?",
"options": ["Looks right", "Different stage", "Not sure — proceed anyway"],
"context_summary": "Detected stage: Seed (high confidence). Key evidence: $4.2M ARR, 3 paying enterprise customers, $5M raise ask. AI company: Yes — inference costs in COGS. Expected framework: Sequoia seed (12-15 slides). Slides in deck: 14."
}
GATE_EOF
The script schema-validates the body and injects metadata.run_id. Never write gate_state.json directly via heredoc. A refused emit writes nothing: on exit 1, fix the body and re-emit. If it printed answered instead (an answer this run already holds, such as one sent with the request), do not ask and do not answer it: re-run setup_run.py and branch on gate_action.
context_summary must not name any stage other than --stage — including quoting the deck's own claim. The producer refuses it, and states the disagreement itself: it reads claimed_stage from deck_inventory.json and appends (The deck states: X. This review reads it as Y.). A round the deck says was already closed goes in Step 2's prior_rounds, which the producer prints on its own line; a round it is raising now is claimed_stage, never prior_rounds. Write the evidence; let the producer name the stages.
Then ask the founder with AskUserQuestion, using the printed needs_input question and its options verbatim and in the order printed, and do not end your turn on the JSON: it is the record, never a message to paste. Use the needs_input block gate_state.py emit printed, verbatim. Do not retype the question or the options: the canonical options are enforced on the FILE, so a hand-written payload can show the founder a shorter list than the one that was recorded — including one with no way to decline. The printed block's shape:
{
"needs_input": {
"gate_state_path": "<absolute path to gate_state.json>",
"question": "Does this stage detection look right?",
"options": ["Looks right", "Different stage", "Not sure — proceed anyway"],
"context_summary": "..."
}
}
The needs_input block emit prints carries confirmed_stage and a context_summary with the stage appended. Present that summary, not your own — a hand-written one let a founder read "Detected stage: Seed" while the record authorized Series A.
--stage is the stage token this gate is ASKING about (pre_seed/seed/series_a/series_b/growth), and it is required. It binds the founder's answer to the profile it confirms: without it, a gate that confirmed Seed authorised a report graded as Series A after the profile was rebuilt, because nothing connected the two. compose_report.py checks the recorded stage against the composed profile as a TRANSITION, not an equality: an out_of_scope_choice asked about growth and answered Proceed anyway (best-effort) is expected to leave a series_a profile at low confidence, and is refused if it leaves anything else. For the in-scope gates the stage must be unchanged, so after a Different stage rebuild, re-emit the gate with the new stage rather than reusing the old record.
out_of_scope_choice may only be emitted for series_b/growth, and stage_confirmation only for the in-scope three — the script refuses the other way round, because a stage_confirmation about an out-of-scope deck offers no way to decline.
For out-of-scope stages (series_b, growth): use gate_id: "out_of_scope_choice", question "This looks out of scope. What should I do?", options ["Stop review", "Different stage", "Proceed anyway (best-effort)"].
THE ORDER OF THOSE OPTIONS IS THE PRODUCT DECISION, not a formatting detail. Declining leads because a first option reads as the default, and the default for a deck this rubric does not fit is to not grade it. Measured on a live run: the record was written correctly — gate_state.py enforces this exact list, in this exact order, on the file — and the founder was then shown it reversed, with Proceed anyway (best-effort) in the default slot and Stop review last. Every artifact-based check passed, because the artifact was right; the only wrong thing was what a person saw. Reorder nothing, mark nothing "recommended", and do not lead with proceeding.
After the gate (you are resuming on Step 1's gate_action, or you just auto-satisfied): branch on gate_action, not on the answer string — the same answer means different things on different gates, and the transition was already decided for you:
stop — declined. Stop here. Do not run later steps, do not compose a report, and say plainly who asked you to stop (declined_by). compose_report.py refuses this case too, and a decline stays decisive for the rest of the run even if another gate is asked afterwards.
continue — proceed to Step 4 with the detected stage.
continue_if_rebuilt — rebuild the profile at low confidence FIRST (the two branches below say to which stage), then proceed. compose_report.py verifies the rebuilt profile, so skipping it fails the run rather than silently grading the deck as confirmed.
rebuild — an intermediate answer. Act on it below and re-emit the gate; this run is not finished asking.
reask — no usable answer. Emit the gate.
The per-answer detail, for the branch gate_action sent you to:
-
Looks right (continue): proceed to Step 4 with the detected stage.
-
Different stage (rebuild): emit a second gate (gate_id stage_choice) via gate_state.py emit to ask which stage. The candidates, each label with the --rebuild-stage token it maps to: Pre-seed (pre_seed), Seed (seed), Series A (series_a), Series B (series_b), Growth (growth) — that is the complete enum, and anything outside it fails argparse when the answer is rebuilt below. AskUserQuestion renders at most four options, so offer exactly four: the enum minus the stage stage_profile.json currently holds. Reaching this gate means the founder just rejected that stage, so it can never be the answer. On a repeat pass, drop the stage the profile holds NOW, not the one first detected — otherwise you re-offer what they just rejected and hide the one they now want. Never add an explicit Other — the tool supplies one. Treat this as a fresh gate — ask from its printed needs_input the same way. When that one comes back answered, translate the founder's pick to its token and rebuild the profile for the chosen stage at high confidence (the founder explicitly picked it). Then re-emit the gate the CHOSEN stage calls for, not always stage_confirmation: picking series_b or growth puts the deck out of scope, and confirming it through stage_confirmation never offers Stop review — the founder would be told their deck is out of scope by a question that does not let them decline. For those two, emit out_of_scope_choice; for the in-scope three, stage_confirmation:
cp "$REVIEW_DIR/stage_profile.json" "$STAGING_DIR/sp.json"
cat "$STAGING_DIR/sp.json" | python3 "$SCRIPTS/stage_profile.py" \
--rebuild-stage <chosen-token> --confidence high \
--run-id "$RUN_ID" -o "$REVIEW_DIR/stage_profile.json"
-
Not sure — proceed anyway: proceed with the detected stage at low confidence:
cp "$REVIEW_DIR/stage_profile.json" "$STAGING_DIR/sp.json"
cat "$STAGING_DIR/sp.json" | python3 "$SCRIPTS/stage_profile.py" \
--rebuild-stage <detected> --confidence low \
--run-id "$RUN_ID" -o "$REVIEW_DIR/stage_profile.json"
-
Stop review (out-of-scope): exit. Do not run later steps.
-
Proceed anyway (best-effort): rebuild the profile at low confidence with --rebuild-stage series_a --confidence low (same staged-stdin invocation as above).
stage_profile.py requires --run-id and -o, and reads the existing profile from stdin. Stage the current stage_profile.json to $STAGING_DIR/sp.json first and pipe that in — never cat and -o the same file in one command, which races and truncates it.
Context A hand-off protocol (file transport + gate)
Say one of these to the founder, verbatim — nothing else, and nothing about the machinery
in the rest of this section. Every founder-facing leak measured in a live run happened at one
of these transitions and none anywhere else, so the line is supplied rather than composed:
| moment | say exactly |
|---|
| starting the slide reviews | "Reading your deck slide by slide — this takes a minute." |
| starting the checklist | "Scoring it against the 35-criteria rubric." |
| a gate passed, moving on | say nothing — the founder has no stake in it |
| a gate failed, retrying | "Still working on that — one moment." |
Every Context A dispatch prompt carries an OUTPUT_PATH: line built from $HANDOFF_AGENT. The
sub-agent WRITES its output JSON to that path with its Write tool and returns only a small receipt:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}. The payload leaves the model
exactly once (into the Write call) — never re-type sub-agent JSON into a heredoc.
$HANDOFF_AGENT and $HANDOFF_DIR name the SAME directory by two different paths — they are not
interchangeable. $HANDOFF_DIR is the absolute VM path your shell uses (python3, check_handoff.py,
producer pipes). $HANDOFF_AGENT is the absolute file-tool path of the same directory (proven at
Step 0), and it is the ONLY one that goes in a dispatch prompt. Putting $HANDOFF_DIR in an
OUTPUT_PATH line hands the sub-agent an absolute /sessions/... path the host-loop gate denies;
putting $HANDOFF_AGENT in a shell command resolves it against the wrong cwd. Rule of thumb: agent
namespace in prompts, shell namespace in bash.
The receipt is the ONE exemption from the never-re-type rule. "Never re-type" governs the
payload — the extraction JSON, the coaching commentary, anything the founder's numbers pass through.
The receipt is a two-field acknowledgement the sub-agent returns in its final message, and reading
output_path out of it to pass to check_handoff.py --agent-path is expected, not a violation. If it
were forbidden, the hand-off could not be gated at all.
Path idiom for dispatch prompts (host-loop path gate): OUTPUT_PATH and any under-outputs artifact
READ path a sub-agent is given are absolute file-tool paths, never relative ones (a relative
file-tool path is refused) —
built from the resolve_artifacts_root.py --agent namespace ($HANDOFF_AGENT / $REVIEW_DIR_AGENT).
Never hand a sub-agent an absolute /sessions/... path for a file-tool Read/Write — the host-loop path
gate denies it (steering shell work to the bash tool instead). Bundled references/*.md are the one
exception: pass them as the literal ${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/... token (it is
pre-resolved to a host-readable path), never the shell's path to the plugin folder.
After EVERY Context A dispatch, gate before piping (<step> = the dispatch's file stem):
printf '%s' '<agent final message verbatim>' | \
python3 "$SHARED_SCRIPTS/check_handoff.py" "$HANDOFF_DIR/<step>_output.json" \
--agent-path "$HANDOFF_AGENT/<step>_output.json" --receipt-json -
<!-- skill-quality-ci: bash-after-subagent-ok -->
Branch on the exit code (complete state machine — do not improvise):
- Exit 0 → pipe the file through the producer:
cat "$HANDOFF_DIR/<step>_output.json" | python3 "$SCRIPTS/<producer>.py" ...
- Exit 3 (missing/empty file — receipt may be fabricated) → redo-dispatch: fresh Task, same prompt plus one line: "your receipt claimed a file at
<path> but none exists; use Write to create exactly that path."
- Exit 4 (file exists, invalid JSON) → repair-dispatch: fresh Task: "Read
<OUTPUT_PATH>; it fails JSON parsing with <verbatim detail from the diagnostic>; fix and rewrite it; return the receipt."
- Exit 5 (receipt echoes a different path) → repair-dispatch telling the agent the exact expected OUTPUT_PATH (it wrote somewhere else).
- Exit 6 (receipt unparseable / no
output_path key) → redo-dispatch with "return ONLY the receipt JSON — no fences, no prose."
- Producer schema rejection (the pipe fails next) → repair-dispatch with the producer's stderr verbatim.
- Exit 8 (
path_namespace_mismatch) → the sub-agent complied; the agent-namespace prefix was wrong. Its relative OUTPUT_PATH resolved against the outputs mount instead of the session root, so the file landed at the doubled path reported in found_at. Only a RELATIVE agent path can do this, so the Step 0 proof did not run: run it. Do NOT treat this as a fabricated receipt (that is exit 3), and do NOT read the hand-off from found_at — it is diagnostic only. Re-run resolve_artifacts_root.py --agent, rebuild the agent-namespace prefix from the printed value, and re-dispatch. Counts against the same 2-dispatch retry budget.
- Blocked
write_refused (the sub-agent's Write was refused — its path was relative or a /sessions/... path) → run the Step 0 proof if it did not run, rebuild the agent-namespace paths from the resolver, re-dispatch ONCE. A second write_refused STOPs with both details quoted.
- Any other exit (script crash etc.) → STOP with the stderr.
- After ANY corrective dispatch, resume from
check_handoff.py — never pipe to the producer unchecked.
Retry budget: max 2 corrective dispatches per step, of any kind, in any combination (max 3
total dispatches). After the second corrective dispatch fails any gate: STOP and report the exact
diagnostic to the founder. The main thread MUST NOT author or patch analytical content itself —
filling in the JSON is the fabrication failure mode this architecture exists to prevent. A
status: "blocked" return is not a gate retry, but it is bounded: at most ONE input-fix
re-dispatch per step; a second blocked return STOPs with both reasons quoted.
Graceful degrade (fleet heterogeneity): if the FIRST corrective dispatch also exits 3 while the
agent's receipt claims complete with the correctly echoed path, treat the host's filesystem
topology as hand-off-incompatible: fall back to message-channel transport for the REST of this run
(sub-agent returns full JSON in its final message; apply the tolerant JSON extraction protocol;
stage to $STAGING_DIR/<step>_input.json; same producer pipe), and tell the founder in one plain sentence that this run's working files were passed
directly instead of through outputs/, so its audit trail is incomplete (the results are unaffected).
A refused write is NOT this case: it returns write_refused (above).
Retries overwrite the same OUTPUT_PATH (nothing under the outputs mount is deleted, by this skill's rule — never rm
under $REVIEW_DIR). Hand-off files are not canonical artifacts: producers consume them only via
the explicit pipe, and compose_report.py never reads handoff/.
Ad-hoc scratch (NOT sub-agent hand-off) goes to $STAGING_DIR in /tmp, never anywhere under the
outputs mount — see founder-skills/references/skill-execution-model.md.
Step 3.5: Extract the Deck's Numbers -> ledger.json (Context A dispatch)
The deck states numbers. Steps 3.5-3.8 record them, corroborate their quotes against a
ledger-blind second reading, and do the arithmetic the deck implies but never shows. Every figure
a founder sees in the numbers section of the report comes from here.
Dispatch the deck-review sub-agent in Context A (LEDGER_EXTRACTION). Call the Task
tool with subagent_type: "founder-skills:deck-review". Substitute <HANDOFF_AGENT> /
<REVIEW_DIR_AGENT> with the agent-namespace values and <RUN_ID> with $RUN_ID; inline
the deck's full text under DECK: (same idiom as SLIDE_REVIEWS).
Dispatch prompt template:
CONTEXT: LEDGER_EXTRACTION
OUTPUT_PATH: <HANDOFF_AGENT>/ledger_output.json
RUN_ID: <RUN_ID>
You are the deck-review agent dispatched in Context A (LEDGER_EXTRACTION). Record
every number the deck states. Do not compute anything, do not relate figures to
each other, and do not record a number the deck does not state.
DECK:
<inline the deck's full extracted text here — verbatim, all slides>
Record each figure at FULL SCALE. A slide reading "$493K" is 493000, never 493 —
this single error is the one that makes every later calculation wrong by a factor
of a thousand, and `raw` is what it is checked against.
If a trailing letter is a UNIT rather than a multiplier, spell it out in `raw`.
A slide reading "200-400m" of building height is `raw: "200-400 metres"`, not
"200-400m" — bare `m` reads as millions and a space does not help. Same for
`k`, `b` and `t`. You are the only one who can tell these apart; nothing
downstream can.
A count the slide spells out in words is a number the deck states: "three design
partners" is value 3 with raw "three"; "Fifteen years" is 15 with raw "Fifteen years".
Record it with the words as printed — never skip it, never retype it as digits.
Ordinals ("a fourth partner") and fractions are not counts; leave those out.
`quote` must be the VERBATIM sentence or table row the figure was read from. It is
re-found by a ledger-blind reader in the same extracted text; a quote that is not
found there at all — one you composed, summarised, or read off a chart — is dropped.
The match is not word-for-word: it falls back to a similarity ratio, so a close
rewording can pass, and it compares text only and never checks the value. Copy the
wording anyway; that is what makes the check worth running. Keep the words that say
what the number IS: `"Net revenue $493K"`, never `"$493K"`. A quote of nothing but
the figure is matched wherever that figure is printed, on any slide, so it confirms
nothing.
Use your Write tool to write to OUTPUT_PATH exactly the shape expected by
ledger.py (no metadata block; the producer script adds it):
{
"figures": [
{"id": "gmv_2024", "value": 493000, "raw": "$493K", "unit_kind": "money",
"label": "GMV 2024", "slide": 6, "quote": "GMV of $493K in 2024",
"currency": "USD", "period": "year"}
]
}
`unit_kind` must be exactly one of `money`, `count`, `percent`, `multiple`,
`duration`, `date`. `id` must be unique and descriptive; pick a word that is not
ordinary English prose. `currency` is required for money. `period` is required for
any rate ("month", "year") — a monthly figure compared against an annual one is
refused rather than guessed at.
All string values must be JSON-escaped; the file must parse with a strict JSON parser.
Then return ONLY the receipt JSON in your final assistant message:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}
Do NOT write any file other than OUTPUT_PATH.
After the sub-agent returns: gate the hand-off per the Context A hand-off protocol, then pipe:
cat "$HANDOFF_DIR/ledger_output.json" | \
python3 "$SCRIPTS/ledger.py" --run-id "$RUN_ID" --inventory "$REVIEW_DIR/deck_inventory.json" \
-o "$REVIEW_DIR/ledger.json" --pretty
<!-- skill-quality-ci: bash-after-subagent-ok -->
If the pipe fails, the ledger disagreed with itself — most often a figure recorded at the
wrong scale. The error names the figure. Re-dispatch with the correction; do not hand-edit
the file.
Step 3.6: Re-Search the Extracted Text, Ledger-Blind -> second_read.json (Context A dispatch)
A quote is trusted when it is re-found by a second reader who never saw the ledger.
Checking the ledger's quote against the ledger itself would prove nothing.
Dispatch a FRESH sub-agent in Context A (SECOND_READ) — call the Task tool with
subagent_type: "founder-skills:deck-review". Give it the slide numbers and
nothing else. It must not receive the ledger, any figure from it, or any summary of it —
the independence is the whole value, and it is a property of this prompt.
Read $REVIEW_DIR/ledger.json and collect the distinct slide values. Only those slides
are transcribed; a full re-read of every slide costs the founder time and money for pages
that carry no figures.
Dispatch prompt template:
CONTEXT: SECOND_READ
OUTPUT_PATH: <HANDOFF_AGENT>/second_read_output.json
RUN_ID: <RUN_ID>
You are the deck-review agent dispatched in Context A (SECOND_READ). Copy out the
slides listed below from the extracted deck text supplied under DECK, verbatim.
Include every number, label, axis value, table cell and footnote it contains.
You are NOT re-reading the original file: this is the same extracted text the ledger
agent worked from. What your pass can establish is that a quote exists in that text —
not that the extraction matched the slide.
SLIDES TO COPY OUT: <comma-separated slide numbers>
DECK:
<inline the deck's full extracted text here — verbatim, all slides>
Do not summarise, do not interpret, do not correct anything that looks wrong, and do
not omit a figure because it seems minor or duplicated.
Use your Write tool to write to OUTPUT_PATH:
{
"slides_transcribed": [6, 7, 11],
"transcript": "Slide 6: ... Slide 7: ... Slide 11: ..."
}
Then return ONLY the receipt JSON in your final assistant message:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}
Do NOT write any file other than OUTPUT_PATH.
After the sub-agent returns: gate the hand-off, then copy it into place:
cat "$HANDOFF_DIR/second_read_output.json" | \
python3 -c 'import json,sys; d=json.load(sys.stdin); d.setdefault("metadata",{})["run_id"]=sys.argv[1]; json.dump(d,open(sys.argv[2],"w"),indent=2)' \
"$RUN_ID" "$REVIEW_DIR/second_read.json"
<!-- skill-quality-ci: bash-after-subagent-ok -->
If this copy fails, treat it as a bad hand-off and repair-dispatch (same branch as exit 4).
reconcile.py consumes second_read.json unconditionally, so a missing or malformed one takes
down the numeric chain that slide_reviews.py --reconciliation gates on.
Step 3.7: Propose Which Figures Relate (Context A dispatch)
Dispatch the deck-review sub-agent in Context A (RELATION_PROPOSAL) — call the Task
tool with subagent_type: "founder-skills:deck-review". Choosing which
figures belong together is judgment and stays with the model; the arithmetic is not, and
does not.
Dispatch prompt template:
CONTEXT: RELATION_PROPOSAL
OUTPUT_PATH: <HANDOFF_AGENT>/relations_output.json
RUN_ID: <RUN_ID>
You are the deck-review agent dispatched in Context A (RELATION_PROPOSAL). Read the
figures at <REVIEW_DIR_AGENT>/ledger.json and propose which of them relate to each
other arithmetically.
DO NOT CALCULATE ANYTHING. You choose the operands and the operator; a script does
the arithmetic, applies scale and currency rules, and decides what it means. A number
you compute here is not checked by anything and will not be used.
Propose a relation whenever the deck implies one an investor would run:
- a rate the deck states AND states the parts of (revenue over volume vs a stated
take rate; spend over customers vs a stated CAC)
- a total the deck states AND lists the components of
- a runway, a multiple, or a per-unit figure the deck states and the inputs to
- **two dated magnitudes plus a growth multiple or CAGR between them** — a market
slide stating a size now, a size in N years, and a "4X" or a rate is three
claims about the same two numbers, so propose `end / start` against the stated
multiple. This is the slide founders paste third-party
research onto and rarely re-check, and every operand is printed rather than
inferred.
- a characterisation the numbers support that the deck never states at all
Where the deck states a figure your relation should reproduce, name it as
`expected_id`. That is what turns a calculation into a finding: a computed number
that disagrees with a figure the deck itself states is established, not judged.
Use your Write tool to write to OUTPUT_PATH:
{
"relations": [
{"kind": "derived_ratio", "operator": "ratio",
"operands": ["revenue_2024", "gmv_2024"], "expected_id": "take_rate"}
]
}
`kind` must be exactly `contradiction` or `derived_ratio` — nothing else. A sum of
components is `derived_ratio`; the name refers to how the comparison is framed, not to
the operator, and a value invented to fit the operator is rejected.
`operator` must be one of `ratio`, `product`, `sum`, `increase_by`, `difference`,
`runway`, `inverse_change`, `implied_base`.
- `runway`: cash ids, then burn ids. Given a burn plan, never divide by one year's burn:
add `"schedule": [{"id": "<burn id>", "from": "YYYY-MM", "to": "YYYY-MM"}]` (`to`
inclusive; only the last segment omits it) and optional `"start": "YYYY-MM"`.
- `inverse_change`: one percent operand, `"direction": "reduction"` or `"increase"`, against
the stated change in its reciprocal (only resistivity/conductivity,
resistance/conductance, cost per unit/units per dollar).
- `implied_base`: a money figure, then the percent of a whole it is; no `expected_id`.
Two optional fields go with `expected_id`. `"relation": "at_most"` says the deck states a
**ceiling** the other figures must not exceed — a production capacity, a budget, a headcount
cap — rather than a target they should match; the default is `equals`. `"per": "year"` says
the two operands are snapshots one year apart, so their difference is an annual rate; it is
checked against the years the figures carry and ignored when they do not bear it out. Use
them together for "the plan needs more per year than the line can build".
`operands` are `id` values from the ledger, in order — for `ratio`, numerator first.
`expected_id` is optional and omitted when the deck states no counterpart.
Then return ONLY the receipt JSON in your final assistant message:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}
Do NOT write any file other than OUTPUT_PATH.
Step 3.8: Reconcile -> reconciliation.json
Gate the Step 3.7 hand-off, then run the arithmetic:
cat "$HANDOFF_DIR/relations_output.json" | \
python3 "$SCRIPTS/reconcile.py" --ledger "$REVIEW_DIR/ledger.json" \
--second-read "$REVIEW_DIR/second_read.json" --inventory "$REVIEW_DIR/deck_inventory.json" \
--run-id "$RUN_ID" -o "$REVIEW_DIR/reconciliation.json"
<!-- skill-quality-ci: bash-after-subagent-ok -->
status records what happened, and all three outcomes are legitimate:
checked — quotes were corroborated and relations computed.
no_figures — the deck states too few numbers to relate anything. Refused if the
deck's own text is full of numerals, because an empty ledger is the cheapest way to skip
this work and must not be indistinguishable from a wordless deck.
gate_failed — too little of the ledger survived the second read. The review
continues; the numbers section does not appear.
${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/schemas/reconciliation.schema.json answers what suppressed means and which verdicts reach a founder (contradiction and derived only) — read it rather than inferring from the counts.
Do not report a contradiction to the founder from this step. Step 6 renders them, once,
from the artifact.
Say exactly one of these, and nothing more: with no contradiction and no
exceeds_stated_limit relation, "Your figures line up." With one or more of either,
"I found a couple of things in your numbers — I'll detail them in the report." Read both
verdicts: a plan running past a limit the deck states is a finding, and saying the figures
line up in front of one is false. Name no figure, no count, and no slide before Step 6 — a described limit
("at most one plain sentence") produced a mid-pipeline line that gave both contradictions
with their figures, pre-empting the render.
Step 3.9: Review the Disagreements Before Showing Them (Context A dispatch)
Run this only when reconciliation.json reports a contradiction or rounding_gap. With neither,
interpretation.status is already not_needed and there is nothing to review. An
exceeds_stated_limit relation is NOT reviewable here — the interpretation pass may withdraw
only those two — so a run whose findings are all of that kind skips this step.
Arithmetic can be right about a comparison that should never have been made. Two cases
recur, both of which a founder would rightly reject: a sum of listed components against a
stated total the deck never claimed the list exhausted, and a gap inside what the author's
own "~" was meant to cover. Neither is decidable by a rule — the first is a question about
how the slide was laid out, the second about how loose an approximation was meant to be.
This pass may only withdraw. It cannot add a finding, change a number, or turn a
disagreement into an agreement.
Build the contradiction list with the engine rather than by hand — it prints each one with
the exact operator, operand ids and expected_id that a withdrawal must name, plus the quote
and slide of every figure involved. Retyping those is how a withdrawal comes back matching
nothing, which fails the step:
python3 "$SCRIPTS/reconcile.py" --print-downgrade-stanza "$REVIEW_DIR/reconciliation.json" \
--ledger "$REVIEW_DIR/ledger.json"
Paste that output verbatim under CONTRADICTIONS: below. An empty array is printed when
there is nothing to interpret, and that is a complete and correct answer.
Dispatch the deck-review sub-agent in Context A (INTERPRETATION) — call the Task tool
with subagent_type: "founder-skills:deck-review".
Dispatch prompt template:
CONTEXT: INTERPRETATION
OUTPUT_PATH: <HANDOFF_AGENT>/interpretation_output.json
RUN_ID: <RUN_ID>
You are the deck-review agent dispatched in Context A (INTERPRETATION). Below are
comparisons the arithmetic found to disagree with a figure the deck itself states.
Your job is to withdraw any that should not be put to the founder as a disagreement.
An entry whose verdict is `rounding_gap` is a total its parts reach only through their
rounding; the same two grounds apply to it.
Withdraw a comparison ONLY when one of these is true:
partial_enumeration the deck lists some components and states a total, but never
says the list is complete. A breakdown is not a claim of
exhaustiveness. Look at how the slide presents them: a total
row directly under contiguous rows in one table is a claim;
two items named in prose is not.
approximate_stated_figure the deck marked its own figure approximate ("~3.5%", "about
40") and the computed value sits inside what that
approximation plausibly covers.
Withdraw NOTHING else. In particular, do NOT withdraw a comparison because you think
the relation was set up wrongly — a deck that writes "400%" where it means four times
has made exactly the kind of imprecision an investor's analyst catches, and that is a
finding, not a mistake in the comparison.
When in doubt, keep it. A disagreement left in is reviewed by the founder, who knows
their own deck; one withdrawn here is never seen by anyone.
CONTRADICTIONS:
<the printed stanza: each entry carries its rendered line, operator, operand ids,
expected_id, and the quote and slide of every figure involved>
Use your Write tool to write to OUTPUT_PATH:
{
"downgrades": [
{"operator": "sum", "operands": ["f37", "f38"], "expected_id": "f39",
"class": "partial_enumeration",
"reason": "one sentence, in plain words, naming what the deck does not claim"}
]
}
Address each comparison by its exact `operator`, `operands` and `expected_id` as given
above — a downgrade that matches nothing is rejected, and the step fails. An empty
`downgrades` array is a complete and correct answer.
Then return ONLY the receipt JSON in your final assistant message:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}
Do NOT write any file other than OUTPUT_PATH.
After the sub-agent returns: gate the hand-off, then re-run the same reconcile command
with the withdrawals supplied. It is deterministic and rewriting the artifact for this run
is intended — the withdrawal is an input to the decision about what a founder sees, never
an edit to the decision's output.
cat "$HANDOFF_DIR/relations_output.json" | \
python3 "$SCRIPTS/reconcile.py" --ledger "$REVIEW_DIR/ledger.json" \
--second-read "$REVIEW_DIR/second_read.json" --inventory "$REVIEW_DIR/deck_inventory.json" \
--downgrades "$HANDOFF_DIR/interpretation_output.json" \
--run-id "$RUN_ID" -o "$REVIEW_DIR/reconciliation.json"
<!-- skill-quality-ci: bash-after-subagent-ok -->
If it fails, a withdrawal named a comparison that does not exist or gave a reason outside
the two above. The error names which. Re-dispatch with the correction; the previous
artifact is untouched and still valid.
Step 4: Review Each Slide -> slide_reviews.json (Context A dispatch)
Dispatch the deck-review sub-agent in Context A (SLIDE_REVIEWS). Do not do the slide analysis yourself in the main thread — dispatch it. Call the Task tool with subagent_type: "founder-skills:deck-review" and the prompt below, so the analysis runs in the scoped agent (its tools: allowlist binds; a type-less dispatch falls back to the wildcard general-purpose agent).
Before dispatching, substitute placeholders in the template below: replace <HANDOFF_AGENT> and <REVIEW_DIR_AGENT> with the agent-namespace values (from resolve_artifacts_root.py --agent — absolute file-tool paths, proven at Step 0; NOT absolute /sessions/... paths, which the host-loop gate denies), <RUN_ID> with $RUN_ID, and inline the deck's full text under DECK: (the sub-agent needs verbatim figures — do not point it at an upload path). Leave the ${CLAUDE_PLUGIN_ROOT}/... reference paths literal — they are pre-resolved to a host-readable path. The sub-agent has no access to your shell variables.
Dispatch prompt template:
CONTEXT: SLIDE_REVIEWS
OUTPUT_PATH: <HANDOFF_AGENT>/slide_reviews_output.json
RUN_ID: <RUN_ID>
You are the deck-review agent dispatched in Context A (SLIDE_REVIEWS). The deck's
full text is inlined below under DECK. Read the stage profile at
<REVIEW_DIR_AGENT>/stage_profile.json. Compare each slide against the stage-specific
framework and non-negotiable principles from the two files below.
If a reference path below cannot be read (refused or not found), read the same file under the plugin folder your own instructions name.
${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/deck-best-practices.md and
${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/checklist-criteria.md.
DECK:
<inline the deck's full extracted text here — verbatim, all slides>
For each slide: identify strengths, weaknesses, and specific recommendations.
Map to expected framework. Flag missing expected slides. Every critique must
cite a specific best-practice principle. **Attribute each principle to its SOURCE
by name — YC, Sequoia, DocSend, a16z, Carta — never by our reference filename.**
A filename in `best_practice_refs` reaches the founder's report. When you reference deck figures, quote
them verbatim from the deck content — do not paraphrase or round numbers,
percentages, dates, or named metrics.
Use your Write tool to write to OUTPUT_PATH exactly the shape expected by
slide_reviews.py (no metadata block; the producer script adds it):
{
"reviews": [
{"slide_number": 1, "maps_to": "...", "strengths": ["..."],
"weaknesses": ["..."], "recommendations": ["..."],
"best_practice_refs": ["..."]}
],
"missing_slides": [
{"expected_type": "...", "importance": "important", "recommendation": "..."}
],
"overall_narrative_assessment": "..."
}
`importance` must be exactly one of `critical`, `important`, `nice_to_have`
(underscores only).
Say two deck figures disagree only when <REVIEW_DIR_AGENT>/reconciliation.json
lists that disagreement, or the deck writes one quantity two ways (quote both). A
metric and its inverse, a part of a later total, or a figure labelled conservative
is not a disagreement.
All string values must be JSON-escaped (`\n` for line breaks, `\"` for embedded
quotes); the file must parse with a strict JSON parser.
Then return ONLY the receipt JSON in your final assistant message:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}
Do NOT write any file other than OUTPUT_PATH — you never write a canonical
artifact; anything else you write bypasses schema validation and
run_id stamping.
After the sub-agent returns: gate the hand-off per the Context A hand-off protocol, then pipe:
cat "$HANDOFF_DIR/slide_reviews_output.json" | \
python3 "$SCRIPTS/slide_reviews.py" --run-id "$RUN_ID" -o "$REVIEW_DIR/slide_reviews.json" \
--reconciliation "$REVIEW_DIR/reconciliation.json" --pretty
<!-- skill-quality-ci: bash-after-subagent-ok -->
Step 5: Score Checklist -> checklist.json (Context A dispatch)
Dispatch the deck-review sub-agent in Context A (CHECKLIST). Call the Task tool with subagent_type: "founder-skills:deck-review" and the prompt below. Substitute <HANDOFF_AGENT> / <REVIEW_DIR_AGENT> with the agent-namespace values and <RUN_ID> with $RUN_ID; leave the ${CLAUDE_PLUGIN_ROOT}/... reference path literal (same idiom as SLIDE_REVIEWS).
Dispatch prompt template:
CONTEXT: CHECKLIST
OUTPUT_PATH: <HANDOFF_AGENT>/checklist_output.json
RUN_ID: <RUN_ID>
You are the deck-review agent dispatched in Context A (CHECKLIST).
If a reference path below cannot be read (refused or not found), read the same file under the plugin folder your own instructions name.
Evaluate all
35 criteria from ${CLAUDE_PLUGIN_ROOT}/skills/deck-review/references/checklist-criteria.md
using the deck content (read from <REVIEW_DIR_AGENT>/slide_reviews.json for
reference), the stage profile at <REVIEW_DIR_AGENT>/stage_profile.json, and the
deck inventory at <REVIEW_DIR_AGENT>/deck_inventory.json.
Assess ALL 35 criteria including the 4 AI criteria and the 5 Design & Readability
criteria — do NOT self-gate. `checklist.py` applies deterministic AI-criteria
gating from `deck_inventory.json`'s `ai_company_status` AND deterministic
Design-criteria gating from its `input_format` (forcing Design & Readability to
`not_applicable` when the deck was described in text rather than uploaded as a
file) after you return — assess every criterion regardless of format.
Evidence quality rules:
- Every fail and warn `evidence` MUST include BOTH what this deck actually does (quote or describe the specific slide content) AND the best-practice principle or benchmark it falls short of — the deck observation is not optional.
- Every pass MUST record in `evidence` what was checked.
- not_applicable items MUST include a reason.
- `notes` is the specific change the founder should make — imperative, concrete, particular to this deck, never a restatement of the criterion or a record of what you checked. Required on fail and warn; omit it entirely on pass and not_applicable.
- Before asserting a visual or design element is absent (photos, charts,
diagrams, logos), check the `visuals` field of every relevant slide in
`deck_inventory.json` and cite the slide number(s) you checked in the evidence.
Evaluate every one of these 35 criteria — one item per id, no omissions, no
invented ids. Grouped by category:
Narrative Flow:
- purpose_clear
- headlines_carry_story
- narrative_arc_present
- strongest_proof_early
- story_stands_alone
Slide Content:
- problem_quantified
- solution_shows_workflow
- why_now_has_catalyst
- market_bottom_up
- competition_honest
- business_model_clear
- gtm_has_proof
- team_has_depth
Stage Fit:
- stage_appropriate_structure
- stage_appropriate_traction
- stage_appropriate_financials
- ask_ties_to_milestones
- round_size_realistic
Design & Readability:
- one_idea_per_slide
- minimal_text
- slide_count_appropriate
- consistent_design
- mobile_readable
Common Mistakes:
- no_vague_purpose
- no_nice_to_have_problem
- no_hype_without_proof
- no_features_over_outcomes
- no_dodged_competition
AI Company (score all 4; the producer script gates them after you return):
- ai_retention_rebased
- ai_cost_to_serve_shown
- ai_defensibility_beyond_model
- ai_responsible_controls
Diligence Readiness:
- numbers_consistent
- data_room_ready
- contact_info_present
When you cite deck figures in evidence, quote them verbatim from the deck
content — do not paraphrase or round numbers, percentages, dates, or named
metrics.
Evidence prints VERBATIM in the founder's report, so name the source the way the
founder knows it — never by our filename or a dispatch label. They saw their deck,
not `deck_inventory.json` or `deck-best-practices.md`.
Instead of: "slide_reviews.json shows no competition slide"
Write: "the deck has no competition slide"
State what is true of the DECK.
Use your Write tool to write to OUTPUT_PATH the items array without a summary
(the producer script computes the summary):
{"items": [{"id": "purpose_clear", "status": "pass", "evidence": "...", "notes": "..."}, ...all 35 items...]}
On the four visual Design & Readability criteria add `"verified_by"`: `"measured"` only
when you directly observed the property with a tool that can observe it, `"inferred"` when
you reasoned it from other evidence, `"not_possible"` when you had no way to observe it.
Leaving it off means unstated, which is not the same as measured.
All string values must be JSON-escaped (`\n` for line breaks, `\"` for embedded
quotes); the file must parse with a strict JSON parser.
Then return ONLY the receipt JSON in your final assistant message:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}
Do NOT write any file other than OUTPUT_PATH — you never write a canonical
artifact; anything else you write bypasses schema validation and
run_id stamping.
After the sub-agent returns: gate the hand-off per the Context A hand-off protocol, then pipe through the producer script (with --inventory so the producer applies deterministic AI-criteria AND Design-criteria gating from deck_inventory.json):
cat "$HANDOFF_DIR/checklist_output.json" | python3 "$SCRIPTS/checklist.py" --run-id "$RUN_ID" --pretty \
--inventory "$REVIEW_DIR/deck_inventory.json" \
--reconciliation "$REVIEW_DIR/reconciliation.json" \
-o "$REVIEW_DIR/checklist.json"
--reconciliation scores numbers_consistent from the arithmetic, so keep it here (a what-if rerun omits it: see the What-If rule).
<!-- skill-quality-ci: bash-after-subagent-ok -->
Step 6: Compose Report
python3 "$SCRIPTS/compose_report.py" --dir "$REVIEW_DIR" --pretty \
-o "$REVIEW_DIR/report.json" \
--write-md "$REVIEW_DIR/report.md" \
--gate-state "$REVIEW_DIR/gate_state.json"
Always pass --gate-state, including on a run you believe never gated. An absent file is fine and says nothing; the flag is what lets the report disclose a stage that was confirmed on the founder's behalf rather than by them. Deciding not to pass it is deciding they do not need to know.
compose_report.py writes both report.json and report.md. Do NOT re-write report_markdown from report.json via heredoc — heredoc re-writing can corrupt report.json. Compose owns the file outputs.
High-severity warnings split into two kinds — treat them differently:
- Pipeline-integrity warnings (
CORRUPT_ARTIFACT, MISSING_ARTIFACT, STALE_ARTIFACT, SCHEMA_VIOLATION, MISSING_METADATA, AI_CRITERIA_MISSING, UNSUPPORTED_CHECKLIST_CRITIQUE, CHECKLIST_VALIDATION_FAILED) mean the run itself is broken. Fix the underlying pipeline issue and re-run compose.
- Content findings (
CHECKLIST_FAILURES_CRITICAL, and medium codes like SLIDE_COUNT_EXTREME, STAGE_MISMATCH) are the review's honest verdict about the deck. Report them to the founder as-is — never re-score, re-dispatch, or otherwise make them disappear.
A warning code not named above is still real. Treat it by what it is, never by
silence: fix it and re-run if the run itself is broken, otherwise say what it means
for the founder in plain language. A FOUNDER_TEXT_TOKEN naming an internal FILE is
the one to watch — that text is still in report.md and must be removed before you
hand anything over.
Remove it upstream: re-dispatch the step that produced the text, then re-run compose.
Never sed, Edit, or otherwise hand-edit a composed file. Compose owns its outputs, and a
hand-edit fixes only the surface you touched — measured, it cleaned report.md and left the
same 13 occurrences in report.json. The token almost always enters in a sub-agent's
best_practice_refs, so the upstream step is SLIDE_REVIEWS.
--strict counts content findings too, so use it as a pipeline gate only when the checklist outcome is already known-clean.
Post-write verification: compose_report.py exits 2 if an output is missing or empty. On a non-zero exit, read compose_result.json beside the report for the codes. Exit 10 is a question, not a failure: for the gate its JSON names (blocked_by_gate), a stage gate follows the gate_action table (rebuild first, then emit); any other is recorded with its answer_command, or not-applicable if moot. Then re-run setup_run.py and compose again. Any other non-zero exit: stop and report the exact stderr — do not proceed to Step 7.
Step 7: Post-Compose Coaching Commentary (Context B dispatch — POST_COMPOSE_COACHING)
Say exactly: "Adding the coaching notes." Then say nothing further until the report is
delivered. The paragraphs below are the densest plumbing in the skill, and this step produced
three measured leaks; the supplied sentence replaces composing your own.
Dispatch the deck-review sub-agent in Context B. Call the Task tool with subagent_type: "founder-skills:deck-review" after compose_report.py has successfully written both report.json and report.md.
Mitigation 2 protocol: the main thread reads the structured coaching_payload from report.json and STAGES it as a file in the hand-off dir; the sub-agent Reads it from the agent namespace (a functionally required read, so a wrong prefix fails loudly before anything is written). The sub-agent does NOT Read full report.md — it consumes the staged coaching_payload.json directly, composes the coaching commentary, and WRITES it as plain markdown to the OUTPUT_PATH hand-off file (a .md file) with its Write tool — no JSON, no escaping — returning only a small receipt (the same file transport as Context A — the commentary leaves the model exactly once, into the Write call; the main thread never re-types it). The main thread gates that file with check_handoff.py --format=markdown, transforms it into the JSON transport envelope with md_to_commentary.py (deterministic escaping — json.dumps cannot emit malformed JSON), then pipes it into the shared insert_coaching.py script (idempotency matrix, uuid-marker replacement, run_id-parity verification — all deterministic, unchanged). See the deck-review agent body's "Context B — Post-compose coaching dispatch (POST_COMPOSE_COACHING)" section for the full procedure.
<!-- skill-quality-ci: bash-after-subagent-ok -->
python3 -c '
import json, sys
data = json.load(open(sys.argv[1]))
json.dump(data["coaching_payload"], open(sys.argv[2], "w"), indent=2)
print(json.dumps({"staged": sys.argv[2]}))
' "$REVIEW_DIR/report.json" "$HANDOFF_DIR/coaching_payload.json"
This STAGES the payload as a file and prints only a small receipt.
Never capture it into a shell variable — each Bash call runs in a fresh
shell, so the variable would be unreadable and gone. The sub-agent Reads the staged file from
the agent namespace; the payload is no longer pasted into the dispatch prompt.
A file, not an inlined blob, for two reasons: it makes the dispatch's first act a REQUIRED read
in the agent namespace, so a wrong prefix fails loudly before anything is written (a read the
agent does not need is a read it can skip, so the probe has to BE the payload); and the payload
leaves the model exactly once, where a re-typed JSON blob can be truncated or re-indented into a
different meaning.
Dispatch prompt template (substitute <HANDOFF_AGENT> with the Step-0 agent-namespace value — the same rule as every Context A dispatch; the sub-agent has no shell vars, so paste the printed value):
CONTEXT: POST_COMPOSE_COACHING
OUTPUT_PATH: <HANDOFF_AGENT>/coaching.md
You are dispatched to add coaching commentary to a deck review.
The compose_report.py script has finished. The structured `coaching_payload` has
been STAGED AS A FILE for you — it is not inlined in this prompt.
Read the coaching payload at <HANDOFF_AGENT>/coaching_payload.json.
If that Read FAILS, write NO file and return exactly:
{"status": "blocked", "reason": "handoff_path_unresolvable", "attempted": "<the path you tried>"}
Do not Glob for it, do not guess a different prefix, do not proceed from memory —
a failed Read here means the hand-off prefix is wrong and the main thread must
re-issue the dispatch. Reporting it is the correct outcome.
Follow your agent body's Context B procedure (POST_COMPOSE_COACHING):
1. Compose commentary from the STAGED coaching_payload (failed_items,
warned_items, summary, high_severity_warnings, stage, ai_company_status,
design_gate, numeric_findings). If `design_gate.design_reviewed` is false, that many design
criteria were never assessed — say so rather than writing as though the
deck's look had been judged. Mention a numeric disagreement only if it is in
`numeric_findings`; an empty list does not mean the figures agree.
Do NOT Read the full report.md. Do NOT edit report.md or any canonical artifact.
The commentary is appended to the founder's report, so write it in their language:
never a checklist item id (`STRUCT_03`), a status enum (`major_revision`), a warning
code, or one of our filenames. Say what the finding IS.
Instead of: "The `major_revision` verdict reflects STRUCT_03 failing"
Write: "The deck needs substantial work before it is ready to send"
2. Use your Write tool to write to OUTPUT_PATH exactly the coaching commentary
as plain markdown — do NOT wrap it in JSON, do NOT escape anything (your
Write tool handles newlines and quotes). WITHOUT a '## Coaching Commentary'
heading and WITHOUT the insertion_marker string.
Do NOT write any file other than OUTPUT_PATH — insertion into report.md is the
main thread's job, via the shared md_to_commentary.py + insert_coaching.py scripts.
3. Return:
{"status": "complete", "output_path": "<echo of OUTPUT_PATH>"}
OR, if the payload is unusable (write no file):
{"status": "blocked", "reason": "<specific gap>"}
Stop after returning the receipt JSON. Do not narrate.
After the sub-agent returns: if its final message is a {"status": "blocked", "reason": ...} object, stop and report the reason to the founder — do not run the gate. The one exception is write_refused: run the Step 0 proof if it did not run, rebuild <HANDOFF_AGENT> from the resolver, and re-dispatch once; a second write_refused stops. Otherwise gate the hand-off, then (on gate exit 0) transform and insert deterministically. The commentary leaves the model exactly once (into the sub-agent's Write call) — NEVER re-type the sub-agent's markdown into a heredoc or a python -c argument.
<!-- skill-quality-ci: bash-after-subagent-ok -->
# Step 0 already resolved PLUGIN_ROOT once, deterministically — reuse its printed
# value here rather than re-running the self-heal search in this fresh shell.
SHARED_SCRIPTS="<printed PLUGIN_ROOT>/scripts"
printf '%s' '<agent final message verbatim>' | \
python3 "$SHARED_SCRIPTS/check_handoff.py" "$HANDOFF_DIR/coaching.md" \
--format=markdown --agent-path "$HANDOFF_AGENT/coaching.md" --receipt-json - \
--marker '<EXACT insertion_marker string from report.json coaching_payload>'
On gate exit 0, transform the gated hand-off FILE into the JSON transport envelope and insert (feed the file, never re-type the message):
<!-- skill-quality-ci: bash-after-subagent-ok -->
SHARED_SCRIPTS="<printed PLUGIN_ROOT>/scripts"
python3 "$SHARED_SCRIPTS/md_to_commentary.py" "$HANDOFF_DIR/coaching.md" | \
python3 "$SHARED_SCRIPTS/insert_coaching.py" \
--report "$REVIEW_DIR/report.md" \
--report-json "$REVIEW_DIR/report.json" \
--marker '<EXACT insertion_marker string from report.json coaching_payload>' \
--verify-artifact "$REVIEW_DIR/deck_inventory.json" \
--verify-artifact "$REVIEW_DIR/stage_profile.json" \
--verify-artifact "$REVIEW_DIR/slide_reviews.json" \
--verify-artifact "$REVIEW_DIR/checklist.json" \
--verify-artifact "$REVIEW_DIR/reconciliation.json"
The gate (check_handoff.py --format=markdown) verifies the sub-agent's hand-off file exists, is non-empty, matches the receipt's echoed path, and passes the content-shape gate (not receipt-shaped, no marker collision); md_to_commentary.py wraps the raw markdown in the {"commentary_markdown": ...} envelope (escaping by construction via json.dumps); insert_coaching.py then performs the 6-state idempotency check, replaces the marker with ## Coaching Commentary + the commentary in a single in-place write, and verifies run_id parity across all 4 producer artifacts. Branch on the exit code (complete state machine — do not improvise):
- Exit 0 from the chain —
insert_coaching.py's receipt on stdout says inserted (or already_inserted on a resume). Present report_path to the founder and proceed.
check_handoff.py exit 3 (missing/empty file — receipt may be fabricated) → redo-dispatch: fresh Task, same prompt plus one line: "your receipt claimed a file at <path> but none exists; use Write to create exactly that path."
- Exit 5 (receipt echoes a different path) → repair-dispatch telling the agent the exact expected OUTPUT_PATH.
- Exit 6 (receipt unparseable / no
output_path key) → redo-dispatch with "return ONLY the receipt JSON — no fences, no prose." (A status: "blocked" final message is NOT exit 6 — it was handled before the gate.)
- Exit 7 (content-shape gate failed — receipt-shaped or marker-bearing file) → repair-dispatch: "your file wasn't the coaching commentary — write the coaching markdown, nothing else, to
<OUTPUT_PATH>."
- Exit 8 (
path_namespace_mismatch) → the sub-agent complied; the agent-namespace prefix was wrong. Its relative OUTPUT_PATH resolved against the outputs mount instead of the session root, so the file landed at the doubled path reported in found_at (only a relative path can do this: run the Step 0 proof). Do NOT treat this as a fabricated receipt, and do NOT read the hand-off from found_at — re-dispatch with the corrected agent-namespace prefix (re-run resolve_artifacts_root.py --agent and rebuild <HANDOFF_AGENT> from the printed value). Counts against the same 2-dispatch retry budget.
- Any other exit (script crash, unreadable file, invalid UTF-8) → STOP with the stderr.
check_handoff.py exit 4 is reachable here too: gate 1 already confirmed the file exists and is non-empty, so a failure opening it afterwards is an IO/permission fault, not a malformed hand-off. A decode error raises before any typed exit and surfaces as a traceback.
insert_coaching.py exit 1 (blocked; stdout carries {"status": "blocked", "reason": ...}) → stop and report the exact reason. Do NOT hand-edit report.md — if the reason mentions a truncated report or a missing marker, re-run compose_report.py --write-md and retry the chain. If the reason is commentary_markdown missing or empty, treat as a malformed hand-off: repair-dispatch quoting the reason.
- After ANY corrective dispatch, resume from the gate chain — never feed the transform+insert pipe an ungated file.
Retry budget: max 2 corrective dispatches (same rule as Context A). Graceful degrade: if the FIRST corrective dispatch also exits 3 while the receipt claims complete with the correctly echoed path, treat the host topology as hand-off-incompatible and fall back to message-channel transport, and tell the founder in one plain sentence (see Context A's degrade rule). The corrective dispatch MUST ask for the commentary inline for this to be reachable — add: "the file hand-off is not working in this environment; return the coaching commentary itself as your final message, as raw markdown, with no receipt JSON and no fences." Without that line the fallback is unreachable: the normal Context B prompt instructs the agent to return ONLY the receipt and not to narrate, so its final message contains no markdown to stage. Then stage that returned markdown to $STAGING_DIR/coaching.md via a single-quoted <<'COACHING_EOF' heredoc (apostrophe-safe), and run the same md_to_commentary.py "$STAGING_DIR/coaching.md" | insert_coaching.py chain against that staged file.
Step 8 (Optional): Generate Visual Report
python3 "$SCRIPTS/visualize.py" --dir "$REVIEW_DIR" -o "$REVIEW_DIR/report.html" \
--gate-state "$REVIEW_DIR/gate_state.json" --run-id "$RUN_ID"
--gate-state is required here, as in Step 6. The gate once sat only on compose, so a declined review still produced a complete report.html — the file a founder opens. --ungated is for fixtures.
Do not hand this over here — the Deliver step below is the only place work reaches the founder, and it sends the complete set as files. A path presented here is the partial-delivery bug.
Step 9: Deliver Artifacts
Copy final deliverables to the workspace root — $ARTIFACTS_ROOT/.., i.e. the promoted outputs mount itself, NOT $ARTIFACTS_ROOT and NOT $REVIEW_DIR: {Company}_Deck_Review.md, .html (if generated), .json (optional). Concretely, if $ARTIFACTS_ROOT is <mount>/artifacts then these go to <mount>/. That is the level the founder sees as deliverable cards; artifacts/ below it is working state. Do not infer the level by elimination — dirname "$ARTIFACTS_ROOT" is the answer.
Then close the run's file list (for a host; nothing to tell the founder):
python3 "<printed PLUGIN_ROOT>/scripts/run_status.py" deliverables --run-id "$RUN_ID" --artifacts-root "$ARTIFACTS_ROOT" --final || :
Send the finished work to the founder — the complete set, as files. Not a path, and not a subset.
A path is not a deliverable in Cowork — whether the workspace it names outlives the task depends on how
that task was started, so a founder who was handed only a path may end up with nothing. (That is your
reason for sending files; it is not something to tell the founder — see the no-claims rule below.) Send
every finished document you produced for them, and frame them as results you generated rather than
something they asked to look at.
Then hand them over by name — one named entry per document. Sending the files and handing them
over are different acts: a founder looking at a row of cards cannot tell which document is which.
Write each deliverable into your message as its own named entry — a link where this surface renders
one that opens (on a /sessions session tree, computer:// + the absolute path you just copied it
to), otherwise the label with the path stated beside it — labelled by what the document IS, in the
founder's words (the written report, the charts), not by its filename. "The files are above" is not a
hand-over. Where a step prints the hand-over, the printed entries are these. This does not conflict with the never-name-a-file rule: the founder reads your label,
never the path. Never paste a report's body into the message — link or name it.
Then offer the working data — once, in one sentence. For example: "If you want to keep the working
data behind this — to pick it up later, or feed it into another analysis — say so and I'll send it as a
single archive." Make no claim about whether anything persists, in either direction: that depends on
how the task was started, and it is not something to assert. If a folder is connected to the task, offer
to write the full set there instead.
If the founder says yes, assemble the archive in your working scratch but write the finished file
into the same directory as the deliverables, and send it from there. (Your reason, not something to
tell the founder: a scratch path cannot be handed over at all, and attempting it fails the entire
delivery — taking the real deliverables down with it. The scratch dir stays where it is; only the
finished archive moves.) Include only the reusable
inputs — the validated figures and extractions this analysis was built from, plus the composed report
data. Never include pipeline hand-off files, receipts, coaching payloads, or gate state: they mean
nothing outside the run that made them.
Do not rm anything under $REVIEW_DIR — it is the promoted outputs/ tree in Cowork, where
deleting a user-visible path is unsafe (and the parity gate flags it). Scratch already lives in
$STAGING_DIR (/tmp, reclaimed by the sandbox). The answered gate_state.json is left in place; a
later fresh review of this company is not misread as a resume because setup_run.py --clean deletes a
stale answered gate (run_id mismatch) at the start of the next run.
Gotchas
- "Looks polished" bias: A well-designed deck is not a strong deck. Score content, narrative, and evidence independently of visual quality. The checklist separates design (5 items) from content (8 items) for this reason.
- Template / AI-generated copy: If multiple slides use generic phrasing ("revolutionize," "disrupt," "world-class team") with no specifics, flag this in coaching commentary as a credibility risk — investors notice formulaic decks. This is not a checklist item but affects overall narrative assessment.
- Benchmarks are medians, not gates: A $3M seed round in a $1B TAM market is not automatically wrong — context matters. Use benchmarks from
deck-best-practices.md as reference points, not hard pass/fail thresholds. The coaching commentary should explain deviations rather than penalize them.
- Founder provided text, not a file: When the founder describes slides in conversation rather than uploading a file, adapt: write
deck_inventory.json from the conversation, set input_format: "text", and note reduced confidence in visual/design assessments. This is enforced deterministically, not just by prose: checklist.py reads input_format from deck_inventory.json (the same --inventory flag that drives AI-criteria gating) and forces the 4 visual Design & Readability criteria to not_applicable whenever it is "text" (or whenever input_quality is "image_only"/"partial", or any slide carries visual_evidence_captured: false), regardless of what the sub-agent scored them — slide_count_appropriate stays scored, since a slide count survives a text description. If the founder later shares screenshots, set input_format to whatever format now applies (e.g. pdf) so the gate does not fire and Design gets scored normally.
- Cross-skill context: If
founder_context.py returned prior market-sizing or financial-model-review runs, mention relevant findings in coaching commentary (e.g., "Your market sizing calculated $X TAM — your deck claims $Y"). Do not hard-fail on discrepancies; flag them for the founder.
Main-Thread Return
This skill runs inline in the main thread (not as a sub-agent). The final outcome the main thread delivers to the founder is:
-
In Claude Code: the path to $REVIEW_DIR/report.md — there the path is the deliverable, because
./artifacts/ is durable. In Cowork: the delivered files are the deliverable; a path
names a workspace that may not outlive the task.
-
The headline outcome fields, sourced from the coaching_payload staged in Step 7 (summary.score_pct, summary.overall_status, high_severity_warnings, design_gate) plus the insert_coaching.py receipt (status, report_path, run_id). The Context B sub-agent no longer echoes these — do not source them from its return.
Nesting matters here, and it is mixed — read the path, not the pattern: score_pct and overall_status sit under coaching_payload.summary; reading coaching_payload.score_pct returns null while the real number sits one level down, and a live run did exactly that. But high_severity_warnings is top level — reaching under summary for it returns null too, in the opposite direction.
-
Optionally: the HTML report path from Step 8.
Do NOT inline report_markdown in the assistant message. The founder reads the file via the path — inlining round-trips ~25 KB of markdown through the parent context for no benefit.
Scoring
- Each of 35 items: pass / fail / warn / not_applicable
score_pct = (pass + 0.5 x warn) / (total - not_applicable) x 100 — a warn is partial credit, not a fail
- Overall: "strong" (>=85%), "solid" (>=70%), "needs_work" (>=50%), "major_revision" (<50%)
What-If Recomputation Rule
If the founder asks "what's my score if I fix X" or any score recomputation question: re-run checklist.py starting from the delivered $REVIEW_DIR/checklist.json items (not the reviewer's hand-off, which carries its own reading of numbers_consistent), changing only the hypothetical, with no --reconciliation and output to stdout, never -o (or read the Appendix evidence from report.md) and present the script's output. Never compute a revised score by mental arithmetic in the chat — the formula is non-trivial (a warn earns half credit, a fail none; N/A items are excluded from the denominator) and off-by-one errors cause real harm.
Feedback
If a run ends blocked or failed, after you report the reason to the founder, add one line:
If this looks wrong or didn't finish, you can flag it: type /founder-skills:feedback.
On unsolicited praise or frustration, you may tell the founder they can type /founder-skills:feedback once — never routinely, never mid-workflow, never more than once per session.