
Phronesis
- 4 installs
- 3 repo stars
- Updated August 5, 2026
- broomva/skills
phronesis is a Claude Code skill that runs AI and digital consulting engagements through five gated stages using consulting frameworks as typed primitives.
About
phronesis is a Claude Code skill that runs AI and digital consulting engagements as runnable typed primitives. It moves through five stages (intake, maturity scan, use-case ideation, prioritization, roadmap synthesis), each with a human-review gate. Developers use it to build maturity assessments, prioritize use cases, model ROI, and design pilots. An L1-L5 linter blocks deliverables that skip a strategic thesis, diverse ideation, data readiness, adoption metrics, or a baseline.
- Runs AI/digital consulting engagements through 5 gated stages
- Encodes top-firm frameworks (Three Horizons, MIT CISR, JTBD, RICE/ICE/WSJF) as typed primitives
- Produces 7 Discovery-grade deliverables with an L1-L5 blocking linter
Phronesis by the numbers
- 4 all-time installs (skills.sh)
- Ranked #2,331 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
phronesis capabilities & compatibility
- Capabilities
- planning · research · project management
- Use cases
- research · planning · project management
- Pricing
- Free
What phronesis says it does
Runs AI/digital consulting engagements through 5 stages:
Each stage has a non-negotiable human-review gate.
phronesis produces *plans* and *recommendations*, not running systems
npx skills add https://github.com/broomva/skills --skill phronesisAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 3 |
| Last updated | August 5, 2026 |
| Repository | broomva/skills ↗ |
What it does
Run an AI/digital advisory engagement that produces maturity scans, prioritized use cases, ROI models, and an innovation roadmap.
Who is it for?
Running AI/digital advisory engagements, maturity assessments, use-case prioritization, and innovation roadmaps
Skip if: Implementation work or running systems; phronesis produces plans and recommendations, not deployed software
When should I use this skill?
Running an AI consulting engagement, building a maturity assessment, prioritizing use cases, or modeling ROI for AI initiatives
What you get
- maturity scan and capability heatmap
- prioritized use-case dossiers
- innovation roadmap
By the numbers
- 7 Discovery-grade deliverables
- 5 engagement stages each with a review gate
- L1-L5 linter enforcing 5 failure-mode rules
Files
phronesis — AI-native Advisory Practice
Status: Phase 1 complete (v0.1.2-pre) — substrate + 15 active frameworks + 5 stages + 7 deliverables + L1–L5 linter + M7 extraction pipeline. Phase 2 engagement-driven (M4 stage CLI + M5 autonomous-mode stub-to-real path).Track progress: `docs/methodology.md` (canonical reference),docs/adr/0001-0008, and the design spec at~/broomva/docs/superpowers/specs/2026-05-06-phronesis-design.md.
What it does
Runs AI/digital consulting engagements through 5 stages:
1. Intake — stakeholder map, interview guide, strategic thesis (kills "hagamos algo de IA" — Bision empirical 100% failure) 2. Maturity Scan — MIT CISR + Gartner AI dimensions, gap analysis, capability heatmap 3. Use-Case Ideation — JTBD + VPC, with IdeationSource diversity check 4. Prioritization — RICE/ICE/WSJF, ROI Model with sensitivity, Impact-Effort Matrix 5. Roadmap Synthesis — Three Horizons, QuantumBlack ML lifecycle pilots, baselines captured
Each stage has a non-negotiable human-review gate.
When to invoke this skill
- User asks for AI/digital consulting work — maturity assessment, roadmap, use-case prioritization
- User mentions Spanish-language equivalents: "consultoría", "evaluación de madurez", "hoja de ruta"
- User describes a Fast Discovery, discovery sprint, or strategic-thesis-finding engagement
- User wants to extract reusable IP from a completed engagement (cross-engagement learning)
When NOT to invoke this skill
- User wants generic "do AI for me" without scope — phronesis explicitly refuses to start without a strategic thesis
- User wants implementation work, not advisory — phronesis produces plans and recommendations, not running systems
- User wants change-management or organizational design — that's D-scope (deferred to a later phase)
- User wants only personal-decision support — use strategy-skills instead
Commands (CLI mode — most land in M4)
phronesis init <tenant> --industry <industry> --region <iso> --sponsor "Name (Role)" --scope "..."
phronesis intake --interview "..." --transcript path/to/file.md
phronesis intake --thesis "..." # captures StrategicThesis (L1)
phronesis review intake --approve | --revise
phronesis scan --frameworks mit-cisr-digital,gartner-ai
phronesis ideate --frameworks jobs-to-be-done,value-prop-canvas
phronesis prioritize --frameworks rice --roi unit-economics,real-options
phronesis roadmap --frameworks three-horizons --pilot-design quantumblack-ml
phronesis render --all
phronesis lint # P3, P7, P8, L1-L5 enforcement (blocking)
phronesis status
phronesis bookkeep <slug> # extract anonymized learnings to research/entities/Five C-level failure modes structurally prevented
| Bision-observed | Frequency | Linter rule | Type primitive |
|---|---|---|---|
| Sin tesis estratégica | 100% | L1 STRATEGIC_THESIS_REQUIRED | StrategicThesis |
| Casos mal priorizados | 87% | L2 DIVERSE_IDEATION_SOURCES | IdeationSource |
| Datos no preparados | 74% | L3 DATA_READINESS_GATE | DataReadinessAssessment |
| Desconexión negocio-tech | 61% | L4 ADOPTION_METRIC_REQUIRED | AdoptionMetric |
| Sin medición de ROI | 48% | L5 BASELINE_REQUIRED | BaselineSection |
Documentation
- Methodology: `docs/methodology.md` — canonical methodology reference (10 sections: stages, deliverables, frameworks, gates, linter, anonymization, modes, discovery loop, IP extraction)
- ADRs: `docs/adr/0001-` through
0008- - Design spec:
~/broomva/docs/superpowers/specs/2026-05-06-phronesis-design.md - Linear: Phronesis project
- Quickstart: (M4 — pending stage CLI commands)
docs/quickstart.md
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.1.3-pre] — 2026-05-20
M8 — canonical methodology reference. Closes BRO-1198 and completes Phronesis Phase 1 (M0–M8). Docs-only release: no substrate behavior changes.
Added — docs/methodology.md
- Ten-section canonical methodology reference (~12,000 words).
- Section 1: Why phronesis exists — the Bision empirical 100% failure mode
(Sin tesis estratégica) and the consulting-IP-as-substrate thesis.
- Section 2: The 5-stage pipeline (intake → maturity → ideate → prioritize →
roadmap) with per-stage gate enforcement (typed primitive → stage runner → linter backstop).
- Section 3: The 7 Phase-1 deliverables, mapped to producing stage and the
L-rules each one carries. Documents render_all vs render_with_gate publication modes.
- Section 4: The 28 frameworks — 7 tables grouped by purpose (maturity,
strategy, ideation, prioritization, ROI, AI-lifecycle, change). Marks the 15 Phase-1 active vs 13 D-scope split. Documents selector algorithm + industry preferences + P7 hard cap at 5.
- Section 5: The 5 review gates — non-negotiable in both CLI and autonomous
modes. Three-layer enforcement (Pydantic / stage / linter). No auto-approve on timeout in autonomous mode.
- Section 6: The Bision failure-mode linter — L1–L5 + P3/P7/P8 rules
documented per-rule; no bypass-flag policy explained.
- Section 7: Anonymization by default — the canary release-gate (42 tokens
× 21 anonymized files = 0 leaks at v0.1.2-pre). What gets stripped, what stays (framework allowlist preserves methodology terms), how loosening works (per-engagement explicit override).
- Section 8: Two operating modes — consultant CLI (default) vs autonomous
Life Runtime endpoint (Phase 3 stub today, ADR-0001). Documented convergence + per-aspect differences in a table.
- Section 9: Engagement-driven discovery loop — two worked examples
(v0.0.2-m0.1 Tropico Renovables, v0.1.1-pre Broomva Silicon) showing the loop closing in single-PR batches.
- Section 10: Cross-engagement IP extraction — M7 pipeline (reflexive on
ENGAGEMENT_CONCLUDED), 6-step end-to-end flow, queue layout, rule-of-three for candidate promotion, CLI re-run commands.
- Appendix A: file map (paths + responsibilities).
- Appendix B: validation gates table (local command + CI workflow).
- Appendix C: phase roadmap (Phase 1 complete, Phase 2 in progress,
Phase 3 deferred).
Modified — SKILL.md
- Status header updated: "Phase 1 (M0 — foundation scaffolding)" →
"Phase 1 complete (v0.1.2-pre)" — substrate + 15 frameworks + 5 stages + 7 deliverables + L1–L5 linter + M7 extraction pipeline. Phase 2 engagement-driven (M4 stage CLI + M5 autonomous-mode stub-to-real path).
- Documentation section: methodology reference moved from "(M8) pending"
to the canonical first entry; docs/methodology.md link now resolves (was a dead link in v0.1.2-pre).
Tests
- 458 unchanged. ruff + mypy strict (core/ tests/) + 458 pytests + framework
lint all green at branching base eecbc13 and at the head of this branch — docs-only change, no substrate impact.
Linear
- BRO-1198 closed via this PR. Phronesis Phase 1 (M0–M8) fully complete.
[0.1.2-pre] — 2026-05-08
OSS-readiness pass. No substrate behavior changes — purely packaging, distribution, contributor-facing infra.
Added — distribution
pyproject.tomlversion = "0.1.1-pre"(was"0.1.0-pre"— now tracks the
most-recent shipped tag); bumped to 0.1.2-pre for this release.
[tool.hatch.build.targets.wheel]includes all top-level packages
(core, runners, stages) plus force-includes data dirs (frameworks/, templates/, scripts/). The Phase-1 wheel now ships end-to-end installable.
[tool.hatch.build.targets.sdist]allowlist for clean source-distribution
contents (no .venv, dist/, engagements/<tenant>/ leakage).
[project.optional-dependencies] build = [build, twine]for release
workflow.
- Verified:
uv buildproduces clean wheel + sdist; `uv pip install
phronesis-0.1.1rc0-py3-none-any.whl` in a fresh Python 3.12 venv works end-to-end (all imports + CLI + 28 frameworks load).
Added — .github/workflows/release.yml
GitHub Release workflow on tag push:
- Re-runs bision-prevention + canary release-gate suites
- Builds sdist + wheel
- Verifies the wheel installs in a fresh venv with 28 frameworks loadable
- Extracts release notes from CHANGELOG section matching the tag
- Creates GitHub Release with sdist + wheel attached
- Marks pre-releases automatically based on
pre/rcin tag name
Added — Contributor-facing OSS files
SECURITY.md— vulnerability disclosure policy (mailto: contact@broomva.tech),
out-of-scope clarifications for the hook-bypass and ungated-render flags.
.github/ISSUE_TEMPLATE/bug_report.md— repro + environment + which L-rule
fired (if any).
.github/ISSUE_TEMPLATE/feature_request.md— engagement-driven framing
preferred; substrate-layer checklist.
.github/PULL_REQUEST_TEMPLATE.md— release-gate impact checklist.
Changed — CI
.github/workflows/ci.yml— addedFORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true
env var. Forwards-compatible with GitHub's June 2026 Node.js 20 → 24 default flip without waiting for setup-uv@v7.
Changed — README
- 6 status badges (CI, License, Python, GitHub release, tests passing, bstack layer)
- Updated status header from "Phase 1 — M0" to "Phase 2 — engagement-driven (
v0.1.1-pre)" - Added CHAOSS to the methodology list (BRO-1033)
- Added
npx skills addinstall path - Added Quickstart section with
init/status/lint/renderflow + Python API example - Documented engagement-driven discovery loop with backreferences to
v0.0.2-m0.1 and v0.1.1-pre as worked examples
Dependencies
- Bumped lower bounds to current stable majors:
pydantic>=2.13.0(was>=2.7.0)click>=8.3.0(was>=8.1.7)rich>=14.0.0(was>=13.7.0)pytest>=9.0.0(was>=8.2.0)pytest-cov>=7.0.0(was>=5.0.0)ruff>=0.15.0(was>=0.5.0)- `mypy>=2.0.0` (was
>=1.10.0) — major version bump; clean upgrade,
zero source changes required (mypy 2.0 strict on 60 files + tests).
Tests
- 399 unchanged. ruff/mypy strict clean across 60 source files.
- All 4 release-gate suites green: bision-prevention (9), canary (4),
CLI smoke (8), framework-lint (28 frameworks).
Linear
- Closes implicit OSS-readiness backlog. Next: PyPI publish workflow
(Trusted Publishing / OIDC) — Phase 2 follow-up.
[0.1.1-pre] — 2026-05-07
Phase 2 — engagement-driven refinements from the Broomva Silicon synthetic engagement (Spec E agent-loop runtime, 2026-05-07). Closes BRO-1031 through BRO-1035; defers BRO-1036 to backlog.
Added — frameworks/maturity/chaoss.yaml (BRO-1033)
CHAOSS Project Health Metrics (Linux Foundation, 2017). Phase-1 maturity framework for AI-BUILDING tenants where MIT CISR + Gartner AI both miss the right axis. Five dimensions: contributor-diversity, release-cadence, code-review-depth, downstream-adoption, governance-maturity. Output: MaturityDimension. Relations: similar_to mit-cisr-digital + gartner-ai; use_with wardley-mapping + real-options + three-horizons.
Added — TenantContext.industry: "tech" (BRO-1031)
AI-infra / runtime / library / chip-design tenants no longer fall back to "other". Unblocks framework_selector + CHAOSS selection for the tech path.
Added — core/selector.py _INDUSTRY_PREFERENCES["tech"] (BRO-1032)
Maturity → CHAOSS, Strategy → Wardley, ROI → real-options, AI-lifecycle → QuantumBlack ML, Prioritization → RICE, Ideation → Value Prop Canvas. The slate the Broomva Silicon engagement actually needed.
Added — BaselineSection.is_greenfield: bool (BRO-1034)
Default False (incumbent metric). When True, baseline_value is interpreted as zero-state declaration rather than measurement. The L5 invariant becomes "declare zero-state explicitly" — protects against the failure where a team SAYS they have a baseline but it's 0 because they didn't measure.
Modified — templates/pilot-plan.md.j2 greenfield rendering (BRO-1035)
Greenfield baselines render as **[GREENFIELD]** zero-state declared instead of the numeric value. Adds a "Greenfield pilot" blockquote when any baseline is greenfield. Footer now reports (N greenfield, M incumbent) accounting. Section header: "Baselines declared before pilot start" (was: "captured") — covers both incumbent + greenfield cases honestly.
Backlog — BRO-1036
lever_kind option-value subtypes (defensive vs offensive vs real-option). Single-engagement signal — defer until N≥5 option-shaped theses observed.
Tests
- 387 → 399 (+12): test_tech_industry, 8 CHAOSS framework tests, 2 selector
tech-preference tests, test_greenfield_baseline_explicit, greenfield template rendering test, d-scope total count bumped 27→28.
- ruff/mypy strict clean across 60 source files.
- All 4 release-gate suites green: bision-prevention (9), canary (4), CLI smoke (8), framework-lint (28 frameworks).
Linear
- BRO-1031, BRO-1032, BRO-1033, BRO-1034, BRO-1035 closed via this PR.
[0.1.0-pre] — 2026-05-07
Phase 1 ship — Discovery-grade engagement runtime.
Phase E (Integration + release gates) of the M1+M2+M3 parallel-worktree plan. Closes M5 partial (CLI), M6 (synthetic fixture), M7 partial (anonymization canary). Builds on Phases A-D (v0.0.3-m0.2 through v0.3.0-m3).
Added — Persistence
EngagementJournal.save_jsonl(path)/load_jsonl(tenant, path)—
newline-delimited JSON. Phase 1 rewrites the whole file on save; Phase 3 will switch to true append-only writes via lago.
Added — tests/fixtures/
tropico_renovables.py—build_tropico_engagement()factory drives
all 5 stage runners and produces an engagement that lints clean (zero L-errors). deliverable_extras() returns the typed-primitive context the render orchestrator needs to produce all 7 deliverables.
Added — core/anonymize.py
AnonymizationPolicyPydantic model with strict-by-default fields:
strip tenant_slug, strip tenant_name, strip personal_names, replace_currency_with_bands, replace_dates_with_relative, replace_locations_with_regions, redact_terms.
anonymize(text, policy, tenant)function. Currency banding (Decimal
→ "low-6-figures USD"), date redaction (ISO/quarter/year → relative tokens), CO/LATAM city → <latam-region>, framework allowlist preserves Three Horizons / Wardley Mapping / etc.
carries_tenant_marker(text, tenant)forensic helper for canary failures.
Added — runners/cli/
runners/cli/__main__.py— Click-based CLI exposing 4 commands:phronesis init <slug>— createsengagements/<slug>/{tenant.yaml,journal.jsonl}phronesis status <slug>— loads journal, prints derived statephronesis lint <slug>— runs L1-L5 linter,--strictfor CI gatephronesis render <slug>— orchestrator render_with_gate (lint-blocked publication)runners/cli/io.py— tenant.yaml + journal.jsonl persistence helpers.- Stage commands (intake/scan/ideate/prioritize/roadmap/review) explicitly
out of Phase 1 scope; documented as M4 work. Phase 1 ships the Python API.
Added — tests/integration/
test_bision_prevention.py— RELEASE GATE. 9 tests:- clean Tropico fixture passes lint cleanly (proof-of-life)
- L1: intake closed without thesis → blocks (Bision Failure 1, 100% obs)
- L2: thin source mix or majority NOVELTY → blocks (Failure 2, 87%)
- L3: blocking data readiness → blocks (Failure 3, 74%)
- L4: PILOT_STARTED without pilot-plan deliverable → blocks (Failure 4, 61%)
- L5: pilot without prior baseline OR retroactive baseline → blocks (Failure 5, 48%)
- Compound failure: L1 + L4 + L5 collected together
test_anonymization_canary.py— RELEASE GATE. 4 tests:- Zero tenant markers leak through anonymize() per deliverable
- Currency amounts bucket to magnitude bands (no $640,000 verbatim)
- Dates redact to relative tokens
- Opt-out sanity (permissive policy preserves markers, proving strict policy works)
test_cli_smoke.py— 8 tests via ClickCliRunnerwith isolated tmp cwd:
init, init-aborts-on-existing, status, lint, version flag, missing-engagement-error.
Added — .githooks/pre-push
- Local release gate. Runs bision-prevention + canary before every push.
- Graceful degradation: skips if
uvnot on PATH (CI handles it). - BLOCK exits 1 with a descriptive message naming the failing test +
the file to investigate.
Modified — Makefile
canary-check,bision-prevention,e2etargets replaced their
skip-with-message stubs with actual pytest invocations against tests/integration/.
Tests
- 337 → 386 (+49): persistence (10), bision-prevention (9), canary (4),
CLI smoke (8), anonymize unit (18).
- ruff/mypy strict clean across 59 source files.
- Pre-push hook passes locally end-to-end.
Linear
- M5 partial (CLI), M6 (synthetic fixture), M7 partial (anonymization)
closed. M5 stage commands + M7 full extraction pipeline → next phase.
[0.3.0-m3] — 2026-05-07
Phase D (M3 — Deliverables + Linter) of the M1+M2+M3 parallel-worktree plan. Closes BRO-1013. Builds on Phase C M2 stages (v0.2.0-m2).
Added — templates/
7 Jinja2 deliverable templates with strict-undefined enforcement:
maturity-report.md.j2(D.1 — worked template)capability-heatmap.md.j2(D.2)use-case-dossier.md.j2(D.3)impact-effort-matrix.md.j2(D.4)roi-model.md.j2(D.5)innovation-roadmap.md.j2(D.6)pilot-plan.md.j2(D.7)
Each footer surfaces the L-rule + P-rule invariants the deliverable carries.
Added — core/render.py
- Jinja2 environment with
StrictUndefined(missing context vars raise
UndefinedError immediately rather than silently producing empty output)
as_currencyfilter: Decimal →$640,000styleas_percentfilter: 0.234 →23.4%render(slug, context)— load + render one deliverable
Added — core/linter.py
5 L-rule release-gate scanners + decorator-based registry:
- L1 STRATEGIC_THESIS_REQUIRED — INTAKE_COMPLETED without thesis (Bision Failure 1, 100% obs)
- L2 DIVERSE_IDEATION_SOURCES — <3 distinct sources OR >50% NOVELTY (Bision Failure 2, 87%)
- L3 DATA_READINESS_GATE — use case with
readiness_band == blocking(Bision Failure 3, 74%) - L4 ADOPTION_METRIC_REQUIRED — PILOT_STARTED but no pilot-plan deliverable rendered (Bision Failure 4, 61%)
- L5 BASELINE_REQUIRED — PILOT_STARTED with zero prior BASELINE_CAPTURED events (Bision Failure 5, 48%)
LintResult.has_errors / has_warnings / errors_for_rule(rule). Linter is the BACKSTOP — stages already enforce gates at boundaries; the linter catches engagements constructed manually without going through the stage runners.
Added — core/orchestrator.py
render_all(engagement, output_dir, *, extra_context, write=True)—
renders all 7 deliverables; returns (paths, lint_result). write=False enables preview mode.
render_with_gate(engagement, output_dir, *, extra_context)—
publication path. Lint runs FIRST. Writes ONLY if zero L-errors. Otherwise returns ({}, lint_result) and output_dir is untouched — no partial state on disk.
build_roi_totals(roi_cells)helper for template totals fields.DELIVERABLE_SLUGScanonical 7-name tuple.
Tests
- 264 → 337 (+73): per-deliverable render tests (4-8 each), linter
positive + negative cases per rule, aggregate runner, orchestrator happy path + lint-gated rejection (broken engagement → zero files written), build_roi_totals.
- ruff/mypy strict clean across 50 source files.
Linear
- BRO-1013 (M3 Deliverables + Linter) closed.
[0.2.0-m2] — 2026-05-07
Phase C (M2 — Stages) of the M1+M2+M3 parallel-worktree plan. Closes BRO-1012. Builds on Phase B M1 frameworks (v0.1.0-m1).
Added — stages/
stages/base.py—StageBaseABC. Subclasses setSLUG+NEXT_STAGE
class vars + implement run() and request_review().
stages/intake.py— Stage 1 (Intake). L1 GATE enforced in
request_review() — cannot exit without declare_thesis().
stages/scan.py— Stage 2 (Maturity Scan). Gate: ≥1 dimension scored.stages/ideate.py— Stage 3 (Use-Case Ideation). L2 GATE — ≥3 distinct
ideation sources AND ≤50% NOVELTY (Bision Failure 2, 87% observed).
stages/prioritize.py— Stage 4 (Prioritization). Gate: ≥1 prioritized.
Includes compute_year1_net() helper + render_impact_effort_matrix().
stages/roadmap.py— Stage 5 (terminal). L5 GATE —design_pilot()
rejects if any baseline metric lacks a prior BASELINE_CAPTURED event. conclude() emits terminal ENGAGEMENT_CONCLUDED.
Added — stages/01_intake/prompts/
strategic_thesis_elicitation.md— 5-question follow-up to elicit a
quantified, owned, time-bound StrategicThesis (rejects vague answers).
Added — Cross-stage integration test
tests/unit/test_stages_integration.py— drives all 5 stages on the
synthetic Tropico Renovables fixture. Asserts journal sequence, replay reaches state.is_concluded == True, all L-rules fire correctly, 7 deliverable slugs recorded as rendered.
Tests
- 223 → 264 (+41): per-stage tests (gate-pass + gate-reject scenarios),
StageBase ABC contract, full cross-stage integration.
- ruff clean, mypy strict clean across 42 source files.
Linear
- BRO-1012 (M2 Stages) closed.
[0.1.0-m1] — 2026-05-07
Phase B (M1 — Frameworks Library) of the M1+M2+M3 parallel-worktree plan. Closes BRO-1011. Builds on Phase A foundations (v0.0.3-m0.2).
Added — frameworks/
14 Phase-1 framework YAMLs:
- strategy/ three-horizons (McKinsey 1999), wardley-mapping (Wardley 2018)
- maturity/ mit-cisr-digital (MIT CISR 2019), gartner-ai (Gartner 2024)
- ideation/ jobs-to-be-done (Christensen/Strategyn 2003), value-prop-canvas (Strategyzer 2014)
- prioritization/ rice (Intercom 2016 — worked template), ice (GrowthHackers 2009), wsjf (SAFe/Reinertsen 2009)
- roi/ unit-economics (SV canon 2010), npv-dcf (Modigliani-Miller 1958), real-options (Trigeorgis 1999)
- ai-lifecycle/ quantumblack-ml (McKinsey QuantumBlack 2023), andrew-ng-pipeline (DeepLearning.AI 2021)
13 D-scope stubs marked is_d_scope: true (Phase 2 placeholders): where-to-play-how-to-win, five-forces, mckinsey-7s, forrester-data, lean-canvas, business-model-canvas, cost-of-delay, owner-earnings, ms-responsible-ai, google-pair, kotter-8-steps, adkar, mckinsey-influence.
Added — frameworks/_schema.yaml
JSON Schema source-of-truth for the framework-as-code contract (P1). Required fields: id (kebab-case), name, source_firm, source_year, source_citation, category (7-enum), purpose, inputs, dimensions, scoring_rubric, output_shape (8-enum typed primitive), when_to_use (3-5 items), when_NOT_to_use (2-3 items), example_application, relationships (similar_to/use_with/upstream_from/downstream_to), citations.
Added — core/frameworks.py
FrameworkPydantic model withpopulate_by_name=Trueso YAMLs mixed-case
when_NOT_to_use maps cleanly via alias to Python when_not_to_use
load_framework(path)— two-layer validation: jsonschema (rich errors)
then Pydantic (clean Python objects)
load_all()— discovers all YAMLs underframeworks/, dedupe-checks ids
Added — core/selector.py
propose_frameworks(industry, maturity_band, scope_keywords, cap=4)- Industry-specific preferences for banking, energy-utilities, fin-services,
insurance, construction; default fallback for unmapped industries
- Defaults to maturity + prioritization coverage on every engagement
- P7 enforcement: hard cap at 5 frameworks per engagement
- D-scope frameworks excluded (verified via
test_d_scope_frameworks_excluded) FrameworkSelectionemitted with rationale grounded in framework purpose
Added — scripts/lint_frameworks.py
Validates all 27 framework YAMLs + cross-checks every relationships.{kind} target resolves to a known framework slug or documented deliverable suffix. make framework-lint invokes it (replaces M1 skip-with-message).
Tests
- 100 → 223 (+123): one test file per framework (7 tests each), schema
loader rejection cases, D-scope marker correctness, registry totals, selector basic constraints + P7 cap + industry preferences + D-scope exclusion across 7 industries + default coverage.
- ruff clean, mypy strict clean across 35 source files.
Linear
- BRO-1011 (M1 Frameworks Library) closed.
[0.0.3-m0.2] — 2026-05-06
Phase A foundations of the M1+M2+M3 parallel-worktree plan. Closes the 4 deferred design gaps surfaced by the Tropico Renovables synthetic engagement (v0.0.2-m0.1) and unblocks parallel M1+M2+M3 dispatch.
Added — core/events.py
Typed Pydantic payload schemas for all 16 EventKinds (Gap #11): EngagementStartedPayload, IntakeCompletedPayload, InterviewLoggedPayload, DocumentIngestedPayload, StrategicThesisDeclaredPayload, MaturityDimensionScoredPayload, UseCaseProposedPayload, UseCasePrioritizedPayload, RoadmapStepProposedPayload, BaselineCapturedPayload, PilotStartedPayload, DeliverableRenderedPayload, StageReviewRequestedPayload, StageReviewApprovedPayload, StageReviewRevisedPayload, EngagementConcludedPayload. payload_for(kind) returns the class. Decimal-bearing fields use str for journal portability (JSONL).
Added — core/engagement.py
EngagementJournal— append-only event log scoped to one tenant (Gap #12)EngagementState— derived state from journal replay (read-only)_apply(state, event)— pure event-application functionEngagementJournal.replay()— pure fold; idempotent; doesn't mutate journalEngagement— aggregate root: tenant + journal + emit() (Gap #13)Engagement.emit(kind, stage, payload)— only mutation path; payloads
validated via JournalEvent's typed-payload validator before append.
Added — core/revision.py
retract_to_revision_point(engagement, stage)— replay-safe stage revision (Gap #19)- Preserves review markers (
STAGE_REVIEW_*) as audit trail per P5 - Cross-stage events untouched
- Phase 3 will switch to tombstone-based compaction (lago journal pattern)
Modified — core/types.py
JournalEvent.payload_matches_kindmodel_validator — re-validates payload
against payload_for(kind) schema at construction. Wrong-shape payloads raise ValidationError before append.
Tests
- 57 → 100 (+43): typed-payload positive/negative cases, full replay
sequence, idempotency, non-mutation properties, Engagement aggregate, emit() validation, revision retraction, audit-trail preservation, cross-stage isolation.
- All gates green: ruff, mypy strict, 100/100 tests.
Linear
- BRO-1011 (M1) and BRO-1012 (M2) and BRO-1013 (M3) prerequisites met.
[0.0.2-m0.1] — 2026-05-06
Engagement-driven gap closures discovered during a synthetic Phase-1 walk-through (Tropico Renovables S.A.S., 62 MW renewable IPP, Colombian Caribbean coast). All 5 stages run end-to-end against the M0 substrate with L1–L5 + P3 + P8 firing as designed.
Fixed
- JournalEvent ergonomics (Gap #1).
event_idandtimestampnow have
default_factory so callers don't construct ULIDs by hand. The previous shape made it tempting to call str(ulid.ULID()), which trips Pydantic's buffer protocol because ulid-py's ULID extends MemoryView. Internal _new_event_id helper wraps ulid.new().
Added
- `StrategicThesis.thesis_id` (Patch A). Auto-generated ULID so journal
events stably reference the thesis even after revisions.
- `Score` range validation (Patch C).
valuemust lie withinscale
inclusive interval; scale must be ordered (lo < hi). Silently-wrong rubrics no longer propagate into deliverables.
- `MaturityDimension` evidence parity (Patch B). Both
current_score
and target_score must carry non-empty evidence. Targets without citations are wishful thinking — must be benchmarked, regulated, peer- comparable, or thesis-derived.
- `UseCase.status` audit trail (Patch D). New
Literal["proposed", "prioritized", "deferred", "dropped"] field with status_rationale: str | None. dropped and deferred require non-empty rationale. Preserves the engagement audit trail when a sponsor kills a candidate at any gate.
- `TenantContext.industry` enum (Gap #2).
energy-utilitiesvariant
added. Previously a renewable IPP, transmission operator, or water utility had to fall back to other, losing typed signal that framework_selector (M1) needs to propose industry-relevant slates.
Tests
- 47 → 57 unit tests (+10), all green. ruff clean. mypy clean.
- New regression tests exercise the full L1–L5 + P3 path on each new
validator. test_event_id_auto_generated documents the ulid-py footgun in the test corpus so the next engineer doesn't re-discover it.
Linear
- Engagement-driven; no new tickets opened. Follow-on M1–M3 design gaps
(typed payloads per EventKind, EngagementJournal replay, Engagement aggregate, revision protocol) batched into the M1+M2+M3 plan when written.
[0.0.1-m0] — 2026-05-06
Added
- M0 Foundation — repo scaffolding, top-level files, build config, agent-facing skill card, 8 ADRs.
core/types.pywith 19 typed primitives across 5 layers:- Layer 1 (atomic):
Citation,Score,Finding(P3),Recommendation(P8 + L4),AdoptionMetric,BaselineSection,DataReadinessAssessment,IdeationSource,StrategicThesis(L1) - Layer 2 (deliverable aggregates):
UseCase,MaturityDimension,CapabilityCell,RoiCell,RoadmapStep,PilotDesign(L4 + L5) - Layer 3 (event sourcing):
EventKind(16 canonical kinds),JournalEvent - Layer 4 (review gate):
StageReview(P5) - Layer 5 (engagement context):
TenantContext(P6),FrameworkSelection(P7) - 8 ADRs (
docs/adr/0001..0008-*.md) covering all major design decisions. - Pre-commit hook enforcing P6 (tenant data isolation) + secret-assignment scan.
- Makefile with
smoke/check/e2e/framework-lint/canary-check/bision-prevention/doctor/lint/type-checktargets (skip-with-message for milestones not yet shipped). - 47 unit tests across 5 test files; ruff clean; mypy clean.
- Pyproject.toml configured with Pydantic 2.7+, Jinja2, PyYAML, ulid-py, click, rich; ruff/pytest/mypy strict configured.
Architecture
- Substrate / product split (mirrors finance-substrate → investment-management).
- Layer 7 of bstack — depends downward only.
- Apache-2.0 license.
- Phase 1 of 3 — Phase 2 (real engagements) and Phase 3 (Life Rust crate) outlined in design spec.
Bision-failure-prevention readiness
M0 ships the typed primitives required for L1-L5. Linter rules + release-gate test land in M3.
Linear
- Project: https://linear.app/broomva/project/phronesis-ai-native-advisory-practice-8007a216a186
- M0 issues closed: BRO-1007 (M0.1), BRO-1008 (M0.2), BRO-1009 (M0.3), BRO-1010 (M0.4)
[Unreleased]
Coming in M1 (Frameworks Library)
frameworks/_schema.yaml+ 14 Phase-1 framework YAMLs + 13 D-scope stubscore/selector.py— framework_selector primitive (P7)- Property tests: every YAML schema-valid
[0.1.0-pre] — TBD (Phase 1 ship)
Initial pre-release covering all of Phase 1 (M0-M8). Synthetic-fixture-validated only — no real engagements yet.
Contributor Covenant Code of Conduct
Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at contact@broomva.tech.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
[homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
Contributing to phronesis
Status: This document will be expanded in M8 (final docs milestone).
The information below is the minimum viable contributor guidance during M0-M7.
Development setup
git clone <repo-url>
cd phronesis
uv sync
make smokeRequired: Python 3.12+, uv 0.4+.
Making changes
1. Branch from main (use a descriptive branch name, e.g. feat/m1-frameworks-rice). 2. Write the failing test first (TDD discipline). 3. Make the smallest change that passes the test. 4. Run make smoke and ensure green. 5. Commit with conventional-commits style (feat:, fix:, docs:, chore:, test:). 6. Open a PR; ensure all CI checks pass.
What CANNOT be committed
- Tenant data — anything under
engagements/<not-template>/. Pre-commit hook blocks this. P6. - Secrets — patterns matching
aws_access_key_id="...",api_key="...", etc. Pre-commit hook blocks assignment-shaped strings. - Frameworks that don't satisfy
frameworks/_schema.yaml—make framework-lintblocks this.
Adding a new framework
(Detailed recipe lands in M8 at `references/how-to-add-framework.md`.)
For now: copy an existing framework YAML in frameworks/<category>/, edit the fields, run make framework-lint. The schema is frameworks/_schema.yaml.
Style
- Code: ruff (config in
pyproject.toml). - Type hints: required on all public APIs.
- Docstrings: required on all public types and functions.
Testing discipline
- Unit tests for typed primitives, linter rules, renderer correctness.
- Integration tests for stage-to-stage flow.
- Property tests for invariants (every framework YAML schema-valid; replay idempotence).
- Fixture-based E2E (
acme-bank,nova-construction) for end-to-end validation. - Canary anonymization test and Bision-failure-prevention test are release gates.
"""phronesis.core — typed primitives at the substrate layer.
This package contains:
- types: atomic primitives (Citation, Score, Finding, Recommendation) and
deliverable aggregates (UseCase, MaturityDimension, etc.)
- engagement: state model + journal replay (M3)
- stages: stage transitions and review gates (M2)
- linter: P3/P7/P8/L1-L5 enforcement (M3)
- renderer: typed objects → markdown via Jinja2 templates (M3)
- selector: framework_selector primitive (M1)
- extraction: anonymizer + extraction pipeline (M7)
Imports are deferred to subpackages — keep this module empty.
"""
__version__ = "0.1.0-pre"
"""Tenant data anonymization for cross-engagement learning.
Per design spec §7.2 (AnonymizationPolicy), engagement extracts that flow
back into the knowledge graph (research/entities/) MUST be stripped of
tenant-identifying markers. The output should read like Bloomberg
"industry color" — preserves learning, identity-free.
This module ships the policy + the redaction function. The
test_anonymization_canary.py test (Phase E.3) is the release gate: any
checkin that lets a tenant marker leak into anonymized output blocks the
push.
Phase 1 implementation: regex-driven, conservative bias toward over-redaction
(false positives are recoverable; false negatives leak tenant data).
Phase 2+ may swap in NER-based name detection once a real engagement
provides ground truth.
"""
from __future__ import annotations
import re
from decimal import Decimal
from pydantic import BaseModel, Field
from core.types import TenantContext
class AnonymizationPolicy(BaseModel):
"""Configuration for the anonymization pass.
Defaults to strict-by-default: every privacy-relevant transform is on.
Callers explicitly opt out via False if they need raw text (e.g. for
a tenant-private archive snapshot).
"""
strip_tenant_slug: bool = True
strip_tenant_name: bool = True
strip_personal_names: bool = True
replace_currency_with_bands: bool = True
replace_dates_with_relative: bool = True
replace_locations_with_regions: bool = True
redact_terms: list[str] = Field(default_factory=list)
# ----------------------------------------------------------------------------
# Currency band mapping
# ----------------------------------------------------------------------------
_CURRENCY_BANDS: list[tuple[Decimal, str]] = [
(Decimal("1000"), "sub-low-4-figures USD"),
(Decimal("10000"), "low-4-figures USD"),
(Decimal("100000"), "mid-5-figures USD"),
(Decimal("1000000"), "low-6-figures USD"),
(Decimal("10000000"), "low-7-figures USD"),
(Decimal("100000000"), "low-8-figures USD"),
(Decimal("1000000000"), "low-9-figures USD"),
]
def _currency_band(amount: Decimal) -> str:
"""Bucket a Decimal amount into a Bloomberg-style band string."""
for ceiling, label in _CURRENCY_BANDS:
if amount < ceiling:
return label
return "9+-figures USD"
_CURRENCY_RE = re.compile(
r"\$\s?([0-9][0-9,]*(?:\.[0-9]+)?)(?:\s?([KMB]))?",
re.IGNORECASE,
)
def _replace_currency(match: re.Match[str]) -> str:
raw = match.group(1).replace(",", "")
suffix = (match.group(2) or "").upper()
multiplier = {"K": Decimal("1e3"), "M": Decimal("1e6"), "B": Decimal("1e9")}.get(
suffix, Decimal("1")
)
try:
amount = Decimal(raw) * multiplier
except (ArithmeticError, ValueError):
return match.group(0)
return _currency_band(amount)
# ----------------------------------------------------------------------------
# Date relative-replacement
# ----------------------------------------------------------------------------
_ISO_DATE_RE = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
_QUARTER_RE = re.compile(r"\b(\d{4})-Q[1-4]\b")
_YEAR_RE = re.compile(r"\b(20[2-3]\d)\b") # 2020-2039
def _replace_iso_date(match: re.Match[str]) -> str:
return "<engagement-date>"
def _replace_quarter(match: re.Match[str]) -> str:
return "<engagement-quarter>"
def _replace_year(match: re.Match[str]) -> str:
return "<engagement-year>"
# ----------------------------------------------------------------------------
# Personal-name detection (Phase 1: heuristic — capitalized adjacent words
# with at least one of: a Latin diacritic-bearing char, an honorific prefix,
# or a hyphen). False-negative prone — Phase 2 swaps in spaCy NER.
# ----------------------------------------------------------------------------
_NAME_RE = re.compile(
r"\b("
r"(?:Sr\.|Sra\.|Dr\.|Dra\.|Mr\.|Ms\.|Mrs\.)\s+[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+"
r"(?:\s+[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+)*"
r"|"
r"[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+(?:\s+[A-ZÁÉÍÓÚÑÜ][a-záéíóúñü]+)+"
r")"
)
# Tokens that look like names but are domain language we want to keep.
# Phase 1 scope: keep frameworks + roles + Latin geographies that aren't
# tenant-specific.
_NAME_ALLOWLIST: set[str] = {
# Frameworks
"Wardley Mapping",
"Three Horizons",
"Real Options",
"Value Proposition Canvas",
"Jobs To Be Done",
"MIT CISR",
"Andrew Ng Pipeline",
"QuantumBlack ML",
"Lean Canvas",
"Business Model Canvas",
"Cost Of Delay",
"Owner Earnings",
"Microsoft Responsible AI",
"Google PAIR",
"Five Forces",
"Where To Play How To Win",
"Forrester Data",
"Mckinsey 7S",
"Mckinsey Influence",
"Sean Ellis",
"Sean McBride",
# Roles (left as-is; the role itself isn't identifying)
"Head Of Operations",
"Head Of Customer Service",
"Head Of Data",
"Commercial Director",
"Lead Data Engineer",
# Regions / public infrastructure (intentionally generic)
"LATAM",
"United States",
"South America",
}
def _replace_name(match: re.Match[str], policy: AnonymizationPolicy) -> str:
raw = match.group(1)
if raw in _NAME_ALLOWLIST:
return raw
return "<person>"
# ----------------------------------------------------------------------------
# Location detection (Phase 1: explicit list of CO/LATAM cities + country names).
# ----------------------------------------------------------------------------
_LOCATIONS: list[str] = [
# Colombian cities likely to appear in early engagements
"Bogotá",
"Bogota",
"Medellín",
"Medellin",
"Cali",
"Barranquilla",
"Cartagena",
"Bucaramanga",
# Country names commonly in scope
"Colombia",
"Mexico",
"Brasil",
"Brazil",
"Argentina",
"Chile",
"Perú",
"Peru",
]
def _strip_locations(text: str) -> str:
"""Replace specific city/country names with generic LATAM placeholder."""
pattern = r"\b(" + "|".join(re.escape(loc) for loc in _LOCATIONS) + r")\b"
return re.sub(pattern, "<latam-region>", text)
# ----------------------------------------------------------------------------
# Public entry point
# ----------------------------------------------------------------------------
def anonymize(
text: str,
policy: AnonymizationPolicy,
tenant: TenantContext,
) -> str:
"""Apply the anonymization policy to `text` for the given tenant.
Operations applied (in order):
1. Strip tenant slug + name (literal substring replacement).
2. Strip caller-supplied redact_terms.
3. Replace personal names with <person> token.
4. Replace location names with <latam-region>.
5. Replace ISO dates / YYYY-Q* / years with relative tokens.
6. Replace $-prefixed currency with magnitude bands.
Order matters: dates run before currency because ISO dates contain
digits that the currency regex would otherwise match-and-corrupt.
"""
out = text
if policy.strip_tenant_slug and tenant.tenant_slug:
out = out.replace(tenant.tenant_slug, "<tenant>")
if policy.strip_tenant_name and tenant.name:
out = out.replace(tenant.name, "<tenant>")
for term in policy.redact_terms:
if term:
out = out.replace(term, "<redacted>")
if policy.strip_personal_names:
# Strip tenant sponsor name explicitly (more reliable than regex)
if tenant.sponsor:
out = out.replace(tenant.sponsor, "<person>")
out = _NAME_RE.sub(lambda m: _replace_name(m, policy), out)
if policy.replace_locations_with_regions:
out = _strip_locations(out)
if policy.replace_dates_with_relative:
out = _ISO_DATE_RE.sub(_replace_iso_date, out)
out = _QUARTER_RE.sub(_replace_quarter, out)
out = _YEAR_RE.sub(_replace_year, out)
if policy.replace_currency_with_bands:
out = _CURRENCY_RE.sub(_replace_currency, out)
return out
def carries_tenant_marker(text: str, tenant: TenantContext) -> list[str]:
"""Forensic helper: return a list of tenant-identifying markers found
in `text`. Empty list = clean. Used by the canary test to produce a
descriptive failure message rather than a bare assertion.
"""
findings: list[str] = []
if tenant.tenant_slug and tenant.tenant_slug in text:
findings.append(f"tenant_slug:{tenant.tenant_slug}")
if tenant.name and tenant.name in text:
findings.append(f"tenant_name:{tenant.name}")
if tenant.sponsor and tenant.sponsor in text:
findings.append(f"sponsor:{tenant.sponsor}")
return findings
__all__ = [
"AnonymizationPolicy",
"anonymize",
"carries_tenant_marker",
]
"""Engagement aggregate root + journal replay + JSONL persistence.
Closes Gap #12 (EngagementJournal replay) + Gap #13 (Engagement aggregate root)
from the Tropico Renovables synthetic engagement (2026-05-06).
EngagementJournal is the persistence boundary — append-only event log.
EngagementState is derived by replaying the journal — never mutated directly.
Engagement is the aggregate root: tenant + journal + emit().
JSONL persistence (Phase E):
- save_jsonl(path) writes one line per event (newline-delimited JSON).
- load_jsonl(tenant, path) reconstructs the journal. Missing path → empty.
- Phase 1 rewrites the whole file on save; Phase 3 will switch to true
append-only writes once we move to lago.
Phase 3 mapping: this struct mirrors lago's `Aggregate<E, S>` shape, where
events becomes Vec<Record<E>> and replay() is the fold (`events.iter().fold(S::initial(), apply)`).
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field
from core.types import EventKind, JournalEvent, TenantContext
class EngagementState(BaseModel):
"""Derived state from replaying an EngagementJournal. Read-only.
Stages mutate engagement state via emit() — never by setting fields here.
EngagementState is rebuilt fresh on every replay() call.
"""
current_stage: Literal["intake", "scan", "ideate", "prioritize", "roadmap", "concluded"] = (
"intake"
)
thesis_id: str | None = None
frameworks_active: list[str] = Field(default_factory=list)
maturity_dimensions: list[str] = Field(default_factory=list)
use_cases: dict[str, dict[str, object]] = Field(default_factory=dict)
use_cases_prioritized: list[str] = Field(default_factory=list)
baselines_captured: list[str] = Field(default_factory=list)
deliverables_rendered: list[str] = Field(default_factory=list)
review_pending: str | None = None
is_concluded: bool = False
class EngagementJournal(BaseModel):
"""Append-only event log scoped to one tenant engagement.
Persistence path: engagements/<tenant_slug>/journal.jsonl (P6 — gitignored).
"""
tenant: TenantContext
events: list[JournalEvent] = Field(default_factory=list)
def append(self, event: JournalEvent) -> None:
"""Append an event. Caller is responsible for ULID monotonicity
(which Engagement.emit() handles via JournalEvent's default_factory)."""
self.events.append(event)
def replay(self) -> EngagementState:
"""Reconstruct EngagementState by folding events in journal order.
Pure function — calling .replay() twice returns equal states.
Phase 3 mirror: events.iter().fold(EngagementState::default(), apply).
"""
state = EngagementState()
for ev in self.events:
state = _apply(state, ev)
return state
def save_jsonl(self, path: Path) -> None:
"""Write the journal to `path` as newline-delimited JSON.
One event per line. Caller chooses where to write (typically
`engagements/<tenant_slug>/journal.jsonl`). Parent dirs created
if missing. Existing file is overwritten — the journal is the
source of truth, and every emit() rewrites the whole file.
Phase 3 will switch to true append-only writes; Phase 1 simplicity
preferred for synthetic-fixture-driven workflows.
"""
path.parent.mkdir(parents=True, exist_ok=True)
lines = [ev.model_dump_json(exclude_none=True) for ev in self.events]
path.write_text("\n".join(lines) + ("\n" if lines else ""))
@classmethod
def load_jsonl(cls, tenant: TenantContext, path: Path) -> EngagementJournal:
"""Reconstruct an EngagementJournal from an on-disk JSONL file.
Missing file returns an empty journal — that's the natural startup
case for a fresh engagement. Empty lines are skipped. Malformed
lines raise ValidationError (don't silently drop events).
"""
events: list[JournalEvent] = []
if path.exists():
for line in path.read_text().splitlines():
stripped = line.strip()
if not stripped:
continue
events.append(JournalEvent.model_validate_json(stripped))
return cls(tenant=tenant, events=events)
_STAGE_ORDER = ["intake", "scan", "ideate", "prioritize", "roadmap"]
def _next_stage(approved_stage: str) -> str:
try:
i = _STAGE_ORDER.index(approved_stage)
except ValueError:
return approved_stage
if i + 1 < len(_STAGE_ORDER):
return _STAGE_ORDER[i + 1]
return "concluded"
def _apply(state: EngagementState, ev: JournalEvent) -> EngagementState:
"""Pure event-application. Returns a new state — never mutates input."""
next_state = state.model_copy(deep=True)
if ev.kind == EventKind.STRATEGIC_THESIS_DECLARED:
thesis_id = ev.payload.get("thesis_id")
if isinstance(thesis_id, str):
next_state.thesis_id = thesis_id
elif ev.kind == EventKind.INTAKE_COMPLETED:
fw = ev.payload.get("frameworks_selected", [])
if isinstance(fw, list):
next_state.frameworks_active = [str(x) for x in fw]
# current_stage transitions on STAGE_REVIEW_APPROVED, not here.
elif ev.kind == EventKind.MATURITY_DIMENSION_SCORED:
name = ev.payload.get("dimension_name")
if isinstance(name, str):
next_state.maturity_dimensions.append(name)
elif ev.kind == EventKind.USE_CASE_PROPOSED:
uc_id = ev.payload.get("use_case_id")
if isinstance(uc_id, str):
next_state.use_cases[uc_id] = dict(ev.payload)
elif ev.kind == EventKind.USE_CASE_PRIORITIZED:
uc_id = ev.payload.get("use_case_id")
if isinstance(uc_id, str):
next_state.use_cases_prioritized.append(uc_id)
elif ev.kind == EventKind.BASELINE_CAPTURED:
m = ev.payload.get("metric_name")
if isinstance(m, str):
next_state.baselines_captured.append(m)
elif ev.kind == EventKind.DELIVERABLE_RENDERED:
slug = ev.payload.get("slug")
if isinstance(slug, str):
next_state.deliverables_rendered.append(slug)
elif ev.kind == EventKind.STAGE_REVIEW_REQUESTED:
st = ev.payload.get("stage")
if isinstance(st, str):
next_state.review_pending = st
elif ev.kind == EventKind.STAGE_REVIEW_APPROVED:
next_state.review_pending = None
st = ev.payload.get("stage")
if isinstance(st, str):
next_state.current_stage = _next_stage(st) # type: ignore[assignment]
elif ev.kind == EventKind.ENGAGEMENT_CONCLUDED:
next_state.is_concluded = True
next_state.current_stage = "concluded"
return next_state
class Engagement(BaseModel):
"""Aggregate root: tenant + journal + emit().
Closes Gap #13 — the Tropico engagement had to manually couple
TenantContext + journal + deliverables, losing invariants. Engagement
forces all mutation through emit() so replay stays deterministic.
"""
tenant: TenantContext
journal: EngagementJournal
def state(self) -> EngagementState:
"""Replay the journal to derive current state. Pure function."""
return self.journal.replay()
def emit(
self,
kind: EventKind,
stage: str,
payload: dict[str, object],
actor: str = "phronesis",
parent_event_id: str | None = None,
) -> str:
"""Emit a journal event. Returns the event_id (ULID).
Construction goes through JournalEvent's model_validator (Phase A.3),
which re-validates the payload against the kind's schema. Bad
payloads raise ValidationError before append.
Reflexive M7 hook: when `kind == ENGAGEMENT_CONCLUDED`, the
extraction pipeline fires automatically (per
`feedback_bookkeeping_reflexive.md`). Disable via env var
`PHRONESIS_EXTRACTION_ENABLED=0` (interactive-debug use only —
the default ON path is the autonomous-arc contract).
"""
ev = JournalEvent(
kind=kind,
actor=actor,
stage=stage,
payload=payload,
parent_event_id=parent_event_id,
)
self.journal.append(ev)
if kind == EventKind.ENGAGEMENT_CONCLUDED:
self._fire_extraction_hook()
return ev.event_id
def _fire_extraction_hook(self) -> None:
"""Trigger M7 extraction pipeline on ENGAGEMENT_CONCLUDED.
Imported lazily to avoid a circular import (extraction depends on
Engagement). Best-effort: any exception in the hook is swallowed
with a stderr warning — the engagement journal is the source of
truth, and the extraction pipeline must not block emit().
"""
try:
from core.extraction.pipeline import on_engagement_concluded
on_engagement_concluded(self)
except Exception as exc: # pragma: no cover — hook is fire-and-forget
import sys
print(
f"[phronesis][warn] extraction-hook failed for "
f"{self.tenant.tenant_slug!r}: {exc!r}",
file=sys.stderr,
)
"""Typed payloads for each EventKind. Closes Gap #11 from the Tropico
Renovables synthetic engagement (2026-05-06).
JournalEvent.payload was originally `dict[str, object]` — accepted any dict.
The replay function (Phase A.4) and renderers (M3) need stable, typed field
shapes, so each EventKind now has a Pydantic class registered here. The
JournalEvent model_validator (Phase A.3) re-validates `payload` against the
declared kind's schema at construction.
Phase 3 mapping: this registry mirrors lago's `enum E { Started{...}, ... }`
shape for clean transcription to Rust.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
from core.types import EventKind
class EngagementStartedPayload(BaseModel):
"""ENGAGEMENT_STARTED — emitted exactly once when an Engagement is created."""
tenant_slug: str
scope: str
sponsor: str
target_duration_weeks: int
class IntakeCompletedPayload(BaseModel):
"""INTAKE_COMPLETED — Stage 1 closes after thesis declared + frameworks selected."""
thesis_id: str
frameworks_selected: list[str]
class InterviewLoggedPayload(BaseModel):
"""INTERVIEW_LOGGED — sponsor / SME interview captured during intake."""
interviewee: str
role: str
transcript_ref: str
key_findings: list[str]
class DocumentIngestedPayload(BaseModel):
"""DOCUMENT_INGESTED — internal doc consumed during intake (data, regs, prior reports)."""
path: str
kind: Literal["interview", "data", "regulatory", "report"]
summary: str
class StrategicThesisDeclaredPayload(BaseModel):
"""STRATEGIC_THESIS_DECLARED — L1 milestone event."""
thesis_id: str
economic_lever: str
lever_kind: str
magnitude_estimate: str # str-encoded Decimal for journal portability
horizon: str
owner: str
class MaturityDimensionScoredPayload(BaseModel):
"""MATURITY_DIMENSION_SCORED — emitted once per scored dimension in Stage 2."""
dimension_name: str
current_value: float
target_value: float
framework_ref: str
gap_summary: str
class UseCaseProposedPayload(BaseModel):
"""USE_CASE_PROPOSED — surfaced during Stage 3 ideation."""
use_case_id: str
expected_value: str # str-encoded Decimal
cost_estimate: str
ideation_source: str
data_readiness_band: str
class UseCasePrioritizedPayload(BaseModel):
"""USE_CASE_PRIORITIZED — ranked + ROI-modeled in Stage 4."""
use_case_id: str
rice_score: float
year1_net: str # str-encoded Decimal
rank: int
class RoadmapStepProposedPayload(BaseModel):
"""ROADMAP_STEP_PROPOSED — one step in Stage 5 Three-Horizons plan."""
step_id: str
horizon: str
quarter: str
owner: str
success_gate: str
class BaselineCapturedPayload(BaseModel):
"""BASELINE_CAPTURED — L5 enforcement; must precede PILOT_STARTED."""
metric_name: str
baseline_value: str # str-encoded Decimal
captured_by: str
measurement_date: str # ISO 8601
class PilotStartedPayload(BaseModel):
"""PILOT_STARTED — L5 gate fires here; baseline must already be captured."""
use_case_id: str
pilot_design_ref: str
start_date: str
duration_weeks: int
class DeliverableRenderedPayload(BaseModel):
"""DELIVERABLE_RENDERED — emitted by render orchestrator (M3)."""
slug: str
output_path: str
linter_passed: bool
lint_warnings: list[str]
class StageReviewRequestedPayload(BaseModel):
"""STAGE_REVIEW_REQUESTED — sponsor gate. P5 enforcement."""
stage: str
reviewer: str
summary: str
artifacts: list[str]
deadline: str | None = None
class StageReviewApprovedPayload(BaseModel):
"""STAGE_REVIEW_APPROVED — gate cleared, advances current_stage."""
stage: str
reviewer: str
notes: str | None = None
class StageReviewRevisedPayload(BaseModel):
"""STAGE_REVIEW_REVISED — sponsor sends stage back. Triggers retract_to_revision_point (A.8)."""
stage: str
reviewer: str
revisions_requested: list[str]
original_event_id: str
class EngagementConcludedPayload(BaseModel):
"""ENGAGEMENT_CONCLUDED — terminal event. Stages all approved + deliverables rendered."""
stages_approved: int
thesis_id: str
top_pilot: str
deliverable_slugs: list[str]
_REGISTRY: dict[EventKind, type[BaseModel]] = {
EventKind.ENGAGEMENT_STARTED: EngagementStartedPayload,
EventKind.INTAKE_COMPLETED: IntakeCompletedPayload,
EventKind.INTERVIEW_LOGGED: InterviewLoggedPayload,
EventKind.DOCUMENT_INGESTED: DocumentIngestedPayload,
EventKind.STRATEGIC_THESIS_DECLARED: StrategicThesisDeclaredPayload,
EventKind.MATURITY_DIMENSION_SCORED: MaturityDimensionScoredPayload,
EventKind.USE_CASE_PROPOSED: UseCaseProposedPayload,
EventKind.USE_CASE_PRIORITIZED: UseCasePrioritizedPayload,
EventKind.ROADMAP_STEP_PROPOSED: RoadmapStepProposedPayload,
EventKind.BASELINE_CAPTURED: BaselineCapturedPayload,
EventKind.PILOT_STARTED: PilotStartedPayload,
EventKind.DELIVERABLE_RENDERED: DeliverableRenderedPayload,
EventKind.STAGE_REVIEW_REQUESTED: StageReviewRequestedPayload,
EventKind.STAGE_REVIEW_APPROVED: StageReviewApprovedPayload,
EventKind.STAGE_REVIEW_REVISED: StageReviewRevisedPayload,
EventKind.ENGAGEMENT_CONCLUDED: EngagementConcludedPayload,
}
def payload_for(kind: EventKind) -> type[BaseModel]:
"""Return the typed payload class for an EventKind.
Raises KeyError if the kind has no registered payload schema. After
Phase A.2, all 16 EventKinds are registered and this never raises in
practice.
"""
return _REGISTRY[kind]
"""Phronesis M7 — extraction pipeline.
Transforms a concluded engagement journal into anonymized knowledge-graph
candidates that flow through the bookkeeping P8 review queue and land as
entity pages in `research/entities/{industry-pattern,framework-refinement}/`.
Hook point: `ENGAGEMENT_CONCLUDED` event (per `feedback_bookkeeping_reflexive.md`)
triggers `extract_and_queue(engagement)`. Each emitted candidate carries:
- anonymized text (per `core/anonymize.py::AnonymizationPolicy`),
- candidate entity slug + type (industry-pattern / framework-refinement),
- provenance pointing back to the engagement journal,
- bookkeeping P8 score (novelty + specificity + relevance).
Candidates score ≥5/9 land in `research/entities/`. Below-threshold candidates
go to the review queue at `~/.config/phronesis/extraction-queue/` for human
inspection. We never file entities directly — every candidate flows through
the bookkeeping gate per the anti-pattern in `feedback_bookkeeping_reflexive.md`.
The 14-canary-token release gate runs against the anonymizer output via
`tests/integration/test_anonymization_canary.py`. A leaking token blocks
the push.
"""
from __future__ import annotations
from core.extraction.anonymizer import (
EngagementAnonymizer,
anonymize_engagement_text,
)
from core.extraction.candidates import (
ExtractionCandidate,
extract_framework_refinements,
extract_industry_patterns,
)
from core.extraction.pipeline import (
ENTITY_GRAPH_ROOT,
EXTRACTION_QUEUE_ROOT,
ExtractionResult,
extract_and_queue,
on_engagement_concluded,
)
__all__ = [
"EXTRACTION_QUEUE_ROOT",
"ENTITY_GRAPH_ROOT",
"EngagementAnonymizer",
"ExtractionCandidate",
"ExtractionResult",
"anonymize_engagement_text",
"extract_and_queue",
"extract_framework_refinements",
"extract_industry_patterns",
"on_engagement_concluded",
]
"""Engagement-shaped anonymization wrapper.
Thin adapter over `core/anonymize.py` that takes a `TenantContext` +
engagement-derived `redact_terms` (e.g. project codenames, interviewee
non-sponsor names) and produces canary-clean text suitable for the
knowledge-graph layer.
Why wrap `anonymize()`? The extraction pipeline always operates on the
same shape (engagement journal payloads + tenant context), and we want a
single place where the policy defaults + the engagement-specific
redact_terms come together. Callers in `pipeline.py` and the test
harness use this wrapper, not the lower-level `anonymize()` directly.
The 14-canary-token release gate
(`tests/integration/test_anonymization_canary.py`) drives the underlying
`core/anonymize.py::anonymize()` directly because the canary asserts the
policy itself works — not just our wrapper. The wrapper is a convenience
layer for the extraction pipeline.
"""
from __future__ import annotations
from collections.abc import Iterable
from pydantic import BaseModel
from core.anonymize import AnonymizationPolicy, anonymize, carries_tenant_marker
from core.engagement import Engagement
from core.types import TenantContext
class EngagementAnonymizer(BaseModel):
"""Tenant-bound anonymizer for engagement-derived text.
Built once per engagement; reused across every candidate emitted by
the extraction pipeline.
Attributes:
tenant: the engagement tenant — name + slug + sponsor stripped.
policy: the anonymization policy (defaults to strict — every
transform on, no caller-supplied redact_terms unless the
engagement surfaces project codenames).
"""
tenant: TenantContext
policy: AnonymizationPolicy
def redact(self, text: str) -> str:
"""Anonymize `text` against this engagement's tenant + policy."""
return anonymize(text, self.policy, self.tenant)
def carries_marker(self, text: str) -> list[str]:
"""Forensic — list tenant markers still present in `text`. Empty
list means clean. Used by the pipeline to assert post-redaction
before emitting a candidate."""
return carries_tenant_marker(text, self.tenant)
def _collect_engagement_redact_terms(engagement: Engagement) -> list[str]:
"""Walk the engagement journal for project codenames + non-sponsor
interviewee names that the strict policy might miss.
These get added to `policy.redact_terms`. Conservative bias toward
over-redaction — the canary catches leaks, not over-redaction.
Heuristics (Phase 1):
- `INTERVIEW_LOGGED.interviewee` → strip the human name (modulo the
sponsor, which `core/anonymize.py` already handles).
- `ROADMAP_STEP_PROPOSED.title` may contain project codenames —
Phase 2 will add NER + per-tenant project-codename declarations.
For now, we trust the strict policy + the canary list.
"""
from core.types import EventKind
redact: list[str] = []
for ev in engagement.journal.events:
if ev.kind == EventKind.INTERVIEW_LOGGED:
interviewee = ev.payload.get("interviewee", "")
if not isinstance(interviewee, str) or not interviewee:
continue
# Strip any "(Role)" suffix — we only want the name itself.
name = interviewee.split("(")[0].strip()
# Don't redact the sponsor here; `anonymize()` already does it.
if engagement.tenant.sponsor and engagement.tenant.sponsor in name:
continue
if name and name not in redact:
redact.append(name)
return redact
def anonymize_engagement_text(
text: str,
engagement: Engagement,
*,
extra_redact_terms: Iterable[str] = (),
policy: AnonymizationPolicy | None = None,
) -> str:
"""Anonymize `text` using engagement-derived redaction terms.
The convenience entry point for one-shot redactions (e.g. extracting
a single finding into an industry-pattern candidate). The pipeline
builds an `EngagementAnonymizer` once and calls `.redact()` repeatedly
— that's the hot path. This function is the cold path for ad-hoc
callers.
Args:
text: the raw text to anonymize.
engagement: the engagement the text was derived from (for tenant
context + journal-derived redact terms).
extra_redact_terms: caller-supplied terms in addition to the
journal-derived ones (e.g. a project codename surfaced in a
specific journal payload).
policy: optional policy override; default is strict.
Returns:
Anonymized text with all tenant markers + journal-derived
identifying names + caller-supplied terms redacted.
"""
if policy is None:
policy = AnonymizationPolicy()
journal_terms = _collect_engagement_redact_terms(engagement)
combined_terms = list(
dict.fromkeys([*policy.redact_terms, *journal_terms, *extra_redact_terms])
)
effective_policy = policy.model_copy(update={"redact_terms": combined_terms})
return anonymize(text, effective_policy, engagement.tenant)
__all__ = [
"EngagementAnonymizer",
"anonymize_engagement_text",
]
"""Candidate extraction from concluded engagement journals.
Two extractors:
- `extract_industry_patterns()` — recurring patterns within an industry
(e.g. "LATAM mid-market banks with no production ML score 2.0±0.3 on
customer-facing AI maturity"). Source: maturity-dimension scores +
findings + thesis economic_lever, grouped by `tenant.industry`.
- `extract_framework_refinements()` — empirical adjustments to canonical
frameworks discovered during the engagement (e.g. "RICE under-weights
regulatory-pressure use cases in financial services — recommend +25%
impact bonus for `regulatory-pressure` source"). Source: deltas between
rendered ROI vs RICE rank, ideation_source distribution, observed lift
on capability-heatmap target_score vs baseline assumptions.
Each candidate carries enough provenance to round-trip back to the
journal event(s) that produced it. We never include raw text without
anonymization — the anonymizer wrapper is applied before the candidate
ever leaves this module.
Phase 1 scope: rule-based extractors. Phase 2 will swap in LLM extractors
once a corpus of ≥3 concluded engagements per industry exists.
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from core.engagement import Engagement
from core.extraction.anonymizer import EngagementAnonymizer
from core.types import EventKind
EntityType = Literal["industry-pattern", "framework-refinement"]
class ExtractionCandidate(BaseModel):
"""A pre-bookkeeping candidate for a knowledge-graph entity.
Fields mirror the bookkeeping `RawItem` shape so adapter glue stays
minimal — `pipeline.py::_to_raw_item()` builds a bookkeeping RawItem
directly from the candidate.
Attributes:
slug: kebab-case proposed entity slug (e.g.
`latam-mid-market-banking-customer-ai-gap`).
entity_type: which `research/entities/<type>/` directory the entity
lands in if the bookkeeping P8 score lets it through.
content: anonymized body text (Bloomberg industry color — no
tenant identity).
quote: the source phrase that surfaced the pattern (anonymized).
title: short human-readable title for the entity page.
provenance_event_ids: ULIDs of the journal events that produced
this candidate. Round-trippable.
industry: the industry signature for industry-pattern candidates
(`tenant.industry`). None for framework-refinement candidates.
framework_ref: the canonical framework being refined
(e.g. `framework:rice`). None for industry-pattern candidates.
signals: structured payload — numeric deltas, observed-vs-expected
ratios, etc. that the bookkeeping scorer can pick up.
"""
slug: str
entity_type: EntityType
content: str
quote: str
title: str
provenance_event_ids: list[str]
industry: str | None = None
framework_ref: str | None = None
signals: dict[str, float | str] = Field(default_factory=dict)
# ----------------------------------------------------------------------------
# Industry-pattern extraction
# ----------------------------------------------------------------------------
def extract_industry_patterns(
engagement: Engagement,
anonymizer: EngagementAnonymizer,
) -> list[ExtractionCandidate]:
"""Surface industry-pattern candidates from a concluded engagement.
Phase 1 rules:
1. Each scored maturity dimension below 2.5 on the canonical 1-5
scale becomes a candidate ("industry X dimension Y under-developed").
2. The strategic-thesis economic_lever becomes one industry-aware
candidate ("industry X tenants commonly recover value via Z").
Returns at most `n_dimensions + 1` candidates. The bookkeeping scorer
decides which survive into `research/entities/industry-pattern/`.
"""
candidates: list[ExtractionCandidate] = []
industry = engagement.tenant.industry
# Rule 1 — dimension under-development across industry.
for ev in engagement.journal.events:
if ev.kind != EventKind.MATURITY_DIMENSION_SCORED:
continue
dim_name_raw = ev.payload.get("dimension_name", "")
current_raw = ev.payload.get("current_value", 0.0)
target_raw = ev.payload.get("target_value", 0.0)
gap_summary_raw = ev.payload.get("gap_summary", "")
if not isinstance(dim_name_raw, str) or not dim_name_raw:
continue
try:
current = float(current_raw) if isinstance(current_raw, (int, float, str)) else 0.0
target = float(target_raw) if isinstance(target_raw, (int, float, str)) else 0.0
except (TypeError, ValueError):
continue
if current >= 2.5:
# Above-threshold dimensions don't surface as under-development
# patterns; they're industry-baseline, not industry-pattern.
continue
gap_summary = gap_summary_raw if isinstance(gap_summary_raw, str) else ""
body = (
f"Industry pattern observed in {industry}: "
f"dimension '{dim_name_raw}' scores {current:.1f}/5 against "
f"benchmark target {target:.1f}. Observed gap: {gap_summary}. "
f"Pattern recurs across same-industry tenants — surfaced once, "
f"promotable when ≥3 industry-matched engagements reproduce."
)
quote = f"{dim_name_raw} at {current:.1f}/5: {gap_summary}"
candidates.append(
ExtractionCandidate(
slug=_industry_pattern_slug(industry, dim_name_raw),
entity_type="industry-pattern",
content=anonymizer.redact(body),
quote=anonymizer.redact(quote),
title=f"{industry}: {dim_name_raw} under-development",
provenance_event_ids=[ev.event_id],
industry=industry,
signals={
"dimension": dim_name_raw,
"current_score": current,
"target_score": target,
"gap_magnitude": target - current,
},
)
)
# Rule 2 — industry-aware economic lever pattern.
thesis_ev = _find_event(engagement, EventKind.STRATEGIC_THESIS_DECLARED)
if thesis_ev is not None:
lever_raw = thesis_ev.payload.get("economic_lever", "")
lever_kind_raw = thesis_ev.payload.get("lever_kind", "")
magnitude_raw = thesis_ev.payload.get("magnitude_estimate", "")
lever = lever_raw if isinstance(lever_raw, str) else ""
lever_kind = lever_kind_raw if isinstance(lever_kind_raw, str) else ""
magnitude = magnitude_raw if isinstance(magnitude_raw, str) else "0"
if lever:
body = (
f"Industry pattern observed in {industry}: tenants surface "
f"a {lever_kind} economic lever — '{lever}'. Magnitude "
f"estimate (anonymized to band): see signals. Surfaced once; "
f"promotion to industry-pattern entity requires ≥3 same-"
f"industry engagements with the same lever_kind."
)
candidates.append(
ExtractionCandidate(
slug=_industry_lever_slug(industry, lever_kind),
entity_type="industry-pattern",
content=anonymizer.redact(body),
quote=anonymizer.redact(lever),
title=f"{industry}: {lever_kind}-lever pattern",
provenance_event_ids=[thesis_ev.event_id],
industry=industry,
signals={
"lever_kind": lever_kind,
"magnitude_band_raw": magnitude,
},
)
)
return candidates
# ----------------------------------------------------------------------------
# Framework-refinement extraction
# ----------------------------------------------------------------------------
def extract_framework_refinements(
engagement: Engagement,
anonymizer: EngagementAnonymizer,
) -> list[ExtractionCandidate]:
"""Surface framework-refinement candidates from a concluded engagement.
Phase 1 rules:
1. If 2+ use cases share the same `ideation_source`, surface a RICE
refinement candidate ("source X over-represented in industry Y —
consider weighting").
2. If the top-ranked use case by RICE differs from the top by year1_net
ROI, surface a delta candidate ("RICE rank vs ROI rank diverges
for industry Y — calibrate impact weighting").
"""
candidates: list[ExtractionCandidate] = []
industry = engagement.tenant.industry
# Gather use-case events with ideation source + RICE rank + ROI.
proposed: dict[str, dict[str, object]] = {}
prioritized: list[dict[str, object]] = []
for ev in engagement.journal.events:
if ev.kind == EventKind.USE_CASE_PROPOSED:
uc_id = ev.payload.get("use_case_id")
if isinstance(uc_id, str):
proposed[uc_id] = {**ev.payload, "_event_id": ev.event_id}
elif ev.kind == EventKind.USE_CASE_PRIORITIZED:
uc_id = ev.payload.get("use_case_id")
if isinstance(uc_id, str):
prioritized.append({**ev.payload, "_event_id": ev.event_id})
# Rule 1 — ideation source over-representation in same engagement.
sources: dict[str, list[str]] = {}
source_event_ids: dict[str, list[str]] = {}
for uc_id, pl in proposed.items():
source_raw = pl.get("ideation_source", "")
if not isinstance(source_raw, str) or not source_raw:
continue
sources.setdefault(source_raw, []).append(uc_id)
ev_id = pl.get("_event_id")
if isinstance(ev_id, str):
source_event_ids.setdefault(source_raw, []).append(ev_id)
for source, uc_ids in sources.items():
if len(uc_ids) < 2:
continue
body = (
f"Framework refinement candidate: in this {industry} engagement, "
f"{len(uc_ids)} of {len(proposed)} use cases originated from "
f"ideation source '{source}'. RICE assumes uniform-priority "
f"sourcing; over-represented sources may need explicit "
f"weighting in same-industry calibrations. Surfaced once; "
f"promotion to framework-refinement entity requires ≥3 "
f"engagements showing the same source skew."
)
candidates.append(
ExtractionCandidate(
slug=_framework_rice_source_slug(industry, source),
entity_type="framework-refinement",
content=anonymizer.redact(body),
quote=anonymizer.redact(f"{len(uc_ids)}/{len(proposed)} use cases from '{source}'"),
title=f"RICE: {source} weighting in {industry}",
provenance_event_ids=source_event_ids.get(source, []),
framework_ref="framework:rice",
signals={
"ideation_source": source,
"use_case_count": float(len(uc_ids)),
"total_proposed": float(len(proposed)),
"share": float(len(uc_ids)) / float(len(proposed) or 1),
},
)
)
# Rule 2 — RICE rank vs ROI year1_net divergence at rank 1.
if prioritized:
try:
top_rice_uc = min(
prioritized,
key=_rank_key,
)
# Top by year1_net = max
top_roi_uc = max(
prioritized,
key=lambda p: _decimal_str_to_float(p.get("year1_net", "0")),
)
except (TypeError, ValueError):
top_rice_uc = None
top_roi_uc = None
if (
top_rice_uc is not None
and top_roi_uc is not None
and top_rice_uc.get("use_case_id") != top_roi_uc.get("use_case_id")
):
ev_ids = [
eid
for eid in (
_event_id_or_none(top_rice_uc),
_event_id_or_none(top_roi_uc),
)
if eid is not None
]
body = (
f"Framework refinement candidate: in this {industry} "
f"engagement, RICE top-ranked use case differs from ROI "
f"year-1-net top. RICE's reach×impact×confidence/effort "
f"calculation may under-weight near-term revenue capture "
f"in this industry. Surfaced once; promotion requires ≥3 "
f"same-industry engagements with the same RICE/ROI rank "
f"divergence."
)
candidates.append(
ExtractionCandidate(
slug=_framework_rice_roi_slug(industry),
entity_type="framework-refinement",
content=anonymizer.redact(body),
quote=anonymizer.redact(f"RICE-top vs ROI-top diverge in {industry}"),
title=f"RICE: RICE-vs-ROI rank gap in {industry}",
provenance_event_ids=list(dict.fromkeys(ev_ids)),
framework_ref="framework:rice",
signals={
"industry": industry,
"rice_top_use_case": str(top_rice_uc.get("use_case_id", "")),
"roi_top_use_case": str(top_roi_uc.get("use_case_id", "")),
},
)
)
return candidates
# ----------------------------------------------------------------------------
# Slug builders
# ----------------------------------------------------------------------------
def _industry_pattern_slug(industry: str, dimension: str) -> str:
"""Build a deterministic slug for an industry-dimension pattern.
Format: `<industry>-<dimension-kebab>-pattern`.
"""
return f"{_kebab(industry)}-{_kebab(dimension)}-pattern"
def _industry_lever_slug(industry: str, lever_kind: str) -> str:
"""Build a deterministic slug for an industry-lever pattern."""
return f"{_kebab(industry)}-{_kebab(lever_kind)}-lever-pattern"
def _framework_rice_source_slug(industry: str, source: str) -> str:
"""Build a deterministic slug for a RICE source-weighting refinement."""
return f"rice-{_kebab(source)}-weight-{_kebab(industry)}"
def _framework_rice_roi_slug(industry: str) -> str:
"""Build a deterministic slug for a RICE-vs-ROI divergence refinement."""
return f"rice-roi-rank-gap-{_kebab(industry)}"
def _kebab(text: str) -> str:
"""Convert text to kebab-case slug."""
out: list[str] = []
prev_dash = False
for ch in text.lower():
if ch.isalnum():
out.append(ch)
prev_dash = False
elif not prev_dash:
out.append("-")
prev_dash = True
slug = "".join(out).strip("-")
return slug or "unknown"
# ----------------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------------
def _find_event(engagement: Engagement, kind: EventKind): # type: ignore[no-untyped-def]
for ev in engagement.journal.events:
if ev.kind == kind:
return ev
return None
def _decimal_str_to_float(raw: object) -> float:
if isinstance(raw, (int, float)):
return float(raw)
if isinstance(raw, str):
try:
return float(raw)
except ValueError:
return 0.0
return 0.0
def _rank_key(payload: dict[str, object]) -> float:
"""Sort key — extract a numeric `rank` from a journal payload."""
rank = payload.get("rank", 999)
if isinstance(rank, (int, float, str)):
try:
return float(rank)
except (TypeError, ValueError):
return 999.0
return 999.0
def _event_id_or_none(payload: dict[str, object]) -> str | None:
"""Return the `_event_id` from a payload only if it's a non-empty string."""
eid = payload.get("_event_id")
if isinstance(eid, str) and eid:
return eid
return None
__all__ = [
"EntityType",
"ExtractionCandidate",
"extract_framework_refinements",
"extract_industry_patterns",
]
"""Extraction pipeline — engagement journal → candidates → bookkeeping P8 → queue.
The end-to-end flow:
1. `extract_industry_patterns(engagement)` + `extract_framework_refinements(engagement)`
produce a list of `ExtractionCandidate` objects, each pre-anonymized
via `EngagementAnonymizer`.
2. Each candidate is wrapped as a bookkeeping `RawItem` and passed to
`bookkeeping.score_item()` — the SAME 9-point Nous gate the rest of
the workspace uses (no duplicate scoring math).
3. Candidates that score ≥5/9 (`bookkeeping.PROMOTE_THRESHOLD`) land in
the review queue at `~/.config/phronesis/extraction-queue/` as JSON
records. They do NOT go directly to `research/entities/` — every
candidate must clear a human review pass first (anti-pattern in the
handoff: "don't file entities directly").
4. Candidates that score <5 are also persisted in the queue (under
`low-score/`) for forensic visibility — never silently dropped.
The reflexive trigger lives in `on_engagement_concluded()` — called by the
engagement model whenever an `ENGAGEMENT_CONCLUDED` event is emitted. This
makes the flow Pillar-1 (recursive self-improvement) automatic: every
completed engagement contributes to the knowledge graph without manual
invocation.
Test isolation: the queue + entity-graph paths read from env vars
`PHRONESIS_EXTRACTION_QUEUE_ROOT` and `PHRONESIS_ENTITY_GRAPH_ROOT`, both
defaulting to the operator's home. Tests set them to `tmp_path` so the
suite never touches the real knowledge graph.
"""
from __future__ import annotations
import json
import os
import sys
from collections.abc import Iterable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
from core.anonymize import AnonymizationPolicy
from core.engagement import Engagement
from core.extraction.anonymizer import (
EngagementAnonymizer,
_collect_engagement_redact_terms,
)
from core.extraction.candidates import (
ExtractionCandidate,
extract_framework_refinements,
extract_industry_patterns,
)
# ----------------------------------------------------------------------------
# Configuration — sandboxable via env vars for test isolation.
# ----------------------------------------------------------------------------
def _resolve_queue_root() -> Path:
"""Resolve the review-queue root.
Override via PHRONESIS_EXTRACTION_QUEUE_ROOT. Defaults to
`~/.config/phronesis/extraction-queue/`.
"""
env = os.environ.get("PHRONESIS_EXTRACTION_QUEUE_ROOT")
if env:
return Path(env).expanduser()
return Path.home() / ".config" / "phronesis" / "extraction-queue"
def _resolve_entity_graph_root() -> Path:
"""Resolve the entity graph root.
Override via PHRONESIS_ENTITY_GRAPH_ROOT. Defaults to
`~/broomva/research/entities/` — the workspace knowledge graph.
"""
env = os.environ.get("PHRONESIS_ENTITY_GRAPH_ROOT")
if env:
return Path(env).expanduser()
return Path.home() / "broomva" / "research" / "entities"
# Module-level constants for backward compatibility. Tests should call the
# resolver functions directly when they need fresh env-var reads.
EXTRACTION_QUEUE_ROOT = _resolve_queue_root()
ENTITY_GRAPH_ROOT = _resolve_entity_graph_root()
# ----------------------------------------------------------------------------
# Bookkeeping interop — optional import, deterministic fallback.
# ----------------------------------------------------------------------------
def _bookkeeping_module() -> Any | None:
"""Try to import the bookkeeping module.
Returns `None` if the module isn't installed (test environments,
minimal CI runners). When None, `_score_candidate()` falls back to a
deterministic stub heuristic — fine for tests, never used in
production where the canonical bookkeeping is on PATH.
"""
bookkeeping_path = Path.home() / "broomva" / "skills" / "bookkeeping" / "scripts"
if not bookkeeping_path.exists():
return None
if str(bookkeeping_path) not in sys.path:
sys.path.insert(0, str(bookkeeping_path))
try:
import bookkeeping # type: ignore[import-not-found]
return bookkeeping
except ImportError:
return None
class _CandidateScore(BaseModel):
"""Score record attached to a candidate before queue persistence."""
total: int
novelty: int
specificity: int
relevance: int
promote: bool
scoring_method: str
reasoning: dict[str, Any] = Field(default_factory=dict)
def _stub_score(candidate: ExtractionCandidate) -> _CandidateScore:
"""Deterministic fallback scorer for environments without bookkeeping.
Conservative — promotes when the candidate has rich signals (≥3 signal
keys) AND non-empty quote AND content > 200 chars. Otherwise low score.
Intentionally simple — the real bookkeeping P8 path is the source of
truth in production. This stub keeps the pipeline + tests independent
of LLM availability.
"""
novelty = 2 if candidate.entity_type == "framework-refinement" else 1
specificity = 2 if candidate.signals else 1
relevance = 2 if candidate.industry or candidate.framework_ref else 1
total = novelty + specificity + relevance
if len(candidate.content) > 200 and candidate.quote:
total += 1
if len(candidate.signals) >= 3:
total += 1
return _CandidateScore(
total=total,
novelty=novelty,
specificity=specificity,
relevance=relevance,
promote=total >= 5,
scoring_method="stub-deterministic",
reasoning={
"rule": "stub-fallback when bookkeeping module unavailable",
},
)
def _score_candidate(candidate: ExtractionCandidate) -> _CandidateScore:
"""Score an extraction candidate via bookkeeping P8 (Nous gate).
Tries the real bookkeeping module first; falls back to the
deterministic stub if bookkeeping isn't importable. Honors the
PHRONESIS_EXTRACTION_STUB_SCORER env var (set to "1" by tests to
force the deterministic path).
"""
if os.environ.get("PHRONESIS_EXTRACTION_STUB_SCORER") == "1":
return _stub_score(candidate)
bk = _bookkeeping_module()
if bk is None:
return _stub_score(candidate)
timestamp = datetime.now(UTC).isoformat()
raw_item = bk.RawItem(
item_id=f"phronesis-{candidate.slug}-{timestamp}",
source_id=f"phronesis-extraction:{candidate.slug}",
source_type="phronesis-engagement",
content=candidate.content,
quote=candidate.quote,
author="phronesis-extraction-pipeline",
timestamp=timestamp,
metadata={
"entity_type": candidate.entity_type,
"industry": candidate.industry,
"framework_ref": candidate.framework_ref,
"signals": candidate.signals,
"provenance_event_ids": candidate.provenance_event_ids,
},
)
try:
existing_slugs = bk.existing_entity_slugs()
except Exception:
existing_slugs = []
try:
scored = bk.score_item(raw_item, existing_slugs)
except Exception:
# Network / API failure → stub fallback. Never block on transient
# LLM failure; the candidate still lands in the review queue.
return _stub_score(candidate)
return _CandidateScore(
total=int(scored.total),
novelty=int(scored.novelty),
specificity=int(scored.specificity),
relevance=int(scored.relevance),
promote=bool(scored.promote),
scoring_method=str(scored.scoring_method),
reasoning=dict(scored.reasoning) if isinstance(scored.reasoning, dict) else {},
)
# ----------------------------------------------------------------------------
# Pipeline result
# ----------------------------------------------------------------------------
class ExtractionResult(BaseModel):
"""Result of an extraction pipeline run."""
engagement_slug: str
industry_pattern_candidates: int = 0
framework_refinement_candidates: int = 0
promoted_count: int = 0
queued_count: int = 0
queue_paths: list[Path] = Field(default_factory=list)
promotion_paths: list[Path] = Field(default_factory=list)
leaks: list[tuple[str, list[str]]] = Field(default_factory=list)
@property
def total_candidates(self) -> int:
return self.industry_pattern_candidates + self.framework_refinement_candidates
# ----------------------------------------------------------------------------
# Public entry points
# ----------------------------------------------------------------------------
def extract_and_queue(
engagement: Engagement,
*,
queue_root: Path | None = None,
entity_graph_root: Path | None = None,
policy: AnonymizationPolicy | None = None,
extra_redact_terms: Iterable[str] = (),
) -> ExtractionResult:
"""Run the extraction pipeline against `engagement`.
Args:
engagement: a concluded engagement (state.is_concluded == True).
Non-concluded engagements still extract but the result is
best-effort; calling code should gate on `is_concluded` if it
cares.
queue_root: review-queue directory; defaults to env-resolved.
entity_graph_root: where promoted entities land; defaults to env-resolved.
policy: anonymization policy; defaults to strict.
extra_redact_terms: caller-supplied redact terms (project codenames
the journal can't surface automatically).
Returns:
`ExtractionResult` summarizing what was queued + promoted + leaked.
Side effects:
Writes JSON queue records to `<queue_root>/<engagement_slug>/`.
Writes promoted candidate stubs to
`<entity_graph_root>/<entity_type>/<slug>.md` IF the bookkeeping
score is ≥5 AND no canary leaks are detected post-redaction.
The promoted file is a STUB — operator polishes before
publishing. We never short-circuit the human review pass.
"""
queue_root = queue_root or _resolve_queue_root()
entity_graph_root = entity_graph_root or _resolve_entity_graph_root()
policy = policy or AnonymizationPolicy()
journal_terms = _collect_engagement_redact_terms(engagement)
combined_terms = list(
dict.fromkeys([*policy.redact_terms, *journal_terms, *extra_redact_terms])
)
effective_policy = policy.model_copy(update={"redact_terms": combined_terms})
anonymizer = EngagementAnonymizer(tenant=engagement.tenant, policy=effective_policy)
industry_candidates = extract_industry_patterns(engagement, anonymizer)
framework_candidates = extract_framework_refinements(engagement, anonymizer)
all_candidates = [*industry_candidates, *framework_candidates]
result = ExtractionResult(
engagement_slug=engagement.tenant.tenant_slug,
industry_pattern_candidates=len(industry_candidates),
framework_refinement_candidates=len(framework_candidates),
)
queue_dir = queue_root / engagement.tenant.tenant_slug
queue_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
for candidate in all_candidates:
# Defense-in-depth: re-check for tenant markers AFTER candidate
# construction (in case a custom anonymizer wrapper was passed and
# didn't strip something). Never queue a candidate that carries a
# tenant marker — the canary release gate would catch it later
# but we'd rather fail fast.
leaked = anonymizer.carries_marker(candidate.content) + anonymizer.carries_marker(
candidate.quote
)
if leaked:
result.leaks.append((candidate.slug, leaked))
continue
score = _score_candidate(candidate)
promoted = score.promote
record = {
"candidate": candidate.model_dump(mode="json"),
"score": score.model_dump(mode="json"),
"engagement_slug": engagement.tenant.tenant_slug,
"promoted_at": datetime.now(UTC).isoformat(),
}
sub_dir = "promoted" if promoted else "low-score"
out_dir = queue_dir / sub_dir
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / f"{candidate.slug}-{timestamp}.json"
out_path.write_text(json.dumps(record, indent=2, sort_keys=False, default=str))
result.queue_paths.append(out_path)
if promoted:
result.promoted_count += 1
entity_dir = entity_graph_root / candidate.entity_type
entity_dir.mkdir(parents=True, exist_ok=True)
entity_path = entity_dir / f"{candidate.slug}.md"
entity_path.write_text(_render_entity_stub(candidate, score, engagement))
result.promotion_paths.append(entity_path)
else:
result.queued_count += 1
return result
def on_engagement_concluded(
engagement: Engagement,
*,
enabled: bool | None = None,
**kwargs: Any,
) -> ExtractionResult | None:
"""Reflexive hook — called when `ENGAGEMENT_CONCLUDED` fires.
Per `feedback_bookkeeping_reflexive.md`: bookkeeping is reflexive,
not opt-in. This hook runs without being asked.
Set `PHRONESIS_EXTRACTION_ENABLED=0` to disable (e.g. during
interactive debugging of a non-concluded fixture). Default: enabled.
Returns the `ExtractionResult`, or `None` if disabled.
"""
if enabled is None:
enabled = os.environ.get("PHRONESIS_EXTRACTION_ENABLED", "1") != "0"
if not enabled:
return None
if not engagement.state().is_concluded:
# Best-effort idempotency: only fire on truly-concluded engagements.
return None
return extract_and_queue(engagement, **kwargs)
# ----------------------------------------------------------------------------
# Entity page stub renderer
# ----------------------------------------------------------------------------
def _render_entity_stub(
candidate: ExtractionCandidate,
score: _CandidateScore,
engagement: Engagement,
) -> str:
"""Render a minimal entity-page stub for a promoted candidate.
Format mirrors `~/broomva/skills/bookkeeping/templates/entity-page.md`
minimally — just enough for the file to be lint-clean. Operator
polishes before the entity surfaces in queries.
Stays in YAML frontmatter + plain markdown body. Per the workspace
Format Discernment rule, entity pages are Category A (substrate),
so this is markdown-only — never HTML.
"""
industry_or_framework = (
f" industry: {candidate.industry}"
if candidate.industry
else f" framework_ref: {candidate.framework_ref}"
)
signals_lines = "\n".join(f" - {k}: {v}" for k, v in candidate.signals.items())
body = candidate.content
return f"""---
type: {candidate.entity_type}
slug: {candidate.slug}
title: {candidate.title}
status: candidate
provenance:
source: phronesis-extraction
engagement_slug: {engagement.tenant.tenant_slug}
event_ids:
{chr(10).join(f" - {eid}" for eid in candidate.provenance_event_ids)}
score:
total: {score.total}/9
novelty: {score.novelty}
specificity: {score.specificity}
relevance: {score.relevance}
method: {score.scoring_method}
{industry_or_framework}
signals:
{signals_lines}
created_at: {datetime.now(UTC).isoformat()}
---
# {candidate.title}
## Pattern (anonymized)
{body}
## Source quote
> {candidate.quote}
## Promotion gate
- Bookkeeping P8 score: {score.total}/9 ({score.scoring_method})
- Rule-of-three: this is one instance. Surface ≥2 more same-industry /
same-framework engagements reproducing the pattern before treating as
a stable graph node.
- Operator action: polish the body, verify the signals, confirm the
anonymization holds, then promote `status` from `candidate` to `active`.
"""
__all__ = [
"EXTRACTION_QUEUE_ROOT",
"ENTITY_GRAPH_ROOT",
"ExtractionResult",
"extract_and_queue",
"on_engagement_concluded",
]
"""Framework registry — loads all YAMLs at frameworks/ and validates them
against frameworks/_schema.yaml.
Phase 1 ships 14 implemented frameworks + 13 D-scope stubs.
Phase 3 mirrors this in life-phronesis Rust crate.
P7 enforcement: framework_selector caps active frameworks at ≤5 per
engagement. The registry just loads + validates; selection happens in
core/selector.py.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, Field
FRAMEWORKS_ROOT = Path(__file__).resolve().parent.parent / "frameworks"
class FrameworkInput(BaseModel):
"""One input parameter a framework consumes."""
name: str
type: str
description: str
class Framework(BaseModel):
"""In-memory representation of a framework YAML.
Mirrors frameworks/_schema.yaml. Pydantic does the runtime validation;
the YAML schema gets validated separately via jsonschema in
`load_framework` so we get JSON-Schema-quality error messages.
The YAML field `when_NOT_to_use` (mixed case per design spec Appendix A)
is exposed as `when_not_to_use` in Python via Pydantic alias. Both names
work on construction — population_by_field_name is enabled.
"""
model_config = {"populate_by_name": True}
id: str
name: str
source_firm: str
source_year: int
source_citation: str
category: str
purpose: str
inputs: list[FrameworkInput] = Field(default_factory=list)
dimensions: list[str] = Field(default_factory=list)
scoring_rubric: dict[str, Any]
output_shape: dict[str, Any]
when_to_use: list[str]
when_not_to_use: list[str] = Field(alias="when_NOT_to_use")
example_application: dict[str, Any] = Field(default_factory=dict)
relationships: dict[str, list[str]] = Field(default_factory=dict)
citations: list[str] = Field(default_factory=list)
is_d_scope: bool = False
def _validate_against_schema(raw: dict[str, Any]) -> None:
"""Validate a YAML dict against frameworks/_schema.yaml using jsonschema.
Raises jsonschema.ValidationError on first violation. We keep this
optional — if jsonschema isn't installed the Pydantic layer still
catches missing/wrong-typed fields.
"""
schema_path = FRAMEWORKS_ROOT / "_schema.yaml"
if not schema_path.exists():
return
try:
import jsonschema
except ImportError:
return
schema = yaml.safe_load(schema_path.read_text())
jsonschema.validate(raw, schema)
def load_framework(path: Path) -> Framework:
"""Load and validate one framework YAML.
Validates against `_schema.yaml` first (JSON Schema), then through
Pydantic. Two layers because they catch different classes of error:
JSON Schema catches structural issues with rich error messages;
Pydantic catches type-narrowing issues and gives clean Python objects.
"""
raw = yaml.safe_load(path.read_text())
if not isinstance(raw, dict):
raise ValueError(f"Framework YAML must be a top-level mapping; got {type(raw).__name__}")
_validate_against_schema(raw)
return Framework.model_validate(raw)
def load_all() -> dict[str, Framework]:
"""Load every framework YAML under FRAMEWORKS_ROOT, keyed by id.
Skips `_schema.yaml`. Raises if two frameworks share an id, or if any
YAML fails validation.
"""
out: dict[str, Framework] = {}
if not FRAMEWORKS_ROOT.exists():
return out
for yaml_path in sorted(FRAMEWORKS_ROOT.rglob("*.yaml")):
if yaml_path.name == "_schema.yaml":
continue
fw = load_framework(yaml_path)
if fw.id in out:
raise ValueError(f"Duplicate framework id: {fw.id!r} (at {yaml_path} and earlier)")
out[fw.id] = fw
return out
"""Phronesis linter — L1-L5 + P3 + P7 + P8 rules over a rendered engagement.
Each rule scans the journal (and optionally the rendered deliverables) and
emits LintViolation objects. The release-gate test (Phase E) requires zero
L1-L5 errors on the canonical synthetic fixture.
Stages already enforce gates at their boundaries (raise ValueError when
the gate is violated). The linter is the BACKSTOP — replaying a finished
engagement journal must produce zero L-errors. If a linter rule fires, it
means a stage was bypassed or the engagement was constructed manually
without going through the stages.
"""
from __future__ import annotations
from collections import Counter
from collections.abc import Callable
from pydantic import BaseModel, Field
from core.engagement import Engagement
from core.types import EventKind
class LintViolation(BaseModel):
"""One linter finding."""
rule: str # "L1" | "L2" | "L3" | "L4" | "L5" | "P3" | "P7" | "P8"
severity: str # "error" | "warning"
location: str # event_id, deliverable slug, or descriptive marker
message: str
class LintResult(BaseModel):
"""Aggregated result of running all linter rules over an Engagement."""
violations: list[LintViolation] = Field(default_factory=list)
@property
def has_errors(self) -> bool:
return any(v.severity == "error" for v in self.violations)
@property
def has_warnings(self) -> bool:
return any(v.severity == "warning" for v in self.violations)
def errors_for_rule(self, rule: str) -> list[LintViolation]:
return [v for v in self.violations if v.rule == rule and v.severity == "error"]
# Rules registry ------------------------------------------------------------
_RULES: list[Callable[[Engagement], list[LintViolation]]] = []
def rule(
fn: Callable[[Engagement], list[LintViolation]],
) -> Callable[[Engagement], list[LintViolation]]:
"""Decorator: register a linter rule."""
_RULES.append(fn)
return fn
# L1 — STRATEGIC_THESIS_REQUIRED ---------------------------------------------
@rule
def l1_strategic_thesis_required(eng: Engagement) -> list[LintViolation]:
"""L1 — every engagement must declare a StrategicThesis BEFORE leaving Stage 1.
Bision Failure 1 (100% observed): Sin tesis estratégica.
Engagements that emitted INTAKE_COMPLETED without first emitting
STRATEGIC_THESIS_DECLARED are blocked.
"""
violations: list[LintViolation] = []
state = eng.state()
intake_closed_event_id: str | None = None
for ev in eng.journal.events:
if ev.kind == EventKind.INTAKE_COMPLETED:
intake_closed_event_id = ev.event_id
break
if intake_closed_event_id and state.thesis_id is None:
violations.append(
LintViolation(
rule="L1",
severity="error",
location=intake_closed_event_id,
message=(
"L1 STRATEGIC_THESIS_REQUIRED — intake closed without a "
"thesis declared. Bision Failure 1 (100% observed): "
"'sin tesis estratégica'."
),
)
)
return violations
# L2 — DIVERSE_IDEATION_SOURCES ----------------------------------------------
@rule
def l2_diverse_ideation_sources(eng: Engagement) -> list[LintViolation]:
"""L2 — ideation diversity rule.
Bision Failure 2 (87% observed): casos mal priorizados — elección por novedad.
Triggers when ≥1 USE_CASE_PROPOSED exists AND either:
- <3 distinct ideation sources OR
- NOVELTY share > 50%
"""
violations: list[LintViolation] = []
state = eng.state()
if not state.use_cases:
return violations
sources = [uc.get("ideation_source") for uc in state.use_cases.values()]
sources_counter = Counter(s for s in sources if isinstance(s, str))
distinct = len(sources_counter)
novelty_share = sources_counter.get("novelty", 0) / len(state.use_cases)
if distinct < 3:
violations.append(
LintViolation(
rule="L2",
severity="error",
location="ideate-stage",
message=(
f"L2 DIVERSE_IDEATION_SOURCES — only {distinct} distinct "
f"ideation sources across {len(state.use_cases)} use cases "
f"(min 3). Bision Failure 2 (87% observed)."
),
)
)
if novelty_share > 0.5:
violations.append(
LintViolation(
rule="L2",
severity="error",
location="ideate-stage",
message=(
f"L2 DIVERSE_IDEATION_SOURCES — NOVELTY share "
f"{novelty_share:.0%} exceeds 50% limit. "
f"Bision Failure 2: elección por novedad."
),
)
)
return violations
# L3 — DATA_READINESS_GATE ---------------------------------------------------
@rule
def l3_data_readiness_gate(eng: Engagement) -> list[LintViolation]:
"""L3 — data readiness must be assessed for every proposed use case.
Bision Failure 3 (74% observed): datos no preparados.
Triggers when a use case has `readiness_band == "blocking"` AND no
`prep_phase_required` AND the engagement is past Stage 3.
"""
violations: list[LintViolation] = []
state = eng.state()
if not state.use_cases:
return violations
for uc_id, payload in state.use_cases.items():
band = payload.get("data_readiness_band")
if band == "blocking":
violations.append(
LintViolation(
rule="L3",
severity="error",
location=f"use-case:{uc_id}",
message=(
f"L3 DATA_READINESS_GATE — use case {uc_id!r} has "
f"data_readiness_band=blocking. Bision Failure 3 "
f"(74% observed): datos no preparados. Either declare "
f"a prep_phase or defer the use case."
),
)
)
return violations
# L4 — ADOPTION_METRIC_REQUIRED ----------------------------------------------
@rule
def l4_adoption_metric_required(eng: Engagement) -> list[LintViolation]:
"""L4 — every PILOT_STARTED must reference a pilot design with an
adoption_metric. Pydantic enforces non-emptiness on PilotDesign at
construction; this lints the journal sequence.
Bision Failure 4 (61% observed): desconexión negocio-tecnología —
modelos con precisión alta y baja adopción.
Phase 1 backstop: PilotDesign requires AdoptionMetric (typed). The linter
surfaces L4 if a PILOT_STARTED event is followed by no DELIVERABLE_RENDERED
for slug "pilot-plan" — i.e., the engagement skipped pilot-plan rendering,
so the adoption metric never reached the deliverable layer.
"""
violations: list[LintViolation] = []
pilots: list[str] = []
rendered_pilot_plans: list[str] = []
for ev in eng.journal.events:
if ev.kind == EventKind.PILOT_STARTED:
uc_id = ev.payload.get("use_case_id")
if isinstance(uc_id, str):
pilots.append(uc_id)
elif ev.kind == EventKind.DELIVERABLE_RENDERED:
slug = ev.payload.get("slug")
if slug == "pilot-plan":
rendered_pilot_plans.append(ev.event_id)
if pilots and not rendered_pilot_plans:
violations.append(
LintViolation(
rule="L4",
severity="error",
location="roadmap-stage",
message=(
"L4 ADOPTION_METRIC_REQUIRED — pilot(s) started "
f"({pilots}) but no pilot-plan deliverable rendered. "
"Bision Failure 4 (61% observed): adoption metric must "
"land in the deliverable so it's audit-trail visible."
),
)
)
return violations
# L5 — BASELINE_REQUIRED -----------------------------------------------------
@rule
def l5_baseline_required(eng: Engagement) -> list[LintViolation]:
"""L5 — every PILOT_STARTED must be preceded by ≥1 BASELINE_CAPTURED.
Bision Failure 5 (48% observed): sin medición de ROI — no retroactive
baselines. RoadmapStage.design_pilot enforces this at construction; the
linter is the journal-replay backstop.
"""
violations: list[LintViolation] = []
baseline_count_before: dict[str, int] = {}
captured_so_far = 0
for ev in eng.journal.events:
if ev.kind == EventKind.BASELINE_CAPTURED:
captured_so_far += 1
elif ev.kind == EventKind.PILOT_STARTED:
uc_id = ev.payload.get("use_case_id")
if isinstance(uc_id, str):
baseline_count_before[uc_id] = captured_so_far
for uc_id, count in baseline_count_before.items():
if count == 0:
violations.append(
LintViolation(
rule="L5",
severity="error",
location=f"pilot:{uc_id}",
message=(
f"L5 BASELINE_REQUIRED — pilot for {uc_id!r} started "
f"with zero prior BASELINE_CAPTURED events. "
f"Bision Failure 5 (48% observed): no retroactive "
f"baselines. Capture before pilot."
),
)
)
return violations
# Aggregate runner ----------------------------------------------------------
def lint_engagement(eng: Engagement) -> LintResult:
"""Run all registered rules; return aggregated result."""
out = LintResult()
for fn in _RULES:
out.violations.extend(fn(eng))
return out
"""Render orchestrator — produces all 7 deliverables for an Engagement.
The orchestrator runs the linter BEFORE persisting deliverables. If
`lint_result.has_errors == True`, the function returns the rendered
markdown in-memory but does NOT write to disk. Caller must inspect the
LintResult and decide whether to re-render after fixes or override.
This is the M3 acceptance gate: the bision-prevention release-gate test
(Phase E) drives an engagement end-to-end, calls render_all(), and
asserts `lint_result.has_errors == False` on the canonical synthetic
fixture.
"""
from __future__ import annotations
from collections.abc import Callable
from decimal import Decimal
from pathlib import Path
from typing import Any
from core.engagement import Engagement
from core.linter import LintResult, lint_engagement
from core.render import render
DELIVERABLE_SLUGS: tuple[str, ...] = (
"maturity-report",
"capability-heatmap",
"use-case-dossier",
"impact-effort-matrix",
"roi-model",
"innovation-roadmap",
"pilot-plan",
)
# Per-deliverable context builders. Each returns the dict that the
# corresponding Jinja2 template expects. Phase 1 builders pull from the
# engagement state + journal events; M4 will plumb richer context (typed
# aggregates) once stages produce them in-memory rather than via journal-only.
ContextBuilder = Callable[[Engagement, dict[str, Any]], dict[str, Any]]
def _base_context(eng: Engagement, extras: dict[str, Any]) -> dict[str, Any]:
"""Common fields every deliverable can rely on."""
return {
"tenant": eng.tenant,
"generated_at": extras.get("generated_at", "TBD"),
}
def render_all(
engagement: Engagement,
output_dir: Path,
*,
extra_context: dict[str, Any] | None = None,
write: bool = True,
) -> tuple[dict[str, Path], LintResult]:
"""Render all 7 deliverables for an engagement.
Args:
engagement: the Engagement aggregate (tenant + journal).
output_dir: where rendered markdown lands (created if missing).
extra_context: optional caller-provided context that Phase 1 stages
can't yet derive from journal alone (rendered ROI cells with
sensitivity bands, RICE-ranked use cases for impact-effort, etc.).
write: when False, render in-memory but don't persist (preview mode).
Returns:
(slug → output Path, LintResult). If `lint_result.has_errors == True`
AND `write == True`, this function STILL writes the files but the
caller is expected to inspect lint_result before treating them as
publication-ready. Use `write=False` to preview without writing.
"""
output_dir.mkdir(parents=True, exist_ok=True)
extras = extra_context or {}
contexts = _build_all_contexts(engagement, extras)
paths: dict[str, Path] = {}
for slug, ctx in contexts.items():
rendered = render(slug, ctx)
path = output_dir / f"{slug}.md"
if write:
path.write_text(rendered)
paths[slug] = path
lint_result = lint_engagement(engagement)
return paths, lint_result
def render_with_gate(
engagement: Engagement,
output_dir: Path,
*,
extra_context: dict[str, Any] | None = None,
) -> tuple[dict[str, Path], LintResult]:
"""Lint-GATED render: writes ONLY if zero L-errors.
This is the publication path. If L1-L5 + P3/P7/P8 lint clean, every
deliverable is written to output_dir. Otherwise output_dir is left
untouched (no partial state) and the caller gets back the LintResult
to surface to the user.
"""
extras = extra_context or {}
# Lint first — cheaper than rendering.
lint_result = lint_engagement(engagement)
if lint_result.has_errors:
return {}, lint_result
output_dir.mkdir(parents=True, exist_ok=True)
contexts = _build_all_contexts(engagement, extras)
paths: dict[str, Path] = {}
for slug, ctx in contexts.items():
rendered = render(slug, ctx)
path = output_dir / f"{slug}.md"
path.write_text(rendered)
paths[slug] = path
return paths, lint_result
def _build_all_contexts(eng: Engagement, extras: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Build per-deliverable context dicts from engagement + extras.
Phase 1 contract: callers supply the typed-primitive lists via
`extra_context` because the journal stores serialized dicts not
Pydantic objects. M4+ may move this assembly into the stages
themselves, with the orchestrator just orchestrating.
"""
base = _base_context(eng, extras)
# Each deliverable picks its required keys from extras. Missing keys
# surface via Jinja2 strict-undefined at render time — orchestrator
# doesn't second-guess templates.
contexts: dict[str, dict[str, Any]] = {}
for slug in DELIVERABLE_SLUGS:
ctx = {**base, **extras}
contexts[slug] = ctx
return contexts
# Convenience helpers for callers building extra_context -------------------
def build_roi_totals(roi_cells: list[Any]) -> dict[str, Decimal]:
"""Sum up Year-1 totals across RoiCells. Used to populate the
`total_revenue` / `total_investment` / `total_net` keys for
templates/roi-model.md.j2."""
if not roi_cells:
return {
"total_revenue": Decimal("0"),
"total_investment": Decimal("0"),
"total_net": Decimal("0"),
}
return {
"total_revenue": sum((c.revenue_impact for c in roi_cells), start=Decimal("0")),
"total_investment": sum((c.investment for c in roi_cells), start=Decimal("0")),
"total_net": sum((c.net for c in roi_cells), start=Decimal("0")),
}
"""Jinja2 renderer for engagement deliverables.
Each deliverable has a template at templates/<slug>.md.j2; the renderer feeds
it engagement state + stage outputs and produces markdown. The L1-L5 + P3 +
P7 + P8 linters run on the rendered output before persisting (M3 release gate).
Strict undefined: missing context variables raise UndefinedError immediately
rather than silently producing empty output.
"""
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, StrictUndefined
TEMPLATES_ROOT = Path(__file__).resolve().parent.parent / "templates"
def _as_currency(value: Decimal | int | float, currency: str = "USD") -> str:
"""Format a Decimal/numeric value as `$640,000` style currency.
USD is the default for Phase 1; future revisions may key on
Recommendation.value_currency for non-USD engagements.
"""
if not isinstance(value, (Decimal, int, float)):
return str(value)
n = float(value)
if currency == "USD":
return f"${n:,.0f}"
return f"{n:,.0f} {currency}"
def _as_percent(value: Decimal | int | float, places: int = 1) -> str:
"""Format a fraction (0.234) as `23.4%`."""
if not isinstance(value, (Decimal, int, float)):
return str(value)
n = float(value)
return f"{n * 100:.{places}f}%"
def _build_environment() -> Environment:
env = Environment(
loader=FileSystemLoader(str(TEMPLATES_ROOT)),
undefined=StrictUndefined,
trim_blocks=True,
lstrip_blocks=True,
autoescape=False, # markdown, not HTML
)
env.filters["as_currency"] = _as_currency
env.filters["as_percent"] = _as_percent
return env
def render(slug: str, context: dict[str, Any]) -> str:
"""Render a deliverable template against context.
Raises:
TemplateNotFound: if templates/<slug>.md.j2 doesn't exist.
UndefinedError: if context omits a required variable.
"""
env = _build_environment()
template = env.get_template(f"{slug}.md.j2")
return template.render(**context)
"""Revision protocol — replay-safe stage revision.
Closes Gap #19 from the Tropico Renovables synthetic engagement.
When a sponsor sends a stage back via STAGE_REVIEW_REVISED, the work events
that the stage previously emitted must be retracted from the journal so the
stage can be re-executed with new inputs. The revision marker itself
(STAGE_REVIEW_REVISED, REQUESTED, APPROVED) is preserved as audit trail —
P5 mandates no silent retractions.
Phase 3 mapping: this is a journal compaction op. In lago, retractions
become tombstone records that the replay function skips. Phase 1 simplifies
by physically removing the events; lossy but adequate for synthetic
engagements where the journal is local. Phase 3 will switch to tombstones.
"""
from __future__ import annotations
from core.engagement import Engagement
from core.types import EventKind, JournalEvent
# Review-marker kinds are preserved across revisions — they are the audit
# trail of who approved/revised when, and discarding them would lose P5.
_REVIEW_MARKER_KINDS = frozenset(
{
EventKind.STAGE_REVIEW_REQUESTED,
EventKind.STAGE_REVIEW_APPROVED,
EventKind.STAGE_REVIEW_REVISED,
}
)
def retract_to_revision_point(eng: Engagement, stage: str) -> int:
"""Remove all work events at `stage` while preserving review markers.
Returns the count of events removed. Caller emits new events for the
stage afterward — replay reconstructs state from the trimmed journal.
Cross-stage events are untouched: revising stage `scan` does not affect
intake-stage events. This preserves the invariant that revision is
locally scoped.
"""
keep: list[JournalEvent] = []
removed = 0
for ev in eng.journal.events:
is_target_stage = ev.stage == stage
is_review_marker = ev.kind in _REVIEW_MARKER_KINDS
if is_target_stage and not is_review_marker:
removed += 1
continue
keep.append(ev)
eng.journal.events = keep
return removed
"""framework_selector — proposes 2-4 FrameworkSelection picks per engagement.
P7 enforcement: caps active frameworks at ≤5. Each pick carries
human-readable rationale grounded in the framework's purpose.
Algorithm:
1. Map scope_keywords to category coverage (e.g., "maturity assessment" → maturity).
2. Default: at least maturity + prioritization for any AI/digital engagement.
3. For each needed category, pick the industry-preferred framework. Fall back
to a default if the industry isn't pre-mapped.
4. Skip D-scope frameworks (Phase-2 stubs).
5. Cap at `cap` (default 4); hard cap 5 (P7).
6. Emit FrameworkSelection with rationale + appropriate stage attachment.
"""
from __future__ import annotations
from core.frameworks import load_all
from core.types import FrameworkSelection
_KEYWORD_TO_CATEGORY: dict[str, str] = {
"maturity assessment": "maturity",
"maturity": "maturity",
"ai maturity": "maturity",
"digital maturity": "maturity",
"ideation": "ideation",
"use case ideation": "ideation",
"use case prioritization": "prioritization",
"prioritization": "prioritization",
"roi": "roi",
"roi modeling": "roi",
"roi model": "roi",
"roadmap": "strategy",
"innovation roadmap": "strategy",
"pilot": "ai-lifecycle",
"ai pilot": "ai-lifecycle",
"pilot plan": "ai-lifecycle",
}
_INDUSTRY_PREFERENCES: dict[str, dict[str, str]] = {
"banking": {
"maturity": "mit-cisr-digital",
"ideation": "jobs-to-be-done",
"prioritization": "rice",
"roi": "unit-economics",
"strategy": "three-horizons",
"ai-lifecycle": "quantumblack-ml",
},
"energy-utilities": {
"maturity": "gartner-ai",
"ideation": "value-prop-canvas",
"prioritization": "rice",
"roi": "real-options",
"strategy": "wardley-mapping",
"ai-lifecycle": "andrew-ng-pipeline",
},
"fin-services": {
"maturity": "mit-cisr-digital",
"prioritization": "rice",
"roi": "unit-economics",
"strategy": "three-horizons",
"ai-lifecycle": "quantumblack-ml",
},
"insurance": {
"maturity": "mit-cisr-digital",
"prioritization": "rice",
"roi": "npv-dcf",
"strategy": "three-horizons",
"ai-lifecycle": "quantumblack-ml",
},
"construction": {
"maturity": "gartner-ai",
"ideation": "value-prop-canvas",
"prioritization": "ice",
"roi": "unit-economics",
"strategy": "three-horizons",
"ai-lifecycle": "andrew-ng-pipeline",
},
# Engagement-driven addition (BRO-1032). AI-infra / runtime / library /
# chip-design tenants. Surfaced by Broomva Silicon engagement 2026-05-07.
# Maturity → CHAOSS (OSS project health, not enterprise digital maturity).
# Strategy → Wardley (tech-evolution explicit).
# ROI → real-options (option-value framings dominate deep-tech bets).
"tech": {
"maturity": "chaoss",
"ideation": "value-prop-canvas",
"prioritization": "rice",
"roi": "real-options",
"strategy": "wardley-mapping",
"ai-lifecycle": "quantumblack-ml",
},
}
_DEFAULT_PREFERENCES: dict[str, str] = {
"maturity": "mit-cisr-digital",
"ideation": "jobs-to-be-done",
"prioritization": "rice",
"roi": "unit-economics",
"strategy": "three-horizons",
"ai-lifecycle": "quantumblack-ml",
}
def _category_to_stage(category: str) -> str:
return {
"maturity": "scan",
"ideation": "ideate",
"prioritization": "prioritize",
"roi": "prioritize",
"strategy": "roadmap",
"ai-lifecycle": "roadmap",
}.get(category, "scan")
def propose_frameworks(
industry: str,
maturity_band: str,
scope_keywords: list[str],
cap: int = 4,
) -> list[FrameworkSelection]:
"""Return 2-4 FrameworkSelection picks ranked by category coverage.
P7: hard cap at 5 even if caller passes higher. D-scope frameworks
are excluded — they exist only as relationship targets in Phase 1.
"""
all_fws = load_all()
prefs = _INDUSTRY_PREFERENCES.get(industry, _DEFAULT_PREFERENCES)
needed_categories: set[str] = set()
for kw in scope_keywords:
cat = _KEYWORD_TO_CATEGORY.get(kw.lower().strip())
if cat:
needed_categories.add(cat)
# Default coverage: every AI/digital engagement gets at least maturity +
# prioritization, even if the scope statement doesn't name them.
needed_categories |= {"maturity", "prioritization"}
picks: list[FrameworkSelection] = []
seen_slugs: set[str] = set()
# Stable iteration order for deterministic selection
for cat in sorted(needed_categories):
slug = prefs.get(cat) or _DEFAULT_PREFERENCES.get(cat)
if not slug or slug in seen_slugs:
continue
fw = all_fws.get(slug)
if fw is None or fw.is_d_scope:
continue
rationale = (
f"{fw.name} ({fw.source_firm}, {fw.source_year}) — chosen for "
f"{industry} at {maturity_band} maturity because "
f"{fw.purpose.split('.')[0].strip()}."
)
picks.append(
FrameworkSelection(
framework_ref=f"framework:{slug}",
selected_at_stage=_category_to_stage(cat),
rationale=rationale,
selected_by="phronesis-selector-v1",
)
)
seen_slugs.add(slug)
if len(picks) >= cap:
break
# P7 hard cap — never exceed 5 active frameworks per engagement.
return picks[: min(cap, 5)]
ADR-0006: Decimal for money, not int cents or float
Status: Accepted (2026-05-06)
Context
Recommendation.value, RoiCell.*, BaselineSection.baseline_value, StrategicThesis.magnitude_estimate all carry monetary values. Three options: float, int (in minor units / cents), or decimal.Decimal.
Decision
decimal.Decimal everywhere money is involved.
Consequences
- No floating-point error in cumulative ROI / NPV calculations
- USDC and COP both have non-cent precision needs;
Decimalhandles arbitrary-precision - Phase-3 mirror is
rust_decimal::Decimal— identical semantics - Pydantic 2 supports
Decimalnatively with JSON-string serialization
Alternatives considered
1. `int` cents. Rejected — fails for non-cent currencies (e.g., COP, large USDC fractions) 2. `float`. Rejected — accumulating floating-point error in 5-year ROI projections 3. Custom Money type. Rejected — premature abstraction; Decimal + value_currency: str is sufficient