diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 934f1cd..28d4104 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,5 @@ { "name": "lool-founder-skills", - "description": "Skills for startup founders by lool ventures", "owner": {"name": "lool ventures"}, "plugins": [ { diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 041ab52..3253326 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -28,7 +28,10 @@ What you expected to happen instead. - [ ] Market Sizing - [ ] Deck Review +- [ ] Financial Model Review - [ ] IC Simulation +- [ ] Competitive Positioning +- [ ] Cap Table - [ ] Other / not skill-specific ## Additional context diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96da702..be625af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: - run: uv run mypy founder-skills/skills/ic-sim/scripts/ - run: uv run mypy founder-skills/skills/financial-model-review/scripts/ - run: uv run mypy founder-skills/skills/competitive-positioning/scripts/ + - run: uv run mypy founder-skills/skills/cap-table/scripts/ - run: uv run mypy founder-skills/tests/ test: diff --git a/.github/workflows/cowork-replay.yml b/.github/workflows/cowork-replay.yml new file mode 100644 index 0000000..acfc9da --- /dev/null +++ b/.github/workflows/cowork-replay.yml @@ -0,0 +1,80 @@ +name: cowork-replay + +# Token-free, agent-free PR gate for the fleet cowork-harness cassettes (cowork-harness >= 0.9.0): +# 1. lint — scenario no-silent-false-green invariants (CLI subcommand since 0.4.0) +# 2. privacy gate — HARD: PII/host-path scan (--skip-staleness). A finding fails the job. +# 3. staleness gate — WARN: --skip-privacy under continue-on-error. A skill edit re-stales the +# affected cassette(s) and CI can't re-record, so this stays warn-not-fail. +# 0.9.0 makes the WARN *actionable*: `fileSigs` names the exact changed file +# (e.g. "1 changed: skills/x/SKILL.md") instead of a coarse bucket — so a real +# drift points right at the file. Re-record on a cadence (see cowork-tests/README.md). +# 4. replay — content + manifest-backed file_exists/artifact_json across the whole dir. +# No model tokens, no Docker, no staged agent binary → runs on a stock GitHub-hosted runner. +# +# The LIVE lane (recording cassettes) needs the staged Cowork agent ELF (not redistributable, not on +# hosted runners) → self-hosted/local only. See cowork-tests/README.md. + +on: + pull_request: + paths: + - 'founder-skills/skills/**' + - 'founder-skills/scripts/**' + - 'founder-skills/references/**' + - 'cowork-tests/**' + - '.github/workflows/cowork-replay.yml' + +jobs: + replay: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: npm i -g 'cowork-harness@0.16.0' # EXACT pin (not a >= floor): pre-1.0 minors can break — a future cassette-format bump would replay-FAIL committed cassettes in CI, which cannot re-record. Bump deliberately after the ~30s token-free gate below (lint + privacy + replay) is green on the new version. This 0.15.0→0.16.0 bump was verified that way: lint + privacy + canary + replay all green on 0.16.0 against the committed cassettes. 0.13/0.14/0.15/0.16 carry NO breaking changes, cassette format stays **v6**, agent ELF stays 2.1.181, baseline stays 1.15200.0 → the bump is purely additive, no re-record forced. (Replay re-emits the RECORDED answers — it never runs the decider — so replay-green proves format compat, not decider behavior; 0.16's new scripted-`choose:` prefix-anchor tier is lowest-priority + fires only at RECORD time, so it cannot change a passing replay.) New since 0.12.0: `inspect ` (artifact digest, works on torn-down runs) + partial-run salvage on an unanswered gate + capability pre-flight `requires_capabilities`/exit-3 (0.13; `runs gc`→top-level `prune`, decider near-miss label tolerance); `replay --output-format json` exposes `staleness[]`/`skippedAssertions` + a `--fail-on-skill-drift` release gate (0.14); refined staleness diagnosis — a scoped skill now reports BOTH its own + shared-root drift, per-file detail on the replay lane (0.15); **H10 decider fix (0.16): `--decider-llm` replies by option NUMBER (no more option-echo whiff) + answers multiSelect; new `--decider-model ` override (live/authoring only — never reaches a committed cassette); scripted `choose:`/`--answer` prefix-anchor (boundary `startsWith`, fails loud on ambiguity — prefix-only); `docs/debugging.md` on-ramp**. DELIBERATELY NOT adopting `--fail-on-skill-drift` here: the cap-table skill is under active modification and the fleet shares roots (`scripts/`/`references/`/`agents/`), so it would red all 13 on every skill PR — keep the staleness gate WARN-only (continue-on-error) below; the baseline+skill staleness clears on the next release-cadence `rerecord.sh`. Carries forward: `record --concurrency` (0.11); bundled PyYAML + live-decider flags + `stalled` verdict (0.10); git-tracked staleness boundary + fileSigs (0.9); class-scoped allows (0.5) + `--skip-staleness`/`--skip-privacy` (0.6). + + - name: Lint scenarios (no silent false-greens) + run: cowork-harness lint cowork-tests/scenarios/ # 0.9.0: lint accepts a directory + + - name: Privacy gate (HARD) + # The PII guard that actually matters here is **synthetic-only recording**: every cassette's + # subject is a fictional company (Cadence / Acmecorp), so no real founder/company data exists to + # leak. Given that, the allowlist tunes the default scanner classes — using 0.5.0's CLASS-SCOPED + # flags so an allow can't bleed across classes (the 0.5.0 hardening): + # * currency (`--allow`) — synthetic dollar figures the skills emit. + # * DOMAIN class (`--allow-domain`) — allowed wholesale. The research/coaching skills + # (market-sizing, comp-pos, deck-review) cite 150+ PUBLIC third-party domains: public, non-PII; + # a real leak would surface a real NAME (caught by synthetic-only review) and a real EMAIL + # (caught below), not merely a domain. An enumerated 150-domain list would be unmaintainable. + # `--allow-domain` applies ONLY to domain findings — structurally it cannot clear an email + # (0.5.0 replaced our earlier `^...$`-anchored generic `--allow`, which had to be hand-anchored + # to avoid suppressing emails — see the F-2 footgun). + # * email (`--allow-email`) — only the SYNTHETIC domains (acmecorp.com — fictional deck co; + # example.com — RFC-2606 reserved, synthetic Carta stakeholders). Any OTHER email + # (e.g. a real founder@realstartup.com) FAILS the gate — the live PII tripwire. + # The email tripwire is the one guard this posture leans on hardest. The SAME $ALLOW is reused for + # an email CANARY below: a purpose-built cassette carrying a non-synthetic email + # (analyst@realstartup.com) MUST trip. If it stops tripping, the tripwire is disabled → job fails. + # Defining $ALLOW once means the gate and the canary can never drift apart. + run: | + # Canonical allowlist — single source of truth shared with cowork-tests/rerecord.sh. + source cowork-tests/privacy-allowlist.sh + # Real gate: committed cassettes must be clean under the allowlist. + cowork-harness verify-cassettes cowork-tests/cassettes/ --skip-staleness "${ALLOW[@]}" + # Email canary: the SAME allowlist must still FLAG a non-synthetic email. If verify-cassettes + # exits 0 here, the email class is no longer catching real addresses → fail loudly. + if cowork-harness verify-cassettes cowork-tests/canary/email-canary.cassette.json --skip-staleness "${ALLOW[@]}"; then + echo "::error:: email canary did NOT trip — the email PII tripwire is disabled (a real email would pass). Check --allow-email is not over-broad and that no generic --allow matches an address." + exit 1 + fi + echo "email canary tripped as expected — email tripwire is live." + + - name: Staleness gate (WARN — does not fail the job; see header F8) + continue-on-error: true + run: cowork-harness verify-cassettes cowork-tests/cassettes/ --skip-privacy + + - name: Replay content + artifact_json assertions (token-free) + run: cowork-harness replay cowork-tests/cassettes/ --output-format json diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index 5f9dee5..6d17500 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -28,6 +28,12 @@ jobs: founder-skills/tests/*) return 1 ;; .github/*) return 1 ;; docs/*) return 1 ;; + # In-plugin content ships to users and DOES require a bump, even + # though it is Markdown — list these before the generic *.md rule. + # (Per VERSIONING.md: SKILL.md / agent / reference changes = PATCH.) + founder-skills/skills/*/SKILL.md) return 0 ;; + founder-skills/skills/*/references/*.md) return 0 ;; + founder-skills/agents/*.md) return 0 ;; *.md) return 1 ;; .gitignore|.editorconfig) return 1 ;; uv.lock) return 1 ;; diff --git a/.gitignore b/.gitignore index 4874173..e0e3f3b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,9 +12,11 @@ docs/internal/ uv.lock .worktrees/ artifacts/ +runs/ cap-table-workspace/ docs/plans/ test-feedback-plugin/ scripts/sync-test-repo.sh .superpowers/ docs/superpowers/ +.playwright-mcp/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dee417..9d764fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,189 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added — cap-table explorer animation layer + pre-money slider + +- The cap-table explorer's interactive output now animates on every scenario change, per the design + spec: the three hero metrics (founder %, price/share, post-round FD shares) tick from their previous + value to the new one; the ownership donut value-morphs its wedges; the dilution Sankey transitions; + and the Founder-Impact callout and compare banner slide in. All animations honor + `prefers-reduced-motion`. +- New **pre-money "what-if" slider** in the explorer. A new `sweep.py` generator re-runs the + priced-round solver across a pre-money range (holding new money fixed) and writes a schema-locked + `sweep.json`; the explorer renders a slider that scrubs these precomputed **real solver frames** — it + snaps to discrete frames so every value shown (number and chart) is real math, never interpolated. + Optional and fully backward-compatible (no `sweep.json` → no slider). Wired into the cap-table + workflow for priced rounds; the slider is screen-reader accessible. + +### Fixed — cap-table explorer donut colors + +- The explorer's ownership donut rendered every wedge in grey (and labelled them "founders pct", etc.) + because the colour lookup didn't account for the `_pct` suffix on ownership keys. Wedges and legend + swatches now show their correct class colours. + +## [0.5.1] - 2026-06-18 — Fleet-wide hardening: audit remediation, brand theme, self-sufficient reports + +### Highlights + +A broad correctness, observability, and presentation pass across all six skills following 0.5.0's +cap-table introduction. The headline work: a full-repo audit remediation hardening every skill and +the shared scripts; the lool brand theme applied to every generated HTML artifact; self-sufficient +reports that read standalone without the chat context; a founder feedback channel; deterministic +`run_id` stamping, artifacts-root resolution, and fleet-wide outputs-tree safety; and a large +cap-table extraction- and math-correctness pass. New drift-contract and renderer-key-coverage test +suites lock each skill's prose to its producers, and a fleet-wide cowork-harness replay gate +exercises every skill under Cowork's runtime token-free on each PR — so these fixes can't silently +regress. + +### Added — feedback channel + +- `/founder-skills:feedback` command — drafts a bug report, idea, help request, or "founder win" + and hands the user a prefilled GitHub Issue / Discussion link (or a private `mailto:` to + founder-skills@lool.vc) to submit themselves. The plugin transmits nothing automatically; a + privacy hard-stop keeps company names, numbers, file paths, and transcript data out of the draft. +- Every generated report (Markdown + HTML) now carries a "Share feedback" link in its footer, + routing to the Ideas & Feedback discussion category. +- Skills surface `/founder-skills:feedback` on a blocked/failed run and on unsolicited sentiment + (once per session, never routine). +- cap-table report footer harmonized with the other five skills (now links back to the repo and + lool ventures; drops the internal rule-pack version line). + +### Added — self-sufficient reports + +- Every skill's report (Markdown + HTML) now stands alone — it carries the context, definitions, and + provenance needed to be read and shared without the originating chat session. Rolled out across + all six skills (deck-review, market-sizing, ic-sim, competitive-positioning, + financial-model-review, cap-table). + +### Added — lool brand theme + +- The lool visual identity is applied to every generated HTML artifact across all six skills: + design-token CSS plus the Sora variable font (OFL) embedded base64-inline so artifacts stay + self-contained, with a footer credit. A theme-sync contract test keeps each skill's `_theme.py` + copy identical. + +### Added — cowork-harness replay gate + +- A token-free **replay** PR gate (`.github/workflows/cowork-replay.yml`) exercises the skills under + Claude Cowork's runtime via `cowork-harness` (≥ 0.7.1). Recording is live (staged agent + Docker); + replay/verify run token- and agent-free in stock CI. **11 committed cassettes:** six cap-table + scenarios (Lane 1/2/4 extraction, anti-hallucination, priced-round + BBWA anti-dilution, + fast-assess routing) plus a per-skill fleet-parity smoke for market-sizing, ic-sim, + competitive-positioning, deck-review, and financial-model-review — each proving the artifacts-root + resolver lands deliverables at `outputs/artifacts/-/` with no host-path leak and no + `outputs/` delete. +- The suite lives at the repo root (`cowork-tests/`), **outside** the hashed plugin mount, so editing + a scenario or fixture no longer churns the cassette staleness fingerprint. Staleness is further + **scoped per skill** — each scenario declares the skill it exercises, and + `founder-skills/.cowork-hashignore` drops `tests/` (pytest is not skill runtime) — so editing one + skill re-stales only its own cassette. +- The CI gate is split: **privacy is hard-fail** (class-scoped `--allow-domain` / `--allow-email` + allowlists; only synthetic email domains permitted) and **staleness is warn-only** (the whole-plugin + mount otherwise re-stales every cassette on any skill edit). An **email canary** must trip under the + same allowlist, so the job fails if the email tripwire is ever silently disabled. All fixtures are + synthetic. + +### Added — other + +- **cap-table:** Articles-of-Association extraction dispatch template (Lane 1); `--mode=grid` dumps + the Lane-3 cell grid deterministically; vision fallback for image-only documents in the evidence + verifier. +- **cap-table — deterministic Lane-3 (freeform spreadsheet) mapping.** Replaces the agent-authored + heredoc that wrote Lane-3 artifacts with a pure, unit-tested mapper (`freeform_mapper.py`, behind + `extract_cap_table.py --mode=freeform-emit`). A closed agent↔producer contract + (`references/schemas/freeform-role-map.json`) pins block types + column-role values to schema + fields, so the structure-detection sub-agent and the producer can't drift. Off-contract roles and + fields freeform can't supply (a note's `interest_rate_type`, a preferred series' issue price, an + enum `plan_type`) become founder-confirmation **blockers** (a human-in-the-loop gate; answers + return via `--answer BLOCK.FIELD=VALUE`) rather than fabrications. Per-target-array stable instrument + ids, merged-cell/sheet-qualified-range handling, and a `cap_state.py` `E_NO_EQUITY_BASE` guard that + turns the old silent all-zero-snapshot (founders + option_pool both absent while instruments are + present) into a loud error. +- **deck-review:** resume now preserves same-run pipeline artifacts across the stage-gate + round-trip. + +### Changed — determinism & observability + +- **Unified `run_id` stamping.** All producers now inject `metadata.run_id` via a required + `--run-id` CLI flag, so every artifact in a run shares one identifier and compose can enforce + parity. Applied across ic-sim, market-sizing, competitive-positioning, deck-review, + financial-model-review, and cap-table; a static orchestration guard asserts CLI-stamping producer + pipes carry `--run-id`. +- **Deterministic artifacts-root resolution.** The inline `ARTIFACTS_ROOT` path computation in each + SKILL.md Step 0 block was guidance the agent paraphrased, not code it ran verbatim — it kept the + intent ("under `outputs/`") but dropped the detection, landing `outputs/` in one run and + `outputs/artifacts/` in another, desyncing cross-skill `find_artifact.py` resolution and + path-based assertions. All six skills now invoke a shared `scripts/resolve_artifacts_root.py` + (fixed resolution order, deterministic, creates the dir) as one opaque command. +- **Outputs-tree safety (fleet-wide).** In Cowork the per-run work dir is the promoted, user-visible + `outputs/` tree, where staging scratch or deleting artifacts is unsafe — Cowork can deny the delete + and the parity gate flags it. All six skills now stage sub-agent JSON in a `/tmp` mktemp dir and + overwrite each artifact in place every run instead of a fresh-start `rm`; a fresh per-run `run_id` + plus compose's `STALE_ARTIFACT` parity check backstop any skipped-step leftover. deck-review keeps + its `setup_run.py` resume lifecycle, with its `--clean` delete now tolerant of a Cowork-denied + delete. A `test_skill_orchestration` guard flags any `outputs/`-tree `.staging` path or `rm`. +- **CI version-bump filter** now requires in-plugin Markdown bumps, matching the documented + versioning policy. + +### Fixed — fleet-wide audit remediation + +A full-repo audit hardened all six skills and the shared scripts. By area: + +- **cap-table:** math correctness (conversion-cap-price fallback, anti-dilution baseline, donut + palette, summary counts); extraction correctness (AoA merge, Carta fabrication guard, share-suffix + parsing); pre-money SAFE now honors the document's two conversion branches and a pool-inclusive + denominator; warrants join the broad-based anti-dilution base and the rule text matches the NVCA + charter it cites; the solver flags economically impossible rounds instead of returning garbage; + note conversion surfaces as a dilution driver; anti-dilution meta keys excluded from the explorer + donut/legend; `pdfplumber` declared so a missing parser blocks the hallucination gate rather than + silently degrading; rule-pack version single-sourced and bound into every producer; the dead + ITA-SAFE citation replaced with live gov.il primary sources and honest Carta provenance. +- **financial-model-review:** burn multiple was divided by net-new ARR instead of monthly ΔMRR (a + 12× overstatement) — fixed, plus three more 12× period-mismatch bugs and a GRR sanity guard; + partial models now evaluate all 46 checklist items and data rows survive header detection; the + extracted-values review is a hard stop gate rather than a drive-by; stops overstating a + default-alive company as "on track to profitability"; magic number uses the full S&M base and + Rule of 40 uses realized YoY with honest benchmark labels (dead Mosaic citations retired); the + checklist sub-agent no longer self-gates (gating belongs to the producer); MARKER_COLLISION + pre-scan before status render; present-but-null numeric fields guarded in math and validators. +- **market-sizing:** unit-aware sensitivity parameter values — the Value column holds the input + parameter (currency / count / percent), so the old USD-for-low/high, raw-number-for-base rendering + printed percentages and counts as dollars; a new `_fmt_param_value` formats each cell by parameter + name. Also: tolerates non-numeric deck claims and null notes; compose reordered so + MARKER_COLLISION reflects in both status and the Warnings section. +- **deck-review:** 3-value `ai_company_status` with producer-applied AI-criteria gating and verbatim + pass-through; canonical scoring IDs enumerated in dispatch templates; `checklist.py` omits null + evidence/notes, requires `--run-id`, and fails closed on `-o` validation errors; `gate_state` + answer handling survives corrupt files; resume detection moved into `setup_run.py`; visualize + legend color and gauge fixes. +- **ic-sim:** derives `consensus_strength`, fixes warnings ordering, guards renderers against + malformed artifacts; resolves cross-owned straggler findings shared with market-sizing. +- **competitive-positioning:** `EVID_02` mode-gating prose corrected; scripts hardened against + malformed artifacts; `checklist.py` gains `--input-mode`/`--run-id` flags; all three + artifact-integrity warning codes named in SKILL.md. +- **shared scripts:** `find_artifact.py` conformed to the `--pretty` / `-o` / JSON-stdout script + convention; `founder_context.py` now performs a real recursive deep merge; `marketplace.json` + drops the top-level `description` to match the documented format. + +### Tests + +- **Drift-contract suites** pin each skill's SKILL.md prose to its script source across all six + skills, and surfaced/fixed several dead `coaching_payload` shell-variable captures and an + input-mode attribution bug. +- **Renderer key-coverage** tests across all six skills assert every produced key is either rendered + or explicitly excluded. +- Regression suites added for the cap-table extraction and math fixes. + +### Docs + +- README adds the cap-table skill section and documents six agents and Python 3.10+; CONTRIBUTING and + SECURITY include cap-table; VERSIONING clarifies that tags gate releases and reconciles the + no-bump cases; CLAUDE.md sync-test-repo framing and e2e figures corrected. + ## [0.5.0] - 2026-06-10 — New skill: cap-table; financial-model-review hardening +### Highlights + This release ships the cap-table skill for the first time and completes a pre-distribution hardening pass on financial-model-review. Users upgrading from 0.4.7 get both skills in their first-ever stable form — neither was available in any prior distributed release. @@ -176,7 +357,7 @@ Gate 2 no longer blocks publication for profitable or default-alive companies. P #### HTML self-containment and escaping - **Chart.js vendored into `explore.py`.** The explorer previously loaded Chart.js from a CDN. The Cowork iframe sandbox blocks external fetches; offline `file://` viewing also broke. Copied the vendored `chart.min.js` (already used by `competitive-positioning/scripts/explore.py`) into `financial-model-review/scripts/vendor/` and switched to inline embedding. -- **`` injection hardening.** Founder-document-derived data (company names, LLM-extracted strings) embedded as JSON in `` injection hardening.** Founder-document-derived data (company names, LLM-extracted strings) embedded as JSON in ` +{_theme.FOOTER_CREDIT_HTML} """ @@ -663,6 +1630,7 @@ def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--dir", required=True) p.add_argument("-o", "--output", required=True) + p.add_argument("--pretty", action="store_true", help="Indent the JSON receipt printed to stdout") args = p.parse_args() def _read(name: str) -> dict[str, Any]: @@ -674,17 +1642,30 @@ def _read(name: str) -> dict[str, Any]: scenarios_doc = _read("scenarios.json") counsel_packet = _read("counsel_packet.json") + # sweep.json is optional — present only when a pre-money sweep was generated. + sweep: dict[str, Any] | None = None + sweep_path = os.path.join(args.dir, "sweep.json") + if os.path.exists(sweep_path): + with open(sweep_path, encoding="utf-8") as f: + sweep = json.load(f) + html_out = render_explorer_html( inputs=inputs, cap_state=cap_state, scenarios_doc=scenarios_doc, counsel_packet=counsel_packet, + sweep=sweep, ) out = os.path.abspath(args.output) os.makedirs(os.path.dirname(out) or ".", exist_ok=True) with open(out, "w", encoding="utf-8") as f: f.write(html_out) - print(json.dumps({"ok": True, "path": out, "bytes": len(html_out.encode("utf-8"))})) + print( + json.dumps( + {"ok": True, "path": out, "bytes": len(html_out.encode("utf-8"))}, + indent=2 if args.pretty else None, + ) + ) return 0 diff --git a/founder-skills/skills/cap-table/scripts/extract_aoa.py b/founder-skills/skills/cap-table/scripts/extract_aoa.py index 271755c..d9abb1a 100644 --- a/founder-skills/skills/cap-table/scripts/extract_aoa.py +++ b/founder-skills/skills/cap-table/scripts/extract_aoa.py @@ -49,6 +49,7 @@ from typing import Any sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _rule_pack import RULE_PACK_VERSION # noqa: E402 _SCHEMA_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -56,8 +57,6 @@ "schemas", ) -RULE_PACK_VERSION = "0.4.0" - VALID_LIQ_PREF_TYPES = {"non_participating", "participating", "participating_capped"} VALID_ANTI_DILUTION = { "none", @@ -299,63 +298,75 @@ def merge_into_inputs( source_doc: str | None, extraction_confidence_per_series: dict[str, str] | None = None, aoa_findings: dict[str, Any] | None = None, + replace_existing: bool = False, ) -> dict[str, Any]: """Merge validated AoA preferred_series block into existing inputs.json. Behavior: - - Reads existing inputs.json (must exist) + - Reads existing inputs.json (must exist; missing → status 'merge_failed'). - For each series in `preferred_series`: if a series with the same - `series_name` already exists, error out (caller must resolve manually). - Otherwise append to inputs.preferred_series[]. - - Stamps `extraction_provenance` on each new entry so downstream tooling - can trace back to the AoA. - - Writes inputs.json back. + `series_name` already exists and `replace_existing` is False, this is a + CONFLICT — nothing is written (atomic), status 'conflict'. With + `replace_existing=True`, the existing entry is replaced in place with + fresh provenance. + - Otherwise append to inputs.preferred_series[]. + - Stamps `extraction_provenance` on each new/replaced entry. + - Writes inputs.json back only when there are no unresolved conflicts. Returns a structured receipt (counts, paths, any conflicts). """ if not os.path.exists(inputs_path): - return {"status": "error", "reason": f"inputs.json not found at {inputs_path}"} + return {"status": "merge_failed", "reason": f"inputs.json not found at {inputs_path}"} with open(inputs_path, encoding="utf-8") as f: inputs = json.load(f) existing_series = inputs.setdefault("preferred_series", []) - existing_names = {s.get("series_name") for s in existing_series} + existing_index = {s.get("series_name"): i for i, s in enumerate(existing_series) if isinstance(s, dict)} - added: list[str] = [] - conflicts: list[str] = [] now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + # First pass: detect conflicts WITHOUT mutating, so a no-flag conflict is an + # atomic no-write (nothing partially merged). + conflicts = [ + series.get("series_name") + for series in preferred_series + if isinstance(series.get("series_name"), str) and series.get("series_name") in existing_index + ] + if conflicts and not replace_existing: + return { + "status": "conflict", + "added": [], + "conflicts": conflicts, + "reason": ( + f"{len(conflicts)} series already present in inputs.preferred_series[]: " + f"{conflicts}. Nothing was written. Re-run with --replace-existing to " + f"overwrite in place." + ), + } + + added: list[str] = [] + replaced: list[str] = [] for series in preferred_series: name = series.get("series_name") if not isinstance(name, str): # Series without a string series_name is invalid — skip silently continue - if name in existing_names: - conflicts.append(name) - continue new_entry = dict(series) - # Provenance stamp confidence_level = (extraction_confidence_per_series or {}).get(name, "medium") new_entry["extraction_provenance"] = { "source_doc": source_doc or "", "extraction_confidence": confidence_level, "extracted_at": now, } - existing_series.append(new_entry) - added.append(name) - - if conflicts: - return { - "status": "conflict", - "added": added, - "conflicts": conflicts, - "reason": ( - f"{len(conflicts)} series already present in inputs.preferred_series[]: " - f"{conflicts}. Caller must resolve (replace vs skip vs rename) before " - f"writing AoA extraction. Use --replace-existing to force overwrite." - ), - } + if name in existing_index: + # replace_existing path: overwrite in place with fresh provenance. + existing_series[existing_index[name]] = new_entry + replaced.append(name) + else: + existing_index[name] = len(existing_series) + existing_series.append(new_entry) + added.append(name) # Persist AoA-level findings (pay_to_play_detected, etc.) so # rule_audit.py --phase=post_math's _runtime_event_predicate can suppress @@ -386,6 +397,8 @@ def merge_into_inputs( "status": "merged", "added": added, "added_count": len(added), + "replaced": replaced, + "replaced_count": len(replaced), "total_preferred_series_after_merge": len(existing_series), "inputs_path": os.path.abspath(inputs_path), "aoa_findings_persisted": bool(aoa_findings), @@ -457,15 +470,26 @@ def _cli() -> int: source_doc=args.source_doc, extraction_confidence_per_series=series_conf, aoa_findings=aoa_findings_to_persist, + replace_existing=args.replace_existing, ) receipt["merge"] = merge_result - if merge_result.get("status") == "conflict" and not args.replace_existing: + merge_status = merge_result.get("status") + if merge_status == "conflict": + # No-write atomic conflict (no --replace-existing). + receipt["status"] = "conflict" sys.stderr.write( f"extract_aoa.py: merge conflict — {len(merge_result['conflicts'])} " - f"series already present. Use --replace-existing to force.\n" + f"series already present. Nothing written. Re-run with --replace-existing.\n" ) print(json.dumps(receipt, indent=2 if args.pretty else None)) return 2 + if merge_status == "merge_failed": + receipt["status"] = "merge_failed" + sys.stderr.write(f"extract_aoa.py: merge failed — {merge_result.get('reason')}\n") + print(json.dumps(receipt, indent=2 if args.pretty else None)) + return 1 + # merged + receipt["status"] = "merged" print(json.dumps(receipt, indent=2 if args.pretty else None)) return 0 diff --git a/founder-skills/skills/cap-table/scripts/extract_cap_table.py b/founder-skills/skills/cap-table/scripts/extract_cap_table.py index 8627bb7..ac2741d 100644 --- a/founder-skills/skills/cap-table/scripts/extract_cap_table.py +++ b/founder-skills/skills/cap-table/scripts/extract_cap_table.py @@ -16,8 +16,8 @@ references/carta-pulley-mapping.md). * --mode=pulley: stub — Pulley column mapping is Phase 1 follow-up (no real Pulley exports in the corpus yet to verify against). - * --mode=freeform: validates Context-A SPREADSHEET_STRUCTURE_DETECTION - sub-agent output; the actual cell-mapping happens in the sub-agent. + * --mode=freeform-emit: deterministically maps Context-A SPREADSHEET_STRUCTURE_DETECTION + blocks (stdin) + the --xlsx grid into schema-valid inputs.json + instruments.json. Carta extractor implementation notes (per real-world corpus): * Carta puts a banner in rows 2-3; real headers are in row 5. @@ -35,6 +35,7 @@ import argparse import io import json +import math import os import re import sys @@ -214,6 +215,22 @@ def _normalize_discount(d: Any) -> tuple[float | None, str | None]: return None, f"discount value {v} out of expected range" +def _to_iso_date(v: Any) -> str | None: + """Convert a spreadsheet date cell to ISO 8601 string (or None). + + Module-scope so both the Carta mapper and the Lane-3 freeform mapper share it. + """ + if v is None or v == "": + return None + if hasattr(v, "date"): # datetime + result: str = v.date().isoformat() + return result + if hasattr(v, "isoformat"): # date + result_d: str = v.isoformat() + return result_d + return str(v)[:10] + + def _infer_safe_form(cap: Any, discount: Any) -> str: """Infer the SAFE form from cap + discount presence.""" has_cap = cap is not None and cap != "" and float(cap) > 0 @@ -259,18 +276,6 @@ def _convertible_record_to_instrument(rec: dict[str, Any], idx: int) -> tuple[st warnings_list.append(f"{sec_id}: skipped (converted={converted!r} cancelled={cancelled!r})") return ("skip", {}, warnings_list) - def _to_iso_date(v: Any) -> str | None: - """Convert a Carta date cell to ISO 8601 string (or None).""" - if v is None or v == "": - return None - if hasattr(v, "date"): # datetime - result: str = v.date().isoformat() - return result - if hasattr(v, "isoformat"): # date - result_d: str = v.isoformat() - return result_d - return str(v)[:10] - investor_name = (rec.get("Stakeholder Name") or "").strip() issue_date = _to_iso_date(rec.get("Issue Date")) or "1900-01-01" @@ -288,6 +293,17 @@ def _to_iso_date(v: Any) -> str | None: if is_safe: form = _infer_safe_form(valuation_cap, discount_raw) + # Carta's "Valuation Cap" column does not distinguish pre-money from + # post-money. We map cap-only to the post-money form (the modern default + # and the Carta export skew), but a legacy pre-money SAFE would get the + # wrong company-capitalization denominator (Gotcha #1). Warn and cap + # confidence at "medium" so a downstream reviewer confirms the vintage. + if valuation_cap and form == "yc_postmoney_cap": + warnings_list.append( + f"{sec_id}: cap mapped to post_money_valuation_cap (Carta export does not " + f"distinguish pre/post-money). If this is a legacy pre-money SAFE, the " + f"company-capitalization denominator differs (Gotcha #1) — confirm vintage." + ) return ( "safe", { @@ -302,18 +318,60 @@ def _to_iso_date(v: Any) -> str | None: "form": form, "conversion_price_override": None, "source_document": f"carta:{sec_id}", - "extraction_confidence": "high", + "extraction_confidence": "medium", }, warnings_list, ) - # Convertible note + # Convertible note. + # + # The Carta export does not carry a qualified-financing threshold or a + # maturity-default treatment, and the math is sensitive to both + # (maturity_default_treatment selects the note_conversion 7-branch path; the + # QF threshold gates conversion). Do NOT fabricate them — leave null so the + # math producer surfaces a structural blocker rather than running on a guess. + # day_count_basis=365 and interest_converts_to_shares=True are standard + # conventions we keep, but they are still assumptions on a Carta import, so + # they (and the other unsupplied fields) get receipt warnings. Carta notes + # are capped at "medium" extraction_confidence for the same reason. + # Determine interest_rate_type from the Carta row. + # Carta exports carry a numeric Interest Rate column but no type qualifier. + # When a rate is present, default to fixed_numeric_simple (simple interest, + # fixed numeric rate — the most common convention for convertible notes). + # When no rate, use "none". Both are assumptions; warn so the agent asks. + assumed_irt = "fixed_numeric_simple" if interest_rate else "none" + warnings_list.append( + f"{sec_id}: interest_rate_type assumed {assumed_irt!r} (Carta export carries no rate-type qualifier) " + f"— confirm with note text" + ) + + for assumed_field, note in ( + ("day_count_basis", "assumed 365 (Carta export carries no day-count basis); confirm with note text"), + ( + "interest_converts_to_shares", + "assumed true (Carta export does not state whether accrued interest converts); confirm with note text", + ), + ( + "qualified_financing_threshold", + "left null (Carta export carries no QF threshold) — provide before running conversion math", + ), + ( + "maturity_default_treatment", + "left null (Carta export carries no maturity-default treatment) — provide before running maturity math", + ), + ( + "capitalization_denominator", + "left null (Carta export carries no cap-denominator) — confirm with note text", + ), + ): + warnings_list.append(f"{sec_id}: {assumed_field} {note}") return ( "note", { "id": f"note_{idx:03d}", "investor_name": investor_name, "principal": float(principal), - "annual_interest_rate": float(interest_rate), + "annual_interest_rate": float(interest_rate) if interest_rate else None, + "interest_rate_type": assumed_irt, "day_count_basis": 365, "compounding_periods_per_year": None, "interest_converts_to_shares": True, @@ -323,18 +381,49 @@ def _to_iso_date(v: Any) -> str | None: "discount_multiplier": discount_mult, "capitalization_denominator": None, "capitalization_denominator_policy": "Carta-supplied: confirm with note text", - "qualified_financing_threshold": 1_000_000.0, # default; confirm + "qualified_financing_threshold": None, "maturity_date": maturity_date, - "maturity_default_treatment": "convert_at_cap", + "maturity_default_treatment": None, "maturity_conversion_price_override": None, "non_qualified_financing_treatment": None, "source_document": f"carta:{sec_id}", - "extraction_confidence": "high", + "extraction_confidence": "medium", }, warnings_list, ) +def _extract_carta_fd_total(rows: list[Any]) -> int | None: + """Carta's INDEPENDENT printed grand fully-diluted total from the Summary Cap Table rows. + + Locates the 'Fully Diluted Shares' header column (EXACT normalized match, so the sibling + 'Fully Diluted Shares with …' column is not picked), then the grand-total row labeled 'Totals', + and returns its value in that column. None if either isn't found. Pure (takes row tuples).""" + + def _norm(s: Any) -> str: + return " ".join(str(s).split()).lower() if s is not None else "" + + fd_col = None + for row in rows: + for j, cell in enumerate(row): + if _norm(cell) == "fully diluted shares": + fd_col = j + break + if fd_col is not None: + break + if fd_col is None: + return None + for row in rows: + label = next((c for c in row if c is not None and str(c).strip()), None) + if label is not None and _norm(label) == "totals": + val = row[fd_col] if fd_col < len(row) else None + try: + return int(round(float(val))) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + return None + + def _carta_extract(xlsx_path: str) -> dict[str, Any]: """Extract structured data from a Carta XLSX into our canonical format. @@ -355,19 +444,20 @@ def _carta_extract(xlsx_path: str) -> dict[str, Any]: # skipped, discount normalization not applied, both rows classified as # notes). Raising E_CARTA_FINGERPRINT_MISMATCH tells the founder the # file isn't the expected shape so they can re-export or fall back to - # --mode=freeform. + # Lane 3 (--mode=freeform-emit). if format_detected == "unknown": raise CartaFingerprintMismatchError( "E_CARTA_FINGERPRINT_MISMATCH: --mode=carta was specified but the workbook " f"sheet names {sheet_names!r} do not match Carta's verified Summary Cap Table + " "Convertible Ledger fingerprint. The export may be from a different version of " "Carta, a different vendor (Pulley/etc.), or a custom workbook. Re-export the " - "Carta cap table or use --mode=freeform to dispatch a Context-A " - "SPREADSHEET_STRUCTURE_DETECTION sub-agent." + "Carta cap table, or fall back to Lane 3: dispatch a Context-A " + "SPREADSHEET_STRUCTURE_DETECTION sub-agent and run --mode=freeform-emit." ) # 1. Read Summary Cap Table for share-class totals summary_totals: dict[str, Any] = {} + fd_total = None if "Summary Cap Table" in sheet_names: sheets_consumed.append("Summary Cap Table") ws = wb["Summary Cap Table"] @@ -397,6 +487,12 @@ def _carta_extract(xlsx_path: str) -> dict[str, Any]: summary_totals["as_of_date"] = date_str except StopIteration: pass + # A1: capture Carta's INDEPENDENT printed grand fully-diluted total (the 'Totals' row) so cap_state + # can cross-foot the computed FD against it. Independent because Carta computes it, not the rebuilt + # rows — the one non-circular reconciliation anchor. + fd_total = _extract_carta_fd_total(list(ws.iter_rows(values_only=True))) + if fd_total is not None: + summary_totals["fully_diluted"] = fd_total # 2. Extract convertibles from Convertible Ledger (if present) instruments_safes = [] @@ -455,8 +551,9 @@ def _mode_carta(args: argparse.Namespace) -> int: "blocker": "E_CARTA_FINGERPRINT_MISMATCH", "error": str(e)[:600], "remedy": ( - "The workbook isn't the Carta shape we extract from. Re-export from Carta, " - "or fall back to --mode=freeform to dispatch Context-A SPREADSHEET_STRUCTURE_DETECTION." + "The workbook isn't the Carta shape we extract from. Re-export from Carta, or " + "fall back to Lane 3 (--mode=freeform-emit) via a Context-A " + "SPREADSHEET_STRUCTURE_DETECTION dispatch." ), } print(json.dumps(err_receipt, indent=2)) @@ -467,7 +564,9 @@ def _mode_carta(args: argparse.Namespace) -> int: "mode": "carta", "blocker": "carta_extraction_failed", "error": f"{type(e).__name__}: {e}"[:300], - "remedy": "Fall back to --mode=freeform and dispatch Context-A SPREADSHEET_STRUCTURE_DETECTION.", + "remedy": ( + "Fall back to Lane 3 (--mode=freeform-emit) via a Context-A SPREADSHEET_STRUCTURE_DETECTION dispatch." + ), } print(json.dumps(err_receipt, indent=2)) return 1 @@ -501,6 +600,8 @@ def _mode_carta(args: argparse.Namespace) -> int: existing = json.load(f) existing_meta = dict(existing.get("metadata") or {}) existing_meta["schema_version"] = "v0.5.0-instruments" + if getattr(args, "run_id", None): + existing_meta["run_id"] = args.run_id merged = { "safes": existing.get("safes", []) + result["instruments"]["safes"], "convertible_notes": existing.get("convertible_notes", []) + result["instruments"]["convertible_notes"], @@ -528,51 +629,257 @@ def _mode_pulley_stub(args: argparse.Namespace) -> int: "blocker": "pulley_mapping_not_yet_implemented", "remedy": ( "Pulley extraction is a Phase 1 follow-up — no real Pulley exports " - "in the test corpus to verify against. Run --mode=freeform and " - "dispatch the Context A sub-agent SPREADSHEET_STRUCTURE_DETECTION." + "in the test corpus to verify against. Dispatch the Context-A " + "SPREADSHEET_STRUCTURE_DETECTION sub-agent and run --mode=freeform-emit (Lane 3)." ), } print(json.dumps(receipt, indent=2)) return 1 -def _mode_freeform_validate(args: argparse.Namespace) -> int: - """Validate Context-A sub-agent output for spreadsheet structure detection. +def _serialize_cell(value: Any) -> Any: + """Convert a cell value to a JSON-serializable form. - Expects JSON on stdin matching the agent's SPREADSHEET_STRUCTURE_DETECTION - return shape: {"blocks": [{"block_type", "sheet", "cell_range", - "column_role_map", "confidence", "evidence"}, ...]}. - - For v0.1: validates the shape, surfaces ambiguities, but does NOT yet - convert the cell-mapping to instruments.json automatically — that's - Phase 1 follow-up work. The agent's mapping is captured to - extraction_audit.json and the founder confirms before commit. + openpyxl data_only=True returns computed values, which may include + datetime.datetime / datetime.date / datetime.time objects (none of which + are JSON-serializable) and datetime.timedelta for duration-formatted + cells. Convert datetimes/times to ISO strings and timedeltas to their + string form; leave everything else as-is. """ - payload = json.load(sys.stdin) - if "blocks" not in payload or not isinstance(payload["blocks"], list): - sys.stderr.write("extract_cap_table.py freeform: expected {blocks: [...]} on stdin\n") + import datetime + + if isinstance(value, (datetime.datetime, datetime.date, datetime.time)): + return value.isoformat() + if isinstance(value, datetime.timedelta): + return str(value) + return value + + +# --- Lane-3 grid payload compaction (H4: control-frame size cap) ------------- +# +# The --mode=grid dump is inlined into the SPREADSHEET_STRUCTURE_DETECTION +# dispatch prompt, which becomes a harness/Cowork control frame with a hard size +# ceiling (256 KiB). A large freeform workbook can blow past that. But the grid +# is consumed ONLY for structure/role detection — the deterministic +# --mode=freeform-emit phase re-reads the FULL grid straight from the file — so +# the structure-detection grid can be trimmed, rounded, and row-elided down to a +# byte budget with no effect on final-output fidelity. + +GRID_BUDGET_BYTES = 200_000 # ~195 KiB; headroom under the 256 KiB control-frame cap +_GRID_FLOAT_SIG = 8 # significant figures kept when rounding floats +_GRID_ELIDE_HEAD = 40 # data rows kept at the top of an elided block +_GRID_ELIDE_TAIL = 10 # data rows kept at the bottom of an elided block + + +def _grid_cell_blank(v: Any) -> bool: + return v is None or (isinstance(v, str) and v.strip() == "") + + +def _used_bounds(rows: list[Any], merged_ranges: list[str]) -> tuple[int, int]: + """Return (last_row, last_col), 1-based, covering every non-blank cell plus + any merged range. (0, 0) when the sheet has no content. Used to drop the + phantom blank padding openpyxl reports beyond the real used range.""" + from openpyxl.utils import range_boundaries # type: ignore[import-untyped] + + last_row = 0 + last_col = 0 + for r_idx, row in enumerate(rows, start=1): + for c_idx, cell in enumerate(row, start=1): + if not _grid_cell_blank(cell): + last_row = max(last_row, r_idx) + last_col = max(last_col, c_idx) + for mr in merged_ranges: + try: + _min_col, _min_row, max_col, max_row = range_boundaries(str(mr)) + except Exception: + continue + if max_row: + last_row = max(last_row, int(max_row)) + if max_col: + last_col = max(last_col, int(max_col)) + return last_row, last_col + + +def _trim_sheet(raw: dict[str, Any]) -> dict[str, Any]: + """Trim a raw sheet to its used bounding box (drops phantom trailing rows and + columns; keeps interior blanks so column index still maps to column letter).""" + from openpyxl.utils import get_column_letter # type: ignore[import-untyped] + + rows = raw.get("rows", []) + last_row, last_col = _used_bounds(rows, raw.get("merged_ranges", [])) + trimmed = [list(row[:last_col]) + [None] * max(0, last_col - len(row)) for row in rows[:last_row]] + out = dict(raw) + out["rows"] = trimmed + out["dimensions"] = f"A1:{get_column_letter(last_col)}{last_row}" if last_row and last_col else "A1:A1" + return out + + +def _round_sig(x: float, sig: int) -> float: + if x == 0 or not math.isfinite(x): + return x + digits = sig - int(math.floor(math.log10(abs(x)))) - 1 + return round(x, digits) + + +def _round_floats(rows: list[Any], sig: int = _GRID_FLOAT_SIG) -> list[Any]: + """Round every float cell to `sig` significant figures; leave ints/strings/None + untouched. Structure detection never needs 15-digit precision.""" + return [[_round_sig(c, sig) if isinstance(c, float) else c for c in row] for row in rows] + + +def _elide_sheet(raw: dict[str, Any], head: int = _GRID_ELIDE_HEAD, tail: int = _GRID_ELIDE_TAIL) -> dict[str, Any]: + """Collapse a tall block: keep `head` rows from the top and `tail` from the + bottom, replacing the middle with a marker. Kept rows become indexed objects + ({"r": <1-based row>, "c": [cells]}) so the sub-agent still reports cell_range + in true spreadsheet coordinates; the marker is {"elided": n, "rows": "a-b"}.""" + rows = raw.get("rows", []) + n = len(rows) + if n <= head + tail: + return raw # nothing worth eliding + indexed: list[Any] = [{"r": i + 1, "c": rows[i]} for i in range(head)] + first_elided, last_elided = head + 1, n - tail + indexed.append({"elided": last_elided - first_elided + 1, "rows": f"{first_elided}-{last_elided}"}) + indexed.extend({"r": i + 1, "c": rows[i]} for i in range(n - tail, n)) + out = dict(raw) + out["rows"] = indexed + out["indexed"] = True + return out + + +def _compact_sheets(raw_sheets: dict[str, Any], budget: int) -> tuple[dict[str, Any], dict[str, Any]]: + """Compact the grid under `budget` bytes via escalating tiers (each applied + only while still over budget): trim phantom blanks → round floats → elide tall + sheets (largest first). Returns (sheets, meta) where meta records which tiers + fired, the final payload size, and whether it is still over budget.""" + + def measure(sh: dict[str, Any]) -> int: + return len(json.dumps({"ok": True, "mode": "grid", "sheets": sh}, separators=(",", ":"))) + + applied: list[str] = ["trim"] + sheets = {name: _trim_sheet(raw) for name, raw in raw_sheets.items()} + + if measure(sheets) > budget: + applied.append("round_floats") + for name in sheets: + sheets[name]["rows"] = _round_floats(sheets[name]["rows"]) + + if measure(sheets) > budget: + applied.append("elide_rows") + for name in sorted(sheets, key=lambda n: len(json.dumps(sheets[n], default=str)), reverse=True): + if measure(sheets) <= budget: + break + sheets[name] = _elide_sheet(sheets[name]) + + payload_bytes = measure(sheets) + meta = { + "applied": applied, + "payload_bytes": payload_bytes, + "budget_bytes": budget, + "over_budget": payload_bytes > budget, + } + return sheets, meta + + +def _mode_grid(args: argparse.Namespace) -> int: + """Dump every sheet of --xlsx as a cell-value grid for Lane-3 dispatch. + + Output shape (to stdout): + {"ok": true, "mode": "grid", + "sheets": { + "": { + "dimensions": "", + "rows": [[...], ...], // values_only; None for blank cells + "merged_ranges": ["A4:C4", ...], + "indexed": true // present only when rows were elided + }, ... + }, + "compaction": {"applied": [...], "payload_bytes": N, "budget_bytes": B, "over_budget": false}} + + The grid is compacted under a byte budget (default GRID_BUDGET_BYTES, override + with --grid-budget-bytes) so it fits the control-frame cap; an elided sheet's + rows are indexed objects ({"r","c"}) interleaved with {"elided","rows"} markers. + If the grid cannot be compacted under budget, a `grid_too_large` blocker is + returned (exit 1) rather than overflowing the control frame. + + With -o/--output the full JSON is written to the file and a compact + receipt is emitted to stdout confirming the write path. + """ + if not args.xlsx: + sys.stderr.write("--xlsx required for --mode=grid\n") + return 1 + if not os.path.exists(args.xlsx): + err: dict[str, Any] = { + "ok": False, + "mode": "grid", + "blocker": "file_not_found", + "error": f"file not found: {args.xlsx}", + } + print(json.dumps(err)) return 1 - audit = { - "mode": "freeform", - "blocks_detected": len(payload["blocks"]), - "blocks": payload["blocks"], - "low_confidence_blocks": [b for b in payload["blocks"] if b.get("confidence") in {"low", "medium"}], - "ambiguities": [a for b in payload["blocks"] for a in b.get("ambiguities", [])], - "next_action": ( - "Present low_confidence_blocks + ambiguities to the founder via " - "AskUserQuestion. Once confirmed, map each block into " - "inputs.json + instruments.json (Phase 1 follow-up: automate the mapping)." - ), + try: + wb = _open_xlsx(args.xlsx) + except Exception as e: + err = { + "ok": False, + "mode": "grid", + "blocker": "load_failed", + "error": f"{type(e).__name__}: {e}", + } + print(json.dumps(err)) + return 1 + + raw_sheets: dict[str, Any] = {} + for ws in wb.worksheets: + rows = [[_serialize_cell(cell) for cell in row] for row in ws.iter_rows(values_only=True)] + merged = [str(r) for r in ws.merged_cells.ranges] + raw_sheets[ws.title] = { + "dimensions": ws.dimensions, + "rows": rows, + "merged_ranges": merged, + } + + budget = args.grid_budget_bytes if getattr(args, "grid_budget_bytes", None) else GRID_BUDGET_BYTES + sheets, compaction = _compact_sheets(raw_sheets, budget) + + if compaction["over_budget"]: + blocker = { + "ok": False, + "mode": "grid", + "blocker": "grid_too_large", + "compaction": compaction, + "error": ( + f"freeform grid is {compaction['payload_bytes']} bytes after compaction, over the " + f"{budget}-byte control-frame budget. Split the workbook into per-sheet files and run " + "--mode=grid on each, or reconstruct the cap table conversationally (Lane 4)." + ), + } + print(json.dumps(blocker, indent=2 if args.pretty else None)) + return 1 + + payload: dict[str, Any] = { + "ok": True, + "mode": "grid", + "sheets": sheets, + "compaction": compaction, } if args.output: out = os.path.abspath(args.output) os.makedirs(os.path.dirname(out) or ".", exist_ok=True) with open(out, "w", encoding="utf-8") as f: - json.dump(audit, f, indent=2) - audit["written_to"] = out - print(json.dumps(audit, indent=2 if args.pretty else None)) + json.dump(payload, f, indent=2 if args.pretty else None) + receipt: dict[str, Any] = { + "ok": True, + "mode": "grid", + "written_to": out, + "sheet_count": len(sheets), + "compaction": compaction, + } + print(json.dumps(receipt, indent=2 if args.pretty else None)) + else: + print(json.dumps(payload, indent=2 if args.pretty else None)) + return 0 @@ -617,24 +924,198 @@ def _check_supported_input_type(path: str) -> tuple[bool, str]: return True, "" +def _mode_freeform_emit(args: argparse.Namespace) -> int: + """Deterministically map SPREADSHEET_STRUCTURE_DETECTION blocks (stdin) + the + --xlsx grid into inputs.json + instruments.json under --dir. + + Required-but-unsupplied fields surface as blockers (no fabrication); the founder's + answers come back via --answer BLOCK.FIELD=VALUE (e.g. + --answer 0.interest_rate_type=fixed_numeric_simple). On a clean map (no blockers) + both artifacts are schema-validated and written. Blockers are a GATE (exit 0), not + an error: the agent resolves them with the founder and re-runs with --answer. + """ + import freeform_mapper # lazy: freeform_mapper imports from this module + + if not args.xlsx: + sys.stderr.write("--xlsx required for --mode=freeform-emit\n") + return 1 + if not args.dir: + sys.stderr.write("--dir required for --mode=freeform-emit\n") + return 1 + if not os.path.exists(args.xlsx): + print(json.dumps({"ok": False, "blocker": "file_not_found", "error": f"file not found: {args.xlsx}"})) + return 1 + + try: + payload = json.load(sys.stdin) + except Exception as e: + sys.stderr.write(f"freeform-emit: expected {{blocks:[...]}} JSON on stdin ({e})\n") + return 1 + blocks = payload.get("blocks") if isinstance(payload, dict) else None + if not isinstance(blocks, list): + print(json.dumps({"ok": False, "blocker": "bad_input", "error": "stdin must be {blocks:[...]}"})) + return 1 + + # Build the cell grid from the workbook (same shape as --mode=grid). + try: + wb = _open_xlsx(args.xlsx) + except Exception as e: + print(json.dumps({"ok": False, "blocker": "load_failed", "error": f"{type(e).__name__}: {e}"})) + return 1 + sheets: dict[str, Any] = {} + for ws in wb.worksheets: + rows = [[_serialize_cell(cell) for cell in row] for row in ws.iter_rows(values_only=True)] + sheets[ws.title] = { + "dimensions": ws.dimensions, + "rows": rows, + "merged_ranges": [str(r) for r in ws.merged_cells.ranges], + } + grid = {"ok": True, "mode": "grid", "sheets": sheets} + + # Company meta must already exist (Step 2 wrote inputs.json); equity merges into it. + inputs_path = os.path.join(args.dir, "inputs.json") + if not os.path.exists(inputs_path): + print( + json.dumps( + { + "ok": False, + "blocker": "no_inputs_json", + "remedy": ( + "Run Step 2 first to establish company/mode in inputs.json; " + "freeform-emit merges equity into it." + ), + } + ) + ) + return 1 + with open(inputs_path, encoding="utf-8") as f: + existing_inputs = json.load(f) + + answers: dict[str, Any] = {} + for kv in args.answer or []: + if "=" not in kv: + sys.stderr.write(f"--answer must be BLOCK.FIELD=VALUE, got {kv!r}\n") + return 1 + k, v = kv.split("=", 1) + answers[k.strip()] = v.strip() + + result = freeform_mapper.map_freeform( + blocks, grid, existing_inputs=existing_inputs, answers=answers, run_id=args.run_id or "" + ) + + if result["blockers"]: + # A schema/empty blocker (a block carried the wrong field schema — row_range/columns instead of + # cell_range/column_role_map — or equity blocks mapped 0 records) is NOT founder-answerable: it + # needs a re-dispatch with the correct field names. Check this FIRST (its field values are + # disjoint from the off-contract test below, and its reasons never contain "off-contract"). + schema_empty = any(b.get("field") in {"cell_range", "column_role_map", "emit"} for b in result["blockers"]) + # An off-contract blocker (the sub-agent emitted a block_type/role outside the closed + # vocabulary) is NOT founder-answerable — steer it to a re-dispatch, not an AskUserQuestion. + off_contract = any( + b.get("field") == "block_type" or "off-contract" in str(b.get("reason", "")) for b in result["blockers"] + ) + if schema_empty: + next_action = ( + "One or more SPREADSHEET_STRUCTURE_DETECTION blocks used the wrong field schema or mapped " + "zero rows: each block must carry `cell_range` (the DATA rows, e.g. 'A5:F12') and " + "`column_role_map` (column-letter -> role) — NOT `row_range`/`columns`/`rows`. Re-dispatch " + "SPREADSHEET_STRUCTURE_DETECTION with the correct field names and ranges that point at the " + "data rows (not headers/blank rows); do not ask the founder about these." + ) + elif off_contract: + next_action = ( + "One or more blocks are off-contract: the SPREADSHEET_STRUCTURE_DETECTION sub-agent used a " + "block_type or column-role value outside the closed vocabulary (see the contract in " + "agents/cap-table.md / references/schemas/freeform-role-map.json). Re-dispatch " + "SPREADSHEET_STRUCTURE_DETECTION and emit ONLY contract block_types/roles — do not ask the " + "founder about these. Any remaining founder-answerable blockers still use " + "--answer .=." + ) + else: + next_action = ( + "Resolve each blocker with the founder via AskUserQuestion, then re-run with " + "--answer .= for the answerable fields." + ) + print( + json.dumps( + { + "ok": False, + "mode": "freeform-emit", + "blockers": result["blockers"], + "warnings": result["warnings"], + "next_action": next_action, + }, + indent=2 if args.pretty else None, + ) + ) + return 0 # a gate, not an error + + errs: dict[str, Any] = {} + iv = validate(result["inputs"], load_schema(os.path.join(_SCHEMA_DIR, "inputs.schema.json"))) + if iv: + errs["inputs.json"] = iv + nv = validate(result["instruments"], load_schema(os.path.join(_SCHEMA_DIR, "instruments.schema.json"))) + if nv: + errs["instruments.json"] = nv + if errs: + print(json.dumps({"ok": False, "mode": "freeform-emit", "errors": errs}, indent=2 if args.pretty else None)) + return 1 + + os.makedirs(args.dir, exist_ok=True) + for fname, data in (("inputs.json", result["inputs"]), ("instruments.json", result["instruments"])): + with open(os.path.join(args.dir, fname), "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + print( + json.dumps( + { + "ok": True, + "mode": "freeform-emit", + "written": ["inputs.json", "instruments.json"], + "dir": os.path.abspath(args.dir), + "warnings": result["warnings"], + }, + indent=2 if args.pretty else None, + ) + ) + return 0 + + def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument( "--mode", required=True, - choices=["validate", "carta", "pulley", "freeform", "auto"], + choices=["validate", "carta", "pulley", "freeform-emit", "auto", "grid"], help=( "validate: schema-check existing JSON in --dir; carta: extract from " "Carta XLSX (--xlsx); pulley: stub; freeform: validate Context-A " "sub-agent output (stdin); auto: sniff sheet fingerprint of --xlsx " - "and dispatch to carta/pulley/freeform." + "and dispatch to carta/pulley/freeform; grid: dump all sheets as " + "a cell-value grid for Lane-3 SPREADSHEET_STRUCTURE_DETECTION dispatch." ), ) p.add_argument("--dir", help="Required for --mode=validate") p.add_argument("--xlsx", help="Path to XLSX file (for carta/pulley/auto)") p.add_argument("--instruments", help="(Carta mode) Where to write/append instruments.json") p.add_argument("-o", "--output", help="Where to write extraction_audit.json") + p.add_argument("--run-id", dest="run_id", help="Run identifier stamped into metadata.run_id") + p.add_argument( + "--grid-budget-bytes", + dest="grid_budget_bytes", + type=int, + default=None, + help=( + "(grid mode) byte budget for the compacted cell grid; defaults to " + f"{GRID_BUDGET_BYTES} (headroom under the 256 KiB control-frame cap)." + ), + ) p.add_argument("--pretty", action="store_true") + p.add_argument( + "--answer", + action="append", + metavar="BLOCK.FIELD=VALUE", + help="(freeform-emit) founder answer to a blocker, e.g. 0.interest_rate_type=fixed_numeric_simple. Repeatable.", + ) args = p.parse_args() # Normalize the xlsx path if provided (handle macOS dupe suffixes) @@ -682,8 +1163,8 @@ def main() -> int: "detected_format": "freeform", "remedy": ( "Workbook does not match Carta or Pulley fingerprints. " - "Run --mode=freeform with the Context-A SPREADSHEET_STRUCTURE_DETECTION " - "sub-agent output piped on stdin." + "Dispatch the Context-A SPREADSHEET_STRUCTURE_DETECTION sub-agent and pipe " + "its blocks to --mode=freeform-emit (Lane 3)." ), "sheet_names": wb.sheetnames, }, @@ -695,7 +1176,12 @@ def main() -> int: return _mode_carta(args) if args.mode == "pulley": return _mode_pulley_stub(args) - return _mode_freeform_validate(args) + if args.mode == "grid": + return _mode_grid(args) + if args.mode == "freeform-emit": + return _mode_freeform_emit(args) + sys.stderr.write(f"unknown mode: {args.mode}\n") + return 1 if __name__ == "__main__": diff --git a/founder-skills/skills/cap-table/scripts/extract_instrument.py b/founder-skills/skills/cap-table/scripts/extract_instrument.py index 7d15700..ff6d1fe 100644 --- a/founder-skills/skills/cap-table/scripts/extract_instrument.py +++ b/founder-skills/skills/cap-table/scripts/extract_instrument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # /// script # requires-python = ">=3.10" -# dependencies = [] +# dependencies = ["pdfplumber", "python-docx", "openpyxl"] # /// """Anti-hallucination validator for Lane-1 instrument extraction. @@ -10,10 +10,11 @@ gates (per SKILL.md §5.1), surfaces ambiguities for AskUserQuestion, and appends the validated instrument into instruments.json. -Per Gotcha #3: this script also normalizes discount values: if a value > 1 -is given for discount_multiplier, treat it as percent and convert to -multiplier form (90 → 0.90 = 10% discount; 80 → 0.80 = 20% discount; etc.) -with a warning. +Per Gotcha #3: this script also normalizes discount values to the canonical +multiplier form. A value >= 50 is read as a percent-multiplier (80 → 0.80 = +20% discount); a value in (1, 50) is read as a discount-rate percent (20 → +0.80 multiplier); a value in (0, 1] passes through as an already-canonical +multiplier; <= 0 is rejected. See normalize_discount_multiplier. """ from __future__ import annotations @@ -27,8 +28,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _artifact_writer import ArtifactValidationError, load_schema, write_artifact # noqa: E402 from cross_checker import cross_check as _cross_check # noqa: E402 +from evidence_verifier import ( # noqa: E402 + MissingDependencyError, # noqa: E402 + report_to_dict, + verify_extraction, +) from evidence_verifier import _load_doc_text as _ev_load_doc_text # noqa: E402 -from evidence_verifier import report_to_dict, verify_extraction # noqa: E402 from extractors import ExtractionContext as _ExtractionContext # noqa: E402 from invariant_checker import check_instrument as _invariant_check # noqa: E402 from invariant_checker import report_to_dict as _invariant_report_to_dict # noqa: E402 @@ -99,23 +104,54 @@ def normalize_discount_multiplier(d: float | None) -> tuple[float | None, str | None]: - """If discount looks like percent (>1), convert to multiplier and warn.""" + """Normalize a discount input to the canonical multiplier form (Gotcha #3). + + The canonical field stores the MULTIPLIER (0.80 = a 20% discount). YC SAFEs + phrase the "Discount Rate" as the multiplier itself (e.g. "Discount Rate is + 80%"), so a raw value >= 50 is interpreted as a percent-multiplier + (80 -> 0.80); a value in (1, 50) is interpreted as a discount-rate percent + (20 -> 0.80 multiplier); a value in (0, 1] is already a multiplier and passes + through (with a warning below 0.5, since that is an unusually deep discount). + Matches extractors/safe/discount_multiplier.py and the backward_verifier + prompt template so the three never disagree. + + Returns (multiplier_or_None, warning_or_error_message). + """ if d is None: return None, None - if d > 1.0 and d <= 100.0: - # Treat as percent — e.g., 20 → 0.80 (20% discount) + if d <= 0: + return None, ( + f"discount value {d} <= 0 is invalid. The canonical field is the multiplier " + f"(0.80 = 20% discount); ask founder for clarification." + ) + if d <= 1.0: + # Already a multiplier (0.80 = 20% discount). Pass through. + if d < 0.5: + return d, ( + f"discount multiplier {d:.4f} implies a discount deeper than 50% — unusual; " + f"confirm with founder (the field is the multiplier, not the discount rate)." + ) + return d, None + if d < 50.0: + # Discount-RATE percent (e.g. 20 means a 20% discount → 0.80 multiplier). multiplier = 1.0 - (d / 100.0) return multiplier, ( - f"discount value {d} > 1 — interpreted as a percent and converted to " - f"multiplier form {multiplier:.4f}. Per Gotcha #3, the canonical field " - f"is the multiplier (0.80 = 20% discount); confirm with founder." + f"discount value {d} interpreted as a discount-rate percent and converted to " + f"multiplier form {multiplier:.4f}. Per Gotcha #3 the canonical field is the " + f"multiplier (0.80 = 20% discount); confirm with founder." ) - if d > 100.0: - return None, ( - f"discount value {d} > 100 — uninterpretable. Field is the multiplier " - f"(0.80 = 20% discount); ask founder for clarification." + if d <= 100.0: + # Percent-MULTIPLIER (e.g. 80 means the multiplier is 0.80, a 20% discount). + multiplier = d / 100.0 + return multiplier, ( + f"discount value {d} interpreted as a percent-multiplier and converted to " + f"multiplier form {multiplier:.4f}. Per Gotcha #3 the canonical field is the " + f"multiplier (0.80 = 20% discount); confirm with founder." ) - return d, None + return None, ( + f"discount value {d} > 100 — uninterpretable. The field is the multiplier " + f"(0.80 = 20% discount); ask founder for clarification." + ) def validate_safe(fields: dict[str, Any]) -> list[str]: @@ -408,6 +444,7 @@ def main() -> int: "(E_DUPLICATE_INSTRUMENT_ID) and the file is left unchanged.", ) p.add_argument("--pretty", action="store_true") + p.add_argument("-o", "--output", default=None, help="Write the receipt to this file; emit a receipt to stdout") args = p.parse_args() extraction = json.load(sys.stdin) @@ -634,7 +671,20 @@ def _upsert_or_error( else: from pathlib import Path as _Path - doc_text = _ev_load_doc_text(_Path(args.source_doc)) + try: + doc_text = _ev_load_doc_text(_Path(args.source_doc)) + except MissingDependencyError as _e: + # Blocking gate must fail loudly on a missing parser, not + # silently degrade to 'unverifiable_doc' (which would pass). + receipt["evidence_verification"] = { + "overall_status": "error", + "error": "E_MISSING_DEPENDENCY", + "dependency": _e.dependency, + "detail": str(_e), + } + sys.stderr.write(str(_e) + "\n") + print(json.dumps(receipt, indent=2 if args.pretty else None)) + return 1 verifier_input = build_verifier_input(fields, confidence) verification_report = verify_extraction(verifier_input, doc_text) raw_report = report_to_dict(verification_report) @@ -700,8 +750,13 @@ def _upsert_or_error( _backstop_extractors = [] if _backstop_extractors: # Reuse doc_text from the verify block if available; otherwise reload. - # Using locals() avoids ruff F823 on the unbound-name path. - _doc_text = locals().get("doc_text") or _ev_load_doc_text(_Path(args.source_doc)) + # Using locals() avoids ruff F823 on the unbound-name path. Cross-check + # is informational; a missing parser here downgrades to no-op rather + # than crashing (the blocking gate already covers the loud path). + try: + _doc_text = locals().get("doc_text") or _ev_load_doc_text(_Path(args.source_doc)) + except MissingDependencyError: + _doc_text = "" ctx = _ExtractionContext(instrument_type=itype, source_text=_doc_text, source_path=args.source_doc) per_field_cross: list[dict[str, Any]] = [] n_demotions = 0 @@ -760,7 +815,12 @@ def _upsert_or_error( attention_fields.add(r["field_name"]) receipt["attention_needed_fields"] = sorted(attention_fields) - print(json.dumps(receipt, indent=2 if args.pretty else None)) + if args.output: + with open(args.output, "w", encoding="utf-8") as _fh: + json.dump(receipt, _fh, indent=2 if args.pretty else None) + print(json.dumps({"ok": True, "output": os.path.abspath(args.output)}, indent=2 if args.pretty else None)) + else: + print(json.dumps(receipt, indent=2 if args.pretty else None)) return 0 diff --git a/founder-skills/skills/cap-table/scripts/extract_pdf_tables.py b/founder-skills/skills/cap-table/scripts/extract_pdf_tables.py new file mode 100644 index 0000000..3cc8526 --- /dev/null +++ b/founder-skills/skills/cap-table/scripts/extract_pdf_tables.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""B2 — OCR an image-only PDF into a cell grid (binary-only). + +Image-only cap-table PDFs (no text layer) are read today by raw model vision, which under-extracts dense +tables silently (the Siteaware P-1 failure). This producer rasterizes the PDF (`pdftoppm`) and OCRs each +page with table structure (`tesseract … tsv`, which gives per-word bounding boxes), then reconstructs a +cell grid by clustering words into rows (by y) and columns (by x). It emits the SAME `--mode=grid` JSON the +Lane-3 freeform pipeline already consumes — so an OCR'd image PDF flows into the (F1-hardened) +SPREADSHEET_STRUCTURE_DETECTION + freeform_mapper machinery instead of being eyeballed. + +Binary-only (subprocess `pdftoppm` + `tesseract`) — NO Python OCR deps — so it runs in the full-parity +agent image (`cowork-agent-full:2`) as-is (both binaries ship there). OCR is lossy; the grid is still +strictly better than raw vision because it gives the structure-detection sub-agent addressable cell text. + +The grid reconstruction (`tsv_words_to_grid`) is pure + unit-tested; the binary OCR (`ocr_pdf_to_grid`) is +integration-tested only when the binaries are present. +""" + +from __future__ import annotations + +import argparse +import csv +import io +import json +import os +import subprocess +import sys +import tempfile +from typing import Any + + +def _cluster_1d(values: list[int], gap: float) -> list[float]: + """Cluster sorted 1-D positions into bins; return each bin's mean. A new bin starts when the jump from + the previous value exceeds `gap`.""" + if not values: + return [] + vals = sorted(values) + bins: list[list[int]] = [[vals[0]]] + for v in vals[1:]: + if v - bins[-1][-1] > gap: + bins.append([v]) + else: + bins[-1].append(v) + return [sum(b) / len(b) for b in bins] + + +def tsv_words_to_grid(words: list[dict[str, Any]]) -> list[list[str]]: + """Reconstruct a row×column grid from OCR word boxes (each: text, left, top, width, height). + + Rows: words whose `top` falls within a y-tolerance (median height) are one row. Columns: word `left` + positions are clustered into column bins (gap = median width) and each word assigned to the nearest + bin. Cells with multiple words are space-joined in left order. Pure + deterministic.""" + ws = [w for w in words if str(w.get("text", "")).strip()] + if not ws: + return [] + heights = sorted(int(w.get("height", 0)) for w in ws) + widths = sorted(int(w.get("width", 0)) for w in ws) + med_h = heights[len(heights) // 2] or 12 + med_w = widths[len(widths) // 2] or 30 + y_tol = max(med_h * 0.7, 6) + # A column break is a horizontal gap wider than ~1.5 word-widths; intra-cell word spacing is smaller, + # so adjacent words in one cell (e.g. "Acme Ventures") stay together while real columns split. + col_gap = max(med_w * 1.5, 24) + + col_centers = _cluster_1d([int(w["left"]) for w in ws], col_gap) + + def col_index(left: int) -> int: + return min(range(len(col_centers)), key=lambda i: abs(col_centers[i] - left)) + + # group into rows by top + rows: list[list[dict[str, Any]]] = [] + for w in sorted(ws, key=lambda x: int(x["top"])): + if rows and (int(w["top"]) - int(rows[-1][0]["top"])) <= y_tol: + rows[-1].append(w) + else: + rows.append([w]) + + grid: list[list[str]] = [] + for row in rows: + cells: list[list[str]] = [[] for _ in col_centers] + for w in sorted(row, key=lambda x: int(x["left"])): + cells[col_index(int(w["left"]))].append(str(w["text"]).strip()) + grid.append([" ".join(c).strip() for c in cells]) + return grid + + +def grid_payload(sheets: dict[str, list[list[str]]]) -> dict[str, Any]: + """Wrap reconstructed per-sheet grids in the `--mode=grid` shape the freeform pipeline consumes.""" + return { + "ok": True, + "mode": "grid", + "source": "ocr_image_pdf", + "sheets": {name: {"dimensions": "", "rows": rows, "merged_ranges": []} for name, rows in sheets.items()}, + } + + +def _tesseract_words(png_path: str) -> list[dict[str, Any]]: + out = subprocess.run(["tesseract", png_path, "stdout", "tsv"], capture_output=True, text=True, check=True).stdout + words = [] + for r in csv.DictReader(io.StringIO(out), delimiter="\t"): + if str(r.get("text", "")).strip(): + words.append( + { + "text": r["text"], + "left": int(r["left"]), + "top": int(r["top"]), + "width": int(r["width"]), + "height": int(r["height"]), + } + ) + return words + + +def ocr_pdf_to_grid(pdf_path: str, dpi: int = 200) -> dict[str, list[list[str]]]: + """Render the PDF to per-page PNGs (pdftoppm) and OCR each into a grid (tesseract). Binary-only.""" + sheets: dict[str, list[list[str]]] = {} + with tempfile.TemporaryDirectory() as td: + prefix = os.path.join(td, "page") + subprocess.run(["pdftoppm", "-png", "-r", str(dpi), pdf_path, prefix], check=True) + pngs = sorted(f for f in os.listdir(td) if f.endswith(".png")) + for i, png in enumerate(pngs, start=1): + sheets[f"page_{i}"] = tsv_words_to_grid(_tesseract_words(os.path.join(td, png))) + return sheets + + +def main() -> int: + p = argparse.ArgumentParser(description="OCR an image-only PDF into a --mode=grid payload (binary-only).") + p.add_argument("pdf") + p.add_argument("--dpi", type=int, default=200) + p.add_argument("--pretty", action="store_true") + args = p.parse_args() + for binary in ("pdftoppm", "tesseract"): + if subprocess.run(["which", binary], capture_output=True).returncode != 0: + print(json.dumps({"ok": False, "mode": "ocr-grid", "error": f"{binary} not installed"})) + return 1 + try: + sheets = ocr_pdf_to_grid(args.pdf, dpi=args.dpi) + except Exception as e: # noqa: BLE001 + print(json.dumps({"ok": False, "mode": "ocr-grid", "error": f"{type(e).__name__}: {e}"})) + return 1 + print(json.dumps(grid_payload(sheets), indent=2 if args.pretty else None)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/founder-skills/skills/cap-table/scripts/extractors/safe/purchase_amount.py b/founder-skills/skills/cap-table/scripts/extractors/safe/purchase_amount.py index ad24449..b8b1e6f 100644 --- a/founder-skills/skills/cap-table/scripts/extractors/safe/purchase_amount.py +++ b/founder-skills/skills/cap-table/scripts/extractors/safe/purchase_amount.py @@ -16,8 +16,9 @@ # `$500,000` or `$500000` or `$5.5M` or `$5,000,000.00`. We deliberately # don't accept bare "500000" without a `$` — too noisy on legal docs that -# reference share counts, dates, etc. -_AMOUNT_PATTERN = r"\$\s*([\d,]+(?:\.\d{2})?)" +# reference share counts, dates, etc. An optional K/M/B/MM suffix is captured +# separately so _amount_to_int can scale it. +_AMOUNT_PATTERN = r"\$\s*([\d,]+(?:\.\d+)?)\s*(K|M|MM|B|bn)?" _QUOTE_OPEN = r"[\"“]" # ASCII " or U+201C left double quote _QUOTE_CLOSE = r"[\"”]" # ASCII " or U+201D right double quote @@ -30,15 +31,30 @@ ) -def _amount_to_int(raw: str) -> int | None: - """Convert '500,000' / '5,000,000.00' / '5.5M' to int dollars.""" +_SUFFIX_MULTIPLIERS = { + "k": 1_000, + "m": 1_000_000, + "mm": 1_000_000, + "b": 1_000_000_000, + "bn": 1_000_000_000, +} + + +def _amount_to_int(raw: str, suffix: str | None = None) -> int | None: + """Convert '500,000' / '5,000,000.00' / ('5.5', 'M') to int dollars.""" cleaned = raw.replace(",", "").strip() if not cleaned: return None try: - return int(float(cleaned)) + value = float(cleaned) except ValueError: return None + if suffix: + mult = _SUFFIX_MULTIPLIERS.get(suffix.lower()) + if mult is None: + return None + value *= mult + return int(value) def extract(ctx: ExtractionContext) -> list[FieldExtraction]: @@ -57,7 +73,7 @@ def extract(ctx: ExtractionContext) -> list[FieldExtraction]: out: list[FieldExtraction] = [] for m in matches: - amount = _amount_to_int(m.group(1)) + amount = _amount_to_int(m.group(1), m.group(2)) if amount is None: continue # The full evidence quote is the matched span. diff --git a/founder-skills/skills/cap-table/scripts/extractors/safe/valuation_cap.py b/founder-skills/skills/cap-table/scripts/extractors/safe/valuation_cap.py index 9fd1381..b98533e 100644 --- a/founder-skills/skills/cap-table/scripts/extractors/safe/valuation_cap.py +++ b/founder-skills/skills/cap-table/scripts/extractors/safe/valuation_cap.py @@ -16,7 +16,10 @@ from extractors.types import ExtractionContext, FieldExtraction, SourceSpan -_AMOUNT = r"(?:US)?\$\s*([\d,]+(?:\.\d+)?)\s*(?:million|M|MM|billion|B|bn)?" +# Capture the magnitude suffix in its own group so scaling is driven by the +# matched suffix only — never by stray substrings (e.g. the ' m' inside +# ' money') elsewhere in the surrounding text. +_AMOUNT = r"(?:US)?\$\s*([\d,]+(?:\.\d+)?)\s*(million|MM|M|billion|bn|B)?" # Post-money pattern: "Post-Money Valuation Cap" is $X POST_MONEY_PATTERN = re.compile( @@ -40,7 +43,7 @@ ) -def _parse_amount(raw: str, suffix_text: str = "") -> int | None: +def _parse_amount(raw: str, suffix: str | None = None) -> int | None: cleaned = raw.replace(",", "").strip() if not cleaned: return None @@ -48,10 +51,10 @@ def _parse_amount(raw: str, suffix_text: str = "") -> int | None: n = float(cleaned) except ValueError: return None - suffix = suffix_text.lower() - if "million" in suffix or " m" in suffix or "mm" in suffix: + s = (suffix or "").lower() + if s in {"million", "mm", "m"}: n *= 1_000_000 - elif "billion" in suffix or " b" in suffix or "bn" in suffix: + elif s in {"billion", "bn", "b"}: n *= 1_000_000_000 return int(n) @@ -85,7 +88,7 @@ def extract(ctx: ExtractionContext) -> list[FieldExtraction]: if post_matches: # Standard case: "Post-Money Valuation Cap" is $X m = post_matches[0] - val = _parse_amount(m.group(1), text[m.start() : m.end()]) + val = _parse_amount(m.group(1), m.group(2)) amb = None if bare_matches: amb = ( @@ -111,7 +114,7 @@ def extract(ctx: ExtractionContext) -> list[FieldExtraction]: # Take the value from the bare match but classify # as post-money on the basis of the formula references. m = bare_matches[0] - val = _parse_amount(m.group(1), text[m.start() : m.end()]) + val = _parse_amount(m.group(1), m.group(2)) out.append( FieldExtraction( name="post_money_valuation_cap", @@ -131,7 +134,7 @@ def extract(ctx: ExtractionContext) -> list[FieldExtraction]: elif bare_matches: # Pure pre-money: bare "Valuation Cap" with no "Post-Money" anywhere m = bare_matches[0] - val = _parse_amount(m.group(1), text[m.start() : m.end()]) + val = _parse_amount(m.group(1), m.group(2)) out.append( FieldExtraction( name="pre_money_valuation_cap", diff --git a/founder-skills/skills/cap-table/scripts/flip_scenario.py b/founder-skills/skills/cap-table/scripts/flip_scenario.py index 65c144b..d1f7181 100644 --- a/founder-skills/skills/cap-table/scripts/flip_scenario.py +++ b/founder-skills/skills/cap-table/scripts/flip_scenario.py @@ -24,10 +24,15 @@ import argparse import json +import os import sys from copy import deepcopy from typing import Any +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _emit import add_output_args, emit # noqa: E402 +from _rule_pack import RULE_PACK_VERSION # noqa: E402 + def flip_share_for_share( cap_state: dict[str, Any], @@ -159,7 +164,7 @@ def flip_share_for_share( "output_field": "flip_exchange_ratio", "source_type": "rule", "rule_id": "delaware_flip.share_exchange_mechanics", - "rule_pack_version": "0.3.2", + "rule_pack_version": RULE_PACK_VERSION, "source_ref": None, }, ], @@ -172,7 +177,7 @@ def _cli() -> int: p.add_argument("--cap-state", required=True) p.add_argument("--iia-grants", action="store_true") p.add_argument("--section-102-grants", type=int, default=0) - p.add_argument("--pretty", action="store_true") + add_output_args(p) args = p.parse_args() with open(args.cap_state, encoding="utf-8") as f: @@ -182,7 +187,7 @@ def _cli() -> int: iia_grants_in_history=args.iia_grants, section_102_grants_outstanding=args.section_102_grants, ) - print(json.dumps(result, indent=2 if args.pretty else None)) + emit(result, args) return 0 diff --git a/founder-skills/skills/cap-table/scripts/freeform_mapper.py b/founder-skills/skills/cap-table/scripts/freeform_mapper.py new file mode 100644 index 0000000..a61098e --- /dev/null +++ b/founder-skills/skills/cap-table/scripts/freeform_mapper.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Lane-3 freeform deterministic mapper (the "Phase 1 follow-up" the freeform stub named). + +Pure function: maps a SPREADSHEET_STRUCTURE_DETECTION block set + a `--mode=grid` cell +grid into schema-valid inputs/instruments *proposals* plus an explicit blocker list. No +LLM, no network — a fixed (blocks, grid) maps deterministically. The agent<->producer +contract is the single source of truth in references/schemas/freeform-role-map.json: +the agent emits only roles listed there; this maps role -> schema field. Off-contract +roles and required-but-unsupplied fields become BLOCKERS (never silent skips / fabrication). +""" + +from __future__ import annotations + +import copy +import json +import os +from typing import Any + +# Reuse the deterministic helpers (no behavior change; _to_iso_date is module-scope now). +from extract_cap_table import ( # type: ignore[import-not-found] + _infer_safe_form, + _normalize_discount, + _to_iso_date, +) +from openpyxl.utils import column_index_from_string, range_boundaries # type: ignore[import-untyped] + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROLE_MAP_PATH = os.path.join(_HERE, "..", "references", "schemas", "freeform-role-map.json") +_DATE_SENTINEL = "1900-01-01" +# Provenance marker stamped ONLY when this mapper genuinely produced the equity base from the sheet. +# Its ABSENCE on a confirmed base is what cap_state uses to flag a model-reconstructed base — so keep this +# spelling in lock-step with cap_state.py's check (a typo here would silently make every freeform run warn). +_PROVENANCE_DETERMINISTIC = "deterministic_mapped" +_INPUTS_SCHEMA_VERSION = "v0.5.0-inputs" +_INSTRUMENTS_SCHEMA_VERSION = "v0.5.0-instruments" + + +def _load_role_map(path: str | None = None) -> dict[str, Any]: + with open(path or _ROLE_MAP_PATH, encoding="utf-8") as f: + data: dict[str, Any] = json.load(f) + return data + + +def _is_blank(v: Any) -> bool: + return v is None or (isinstance(v, str) and v.strip() == "") + + +def _f(v: Any) -> float | None: + # Crash-safe (L1-A): a non-numeric value in a numeric role returns None (→ the caller's + # required-field blocker fires) instead of an uncaught ValueError. Blank also → None. + if _is_blank(v): + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _i(v: Any) -> int | None: + f = _f(v) + return None if f is None else int(round(f)) + + +# L1-A — orientation/type-coherence guard. A transposed sheet (holders/series as COLUMNS) mis-mapped to a +# normal vertical block produces records where a numeric-role column is text, or the name column is numeric +# / holds field labels. These sets drive the per-role coherence check in _orientation_blocker. +_NUMERIC_ROLES = { + "shares", + "common_shares", + "amount", + "principal", + "issue_price", + "original_conversion_price", + "current_conversion_price", + "authorized", + "issued", + "unallocated", + "annual_interest_rate", + "interest_rate", + "discount", + "discount_multiplier", + "valuation_cap", +} +_NAME_ROLES = {"holder_name", "series_name", "investor_name"} +# Whole-cell, lowercased: unambiguous column-header terms that are never a real holder/series name. +_FIELD_LABEL_STOPLIST = { + "shares", + "total", + "subtotal", + "price", + "issue price", + "ownership", + "fully diluted", + "authorized", + "issued", + "unallocated", + "%", +} + + +def _looks_numeric(v: Any) -> bool: + if _is_blank(v): + return False + try: + float(v) + return True + except (TypeError, ValueError): + return False + + +def _orientation_blocker(rows: list[dict[str, Any]]) -> str | None: + """Detect a transposed / mis-mapped block by per-role TYPE COHERENCE. + + A numeric role whose column is predominantly non-numeric, or a name role whose column is + predominantly numeric or holds a whole-cell field-label, signals a transposed/mis-mapped sheet + (holders/series as COLUMNS). Returns a blocker reason, or None if correctly oriented. WHOLE-CELL + match only (never substring), so legit entity names ("Class A Holdings", "500 Startups") pass. + Does NOT catch the section-label all-numeric transpose (name col = arbitrary text, data numeric) — + that residual is owned by L1-B (correct transpose mapping), per the reliability plan.""" + cols: dict[str, list[Any]] = {} + for raw in rows: + for role, val in raw.items(): + if not _is_blank(val): + cols.setdefault(role, []).append(val) + for role, vals in cols.items(): + if role in _NUMERIC_ROLES and vals: + non_numeric = sum(1 for v in vals if not _looks_numeric(v)) + if non_numeric * 2 > len(vals): + return ( + f"column mapped to numeric role {role!r} is predominantly non-numeric " + f"({non_numeric}/{len(vals)} cells) — likely a transposed/mis-mapped sheet " + "(holders/series laid out as COLUMNS). Re-emit cell_range + column_role_map with the " + "correct orientation, or fall back to Lane 4." + ) + for role, vals in cols.items(): + if role in _NAME_ROLES and vals: + numeric = sum(1 for v in vals if _looks_numeric(v)) + if numeric * 2 > len(vals): + return ( + f"column mapped to name role {role!r} is predominantly numeric — likely a " + "transposed/mis-mapped sheet. Re-emit with the correct orientation." + ) + for v in vals: + if isinstance(v, str) and v.strip().lower() in _FIELD_LABEL_STOPLIST: + return ( + f"name role {role!r} contains the field-label {v!r} as a holder/series name — " + "likely a transposed sheet (field labels down the name column). Re-emit correctly." + ) + return None + + +def _block_rows(block: dict[str, Any], grid: dict[str, Any]) -> list[dict[str, Any]]: + """Yield one {role: value} dict per non-blank row in the block's cell_range. + + column_role_map is keyed by COLUMN LETTER; rows from --mode=grid are sheet-origin + positional tuples (column A == index 0), so a letter maps via column_index_from_string-1. + """ + sheet = block.get("sheet") + cr = str(block.get("cell_range", "")) + if "!" in cr: # strip a "Sheet!A1:B2" qualifier + cr = cr.split("!", 1)[1] + _mc, min_row, _xc, max_row = range_boundaries(cr) + rows = grid.get("sheets", {}).get(sheet, {}).get("rows", []) + role_map = block.get("column_role_map", {}) + out: list[dict[str, Any]] = [] + for r in range(int(min_row or 1), int(max_row or 1) + 1): + row = rows[r - 1] if 0 <= r - 1 < len(rows) else [] + raw: dict[str, Any] = {} + for letter, role in role_map.items(): + ci = column_index_from_string(str(letter)) - 1 + raw[role] = row[ci] if 0 <= ci < len(row) else None + if all(_is_blank(v) for v in raw.values()): + continue # blank / merged-spacer row + out.append(raw) + return out + + +def map_freeform( + blocks: list[dict[str, Any]], + grid: dict[str, Any], + existing_inputs: dict[str, Any] | None = None, + answers: dict[str, Any] | None = None, + run_id: str = "", + role_map: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Map detected blocks -> {inputs, instruments, blockers, warnings}. Deterministic.""" + rm = role_map or _load_role_map() + bt_defs = rm["block_types"] + hard = rm["hard_block_block_types"] + ignore = set(rm["ignore_block_types"]) + answers = answers or {} + + inputs: dict[str, Any] = copy.deepcopy(existing_inputs) if existing_inputs else {} + inputs.setdefault("metadata", {}) + inputs["metadata"]["run_id"] = run_id + inputs["metadata"].setdefault("schema_version", _INPUTS_SCHEMA_VERSION) + # (cap_base_source is stamped AFTER mapping — only when an equity base was actually produced; see end.) + + instruments: dict[str, Any] = { + "safes": [], + "convertible_notes": [], + "warrants": [], + "option_grants": [], + "metadata": {"run_id": run_id, "schema_version": _INSTRUMENTS_SCHEMA_VERSION}, + } + blockers: list[dict[str, Any]] = [] + warnings: list[str] = [] + + founders_acc: list[dict[str, Any]] = [] + preferred_acc: list[dict[str, Any]] = [] + option_pool_new: dict[str, Any] | None = None + + existing_pref_names = { + p.get("series_name") for p in (existing_inputs or {}).get("preferred_series", []) if isinstance(p, dict) + } + + def block_blocker(i: int, bt: str, field: str, reason: str) -> None: + blockers.append({"block_index": i, "block_type": bt, "field": field, "reason": reason}) + + safe_n = 0 + note_n = 0 + + for i, block in enumerate(blocks): + bt = block.get("block_type", "") + if bt in ignore: + continue + if bt in hard: + block_blocker(i, bt, "block", hard[bt]) + continue + if bt not in bt_defs: + block_blocker(i, bt, "block_type", f"unknown block_type {bt!r} (off-contract)") + continue + + # (1a) Required block-field schema. An equity block MUST carry a non-empty cell_range AND a + # non-empty column_role_map. The structure sub-agent sometimes drifts to row_range/columns; an + # empty column_role_map then skips every row, silently mapping nothing — so fail loud and name + # the correct field names instead of writing an empty cap base. + cell_range = block.get("cell_range") + role_map = block.get("column_role_map") + if not (isinstance(cell_range, str) and cell_range.strip()): + block_blocker( + i, + bt, + "cell_range", + "required field 'cell_range' (data rows, e.g. 'A5:F12') missing or empty; got keys " + f"{sorted(block.keys())}. Emit cell_range + column_role_map, not row_range/columns.", + ) + continue + if not (isinstance(role_map, dict) and role_map): + block_blocker( + i, + bt, + "column_role_map", + "required field 'column_role_map' (column-letter -> role) missing or empty; got keys " + f"{sorted(block.keys())}. Emit cell_range + column_role_map, not row_range/columns.", + ) + continue + + defn = bt_defs[bt] + roles = defn["roles"] + # Contract: every column_role_map value must be a known role for this block. + unknown = [role for role in block.get("column_role_map", {}).values() if role not in roles] + if unknown: + for role in unknown: + block_blocker(i, bt, role, f"unknown role {role!r} for {bt} (off-contract role-map value)") + continue # don't trust a contract-violating block + + rows = _block_rows(block, grid) + cr_bare = str(block.get("cell_range", "")).split("!", 1)[-1] + src = f"freeform:{block.get('sheet')}!{cr_bare}" + if not rows: + # MR-2: an equity block whose cell_range maps to zero data rows is a silent drop in the + # MIXED case (the global 0-records backstop only fires when EVERY equity block is empty). + # Surface it so a partially-dropped sheet is never reported as a clean success. + warnings.append( + f"block {i} ({bt}): cell_range {cr_bare!r} yielded 0 data rows — verify it points at the " + "data rows (not headers/blank rows); this block contributed nothing." + ) + + # L1-A: fail loud on a transposed / type-incoherent block instead of crashing or silently + # emitting garbage records. (Section-label all-numeric transpose remains a residual — L1-B.) + _orient = _orientation_blocker(rows) + if _orient: + block_blocker(i, bt, "orientation", _orient) + continue + + if bt == "founders_block": + for raw in rows: + name = raw.get("holder_name") + shares = _i(raw.get("shares")) + if _is_blank(name): + block_blocker(i, bt, "name", "founder row missing holder_name") + continue + if shares is None: + block_blocker(i, bt, "common_shares", f"founder {name!r} missing share count") + continue + rec = {"name": str(name), "common_shares": shares} + if not _is_blank(raw.get("founder_id")): + rec["founder_id"] = str(raw["founder_id"]) + if not _is_blank(raw.get("common_class")): + rec["common_class"] = str(raw["common_class"]) + if not _is_blank(raw.get("voting_multiple")): + rec["voting_rights_multiple"] = _f(raw["voting_multiple"]) + founders_acc.append(rec) + + elif bt == "preferred_series_block": + for raw in rows: + sname = raw.get("series_name") + if _is_blank(sname): + block_blocker(i, bt, "series_name", "preferred row missing series_name") + continue + oip = _f(raw.get("issue_price")) + if oip is None: + block_blocker(i, bt, "original_issue_price", f"series {sname!r}: no issue price (never fabricated)") + continue + ocp = _f(raw.get("original_conversion_price")) + ocp = oip if ocp is None else ocp # default 1:1 at fresh issuance + ccp = _f(raw.get("current_conversion_price")) + ccp = ocp if ccp is None else ccp + idate = _to_iso_date(raw.get("issue_date")) + if idate is None: + idate = _DATE_SENTINEL + warnings.append(f"preferred {sname!r}: issuance_date defaulted to {_DATE_SENTINEL} (confirm)") + if sname in existing_pref_names or sname in {p["series_name"] for p in preferred_acc}: + block_blocker( + i, bt, "series_name", f"conflict: series {sname!r} already in inputs.json — keeping existing" + ) + continue + shares = _i(raw.get("shares")) or 0 + preferred_acc.append( + { + "series_name": str(sname), + "shares": shares, + "original_issue_price": oip, + "original_conversion_price": ocp, + "current_conversion_price": ccp, + "issuance_date": idate, + } + ) + + elif bt == "option_pool_block": + if not rows: + continue + raw = rows[0] + plan_type = answers.get(f"{i}.plan_type", raw.get("plan_type")) + valid = defn["enum_fields"]["plan_type"] + if _is_blank(plan_type) or plan_type not in valid: + block_blocker(i, bt, "plan_type", f"plan_type {plan_type!r} absent or not in {valid}") + continue + authorized = _i(raw.get("authorized")) + issued = _i(raw.get("issued")) or 0 + if authorized is None: + block_blocker(i, bt, "authorized", "option_pool missing authorized share count") + continue + unalloc = _i(raw.get("unallocated")) + if unalloc is None: + unalloc = authorized - issued + option_pool_new = { + "plan_type": str(plan_type), + "authorized": authorized, + "issued": issued, + "unallocated": unalloc, + } + + elif bt == "safes_block": + for raw in rows: + inv = raw.get("investor_name") + amt = _f(raw.get("amount")) + if _is_blank(inv): + block_blocker(i, bt, "investor_name", "SAFE row missing investor_name") + continue + if amt is None: + block_blocker(i, bt, "purchase_amount", f"SAFE {inv!r} missing purchase amount") + continue + disc_raw = raw.get("discount") + disc_mult, disc_warn = _normalize_discount(disc_raw) + if disc_warn and not _is_blank(disc_raw): + warnings.append(f"SAFE {inv!r}: {disc_warn}") + post_cap = _f(raw.get("post_money_cap")) + pre_cap = _f(raw.get("pre_money_cap")) + idate = _to_iso_date(raw.get("issue_date")) + if idate is None: + idate = _DATE_SENTINEL + warnings.append(f"SAFE {inv!r}: issuance_date defaulted to {_DATE_SENTINEL} (confirm)") + rec = { + "id": f"safe_{safe_n:03d}", + "investor_name": str(inv), + "purchase_amount": amt, + "post_money_valuation_cap": post_cap, + "pre_money_valuation_cap": pre_cap, + "discount_multiplier": disc_mult, + "issuance_date": idate, + "form": _infer_safe_form(post_cap if post_cap else pre_cap, disc_raw), + "source_document": src, + "extraction_confidence": "medium", + } + if post_cap and rec["form"] == "yc_postmoney_cap": + warnings.append( + f"SAFE {inv!r}: cap mapped post-money (freeform cannot distinguish " + "pre/post — confirm vintage, Gotcha #1)" + ) + safe_n += 1 + instruments["safes"].append(rec) + + elif bt == "notes_block": + for raw in rows: + inv = raw.get("investor_name") + principal = _f(raw.get("principal")) + if _is_blank(inv): + block_blocker(i, bt, "investor_name", "note row missing investor_name") + continue + if principal is None: + block_blocker(i, bt, "principal", f"note {inv!r} missing principal") + continue + irt = answers.get(f"{i}.interest_rate_type", raw.get("interest_rate_type")) + valid = defn["enum_fields"]["interest_rate_type"] + if _is_blank(irt) or irt not in valid: + block_blocker( + i, + bt, + "interest_rate_type", + f"interest_rate_type {irt!r} absent or not in {valid} (founder must confirm)", + ) + continue + disc_raw = raw.get("discount") + disc_mult, disc_warn = _normalize_discount(disc_raw) + if disc_warn and not _is_blank(disc_raw): + warnings.append(f"note {inv!r}: {disc_warn}") + ndate = _to_iso_date(raw.get("issue_date")) + if ndate is None: + ndate = _DATE_SENTINEL + warnings.append(f"note {inv!r}: issuance_date defaulted to {_DATE_SENTINEL} (confirm)") + rec = { + "id": f"note_{note_n:03d}", + "investor_name": str(inv), + "principal": principal, + "annual_interest_rate": _f(raw.get("interest_rate")), + "interest_rate_type": str(irt), + "valuation_cap": _f(raw.get("valuation_cap")), + "discount_multiplier": disc_mult, + "issuance_date": ndate, + "maturity_date": _to_iso_date(raw.get("maturity_date")), + "source_document": src, + "extraction_confidence": "medium", + } + note_n += 1 + instruments["convertible_notes"].append(rec) + + # (1b) Global silent-empty backstop. ≥1 equity block was declared but produced ZERO records this + # call (accumulators are pre-merge, so keep-existing duplicates still count as mapped) and no + # per-block blocker already explains it → fail loud instead of writing an empty cap base. Catches a + # well-formed block whose cell_range points at blank rows, which (1a)'s field-presence check cannot + # see. + if ( + any(b.get("block_type") in bt_defs for b in blocks) + and not (founders_acc or preferred_acc or option_pool_new or safe_n or note_n) + and not blockers + ): + block_blocker( + -1, + "*", + "emit", + "equity block(s) were declared but 0 records mapped — verify each cell_range points at the " + "data rows and column_role_map names the columns; no rows were extracted.", + ) + + # --- merge equity into inputs (keep-existing-on-conflict + warn) --- + if founders_acc: + if (existing_inputs or {}).get("founders"): + warnings.append("founders already present in inputs.json — keeping existing, ignoring sheet founders") + else: + inputs["founders"] = founders_acc + if preferred_acc or existing_pref_names: + inputs["preferred_series"] = list((existing_inputs or {}).get("preferred_series", [])) + preferred_acc + if option_pool_new is not None: + if (existing_inputs or {}).get("option_pool"): + warnings.append("option_pool already present in inputs.json — keeping existing, ignoring sheet pool") + else: + inputs["option_pool"] = option_pool_new + + # Lane-3 carve-out for the cap_state default-to-assumed warn: the founder's sheet IS the cap-base + # source of truth, so a freeform-mapped base is confirmed — but ONLY when the emit actually produced + # or merged an equity base, never on an empty/partial result (so a downstream consumer can't read + # "confirmed" off an empty cap base). setdefault keeps an explicit pre-existing value (e.g. "assumed"). + if ( + inputs.get("founders") + or inputs.get("common_batches") + or inputs.get("preferred_series") + or inputs.get("option_pool") + ): + inputs["metadata"].setdefault("cap_base_source", "confirmed") + + # Provenance: stamp deterministic_mapped ONLY when the mapper itself produced equity THIS call + # (the accumulators) — NOT merely when inputs.get(...) is truthy, which would inherit equity merged + # from existing_inputs and falsely claim a model-built base was deterministically mapped. + if founders_acc or preferred_acc or option_pool_new or safe_n or note_n: + inputs["metadata"].setdefault("cap_base_provenance", _PROVENANCE_DETERMINISTIC) + + blockers.sort(key=lambda b: (b["block_index"], b["field"])) + return {"inputs": inputs, "instruments": instruments, "blockers": blockers, "warnings": warnings} diff --git a/founder-skills/skills/cap-table/scripts/invariant_checker.py b/founder-skills/skills/cap-table/scripts/invariant_checker.py index e5e63b6..3742ee3 100644 --- a/founder-skills/skills/cap-table/scripts/invariant_checker.py +++ b/founder-skills/skills/cap-table/scripts/invariant_checker.py @@ -1,4 +1,8 @@ #!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// """Real-world-bounds invariant checker for cap-table Lane-1 extraction. Forward verification (`evidence_verifier`) catches HALLUCINATIONS: @@ -33,6 +37,7 @@ import argparse import json +import os import sys from dataclasses import dataclass, field from typing import Any @@ -63,8 +68,10 @@ "annual_interest_rate": {"min": 0.0, "max": 0.20}, "discount_multiplier": {"min": 0.50, "max": 1.0}, "qualified_financing_threshold": {"min": 100_000, "max": 100_000_000}, - # Maturity 6 months to 7 years. Most CLAs are 18-36 months. - "maturity_months": {"min": 6, "max": 84}, + # NOTE: maturity is stored as maturity_date (ISO string), not a month + # count, so no numeric bound applies here — a maturity_months bound would + # be dead code (fields.get('maturity_months') is always None) and would + # over-count n_checks. "day_count_basis": {"min": 360, "max": 365}, # 360 (banker's) or 365 }, "term_sheet": { @@ -310,6 +317,7 @@ def main() -> int: if args.output: with open(args.output, "w") as f: f.write(payload) + print(json.dumps({"ok": True, "output": os.path.abspath(args.output)}, indent=2 if args.pretty else None)) else: print(payload) diff --git a/founder-skills/skills/cap-table/scripts/note_conversion.py b/founder-skills/skills/cap-table/scripts/note_conversion.py index 7ed381f..ade87b2 100644 --- a/founder-skills/skills/cap-table/scripts/note_conversion.py +++ b/founder-skills/skills/cap-table/scripts/note_conversion.py @@ -32,14 +32,18 @@ import argparse import json +import os import sys from datetime import date from typing import Any -RULE_PACK_VERSION = "0.4.0" +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _emit import add_output_args, emit # noqa: E402 +from _rule_pack import RULE_PACK_VERSION # noqa: E402 E_NOTE_NO_CONVERSION_PATH = "E_NOTE_NO_CONVERSION_PATH" E_NOTE_OVERRIDE_BRANCH_MISMATCH = "E_NOTE_OVERRIDE_BRANCH_MISMATCH" +E_NOTE_INVALID_PRICE_INPUT = "E_NOTE_INVALID_PRICE_INPUT" def _parse_date(s: str | None) -> date | None: @@ -90,12 +94,15 @@ def _classify_branch( *, priced_round_new_money: float | None, qualified_financing_price: float | None, -) -> str: +) -> tuple[str, bool]: """Decide which branch this note falls into based on inputs. Order matters: maturity_* branches take precedence when no priced-round inputs are supplied. Priced-round branches only fire when both new_money and the resolved qualified_financing_price are present. + + Returns (branch_name, maturity_default_treatment_was_absent) so the + caller can emit a disclosure warning when the field was defaulted. """ cap = note.get("valuation_cap") denom = note.get("capitalization_denominator") @@ -103,49 +110,50 @@ def _classify_branch( threshold = note.get("qualified_financing_threshold") nqft = note.get("non_qualified_financing_treatment") override = note.get("maturity_conversion_price_override") + mdt_absent = "maturity_default_treatment" not in note mdt = note.get("maturity_default_treatment", "convert_at_cap") - # Priced-round path + # Priced-round path — mdt_absent is irrelevant here (maturity path not taken) if priced_round_new_money is not None and qualified_financing_price is not None: if threshold is not None and priced_round_new_money < threshold: if nqft == "convert_anyway": # Threshold bypassed by counsel; fall through to cap/discount path pass elif nqft == "do_not_convert": - return "maturity_extend" # treats as extension past financing + return "maturity_extend", False elif nqft == "negotiate": - return "maturity_counsel_review" + return "maturity_counsel_review", False else: # null - return "threshold_not_met" + return "threshold_not_met", False # Threshold met or bypassed — pick cap_conversion vs discount_only if cap is not None and denom is not None and denom > 0: - return "cap_conversion" + return "cap_conversion", False if cap is None and discount is not None: - return "discount_only" + return "discount_only", False # Otherwise we'll be flagged as no-path below - # Maturity path + # Maturity path — mdt_absent is relevant when we enter this path if mdt == "convert_at_cap": if override is not None: - return "maturity_convert_at_cap" # override branch (same label) + return "maturity_convert_at_cap", mdt_absent if cap is not None and denom is not None and denom > 0: - return "maturity_convert_at_cap" + return "maturity_convert_at_cap", mdt_absent # cap missing + no override → error caller handles - return "no_conversion_path" + return "no_conversion_path", mdt_absent if mdt == "repay": if override is not None: - return "override_mismatch" - return "maturity_repay" + return "override_mismatch", mdt_absent + return "maturity_repay", mdt_absent if mdt == "extend": if override is not None: - return "override_mismatch" - return "maturity_extend" + return "override_mismatch", mdt_absent + return "maturity_extend", mdt_absent if mdt == "counsel_review": if override is not None: - return "override_mismatch" - return "maturity_counsel_review" - return "no_conversion_path" + return "override_mismatch", mdt_absent + return "maturity_counsel_review", mdt_absent + return "no_conversion_path", mdt_absent # Default proxy rate for statutory ITA Section 3(j) when no @@ -232,12 +240,33 @@ def convert_note( ) balance = _conversion_balance(note, accrued) - branch = _classify_branch( + branch, mdt_was_absent = _classify_branch( note, priced_round_new_money=priced_round_new_money, qualified_financing_price=qualified_financing_price, ) + # Disclosure warning when maturity_default_treatment was absent and the + # maturity path was entered via the default ("convert_at_cap"). Standard + # note forms (e.g. Fenwick seed-stage template) default to repayment on + # majority-holder demand at maturity; the convert_at_cap default is an + # unsourced convention. Founders must confirm the actual note text. + if mdt_was_absent: + warnings.append( + { + "code": "maturity_default_treatment_defaulted", + "severity": "medium", + "message": ( + "maturity_default_treatment was not specified in this note's " + "inputs; the math producer defaulted to 'convert_at_cap'. " + "Standard note forms (e.g. Fenwick seed-stage template) " + "commonly default to repayment on majority-holder demand at " + "maturity — the note text must be confirmed before relying on " + "the convert_at_cap path." + ), + } + ) + base: dict[str, Any] = { "note_id": note["id"], "branch": branch, @@ -251,7 +280,11 @@ def convert_note( base["error"] = E_NOTE_NO_CONVERSION_PATH base["reason"] = ( "convert_at_cap requires non-null valuation_cap + capitalization_denominator " - "OR maturity_conversion_price_override; neither provided" + "(the share count defined by the note's 'Company Capitalization' clause — " + "ask the founder; commonly the pre-financing fully-diluted count) " + "OR maturity_conversion_price_override; neither provided. " + "Do NOT default to cap_state fully-diluted — the denominator definition " + "is note-text-specific and must be confirmed with the founder or note text." ) return base @@ -298,6 +331,11 @@ def convert_note( if branch == "maturity_convert_at_cap" and override is not None: # Override branch — bypass rule + if override <= 0: + base["branch"] = "rejected" + base["error"] = E_NOTE_INVALID_PRICE_INPUT + base["reason"] = f"maturity_conversion_price_override must be > 0; got {override!r}" + return base conversion_price = override base["conversion_price"] = conversion_price base["conversion_shares"] = balance / conversion_price @@ -333,6 +371,11 @@ def convert_note( ) if branch != "maturity_convert_at_cap" and discount is not None and qualified_financing_price is not None: + if discount <= 0: + base["branch"] = "rejected" + base["error"] = E_NOTE_INVALID_PRICE_INPUT + base["reason"] = f"discount_multiplier must be > 0; got {discount!r}" + return base discount_price = qualified_financing_price * discount candidate_prices.append(("discount_price", discount_price)) provenance.append( @@ -402,7 +445,7 @@ def derive_scenario_completeness(per_note: dict[str, dict[str, Any]]) -> str: def _cli() -> int: shared = argparse.ArgumentParser(add_help=False) - shared.add_argument("--pretty", action="store_true") + add_output_args(shared) p = argparse.ArgumentParser(description=__doc__) sub = p.add_subparsers(dest="cmd", required=True) @@ -439,7 +482,7 @@ def _cli() -> int: ) result = {"accrued_interest": accrued} - print(json.dumps(result, indent=2 if args.pretty else None)) + emit(result, args) return 0 diff --git a/founder-skills/skills/cap-table/scripts/option_pool.py b/founder-skills/skills/cap-table/scripts/option_pool.py index 198b23f..335940e 100644 --- a/founder-skills/skills/cap-table/scripts/option_pool.py +++ b/founder-skills/skills/cap-table/scripts/option_pool.py @@ -9,7 +9,11 @@ top-up increases pre-money FD; when `post_money`, denominator includes new money. The rule pack's `target_basis` enum has four values (`pre_money | post_money | post_money_excluding_converting_securities | -custom`). This script implements all four. +custom`). `pre_money`, `post_money`, and `custom` (a `pre_money` fallback) +have distinct formulas here. `post_money_excluding_converting_securities` +shares the `post_money` formula: the converting-securities exclusion is the +caller's responsibility (it must pre-adjust pre_topup_FD to remove SAFE/note +shares before calling), so no separate branch is needed. Formula (rule pack): For post-money target: @@ -21,11 +25,13 @@ from __future__ import annotations import argparse -import json +import os import sys from typing import Any -RULE_PACK_VERSION = "0.4.0" +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _emit import add_output_args, emit # noqa: E402 +from _rule_pack import RULE_PACK_VERSION # noqa: E402 def required_topup( @@ -118,9 +124,9 @@ def required_topup( ) clarifying_question = { "question": ( - "You asked for a 10% pool refresh on a pre-money basis. Under the literal " + f"You asked for a {target:.1%} pool refresh on a pre-money basis. Under the literal " "reading, the existing pool already meets that target so the refresh is " - 'a no-op. Series A term sheets usually mean "10% post-close unallocated" — ' + f'a no-op. Series A term sheets usually mean "{target:.1%} post-close unallocated" — ' "which would require a real top-up. Which interpretation matches your term sheet?" ), "options": [ @@ -168,7 +174,7 @@ def _cli() -> int: required=True, choices=["pre_money", "post_money", "post_money_excluding_converting_securities", "custom"], ) - p.add_argument("--pretty", action="store_true") + add_output_args(p) args = p.parse_args() result = required_topup( @@ -178,7 +184,7 @@ def _cli() -> int: new_money_shares=args.new_money_shares, target_basis=args.basis, ) - print(json.dumps(result, indent=2 if args.pretty else None)) + emit(result, args) return 0 diff --git a/founder-skills/skills/cap-table/scripts/pdf_probe.py b/founder-skills/skills/cap-table/scripts/pdf_probe.py new file mode 100644 index 0000000..6fcb48b --- /dev/null +++ b/founder-skills/skills/cap-table/scripts/pdf_probe.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = ["pdfplumber"] +# /// +"""B0 — image-only PDF probe (per-page). + +A cap-table PDF whose tables are images (no text layer) is read today by raw model vision, which +under-extracts dense tables silently (the Siteaware P-1 failure). This probe lets the skill DETECT that +case before reading, so it can warn + mark the result low-confidence (B3) instead of silently trusting a +hollow vision extraction. + +Detection is PER-PAGE, not whole-doc: a multi-page doc with one text cover page but image-only table pages +must classify as image-only — a whole-doc char total would clear the floor and miss exactly that shape. + +Output: a JSON receipt to stdout, e.g. + {"ok": true, "mode": "pdf-probe", "kind": "image_only", "image_only": true, + "total_pages": 17, "pages_below_floor": 16, "per_page_char_floor": 100} +""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +# Per-page text-character floor below which a page is "image-only" (no usable text layer). 100 chars/page +# matches the heuristic already documented for the agent (agents/cap-table.md). +PER_PAGE_CHAR_FLOOR = 100 + + +def classify_pages(page_char_counts: list[int], floor: int = PER_PAGE_CHAR_FLOOR) -> dict[str, Any]: + """Classify a PDF as image-only from per-page stripped-text character counts. + + image-only iff there are no readable pages at all, OR a MAJORITY of pages fall below the per-page + floor (so a single text cover page can't mask image-only table pages). Pure + side-effect free.""" + total = len(page_char_counts) + below = sum(1 for c in page_char_counts if c < floor) + image_only = total == 0 or (below / total) >= 0.5 + return { + "total_pages": total, + "pages_below_floor": below, + "per_page_char_floor": floor, + "image_only": image_only, + "kind": "image_only" if image_only else "text", + } + + +def _page_char_counts(pdf_path: str) -> list[int]: + """Per-page stripped-text length via pdfplumber (raises on a missing parser — fail loud, never + silently treat a parse failure as text).""" + import pdfplumber # noqa: PLC0415 + + with pdfplumber.open(pdf_path) as pdf: + return [len((p.extract_text() or "").strip()) for p in pdf.pages] + + +def probe_pdf(pdf_path: str, floor: int = PER_PAGE_CHAR_FLOOR) -> dict[str, Any]: + return classify_pages(_page_char_counts(pdf_path), floor=floor) + + +def main() -> int: + p = argparse.ArgumentParser(description="Probe whether a PDF is image-only (no text layer), per-page.") + p.add_argument("pdf", help="path to the PDF") + p.add_argument("--floor", type=int, default=PER_PAGE_CHAR_FLOOR, help="per-page char floor") + p.add_argument("--pretty", action="store_true") + args = p.parse_args() + try: + result = probe_pdf(args.pdf, floor=args.floor) + except ImportError: + print(json.dumps({"ok": False, "mode": "pdf-probe", "error": "pdfplumber not installed"})) + return 1 + except Exception as e: # noqa: BLE001 + print(json.dumps({"ok": False, "mode": "pdf-probe", "error": f"{type(e).__name__}: {e}"})) + return 1 + print(json.dumps({"ok": True, "mode": "pdf-probe", **result}, indent=2 if args.pretty else None)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/founder-skills/skills/cap-table/scripts/priced_round.py b/founder-skills/skills/cap-table/scripts/priced_round.py index f0fcaac..aa3d2fc 100644 --- a/founder-skills/skills/cap-table/scripts/priced_round.py +++ b/founder-skills/skills/cap-table/scripts/priced_round.py @@ -55,6 +55,7 @@ import argparse import copy import json +import os import sys from typing import Any @@ -66,9 +67,17 @@ AITKEN_FALLBACK_STEP_RATIO = 20.0 SIGN_FLIP_DAMP_ALPHA = 0.5 SIGN_FLIP_DETECTION_WINDOW = 3 +# PPS sanity floor: a converged price below this value has no valid economic +# interpretation. Any realistic instrument conversion results in a PPS of at +# least a fraction of a cent ($0.000001); sub-floor values indicate the +# iteration collapsed toward zero because the instruments collectively demand +# ≥100% of the company (purchase amounts exceed caps / combined fractions ≥ 1). +PPS_SANITY_FLOOR = 1e-6 # Import sibling math producers -sys.path.insert(0, __file__.rsplit("/", 1)[0]) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _emit import add_output_args, emit # noqa: E402 +from _rule_pack import RULE_PACK_VERSION # noqa: E402 from anti_dilution import ( # noqa: E402 bbwa_new_conversion_price, full_ratchet_new_conversion_price, @@ -80,9 +89,6 @@ detect_mfn_cycles, ) -RULE_PACK_VERSION = "0.4.0" - - # ============================================================================ # Adjuster Protocol — types # ============================================================================ @@ -139,6 +145,17 @@ def _resolve_mfn_elections(safes: list[dict[str, Any]]) -> list[dict[str, Any]]: shadow["pre_money_valuation_cap"] = anchor.get("pre_money_valuation_cap") shadow["discount_multiplier"] = anchor.get("discount_multiplier") shadow["_mfn_inherited_from"] = elected_id + # Audit fields so the resolved election is visible in per_safe (cap_state can't + # distinguish two scenarios that elect different siblings — see fix plan §3c). + if anchor.get("post_money_valuation_cap") is not None: + shadow["_mfn_inherited_cap"] = anchor.get("post_money_valuation_cap") + shadow["_mfn_inherited_cap_type"] = "post_money" + elif anchor.get("pre_money_valuation_cap") is not None: + shadow["_mfn_inherited_cap"] = anchor.get("pre_money_valuation_cap") + shadow["_mfn_inherited_cap_type"] = "pre_money" + shadow["_mfn_inherited_discount"] = anchor.get("discount_multiplier") + # The override pre-pass stamps "scenario_override"; default to "instrument" otherwise. + shadow.setdefault("_mfn_election_source", "instrument") out[i] = shadow changed = True if not changed: @@ -146,6 +163,146 @@ def _resolve_mfn_elections(safes: list[dict[str, Any]]) -> list[dict[str, Any]]: return out +def _apply_mfn_election_overrides( + safes: list[dict[str, Any]], elections: Any +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + """Apply scenario-level MFN election overrides onto a shadow copy of safes. + + `elections` is a map ``{electing_safe_id: elected_against_safe_id}`` carried on + a scenario's ``parameters.mfn_elections``. For each entry the electing + ``yc_uncapped_mfn`` SAFE's ``mfn_provision.elected_against_safe_id`` is set on a + shadow record (the scenario override REPLACES any instrument-baked election), so + the downstream ``_resolve_mfn_elections`` inherits the chosen sibling's terms. + + Returns ``(shadow_safes, blockers, warnings)`` and NEVER mutates the caller's + records (the nested ``mfn_provision`` dict is copied before mutation — a bare + ``dict(s)`` would share the original ref). A no-op (``None``/``{}``) returns the + input list unchanged. Any other malformed shape, or a semantically invalid + election, returns a structural blocker rather than crashing or silently ignoring. + """ + if elections is None or elections == {}: + return safes, [], [] + if not isinstance(elections, dict) or not all( + isinstance(k, str) and isinstance(v, str) for k, v in elections.items() + ): + return ( + safes, + [ + { + "code": "E_MFN_ELECTIONS_BAD_SHAPE", + "instance_id": None, + "remedy": "mfn_elections must be a {electing_safe_id: elected_against_safe_id} " + "object with string keys and values.", + } + ], + [], + ) + by_id = {s.get("id"): s for s in safes} + out = [dict(s) for s in safes] + out_by_id = {s.get("id"): s for s in out} + blockers: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + for electing_id, elected_id in elections.items(): + s = out_by_id.get(electing_id) + if s is None: + blockers.append( + { + "code": "E_SAFE_MFN_ELECTION_UNKNOWN_SAFE", + "instance_id": electing_id, + "remedy": f"mfn_elections references unknown SAFE id {electing_id!r}.", + } + ) + continue + if s.get("form") != "yc_uncapped_mfn": + blockers.append( + { + "code": "E_SAFE_MFN_ELECTION_NOT_MFN", + "instance_id": electing_id, + "remedy": f"mfn_elections set on {electing_id!r}, which is not a yc_uncapped_mfn SAFE.", + } + ) + continue + if elected_id == electing_id or elected_id not in by_id: + blockers.append( + { + "code": "E_SAFE_MFN_ELECTION_BAD_TARGET", + "instance_id": electing_id, + "remedy": f"mfn election target {elected_id!r} is the SAFE itself or not a known SAFE id " + "(if filtered by safe_ids, the target must be in the active set).", + } + ) + continue + mfn = dict(s.get("mfn_provision") or {}) + prior = mfn.get("elected_against_safe_id") + mfn["elected_against_safe_id"] = elected_id + mfn["elected"] = True + s["mfn_provision"] = mfn + s["_mfn_election_source"] = "scenario_override" + if prior is not None and prior != elected_id: + warnings.append( + { + "code": "W_MFN_ELECTION_OVERRIDES_INSTRUMENT", + "instance_id": electing_id, + "detail": f"scenario election against {elected_id!r} overrides the instrument's " + f"baked election against {prior!r} (counterfactual).", + } + ) + return out, blockers, warnings + + +def _mfn_not_most_favorable_warnings( + safes: list[dict[str, Any]], per_safe: dict[str, dict[str, Any]] +) -> list[dict[str, Any]]: + """Post-solve check: flag any MFN-resolved SAFE that did NOT elect the most-favorable + (lowest realized conversion price) sibling available to it. + + Must run after convergence because the effective price is + ``min(cap_price, pps*discount, pps)`` — price-dependent. The candidate set for an + MFN holder is every other resolved, non-`yc_uncapped_mfn`, non-rejected SAFE (each + has a realized ``conversion_price`` in ``per_safe``). Under a real YC MFN the holder + always takes most-favorable, so a non-most-favorable election is a counterfactual. + """ + candidate_prices: dict[str, float] = {} + for s in safes: + sid = s.get("id") + # Candidates are the real anchor siblings an MFN could elect — exclude unresolved + # uncapped MFNs AND already-resolved MFN holders (their post-resolution form mirrors an + # anchor, but they are not themselves an electable sibling). + if sid is None or s.get("form") == "yc_uncapped_mfn" or s.get("_mfn_inherited_from"): + continue + entry = per_safe.get(sid) or {} + if entry.get("branch") == "rejected": + continue + cp = entry.get("conversion_price") + if cp is not None: + candidate_prices[str(sid)] = float(cp) + warnings: list[dict[str, Any]] = [] + for s in safes: + elected = s.get("_mfn_inherited_from") + if not elected: + continue + sid = s.get("id") + if sid is None: + continue + elected_price = (per_safe.get(sid) or {}).get("conversion_price") + others = {k: v for k, v in candidate_prices.items() if k != sid} + if elected_price is None or not others: + continue + best = min(others.values()) + if float(elected_price) > best + 1e-9: + warnings.append( + { + "code": "W_MFN_NOT_MOST_FAVORABLE", + "instance_id": sid, + "detail": f"MFN SAFE {sid!r} elected {elected!r} (conversion price " + f"{float(elected_price):.6f}) but a more favorable sibling exists " + f"(best {best:.6f}). Real YC MFN would take the most-favorable terms; " + "treat this election as a counterfactual.", + } + ) + return warnings + + # ============================================================================ # AntiDilutionAdjuster (stage: adjust_cap_state) # ============================================================================ @@ -168,7 +325,13 @@ def _compute_a_denominator(components: dict[str, int], basis: str) -> float: """Compute A from frozen pre-financing components per NVCA §4.4.4. nvca_broad: common + preferred-as-converted + options outstanding + options reserved - nvca_narrow: common + preferred-as-converted only + + warrants_underlying_total. + NVCA §4.4.4 includes "Options outstanding" in A, and the NVCA + definition of "Option" expressly includes warrants ("rights, + options or warrants to purchase shares of Common Stock"). + Outstanding warrants therefore belong in the broad basis. + nvca_narrow: common + preferred-as-converted only (excludes options and + warrants per the NVCA footnote's narrow-variant description). """ if basis == "nvca_broad": return float( @@ -176,6 +339,7 @@ def _compute_a_denominator(components: dict[str, int], basis: str) -> float: + components["preferred_shares_as_converted"] + components["options_outstanding"] + components["options_available"] + + components.get("warrants_underlying_total", 0) ) elif basis == "nvca_narrow": return float(components["common_shares"] + components["preferred_shares_as_converted"]) @@ -311,10 +475,12 @@ def _preferred_as_converted_total(preferred_series: list[dict[str, Any]]) -> int shares = int(s.get("shares", 0)) ocp = float(s.get("original_conversion_price", s.get("original_issue_price", 1.0))) ccp = float(s.get("current_conversion_price", ocp)) - if ccp == 0: - total += shares - else: - total += int(round(shares * (ocp / ccp))) + if ccp <= 0: + raise ValueError( + f"E_PREFERRED_SERIES_INVALID_PRICE: preferred_series[{s.get('series_id', '?')}]" + f".current_conversion_price resolves to {ccp} (must be > 0)." + ) + total += int(round(shares * (ocp / ccp))) return total @@ -413,6 +579,7 @@ def _safe_shares_at_price( company_capitalization: float, pre_money_fd: float, equity_financing_price: float, + pre_money_valuation: float | None = None, ) -> tuple[float, dict[str, dict[str, Any]]]: """Sum SAFE shares at a given (candidate) equity_financing_price. @@ -420,8 +587,13 @@ def _safe_shares_at_price( immediately prior to the equity financing = adj_pre_fd + converting securities, EXCLUDING new-money shares and in-connection pool top-ups; per rule `safe.company_capitalization_yc_post_money`) AND `pre_money_fd` - (pre-financing FD, constant). The math producer routes on form: post-money - forms use company_capitalization; pre-money (legacy) forms use pre_money_fd. + (pre-financing FD including in-connection pool top-up for pre-money forms, + per the YC pre-money SAFE "Company Capitalization" definition). The math + producer routes on form: post-money forms use company_capitalization; + pre-money (legacy) forms use pre_money_fd. + + `pre_money_valuation` is forwarded to `convert_safe_priced_round` so the + §(a)(1)/§(a)(2) branch selection can fire for pre-money SAFE forms. """ total = 0.0 per_safe: dict[str, dict[str, Any]] = {} @@ -436,9 +608,17 @@ def _safe_shares_at_price( pre_money_fd=pre_money_fd, equity_financing_price=equity_financing_price, conversion_price_override=s.get("conversion_price_override"), + pre_money_valuation=pre_money_valuation, ) - if s.get("_mfn_inherited_from"): - r["_mfn_inherited_from"] = s["_mfn_inherited_from"] + for _mfn_key in ( + "_mfn_inherited_from", + "_mfn_election_source", + "_mfn_inherited_cap", + "_mfn_inherited_cap_type", + "_mfn_inherited_discount", + ): + if s.get(_mfn_key) is not None: + r[_mfn_key] = s[_mfn_key] per_safe[s["id"]] = r if r.get("branch") != "rejected": total += r.get("conversion_shares", 0.0) @@ -482,6 +662,7 @@ def solve_priced_round( target_pool_percent: float | None = None, target_basis: str = "pre_money", conversion_event_date: str | None = None, + mfn_elections: dict[str, Any] | None = None, max_iterations: int = DEFAULT_MAX_ITERATIONS, convergence_threshold: float = DEFAULT_CONVERGENCE_THRESHOLD, ) -> dict[str, Any]: @@ -510,6 +691,43 @@ def solve_priced_round( blockers: list[dict[str, Any]] = [] + # Notes present but no conversion date → structural-only blocker (never + # crash). Mirrors run_scenario's note-path E_NOTE_NO_CONVERSION_DATE shape. + if notes and not conversion_event_date: + return { + "completeness": "structural_only", + "blockers": [ + { + "code": "E_NOTE_NO_CONVERSION_DATE", + "instance_id": None, + "remedy": "Provide conversion_event_date when convertible notes are present.", + } + ], + "per_safe": {}, + "per_note": {}, + "math_provenance": [], + } + + # After the guard above, conversion_event_date is non-None whenever notes + # are present. Narrow to a non-Optional local for the note-conversion calls + # (the bare assert is avoided so -O cannot strip note conversions). + note_conversion_date: str = conversion_event_date or "" + + # Scenario-level MFN election overrides (parameters.mfn_elections) — apply BEFORE + # structural resolution so the elected sibling's terms are the ones inherited. + # The structural override-conflict warning is held here and merged into + # solver_warnings once that list exists (W_MFN_NOT_MOST_FAVORABLE is computed + # later, post-convergence, since it is price-dependent). + safes, _mfn_override_blockers, _mfn_override_warnings = _apply_mfn_election_overrides(safes, mfn_elections) + if _mfn_override_blockers: + return { + "completeness": "structural_only", + "blockers": _mfn_override_blockers, + "per_safe": {}, + "per_note": {}, + "math_provenance": [], + } + # MFN resolution is STRUCTURAL — one-time pre-pass, not per-iter. safes = _resolve_mfn_elections(safes) @@ -529,6 +747,7 @@ def solve_priced_round( "blockers": blockers, "per_safe": {}, "per_note": {}, + "math_provenance": [], } pre_fd = float(working_cap_state["as_converted_totals"]["fully_diluted_shares"]) @@ -546,26 +765,33 @@ def solve_priced_round( "blockers": blockers, "per_safe": {}, "per_note": {}, + "math_provenance": [], } # IMMUTABLE A SNAPSHOT — frozen at iteration zero per NVCA §4.4.4 + # "immediately prior to such issue." Includes warrants_underlying_total + # per NVCA's Option definition (which expressly includes warrants). pre_ats = working_cap_state["as_converted_totals"] pre_financing_a_components = { "common_shares": int(pre_ats["common_shares"]), "preferred_shares_as_converted": int(pre_ats["preferred_shares_as_converted"]), "options_outstanding": int(pre_ats["options_outstanding"]), "options_available": int(pre_ats["options_available"]), + "warrants_underlying_total": int(pre_ats.get("warrants_underlying_total", 0)), } # IMMUTABLE CP1 SNAPSHOTS — frozen at iter 0 per AD-protected series. # Without freezing, AntiDilutionAdjuster would read the iter-mutated CCP # and apply AD on top of itself (ratchet-on-ratchet — a future extension). preferred_series = working_cap_state.get("preferred_series", []) + # cap_state guarantees current_conversion_price is always written (fallback + # chain current → original_conversion_price), so the second slot is + # unreachable in practice; it exists only as a defensive guard. pre_financing_cp1_snapshots: dict[str, float] = { s["series_id"]: float( s.get( "current_conversion_price", - s.get("original_conversion_price", s.get("original_issue_price", 1.0)), + s.get("original_conversion_price", 1.0), ) ) for s in preferred_series @@ -595,10 +821,20 @@ def solve_priced_round( # `safe.company_capitalization_yc_post_money`, this EXCLUDES new-money # financing shares and in-connection pool top-ups. company_cap_estimate = float(pre_fd) + # pm_pre_money_fd_estimate tracks the denominator for pre-money SAFE forms: + # per the YC pre-money SAFE "Company Capitalization" clause, this INCLUDES + # the in-connection pool top-up. Initialised to pre_fd (0 topup at iteration 0); + # updated at the end of each iteration once pool_topup_shares is known. + pm_pre_money_fd_estimate = float(pre_fd) aitken_engaged = False + aitken_fallback_engaged = False damping_engaged = False ad_breakdown: list[dict[str, Any]] = [] ad_warnings: list[dict[str, Any]] = [] + solver_warnings: list[dict[str, Any]] = [] + # Structural MFN override-conflict warnings collected pre-solve; merged here so + # they survive the AD direct-assign at result["warnings"] below and reach the sink. + solver_warnings.extend(_mfn_override_warnings) # Initialize per-iter outputs (used in convergence-loop scope) safe_shares: float = 0.0 @@ -642,17 +878,23 @@ def solve_priced_round( # SAFE definition. The fixed-point loop self-consistently resolves the # circular dependency (converting securities appear in both the numerator # share count and the denominator they convert against). + # pm_pre_money_fd_estimate is the denominator for pre-money SAFE forms: + # adj_pre_fd + pool_topup_shares from the previous iteration, per the YC + # pre-money SAFE "Company Capitalization" clause (includes in-connection + # pool increase; rule `safe.pre_money_cap_conversion`). safe_shares, per_safe = _safe_shares_at_price( safes, company_capitalization=company_cap_estimate, - pre_money_fd=adj_pre_fd, + pre_money_fd=pm_pre_money_fd_estimate, equity_financing_price=price, + pre_money_valuation=pre_money, ) if notes: - assert conversion_event_date, "conversion_event_date required when notes present" + # conversion_event_date guaranteed non-None by the structural guard + # at the top of solve_priced_round (returns early otherwise). note_shares, per_note = _note_shares_at_price( notes, - conversion_event_date=conversion_event_date, + conversion_event_date=note_conversion_date, priced_round_new_money=new_money, qualified_financing_price=price, ) @@ -689,6 +931,11 @@ def solve_priced_round( # in-connection pool top-up per the YC post-money SAFE definition # (rule `safe.company_capitalization_yc_post_money`). company_cap_estimate = adj_pre_fd + safe_shares + note_shares + # Update pm_pre_money_fd_estimate for the next iteration: adj_pre_fd + + # the in-connection pool top-up just computed. Per the YC pre-money SAFE + # "Company Capitalization" clause, the pool increase in connection with + # the equity financing IS included in the denominator for pre-money SAFEs. + pm_pre_money_fd_estimate = adj_pre_fd + pool_topup_shares rel_change = abs(new_price - price) / max(price, 1e-12) abs_change = abs(new_price - price) @@ -716,6 +963,21 @@ def solve_priced_round( # Apply Aitken projection as the next-iter starting point price = projection history[-1] = price + elif not aitken_fallback_engaged: + # Fence tripped: projected step exceeds the 20× vanilla + # bound. Abort acceleration and revert to vanilla + # iteration; record + warn so the watchlist rule + # anti_dilution.solver_aitken_fallback_engaged can fire. + aitken_fallback_engaged = True + solver_warnings.append( + { + "code": "W_SOLVER_AITKEN_FALLBACK", + "detail": ( + "Aitken acceleration projected a step > 20× the vanilla " + "step; reverted to unaccelerated fixed-point iteration." + ), + } + ) # Termination if rel_change < convergence_threshold and abs_change < DEFAULT_ABS_THRESHOLD: @@ -757,13 +1019,20 @@ def solve_priced_round( safe_shares, per_safe = _safe_shares_at_price( safes, company_capitalization=company_cap_estimate, - pre_money_fd=adj_pre_fd, + pre_money_fd=pm_pre_money_fd_estimate, equity_financing_price=price, + pre_money_valuation=pre_money, ) - if notes and conversion_event_date: + # W_MFN_NOT_MOST_FAVORABLE is price-dependent (effective price = min(cap, pps*disc, pps)), + # so it can only be computed AFTER convergence, from the realized per_safe prices. Appended + # to solver_warnings (merged at the sink below, surviving the AD direct-assign). + solver_warnings.extend(_mfn_not_most_favorable_warnings(safes, per_safe)) + if notes: + # conversion_event_date guaranteed non-None by the structural guard + # at the top of solve_priced_round (returns early otherwise). note_shares, per_note = _note_shares_at_price( notes, - conversion_event_date=conversion_event_date, + conversion_event_date=note_conversion_date, priced_round_new_money=new_money, qualified_financing_price=price, ) @@ -819,12 +1088,20 @@ def solve_priced_round( # Pre-AD baseline (only meaningful when AD fired) if has_ad_protection and ad_breakdown: + # pre_pps is the iteration-0 price estimate, not the converged PPS — the pre-AD baseline is an illustrative counterfactual, accurate to ~tenths of a pp. pre_ad_new_money_shares = new_money / pre_pps if pre_pps > 0 else 0.0 + # Reconstruct the pre-AD FD denominator from the SAME components the + # post-AD post_fd uses (via final_ats) so the only difference is the + # frozen pre-AD preferred-as-converted. final_ats["common_shares"] + # already includes common_batches; warrants_underlying_total is included + # in FD per §6.1. Omitting batches/warrants overstates the pre-AD + # baseline and corrupts the headline AD delta. pre_ad_post_fd = ( - founders_shares + final_ats["common_shares"] + pre_ad_preferred_as_converted + final_ats["options_outstanding"] + final_ats["options_available"] + + final_ats.get("warrants_underlying_total", 0) + pool_topup_shares + safe_shares + note_shares @@ -836,6 +1113,47 @@ def solve_priced_round( aggregate["preferred_pct_pre_anti_dilution"] = preferred_pct_pre_ad aggregate["anti_dilution_delta_pct_points"] = (aggregate["founders_pct"] - founder_pct_pre_ad) * 100 + # Post-convergence economic-validity guard. + # + # The fixed-point iteration terminates when |Δp/p| < 1e-6 AND |Δp| < 1e-9. + # When instruments collectively demand ≥100% of the company (e.g. a post-money + # SAFE whose purchase_amount exceeds its post_money_cap), the iteration drives + # PPS toward zero. At near-zero PPS both thresholds pass trivially (abs_change + # is tiny because the price is tiny; rel_change divides by max(price,1e-12)). + # The function then falsely reports converged=True / completeness="full" with + # astronomically large share counts. + # + # Guard conditions (any one is sufficient): + # 1. Converged PPS < PPS_SANITY_FLOOR — a price below a fraction of a cent + # across any realistic cap has no valid economic interpretation; it can only + # arise when the iteration collapsed toward zero. + # 2. founders_pct ≤ 0 — the pre-existing shareholders have been completely + # displaced, which is algebraically impossible for a finite instrument set. + # 3. Any ownership fraction outside (0, 1) — sanity check against negative + # or >100% individual-class fractions that indicate a degenerate solution. + _fraction_fields = ("founders_pct", "preferred_pct", "safe_pct", "note_pct", "new_money_pct") + _degenerate = ( + price < PPS_SANITY_FLOOR + or aggregate["founders_pct"] <= 0.0 + or any(not (0.0 <= aggregate[k] <= 1.0) for k in _fraction_fields) + ) + if _degenerate: + converged = False + blockers.append( + { + "code": "E_SOLVER_NO_VALID_FIXED_POINT", + "instance_id": None, + "remedy": ( + "The round as specified has no valid economic solution: the instruments " + "and/or new money collectively demand ≥100% of the company (purchase " + "amounts exceed post-money caps, or combined cap fractions ≥ 1). " + "Verify that each SAFE's purchase_amount < post_money_cap and that the " + "aggregate SAFE fractions (Σ purchase_i/cap_i) leave room for founders " + "and new investors. Counsel or the cap-table model should be reviewed." + ), + } + ) + # Determine scenario completeness — bucket rejected SAFEs by error code so # E_UNKNOWN_SAFE_FORM surfaces a clear blocker (not the generic fallback). rejected_safes = {s: r for s, r in per_safe.items() if r.get("branch") == "rejected"} @@ -874,15 +1192,6 @@ def solve_priced_round( "equity_financing_price": price, "iterations": iterations, "converged": converged, - "post_round_fully_diluted_shares": int(round(post_fd)), - "shares_breakdown": { - "pre_round_fully_diluted": int(pre_fd), - "safe_converted": int(round(safe_shares)), - "note_converted": int(round(note_shares)), - "pool_topup": int(round(pool_topup_shares)), - "new_money": int(round(new_money_shares)), - }, - "aggregate_ownership_by_class": aggregate, "per_safe": per_safe, "per_note": per_note, "convergence_history": history, @@ -897,6 +1206,26 @@ def solve_priced_round( ], } + # Quantitative share-count fields are omitted when the economic-validity + # guard fired: the computed values are degenerate (astronomically large) + # and emitting them would propagate garbage to downstream consumers. + # Callers should inspect completeness / blockers before reading share counts. + if not _degenerate: + result["post_round_fully_diluted_shares"] = int(round(post_fd)) + result["shares_breakdown"] = { + "pre_round_fully_diluted": int(pre_fd), + # AD ratcheting mutates CCP, inflating preferred-as-converted; this + # is the delta between the pre-AD pre_fd and the AD-adjusted + # adj_pre_fd. Without it the breakdown does not reconcile to + # post_round_fully_diluted_shares whenever AD fires. + "ad_delta": int(round(adj_pre_fd - pre_fd)), + "safe_converted": int(round(safe_shares)), + "note_converted": int(round(note_shares)), + "pool_topup": int(round(pool_topup_shares)), + "new_money": int(round(new_money_shares)), + } + result["aggregate_ownership_by_class"] = aggregate + # Additive AD outputs (emitted whenever ≥1 series carries AD protection, # so callers can inspect CCP state regardless of whether AD triggered) if has_ad_protection: @@ -918,13 +1247,18 @@ def solve_priced_round( if ad_warnings: result["warnings"] = ad_warnings - # Convergence diagnostics (emitted when sign-flip damping or Aitken engaged) - if aitken_engaged or damping_engaged: + # Convergence diagnostics (emitted when sign-flip damping or Aitken/fence engaged) + if aitken_engaged or aitken_fallback_engaged or damping_engaged: result["convergence_diagnostics"] = { "aitken_engaged": aitken_engaged, + "aitken_fallback_engaged": aitken_fallback_engaged, "damping_engaged": damping_engaged, } + if solver_warnings: + result.setdefault("warnings", []) + result["warnings"].extend(solver_warnings) + return result @@ -939,7 +1273,12 @@ def _cli() -> int: p.add_argument("--conversion-date", default=None) p.add_argument("--max-iter", type=int, default=DEFAULT_MAX_ITERATIONS) p.add_argument("--threshold", type=float, default=DEFAULT_CONVERGENCE_THRESHOLD) - p.add_argument("--pretty", action="store_true") + p.add_argument( + "--mfn-elections", + default=None, + help="JSON map {electing_safe_id: elected_against_safe_id} of scenario MFN elections.", + ) + add_output_args(p) args = p.parse_args() with open(args.cap_state, encoding="utf-8") as f: @@ -947,6 +1286,8 @@ def _cli() -> int: with open(args.instruments, encoding="utf-8") as f: instruments = json.load(f) + mfn_elections = json.loads(args.mfn_elections) if args.mfn_elections else None + result = solve_priced_round( cap_state=cap_state, safes=instruments.get("safes", []), @@ -956,10 +1297,11 @@ def _cli() -> int: target_pool_percent=args.target_pool_pct, target_basis=args.target_basis, conversion_event_date=args.conversion_date, + mfn_elections=mfn_elections, max_iterations=args.max_iter, convergence_threshold=args.threshold, ) - print(json.dumps(result, indent=2 if args.pretty else None, default=str)) + emit(result, args, default=str) return 0 diff --git a/founder-skills/skills/cap-table/scripts/quick_assess.py b/founder-skills/skills/cap-table/scripts/quick_assess.py index c075b5a..539fa9a 100644 --- a/founder-skills/skills/cap-table/scripts/quick_assess.py +++ b/founder-skills/skills/cap-table/scripts/quick_assess.py @@ -44,12 +44,12 @@ from typing import Any # Import sibling math producers -sys.path.insert(0, __file__.rsplit("/", 1)[0]) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _rule_pack import RULE_PACK_VERSION # noqa: E402 from cap_state import build_cap_state # noqa: E402 from priced_round import solve_priced_round # noqa: E402 SCHEMA_VERSION = "v0.1.0-cap-table-fast-assess" -RULE_PACK_VERSION = "0.4.0" def _fingerprint(prompt: str, attached_docs: list[str]) -> dict[str, Any]: @@ -91,6 +91,7 @@ def quick_assess( new_money: float, target_pool_percent: float | None, target_basis: str, + event_date: str | None = None, founder_prompt: str = "", attached_docs: list[str] | None = None, run_id_override: str | None = None, @@ -110,6 +111,22 @@ def quick_assess( } cs = build_cap_state(inputs, instruments) + # When notes are present but no conversion date was supplied, fast-assess + # defaults to today's date AND discloses the assumption (the math producer + # solve_priced_round NEVER defaults — it returns a structural blocker). This + # keeps the directional answer flowing while flagging the assumption. + assumptions: list[str] = [] + resolved_event_date = event_date + if (notes or []) and not resolved_event_date: + resolved_event_date = _dt.date.today().isoformat() + assumptions.append( + f"No note conversion date supplied; assumed today ({resolved_event_date}) " + f"for convertible-note conversion math." + ) + + # Scope note: fast-assess does NOT honor scenario-level mfn_elections overrides — it has + # no scenario parameters. Baked instrument elections (mfn_provision) still resolve inside + # the solver. For counterfactual MFN elections, use the full pipeline (run_scenario). solver_result = solve_priced_round( cap_state=cs, safes=safes, @@ -118,6 +135,7 @@ def quick_assess( new_money=new_money, target_pool_percent=target_pool_percent, target_basis=target_basis, + conversion_event_date=resolved_event_date, ) completeness = solver_result.get("completeness", "structural_only") @@ -174,12 +192,31 @@ def quick_assess( "For full structured artifacts (scenarios, counsel packet, anti-dilution, " "rule_audit), re-run cap-table without fast-assess mode." ), + "assumptions": assumptions, } + # Surface cap_state warnings (S2 W_CAP_BASE_ASSUMED, S3 W_FOUNDER_LOOKS_LIKE_INVESTOR) into the + # sentinel too — only when non-empty (the schema declares `warnings` optional; an empty array + # would still validate but adds noise). This carries the same backstops the full pipeline shows. + _cs_warnings = list(cs.get("warnings") or []) + if _cs_warnings: + sentinel["warnings"] = _cs_warnings # Founder-facing markdown md_lines: list[str] = [] md_lines.append(f"# {company_name} — Fast-Assess Cap Table") md_lines.append("") + if "W_CAP_BASE_ASSUMED" in _cs_warnings: + md_lines.append( + "> ⚠ **Cap base assumed, not confirmed** — these ownership figures are directional; " + "confirm founder share counts / pool before relying on them." + ) + md_lines.append("") + if "W_FOUNDER_LOOKS_LIKE_INVESTOR" in _cs_warnings: + md_lines.append( + "> ⚠ **A listed founder resembles an investment entity** (Ventures/Capital/Fund) — " + "confirm it is a founder, not an investor." + ) + md_lines.append("") md_lines.append( "_Fast-assess mode: a 1-page directional answer. For the full review " "(counsel packet, rule audit, anti-dilution, interactive explorer), " @@ -353,6 +390,13 @@ def quick_assess( md_lines.append("_Ask for the full review to get all of the above plus the counsel-handoff packet._") md_lines.append("") + if assumptions: + md_lines.append("## Assumptions") + md_lines.append("") + for a in assumptions: + md_lines.append(f"- {a}") + md_lines.append("") + sentinel["_report_md"] = "\n".join(md_lines) return sentinel @@ -373,6 +417,12 @@ def _cli() -> int: p.add_argument("--new-money", type=float, required=True) p.add_argument("--target-pool-percent", type=float, default=None) p.add_argument("--target-basis", default="post_money") + p.add_argument( + "--event-date", + default=None, + help="ISO conversion event date for convertible notes. If notes are present and " + "this is omitted, fast-assess defaults to today and discloses the assumption.", + ) p.add_argument("--review-dir", required=True, help="Output directory (e.g. cap-table-{slug}-fastassess/)") p.add_argument("--founder-prompt", default="") p.add_argument("--attached-doc", action="append", default=[]) @@ -407,6 +457,7 @@ def _cli() -> int: new_money=args.new_money, target_pool_percent=args.target_pool_percent, target_basis=args.target_basis, + event_date=args.event_date, founder_prompt=args.founder_prompt, attached_docs=args.attached_doc, run_id_override=args.run_id, diff --git a/founder-skills/skills/cap-table/scripts/rule_audit.py b/founder-skills/skills/cap-table/scripts/rule_audit.py index 18795e0..627285b 100644 --- a/founder-skills/skills/cap-table/scripts/rule_audit.py +++ b/founder-skills/skills/cap-table/scripts/rule_audit.py @@ -120,11 +120,18 @@ def _evaluate_date_status( if event_date_value is None: return "missing_event_date", False, False + # Near-edge proximity is measured from the audit reference date (`today`, + # defaulting to the event_date when no override is supplied) — it answers + # "is this rule's window about to open/close relative to when we're + # auditing?", which is what the watchlist consumes. The --today CLI override + # exists so tests can pin the reference date deterministically. + reference = today if today is not None else event_date_value + # Window-bounded if start is not None and event_date_value < start: # pre_effective; check near_start - delta_start = (start - event_date_value).days - near_start = delta_start <= near_start_days + delta_start = (start - reference).days + near_start = 0 <= delta_start <= near_start_days return "pre_effective", False, near_start if end is not None and event_date_value > end: @@ -133,7 +140,7 @@ def _evaluate_date_status( # in_window — check near_end if end exists near_end = False if end is not None: - delta_end = (end - event_date_value).days + delta_end = (end - reference).days near_end = 0 <= delta_end <= near_end_days return "in_window", near_end, False @@ -857,10 +864,11 @@ def _action_for_status(entry: dict[str, Any], rule: dict[str, Any]) -> str: if f == "stale": return "Benchmark dataset is stale; refresh annually per rule notes." if f == "unknown": - return "Set benchmark_reference_date in inputs.event_dates to use current data." + return "Set a benchmark reference date in your inputs to use current data." return "Benchmark fresh; renders as context only." if status == "missing_event_date": - return f"Provide {entry.get('event_date_field')} on the relevant instance." + field = (entry.get("event_date_field") or "date").replace("_", " ") + return f"Provide the {field} for this instance." if status == "expired": return "Rule's applicability window has passed; counsel review for current regime." if status == "pre_effective": @@ -1034,6 +1042,11 @@ def build_counsel_review_items( if rule_id in seen_rules: continue seen_rules.add(rule_id) + # Carry the (instance_type, instance_id) the gating already computed so + # downstream consumers can show what a counsel item applies to. Exactly + # one item per rule; the matched instances ride along as a + # deterministically-sorted list. + inst_list = _counsel_instances(instances) items.append( { "rule_id": rule_id, @@ -1044,11 +1057,68 @@ def build_counsel_review_items( "counsel_question": rule.get("summary", ""), "documents_needed": [], # placeholder; design §17 promises richer field "source_ids": rule.get("source_ids", []), + "instances": inst_list, + "relevance_tier": _counsel_relevance_tier(inst_list), + "applies_to": _counsel_applies_to(inst_list), } ) + # A general-scoped item whose DOMAIN has a specific instrument/scenario match + # elsewhere in this cap table is "likely relevant": the class is present even + # though this rule matched no exact instance of its own. Deterministic + # (depends only on the assembled items). + specific_domains = {it["domain"] for it in items if it["relevance_tier"] == "applies" and it.get("domain")} + for it in items: + if it["relevance_tier"] == "general" and it.get("domain") and it["domain"] in specific_domains: + it["relevance_tier"] = "likely" return items +# Instance types that are not tied to a specific instrument/scenario in this +# cap table — counsel items scoped only to these read as "applies to all". +_GENERAL_INSTANCE_TYPES = {"global", "engagement", None, ""} + + +def _counsel_instances(instances: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + """Distinct (instance_type, instance_id) pairs for the gating entries that + actually matched, sorted None-safely for deterministic output.""" + seen: set[tuple[Any, Any]] = set() + out: list[dict[str, Any]] = [] + for e in instances.values(): + if not (e.get("applies_when_matched") and e.get("status") not in {"pre_effective", "expired"}): + continue + key = (e.get("instance_type"), e.get("instance_id")) + if key in seen: + continue + seen.add(key) + out.append({"instance_type": e.get("instance_type"), "instance_id": e.get("instance_id")}) + out.sort(key=lambda x: (x["instance_type"] or "", x["instance_id"] or "")) + return out + + +def _counsel_relevance_tier(inst_list: list[dict[str, Any]]) -> str: + """ "applies" when the item matched a real instrument/scenario in this cap + table; "general" when only global/engagement-scoped. We do not invent a + per-scenario tier the data cannot support.""" + has_specific = any(i.get("instance_type") not in _GENERAL_INSTANCE_TYPES for i in inst_list) + return "applies" if has_specific else "general" + + +def _counsel_applies_to(inst_list: list[dict[str, Any]]) -> str: + """Short founder-facing label of what an item applies to.""" + ids = [ + i["instance_id"] + for i in inst_list + if i.get("instance_id") and i.get("instance_type") not in _GENERAL_INSTANCE_TYPES + ] + if not ids: + return "all" + uniq: list[str] = [] + for x in ids: + if str(x) not in uniq: + uniq.append(str(x)) + return ", ".join(uniq[:3]) + ("…" if len(uniq) > 3 else "") + + def build_source_notes( gating: dict[str, dict[str, dict[str, Any]]], rules: dict[str, Any], diff --git a/founder-skills/skills/cap-table/scripts/run_scenario.py b/founder-skills/skills/cap-table/scripts/run_scenario.py index f571793..fdfe26f 100644 --- a/founder-skills/skills/cap-table/scripts/run_scenario.py +++ b/founder-skills/skills/cap-table/scripts/run_scenario.py @@ -27,6 +27,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _artifact_writer import ArtifactValidationError, load_schema, write_artifact # noqa: E402 +from _rule_pack import RULE_PACK_VERSION # noqa: E402 from flip_scenario import flip_share_for_share # noqa: E402 from note_conversion import convert_note, derive_scenario_completeness # noqa: E402 from priced_round import solve_priced_round # noqa: E402 @@ -97,7 +98,18 @@ def run_safe_conversion_scenario( per_safe: dict[str, dict[str, Any]] = {} if priced_pre is None or priced_new is None: - # Cap-implied path only + # Cap-implied path only. MFN elections need a priced round to resolve against + # (convert_safe_cap_implied has no election path) — surface a blocker once rather + # than silently dropping the param. + if params.get("mfn_elections"): + blockers.append( + { + "code": "E_SAFE_MFN_ELECTION_REQUIRES_PRICED_ROUND", + "instance_id": None, + "remedy": "mfn_elections requires a priced round (priced_round_pre_money / " + "priced_round_new_money); the cap-implied path cannot resolve an MFN election.", + } + ) for s in safes: r = convert_safe_cap_implied( purchase_amount=s["purchase_amount"], @@ -124,7 +136,7 @@ def run_safe_conversion_scenario( "output_field": "cap_implied_outputs", "source_type": "rule", "rule_id": "safe.post_money_cap_conversion", - "rule_pack_version": "0.3.2", + "rule_pack_version": RULE_PACK_VERSION, "source_ref": None, } ], @@ -141,6 +153,7 @@ def run_safe_conversion_scenario( target_pool_percent=params.get("target_pool_percent"), target_basis=params.get("target_basis", "pre_money"), conversion_event_date=params.get("transaction_event_date"), + mfn_elections=params.get("mfn_elections"), ) @@ -251,6 +264,7 @@ def run_priced_round_scenario( target_pool_percent=params.get("target_pool_percent"), target_basis=params.get("target_basis", "pre_money"), conversion_event_date=params.get("transaction_event_date"), + mfn_elections=params.get("mfn_elections"), ) if warrant_events: diff --git a/founder-skills/skills/cap-table/scripts/safe_conversion.py b/founder-skills/skills/cap-table/scripts/safe_conversion.py index e8303c6..ce5e61a 100644 --- a/founder-skills/skills/cap-table/scripts/safe_conversion.py +++ b/founder-skills/skills/cap-table/scripts/safe_conversion.py @@ -34,16 +34,19 @@ import argparse import json +import os import sys from typing import Any -# Rule pack version this script targets. Bumped when math semantics change. -RULE_PACK_VERSION = "0.4.0" +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _emit import add_output_args, emit # noqa: E402 +from _rule_pack import RULE_PACK_VERSION # noqa: E402 # Typed error codes (mirror design doc §5.1) E_SAFE_REQUIRES_CONVERSION_EVENT = "E_SAFE_REQUIRES_CONVERSION_EVENT" E_SAFE_CAP_MISSING_DENOMINATOR = "E_SAFE_CAP_MISSING_DENOMINATOR" E_SAFE_CIRCULAR_MFN = "E_SAFE_CIRCULAR_MFN" +E_SAFE_INVALID_PRICE_INPUT = "E_SAFE_INVALID_PRICE_INPUT" E_UNKNOWN_SAFE_FORM = "E_UNKNOWN_SAFE_FORM" @@ -146,6 +149,7 @@ def convert_safe_priced_round( conversion_price_override: float | None = None, pre_money_valuation_cap: float | None = None, pre_money_fd: float | None = None, + pre_money_valuation: float | None = None, ) -> dict[str, Any]: """Post-financing output set (requires a real conversion event). @@ -155,14 +159,24 @@ def convert_safe_priced_round( * cap_plus_discount → min(cap_price, discount_price) * yc_postmoney_discount → discount branch only * yc_uncapped_mfn → REJECTED (caller must resolve MFN trigger first) - * yc_premoney_cap_only → cap branch (pre-money denominator) - * pre_money_cap_and_discount_legacy → min(cap_price[pre-money], discount_price) + * yc_premoney_cap_only → Standard-Preferred branch (round price) when + pre_money_valuation ≤ pre_money_valuation_cap per YC pre-money SAFE + §(a)(1); cap branch (pre-money denominator) when > cap per §(a)(2). + * pre_money_cap_and_discount_legacy → same §(a)(1)/§(a)(2) branch + selection, then min(winning_price, discount_price). Denominator routing: post-money forms use `company_capitalization` (= Company Capitalization immediately prior to the equity financing: adj_pre_fd + converting securities, EXCLUDING new-money shares and in-connection pool top-ups); pre-money forms use `pre_money_fd` - (= pre-financing FD constant). See `POST_MONEY_FORMS` / `PRE_MONEY_FORMS`. + (= pre-financing FD including in-connection pool increase, per the YC + pre-money SAFE "Company Capitalization" definition). See `POST_MONEY_FORMS` + / `PRE_MONEY_FORMS`. + + `pre_money_valuation` (the round's pre-money valuation) is required for + pre-money forms when a priced round is known; without it the §(a)(1) ≤-cap + branch cannot be selected. When absent the cap-price path is used as a + conservative fallback and a note is emitted. Caller is responsible for resolving MFN cherry-pick BEFORE calling this function (yc_uncapped_mfn with a trigger should be re-presented as if @@ -242,44 +256,96 @@ def convert_safe_priced_round( } candidate_prices: list[tuple[str, float]] = [] + notes: list[str] = [] # Cap candidate — route denominator on form. # Post-money forms use the iterating post-money FD as denominator - # (`company_capitalization`); pre-money forms use the constant pre-financing - # FD (`pre_money_fd`). This is the load-bearing distinction between the two - # SAFE families. - cap_value: float | None = None - cap_denom: float | None = None + # (`company_capitalization`); pre-money forms use `pre_money_fd` which + # includes the in-connection pool increase per the YC pre-money SAFE + # "Company Capitalization" definition. This is the load-bearing distinction + # between the two SAFE families. cap_provenance_rule_id: str = "safe.post_money_cap_conversion" if form in POST_MONEY_FORMS and post_money_valuation_cap is not None and post_money_valuation_cap > 0: - cap_value = post_money_valuation_cap - cap_denom = company_capitalization - cap_provenance_rule_id = "safe.post_money_cap_conversion" - elif form in PRE_MONEY_FORMS and pre_money_valuation_cap is not None and pre_money_valuation_cap > 0: - cap_value = pre_money_valuation_cap - cap_denom = pre_money_fd - # Pre-money SAFE math is the LEGACY YC SAFE conversion (pre-Oct-2018). - # The rule pack rule `safe.pre_money_cap_conversion` (added in v0.3.0) - # documents this branch. - cap_provenance_rule_id = "safe.pre_money_cap_conversion" - - if cap_value is not None: - if cap_denom is None or cap_denom <= 0: + if company_capitalization is None or company_capitalization <= 0: return { "branch": "rejected", "error": E_SAFE_CAP_MISSING_DENOMINATOR, "reason": ( - f"cap branch requires non-zero denominator " - f"({'pre_money_fd' if form in PRE_MONEY_FORMS else 'company_capitalization'}); " - f"got {cap_denom!r}" + f"cap branch requires non-zero denominator (company_capitalization); got {company_capitalization!r}" ), } - cap_price = cap_value / cap_denom + cap_price = post_money_valuation_cap / company_capitalization candidate_prices.append(("cap_price", cap_price)) + cap_provenance_rule_id = "safe.post_money_cap_conversion" + + elif form in PRE_MONEY_FORMS and pre_money_valuation_cap is not None and pre_money_valuation_cap > 0: + # YC pre-money SAFE §(a): branch on whether round pre-money valuation + # is ≤ cap or > cap. + # §(a)(1): pre_money_valuation ≤ cap → Standard Preferred (round price) + # §(a)(2): pre_money_valuation > cap → Safe Preferred (cap price) + # When pre_money_valuation is unknown the cap-price path is used as a + # conservative fallback (investor gets fewer shares — conservative for + # the founder-side; counsel should confirm branch when at/below-cap). + cap_provenance_rule_id = "safe.pre_money_cap_conversion" + if ( + pre_money_valuation is not None + and pre_money_valuation <= pre_money_valuation_cap + and equity_financing_price is not None + and equity_financing_price > 0 + ): + # §(a)(1): round price applies; cap price is disregarded. + # The investor gets purchase / round_price shares. + # The discount candidate (if any) still competes — investor + # always gets the more favorable (more shares) price. + candidate_prices.append(("round_price", equity_financing_price)) + else: + # §(a)(2): pre_money_valuation > cap, OR round context not available. + if pre_money_valuation is None and equity_financing_price is not None: + # No valuation supplied; emit an informational note. + notes.append( + "pre_money_valuation not provided; cap-price path used as conservative " + "fallback. If the round pre-money valuation is ≤ the SAFE cap, §(a)(1) " + "of the YC pre-money SAFE applies and the investor converts at the round " + "price instead (more investor-favorable)." + ) + elif ( + pre_money_valuation is not None + and pre_money_valuation <= pre_money_valuation_cap + and (equity_financing_price is None or equity_financing_price <= 0) + ): + # §(a)(1) is KNOWN to apply (valuation ≤ cap) but no round price is + # available to price it — the cap-branch numbers below are the + # wrong branch and must not be presented as the document's answer. + notes.append( + "pre_money_valuation ≤ cap, so §(a)(1) of the YC pre-money SAFE applies " + "(round-price conversion) — but no round price per share was provided. " + "The cap-price figures below are placeholders from the wrong branch; " + "re-run with the round price to get the document's answer." + ) + if pre_money_fd is None or pre_money_fd <= 0: + return { + "branch": "rejected", + "error": E_SAFE_CAP_MISSING_DENOMINATOR, + "reason": (f"cap branch requires non-zero denominator (pre_money_fd); got {pre_money_fd!r}"), + } + cap_price = pre_money_valuation_cap / pre_money_fd + candidate_prices.append(("cap_price", cap_price)) # Discount candidate (rule: safe.discount_rate_semantics) if discount_multiplier is not None and equity_financing_price is not None: + if discount_multiplier <= 0: + return { + "branch": "rejected", + "error": E_SAFE_INVALID_PRICE_INPUT, + "reason": f"discount_multiplier must be > 0; got {discount_multiplier!r}", + } + if equity_financing_price <= 0: + return { + "branch": "rejected", + "error": E_SAFE_INVALID_PRICE_INPUT, + "reason": f"equity_financing_price must be > 0; got {equity_financing_price!r}", + } discount_price = equity_financing_price * discount_multiplier candidate_prices.append(("discount_price", discount_price)) @@ -294,12 +360,25 @@ def convert_safe_priced_round( winning_label, conversion_price = min(candidate_prices, key=lambda kv: kv[1]) shares = purchase_amount / conversion_price - branch = "cap_branch" if winning_label == "cap_price" else "discount_branch" - if len(candidate_prices) == 2: - branch = "cap_and_discount_branch" + labels = {label for label, _ in candidate_prices} + if len(candidate_prices) == 1: + if winning_label == "cap_price": + branch = "cap_branch" + elif winning_label == "round_price": + branch = "round_price_branch" + else: + branch = "discount_branch" + else: + # Two candidates: name the combination + if "cap_price" in labels and "discount_price" in labels: + branch = "cap_and_discount_branch" + elif "round_price" in labels and "discount_price" in labels: + branch = "round_price_and_discount_branch" + else: + branch = "cap_and_discount_branch" # fallback provenance = [] - if any(label == "cap_price" for label, _ in candidate_prices): + if "cap_price" in labels: provenance.append( { "output_field": "cap_price", @@ -309,7 +388,17 @@ def convert_safe_priced_round( "source_ref": None, } ) - if any(label == "discount_price" for label, _ in candidate_prices): + if "round_price" in labels: + provenance.append( + { + "output_field": "round_price", + "source_type": "rule", + "rule_id": cap_provenance_rule_id, + "rule_pack_version": RULE_PACK_VERSION, + "source_ref": None, + } + ) + if "discount_price" in labels: provenance.append( { "output_field": "discount_price", @@ -338,13 +427,16 @@ def convert_safe_priced_round( } ) - return { + result: dict[str, Any] = { "branch": branch, "candidates": dict(candidate_prices), "conversion_price": conversion_price, "conversion_shares": shares, "math_provenance": provenance, } + if notes: + result["notes"] = notes + return result def detect_mfn_cycles(safes: list[dict[str, Any]]) -> list[set[str]]: @@ -396,7 +488,7 @@ def detect_mfn_cycles(safes: list[dict[str, Any]]) -> list[set[str]]: def _cli() -> int: shared = argparse.ArgumentParser(add_help=False) - shared.add_argument("--pretty", action="store_true") + add_output_args(shared) p = argparse.ArgumentParser(description=__doc__) sub = p.add_subparsers(dest="cmd", required=True) @@ -411,13 +503,32 @@ def _cli() -> int: pr.add_argument( "--form", required=True, - choices=["yc_postmoney_cap", "yc_postmoney_discount", "yc_uncapped_mfn", "cap_plus_discount", "other"], + choices=[ + "yc_postmoney_cap", + "yc_postmoney_discount", + "yc_uncapped_mfn", + "cap_plus_discount", + "yc_premoney_cap_only", + "pre_money_cap_and_discount_legacy", + ], ) pr.add_argument("--cap", type=float, default=None) pr.add_argument("--discount", type=float, default=None, help="Multiplier form: 0.80 = 20%% discount") pr.add_argument("--company-cap", type=float, required=True) pr.add_argument("--equity-price", type=float, default=None) pr.add_argument("--override", type=float, default=None, help="Conversion price override (counsel-supplied)") + pr.add_argument( + "--pre-money-cap", + type=float, + default=None, + help="Pre-money valuation cap (legacy pre-money forms)", + ) + pr.add_argument( + "--pre-money-fd", + type=float, + default=None, + help="Pre-money fully-diluted denominator (legacy pre-money forms)", + ) cycles = sub.add_parser("detect-mfn-cycles", parents=[shared], help="Detect circular MFN chains") cycles.add_argument("--instruments", required=True) @@ -439,6 +550,8 @@ def _cli() -> int: company_capitalization=args.company_cap, equity_financing_price=args.equity_price, conversion_price_override=args.override, + pre_money_valuation_cap=args.pre_money_cap, + pre_money_fd=args.pre_money_fd, ) else: # detect-mfn-cycles with open(args.instruments, encoding="utf-8") as f: @@ -449,10 +562,7 @@ def _cli() -> int: "error": E_SAFE_CIRCULAR_MFN if cycle_list else None, } - if args.pretty: - print(json.dumps(result, indent=2)) - else: - print(json.dumps(result)) + emit(result, args) return 0 diff --git a/founder-skills/skills/cap-table/scripts/sweep.py b/founder-skills/skills/cap-table/scripts/sweep.py new file mode 100644 index 0000000..e7310f0 --- /dev/null +++ b/founder-skills/skills/cap-table/scripts/sweep.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Generate sweep.json — a parametric pre-money sweep for the explorer slider. + +Re-runs the priced-round solver across a range of pre-money valuations (holding +``new_money`` and every other parameter fixed), producing K real solver frames +the explorer's slider scrubs between. Every frame is real solver output — the +slider snaps to discrete frames, so no fabricated in-between ownership is ever +shown ("we don't make the numbers up"). + +No new math: it builds a list of ``priced_round`` requests that vary +``parameters.pre_money`` and runs them through the existing +``run_all_scenarios`` path (which re-runs the pre-round warrant pump + solver +per frame). + +Input: an artifact dir with inputs/instruments/cap_state/scenarios.json. Picks a +base ``priced_round`` scenario that has both ``parameters.pre_money`` and +``parameters.new_money``. Output: sweep.json (schema-locked); JSON receipt to +stdout. When no eligible base scenario exists, writes an empty sweep (the +explorer simply renders no slider). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Any + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) + +from _artifact_writer import ArtifactValidationError, load_schema, write_artifact # noqa: E402 +from run_scenario import run_all_scenarios # noqa: E402 + +_SCHEMA_DIR = os.path.join(os.path.dirname(_HERE), "references", "schemas") + +DEFAULT_STEPS = 13 +# Sweep ±50% around the base pre-money. +_RANGE_LO = 0.5 +_RANGE_HI = 1.5 +# Only the fields the slider view consumes — keeps the inlined payload small. +_SLIDER_FIELDS = ( + "completeness", + "cap_implied_only", + "aggregate_ownership_by_class", + "equity_financing_price", + "post_round_fully_diluted_shares", + "shares_breakdown", + "founder_impact", + "per_safe", + "per_note", + "blockers", +) + + +def _find_base_scenario(scenarios: list[dict[str, Any]]) -> dict[str, Any] | None: + """First priced_round scenario carrying both pre_money and new_money.""" + for s in scenarios: + if s.get("type") == "priced_round": + p = s.get("parameters") or {} + if p.get("pre_money") is not None and p.get("new_money") is not None: + return s + return None + + +def build_sweep_requests(base: dict[str, Any], *, steps: int) -> list[dict[str, Any]]: + """K priced_round requests varying pre_money, holding every other param fixed.""" + base_params = dict(base.get("parameters") or {}) + base_pre = float(base_params["pre_money"]) + lo, hi = base_pre * _RANGE_LO, base_pre * _RANGE_HI + requests: list[dict[str, Any]] = [] + for i in range(steps): + # steps==1 → the base value (frac 0.5), not the low end. + frac = i / (steps - 1) if steps > 1 else 0.5 + pre = round(lo + (hi - lo) * frac, 2) + params = dict(base_params) + params["pre_money"] = pre + requests.append( + { + "scenario_id": f"sweep_{i:02d}", + "label": f"Pre ${pre / 1e6:.1f}M", + "type": "priced_round", + "parameters": params, + } + ) + return requests + + +def _trim_outputs(outputs: dict[str, Any]) -> dict[str, Any]: + return {k: outputs.get(k) for k in _SLIDER_FIELDS if k in outputs} + + +def build_sweep( + *, + inputs: dict[str, Any], + instruments: dict[str, Any], + cap_state: dict[str, Any], + scenarios: list[dict[str, Any]], + steps: int, +) -> dict[str, Any]: + base = _find_base_scenario(scenarios) + if base is None: + return { + "axis": "pre_money", + "base_scenario_id": None, + "base_pre_money": None, + "note": "No priced_round scenario with pre_money + new_money; no sweep generated.", + "frames": [], + } + requests = build_sweep_requests(base, steps=steps) + results = run_all_scenarios( + inputs=inputs, + instruments=instruments, + cap_state=cap_state, + scenario_requests=requests, + ) + frames: list[dict[str, Any]] = [] + for req, res in zip(requests, results, strict=True): + co = res["computed_outputs"] + valid = co.get("completeness") in ("full", "mixed") and not co.get("blockers") + frames.append( + { + "pre_money": req["parameters"]["pre_money"], + "new_money": req["parameters"].get("new_money"), + "valid": bool(valid), + "outputs": _trim_outputs(co), + } + ) + return { + "axis": "pre_money", + "base_scenario_id": base["scenario_id"], + "base_pre_money": float(base["parameters"]["pre_money"]), + "frames": frames, + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--dir", required=True, help="Artifact dir with inputs/instruments/cap_state/scenarios.json") + ap.add_argument("--run-id", required=True) + ap.add_argument("-o", "--output", required=True) + ap.add_argument("--steps", type=int, default=DEFAULT_STEPS) + ap.add_argument("--pretty", action="store_true") + args = ap.parse_args() + + def _read(name: str) -> Any: + with open(os.path.join(args.dir, name), encoding="utf-8") as f: + return json.load(f) + + inputs = _read("inputs.json") + instruments = _read("instruments.json") + cap_state = _read("cap_state.json") + scen = _read("scenarios.json") + scenarios = scen.get("scenarios", []) if isinstance(scen, dict) else scen + + data = build_sweep( + inputs=inputs, + instruments=instruments, + cap_state=cap_state, + scenarios=scenarios, + steps=args.steps, + ) + schema = load_schema(os.path.join(_SCHEMA_DIR, "sweep.schema.json")) + try: + receipt = write_artifact( + data=data, + schema=schema, + run_id=args.run_id, + output_path=args.output, + pretty=args.pretty, + ) + except ArtifactValidationError as e: + sys.stderr.write(f"sweep.py: schema validation failed: {e}\n") + return 1 + print(json.dumps(receipt, indent=2 if args.pretty else None)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/founder-skills/skills/cap-table/scripts/verify_one.py b/founder-skills/skills/cap-table/scripts/verify_one.py new file mode 100644 index 0000000..c563bf6 --- /dev/null +++ b/founder-skills/skills/cap-table/scripts/verify_one.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# dependencies = [] +# /// +"""Single-question cited lookup from the rule pack — the lightweight answer path. + +`--rule-lookup ` returns the cited constant a rule carries (e.g. the +QSBS OBBBA date-window start), its primary-source citations, and the reliance +boundary — for a bare eligibility/date question that needs a fact, not a solver +run. The model never computes the fact: it comes verbatim from the rule pack. + +ALLOWLIST BY DATA, NOT BY HARDCODED LIST: a rule is answerable here ONLY if it +exposes a recognized STRUCTURED constant (currently `date_window.start`). Rules +whose trap fact is not a stored constant — e.g. the Section 102 capital-gains +holding clock, which runs from a plan/trustee-specific deposit date the pack +does not store — return status "escalate": collect the founder's input and treat +as a counsel determination. This guard is what stops the path from echoing a +non-constant field (like `grant_date`) as if it were the answer. + +A rule carrying `counsel_review: true` is ALWAYS a flag/handoff, never an +eligibility conclusion (see the skill's Reliance Boundary). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections.abc import Iterator + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_RULES = os.path.join(HERE, "..", "references", "cap-table-rules.json") + +RELIANCE_BOUNDARY = ( + "State the cited fact (date window / threshold / clock) and stop. Do NOT " + "conclude that the founder does or will qualify — eligibility is a counsel " + "determination. Emit a counsel item." +) + + +def _iter_rules(pack: dict) -> Iterator[dict]: + """Yield every rule object across the pack's domains.""" + domains = pack.get("domains", {}) + groups = domains.values() if isinstance(domains, dict) else domains + for group in groups: + rules = group.get("rules", []) if isinstance(group, dict) else group + for rule in rules or []: + if isinstance(rule, dict): + yield rule + + +def find_rule(pack: dict, rule_id: str) -> dict | None: + for rule in _iter_rules(pack): + if (rule.get("id") or rule.get("rule_id")) == rule_id: + return rule + return None + + +def resolve_citations(pack: dict, rule: dict) -> list[dict]: + bib = {e.get("source_id"): e for e in pack.get("source_bibliography", []) if isinstance(e, dict)} + out = [] + for sid in rule.get("source_ids", []) or []: + entry = bib.get(sid) + if entry: + out.append({k: entry.get(k) for k in ("source_id", "title", "publisher", "url")}) + else: + out.append({"source_id": sid, "title": None, "publisher": None, "url": None}) + return out + + +def extract_constant(rule: dict) -> dict | None: + """Return a recognized STRUCTURED constant, or None (→ escalate). + + Recognized: `date_window.start` — a concrete date the rule pins. Extend here + as more structured constants are added to the pack; never fall back to a + non-constant field (e.g. `event_date_field`) as "the fact". + """ + dw = rule.get("date_window") + if isinstance(dw, dict) and dw.get("start"): + return { + "kind": "date_window_start", + "value": dw["start"], + "keyed_on": dw.get("event_date_field"), + "note": dw.get("notes"), + } + return None + + +def lookup(pack: dict, rule_id: str) -> dict: + rule = find_rule(pack, rule_id) + if rule is None: + return { + "rule_id": rule_id, + "lookup_status": "not_found", + "answer": f"No rule with id {rule_id!r} in the rule pack.", + } + + citations = resolve_citations(pack, rule) + counsel_review = bool(rule.get("counsel_review")) + const = extract_constant(rule) + + base = { + "rule_id": rule_id, + "counsel_review": counsel_review, + "citations": citations, + "reliance_boundary": RELIANCE_BOUNDARY, + } + + if const is None: + # No stored constant: do not fabricate one. Escalate for founder input. + base.update( + lookup_status="escalate", + constant=None, + escalation_reason=( + "This rule does not carry a fixed constant answerable from the rule " + "pack alone — the trap fact depends on inputs the pack does not store " + "(e.g. a plan/trustee-specific date). Do not answer from a default." + ), + answer=( + f"[{rule_id}] cannot be answered from a stored constant. " + f"{rule.get('summary', '').strip()} " + "Collect the specific date/fact from the founder and treat as a " + "counsel-reviewed determination — do not state a default as the answer." + ).strip(), + ) + return base + + cite_str = ( + "; ".join( + f"{c.get('title') or c.get('source_id')}" + (f" — {c['url']}" if c.get("url") else "") for c in citations + ) + or "(see rule pack source_ids)" + ) + keyed = f" (keyed on {const['keyed_on']})" if const.get("keyed_on") else "" + answer = ( + f"Cited fact [{rule_id}]: window start = {const['value']}{keyed}. " + + (f"{const['note']} " if const.get("note") else "") + + f"Source: {cite_str}. " + + ( + "This is counsel-reviewed: the date above is a fact; whether the founder " + "ultimately qualifies is a counsel determination — flag it, do not conclude it." + if counsel_review + else "" + ) + ).strip() + base.update(lookup_status="answered", constant=const, escalation_reason=None, answer=answer) + return base + + +def main() -> int: + p = argparse.ArgumentParser(description="Single-question cited rule-pack lookup.") + p.add_argument( + "--rule-lookup", + metavar="RULE_ID", + required=True, + help="rule_id to look up (e.g. delaware_cross_border.qsbs_date_sensitive)", + ) + p.add_argument("--rules", default=DEFAULT_RULES, help="path to cap-table-rules.json") + p.add_argument("-o", "--output-json", default=None, help="write the result JSON to a file") + p.add_argument("--pretty", action="store_true") + args = p.parse_args() + + try: + with open(args.rules, encoding="utf-8") as fh: + pack = json.load(fh) + except (OSError, json.JSONDecodeError) as e: + print(json.dumps({"error": f"could not load rules: {e}"}), file=sys.stderr) + return 2 + + result = lookup(pack, args.rule_lookup) + indent = 2 if args.pretty else None + + if args.output_json: + with open(args.output_json, "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=2) + print( + json.dumps({"ok": True, "path": args.output_json, "lookup_status": result["lookup_status"]}, indent=indent) + ) + else: + print(json.dumps(result, indent=indent)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/founder-skills/skills/cap-table/scripts/visualize.py b/founder-skills/skills/cap-table/scripts/visualize.py index d404ecc..48f3b69 100644 --- a/founder-skills/skills/cap-table/scripts/visualize.py +++ b/founder-skills/skills/cap-table/scripts/visualize.py @@ -13,24 +13,172 @@ from __future__ import annotations import argparse +import datetime import html import json import os +import re import sys +from collections.abc import Callable from typing import Any -# Color palette — see design §10 -PALETTE = { - "founders": "#2563EB", - "preferred": "#7C3AED", - "option_pool": "#0891B2", - "safe": "#DC2626", - "note": "#EA580C", - "new_money": "#059669", - "neutral": "#6B7280", +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _labels # noqa: E402 +import _palette # noqa: E402 +import _rules # noqa: E402 +from _rule_pack import RULE_PACK_VERSION # noqa: E402 + + +def _rule_html( + rule_id: str, + *, + item_title: str | None = None, + item_source_ids: list[str] | None = None, + compact: bool = False, +) -> str: + """Readable rule reference: title → primary source (new tab), summary as a + tooltip. Full form adds extra 'also' source links + the raw rule_id as + small-print; `compact=True` (for dense tables) keeps just the linked title + and folds the rule_id into the tooltip.""" + ref = _rules.rule_ref(rule_id, item_title=item_title, item_source_ids=item_source_ids) + title = html.escape(str(ref["title"]), quote=True) + summary = str(ref["summary"]) + tip = html.escape((f"{summary} · " if summary else "") + rule_id if compact else summary, quote=True) + links = ref["links"] + if links: + primary = html.escape(str(links[0][1]), quote=True) + out = f'{title} ↗' + if not compact and links[1:]: + joined = " · ".join( + f'{html.escape(str(p), quote=True)} ↗' + for p, u in links[1:] + ) + out += f' · also {joined}' + else: + out = f'{title}' if tip else title + if not compact: + out += f' {html.escape(str(rule_id), quote=True)}' + return out + + +_COUNSEL_DOMAIN_LABELS = { + "safe": "SAFEs & Israeli tax", + "israel_equity_tax": "Section 102 & equity tax", + "israeli_ltd": "Israeli company administration", + "israeli_aoa": "Articles of Association", + "delaware_cross_border": "Cross-border structure", + "delaware_flip": "Delaware flip", + "convertible_notes": "Convertible notes", + "anti_dilution": "Anti-dilution", + "dual_class": "Dual-class shares", + "option_pool": "Option pool", + "warrants": "Warrants", + "founder_benchmarks": "Founder benchmarks", + "cap_table": "Cap table", +} + + +def counsel_domain_label(slug: str) -> str: + return _COUNSEL_DOMAIN_LABELS.get(slug, slug.replace("_", " ").title()) + + +# status value → (pill text, tint key). Covers every value _rules._wl_status +# can surface: current_status (legal/tax) AND freshness_status (benchmarks). +_STATUS_PILL = { + "in_window": ("Active now", "success"), + "pre_effective": ("Opens soon", "warning"), + "missing_event_date": ("Needs a date", "warning"), + "date_tracking_only": ("Tracking", "neutral"), + "expired": ("Window passed", "faint"), + "not_date_sensitive": ("—", "neutral"), + "stale": ("Refresh data", "warning"), + "fresh": ("Current", "neutral"), + "unknown": ("Set a date", "warning"), +} +_PILL_TINT = { + "success": ("var(--lool-success-tint)", "var(--lool-success)"), + "warning": ("var(--lool-warning-tint)", "var(--lool-warning)"), + "neutral": ("var(--lool-paper-2)", "var(--lool-subtle)"), + "faint": ("var(--lool-paper-2)", "var(--lool-faint)"), } +def watchlist_status_pill(status: str | None) -> str: + text, tint = _STATUS_PILL.get(status or "", ((status or "").replace("_", " ") or "—", "neutral")) + bg, fg = _PILL_TINT[tint] + return f'{_esc(text)}' + + +def _parse_iso(d: Any) -> datetime.date | None: + try: + return datetime.date.fromisoformat(str(d)) + except (ValueError, TypeError): + return None + + +def watchlist_next_date(dates: list[str], status: str | None, as_of: str) -> str: + """Single neutral date cell. The status pill carries Active/Opens/Passed; + this just shows the relevant event date (no Opens/Until/Ended verbs, since + the watchlist only carries trigger dates, not window boundaries).""" + parsed = [p for p in (_parse_iso(d) for d in (dates or [])) if p is not None] + if not parsed: + return _rules.format_dates(dates) if dates else "—" + ref = _parse_iso(as_of) + if ref is None: + return min(parsed).isoformat() + future = [p for p in parsed if p >= ref] + return (min(future) if future else max(parsed)).isoformat() + + +def counsel_item_html(item: dict[str, Any]) -> str: + """Structured counsel block (mock layout): bold title, question, primary + source link + secondary 'also' links, and the rule code as muted mono + small-print. Reuses _rules.rule_ref for title/links.""" + ref = _rules.rule_ref(item["rule_id"], item_title=item.get("title"), item_source_ids=item.get("source_ids")) + title = _esc(str(ref["title"])) + question = _esc(item.get("counsel_question", "")) + links = ref["links"] + src_html = "" + if links: + primary_pub, primary_url = links[0] + src_html = ( + f'{_esc(primary_pub)} ↗' + ) + if links[1:]: + also = " · ".join( + f'{_esc(p)} ↗' for p, u in links[1:] + ) + src_html += f'· also {also}' + code_html = f'{_esc(item["rule_id"])}' + return ( + '
' + f'
{title}
' + f'
{question}
' + f'
{src_html}{code_html}
' + "
" + ) + + +# Ownership-class colors live in the shared _palette module so the report and +# the explorer can't drift (design E3). Re-exported here because existing tests +# read visualize.PALETTE. +PALETTE = _palette.PALETTE + +# Pre-AD / delta line-items that aggregate_ownership_by_class may carry. They +# are not ownership slices — render_donut/render_legend must exclude them so +# they neither draw a wedge nor double-encode the already-in-pp delta field. +EXCLUDED_OWNERSHIP_KEYS = { + "founders_pct_pre_anti_dilution", + "preferred_pct_pre_anti_dilution", + "anti_dilution_delta_pct_points", +} + + +def _palette_color(cat: str) -> str: + return _palette.slice_color(cat) + + def _esc(s: Any) -> str: """HTML-escape per design doc §10.""" return html.escape(str(s) if s is not None else "", quote=True) @@ -52,71 +200,187 @@ def _money(m: float | None) -> str: return f"${m:,.0f}" +def _money_compact(m: float | None) -> str: + """`_money` with a trailing `.00` stripped (so $18.00M → $18M) but real + decimals kept ($18.50M stays). Used for comparison column labels.""" + return re.sub(r"\.00(?=[BMK]?$)", "", _money(m)) + + +def _ordered_items(breakdown: dict[str, float], order: list[str]) -> list[tuple[str, float]]: + """Walk `order` (bare class names) and emit (key, value) for each class + present in `breakdown`, matching either the bare key or its `_pct` form. + Keys absent from `order` are dropped (order lists every renderable class).""" + items: list[tuple[str, float]] = [] + for name in order: + if name in breakdown: + items.append((name, breakdown[name])) + elif f"{name}_pct" in breakdown: + items.append((f"{name}_pct", breakdown[f"{name}_pct"])) + return items + + def render_donut( breakdown: dict[str, float], *, size: int = 200, - label: str = "", + center_value: str = "", + center_label: str = "", ) -> str: - """Render an SVG donut. `breakdown` maps category → percentage (0-1).""" - cx = cy = size // 2 - r_outer = size // 2 - 10 - r_inner = r_outer - 30 + """Inline SVG donut. `breakdown` maps class → fraction (0-1), keyed either + bare (``founders``) or `_pct`-suffixed (``founders_pct``). Wedges are drawn + at raw `frac × 360°` (no renormalization) so a wedge, its legend %, and the + headline founder % are the same number; classes below EPS or in + EXCLUDED_OWNERSHIP_KEYS are dropped. `center_value`/`center_label` print in + the hole.""" import math - total = sum(breakdown.values()) - if total <= 0: - return f'' + cx = cy = size / 2 + r_outer = size / 2 - 6 + r_inner = r_outer * 0.62 + + slices = [ + (cat, frac) + for cat, frac in _ordered_items(breakdown, _palette.ORDER_DONUT) + if cat not in EXCLUDED_OWNERSHIP_KEYS and frac >= _palette.EPS + ] + + paths: list[str] = [] + if not slices or sum(f for _, f in slices) <= 0: + paths.append(f'') + else: + start = -math.pi / 2 # 12 o'clock + for cat, frac in slices: + end = start + frac * 2 * math.pi # raw frac, denominator 1.0 + x1, y1 = cx + r_outer * math.cos(start), cy + r_outer * math.sin(start) + x2, y2 = cx + r_outer * math.cos(end), cy + r_outer * math.sin(end) + large = 1 if (end - start) > math.pi else 0 + paths.append( + f'' + ) + start = end - paths = [] - start_angle = -math.pi / 2 # 12 o'clock - for cat, frac in breakdown.items(): - if frac <= 0: - continue - end_angle = start_angle + (frac / total) * 2 * math.pi - x1 = cx + r_outer * math.cos(start_angle) - y1 = cy + r_outer * math.sin(start_angle) - x2 = cx + r_outer * math.cos(end_angle) - y2 = cy + r_outer * math.sin(end_angle) - x1i = cx + r_inner * math.cos(end_angle) - y1i = cy + r_inner * math.sin(end_angle) - x2i = cx + r_inner * math.cos(start_angle) - y2i = cy + r_inner * math.sin(start_angle) - large_arc = 1 if (end_angle - start_angle) > math.pi else 0 - color = PALETTE.get(cat, PALETTE["neutral"]) - d = ( - f"M {x1} {y1} A {r_outer} {r_outer} 0 {large_arc} 1 {x2} {y2} " - f"L {x1i} {y1i} A {r_inner} {r_inner} 0 {large_arc} 0 {x2i} {y2i} Z" + # Hole (turns the pie into a donut) + paths.append(f'') + + center = "" + if center_value: + center = ( + f'{_esc(center_value)}' ) - paths.append(f'') - start_angle = end_angle - - label_svg = "" - if label: - label_svg = f'{_esc(label)}' - return f'{"".join(paths)}{label_svg}' - - -def render_legend(breakdown: dict[str, float]) -> str: - items = [] - for cat, frac in breakdown.items(): - # Skip the pre-AD / delta-pp fields here — they're rendered - # separately by the scenario-card AD block (in render_report_html). - # `_pct(frac)` multiplies by 100, which would double-encode the - # already-in-pp anti_dilution_delta_pct_points field. - if cat in { - "founders_pct_pre_anti_dilution", - "preferred_pct_pre_anti_dilution", - "anti_dilution_delta_pct_points", - }: + if center_label: + center += ( + f'' + f"{_esc(center_label)}" + ) + return f'{"".join(paths)}{center}' + + +def render_legend(breakdown: dict[str, float], *, fd: float | None = None) -> str: + """Legend rows `swatch · Label · pct` (+ ` · N sh` when `fd` given). Plain + class labels via _palette; classes below EPS or excluded are skipped; rows + follow ORDER_LEGEND.""" + rows: list[str] = [] + for cat, frac in _ordered_items(breakdown, _palette.ORDER_LEGEND): + if cat in EXCLUDED_OWNERSHIP_KEYS or frac < _palette.EPS: continue - color = PALETTE.get(cat, PALETTE["neutral"]) - items.append( - f'
  • ' - f'' - f"{_esc(cat.replace('_', ' '))}: {_pct(frac)}
  • " + color = _palette.slice_color(cat) + label = _esc(_palette.slice_label(cat)) + shares = "" + if fd: + shares = f'{int(round(frac * fd)):,} sh' + rows.append( + '
  • ' + f'' + f'{label}' + f'{_pct(frac)}' + f"{shares}
  • " + ) + return f'
      {"".join(rows)}
    ' + + +def _scenario_before_pct(cap_state: dict[str, Any]) -> float: + """Founders-only 'today' fraction — same basis as _compute_founder_impact + and the producer's founders_pct (excludes common_batches).""" + ats = cap_state["as_converted_totals"] + fd = ats.get("fully_diluted_shares") or 0 + f_shares = sum(int(f.get("common_shares", 0)) for f in cap_state.get("founders", [])) + return f_shares / fd if fd else 0.0 + + +def _col_label(s: dict[str, Any]) -> str: + p = s.get("parameters") or {} + pre = p.get("pre_money", p.get("priced_round_pre_money")) + raise_ = p.get("new_money", p.get("priced_round_new_money")) + if pre is not None and raise_ is not None: + return f"{_money_compact(pre)} pre · {_money_compact(raise_)}" + return _esc(str(s.get("label", s.get("scenario_id", "Scenario")))) + + +def render_comparison_table( + full_scenarios: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]], + cap_state: dict[str, Any], +) -> str: + """Comparison across ≥2 fully-modeled scenarios. Returns "" when <2. + Columns = scenarios; rows = founders after / dilution vs today / price / + shares. Missing fields render '—'; the max-founders_pct column is flagged + 'least dilutive'.""" + if len(full_scenarios) < 2: + return "" + + before = _scenario_before_pct(cap_state) + cols: list[dict[str, Any]] = [] + for s, co, agg in full_scenarios: + after = agg.get("founders_pct") + fi = co.get("founder_impact") + if fi and fi.get("delta_pp") is not None: + delta = fi["delta_pp"] + elif after is not None: + delta = (after - before) * 100 + else: + delta = None + cols.append( + { + "label": _col_label(s), + "after": after, + "delta": delta, + "price": co.get("equity_financing_price"), + "fd": co.get("post_round_fully_diluted_shares"), + } ) - return f'
      {"".join(items)}
    ' + + afters = [c["after"] for c in cols if c["after"] is not None] + best_after = max(afters) if afters else None + + def _cell(v: str, *, best: bool) -> str: + bg = ' style="background:rgba(47,138,86,0.07);"' if best else "" + return f'{v}' + + head = 'Metric' + for c in cols: + is_best = best_after is not None and c["after"] == best_after + tag = 'least dilutive' if is_best else "" + cls = " cmp-best-col" if is_best else "" + head += f'{c["label"]}{tag}' + + def _row(label: str, fmt: Callable[[dict[str, Any]], str]) -> str: + cells = "" + for c in cols: + is_best = best_after is not None and c["after"] == best_after + cells += _cell(fmt(c), best=is_best) + return f'{label}{cells}' + + rows = ( + _row("Founders after round", lambda c: _pct(c["after"]) if c["after"] is not None else "—") + + _row("Dilution vs. today", lambda c: f"{c['delta']:+.1f} pts" if c["delta"] is not None else "—") + + _row("Price per share", lambda c: f"${c['price']:.2f}" if c["price"] is not None else "—") + + _row("Shares after round", lambda c: f"{int(c['fd']):,}" if c["fd"] is not None else "—") + ) + return f'
    {head}{rows}
    ' def render_report_html( @@ -129,21 +393,26 @@ def render_report_html( ) -> str: company = _esc(inputs.get("company_name", "Company")) scenarios = scenarios_doc.get("scenarios", []) - fd = cap_state["as_converted_totals"]["fully_diluted_shares"] + + # Step 1: founders-only pre_breakdown + other_common slice + ats = cap_state["as_converted_totals"] + fd = ats.get("fully_diluted_shares") or 0 + founder_shares = sum(int(f.get("common_shares", 0)) for f in cap_state.get("founders", [])) + batch_shares = sum(int(b.get("shares", 0)) for b in cap_state.get("common_batches", [])) pre_breakdown = ( { - "founders": cap_state["as_converted_totals"]["common_shares"] / fd, - "preferred": cap_state["as_converted_totals"]["preferred_shares_as_converted"] / fd, - "option_pool": ( - cap_state["as_converted_totals"]["options_outstanding"] - + cap_state["as_converted_totals"]["options_available"] - ) - / fd, - "warrants": cap_state["as_converted_totals"].get("warrants_underlying_total", 0) / fd, + "founders": founder_shares / fd, + "other_common": batch_shares / fd, + "preferred": ats["preferred_shares_as_converted"] / fd, + "option_pool": (ats["options_outstanding"] + ats["options_available"]) / fd, + "warrants": ats.get("warrants_underlying_total", 0) / fd, } if fd else {} ) + today_founder_pct = founder_shares / fd if fd else 0.0 + fd_total_str = f"{int(fd):,}" if fd else "—" + as_of = cap_state.get("as_of_date", "") # Voting_pct per-holder table when dual-class (§6.5 HTML mirror). founders_list_v = cap_state.get("founders") or [] @@ -164,7 +433,7 @@ def render_report_html( vrm = float(b.get("voting_rights_multiple") or 1.0) shares = int(b.get("shares") or 0) rows_v.append((f"Batch {b.get('batch_id') or b.get('holder_id', '?')}", cls, shares, vrm, shares * vrm)) - preferred_as_conv_v = int(cap_state["as_converted_totals"]["preferred_shares_as_converted"]) + preferred_as_conv_v = int(ats["preferred_shares_as_converted"]) if preferred_as_conv_v > 0: rows_v.append( ("Preferred (as-converted)", "preferred", preferred_as_conv_v, 1.0, float(preferred_as_conv_v)) @@ -178,9 +447,9 @@ def render_report_html( ) voting_pct_html = ( "

    Voting power (dual-class)

    " - "

    Dual-class structure detected. Voting % = shares × voting_rights_multiple, normalized across all voting holders. " + "

    Dual-class structure detected. Voting % = shares × voting_rights_multiple, normalized across all voting holders. " "Preferred treated as 1× per v0.5.0 simplification; see dual_class.founder_super_voting counsel item.

    " - "" + '
    HolderClassSharesVoting unitsVoting %
    ' f"{voting_rows_html}
    HolderClassSharesVoting unitsVoting %
    " ) @@ -214,158 +483,311 @@ def render_report_html( aoa_rows_html += f"{_esc(label)}{rendered}" aoa_findings_html = ( "

    Articles of Association — extracted findings

    " - "" + '
    FindingValue
    ' f"{aoa_rows_html}
    FindingValue
    " ) - scenario_cards = [] + # Step 2: classify scenarios into ordered buckets + rich: list[tuple[dict[str, Any], dict[str, Any], dict[str, Any]]] = [] + cap_implied: list[dict[str, Any]] = [] + pending: list[dict[str, Any]] = [] for s in scenarios: - co = s["computed_outputs"] + co = s.get("computed_outputs", {}) or {} completeness = co.get("completeness", "structural_only") agg = co.get("aggregate_ownership_by_class") or {} - if completeness in {"full", "mixed"} and agg: - # agg contains a `founders_by_class` map sub-object; filter to - # scalar pct values before passing to donut/legend so the SVG - # math doesn't try to add a dict. - agg_scalar = {k: v for k, v in agg.items() if isinstance(v, (int, float))} - donut = render_donut(agg_scalar, size=180, label=_pct(agg.get("founders_pct", 0))) - details = render_legend(agg_scalar) - fi = co.get("founder_impact", {}) or {} - impact_line = _esc(fi.get("plain_language", "")) - # Render AD breakdown when present - ad_bd = co.get("anti_dilution_breakdown") or [] - if ad_bd: - pre_ad = agg.get("founders_pct_pre_anti_dilution") - delta = agg.get("anti_dilution_delta_pct_points") - ad_summary_parts = [] - if pre_ad is not None: - ad_summary_parts.append(f"Pre-AD baseline: {_pct(pre_ad)}") - if delta is not None: - sign = "−" if delta < 0 else "+" - ad_summary_parts.append(f"AD impact: {sign}{abs(delta):.2f} pp") - details += ( - '

    ' + " | ".join(ad_summary_parts) + "

    " - ) - # Per-series AD rows - series_rows = [] - for bd in ad_bd: - sid = _esc(bd.get("series_id", "?")) - ptype = _esc(bd.get("protection_type", "?").replace("_", " ")) - cb = bd.get("ccp_before", 0) - ca = bd.get("ccp_after", 0) - floor_note = " (floor clamped)" if bd.get("floor_applied") else "" - series_rows.append(f"
  • {sid} ({ptype}): CCP ${cb:.4f} → ${ca:.4f}{floor_note}
  • ") - details += '
      ' + "".join(series_rows) + "
    " + agg_scalar = {k: v for k, v in agg.items() if isinstance(v, (int, float))} + if completeness in {"full", "mixed"} and agg_scalar: + rich.append((s, co, agg_scalar)) + elif co.get("cap_implied_only") and co.get("per_safe"): + cap_implied.append(s) else: - donut = '
    Pending
    ' - details = "No resolved ownership yet — see blockers." - impact_line = "" - blockers_html = "" - if co.get("blockers"): - blockers_html = ( - "
      " - + "".join(f"
    • {_esc(b['code'])}: {_esc(b['remedy'])}
    • " for b in co["blockers"]) - + "
    " + pending.append(s) + comparison_html = render_comparison_table(rich, cap_state) + + # Step 3: build cards in bucket order (rich → cap-implied → pending) + scenario_cards: list[str] = [] + for s, co, agg_scalar in rich: + donut = render_donut(agg_scalar, size=128, center_value=_pct(agg_scalar.get("founders_pct", 0))) + legend = render_legend(agg_scalar) + after = agg_scalar.get("founders_pct") + fi = co.get("founder_impact") or {} + if fi.get("delta_pp") is not None: + delta_pp = fi["delta_pp"] + elif after is not None: + delta_pp = (after - _scenario_before_pct(cap_state)) * 100 + else: + delta_pp = None + delta_txt = f"{delta_pp:+.1f} pts" if delta_pp is not None else "—" + price = co.get("equity_financing_price") + price_txt = f"${price:.2f}" if price is not None else "—" + fd_after = co.get("post_round_fully_diluted_shares") + fd_txt = f"{int(fd_after):,}" if fd_after is not None else "—" + impact = ( + _esc(fi.get("plain_language", "")) + if fi.get("plain_language") + else ( + f"Founders held {_pct(_scenario_before_pct(cap_state))} before; after, " + f"{_pct(after) if after is not None else '—'} — {delta_txt}." + ) + ) + ad_html = "" + ad_bd = co.get("anti_dilution_breakdown") or [] + if ad_bd: + pre_ad = agg_scalar.get("founders_pct_pre_anti_dilution") + d = agg_scalar.get("anti_dilution_delta_pct_points") + parts = [] + if pre_ad is not None: + parts.append(f"Pre-AD baseline {_pct(pre_ad)}") + if d is not None: + parts.append(f"AD impact {'−' if d < 0 else '+'}{abs(d):.2f} pp") + series = "".join( + f"
  • {_esc(b.get('series_id', '?'))} " + f"({_esc(str(b.get('protection_type', '?')).replace('_', ' '))}): " + f"CCP ${b.get('ccp_before', 0):.4f} → ${b.get('ccp_after', 0):.4f}" + f"{' (floor clamped)' if b.get('floor_applied') else ''}
  • " + for b in ad_bd ) + ad_html = f'

    {" | ".join(parts)}

      {series}
    ' scenario_cards.append(f""" -
    -

    {_esc(s.get("label", s["scenario_id"]))}

    +
    +
    +

    {_esc(s.get("label", s["scenario_id"]))}

    + Fully modeled +
    {donut} -
    -

    Type: {_esc(s["type"])} | Completeness: {_esc(completeness)}

    - {details} -

    {impact_line}

    - {blockers_html} +
    +
    +
    {_pct(after) if after is not None else "—"}
    founders after · {delta_txt}
    +
    {price_txt}
    price per share
    +
    {fd_txt}
    shares after round
    +
    + {legend}
    -
    -""") + {ad_html} +

    {impact}

    +
    """) + + for s in cap_implied: + co = s["computed_outputs"] + rows = "".join( + f'{_esc(sid)}{_pct(r.get("cap_implied_ownership", 0))}' + f'${float(r.get("safe_price") or 0):.4f}' + f'{int(r.get("cap_implied_shares") or 0):,}' + for sid, r in co["per_safe"].items() + ) + scenario_cards.append(f""" +
    +
    +

    {_esc(s.get("label", s["scenario_id"]))}

    + Structure only +
    +

    {_esc(_labels.CAP_IMPLIED_GLOSS)}

    + + {rows}
    SAFECap-implied %PriceShares
    +
    """) + for s in pending: + co = s.get("computed_outputs", {}) or {} + if co.get("blockers"): + body = ( + "
      " + + "".join(f"
    • {_esc(b['code'])}: {_esc(b['remedy'])}
    • " for b in co["blockers"]) + + "
    " + ) + else: + body = "

    No resolved ownership yet.

    " + scenario_cards.append(f""" +
    +
    +

    {_esc(s.get("label", s["scenario_id"]))}

    + {_esc(_labels.humanize("completeness", co.get("completeness", "structural_only")))} +
    + {body} +
    """) + + scripts_dir = os.path.dirname(os.path.abspath(__file__)) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + import _theme + + brand_css = _theme.brand_css() + + # Step 4: counsel grouped by domain via counsel_item_html; watchlist 4-col + counsel_items = counsel_packet.get("items", []) counsel_html = "" - if counsel_packet.get("items"): - items_html = [] + if counsel_items: by_domain: dict[str, list[dict[str, Any]]] = {} - for it in counsel_packet["items"]: + for it in counsel_items: by_domain.setdefault(it.get("domain", "other"), []).append(it) - for domain in sorted(by_domain.keys()): - items_html.append(f"

    {_esc(domain.replace('_', ' ').title())}

    ") - items_html.append("
      ") - for it in by_domain[domain]: - items_html.append( - f"
    • {_esc(it['title'])} " - f"({_esc(it['rule_id'])}) — {_esc(it.get('counsel_question', ''))}
    • " - ) - items_html.append("
    ") - counsel_html = "".join(items_html) + blocks = [] + for domain in sorted(by_domain): + items_html = "".join(counsel_item_html(it) for it in by_domain[domain]) + blocks.append( + f'
    ' + f"{_esc(counsel_domain_label(domain))}
    {items_html}
    " + ) + counsel_html = "".join(blocks) + + wl_groups = _rules.group_watchlist(rule_audit.get("date_sensitive_watchlist", [])) + if wl_groups: + wl_rows = "".join( + "" + f"{_esc(g['title'])}" + f"{watchlist_status_pill(g['status'])}" + f'{_esc(watchlist_next_date(g["dates"], g["status"], as_of))}' + f'{_esc(g["action"])}' + "" + for g in wl_groups + ) + watchlist_html = ( + "

    Date-sensitive watchlist

    " + '

    Rules with a time window — one row each.

    ' + '
    ' + "" + f"{wl_rows}
    RuleStatusNext dateWhat to do
    " + ) + else: + watchlist_html = "

    Date-sensitive watchlist

    No date-sensitive rules apply.

    " + counsel_count = len(counsel_items) return f""" Cap Table — {company} -
    -

    Cap Table — {company}

    - As of {_esc(cap_state.get("as_of_date", ""))} +
    +
    +
    +
    +
    +
    Cap table report
    +

    {company}

    +
    +
    As of {_esc(as_of)}
    {fd_total_str} FD shares today
    +
    -

    Current cap state (pre-financing)

    -
    - {render_donut(pre_breakdown, size=180, label="current")} - {render_legend(pre_breakdown)} -
    -{voting_pct_html} -{aoa_findings_html} - -

    Scenarios modeled

    -{"".join(scenario_cards)} - -

    Counsel review required ({len(counsel_packet.get("items", []))} items)

    -{counsel_html or "

    No counsel items.

    "} - -

    Date-sensitive watchlist

    - - - - { - "".join( - f"" - f"" - f"" - for w in rule_audit.get("date_sensitive_watchlist", []) - ) - } - -
    RuleScopeStatus / FreshnessDate
    {_esc(w['rule_id'])}{_esc(w['scope'])}{_esc(w.get('current_status') or w.get('freshness_status') or '—')}{_esc(w.get('event_date_value') or '—')}
    +

    Ownership today (pre-financing)

    +
    + {render_donut(pre_breakdown, size=150, center_value=_pct(today_founder_pct), center_label="founders")} + {render_legend(pre_breakdown, fd=fd)} +
    + {voting_pct_html} + + {f"

    Comparing the priced rounds

    {comparison_html}" if comparison_html else ""} + +

    Each scenario in detail

    + {"".join(scenario_cards)} + {aoa_findings_html} - @@ -376,6 +798,7 @@ def main() -> int: p = argparse.ArgumentParser(description=__doc__) p.add_argument("--dir", required=True) p.add_argument("-o", "--output", required=True) + p.add_argument("--pretty", action="store_true", help="Indent the JSON receipt printed to stdout") args = p.parse_args() def _read(name: str) -> dict[str, Any]: @@ -400,7 +823,12 @@ def _read(name: str) -> dict[str, Any]: os.makedirs(os.path.dirname(out) or ".", exist_ok=True) with open(out, "w", encoding="utf-8") as f: f.write(html_out) - print(json.dumps({"ok": True, "path": out, "bytes": len(html_out.encode("utf-8"))})) + print( + json.dumps( + {"ok": True, "path": out, "bytes": len(html_out.encode("utf-8"))}, + indent=2 if args.pretty else None, + ) + ) return 0 diff --git a/founder-skills/skills/competitive-positioning/SKILL.md b/founder-skills/skills/competitive-positioning/SKILL.md index 765781c..ca8c631 100644 --- a/founder-skills/skills/competitive-positioning/SKILL.md +++ b/founder-skills/skills/competitive-positioning/SKILL.md @@ -87,11 +87,10 @@ Every analysis deposits structured JSON artifacts into a working directory. The |------|----------|----------| | 2 | `product_profile.json` | Agent (main) | | 3 | `landscape_draft.json` | Agent (main) | -| 4 | `landscape_enriched.json` | Context A dispatch: LANDSCAPE_RESEARCH | -| 4b | `landscape.json` | `validate_landscape.py` (from enriched) | -| 5a | `moat_scores.json` | Context A dispatch: MOAT_SCORING → `score_moats.py` | -| 5b | `positioning_scores.json` | Context A dispatch: POSITIONING_SCORING → `score_positioning.py` | -| 5c | `positioning.json` | Agent (main — views, moats, stress-tests) | +| 4 | `landscape.json` | Context A dispatch: LANDSCAPE_RESEARCH → `validate_landscape.py` | +| 5a | `positioning.json` | Agent (main — views, moats, stress-tests) | +| 5b | `moat_scores.json` | Context A dispatch: MOAT_SCORING → `score_moats.py` | +| 5c | `positioning_scores.json` | Context A dispatch: POSITIONING_SCORING → `score_positioning.py` | | 6 | `checklist.json` | Context A dispatch: CHECKLIST → `checklist.py` | | 7 | `report.json` | `compose_report.py` reads all | | 7d | `report.html` | `visualize.py` | @@ -113,21 +112,24 @@ Keep the founder informed with brief, plain-language updates at each step. Never ```bash SCRIPTS="${CLAUDE_PLUGIN_ROOT}/skills/competitive-positioning/scripts" -REFS="${CLAUDE_PLUGIN_ROOT}/skills/competitive-positioning/references" -SHARED_SCRIPTS="${CLAUDE_PLUGIN_ROOT}/scripts" -SHARED_REFS="${CLAUDE_PLUGIN_ROOT}/references" -if ls "$(pwd)"/mnt/*/ >/dev/null 2>&1; then - ARTIFACTS_ROOT="$(ls -d "$(pwd)"/mnt/*/ | head -1)artifacts" -elif ls "$(pwd)"/sessions/*/mnt/*/ >/dev/null 2>&1; then - ARTIFACTS_ROOT="$(ls -d "$(pwd)"/sessions/*/mnt/*/ | head -1)artifacts" -else - ARTIFACTS_ROOT="./artifacts" +# In Cowork, CLAUDE_PLUGIN_ROOT substitutes to a host-side path that does not +# exist inside the session VM — self-heal by locating the plugin mount: +if [ ! -d "$SCRIPTS" ]; then + SCRIPTS="$(find /sessions -type d -path '*/skills/competitive-positioning/scripts' 2>/dev/null | head -1)" fi +if [ -z "$SCRIPTS" ] || [ ! -d "$SCRIPTS" ]; then + SCRIPTS="$(find / -type d -path '*/skills/competitive-positioning/scripts' 2>/dev/null | head -1)" +fi +PLUGIN_ROOT="${SCRIPTS%/skills/*}" +REFS="$PLUGIN_ROOT/skills/competitive-positioning/references" +SHARED_SCRIPTS="$PLUGIN_ROOT/scripts" +SHARED_REFS="$PLUGIN_ROOT/references" +# Resolve the canonical artifacts root via a SCRIPT, not inline bash (the agent paraphrases inline +# path computations → outputs/ vs outputs/artifacts/ drift across runs). Deterministic + creates it. +python3 "$SHARED_SCRIPTS/resolve_artifacts_root.py" # prints ARTIFACTS_ROOT — use the printed path verbatim as ARTIFACTS_ROOT in every later block (a captured var dies in the next fresh shell) ``` -The path setup handles both Claude Code (local filesystem) and Cowork (mounted sessions). In most cases, only the first branch (`./artifacts`) applies. - -If `CLAUDE_PLUGIN_ROOT` is empty OR the path it resolves to does not exist in your environment (in Claude Cowork it substitutes to a host-side path that is not present inside the session VM — test with `ls`), fall back: run `Glob` with pattern `**/skills/competitive-positioning/scripts/validate_landscape.py`, strip to get `SCRIPTS`, derive `REFS` and `SHARED_SCRIPTS`. In Claude Cowork this is always the case — don't retry the substituted path; go straight to the Glob fallback. If Glob returns multiple matches, prefer the one under a plugin mount (`.remote-plugins/` or the plugins cache) over any workspace copy. If Glob returns nothing, locate it with Bash: `find / -path '*/skills/competitive-positioning/scripts/validate_landscape.py' 2>/dev/null | head -5`. +The path setup handles both Claude Code (local filesystem) and Cowork (mounted sessions). The Step 0 block self-heals when `${CLAUDE_PLUGIN_ROOT}` doesn't resolve (Cowork). If it still comes up empty, locate the anchor manually: `find / -path '*/skills/competitive-positioning/scripts/validate_landscape.py' 2>/dev/null | head -5` and derive the variables from it. **If `ARTIFACTS_ROOT` resolves to `./artifacts` but no `artifacts/` directory exists at `$(pwd)`:** The workspace may not be mounted yet. Use `Glob` with pattern `**/artifacts/founder_context.json` to locate existing artifacts, and derive `ARTIFACTS_ROOT` from the result. If nothing is found, `mkdir -p ./artifacts` and proceed. @@ -136,15 +138,20 @@ After Step 1 (when the slug is known): ```bash ANALYSIS_DIR="$ARTIFACTS_ROOT/competitive-positioning-${SLUG}" mkdir -p "$ANALYSIS_DIR" -mkdir -p "$ANALYSIS_DIR/.staging" # for ad-hoc sub-agent JSON staging +# Sub-agent JSON staging lives OUTSIDE the promoted outputs/ tree. Anything under $ANALYSIS_DIR is a +# user-visible deliverable in Cowork, and deleting under outputs/ is unsafe there — so stage scratch +# in a temp dir, which is safe to both create and reclaim. Use the printed path verbatim in later steps. +STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/competitive-positioning-${SLUG:-co}.staging.XXXXXX")" RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)" ``` -Pass `RUN_ID` to all sub-agents. Every artifact must include `"metadata": {"run_id": "$RUN_ID"}`. `compose_report.py` checks run_id consistency — a mismatch triggers `STALE_ARTIFACT`. +Pass `RUN_ID` to all sub-agents. Every artifact must include `"metadata": {"run_id": "$RUN_ID"}`. `compose_report.py` checks run_id consistency — a mismatch triggers `STALE_ARTIFACT`. Its sibling integrity checks emit `CORRUPT_ARTIFACT` (artifact file is not valid JSON) and `UNVALIDATED_ARTIFACT` (artifact exists but was written directly instead of through its producer script — the `_produced_by` stamp is missing or wrong). All three are high-severity: fix the artifact by re-running the producer; never hand-edit it to silence the warning. -If `ANALYSIS_DIR` already contains artifacts from a previous run, remove them before starting: - - rm -f "$ANALYSIS_DIR"/{product_profile,landscape_draft,landscape_enriched,landscape,positioning,moat_scores,positioning_scores,checklist,report}.json "$ANALYSIS_DIR/report.html" "$ANALYSIS_DIR/explore.html" +**Overwrite-in-place — do NOT delete prior artifacts under `$ANALYSIS_DIR`.** It is the promoted +`outputs/` tree in Cowork, where deleting a user-visible path is unsafe (Cowork can deny it; the parity +gate flags it). Each producer writes its artifact fresh via `-o` every run, and `RUN_ID` is minted fresh +per run — so if a prior run left an artifact a later step doesn't regenerate, `compose_report.py`'s +`STALE_ARTIFACT` check (run_ids must match) catches the mismatch. No bulk `rm` is needed or wanted. ### Step 1: Read or Create Founder Context @@ -170,7 +177,11 @@ Extract from the founder's materials or conversation: company name, product desc **For deck mode:** Read ALL pages of the deck systematically — not just the competition slide. Problem, solution, traction, and team slides contain competitive claims and differentiation context that inform the analysis. If the deck has a competition slide with its own positioning axes, record them in `product_profile.json` under `deck_axes` for potential use as a secondary positioning view. -Write `product_profile.json` to `$ANALYSIS_DIR`. Consult `references/artifact-schemas.md` for the schema. +Write `product_profile.json` to `$ANALYSIS_DIR`. Consult `references/artifact-schemas.md` for the schema. Set `INPUT_MODE` to the chosen mode (`deck`, `conversation`, or `document`) — Step 6's checklist pipe passes it to `checklist.py --input-mode` so mode gating is applied correctly: + +```bash +INPUT_MODE="deck" # or "conversation" / "document" +``` If materials are sparse, use `AskUserQuestion` to gather missing fields. At minimum: product description, target customers, and what the founder believes differentiates them. @@ -206,17 +217,18 @@ Apply all corrections to `landscape_draft.json` before proceeding. ### Sub-agent JSON staging When a sub-agent returns JSON too large for bash heredoc, write it to -`$ANALYSIS_DIR/.staging/_input.json` first, then pipe via: +`$STAGING_DIR/_input.json` first, then pipe via: ```bash -cat "$ANALYSIS_DIR/.staging/_input.json" | python3 "$SCRIPTS/.py" ... +cat "$STAGING_DIR/_input.json" | python3 "$SCRIPTS/.py" ... ``` -The `.staging/` directory is created at setup and removed at cleanup. -This avoids `Operation not permitted` errors that occur when writing to -the session outputs mount (Cowork marks it read-only post-write). +`$STAGING_DIR` is a `/tmp` scratch dir created at setup; the sandbox reclaims it — never `rm` it (and +never stage scratch under `$ANALYSIS_DIR`, the promoted outputs/ tree). Staging in `/tmp` also avoids the +`Operation not permitted` errors that occur when writing to the session outputs mount (Cowork marks it +read-only post-write). -### Step 4: Research & Enrich Competitors -> `landscape_enriched.json` -> `landscape.json` (Context A: LANDSCAPE_RESEARCH dispatch) +### Step 4: Research & Enrich Competitors -> `landscape.json` (Context A: LANDSCAPE_RESEARCH dispatch) **Dispatch the competitive-positioning sub-agent in Context A (LANDSCAPE_RESEARCH).** The sub-agent declares `WebSearch` in its tool allowlist and performs the research itself — dispatch it via the `Task` tool so the research runs in an isolated context. @@ -254,30 +266,33 @@ Return JSON only — exactly the shape expected by validate_landscape.py: "input_mode": "", "metadata": {"run_id": ""} } +Do NOT write, edit, or create ANY files — your ONLY output is the JSON in your final assistant message. Files you write directly would bypass schema validation and run_id stamping and will be overwritten. ``` **After the sub-agent returns:** apply the tolerant JSON extraction protocol (see "Skill Execution Model" preamble) to obtain the structured JSON. If `suggested_additions` exist, present them to the founder and ask which to include. Merge approved ones into `competitors[]`. Then pipe through the producer script: ```bash -cat <<'LANDSCAPE_EOF' | python3 "$SCRIPTS/validate_landscape.py" --pretty -o "$ANALYSIS_DIR/landscape.json" +cat <<'LANDSCAPE_EOF' | python3 "$SCRIPTS/validate_landscape.py" --pretty --run-id "$RUN_ID" -o "$ANALYSIS_DIR/landscape.json" LANDSCAPE_EOF ``` Fix any errors (exit 1) and re-run. Warnings are acceptable — address medium-severity ones in the report. -### Gate 2: Founder Validation of Positioning +### Gate 2: Founder Validation of Axis Selection **MANDATORY STOP — TWO SEPARATE STEPS, same pattern as Gate 1.** -**Step A: Output a chat message** with the positioning preview. +At this point no competitor coordinates exist yet — those are produced in Step 5 (POSITIONING_SCORING) and written to `positioning.json`. Gate 2 validates **which axis pair(s)** to plot on and **which competitors** belong on the map, NOT coordinate positions. + +**Step A: Output a chat message** with the chosen axis pair(s) (the candidate axes from Step 3, with their rationale) and the confirmed competitor set that will be positioned. **Step B: AFTER the chat message, call `AskUserQuestion`** with ONLY a short question. -Question: `Does this positioning look right?` -Options: `Proceed to scoring` / `Adjust positions` / `Change axes` / `Other changes` +Question: `Do these positioning axes look right?` +Options: `Proceed to scoring` / `Change axes` / `Adjust competitor set` / `Other changes` -If founder changes an axis (not just coordinates), re-assign ALL competitor coordinates on the new axis with fresh evidence. Apply all corrections before proceeding to Step 5. +If the founder changes an axis pair or the competitor set, apply the change before proceeding to Step 5. Founder adjustments to individual coordinates happen later — at the Step 5 founder-override flow, after coordinates have been assigned. ### Step 5: Positioning & Moat Assessment -> `positioning.json` + Dispatch Moat/Positioning Scoring (Context A) @@ -319,6 +334,7 @@ Return JSON only — exactly the shape expected by score_moats.py: }, "metadata": {"run_id": ""} } +Do NOT write, edit, or create ANY files — your ONLY output is the JSON in your final assistant message. Files you write directly would bypass schema validation and run_id stamping and will be overwritten. ``` **POSITIONING_SCORING dispatch prompt:** @@ -360,22 +376,25 @@ Return JSON only — exactly the shape expected by score_positioning.py: "differentiation_claims": [...], "metadata": {"run_id": ""} } +Do NOT write, edit, or create ANY files — your ONLY output is the JSON in your final assistant message. Files you write directly would bypass schema validation and run_id stamping and will be overwritten. ``` **After both sub-agents return:** apply the tolerant JSON extraction protocol to each. Pipe MOAT_SCORING output through `score_moats.py` and POSITIONING_SCORING output through `score_positioning.py`: ```bash -cat <<'MOAT_EOF' | python3 "$SCRIPTS/score_moats.py" --pretty -o "$ANALYSIS_DIR/moat_scores.json" +cat <<'MOAT_EOF' | python3 "$SCRIPTS/score_moats.py" --pretty --run-id "$RUN_ID" -o "$ANALYSIS_DIR/moat_scores.json" MOAT_EOF ``` ```bash -cat <<'POS_EOF' | python3 "$SCRIPTS/score_positioning.py" --pretty -o "$ANALYSIS_DIR/positioning_scores.json" +cat <<'POS_EOF' | python3 "$SCRIPTS/score_positioning.py" --pretty --run-id "$RUN_ID" -o "$ANALYSIS_DIR/positioning_scores.json" POS_EOF ``` +**Founder coordinate-override flow (optional):** Now that competitor coordinates exist, present the positioned map to the founder if they asked to adjust positions (or flagged "Adjust positions" at any earlier gate). If the founder corrects a specific coordinate, update the corresponding point in `positioning.json` and re-run `score_positioning.py`, stamping `x_evidence_source` / `y_evidence_source: "founder_override"` on the changed coordinate so `compose_report.py` records it via `FOUNDER_OVERRIDE_COUNT`. Re-pipe the updated `positioning.json` views through `score_positioning.py` to refresh `positioning_scores.json`. + ### Step 6: Score Checklist -> `checklist.json` (Context A: CHECKLIST dispatch) **REQUIRED — read `$REFS/checklist-criteria.md` now.** @@ -405,16 +424,20 @@ what was checked. Return JSON only — the items array without a summary (the producer script computes the summary): {"items": [{"id": "COVER_01", "status": "pass", "evidence": "...", "notes": "..."}, ...all 25 items...]} +Do NOT write, edit, or create ANY files — your ONLY output is the JSON in your final assistant message. Files you write directly would bypass schema validation and run_id stamping and will be overwritten. ``` -**After the sub-agent returns:** apply the tolerant JSON extraction protocol to obtain the structured JSON. Then pipe through the producer script: +**After the sub-agent returns:** apply the tolerant JSON extraction protocol to obtain the structured JSON. Then pipe through the producer script. The sub-agent returns items only — pass the real input mode and run_id on the CLI so `checklist.py` gates the right items and stamps `metadata.run_id`: ```bash -cat <<'CHECKLIST_EOF' | python3 "$SCRIPTS/checklist.py" --pretty -o "$ANALYSIS_DIR/checklist.json" +cat <<'CHECKLIST_EOF' | python3 "$SCRIPTS/checklist.py" --pretty \ + --input-mode "$INPUT_MODE" --run-id "$RUN_ID" -o "$ANALYSIS_DIR/checklist.json" CHECKLIST_EOF ``` +`$INPUT_MODE` is the mode established in Steps 1-2 (`deck`, `conversation`, or `document`). Without `--input-mode`, deck/document runs silently default to `conversation` and mis-gate NARR_03/EVID_04; without `--run-id`, `checklist.json` carries no run_id and the Step 7c verifier blocks. + ### Step 7: Compose, Validate, and Post-Compose Coaching **7a — Compose report JSON (two-pass pattern):** @@ -429,7 +452,7 @@ python3 "$SCRIPTS/compose_report.py" --dir "$ANALYSIS_DIR" --pretty \ `compose_report.py` writes both `report.json` and `report.md` deterministically. **Do NOT** read `report_markdown` out of `report.json` and re-write it via heredoc. -Inspect the warnings in the output. Fix any high-severity warnings (missing artifacts, stale run_id) and re-run Pass 1. +Inspect the warnings in the output. Fix any high-severity warnings (missing artifacts, stale run_id, corrupt JSON, artifacts not written by their producer script) and re-run Pass 1. **Pass 2 (with acceptances):** If any medium-severity warnings should be accepted, add `accepted_warnings` to `positioning.json` with the warning code, match pattern, and reason. Then re-run with `--strict`: @@ -451,13 +474,17 @@ python3 "$SCRIPTS/compose_report.py" --dir "$ANALYSIS_DIR" --strict --pretty \ ```bash -COACHING_PAYLOAD="$(python3 -c ' +python3 -c ' import json, sys data = json.load(open(sys.argv[1])) print(json.dumps(data["coaching_payload"], indent=2)) -' "$ANALYSIS_DIR/report.json")" +' "$ANALYSIS_DIR/report.json" ``` +The payload prints to stdout — copy it from the tool result into the dispatch +prompt below. (Never capture it into a shell variable: each Bash call runs in a +fresh shell, so the variable would be unreadable and gone.) + **Dispatch prompt template:** ``` @@ -468,7 +495,7 @@ You are dispatched to add coaching commentary to a competitive positioning revie The compose_report.py script has finished. The structured `coaching_payload` from report.json is: - + Follow your agent body's Context B procedure (POST_COMPOSE_COACHING): @@ -523,11 +550,19 @@ Copy final deliverables to workspace root with clean names: cp "$ANALYSIS_DIR/report.md" "./${COMPANY_NAME}_Competitive_Positioning.md" cp "$ANALYSIS_DIR/report.html" "./${COMPANY_NAME}_Competitive_Positioning.html" 2>/dev/null cp "$ANALYSIS_DIR/explore.html" "./${COMPANY_NAME}_Competitive_Explorer.html" 2>/dev/null -rm -rf "$ANALYSIS_DIR/.staging" 2>/dev/null || true ``` +Scratch lives in `$STAGING_DIR` (`/tmp`, reclaimed by the sandbox) — no cleanup needed. **Do not `rm` +anything under `$ANALYSIS_DIR`** — it is the promoted `outputs/` tree in Cowork, where deleting a +user-visible path is unsafe (and the parity gate flags it). + Where `COMPANY_NAME` is the company name with spaces replaced by underscores (e.g., "Acme Corp" -> "Acme_Corp"). Present the file paths to the user. +**Presenting the report to the founder:** +- Answer placement and moat questions **from the points/evidence tables in report.md** — never re-derive or restate coordinates from memory. +- If the founder disputes a coordinate (e.g., "we're faster than you placed us"), use the **founder coordinate-override flow** (Step 5): update the specific point in `positioning.json` with `x_evidence_source: "founder_override"` and re-run `score_positioning.py` to refresh `positioning_scores.json`, then re-run `compose_report.py`. Do NOT re-explain a placement from chat context. +- For what-if competitive scenarios (e.g., "what if we added this moat?"), note the gap and invite the founder to re-run the full skill after updating the relevant data. + ## Scoring ### Moat Scoring @@ -547,7 +582,7 @@ Where `COMPANY_NAME` is the company name with spaces replaced by underscores (e. ## Cross-Agent Integration -This skill imports artifacts from prior deck-review (competition slide claims) and market-sizing (market scope validation) analyses. Imported artifacts are recorded with dates. Imports older than 7 days are flagged as `STALE_IMPORT`. +This skill imports artifacts from prior deck-review (competition slide claims) and market-sizing (market scope validation) analyses. Imported artifacts are recorded with dates so cross-skill findings can be cited with their provenance. ## Main-Thread Return @@ -558,3 +593,10 @@ This skill runs inline in the main thread (not as a sub-agent). The final outcom - Optionally: the HTML report paths from Steps 7d and 7e. **Do NOT inline `report_markdown` in the assistant message.** The founder reads the file via the path. + +## Feedback + +If a run ends **blocked or failed**, after you report the reason to the founder, add one line: +> _If this looks wrong or didn't finish, you can flag it: `/founder-skills:feedback`._ + +On **unsolicited** praise or frustration, you may mention `/founder-skills:feedback` once — never routinely, never mid-workflow, never more than once per session. diff --git a/founder-skills/skills/competitive-positioning/references/artifact-schemas.md b/founder-skills/skills/competitive-positioning/references/artifact-schemas.md index cbce341..93d6d58 100644 --- a/founder-skills/skills/competitive-positioning/references/artifact-schemas.md +++ b/founder-skills/skills/competitive-positioning/references/artifact-schemas.md @@ -7,16 +7,16 @@ JSON schemas for all artifacts deposited during the competitive positioning work These decisions were deferred from the design spec and are resolved here. Scripts and agent implementations must follow these exactly. 1. **Stress-tests live as top-level `differentiation_claims[]` in `positioning.json`** — claims span axes, so they are not nested under `views[]`. -2. **`suggested_axes` in `landscape_enriched.json` is informational only** — they inform the agent's axis selection but are NOT copied into `positioning.json`. Only the agent's canonical selection appears in `positioning.json`. -3. **`suggested_additions` entries carry a `merged: true/false` audit trail** — after the mini-gate, approved additions have `merged: true`; declined ones have `merged: false`. Both remain in `landscape_enriched.json` for audit. `validate_landscape.py` reads only the main `competitors[]` list (where merged additions have already been placed by the agent), never `suggested_additions`. +2. **`suggested_axes` in the LANDSCAPE_RESEARCH return payload is informational only** — they inform the agent's axis selection but are NOT copied into `positioning.json`. Only the agent's canonical selection appears in `positioning.json`. +3. **`suggested_additions` entries carry a `merged: true/false` flag** — after the mini-gate, approved additions have `merged: true`; declined ones have `merged: false`. `validate_landscape.py` reads only the main `competitors[]` list (where merged additions have already been placed by the agent), never `suggested_additions`. 4. **Vanity axis calculation excludes `_startup`** — the ">80% within 20% range" check counts only competitor points (not `_startup`). A lone differentiated startup should not flip the vanity metric. 5. **Rank-based differentiation uses competitor-only ranking** — `_startup` is excluded from the ranking pool. The differentiation score measures where the startup would rank among competitors on each axis. If the startup would be ranked 1st among N competitors on both axes, differentiation is high. 6. **Adjacent category alone suppresses `MISSING_DO_NOTHING`** — having at least one competitor with `category: "adjacent"` or `category: "do_nothing"` is sufficient. The warning fires only when neither category is present. 7. **`research_depth` allowed values: `full`, `partial`, `founder_provided`** — `full` = enriched in Phase A+B of research. `partial` = added via `suggested_additions` mini-gate with only gap-detection evidence. `founder_provided` = no web research was performed (search tools unavailable or agent knowledge only). `SHALLOW_COMPETITOR_PROFILE` fires for `partial` competitors with <3 `sourced_fields_count`. `RESEARCH_DEPTH_LOW` fires when the global `research_depth` is `founder_provided` AND fewer than 4 competitors have `sourced_fields_count >= 3`. 8. **Agent must score every landscape slug for moats** — every competitor in `landscape.json` (by slug) must have an entry in `positioning.json`'s `moat_assessments`. `_startup` must also be scored. Individual moat dimensions may be `not_applicable` but require explicit `evidence` explaining why (e.g., "Network effects do not apply to single-player productivity tools"). -9. **High-severity warning codes** (block under `--strict`): `MISSING_LANDSCAPE`, `MISSING_POSITIONING_SCORES`, `MISSING_MOAT_SCORES`, `MISSING_CHECKLIST`, `CORRUPT_ARTIFACT`, `STALE_ARTIFACT`. Medium-severity codes (reportable, can be accepted): `MISSING_DO_NOTHING`, `SHALLOW_COMPETITOR_PROFILE`, `VANITY_AXIS_WARNING`, `MOAT_WITHOUT_EVIDENCE`, `RESEARCH_DEPTH_LOW`. Low-severity: `FOUNDER_OVERRIDE_COUNT`. Info: `SEQUENTIAL_FALLBACK`. +9. **Warning codes by severity** — see the full Warning Severity Reference table near the end of this document for the authoritative list and triggers. High-severity (block under `--strict`): `MISSING_LANDSCAPE`, `MISSING_POSITIONING`, `MISSING_POSITIONING_SCORES`, `MISSING_MOAT_SCORES`, `MISSING_CHECKLIST`, `CORRUPT_ARTIFACT`, `STALE_ARTIFACT`, `UNVALIDATED_ARTIFACT`. Medium-severity (reportable, any can be accepted): `MISSING_DO_NOTHING`, `SHALLOW_COMPETITOR_PROFILE`, `VANITY_AXIS_WARNING`, `MOAT_WITHOUT_EVIDENCE`, `RESEARCH_DEPTH_LOW`, `MISSING_CANONICAL_MOAT`, `INCOMPLETE_SCORING`. Low-severity: `FOUNDER_OVERRIDE_COUNT`, `MARKER_COLLISION`. Info: `SEQUENTIAL_FALLBACK`, `CHECKLIST_ALL_PASS`. 10. **Provenance fields** — `positioning.json` points carry `x_evidence_source` and `y_evidence_source` (values: `"researched"`, `"agent_estimate"`, `"founder_override"`). Moat entries carry `evidence_source` with the same value set. `compose_report.py` counts `founder_override` occurrences and emits `FOUNDER_OVERRIDE_COUNT` as a low-severity metric. -11. **`input_mode` lives in `landscape.json` metadata** — `validate_landscape.py` passes through `input_mode` from `landscape_enriched.json`. Values: `"deck"`, `"conversation"`, `"document"`. `checklist.py` reads `input_mode` from `landscape.json` to apply mode-based gating. +11. **`input_mode` lives in `landscape.json`** — `validate_landscape.py` passes through `input_mode` from the LANDSCAPE_RESEARCH return payload into `landscape.json`. Values: `"deck"`, `"conversation"`, `"document"`. `checklist.py` applies mode-based gating from the `--input-mode` flag the main thread stamps (established in Steps 1-2), not from `landscape.json`. --- @@ -47,6 +47,7 @@ Every artifact includes a `metadata` object: | `business_model` | string | yes | Revenue model (SaaS, marketplace, etc.) | | `input_mode` | string | yes | `"deck"`, `"conversation"`, or `"document"` — how the analysis was initiated | | `source_materials` | string[] | yes | What was provided (e.g., `["pitch deck (PDF)", "founder conversation"]`) | +| `deck_axes` | object[] | no | Deck-mode only: positioning axes the deck's competition slide used, captured for potential reuse as a secondary positioning view. Each entry: `{x_axis, y_axis, source_slide}`. Informational — no script reads it. | | `metadata` | object | yes | `{run_id}` | **Example:** @@ -147,16 +148,16 @@ Contains the initial competitor identification and candidate axis pairs. Updated --- -## landscape_enriched.json +## LANDSCAPE_RESEARCH sub-agent return shape -**Producer:** Research sub-agent (Step 4) or main agent in sequential mode +**Producer:** Research sub-agent (Step 4) or main agent in sequential mode. **Not persisted to disk** — this is the JSON the sub-agent returns to the main thread, which pipes it straight through `validate_landscape.py -o landscape.json`. There is no `landscape_enriched.json` file on disk. -Contains enriched competitor profiles with sourced evidence. The main agent merges approved `suggested_additions` into `competitors[]` before writing the final version that `validate_landscape.py` reads. +Contains enriched competitor profiles with sourced evidence. The main agent merges approved `suggested_additions` into `competitors[]` before piping the payload to `validate_landscape.py`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `competitors` | object[] | yes | Enriched competitor profiles (includes any merged suggested additions) | -| `suggested_additions` | object[] | no | Competitors discovered during gap detection (audit trail) | +| `suggested_additions` | object[] | no | Competitors discovered during gap detection; presented to the founder at the mini-gate. Approved entries are merged into `competitors[]` before the payload is piped to `validate_landscape.py` | | `suggested_axes` | object[] | no | Additional axis pairs suggested by research findings (informational only) | | `assessment_mode` | string | yes | `"sub-agent"` or `"sequential"` | | `research_depth` | string | yes | Global research depth: `"full"`, `"partial"`, or `"founder_provided"` | @@ -340,7 +341,7 @@ Contains the canonical positioning views, moat assessments, differentiation stre | Field | Type | Required | Description | |-------|------|----------|-------------| -| `code` | string | yes | Warning code (medium-severity only: `MISSING_DO_NOTHING`, `SHALLOW_COMPETITOR_PROFILE`, `VANITY_AXIS_WARNING`, `MOAT_WITHOUT_EVIDENCE`, `RESEARCH_DEPTH_LOW`) | +| `code` | string | yes | Warning code. Any medium-severity code can be accepted: `MISSING_DO_NOTHING`, `SHALLOW_COMPETITOR_PROFILE`, `VANITY_AXIS_WARNING`, `MOAT_WITHOUT_EVIDENCE`, `RESEARCH_DEPTH_LOW`, `MISSING_CANONICAL_MOAT`, `INCOMPLETE_SCORING`. High-severity codes are never acceptable. | | `match` | string | yes | Case-insensitive substring to match against warning message | | `reason` | string | yes | Why this warning is expected/acceptable | @@ -853,7 +854,6 @@ Stubs are recognized by `compose_report.py` and bypass related validation checks It does NOT appear in: - `landscape.json` — which contains only competitors - `landscape_draft.json` — which contains only competitors -- `landscape_enriched.json` — which contains only competitors All downstream scripts and cross-artifact validation exempt `_startup` from competitor-matching checks. It is not an orphan. Specifically: - `validate_landscape.py` — ignores `_startup` (it is not in the competitor list) @@ -869,15 +869,23 @@ All downstream scripts and cross-artifact validation exempt `_startup` from comp | Code | Severity | Trigger | `--strict` | |------|----------|---------|------------| | `MISSING_LANDSCAPE` | high | `landscape.json` not found | exit 1 | +| `MISSING_POSITIONING` | high | `positioning.json` not found | exit 1 | | `MISSING_POSITIONING_SCORES` | high | `positioning_scores.json` not found | exit 1 | | `MISSING_MOAT_SCORES` | high | `moat_scores.json` not found | exit 1 | | `MISSING_CHECKLIST` | high | `checklist.json` not found | exit 1 | -| `CORRUPT_ARTIFACT` | high | Artifact exists but fails JSON parse or required field missing | exit 1 | +| `CORRUPT_ARTIFACT` | high | Artifact exists but fails JSON parse, is not a JSON object, or fails a cross-artifact integrity check (orphan/axis mismatch) | exit 1 | | `STALE_ARTIFACT` | high | `run_id` mismatch across artifacts | exit 1 | +| `UNVALIDATED_ARTIFACT` | high | Artifact exists but `_produced_by` does not match its producer script (written by hand instead of run through the script) | exit 1 | | `MISSING_DO_NOTHING` | medium | No `do_nothing` or `adjacent` competitor in landscape | can be accepted | | `SHALLOW_COMPETITOR_PROFILE` | medium | Competitor with `research_depth: "partial"` and `sourced_fields_count < 3` | can be accepted | | `VANITY_AXIS_WARNING` | medium | Axis flagged as vanity by `score_positioning.py` | can be accepted | | `MOAT_WITHOUT_EVIDENCE` | medium | Moat rated `strong` with evidence <20 chars | can be accepted | | `RESEARCH_DEPTH_LOW` | medium | Global `research_depth: "founder_provided"` with <4 competitors having `sourced_fields_count >= 3` | can be accepted | +| `MISSING_CANONICAL_MOAT` | medium | A company is missing one of the 6 canonical moat dimensions | can be accepted | +| `INCOMPLETE_SCORING` | medium | A landscape competitor is missing from `moat_scores` or positioning views | can be accepted | | `FOUNDER_OVERRIDE_COUNT` | low | N positioning coordinates or moat ratings have `evidence_source: "founder_override"` | report only | -| `SEQUENTIAL_FALLBACK` | info | `assessment_mode: "sequential"` in `landscape_enriched.json` | report only | +| `MARKER_COLLISION` | low | Report body contains the coaching-marker substring (informational; the per-run uuid prevents real collisions) | report only | +| `SEQUENTIAL_FALLBACK` | info | `assessment_mode: "sequential"` in `positioning.json` or `landscape.json` | report only | +| `CHECKLIST_ALL_PASS` | info | Every checklist item passed (flagged to review for self-grading bias) | report only | + +**Accepting medium-severity warnings:** `accepted_warnings[]` can accept *any* medium-severity code, not only the five most common ones — so `MISSING_CANONICAL_MOAT` and `INCOMPLETE_SCORING` are acceptable too. High-severity codes are integrity violations and can never be accepted. diff --git a/founder-skills/skills/competitive-positioning/references/checklist-criteria.md b/founder-skills/skills/competitive-positioning/references/checklist-criteria.md index cfc0cf0..a3c3c09 100644 --- a/founder-skills/skills/competitive-positioning/references/checklist-criteria.md +++ b/founder-skills/skills/competitive-positioning/references/checklist-criteria.md @@ -15,7 +15,7 @@ Overall score = `(pass_count + 0.5 * warn_count) / (total - na_count) * 100` ## Mode Gating Table -Items are auto-gated to `not_applicable` based on `input_mode` (from `landscape.json`). The agent should not waste effort assessing gated items. +Items are auto-gated to `not_applicable` based on `input_mode`. The main thread passes the mode via `--input-mode` flag to `checklist.py`; the script falls back to the `input_mode` field in stdin JSON, then to `conversation`. The agent should not waste effort assessing gated items. | ID | Label | `deck` | `conversation` | `document` | |----|-------|--------|----------------|------------| @@ -177,7 +177,7 @@ Items are auto-gated to `not_applicable` based on `input_mode` (from `landscape. **Fail:** Fewer than 40% of competitors have sourced evidence. Analysis is primarily based on agent knowledge or founder claims. **Warn:** 40-60% of competitors have sourced evidence. Mixed research quality. **Basis:** An analysis built primarily on agent knowledge (training data) is less reliable than one built on current research. Investors value current, sourced data. -**Mode gating:** Auto-gated to `not_applicable` in `deck` mode (deck-sourced analyses rely on deck claims as primary input). +**Mode gating:** None — EVID_02 is active in all input modes (deck, conversation, document). Only EVID_04 and NARR_03 are auto-gated; see the gating table above. ### `EVID_03` **Label:** Evidence sources distinguished (researched vs. estimated) diff --git a/founder-skills/skills/competitive-positioning/scripts/_theme.py b/founder-skills/skills/competitive-positioning/scripts/_theme.py new file mode 100644 index 0000000..dccf2c0 --- /dev/null +++ b/founder-skills/skills/competitive-positioning/scripts/_theme.py @@ -0,0 +1,107 @@ +"""Brand theme for generated HTML artifacts. + +Provides the design-token CSS (palette, type, radii) and an offline +``@font-face`` rule with the Sora variable webfont embedded as base64, so +generated HTML stays self-contained (no CDN). The font file lives in +``references/brand/fonts/`` at the plugin root; when absent, artifacts +fall back to the system font stack. +""" + +from __future__ import annotations + +import base64 +import os + +_BRAND_FONT_PATH = os.path.normpath( + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "..", + "..", + "references", + "brand", + "fonts", + "Sora-variable.woff2", + ) +) + +# Palette and primitives: white surfaces, blue accents, charcoal text, +# square-cornered blocks with hairline rules, muted semantic status colors. +TOKENS_CSS = """\ +:root { + --lool-blue: #0D549D; + --lool-blue-deep: #093F78; + --lool-royal: #1B5FB2; + --lool-azure: #21A2E3; + --lool-azure-deep: #1488C8; + --lool-sky: #48B4EA; + --lool-slate: #374B65; + --lool-slate-blue: #365A8A; + + --lool-white: #FFFFFF; + --lool-paper: #FAFAFA; + --lool-paper-2: #F1F4F4; + --lool-line: #D7DBE0; + --lool-line-2: #E5EEF8; + --lool-line-form: #C0CFDD; + + --lool-ink: #333333; + --lool-nav: #414042; + --lool-mute: #777777; + --lool-subtle: #7D90A3; + --lool-faint: #A6AEB5; + + --lool-success: #2F8A56; + --lool-success-tint: #EAF4EE; + --lool-warning: #C9892B; + --lool-warning-tint: #FAF3E5; + --lool-danger: #C0392B; + --lool-danger-tint: #FAECEA; + + --font-body: 'Sora', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Helvetica, Arial, sans-serif; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', ui-monospace, Menlo, + monospace; + + --r-input: 4px; + --r-pill: 50px; + --shadow-soft: 0 8px 30px rgba(16, 32, 64, 0.08); +} +""" + +# Subtle provenance line rendered at the bottom of each artifact. +FOOTER_CREDIT_HTML = ( + '' +) + +FOOTER_CREDIT_CSS = """\ +.footer-credit { + font-size: 0.75rem; color: var(--lool-subtle); + padding: 24px 32px 8px; letter-spacing: 0.02em; +} +""" + + +def font_face_css() -> str: + """``@font-face`` rule with Sora embedded base64-inline. + + Returns an empty string when the font file is missing so the artifact + degrades to the system stack instead of failing to generate. + """ + if not os.path.isfile(_BRAND_FONT_PATH): + return "" + with open(_BRAND_FONT_PATH, "rb") as f: + b64 = base64.b64encode(f.read()).decode("ascii") + return ( + "@font-face { font-family: 'Sora'; font-style: normal; " + "font-weight: 100 800; font-display: swap; " + f"src: url(data:font/woff2;base64,{b64}) format('woff2'); }}" + ) + + +def brand_css() -> str: + """Font-face + token CSS, ready to inject at the top of a """ + ) def compose_explorer(dir_path: str) -> str: """Build the full interactive explorer HTML.""" + scripts_dir = os.path.dirname(os.path.abspath(__file__)) + if scripts_dir not in sys.path: + sys.path.insert(0, scripts_dir) + import _theme + + brand_css = _theme.brand_css() + footer_credit = _theme.FOOTER_CREDIT_HTML + payload = _build_data_payload(dir_path) data_json = _safe_json_embed(payload) company_name = _esc(payload["company_name"]) @@ -287,7 +321,7 @@ def compose_explorer(dir_path: str) -> str: Competitive Explorer: {company_name} -{_css()} +{_css(brand_css)}
    @@ -341,25 +375,25 @@ def compose_explorer(dir_path: str) -> str:
    -