CLAUDE.md · git:20261101.53cd815 · 2026-11-01 · sha256 53f32397320e6301

CLAUDE.md git:20261101.53cd815C

Immutable. This exact content never changes and is served at /api/v1/blob/53f32397320e6301.

# CLAUDE.md

> This file is distilled: it keeps the rules and not the measurements behind them. **Those are NOT
> in git history** — `docs/internal/claude-md-archive/` holds the only copy of the pre-distillation
> file, untracked, on one disk. Read it when a rule here states a conclusion and you need what was
> measured to reach it; several were learned by a run that cost money. If that directory is gone, so
> is the reasoning. Cite nothing in this file by SHA: the history has been rewritten and pre-rewrite
> hashes do not resolve. Name the change and grep for the identifier.

## Repository Structure

- `founder-skills/` — Claude Code plugin (SDK/CLI-based)
- `founder-skills/.claude-plugin/plugin.json` — Plugin manifest
- `founder-skills/skills/feedback/` — `/founder-skills:feedback`, a founder-only skill (`disable-model-invocation: true`; runs no shell, so it is exempt by name from the no-disable invariant and left out of the listing-budget sum, since the CLI does not list such a skill to the model) that drafts a bug/idea/help/win report, hands the user a prefilled GitHub/`mailto:` link to submit, and transmits nothing automatically
- `founder-skills/skills/market-sizing/` — Market sizing skill with scripts and references
- `founder-skills/skills/deck-review/` — Deck review skill with scripts and references
- `founder-skills/agents/market-sizing.md` — Market sizing agent definition
- `founder-skills/agents/market-sizing-redteam.md` — RED_TEAM agent: attacks a finished sizing the way a skeptical investor would, dispatched at exactly one step after the math and validation are complete. **The fleet has EIGHT agents, not six** — market-sizing and competitive-positioning each carry a second, red-team agent, and a skill pinning two is why the critique-corpus agent term is a UNION rather than the scalar `agents/<skill>.md`.
- `founder-skills/agents/deck-review.md` — Deck review agent definition
- `founder-skills/agents/competitive-positioning-redteam.md` — competitive-positioning's RED_TEAM agent: attacks a finished positioning analysis. A separate agent because the shared one's body is the scoring rubric the analysis was built with, and a reviewer carrying the builder's rubric inherits the frame it exists to escape.
- `founder-skills/skills/ic-sim/` — IC simulation skill with scripts and references
- `founder-skills/agents/ic-sim.md` — IC simulation agent definition
- `founder-skills/scripts/session-setup.sh` — SessionStart hook (persists CLAUDE_PLUGIN_ROOT; after a compaction runs `compaction_reread.py`)
- `founder-skills/scripts/founder_context.py` — Founder context management (init/read/merge/validate)
- `founder-skills/scripts/find_artifact.py` — Artifact path discovery across skills
- `founder-skills/scripts/insert_coaching.py` — Shared Context B coaching-commentary inserter (idempotency matrix, uuid-marker replacement, run_id parity)
- `founder-skills/scripts/check_handoff.py` — Shared Context A file hand-off gate (typed exit codes for main-thread branching)
- `founder-skills/scripts/merge_json.py` — Shallow-merge of parallel sub-agent hand-off files for producer pipes
- `founder-skills/scripts/md_to_commentary.py` — Wraps a sub-agent's raw-markdown coaching commentary into the JSON envelope `insert_coaching.py` consumes (the model never hand-escapes the commentary)
- `founder-skills/scripts/_founder_text.py` — Shared founder-facing text policy: four token types, three behaviours (humanize private enums + field names; keep stable identifiers and diagnostic codes verbatim). Every `compose_report.py` substitutes then scans with it; `insert_coaching.py` scans the commentary. `identifier_values()` is cap-table-only — its docstring says why.
- `founder-skills/scripts/resolve_artifacts_root.py` — Canonical + agent-namespace artifacts-root resolver (`--agent` for HANDOFF_AGENT derivation)
- `founder-skills/scripts/_gates.py` — The gate registry (every question the six analysis skills ask, with stable gate and option ids) and the per-run answer ledger; `--dump-contract` writes `data/host-contract.json`
- `founder-skills/scripts/_run_status.py` + `run_status.py` — The per-run status file (`runs/<RUN_ID>/run_status.json`), its lock and the run-level writers; the CLI's `start` / `bind` / `finish` / `fail` / `show` / `deliverables`
- `founder-skills/scripts/record_gate_answer.py` — The answer recorder (`open` / `answer` / `default` / `not-applicable` / `require` / `derive` / `list` / `show`)
- `founder-skills/scripts/_form_reply.py` — Desktop question-form reply reader; ships OFF (`FORM_REPLY_ENABLED = False`)
- `founder-skills/scripts/_host_text.py` — Context supplied with the request (`host_context`): the whole-value caps (malformed → the question waits) and the per-item drop causes (`CAUSES`; a dropped item's text is never copied into anything the run writes), plus `keep_tokens` for the founder-text keep-set. ic-sim's `scripts/` carries a byte-identical copy, held by `test_host_context.py`.
- `founder-skills/data/host-contract.json` — The published host contract, generated from `_gates.py` and held equal to it by a test
- `founder-skills/data/script-hashes.json` — The plugin's own files by sha256 (every `.py` outside the top-level `tests/`, agent files by their body), generated by `script_hashes.py --write`, never hand-edited, held equal to the tree by `test_script_hashes.py`; the dispatch-prompt hook checks the plugin against it
- `founder-skills/references/` — Shared reference files (benchmarks, Israel guidance, etc.)
- `founder-skills/references/brand/` — Brand tokens + Sora variable webfont (OFL) for generated HTML artifacts; embedded base64-inline so artifacts stay self-contained
- `founder-skills/tests/test_market_sizing.py` — Market sizing regression tests
- `founder-skills/tests/test_deck_review.py` — Deck review regression tests
- `founder-skills/tests/test_ocr_check.py`, `test_deck_ocr.py`, `test_ocr_gate.py`, `test_ocr_report_line.py` — deck-review Step 3.95: the numeral rule and its pinned residuals, the receipt (stub binaries on an emptied PATH; one real-tool test where tesseract is installed), Step 4's gate, and the founder sentence in both renderers. `slide_review_inputs.py` gives a test that only wants slide reviews the `--ledger` / `--deck-text` pair. The image-only fixture deck is `fixtures/decks/synthetic-image-deck.pdf`, built by `make_synthetic_image_deck.py` beside it
- `founder-skills/tests/test_reconcile.py` — The numeric engine's 60 judgement calls, each pinned to a real corpus line
- `founder-skills/tests/test_reconcile_per_customer.py` — The engine's one-way test of revenue per paying customer against a stated average contract value: the label table that admits a pair, and every shape it must refuse (forecasts, targets, segment ACVs, pipeline counts, another slide, a second ARR on the slide, a bare customer count, an ACV at today's prices, a non-annual revenue, a ceiling), and the refusal of an average deal or contract size with no stated period, which is never compared with revenue per customer (an average deal or contract size over a count of actual closed deals keeps the two-sided comparison unless the numerator is recurring revenue, an ACV over a count of deals is never compared, and a period-less ACV the floor did not take is compared only below revenue per paying buyer)
- `founder-skills/tests/test_reconcile_rate_vs_figure.py` — The engine's admission list for a rate (money, count, a share or a multiple per a count, a time or money) against a stated figure with no period: compared only when the figure is that rate (written per the denominator's own words, in the denominator's time unit, a named rate from a closed table, a deal size over a count of deals, pilot revenue over paid pilots, a period-less ACV that revenue per paying buyer falls below), carrying no parenthetical but a currency, scale or descriptive one, and never over a forecast numerator or one of another recurring period; over a count, never money of another kind than the figure names (spending per employee against revenue per employee), CAC over a count marked as the whole base, or an ARPU-like figure that writes no period over a numerator whose period is known; a stated CAC only over a label from the acquisition-spend list and a count from the new-customer list, and never gross transaction value or bookings against revenue, recurring revenue over units against a selling price, or part of spending against burn per head, each refusal with its exact reason; every refusal row is off-value and forced open by a lever, so it reaches the decision. A share or multiple per anything is never compared
- `founder-skills/tests/test_slide_reviews_contradicted.py` — `slide_reviews.py`'s warn-only `contradicted_strengths`: a strength on a contradicted stated figure's own slide that writes its numerals (spelled small numbers excluded); never changes the artifact or the exit code
- `founder-skills/tests/test_reconcile_producer.py` — The engine's producer layer: the gate, the three statuses, and that only `select()` decides founder-visible output
- `founder-skills/tests/test_reconcile_stage_surface.py` — The engine's stage names, their leading parameters and the `Figure` / `Relation` fields an offline before-and-after comparison runs it by; a rename there is a deliberate edit here too
- `founder-skills/tests/test_ledger.py` — Ledger validation, chiefly the `raw`-vs-`value` scale check
- `founder-skills/tests/test_quote_match_sync.py` — the copies of cap-table's matcher (deck-review, market-sizing, competitive-positioning) must not drift
- `founder-skills/tests/test_ic_sim.py` — IC simulation regression tests
- `founder-skills/tests/test_host_context.py` (the shared gate, its value checks, `run_status.py check`), `test_ic_host_context.py` (ic-sim's producer, compose, the report, founder context, the skill and agent text), `test_ic_host_profile.py` (a fund profile supplied with the request, Step 1 to compose) — values a request supplies. Fixtures for them live in `tests/fixtures/ic-sim-request/`, never `tests/fixtures/ic-sim/` (copied whole by the no-ledger goldens).
- `founder-skills/tests/test_visualize_market_sizing.py` — Market sizing HTML visualization tests
- `founder-skills/tests/test_visualize_deck_review.py` — Deck review HTML visualization tests
- `founder-skills/tests/test_visualize_ic_sim.py` — IC simulation HTML visualization tests
- `founder-skills/skills/financial-model-review/` — Financial model review skill with scripts and references
- `founder-skills/agents/financial-model-review.md` — Financial model review agent definition
- `founder-skills/tests/test_financial_model_review.py` — Financial model review regression tests
- `founder-skills/tests/test_visualize_financial_model_review.py` — Financial model review HTML visualization tests
- `founder-skills/skills/competitive-positioning/` — Competitive positioning skill with scripts and references
- `founder-skills/agents/competitive-positioning.md` — Competitive positioning agent definition
- `founder-skills/tests/test_competitive_positioning.py` — Competitive positioning regression tests
- `founder-skills/tests/test_competitive_positioning_red_team.py` — competitive-positioning's outside review: the rewording, the producer, the reviewed-state fingerprint, the compose gate and codes, and both pages rendering from the copy
- `founder-skills/tests/test_visualize_competitive_positioning.py` — Competitive positioning HTML visualization tests
- `founder-skills/tests/test_founder_document_names.py` — A founder's own `.md` / `.json` documents are not our files: `_founder_text.scan(keep_files=)` blanks this run's document names at filename boundaries (a kept name never hides one of ours, and a name the scan reads as ours is never kept), `OUR_OUTPUT_NAMES` covers every skill's artifact constants and both review-wording tables, and competitive-positioning (compose, both pages, the delivery gate) and market-sizing's compose take the names from this run's review copies and document mirror, each with a lever (market-sizing's scan of quoted review sentences takes the same names and has no test of its own)
- `founder-skills/tests/test_cp_view_criteria.py` (the checklist wording owner, fail-closed), `test_cp_pages_verify.py` (the delivery gate over the composed and rendered fixture, with a lever), `test_cp_payload_golden.py` (the coaching payload as sha256 per key, `fixtures/golden/competitive-positioning/coaching_payload.sha256.json`) — competitive-positioning's analysis checklist
- `founder-skills/skills/cap-table/` — Cap-table skill (SAFE / note conversion, priced rounds, anti-dilution, Israeli ↔ Delaware flips)
- `founder-skills/agents/cap-table.md` — Cap-table agent definition (Context A extraction + Context B coaching)
- `founder-skills/tests/test_cap_table.py` — Cap-table regression tests (math producers + 11-gotcha regression suite)
- `founder-skills/tests/test_cap_table_freeform.py` — Lane-3 freeform mapper tests (`freeform_mapper.map_freeform` golden maps + blockers + `--mode=freeform-emit` CLI + the `cap_state` `E_NO_EQUITY_BASE` guard)
- `founder-skills/tests/test_cap_table_pool_basis.py` — the pool-basis gate across every entry point; `_pool_sizing_claims.py` + `test_pool_sizing_claims.py` — the judge the paid cap-table lanes use on coaching pool sentences, and its free table; the same file ships as the cap-table skill's `scripts/_pool_sizing_claims.py` for the in-pipeline backstop, held byte-identical by `test_pool_claims_check.py`
- `cowork-tests/lint_skill_gate.py` + `lint-skill-allowlist.json` — CI's `lint-skill` gate: a pinned list of accepted (rule, file, count) findings that may only shrink; any other WARN fails
- `cowork-tests/handover_check_cassette.py` — HARD CI step: each recording that ran a closing script carries the printed hand-over whole and the Stop hook found it; reuses the hook's own logic
- `cowork-tests/preflight_classify.sh` — `rerecord.sh`'s cost pre-flight classifier (budget refusal vs a scenario that did not load, or a `⚠ input error:` a real record would refuse, which exits 0), keyed on the harness's message, not its exit code
- Hook and harness tests: `test_stop_handover_hook.py` (the Stop hook's checks), `test_stop_reply_tool.py` (`FS_HOST_REPLY_TOOL`: the hook's judgement of a reply tool's sends, the line's parse, record, check and notices, and the hook's copy of the rules), `test_dispatch_prompt_hook.py`, `test_review_page_hook.py`, `test_two_figures_hook.py`, `test_shared_tmp_hook.py`, `test_asked_gate_hook.py`, `test_asked_evidence_fold.py`, `test_dispatch_type_check.py`, `test_dispatch_prompt_rejection.py`, `test_dispatch_comparand.py`, `test_handoff_audit.py`, `test_handover_check_cassette.py`, `test_lint_skill_gate.py`, `test_rerecord_preflight.py`, `test_script_hashes.py` (the manifest, the hash rules and the lever: a byte flipped in a copy of the plugin), `test_stop_no_handover.py` (the Stop hook under `FS_HOST_NO_HANDOVER`: the host line as comparand, the request's own line, the rows that never count), `test_cassette_file_tool_paths.py` (no relative or `/sessions/` path reaches a file tool in any cassette), `test_fmr_handover.py`
- `founder-skills/tests/test_visualize_cap_table.py` — Cap-table HTML visualization tests
- `founder-skills/tests/mutation_corpus.py` + `test_mutation_corpus.py` — Curated mutant corpus over cap-table's math producers: `MUST_KILL` / `KNOWN_SURVIVORS`, both SHRINK-ONLY; every kill names the test that must notice (`killed_by`, measured never guessed — a kill from anywhere else fails); a no-op control must stay PASSING. The registry lives in the non-`test_*` module deliberately — its docstring says why.
- `founder-skills/tests/test_release_gating.py` — The release chain asserted: every job in `publish-release`'s TRANSITIVE `needs` closure must run on a tag push, because in Actions a skipped dependency SKIPS the dependent (four tags once shipped with no Release). Frozen `if:` strings + a simulated-tag-push evaluator that raises on an unknown context property. Wiring only, never that a job passes. The closure is `verify-branch-gate` + `contract-tests`; the paid jobs are pinned OFF the tag and ON a release-branch dispatch, publish's `if:` may call no status function, the verification may not `continue-on-error`, and `branch_gate_check.py`'s `REQUIRED_JOBS` must equal the paid jobs' display names.
- `.github/scripts/branch_gate_check.py` + `founder-skills/tests/test_branch_gate_check.py` — The tag-time paid-gate check: a completed `workflow_dispatch` run of `skill-quality.yml` at the tagged commit (full SHA; any branch) whose latest attempt has both paid jobs, by display name, `success`. Fails closed on any API error. `branch-gate-selftest` runs it on every PR that matches the workflow's paths filter, under the job token (a released commit must pass, a made-up one must fail with the clean rejection line). Renaming a paid job's display name: change it and `REQUIRED_JOBS` together in one release (the release-branch run then reports the new name), and after that release point the self-test's SHA/tag at it, since older runs carry the old name.
- `founder-skills/tests/cowork_async_subagent_filter.py` — Cowork sub-agent tool-name compatibility helper (skill-quality CI; v0.4.0-regression detector)
- `cowork-tests/leak_scan.py` — Founder-facing "internal plumbing" leak detector: nine syntactic classes plus the semantic `plumbing_verb`. Point it at a cassette FILE or `events.jsonl`; a directory glob finds only `*.json` and reports a silent false-clean.
- `founder-skills/tests/test_founder_facing_leaks.py` — A REPORT over `leak_scan.py` across the committed cassettes, not a gate: it prints every founder-facing leak and never fails on the count (demoted 2026-10-08, because the count varies about 5x on identical input and the class set varies too; the file's header holds the measurements). It still fails if the detector loses a known class or no cassette is scanned. Read the report after a re-record; a rise there is a prompt to look, not a verdict. Clean narration is not proven by a low count either: what still passes is internal vocabulary with no plumbing verb ("canonical artifacts", "schema-drift warning", bare `STOP`/`BLOCKED`, "Gate 1 passes"). Do not fix that by enumerating words — extending the classes was tried and does NOT red the suite, but an enumerated blocklist is unwinnable by the detector's own design note.
- `founder-skills/tests/compose_invocations.py` — Per-skill compose-script invocation registry (skill-quality CI)
- `founder-skills/tests/test_cowork_async_subagent_filter.py` — Helper unit tests
- `founder-skills/tests/test_cowork_invariants.py` — Per-agent persistence + dangerous-tool declaration invariants
- `founder-skills/tests/test_plugin_payload.py` — Every file of 1 KiB or more tracked under `founder-skills/` (tests and fixtures included) compresses no better than 30:1. Desktop's plugin download refuses the WHOLE plugin, silently, when any entry compresses better than 50:1, so the install reports nothing and the plugin never appears. Store a large repetitive fixture (a recorded transcript) gzipped.
- `founder-skills/tests/test_cowork_harness_floors.py` — Drift guards for the cowork-harness version surface: the per-site registry splitting CI SELECTORS (pinned exactly, `4.11.0`) from FLOORS (recording `>=4.11.0`, replay `>=3.8.0`) — three postures, deliberately different, `uses:`-vs-`version:` major agreement (the action ref and the CLI move independently), and the derived cassette-format facts prose keeps restating wrongly. Every extraction asserts its own pattern matched, so a rotted regex reds instead of greening.
- `founder-skills/tests/test_skill_orchestration.py` — Per-SKILL.md frontmatter + sub-agent-cue-then-bash regression detector
- `founder-skills/tests/test_compose_invariants.py` — `coaching_payload` shape + `STALE_ARTIFACT` regression
- `founder-skills/tests/coaching_payload_keys.py` + `test_coaching_payload_key_coverage.py` — The coach's key list DERIVED from each `compose_report.py` by static AST (every unresolvable construct raises), asserted against agent bodies only. A lower bound in the addition direction — it does not replace `test_compose_invariants.py`'s `_COACHING_COVERAGE_KEYS` and is blind to SHAPE; `test_high_severity_warning_shape_matches_the_producer` covers that axis.
- `founder-skills/tests/test_cap_table_warning_labels.py` — cap-table may not hand the coach a raw `E_` / `W_` code under a founder-facing name. Asserts the humanizer's OUTPUT SHAPE over every code literal (not per-code coverage, which rots and gets deleted), and SEEDS a blocker + solver warning because the fixture carries none.
- `founder-skills/tests/test_insert_coaching.py` — `insert_coaching.py` suite (6-state idempotency matrix, run_id parity, single-pass write-back, adversarial commentary)
- `founder-skills/tests/test_check_handoff.py` — `check_handoff.py` suite (typed exit paths 0/3/4/5/6, adversarial file states, tolerant receipt extraction)
- `founder-skills/tests/test_redteam_core.py` — `_redteam_core.py` suite; also pins each skill's copy of `list_documents` to the core's
- `founder-skills/tests/test_merge_json.py` — `merge_json.py` suite (merge order, --set overrides, error paths)
- `founder-skills/tests/test_resolve_artifacts_root.py` — Artifacts-root resolver suite (Cowork mount signatures + agent-namespace root)
- `founder-skills/tests/dead_payload.py` — Shared analyzer for embedded-but-unread JS payload keys with three verdicts: `read`, `unread`, `unverifiable` (computed-name access — neither blanket consumption nor death).
- `founder-skills/tests/test_dead_payload.py` — Analyzer unit tests + all four embedders (three `explore.py` + `review_inputs.py`). Pins which payload objects are dynamic, so a generator switching to computed access cannot quietly reduce coverage.
- `founder-skills/tests/test_dispatch_schema_drift.py` — A dispatch template may not instruct a field nothing consumes; reads every fenced block on both prompt surfaces (json-only sees about a third). Cannot see shape-level drift (`x_axis_rationale` is both the obsolete authoring shape and a legitimate internal one), so a direct axis-shape assertion covers that.
- `founder-skills/tests/test_html_founder_text.py` — Fleet ratchet: no internal token in founder-visible text of any generated HTML. Text nodes only; attribute values and script bodies are not founder-facing prose.
- `founder-skills/tests/test_compose_invariants.py` also scans **every delivered markdown, not just `report.md`** via `_EXTRA_DELIVERABLES` (cap-table's counsel packet today), seeding one counsel item because the fixture flags none — without it the packet is boilerplate and passes with the leak present.
- `founder-skills/tests/test_delivery_coverage.py` — The fleet's delivery-defect coverage map, asserted rather than described; records the known "computed, not rendered" gap (gated only in competitive-positioning and financial-model-review) and one Gate-1 render contract as explicitly WEAK.
- `founder-skills/tests/test_theme_sync.py` — Brand-theme invariants: per-skill `_theme.py` copies identical, brand font present, font embeds in CSS
- `founder-skills/scripts/_ui.py` — Shared page components for the redesigned HTML pages (page shell with the one footer, header/verdict card, score ring, grouped checklist with CSS-only filter chips; tiles, cards, nav and tables as CSS classes). Copied byte-identical into each adopting skill's `scripts/` (financial-model-review, deck-review, market-sizing, ic-sim, cap-table, competitive-positioning), held by `test_ui_sync.py`; cap-table's explorer renders only its `footer()`, with a copied `FOOTER_CSS` held to `UI_CSS`'s rules and `:root` values by `test_ui_sync.py`; components render only strings the caller gives them. FMR's `_checklist_view.py` owns its grouping, labels and score format for both FMR pages.
- `founder-skills/tests/_dom.py` + `test_page_structure.py` + `test_ui.py` — Page-structure checks for the redesigned pages, read by id, `data-*` and text through a stdlib parser (class names are asserted only in `test_ui.py`): every criterion once with its status, one footer, every class has a rule, each ring states the score as its report.md does.
- Gate and run-status tests: `test_gate_registry.py` (registry shape, contract file in sync), `test_record_gate_answer.py`, `test_run_status.py`, `test_form_reply.py`, `test_insert_coaching_status.py`, `test_html_run_stamp.py` (every HTML writer's `--run-id` stamp and `deliverables` entry; registry `html_writer_invocations.py`), `test_gate_writers_mirror.py` (`gate_state.py`, `record_revision_answer.py`, `extract_cap_table.py` mirror the ledger), `test_run_ref_sync.py` (the per-skill `_run_ref.py` copies), `test_resume_evidence.py` (what a resumed invocation rewrites), `test_superseded_deliverables.py` (a later run taking an earlier run's folder), `test_compose_result.py` (every compose's `compose_result.json`, and the per-skill copies in sync), `test_no_ask.py` (a request carrying `FS_HOST_NO_ASK`, through the scripts and the hooks), `test_no_handover_line.py` (`FS_HOST_NO_HANDOVER`: the line, its record, the reply-tool conflict, the status fields; the closers' half is `gate_run_helpers.check_host_closer`, called from each closer skill's gates tests), `test_usage_fix_lines.py` (the `fix:` line three scripts add to a usage error, held to the SKILL.md block it quotes), and one per wired skill: `test_deck_review_gates.py`, `test_fmr_gates.py`, `test_ms_gates.py`, `test_ic_gates.py`, `test_cp_gates.py`, `test_ct_gates.py`. Shared fixtures: `gate_run_helpers.py`; `no_ledger_goldens.py` + `fixtures/no_ledger_goldens.json` pin that a run with no ledger behaves byte-identically to before.
- `founder-skills/tests/test_e2e_deck_review.py`, `test_e2e_financial_model_review.py`,
  `test_e2e_market_sizing.py`, `test_e2e_cap_table.py` — the FOUR paid end-to-end lanes; LLM-driven;
  carry the `e2e` marker. Derive the list with `ls founder-skills/tests/test_e2e_*.py`; a hand-list
  here has gone stale before.
  Shared plumbing in `tests/_e2e_harness.py`. deck-review keeps its own SDK loop and does not use the
  harness's driver (it is the lane the release gates on; fold the driver in when a failure costs a
  re-run rather than a patch release). It imports the account-connector switch
  (`ENABLE_CLAUDEAI_MCP_SERVERS=false`) and its post-run check, which every lane runs
  (`test_lane_connector_isolation.py` holds both, free); the run-status readers; and the capture
  pieces (`RunCapture`, `record_message`, `dispatch_report` and the two dispatch assertions), into
  which it folds each message for its CHECKLIST dispatch report. Both dispatch assertions are gated,
  as on the FMR and market-sizing lanes. A report that cannot be built, or a CHECKLIST dispatch with
  no PreToolUse hook response, fails the lane. A `[dispatch-type]` hold is never a false hold.
  Each lane answers its questions up front with `FS_HOST_` lines and asserts the run status;
  `test_lane_host_lines.py` checks, free, that every line a lane sends is one the contract accepts.
  The deck lanes fail when a deck-reading dispatch (ledger extraction, second read, slide reviews) was not
  given, or did not read, the deck text (`deck_text_dispatch_problems`, succeeded dispatches only; a capture
  with no child calls is a note); `test_lane_deck_text_checks.py` holds it, free, on synthetic captures.
  **One lane per changed coaching-payload builder is the rule**: contract tests pin that a payload
  key is emitted and named on both prompts, and structurally cannot show a sub-agent reading it.
- `evals/financial-model-review/` — `metric_claim_corpus.json`, the checklist sentences naming a ratio metric from review runs of the tracked synthetic fixtures, and `harvest_metric_claims.py`, which adds a run's sentences (dry run by default; it admits a run only when the run's own `model_data.json` `sheets` equal what `extract_model.py` extracts today from one of the tracked synthetic fixtures, and refuses a run with no `model_data.json`; the run id, company and folder name are never read). `founder-skills/tests/test_fmr_metric_claim_corpus.py` replays the corpus through the shipped METRIC_SELF_CONTRADICTION check and expects exactly its `expected` rows, which a person edits by hand.
- `founder-skills/tests/fixtures/` — Synthetic test inputs (deck-review compose-invariant fixtures + synthetic deck for e2e + golden expected file)
- `.github/workflows/skill-quality.yml` — Skill-quality CI (contract tests per-PR, e2e smoke on internal PRs only)
- `artifacts/` — Persistent working directory for skill run artifacts (gitignored, created at runtime)

## Plugin Structure

- `.claude-plugin/marketplace.json` — Marketplace manifest (root level)
- `founder-skills/.claude-plugin/plugin.json` — Plugin manifest with hooks
- marketplace.json carries `name`, a top-level `description` (shown to users; `claude plugin validate` warns without it), `owner` and `plugins` (each with `name`, `source`, `description`), the shape Anthropic's own catalogs use
- Do NOT add `version` or `metadata` fields to marketplace.json

## Script Conventions

- Scripts use PEP 723 inline metadata; default to `python`, `uv run` optional
- Scripts output JSON to stdout, warnings/errors to stderr
- All scripts support `--pretty` for human-readable output and `-o <file>` to write to file (skill scripts emit a JSON receipt to stdout confirming the write)
- Skill-local scripts live in `founder-skills/skills/<skill>/scripts/`
- **A producer that rejects its input MUST fail loudly: diagnostic to stdout, a line to stderr, `-o` left untouched, exit non-zero.** Six producers across four skills independently got this wrong — they wrote a `{"validation": {"status": "invalid"}}` stub *through* `-o` and returned 0, which both destroyed the prior good artifact and made every SKILL.md's "the pipe fails next" error branch unreachable; the only downstream signal was a *medium* warning naming a symptom. Use the `_fail_invalid` helper (canonical copy: `skills/market-sizing/scripts/market_sizing.py`). Stamp `metadata`/`graded_against` **before** calling it, so the diagnostic still carries provenance. `tests/test_skill_contract.py::test_producer_rejects_loudly_without_clobbering` runs each producer against a rejecting payload and asserts all three properties — deliberately behavioural, because the structural version ("must contain `_fail_invalid` or a `sys.exit(1)`") was measured **vacuous**: every one of these scripts already had `sys.exit(1)` for malformed-JSON errors. Add new producers to that registry. And no output may resolve to one of the script's own inputs: every writer with an output flag calls `_out_guard.refuse_output_onto_input` first, with every output it writes (`OUTPUT_IS_INPUT`, exit 1, nothing written). A new writer goes through it too: `test_out_guard_coverage.py` reds on an output flag that is neither passed to the guard nor exempt by `(script, flag)` with a reason, and the exemption map may only shrink. `insert_coaching.py` keeps its own, stricter guard.
- **The audit trail does not require clobbering, and a sidecar needs a downstream reader.** A producer may keep a rejected artifact beside the canonical one as `<canonical-filename>.rejected.json` (appended, so `competitor_verification.json.rejected.json` — not a replaced extension), leaving `-o` untouched. `verify_competitors.py` does this. **If it does, something downstream MUST key on the sidecar**: for an OPTIONAL artifact, absence otherwise reads as "the run skipped this step" and the deliverable presents unchecked analysis exactly as it presents checked analysis. Two ways for that signal to go missing, both measured here — key it on absence alone and a prior run's file silences it, so compare run_ids; and a *medium* code naming a symptom (`STALE_ARTIFACT`) is not the signal, because the founder needs the cause. `compose_report.py`'s `VERIFICATION_REJECTED` is high and does both.
- Compose scripts pair with it: a canonical artifact carrying `validation.status == "invalid"` raises `ARTIFACT_INVALID` (or `SIZING_INVALID` in market-sizing) at **high** severity, so it cannot be accepted away via `accepted_warnings`.

## Shared Scripts

- **`founder_context.py`** — Per-company context management (init/read/merge/validate subcommands); protects 11 key metric fields. `read --run-id R --skill S` with a run ledger: each Step-1 field needs a record (exit 10 opens the missing ones and prints what to ask; exit 12 under `FS_HOST_NO_ASK`), a typed value must be the recorded one (exit 1 `GATE_RECORD_MISMATCH`), several contexts (or one, when the request answers `ctx_select_company` or its `ctx_basics.company_name` line names a different company, or `--expect-company` names one other than the single stored context's) make `ctx_select_company` owed (exit 10, was 2), and no context is `CONTEXT_NOT_FOUND` (exit 1). Without a ledger, byte-identical to before (goldens). Context supplied with the request is never founder context: `merge` refuses a payload carrying `host_context` (`HOST_CONTEXT_NOT_FOUNDER_CONTEXT`); every skill reads founder context at Step 1.
- **`find_artifact.py`** — Resolves artifact paths by skill name, artifact filename, and optional company slug
- **`insert_coaching.py`** — Deterministic Context B insertion: reads the sub-agent's commentary JSON, applies the 6-state idempotency matrix, replaces the per-run uuid `insertion_marker` with `## Coaching Commentary` + commentary in a single in-place write, and verifies `run_id` parity across `--verify-artifact` paths (exit 0 inserted/already_inserted; exit 1 blocked with JSON diagnostic). A usage error (exit 2) adds a `fix:` line with the insert command from the calling skill's own SKILL.md bash block, the skill read from `--report`'s `<skill>-<slug>` folder; with no known skill, both forms. Every skill's POST_COMPOSE_COACHING step calls it; sub-agents only compose commentary and never edit `report.md`. A report that declares `COACHING_REQUIRES_CHECK_RECORD` (cap-table's only) is inserted into only with a passing check record for that run (see `pool_claims_check.py`). With a run ledger, a successful insert marks the run `complete` (with `deliverables_status` `pending` where pages follow the coaching) and a block writes `running` / `COACHING_BLOCKED`; the status write never changes stdout or the exit code. A blocked diagnostic always goes to stdout and `-o` is left untouched; `-o` onto a file it reads, the report.json / report.md beside the report, or an existing file that is not its receipt is refused first (`OUTPUT_IS_INPUT`). In a dir carrying any run's `run_ref.json` it refuses an insert with no `--verify-artifact` (`RUN_ID_REQUIRED`) or whose artifacts' run is not bound there (`RUN_NOT_BOUND`); a `--report-json` whose top-level `run_id` is another run's is `RUN_ID_MISMATCH`, checked before anything is written (a missing id on either side is not compared). Each carries `code` and a model-facing `fix`. Unbound dirs: unchanged.
- **`check_handoff.py`** — Context A file hand-off gate: verifies a sub-agent's output file exists/parses as JSON and (optionally) that its receipt's `output_path` matches (`--agent-path` accepts the agent-namespace echo). Typed exit codes (0 ok / 3 missing-or-empty / 4 bad JSON / 5 path mismatch / 6 unparseable receipt / 7 content-shape invalid / **8 path-namespace mismatch**) for main-thread branching. Exit 8 fires when no file is at the expected path but one IS where a **doubled** agent-namespace prefix would have put it — reported ahead of exit 3 because the two are indistinguishable from the file check yet need opposite responses (3 = the receipt may be fabricated; 8 = the agent complied and the path was wrong). Its `found_at` is **diagnostic only**: never read the hand-off from it, or exit 0 stops meaning "the file is at the contracted path" for the ~50 downstream `$HANDOFF_DIR` references.
- **`merge_json.py`** — Shallow-merges multiple JSON object hand-off files (later files win; `--set key=value` overrides) into one stream for producer pipes — used when a step consumes the union of parallel sub-agent outputs (e.g. market-sizing "both").
- **`md_to_commentary.py`** — Transport envelope for Context B: the sub-agent writes coaching commentary as **plain markdown** (never JSON, never escaped), this wraps it into the payload `insert_coaching.py` reads. Quotes and line breaks in the commentary can't break the hand-off.
- **`_out_guard.py`** (plugin-root canonical, a byte-identical copy in each skill's `scripts/`, held by `test_out_guard_sync.py`) — refuses an output that names one of the call's own input files: the existing files on its command line (stdin never counts, so a stdin-fed in-place fix stays legal) plus the files it reads that argv does not name (`extra=`: a compose's or page's artifacts in `--dir`), by `realpath` and, when both exist, by inode. `in_place=` names a documented read-then-rewrite (market-sizing's `--replay sizing.json -o sizing.json`); `protect=` (the two receipt writers) also refuses an existing `-o` that is a canonical artifact or carries `metadata.run_id`, never an earlier receipt. Prints nothing on the pass path.
- **`resolve_artifacts_root.py`** — Resolves the canonical artifacts root AND the agent-namespace root (`--agent` / `--json`): in Cowork the sub-agents' file tools see the `outputs/` mount at a different prefix than the VM shell, so SKILL.mds derive `HANDOFF_AGENT` from `--agent` when building `OUTPUT_PATH` dispatch lines. Warns (never fails) when `--dir-name` names a directory with no mirror under the canonical root: a mistyped name still yields a well-formed path, and the symptom — every hand-off failing `check_handoff.py` exit 3 — reads as a fabricated receipt rather than a bad path, so the state machine spends its retry budget on redo-dispatches that cannot succeed. On a Cowork session tree the agent root is an ABSOLUTE host path: the main thread proves it once with `--set-host-outputs-dir` against a probe its Write tool wrote (exit 2 bad shape / 4 probe missing / 5 mismatch); until then, agent-namespace calls exit 6.
- **`_compose_result.py`** (one identical copy per skill's `scripts/`, held equal by a test) — every `compose_report.py` runs `main()` inside it. In a run dir carrying any `handoff/*/run_ref.json` it removes the earlier record on entry and writes `compose_result.json` on exit (exit code, codes, the gate waited on, the end of each stream; run id top level, never `metadata.run_id`, so it is never offered for reuse). Streams, argv handling and exit code are unchanged; a dir with no ref is untouched. Not a deliverable.
- **`_handoff_audit.py`** — Gate records: `check_handoff.py` writes `<file>.gate.json` (with its sha256 and `gated_at`, rewritten on every pass) on exit 0; each compose names every step with no record (`HANDOFF_BYPASSED`, medium, not acceptable where acceptance exists). The evidence is an ABSENT record, never something the fallback path writes. A missing `handoff/<run_id>/` is not silence: it means no records, so every step the artifacts show ran is named. Residual: a step required only because its hand-off file is in the dir (cap-table's per-document extractions, competitive-positioning's enrichment re-dispatch) stops being required when the dir is deleted, so deleting it can still remove that warning. A run that dispatched no sub-agent has none of those artifacts and is required to show nothing. Residual: hand-writing a gate record, or deleting a canonical artifact (which removes its step's requirement), still defeats it.
- **`_delivery_check.py`** — The Stop hook's delivery half (see **Hooks**): a successful report build whose files were linked but never attached, on a host that offered a delivery tool; and financial-model-review's review page left unsent by a turn that ended waiting at its values check. Loaded by path from `stop_handover_check.py`.
- **`pretooluse_dispatch.py`**, **`dispatch_type_check.py`**, **`asked_gate_check.py`**, **`dispatch_prompt_check.py`**, **`two_figures_check.py`**, **`no_ask_check.py`**, **`review_page_check.py`**, **`shared_tmp_check.py`** — the PreToolUse hook's body and its seven checks (see **Hooks**).
- **`script_hashes.py`** — The plugin's own files by hash, for the dispatch-prompt hook: `data/script-hashes.json` lists every `.py` outside the top-level `tests/` (folders starting `.` or `__` and files starting `.` skipped; `__init__.py` listed) and every `agents/*.md` by the text after its front matter (the synced and org installs rewrite front matter, not the body), CRLF folded to LF. A symlinked folder inside the plugin is not walked (its files read as missing). `--write` regenerates it, `--check` (optionally `--root DIR`) exits 1 naming what differs and how to regenerate, 0 when clean or when no usable manifest exists. `verify_detail` never raises: no usable manifest, or more than `max(5, entries // 10)` differing LISTED files, returns no list and a reason (fail-open); an unlisted script is always named and never counts toward that threshold. Python 3.9, loaded by the hook.
- **`_handover_check.py`** — The hand-over containment rule, in ONE place: the e2e lane and the Stop hook both ask whether the founder's message carries the printed message whole, and a rule with two copies drifts (the lane greens on one reading while the hook blocks on another). Link targets are dropped and whitespace squashed before comparing — the two runtimes render different links for the same file, and a re-typed indent is not a change. Containment, not digit-absence: a message is also wrong when it DELETES a printed line, which no digit check can see.
- **`_redteam_core.py`** — The adversarial review's skill-neutral half: which findings may reach a founder, and which review is shown (two reviews with no findings or rejected findings are compared by hand-off hash only, so a clean second round is a second review, not a copy). Both red-team skills load it by path; what differs per skill (the quote matcher, the review's wording, the parameter names a finding may name, what a review is taken against) is passed in.
- **`stop_handover_check.py`** — The Stop hook's body (see **Hooks**); loads `_handover_check.py` by path, since plugin-root `scripts/` is not a package.
- **`_gates.py`** — The gate registry and ledger. A `request_only` option (hidden; only a request line records it, `REQUEST_ONLY_OPTION` otherwise) carries a value checked at Step 1 by a filesystem-free predicate listed in `DRY_REQUIRES`; a value that cannot be read sets `pre_answer_malformed` beside `pre_answer_unlisted` and the run waits `PRE_ANSWER_MALFORMED`. `reads_in` names the skills that use a shared gate (`host_context`: ic-sim); the others list its line ignored as `not_used_by_skill`. Every `FS_HOST_VALUE` is capped at `VALUE_MAX` (32,768). `FS_HOST_REPLY_TOOL <tool_name> <input_key>` (no gate; two words, `REPLY_TOOL_NAME_RE` / `REPLY_TOOL_KEY_RE`, `REPLY_TOOL_REFUSED`) is recorded as `ledger.reply_tool` and shown as `run_status.json` `reply_tool` (a later line replaces it; `resume_prompt` never repeats it, because a pasted resume prompt is a founder row and the ledger's line is model-typed) for the record only (the Stop hook reads the line from the request's own message, never the ledger), and skills outside `REPLY_TOOL_SKILLS` list it `not_used_by_skill`. `FS_HOST_NO_HANDOVER` (no gate, nothing after it; refused beside a reply-tool line in one request, and on a resume that adds one to a run holding the other) is recorded as `ledger.no_handover`, sticky and never repeated by `resume_prompt`, read only by the three closers (`HANDOVER_SKILLS`, an alias of `REPLY_TOOL_SKILLS`); `run_status.json` shows `no_handover` and the closer's `handover: "host"`. One literal (`GATES`) names every question the six analysis skills ask, its options, who records it, its default reasons, when it is owed and whether a hook checks it was asked (`asked_check`). Ids are contract (change only by addition); labels are presentation, held to each skill's text by sync tests. The ledger is `<ARTIFACTS_ROOT>/runs/<RUN_ID>/gates.json`; every write takes the run lock, writes the ledger, settles which follow-ups are still owed (closing one no longer owed `not_applicable / script`, re-opening one owed again) and re-derives the status. Five gates are recorded by three scripts of their own (deck-review's `gate_state.py` for its three stage gates, market-sizing's `record_revision_answer.py`, cap-table's `extract_cap_table.py`), which keep their own files as mirrors; the recorder refuses those. A predicate that cannot decide for this run (an unreadable or other-run file) raises `GATE_UNDECIDABLE` (exit 2), never "not owed". What a record proves: a listed option was recorded for this run and, for a bound gate, what it confirmed has not changed — not that a person chose it. `--dump-contract` writes `data/host-contract.json`.
- **`_run_status.py`** / **`run_status.py`** — The status file a host reads (`runs/<RUN_ID>/run_status.json`, schema 1): `running` / `waiting` / `complete` / `refused` with a stable code, every gate of the skill and mode, `waiting_on`, `resume_prompt`, `disclosures`, `notices`, `deliverables` + `deliverables_status`, `handed_over_at`, and `invocations[]` (per invocation, which run-dir files it left untouched or rewrote, from a manifest taken at its start; null, never empty, when none could be taken). `start` takes `FS_HOST_RUN_ID=` or mints an id, validates every `FS_HOST_` line before the ledger exists (`PRE_ANSWER_INVALID` writes a `refused` status and exits 1; a later `start` under that id moves it to `run_status.json.replaced-<n>` and starts fresh) and resumes only a `waiting` run of the same skill; `RUN_ID_IN_USE` / `RUN_ID_FINISHED` are print-only (appended to `runs/<id>/start_refusals.jsonl`, never written to that id's status); `RUN_ID_MALFORMED` is printed and writes nothing. `bind` writes `<run dir>/handoff/<RUN_ID>/run_ref.json`, which is what tells a skill's own scripts the run has a ledger. Every bind (a re-bind included) stamps it with `bound_seq` (one more than the highest in the folder) and `bound_at`: the folder's current run is its newest bind (`folder_current_run`), read from the refs alone, and `superseded_by` stays the host-facing mirror. A compose-driven reopen from a folder other than the run's own (an archived copy) records that folder as the run's `run_dir_shell` and clears its own `superseded_by`, and marks no other run (`take_folder(from_dir=)`). `bind` and every reopen also take the run dir for that run (`_gates.take_folder_noted`): each other run whose listed files or unfinished work are in it is marked `superseded_by` it, one run lock at a time with a bounded wait; a marking that times out is a `SUPERSEDE_NOT_RECORDED` notice, retried by the run's later transactions at most once a minute, and a retry never takes the folder back. `finish --mode` ends a mode with no coaching; `fail --code` marks a run `refused` on a failure it cannot recover from (refused `RUN_WAITING` while a question is open); `deliverables --final` closes the page list. `check --skill S [--as-of]` is a dry run of `start` plus the request-only value checks (`_gates.dry_check`, only the predicates in `DRY_REQUIRES`; any other `requires` is `not_checked_here`): exit 0 accepted, 1 `start` would refuse, 4 a value would wait, 2 usage; writes nothing. `resumable` (`RESUMABLE_BY_SURFACE`) is `true` on `cli_artifacts_root` (a later, separate process over the same root resumed a waiting run) and `"same_session"` on every other surface until a new session there is shown to see an earlier one's files; moving an entry is a deliberate edit.
- **`record_gate_answer.py`** — The recorder. `open` writes `waiting` and prints `needs_input` (the question, the shown options, the exact `answer_command`) BEFORE the question is asked, or applies the request's pre-answer (`"applied": "pre_answer"`); `answer` validates every segment before writing any; `default --reason` takes only the registry's reasons (a refusal names the gate's); `not-applicable` takes several `--gate` (all or none). `derive` records an answer the request's `FS_HOST_DERIVE` line let the run take from the materials, with its source. A recorded answer stands (`ANSWER_STANDS`), except a default replaced by an answer, a bound answer whose binding changed, `--after-hold` once per run on a hook-checked gate, and a no-ask default replaced by a not-applicable record on the gates `NA_REPLACES_NO_ASK_DEFAULT` lists (`cp_product_availability`). Under `FS_HOST_NO_ASK` that replacement is the only `not-applicable` it takes (`NO_ASK_NOT_APPLICABLE` otherwise; not when the request named the gate in `FS_HOST_WAIT`), recorded with basis `model_no_ask` and disclosed `MATERIALS_STATED:<gate>` (keyed on that basis, so a record made before a resume added the flag is not listed); scripts' own not-applicable records are unaffected. `list` prints the host contract. Exit 0 / 1 rejected (ledger and status untouched) / 2 usage or unreachable / 10 waiting / 11 not owed / 12 waiting under `FS_HOST_NO_ASK` (nothing asked, nothing recorded by hand).
- **`_form_reply.py`** — Reads a Desktop question-form reply into gate answers, matched against the labels the form was built from, all or nothing. **Ships switched off** (`FORM_REPLY_ENABLED = False`): no live reply has been captured, so the recorder refuses `--form-reply` (`FORM_REPLY_DISABLED`) and the asked-gate hook ignores forms. Loaded by a hook, so Python 3.9-clean.
- **`_run_ref.py`** (one identical copy per skill's `scripts/`, held equal by `test_run_ref_sync.py`) — "does this run have a ledger?", answered from the run dir alone (`handoff/<RUN_ID>/run_ref.json`). With no ref a skill script takes its old path and loads nothing shared; with one it loads the shared modules by path (`load_shared`), which raises when they are unreachable. It also answers who owns a folder without loading anything: `folder_current_run` (the newest bind, by `bound_seq`; source-equal to `_run_status`'s), `refuse_not_this_runs` (in a folder that carries any ref: `RUN_ID_REQUIRED`, `RUN_NOT_BOUND`, `RUN_SUPERSEDED` for an id a later bind replaced, `ARTIFACT_OTHER_RUN` for a run file the step reads that carries another id, files under `handoff/` and outside the folder not compared), and `compose_run_or_exit` (compose's run is the folder's, never the artifacts'; required artifacts must carry it, another run's optional one is read as absent). Every producer that reads run files by path passes them (`test_folder_ownership.py`'s parity scan, exemptions by `(script, flag)` with a reason).
- **`data/host-contract.json`** — Generated by `_gates.py --dump-contract`, never hand-edited; a test holds it equal to the registry. What a host builds against: request-line grammar, gates and options (`pre_answerable`, `takes_value`), status fields, codes, exit codes, `resumable`, and the notes.

## Market Sizing Scripts

- **`market_sizing.py`** — TAM/SAM/SOM calculator (top-down, bottom-up, or both). Also the only place FX happens. On the skill's path (inputs by reference, `--validation --inputs`) the rate is a sourced `fx_rate` entry in `validation.json` with from/to/as_of and a listed source; `--fx-rate` and `*_currency` flags are refused (`E_FX_ON_REFERENCE_PATH`), a missing rate is `E_FX_RATE_MISSING` (remedy `record_research`), and a rate is never inverted. The legacy numeric path still takes `--fx-rate`/`--fx-as-of`/`--fx-source`; there a declared foreign currency with **no** rate is a hard error, never a guess. Conversions are recorded in `sizing.json`'s `fx` block and disclosed to the founder in both `report.md` and `report.html`; the recorded `converted_value` IS the number the math consumed, which is what lets `compose_report.py` compare a founder-stated figure across the conversion.
- **`sensitivity.py`** — Stress-test assumptions with low/base/high ranges and confidence-based auto-widening. **RECONCILES rather than defers:** a range's own `confidence` used to be absolute, which let a caller tag a medium-confidence parameter `sourced` and escape widening entirely; the stricter of the declared and cross-referenced tiers now wins (it can only ever WIDEN). `confidence_source` on each scenario records where the tier came from — `range` / `validation` / `reconciled` / `default` — because "no widening happened" and "no widening was called for" were previously the same artifact. With `--sizing`, base values come from `sizing.json` (`base_source`, `graded_against`), and a founder-stated input the agent left unranged is varied at least ±30% and across `founder_stated_alternatives`.
- **`checklist.py`** — Validates 22-item self-check with pass/fail per item; `--sizing` stamps `graded_against`, and `--inputs` adds the inputs it graded, so a later edit to `inputs.json` names the checklist stale
- **`compose_report.py`** — Assembles report from artifacts, validates cross-artifact consistency
- **`closing_message.py`** — Prints the founder-facing hand-over message from `report.json`, and writes the same text to `handover.txt` beside the report for the check that runs after. It carries the report's own `verdict` paragraph — the words the page opens with — because a message that asked the founder to go read the verdict was measured 0/2 at hostloop: the model dropped the pointer and wrote the verdict itself, with its own rounding. Composing this message in chat is what it replaces. Refuses (exit 2, nothing printed or written, no `handed_over_at`) a report.json whose `run_id` is another run's than the folder's inputs carry (`RUN_ID_MISMATCH`); a missing id on either side is not compared. Every compose stamps that top-level `run_id`, last. When the run that holds the folder recorded `FS_HOST_NO_HANDOVER` (all three closers, via `host_delivers` in the skill's gates module; a bound folder whose current run cannot be told, or whose ledger cannot be read, exits 2), it prints only `_handover_check.HOST_DELIVERS_LINE`, writes that to `handover.txt` (a failed write removes the older file and still prints; exit 2 only when neither works) and stamps `handover: "host"` on the run the flag was read from, after every refusal above.
- **`_upload_names.py`** — The founder's filenames as uploaded: the cloud-lane `<8-hex>-` upload prefix is stripped at RENDER time (compose's final markdown + `verdict`, and visualize's adversarial section + verdict, where it runs BEFORE `_esc` or an escaped `'`/`&` stops matching), and once in the review's prose at pipe time: `red_team.py` maps it after `humanize_review_text`, which protects the stored names and every name as uploaded except one of our own files' names, each as a whole token (so an upload named like one of our files cannot shield our own mention, and a founder's own file is never called ours), and before the policy's substitute, which would rewrite a word inside a prefixed name. It is not renamed at the source, because `red_team.py` opens `<uploads-dir>/<file>` and the OCR sidecars key on it, so citations, quotes and source lists keep the stored names. The payload builder is untouched: its review sentences carry what the stored review carries (uploaded names, from a piped review), and `test_upload_names.py` pins by whole-payload equality that the render-time map does not reach it. Not covered: checklist notes and estimate reasons pass the policy before compose's map (`_view._founder_prose`), and visualize never maps them. Stripping needs EVERY known name to be prefixed; a batch of one needs an a-f letter, since `20240115-deck.pdf` is a date; a collision keeps the prefix. The root fix is to strip while mirroring in Step 6c, but that is a SKILL.md change and cannot be exercised on the lane it targets. competitive-positioning carries a byte copy (held by `test_cp_upload_names.py`), applied differently: its names are this run's review copies plus its `handoff/<run_id>/docs` mirror (`_cp_redteam_copy.upload_display`, never `redteam.json`), `cp_red_team.py` strips them in the review's prose at pipe time, after its own wording and with the same protected set as market-sizing's (before the policy's substitute rewrites a word inside one), compose maps report.md after the quote lift and before the policy, `criterion_text` maps checklist evidence first, and both pages map every loaded artifact (`humanize_tree`, `evidence_quote` excepted). The stored review's prose therefore carries the uploaded names (its citations, quotes and source lists keep the stored ones), so the coaching payload's outside-review sentences do too, while its other fields keep the stored names.
- **`_params.py`** — One owner of each sizing figure's unit and print format (money/count/percent), and of the builds' gap as a factor.
- **`_provenance.py`** — Resolves inputs by reference (assumption / founder_stated / derived / estimate) into value, unit, normalisation, grade and entries; refuses unit mismatches with a remedy_kind.
- **`_view.py`** — One owner of every founder-visible figure and derived fact for compose and visualize: verdict and "Short answer", sizing-integrity codes, claim ranges and disagreements, checked multiples.
- **`_quote_value.py`** — Whether a research entry's recorded figure is written in its `quote` (the passage of the source that states it; `references/web-sources.md` says when the main thread records one). Unit-aware, at the written number's own precision capped at 5% (deck-review's `ledger.py` rule, re-implemented); `fx_rate` and unknown units never match. `market_sizing.py` stamps `quote_checks` per entry in `sizing.json` (absent when a name repeats), and `_view.quote_sentence`, rendered in report.md's Sources Used and report.html's Assumption Confidence, counts the sourced market figures the sizing uses (exchange rates aside) and how many have a matching passage; no stamp, or a counted entry the stamp lacks, means no sentence. It proves only that recorded text contains the figure: a snippet or a paraphrase passes, and the wording claims nothing more.
- **`record_revision_answer.py`** / `_revision_answer.py` — Records Step 6d's deliver-or-revise answer; its absence, when an accepted high finding names a parameter, raises `REVISION_NOT_OFFERED` (with a run ledger, compose instead applies the request's answer through the same path, or under `FS_HOST_NO_ASK` with none sent takes `deliver`, disclosed, and otherwise exits 10 asking for it). `--from-pre-answer` opens the question and applies the request's answer.
- **`_ms_gates.py`** — market-sizing's gate checks for its generator, producers, compose and closer (exit 10 naming the question, `RUN_FINISHED`, `RUN_ID_REQUIRED`; a named run goes through `_run_ref.refuse_not_this_runs`, so an id not bound there is `RUN_NOT_BOUND` and one a later bind replaced is `RUN_SUPERSEDED`); the parts it shares with `_fmr_gates.py` are copies held to it by a test. `refuse_finished_write` is the delivered-analysis guard: compose, `market_sizing.py`, `sensitivity.py`, `checklist.py`, `red_team.py` (imported late, after the shared-scripts refusal a lone copy must meet first) and the generator (every context, `--redo` included) refuse `RUN_FINISHED` before any write on a `complete` or `refused` run, with or without a changed answer; keyed on the output folder's own ref, so a what-if outside the run folder never meets it. The page, the closer, a repeated coaching insert and `deliverables --final` do not call it.
- **`dispatch_prompt.py`** — Generates the RED_TEAM, CHECKLIST and the two sizing sub-agent dispatches (TOP_DOWN_METHODOLOGY, BOTTOM_UP_METHODOLOGY; the sizing prompts refuse, exit 10, until the approach and any two-figure choice are recorded for a run with a ledger) from identifiers on disk (CHECKLIST reads a methodology copy with the revision record stripped, so the grader grades the analysis as it stands): the model supplies paths and ids, every sentence comes from this file, and the e2e lane regenerates the prompt and asserts the dispatched one is byte-identical. A sizing build the comparison flagged (`comparison.warning` / `sam_warning` / `som_warning`) may be printed once more per run with `--redo comparison` (a value-taking option, so the dispatch hook's subcommand reading holds; `--suspect <input>` from the build's own inputs): the generator writes the block (flagged levels, never a figure or which build is higher, and the only files the sub-agent may open), records the print (`_sizing_redo.py`), and refuses with `SIZING_REDO_*` codes (no context line on stdout; on a finished run the redo is refused like every print, `RUN_FINISHED` in the shared `rejected` shape); a plain print of that build while its redo has not reached the sizing is `SIZING_REDO_PENDING`, until the run has moved past the sizing (then a new redo is TOO_LATE and Step 6d needs the plain prompt); only a producer's rejection reprints a consumed redo. A usage error naming the sensitivity step adds a `fix:` line: SENSITIVITY_TEST has no generator, and the line names the SKILL.md heading whose template to send. The founder's documents are listed FIRST, before any of our artifacts — a reader who opens the analysis's reading of the deck before the deck inherits its frame, and the step exists to escape the constructor's framing.
- **`_sizing_redo.py`** — The comparison redo's record (`sizing_redo.json`, and with a ledger the same record in the ledger under `sizing_redo`, written in a transaction, read first, so deleting the file neither erases the disclosure nor reopens the budget; with a ref and an unreachable ledger every reader refuses, never reading the file; a finished run is refused before it, by the generator's `_ms_gates.refuse_finished_write`); the ONE `consumed` predicate (the redo hand-off's gate record has `gated_at` after the print AND the file's mtime is after it AND the sha matches; no fallback), used by the generator's SPENT and by `fill_after`, which `market_sizing.py` runs to record, once, the gap the redo produced and which research entries the sizing uses were added or changed since the first print (a per-entry digest, the label and the quote excluded). Once `after` is written, the outcome is the record's alone: Step 6d's sizing re-dispatch runs before HANDOFF_DIR points at `r2/` and rewrites round 1's hand-off, which must not turn a redo that ran into "not run". Compose's `SIZING_REDO_NOT_RUN` (medium, not acceptable; exactly when `after` is absent) and the report sentence (`_view.redo_sentence`, both surfaces, through the founder-text policy) read only the record. Residuals, stated in its docstring: the budget limits prints, not dispatches (the identical redo prompt can be sent again; the e2e lane counts dispatches, a run does not); with no ledger, deleting the file or a fresh run id resets it; the redo sub-agent may open its own OUTPUT_PATH (the file tool reads an existing file before overwriting it).
- **`ocr_uploads.py`** — Writes a machine-read text sidecar per page of every image-only upload, so a red-team citation to a scanned page can be checked against text rather than re-read by vision. Binary-only (`pdftoppm` + `tesseract`, the cap-table pattern) with NO Python OCR dependency: when either binary is absent it exits 0 with `ocr_available: false` and writes only its receipt (no sidecars), every document citation stays `quote_verified: null`, and that is disclosed. A missing OCR binary must never block a run. It writes `<out>/receipt.json` per document as it goes, so a re-run after a timeout RESUMES; `dispatch_prompt.py` refuses (exit 2) while any image-only PDF is not covered by the receipt — an `ocr_available: false` receipt counts as covered.
- **`visualize.py`** — Generates self-contained HTML with SVG charts; outputs HTML (not JSON)

## Deck Review Scripts

- **`checklist.py`** — Scores 35 criteria across 7 categories (pass/fail/warn/not_applicable) with overall score percentage. It reads `--inventory` before validating, so a criterion its gates force to not_applicable (AI on a `not_ai` deck, design on an unrendered one) needs no evidence or fix; ids and statuses are still checked
- **`dr_dispatch_prompt.py`** — Prints Step 5's CHECKLIST sub-agent prompt from identifiers (`checklist --run-id --handoff-agent --review-dir-agent --review-dir`, `--correction` for a redo, `producer-rejected --detail-file` for every producer rejection, `SLIDE_REVIEWS_NOT_READ` included). Prints nothing until this run's `slide_reviews.json` carries a read token (`SLIDE_REVIEWS_MISSING`, exit 1, no context line, its own `fix`: run Step 4's pipe, then the generator again), and refuses `ARTIFACT_OTHER_RUN` in a bound folder when a file the grader reads is another run's. It never reads the token: `_slide_reviews_file.py` holds the "is this run's file usable" half both it and `checklist.py` use, the generator importing only `usable`, and the token comparison stays in `checklist.py`. Every option takes a value (the dispatch hook reads the first positional after the script as the context). Registered with the dispatch-prompt hook; a scoring file.
- **`compose_report.py`** — Assembles deck review artifacts into final report with cross-artifact validation
- **`visualize.py`** — Generates self-contained HTML with SVG charts; outputs HTML (not JSON)
- **`ledger.py`** — Validates the extracted numeric ledger. Its load-bearing check is `raw` against `value`: they are two independent statements about the same figure, so their disagreement catches the scale-slip class ("$493K" recorded as 493) **without seeing the deck**. Tolerance comes from `raw`'s own significant figures, not a flat percentage — "$1.2M" legitimately covers 1.15M–1.25M and a flat 2% rejects a correct extraction. A range is accepted at either of its two numbers and no other, which is safe only because `reconcile.py` reads every range from its low end and treats a sum, difference or product of a range as a range.
- **`reconcile.py`** — The arithmetic. Corroborates each figure's quote against a second read that never saw the ledger, computes the model's proposed relations, applies the tolerance/materiality/convention rules, and **`select()` is the single place that decides what a founder sees**. `relations` in the artifact holds only the survivors; everything else is a count, so no renderer can reach past the decision. Two rules that have each already been violated once: split founder-facing output on **`verdict`** (what the engine computed), never on `kind` (what the model proposed) — they routinely differ, and the flagship finding is proposed `derived_ratio` and returns `contradiction`; and never render a `unit_kind` enum into prose ("per count" reached a rendered line). `--explain` is for maintainers: it prints every computed relation with its working and writes nothing (refused with `-o` or beside `--print-downgrade-stanza`).
- **`_quote_match.py`** — Copy of cap-table's `quote_in_doc` + normalizers (skill scripts are standalone and cannot import across skills). `tests/test_quote_match_sync.py` compares the parsed function bodies. **`value_in_doc` is deliberately NOT copied**: on decks it false-passes 5.7% cross-deck and 37% on plausible round numbers, against `quote_in_doc`'s 0.8%, so the cap-table precedent is inverted here on purpose.
- **`gate_state.py`** — The stage gate's producer and answer writer (`emit` / `answer`), one of the three writers that record their own gate. With a run ledger it opens and records `stage_confirmation` / `stage_choice` / `out_of_scope_choice` in the ledger and keeps `gate_state.json` as the mirror; `emit` applies a request's answer (`answer_source: "host"`), except a `stage_confirmation` that disagrees with the deck or (`looks_right`) a low-confidence detection, which waits `PRE_ANSWER_UNLISTED`. A stage answer is bound to the stage it confirmed. Its refusals carry the status code (`GATE_UNANSWERED`, `GATE_INTERMEDIATE`, `OUT_OF_SCOPE_UNANSWERED`, `AUTO_SATISFY_NOT_ALLOWED`, `GATE_INVALID`, `GATE_OTHER_RUN`, `PROFILE_MISMATCH`, `FOUNDER_DECLINED`). `context_summary` may carry the producer's `(Earlier rounds the deck reports: …)` line, from `deck_inventory.json`'s `prior_rounds`.
- **`setup_run.py`** — Resolves the review dir and run id. `--clean` removes earlier runs' checkpoints but keeps those stamped with this `--run-id` (and this run's gate file when it carries history); `reuse_checkpoints` is true only when a checkpoint was kept, so an unbound driver must mint a fresh id per review. A run already bound (`run_ref.json` present) is this run's: nothing is cleaned. After compose exits 10, an open stage question is reported as `gate_action: reask` with `gate_id` (ledger runs only).
- **`deck_inventory.py`** — Validates the deck inventory. `prior_rounds` (optional): rounds the deck says were already closed, each a stage token and an optional integer year (1990 to next year); a round being raised now is `claimed_stage`, never `prior_rounds`. `observations` (at most 12 notes of at most 300 characters) and a slide's `printed_label` (at most 16) are the reviewer's own notes, never deck text: capped in the producer, since the schema validator ignores `maxItems` / `maxLength`; over a cap is refused, never truncated.
- **`deck_text.py`** — Writes the run's one canonical `deck_text.md`: front matter (`run_id`, `source`, per-slide `pages_source`, `deck_sha256`, `warnings` as codes), then one `## Slide N` section per inventory slide. A PDF page's text layer, a PPTX slide in `presentation.xml` order (speaker notes from it, or from `--notes-from` for its PDF conversion), or a `.txt` / `.md` deck split on `SLIDE N` marker lines (`--staged` records a deck the main thread wrote from the founder's message as `pasted`, refused on a binary deck, `STAGED_FORMAT`); a slide with no text falls back to its inventory record, so for an image deck the inventory IS the deck text. No text is dropped: a slide count or marker that disagrees with the inventory is a warning (`PAGE_COUNT_MISMATCH`, `NO_SLIDE_MARKERS`, `DUPLICATE_SLIDE_MARKER`), never a cut. It never reads `observations` or `printed_label`.
- **`_deck_text_file.py`** — The deck text's shared helpers: the front matter closes at the file's SECOND `---` line (a markdown body may carry its own), `body_sha256` hashes what follows it, and `stamp(path, run_id)` returns that hash or a refusal (`DECK_TEXT_UNREADABLE`, `DECK_TEXT_INVALID`, `DECK_TEXT_OTHER_RUN`). Every producer that reads the deck text stamps `metadata.deck_text_sha256` through it, so editing the front matter changes no stamp and editing a slide changes every one.
- **`second_read.py`** — Step 3.6's producer: validates the SECOND_READ hand-off (`transcript`, `slides_transcribed`; `SECOND_READ_INVALID`), stamps `metadata.run_id` and, with `--deck-text`, the deck text's body hash, dropping any stamp the hand-off carried. Replaces an inline `python3 -c`. `slide_reviews.py --deck-text` (required, beside `--ledger`) stamps the same way, checked before stdin is read, without changing the read token.

- **`deck_ocr.py`** / **`ocr_check.py`** / **`_ocr_scope.py`** — Step 3.95, the machine read of image slides. `_ocr_scope` is the one scope reader (ids among the kept relations' operands and `expected_id`, not dates, on a `pages_source: inventory` slide) for the producer, the check and Step 4's gate. `deck_ocr.py` renders and tesseract-reads only those slides; `-o` is a folder (`receipt.json`, `p<N>.txt`), resumed until `"complete": true`, each call starting a new page only within `--budget-seconds` (default 30, always at least one page), a page `failed` after two attempts; a missing tool or unreadable PDF exits 0 as `unavailable`; refuses `DECK_NOT_FOUND`, `OUTPUT_NOT_DIR`, `LEDGER_UNREADABLE`, `RECONCILIATION_UNREADABLE`. `ocr_check.py` counts the figures the read found as written (reconcile's grammar, never copied) into `ocr_check.json` (status and counts only; each figure's result stays in the hand-off `figures.json`); exit 2 `OCR_NOT_RUN` / `OCR_INCOMPLETE` (also for a receipt of another deck, scope or page cap), exit 1 `OCR_OTHER_RUN`. `_reconciliation_prose.ocr_read_line` renders the counts-only sentence in both report.md and report.html; it never names a figure. No dispatch from Step 4 on reads `ocr/` or `ocr_check.json`.

**The numeric chain (Steps 3.5–3.8) is gated by `slide_reviews.py --reconciliation`, not by `MISSING_ARTIFACT`.** Step 3.95 is gated by the same producer: with `--ledger` and `--deck-text` it refuses `OCR_CHECK_MISSING` / `OCR_CHECK_OTHER_RUN` / `OCR_CHECK_STALE` (the check carries `reconciliation_sha256`) / `OCR_CHECK_WRONG_DECK` whenever a figure the kept relations use sits on an image slide; a deck with none needs no check. Measured: removing a required artifact leaves `compose_report.py` exiting **0** with a complete report, so a step whose only downstream consumer is a warning gets skipped in silence — which is exactly what happened to the removed claim-check step. The gate sits on the producer of the deliverable, and checks `run_id` parity rather than mere presence (a stale artifact from an earlier review of the same company otherwise satisfies an existence check, and `setup_run.py --clean`, which removes the skill's own earlier-run checkpoints (scripts delete under outputs only their own files; a failed atomic write also removes its temp file), tolerates a refused delete, which hosts before Desktop 2.16120.0 gave; the model never deletes there).

## IC Simulation Scripts

- **`fund_profile.py`** — Validates fund profile structure (archetypes, check size, thesis, portfolio). Partners are role names only: closed key sets (archetype `role` + `focus_areas`; portfolio `name` / `sector` / `status`; the top-level keys), and a source URL that is a person's page (LinkedIn `/in/` or `/pub/`, also behind `/mwlite/`; Crunchbase `/person/`; path percent-decoded, `https:/host` read as the host) is refused; codes `PARTNER_NAME_NOT_ROLE` (a `name` equal to the role label is tolerated and not written), `ARCHETYPE_FIELD_NOT_ALLOWED`, `FUND_FIELD_NOT_ALLOWED`, `PORTFOLIO_FIELD_NOT_ALLOWED`, `SOURCE_IS_A_PERSON`; a diagnostic names where, never the refused value, and on those codes withholds the archetypes, portfolio, sources and unknown keys from its stdout echo (any other refusal echoes the whole input, free text included: it reaches only the main thread that wrote it). `ROLE_LABELS` here is what compose renders the Fund Profile's partner labels from. With a run ledger, refuses (exit 10) until the mode and fund questions are recorded, refuses a profile whose `mode` is not the recorded fund choice (`GATE_RECORD_MISMATCH`), and refuses `RUN_FINISHED` on a delivered run. `--from-request` builds the profile from the run's recorded `ic_fund_mode=host_profile` value (a fund profile supplied with the request; the model never types it): the Step-1 caps again (`FUND_FIELD_TOO_LONG`, `FUND_FIELD_INVALID_CHARS`, via `_gates.fund_value_problem`), the request rules, the people rules, a closed `profile_source` stamp, written compactly and deterministically; `HOST_PROFILE_NEEDS_RUN` with no ledger, `HOST_PROFILE_REQUIRED` for a stdin profile on such a run. `founder_lines()` owns the fund's kind and provenance wording (compose renders it; the page must too)
- **`host_context.py`** — Steps 2-3: copies the kept items of the recorded `host_context` into `startup_profile.json` (it first applies a pending line Step 1 did not open, through `_ic_gates.require_or_exit`, and waits 10 / 12 on one that cannot be read; re-derived from the ledger; `GATE_RECORD_MISMATCH` on any disagreement; `HOST_CONTEXT_NOT_SUPPLIED` for a model-written block); no ledger is a no-op. `host_context_lines()` / `host_context_parts()` own the founder-facing wording. On an attended run a question only a request line can answer is never in `needs_input`: `open`, `host_context.py` and compose exit 10 with `request_waiting` (`open` then asks nothing else that turn), and the run stays `waiting` / `PRE_ANSWER_MALFORMED` for the corrected line's resume. Compose refuses a profile that disagrees with the run (`HOST_CONTEXT_MISSING`, also for a pending line no step applied; `HOST_CONTEXT_NOT_SUPPLIED`; and for the fund `FUND_PROFILE_SOURCE_MISMATCH`, also when a supplied fund has no profile), renders the kept items verbatim with a keep-set passed to both `substitute` and `scan`, and records `report.json.host_context`
- **`_ic_gates.py`** — ic-sim's gate checks for `fund_profile.py`, compose and the page: one run-id derivation (the id most of the run's files agree on), `RUN_ID_REQUIRED`, exit 10 naming the question, `RUN_FINISHED` on a `complete` run; `refuse_open_gates` / `coaching_pending` are `_ms_gates.py`'s, held equal by a test
- **`detect_conflicts.py`** — Validates agent-produced conflict assessments and computes summary stats
- **`persist_partner.py`** — Steps 6 and 6b's persisting step: pipes one partner's gated hand-off into `partner_{assessment,rebuttal}_<archetype>.json`, replacing any input `metadata` / `graded_against` with the run id and the fund-profile stamp. Refuses loudly (exit 1, `-o` untouched) a hand-off whose `partner` is another archetype (`PARTNER_MISMATCH`), an `-o` that is not that archetype's file (`OUTPUT_NAME_MISMATCH`), a dir with no readable fund profile (`FUND_PROFILE_MISSING`), a key outside the dispatch template's closed set for its kind, which also refuses a body of the other kind (`PARTNER_FIELD_NOT_ALLOWED`), and the same hand-off re-piped after the profile changed (`HANDOFF_NOT_REDISPATCHED`).
- **`score_dimensions.py`** — Scores 28 dimensions across 7 categories with conviction-based scoring; a missing or bad `--fund-mode` adds a `fix:` line with SKILL.md's line for it and the two choices
- **`compose_report.py`** — Assembles IC simulation artifacts into final report with cross-artifact validation; with a run ledger, refuses (exit 10) to write a Decline up until `ic_decline_confirmation` is recorded (owed only on a `pass`/`hard_pass` verdict). After the run gates it refuses (exit 1, nothing written, `FUND_PROFILE_NAMES_PEOPLE` in the `_ic_gates` rejection shape, so `compose_result.json` carries it) a `fund_profile.json` with a place for a person by the producer's own rules (`people_problems`: any key the producer would refuse, so a profile it wrote never trips it), which catches both a profile written before partners became role names and one written around the producer; the one exemption is an old generic profile's invented `background`. SKILL.md routes it to a rebuild from Step 4. Then, also before any write, it refuses `PARTNER_ARTIFACTS_STALE` (same shape, also in the host contract) when any of the six partner files, `conflict_check.json`, `discussion.json` or `score_dimensions.json` was not built from the current fund profile: `persist_partner.py` (Steps 6 and 6b), `detect_conflicts.py` (both paths), `compose_discussion.py` and `score_dimensions.py` stamp `graded_against.fund_profile` (the profile beside their `-o`, fingerprinted without `metadata` / `validation` / `accepted_warnings`, so accepting a warning stales nothing), and a missing stamp counts as stale in every mode (a pre-stamp run resumed under this version redoes Steps 5-8 once; a missing profile or artifact is left to `MISSING_ARTIFACT`). This is what makes the rebuild remedy real: rebuilding Step 4 alone no longer composes. `compose_discussion.py` refuses the same code (exit 1, `-o` untouched) when a partner file is unstamped or another profile's, so redoing Steps 7-8 alone after a rebuild does not re-stamp stale round-1/2 prose. What a stamp proves: the profile on disk when the script ran, which is the one the step's dispatch inlined only if the SKILL.md order was followed; a hand-written stamp still defeats it. `persist_partner.py` and `score_dimensions.py` also record `graded_against.source_sha256` (the hand-off bytes they consumed) and refuse `HANDOFF_NOT_REDISPATCHED` (exit 1, `-o` untouched, in the host contract) when the existing `-o` was built against an earlier profile from byte-for-byte the same hand-off: re-piping old hand-offs is not a redo. Never refused: a repeat under the same profile, or a missing / unreadable `-o`. Residuals: a one-byte edit of the old hand-off, or deleting `-o` first, passes; `detect_conflicts.py` is exempt (a clean `{"conflicts": []}` hand-off legitimately repeats byte for byte, and `--generic-stub` has no sub-agent at all). Posture shared with every ic-sim producer, not fixed here: `persist_partner.py` has no run gate (no `RUN_ID_REQUIRED` / `RUN_FINISHED`, `--run-id` not compared with the dir's ref), and its `-o` check is basename-only (another sim dir or a symlink is accepted) — the planned fleet-wide `OUTPUT_IS_INPUT` guard is the place for that. The page does not check staleness (it runs on finished runs, where no step can redo them).
- **`visualize.py`** — The report page on the shared `_ui.py` shell (verdict and conviction ring, key findings, the supplied context verbatim, the debate, all 28 dimensions as a filterable checklist with a `neutral` tone for a moderate result, the radar, the fund panel from `fund_profile.founder_lines()` / `illustrative_note()`, conflicts); outputs HTML (not JSON). Every page-built string goes through the founder-text policy with `host_context.keep_set` (the keep-set report.md uses, recorded as `report.json.host_context.keep_tokens`), then `_esc`. Refuses the same old profile with the same code (it has no run gate, so it also runs on a finished old folder).
- **`_ic_view.py`** — The one owner of the verdict sentence, thin-basis note, score text, partner verdict words (`VERDICT_WORD`: never a bare "pass"), ring tone and check-size text that report.md and the page share. Not a scoring file.

## Financial Model Review Scripts

- **`extract_model.py`** — Extracts structured data from Excel (.xlsx) and CSV files into model_data.json
- **`validate_extraction.py`** — Anti-hallucination gate: cross-references model_data.json against inputs.json (company name, salary, revenue, cash traceability, scale plausibility); `--fix` auto-corrects scale denomination issues (e.g., model in $000)
- **`validate_inputs.py`** — Four-layer validation of inputs.json (structural, consistency, sanity, completeness); `--fix` auto-corrects sign errors
- **`checklist.py`** — Scores 46 criteria across 7 categories with profile-based auto-gating by stage/geography/sector; `--inputs <path>` supplies the document to fingerprint (the sub-agent's payload carries `company`, not the whole inputs, so without it the fingerprint is null and staleness is undetectable for this artifact)
- **`unit_economics.py`** — Computes and benchmarks 11 unit economics metrics against stage-appropriate targets. With a run ref in its `-o` dir it refuses (exit 10, `-o` untouched) until the values check (`fmr_extracted_values`) is recorded for the run and still matches `inputs.json`; `runway.py` also waits for the Step 1 cash questions. A finished run whose figures changed is `RUN_FINISHED`; a bound dir with no run id is `RUN_ID_REQUIRED`. The checks live in the skill-local `_fmr_gates.py`.
- **`runway.py`** — Multi-scenario runway stress-test with decision points and default-alive analysis
- **`compose_report.py`** — Assembles financial model review artifacts into final report with cross-artifact validation
- **`visualize.py`** — Generates self-contained HTML with SVG charts; outputs HTML (not JSON)
- **`explore.py`** — Generates self-contained interactive HTML explorer from review artifacts; outputs HTML (not JSON)
- **`review_inputs.py`** — Dual-mode review viewer: HTTP server with live validation (Claude Code) or self-contained static HTML with JS sanity metrics (Cowork); outputs HTML. The static/Cowork branch must stay write-back-safe: guard every `/api/*` `fetch` behind the build-time `IS_STATIC` flag with a lexical `if/else` (an early-return guard reads as unguarded to the write-back analyzer), name the fetch response `resp`/`res`/`response` and check `resp.ok`, and keep literal `<script>`/`</script>` tokens out of docstrings (the block extractor mis-reads them). `test_review_inputs.py::test_every_api_fetch_in_the_static_page_is_in_an_is_static_else_branch` pins the guard on every `/api/` fetch of a static build (the harness analyzer reports `/api/feedback` as advisory with or without it, so it cannot).
- **`_theme.py`** — Brand theme helper: design-token CSS + base64 @font-face from `references/brand/`; every skill's scripts dir carries an identical copy (standalone scripts can't import across skills) and all HTML generators inject `_theme.brand_css()`; `tests/test_theme_sync.py` enforces the copies stay identical — edit one, re-copy to all
- **`apply_corrections.py`** — Processes founder's downloaded corrections file: coerces, normalizes, merges overrides, writes corrected_inputs.json + extraction_corrections.json. Its `--run-id` history is the evidence `corrections_applied` and the cash follow-up's `provided` are checked against (`_gates` `REQUIRES`). A delivered review's one cash update must give a finite balance and a `YYYY-MM` date (`_cash_given`, which cleans the two values with `_coerce_state` itself); one that blanks either is `RUN_FINISHED`
- **`_evidence_multiple.py`** — Detects a "times" comparison an assessor's own cited figures contradict, and appends the founder-facing caveat. A sibling module because FOUR surfaces render the same assessor-written evidence and none sees the others' output (`compose_report.py` → `report.md` and the coaching payload, `visualize.py` → `report.html`, `explore.py` → `explorer.html`); the first cut lived in compose and left both HTML pages showing the unsupported number plain. Calibration is a measured false-positive rate, not a judgement: `founder-skills/tests/evidence_multiple_corpus.py` re-runs it over kept run dirs and **imports this module** rather than restating the grammar — a tool with its own copy reports a healthy rate for a detector that has stopped working, which is how an earlier attempt shipped a pattern containing a literal backspace that matched nothing. Baseline 2026-09-19: 2,854 real findings, 39 stating a comparison, 1 fire (the defect). **Re-run it as reviews accumulate**; a fire count above the number of real defects means the check is miscalibrated.
- **`_fingerprint.py`** — Stable fingerprints of a producer's inputs, so a stale output is detectable. `run_id` parity cannot see this class: `apply_corrections.py` rewrites `inputs.json` **within** a run, so pre- and post-correction outputs share a run_id. `checklist.py` / `unit_economics.py` / `runway.py` stamp `graded_against`; `verify_review.py` recomputes the current `inputs.json` hash and compares. Comparing outputs to each other is insufficient — they agree while all are stale.
- **`verify_review.py`** — Review completeness gate: checks artifact existence, content quality (evidence, critical fields, metrics), and cross-artifact consistency; exit 0 = publishable, exit 1 = gaps
- **`fmr_closing_message.py`** — Prints the founder-facing hand-over from `report.json`'s computed `verdict` (the report's own first paragraph: model quality, base runway, and the runway at today's burn when that is the planning number) and writes it to `handover.txt` for the Stop hook; exit 2 without a verdict. Same contract as `cp_closing_message.py`, named apart so the hook can tell the skills apart (`closing_message.py` is a substring of it; the hook matches whole names). Prints the unreviewed-values sentence before the offer when report.json's `disclosures` say so, and stamps `handed_over_at` on a run with a ledger (stdout never changes). Refuses another run's report.json as `closing_message.py` does. The quick-check path has no closer.
- **`_fmr_gates.py`** — financial-model-review's gate checks for its producers, compose and the closer. Acts only when the review dir carries this run's `run_ref.json`; with none it returns at once and loads nothing. `unit_economics.py`, `runway.py` and compose exit 10 until `fmr_extracted_values` (and, for runway, the cash questions) is recorded or after `inputs.json` changes outside the two cash paths; a producer call into a bound dir with no run id is `RUN_ID_REQUIRED`; a changed figure after `complete` is `RUN_FINISHED`, except the cash follow-up, which reopens the run (`revision` + 1). A delivered (or stopped) review is never rebuilt in place: `settle_cash_followup` makes compose exit 10 for a post-delivery cash reply that reached runway unrecorded and otherwise refuse `RUN_FINISHED`, a plain recompose included; compose's own disclosed default is recorded only while the run is running, and the recorder's `default` on the follow-up of a `complete` run is `RUN_FINISHED` once a post-delivery cash call is on record. The parts shared with `_ms_gates.py` are copies held to it by a test.
- **`fmr_dispatch_prompt.py`** — Prints the CHECKLIST sub-agent prompt from identifiers (`checklist --run-id --handoff-agent --review-dir-agent --review-dir`), with `--correction` for a corrective redo (incl. `producer-rejected --detail-file`). It decides the template's one condition itself: this run's `inputs.json` `model_format` (deck or conversational: no extraction, whatever is on disk) and then the presence of `model_data.json` pick the one structural-error sentence printed. An empty identifier flag is refused (exit 2). Named apart from the other two generators, which the test suite imports into the same process.

## Cap Table Scripts

Rule-pack-driven cap-table math. Every math producer cites a `rule_id` from `cap-table-rules.json` (v0.2.8+). The pipeline is two-phase per design §9 Step 4.5 / Step 6: `rule_audit.py --phase=pre_math` writes the gating block math producers consume; `--phase=post_math` composes watchlist + counsel items after math runs.

- **`cap_state.py`** — Aggregates inputs + instruments into `cap_state.json` with `as_converted_totals` (the pre-financing snapshot the YC SAFE `company_capitalization` denominator binds to per Gotcha #1)
- **`safe_conversion.py`** — YC post-money SAFE math; all 5 forms; cap-implied + post-financing output sets; MFN cycle detection (Gotcha #4)
- **`note_conversion.py`** — Convertible-note math with full 7-branch enum (cap_conversion / discount_only / maturity_* / threshold_not_met) + override branch
- **`option_pool.py`** — Option-pool top-up math over the rule pack's five `target_basis` values (rule pack 0.4.10). Three are computed: `post_money` (the unallocated pool after the round is the target), `post_money_increase` (only the options ADDED in the round are the target — a different number whenever unallocated options already exist) and `pre_money`. The other two are refused, never approximated: `post_money_excluding_converting_securities` has no sourced definition as a pool denominator (every source sizes the pool on fully-diluted post-money INCLUDING conversions; "excluding converting securities" is real practice for the PRICE, not the pool), so the solver's basis gate (`priced_round.py`) blocks it with `E_POOL_BASIS_EXCLUDING_NOT_MODELED` when SAFEs or notes convert, unless the founder chooses the plain post-money reading (then `W_EXCLUDING_BASIS_MODELED_AS_POST_MONEY`, high); `custom` is blocked with `E_POOL_BASIS_NOT_MODELED` unless the founder states which computed measure the document matches (then `W_CUSTOM_BASIS_STATED_BY_FOUNDER`, medium). Either post-money reading beside existing unallocated options is ALWAYS disclosed — `post_money` as `W_POOL_BASIS_READING_NOT_CONFIRMED` plus the computed figure for the new-options-only reading, `post_money_increase` as `W_POOL_BASIS_READ_AS_NEW_OPTIONS_ONLY` with the whole-pool reading as its comparison — and no parameter clears it: a founder-answer flag the model writes is satisfied by writing it (it was tried, and a paid run wrote it with no question asked); a priced round also carries the pool on the other sizing basis, as computed figures or a stated reason it was not computed. **Each scenario's Option pool section in the report states another sizing, with its computed figures** (`_pool_text.py`); the coaching commentary does not discuss the pool's sizing at all.
- **`_pool_text.py`** — The option pool's founder-facing text in one place: the sizing labels (named by the share count each measure counts; the plain post-money measure bridges once to a term sheet's "10% post-money pool", and "pre-money" is never printed beside "pool"), the comparison and new-options digests, and `pool_section()`, which report.md, report.html and explorer.html all render per scenario. compose re-exports its names. The coaching payload carries none of it: the pool's dilution-driver entry has a `reference` to the section, and the pool-basis disclosures ride report.json's `report_disclosures` for the hand-over, as does a flip's unmodelled Section 102 line.
- **`pool_claims_check.py`** — Step 11's option-pool backstop. Judges the coach's commentary with `_pool_sizing_claims.py` against the run's computed figures (only for a single post-money or pre-money basis; otherwise `not_judged`, which adds the same founder line as a check that could not run plus `POOL_CHECK_NOT_RUN`), exempting only the sentences this run's own Option pool sections state, rebuilt from `scenarios.json` by the section builder rather than parsed out of a report. Exit 9 on findings (one corrective dispatch inside the 2-dispatch budget; with the budget spent, straight to `--strip`), then `--strip` removes the flagged claims, keeps `coaching.md` as written, and adds one founder line. Writes `coaching.md.pool-check.json` every run. Refuses (exit 2, `-o` unwritten) when this run's `scenarios.json` is missing, unreadable or has no run_id — failing open there would let a mistyped path skip the judge; past that point it fails open (`action: error`, this run's id). Its `-o` file is NOT an envelope `insert_coaching.py` accepts: **`pool_check_release.py`** turns it into one only when the record exists, matches the commentary and the checked file by sha256, is this run's, and its action is not `findings`.
- **The insertion requires the check.** cap-table's compose writes a `COACHING_REQUIRES_CHECK_RECORD` declaration beside the insertion marker; `insert_coaching.py` then refuses any envelope without a passing `check_record` for that run whose checked text is the text being inserted, and removes the declaration on insert. No other skill declares it, so their insertions are unchanged. What it does not stop: hand-editing `report.md`, or a hand-written record naming this run. The paid cap-table lanes assert the record exists for the run and that report.md carries the checked file.
- **`pool_clause.py`** — Checks that a pool sentence quoted from an uploaded document is really in it, so it may be shown inside the *Pool basis* question. Exact or whitespace/dash/quote-normalized matching ONLY, bounded to a whole sentence or clause, with a `(cid:N)` glyph as a barrier. Deliberately narrower than `evidence_verifier.quote_in_doc`: fuzzy matching accepted a meaning-flipped pool sentence, and compact matching makes `10.5%` equal `105%`. It is evidence, never the basis — only the answer to that question chooses the reading.
- **`anti_dilution.py`** — BBWA (with CP1 divisor per Gotcha #2) + full ratchet
- **`priced_round.py`** — Solver/orchestrator: fixed-point iteration for coupled SAFE + note + pool + new_money + AD systems. `solve_priced_round` is the ONE pool-basis gate every entry point (run_scenario, quick_assess, the CLI) reaches: it refuses or substitutes the basis and appends the pool-basis disclosures.
- **`flip_scenario.py`** — Israeli ↔ Delaware flip (v0.1: 1:1 share-for-share only per Gotcha #7)
- **`rule_audit.py`** — Two-phase (`--phase=pre_math` / `--phase=post_math`); scope-aware apply contract (`legal_tax_applicability` / `benchmark_freshness` / `not_applicable`); 5 mutually exclusive statuses + 2 near-edge overlays
- **`run_scenario.py`** — Top-level scenario orchestrator (routes by `scenario.type`); re-solves a priced round with a pool target on the other sizing (`pool_sizing_counterfactual`) and, where the reading was not confirmed, on the new-options-only reading (`pool_increase_reading`). `sweep.py` turns both off.
- **`counsel_packet.py`** — Counsel-handoff packet (json + md); standalone deliverable
- **`compose_report.py`** — Assembles report.md + report.json (with embedded `coaching_payload` block, schema_version `v0.5.0-cap-table`); priced-round reports carry a source-document reconciliation section (computed vs source-stated PPS / pre-money / FD from `inputs.stated_totals`). It does not compare `--run-id` with the artifacts: unbound, it stamps the id it is given and the insert and the pages refuse a foreign one (`RUN_ID_MISMATCH`); bound, `_ct_gates` refuses an id not bound there (`RUN_NOT_BOUND`)
- **`concise_report.py`** — Concise-mode report: renders the headline numbers straight from `scenarios.json` (lightweight math path; skips counsel_packet / full compose / visualize / explore / coaching)
- **`verify_one.py`** — Single-question cited rule-pack lookup (`--rule-lookup <rule_id>` → the cited constant + reliance boundary; lightweight answer path, writes no artifact)
- **`visualize.py`** — Self-contained `report.html` on the shared `_ui.py` shell (header card, section menu, callouts, shared tables and cards; inline SVG donut, no CDN; no print button, its `@page` and print rules kept)
- **`explore.py`** — Self-contained `explorer.html` (vanilla JS interactive scenario picker; number tickers, donut value-morph, Sankey transition, card slide-in, and an optional pre-money sweep slider when `sweep.json` is present)
- **`sweep.py`** — Optional `sweep.json` generator: a pre-money parametric sweep (K real priced-round solver frames, `new_money` held fixed) powering the explorer's slider; reuses the existing `run_all_scenarios` path (no new math). Slider snaps to discrete real frames.
- **`extract_instrument.py`** — Lane-1 anti-hallucination validator (sub-agent does extraction; this validates returned JSON, normalizes `discount_multiplier` per Gotcha #3). Supports `--verify` / `--verify-blocking` / `--invariants` / `--cross-check` / `--source-doc` flags, all default-on; uses `--no-<flag>` to opt out. Skips evidence checks on a ~30-field synthesized-fields list (form, jurisdiction, derived counts, etc.). The blocking gates run **before** `write_artifact`, so a refusal leaves `--instruments` untouched rather than persisting the extraction it just rejected. `--instruments` itself is a checked precondition: unreadable, non-object, a non-list array, a non-dict `metadata`, or an object carrying none of the four instrument arrays (i.e. the wrong file) are refused with `E_INSTRUMENTS_FILE_UNUSABLE`. It APPENDS, so a wrong-path write is unrecoverable — and the schema cannot stand in for the check, since the validator ignores `additionalProperties`.
- **`_ct_gates.py`** — cap-table's gate checks for `cap_state.py`, `run_scenario.py`, `quick_assess.py`, `extract_instrument.py`, compose and the terms-only renderer: exit 10 naming the question, exit 1 `GATE_RECORD_MISMATCH` for a request or file that disagrees with a recorded answer, `RUN_ID_REQUIRED` for an empty `--run-id` in a bound dir (every producer), and `RUN_NOT_BOUND` for a `--run-id` with no `run_ref.json` in a dir that carries another run's (every script that takes a `--run-id`: the producers, both pages, and the four that open no ledger, `sweep.py`, `detect_structure.py`, `write_coverage_disclosure.py`, `cap_state_after_round.py`; exit 1 and no artifact written, though compose still leaves its `compose_result.json` record of the refusal, stamped with the refused id: without the guard that id had no ledger, so every gate check was skipped in silence and the artifacts carried the wrong run). A dir carrying two runs' refs (a later run bound into an earlier run's folder) belongs to the newest bind: the earlier id is `RUN_SUPERSEDED`. compose checks this before `reopen_if_complete`, so a replaced review never takes the folder back by a what-if. A what-if on a `complete` review reopens it as a new revision, and a request a recorded answer rules out asks that question again. With a ledger, `extract_instrument.py` writes `extraction_pending.json` and asks the founder to confirm flagged values before it saves them (`--doc-text` / `--doc-text-source` verify a transcription in place of the text layer); the freeform emit records a spreadsheet-built base exempt from the base question, bound to that base. A priced round that converts a note with no stated qualified-financing threshold carries a disclosure that it was treated as met.
- **`extract_cap_table.py`** — Lane-2/3/4 (validate mode + Carta/Pulley stub + freeform Context-A output validator). `--mode=freeform-emit` deterministically maps Lane-3 SPREADSHEET_STRUCTURE_DETECTION blocks → schema-valid `inputs.json`+`instruments.json` (founder answers to gate blockers via repeatable `--answer BLOCK.FIELD=VALUE`).
- **`freeform_mapper.py`** — Pure `map_freeform(blocks, grid, existing_inputs, answers)` behind `--mode=freeform-emit`. Maps detected blocks via the closed `references/schemas/freeform-role-map.json` contract; off-contract roles + required-but-unsupplied fields (interest_rate_type, preferred OIP, enum plan_type) become blockers (no fabrication); per-target-array stable ids; merges equity into existing inputs (keep-existing-on-conflict). Deterministic.
- **`pdf_probe.py`** — Per-page probe of whether a cap-table PDF has a text layer or is image-only (decides if OCR/table extraction is needed vs raw text). [dep: pdfplumber]
- **`extract_pdf_tables.py`** — OCRs an image-only cap-table PDF (no text layer) into a cell grid for ingestion.
- **`_docx_text.py`** — Tracked-changes-aware `.docx` reader (stdlib `zipfile` + `xml.etree`): `detect_tracked_changes()` + `extract_text(revisions="accept")` keep `<w:ins>` and drop `<w:del>` so accepted-redline terms survive (python-docx drops both).
- **`evidence_verifier.py`** — Forward verifier. Three-layer check (quote_in_doc / value_in_quote / value_in_doc) catching HALLUCINATIONS. 3.6% FPR / 100% TPR on the private eval set.
- **`backward_verifier.py`** — Backward verifier (two-phase `--phase=prompt`/`--phase=score` CLI). Catches SEMANTIC CONFUSION via fresh-sub-agent re-extraction. WARN-mode default.
- **`invariant_checker.py`** — Real-world-bounds checker. Per-field ranges + cross-field math invariants. 0% FPR / 63% TPR.
- **`cross_checker.py`** — Demote-only confidence modulator when multiple extractors disagree.
- **`_normalize.py`** — Shared text-normalization primitives (normalize_text, compact_form, numeric_tokens, date_tokens).
- **`extractors/`** — Span-preserving extraction module: `FieldExtraction`, `SourceSpan`, `ExtractionContext`, `ExtractorProtocol` types + 5 SAFE backstop extractors (`extractors/safe/`).

## Competitive Positioning Scripts

- **`validate_landscape.py`** — Validates competitor list structure, checks slug uniqueness, preserves provenance
- **`verify_competitors.py`** — Adversarial competitor-set verification validator (Step 3.5, before Gate 1): validates the COMPETITOR_VERIFICATION sub-agent's per-competitor verdicts (genuine/adjacent/not_a_competitor), enforces the show-your-work gate (a flag must carry reasoning + independent buyer/job characterization), cross-checks landscape slug coverage, computes summary. Validator, not detector. Catches false-positive competitors (surface-level matches that don't genuinely compete). Emits `summary.challenge_slugs` — the subset of `flagged_slugs` that actually challenges the draft — so Gate 1 reads a judgement rather than re-deriving one across two disjoint vocabularies. On rejection it leaves `-o` untouched and keeps the artifact in a `.rejected.json` sidecar; `compose_report.py` raises `VERIFICATION_REJECTED` (high) on it, keyed on run_id so a prior run's file cannot silence a fresh refusal.
- **`cp_red_team.py`** — Validates the RED_TEAM reviewer's findings under the shared `_redteam_core.py`, rewords them (`_cp_redteam_text.py`) and writes this run's copy first (`_cp_redteam_copy.py`); every page renders from the copy, so editing `redteam.json` changes nothing the founder sees. Refuses loudly without the shared core or a readable `landscape.json` (a review with no competitor names would print slugs). `_cp_redteam_text.py` leaves a one-word slug alone: in prose it is more likely the ordinary word.
- **`record_red_team_skip.py`** — Records why the review did not run, from a closed list of reasons. Under `FS_HOST_NO_ASK` `founder_declined` is refused before the write (`NO_ASK_FOUNDER_DECLINED`, top level and under `validation`); competitive-positioning's compose refuses a hand-written one, and market-sizing's compose the same reason in `methodology.json`. **compose refuses a run with neither this run's review nor this run's skip** (exit 1, `-o` untouched), keyed on this run's id (the id most usable required artifacts carry, an even split going to the id whose files were produced last, so one leftover file cannot decide it; the id the review is resolved for; a stub's id never counts), never on the hand-off dir, so deleting `handoff/<run_id>/` cannot silence it (the Cowork outputs folder allows deletes since Desktop 2.16120.0; "never delete under outputs" is our rule). A review or skip of another id the set carries does not satisfy it: a mixed set with no review of this run is refused, naming the ids; one reviewed for this run passes to `STALE_ARTIFACT` (high). A run that dispatched no sub-agent records `no_subagent_dispatch`. market-sizing's `REVISION_NOT_OFFERED` likewise no longer goes silent without the dir. The four deliver-only review codes are medium and cannot be accepted away, not high as in market-sizing, because Step 7 composes under `--strict` and a high code there would stop a run nothing can fix.
- **`_quote_match.py`** — A copy of cap-table's matcher, held to it by `test_quote_match_sync.py`.
- **`cp_closing_message.py`** — Prints the founder-facing hand-over from `report.json`'s computed `verdict` (the map sentences with "If delivered" and the competitor asymmetry, the claims tally, defensibility, the public record) and writes it to `handover.txt` for the Stop hook; refuses without a verdict, or a report.json of another run than its artifacts' majority; stamps `handed_over_at` with a run ledger. Same contract as market-sizing's `closing_message.py`.
- **`cp_dispatch_prompt.py`** — Prints the MOAT_SCORING, POSITIONING_SCORING, CHECKLIST, STARTUP_RESEARCH and RED_TEAM sub-agent prompts (RED_TEAM lists the founder's documents before the analysis's files, and leaves out the checklist, which is the analysis grading itself) (`--correction` for a corrective redo); each is registered with the dispatch-prompt hook, so a prompt that is not the printed one is held. Named apart from market-sizing's `dispatch_prompt.py`, which the test suite imports into the same process. With a run ledger it prints nothing (exit 10) while a question a prompt rests on is unrecorded or open, and takes the recorded scoring basis (another is `GATE_RECORD_MISMATCH`).
- **`record_deferred_recall.py`** — Appends the recall candidates the founder declined at Gate 1 to `landscape_draft.json`'s `deferred_recall_candidates[]` in place, keeping the `_produced_by` stamp. `--from-verification` reads them from `competitor_verification.json` (no hand-built list); with a run ledger it refuses (exit 10) while Gate 1 has no final answer.
- **`_cp_gates.py`** — competitive-positioning's gate checks for `record_deferred_recall.py --from-verification` (Gate 1's deferral, and the check before Step 4), `validate_landscape.py`, the generator, `persist_agent_artifact.py`, compose and the closer: exit 10 naming the question, `RUN_ID_REQUIRED`, `RUN_FINISHED` only for a new analysis's first writes; compose on a `complete` run starts a new revision. The parts shared with `_ms_gates.py` are copies held to it by a test. Gate 1's question and its lines are printed by `open` (`_gates.py`'s CP fillers).
- **`score_moats.py`** — Scores 6+ moat dimensions per company; refuses a patent/licence/certification custom moat (scored under `regulatory_barriers`) and a custom moat with no `definition`; rewords our file names in evidence at pipe time; keeps the run's first copy (`_cp_first_copy.py`) so "Founder Override" shows only for a value that changed
- **`score_positioning.py`** — Scores positioning views: ranks with ties recorded, the geometry the founder-facing sentences are built from (who is ahead on both axes, nearest competitor, lead over the best rival per axis), a planned position kept apart from today's (`scored_point`, `today`), and claim `verdict_counts`
- **`validate_startup_research.py`** — Validates the STARTUP_RESEARCH hand-off and COMPUTES each patent publication's status from its office and kind code (granted / published / unknown, never "pending"); rejects loudly.
- **`_cp_view.py`** — The one place founder-facing wording is decided: the map sentences ("If delivered", ties, bands), the verdict paragraph, the proof-gap rows, the public-record rows and the outside review's outcome sentence. Every renderer and the coaching payload call it. It also owns the analysis checklist's wording for report.md and both pages (`checklist_groups`, `criterion_text`: ids become their labels, context words plain words, the fleet policy, loaded by path, humanizes, and a sentence naming an internal file is dropped with a note; it fails closed).
- **`checklist.py`** — Scores ~25 quality criteria across 6 categories with mode-based gating
- **`verify_positioning.py`** — The delivery gate (`--gate 1` mid-run, `--gate 2` at the end): artifacts present, rendered checks (what the artifacts hold reaches report.md and report.html; no internal field name, artifact filename or criterion id in report.md), cross-artifact checks; exit 1 on any error. At gate 2 it also scans the two pages' visible text (text nodes only, never script or style; an unparseable page is skipped) for internal file names and criterion ids, reported as WARNINGS only, never a gap; it reads static text, so text the explorer's script builds in the browser is outside it.
- **`compose_report.py`** — Assembles report with cross-artifact validation, warning system, and accepted warnings; its last body section, before Warnings, is the "Analysis Checklist" (every criterion per category, its evidence through `_cp_view.criterion_text`, made inert for markdown)
- **`visualize.py`** — Generates self-contained HTML on the shared `_ui.py` shell (header card with the analysis-quality ring, section menu, the checklist) with SVG positioning map, moat radar, competitor table; outputs HTML (not JSON)
- **`explore.py`** — Generates interactive HTML explorer on the shared `_ui.py` shell (the report page's header card and ring; a Checklist tab leading to the analysis checklist, rendered on the page outside the tab panels and never in `DATA`) with Chart.js scatter plot (vendored, no CDN for the 2D view; the optional 3D View tab lazy-loads Plotly from a CDN on demand), view switching, bubble encoding controls, and company detail panels; outputs HTML (not JSON)

## Dev Setup

Install dev dependencies:

```bash
uv sync --extra dev
# or: pip install -e ".[dev]"
```

## Linting & Formatting

```bash
uv run ruff check .          # lint
uv run ruff format .         # auto-format
uv run ruff format --check . # check formatting without changes
```

## Type Checking

Scripts in different skills share filenames (`checklist.py`, `compose_report.py`), so mypy must be run per directory:

```bash
uv run mypy founder-skills/skills/market-sizing/scripts/
uv run mypy founder-skills/skills/deck-review/scripts/
uv run mypy founder-skills/skills/ic-sim/scripts/
uv run mypy founder-skills/skills/financial-model-review/scripts/
uv run mypy founder-skills/skills/competitive-positioning/scripts/
uv run mypy founder-skills/skills/cap-table/scripts/
uv run mypy founder-skills/tests/
uv run mypy founder-skills/scripts/
```

## Using `cowork-harness critique` (read before trusting a grade)

Rules live at **4.11.0**. Each was learned by measurement; the measurements and the version-by-version
history are archived locally beside the per-release adoption plans.

**Invoking**
- `critique` takes a POSITIONAL skill folder. Always pass `--out <skill>.json --output-format json`: `--out` alone writes TEXT, and `json.load` then fails with `Expecting value: line 1 column 1`, which reads as a corrupt report (1.24.0 warns at parse time). The index cannot attribute a critique (records `command: "skill"`, no `skill` field, `session.json` has neither `skill` nor `prompt`), so `--out` is the only provenance. `--label` lands on turn 1 only, deliberately.
- Tier prerequisites: `container` / `hostloop` need a token in the environment or in `.env` (`CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_API_KEY`) — ours lives in `cowork-tests/.env`, and `.env` is discovered from CWD only, so run from `cowork-tests/` or `doctor` reports a misleading Keychain error. The evaluator passes need `claude` on PATH (`COWORK_HARNESS_CLAUDE_BIN` overrides). Upstream docs said the opposite until 1.19.0.
- Use `--fidelity cowork` (resolved once, before either turn spawns, via the pinned baseline's loop gate; echoed `[loop] cowork → <tier>`, reported as `requestedFidelity`). The default `container` is not the tier the cassettes record at. `microvm` / `protocol` refused; `chat` refuses `cowork`. `--dotenv`: the child CLI loads that file before deciding, so a `CLAUDE_FORCE_HOST_LOOP` in `./.env` is read during resolution.
- Do NOT swap `--evaluator-model` on a fleet sweep. For document-analysis skills — every skill here — the task turn is ~61% of spend and the evaluator ~30%, so the saving is a third of its advertised size and it trades away the injection-resistance property verified for the default evaluator only. The levers are `--model` and probe scope. The default task-turn timeout is 30 min, which is NOT enough for market-sizing on a large corpus once the revision round runs: a 2026-09-24 run was killed mid-turn after two red-team rounds, produced no report, and was billed anyway. Pass `--timeout` for that shape. The report's `cost:` line prints the evaluator share.
- `--ablate-skill` is ONE arm; with `--repeat N` it yields N ablated runs and zero treatment runs — run again without the flag. Rollups label arms (`[ABLATED — control arm]`, `[MIXED ARMS: k/n ablated]`); `evals/cap-table/run_reliability_bench.py` parses `rollups[]` and reads these defensively.
- `git add` untracked skill files first. Untracked `references/**` or the agent body → EXCLUDED and named in `evidenceBudget.corpusExcluded` — the CORRECT outcome (evaluator sees what the agent sees), and a "the skill never explains X" finding against it is properly grounded. Untracked `SKILL.md` → `skillMdStatus: "untracked"`, content withheld, forces `not-adjudicable`. A plugin-root reference shows up as `corpusOmitted[].alsoUntracked` — three-state, absent ≠ false.

**Reading a report**
- Budget from `report.costUsd.totalUsd`; `index.jsonl` omits the evaluator passes. Each critique also writes a roll-up row (`critiqueTotalUsd`), so `stats` totals are exact and label-filtered totals are not short (`unpricedRuns` says when a total is a floor). `costUsd` carries a per-pass `{input, output, cacheRead}` split. Whole-content packaging costs +5–18% per critique.
- `record`'s `ok` means "exited 0"; the recording's verdict is `results[0].verdict.pass` (4.0.0). A refusal AFTER the run (a failing live verdict, a privacy finding) exits 1 with `ok: false`, category `runtime`, and carries the run it refused in `results[0]` (a single file) or on the failed item's `verdict`/`result` (a batch), whose cost counts toward `--max-budget-usd`. A refusal BEFORE the run (credentials, model, budget, policy) carries no run.
- Items carry `idea` / `recommendedAction` / `evidence` / `source`. There is no `title`.
- A `scripts/`-grounded `not-adjudicable` means "the evaluator could not SEE the code", not "the claim is false" — `scripts/` is outside the corpus by design. If a script's contract matters to how the skill is used, state it in SKILL.md or a `references/` file. Bites deck-review hardest: its gate contract lives in `gate_state.py`.
- `noSkillFilesRead` is observational. ic-sim trips it on a correct run because `evaluation-criteria.md` / `partner-archetypes.md` are inlined into `agents/ic-sim.md` (each reference's header says so); do not add reads to silence it. Since 2.5.0 it keys on the wide `unionReferenceAccesses` (Read ∪ Grep ∪ a Bash command naming the path, main ∪ sub-agents) with a third state `referenceAccessUnobservable` that must never be read as "none". A `$VAR`-built path is an accepted detector miss, and our SKILL.mds use `"$SCRIPTS/…"` — one literal path flips the signal. The matching `reference_read` / `no_observed_reference_access` scenario keys evaluate on replay but are NOT adopted (freezing one raises the replay floor to 2.5.0).
- One critique is a sample (upstream measured 78 vs 50 extracted figures across two runs of one bug). Reproduce ≥ 2 runs. A fleet-consistency defect is out of scope for any single critique by construction; that is what the drift tests are for.
- `result.json`'s `models` can contain `<synthetic>` (a locally fabricated turn, recorded verbatim). Drop `<…>` entries before using it as provenance. After a re-record, check `provenance.model` is UNIFORM across the corpus — a corpus silently spanning two models is a real hazard (tier changes correctness) and nothing else checks it. The AGENT version needs no hand check since 4.2.0: `verify-cassettes` (a `notes[]` entry) and `replay` (a `::notice::`) print an `agent-version` note when a recording's agent differs from the one its baseline pins for that tier. None is expected on a recording made under 4.11.0, which pins the agent Desktop stages (2.1.308); a cassette recorded on an older agent and hand re-stamped carries one, which is why 4.10.0 re-stamped none; under 4.5.0 a 2.1.293-vs-2.1.289 note on every recording was the accepted hostloop patch substitution. The note never fails a gate, so read for it.
- `resultErrorKind: usage_limit` (4.7.1) means a sub-agent hit the account's usage limit: the run is no evidence about the skill, never a skill failure. `record` refuses to freeze one without `--allow-failing` (which we never pass) and `critique` exits 2; re-run it. On the cloud lane a sub-agent's failure text is not shown, so it is not detected there.
- A green run prints `·`-prefixed warn signals in its footer (`undelivered_deliverables`, `ended_with_question`, `scan_unavailable`, `exec_infra_error`, `prompt_asset_missing`, and since 4.3.0 `partly_scripted_gate`: a question batch whose `answers:` match only some sub-questions goes wholly to `on_unanswered`). Read it.
- `[provenance] model=… skill=offered,invoked ablated=…` rides every verdict and `results[].provenance`. `skill=…invoked` means the Skill channel was used, NOT that the skill under test ran — `skill_triggered` is the identity assert. `offered,unknown` / `unknown` mean evidence unavailable, never "no". `--compact` / `--demo` suppress it, and `[status]`.
- Keep pre-upgrade reports: per-item verdicts are not comparable across corpus-packaging changes, but per-skill `not-adjudicable` counts on identical prompts are, paired with the `citationResolved:false` rate.

**Corpus size (`evidenceBudget`)**
- Four classes count against a 512 KiB ceiling: SKILL.md + every file under the skill's own `references/` (ANY extension — schemas and rule packs count) + every agent the skill resolves (a UNION: `agents/<skill>.md`, every pinned `subagent_type` literal, every agent whose `name:` equals the skill, and the transitive closure of pins inside those bodies — market-sizing and competitive-positioning each pin two, which is why the term is a union; the fleet has EIGHT agents) + every plugin-root `references/` file the skill's own text points at. Over the ceiling, content is cut before grading and named in `evidenceBudget.corpusCuts` (`skillMdTruncated` is gone).
- A data file scripts read by path belongs in `data/`, not `references/` (`cap-table-rules.json` moved 2026-08-01). Only evaluator-citable evidence is corpus. Relocating prose between SKILL.md and `references/` is corpus-neutral.
- Measure with `tests/_critique_corpus.py` (`_corpus_bytes` in `test_skill_contract.py`) or `evidenceBudget.corpusBytes` on a real report. `lint-skill` (>=3.7.0 — earlier versions omit a whole class) prints the number only at ≥ 80% of the ceiling, so its silence is not a measurement. `test_skill_contract.py` guards the ceiling and cap-table's `> 10,000 B` margin; the margin figure has rotted in prose repeatedly — derive it.

**Scenario and cassette tooling**
- Everything the harness prints goes to STDERR, `--help` included since 4.0.0; stdout is empty. Never poll with `status | grep` (exits immediately and silently) and never "fix" that with `2>&1`. Use `status <dir> --follow` or `status <dir> --output-format json`; long runs also heartbeat on stderr (`COWORK_HARNESS_NO_HEARTBEAT` / `_HEARTBEAT_MS`). `[status] <outDir>` is withheld under `--compact`/`--demo` but `status.json` is written regardless. Do not watch the outputs dir for artifacts.
- `--run-dir <fresh dir>` isolates a run's history, and with it disables `--max-budget-usd`: the cap prices from prior runs, and a fresh dir has none, so the run proceeds UNCAPPED. Never isolate a paid re-record that way.
- `lint` is LENIENT; the loader is STRICT. Validate ONE scenario with `record scenarios/<s>.yaml --dry-run --out /tmp/probe.cassette.json` — `--out` is mandatory, because the bare form also pre-checks the cassette DESTINATION and refuses a valid scenario with no committed cassette (exit 2 at ≤ 3.1.0, 1 at ≥ 3.2.0; `fidelity: container` is exempt). Never spend `--allow-host-inventory-fixture` on a load check. For the WHOLE corpus use the DIRECTORY arm, `record scenarios/ --dry-run --quiet` (what CI runs): 0 = all load and under cap; 1 = a scenario did not LOAD (`✗ broken:` on stderr), a path-independent refusal (prompt policy, assert contradiction, duplicate target), or — since 4.0.0 — the budget gate refused, with or without broken files beside it (it was 2 before); 2 = a usage or setup error. Anything wrapping it must tell a budget refusal from a load failure, and not by exit code: 4.0.0 moves the budget refusal from 2 to 1. `rerecord.sh` keys on the harness's "refused before spending" line plus any other `✗` line (`cowork-tests/preflight_classify.sh`), so it reads right under both. Exit 0 is not the whole verdict since 4.1.0: an input a real `record` would refuse (a bad path, effort or baseline, or a tier-vacuous negative tool assert) is listed under `inputErrors[]` in `--output-format json` with a `⚠ input error:` line, and the exit code stays 0 — CI gates on `ok` AND `inputErrors == []`, requiring the key (a budget refusal's envelope replaces the payload and has none). Non-recursive; a file with no `prompt:` key is `· skipped:`, not broken.
- `lint --strict` fails on ERROR and WARN and hides INFO (4.0.0; before it, it failed on INFO too); CI keeps its explicit `--min-severity WARN`, so the step means the same under either. Read the exit code, not the summary line. `strict: true` belongs on the lint step only — on replay steps it also fails on cassette staleness, which is WARN-only by design.
- `lint`'s vacuous-gate fix line is wrong for a scenario DESIGNED to fire no gates (`cap-table-lane3-freeform`): the remedy there is to drop `gate_answers_delivered`, not add a companion.
- `lint` and the bundled `scenario.py lint` do NOT catch a `tool_not_called` / `subagent_tool_absent` naming a tool the tier does not serve (their tier table has no `cowork` row). `record --dry-run` does since 4.1.0, on both arms: it lists it under `inputErrors[]` with exit 0, which is why CI gates on that list. At hostloop the shell is `mcp__workspace__bash`, so `'Bash'` there is vacuous. Our own Context A agents declare no shell tool of any name, so the corrected assert is unviolatable for agents we wrote; its live value is a dispatch we did NOT write — a `Task` with no `subagent_type` falls back to `general-purpose` with a wildcard tool surface including workspace bash. Do not delete it as vacuous. `test_cowork_invariants.py`'s per-agent tool declarations are the primary enforcement.
- After editing any `assert:` block: `replay cassettes/<s>.cassette.json --assert-from scenarios/<s>.yaml` — free, ~1 s, no Docker. It refuses on prompt drift, and that refusal means "re-record required". On every stale lane its exit 1 also carries a `skill-source drift (--fail-on-skill-drift)` line — read the per-assert lines, and never waive it with `--allow-failing`. `verify-run` is the kept-run-dir equivalent and fail-closes on a PARTIAL run.
- A recorded cassette is NOT relocatable (`scenario.session` / `scenarioSource` are directory-relative). Never rehearse on a `/tmp` copy — the copy manufactures `unverifiable-skill` / "skill dirs not resolvable" and hides the real finding.
- `record <dir> --case <stem>` (4.7.0, repeatable) limits a directory record, its `--dry-run` and its `--max-budget-usd` pre-flight to the named stems, which is how `rerecord.sh` prices a refresh; every pre-spend check then covers only those stems, so a broken scenario nobody names is caught only by the plain directory dry-run CI runs. It is refused with a single-file target.
- Load refusals added since 4.6.0: on `lane: remote`, every key that reads the container's files (`artifact_text`, `artifact_json`, `file_absent`, `no_unexpected_files`, `computer_links_resolve*`, `no_lost_write_back`, `file_exists authored: true`, judged keys with `evidence_files`) is refused at load and `lint` reports ERROR `lane-remote-incompatible-key` (4.6.1; plain `file_exists` is the remote-lane form); a negative assert on a device tool (4.8.0) or an `mcp__visualize__*` tool (4.9.0) is refused too.
- Under `on_unanswered: fail`, an unanswered `web_fetch` approval fails the run, exit 2 (4.9.0); `first` still denies it silently. Before recording a `fail` scenario that may fetch, give it an explicit `answers:` rule (`when_tool: "webfetch:<domain>"`).
- Every recording since 4.7.0 carries `hookFailureBlocks`, the events that may carry an `onFailure: "block"` hook, read from the staged plugins, the run's config dir and (at hostloop) the host's managed settings. Ours should read `{"events": []}` and stamp v12; an event listed, or an inventory left `unknown` by a settings file that does not parse, stamps v16 and reds the floors test's single `_CASSETTE_VERSION`. `rerecord.sh` refuses a managed settings file that does not parse or names `onFailure`. Check it after every re-record. A 4.10.0 recording on agent 2.1.299 can also carry an extra `subagent_result_meta` frame for a forked Skill's envelope (upstream: a no-op for consumers); expect it in the first normalized `diff`.
- `semantic_matches` judges top-level `assistant_text` ONLY — no tool blocks, no sub-agent text (unless `include_subagent_text: true`). We use none; keep it that way. The key is `subagent_dispatched` (with "ed"; four upstream surfaces misspelled it). `present_files_called` is `z.literal(true)` / "at least one file", no per-file match — which is why `cowork-tests/delivery_check.py` exists. `subagent_declared_but_unused` is near-always vacuous (0 of 1091 real dispatches carry a declared tool list).
- `assertions --list` emits `{key, description}` — no replay class; read classes from `docs/scenario.md`.
- Look BACK over runs already made with `stats <scenario> --group-by skill-hash --runs`; runs before and after an edit share a scenario dir and `stats` warns on `distinctSkillHashes`. Narrow with `--skill-hash <prefix>` or `--label`; `--since` breaks when two versions run on one day. A PLANNED A/B is `eval` (below).
- `eval` (4.2.0, EXPERIMENTAL upstream) runs two plugin versions interleaved and compares how often each assertion passes, and, since 4.9.0, the `metrics:` a scenario declares (a not-compared metric row exits 1, with no off-switch; none of ours declares one). Every eval is a paid run needing the owner's approval — there is no standing budget. It is never a CI gate or a routine step: most of our assertions pass near 100%, where a rise is undetectable and the smallest detectable drop at 5 reps is 80pp, so it would print "no detectable change" as unearned reassurance. Use it where it has power: a fix aimed at a lane whose pass rate sits between roughly 25% and 75% (the flaky lanes), or a content change once an assertion (e.g. `artifact_json` over a checklist) turns that change into pass/fail. Before spending, read the minimum detectable difference it prints while planning and stop if the expected effect is smaller. Pin concrete model ids; `--out` must be OUTSIDE a git work tree, while a `git:<ref>:<path>` arm needs the cwd INSIDE one; read `summary.erroredArms` before any row. `rerecord.sh` runs `prune --keep-last 5`, which trims eval runs too and leaves a report's run links dead (`eval report <dir>` still rebuilds it). A label is a signal to investigate, never proof.

**Privacy gate**
- `privacy-allowlist.sh` defines a bash ARRAY, so `source` alone changes nothing: `source cowork-tests/privacy-allowlist.sh && cowork-harness verify-cassettes cowork-tests/cassettes "${ALLOW[@]}"`. Sourced-but-unexpanded reports thousands of findings and looks exactly like a broken allowlist. Every `--allow*` regex is FULL-MATCH inside the harness's own wrapping — `founder-skills:.*` clears a class, `^founder-skills:` clears zero. Over-tight fails safe; over-loose disarms a class silently. Re-count after any edit and confirm `canary/email-canary.cassette.json` still flags `[email]` — it is hand-authored, deliberately `cassetteVersion` 10, and is never re-recorded.
- Never pipe `verify-cassettes` to `tail` (deterministic `EAGAIN` crash replaces the verdict). Redirect to a file.
- The per-class header counts INFORMATIONAL classes; `findings by class: unscanned N` with exit 1 is staleness / scenario-drift, not PII. CI's privacy step passes `--skip-staleness --skip-scenario-drift` and exits 0. A `replaced-builtin` NOTE on `host-path-canary` is not a finding and not a reason to re-record.
- `host-inventory`: our own plugin's `agents[]` / `skills[]` entries are exempt because every recording declares `founder-skills` in `plugins[]` (1.19.0+; the built-in skill roster was fixed in 1.25.0 and extended for 3.6.0 — fix the roster, never allow). There is deliberately NO allow entry for them — a suppression that suppresses nothing invites misreading the gate. If a re-record reds this gate on our namespace, the cause is a missing `plugins[]` declaration, not a leak. The arrays live inside the `system` init frame in `events[]`, whose entries are JSON-ENCODED STRINGS — a walker that does not `json.loads` string leaves concludes the axis does not exist (and `scenario.skills` is a different, staleness-scoping key). The zero is structural (the axis targets `protocol`; we record at hostloop), confirmed non-vacuous by probe. This makes the CLI floor load-bearing: floor every consumer of `privacy-allowlist.sh` at `>=1.19.0` — though the **replay floor is `>=3.8.0`, the RECORDING floor `>=4.11.0`, and the CI selectors are PINNED EXACTLY at `4.11.0`** (see Release Process).
- The predicates that would mean a REAL leak — `mcp_servers[].name`, `account.email` / `.organization` / `.subscriptionType`, a `mcp__<server>__…` tool naming a foreign server — return NONE across the corpus. Not covered: the command and plugin catalogs and command descriptions. A green is a backstop, not proof.

**`replay --mutate`**
- It SAMPLES — 10 per file, 50 total, per-file cap applied first — so `50/50 … CAUGHT BY NOTHING` is coverage thinness, not an assertion-failure rate, and the `(sampled N of M eligible …)` parenthetical's M is not the ratio's denominator. Aggregate over the JSON `mutation` object (`{sampled, eligible, truncatedBy, caps, uncaught}`), not stderr. Globs are anchored and case-sensitive against `outputs/…`-prefixed paths: `'**/handoff/**'`, never `'handoff/**'`; `'**/report.json'` cannot match a root-level `report.json`. Scoped to the delivered report it is exhaustive, not sampled: `--mutate-include '**/report.json' --mutate-max-per-file 500`. Reporting-only by design — a count ratchet over `report.json` would red on every legitimate re-record. An artifact over the record's body cap (64 KiB by default) is committed body-less, and is then neither mutatable nor `artifact_json`-assertable; `rerecord.sh` records `deck-review-smoke` and `competitive-positioning-smoke` at 128 KiB so their reports keep a body.

## Running Tests

```bash
uv run pytest                                       # all tests (e2e auto-skips without auth; cowork auto-skips without the harness CLI)
uv run pytest founder-skills/tests/ -v              # verbose
uv run pytest founder-skills/tests/ -v -m "not e2e and not mutation" # explicitly skip the paid + slow lanes
uv run pytest -m cowork                             # token-free cowork-harness cassette replay (needs `npm i -g cowork-harness@4.11.0` — exact, matching CI; no Docker/token)
uv run pytest -m mutation                           # curated mutant corpus (~14 min serial, ~3.5 across 10 workers, ~4.5 on half the cores beside the suite; deselected by default — see the WARNING below)
uv run pytest founder-skills/tests/ -q -m "not e2e and not mutation" -n auto  # the suite across the cores (pytest-xdist, a dev dependency): ~2 min instead of ~11
```

**`addopts` deselects `e2e` AND `mutation`, and a command-line `-m` OVERRIDES `addopts` rather than
adding to it.** So a bare `-m "not e2e"` silently RE-SELECTS the ~14-minute mutation corpus — and on a
working tree that is red for any unrelated reason it reports `THE NO-OP CONTROL FAILED`, which reads as
a break the contributor did not cause. Every explicit `-m` in this repo therefore says
`-m "not e2e and not mutation"`: `ci.yml`'s test job, `skill-quality.yml`'s contract-tests job,
`scripts/pre-tag.sh`, `CONTRIBUTING.md`, and the two invocations in this file. That list was wrong when
first written — it named three sites and this file itself disproved it six lines up. The mutation lane has its own `mutation-corpus` job (dispatch only) and its own
preflight gate; both must pass `-m mutation`, since naming the file alone collects nothing and reports green. `scripts/pre-tag.sh` runs the
suite and the mutation corpus at the same time, each across half the cores (`-n`), with the other gates
in the background, and prints every gate's output in a fixed order (about 5 minutes, against about 27 one
after another); `PRETAG_SERIAL=1` runs them one after another. The corpus re-proves its no-op control in
each worker's sandbox at teardown, so it runs the same serially and in parallel.

**After editing any plugin `.py` or agent body, run `python3 founder-skills/scripts/script_hashes.py --write`** (and commit `data/script-hashes.json` with the change) before the hook suites: they run the hook against this checkout, which then reads as a modified plugin; the modules that run it fail once with that command (`plugin_hashes_current` in `conftest.py`). After merging two branches that each changed plugin `.py` files, run the same one command and commit the manifest by name: a merge runs no pre-commit hook, so only the sync test notices.

**A skill's own test file is not what guards it.** `test_<skill>.py` covers the producers;
`test_<skill>_skill_contract.py` covers the SKILL.md / agent-body contracts, and there are
cross-cutting guards besides (`test_skill_contract.py` size ceilings, `test_cowork_invariants.py`
tool declarations, per-script suites like `test_backward_verifier.py`). Editing a SKILL.md or an
agent body and running only the matching `test_<skill>.py` reports green while the contract tests
fail. Run `-m "not e2e and not mutation"` before believing a skill change is done.

**Contract tests slice a fixed character window from an anchor** (`skill_text[start : start + N]`).
An additive edit to a dispatch template can push the tail past that boundary, so the test fails on
content that is still present. Prefer bounding on structure — the template's closing fence, the next
`##` heading — over widening N, which only defers the next break.

The `cowork` lane (`tests/test_cowork_cassette_replay.py`) replays the committed
`cowork-tests/cassettes/` through the harness's shipped pytest helper — deterministic,
no Docker or token. It is a **local-dev showcase**, not wired into `ci.yml` (the packaged
GitHub Action already replays in the `cowork-replay` workflow); it auto-skips when the
`cowork-harness` CLI is absent, so the default `uv run pytest` stays green on machines
without it.

The four e2e smokes drive the SDK against synthetic fixtures. Auth options (any one):

- `ANTHROPIC_API_KEY` env var (per-token API; ~$5-15/run)
- `CLAUDE_CODE_OAUTH_TOKEN` env var (subscription, long-lived token from `claude setup-token`)
- Local subscription auth: `claude /login` populates the macOS Keychain entry `Claude Code-credentials` (or `~/.claude/.credentials.json` on Linux/Windows)

For live progress during the e2e run (~5-20 min wall time), add `-s`:

```bash
uv run pytest founder-skills/tests/test_e2e_deck_review.py -v -m e2e --tb=short -s
```

Without `-s` the run looks silent (pytest captures stdout); with `-s` you see auth-detected, prompt, and per-message tool calls (`Bash`, `Read`, `Skill`, `Task`, etc.) as the SDK stream arrives.

The `e2e` marker keeps these tests out of the default per-PR `ci.yml` run; they execute only in the dedicated `skill-quality.yml` workflow.

## Internal Docs

- `docs/internal/` — Design docs and internal notes; never tracked or committed (gitignored)

## Hooks

- **SessionStart** (`founder-skills/scripts/session-setup.sh` → `compaction_reread.py`): two jobs. (1) Writes `CLAUDE_PLUGIN_ROOT` into `CLAUDE_ENV_FILE`, single-quoted. Nothing reads that: no script reads the variable, and Step 0 resolves the root itself. SKILL.mds use the braced token, which Claude Code's loader substitutes, except in a skill invoked on cloud before the conversation's first turn that needs a shell or file tool. Whether the hook fires in a cloud session is unverified (a cloud task leaves no local session dir to inspect); since nothing reads the variable, either answer is harmless there. (2) On `source: "compact"` only (matched in the shell on the runtime's compact JSON, so no other session start launches an interpreter), the body adds one `additionalContext` note: the re-attached SKILL.md was cut before Step 1, so Read it from the `### Step` the run is on, with the run's own shell paths (`scripts_dir_shell`, `shared_scripts_dir_shell`) from its status file. It speaks only when the main thread started a founder-skills skill (`dispatch_type_check.invocations`: a `Skill` call or a typed slash command, never a skill name a tool returned) and the latest `start` result's status is `running` or `waiting` for that skill; a finished run is never told it is open. The runtime fires the same event for an ordinary sub-agent's compaction with an identical payload, so the note opens "If you are the main thread running …" and ends "If you are a sub-agent, ignore this"; that wording is the only separation. It lands after the cut copy, last in context; whether the note changes behaviour is not yet measured. POSIX `sh`, fails open (every path exits 0; a missing body is one stderr line), its body held to the hooks' Python floor. The Stop hook's no-ask end (`NO_ASK_END_REASON`) adds a re-read sentence when a main-thread `compact_boundary` follows the request. Every Stop-hook window (hand-over, delivery, review page, no-ask) starts at the founder's latest message: a compaction summary row (`isCompactSummary`) and a background agent's result delivered as a user row (`origin.kind` not human, or text opening `<task-notification>`) are not prompts.
- **Stop** (`founder-skills/scripts/stop-handover-check.sh` → `stop_handover_check.py`): after the model's final turn, checks that the founder's message carries the printed hand-over whole and adds nothing numeric, for each skill in its `SKILLS` table (market-sizing's `closing_message.py`, competitive-positioning's `cp_closing_message.py`, financial-model-review's `fmr_closing_message.py`; each trigger matched as a whole script name, the last closing call of the prompt deciding, each skill reading only its own `handover.txt`, or — when none is under the payload's `cwd`, which at the host-loop spawn newer Desktop uses is `/var/empty` — the same text sliced from the transcript's record of its closing call's output, opener line to closing offer, with a cut-short result not used, and only the last closing call's result read, never an earlier one's); a `{"decision": "block", "reason": …}` reply sends the model back **once**. When the request names its reply tool (`FS_HOST_REPLY_TOOL <tool_name> <input_key>`, read from a founder's own message only — any prompt of the session, last valid line wins, `<command-args>` unwrapped, never the run's ledger, whose lines the model types into `run_status.py start`, so a ledger route would let the model name any tool as the founder's message) and the turn sent through that tool after the closing call (exact name, the named input a non-empty string, main thread, result not `is_error`), the sends alone are that message: plain text and the payload's last text are not read. With the line and no send, the plain text is judged as before, so a hand-over written only as plain text passes (an accepted residual: blocking it would block every run on a host whose tool calls do not reach the transcript). Whenever the line names the tool, send or not, a block asks for the follow-up through it, in the reason's lead sentence, never beside `CORRECTION_LEAD`. The line is looked for only on a stop whose prompt ran a closing script; one the hook's copy of the shape rejects is ignored with a stderr line. Without the line, the judgement is unchanged. On `stop_hook_active`, and only after a hand-over block (the feedback row carries `CORRECTION_LEAD`), it judges the follow-up (through the reply tool when named) and writes one stderr line (`second stop: hand-over passed` / `… failed: <why>`), never a second block; after a delivery or no-ask block it writes nothing. Not exercised on any lane or recording: only a host run with the line proves it live (owed). When that last call failed (`is_error`, or a `[exit N]` lead) and printed no hand-over — a closer refusing another run's report — nothing is judged against any `handover.txt` (the one on disk is an earlier run's): the block names the refusal and asks to compose again and re-run the closer, never dictating a hand-over. A closer the shared-/tmp check held (`[shared-tmp][<path>]` after the runtime's hook-error prefix) never ran: the block names the path and asks to run it again with no redirect into the shared temp folder, without repeating the marker, which that check would count as a hold. The same hook also checks DELIVERY (`_delivery_check.py`): when a founder-skills report build succeeded in the prompt through a shell tool, no shell call failed after it (one the shared-/tmp check held, its result carrying `[shared-tmp]` after the runtime's `PreToolUse:<tool> hook error: ` prefix, never ran and is not a failure), the text after the build (restarting after a hook's feedback turn; a reply tool's sends are not read here) links or names a deliverable (for a closing script's build, carrying its printed hand-over whole counts, since on cloud those name each file by label alone), the host offered a delivery tool (a name containing `present_files`, or `SendUserFile`, in the transcript's `prompt_snapshot` tools or `deferred_tools_delta`), and no delivery call followed the build (the prompt's last compose when it ran one, so a delivery before a closing script that follows still counts), it asks the model to attach the files. A connected-folder write (`…__device_commit_files`, served on cloud as `mcp__remote-devices__device_commit_files`) is counted as delivery by the cassette checker (`cowork-tests/delivery_check.py`, suffix-matched), not by this hook. On the cloud lane the three closers print each document's label without a path (a path there is dead text beside the file card), so there only the carried hand-over names a deliverable; the reason says the message "points the founder to" the files, true either way. Known gap: a cloud closing message rewritten without the hand-over and without a path is not asked to attach. It never fires on the plain CLI (`entrypoint: cli`), on a host that offers no delivery tool, or on a transcript that records no tool list. Measured 2026-09-29: it fires on the three real link-only misses and on none of the runs that delivered. Both checks share one block; when both fail, the delivery comes first and the printed hand-over is sent last, because nothing after the rewrite is checked. Only when neither fires, a third ask (`missing_review_delivery`, its own conditions, not the report check's message-shape one): financial-model-review's static review page (`review_inputs.py … --static`) built in the prompt, no successful delivery naming the page, no report build and no `AskUserQuestion` after it (one the PreToolUse check held does not count), a delivery tool offered, not the CLI — a turn that ended WAITING at the values check. Its reason names the pending question and never calls the run finished. A review-page delivery is not the report's and the report's is not the page's; a `record_gate_answer.py` call is neither a build nor a delivery. A block APPENDS — the faulted message stays on screen and the rewrite lands beneath it — so the reason dictates a follow-up the founder will read, not an internal note. Its lead (`CORRECTION_LEAD`) states provenance and which figure wins on a conflict, never that the message above was wrong: neither failure `_handover_check.py` reports establishes a wrong figure (a correct paraphrase fails containment too), and a production block once told a founder to disregard figures that were right. The wrapper is POSIX `sh` (it runs host-native on macOS at hostloop and under dash in the Cowork VM) and **fails open** when `python3` is absent or the hook's Python file is missing: the hook enforces, the skill never depends on it. It fires at the end of EVERY turn in every session with the plugin enabled; any stop in which the current prompt ran no closing script, no successful report build and no successful static review build exits 0 with no output, and any error exits 0 with one stderr line. The Stop event can arrive before the final text reaches the transcript file, so when the transcript ends on a tool result the payload's `last_assistant_message` is judged as that text — only then, since the transcript is the record of what the founder saw. It fires and blocks in a cloud session (measured 2026-09-21). Under `FS_HOST_NO_HANDOVER` the hook still never reads the ledger's flag: a closing call that is a shell call running the closer through python and printed the host line (`printed_from_result`, only with no hand-over opener in the result) makes that line the comparand, with its own `HOST_LINE_LEAD` block that never says "founder"; when the line also stands on its own line in a founder's own message of the prompt (`two_figures_check.no_handover_in`), nothing about the final message is judged (no containment, delivery or review-page ask), so a host's own memo passes, while the no-ask end check still runs (a refused closer is still named, without the word "founder"). With the host line printed but not in the request's own message, the delivery ask fires only when the model's own text links a file it did not attach.
- **PreToolUse** (`founder-skills/scripts/pretooluse-dispatch.sh` → `pretooluse_dispatch.py`), matcher `Agent|Task|AskUserQuestion|mcp__workspace__bash` (an exact list; `Bash` deliberately absent): ONE process runs every check in `CHECKS` order, first hold wins, each returning at its own tool-name and prefix tests. Seven checks: four on dispatches, which return at once on a question, two on the question tool, which return at once on a dispatch, and one on the shell, which a shell call runs alone (`SHELL_CHECKS`; no other call runs it). They run in the order `dispatch_type_check`, `asked_gate_check`, `two_figures_check`, `dispatch_prompt_check`, `no_ask_check`, `review_page_check`, `shared_tmp_check`; the numbers below are labels, not the order. (5) `no_ask_check.py`, on `AskUserQuestion`: denies every question in a prompt whose request carries `FS_HOST_NO_ASK` (or whose run's ledger records that the request carried the flag, read through this prompt's `start` result's ledger path, a regular file only), with no retry; the asked-gate and figures checks return at once on that line or on a ledger with the flag (theirs found through the dispatch's OUTPUT_PATH and `run_ref.json`, run id pinned in both), and the Stop hook, reading the ledger as this check does, then skips its delivery and review-page asks, still checks the printed hand-over, and otherwise asks once only when the run is left `running`. The ledger read is what covers a host that passes its request lines as the skill's arguments: they reach the transcript only in the skill's expanded text, which never counts. Per route: the dispatch checks read the ledger wherever the hook can open OUTPUT_PATH (at the Cowork host loop too, code-read from the same path; on a Cowork-local run the asked-gate check was observed reaching the run's hand-off folder, n=1); the `start` result's path is the session shell's, so at Cowork local the question tool and the end-of-turn check see no-ask only in the request's own message. The ledger records that the request carried a line, not that a person sent it; a shell can hand-edit it, and a held model can open the gate, resume the waiting run under the same id with an answer or no-ask line and open again (the host's own resume flow, so not refused; a test pins it as a residual): it is the first model-writable input that removes a hold rather than adding one, accepted because a forge buys one skipped hold, a forged no-ask makes the recorder refuse the model's own answers, and a forged pre-answer is disclosed (`two_figures_check.py`, THE ONE ARTIFACT READ). (0) `dispatch_type_check.py`, first: a dispatch whose first line (invisible characters removed) opens with one of the fleet's 25 context lines at a word boundary must name an agent its `CONTEXT_AGENTS` table lists — narrowed to the skills the transcript shows the session started (`Skill` calls, `<command-name>`), every one of them; with no `subagent_type`, `claude`, `general-purpose`, another skill's agent or another plugin's (`x:<name>`; only `founder-skills:<name>` or the bare name is ours) it is held naming the agent to send. A session that started no founder-skills skill and a prompt with no OUTPUT_PATH line is another plugin's and passes. `test_dispatch_type_check.py` derives the table from every fenced template line in SKILL.md and `references/` plus every context a SKILL.md asks a generator to print. No transcript: passes, with a stderr line only when the agent is wrong. (1) `dispatch_prompt_check.py`: a dispatch whose first line opens with a registered context at a word boundary (a suffix such as "(round 2)" is still that context) and whose agent is a `PAIRS` entry (market-sizing's RED_TEAM, CHECKLIST and its two sizing contexts, TOP_DOWN_METHODOLOGY / BOTTOM_UP_METHODOLOGY; competitive-positioning's four scoring/research contexts and its RED_TEAM; financial-model-review's CHECKLIST; deck-review's CHECKLIST) must equal, whitespace squashed, the latest printed prompt for its OWN `OUTPUT_PATH`. The comparand is a main-thread tool result paired by tool id with a shell block that runs a TRUSTED generator (from its own `skills/<skill>/scripts/` of an installed plugin — a Cowork, Desktop or CLI plugin mount or cache, the cloud skills mount, the folder the runtime loaded our skill from, or the hook's own — or as `$SCRIPTS/<generator>`; never while a plugin file differs from the hashes this version shipped with (`data/script-hashes.json`: every `.py` outside `tests/`, and agent files by the text after their front matter, which installers rewrite; an unlisted `.py` counts; no manifest, no check, one stderr line), nor after a file tool wrote over a generator, and a generator run from another plugin copy that exists on the hook's host is hashed where it ran; names read through the block's own assignments) and in which every other command is on an ALLOW-list of quiet commands (assignments, `cd`, `mkdir`, `ls`, `wc`, `test`, `true`, `set -e`, `cp`/`mv` with no `/dev` or `/proc` argument, the plugin's `resolve_artifacts_root.py` / `ocr_uploads.py` piped at most into `head`/`tail`/`wc`, and `echo` of literals and of `$?` or the block's own variables naming no context line); any other command, keyword, function or subshell and it is not a comparand. Or the generator is the block's last command (after `;`, a newline or `&&`) and what runs before it may print, provided each step is a plugin script or a known command that cannot leave anything running (no other interpreter, `exec`, `trap`, `awk`, `find`, or `sed` except `sed -n N,Mp FILE`) — the shape models use when they fold the generator into the previous step's block. A block setting `PATH`, `PYTHONPATH` and the like never counts, and `ls` is quiet only before the generator. Or a Read, or a quiet block's `cat`/`head`/`tail`, of the file a generator's stdout was redirected into or the runtime saved an oversized result to, dropped by any later write to it (a sub-agent's included), any later block that cannot be read or runs an interpreter other than the plugin's own scripts, or that writes or names a path it cannot resolve. A redo printed with `--correction producer-rejected --detail-file F` counts only when the transcript shows F written by a failed plugin producer run (`2> F`; the call exited non-zero, or the block's own `echo "EXIT=$?"` right after it printed a non-zero code) and not written since (reading it is fine); the SKILL.mds save it to `$HANDOFF_DIR/producer_rejected.txt`. The two blocks are matched on the file as written or its path through each block's own assignments; in a CLI `Bash` block that does not change folder, `$PWD` is the folder its transcript row records (never on the Cowork shell, whose row folder is the host's). Replayed over the kept run transcripts: 85 of 86 generator calls that printed a prompt are accepted (the other ran after an `if` block) and 7 of 7 redirects were paired (counted before the shared-folder rule below). A redirect into a file directly in `/tmp`, `/private/tmp` or `/var/tmp` never pairs, in its block or later: the folder is shared across sessions, so a failed redirect leaves another session's prompt under that name. `>&` to a file is a write. Else held with the printed prompt as the reason, or, when none exists, with a reason naming what to fix: the command that made the generator's block unvouchable, a redirect (run it again with no redirect), a generator that is not the plugin's own (or, when a plugin file differs from the shipped hashes, naming it, once per session under its own marker `[dispatch-check][plugin-files]`; after that hold every paired dispatch is still compared with its printed prompt and held when it differs, with a stderr line, but never rewritten to it, since a changed plugin's print is not sent into a dispatch; more than `max(5, 10%)` differing LISTED files means the manifest does not describe the install, and the check is off with one stderr line, while an unlisted `.py` is always named), or no printed prompt for this `OUTPUT_PATH` yet (run the generator in a call of its own or last in its block, nothing printing after it; every such hold names the generator and subcommand, `$SCRIPTS/<generator> <subcommand>`, says what `$SCRIPTS` is and lists the generator's identifier flags, never the hook's own folder; when this context's latest generator call refused -- an `is_error` result, or one opening with `[exit N]`, N not 0 (the Cowork host loop's shape), with no context line and a JSON line whose `status` is `rejected` or `refused` -- it echoes only the `code` (capitals and underscores) and says to do what the output says first, never on the stale head). With the generator last, its result is the comparand only for the context that generator printed, since what ran before it may print another context's line. Each check keeps its own marker (`[dispatch-type]`, `[dispatch-check]`) and its own budget. The type check: at most two holds per `OUTPUT_PATH`, then let through with a stderr line. The prompt check counts per `OUTPUT_PATH` and PHASE (`unprinted`, `printed:<sha>`, `stale:<sha>`, the sha the first 8 hex of the squashed print's sha256, carried in the hold as `[dispatch-check][<path>][p:<phase>]`): at most two per phase and four per path, the path's total checked first, then let through with a stderr line; a hold with no phase tag counts in every phase, and the `[plugin-files]` hold counts toward no path. Per phase because two "no printed prompt" holds on a CHECKLIST sent beside the step it reads used to spend the path's whole budget, so a later steered dispatch that needed a hold with the print inline went through unheld. Only the total bounds a path (the `stale:` sha is model-writable, the `printed:` sha changes on every differing reprint): a path that spent two `unprinted` and two `printed:` holds has none left for a `stale:` one, so an unvouchable redo then passes unheld, as it did after two holds before; the release gate's received-prompt check catches it. Sibling dispatches in one message can spend a phase twice in a turn, and a varied OUTPUT_PATH opens a fresh budget, both bounded by the total. Markers are found in the rows' decoded text, so any path counts. `OUTPUT_PATH` is the whole line, trimmed (local-lane paths contain a space); scoping by it separates skills and rounds, so a stale round's prompt sent verbatim to its own path passes and the newer round's missing hand-off fails `check_handoff.py`. A comparison redo printed by the generator (`--redo comparison`) is the comparand like any other: same OUTPUT_PATH, so a hand-written repair after it is rewritten to the redo, and the rewrite notice for a sizing context names it. An older print is never sent in a newer one's place: when the dispatch differs from the vouched print and the newest print for its context and `OUTPUT_PATH` in any main-thread shell or Read result (vouched or not, a failed shell result included; read from the text, never the command) differs too, the dispatch is held as if nothing were printed, asking for the same generator arguments in a shell call of its own, and after two holds in that phase (at most four on the path) it goes through as sent. The trade, accepted: a model-writable result (an `echo`, a `cat` of its own file) turns a rewrite into two holds and a pass, because a reverted redo is silent and falsifies the report while a pass after the holds reds the release gate's received-prompt check. No disclosure code is written from it -- compose reading a hook marker would make the report depend on the hook. **Rewrite:** where a printed prompt exists and the dispatch differs, the check instead allows it with `updatedInput` (the whole input, only `prompt` replaced) and an `additionalContext` notice ("do not send this dispatch again"; re-run the generator, and only a producer's rejection goes back through `--correction`), with no hold budget. It runs only at a transcript CLI `version` ≥ `REWRITE_FLOOR` (CLI 2.1.141), the lowest version observed to honour it (the sub-agent's result row records the replaced text; probed 2026-10-15 on CLI 2.1.141 with a minimal standalone hook, one sync `general-purpose` dispatch on a Haiku model, 2.1.286 as the control: the hook fired, the sub-agent answered the replaced prompt, the result row's `toolUseResult.prompt` was the replaced text and a `hook_additional_context` row carried the notice; the main thread's `tool_use` row keeps the prompt as sent, and at 2.1.286 the call ran async, its answer arriving in a `<task-notification>` row); below it the dispatch is held; only for an input carrying `subagent_type` and `description`; and never after a rewritten dispatch's result row showed a prompt other than the printed one. A hook `allow` does NOT skip a user or org rule: per the CLI's code (2.1.289) the rules are re-checked on the rewritten input, a `deny` rule still wins and an `ask` rule still prompts; a rewritten input missing a required key is denied by schema validation. (2) `two_figures_check.py`: holds market-sizing's TOP_DOWN / BOTTOM_UP sizing dispatch **once** when `inputs.json` records `founder_stated_alternatives` and no `AskUserQuestion` since the current prompt offered every figure. The evidence is the TRANSCRIPT, never `founder_stated_choice` or `gate_defaults` (model-written, so a check they satisfy is satisfied by writing them). The deny reason is the question itself and names the figures no question offered; each sizing dispatch (TOP_DOWN, BOTTOM_UP) is held at most once and its second attempt passes (the hook finds its own `[two-figures-check][<CONTEXT>]` marker in the transcript), which covers "the founder asked not to be asked" without trusting the model's word. Per-dispatch because on the first live firing a parallel sibling spent a shared retry. Every other dispatch returns at the prefix check, which reads the first line as (0) does (`dispatch_type_check.context_of`). An `FS_HOST_ANSWER ms_two_figures=` line in a founder's own message in the current prompt also lets it through, and so does an input the run's ledger records answered from the request's own lines (`pre_answers` applied, bare or per input; the hold names only the inputs left; an answer the model recorded never counts), and a ledger with `no_ask`. Same posture as the Stop hook: POSIX `sh` wrapper, fails open, the skill never depends on it. Fires at hostloop per the runtime's code (plugin PreToolUse observed there). In a cloud session, plugin PreToolUse and Stop hooks were observed firing, including on a turn whose skill was a conversation's first message, and a PreToolUse deny is honoured there; this hook runs there and reads the transcript: on 2026-10-25, in a cloud deck review on 0.26.0, a probe CHECKLIST dispatch to `general-purpose` was held `[dispatch-type]` (which holds only with a readable transcript). (1) has not been seen deciding there: that review's CHECKLIST dispatch sent the printed prompt and drew no hold or notice, as a match should. A run that does nothing visible leaves no hook row in the cloud transcript (only `SessionStart` was logged), so silence is not evidence either way; a deliberately mis-typed probe is. (4) `asked_gate_check.py`, second: holds the dispatch that follows a confirmation **once** until the transcript shows the question was asked. `ROWS` maps (context, agent) to the gate it follows and holds every row of `PLANNED_ROWS`, the four built for: market-sizing's TOP_DOWN / BOTTOM_UP after `ms_methodology`, financial-model-review's CHECKLIST after `fmr_extracted_values`, ic-sim's POST_COMPOSE_COACHING after `ic_decline_confirmation`, owed there only when `score_dimensions.json`'s verdict is `pass` or `hard_pass`; a row is enabled when its skill asks with the registry's labels, and a dispatch no row names returns before loading anything; a question with no slot also matches on the asked text's last sentence, so a lead summary does not hide it. The window runs from the request that started the run (the first carrying `FS_HOST_RUN_ID=<id>`, or the `run_status.py start` result naming the id from the dispatch's `OUTPUT_PATH`), else the latest `Skill` call or slash command of the row's skill, else the whole transcript, to the dispatch, across later messages; a compaction summary is never its start. Evidence, strongest first: an `AskUserQuestion` offering two of the gate's labels, or asking its question with at least one of them (a question alone never passes; matched on letters and digits at a 0.8 `difflib` ratio; a held, errored question does not count, one with no result counts once a founder's message follows); an answered question form, only while `_form_reply.FORM_REPLY_ENABLED`; an `FS_HOST_ANSWER`/`FS_HOST_VALUE <gate>=` line in a founder's own message (never a tool input or result, `isMeta`, a sidechain, a compaction summary, or a background agent's result delivered as a user row: `origin.kind` other than human, or text opening `<task-notification>` — the same rows never count as a founder's reply or a window start, and a marker quoted in them or in `isMeta` text never spends a retry, here or in the figures check); assistant text naming two labels followed by a founder's message (the weakest, kept and measured). After the evidence and before the marker, a gate the run's ledger (found through `run_ref.json`) records answered from the request's own lines (`answered_by_request`, a copy of `_gates`' held equal by a test) passes, recorded as `passed_on: "request"` and never folded; an answer the model recorded never counts. A ledger with `no_ask` is not held and writes no line. The labels and question are the registry's, pinned by a sync test; the hook never loads `_gates.py`. Marker `[asked-gate-check][<agent>:<CONTEXT>]`, one hold per window. On a market-sizing sizing dispatch it also asks `two_figures_check.decide` (with the transcript already read), and one hold carries both questions; the figures marker rides along only when that check itself held. Every decision on an enabled row appends a line to `<run dir>/handoff/<run_id>/asked_evidence.jsonl` when that dir holds the run's `run_ref.json` (never creating a directory; a failed write is one stderr line), carrying the answer's `answered_at` from the ledger; `_run_status.fold_asked_evidence` copies a pass on evidence into the gate's `current.asked_evidence` on every status write (and `run_status.py show` on read), only for an answered gate (`resolution: answered`), when no script recorded one and the `answered_at` matches. A pass on the retry alone leaves it null. It is a measurement, not an attestation; no skill script reads the file. (3) `review_page_check.py`, on `AskUserQuestion` only: holds the question **once** per prompt when financial-model-review's static review page was built in the current prompt and no successful delivery naming it (and no report build) followed, a delivery tool is offered, and it is not the CLI; the reason asks to send the page first, then ask again. It keys on the build, never on the question's wording or options, so any other question passes; a shell call between build and question (a gate record) changes nothing. Marker `[review-page-check]`, found in decoded text. An unattended host that asks no question never reaches it; the Stop hook's review-page ask covers a turn that ends at the gate. (6) `shared_tmp_check.py`, on the Cowork shell: denies a command that writes a fixed name directly in `/tmp`, `/private/tmp` or `/var/tmp` (every local Cowork session shares one `/tmp`, and another session's file there is not writable: the redirect fails, the command before it never runs, and reading the name back shows that session's output). Writes are read from the command's text: an output redirect, `tee`, `dd of=`, `truncate`, `sed -i`, a copy or move destination (a bare `/tmp` one writes the source's name there), any `-o`/`--output`/`--out` value except for the commands `_O_NOT_OUTPUT` lists, whose `-o` is a switch (`unzip`, `grep`, `ls`, `tar`, …) or a value that is not a file (`ps`, `ssh`, `set`, …); wget's document and log (`-O`, `-qO`, `--output-document`, `-o`, `-a` and their long forms; `-` is stdout); a script's own `-o`/`--output` after `bash X` / `sh X` (the shell's own `-o pipefail` before it is not a file); zip's archive and its `-O` (not under `-T`/`-sf`, which read it); tar's archive (`-f X`, `-czf X`, `--file=X`, the dashless `tar czf X`) under a create or append mode; the block's own literal assignments filled in, `${TMPDIR:-/tmp}` read as `/tmp` (bare `$TMPDIR` is not), a relative target after an in-block literal `cd /tmp` (a `cd` or assignment inside `( … )` ends with the subshell; a `case` pattern's `)` does not), heredoc bodies skipped. Never denied: anything left to expand, a folder below `/tmp`, reads, a copy out, a path a main-thread `mktemp` call printed, inline code (`bash -c`, `python3 -c`). Scope: the main thread started one of the six skills (`invocations`, so a skill name a tool returned does not count), or the command runs one of their scripts from an installed plugin (an unassigned variable or this checkout's own path does not count). A sub-agent's call (`agent_id` in the hook input, set only there) passes with a stderr line, since its holds land in the sub-agent's own transcript and could never be counted; no transcript passes. Marker `[shared-tmp][<path>]`, two holds per path on main-thread rows, then let through with a stderr line. Cost: nothing loads unless the command contains `/tmp`, and the transcript is parsed only on a hit. Residuals: a fixed folder below `/tmp`, a name set in an earlier shell call, a `cd` it cannot read, inline code (and the words after it, `bash -c "x" -o /tmp/y`), writes it does not model (a name the URL or server chooses: `curl -O`, `wget -P /tmp`; tar's archive named only by `TAPE`; `gtar` / `bsdtar`; a zip archive named after its `-x` / `-i` patterns; `touch /tmp/flag`, `python3 -m json.tool in /tmp/out`, `pushd /tmp && cmd > out`), false holds (a `case` inside `$( … )`, whose pattern `)` the shared substitution lifter reads as the end of the `$(`; a `case` with `in` on the next line or with no pattern; `zip -FF X --out Y`, which holds X too), the cloud remote-device shell (not in the matcher; a cloud container has its own `/tmp`).
- **Python floor for the plugin hooks: 3.9.** At the host loop the Stop and PreToolUse bodies run host-native on macOS under the system `python3` (3.9), where a 3.10 construct raises and the hook fails open; `test_hook_python_floor.py` checks every module a hook loads (and, where this machine has an older system python3, that each hook decides the same under it), and `pyproject.toml` lints those modules as py39.
- **pre-commit** (`scripts/hooks/pre-commit`): ruff format/lint on staged Python + the privacy-leak guard. Activate once per clone: `git config core.hooksPath scripts/hooks`. On a machine that has the private lists, also `git config --global founderskills.privacyLists required` and the same without `--global` in the main checkout (so a missing list fails a hook; `scripts/pre-tag.sh` checks it); a clone with hooks and no lists opts out with `git config founderskills.privacyLists optional`. All three privacy hooks run the guard beside the hook script (`scripts/hooks/../privacy_guard.py`; the work tree's own when none is there), so under this clone's absolute `core.hooksPath` every worktree runs the main checkout's guard. Bypass a confirmed false positive with `git commit --no-verify`.
- **commit-msg** (`scripts/hooks/commit-msg`): DCO gate — rejects a commit with no `Signed-off-by:` trailer matching the commit author. Validates only; it deliberately does not auto-append the trailer (a sign-off certifies that *you* have the right to submit, so a hook adding it silently would certify on your behalf). Then runs the privacy guard over the message (`--commit-msg`).
- **pre-push** (`scripts/hooks/pre-push`): the privacy guard over each pushed range's added lines and commit messages (`--range`), so a commit made with `--no-verify` is still checked before it leaves the machine.

## Committing (read before any `git commit`)

**Always commit with `git commit -s`.** This repo requires a DCO sign-off on every commit, and `DCO` is a required status check on `main` — an unsigned commit blocks any PR containing it. The `commit-msg` hook will reject an unsigned commit, but reaching that rejection means a wasted round-trip: pass `-s` the first time.

`-s` composes with everything, including the heredoc pattern used throughout this repo:

```bash
git commit -s -F - <<'EOF'
type(scope): subject
...
EOF
```

The trailer is appended to the existing trailer block, after `Co-Authored-By:` / `Claude-Session:`. Order within the block does not matter. To fix a commit already written: `git commit --amend -s`.

Historical note: 155 of the first 159 commits are unsigned, because `enforce_admins` is false on `main` and direct pushes bypass the required check. Do **not** retro-sign them — that would rewrite published history. The convention is that a DCO check only inspects the commits in a given PR, so existing history is grandfathered.

## Privacy Guard

`scripts/privacy_guard.py` blocks confidential-data leaks **without committing the names, figures or text it guards**. Six layers:

- **document** — confidential doc types (`.xlsx`/`.pdf`/`.docx`/…) tracked outside the synthetic-fixture allowlist (add a new legit fixture to `ALLOWLISTED_DOCS`). Name-free.
- **provenance** — the named-after-a-company antipattern triple: a proper-noun company name + a `P-#`/`R-#` round/case ID + a failure word, together (template `the <ProperNoun> <round/case-ID> <failure-word>`). High-precision; name-free.
- **name** — exact real names from `docs/internal/privacy-denylist.txt`, which is **git-ignored and never committed** (the detection logic ships; the names don't). List only distinctive names/phrases — never bare common-word names. CI runs `--tree --no-names` (layers 1+2 only; no name list present), so nothing is disclosed.

- **figure** (local only) — a figure the change ADDS ($ amounts, thousands-separated numbers, decimals, %, `x` multiples, integers of 3+ digits; whole-token, years/versions/UUID fragments and a minimal round-number list skipped) that also occurs in the local private sources and not in our own code at `origin/main`. Skips `cowork-tests/cassettes/` (generated recordings whose timings and counts collide by chance) and the synthetic replay corpus (`SYNTHETIC_CORPUS_FILES`, today only `evals/financial-model-review/metric_claim_corpus.json`): its harvest script admits only runs of tracked synthetic fixtures, and those runs are kept among the private sources, so its figures collide with themselves.
- **verbatim** (local only) — a 6-word run of added text found in the private sources and not in our code at `origin/main`. Until local commits are pushed, text we wrote since `origin/main` and later echoed in a run can also match; that noise shrinks as `origin/main` advances. A recording under `cowork-tests/cassettes/` (`RECORDING_PREFIXES`) is still scanned and still blocks, but only against the founder DOCUMENTS among the sources (`grams_inputs`): `.pdf`/`.csv`/`.docx`/`.pptx`/`.xlsx` anywhere, plus a `.md`/`.txt` whose own folder holds one and whose name carries none of our output names (`OUTPUT_NAME_MARKERS`). Kept run transcripts and reports repeat the skills' own prose and collided with every re-recorded lane; a document quoted into a tool result is the leak the layer exists for there. A recording line has its JSON escapes decoded first (a document's line break arrives as `\n`), and a run of six consecutive integers (line numbering, any width) is skipped on it. The synthetic replay corpus is matched the same way (documents only, escapes decoded); any other file under `evals/` is fully checked. `.docx`/`.pptx`/`.xlsx` are read with the stdlib (both sets); `.xls`/`.doc`/`.ppt` are skipped with a count on stderr.
- **figure-provenance** (everywhere) — a figure on a line that also names real data ("a live run", "the founder's", "from the data room", …; not "measured", which our own notes use constantly). Blocks locally, warns in CI (`--tree`); mark a reviewed synthetic line with `privacy-guard: synthetic`. Skips `cowork-tests/cassettes/`: recordings narrate synthetic runs in the skills' own words ("the founder's", "the deck's").

The private sources are folders listed in `docs/internal/privacy-private-sources.txt` (**git-ignored**; one path per line, `!<substring>` excludes copies of our own code; a relative root is read from the main checkout's root). All three lists are read from the MAIN checkout's `docs/internal/`, from a linked worktree too (`$FOUNDER_SKILLS_PRIVATE_DIR` first; cwd-relative only when no main checkout is found), and are edited only there: a worktree's own copy is never read, and one whose entries differ is noted with counts. A missing list turns its layer off, and in the hook modes (`--staged`, `--commit-msg`, `--range`) prints one line naming it (never the allowlist), which is normal on a public clone; with `founderskills.privacyLists=required` a missing list, an unresolvable main checkout or a worktree denylist/sources copy holding entries main lacks exits 3 instead (1 = findings, 2 = usage). `--tree` and explicit files are unchanged and silent. The index holds only hashes and is cached in `~/.cache/founder-skills/privacy-guard/`. Local false alarms go in `docs/internal/privacy-figure-allowlist.txt` (git-ignored: a committed allowlist would reveal that a public number equals a private one). Findings print path, line and the added token only, never the matching source or its text.

Scans: `--check-switch` (`pre-tag.sh`'s "privacy switch" gate: exit 3 when this machine has the sources list and the switch is unset; `optional` passes with its own line), `--staged` (pre-commit: whole staged files for layers 1-3, ADDED lines for 4-6), `--commit-msg FILE` (commit-msg hook, after the DCO check), `--range A..B` (added lines + commit messages; the `pre-push` hook runs it per pushed range), `--tree` (CI), and `--verbatim-report` with `--range`/`--staged` for per-file verbatim COUNTS (no text) when triaging; a recording's row adds its all-sources count beside the blocking one, so the collision with kept runs stays measured. Tests: `uv run pytest scripts/test_privacy_guard.py` (fake private folder built in `tmp_path`, invented figures only). CI enforces layers 1+2 and reports figure-provenance warnings in the `privacy` job.

## SKILL.md Conventions

Verified against the Claude Code v2.1.120 skill runtime contract and Desktop v1.6259.1 architecture:

- **Skill re-attachment after auto-compaction has TWO budgets, and the second deletes silently.** Cite VALUES, never minified identifiers — every symbol rotates per release and one has collided destructively (`_On` meant 25,000 in one build and 5,000 in the next).
  **Cap 1 — 5,000 tokens per skill, and it is a CHARACTER cap**: the estimator is `Math.round(len/4)`, so `wc -m` measures the right unit with zero conversion error. The stored content is the `Base directory for this skill: <abs path>` prefix (101–240 chars, install-dependent) + the body with frontmatter STRIPPED; it truncates at ≥ 20,002 chars — the last safe length is **20,001**, and 19,900 is merely what survives (`slice(0,19900)` + a 100-char marker). All six analysis SKILL.mds are far over (`feedback` is well under), so every one truncates on every compaction, discarding 75–86% of its body — and truncation is CERTAIN once compaction happens — but compaction itself is low single-digit
percent, not routine: **424 of 26,608 transcripts carry a quoted `compact_boundary`, about 1.6%**
(founder-skills-scoped, ~2.8%; Cowork unmeasured). COUNT THE QUOTED TOKEN, never the bare substring
— a substring grep matches any transcript that merely mentions it and returns roughly three times
as many. Do not argue that a rule late in a SKILL.md "will have been truncated by then": a 30-minute
run with fifteen dispatches recorded zero boundaries. Truncation is head-preserving, so the base-directory line survives and a skill that OWNS A DIRECTORY is recoverable (measured 98% of truncated entries carry it). A single-file command has no recovery path at all; the plugin ships none since `feedback` moved from `commands/feedback.md` to `skills/feedback/`, so it owns a directory like the rest. A new file under `commands/` would bring the class back. Do NOT generalize this to "bundled/builtin skills have no base directory" — `bundled:verify` carries one; directory ownership predicts recoverability, the source prefix does not. Refinement: the predicate is directory DURABILITY — a plugin upgrade removes the version-stamped cache dir, so a session RESUMED after an upgrade holds a recovery path to a dead directory (path-death measured; the failing resume `Read` inferred, not observed). The 98% comes from local Claude Code transcripts, so it covers only skills the loader expanded: a skill invoked on cloud before the conversation's first turn that needs a shell or file tool names `/mnt/skills/plugins/<plugin>:<skill>` as its base directory, absent in the container, so recovery there is inferred to fail. `skills[].path` is a source-qualified identifier (`plugin:cap-table`), not a location — recovery keyed on `path` fails, keyed on the content's first line succeeds; the registry's `skillPath` is renamed to `path` on the way out, so grepping transcripts for `skillPath` finds nothing.
  **Cap 2 — 25,000 tokens combined**, over post-truncation sizes (each analysis skill contributes exactly 5,000; `feedback`, well under the cap, contributes its full size), most-recently-invoked first, per-agent: over budget (strict `>`), the least-recently-used skill's content is set to `""` with no marker — a "re-read if something looks missing" instruction structurally cannot fire for zeroing. Six analysis skills co-invoked = one zeroed; so do five plus `feedback`. A skill still visible in a retained attachment is skipped and consumes no budget. Sub-agent fan-out relieves the main thread.
  **Front-loading a "survival core" is WITHDRAWN** — do not re-propose the additive form: the window is fixed-size, so a prepended core evicts exactly as much from the tail (measured: it would have evicted the `STAGING_DIR` invariants it was written to protect). Reordering is not invisible to tests (`test_ic_sim_skill_contract.py` asserts step order), and the subtractive form has no cheap duplicate to harvest — the per-skill `## Skill Execution Model` sections overlap the shared reference by only 20.6–28.0%. Any front-loading or reordering attempt needs an e2e A/B (~$10) — contract tests cannot see behavioural change — and a justified `test_skill_md_does_not_grow` ceiling raise. Relocating prose to `references/` (target ≤ 20,001 chars of rendered prompt) is exempt from both caps but lands in the separate post-compaction file-restore budget: the 5 most recently read files, 5,000 tokens each, per-agent (a plain dispatch starts empty, `context: fork` copies the parent's — we use no `fork`; re-check if one is added). Neither conflicts with the retired "never move prose into `references/`" rule, which was about the 512 KiB critique corpus. Full derivation: `docs/internal/2026-08-28-skill-frontloading-plan.md` rev 2 and `docs/internal/2026-08-28-skill-compaction-budget-and-frontloading-plan.md`.
- **Env vars in skill bodies:** Use `${CLAUDE_PLUGIN_ROOT}` (braced form) — the plugin content expander substitutes it at load time on every lane but one: a skill invoked on cloud before the conversation's first turn that needs a shell or file tool arrives with the text literal (the cloud session is created by that turn; remote-device tools do not create it), so each skill's Step 0 detects that and prints `READ_ROOT=` to use in its place. Bare `$CLAUDE_PLUGIN_ROOT` only resolves at Bash subprocess time and depends on `CLAUDE_ENV_FILE` being sourced; the gist flags this as unconfirmed for skill subprocesses. The braced form is the contract.
- **Frontmatter keys** must come from the documented set: `name`, `description`, `when_to_use`, `allowed-tools`, `argument-hint`, `arguments`, `context`, `agent`, `model`, `effort`, `user-invocable`, `disable-model-invocation`, `paths`, `hooks`, `shell`, `created_by`. (`version` is parsed but tagged "[Undocumented] Informational only" in gist 1 — don't rely on it.) Custom keys are silently dropped — and do not add a `## Skill Metadata` body section for them either; the plugin version lives in `plugin.json`. **Avoid undocumented nested structures** (e.g. don't add a custom `metadata: {…}` block). The documented fields that *do* take structured values (`shell.interpreter`, `hooks.PreToolUse`, `paths` list, `arguments` list) are fine — they're explicitly specified.
- **Two parsers, two discovery outcomes (important):**
  - The **CLI runtime** (when invoking a skill) reads the full documented set above via a YAML parser.
  - **Desktop's skill discovery scanner** is **regex-based**, supports `>` and `|` block scalars, and only recognises **`name`, `description`, `argument-hint`, `user-invocable`**. Per gist 2 §"Skill discovery logic", this scanner gates *whether the skill is discovered at all* in Desktop/Cowork — not just what the UI displays. A SKILL.md whose frontmatter the regex parser can't navigate may fail to register entirely.
  - **`when_to_use` is invisible to Desktop** — only the CLI runtime reads it. So mirror the key trigger phrases into `description` (which Desktop *does* read) for users browsing the Settings UI to find your skill.
  - Undocumented nested YAML (e.g. a custom `metadata: {…}` block) can confuse the regex parser — even though the keys would be dropped semantically, the parser still has to walk past them and may misparse adjacent fields. Stick to the documented set; if you need structured config, use one of the documented structured fields.
- **`description` + `when_to_use` budget:** combined length per skill ≤ 1,536 chars. Across all skills, keep the sum ≤ 6,000 chars (the shared listing budget is context window × chars per token (~3; 4 on Haiku 4.5) × 1%: about 6,000 chars at 200K, about 30,000 at 1M, 8,000 on Haiku 4.5; on overflow, skills drop to name-only one at a time, least recently used first — reported by skill-creator-plus against CLI 2.1.280, not measured here). The total-listing-budget regression test enforces this.
- **No shell substitution `` !`cmd` ``, fenced `` ```! `` blocks or `$ARGUMENTS`** in a SKILL.md, command or agent file. Heavy work belongs in scripts the model runs via Bash tool calls — see existing `Phase 0` setup in any SKILL.md.
- **Regression test:** `founder-skills/tests/test_skill_contract.py` enforces all of the above. Run before opening a PR that touches a SKILL.md.
- **Pre-publish validation:** Run `claude plugin validate founder-skills` to validate manifests against the CLI's schemas (requires CLI v2.1.131+). CI does this automatically on every PR.

## Release Process

**Pushing `main` ships.** Users install from the marketplace clone that tracks `main`, so the paid gate (`deck-review-e2e-smoke` and `mutation-corpus` in `.github/workflows/skill-quality.yml`) runs ONCE per release, on a `release/vX.Y.Z` branch BEFORE `main` moves, dispatched by hand. The tag push does not run it again: `verify-branch-gate` checks the tag against both `pyproject.toml` and `founder-skills/.claude-plugin/plugin.json` versions, checks the CHANGELOG's Scoring changes section, and finds the green dispatched run at the tagged commit (`.github/scripts/branch_gate_check.py`); then `publish-release` creates the GitHub Release. `scripts/pre-tag.sh vX.Y.Z` runs the version and Scoring checks locally first. Per-PR e2e is off by default; opt in via manual dispatch for architectural-surface PRs (list below).

### Release ordering

0. **Refresh cowork cassettes** (release cadence, paid, local): `cowork-tests/rerecord.sh`. It preflights `cowork-harness doctor --tier hostloop` (needs Docker, the `:2` agent image, and BOTH staged agent binaries — the Linux/arm64 ELF and the native Desktop host binary the hostloop agent loop spawns), records in a bounded parallel pool, prints a normalized `cowork-harness diff` per refreshed cassette (the primary drift review), and tails `stats` + `prune` (runs land in `~/.cowork-harness/runs`, so `stats` can trend reliability across re-records). `fidelity: cowork` records at native hostloop; host paths are stripped by `cowork-tests/.cowork-redact.json`, and `record` refuses a cassette whose asserts or `computer://` links redaction broke. The cowork-replay staleness gate is WARN-only, so cassettes drift between releases. Skip only if nothing under `skills/`, `scripts/`, `references/`, `agents/` or `plugin.json` changed since the last refresh — see "Cassettes and the assert block" below. Every tier records a local session. New Pro and Max Cowork tasks run only in a cloud session (observed 2026-10-07, Desktop 2.26454.0; their scheduled tasks keep a "Require this computer" switch, off by default), so for those founders a green refresh is evidence about behaviour, not about the lane they get — see standing risk (5). Team org tasks run in a cloud session too (observed 2026-10-25: a new Team task reported `CLAUDE_CODE_ENTRYPOINT=remote_cowork`), so a green refresh is not evidence about Team founders' lane either.

**cowork-harness version posture.** Three postures, per site — never collapse them into one number:

* **CI selectors are PINNED EXACTLY at `4.11.0`** — the four workflow `version:` inputs, the `skill-static-analysis` `npm i -g`, and the install lines in `CONTRIBUTING.md`, this file and `pyproject.toml`. Never a caret: a range auto-adopts upstream releases into CI with nobody choosing it, and CI steps have gone red on rules the harness added. Raise the pin in a deliberate adoption pass (write the adoption plan first, under the internal docs dir), never to chase a red.
* **RECORDING floor `>=4.11.0`** — `cowork-tests/rerecord.sh`, four sites; find the gate with `grep -n 'minor.*-ge'`, never by line number. The gate keeps the shape `-eq <major> && -ge <minor>` because the floors test's regex READS that shape — "simplifying" it blinds the guard, not the gate. The floor moves for four distinct kinds of reason (plus a fifth, 4.2.0's, below), and only the first is a re-record trigger. 4.11.0 holds it now, for reason (3) plus a moved `latest`, and it IS a re-record trigger: its `latest` (desktop-3.1623.4) pins the staged agent 2.1.308 (sha256 match; under 4.10.0 `doctor` reports its pinned 2.1.299 pruned and falls back with a mismatch), and its spawn env adds `CLAUDE_CODE_ENTRYPOINT_ON_REQUESTS=""`, reason (1), though no script of ours reads it; the system prompt and allowlist are unchanged. No re-stamp: the agent moved and no committed cassette ran 2.1.308. 4.10.0 held it before, for reason (3) plus a moved `latest`, and it is NOT a re-record trigger: 4.6.0 pinned agent 2.1.293, which Desktop 2.42304.0 has pruned (4.6.0's `doctor` falls back to 2.1.299 with a sha256 mismatch), while 4.10.0's `latest` (2.42304.0) pins the staged 2.1.299 (sha256 match); spawn, mount layout, network and the system prompt are unchanged at every `latest` hop from 2.26454.2, and no cassette was re-stamped, because the agent moved and no committed cassette ran 2.1.299. 4.6.0 held it before, for the same reason: 4.5.0 pinned agent 2.1.289, which Desktop no longer staged, while 4.6.0's `latest` (2.26454.2) pinned the staged 2.1.293; 2.26454.2 differs from 2.26454.0 only in the agent, so `deck-review-smoke`, recorded under 4.5.0 on 2.1.293, was re-stamped. 4.5.0 held it before that, for reason (1) on all three inputs: the agent moved 2.1.284 -> 2.1.289 (`latest` = 2.26454.0), the spawn env turned auto-memory OFF at every tier, and the hostloop bash tool runs `bash -c` and rewrites a plugin's host path to its VM mount; the cassettes recorded before it stay `[stale] baseline moved` (WARN in CI) until the release re-records them. The reasons: (1) a release changes a fidelity input (emulated tool surface, spawn env, system prompt); (2) a release adds a record-time field that cannot be backfilled (`environment.harnessVersion` 1.11.0, `environment.model` + session fingerprint 3.1.0 — `rehash` migrates hash FORMATS, never fingerprint shape, and cannot cross a cassette-version boundary) or refuses pre-spend — in `executeScenario`, past the loader — a scenario older versions accepted (2.5.0; `--dry-run` reports that class in `inputErrors[]` since 4.1.0); (3) the pinned agent ELF no longer matches what Desktop stages — `doctor --tier hostloop` then reports a tolerated mismatch that a paid cassette would freeze, and recording needs `COWORK_HARNESS_ALLOW_AGENT_FALLBACK=1`; (4) a scenario asserts a key newer than the CLI — the loader HARD-REJECTS with `Unrecognized key`, `lint` on the same file exits 0, and `cassetteVersion` does not bump, so nothing warns (1.24.0, `file_absent` / `question_options`). 4.2.0 held this slot for a FIFTH kind of reason, NOT a re-record trigger: its `latest` baseline moved to 2.16120.0, so a cassette recorded under an older CLI is stale against the CI pin the day it is recorded. Its one fidelity-input change (spawn env: `PYTHONDONTWRITEBYTECODE=1`) was assessed and the committed cassettes were RE-STAMPED (`fingerprint.baseline`, one line each), not re-recorded: at hostloop the variable reaches only the host-side hooks, never the shell the skill scripts run in, and bytecode changes nothing a cassette records. (4.0.0 held this slot before, for reason (3) and the major-version trigger: its pinned agent ELF matches the one Desktop stages (2.1.284; `doctor` reports a sha256 match), where 3.10.0's did not.) (3.10.0 held this slot before, for reason (1): the host-loop spawn moved -- the agent process runs at `/var/empty` (or a per-run `host-cwd`), with `--settings`, deny rules on `--disallowedTools` and the path gate's re-anchoring (`src/runtime/hostloop.ts`, `src/runtime/argv.ts`), plus one system-prompt line (`hostloop-prompt.ts`). Under it a relative file-tool path is refused as production refuses it; a cassette recorded below it freezes a world where one passes.) (3.8.0 held this slot before, for reason (1) on all three inputs at once: `runtime/argv.ts` passes `--include-hook-events`, `hostloop/{skills,workspace}-handler.ts` move the emulated tool surface, and `prompt.ts` / `prompt/subagent-manifest.ts` move the system prompt. It is also the first release under which a hook is assertable at all.) (3.6.0 held this slot for reasons (1) and (3): the `CLAUDE_CODE_DESKTOP_APP_VERSION` injection and the ELF Desktop 2.2553.1 stages.)
* **Replay skip-guard `>=3.8.0`** (no upper bound: the check is `version < (3, 8, 0)`) — `test_cowork_cassette_replay.py::_MIN_HARNESS`, the only replay-floor site. It decides whether the local replay test runs at all, not which CLI CI installs; raising it turns a below-floor developer's red into a silent skip. It moved off `^2.1.0` on 2026-09-24 because `market-sizing-remote-lane` froze `hook_event_fired: Stop`, which an older CLI cannot grade — a scenario needed it, which is the only reason it ever moves.

`test_cowork_harness_floors.py` pins every site, this file included, and `uses:` (action) vs `version:` (CLI) major agreement — the two move independently. Read values from it; a version number in prose here has been stale on four separate occasions.

**Adopting a new harness release** — the checklist that has been wrong most often:
- Diff `src/` and `schema/` WHOLE between tags. A prefix list (`baselines/`, `src/runtime/…`) is a hand-list with extra steps; it missed a moved session fingerprint (3.2.0).
- Diff the baseline JSON leaf-by-leaf; compare `network.allowDomains` as a SET (order-only churn has read as a change three times); name what moved rather than listing what you checked (a hand-list once "verified" three keys that do not exist, and classifying the whole `provenance.*` prefix as noise missed a flipped gate — 3.0.0's `subagentPromptServerOverride` ON means no cassette can prove what sub-agent prompt text production sent). `baselines/` byte-identity is NOT "no fidelity debt" — 2.4.0 moved emulation code (`hostLoopCwds`, `container` `WebFetch` aliasing) with an identical baseline, and spawn-env changes in `src/runtime/argv.ts` (`NO_PROXY`, `CLAUDE_CODE_DESKTOP_APP_VERSION`) are invisible to any baseline diff.
- Our baseline is whatever `latest` resolves to (nothing declares `baseline:`): each cassette's `fingerprint.baseline` is what it recorded against, `latest` is the numerically highest file in the harness's `baselines/` dir. Re-derive after any `sync` or Desktop bump.
- Run every token-free surface before and after — `replay` over all cassettes, `verify-cassettes` with the expanded allowlist, `lint --strict --min-severity WARN`, `record --dry-run` (both arms), `analyze-skill --strict` (exits 0 on advisory findings; only `error` gates), `lint-skill --json` through `cowork-tests/lint_skill_gate.py` — and diff the outputs. Settle an exit code by measuring, never by inheriting upstream prose.
- Standing risks with no cassette tripwire: (1) the elicitation-conflict gap — every SKILL.md's `AskUserQuestion` "(NOT plain chat)" directive can be silently overridden by the host's injected form guidance, unobservable by any harness run; cap-table's Step 0 hand-off gate is the one site without the fallback sentence (its fallback is scoped to a different gate). (2) The `Artifact` tool has been seen in a real local session (2026-10-03); the harness still serves it at no tier (4.9.1's `sync` records its conditions in the baseline, nothing more). (3) Real Cowork re-syncs plugins into a live session — a quick pass about every 20 minutes, a full pass at app start and hourly; the harness stages once and never re-stages — a limit on what a green cassette proves. (4) `COWORK_EGRESS_PROXY` / `COWORK_DOCKER_NETWORK` never worked and were removed in 1.20.0 — do not add them from an old README (`COWORK_PROXY_IMAGE` is live). (5) No tier models a cloud session, the lane new Pro, Max and Team Cowork tasks run in (Pro and Max observed 2026-10-07; Team 2026-10-25, a new task in the Team org reporting `remote_cowork`). An "Only on this computer" switch does not show the lane: Team tasks have been seen going to the cloud with it ON, so check `CLAUDE_CODE_ENTRYPOINT` in the task's own shell. Observed there on 0.26.0 (2026-10-25): the session runs the account's synced copy of the plugin (one folder per org and account, under the container's synced-plugins dir), not the marketplace clone; the main thread has a page-fetch tool and, in a session in auto mode, opening a page asks no approval (whether it asks outside auto mode is not measured; its result is a summary, so a recorded passage can be a paraphrase); tesseract and pdftoppm are present, and Step 3.95 read the image deck; an attachment lands in `/mnt/user-data/attachments/<conversation-id>/` with an `<8-hex>-` name prefix (that run's `--uploads` exited 3, since `uploads/` was empty; it now answers the single attachments folder holding a file, after the two uploads folders, and exits 3 naming the ambiguity when several do). There a skill typed as a conversation's first message is expanded outside Claude Code: tokens and `$ARGUMENTS` arrive as written and the base directory does not exist. Hooks and sub-agents still work on that path. No recording can reproduce it; only a probe in a real cloud session can. 4.8.0's `device_bridge` (`lane: remote` + `fidelity: container`) narrows this: it serves `device_bash` / `get_device_info` against copies of the connected folders. It does not model the first-message facts above, and it never serves `device_commit_files`.
- The upstream hook-detection fix (manifest-declared hooks) is gated to `protocol` only; our `SessionStart` hook disclosure cannot fire at hostloop. Not a bug for us; recorded so nobody expects it.

**Cassettes and the assert block** — rules, not history (the history is archived locally with the per-release adoption plans):
- Staleness is skill-scoped (each scenario's `skills:` key) but the mount is whole-plugin — narrowing the mount is what reintroduces a false-green. "Shared root" = everything NOT under `skills/<x>/`, so `plugin.json` and the plugin-root `scripts/`, `agents/` and `references/` stale the fleet too (`skills/feedback/` is skill-scoped now, no longer shared root). In practice every cassette is already fleet-stale on baseline drift, so "just re-record that skill's lanes" leaves the rest red. Derive corpus facts with `python cowork-tests/cassette_inventory.py` and `cowork-harness verify-cassettes cowork-tests/cassettes --skip-scenario-drift`; never from prose. Bare `rerecord.sh` refreshes only scenarios with a committed cassette (it prints what it skipped); author a new one by name. After a refresh: confirm green, commit `cassettes/` by name, then grep the new cassettes for improvised shell (`curl|wget|pip install|uv run|npx|apt-get|git clone`) — egress from bash has been live since 1.20.0 and no SKILL.md sanctions it.
- Plain `replay` evaluates the assert block FROZEN in the cassette, never the on-disk YAML. An assert added after recording is invisible to CI; one deleted after recording still runs. After any `assert:` edit, diff `json.load(cassette)["scenario"]["assert"]` against `yaml.safe_load(scenario)["assert"]` key sets.
- `replay <cassette> --assert-from <yaml> --write` persists the on-disk block for free — but `--allow-failing` disables its only drift gate, so a block written that way passes against pre-drift events, which is worse than silence (silence prompts a re-record; green does not). Do not cite those greens as evidence about current gate handling — `deck-review-gate-stop` (recorded fresh) is the evidence. Sequence a write-back with the fix, never before it. A refusal (`answers drifted`, `prompt drifted`) means a real re-record; re-derive which lanes refuse with a loop of `replay <cassette> --reassert` checking rc=2.
- `deck-review-numeric-chain` is in `_NO_CASSETTE_ALLOWLIST` and must never be recorded: a frozen recording would preserve the defects it exists to surface.
- Re-record trigger: every harness major, and any release whose changelog touches one of the three fidelity inputs. Nothing automatic flags it — the changelog is the authority. The one sanctioned exception is upstream's re-stamp (`docs/cassette.md`, "Clearing a drifted baseline"): when a baseline moves and the change reaches neither the process running the skill's scripts nor anything a cassette records, set `fingerprint.baseline` by hand and record the argument in the adoption plan (4.2.0 is the precedent; its plan states why). 4.5.0 is NOT one: upstream forbids re-stamping across 4.3.0's auto-memory change, and its agent moved. 4.6.0 IS one, for a cassette recorded on agent 2.1.293 only (`deck-review-smoke`): its baseline moved only in the agent the recording already ran; the 4.5.0 ban still covers every cassette recorded before 4.5.0. 4.11.0 is NOT one either: the agent moved 2.1.299 -> 2.1.308 and the spawn env changed. 4.10.0 is NOT one: the agent moved 2.1.293 -> 2.1.299 and no committed cassette ran 2.1.299. A re-stamp is an assertion the tool cannot check. `rerecord.sh` enforces a **harness floor of `>=4.11.0`**.

0.5. **Run the gates `pytest` does not.** A green `uv run pytest` is NOT a green CI. Two separate
   gates have to pass before you tag, and neither is reachable from the test suite:
   - **mypy over all EIGHT directories** — the six `skills/*/scripts/` dirs, **`founder-skills/tests/`
     and `founder-skills/scripts/`** (`ci.yml:35-42`; it is easy to miss by reading only the first few
     `- run:` lines). **The steps run under `-e`, so the first failure MASKS every later one**:
     clearing one error does not turn the job green, it advances it to the next failing step. v0.7.0
     burned two tags on exactly this — a `verify_positioning.py` shadowed-variable error hid 8 errors
     in `tests/`.
   - **`uv run ruff format --check .` and `uv run ruff check .`**.
   Run all of them (`scripts/pre-tag.sh` runs them all), then bump. Each failure the
   branch run finds costs another paid run. The tag run checks only the version, the Scoring
   changes section and the branch run's evidence, which pre-tag and step 6 check first.
1. Bump versions in `pyproject.toml` and `founder-skills/.claude-plugin/plugin.json` (must match)
2. Update `CHANGELOG.md` — and **read the diffs, not the commit messages**. The v0.7.0 pass found a
   duplicated entry, four script filenames in user-facing text (0.6.0 names zero `.py` to users), and
   three internal war stories. Match the format the previous release established (titled release +
   `### Highlights`, then Added / Changed / Fixed) rather than dumping bullets under `Fixed`.
   Every release section has `### Scoring changes` ("None." when nothing changed): it is a downstream
   contract, checked at tag time against the scoring-file registry in `.github/scripts/scoring_changes_check.py`.
3. `git commit -s -m "release: vX.Y.Z"`, then `scripts/pre-tag.sh vX.Y.Z`, which also checks the
   tag against both manifests and the Scoring changes section (a dispatched gate run skips both).
   Before anything leaves the machine: `main` must fast-forward from `origin/main`
   (`git fetch origin main && git merge-base --is-ancestor origin/main HEAD`; never `--force` to
   `main`), `python3 scripts/privacy_guard.py --tree --no-names` and `--range origin/main..HEAD` must
   be clean, and `git diff --stat origin/main..HEAD` must name nothing under `docs/internal/`,
   `docs/plans/` or `evals-local/`.
4. **Open the release PR from a branch; `main` does not move yet.** Write the notes to a file first,
   so a failure of `changelog-notes.py` stops here rather than opening a PR with an empty body:

   ```bash
   git push origin HEAD:refs/heads/release/vX.Y.Z
   python3 .github/scripts/changelog-notes.py vX.Y.Z > /tmp/notes.md
   gh pr create --base main --head release/vX.Y.Z --title "release: vX.Y.Z" --body-file /tmp/notes.md
   ```

   The PR is the review page and the record. It runs `ci.yml` (lint, typecheck, tests, the
   name-free privacy guard, manifest validation), `version-check.yml`, skill-quality's free contract
   tests and its self-test of the tag-time check (`branch-gate-selftest`), `cowork-replay.yml` when its paths changed, and DCO. It holds only the release range, so
   every commit in it must carry a sign-off.
5. **Run the paid gate on the branch:** `gh workflow run skill-quality.yml --ref release/vX.Y.Z`.
   A dispatch runs `deck-review-e2e-smoke` (the three e2e lanes) and `mutation-corpus`, never
   `publish-release`. Wait for both green.
   - A red lane: read the failing assertion before anything else. A re-run is PAID (the three lanes
     again), so re-run once only when the assertion reads as LLM variance; never re-run blind.
   - A real failure: the version has not shipped, so it needs no new bump. Put the fix below the
     release commit, so `release: vX.Y.Z` stays last (amend it in with `git commit --amend -s`, or
     commit it and rebase it under the release commit), add it to that version's CHANGELOG section,
     re-run `scripts/pre-tag.sh vX.Y.Z`, then
     `git push --force-with-lease origin HEAD:refs/heads/release/vX.Y.Z` and dispatch again. A lease
     refused with `(stale info)` means the local view of the branch is old: `git fetch origin
     release/vX.Y.Z` and push again. The bump-again rule in VERSIONING.md applies only once `main`
     carries the version.
   - **The three opt-in deck-review lanes** (contradiction, numeric operators, image deck) run only on request:
     `gh workflow run skill-quality.yml --ref release/vX.Y.Z -f extra_deck_lanes=true`, a SEPARATE
     dispatch from the gate's (about $10-30 more). That run repeats the gate's lanes as well, and a red
     extra lane reds its job; `branch_gate_check.py` accepts any green dispatched run at the commit, so
     a red extra run does not block the tag once the plain gate run is green. Run them when the numeric
     chain or its proposer prompt changed. That dispatch also installs `tesseract-ocr` and `poppler-utils` for
     the image-deck lane (`RUN_PAID_E2E_OCR`), the only paid run that reaches Step 3.95; on it the market-sizing
     lane's scanned upload meets a real tesseract too, which is expected. Locally (billed, the tools on PATH):
     `RUN_PAID_E2E=1 RUN_PAID_E2E_OCR=1 uv run pytest founder-skills/tests/test_e2e_deck_review.py -k image_deck -v -m e2e --tb=short -s --basetemp /tmp/fs-e2e`.
   - **The opt-in financial-model-review unclassified-revenue-model lane** runs only on request:
     `gh workflow run skill-quality.yml --ref release/vX.Y.Z -f extra_lanes=true` (about $5-15), a
     separate dispatch from the gate's, with the same posture as the deck lanes above (the env
     `RUN_PAID_E2E_FMR_UNCLASSIFIED` is set from the input, never an `if:`; the lane is named in
     `ALLOWED_SKIPS`). It is a measurement: a red on its first assertion reports what the run wrote for
     `company.revenue_model_type` / `unclassified_reason`, not a regression. Locally (billed):
     `RUN_PAID_E2E=1 RUN_PAID_E2E_FMR_UNCLASSIFIED=1 uv run pytest founder-skills/tests/test_e2e_financial_model_review.py -k unclassified -v -m e2e --tb=short -s --basetemp /tmp/fs-e2e` (`--basetemp` OUTSIDE `~/.claude`).
   - **The market-sizing compaction lane** (`test_market_sizing_compaction_lane`) is local only:
     `RUN_PAID_E2E_MS_COMPACTION` is never set by the workflow, and the lane is in `ALLOWED_SKIPS`. It sets
     `CLAUDE_CODE_AUTO_COMPACT_WINDOW` to the floor so the run compacts mid-way, writes
     `compaction_report.json` (C, R, F, as `_e2e_harness.compaction_report` defines them; written on a
     budget stop too), and asserts only that the run completed. It refuses to start without
     `RUN_PAID_E2E_MODEL` (a pinned model id) and caps each run with the SDK's `max_budget_usd`
     (`RUN_PAID_E2E_MAX_BUDGET_USD`, default 25). C is the main thread's own `compact_boundary`; a run
     without one placed in the stream fails as VOID, and is not a result.
6. **Green: fast-forward `main`, then tag.** Green means the paid gate AND every PR check
   (`gh pr checks release/vX.Y.Z`): the push to `main` goes around branch protection
   (`enforce_admins` is false), so nothing else enforces lint, typecheck, tests, DCO or the review.
   Then confirm a green run tested `git rev-parse HEAD`, with the check the tag will run (free):
   `GH_REPO=lool-ventures/founder-skills python3 .github/scripts/branch_gate_check.py --sha "$(git rev-parse HEAD)" --tag vX.Y.Z`.

   ```bash
   git push origin HEAD:main
   git tag vX.Y.Z && git push origin vX.Y.Z
   ```

   The push to `main` is what ships. Per GitHub's behaviour, the PR is marked merged when its head
   lands on `main`; never use its merge, squash or rebase buttons — a merge commit lands unsigned
   and a rebase rewrites the SHAs the gate tested. The tag push does NOT run the paid gate again:
   it runs `contract-tests` and `verify-branch-gate` (version, Scoring changes, and the green
   dispatched run at this commit), and `publish-release` waits on both. The tag run costs nothing.
   - A red `verify-branch-gate` because the branch run was still in progress, or a transient API
     error: `gh run rerun <tag-push run id> --failed` (free). Re-running the failed jobs re-fires
     `publish-release` too.
   - No green branch run at this commit (a hotfix, or the run was at another commit): the override.
     `gh workflow run skill-quality.yml --ref vX.Y.Z` runs the paid jobs at the tag's commit (a
     dispatch never publishes); once green, `gh run rerun <tag-push run id> --failed`. Two limits:
     a run can be re-run only within **30 days** of its first attempt, and a re-run executes the
     workflow file **at that run's own commit** (so a tag whose commit predates the
     `verify-branch-gate` job re-runs the old paid tag job instead). Past either, publish by hand
     (step 8's command).
   - A real failure (a wrong version or Scoring section): `main` already shipped this version. KEEP
     the tag — it marks what shipped — and let it stand with no Release; then release the next patch
     through these same steps. Deleting it breaks the next release: `scoring_changes_check.py` diffs
     from the tag of the previous CHANGELOG section and fails when that tag is missing. Never re-tag
     a version `main` has carried.
7. **Delete the release branch if it still exists.** This repository is set to delete a merged PR's head branch (`delete_branch_on_merge`), and the PR is marked merged when its head reaches `main`, so it is usually gone already: `git ls-remote --exit-code origin refs/heads/release/vX.Y.Z && git push origin :release/vX.Y.Z`.
8. **The GitHub Release is now created FOR you — do not run `gh release create` by hand.**
   `publish-release` in `skill-quality.yml` fires on a tag PUSH (and only a push — a
   `workflow_dispatch` on a tag ref is excluded, or dispatching the rehearsal would publish), after
   `verify-branch-gate` finds the green branch run. It builds the notes and the title from `CHANGELOG.md` via
   `.github/scripts/changelog-notes.py`, which emits exactly `vX.Y.Z — <title>` to match the
   releases published by hand before it existed.

   **Running it manually now races the workflow**; whichever loses reds on "release already exists"
   (there is no `--clobber` and no existence check). If you need to publish by hand — the automation
   failed, or you are backfilling an old tag — this is the command it replaces:

   ```bash
   # notes = that version's CHANGELOG section; title = the section's own "— <title>" text
   gh release create vX.Y.Z --verify-tag --latest \
     --title "vX.Y.Z — <changelog title>" -F <(python3 .github/scripts/changelog-notes.py vX.Y.Z)
   ```

   **Rehearse the notes without tagging** (free, publishes nothing):
   `gh workflow run skill-quality.yml -f verify_release_notes_for=vX.Y.Z`.

   A **tag** and a **Release** are different objects: a Release is created only by `gh release create`
   or the web UI. Because this step was never written down, **four tags shipped with no Release**
   (v0.3.1, v0.6.0, v0.7.0, v0.7.1) and the repo's "Latest" badge sat on **v0.4.7 for two months** while
   the project kept shipping — a public repo advertising itself as stale on the day it released.
   Backfilled 2026-08-07; v0.3.1 deliberately left alone. Nothing was broken by the omission (users
   install from the marketplace clone tracking `main`, so `plugin.json#version` there is what they get)
   — the cost is entirely perception, which is why nothing failed and it drifted for four releases.

   **The release ENDS here.** `sync-test-repo.sh` is deliberately NOT a release step — see below.

   Two mechanics worth knowing: `--verify-tag` aborts if the tag isn't on the remote (catches a
   forgotten tag push), and **"Latest" is computed, not chronological** — when backfilling
   several, create them oldest-first with `--latest=false` and pass `--latest` only on the newest, or
   the badge lands on whichever GitHub decides. Reversible: a Release can be deleted without touching
   the tag.

**`sync-test-repo.sh` RUNS ONLY WHEN EXPLICITLY ASKED FOR. It is not part of shipping, and "ship vX.Y.Z" is not a request for it.** It is a local, untracked TESTING convenience: it rsyncs `founder-skills/` into a SEPARATE and PUBLIC repo (`yaniv-golan/founderskills-test`) and pushes, so a build can be exercised in Cowork by hand. Nothing in the release depends on it and no user is waiting on it — users install from the marketplace clone that tracks `main`, so `plugin.json#version` on `main` is what they actually pick up (see VERSIONING.md).

It used to be numbered step 7 of the release, which read as "do this to finish shipping" and is wrong twice over: it publishes the working tree — not the tag — into a second public history, and it is a push to a repo the release process has no business touching unattended. If you do run it, run it only after the release gate is green: syncing a broken build means the manual test pass exercises a build you would never ship.

**Model-tier acceptance:** when adopting or recommending a new model tier, run the cap-table reliability bench (`evals/cap-table/run_reliability_bench.py`, see its `README.md`) and record the per-tier correctness; Sonnet 4.6 is the support floor. (The bench lives at repo-root `evals/` — outside the distributed `founder-skills/` plugin — so it isn't shipped to users, mounted into cowork runs, or folded into the cassette staleness hash.)

**Already-distributed retag pitfall:** a version is distributed once `main` carries it — the marketplace clone tracks `main`, so the push in step 6 ships it whether or not the tag run publishes a Release; keep its tag (step 6 says why). The same holds if you had separately run `sync-test-repo.sh` before noticing the bug. Either way, **bump to the next patch version instead of retagging** — Cowork caches by `plugin.json#version`, so retagging the same version will not refresh user caches (`cpd refresh ... --force-fetch -y` is the manual recovery, not always coordinatable across users).

### When to manually dispatch e2e on a PR

Per-PR e2e is off by default. Manually dispatch (`gh workflow run skill-quality.yml --ref <pr-branch>`) when the PR touches architectural surface that contract tests don't fully cover:

- `founder-skills/skills/*/SKILL.md` (frontmatter or trigger phrases)
- `founder-skills/agents/*.md` (tool declarations, model, frontmatter)
- `founder-skills/.claude-plugin/plugin.json`
- `founder-skills/scripts/session-setup.sh` (mutates `CLAUDE_ENV_FILE`)
- `founder-skills/skills/*/scripts/compose_report.py`, **but only when the change reaches the payload builder** — `_emit_coaching_payload` in five skills, `build_coaching_payload` in cap-table (which also runs `_assert_coaching_payload_privacy_clean` over the result; grep for the name rather than assuming, this differs per skill). That function IS the `coaching_payload` contract. `test_compose_invariants.py` checks its *shape* against synthetic fixtures; only e2e exercises the thing that shape exists for, namely a sub-agent reading the payload and writing usable commentary from it. **The check:** does the diff touch the payload builder or anything in its call graph? If yes, dispatch. If the change only alters how a section renders into `report.md`/`report.json`, the contract tests are sufficient and e2e buys nothing.
  This bullet used to name the whole file, which over-triggered: a rendering-only fix would read as needing a $10 paid run it cannot possibly exercise. Two such commits landed under the narrowed reading — a `-1` rank sentinel reaching founders, and a moat-radar caption — both rendering-only, both covered by contract tests, neither dispatched. **Watch the shared-helper case**: `report.md` prose and the payload can call the same helper, and a change there does reach the contract even though the diff looks like rendering. That is exactly why the trigger is the call graph and not the file.
- any of the four `founder-skills/tests/test_e2e_*.py` lanes (derive with `ls`), or `tests/_e2e_harness.py` (the SDK invocation itself)
- `founder-skills/tests/cowork_async_subagent_filter.py` and `compose_invocations.py` (CI-helper meta — if these break, contract tests pass vacuously)
- `pyproject.toml` `dependencies` list or `[project.optional-dependencies]` block (any runtime dep can shift SDK behavior)

Other PRs (skill-internal scripts, fixtures, docs, contract tests): contract tests are sufficient — skip the $10.

## Installing the Plugin in Claude Cowork

Customize → Plugins → the **+** at the right of the Anthropic/Partners/**Personal** tab row →
**Add marketplace** → **Add from a repository** → pick `lool-ventures/founder-skills` in the **URL**
picker → **Sync** → then **+** on the *Founder skills* card to install.

There is no "Personal Plugins" list and no "Browse Plugins" — that was an older layout. The two `+`
buttons are distinct: tab-row `+` adds a *marketplace*, card `+` installs a *plugin*. Syncing alone
installs nothing. To refresh an installed plugin, use **Check for updates** on the marketplace's `⋯`
menu, which enables the plugin's **Update** button.

(Verified against the live UI 2026-07-29; captures under `docs/internal/cowork-ui-validation-2026-07-28/`.)

A private test build skips the marketplace: Customize → Plugins → Add plugin → Upload plugin.

**Start a new conversation** after installing ("New" on the merged interface; a new Cowork task where the Chat/Cowork choice still exists). Running conversations won't pick up the plugin.

## Local CLI Testing with `--plugin-dir`

For iterating on plugin code in the standalone Claude Code CLI (host CLI, not Cowork), bypass the marketplace machinery entirely. From the repo root:

    claude --plugin-dir "$PWD/founder-skills"

This loads our plugin for the session only:
- No marketplace clone
- No `installed_plugins.json` entry
- No enabled-state in settings
- No interaction with Desktop's "Update available" badge or refresh flow
- Gone the moment the session exits

**Use for:** rapid local iteration on SKILL.md / agents / scripts when you don't want to reinstall after every change.

**Caveats:**
- Host CLI only. Cowork uses a VM-pinned binary at `~/Library/Application Support/Claude/claude-code-vm/<version>/claude` (Linux ARM64 ELF) and has no equivalent flag exposed through Desktop.
- Managed-policy block lists still apply — a blocked plugin name fails to load.
- Repeatable for multi-plugin testing: `--plugin-dir A --plugin-dir B`.

## Updating Plugin Files in Cowork Without Reinstalling

**Local sessions only.** These steps edit host copies; a cloud session reads the plugin synced to the account, so test a build there by uploading it (see "Installing the Plugin in Claude Cowork").

Cowork caches plugin files per-session. To hot-patch files for testing without reinstalling:

1. Find the session cache:
   ```bash
   find ~/Library/Application\ Support/Claude/local-agent-mode-sessions -name "SKILL.md" -path "*ic-sim*" 2>/dev/null
   ```

2. There are typically 4 copies per session — 2 marketplace names × (`cache/` + `marketplaces/`):
   ```
   cowork_plugins/cache/<marketplace>/<plugin>/<version>/
   cowork_plugins/marketplaces/<marketplace>/<plugin-dir>/
   ```

3. Copy modified files into all locations:
   ```bash
   SRC="founderskills"
   COWORK_BASE="$HOME/Library/Application Support/Claude/local-agent-mode-sessions/<org-id>/<session-id>/cowork_plugins"
   TARGETS=(
     "$COWORK_BASE/cache/<marketplace1>/<plugin>/<version>"
     "$COWORK_BASE/cache/<marketplace2>/<plugin>/<version>"
     "$COWORK_BASE/marketplaces/<marketplace1>/<plugin-dir>"
     "$COWORK_BASE/marketplaces/<marketplace2>/<plugin-dir>"
   )
   for target in "${TARGETS[@]}"; do
     cp "$SRC/skills/ic-sim/SKILL.md" "$target/skills/ic-sim/SKILL.md"
     # ... repeat for each modified file
   done
   ```

   If you changed any `.py` file or agent body, first run `python3 founder-skills/scripts/script_hashes.py --write`
   and copy `data/script-hashes.json` into every target too. A copied script the shipped hashes do not list
   makes the dispatch check hold the first paired dispatch once, then hold (never rewrite) a dispatch that
   differs from its printed prompt, so the session would behave unlike any real install.

4. **Start a new conversation** — running conversations have already loaded skill bodies into context.

### Verifying the Marketplace Clone Actually Advanced

Cowork's "Refresh" can succeed without the local clone's git HEAD advancing — `CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE` and the SSH↔HTTPS fallback both absorb `git pull` failures silently while still bumping `lastUpdated`. The same trap exists for the standalone Claude Code CLI's marketplace cache.

Use [`claude-plugin-doctor`](https://github.com/yaniv-golan/claude-plugin-doctor) (`cpd`) — it diagnoses drift across all six cache layers of the Claude Code / Claude Desktop plugin system, not just the marketplace clone.

```bash
npm install -g claude-plugin-doctor
```

**The full safe dev loop is:**

1. `./scripts/sync-test-repo.sh` — push your changes to the test repo.
2. **In the Cowork UI**, click Refresh on the marketplace.
3. **In your terminal**, run `cpd refresh lool-founder-skills` to confirm the clone advanced and surface any other drift.
4. If the clone is stale, run `cpd refresh lool-founder-skills --force-fetch -y` — bypasses the broken refresh path with a direct `git fetch && git reset --hard`.
5. Click Update on the plugin in Cowork (or use `cpd refresh lool-founder-skills --auto-update`).
6. Open a new Cowork task to pick up the new content.

Run `cpd refresh` after clicking Refresh in step 2 — Cowork's Refresh is async and user-triggered, so it can't be wired into `sync-test-repo.sh` automatically.

**Always run `cpd check founder-skills@lool-founder-skills` before debugging "why isn't my new SKILL.md being picked up"** — it produces a single-plugin drift report across all six cache layers (marketplace clone, install snapshot, enabled state, RPM, session mounts, content-hash sync). Half the time the answer is the clone never moved; the other half it's a different cache layer.

## Removing / Refreshing a Plugin in Claude Code / Cowork

There is no UI to remove a marketplace. Edit config files directly.

### Claude Code (CLI)

- `~/.claude/plugins/known_marketplaces.json` — delete the marketplace entry
- `~/.claude/plugins/installed_plugins.json` — delete any `pluginname@marketplace` entries
- `~/.claude/plugins/cache/<marketplace>/` — delete to reclaim disk space

### Cowork (Desktop App) — stale version troubleshooting

**Local sessions only.** These steps edit host copies; a cloud session reads the plugin synced to the account, so test a build there by uploading it (see "Installing the Plugin in Claude Cowork").

When Cowork won't pick up a new version, nuke all three locations:

```bash
BASE="$HOME/Library/Application Support/Claude/local-agent-mode-sessions/<org-id>/<user-id>/cowork_plugins"
rm -rf "$BASE/cache/<marketplace>"
rm -rf "$BASE/marketplaces/<marketplace>"
rm -f "$BASE/.install-manifests/<plugin>@<marketplace>.json"
```

Then remove the entries from `known_marketplaces.json` and `installed_plugins.json`, restart Cowork, and re-add the marketplace.

**Tip:** `installed_plugins.json` pins a `gitCommitSha` — compare against `git rev-parse HEAD` to check freshness.

**Pitfall:** When deleting an entry from these JSON files, ensure the preceding entry's trailing comma is removed if it becomes the last entry. A trailing comma produces invalid JSON that `JSON.parse()` rejects, causing "Failed to add marketplace" errors in Cowork.