diff --git a/README.md b/README.md index b1eab98..b5ee9e7 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Canonical repo: `https://gitea.ten31.ai/Ten31AI/boardroom-map`. ## Status -**v0.1.8 — live in production.** Deployed on a StartOS box driving a DGX Spark +**v0.1.9.** Deployed on a StartOS box driving a DGX Spark in single-spark air-gapped mode (gemma-4-31B panel: munger-lens / girdley-operator / buffett-owner). First full grading run completed 2026-07-29: a three-deck company history graded end-to-end into a running @@ -142,7 +142,15 @@ scratch with a different model panel; v0.1.8 added the two-Spark pipeline — secondary-Spark models work in air-gapped mode (dual-homed proxy; graders keep zero egress), extraction and grading run in parallel when their models sit on different Sparks, and single-wave jobs keep the vLLMs warm across -decks instead of reloading the 31B (~6 min) per deck. +decks instead of reloading the 31B (~6 min) per deck; v0.1.9 added per-company +progress reviews — `PROGRESS.md` / "View progress review" reads the whole +graded ledger for the trajectory (composite deltas, improving/declining BDEF +categories and KPIs, recurring vs resolved flags) plus a deterministic +deck-quality gap engine that lists what the materials aren't showing (no +profitability visibility, untargeted KPIs, no forward guidance, dropped KPIs, +thin-evidence categories, no board asks) and renders them as a paste-ready +request list for the next deck (`/api/companies/{slug}/progress(.md)`, no GPU +needed). Known optimization not yet done: the wave is torn down per deck, so the 31B reloads from disk (~6 min) between decks even when the model set is unchanged. diff --git a/orchestrator/app.py b/orchestrator/app.py index 9d75508..ba00acc 100644 --- a/orchestrator/app.py +++ b/orchestrator/app.py @@ -21,6 +21,7 @@ import decks import graders as grader_mod import jobs import ledger as ledger_mod +import progress as progress_mod import serving DATA_DIR = os.environ.get("BM_DATA_DIR", "/data") @@ -406,6 +407,25 @@ def delete_company(slug: str, restore_decks: bool = False): return {"ok": True, "removed": removed, "restored_decks": restored} +def _progress_analysis(slug: str) -> dict: + """Live progress analysis over the ledger (never stale, no file needed).""" + led = ledger_mod.Ledger(LEDGER_DIR) + company = led.get_company(slug) + if company is None: + raise HTTPException(404, "no such company") + return progress_mod.analyze(company, led.deck_records(slug) or []) + + +@app.get("/api/companies/{slug}/progress") +def company_progress(slug: str): + return _progress_analysis(os.path.basename(slug)) + + +@app.get("/api/companies/{slug}/progress.md", response_class=PlainTextResponse) +def company_progress_md(slug: str): + return progress_mod.render_progress_md(_progress_analysis(os.path.basename(slug))) + + @app.get("/api/companies/{slug}/scorecard", response_class=PlainTextResponse) def company_scorecard(slug: str): slug = os.path.basename(slug) diff --git a/orchestrator/jobs.py b/orchestrator/jobs.py index 02a93be..c614e6c 100644 --- a/orchestrator/jobs.py +++ b/orchestrator/jobs.py @@ -51,6 +51,7 @@ import extraction import graders as gr_mod import ledger as ledger_mod import preflight +import progress as progress_mod import scorecard import scoring import serving @@ -605,14 +606,20 @@ class JobRunner: with open(os.path.join(report_deck_dir, "ADJUDICATION.md"), "w") as f: f.write(adjudication_md + "\n") - # Refresh the company scorecard + the /data/reports latest copy. - sc_md = scorecard.render_scorecard(led.get_company(slug_c), led.deck_records(slug_c)) + # Refresh the company scorecard + progress review + /data/reports copies. + company_now = led.get_company(slug_c) + sc_md = scorecard.render_scorecard(company_now, all_recs) sc_path = os.path.join(LEDGER_DIR, slug_c, "SCORECARD.md") os.makedirs(os.path.dirname(sc_path), exist_ok=True) with open(sc_path, "w") as f: f.write(sc_md) with open(os.path.join(REPORTS_DIR, "latest-scorecard.md"), "w") as f: f.write(sc_md) + prog_md = progress_mod.render_progress_md(progress_mod.analyze(company_now, all_recs)) + with open(os.path.join(LEDGER_DIR, slug_c, "PROGRESS.md"), "w") as f: + f.write(prog_md) + with open(os.path.join(REPORTS_DIR, "latest-progress.md"), "w") as f: + f.write(prog_md) self.log(f"[runner] reports saved to {report_deck_dir}") return {"period": period, "deck_id": deck_id, "composite": _composite(record), diff --git a/orchestrator/progress.py b/orchestrator/progress.py new file mode 100644 index 0000000..82f6d8f --- /dev/null +++ b/orchestrator/progress.py @@ -0,0 +1,514 @@ +"""Longitudinal progress review + deck-quality guidance — pure, stdlib only. + +Where scorecard.py answers "how did the latest deck score", this module answers +two portfolio-owner questions across the whole ledger: + + 1. Is the company actually progressing? Composite / BDEF-category / KPI + trajectories across every graded deck, recurring vs resolved red flags. + 2. Is the material good enough to judge them by? A deterministic gap engine + inspects the latest record for what the deck is NOT showing (no + profitability visibility, untargeted KPIs, no forward guidance, thin + evidence per BDEF category, silently dropped KPIs, no board asks) and + turns each gap into a concrete request for the next deck. + +Everything numeric is derived from scoring records the deterministic pipeline +already produced — no model calls, so the dashboard can compute this live. +`analyze(company, records)` returns the JSON shape; `render_progress_md` +renders it as PROGRESS.md. +""" +from __future__ import annotations + +import decks as decks_mod + +_CATEGORIES = "ABCDEFGH" +_CATEGORY_TITLES = { + "A": "Incentive Alignment & Skin in the Game", + "B": "Inversion Discipline & Margin of Safety", + "C": "Circle of Competence & Rational Learning", + "D": "Capital Allocation Quality", + "E": "Moat Durability & Competitive Reality", + "F": "Psychological & Cultural Health", + "G": "Simplicity, Clarity & Decision Velocity", + "H": "Board Value-Add & Governance Quality", +} + +# What a deck that scores well on each BDEF category actually shows (bdef.md's +# "5" anchors, phrased as material to request). Used when a category's evidence +# is thin: the graders had nothing to quote, so the deck isn't showing this. +_CATEGORY_MATERIAL = { + "A": "comp structure and promotion criteria, management/founder ownership and " + "option-pool detail, and which metrics bonuses actually pay on", + "B": "a downside scenario for the plan (what breaks the thesis), cash " + "buffer/runway under that scenario, and pre-committed 'we won't do X' " + "boundaries", + "C": "explicit 'we know / we don't know' boundaries, postmortems on this " + "period's misses, and the criteria used before entering adjacencies", + "D": "expected return on each major use of cash compared against the " + "alternatives — including doing nothing or returning capital", + "E": "specific, testable moat metrics (retention/churn, switching costs, " + "pricing power, win rates) plus named competitive threats and the " + "response to each", + "F": "what went wrong this period surfaced by management first, regrettable " + "attrition, and culture signals beyond an engagement score", + "G": "a one-page summary of the 2–3 decisions that matter, with KPI " + "definitions and format kept identical to the prior deck", + "H": "explicit asks for the board, each with a recommendation and what " + "would make that recommendation wrong", +} + +# Latest-deck evidence_quality below this = the panel scored the category on +# thin air (scores regress to 3 anyway) — the deck isn't showing the material. +THIN_EVIDENCE = 0.35 +# |net composite change| within this band counts as flat. +FLAT_BAND = 1.0 +# Category adjusted-score moves within this band count as flat. +CAT_BAND = 0.3 + + +def _f(x) -> float | None: + return float(x) if isinstance(x, (int, float)) and not isinstance(x, bool) else None + + +def _cats(rec: dict) -> dict: + return ((rec.get("qual") or {}).get("categories")) or {} + + +# ---------------------------------------------------------------- trajectory +def _trend(records: list[dict]) -> dict: + series = [] + prev_comp = None + for r in records: + comp = _f(r.get("composite")) + entry = { + "period": r.get("period"), + "composite": comp, + "quant": _f((r.get("quant") or {}).get("score")), + "qual": _f((r.get("qual") or {}).get("score")), + "penalties": _f((r.get("penalties") or {}).get("total")), + "delta": (round(comp - prev_comp, 1) + if comp is not None and prev_comp is not None else None), + } + series.append(entry) + prev_comp = comp if comp is not None else prev_comp + scored = [s for s in series if s["composite"] is not None] + out = {"series": series, "direction": "insufficient", "net_change": None, + "best": None, "worst": None} + if not scored: + return out + out["best"] = max(scored, key=lambda s: s["composite"]) + out["worst"] = min(scored, key=lambda s: s["composite"]) + if len(scored) < 2: + return out + net = round(scored[-1]["composite"] - scored[0]["composite"], 1) + out["net_change"] = net + deltas = [s["delta"] for s in scored[1:] if s["delta"] is not None] + if abs(net) <= FLAT_BAND: + out["direction"] = "flat" + elif net > 0: + out["direction"] = "improving" if all(d >= -FLAT_BAND for d in deltas) else "mixed" + else: + out["direction"] = "declining" if all(d <= FLAT_BAND for d in deltas) else "mixed" + return out + + +def _category_trends(records: list[dict]) -> dict: + out: dict[str, dict] = {} + for cid in _CATEGORIES: + series = [] + for r in records: + c = _cats(r).get(cid) or {} + series.append({"period": r.get("period"), + "adjusted": _f(c.get("adjusted")), + "evidence_quality": _f(c.get("evidence_quality"))}) + scored = [s for s in series if s["adjusted"] is not None] + if not scored: + continue + latest = scored[-1] + net = (round(latest["adjusted"] - scored[0]["adjusted"], 2) + if len(scored) >= 2 else None) + direction = ("flat" if net is None or abs(net) <= CAT_BAND + else "improving" if net > 0 else "declining") + evid = [s["evidence_quality"] for s in scored if s["evidence_quality"] is not None] + out[cid] = { + "title": _CATEGORY_TITLES[cid], + "series": series, + "latest": latest["adjusted"], + "net_change": net, + "direction": direction if len(scored) >= 2 else "insufficient", + "latest_evidence": latest["evidence_quality"], + "avg_evidence": round(sum(evid) / len(evid), 4) if evid else None, + } + return out + + +def _kpi_trends(records: list[dict]) -> dict: + """Per canonical KPI across records: credit series + a trajectory status.""" + out: dict[str, dict] = {} + order: list[str] = [] + for r in records: + for k in r.get("kpi_results") or []: + cn = (k.get("canonical_name") or k.get("name") or "").strip() + if not cn: + continue + s = out.get(cn) + if s is None: + s = out[cn] = {"name": k.get("name") or cn, "profitability": False, + "series": [], "attempts": 0, "hits": 0, + "last_credit": None, "status": "untargeted", + "last_period": None} + order.append(cn) + if k.get("name"): + s["name"] = k["name"] + s["profitability"] = bool(k.get("profitability", s["profitability"])) + credit = _f(k.get("credit")) + s["series"].append({"period": r.get("period"), "credit": credit, + "target_source": k.get("target_source")}) + s["last_period"] = r.get("period") + if credit is not None: + s["attempts"] += 1 + s["last_credit"] = credit + if credit >= 0.999: + s["hits"] += 1 + latest_period = records[-1].get("period") if records else None + for s in out.values(): + credits = [p["credit"] for p in s["series"] if p["credit"] is not None] + if s["last_period"] != latest_period: + s["status"] = "not-reported" # vanished from the latest deck + elif not credits: + s["status"] = "untargeted" # reported, but never had a target + elif credits[-1] >= 0.999: + s["status"] = ("recovered" if len(credits) >= 2 and credits[-2] < 0.999 + else "on-track") + elif len(credits) >= 2 and all(c < 0.999 for c in credits[-2:]): + s["status"] = "missing-repeatedly" + elif len(credits) >= 2 and credits[-2] >= 0.999: + s["status"] = "slipped" + else: + s["status"] = "missed-latest" + return {cn: out[cn] for cn in order} + + +def _flag_history(records: list[dict]) -> dict: + """Recurring / new-in-latest / resolved flags by code across records.""" + seen: dict[str, dict] = {} + for r in records: + for f in (r.get("penalties") or {}).get("flags") or []: + code = f.get("code") or "flag" + s = seen.setdefault(code, {"code": code, "description": f.get("description") or "", + "severity": 0, "periods": []}) + s["severity"] = max(s["severity"], int(f.get("severity") or 0)) + s["description"] = f.get("description") or s["description"] + if r.get("period") not in s["periods"]: + s["periods"].append(r.get("period")) + latest_codes = {f.get("code") for f in + ((records[-1].get("penalties") or {}).get("flags") or [])} if records else set() + prior_codes = set(seen) - latest_codes + recurring = sorted((s for s in seen.values() if len(s["periods"]) >= 2), + key=lambda s: (-len(s["periods"]), -s["severity"])) + new = sorted((s for s in seen.values() + if s["code"] in latest_codes and len(s["periods"]) == 1), + key=lambda s: -s["severity"]) + resolved = sorted((s for s in seen.values() if s["code"] in prior_codes), + key=lambda s: -s["severity"]) + return {"recurring": recurring, "new_in_latest": new, "resolved": resolved} + + +# ---------------------------------------------------------------- gap engine +def _gaps(company: dict, records: list[dict], categories: dict, + kpis: dict, flags: dict) -> list[dict]: + """Deck-quality findings on the LATEST record: what the material isn't + showing, each with a concrete request for the next deck.""" + if not records: + return [] + latest = records[-1] + out: list[dict] = [] + + def gap(code, severity, title, detail, ask): + out.append({"code": code, "severity": severity, "title": title, + "detail": detail, "ask": ask}) + + q = latest.get("quant") or {} + if (q.get("profitability") or {}).get("na"): + gap("no_profitability_kpis", 3, "No profitability visibility", + "The latest deck reports no profit, margin, or cash KPI the pipeline " + "could score — the heaviest bucket (30/60 quant points) sat empty.", + "Report gross margin, EBITDA (or net burn) and cash runway as " + "first-class KPIs every period, each with a stated target.") + + untargeted = [s["name"] for s in kpis.values() + if s["status"] == "untargeted" and s["last_period"] == latest.get("period")] + if untargeted: + gap("untargeted_kpis", 2, + f"KPIs reported without targets ({len(untargeted)})", + "These KPIs appear in the deck but carry no target from any source " + f"(pinned, prior guidance, or in-deck): {', '.join(sorted(untargeted))}. " + "Untargeted KPIs earn no quant credit and can't be held to account.", + "State a target next to every KPI — or set pinned targets in " + "Configure Companies for the ones the board owns.") + + # Forward guidance: did the latest deck leave targets for any FUTURE period? + latest_key = decks_mod.period_sort_key(latest.get("period")) + extracted = company.get("extracted_targets") or {} + has_forward = any(decks_mod.period_sort_key(p) > latest_key for p in extracted) + if not has_forward: + gap("no_forward_guidance", 3, "No forward guidance for next period", + "No forward targets for a future period were extracted from the " + "latest deck, so the next deck's forecast-integrity bucket will be " + "unscorable and management can't be graded against its own plan.", + "Include an explicit next-period target for each headline KPI " + "(the number, the period, and the direction).") + + if len(records) >= 2 and (q.get("forecast_integrity") or {}).get("na"): + gap("forecast_chain_broken", 2, "Forecast chain broken", + "This deck's actuals could not be matched against the prior deck's " + "targets — either no prior guidance existed or the KPI names changed " + "between periods.", + "Keep KPI names identical period to period (or record aliases in " + "Configure Companies so renamed KPIs still chain).") + + dropped = [s["name"] for s in kpis.values() if s["status"] == "not-reported"] + if dropped: + gap("dropped_kpis", 3, + f"Previously reported KPIs vanished ({len(dropped)})", + f"No longer reported as of {latest.get('period')}: " + f"{', '.join(sorted(dropped))}. Silently dropped KPIs are a classic " + "way bad news leaves a deck.", + "Restore these KPIs — or retire them explicitly with one line on why.") + + thin = [(cid, c) for cid, c in categories.items() + if c.get("latest") is not None + and (c.get("latest_evidence") or 0.0) < THIN_EVIDENCE] + for cid, c in sorted(thin): + gap(f"thin_evidence_{cid}", 2, + f"{cid}. {c['title']} — scored on thin evidence", + f"The panel found almost nothing to quote for this category " + f"(evidence quality {c.get('latest_evidence') or 0.0:.0%}; scores " + "regress to neutral without support). The deck isn't showing this " + "dimension at all.", + f"Add material on {_CATEGORY_MATERIAL[cid]}.") + + asks = ((latest.get("narrative") or {}).get("asks")) or [] + if not asks: + gap("no_board_asks", 2, "No asks for the board", + "The deck asks the board for nothing — the board is positioned to " + "preside rather than govern (BDEF category H).", + "End the deck with the 2–3 decisions that matter, each with a " + "recommendation and what would make it wrong.") + + # Recurring flags only demand action while still open in the latest deck — + # a flag that recurred historically but is clear now shows under "resolved". + latest_codes = {f.get("code") for f in + (latest.get("penalties") or {}).get("flags") or []} + for f in flags.get("recurring") or []: + if f["code"] in latest_codes: + gap(f"recurring_{f['code']}", min(3, f["severity"]), + f"Recurring flag: {f['code']}", + f"Raised in {len(f['periods'])} decks ({', '.join(str(p) for p in f['periods'])}) " + f"and still open: {f['description']}", + f"Address `{f['code']}` head-on in the next deck — acknowledge it " + "and show the fix, so the penalty stops repeating.") + + out.sort(key=lambda g: (-g["severity"], g["code"])) + return out + + +# ---------------------------------------------------------------- entry point +def analyze(company: dict, records: list[dict]) -> dict: + """Full progress analysis for one company over its period-sorted records.""" + company = company or {} + records = records or [] + categories = _category_trends(records) + kpis = _kpi_trends(records) + flags = _flag_history(records) + return { + "schema_version": 1, + "slug": company.get("slug"), + "name": company.get("name") or company.get("slug"), + "deck_count": len(records), + "trend": _trend(records), + "categories": categories, + "kpis": kpis, + "flags": flags, + "gaps": _gaps(company, records, categories, kpis, flags), + } + + +# ---------------------------------------------------------------- markdown +def _fmt(x, digits: int = 1) -> str: + return "—" if x is None else (f"{x:.{digits}f}" if isinstance(x, float) else str(x)) + + +def _signed(x, digits: int = 1) -> str: + if x is None: + return "—" + return f"{'+' if x > 0 else ''}{x:.{digits}f}" + + +_STATUS_LABEL = { + "on-track": "on track", "recovered": "recovered", "slipped": "slipped", + "missed-latest": "missed latest", "missing-repeatedly": "missing repeatedly", + "untargeted": "no target", "not-reported": "NOT REPORTED", +} + + +def render_progress_md(analysis: dict) -> str: + """PROGRESS.md — the analyze() result as a readable review.""" + a = analysis + lines = [f"# Progress review — {a.get('name') or '?'}", ""] + n = a.get("deck_count") or 0 + if not n: + lines.append("No graded decks yet.") + return "\n".join(lines) + "\n" + + t = a.get("trend") or {} + series = t.get("series") or [] + scored = [s for s in series if s.get("composite") is not None] + + # --- verdict + lines.append("## Verdict") + lines.append("") + if len(scored) >= 2: + first, last = scored[0], scored[-1] + lines.append( + f"Trajectory over {n} graded decks: **{t.get('direction')}** — composite " + f"{_fmt(first['composite'])} ({first.get('period')}) → " + f"{_fmt(last['composite'])} ({last.get('period')}), " + f"net {_signed(t.get('net_change'))}. " + f"Best {_fmt((t.get('best') or {}).get('composite'))} " + f"({(t.get('best') or {}).get('period')}); " + f"worst {_fmt((t.get('worst') or {}).get('composite'))} " + f"({(t.get('worst') or {}).get('period')}).") + else: + lines.append(f"Only one graded deck ({scored[0].get('period') if scored else '?'}) — " + "trajectory starts with the next deck.") + gaps = a.get("gaps") or [] + if gaps: + lines.append("") + lines.append(f"Deck quality: **{len(gaps)} gap(s)** in what the material shows " + "— see “What the materials aren't showing” below.") + lines.append("") + + # --- composite table + lines.append("## Composite progress") + lines.append("") + lines.append("| Period | Composite | Δ | Quant | Qual | Penalties |") + lines.append("|---|---|---|---|---|---|") + for s in series: + lines.append(f"| {s.get('period') or '?'} | {_fmt(s.get('composite'))} " + f"| {_signed(s.get('delta'))} | {_fmt(s.get('quant'))} " + f"| {_fmt(s.get('qual'))} | −{_fmt(s.get('penalties'))} |") + lines.append("") + + # --- what's moving + cats = a.get("categories") or {} + kpis = a.get("kpis") or {} + up = [(cid, c) for cid, c in cats.items() if c.get("direction") == "improving"] + down = [(cid, c) for cid, c in cats.items() if c.get("direction") == "declining"] + kpi_good = [s for s in kpis.values() if s["status"] in ("on-track", "recovered")] + kpi_bad = [s for s in kpis.values() + if s["status"] in ("slipped", "missing-repeatedly", "missed-latest", + "not-reported")] + lines.append("## What's moving") + lines.append("") + lines.append("### Improving") + lines.append("") + for cid, c in sorted(up, key=lambda x: -(x[1].get("net_change") or 0)): + lines.append(f"- **{cid}. {c['title']}** — {_fmt(c.get('latest'), 2)} " + f"({_signed(c.get('net_change'), 2)} since first deck)") + for s in sorted(kpi_good, key=lambda s: s["name"].lower()): + label = _STATUS_LABEL[s["status"]] + lines.append(f"- KPI **{s['name']}** — {label} " + f"({s['hits']}/{s['attempts']} targets hit)") + if not up and not kpi_good: + lines.append("- Nothing improving yet.") + lines.append("") + lines.append("### Declining / watch") + lines.append("") + for cid, c in sorted(down, key=lambda x: (x[1].get("net_change") or 0)): + lines.append(f"- **{cid}. {c['title']}** — {_fmt(c.get('latest'), 2)} " + f"({_signed(c.get('net_change'), 2)} since first deck)") + for s in sorted(kpi_bad, key=lambda s: s["name"].lower()): + lines.append(f"- KPI **{s['name']}** — {_STATUS_LABEL[s['status']]}" + + (f" (last credit {_fmt(s['last_credit'], 2)})" + if s["last_credit"] is not None else "")) + if not down and not kpi_bad: + lines.append("- Nothing declining.") + lines.append("") + + # --- KPI trajectory + if kpis: + lines.append("## KPI trajectory") + lines.append("") + lines.append("| KPI | Profit | Trend (oldest → latest) | Hits | Status |") + lines.append("|---|---|---|---|---|") + ordered = sorted(kpis.values(), + key=lambda s: (not s["profitability"], s["name"].lower())) + for s in ordered: + marks = [] + for p in s["series"]: + c = p.get("credit") + marks.append("·" if c is None else "✓" if c >= 0.999 else "✗") + lines.append(f"| {s['name']} | {'yes' if s['profitability'] else ''} " + f"| {' '.join(marks)} | {s['hits']}/{s['attempts']} " + f"| {_STATUS_LABEL[s['status']]} |") + lines.append("") + lines.append("✓ target hit · ✗ missed · `·` no target that period") + lines.append("") + + # --- flags + fl = a.get("flags") or {} + lines.append("## Flag history") + lines.append("") + rec = fl.get("recurring") or [] + if rec: + lines.append("| Recurring flag | Severity | Seen in |") + lines.append("|---|---|---|") + for f in rec: + lines.append(f"| `{f['code']}` | {f['severity']} " + f"| {', '.join(str(p) for p in f['periods'])} |") + lines.append("") + new = fl.get("new_in_latest") or [] + if new: + lines.append("New in the latest deck: " + + ", ".join(f"`{f['code']}`" for f in new)) + lines.append("") + res = fl.get("resolved") or [] + if res: + lines.append("Resolved (raised earlier, clear in the latest deck): " + + ", ".join(f"`{f['code']}`" for f in res)) + lines.append("") + if not rec and not new and not res: + lines.append("No red flags raised on any graded deck.") + lines.append("") + + # --- deck quality gaps + lines.append("## What the materials aren't showing") + lines.append("") + if gaps: + for g in gaps: + lines.append(f"### {g['title']}") + lines.append("") + lines.append(g["detail"]) + lines.append("") + else: + lines.append("No material gaps detected — the deck gives the pipeline " + "everything it needs to grade this company.") + lines.append("") + + # --- the ask list + if gaps: + lines.append("## Requests for the next deck") + lines.append("") + seen: set[str] = set() + i = 0 + for g in gaps: + ask = g["ask"].strip() + if ask.lower() in seen: + continue + seen.add(ask.lower()) + i += 1 + lines.append(f"{i}. {ask}") + lines.append("") + lines.append("*Paste this list into the board follow-up — each item maps " + "to a gap above.*") + return "\n".join(lines).rstrip() + "\n" diff --git a/orchestrator/templates/index.html b/orchestrator/templates/index.html index 2c8c9ee..2764ad9 100644 --- a/orchestrator/templates/index.html +++ b/orchestrator/templates/index.html @@ -149,6 +149,7 @@
${esc('error: '+t)}`,
+ url, `${currentSlug}-PROGRESS.md`);
+ progressOpen=true; btn.textContent='Hide progress review';
+ }catch(e){ alert(e); }
+}
function setViewer(title,html,dlHref,dlName){
- scorecardOpen=false;
- document.getElementById('scBtn').textContent='View SCORECARD.md';
+ resetViewerToggles();
const dl=dlHref?`⬇ download`:'';
document.getElementById('coViewer').innerHTML=
diff --git a/orchestrator/tests/test_progress.py b/orchestrator/tests/test_progress.py
new file mode 100644
index 0000000..20cd498
--- /dev/null
+++ b/orchestrator/tests/test_progress.py
@@ -0,0 +1,192 @@
+"""Tests for progress.py: trajectory, KPI/category trends, and the gap engine."""
+import os
+import sys
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import progress
+
+
+def _cat(adjusted, evidence=0.8):
+ return {"adjusted": adjusted, "evidence_quality": evidence}
+
+
+def _kpi(canonical, credit, profitability=False, source="pinned"):
+ return {"canonical_name": canonical, "name": canonical, "credit": credit,
+ "profitability": profitability,
+ "target_source": None if credit is None else source}
+
+
+def _record(period, composite, cats=None, kpis=None, flags=None,
+ prof_na=False, forecast_na=False, asks=None):
+ return {
+ "period": period, "composite": composite, "deck_id": period.lower(),
+ "quant": {"score": composite * 0.6,
+ "profitability": {"na": prof_na},
+ "other": {"na": False},
+ "forecast_integrity": {"na": forecast_na}},
+ "qual": {"score": composite * 0.4, "categories": cats or {}},
+ "penalties": {"total": 0.0, "flags": flags or []},
+ "kpi_results": kpis or [],
+ "narrative": {"asks": asks if asks is not None else ["approve budget"]},
+ }
+
+
+def _company(extracted_targets=None):
+ return {"slug": "acme", "name": "Acme", "pinned_targets": [],
+ "extracted_targets": extracted_targets or {}}
+
+
+class TestTrend(unittest.TestCase):
+ def test_improving(self):
+ recs = [_record("2025-Q4", 40.0), _record("2026-Q1", 42.0),
+ _record("2026-Q2", 45.0)]
+ a = progress.analyze(_company(), recs)
+ self.assertEqual(a["trend"]["direction"], "improving")
+ self.assertAlmostEqual(a["trend"]["net_change"], 5.0)
+ self.assertEqual(a["trend"]["best"]["period"], "2026-Q2")
+ self.assertEqual(a["trend"]["series"][1]["delta"], 2.0)
+
+ def test_flat_and_declining(self):
+ flat = progress.analyze(_company(), [_record("2026-Q1", 50.0),
+ _record("2026-Q2", 50.5)])
+ self.assertEqual(flat["trend"]["direction"], "flat")
+ down = progress.analyze(_company(), [_record("2026-Q1", 50.0),
+ _record("2026-Q2", 44.0)])
+ self.assertEqual(down["trend"]["direction"], "declining")
+
+ def test_single_deck_insufficient(self):
+ a = progress.analyze(_company(), [_record("2026-Q1", 50.0)])
+ self.assertEqual(a["trend"]["direction"], "insufficient")
+
+
+class TestCategoryAndKpiTrends(unittest.TestCase):
+ def test_category_direction(self):
+ recs = [_record("2026-Q1", 50, cats={"A": _cat(2.5), "B": _cat(4.0)}),
+ _record("2026-Q2", 51, cats={"A": _cat(3.5), "B": _cat(3.0)})]
+ a = progress.analyze(_company(), recs)
+ self.assertEqual(a["categories"]["A"]["direction"], "improving")
+ self.assertEqual(a["categories"]["B"]["direction"], "declining")
+ self.assertAlmostEqual(a["categories"]["A"]["net_change"], 1.0)
+
+ def test_kpi_statuses(self):
+ recs = [
+ _record("2026-Q1", 50, kpis=[_kpi("arr", 1.0), _kpi("churn", 1.0),
+ _kpi("nps", None)]),
+ _record("2026-Q2", 51, kpis=[_kpi("arr", 0.4), _kpi("nps", None)]),
+ ]
+ a = progress.analyze(_company(), recs)
+ self.assertEqual(a["kpis"]["arr"]["status"], "slipped")
+ self.assertEqual(a["kpis"]["churn"]["status"], "not-reported")
+ self.assertEqual(a["kpis"]["nps"]["status"], "untargeted")
+
+ def test_kpi_recovered(self):
+ recs = [_record("2026-Q1", 50, kpis=[_kpi("arr", 0.2)]),
+ _record("2026-Q2", 51, kpis=[_kpi("arr", 1.0)])]
+ a = progress.analyze(_company(), recs)
+ self.assertEqual(a["kpis"]["arr"]["status"], "recovered")
+
+
+class TestFlagHistory(unittest.TestCase):
+ def test_recurring_new_resolved(self):
+ f = lambda code: {"code": code, "description": code, "severity": 2,
+ "points": 2.0, "sources": ["extractor"]}
+ recs = [_record("2026-Q1", 50, flags=[f("adjusted_metrics"), f("related_party")]),
+ _record("2026-Q2", 51, flags=[f("adjusted_metrics"), f("governance_gap")])]
+ a = progress.analyze(_company(), recs)
+ self.assertEqual([x["code"] for x in a["flags"]["recurring"]], ["adjusted_metrics"])
+ self.assertEqual([x["code"] for x in a["flags"]["new_in_latest"]], ["governance_gap"])
+ self.assertEqual([x["code"] for x in a["flags"]["resolved"]], ["related_party"])
+
+
+class TestGapEngine(unittest.TestCase):
+ def codes(self, a):
+ return {g["code"] for g in a["gaps"]}
+
+ def test_no_profitability_gap(self):
+ a = progress.analyze(_company(), [_record("2026-Q2", 50, prof_na=True)])
+ self.assertIn("no_profitability_kpis", self.codes(a))
+
+ def test_untargeted_kpis_gap(self):
+ a = progress.analyze(_company(), [_record("2026-Q2", 50,
+ kpis=[_kpi("nps", None)])])
+ self.assertIn("untargeted_kpis", self.codes(a))
+
+ def test_forward_guidance(self):
+ recs = [_record("2026-Q2", 50)]
+ bare = progress.analyze(_company(), recs)
+ self.assertIn("no_forward_guidance", self.codes(bare))
+ guided = progress.analyze(
+ _company(extracted_targets={"2026-Q3": {"from_deck": "2026-Q2",
+ "targets": [{}]}}), recs)
+ self.assertNotIn("no_forward_guidance", self.codes(guided))
+
+ def test_forecast_chain_broken_needs_history(self):
+ one = progress.analyze(_company(), [_record("2026-Q2", 50, forecast_na=True)])
+ self.assertNotIn("forecast_chain_broken", self.codes(one))
+ two = progress.analyze(_company(), [_record("2026-Q1", 50),
+ _record("2026-Q2", 50, forecast_na=True)])
+ self.assertIn("forecast_chain_broken", self.codes(two))
+
+ def test_dropped_kpis_gap(self):
+ recs = [_record("2026-Q1", 50, kpis=[_kpi("churn", 1.0)]),
+ _record("2026-Q2", 51, kpis=[])]
+ a = progress.analyze(_company(), recs)
+ self.assertIn("dropped_kpis", self.codes(a))
+
+ def test_thin_evidence_gap_names_material(self):
+ a = progress.analyze(_company(),
+ [_record("2026-Q2", 50, cats={"E": _cat(3.0, 0.05)})])
+ gap = next(g for g in a["gaps"] if g["code"] == "thin_evidence_E")
+ self.assertIn("retention/churn", gap["ask"])
+
+ def test_no_board_asks_gap(self):
+ a = progress.analyze(_company(), [_record("2026-Q2", 50, asks=[])])
+ self.assertIn("no_board_asks", self.codes(a))
+ b = progress.analyze(_company(), [_record("2026-Q2", 50)])
+ self.assertNotIn("no_board_asks", self.codes(b))
+
+ def test_recurring_flag_gap(self):
+ f = {"code": "cash_runway_silence", "description": "no runway shown",
+ "severity": 3, "points": 3.0, "sources": ["extractor"]}
+ recs = [_record("2026-Q1", 50, flags=[f]), _record("2026-Q2", 51, flags=[f])]
+ a = progress.analyze(_company(), recs)
+ self.assertIn("recurring_cash_runway_silence", self.codes(a))
+
+ def test_recurring_flag_resolved_in_latest_no_gap(self):
+ f = {"code": "cash_runway_silence", "description": "no runway shown",
+ "severity": 3, "points": 3.0, "sources": ["extractor"]}
+ recs = [_record("2026-Q1", 50, flags=[f]), _record("2026-Q2", 51, flags=[f]),
+ _record("2026-Q3", 52, flags=[])]
+ a = progress.analyze(_company(), recs)
+ self.assertNotIn("recurring_cash_runway_silence", self.codes(a))
+ self.assertIn("cash_runway_silence",
+ [x["code"] for x in a["flags"]["resolved"]])
+
+
+class TestRender(unittest.TestCase):
+ def test_render_full(self):
+ recs = [
+ _record("2026-Q1", 42.0, cats={"A": _cat(2.5), "E": _cat(3.0, 0.1)},
+ kpis=[_kpi("arr", 1.0, profitability=True), _kpi("churn", 0.5)]),
+ _record("2026-Q2", 45.0, cats={"A": _cat(3.4), "E": _cat(3.0, 0.1)},
+ kpis=[_kpi("arr", 1.0, profitability=True), _kpi("churn", 0.3)]),
+ ]
+ md = progress.render_progress_md(progress.analyze(_company(), recs))
+ for section in ("## Verdict", "## Composite progress", "## What's moving",
+ "## KPI trajectory", "## Flag history",
+ "## What the materials aren't showing",
+ "## Requests for the next deck"):
+ self.assertIn(section, md)
+ self.assertIn("improving", md)
+ self.assertIn("✓ ✓", md) # arr hit both periods
+ self.assertIn("missing repeatedly", md) # churn missed twice
+
+ def test_render_empty(self):
+ md = progress.render_progress_md(progress.analyze(_company(), []))
+ self.assertIn("No graded decks yet.", md)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/startos/versions/index.ts b/startos/versions/index.ts
index ce85400..f83a206 100644
--- a/startos/versions/index.ts
+++ b/startos/versions/index.ts
@@ -8,9 +8,10 @@ import { v_0_1_5 } from './v_0_1_5'
import { v_0_1_6 } from './v_0_1_6'
import { v_0_1_7 } from './v_0_1_7'
import { v_0_1_8 } from './v_0_1_8'
+import { v_0_1_9 } from './v_0_1_9'
/** The current version MUST be the first argument (`current`). */
export const versions = VersionGraph.of({
- current: v_0_1_8,
- other: [v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
+ current: v_0_1_9,
+ other: [v_0_1_8, v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
})
diff --git a/startos/versions/v_0_1_9.ts b/startos/versions/v_0_1_9.ts
new file mode 100644
index 0000000..50a734b
--- /dev/null
+++ b/startos/versions/v_0_1_9.ts
@@ -0,0 +1,20 @@
+import { VersionInfo } from '@start9labs/start-sdk'
+
+/** Progress reviews + deck-quality guidance. ExVer form `