diff --git a/CHANGELOG.md b/CHANGELOG.md index 37f456e..6d06abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,31 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] ### Changed +- Migrated `config/portfolio-catalog.yaml` and the example catalog from + `intended_disposition` to `operating_path` (179 + 3 entries; identity value + mapping — `resolve_declared_operating_path` already treated disposition + values as a fallback vintage of the same axis, so no value translation was + needed). `operating_path` is now wired as a first-class normalized catalog + field end-to-end (loader, reconciler `declared_values`, scorecard program + resolution, intent-alignment, and catalog summaries); previously it was + accepted by the resolver but never actually populated from the catalog, so + every entry resolved through the `intended_disposition` fallback. + `intended_disposition` stays as a deprecated read-compat fallback for one + release (resolver, loader, scorecards, catalog validator, and intent + summaries all still honor it); the catalog loader now emits a warning when + an entry still declares it. Resolved `operating_path`, `path_source`, risk + tier, and attention state are unchanged for every project. +- Portfolio truth schema bumped to `0.9.0`: `derived.registry_status` (a + `stale`->`parked` synonym table over `activity_status`) is removed. + `derived.archived` is now a first-class boolean lifecycle fact (sourced from + `github_archived` or a declared `archived` lifecycle state), orthogonal to + the pure-recency `activity_status` axis. The `stale`->`parked` display + relabel moved to a single render-layer function + (`portfolio_truth_types.display_activity_status`). Snapshot + `source_summary.registry_status_counts` is replaced by + `activity_status_counts` + `archived_count`. Risk tier, attention state, and + displayed status are unchanged for every repo; `0.8.0` is accepted as a + legacy schema version. - Hardened the local web UI runner and HTMX fragments against CodeQL-reported command-injection and reflected-XSS paths. - Added a CodeQL code-scanning workflow and documented the public security diff --git a/config/examples/portfolio-catalog.example.yaml b/config/examples/portfolio-catalog.example.yaml index 6644bb1..e54f963 100644 --- a/config/examples/portfolio-catalog.example.yaml +++ b/config/examples/portfolio-catalog.example.yaml @@ -13,7 +13,7 @@ repos: lifecycle_state: active criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain maturity_program: maintain target_maturity: strong notes: Keep this repo polished because it anchors the current product story. @@ -23,7 +23,7 @@ repos: lifecycle_state: active criticality: medium review_cadence: monthly - intended_disposition: finish + operating_path: finish maturity_program: finish target_maturity: operating RepoC: @@ -32,6 +32,6 @@ repos: lifecycle_state: experimental criticality: low review_cadence: ad-hoc - intended_disposition: experiment + operating_path: experiment maturity_program: experiment target_maturity: foundation diff --git a/config/portfolio-catalog.yaml b/config/portfolio-catalog.yaml index 1e5c486..afd431a 100644 --- a/config/portfolio-catalog.yaml +++ b/config/portfolio-catalog.yaml @@ -50,7 +50,7 @@ groups: category: vanity tool_provenance: grok lifecycle_state: archived - intended_disposition: archive + operating_path: archive order: 50 moneyprjsviagpt: label: GPT Built @@ -61,7 +61,7 @@ groups: category: commercial tool_provenance: gpt lifecycle_state: archived - intended_disposition: archive + operating_path: archive order: 60 iosprojects: label: iOS Projects @@ -77,10 +77,10 @@ repos: _machine/machine-control-tower: owner: d purpose: local machine-ops and Codex operating-layer control surface for durable operator cockpit assets - lifecycle_state: active + lifecycle_state: manual-only criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -90,10 +90,10 @@ repos: agent-bridge: owner: d purpose: local SQLite-backed MCP shared-state bus for coordinating coding agents - lifecycle_state: active + lifecycle_state: manual-only criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -106,7 +106,7 @@ repos: lifecycle_state: active criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -121,7 +121,7 @@ repos: lifecycle_state: active criticality: medium review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: productivity tool_provenance: codex maturity_program: maintain @@ -134,7 +134,7 @@ repos: purpose: deterministic local-first application packet generation lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: commercial tool_provenance: codex maturity_program: maintain @@ -143,32 +143,32 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code DecisionStressTest: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: experiment + operating_path: experiment tool_provenance: codex doctor_standard: full Devil's Advocate: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code evals: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: experiment + operating_path: experiment engraph: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: vanity tool_provenance: claude-code maturity_program: archive @@ -179,26 +179,26 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: experiment + operating_path: experiment HowMoneyMoves: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code JobMarketHeatmap: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code reliability-vault: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: vanity tool_provenance: unknown maturity_program: archive @@ -209,7 +209,7 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: it-work tool_provenance: claude-code LifeCadenceLedger: @@ -217,7 +217,7 @@ repos: purpose: recurring commitment ledger for personal cadence and obligations lifecycle_state: active review_cadence: monthly - intended_disposition: experiment + operating_path: experiment category: vanity tool_provenance: claude-code maturity_program: experiment @@ -225,9 +225,9 @@ repos: MCPAudit: owner: d purpose: local MCP security and drift audit tool - lifecycle_state: active + lifecycle_state: manual-only review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -236,16 +236,16 @@ repos: doctor_standard: full knowledgecore: owner: d - lifecycle_state: active + lifecycle_state: manual-only review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex OrbitMechanic: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code notes: Local checkout for the canonical saagpatel/OrbitMechanics browser-game repo. saagpatel/OrbitMechanics: @@ -254,7 +254,7 @@ repos: lifecycle_state: active criticality: medium review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: fun tool_provenance: claude-code maturity_program: maintain @@ -266,7 +266,7 @@ repos: lifecycle_state: active criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: claude-code maturity_program: maintain @@ -276,10 +276,10 @@ repos: operant-public: owner: d purpose: operating-agent calibration benchmark with shipped public MCP access - lifecycle_state: active + lifecycle_state: manual-only criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: claude-code maturity_program: maintain @@ -290,27 +290,27 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code Recall: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code ResumeEvolver: owner: d lifecycle_state: maintenance review_cadence: monthly - intended_disposition: maintain + operating_path: maintain tool_provenance: codex doctor_standard: basic ScreenshottoDataSelect: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code SignalFlow: @@ -320,7 +320,7 @@ repos: lifecycle_state: active criticality: medium review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: productivity tool_provenance: codex maturity_program: maintain @@ -332,26 +332,26 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code Terroir: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code Cartograph: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code GithubRepoAuditor: owner: d purpose: workbook-first portfolio operating system for repo truth and weekly review lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -363,7 +363,7 @@ repos: purpose: desktop command-center shell for portfolio and operator workflows lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: commercial tool_provenance: codex maturity_program: maintain @@ -374,79 +374,91 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code Seismoscope: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code AIWorkFlow: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: commercial tool_provenance: claude-code job-search-2026: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity Phantom Frequencies: owner: d + purpose: compact authored supernatural-radio game being finished as an Oddworks release lifecycle_state: active - review_cadence: monthly - intended_disposition: maintain - tool_provenance: claude-code + criticality: high + review_cadence: weekly + operating_path: finish + category: commercial + tool_provenance: codex + maturity_program: finish + target_maturity: operating + notes: Release-candidate branch and playtest packet exist; human cohort, signing, and publication gates remain open. Oddworks reactivated this lane on 2026-07-12. RedditSentimentAnalyzer: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code SignalDecay: owner: d + purpose: one-room 8-12 minute tactile signal-tuning game for the Oddworks label lifecycle_state: active - review_cadence: monthly - intended_disposition: maintain - tool_provenance: claude-code + criticality: high + review_cadence: weekly + operating_path: finish + category: commercial + tool_provenance: codex + maturity_program: finish + target_maturity: operating + notes: Verified one-room packaged candidate exists; naive-player duration, tuning, retention, signing, and publication gates remain open. Oddworks reactivated this lane on 2026-07-12. desktop_terrarium: owner: d lifecycle_state: maintenance review_cadence: monthly - intended_disposition: experiment + operating_path: experiment category: fun tool_provenance: codex APIReverse: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code ArguMap: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code AssistSupport: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: it-work tool_provenance: codex bridge-db: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code category: infrastructure notes: Active operating-layer infrastructure, but scope-closed steady maintenance; surface only concrete bridge, sync, health, or cross-system state decisions. @@ -454,51 +466,51 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code DNSWatcher: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: codex category: vanity GlassLayer: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code IncidentReview: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: it-work tool_provenance: codex ink: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code Interruption Resume Studio: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: it-work tool_provenance: claude-code mcpforge: owner: d purpose: FastMCP server generator and validation toolkit - lifecycle_state: active + lifecycle_state: manual-only criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: claude-code maturity_program: maintain @@ -511,7 +523,7 @@ repos: lifecycle_state: active criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: unknown maturity_program: maintain @@ -523,33 +535,33 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code NetworkDecoder: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code NetworkMapper: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code NeuralNetwork: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code notification-hub: owner: d - lifecycle_state: active + lifecycle_state: manual-only review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code category: infrastructure notes: Active operating-layer localhost daemon; surface runtime or operator-signal decisions only after fresh read-only verification. @@ -557,21 +569,21 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code Pulse Orbit: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code ReturnRadar: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code Signal & Noise: @@ -580,7 +592,7 @@ repos: lifecycle_state: active criticality: medium review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code maturity_program: maintain @@ -592,21 +604,21 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: codex thought-trails: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code TradeOffAtlas: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code automation_eligible: true @@ -614,91 +626,98 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain + category: commercial tool_provenance: claude-code Calibrate: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code GhostRoutes: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive tool_provenance: claude-code maturity_program: archive notes: Archived from active operator attention on 2026-06-13. The corrupt local checkout was preserved at /Users/d/Projects/GhostRoutes.corrupt-20260613T124820Z and /Users/d/Projects/GhostRoutes was restored from a fresh origin clone. Keep this repo archived/deferred unless explicitly reactivated. Liminal: owner: d + purpose: focused atmospheric iOS experience being finished as an Oddworks release lifecycle_state: active + criticality: high review_cadence: weekly - intended_disposition: maintain - tool_provenance: claude-code + operating_path: finish + category: commercial + tool_provenance: codex + maturity_program: finish + target_maturity: operating + notes: Verified unsigned device Release bundle and external cohort packet exist; physical-device, human-proof, signing, and publication gates remain open. Oddworks reactivated this lane on 2026-07-12. Nocturne: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code Redact: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code RoomTone: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code TideEngine: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code automation_eligible: true Wavelength: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code ComplianceKit: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: commercial tool_provenance: claude-code ConvictionMapper: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code DevToolsTranslator: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity GPT_RAG: owner: d - lifecycle_state: active + lifecycle_state: manual-only review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex visual-album-studio: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: fun tool_provenance: codex MoneyPRJsViaGPT/StatusPage: @@ -707,7 +726,7 @@ repos: path lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt IncidentWorkbench: @@ -716,20 +735,20 @@ repos: review reports lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: it-work tool_provenance: codex KBFreshnessDetector: owner: d lifecycle_state: maintenance review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: it-work LoreKeeper: owner: d lifecycle_state: maintenance review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: fun tool_provenance: codex Fun:GamePrjs/OrbitForge: @@ -738,33 +757,33 @@ repos: visuals lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: fun tool_provenance: codex PersonalKBDrafter: owner: d lifecycle_state: maintenance review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: it-work PomGambler: owner: d lifecycle_state: maintenance review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: fun tool_provenance: codex ScreenshotAnnotate: owner: d lifecycle_state: maintenance review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: it-work TicketHandoff: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: it-work purpose: On-demand Jira handoff assistant retained as historical support-tooling reference; not active ideation. notes: Parked from default attention on 2026-06-10; reactivate only if IT handoff tooling becomes an explicit product lane. @@ -772,49 +791,54 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' DeepTank: owner: d + purpose: authored aquatic simulation game being evaluated and finished for the Oddworks label lifecycle_state: active + criticality: high review_cadence: weekly - intended_disposition: maintain - notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); - confirm purpose/criticality.' + operating_path: finish + category: commercial + tool_provenance: codex + maturity_program: finish + target_maturity: operating + notes: Active Oddworks lane, but implementation is lease-protected by a dirty worktree with broad src-tauri deletions; preserve it until the existing owner clears or hands off the lease. DesktopPEt: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' EarthPulse: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' SmartClipboard: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' SnippetLibrary: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' TicketDashboard: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity purpose: On-demand native Jira ticket dashboard for local workload visibility and SLA metrics. notes: Already resolved to manual-only baseline; keep out of default ideation unless IT tooling is explicitly reactivated. @@ -822,14 +846,14 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' sovereign: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: fun tool_provenance: claude-code notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); @@ -838,65 +862,65 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' Conductor: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' Echolocate: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' prompt-englab: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' TerraSynth: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain notes: 'Auto-seeded 2026-06-02 from derived signals (path_confidence: medium); confirm purpose/criticality.' AIFortuneTeller: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: fun Grotto: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain ITServiceHealth: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: it-work Notion: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: it-work PortfolioCommandCenter: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex purpose: Canonical desktop command center over GithubRepoAuditor portfolio truth snapshots and weekly command-center artifacts. @@ -905,7 +929,7 @@ repos: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: infrastructure tool_provenance: codex purpose: Public-safe demo mirror of PortfolioCommandCenter retained for fixture/demo workflows, not an independent ideation source. @@ -914,30 +938,30 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain Undertone: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain Veritas: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain cross-system-smoke: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure cross-provider-egress-guard: owner: d purpose: destination-aware egress-control hardening lane for Claude Code and Codex hook surfaces - lifecycle_state: active + lifecycle_state: manual-only criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: claude-code maturity_program: maintain @@ -949,13 +973,13 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: experiment + operating_path: experiment hermes-harness-foundation: owner: d purpose: local-only manifesto-stage foundation for the planned Frontier Sentinel orchestrator lifecycle_state: experimental review_cadence: weekly - intended_disposition: experiment + operating_path: experiment category: infrastructure maturity_program: experiment target_maturity: functional @@ -965,7 +989,7 @@ repos: purpose: architecture and coherence documentation package for the local Operator OS lifecycle_state: experimental review_cadence: monthly - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: codex maturity_program: experiment @@ -977,7 +1001,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: weekly - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: claude-ai maturity_program: experiment @@ -990,7 +1014,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: ad-hoc - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: claude-ai maturity_program: experiment @@ -1003,7 +1027,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: ad-hoc - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: claude-ai maturity_program: experiment @@ -1016,7 +1040,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: ad-hoc - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: claude-ai maturity_program: experiment @@ -1029,7 +1053,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: ad-hoc - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: claude-ai maturity_program: experiment @@ -1042,7 +1066,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: ad-hoc - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: claude-ai maturity_program: experiment @@ -1054,7 +1078,7 @@ repos: purpose: public-safe static portfolio index for hiring-manager review lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code maturity_program: maintain @@ -1065,70 +1089,89 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure stockpulse: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: vanity BattleGrid: owner: d + purpose: focused tactical grid game being evaluated and finished for the Oddworks label lifecycle_state: active - review_cadence: monthly - intended_disposition: maintain - category: fun + criticality: high + review_cadence: weekly + operating_path: finish + category: commercial + tool_provenance: codex + maturity_program: finish + target_maturity: operating + notes: Active Oddworks lane, but implementation is lease-protected by a dirty feature branch that is behind origin; preserve it until the existing owner clears or hands off the lease. + OddworksCabinet: + owner: d + purpose: evidence-first gallery and release surface for small authored Oddworks experiences + lifecycle_state: active + criticality: high + review_cadence: weekly + operating_path: finish + category: commercial + tool_provenance: codex + maturity_program: finish + target_maturity: operating + automation_eligible: false + notes: Verified private candidate exists with genuine Liminal and SignalDecay media and explicit candidate status; public deployment remains gated until release approval. LegalDocsReview: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: it-work Relay: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain ai-fluency-tracker: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code asc-radar: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: it-work tool_provenance: claude-code cost-tracker: owner: d - lifecycle_state: active + lifecycle_state: manual-only review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: claude-code portfolio-health: owner: d - lifecycle_state: active + lifecycle_state: manual-only review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: claude-code IncidentMgmt: owner: d lifecycle_state: dormant review_cadence: quarterly - intended_disposition: maintain + operating_path: maintain category: it-work SlackIncidentBot: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: it-work purpose: On-demand Slack incident lifecycle bot retained as historical support-tooling reference; not active ideation. notes: Parked from default attention on 2026-06-10; reactivate only if incident tooling becomes an explicit product lane. @@ -1136,7 +1179,7 @@ repos: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: it-work purpose: On-demand local ticket documentation assistant retained as historical support-tooling reference; not active ideation. notes: Parked from default attention on 2026-06-10; reactivate only if IT documentation tooling becomes an explicit product lane. @@ -1144,28 +1187,28 @@ repos: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt ContentEngine: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt DatabaseSchema: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt FreeLanceInvoice: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive maturity_program: archive category: commercial tool_provenance: gpt @@ -1173,49 +1216,49 @@ repos: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt OPscinema: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt PixelForge: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt RealEstate: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt ShipKit: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt SynthWave: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt compliance-suite: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt # --- 2026-07-04 portfolio working-set reconciliation: finish contracts --- @@ -1223,86 +1266,86 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: finish + operating_path: finish maturity_program: finish egress-guard-oss: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: finish + operating_path: finish maturity_program: finish proof-pr: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: finish + operating_path: finish maturity_program: finish devils-advocate: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: finish + operating_path: finish maturity_program: finish agent-permission-diff-bot: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: finish + operating_path: finish maturity_program: finish mcpaudit-web: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: finish + operating_path: finish maturity_program: finish # --- 2026-07-04 portfolio working-set reconciliation: maintain contracts --- shadow-mcp: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain portfolio-mcp: owner: d - lifecycle_state: active + lifecycle_state: manual-only review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: infrastructure harness-scorecard: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain ccq: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain plumbline: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain agent-flight-recorder: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain agent-flight-recorder-local: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain reliquary: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain CivicTwin: owner: d purpose: deterministic civic-grid decision simulator with guided response packages and scoring lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: fun tool_provenance: codex notes: Session 2026-07-11 guided 48-hour replay passed after adding four-action response guidance; keep manual-only unless explicitly reactivated. @@ -1312,7 +1355,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: monthly - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: codex maturity_program: experiment @@ -1321,10 +1364,10 @@ repos: Lazarus: owner: d purpose: guarded repository recovery tool that selects safe historical revisions and blocks sensitive-file restoration - lifecycle_state: maintenance + lifecycle_state: manual-only criticality: medium review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -1336,7 +1379,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: monthly - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: codex maturity_program: experiment @@ -1344,17 +1387,17 @@ repos: notes: Session 2026-07-11 one-week and 32-scenario repeated dogfood matrices passed; a private connector remains unjustified. Keep outside default attention unless explicitly reactivated. continuity: owner: d - lifecycle_state: active + lifecycle_state: manual-only review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure peer-agent-tools: owner: d purpose: local peer-agent dispatch and preflight tooling for branch-lease safety and verified agent handoffs - lifecycle_state: active + lifecycle_state: manual-only criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain maturity_program: maintain target_maturity: operating category: infrastructure @@ -1364,35 +1407,35 @@ repos: owner: d lifecycle_state: experimental review_cadence: monthly - intended_disposition: experiment + operating_path: experiment maturity_program: experiment category: infrastructure doctrine-pilot-sandbox: owner: d lifecycle_state: experimental review_cadence: monthly - intended_disposition: experiment + operating_path: experiment maturity_program: experiment category: infrastructure mcp-kit: owner: d lifecycle_state: experimental review_cadence: monthly - intended_disposition: experiment + operating_path: experiment maturity_program: experiment category: infrastructure portfolio-supercharge-handoff: owner: d lifecycle_state: experimental review_cadence: monthly - intended_disposition: experiment + operating_path: experiment maturity_program: experiment category: infrastructure fable-second-sprint: owner: d lifecycle_state: experimental review_cadence: ad-hoc - intended_disposition: experiment + operating_path: experiment maturity_program: experiment category: vanity notes: Keep out of default attention unless the operator explicitly reactivates this lane. @@ -1401,34 +1444,34 @@ repos: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive mcp-permission-audit-deprecation: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive awesome-mcp-servers: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive portfolio-hooks: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive ccq-archive: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive trust-receipt-generator: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive personal-ops-sendwindow-tests: owner: d lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive diff --git a/config/scorecards.yaml b/config/scorecards.yaml index 294af31..40e66c3 100644 --- a/config/scorecards.yaml +++ b/config/scorecards.yaml @@ -225,7 +225,7 @@ programs: - key: disposition label: Archive Disposition check: catalog_field_in - catalog_field: intended_disposition + catalog_field: operating_path values: [archive] weight: 1.0 - key: readme diff --git a/src/analyzers/activity.py b/src/analyzers/activity.py index 356bdd8..694b7e5 100644 --- a/src/analyzers/activity.py +++ b/src/analyzers/activity.py @@ -17,7 +17,6 @@ class ActivityAnalyzer(BaseAnalyzer): name = "activity" - weight = 0.15 def analyze( self, diff --git a/src/analyzers/base.py b/src/analyzers/base.py index 149c025..e0269bf 100644 --- a/src/analyzers/base.py +++ b/src/analyzers/base.py @@ -14,7 +14,6 @@ class BaseAnalyzer(ABC): """Abstract base for all repo analyzers.""" name: str - weight: float @abstractmethod def analyze( diff --git a/src/analyzers/cicd.py b/src/analyzers/cicd.py index 1acf5fd..71a3efa 100644 --- a/src/analyzers/cicd.py +++ b/src/analyzers/cicd.py @@ -22,7 +22,6 @@ class CicdAnalyzer(BaseAnalyzer): name = "cicd" - weight = 0.10 def analyze( self, diff --git a/src/analyzers/code_quality.py b/src/analyzers/code_quality.py index 37b01be..69dff68 100644 --- a/src/analyzers/code_quality.py +++ b/src/analyzers/code_quality.py @@ -50,7 +50,6 @@ class CodeQualityAnalyzer(BaseAnalyzer): name = "code_quality" - weight = 0.15 def analyze( self, diff --git a/src/analyzers/community_profile.py b/src/analyzers/community_profile.py index 7e1b132..331871f 100644 --- a/src/analyzers/community_profile.py +++ b/src/analyzers/community_profile.py @@ -32,7 +32,6 @@ class CommunityProfileAnalyzer(BaseAnalyzer): """ name = "community_profile" - weight = 0.03 def analyze( self, diff --git a/src/analyzers/completeness.py b/src/analyzers/completeness.py index 220b974..336fb54 100644 --- a/src/analyzers/completeness.py +++ b/src/analyzers/completeness.py @@ -52,7 +52,6 @@ class DocumentationAnalyzer(BaseAnalyzer): name = "documentation" - weight = 0.05 def analyze( self, @@ -108,7 +107,6 @@ def analyze( class BuildReadinessAnalyzer(BaseAnalyzer): name = "build_readiness" - weight = 0.05 def analyze( self, diff --git a/src/analyzers/dependencies.py b/src/analyzers/dependencies.py index 5d14f0c..b54a599 100644 --- a/src/analyzers/dependencies.py +++ b/src/analyzers/dependencies.py @@ -41,7 +41,6 @@ class DependenciesAnalyzer(BaseAnalyzer): name = "dependencies" - weight = 0.10 def cache_inputs_hash( self, diff --git a/src/analyzers/interest.py b/src/analyzers/interest.py index 435db32..5dbe3ce 100644 --- a/src/analyzers/interest.py +++ b/src/analyzers/interest.py @@ -35,7 +35,6 @@ class InterestAnalyzer(BaseAnalyzer): """ name = "interest" - weight = 0.0 # Not part of completeness score — separate axis def analyze( self, diff --git a/src/analyzers/readme.py b/src/analyzers/readme.py index 3ee9ab9..0ff5370 100644 --- a/src/analyzers/readme.py +++ b/src/analyzers/readme.py @@ -36,7 +36,6 @@ class ReadmeAnalyzer(BaseAnalyzer): name = "readme" - weight = 0.15 def cache_inputs_hash( self, diff --git a/src/analyzers/security.py b/src/analyzers/security.py index ee07d62..6474789 100644 --- a/src/analyzers/security.py +++ b/src/analyzers/security.py @@ -112,7 +112,6 @@ class SecurityAnalyzer(BaseAnalyzer): """Scans for security surface issues — exposed secrets, dangerous files, missing config.""" name = "security" - weight = 0.0 # Advisory dimension, not part of completeness score def analyze( self, diff --git a/src/analyzers/structure.py b/src/analyzers/structure.py index 8065c8a..02af1a1 100644 --- a/src/analyzers/structure.py +++ b/src/analyzers/structure.py @@ -42,7 +42,6 @@ class StructureAnalyzer(BaseAnalyzer): name = "structure" - weight = 0.10 def cache_inputs_hash( self, diff --git a/src/analyzers/testing.py b/src/analyzers/testing.py index a1566a8..1556408 100644 --- a/src/analyzers/testing.py +++ b/src/analyzers/testing.py @@ -21,7 +21,6 @@ class TestingAnalyzer(BaseAnalyzer): name = "testing" - weight = 0.15 def analyze( self, diff --git a/src/app/portfolio_truth.py b/src/app/portfolio_truth.py index 987b789..dd64770 100644 --- a/src/app/portfolio_truth.py +++ b/src/app/portfolio_truth.py @@ -85,7 +85,7 @@ def run_portfolio_truth_mode(args: Any) -> None: producer_evidence=producer_evidence, producer_repo_root=producer_repo_root, require_producer_evidence=bool( - os.environ.get("GHRA_REQUIRE_PRODUCER_EVIDENCE") == "1" + os.environ.get("GHRA_REQUIRE_PRODUCER_EVIDENCE", "1") == "1" ), ) except (PortfolioTruthPublishError, ValueError) as exc: diff --git a/src/app/run_audit.py b/src/app/run_audit.py index e8505b0..be0b755 100644 --- a/src/app/run_audit.py +++ b/src/app/run_audit.py @@ -53,6 +53,17 @@ DEFAULT_ANALYSIS_WORKERS = 1 MAX_ANALYSIS_WORKERS = 8 DEFAULT_PORTFOLIO_WORKSPACE = Path.home() / "Projects" +_SCORING_PROFILE_RESERVED_KEYS = frozenset( + {"stale_threshold_days", "grade_thresholds", "completeness_tiers"} +) + + +class ScoringProfile(dict[str, float]): + """Flat profile weights plus optional scoring-constant overrides.""" + + def __init__(self, weights: dict[str, float], *, overrides: dict[str, object]) -> None: + super().__init__(weights) + self.overrides = overrides CLI_MODE_GUIDE = """GitHub portfolio operating system with four product modes: First Run setup, baseline creation, first workbook, first control-center read Weekly Review normal workbook-first operator loop @@ -545,6 +556,7 @@ def _analyze_one(repo_meta: RepoMetadata, repo_path: Path) -> RepoAudit: repo_path=repo_path, portfolio_lang_freq=portfolio_lang_freq, custom_weights=custom_weights, + scoring_profile=getattr(custom_weights, "overrides", None), github_client=worker_client, scorecard_enabled=args.scorecard, security_offline=args.security_offline, @@ -1646,7 +1658,13 @@ def _load_scoring_profile(profile_name: str | None) -> tuple[dict[str, float] | profile_path = Path(f"config/scoring-profiles/{profile_name}.json") if profile_path.is_file(): print_info(f"Using scoring profile: {profile_name}") - return json.loads(profile_path.read_text()), normalized + profile = json.loads(profile_path.read_text()) + overrides = { + key: profile.pop(key) + for key in _SCORING_PROFILE_RESERVED_KEYS + if key in profile + } + return ScoringProfile(profile, overrides=overrides), normalized print_warning(f"Scoring profile not found: {profile_path}") return None, normalized diff --git a/src/catalog_validator.py b/src/catalog_validator.py index 6b3830f..4431649 100644 --- a/src/catalog_validator.py +++ b/src/catalog_validator.py @@ -1,5 +1,6 @@ # src/catalog_validator.py """Catalog completeness validator for portfolio-catalog.yaml entries (Arc H A3).""" + from __future__ import annotations from pathlib import Path @@ -11,15 +12,24 @@ "owner", "lifecycle_state", "review_cadence", - "intended_disposition", + "operating_path", ) +# `intended_disposition` is a deprecated vintage of `operating_path` (same value +# domain), kept as a read-compat fallback for one release so entries that haven't +# migrated yet still score as complete. See portfolio_pathing.resolve_declared_operating_path. +_LEGACY_FIELD_ALIASES: dict[str, str] = {"operating_path": "intended_disposition"} + def score_catalog_entry(entry: dict[str, Any] | None) -> float: """Return completeness score (0.0-1.0) for a single catalog repo entry.""" if not entry: return 0.0 - present = sum(1 for f in REQUIRED_FIELDS if entry.get(f)) + present = sum( + 1 + for f in REQUIRED_FIELDS + if entry.get(f) or entry.get(_LEGACY_FIELD_ALIASES.get(f, "")) + ) return present / len(REQUIRED_FIELDS) diff --git a/src/excel_action_items_helpers.py b/src/excel_action_items_helpers.py index cd8aaef..7b34c30 100644 --- a/src/excel_action_items_helpers.py +++ b/src/excel_action_items_helpers.py @@ -4,6 +4,8 @@ from typing import Any +from src.scorer import display_dimension + ACTION_ITEMS_HEADERS = ["#", "Repo", "Action", "Impact", "Effort", "Dimension"] EFFORT_MAP = { "readme": "Low", @@ -42,11 +44,13 @@ def collect_action_items(data: dict[str, Any]) -> list[dict[str, Any]]: for result in audit.get("analyzer_results", []) if result["dimension"] != "interest" } - for dimension, score in sorted(dimension_scores.items(), key=lambda item: item[1])[:2]: + for dimension, score in sorted( + dimension_scores.items(), key=lambda item: item[1] + )[:2]: actions.append( { "repo": audit["metadata"]["name"], - "action": f"Improve {dimension} (currently {score:.1f})", + "action": f"Improve {display_dimension(dimension)} (currently {score:.1f})", "impact": f"Close {gap:.3f} gap to {next_tier}", "effort": EFFORT_MAP.get(dimension, "Med"), "dimension": dimension, @@ -69,7 +73,9 @@ def collect_action_items(data: dict[str, Any]) -> list[dict[str, Any]]: ) effort_order = {"Low": 0, "Med": 1, "High": 2} - actions.sort(key=lambda action: (effort_order.get(action["effort"], 1), action["gap"])) + actions.sort( + key=lambda action: (effort_order.get(action["effort"], 1), action["gap"]) + ) seen: set[tuple[str, str]] = set() unique: list[dict[str, Any]] = [] @@ -102,7 +108,9 @@ def write_action_items_sections( ws.freeze_panes = "A5" if content["sprint_rows"]: - ws.cell(row=3, column=1, value="Weekly Sprint (Top 5 Low-Effort)").font = section_font + ws.cell( + row=3, column=1, value="Weekly Sprint (Top 5 Low-Effort)" + ).font = section_font for col, header in enumerate(ACTION_ITEMS_HEADERS, 1): ws.cell(row=4, column=col, value=header) style_header_row(ws, 4, len(ACTION_ITEMS_HEADERS)) @@ -111,7 +119,9 @@ def write_action_items_sections( style_data_cell(ws.cell(row=row_number, column=col, value=value)) full_start = len(content["sprint_rows"]) + 7 - ws.cell(row=full_start, column=1, value="All Actions (Prioritized)").font = section_font + ws.cell( + row=full_start, column=1, value="All Actions (Prioritized)" + ).font = section_font full_start += 1 for col, header in enumerate(ACTION_ITEMS_HEADERS, 1): ws.cell(row=full_start, column=col, value=header) @@ -136,7 +146,7 @@ def _build_action_rows(actions: list[dict[str, Any]]) -> list[list[Any]]: action["action"], action["impact"], action["effort"], - action["dimension"], + display_dimension(action["dimension"]), ] for index, action in enumerate(actions, start=1) ] diff --git a/src/excel_all_repos_helpers.py b/src/excel_all_repos_helpers.py index fc3f3cf..f9aa007 100644 --- a/src/excel_all_repos_helpers.py +++ b/src/excel_all_repos_helpers.py @@ -9,6 +9,7 @@ from openpyxl.styles import Alignment, Font from openpyxl.utils import get_column_letter from openpyxl.worksheet.datavalidation import DataValidation +from src.scorer import PARTIAL_RUN_BASIS_THRESHOLD, WEIGHTS, display_dimension ALL_REPOS_HEADERS = [ "Repo", @@ -51,6 +52,8 @@ "Size (KB)", "Trend", "Risk Tier", + "Scored Dimensions", + "Scored Weight Sum", ] ALL_REPOS_LONG_TEXT_HEADERS = ("Description", "Topics", "Badges") @@ -59,7 +62,7 @@ ALL_REPOS_INTEREST_COLUMN = 4 ALL_REPOS_TIER_COLUMN = 7 ALL_REPOS_PATTERN_COLUMN = 12 -ALL_REPOS_TREND_COLUMN_OFFSET = 1 +ALL_REPOS_TREND_COLUMN_OFFSET = 3 ALL_REPOS_LONG_TEXT_WIDTH = 60 @@ -100,7 +103,7 @@ def build_all_repo_rows( "commit_pattern": activity.get("commit_pattern", ""), "values": [ repo_name, - audit.get("grade", "F"), + _build_display_grade(audit), round(audit.get("overall_score", 0), 3), round(audit.get("interest_score", 0), 3), audit.get("interest_grade", "—"), @@ -143,6 +146,8 @@ def build_all_repo_rows( metadata.get("size_kb", 0), render_sparkline(trend_scores), (risk_lookup or {}).get(str(repo_name), ""), + ", ".join(audit.get("scored_dimensions", [])), + round(audit.get("scored_weight_sum", 0), 3), ], } ) @@ -158,7 +163,24 @@ def _build_biggest_drag(audit: dict[str, Any]) -> str: if not dimension_scores: return "—" worst_dimension = min(dimension_scores, key=dimension_scores.get) - return f"{worst_dimension} ({dimension_scores[worst_dimension]:.1f})" + return f"{display_dimension(worst_dimension)} ({dimension_scores[worst_dimension]:.1f})" + + +def _build_display_grade(audit: dict[str, Any]) -> str: + """Render partial-run basis disclosure without changing the model grade.""" + grade = str(audit.get("grade", "F")) + if " on " in grade: + return grade + + scored_dimensions = audit.get("scored_dimensions") or [] + scored_weight_sum = audit.get("scored_weight_sum") + if ( + scored_dimensions + and isinstance(scored_weight_sum, (int, float)) + and scored_weight_sum < sum(WEIGHTS.values()) * PARTIAL_RUN_BASIS_THRESHOLD + ): + return f"{grade} on {len(scored_dimensions)}/{len(WEIGHTS)} dimensions" + return grade def _build_grade_reason(audit: dict[str, Any]) -> str: @@ -167,15 +189,15 @@ def _build_grade_reason(audit: dict[str, Any]) -> str: for result in audit.get("analyzer_results", []) if result["dimension"] != "interest" } - grade = audit.get("grade", "F") + grade = _build_display_grade(audit) if not dimension_scores: return grade weakest_dimensions = sorted(dimension_scores.items(), key=lambda item: item[1])[:2] if len(weakest_dimensions) < 2: return grade return ( - f"{grade}: {weakest_dimensions[0][0]}={weakest_dimensions[0][1]:.1f}, " - f"{weakest_dimensions[1][0]}={weakest_dimensions[1][1]:.1f}" + f"{grade}: {display_dimension(weakest_dimensions[0][0])}={weakest_dimensions[0][1]:.1f}, " + f"{display_dimension(weakest_dimensions[1][0])}={weakest_dimensions[1][1]:.1f}" ) diff --git a/src/excel_dashboard_helpers.py b/src/excel_dashboard_helpers.py index 53cb3c7..b5b58da 100644 --- a/src/excel_dashboard_helpers.py +++ b/src/excel_dashboard_helpers.py @@ -774,7 +774,7 @@ def _write_dashboard_distribution_charts( grade_dist = {} for audit in audits: - grade = audit.get("grade", "F") + grade = audit.get("grade", "F").split(" ", 1)[0] grade_dist[grade] = grade_dist.get(grade, 0) + 1 grade_label_col = 27 grade_value_col = 28 diff --git a/src/excel_repo_data_helpers.py b/src/excel_repo_data_helpers.py index 39d3611..ccf328e 100644 --- a/src/excel_repo_data_helpers.py +++ b/src/excel_repo_data_helpers.py @@ -12,6 +12,7 @@ build_score_explanation, ) from src.sparkline import sparkline as render_sparkline +from src.scorer import display_dimension def score_explanation_for_audit(audit: dict) -> dict: @@ -300,7 +301,9 @@ def repo_detail_rows( ) for rank, (dimension, score, summary) in enumerate(ranked_dimensions, 1): lookup_key = f"{repo_name}::{rank}" - dimension_rows.append([lookup_key, repo_name, rank, dimension, score, summary]) + dimension_rows.append( + [lookup_key, repo_name, rank, display_dimension(dimension), score, summary] + ) for run_index, score in enumerate(scores, 1): history_rows.append([repo_name, run_index, score, render_sparkline(scores)]) diff --git a/src/excel_score_explainer_helpers.py b/src/excel_score_explainer_helpers.py index 25409e3..8361168 100644 --- a/src/excel_score_explainer_helpers.py +++ b/src/excel_score_explainer_helpers.py @@ -4,6 +4,8 @@ from typing import Any +from src.scorer import UNSCORED_DIMENSIONS, display_dimension + SCORE_EXPLAINER_HEADERS = ["Dimension", "Weight", "What It Measures", "How to Improve"] DIMENSION_INFO = { @@ -41,6 +43,10 @@ "docs/ dir, CHANGELOG, comment density", "Add docs/ folder or CHANGELOG.md", ), + "description": ( + "Repository description confidence and consistency", + "Add a concise, accurate repository description", + ), } @@ -53,12 +59,21 @@ def build_score_explainer_content( return { "dimension_rows": [ [ - dimension, + display_dimension(dimension), f"{weight:.0%}", DIMENSION_INFO.get(dimension, ("", ""))[0], DIMENSION_INFO.get(dimension, ("", ""))[1], ] for dimension, weight in sorted(weights.items(), key=lambda item: item[1], reverse=True) + ] + [ + [ + display_dimension(dimension), + "Unscored", + DIMENSION_INFO.get(dimension, ("", ""))[0], + DIMENSION_INFO.get(dimension, ("", ""))[1], + ] + for dimension in sorted(UNSCORED_DIMENSIONS) + if dimension not in weights ], "grade_rows": [[grade, f">= {threshold:.0%}"] for threshold, grade in grade_thresholds], "tier_rows": [ diff --git a/src/excel_styles.py b/src/excel_styles.py index ea4ca43..0384f06 100644 --- a/src/excel_styles.py +++ b/src/excel_styles.py @@ -270,8 +270,9 @@ def write_kpi_card( def color_grade_cell(cell, grade: str) -> None: """Apply grade-specific coloring to a cell.""" - if grade in GRADE_FILLS: - cell.fill = GRADE_FILLS[grade] + grade_letter = grade.split(" ", 1)[0] + if grade_letter in GRADE_FILLS: + cell.fill = GRADE_FILLS[grade_letter] cell.font = Font("Calibri", 10, bold=True, color=WHITE) diff --git a/src/models.py b/src/models.py index 762f499..46b81f2 100644 --- a/src/models.py +++ b/src/models.py @@ -97,6 +97,8 @@ class RepoAudit: interest_score: float = 0.0 interest_tier: str = "mundane" grade: str = "F" + scored_dimensions: list[str] = field(default_factory=list) + scored_weight_sum: float = 0.0 interest_grade: str = "F" badges: list[str] = field(default_factory=list) next_badges: list[dict] = field(default_factory=list) @@ -122,6 +124,8 @@ def to_dict(self) -> dict: "completeness_tier": self.completeness_tier, "interest_tier": self.interest_tier, "grade": self.grade, + "scored_dimensions": self.scored_dimensions, + "scored_weight_sum": round(self.scored_weight_sum, 3), "interest_grade": self.interest_grade, "badges": self.badges, "next_badges": self.next_badges, diff --git a/src/operator_control_center_artifacts.py b/src/operator_control_center_artifacts.py index 6f8309a..57364d3 100644 --- a/src/operator_control_center_artifacts.py +++ b/src/operator_control_center_artifacts.py @@ -69,6 +69,7 @@ def write_control_center_artifacts( snapshot, diff_data=diff_dict, portfolio_truth=portfolio_truth, + portfolio_truth_history_dir=portfolio_truth_path.parent if portfolio_truth_path else None, portfolio_truth_reference=str(portfolio_truth_path) if portfolio_truth_path else "", control_center_reference=str(json_path), report_reference=report_reference, diff --git a/src/portfolio_automation.py b/src/portfolio_automation.py index 24635c1..75cb968 100644 --- a/src/portfolio_automation.py +++ b/src/portfolio_automation.py @@ -7,7 +7,7 @@ * the portfolio's ``decision_quality_status`` is ``"trusted"`` (a portfolio-wide gate — when calibration is noisy/mixed/insufficient, nothing is eligible); * the repo's ``path_confidence`` is ``"high"`` (its operating path is settled); -* the repo's ``registry_status`` is active or candidate (not archived/parked); +* the repo's ``activity_status`` is active or candidate (not archived/parked); * the repo's ``context_quality`` is non-trivial (not boilerplate/none/unknown). Selecting candidates does NOT propose or apply any change — proposal creation @@ -25,7 +25,7 @@ # creation, execution re-checks) reuse the exact same gate. TRUSTED_DECISION_QUALITY = "trusted" ELIGIBLE_PATH_CONFIDENCE = frozenset({"high"}) -ELIGIBLE_REGISTRY_STATUS = frozenset({"active", "candidate"}) +ELIGIBLE_ACTIVITY_STATUS = frozenset({"active", "candidate"}) ELIGIBLE_CONTEXT_QUALITY = frozenset({"minimum-viable", "standard", "full"}) MAX_AUTOMATION_CANDIDATES = 25 @@ -53,7 +53,7 @@ class AutomationCandidate: display_name: str repo_full_name: str - registry_status: str + activity_status: str path_confidence: str context_quality: str @@ -61,7 +61,7 @@ def to_dict(self) -> dict[str, str]: return { "repo": self.display_name, "repo_full_name": self.repo_full_name, - "registry_status": self.registry_status, + "activity_status": self.activity_status, "path_confidence": self.path_confidence, "context_quality": self.context_quality, } @@ -80,8 +80,8 @@ def evaluate_automation_eligibility( blockers: list[str] = [] if _text(decision_quality_status) != TRUSTED_DECISION_QUALITY: blockers.append("decision-quality-not-trusted") - if _text(derived.get("registry_status")) not in ELIGIBLE_REGISTRY_STATUS: - blockers.append("registry-status-not-eligible") + if _text(derived.get("activity_status")) not in ELIGIBLE_ACTIVITY_STATUS: + blockers.append("activity-status-not-eligible") if _text(derived.get("path_confidence")) not in ELIGIBLE_PATH_CONFIDENCE: blockers.append("path-confidence-not-high") if _text(derived.get("context_quality")) not in ELIGIBLE_CONTEXT_QUALITY: @@ -109,7 +109,7 @@ def select_automation_candidates( AutomationCandidate( display_name=_text(identity.get("display_name")) or "Repo", repo_full_name=_text(identity.get("repo_full_name")), - registry_status=_text(derived.get("registry_status")), + activity_status=_text(derived.get("activity_status")), path_confidence=_text(derived.get("path_confidence")), context_quality=_text(derived.get("context_quality")), ) diff --git a/src/portfolio_catalog.py b/src/portfolio_catalog.py index 3ddf2a8..68897ac 100644 --- a/src/portfolio_catalog.py +++ b/src/portfolio_catalog.py @@ -4,11 +4,29 @@ from pathlib import Path from typing import Any -VALID_LIFECYCLE_STATES = {"active", "maintenance", "dormant", "experimental", "archived"} +from src.portfolio_pathing import VALID_OPERATING_PATHS + +VALID_LIFECYCLE_STATES = { + "active", + "maintenance", + "manual-only", + "dormant", + "experimental", + "archived", +} VALID_CRITICALITY = {"low", "medium", "high", "critical"} VALID_REVIEW_CADENCE = {"weekly", "monthly", "quarterly", "ad-hoc"} -VALID_INTENDED_DISPOSITIONS = {"maintain", "finish", "archive", "experiment"} -VALID_CATEGORY_TAGS = {"commercial", "it-work", "vanity", "fun", "learning", "infrastructure"} +# Deprecated vintage of VALID_OPERATING_PATHS (same value domain) — kept for one +# release as the enum for the read-compat `intended_disposition` field. +VALID_INTENDED_DISPOSITIONS = VALID_OPERATING_PATHS +VALID_CATEGORY_TAGS = { + "commercial", + "it-work", + "vanity", + "fun", + "learning", + "infrastructure", +} VALID_TOOL_PROVENANCE = {"claude-code", "codex", "gpt", "grok", "claude-ai", "unknown"} VALID_DOCTOR_STANDARDS = {"full", "basic"} VALID_AUTOMATION_ELIGIBLE = {"true", "false"} @@ -28,6 +46,15 @@ def _normalize_enum(value: Any, allowed: set[str]) -> str: return normalized if normalized in allowed else "" +def _declared_operating_path(entry: dict[str, Any]) -> str: + """Read a normalized catalog entry's operating-path axis, preferring the + canonical `operating_path` field and falling back to the deprecated + `intended_disposition` vintage for entries that haven't migrated yet.""" + return _safe_text( + entry.get("operating_path") or entry.get("intended_disposition") + ).lower() + + def load_portfolio_catalog(path: Path | None = None) -> dict[str, Any]: catalog_path = path or DEFAULT_CATALOG_PATH if not catalog_path.is_file(): @@ -48,7 +75,9 @@ def load_portfolio_catalog(path: Path | None = None) -> dict[str, Any]: "path": str(catalog_path), "exists": True, "errors": [], - "warnings": ["PyYAML is not installed, so the portfolio catalog was skipped."], + "warnings": [ + "PyYAML is not installed, so the portfolio catalog was skipped." + ], "defaults": {}, "groups": {}, "repos": {}, @@ -81,8 +110,12 @@ def load_portfolio_catalog(path: Path | None = None) -> dict[str, Any]: errors: list[str] = [] warnings: list[str] = [] defaults = _normalize_defaults(loaded.get("defaults") or {}, errors) - groups = _normalize_group_entries(loaded.get("groups") or {}, defaults, errors, warnings) - repos = _normalize_repo_entries(loaded.get("repos") or {}, defaults, errors, warnings) + groups = _normalize_group_entries( + loaded.get("groups") or {}, defaults, errors, warnings + ) + repos = _normalize_repo_entries( + loaded.get("repos") or {}, defaults, errors, warnings + ) return { "path": str(catalog_path), "exists": True, @@ -101,11 +134,17 @@ def _normalize_defaults(defaults: Any, errors: list[str]) -> dict[str, str]: return {} normalized = { - "lifecycle_state": _normalize_enum(defaults.get("lifecycle_state"), VALID_LIFECYCLE_STATES), + "lifecycle_state": _normalize_enum( + defaults.get("lifecycle_state"), VALID_LIFECYCLE_STATES + ), "criticality": _normalize_enum(defaults.get("criticality"), VALID_CRITICALITY), - "review_cadence": _normalize_enum(defaults.get("review_cadence"), VALID_REVIEW_CADENCE), + "review_cadence": _normalize_enum( + defaults.get("review_cadence"), VALID_REVIEW_CADENCE + ), "category": _normalize_enum(defaults.get("category"), VALID_CATEGORY_TAGS), - "tool_provenance": _normalize_enum(defaults.get("tool_provenance"), VALID_TOOL_PROVENANCE), + "tool_provenance": _normalize_enum( + defaults.get("tool_provenance"), VALID_TOOL_PROVENANCE + ), "maturity_program": _safe_text(defaults.get("maturity_program")).lower(), "target_maturity": _safe_text(defaults.get("target_maturity")).lower(), } @@ -124,6 +163,31 @@ def _normalize_defaults(defaults: Any, errors: list[str]) -> dict[str, str]: return {key: value for key, value in normalized.items() if value} +def _normalize_operating_path( + raw_value: dict[str, Any], *, label: str, warnings: list[str] +) -> tuple[str, str]: + """Normalize the operating-path axis for one catalog entry. + + Returns ``(operating_path, intended_disposition)``. `intended_disposition` is a + deprecated vintage of `operating_path` (same value domain), kept as a read-compat + fallback for one release — see portfolio_pathing.resolve_declared_operating_path. + A raw entry that still declares it is flagged with a deprecation warning, not an + error, so hand-edits that reintroduce the old field get caught at validation time. + """ + operating_path = _normalize_enum( + raw_value.get("operating_path"), VALID_OPERATING_PATHS + ) + intended_disposition = _normalize_enum( + raw_value.get("intended_disposition"), VALID_INTENDED_DISPOSITIONS + ) + if raw_value.get("intended_disposition"): + warnings.append( + f"{label} still declares deprecated 'intended_disposition'; migrate to " + "'operating_path' (intended_disposition is slated for removal next release)." + ) + return operating_path, intended_disposition + + def _normalize_group_entries( entries: Any, defaults: dict[str, str], @@ -132,7 +196,9 @@ def _normalize_group_entries( ) -> dict[str, dict[str, Any]]: if not isinstance(entries, dict): if entries: - errors.append("Portfolio catalog groups must be a mapping keyed by group name.") + errors.append( + "Portfolio catalog groups must be a mapping keyed by group name." + ) return {} normalized_entries: dict[str, dict[str, Any]] = {} @@ -146,7 +212,9 @@ def _normalize_group_entries( errors.append(f"Portfolio catalog group '{key}' must be a mapping.") continue - raw_prefixes = raw_value.get("path_prefixes") or raw_value.get("match_paths") or [] + raw_prefixes = ( + raw_value.get("path_prefixes") or raw_value.get("match_paths") or [] + ) if isinstance(raw_prefixes, str): raw_prefixes = [raw_prefixes] if not isinstance(raw_prefixes, list): @@ -173,6 +241,10 @@ def _normalize_group_entries( ) continue + operating_path, intended_disposition = _normalize_operating_path( + raw_value, label=f"Portfolio catalog group '{key}'", warnings=warnings + ) + normalized = { "group_key": key, "label": _safe_text(raw_value.get("label")) or key, @@ -190,13 +262,16 @@ def _normalize_group_entries( raw_value.get("lifecycle_state"), VALID_LIFECYCLE_STATES ) or defaults.get("lifecycle_state", ""), - "criticality": _normalize_enum(raw_value.get("criticality"), VALID_CRITICALITY) + "criticality": _normalize_enum( + raw_value.get("criticality"), VALID_CRITICALITY + ) or defaults.get("criticality", ""), - "review_cadence": _normalize_enum(raw_value.get("review_cadence"), VALID_REVIEW_CADENCE) + "review_cadence": _normalize_enum( + raw_value.get("review_cadence"), VALID_REVIEW_CADENCE + ) or defaults.get("review_cadence", ""), - "intended_disposition": _normalize_enum( - raw_value.get("intended_disposition"), VALID_INTENDED_DISPOSITIONS - ), + "operating_path": operating_path, + "intended_disposition": intended_disposition, "category": _normalize_enum(raw_value.get("category"), VALID_CATEGORY_TAGS) or defaults.get("category", ""), "tool_provenance": _normalize_enum( @@ -217,6 +292,7 @@ def _normalize_group_entries( ("lifecycle_state", VALID_LIFECYCLE_STATES), ("criticality", VALID_CRITICALITY), ("review_cadence", VALID_REVIEW_CADENCE), + ("operating_path", VALID_OPERATING_PATHS), ("intended_disposition", VALID_INTENDED_DISPOSITIONS), ("category", VALID_CATEGORY_TAGS), ("tool_provenance", VALID_TOOL_PROVENANCE), @@ -245,7 +321,9 @@ def _normalize_repo_entries( ) -> dict[str, dict[str, Any]]: if not isinstance(entries, dict): if entries: - errors.append("Portfolio catalog repos must be a mapping keyed by repo name.") + errors.append( + "Portfolio catalog repos must be a mapping keyed by repo name." + ) return {} normalized_entries: dict[str, dict[str, Any]] = {} @@ -258,6 +336,9 @@ def _normalize_repo_entries( if not isinstance(raw_value, dict): errors.append(f"Portfolio catalog entry '{key}' must be a mapping.") continue + operating_path, intended_disposition = _normalize_operating_path( + raw_value, label=f"Portfolio catalog entry '{key}'", warnings=warnings + ) normalized = { "owner": _safe_text(raw_value.get("owner")), "team": _safe_text(raw_value.get("team")), @@ -266,13 +347,16 @@ def _normalize_repo_entries( raw_value.get("lifecycle_state"), VALID_LIFECYCLE_STATES ) or defaults.get("lifecycle_state", ""), - "criticality": _normalize_enum(raw_value.get("criticality"), VALID_CRITICALITY) + "criticality": _normalize_enum( + raw_value.get("criticality"), VALID_CRITICALITY + ) or defaults.get("criticality", ""), - "review_cadence": _normalize_enum(raw_value.get("review_cadence"), VALID_REVIEW_CADENCE) + "review_cadence": _normalize_enum( + raw_value.get("review_cadence"), VALID_REVIEW_CADENCE + ) or defaults.get("review_cadence", ""), - "intended_disposition": _normalize_enum( - raw_value.get("intended_disposition"), VALID_INTENDED_DISPOSITIONS - ), + "operating_path": operating_path, + "intended_disposition": intended_disposition, "category": _normalize_enum(raw_value.get("category"), VALID_CATEGORY_TAGS) or defaults.get("category", ""), "tool_provenance": _normalize_enum( @@ -287,7 +371,9 @@ def _normalize_repo_entries( "doctor_standard": _normalize_enum( raw_value.get("doctor_standard"), VALID_DOCTOR_STANDARDS ), - "automation_eligible": _safe_text(raw_value.get("automation_eligible", "")).lower() + "automation_eligible": _safe_text( + raw_value.get("automation_eligible", "") + ).lower() == "true", "catalog_key": key, "matched_by": "full-name" if "/" in key else "bare-name", @@ -301,6 +387,7 @@ def _normalize_repo_entries( ("lifecycle_state", VALID_LIFECYCLE_STATES), ("criticality", VALID_CRITICALITY), ("review_cadence", VALID_REVIEW_CADENCE), + ("operating_path", VALID_OPERATING_PATHS), ("intended_disposition", VALID_INTENDED_DISPOSITIONS), ("category", VALID_CATEGORY_TAGS), ("tool_provenance", VALID_TOOL_PROVENANCE), @@ -406,6 +493,7 @@ def catalog_entry_for_repo( "lifecycle_state": "", "criticality": "", "review_cadence": "", + "operating_path": "", "intended_disposition": "", "category": "", "tool_provenance": "", @@ -453,6 +541,7 @@ def group_entry_for_path( "lifecycle_state": "", "criticality": "", "review_cadence": "", + "operating_path": "", "intended_disposition": "", "category": "", "tool_provenance": "", @@ -479,13 +568,16 @@ def build_catalog_line(entry: dict[str, Any]) -> str: segments.append(f"criticality {entry['criticality']}") if entry.get("review_cadence"): segments.append(f"cadence {entry['review_cadence']}") - if entry.get("intended_disposition"): - segments.append(f"disposition {entry['intended_disposition']}") + declared_path = _declared_operating_path(entry) + if declared_path: + segments.append(f"disposition {declared_path}") if entry.get("maturity_program"): segments.append(f"program {entry['maturity_program']}") if entry.get("target_maturity"): segments.append(f"target {entry['target_maturity']}") - return " | ".join(segments) if segments else "Portfolio catalog contract is present." + return ( + " | ".join(segments) if segments else "Portfolio catalog contract is present." + ) def evaluate_intent_alignment( @@ -501,7 +593,7 @@ def evaluate_intent_alignment( "No explicit portfolio catalog contract is recorded for this repo yet.", ) - disposition = _safe_text(entry.get("intended_disposition")).lower() + disposition = _declared_operating_path(entry) tier = _safe_text(completeness_tier).lower() focus = _safe_text(operator_focus) raw_scorecard = entry.get("scorecard") @@ -534,8 +626,15 @@ def evaluate_intent_alignment( "aligned", "The repo is meeting its maintain scorecard target.", ) - if disposition == "finish" and tier in {"wip", "functional"} and focus != "Revalidate": - return ("aligned", "The repo still looks finishable rather than fully off-track.") + if ( + disposition == "finish" + and tier in {"wip", "functional"} + and focus != "Revalidate" + ): + return ( + "aligned", + "The repo still looks finishable rather than fully off-track.", + ) return ( "needs-review", @@ -543,7 +642,9 @@ def evaluate_intent_alignment( ) -def build_portfolio_catalog_summary(audits: list[Any], *, catalog_path: str = "") -> dict[str, Any]: +def build_portfolio_catalog_summary( + audits: list[Any], *, catalog_path: str = "" +) -> dict[str, Any]: lifecycle: Counter[str] = Counter() criticality: Counter[str] = Counter() cadence: Counter[str] = Counter() @@ -566,8 +667,9 @@ def build_portfolio_catalog_summary(audits: list[Any], *, catalog_path: str = "" criticality[entry["criticality"]] += 1 if entry.get("review_cadence"): cadence[entry["review_cadence"]] += 1 - if entry.get("intended_disposition"): - disposition[entry["intended_disposition"]] += 1 + declared_path = _declared_operating_path(entry) + if declared_path: + disposition[declared_path] += 1 if entry.get("maturity_program"): disposition[f"program:{entry['maturity_program']}"] += 1 if entry.get("team") or entry.get("owner"): @@ -616,4 +718,4 @@ def build_intent_alignment_summary(audits: list[Any]) -> dict[str, Any]: return { "counts": dict(counts), "summary": summary, - } \ No newline at end of file + } diff --git a/src/portfolio_context_recovery.py b/src/portfolio_context_recovery.py index 0af7c3c..ee4b3d4 100644 --- a/src/portfolio_context_recovery.py +++ b/src/portfolio_context_recovery.py @@ -16,11 +16,17 @@ temporary_project_reason, upsert_managed_context_block, ) -from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot +from src.portfolio_truth_types import ( + PortfolioTruthProject, + PortfolioTruthSnapshot, + display_activity_status, +) try: import yaml -except ImportError: # pragma: no cover - repo already depends on PyYAML for catalog tests +except ( + ImportError +): # pragma: no cover - repo already depends on PyYAML for catalog tests yaml = None @@ -30,7 +36,7 @@ class ContextRecoveryTarget: project_key: str display_name: str relative_path: str - registry_status: str + activity_status: str context_quality: str primary_context_file: str target_path: str @@ -89,7 +95,9 @@ def build_context_recovery_plan( status = "excluded" else: if not allow_dirty: - dirty_reason = _dirty_worktree_reason(project_path, project.identity.has_git) + dirty_reason = _dirty_worktree_reason( + project_path, project.identity.has_git + ) if dirty_reason: status = "skipped" reason = dirty_reason @@ -105,10 +113,14 @@ def build_context_recovery_plan( project_key=project.identity.project_key, display_name=project.identity.display_name, relative_path=project.identity.path, - registry_status=project.derived.registry_status, + activity_status=display_activity_status( + project.derived.activity_status, archived=project.derived.archived + ), context_quality=project.derived.context_quality, primary_context_file=project.derived.primary_context_file, - target_path=(project_path / project.derived.primary_context_file).as_posix(), + target_path=( + project_path / project.derived.primary_context_file + ).as_posix(), status=status, missing_fields=_missing_fields_for_project(project), reason=reason, @@ -138,7 +150,9 @@ def write_context_recovery_plan_artifacts( return json_path, markdown_path -def write_managed_context_block(project: PortfolioTruthProject, project_path: Path) -> None: +def write_managed_context_block( + project: PortfolioTruthProject, project_path: Path +) -> None: """Refresh the managed context block in a project's primary context file. The single source of truth for the read -> render -> upsert -> write sequence, @@ -146,8 +160,12 @@ def write_managed_context_block(project: PortfolioTruthProject, project_path: Pa automation executor's context-PR path so the two render byte-identical blocks. """ target_file = project_path / project.derived.primary_context_file - existing_text = target_file.read_text(errors="replace") if target_file.exists() else "" - managed_block = render_managed_context_block(_build_context_sections(project, project_path)) + existing_text = ( + target_file.read_text(errors="replace") if target_file.exists() else "" + ) + managed_block = render_managed_context_block( + _build_context_sections(project, project_path) + ) target_file.write_text(upsert_managed_context_block(existing_text, managed_block)) @@ -159,8 +177,12 @@ def apply_context_recovery_plan( catalog_path: Path | None = None, limit: int | None = None, ) -> ContextRecoveryApplyResult: - project_index = {project.identity.project_key: project for project in snapshot.projects} - eligible_targets = [target for target in plan.projects if target.status == "eligible"] + project_index = { + project.identity.project_key: project for project in snapshot.projects + } + eligible_targets = [ + target for target in plan.projects if target.status == "eligible" + ] if limit is not None: eligible_targets = eligible_targets[:limit] @@ -176,7 +198,9 @@ def apply_context_recovery_plan( write_managed_context_block(project, project_path) updated.append(target.project_key) if target.suggested_catalog_seed: - catalog_seeds[_catalog_repo_key(project)] = target.suggested_catalog_seed + catalog_seeds[_catalog_repo_key(project)] = ( + target.suggested_catalog_seed + ) except Exception: failed.append(target.project_key) @@ -223,7 +247,7 @@ def render_context_recovery_plan_markdown(plan: ContextRecoveryPlan) -> str: if target.reason: action = f"{target.status} ({target.reason})" lines.append( - f"| {target.priority_rank} | {target.display_name} | {target.registry_status} | {target.context_quality} | " + f"| {target.priority_rank} | {target.display_name} | {target.activity_status} | {target.context_quality} | " f"{action} | {', '.join(target.missing_fields) or '—'} |" ) return "\n".join(lines) + "\n" @@ -279,12 +303,18 @@ def _missing_fields_for_project(project: PortfolioTruthProject) -> list[str]: "missing_fields": [ label for present, label in ( - (project.derived.project_summary_present, "what the project is"), + ( + project.derived.project_summary_present, + "what the project is", + ), (project.derived.current_state_present, "current state"), (project.derived.stack_present, "stack"), (project.derived.run_instructions_present, "how to run"), (project.derived.known_risks_present, "known risks"), - (project.derived.next_recommended_move_present, "next recommended move"), + ( + project.derived.next_recommended_move_present, + "next recommended move", + ), ) if not present ] @@ -343,8 +373,12 @@ def _merge_catalog_seeds(catalog_path: Path, seeds: dict[str, dict[str, str]]) - catalog_path.write_text(yaml.safe_dump(data, sort_keys=False)) -def _build_context_sections(project: PortfolioTruthProject, project_path: Path) -> dict[str, str]: - primary_sections = _read_markdown_sections(project_path / project.derived.primary_context_file) +def _build_context_sections( + project: PortfolioTruthProject, project_path: Path +) -> dict[str, str]: + primary_sections = _read_markdown_sections( + project_path / project.derived.primary_context_file + ) readme_sections = _read_markdown_sections(project_path / "README.md") summary = _first_nonempty( primary_sections.get("what this project is"), @@ -446,7 +480,9 @@ def _split_markdown_sections(text: str) -> dict[str, str]: in_fenced_code = not in_fenced_code sections.setdefault(current, []).append(line) continue - match = None if in_fenced_code else re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", line) + match = ( + None if in_fenced_code else re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", line) + ) if match: current = _normalize_heading(match.group(1)) sections.setdefault(current, []) @@ -550,7 +586,9 @@ def _correct_run_instructions(text: str, project_path: Path) -> str: if not contradictions: return text corrected = _PRESCRIPTIVE_CMD_PATTERN.sub( - lambda m: f"{detected} {m.group(2)}" if m.group(1).lower() != detected else m.group(0), + lambda m: ( + f"{detected} {m.group(2)}" if m.group(1).lower() != detected else m.group(0) + ), text, ) corrected = _USE_ONLY_PATTERN.sub("", corrected) diff --git a/src/portfolio_pathing.py b/src/portfolio_pathing.py index cab3c1a..da8d67e 100644 --- a/src/portfolio_pathing.py +++ b/src/portfolio_pathing.py @@ -29,6 +29,9 @@ def resolve_declared_operating_path(entry: dict[str, Any]) -> tuple[str, str]: if explicit_path in VALID_OPERATING_PATHS: return explicit_path, "explicit-operating-path" + # Deprecated vintage of `operating_path`, kept as a read-compat fallback for one + # release (catalog migrated to `operating_path` in 0.9.x; see CHANGELOG). Slated + # for deletion once every catalog entry has moved off `intended_disposition`. intended_disposition = _normalize_key(entry.get("intended_disposition")) if intended_disposition in VALID_OPERATING_PATHS: return intended_disposition, "intended-disposition" @@ -47,7 +50,6 @@ def build_operating_path_entry( context_quality: str = "", intent_alignment: str = "", archived: bool = False, - registry_status: str = "", completeness_tier: str = "", decision_quality_status: str = "", ) -> dict[str, Any]: @@ -57,7 +59,6 @@ def build_operating_path_entry( explicit_contract = bool(entry.get("has_explicit_entry")) context_quality = _normalize_key(context_quality) intent_alignment = _normalize_key(intent_alignment) - registry_status = _normalize_key(registry_status) completeness_tier = _normalize_key(completeness_tier) decision_quality_status = _normalize_key(decision_quality_status) @@ -100,13 +101,16 @@ def build_operating_path_entry( "Context quality is still too weak for path guidance to stand on its own." ) - if archived or registry_status == "archived": + if archived: if stable_path != "archive": concerns.append("archived-outside-archive-path") rationale_parts.append( "The repo currently looks archival, but the declared operating path is not archive." ) - elif completeness_tier in {"abandoned", "skeleton"} and stable_path in {"maintain", "finish"}: + elif completeness_tier in {"abandoned", "skeleton"} and stable_path in { + "maintain", + "finish", + }: concerns.append("repo-state-below-path-bar") rationale_parts.append( "Current repo maturity is still below what the declared operating path usually expects." @@ -140,7 +144,9 @@ def build_operating_path_entry( path_override = INVESTIGATE_OVERRIDE if path_confidence == "low" else "" if path_override: - rationale_parts.append("Treat this repo as investigate until path confidence improves.") + rationale_parts.append( + "Treat this repo as investigate until path confidence improves." + ) rationale = " ".join(part for part in rationale_parts if part).strip() if not rationale: @@ -164,7 +170,8 @@ def build_operating_path_line(value: dict[str, Any]) -> str: override = _normalize_key(value.get("path_override")) confidence = _normalize_key(value.get("path_confidence")) or "legacy" rationale = ( - _safe_text(value.get("path_rationale")) or "No operating-path rationale is recorded yet." + _safe_text(value.get("path_rationale")) + or "No operating-path rationale is recorded yet." ) if path: @@ -204,7 +211,11 @@ def build_operating_paths_summary(items: list[Any]) -> dict[str, Any]: ] if path_counts.get("unspecified"): ordered_paths.append(f"Unspecified {path_counts['unspecified']}") - summary = ", ".join(ordered_paths) if ordered_paths else "No operating paths are recorded yet." + summary = ( + ", ".join(ordered_paths) + if ordered_paths + else "No operating paths are recorded yet." + ) if override_counts: summary += ( f". {sum(override_counts.values())} repo(s) currently require an " diff --git a/src/portfolio_repository_state.py b/src/portfolio_repository_state.py new file mode 100644 index 0000000..d8a13ab --- /dev/null +++ b/src/portfolio_repository_state.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import subprocess +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + + +def observe_repository_state(path: Path, *, observed_at: datetime) -> dict[str, Any]: + """Read local Git/worktree state without changing refs or exposing file names.""" + if not (path / ".git").exists(): + return { + "state": "not_a_repository", + "observed_at": observed_at.astimezone(UTC).isoformat(), + } + try: + head = _git(path, "rev-parse", "HEAD") + branch = _git(path, "branch", "--show-current") or None + dirty = _git(path, "status", "--porcelain", "--untracked-files=all") + upstream = _git_optional(path, "rev-parse", "--abbrev-ref", "@{upstream}") + ahead = behind = None + if upstream: + counts = _git(path, "rev-list", "--left-right", "--count", f"{upstream}...HEAD") + behind_text, ahead_text = counts.split() + behind, ahead = int(behind_text), int(ahead_text) + worktrees = [] + for item in _worktrees(path): + worktree_path = Path(item["path"]) + worktree_dirty = _git( + worktree_path, "status", "--porcelain", "--untracked-files=all" + ) + worktrees.append( + { + "path": str(worktree_path), + "head": item.get("head"), + "branch": item.get("branch"), + "detached": item.get("detached", False), + "dirty": bool(worktree_dirty), + "dirty_path_count": len(worktree_dirty.splitlines()) if worktree_dirty else 0, + } + ) + return { + "state": "observed", + "observed_at": observed_at.astimezone(UTC).isoformat(), + "local": { + "path": str(path), + "head": head, + "branch": branch, + "dirty": bool(dirty), + "dirty_path_count": len(dirty.splitlines()) if dirty else 0, + "upstream": upstream, + "upstream_observation_source": "local_tracking_ref" if upstream else "unavailable", + "ahead": ahead, + "behind": behind, + }, + "remote_default_branch": { + "state": "unknown", + "reason": "no independent live remote read was performed by portfolio generation", + }, + "worktrees": worktrees, + } + except (OSError, subprocess.CalledProcessError, ValueError) as exc: + return { + "state": "unknown", + "observed_at": observed_at.astimezone(UTC).isoformat(), + "reason": str(exc), + } + + +def _git(path: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(path), *args], check=True, capture_output=True, text=True + ).stdout.strip() + + +def _git_optional(path: Path, *args: str) -> str | None: + try: + return _git(path, *args) or None + except subprocess.CalledProcessError: + return None + + +def _worktrees(path: Path) -> list[dict[str, Any]]: + output = _git(path, "worktree", "list", "--porcelain") + items: list[dict[str, Any]] = [] + current: dict[str, Any] = {} + for line in output.splitlines() + [""]: + if not line: + if current: + items.append(current) + current = {} + continue + key, _, value = line.partition(" ") + if key == "worktree": + current["path"] = value + elif key == "HEAD": + current["head"] = value + elif key == "branch": + current["branch"] = value.removeprefix("refs/heads/") + elif key == "detached": + current["detached"] = True + return items diff --git a/src/portfolio_risk.py b/src/portfolio_risk.py index 638aa24..5651bf8 100644 --- a/src/portfolio_risk.py +++ b/src/portfolio_risk.py @@ -56,10 +56,9 @@ def build_risk_entry( display_name: str, operating_path: str, path_override: str, - path_confidence: str, context_quality: str, activity_status: str, - registry_status: str, + archived: bool, criticality: str, doctor_standard: str, known_risks_present: bool, @@ -68,7 +67,7 @@ def build_risk_entry( security_critical_alerts: int = 0, ) -> dict[str, Any]: # Short-circuit deferred: archived or archive-path - if registry_status == "archived" or operating_path == "archive": + if archived or operating_path == "archive": return dict(_DEFERRED_ARCHIVED) # Short-circuit deferred: stale and not on maintain path diff --git a/src/portfolio_truth_reconcile.py b/src/portfolio_truth_reconcile.py index 87c7f9f..0ebe024 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -18,6 +18,7 @@ from src.portfolio_context_contract import has_substantive_readme_support from src.portfolio_pathing import build_operating_path_entry from src.portfolio_risk import build_risk_entry +from src.portfolio_repository_state import observe_repository_state from src.portfolio_truth_sources import ( WORKSPACE_DISCOVERY_POLICY_VERSION, discover_workspace_projects, @@ -35,6 +36,7 @@ PortfolioTruthSnapshot, RiskFields, SecurityFields, + display_activity_status, ) from src.registry_parser import _normalize @@ -52,7 +54,12 @@ "declared.target_maturity": ["catalog_repo", "catalog_group", "catalog_defaults"], "declared.operating_path": ["normalized"], "declared.category": ["catalog_repo", "catalog_group", "legacy_registry"], - "declared.tool_provenance": ["catalog_repo", "catalog_group", "inference", "legacy_registry"], + "declared.tool_provenance": [ + "catalog_repo", + "catalog_group", + "inference", + "legacy_registry", + ], "declared.notes": ["catalog_repo", "catalog_group", "legacy_registry"], "derived.stack": ["workspace", "legacy_registry"], "derived.context_quality": ["workspace", "catalog_repo", "catalog_group"], @@ -66,7 +73,7 @@ "derived.next_recommended_move_present": ["workspace"], "derived.last_meaningful_activity_at": ["git", "workspace"], "derived.activity_status": ["derived"], - "derived.registry_status": ["derived"], + "derived.archived": ["derived"], "derived.attention_state": ["derived"], "derived.path_override": ["normalized"], "derived.path_confidence": ["normalized"], @@ -114,7 +121,9 @@ def _derive_has_tests(project_path: Path | None, has_git: bool) -> bool: match = next( f for f in project_path.rglob(pattern) - if f.is_file() and "node_modules" not in f.parts and ".git" not in f.parts + if f.is_file() + and "node_modules" not in f.parts + and ".git" not in f.parts ) if match: return True @@ -134,7 +143,9 @@ def _derive_has_ci(project_path: Path | None, has_git: bool) -> bool: workflows_dir = project_path / ".github" / "workflows" if not workflows_dir.is_dir(): return False - return any(f.suffix in (".yml", ".yaml") and f.is_file() for f in workflows_dir.iterdir()) + return any( + f.suffix in (".yml", ".yaml") and f.is_file() for f in workflows_dir.iterdir() + ) def _derive_has_license(project_path: Path | None, has_git: bool) -> bool: @@ -264,7 +275,10 @@ def build_portfolio_truth_snapshot( for raw_project in workspace_projects ] projects.sort( - key=lambda item: (item.identity.section_marker.lower(), item.identity.display_name.lower()) + key=lambda item: ( + item.identity.section_marker.lower(), + item.identity.display_name.lower(), + ) ) source_summary = { @@ -278,9 +292,10 @@ def build_portfolio_truth_snapshot( "context_quality_counts": dict( Counter(project.derived.context_quality for project in projects) ), - "registry_status_counts": dict( - Counter(project.derived.registry_status for project in projects) + "activity_status_counts": dict( + Counter(project.derived.activity_status for project in projects) ), + "archived_count": sum(1 for project in projects if project.derived.archived), "attention_state_counts": dict( Counter(project.derived.attention_state for project in projects) ), @@ -290,9 +305,13 @@ def build_portfolio_truth_snapshot( if project.provenance.get("github.archived", {}).get("detail") == "true" ), "duplicate_display_names": _duplicate_display_names(projects), - "unresolved_duplicate_display_names": _unresolved_duplicate_display_names(projects), + "unresolved_duplicate_display_names": _unresolved_duplicate_display_names( + projects + ), } - warnings = list(catalog_data.get("errors") or []) + list(catalog_data.get("warnings") or []) + warnings = list(catalog_data.get("errors") or []) + list( + catalog_data.get("warnings") or [] + ) duplicate_display_names = source_summary["unresolved_duplicate_display_names"] if duplicate_display_names: warnings.append( @@ -319,6 +338,11 @@ def build_portfolio_truth_snapshot( notion_context_carried_forward=notion_context_carried_forward, prior_notion_generated_at=prior_notion_generated_at, ), + coverage=_build_coverage_envelope( + projects=projects, + notion_context_carried_forward=notion_context_carried_forward, + notion_context_rows=len(notion_context), + ), exclusions={ "policy_version": WORKSPACE_DISCOVERY_POLICY_VERSION, "counts": dict(sorted(exclusion_counts.items())), @@ -329,6 +353,24 @@ def build_portfolio_truth_snapshot( ) +def _build_coverage_envelope( + *, + projects: list[PortfolioTruthProject], + notion_context_carried_forward: bool, + notion_context_rows: int, +) -> list[dict[str, Any]]: + scanned = sum(project.security.alerts_available for project in projects) + git_observed = sum( + project.repository_state.get("state") == "observed" for project in projects + ) + return [ + {"source": "workspace", "state": "observed", "project_count": len(projects)}, + {"source": "git", "state": "observed" if git_observed else "unknown", "observed_count": git_observed, "project_count": len(projects)}, + {"source": "github_security", "state": "known" if scanned == len(projects) else "partial" if scanned else "unknown", "scanned_count": scanned, "project_count": len(projects)}, + {"source": "notion", "state": "carried_forward" if notion_context_carried_forward else "observed" if notion_context_rows else "unknown", "observed_count": notion_context_rows}, + ] + + def _build_input_envelope( *, workspace_root: Path, @@ -417,25 +459,33 @@ def load_prior_notion_context(latest_path: Path) -> dict[str, dict[str, str]]: def _duplicate_display_names(projects: list[PortfolioTruthProject]) -> list[str]: return sorted( name - for name, count in Counter(project.identity.display_name for project in projects).items() + for name, count in Counter( + project.identity.display_name for project in projects + ).items() if count > 1 ) -def _unresolved_duplicate_display_names(projects: list[PortfolioTruthProject]) -> list[str]: +def _unresolved_duplicate_display_names( + projects: list[PortfolioTruthProject], +) -> list[str]: grouped: dict[str, list[PortfolioTruthProject]] = {} for project in projects: grouped.setdefault(project.identity.display_name, []).append(project) return sorted( name for name, members in grouped.items() - if len(members) > 1 and any(not _has_path_catalog_contract(project) for project in members) + if len(members) > 1 + and any(not _has_path_catalog_contract(project) for project in members) ) def _has_path_catalog_contract(project: PortfolioTruthProject) -> bool: for source in project.provenance.values(): - if source.get("source") == "catalog_repo" and source.get("detail") == project.identity.path: + if ( + source.get("source") == "catalog_repo" + and source.get("detail") == project.identity.path + ): return True return False @@ -457,6 +507,7 @@ def _count(source: dict[str, Any], key: str) -> int: return SecurityFields( alerts_available=bool(dependabot.get("available", False)), + coverage_state="known" if dependabot.get("available", False) else "unknown", dependabot_critical=_count(dependabot, "critical"), dependabot_high=_count(dependabot, "high"), dependabot_medium=_count(dependabot, "medium"), @@ -531,9 +582,21 @@ def _build_truth_project( "owner": _select_declared("owner", repo_entry, group_entry, provenance), "team": _select_declared("team", repo_entry, group_entry, provenance), "purpose": _select_declared("purpose", repo_entry, group_entry, provenance), - "lifecycle_state": _select_declared("lifecycle_state", repo_entry, group_entry, provenance), - "criticality": _select_declared("criticality", repo_entry, group_entry, provenance), - "review_cadence": _select_declared("review_cadence", repo_entry, group_entry, provenance), + "lifecycle_state": _select_declared( + "lifecycle_state", repo_entry, group_entry, provenance + ), + "criticality": _select_declared( + "criticality", repo_entry, group_entry, provenance + ), + "review_cadence": _select_declared( + "review_cadence", repo_entry, group_entry, provenance + ), + "operating_path": _select_declared( + "operating_path", repo_entry, group_entry, provenance + ), + # Deprecated vintage of operating_path, kept as a read-compat fallback for one + # release; resolve_declared_operating_path consumes both with operating_path + # taking precedence. "intended_disposition": _select_declared( "intended_disposition", repo_entry, group_entry, provenance ), @@ -560,7 +623,9 @@ def _build_truth_project( "notes": _select_with_legacy( "notes", repo_entry, group_entry, legacy, raw_project, provenance ), - "doctor_standard": _select_declared("doctor_standard", repo_entry, group_entry, provenance), + "doctor_standard": _select_declared( + "doctor_standard", repo_entry, group_entry, provenance + ), "automation_eligible": bool(repo_entry.get("automation_eligible", False)), } @@ -576,7 +641,9 @@ def _build_truth_project( readme_char_count=derived_readme_char_count, ) provenance["derived.context_quality"] = { - "source": "workspace+catalog" if context_quality != raw_context_quality else "workspace", + "source": "workspace+catalog" + if context_quality != raw_context_quality + else "workspace", "detail": ( f"{raw_context_quality}->{context_quality}" if context_quality != raw_context_quality @@ -597,19 +664,16 @@ def _build_truth_project( } last_activity = raw_project["last_meaningful_activity_at"] - activity_status = _activity_status_for( - last_activity, - declared_values["lifecycle_state"], - now=now, - github_archived=github_archived, - ) - registry_status = _registry_status_for(activity_status) + activity_status = _activity_status_for(last_activity, now=now) + # Lifecycle fact, not a recency observation — orthogonal to activity_status. + archived = github_archived or declared_values["lifecycle_state"] == "archived" path_entry = build_operating_path_entry( { **declared_values, "has_explicit_entry": bool( - repo_entry.get("has_explicit_entry") or group_entry.get("has_explicit_entry") + repo_entry.get("has_explicit_entry") + or group_entry.get("has_explicit_entry") ), "catalog_default_maturity_program": repo_entry.get( "catalog_default_maturity_program", "" @@ -619,8 +683,7 @@ def _build_truth_project( ), }, context_quality=context_quality, - archived=github_archived, - registry_status=registry_status, + archived=archived, ) provenance["declared.operating_path"] = { "source": "normalized", @@ -653,10 +716,9 @@ def _build_truth_project( display_name=raw_project["name"], operating_path=path_entry.get("operating_path", ""), path_override=path_entry.get("path_override", ""), - path_confidence=path_entry.get("path_confidence", "legacy"), context_quality=context_quality, activity_status=activity_status, - registry_status=registry_status, + archived=archived, criticality=declared_values["criticality"], doctor_standard=declared_values["doctor_standard"], known_risks_present=bool(raw_project["known_risks_present"]), @@ -664,15 +726,22 @@ def _build_truth_project( security_high_alerts=security.dependabot_high, security_critical_alerts=security.dependabot_critical, ) + if ( + not security.alerts_available + and risk_entry.get("risk_summary") == "No elevated risk factors." + ): + risk_entry["risk_summary"] = ( + "No non-security risk factors detected; security posture is unknown " + "because alert coverage is unavailable." + ) attention_state = _attention_state_for( - registry_status=registry_status, + activity_status=activity_status, + archived=archived, lifecycle_state=declared_values["lifecycle_state"], operating_path=path_entry.get("operating_path", ""), - intended_disposition=declared_values["intended_disposition"], category=declared_values["category"], path_override=path_entry.get("path_override", ""), risk_entry=risk_entry, - github_archived=github_archived, ) declared = DeclaredFields( @@ -696,10 +765,22 @@ def _build_truth_project( "source": "git" if raw_project["has_git"] and last_activity else "workspace", "detail": "derived", } - provenance["derived.activity_status"] = {"source": "derived", "detail": activity_status} - provenance["derived.registry_status"] = {"source": "derived", "detail": registry_status} - provenance["derived.attention_state"] = {"source": "derived", "detail": attention_state} - provenance["derived.stack"] = {"source": "workspace", "detail": ", ".join(raw_project["stack"])} + provenance["derived.activity_status"] = { + "source": "derived", + "detail": activity_status, + } + provenance["derived.archived"] = { + "source": "derived", + "detail": str(archived).lower(), + } + provenance["derived.attention_state"] = { + "source": "derived", + "detail": attention_state, + } + provenance["derived.stack"] = { + "source": "workspace", + "detail": ", ".join(raw_project["stack"]), + } provenance["derived.context_files"] = { "source": "workspace", "detail": str(len(raw_project["context_files"])), @@ -721,16 +802,22 @@ def _build_truth_project( "detail": str(bool(raw_project[field])).lower(), } - if legacy and legacy.get("status") and legacy["status"] != registry_status: + displayed_status = display_activity_status(activity_status, archived=archived) + if legacy and legacy.get("status") and legacy["status"] != displayed_status: warnings.append( - f"Legacy registry status '{legacy['status']}' differs from derived registry status '{registry_status}'." + f"Legacy registry status '{legacy['status']}' differs from derived registry status '{displayed_status}'." + ) + if not repo_entry.get("has_explicit_entry") and not group_entry.get( + "has_explicit_entry" + ): + warnings.append( + "No explicit catalog contract is recorded for this project yet." ) - if not repo_entry.get("has_explicit_entry") and not group_entry.get("has_explicit_entry"): - warnings.append("No explicit catalog contract is recorded for this project yet.") if path_entry.get("path_override") == "investigate": warnings.append( path_entry.get( - "path_rationale", "Operating path currently requires investigate override." + "path_rationale", + "Operating path currently requires investigate override.", ) ) if github_archived and declared_values["lifecycle_state"] != "archived": @@ -757,10 +844,12 @@ def _build_truth_project( stack_present=bool(raw_project["stack_present"]), run_instructions_present=bool(raw_project["run_instructions_present"]), known_risks_present=bool(raw_project["known_risks_present"]), - next_recommended_move_present=bool(raw_project["next_recommended_move_present"]), + next_recommended_move_present=bool( + raw_project["next_recommended_move_present"] + ), last_meaningful_activity_at=last_activity, activity_status=activity_status, - registry_status=registry_status, + archived=archived, attention_state=attention_state, path_override=path_entry.get("path_override", ""), path_confidence=path_entry.get("path_confidence", "legacy"), @@ -789,7 +878,10 @@ def _build_truth_project( path_risk=risk_entry["path_risk"], security_risk=risk_entry["security_risk"], ) - provenance["risk.risk_tier"] = {"source": "derived", "detail": risk_entry["risk_tier"]} + provenance["risk.risk_tier"] = { + "source": "derived", + "detail": risk_entry["risk_tier"], + } provenance["risk.doctor_gap"] = { "source": "derived", "detail": str(risk_entry["doctor_gap"]).lower(), @@ -801,6 +893,11 @@ def _build_truth_project( risk=risk, security=security, advisory=advisory, + repository_state=( + observe_repository_state(project_path, observed_at=now) + if project_path is not None and has_git + else {"state": "not_a_repository", "observed_at": now.isoformat()} + ), provenance=provenance, warnings=warnings, ) @@ -812,12 +909,17 @@ def _select_declared( group_entry: dict[str, Any], provenance: dict[str, dict[str, str]], ) -> str: - for source_name, source in (("catalog_repo", repo_entry), ("catalog_group", group_entry)): + for source_name, source in ( + ("catalog_repo", repo_entry), + ("catalog_group", group_entry), + ): value = str(source.get(field, "") or "").strip() if value: provenance[f"declared.{field}"] = { "source": source_name, - "detail": str(source.get("catalog_key") or source.get("group_key") or ""), + "detail": str( + source.get("catalog_key") or source.get("group_key") or "" + ), } return value provenance[f"declared.{field}"] = {"source": "fallback", "detail": ""} @@ -837,7 +939,10 @@ def _select_declared_with_default( return value default_value = str(repo_entry.get(default_field, "") or "").strip() if default_value: - provenance[f"declared.{field}"] = {"source": "catalog_defaults", "detail": default_value} + provenance[f"declared.{field}"] = { + "source": "catalog_defaults", + "detail": default_value, + } return default_value return value @@ -867,7 +972,9 @@ def _select_with_legacy( return "" -_GENERATED_SECURITY_NOTE_RE = re.compile(r"^(?:\[security: [^\]]+\]\s*)+", re.IGNORECASE) +_GENERATED_SECURITY_NOTE_RE = re.compile( + r"^(?:\[security: [^\]]+\]\s*)+", re.IGNORECASE +) _GENERATED_PATH_NOTE_PREFIXES = ( "Stable path is ", "No stable operating path is declared yet.", @@ -916,7 +1023,10 @@ def _select_tool_provenance( ): normalized = str(value or "").strip().lower() if normalized: - provenance["declared.tool_provenance"] = {"source": source_name, "detail": normalized} + provenance["declared.tool_provenance"] = { + "source": source_name, + "detail": normalized, + } return normalized provenance["declared.tool_provenance"] = {"source": "fallback", "detail": "unknown"} return "unknown" @@ -932,7 +1042,9 @@ def _resolve_group_key( return "standalone" -def _resolve_group_label(group_entry: dict[str, Any], raw_project: dict[str, Any]) -> str: +def _resolve_group_label( + group_entry: dict[str, Any], raw_project: dict[str, Any] +) -> str: if group_entry.get("section_label"): return str(group_entry["section_label"]) if "Swift" in raw_project.get("stack", []): @@ -950,7 +1062,9 @@ def _resolve_section_marker( return "Standalone Projects" -def _resolve_section_label(group_entry: dict[str, Any], raw_project: dict[str, Any]) -> str: +def _resolve_section_label( + group_entry: dict[str, Any], raw_project: dict[str, Any] +) -> str: if group_entry.get("section_label"): return str(group_entry["section_label"]) if "Swift" in raw_project.get("stack", []): @@ -958,15 +1072,10 @@ def _resolve_section_label(group_entry: dict[str, Any], raw_project: dict[str, A return "Root Level" -def _activity_status_for( - last_activity: datetime | None, - lifecycle_state: str, - *, - now: datetime, - github_archived: bool = False, -) -> str: - if github_archived or lifecycle_state == "archived": - return "archived" +def _activity_status_for(last_activity: datetime | None, *, now: datetime) -> str: + """Pure recency observation. Lifecycle intent (archived) is a separate axis, + computed by the caller and passed to downstream consumers as its own boolean — + see the `archived` local in `_build_truth_project`.""" if last_activity is None: return "stale" delta_days = (now - last_activity).days @@ -977,46 +1086,39 @@ def _activity_status_for( return "stale" -def _registry_status_for(activity_status: str) -> str: - if activity_status == "stale": - return "parked" - return activity_status - - def _attention_state_for( *, - registry_status: str, + activity_status: str, + archived: bool, lifecycle_state: str, operating_path: str, - intended_disposition: str, category: str, path_override: str, risk_entry: dict[str, Any], - github_archived: bool = False, ) -> str: - if ( - github_archived - or registry_status == "archived" - or lifecycle_state == "archived" - or operating_path == "archive" - ): + if archived or operating_path == "archive": return "archived" - if ( - operating_path == "experiment" - or intended_disposition == "experiment" - or lifecycle_state == "experimental" - ): + if operating_path == "experiment" or lifecycle_state == "experimental": return "experiment" - if registry_status == "parked": - return "parked" - if path_override == "investigate" or not operating_path or risk_entry.get("security_risk"): + if lifecycle_state == "manual-only": + return "manual-only" + if activity_status == "stale": + # A declared finish path is itself an unresolved operator decision. It can + # remain valid while the default branch is stale (for example, when work is + # on a release branch or waiting at a human/publication gate), so do not + # silently collapse it back into the parked pool. + return "decision-needed" if operating_path == "finish" else "parked" + if risk_entry.get("security_risk"): return "decision-needed" - if registry_status in {"active", "recent"} and operating_path in {"maintain", "finish"}: + if activity_status in {"active", "recent"} and operating_path in { + "maintain", + "finish", + }: if category == "infrastructure": return "active-infra" if category == "commercial": return "active-product" return "manual-only" - if registry_status in {"active", "recent"}: + if activity_status in {"active", "recent"}: return "manual-only" return "parked" diff --git a/src/portfolio_truth_render.py b/src/portfolio_truth_render.py index f8bd68d..06bb5b1 100644 --- a/src/portfolio_truth_render.py +++ b/src/portfolio_truth_render.py @@ -3,7 +3,19 @@ from collections import Counter, defaultdict from datetime import timezone -from src.portfolio_truth_types import PortfolioTruthProject, PortfolioTruthSnapshot +from src.portfolio_truth_types import ( + PortfolioTruthProject, + PortfolioTruthSnapshot, + display_activity_status, +) + + +def _displayed_status(project: PortfolioTruthProject) -> str: + """Renderer-local shorthand over the ontology's single relabel authority.""" + return display_activity_status( + project.derived.activity_status, archived=project.derived.archived + ) + # Mirrors the weekly digest's MAX_SECURITY_ATTENTION_ITEMS — the portfolio report and # the digest cap the per-repo security callout list at the same depth so the two @@ -100,11 +112,18 @@ def render_portfolio_report_markdown( ) -> str: generated = snapshot.generated_at.astimezone(timezone.utc).strftime("%Y-%m-%d") grouped = _group_projects(snapshot.projects) - context_counts = Counter(project.derived.context_quality for project in snapshot.projects) - registry_counts = Counter(project.derived.registry_status for project in snapshot.projects) - attention_counts = Counter(project.derived.attention_state for project in snapshot.projects) + context_counts = Counter( + project.derived.context_quality for project in snapshot.projects + ) + registry_counts = Counter( + _displayed_status(project) for project in snapshot.projects + ) + attention_counts = Counter( + project.derived.attention_state for project in snapshot.projects + ) operating_path_counts = Counter( - project.declared.operating_path or "unspecified" for project in snapshot.projects + project.declared.operating_path or "unspecified" + for project in snapshot.projects ) override_counts = Counter( project.derived.path_override @@ -165,7 +184,7 @@ def render_portfolio_report_markdown( lines.append( f"| {project.identity.display_name} | `{project.identity.path}` | {project.identity.section_marker} | " f"{project.declared.operating_path or '—'} | {path_status} | " - f"{project.declared.lifecycle_state or '—'} | {project.derived.registry_status} | " + f"{project.declared.lifecycle_state or '—'} | {_displayed_status(project)} | " f"{project.derived.attention_state} | {project.derived.context_quality} | " f"{project.declared.tool_provenance or 'unknown'} | {project.declared.category or 'unknown'} | {project.risk.risk_tier} |" ) @@ -192,10 +211,10 @@ def render_portfolio_report_markdown( lines.append(f"### {marker}") lines.append("") lines.append( - f"- Projects: `{len(projects)}` | Active `{sum(1 for item in projects if item.derived.registry_status == 'active')}`" - f" | Recent `{sum(1 for item in projects if item.derived.registry_status == 'recent')}`" - f" | Parked `{sum(1 for item in projects if item.derived.registry_status == 'parked')}`" - f" | Archived `{sum(1 for item in projects if item.derived.registry_status == 'archived')}`" + f"- Projects: `{len(projects)}` | Active `{sum(1 for item in projects if _displayed_status(item) == 'active')}`" + f" | Recent `{sum(1 for item in projects if _displayed_status(item) == 'recent')}`" + f" | Parked `{sum(1 for item in projects if _displayed_status(item) == 'parked')}`" + f" | Archived `{sum(1 for item in projects if _displayed_status(item) == 'archived')}`" ) lines.append( f"- Default attention: active-product `{sum(1 for item in projects if item.derived.attention_state == 'active-product')}`, " @@ -250,11 +269,11 @@ def render_portfolio_report_markdown( for project in snapshot.projects: if ( project.advisory.legacy_status - and project.advisory.legacy_status != project.derived.registry_status + and project.advisory.legacy_status != _displayed_status(project) ): mismatch_count += 1 lines.append( - f"- `{project.identity.display_name}` legacy status `{project.advisory.legacy_status}` differs from derived status `{project.derived.registry_status}`." + f"- `{project.identity.display_name}` legacy status `{project.advisory.legacy_status}` differs from derived status `{_displayed_status(project)}`." ) if mismatch_count == 0: lines.append( @@ -282,7 +301,8 @@ def _group_projects( grouped[project.identity.section_marker].append(project) ordered = dict( sorted( - grouped.items(), key=lambda item: (item[0] != "Standalone Projects", item[0].lower()) + grouped.items(), + key=lambda item: (item[0] != "Standalone Projects", item[0].lower()), ) ) for marker, members in ordered.items(): @@ -291,7 +311,9 @@ def _group_projects( def registry_project_labels(projects: list[PortfolioTruthProject]) -> dict[str, str]: - duplicate_names = Counter(project.identity.display_name.strip() for project in projects) + duplicate_names = Counter( + project.identity.display_name.strip() for project in projects + ) labels: dict[str, str] = {} for project in projects: display_name = project.identity.display_name.strip() @@ -314,7 +336,7 @@ def _render_standalone_section( ] for project in projects: lines.append( - f"| {project_labels[project.identity.project_key]} | {project.derived.registry_status} | {project.declared.tool_provenance or 'unknown'} | " + f"| {project_labels[project.identity.project_key]} | {_displayed_status(project)} | {project.declared.tool_provenance or 'unknown'} | " f"{project.derived.context_quality} | {', '.join(project.derived.stack) or 'Unknown'} | " f"{', '.join(project.derived.context_files) or '—'} | {project.declared.category or 'unknown'} | {_note_text(project)} |" ) @@ -327,7 +349,9 @@ def _render_group_section( project_labels: dict[str, str], ) -> list[str]: count = len(projects) - title = marker if marker.endswith("Projects") or marker.endswith("/") else f"{marker}" + title = ( + marker if marker.endswith("Projects") or marker.endswith("/") else f"{marker}" + ) if marker.startswith("ITPRJsViaClaude/"): lines = [ f"## {title} (IT Tools — {count} projects)", @@ -337,7 +361,7 @@ def _render_group_section( ] for project in projects: lines.append( - f"| {project_labels[project.identity.project_key]} | {project.derived.registry_status} | {project.declared.tool_provenance or 'unknown'} | " + f"| {project_labels[project.identity.project_key]} | {_displayed_status(project)} | {project.declared.tool_provenance or 'unknown'} | " f"{project.derived.context_quality} | {', '.join(project.derived.context_files) or '—'} | {_note_text(project)} |" ) return lines @@ -354,7 +378,7 @@ def _render_group_section( ) for project in projects: lines.append( - f"| {project_labels[project.identity.project_key]} | {project.derived.registry_status} | {project.declared.tool_provenance or 'unknown'} | " + f"| {project_labels[project.identity.project_key]} | {_displayed_status(project)} | {project.declared.tool_provenance or 'unknown'} | " f"{project.derived.context_quality} | {_note_text(project)} |" ) return lines @@ -404,7 +428,7 @@ def _note_text(project: PortfolioTruthProject) -> str: def _render_summary_section(projects: list[PortfolioTruthProject]) -> list[str]: total = len(projects) - status_counts = Counter(project.derived.registry_status for project in projects) + status_counts = Counter(_displayed_status(project) for project in projects) context_counts = Counter(project.derived.context_quality for project in projects) security = _security_overview(projects) return [ diff --git a/src/portfolio_truth_sources.py b/src/portfolio_truth_sources.py index 81871c2..b34e7d9 100644 --- a/src/portfolio_truth_sources.py +++ b/src/portfolio_truth_sources.py @@ -86,21 +86,42 @@ # nogoprjs -> operator-flagged "no-go" projects, never pursued # smoke-export -> generated AuraForge signed-smoke-export bundles (no real repo) IGNORE_PROJECT_DIR_TOKENS = frozenset({"nogoprjs", "smoke-export"}) -IGNORE_PROJECT_DIR_NAMES = frozenset({"codex backups"}) +IGNORE_PROJECT_DIR_NAMES = frozenset( + { + "codex backups", + "scratch", + "_backups", + "_preserved-local-artifacts", + "sweep-reports", + "_fable-worktrees", + "_codex-worktrees", + } +) +IGNORE_NESTED_PROJECT_DIR_NAMES = frozenset({"packets", "prompts"}) # Transient / generated working directories matched by regex on the dir name — # e.g. a `-tmp-` clone left behind by a tooling run. IGNORE_PROJECT_DIR_PATTERNS: tuple[re.Pattern[str], ...] = (re.compile(r"-tmp-\d+$"),) ARCHIVE_REMOTE_BASENAME_TOKENS = frozenset({"private-archive", "scrubbed-import"}) -WORKSPACE_DISCOVERY_POLICY_VERSION = "workspace_discovery.v1" +WORKSPACE_DISCOVERY_POLICY_VERSION = "workspace_discovery.v2" -def workspace_exclusion_reason(name: str) -> str | None: +def workspace_exclusion_reason(name: str, *, nested: bool = False) -> str | None: """Return the stable policy reason for a non-project directory name.""" lowered = name.lower() - if lowered in IGNORE_PROJECT_DIR_NAMES: + if lowered in {"codex backups", "_backups"}: return "backup-container" + if lowered == "_preserved-local-artifacts": + return "preserved-artifacts" + if lowered == "scratch": + return "scratch-container" + if lowered == "sweep-reports": + return "generated-reports" + if lowered in {"_fable-worktrees", "_codex-worktrees"}: + return "linked-worktree-container" + if nested and lowered in IGNORE_NESTED_PROJECT_DIR_NAMES: + return "nested-content" if any(token in lowered for token in IGNORE_PROJECT_DIR_TOKENS): return "operator-excluded" if "nogoprjs" in lowered else "generated-evidence" if any(pattern.search(name) for pattern in IGNORE_PROJECT_DIR_PATTERNS): @@ -210,7 +231,7 @@ def _discover_nested_projects( for child in sorted(root.iterdir(), key=lambda item: item.name.lower()): if child.name.startswith(".") or not child.is_dir() or child.is_symlink(): continue - exclusion_reason = workspace_exclusion_reason(child.name) + exclusion_reason = workspace_exclusion_reason(child.name, nested=True) if exclusion_reason is not None: _record_exclusion(exclusion_counts, exclusion_reason) continue diff --git a/src/portfolio_truth_trends.py b/src/portfolio_truth_trends.py new file mode 100644 index 0000000..ec182ff --- /dev/null +++ b/src/portfolio_truth_trends.py @@ -0,0 +1,373 @@ +"""Read-side trends for the canonical portfolio-truth snapshot history. + +The producer owns the truth artifact schema. This module only reads existing +``portfolio-truth-*.json`` snapshots and derives movement, so it cannot change +scores, verdicts, rollups, or the producer's output contract. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from pathlib import Path +from typing import Any, Mapping, Sequence + +DEFAULT_HISTORY_DIR = Path("output/history") +DEFAULT_MAX_SNAPSHOTS = 8 +CONTRACT_VERSION = "portfolio_truth_trends_v1" +_TREND_FIELDS = ("attention_state", "activity_status", "risk_tier") + + +def load_portfolio_truth_history( + history_dir: Path = DEFAULT_HISTORY_DIR, + *, + max_snapshots: int = DEFAULT_MAX_SNAPSHOTS, +) -> list[dict[str, Any]]: + """Load the last ``max_snapshots`` valid truth artifacts chronologically. + + The current producer has emitted dated artifacts directly under ``output`` + while the original history contract names ``output/history``. Both layouts + are read, with the conventional ``portfolio-truth-latest.json`` pointer + excluded because it is not a historical observation. + """ + if max_snapshots < 1: + return [] + + candidates: dict[Path, dict[str, Any]] = {} + for directory in _history_directories(history_dir): + if not directory.is_dir(): + continue + for path in directory.glob("portfolio-truth-*.json"): + if path.name == "portfolio-truth-latest.json": + continue + payload = _read_mapping(path) + if payload is None: + continue + generated_at = _text(payload.get("generated_at")) + candidates[path.resolve()] = { + "path": str(path), + "generated_at": generated_at, + "projects": list(payload.get("projects") or []) + if isinstance(payload.get("projects"), list) + else [], + } + + snapshots = sorted( + candidates.values(), + key=lambda item: (item["generated_at"], item["path"]), + ) + return snapshots[-max_snapshots:] + + +def build_verdict_transition_ledger( + history_dir: Path = DEFAULT_HISTORY_DIR, + *, + max_snapshots: int = DEFAULT_MAX_SNAPSHOTS, + current_snapshot: Mapping[str, Any] | None = None, + current_path: Path | None = None, +) -> dict[str, Any]: + """Derive truth-layer transitions from existing snapshot history. + + ``current_snapshot`` is useful to callers that have already loaded the + canonical ``portfolio-truth-latest.json`` pointer. It is merged into the + read window by generated timestamp and does not get written anywhere. + """ + snapshots = load_portfolio_truth_history(history_dir, max_snapshots=max_snapshots) + if current_snapshot is not None and max_snapshots > 0: + snapshots = _with_current_snapshot( + snapshots, + current_snapshot, + current_path=current_path, + max_snapshots=max_snapshots, + ) + + observations = [_snapshot_observations(snapshot) for snapshot in snapshots] + transitions = _build_transitions(snapshots, observations) + activity_streaks = _build_activity_streaks(snapshots, observations) + attention_transitions = [ + item for item in transitions if item["kind"] == "attention_state" + ] + activity_transitions = [ + item for item in transitions if item["kind"] == "activity_status" + ] + risk_changes = [item for item in transitions if item["kind"] == "risk_tier"] + lifecycle_events = [ + item for item in transitions if item["kind"] == "repo_lifecycle" + ] + + return { + "contract_version": CONTRACT_VERSION, + "snapshot_count": len(snapshots), + "window_size": max_snapshots, + "from_generated_at": snapshots[0]["generated_at"] if snapshots else "", + "to_generated_at": snapshots[-1]["generated_at"] if snapshots else "", + "transitions": transitions, + "attention_state_transitions": attention_transitions, + "activity_status_transitions": activity_transitions, + "activity_status_streaks": activity_streaks, + "risk_tier_changes": risk_changes, + "repo_lifecycle_events": lifecycle_events, + "summary": { + "transition_count": len(transitions), + "attention_state_transition_count": len(attention_transitions), + "activity_status_transition_count": len(activity_transitions), + "risk_tier_change_count": len(risk_changes), + "repo_appeared_count": sum( + item["to"] == "present" for item in lifecycle_events + ), + "repo_disappeared_count": sum( + item["to"] == "absent" for item in lifecycle_events + ), + }, + } + + +def render_movement_summary(ledger: Mapping[str, Any]) -> str: + """Render a compact human-readable summary for the weekly digest.""" + transitions = list(ledger.get("transitions") or []) + if not transitions: + return "No verdict movement is recorded across the available truth snapshots." + + grouped: dict[tuple[str, str, str], int] = defaultdict(int) + for item in transitions: + kind = _text(item.get("kind")) + if kind == "repo_lifecycle": + grouped[(kind, _text(item.get("from")), _text(item.get("to")))] += 1 + else: + grouped[(kind, _text(item.get("from")), _text(item.get("to")))] += 1 + + clauses: list[str] = [] + for (kind, previous, current), count in sorted(grouped.items()): + transition = f"{previous}→{current}" + if kind == "repo_lifecycle": + verb = "appeared" if current == "present" else "disappeared" + clauses.append(f"{count} repo{'s' if count != 1 else ''} {verb}") + elif kind == "activity_status": + verb = _movement_verb(previous, current) + clauses.append(f"{count} repo{'s' if count != 1 else ''} {verb} {transition}") + elif kind == "attention_state": + verb = _movement_verb(previous, current) + clauses.append(f"{count} repo{'s' if count != 1 else ''} {verb} {transition}") + elif kind == "risk_tier": + clauses.append( + f"{count} risk-tier change{'s' if count != 1 else ''} {transition}" + ) + + return "; ".join(clauses) + "." + + +def _history_directories(history_dir: Path) -> list[Path]: + directories = [history_dir] + if history_dir.name != "history": + directories.append(history_dir / "history") + else: + directories.append(history_dir.parent) + return list(dict.fromkeys(directory for directory in directories)) + + +def _with_current_snapshot( + snapshots: Sequence[dict[str, Any]], + current_snapshot: Mapping[str, Any], + *, + current_path: Path | None, + max_snapshots: int, +) -> list[dict[str, Any]]: + generated_at = _text(current_snapshot.get("generated_at")) + if not generated_at: + return list(snapshots)[-max_snapshots:] + current = { + "path": str(current_path) if current_path else "", + "generated_at": generated_at, + "projects": list(current_snapshot.get("projects") or []) + if isinstance(current_snapshot.get("projects"), list) + else [], + } + merged = [snapshot for snapshot in snapshots if snapshot["generated_at"] != generated_at] + merged.append(current) + merged.sort(key=lambda item: (item["generated_at"], item["path"])) + return merged[-max_snapshots:] + + +def _snapshot_observations(snapshot: Mapping[str, Any]) -> dict[str, dict[str, Any]]: + observations: dict[str, dict[str, Any]] = {} + for project in snapshot.get("projects") or []: + if not isinstance(project, Mapping): + continue + identity = _mapping(project.get("identity")) + derived = _mapping(project.get("derived")) + risk = _mapping(project.get("risk")) + project_key = ( + _text(identity.get("project_key")) + or _text(identity.get("repo_full_name")) + or _text(identity.get("display_name")) + ) + if not project_key: + continue + observations[project_key] = { + "repo": _text(identity.get("display_name")) or project_key, + "project_key": project_key, + "attention_state": _value(derived.get("attention_state")), + "activity_status": _value(derived.get("activity_status")), + "risk_tier": _value(risk.get("risk_tier")), + } + return observations + + +def _build_transitions( + snapshots: Sequence[Mapping[str, Any]], + observations: Sequence[dict[str, dict[str, Any]]], +) -> list[dict[str, Any]]: + transitions: list[dict[str, Any]] = [] + for index in range(1, len(snapshots)): + previous = observations[index - 1] + current = observations[index] + previous_at = _text(snapshots[index - 1].get("generated_at")) + current_at = _text(snapshots[index].get("generated_at")) + for project_key in sorted(set(previous) | set(current)): + before = previous.get(project_key) + after = current.get(project_key) + if before is None or after is None: + transitions.append( + _transition( + project_key=project_key, + repo=(after or before)["repo"], + kind="repo_lifecycle", + previous="present" if before else "absent", + current="present" if after else "absent", + previous_at=previous_at, + current_at=current_at, + ) + ) + continue + for field in _TREND_FIELDS: + if before[field] == after[field]: + continue + transitions.append( + _transition( + project_key=project_key, + repo=after["repo"], + kind=field, + previous=before[field], + current=after[field], + previous_at=previous_at, + current_at=current_at, + ) + ) + transitions.sort( + key=lambda item: ( + item["to_generated_at"], + item["repo"].lower(), + item["kind"], + ) + ) + return transitions + + +def _build_activity_streaks( + snapshots: Sequence[Mapping[str, Any]], + observations: Sequence[dict[str, dict[str, Any]]], +) -> list[dict[str, Any]]: + keys = sorted({project_key for snapshot in observations for project_key in snapshot}) + streaks: list[dict[str, Any]] = [] + for project_key in keys: + present = [ + (index, snapshot[project_key]) + for index, snapshot in enumerate(observations) + if project_key in snapshot + ] + if not present: + continue + latest_index, latest = present[-1] + if latest_index != len(observations) - 1: + status = "disappeared" + run_count = 1 + since_index = latest_index + else: + status = latest["activity_status"] or "unknown" + run_count = 0 + since_index = latest_index + for index, observation in reversed(present): + if index != since_index or observation["activity_status"] != status: + break + run_count += 1 + since_index = index - 1 + since_index += 1 + streaks.append( + { + "repo": latest["repo"], + "project_key": project_key, + "status": status, + "run_count": run_count, + "consecutive_snapshots": run_count, + "since_generated_at": ( + _text(snapshots[since_index].get("generated_at")) + if snapshots + else "" + ), + "through_generated_at": ( + _text(snapshots[-1].get("generated_at")) if snapshots else "" + ), + } + ) + return streaks + + +def _transition( + *, + project_key: str, + repo: str, + kind: str, + previous: str | None, + current: str | None, + previous_at: str, + current_at: str, +) -> dict[str, Any]: + return { + "repo": repo, + "project_key": project_key, + "kind": kind, + "from": previous, + "to": current, + "from_date": previous_at[:10], + "to_date": current_at[:10], + "from_generated_at": previous_at, + "to_generated_at": current_at, + } + + +def _movement_verb(previous: str, current: str) -> str: + if previous in {"active", "recent", "active-product", "active-infra"} and current in { + "stale", + "archived", + "manual-only", + }: + return "slid" + if previous in {"stale", "archived", "manual-only", "decision-needed"} and current in { + "active", + "recent", + "active-product", + "active-infra", + }: + return "recovered" + return "changed" + + +def _read_mapping(path: Path) -> dict[str, Any] | None: + try: + payload = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _value(value: Any) -> str | None: + text = _text(value) + return text or None diff --git a/src/portfolio_truth_types.py b/src/portfolio_truth_types.py index 734d7c2..b31feba 100644 --- a/src/portfolio_truth_types.py +++ b/src/portfolio_truth_types.py @@ -6,8 +6,13 @@ from pathlib import Path from typing import Any -SCHEMA_VERSION = "0.8.0" -LEGACY_SCHEMA_VERSIONS = {"0.7.0"} +SCHEMA_VERSION = "0.10.0" +# 0.10.0: canonical producer receipts bind the exact checkout; coverage and +# repository/worktree observation envelopes fail closed on unavailable evidence. +# 0.8.0: derived.registry_status removed (was a stale->parked synonym table over +# activity_status); derived.archived added as a first-class lifecycle boolean; +# source_summary.registry_status_counts replaced by activity_status_counts + archived_count. +LEGACY_SCHEMA_VERSIONS = {"0.7.0", "0.8.0", "0.9.0"} DERIVATION_POLICY_VERSION = "portfolio_attention.v2" # The published "latest" portfolio-truth artifact. The producer @@ -22,8 +27,9 @@ def truth_latest_path(output_dir: Path) -> Path: VALID_CONTEXT_QUALITY = {"full", "standard", "minimum-viable", "boilerplate", "none"} -VALID_ACTIVITY_STATUS = {"active", "recent", "stale", "archived"} -VALID_REGISTRY_STATUS = {"active", "recent", "parked", "archived"} +# Pure recency observation. Lifecycle intent (archived) is a separate axis — +# see DerivedFields.archived and display_activity_status() below. +VALID_ACTIVITY_STATUS = {"active", "recent", "stale"} VALID_ATTENTION_STATES = { "active-product", "active-infra", @@ -37,6 +43,7 @@ def truth_latest_path(output_dir: Path) -> Path: VALID_LIFECYCLE_STATES = { "active", "maintenance", + "manual-only", "dormant", "experimental", "archived", @@ -61,6 +68,18 @@ def _serialize_datetime(value: datetime | None) -> str | None: return value.isoformat() +def display_activity_status(activity_status: str, *, archived: bool) -> str: + """The single legitimate place `stale` (an observation) is relabeled `parked` (a + lifecycle judgment) for human-facing surfaces. `archived` is a stored lifecycle fact, + not a relabel, and always wins. Every renderer/report that used to read + ``derived.registry_status`` for display calls this instead of re-deriving the label.""" + if archived: + return "archived" + if activity_status == "stale": + return "parked" + return activity_status + + @dataclass(frozen=True) class IdentityFields: project_key: str @@ -93,6 +112,10 @@ class DeclaredFields: lifecycle_state: str = "" criticality: str = "" review_cadence: str = "" + # Deprecated vintage of `operating_path` (same axis, same value domain). The + # catalog was migrated to declare `operating_path` directly in 0.9.x; this field + # is kept as a read-compat fallback for one release and then deleted. See + # portfolio_pathing.resolve_declared_operating_path and CHANGELOG. intended_disposition: str = "" maturity_program: str = "" target_maturity: str = "" @@ -122,7 +145,9 @@ class DerivedFields: next_recommended_move_present: bool = False last_meaningful_activity_at: datetime | None = None activity_status: str = "stale" - registry_status: str = "parked" + # Lifecycle fact, not a recency observation: github_archived OR declared + # lifecycle_state == "archived". Orthogonal to activity_status. + archived: bool = False attention_state: str = "parked" path_override: str = "" path_confidence: str = "legacy" @@ -179,6 +204,7 @@ class SecurityFields: with zero open alerts, so consumers don't mislabel an unscanned repo as secure.""" alerts_available: bool = False + coverage_state: str = "unknown" dependabot_critical: int = 0 dependabot_high: int = 0 dependabot_medium: int = 0 @@ -206,6 +232,7 @@ class PortfolioTruthProject: risk: RiskFields = field(default_factory=RiskFields) security: SecurityFields = field(default_factory=SecurityFields) advisory: AdvisoryFields = field(default_factory=AdvisoryFields) + repository_state: dict[str, Any] = field(default_factory=dict) provenance: dict[str, dict[str, str]] = field(default_factory=dict) warnings: list[str] = field(default_factory=list) @@ -217,6 +244,7 @@ def to_dict(self) -> dict[str, Any]: "risk": self.risk.to_dict(), "security": self.security.to_dict(), "advisory": self.advisory.to_dict(), + "repository_state": dict(self.repository_state), "provenance": self.provenance, "warnings": list(self.warnings), } @@ -246,6 +274,7 @@ def from_projects( repos_with_open_high_critical = 0 total_open_high = 0 total_open_critical = 0 + unavailable_count = 0 decision_needed_count = 0 default_attention_count = 0 for project in projects: @@ -259,6 +288,8 @@ def from_projects( repos_with_open_high_critical += 1 total_open_high += security.dependabot_high total_open_critical += security.dependabot_critical + else: + unavailable_count += 1 attention = project.derived.attention_state if attention == "decision-needed": decision_needed_count += 1 @@ -269,6 +300,14 @@ def from_projects( risk_tier_counts=risk_tier_counts, security={ "scanned_count": scanned_count, + "unavailable_count": unavailable_count, + "coverage_state": ( + "known" + if unavailable_count == 0 + else "partial" + if scanned_count + else "unknown" + ), "repos_with_open_high_critical": repos_with_open_high_critical, "total_open_high": total_open_high, "total_open_critical": total_open_critical, @@ -302,7 +341,7 @@ class PortfolioTruthSnapshot: coverage: list[dict[str, Any]] = field(default_factory=list) exclusions: dict[str, Any] = field( default_factory=lambda: { - "policy_version": "workspace_discovery.v1", + "policy_version": "workspace_discovery.v2", "counts": {}, } ) diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index d7bb52c..98754f1 100644 --- a/src/portfolio_truth_validate.py +++ b/src/portfolio_truth_validate.py @@ -17,7 +17,6 @@ VALID_CONTEXT_QUALITY, VALID_DOCTOR_STANDARDS, VALID_LIFECYCLE_STATES, - VALID_REGISTRY_STATUS, VALID_RISK_TIERS, PortfolioTruthSnapshot, ) @@ -40,7 +39,9 @@ def validate_truth_snapshot(snapshot: PortfolioTruthSnapshot) -> None: raise ValueError(f"Duplicate project key in truth snapshot: {key}") seen_keys.add(key) if Path(project.identity.path).is_absolute(): - raise ValueError(f"Project path must stay workspace-relative: {project.identity.path}") + raise ValueError( + f"Project path must stay workspace-relative: {project.identity.path}" + ) if project.derived.context_quality not in VALID_CONTEXT_QUALITY: raise ValueError( f"Invalid context quality for {key}: {project.derived.context_quality}" @@ -53,10 +54,6 @@ def validate_truth_snapshot(snapshot: PortfolioTruthSnapshot) -> None: raise ValueError( f"Invalid activity status for {key}: {project.derived.activity_status}" ) - if project.derived.registry_status not in VALID_REGISTRY_STATUS: - raise ValueError( - f"Invalid registry status for {key}: {project.derived.registry_status}" - ) if project.derived.attention_state not in VALID_ATTENTION_STATES: raise ValueError( f"Invalid attention state for {key}: {project.derived.attention_state}" @@ -69,9 +66,11 @@ def validate_truth_snapshot(snapshot: PortfolioTruthSnapshot) -> None: project.derived.known_risks_present, project.derived.next_recommended_move_present, ) - if project.derived.context_quality in {"minimum-viable", "standard", "full"} and not all( - completeness_flags - ): + if project.derived.context_quality in { + "minimum-viable", + "standard", + "full", + } and not all(completeness_flags): raise ValueError( f"Context quality for {key} requires all minimum-viable fields to be present." ) @@ -106,20 +105,33 @@ def _validate_contract_envelope(snapshot: PortfolioTruthSnapshot) -> None: "commit", "ref", "checkout_role", + "checkout_path", "worktree_clean", + "dirty_path_count", "verified_at", + "receipt_id", } missing = sorted(required - producer.keys()) if missing: raise ValueError(f"Producer evidence is missing fields: {missing}") commit = producer.get("commit") - if not isinstance(commit, str) or len(commit) != 40 or any( - char not in "0123456789abcdef" for char in commit + if ( + not isinstance(commit, str) + or len(commit) != 40 + or any(char not in "0123456789abcdef" for char in commit) ): raise ValueError("Producer commit must be a lowercase 40-character SHA.") if producer.get("worktree_clean") is not True: - raise ValueError("Canonical producer evidence must declare a clean worktree.") - notion = snapshot.inputs.get("notion") if isinstance(snapshot.inputs, dict) else None + raise ValueError( + "Canonical producer evidence must declare a clean worktree." + ) + if producer.get("dirty_path_count") != 0: + raise ValueError("Canonical producer evidence must declare zero dirty paths.") + if not snapshot.coverage: + raise ValueError("Portfolio truth coverage envelope is required.") + notion = ( + snapshot.inputs.get("notion") if isinstance(snapshot.inputs, dict) else None + ) if not isinstance(notion, dict): raise ValueError("Portfolio truth inputs.notion is required.") mode = notion.get("mode") diff --git a/src/producer_preflight.py b/src/producer_preflight.py index a8c6685..475b98a 100644 --- a/src/producer_preflight.py +++ b/src/producer_preflight.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import hashlib import json import subprocess from dataclasses import dataclass @@ -10,7 +11,7 @@ from urllib.parse import urlparse -PREFLIGHT_SCHEMA_VERSION = "ghra_producer_preflight.v1" +PREFLIGHT_SCHEMA_VERSION = "ghra_producer_preflight.v2" @dataclass(frozen=True) @@ -19,8 +20,11 @@ class ProducerEvidence: commit: str ref: str checkout_role: str + checkout_path: str worktree_clean: bool + dirty_path_count: int verified_at: datetime + receipt_id: str def to_dict(self) -> dict[str, Any]: return { @@ -28,8 +32,11 @@ def to_dict(self) -> dict[str, Any]: "commit": self.commit, "ref": self.ref, "checkout_role": self.checkout_role, + "checkout_path": self.checkout_path, "worktree_clean": self.worktree_clean, + "dirty_path_count": self.dirty_path_count, "verified_at": self.verified_at.isoformat(), + "receipt_id": self.receipt_id, } @classmethod @@ -39,8 +46,11 @@ def from_dict(cls, payload: dict[str, Any]) -> ProducerEvidence: "commit", "ref", "checkout_role", + "checkout_path", "worktree_clean", + "dirty_path_count", "verified_at", + "receipt_id", } missing = sorted(required - payload.keys()) if missing: @@ -59,13 +69,18 @@ def from_dict(cls, payload: dict[str, Any]) -> ProducerEvidence: raise ValueError("Producer evidence commit must be a lowercase 40-character SHA.") if payload["worktree_clean"] is not True: raise ValueError("Producer evidence must declare a clean worktree.") + if payload["dirty_path_count"] != 0: + raise ValueError("Clean producer evidence must declare dirty_path_count=0.") return cls( repository=str(payload["repository"]), commit=commit, ref=str(payload["ref"]), checkout_role=str(payload["checkout_role"]), + checkout_path=str(payload["checkout_path"]), worktree_clean=True, + dirty_path_count=0, verified_at=parsed_verified_at.astimezone(UTC), + receipt_id=str(payload["receipt_id"]), ) @@ -119,13 +134,21 @@ def inspect_canonical_producer( "pass" if commit == expected_commit else "fail" ) state = "pass" if all(value == "pass" for value in checks.values()) else "fail" + verified_at = now or datetime.now(UTC) + dirty_path_count = len(status.splitlines()) if status else 0 + receipt_material = "\n".join( + (repository, commit, expected_ref, checkout_role, str(repo_root.resolve()), verified_at.isoformat()) + ) evidence = ProducerEvidence( repository=repository, commit=commit, ref=expected_ref, checkout_role=checkout_role, + checkout_path=str(repo_root.resolve()), worktree_clean=not status, - verified_at=now or datetime.now(UTC), + dirty_path_count=dirty_path_count, + verified_at=verified_at, + receipt_id=f"sha256:{hashlib.sha256(receipt_material.encode()).hexdigest()}", ) return ProducerPreflightResult(state=state, checks=checks, evidence=evidence) @@ -137,6 +160,14 @@ def verify_evidence_still_current(repo_root: Path, evidence: ProducerEvidence) - "Producer HEAD changed after preflight: " f"verified={evidence.commit}; current={current}" ) + if str(repo_root.resolve()) != evidence.checkout_path: + raise ValueError( + "Producer checkout changed after preflight: " + f"verified={evidence.checkout_path}; current={repo_root.resolve()}" + ) + status = _git(repo_root, "status", "--porcelain", "--untracked-files=all") + if status: + raise ValueError("Producer worktree became dirty after preflight.") def load_producer_evidence(path: Path) -> ProducerEvidence: diff --git a/src/report_enrichment.py b/src/report_enrichment.py index 021d9a8..004dea8 100644 --- a/src/report_enrichment.py +++ b/src/report_enrichment.py @@ -7,6 +7,7 @@ from src.portfolio_truth_types import truth_latest_path from src.report_contracts import RiskLookupEntry, RiskPosture, TopElevatedEntry +from src.scorer import display_dimension from src.terminology import ACTION_SYNC_CANONICAL_LABELS from src.weekly_packaging import finalize_weekly_pack from src.weekly_scheduling_overlay import apply_weekly_scheduling_overlay @@ -614,9 +615,10 @@ def _top_dimension_labels(scores: dict[str, float], *, reverse: bool) -> list[st for dimension, score in ordered: if dimension == "interest": continue - labels.append( - f"{DIMENSION_LABELS.get(dimension, dimension.replace('_', ' ').title())} ({score:.2f})" - ) + label = DIMENSION_LABELS.get(dimension, dimension.replace("_", " ").title()) + if display_dimension(dimension) != dimension: + label = f"{label} (unscored)" + labels.append(f"{label} ({score:.2f})") if len(labels) == 3: break return labels diff --git a/src/report_state.py b/src/report_state.py index 9757c80..fbb43e9 100644 --- a/src/report_state.py +++ b/src/report_state.py @@ -57,6 +57,8 @@ def audit_from_dict(data: dict) -> RepoAudit: interest_score=data.get("interest_score", 0), interest_tier=data.get("interest_tier", "mundane"), grade=data.get("grade", "F"), interest_grade=data.get("interest_grade", "F"), + scored_dimensions=data.get("scored_dimensions", []), + scored_weight_sum=data.get("scored_weight_sum", 0), badges=data.get("badges", []), next_badges=data.get("next_badges", []), flags=data.get("flags", []), lenses=data.get("lenses", {}), hotspots=data.get("hotspots", []), action_candidates=data.get("action_candidates", []), security_posture=data.get("security_posture", {}), diff --git a/src/reporter.py b/src/reporter.py index 6a58ebb..3e9d77c 100644 --- a/src/reporter.py +++ b/src/reporter.py @@ -49,6 +49,7 @@ build_weekly_review_pack, no_linked_artifact_summary, ) +from src.scorer import display_dimension from src.terminology import ACTION_SYNC_CANONICAL_LABELS from src.weekly_scheduling_overlay import resolve_weekly_story_value @@ -65,7 +66,9 @@ def _truncate(text: str | None, length: int = 60) -> str: return text[:length] + "..." if len(text) > length else text -def _file_path(output_dir: Path, prefix: str, username: str, dt: datetime, ext: str) -> Path: +def _file_path( + output_dir: Path, prefix: str, username: str, dt: datetime, ext: str +) -> Path: return output_dir / f"{prefix}-{username}-{_date_str(dt)}.{ext}" @@ -88,7 +91,10 @@ def _sanitize_for_json(obj: object) -> object: def _has_preflight_issues(preflight_summary: dict) -> bool: return bool( preflight_summary - and (preflight_summary.get("blocking_errors") or preflight_summary.get("warnings")) + and ( + preflight_summary.get("blocking_errors") + or preflight_summary.get("warnings") + ) ) @@ -98,7 +104,9 @@ def _has_preflight_issues(preflight_summary: dict) -> bool: def write_json_report(report: AuditReport, output_dir: Path) -> Path: """Write the full audit report as JSON.""" output_dir.mkdir(parents=True, exist_ok=True) - path = _file_path(output_dir, "audit-report", report.username, report.generated_at, "json") + path = _file_path( + output_dir, "audit-report", report.username, report.generated_at, "json" + ) with open(path, "w") as f: json.dump(_sanitize_for_json(report.to_dict()), f, indent=2) @@ -196,7 +204,9 @@ def write_raw_metadata(report: AuditReport, output_dir: Path) -> Path: def write_pcc_export(report: AuditReport, output_dir: Path) -> Path: """Write PCC-compatible flat JSON array.""" output_dir.mkdir(parents=True, exist_ok=True) - path = _file_path(output_dir, "pcc-import", report.username, report.generated_at, "json") + path = _file_path( + output_dir, "pcc-import", report.username, report.generated_at, "json" + ) records = [] for audit in report.audits: @@ -233,7 +243,9 @@ def write_markdown_report( ) -> Path: """Write human-readable Markdown audit report.""" output_dir.mkdir(parents=True, exist_ok=True) - path = _file_path(output_dir, "audit-report", report.username, report.generated_at, "md") + path = _file_path( + output_dir, "audit-report", report.username, report.generated_at, "md" + ) lines: list[str] = [] _w = lines.append @@ -269,7 +281,9 @@ def write_markdown_report( f"Warnings: {report.preflight_summary.get('warnings', 0)}" ) for check in (report.preflight_summary.get("checks") or [])[:5]: - _w(f"- {check.get('summary', 'Issue detected')} ({check.get('category', 'setup')})") + _w( + f"- {check.get('summary', 'Issue detected')} ({check.get('category', 'setup')})" + ) _w("") report_dict = report.to_dict() @@ -326,7 +340,9 @@ def write_markdown_report( _w( f"- Run Changes: {weekly_pack.get('run_change_summary', build_run_change_summary(diff_data))}" ) - _w(f"- Queue Pressure: {weekly_pack.get('queue_pressure_summary', queue_pressure_summary)}") + _w( + f"- Queue Pressure: {weekly_pack.get('queue_pressure_summary', queue_pressure_summary)}" + ) _w( f"- Trust / Actionability: {weekly_pack.get('trust_actionability_summary', trust_actionability_summary)}" ) @@ -421,17 +437,25 @@ def write_markdown_report( _w( f" - Intent Alignment: {item.get('intent_alignment', 'missing-contract')} — {item.get('intent_alignment_summary', 'Intent alignment cannot be judged until a portfolio catalog contract exists.')}" ) - _w(f" - {item.get('scorecard_line', 'Scorecard: No maturity scorecard is recorded yet.')}") + _w( + f" - {item.get('scorecard_line', 'Scorecard: No maturity scorecard is recorded yet.')}" + ) _w( f" - Maturity Gap: {item.get('maturity_gap_summary', 'No maturity gap summary is recorded yet.')}" ) _w(" - Evidence:") for evidence in item.get("evidence_strip", [])[:5]: - command = f" [{evidence.get('command_hint')}]" if evidence.get("command_hint") else "" + command = ( + f" [{evidence.get('command_hint')}]" + if evidence.get("command_hint") + else "" + ) _w( f" - {evidence.get('label', 'Item')} — {evidence.get('summary', 'No evidence summary is recorded yet.')}{command}" ) - _w(f" - Checkpoint Timing: {item.get('follow_through_checkpoint_timing', 'Unknown')}") + _w( + f" - Checkpoint Timing: {item.get('follow_through_checkpoint_timing', 'Unknown')}" + ) _w( f" - Next Checkpoint: {item.get('follow_through_checkpoint', 'Use the next run or linked artifact to confirm whether the recommendation moved.')}" ) @@ -446,7 +470,9 @@ def write_markdown_report( if weekly_pack.get("top_below_target_scorecard_items"): _w("- Scorecard Gaps:") for item in weekly_pack.get("top_below_target_scorecard_items", [])[:5]: - _w(f" - {item.get('repo', 'Repo')} — {item.get('summary', 'Below target.')}") + _w( + f" - {item.get('repo', 'Repo')} — {item.get('summary', 'Below target.')}" + ) _w( f"- Next Checkpoint: {weekly_pack.get('follow_through_checkpoint_summary', 'Use the next run or linked artifact to confirm whether the recommendation moved.')}" ) @@ -529,7 +555,11 @@ def write_markdown_report( ) _w("- Evidence:") for evidence in briefing.get("evidence_strip", [])[:5]: - command = f" [{evidence.get('command_hint')}]" if evidence.get("command_hint") else "" + command = ( + f" [{evidence.get('command_hint')}]" + if evidence.get("command_hint") + else "" + ) _w( f" - {evidence.get('label', 'Item')} — {evidence.get('summary', 'No evidence summary is recorded yet.')}{command}" ) @@ -556,7 +586,9 @@ def write_markdown_report( if report.operator_summary.get("watch_strategy"): _w(f"- Watch Strategy: `{report.operator_summary.get('watch_strategy')}`") if report.operator_summary.get("watch_decision_summary"): - _w(f"- Watch Decision: {report.operator_summary.get('watch_decision_summary')}") + _w( + f"- Watch Decision: {report.operator_summary.get('watch_decision_summary')}" + ) if report.operator_summary.get("what_changed"): _w(f"- What Changed: {report.operator_summary.get('what_changed')}") if report.operator_summary.get("why_it_matters"): @@ -567,9 +599,13 @@ def write_markdown_report( if report.operator_summary.get("trend_summary"): _w(f"- Trend: {report.operator_summary.get('trend_summary')}") if report.operator_summary.get("accountability_summary"): - _w(f"- Accountability: {report.operator_summary.get('accountability_summary')}") + _w( + f"- Accountability: {report.operator_summary.get('accountability_summary')}" + ) if report.operator_summary.get("follow_through_summary"): - _w(f"- Follow-Through: {report.operator_summary.get('follow_through_summary')}") + _w( + f"- Follow-Through: {report.operator_summary.get('follow_through_summary')}" + ) if report.operator_summary.get("follow_through_checkpoint_summary"): _w( f"- Next Checkpoint: {report.operator_summary.get('follow_through_checkpoint_summary')}" @@ -579,8 +615,12 @@ def write_markdown_report( f"- {ACTION_SYNC_CANONICAL_LABELS['readiness']}: {(report.operator_summary.get('action_sync_summary') or {}).get('summary')}" ) if report.operator_summary.get("next_action_sync_step"): - _w(f"- Next Action Sync Step: {report.operator_summary.get('next_action_sync_step')}") - if (report.operator_summary.get("apply_readiness_summary") or {}).get("summary"): + _w( + f"- Next Action Sync Step: {report.operator_summary.get('next_action_sync_step')}" + ) + if (report.operator_summary.get("apply_readiness_summary") or {}).get( + "summary" + ): _w( f"- Apply Packet: {(report.operator_summary.get('apply_readiness_summary') or {}).get('summary')}" ) @@ -590,10 +630,14 @@ def write_markdown_report( ) command_hint = (report.operator_summary.get("next_apply_candidate") or {}).get( "apply_command" - ) or (report.operator_summary.get("next_apply_candidate") or {}).get("preview_command") + ) or (report.operator_summary.get("next_apply_candidate") or {}).get( + "preview_command" + ) if command_hint: _w(f"- Action Sync Command Hint: `{command_hint}`") - if (report.operator_summary.get("campaign_outcomes_summary") or {}).get("summary"): + if (report.operator_summary.get("campaign_outcomes_summary") or {}).get( + "summary" + ): _w( f"- {ACTION_SYNC_CANONICAL_LABELS['post_apply_monitoring']}: {(report.operator_summary.get('campaign_outcomes_summary') or {}).get('summary')}" ) @@ -601,7 +645,9 @@ def write_markdown_report( _w( f"- Next Monitoring Step: {(report.operator_summary.get('next_monitoring_step') or {}).get('summary')}" ) - if (report.operator_summary.get("intervention_ledger_summary") or {}).get("summary"): + if (report.operator_summary.get("intervention_ledger_summary") or {}).get( + "summary" + ): _w( f"- {ACTION_SYNC_CANONICAL_LABELS['historical_portfolio_intelligence']}: {(report.operator_summary.get('intervention_ledger_summary') or {}).get('summary')}" ) @@ -609,15 +655,21 @@ def write_markdown_report( _w( f"- Next Historical Focus: {(report.operator_summary.get('next_historical_focus') or {}).get('summary')}" ) - if (report.operator_summary.get("automation_guidance_summary") or {}).get("summary"): + if (report.operator_summary.get("automation_guidance_summary") or {}).get( + "summary" + ): _w( f"- {ACTION_SYNC_CANONICAL_LABELS['automation_guidance']}: {(report.operator_summary.get('automation_guidance_summary') or {}).get('summary')}" ) - if (report.operator_summary.get("next_safe_automation_step") or {}).get("summary"): + if (report.operator_summary.get("next_safe_automation_step") or {}).get( + "summary" + ): _w( f"- Next Safe Automation Step: {(report.operator_summary.get('next_safe_automation_step') or {}).get('summary')}" ) - if (report.operator_summary.get("approval_workflow_summary") or {}).get("summary"): + if (report.operator_summary.get("approval_workflow_summary") or {}).get( + "summary" + ): _w( f"- {ACTION_SYNC_CANONICAL_LABELS['approval_workflow']}: {(report.operator_summary.get('approval_workflow_summary') or {}).get('summary')}" ) @@ -649,7 +701,9 @@ def write_markdown_report( _w( f"- Follow-Through Recovery Memory Reset: {report.operator_summary.get('follow_through_recovery_memory_reset_summary')}" ) - if report.operator_summary.get("follow_through_recovery_rebuild_strength_summary"): + if report.operator_summary.get( + "follow_through_recovery_rebuild_strength_summary" + ): _w( f"- Follow-Through Recovery Rebuild Strength: {report.operator_summary.get('follow_through_recovery_rebuild_strength_summary')}" ) @@ -657,7 +711,9 @@ def write_markdown_report( _w( f"- Follow-Through Recovery Reacquisition: {report.operator_summary.get('follow_through_recovery_reacquisition_summary')}" ) - if report.operator_summary.get("follow_through_recovery_reacquisition_durability_summary"): + if report.operator_summary.get( + "follow_through_recovery_reacquisition_durability_summary" + ): _w( f"- Follow-Through Reacquisition Durability: {report.operator_summary.get('follow_through_recovery_reacquisition_durability_summary')}" ) @@ -669,8 +725,12 @@ def write_markdown_report( ) primary_target = report.operator_summary.get("primary_target") or {} if primary_target: - repo = f"{primary_target.get('repo')}: " if primary_target.get("repo") else "" - _w(f"- Primary Target: {repo}{primary_target.get('title', 'Operator target')}") + repo = ( + f"{primary_target.get('repo')}: " if primary_target.get("repo") else "" + ) + _w( + f"- Primary Target: {repo}{primary_target.get('title', 'Operator target')}" + ) if report.operator_summary.get("primary_target_reason"): _w( f"- Why This Is The Top Target: {report.operator_summary.get('primary_target_reason')}" @@ -682,13 +742,17 @@ def write_markdown_report( if report.operator_summary.get("closure_guidance"): _w(f"- Closure Guidance: {report.operator_summary.get('closure_guidance')}") if report.operator_summary.get("primary_target_last_intervention"): - intervention = report.operator_summary.get("primary_target_last_intervention") or {} + intervention = ( + report.operator_summary.get("primary_target_last_intervention") or {} + ) when = (intervention.get("recorded_at") or "")[:10] repo = f"{intervention.get('repo')}: " if intervention.get("repo") else "" title = intervention.get("title", "") event_type = intervention.get("event_type", "recorded") outcome = intervention.get("outcome", event_type) - _w(f"- What We Tried: {when} {event_type} for {repo}{title} ({outcome})".strip()) + _w( + f"- What We Tried: {when} {event_type} for {repo}{title} ({outcome})".strip() + ) if report.operator_summary.get("primary_target_resolution_evidence"): _w( f"- Resolution Evidence: {report.operator_summary.get('primary_target_resolution_evidence')}" @@ -701,7 +765,10 @@ def write_markdown_report( if report.operator_summary.get("primary_target_confidence_reasons"): _w( "- Confidence Reasons: " - + ", ".join(report.operator_summary.get("primary_target_confidence_reasons") or []) + + ", ".join( + report.operator_summary.get("primary_target_confidence_reasons") + or [] + ) ) if report.operator_summary.get("next_action_confidence_label"): _w( @@ -719,12 +786,18 @@ def write_markdown_report( _w( f"- Why This Confidence Is Actionable: {report.operator_summary.get('adaptive_confidence_summary')}" ) - if report.operator_summary.get("primary_target_exception_status") not in {None, "", "none"}: + if report.operator_summary.get("primary_target_exception_status") not in { + None, + "", + "none", + }: _w( f"- Trust Policy Exception: {report.operator_summary.get('primary_target_exception_status')} " f"({report.operator_summary.get('primary_target_exception_reason', 'No trust-policy exception reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_exception_pattern_status") not in { + if report.operator_summary.get( + "primary_target_exception_pattern_status" + ) not in { None, "", "none", @@ -751,7 +824,9 @@ def write_markdown_report( _w( f"- Recovery Confidence Summary: {report.operator_summary.get('recovery_confidence_summary')}" ) - if report.operator_summary.get("primary_target_exception_retirement_status") not in { + if report.operator_summary.get( + "primary_target_exception_retirement_status" + ) not in { None, "", "none", @@ -769,7 +844,9 @@ def write_markdown_report( f"- Policy Debt Cleanup: {report.operator_summary.get('primary_target_policy_debt_status')} " f"({report.operator_summary.get('primary_target_policy_debt_reason', 'No policy-debt reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_class_normalization_status") not in { + if report.operator_summary.get( + "primary_target_class_normalization_status" + ) not in { None, "", "none", @@ -797,7 +874,10 @@ def write_markdown_report( _w( "- Why Class Guidance Shifted: " + ", ".join( - report.operator_summary.get("primary_target_class_trust_reweight_reasons") or [] + report.operator_summary.get( + "primary_target_class_trust_reweight_reasons" + ) + or [] ) ) if report.operator_summary.get("primary_target_class_trust_momentum_status"): @@ -805,7 +885,9 @@ def write_markdown_report( f"- Class Trust Momentum: {report.operator_summary.get('primary_target_class_trust_momentum_status')} " f"({report.operator_summary.get('primary_target_class_trust_momentum_score', 0.0):.2f})" ) - if report.operator_summary.get("primary_target_class_reweight_stability_status"): + if report.operator_summary.get( + "primary_target_class_reweight_stability_status" + ): _w( f"- Reweighting Stability: {report.operator_summary.get('primary_target_class_reweight_stability_status')} " f"({report.operator_summary.get('primary_target_class_reweight_transition_status', 'none')}: " @@ -816,12 +898,16 @@ def write_markdown_report( f"- Class Transition Health: {report.operator_summary.get('primary_target_class_transition_health_status')} " f"({report.operator_summary.get('primary_target_class_transition_health_reason', 'No class transition health reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_class_transition_resolution_status"): + if report.operator_summary.get( + "primary_target_class_transition_resolution_status" + ): _w( f"- Pending Transition Resolution: {report.operator_summary.get('primary_target_class_transition_resolution_status')} " f"({report.operator_summary.get('primary_target_class_transition_resolution_reason', 'No class transition resolution reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_transition_closure_confidence_label"): + if report.operator_summary.get( + "primary_target_transition_closure_confidence_label" + ): _w( f"- Transition Closure Confidence: {report.operator_summary.get('primary_target_transition_closure_confidence_label')} " f"({report.operator_summary.get('primary_target_transition_closure_confidence_score', 0.0):.2f}; " @@ -837,22 +923,30 @@ def write_markdown_report( f"- Pending Debt Freshness: {report.operator_summary.get('primary_target_pending_debt_freshness_status')} " f"({report.operator_summary.get('primary_target_pending_debt_freshness_reason', 'No pending-debt freshness reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_closure_forecast_reweight_direction"): + if report.operator_summary.get( + "primary_target_closure_forecast_reweight_direction" + ): _w( f"- Closure Forecast Reweighting: {report.operator_summary.get('primary_target_closure_forecast_reweight_direction')} " f"({report.operator_summary.get('primary_target_closure_forecast_reweight_score', 0.0):.2f})" ) - if report.operator_summary.get("primary_target_closure_forecast_momentum_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_momentum_status" + ): _w( f"- Closure Forecast Momentum: {report.operator_summary.get('primary_target_closure_forecast_momentum_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_momentum_score', 0.0):.2f})" ) - if report.operator_summary.get("primary_target_closure_forecast_freshness_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_freshness_status" + ): _w( f"- Closure Forecast Freshness: {report.operator_summary.get('primary_target_closure_forecast_freshness_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_freshness_reason', 'No closure-forecast freshness reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_closure_forecast_stability_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_stability_status" + ): _w( f"- Closure Forecast Hysteresis: {report.operator_summary.get('primary_target_closure_forecast_stability_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_hysteresis_status', 'none')}: " @@ -863,12 +957,16 @@ def write_markdown_report( f"- Hysteresis Decay Controls: {report.operator_summary.get('primary_target_closure_forecast_decay_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_decay_reason', 'No closure-forecast decay reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_closure_forecast_refresh_recovery_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_refresh_recovery_status" + ): _w( f"- Closure Forecast Refresh Recovery: {report.operator_summary.get('primary_target_closure_forecast_refresh_recovery_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_refresh_recovery_score', 0.0):.2f})" ) - if report.operator_summary.get("primary_target_closure_forecast_reacquisition_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_reacquisition_status" + ): _w( f"- Reacquisition Controls: {report.operator_summary.get('primary_target_closure_forecast_reacquisition_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_reacquisition_reason', 'No closure-forecast reacquisition reason is recorded yet.')})" @@ -881,7 +979,9 @@ def write_markdown_report( f"({report.operator_summary.get('primary_target_closure_forecast_reacquisition_persistence_score', 0.0):.2f}; " f"{report.operator_summary.get('primary_target_closure_forecast_reacquisition_age_runs', 0)} run(s))" ) - if report.operator_summary.get("primary_target_closure_forecast_recovery_churn_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_recovery_churn_status" + ): _w( f"- Recovery Churn Controls: {report.operator_summary.get('primary_target_closure_forecast_recovery_churn_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_recovery_churn_reason', 'No recovery-churn reason is recorded yet.')})" @@ -893,7 +993,9 @@ def write_markdown_report( f"- Reacquisition Freshness: {report.operator_summary.get('primary_target_closure_forecast_reacquisition_freshness_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_reacquisition_freshness_reason', 'No reacquisition-freshness reason is recorded yet.')})" ) - if report.operator_summary.get("primary_target_closure_forecast_persistence_reset_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_persistence_reset_status" + ): _w( f"- Persistence Reset Controls: {report.operator_summary.get('primary_target_closure_forecast_persistence_reset_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_persistence_reset_reason', 'No persistence-reset reason is recorded yet.')})" @@ -905,7 +1007,9 @@ def write_markdown_report( f"- Reset Refresh Recovery: {report.operator_summary.get('primary_target_closure_forecast_reset_refresh_recovery_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_reset_refresh_recovery_score', 0.0):.2f})" ) - if report.operator_summary.get("primary_target_closure_forecast_reset_reentry_status"): + if report.operator_summary.get( + "primary_target_closure_forecast_reset_reentry_status" + ): _w( f"- Reset Re-entry Controls: {report.operator_summary.get('primary_target_closure_forecast_reset_reentry_status')} " f"({report.operator_summary.get('primary_target_closure_forecast_reset_reentry_reason', 'No reset re-entry reason is recorded yet.')})" @@ -1182,21 +1286,29 @@ def write_markdown_report( f"- Exception Retirement Summary: {report.operator_summary.get('exception_retirement_summary')}" ) if report.operator_summary.get("policy_debt_summary"): - _w(f"- Policy Debt Summary: {report.operator_summary.get('policy_debt_summary')}") + _w( + f"- Policy Debt Summary: {report.operator_summary.get('policy_debt_summary')}" + ) if report.operator_summary.get("trust_normalization_summary"): _w( f"- Trust Normalization Summary: {report.operator_summary.get('trust_normalization_summary')}" ) if report.operator_summary.get("class_memory_summary"): - _w(f"- Class Memory Summary: {report.operator_summary.get('class_memory_summary')}") + _w( + f"- Class Memory Summary: {report.operator_summary.get('class_memory_summary')}" + ) if report.operator_summary.get("class_decay_summary"): - _w(f"- Class Decay Summary: {report.operator_summary.get('class_decay_summary')}") + _w( + f"- Class Decay Summary: {report.operator_summary.get('class_decay_summary')}" + ) if report.operator_summary.get("class_reweighting_summary"): _w( f"- Class Reweighting Summary: {report.operator_summary.get('class_reweighting_summary')}" ) if report.operator_summary.get("class_momentum_summary"): - _w(f"- Class Momentum Summary: {report.operator_summary.get('class_momentum_summary')}") + _w( + f"- Class Momentum Summary: {report.operator_summary.get('class_momentum_summary')}" + ) if report.operator_summary.get("class_reweight_stability_summary"): _w( f"- Reweighting Stability Summary: {report.operator_summary.get('class_reweight_stability_summary')}" @@ -1261,7 +1373,9 @@ def write_markdown_report( _w( f"- Closure Forecast Reacquisition Summary: {report.operator_summary.get('closure_forecast_reacquisition_summary')}" ) - if report.operator_summary.get("closure_forecast_reacquisition_persistence_summary"): + if report.operator_summary.get( + "closure_forecast_reacquisition_persistence_summary" + ): _w( f"- Reacquisition Persistence Summary: {report.operator_summary.get('closure_forecast_reacquisition_persistence_summary')}" ) @@ -1269,7 +1383,9 @@ def write_markdown_report( _w( f"- Recovery Churn Summary: {report.operator_summary.get('closure_forecast_recovery_churn_summary')}" ) - if report.operator_summary.get("closure_forecast_reacquisition_freshness_summary"): + if report.operator_summary.get( + "closure_forecast_reacquisition_freshness_summary" + ): _w( f"- Reacquisition Freshness Summary: {report.operator_summary.get('closure_forecast_reacquisition_freshness_summary')}" ) @@ -1277,7 +1393,9 @@ def write_markdown_report( _w( f"- Persistence Reset Summary: {report.operator_summary.get('closure_forecast_persistence_reset_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_refresh_recovery_summary"): + if report.operator_summary.get( + "closure_forecast_reset_refresh_recovery_summary" + ): _w( f"- Reset Refresh Recovery Summary: {report.operator_summary.get('closure_forecast_reset_refresh_recovery_summary')}" ) @@ -1285,7 +1403,9 @@ def write_markdown_report( _w( f"- Reset Re-entry Summary: {report.operator_summary.get('closure_forecast_reset_reentry_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_persistence_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_persistence_summary" + ): _w( f"- Reset Re-entry Persistence Summary: {report.operator_summary.get('closure_forecast_reset_reentry_persistence_summary')}" ) @@ -1293,7 +1413,9 @@ def write_markdown_report( _w( f"- Reset Re-entry Churn Summary: {report.operator_summary.get('closure_forecast_reset_reentry_churn_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_freshness_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_freshness_summary" + ): _w( f"- Reset Re-entry Freshness Summary: {report.operator_summary.get('closure_forecast_reset_reentry_freshness_summary')}" ) @@ -1301,19 +1423,27 @@ def write_markdown_report( _w( f"- Reset Re-entry Reset Summary: {report.operator_summary.get('closure_forecast_reset_reentry_reset_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_refresh_recovery_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_refresh_recovery_summary" + ): _w( f"- Reset Re-entry Refresh Recovery Summary: {report.operator_summary.get('closure_forecast_reset_reentry_refresh_recovery_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_rebuild_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_rebuild_summary" + ): _w( f"- Reset Re-entry Rebuild Summary: {report.operator_summary.get('closure_forecast_reset_reentry_rebuild_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_rebuild_freshness_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_rebuild_freshness_summary" + ): _w( f"- Reset Re-entry Rebuild Freshness Summary: {report.operator_summary.get('closure_forecast_reset_reentry_rebuild_freshness_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_rebuild_reset_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_rebuild_reset_summary" + ): _w( f"- Reset Re-entry Rebuild Reset Summary: {report.operator_summary.get('closure_forecast_reset_reentry_rebuild_reset_summary')}" ) @@ -1323,7 +1453,9 @@ def write_markdown_report( _w( f"- Reset Re-entry Rebuild Refresh Recovery Summary: {report.operator_summary.get('closure_forecast_reset_reentry_rebuild_refresh_recovery_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_rebuild_reentry_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_rebuild_reentry_summary" + ): _w( f"- Reset Re-entry Rebuild Re-entry Summary: {report.operator_summary.get('closure_forecast_reset_reentry_rebuild_reentry_summary')}" ) @@ -1477,7 +1609,9 @@ def write_markdown_report( _w( f"- Reset Re-entry Rebuild Persistence Summary: {report.operator_summary.get('closure_forecast_reset_reentry_rebuild_persistence_summary')}" ) - if report.operator_summary.get("closure_forecast_reset_reentry_rebuild_churn_summary"): + if report.operator_summary.get( + "closure_forecast_reset_reentry_rebuild_churn_summary" + ): _w( f"- Reset Re-entry Rebuild Churn Summary: {report.operator_summary.get('closure_forecast_reset_reentry_rebuild_churn_summary')}" ) @@ -1492,7 +1626,9 @@ def write_markdown_report( ) if report.operator_summary.get("recent_validation_outcomes"): recent_outcomes = [] - for item in (report.operator_summary.get("recent_validation_outcomes") or [])[:3]: + for item in ( + report.operator_summary.get("recent_validation_outcomes") or [] + )[:3]: recent_outcomes.append( f"{item.get('target_label', 'Operator target')} " f"[{item.get('confidence_label', 'low')}] -> {str(item.get('outcome', 'unresolved')).replace('_', ' ')}" @@ -1516,13 +1652,19 @@ def write_markdown_report( ) _w(f" - Why: {item.get('summary', 'No summary available.')}") _w(f" - Lane Reason: {item.get('lane_reason', 'Operator triage')}") - _w(f" - Next: {item.get('recommended_action', 'Review the latest state.')}") - _w(f" - Last Movement: {build_last_movement_label(item, report.review_summary or {})}") + _w( + f" - Next: {item.get('recommended_action', 'Review the latest state.')}" + ) + _w( + f" - Last Movement: {build_last_movement_label(item, report.review_summary or {})}" + ) _w( f" - Follow-Through: {build_follow_through_status_label(item)} — " f"{build_follow_through_summary(item)}" ) - _w(f" - Checkpoint Timing: {build_follow_through_checkpoint_status_label(item)}") + _w( + f" - Checkpoint Timing: {build_follow_through_checkpoint_status_label(item)}" + ) _w( f" - Escalation: {build_follow_through_escalation_status_label(item)} — " f"{build_follow_through_escalation_summary(item)}" @@ -1659,7 +1801,9 @@ def write_markdown_report( _w("") provider_coverage = report.security_posture.get("provider_coverage", {}) open_alerts = report.security_posture.get("open_alerts", {}) - _w(f"- Average posture score: {report.security_posture.get('average_score', 0):.2f}") + _w( + f"- Average posture score: {report.security_posture.get('average_score', 0):.2f}" + ) _w( f"- Critical repos: {', '.join(report.security_posture.get('critical_repos', [])[:5]) or '—'}" ) @@ -1685,7 +1829,9 @@ def write_markdown_report( for entry in risk_lookup.values(): tier_counts[entry["risk_tier"]] = tier_counts.get(entry["risk_tier"], 0) + 1 elevated_repos = sorted( - name for name, entry in risk_lookup.items() if entry["risk_tier"] == "elevated" + name + for name, entry in risk_lookup.items() + if entry["risk_tier"] == "elevated" ) _w("### Risk Posture") _w("") @@ -1742,13 +1888,17 @@ def write_markdown_report( f"- {ACTION_SYNC_CANONICAL_LABELS['historical_portfolio_intelligence']}: {report.intervention_ledger_summary.get('summary')}" ) if report.next_historical_focus.get("summary"): - _w(f"- Next Historical Focus: {report.next_historical_focus.get('summary')}") + _w( + f"- Next Historical Focus: {report.next_historical_focus.get('summary')}" + ) if report.automation_guidance_summary.get("summary"): _w( f"- {ACTION_SYNC_CANONICAL_LABELS['automation_guidance']}: {report.automation_guidance_summary.get('summary')}" ) if report.next_safe_automation_step.get("summary"): - _w(f"- Next Safe Automation Step: {report.next_safe_automation_step.get('summary')}") + _w( + f"- Next Safe Automation Step: {report.next_safe_automation_step.get('summary')}" + ) if report.approval_workflow_summary.get("summary"): _w( f"- {ACTION_SYNC_CANONICAL_LABELS['approval_workflow']}: {report.approval_workflow_summary.get('summary')}" @@ -1807,12 +1957,18 @@ def write_markdown_report( ) _w("") - if report.rollback_preview.get("items") or report.rollback_preview.get("item_count", 0): + if report.rollback_preview.get("items") or report.rollback_preview.get( + "item_count", 0 + ): _w("### Rollback Preview") _w("") - _w(f"- Available: {'yes' if report.rollback_preview.get('available') else 'no'}") + _w( + f"- Available: {'yes' if report.rollback_preview.get('available') else 'no'}" + ) _w(f"- Items: {report.rollback_preview.get('item_count', 0)}") - _w(f"- Fully reversible: {report.rollback_preview.get('fully_reversible_count', 0)}") + _w( + f"- Fully reversible: {report.rollback_preview.get('fully_reversible_count', 0)}" + ) _w("") if report.security_governance_preview: @@ -1829,7 +1985,11 @@ def write_markdown_report( _w("") governance_summary = report.governance_summary or {} - if governance_summary or report.governance_results.get("results") or report.governance_drift: + if ( + governance_summary + or report.governance_results.get("results") + or report.governance_drift + ): _w("### Governance Operator State") _w("") _w( @@ -1837,15 +1997,21 @@ def write_markdown_report( ) _w(f"- Status: {governance_summary.get('status', 'preview')}") _w(f"- Approved: {'yes' if report.governance_approval else 'no'}") - _w(f"- Needs Re-Approval: {'yes' if governance_summary.get('needs_reapproval') else 'no'}") - _w(f"- Drift Count: {governance_summary.get('drift_count', len(report.governance_drift))}") + _w( + f"- Needs Re-Approval: {'yes' if governance_summary.get('needs_reapproval') else 'no'}" + ) + _w( + f"- Drift Count: {governance_summary.get('drift_count', len(report.governance_drift))}" + ) _w( f"- Applyable Count: {governance_summary.get('applyable_count', report.governance_preview.get('applyable_count', 0) if isinstance(report.governance_preview, dict) else 0)}" ) _w( f"- Applied Count: {governance_summary.get('applied_count', len(report.governance_results.get('results', [])))}" ) - _w(f"- Rollback Available: {governance_summary.get('rollback_available_count', 0)}") + _w( + f"- Rollback Available: {governance_summary.get('rollback_available_count', 0)}" + ) if governance_summary.get("approval_age_days") is not None: _w(f"- Approval Age (days): {governance_summary.get('approval_age_days')}") for item in governance_summary.get("top_actions", [])[:4]: @@ -1928,7 +2094,9 @@ def write_markdown_report( for audit in tier_audits: m = audit.metadata name_link = f"[{m.name}]({m.html_url})" - badges_str = " ".join(f"`{b}`" for b in audit.badges[:3]) if audit.badges else "—" + badges_str = ( + " ".join(f"`{b}`" for b in audit.badges[:3]) if audit.badges else "—" + ) desc = _truncate(m.description) lang = m.language or "—" _w( @@ -1971,7 +2139,9 @@ def write_markdown_report( ) _w("") _w("**Current State**") - _w(f"- {briefing.get('current_state_line', 'No current-state summary is recorded yet.')}") + _w( + f"- {briefing.get('current_state_line', 'No current-state summary is recorded yet.')}" + ) _w(f"- URL: {m.html_url}") _w( f"- Description: {briefing.get('current_state', {}).get('description', m.description or 'No description recorded yet.')}" @@ -2024,7 +2194,7 @@ def write_markdown_report( _w("|-----------|-------|-------------|") for r in audit.analyzer_results: findings = ", ".join(r.findings[:2]) if r.findings else "—" - _w(f"| {r.dimension} | {r.score:.2f} | {findings} |") + _w(f"| {display_dimension(r.dimension)} | {r.score:.2f} | {findings} |") _w("") _w( f"**Language:** {m.language or '—'} | " @@ -2075,7 +2245,9 @@ def write_markdown_report( _w( f"- Intent Alignment: {briefing.get('intent_alignment_line', 'missing-contract: Intent alignment cannot be judged until a portfolio catalog contract exists.')}" ) - _w(f"- Checkpoint Timing: {briefing.get('checkpoint_timing_line', 'Unknown')}") + _w( + f"- Checkpoint Timing: {briefing.get('checkpoint_timing_line', 'Unknown')}" + ) _w( f"- Escalation: {briefing.get('escalation_line', 'Unknown: No stronger follow-through escalation is currently surfaced.')}" ) @@ -2141,7 +2313,9 @@ def _write_ranked_list( for i, name in enumerate(names, 1): audit = audit_map.get(name) if audit: - lines.append(f"{i}. {name} — {audit.overall_score:.2f} ({audit.completeness_tier})") + lines.append( + f"{i}. {name} — {audit.overall_score:.2f} ({audit.completeness_tier})" + ) def _group_by_tier(audits: list[RepoAudit]) -> dict[str, list[RepoAudit]]: @@ -2174,7 +2348,9 @@ def _write_reconciliation_section(lines: list[str], report: AuditReport) -> None # On GitHub but not in registry if recon.on_github_not_registry: - _w(f"### On GitHub but NOT in Registry ({len(recon.on_github_not_registry)} repos)") + _w( + f"### On GitHub but NOT in Registry ({len(recon.on_github_not_registry)} repos)" + ) _w("") _w("| Repo | Tier | Score | Language |") _w("|------|------|-------|----------|") @@ -2189,7 +2365,9 @@ def _write_reconciliation_section(lines: list[str], report: AuditReport) -> None # In registry but not on GitHub if recon.in_registry_not_github: - _w(f"### In Registry but NOT on GitHub ({len(recon.in_registry_not_github)} projects)") + _w( + f"### In Registry but NOT on GitHub ({len(recon.in_registry_not_github)} projects)" + ) _w("") _w("| Project | Registry Status |") _w("|---------|----------------|") @@ -2237,7 +2415,9 @@ def _render_weekly_story_sections(_w, weekly_pack: dict) -> None: continue _w(f"### {section.get('label', 'Weekly Story')}") _w("") - _w(f"- Summary: {section.get('headline', 'No section summary is recorded yet.')}") + _w( + f"- Summary: {section.get('headline', 'No section summary is recorded yet.')}" + ) _w( f"- {section.get('next_label', 'Next Step')}: {section.get('next_step', 'No next step is recorded yet.')}" ) @@ -2246,7 +2426,9 @@ def _render_weekly_story_sections(_w, weekly_pack: dict) -> None: if evidence_items: _w("- Evidence:") for item in evidence_items[:5]: - command = f" [{item.get('command_hint')}]" if item.get("command_hint") else "" + command = ( + f" [{item.get('command_hint')}]" if item.get("command_hint") else "" + ) _w( f" - {item.get('label', 'Item')} — {item.get('summary', 'No evidence summary is recorded yet.')}{command}" ) diff --git a/src/run_instructions_audit.py b/src/run_instructions_audit.py index ff415a2..c38ce91 100644 --- a/src/run_instructions_audit.py +++ b/src/run_instructions_audit.py @@ -31,7 +31,13 @@ "next_recommended_move_present", ) -FORK_JUNK_PATTERNS = (r"-security-fix", r"-cve-", r"-backup-", r"\.bundle$", r"-openssl-") +FORK_JUNK_PATTERNS = ( + r"-security-fix", + r"-cve-", + r"-backup-", + r"\.bundle$", + r"-openssl-", +) DEFAULT_PER_TIER = { "none": 3, "boilerplate": 4, @@ -53,7 +59,9 @@ def assign_bucket(tool_today: bool, verdict: bool, evidence_in_primary: bool) -> return "fp_overclaim" -def assign_drift_bucket(snapshot_claim: bool, tool_today: bool, repo_drifted: bool) -> str: +def assign_drift_bucket( + snapshot_claim: bool, tool_today: bool, repo_drifted: bool +) -> str: if snapshot_claim == tool_today: return "claim_same" return "claim_changed_drift" if repo_drifted else "claim_changed_nodrift" @@ -65,7 +73,7 @@ def select_pilot( eligible = [ p for p in projects - if p["derived"]["registry_status"] != "archived" and not is_fork_junk(p["identity"]["path"]) + if not p["derived"]["archived"] and not is_fork_junk(p["identity"]["path"]) ] selected: list[dict] = [] for tier, count in per_tier.items(): @@ -87,13 +95,17 @@ def build_record(project: dict, workspace_root: str) -> dict: "abs_path": str(Path(workspace_root) / path), "primary_file_name": choose_primary_context_file(context_files), "context_files": context_files, - "snapshot_claims": {field: bool(derived.get(field, False)) for field in CLAIM_FIELDS}, + "snapshot_claims": { + field: bool(derived.get(field, False)) for field in CLAIM_FIELDS + }, } def compute_tool_today(abs_path: str) -> dict: project_path = Path(abs_path) - analysis = analyze_project_context(project_path, _collect_context_files(project_path)) + analysis = analyze_project_context( + project_path, _collect_context_files(project_path) + ) return {field: bool(getattr(analysis, field)) for field in CLAIM_FIELDS} @@ -117,7 +129,9 @@ def compute_drifted(abs_path: str, generated_at: str) -> bool: return is_after(commit_iso, generated_at) -def prepare_pilot(snapshot_path: str, *, per_tier: dict[str, int] = DEFAULT_PER_TIER) -> dict: +def prepare_pilot( + snapshot_path: str, *, per_tier: dict[str, int] = DEFAULT_PER_TIER +) -> dict: snapshot = json.loads(Path(snapshot_path).read_text()) workspace_root = snapshot["workspace_root"] generated_at = snapshot["generated_at"] @@ -148,7 +162,9 @@ def prepare_pilot(snapshot_path: str, *, per_tier: dict[str, int] = DEFAULT_PER_ def main() -> None: import sys - snapshot_path = sys.argv[1] if len(sys.argv) > 1 else "output/portfolio-truth-latest.json" + snapshot_path = ( + sys.argv[1] if len(sys.argv) > 1 else "output/portfolio-truth-latest.json" + ) print(json.dumps(prepare_pilot(snapshot_path), indent=2)) diff --git a/src/scorecards.py b/src/scorecards.py index 6f45e89..56ac093 100644 --- a/src/scorecards.py +++ b/src/scorecards.py @@ -141,7 +141,9 @@ def load_scorecards(path: Path | None = None) -> dict[str, Any]: } -def _normalize_levels(raw_levels: Any, errors: list[str], *, context: str) -> list[dict[str, Any]]: +def _normalize_levels( + raw_levels: Any, errors: list[str], *, context: str +) -> list[dict[str, Any]]: if raw_levels in (None, ""): return [dict(level) for level in DEFAULT_LEVELS] if not isinstance(raw_levels, list): @@ -162,13 +164,21 @@ def _normalize_levels(raw_levels: Any, errors: list[str], *, context: str) -> li if threshold is None: errors.append(f"Scorecard {context} level '{key}' is missing threshold.") continue - levels.append({"key": key, "label": _safe_text(raw.get("label")) or _labelize(key), "threshold": threshold}) + levels.append( + { + "key": key, + "label": _safe_text(raw.get("label")) or _labelize(key), + "threshold": threshold, + } + ) thresholds.append(threshold) if not levels: return [dict(level) for level in DEFAULT_LEVELS] if thresholds != sorted(thresholds): - errors.append(f"Scorecard {context} levels must be ordered by ascending threshold.") + errors.append( + f"Scorecard {context} levels must be ordered by ascending threshold." + ) return [dict(level) for level in DEFAULT_LEVELS] return levels @@ -193,28 +203,39 @@ def _normalize_programs( continue rules = raw_program.get("rules") if not isinstance(rules, list) or not rules: - errors.append(f"Scorecard program '{program_key}' must define at least one rule.") + errors.append( + f"Scorecard program '{program_key}' must define at least one rule." + ) continue normalized_rules: list[dict[str, Any]] = [] for index, raw_rule in enumerate(rules): if not isinstance(raw_rule, dict): - errors.append(f"Scorecard program '{program_key}' rule #{index + 1} must be a mapping.") + errors.append( + f"Scorecard program '{program_key}' rule #{index + 1} must be a mapping." + ) continue normalized_rule = _normalize_rule(raw_rule, errors, program_key=program_key) if normalized_rule: normalized_rules.append(normalized_rule) - levels = _normalize_levels(raw_program.get("levels"), errors, context=f"program '{program_key}'") - target = _normalize_key(raw_program.get("target_maturity")) or DEFAULT_PROGRAM_TARGETS.get(key, "operating") + levels = _normalize_levels( + raw_program.get("levels"), errors, context=f"program '{program_key}'" + ) + target = _normalize_key( + raw_program.get("target_maturity") + ) or DEFAULT_PROGRAM_TARGETS.get(key, "operating") level_keys = {level["key"] for level in levels} if target not in level_keys: errors.append( f"Scorecard program '{program_key}' target_maturity '{target}' is not defined in its levels." ) - target = DEFAULT_PROGRAM_TARGETS.get(key, levels[min(len(levels) - 1, 2)]["key"]) + target = DEFAULT_PROGRAM_TARGETS.get( + key, levels[min(len(levels) - 1, 2)]["key"] + ) programs[key] = { "key": key, "label": _safe_text(raw_program.get("label")) or _labelize(key), - "description": _safe_text(raw_program.get("description")) or "No scorecard description is recorded yet.", + "description": _safe_text(raw_program.get("description")) + or "No scorecard description is recorded yet.", "levels": levels or default_levels, "target_maturity": target, "rules": normalized_rules, @@ -222,18 +243,24 @@ def _normalize_programs( return programs -def _normalize_rule(raw_rule: dict[str, Any], errors: list[str], *, program_key: str) -> dict[str, Any] | None: +def _normalize_rule( + raw_rule: dict[str, Any], errors: list[str], *, program_key: str +) -> dict[str, Any] | None: rule_key = _normalize_key(raw_rule.get("key")) check = _normalize_key(raw_rule.get("check")) if not rule_key: errors.append(f"Scorecard program '{program_key}' has a rule without key.") return None if check not in VALID_RULE_CHECKS: - errors.append(f"Scorecard rule '{rule_key}' in program '{program_key}' uses unsupported check '{check}'.") + errors.append( + f"Scorecard rule '{rule_key}' in program '{program_key}' uses unsupported check '{check}'." + ) return None weight = _normalize_float(raw_rule.get("weight")) if weight is None or weight <= 0: - errors.append(f"Scorecard rule '{rule_key}' in program '{program_key}' must have a positive weight.") + errors.append( + f"Scorecard rule '{rule_key}' in program '{program_key}' must have a positive weight." + ) return None rule = { "key": rule_key, @@ -261,11 +288,15 @@ def _normalize_rule(raw_rule: dict[str, Any], errors: list[str], *, program_key: rule["partial_threshold"] = partial_threshold values = raw_rule.get("values") if isinstance(values, list): - rule["values"] = [_safe_text(value).lower() for value in values if _safe_text(value)] + rule["values"] = [ + _safe_text(value).lower() for value in values if _safe_text(value) + ] return rule -def evaluate_scorecards_for_report(report, scorecards_data: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]: +def evaluate_scorecards_for_report( + report, scorecards_data: dict[str, Any] +) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]: programs = scorecards_data.get("programs") or {} repo_results: list[dict[str, Any]] = [] for audit in report.audits: @@ -275,21 +306,27 @@ def evaluate_scorecards_for_report(report, scorecards_data: dict[str, Any]) -> t return repo_results, summary, programs_summary -def evaluate_repo_scorecard(audit: Any, programs: dict[str, dict[str, Any]]) -> dict[str, Any]: +def evaluate_repo_scorecard( + audit: Any, programs: dict[str, dict[str, Any]] +) -> dict[str, Any]: audit_dict = _mapping(audit) metadata = _mapping(audit_dict.get("metadata")) catalog = { **_mapping(audit_dict.get("portfolio_catalog")), "archive_ready": ( "yes" - if bool(metadata.get("archived")) or _normalize_key(audit_dict.get("completeness_tier")) in {"skeleton", "abandoned"} + if bool(metadata.get("archived")) + or _normalize_key(audit_dict.get("completeness_tier")) + in {"skeleton", "abandoned"} else "no" ), } repo_name = _safe_text(metadata.get("name")) program_key = resolve_program_key(catalog, programs) if not program_key or program_key not in programs: - missing_program = program_key or _safe_text(catalog.get("maturity_program")) or "default" + missing_program = ( + program_key or _safe_text(catalog.get("maturity_program")) or "default" + ) return { "repo": repo_name, "program": missing_program, @@ -308,18 +345,29 @@ def evaluate_repo_scorecard(audit: Any, programs: dict[str, dict[str, Any]]) -> program = programs[program_key] target_maturity = resolve_target_maturity(catalog, program) - rule_results = [_evaluate_rule(audit_dict, catalog, rule) for rule in program.get("rules", [])] + rule_results = [ + _evaluate_rule(audit_dict, catalog, rule) for rule in program.get("rules", []) + ] applicable = [rule for rule in rule_results if rule.status != "not_applicable"] total_weight = sum(rule.weight for rule in applicable) - score = round( - sum(rule.weight * rule.value for rule in applicable) / total_weight, - 3, - ) if total_weight else 0.0 + score = ( + round( + sum(rule.weight * rule.value for rule in applicable) / total_weight, + 3, + ) + if total_weight + else 0.0 + ) maturity_level = _maturity_for_score(score, program.get("levels") or DEFAULT_LEVELS) achieved_rank = _level_rank(maturity_level, program.get("levels") or DEFAULT_LEVELS) target_rank = _level_rank(target_maturity, program.get("levels") or DEFAULT_LEVELS) failed_rules = [rule for rule in applicable if rule.status in {"partial", "fail"}] - top_gaps = [rule.label for rule in sorted(failed_rules, key=lambda item: (item.value, -item.weight))[:3]] + top_gaps = [ + rule.label + for rule in sorted(failed_rules, key=lambda item: (item.value, -item.weight))[ + :3 + ] + ] status = "on-track" if achieved_rank >= target_rank else "below-target" if not applicable: status = "missing-program" @@ -356,46 +404,62 @@ def evaluate_repo_scorecard(audit: Any, programs: dict[str, dict[str, Any]]) -> } -def resolve_program_key(catalog: dict[str, Any], programs: dict[str, dict[str, Any]]) -> str: +def resolve_program_key( + catalog: dict[str, Any], programs: dict[str, dict[str, Any]] +) -> str: explicit = _normalize_key(catalog.get("maturity_program")) if explicit: return explicit default_program = _normalize_key(catalog.get("catalog_default_maturity_program")) if default_program: return default_program - disposition = _normalize_key(catalog.get("intended_disposition")) + # `operating_path` is the canonical field; `intended_disposition` is a deprecated + # read-compat fallback for entries that haven't migrated yet (same value domain). + disposition = _normalize_key( + catalog.get("operating_path") or catalog.get("intended_disposition") + ) if disposition and disposition in programs: return disposition return "default" def resolve_target_maturity(catalog: dict[str, Any], program: dict[str, Any]) -> str: - target = _normalize_key(catalog.get("target_maturity")) or _normalize_key(catalog.get("catalog_default_target_maturity")) + target = _normalize_key(catalog.get("target_maturity")) or _normalize_key( + catalog.get("catalog_default_target_maturity") + ) levels = {level["key"] for level in program.get("levels") or DEFAULT_LEVELS} if target in levels: return target return _normalize_key(program.get("target_maturity")) or "operating" -def _evaluate_rule(audit: dict[str, Any], catalog: dict[str, Any], rule: dict[str, Any]) -> RuleEvaluation: +def _evaluate_rule( + audit: dict[str, Any], catalog: dict[str, Any], rule: dict[str, Any] +) -> RuleEvaluation: check = rule["check"] if check == "dimension_at_least": return _evaluate_at_least_rule( rule, actual=_dimension_score(audit, _safe_text(rule.get("dimension"))), - summary_label=_safe_text(rule.get("summary_label")) or _safe_text(rule.get("dimension")), + summary_label=_safe_text(rule.get("summary_label")) + or _safe_text(rule.get("dimension")), ) if check == "lens_at_least": lenses = _mapping(audit.get("lenses")) - actual = _normalize_float(_mapping(lenses.get(_safe_text(rule.get("lens")))).get("score")) + actual = _normalize_float( + _mapping(lenses.get(_safe_text(rule.get("lens")))).get("score") + ) return _evaluate_at_least_rule( rule, actual=actual, - summary_label=_safe_text(rule.get("summary_label")) or _safe_text(rule.get("lens")), + summary_label=_safe_text(rule.get("summary_label")) + or _safe_text(rule.get("lens")), ) if check == "security_posture_at_least": actual = _normalize_float(_mapping(audit.get("security_posture")).get("score")) - return _evaluate_at_least_rule(rule, actual=actual, summary_label="security posture") + return _evaluate_at_least_rule( + rule, actual=actual, summary_label="security posture" + ) if check == "audit_field_at_least": field = _safe_text(rule.get("field")) actual = _normalize_float(audit.get(field)) @@ -404,10 +468,24 @@ def _evaluate_rule(audit: dict[str, Any], catalog: dict[str, Any], rule: dict[st required_tier = _normalize_key(rule.get("tier")) current_tier = _normalize_key(audit.get("completeness_tier")) if required_tier not in TIER_RANKS: - return RuleEvaluation(rule["key"], rule["label"], "not_applicable", rule["weight"], 0.0, "Required tier is invalid.") + return RuleEvaluation( + rule["key"], + rule["label"], + "not_applicable", + rule["weight"], + 0.0, + "Required tier is invalid.", + ) passed = TIER_RANKS.get(current_tier, -1) >= TIER_RANKS[required_tier] reason = f"{current_tier or 'unknown'} tier {'meets' if passed else 'does not meet'} the {required_tier} bar." - return RuleEvaluation(rule["key"], rule["label"], "pass" if passed else "fail", rule["weight"], 1.0 if passed else 0.0, reason) + return RuleEvaluation( + rule["key"], + rule["label"], + "pass" if passed else "fail", + rule["weight"], + 1.0 if passed else 0.0, + reason, + ) if check == "flag_absent": flag = _safe_text(rule.get("flag")) flags = {str(item).strip() for item in audit.get("flags", [])} @@ -425,20 +503,62 @@ def _evaluate_rule(audit: dict[str, Any], catalog: dict[str, Any], rule: dict[st threshold = _normalize_float(rule.get("threshold")) partial_threshold = _normalize_float(rule.get("partial_threshold")) if threshold is None or actual is None: - return RuleEvaluation(rule["key"], rule["label"], "not_applicable", rule["weight"], 0.0, "No activity age is available.") + return RuleEvaluation( + rule["key"], + rule["label"], + "not_applicable", + rule["weight"], + 0.0, + "No activity age is available.", + ) if actual <= threshold: - return RuleEvaluation(rule["key"], rule["label"], "pass", rule["weight"], 1.0, f"Activity is fresh at {actual} days.") + return RuleEvaluation( + rule["key"], + rule["label"], + "pass", + rule["weight"], + 1.0, + f"Activity is fresh at {actual} days.", + ) if partial_threshold is not None and actual <= partial_threshold: - return RuleEvaluation(rule["key"], rule["label"], "partial", rule["weight"], 0.5, f"Activity is aging at {actual} days.") - return RuleEvaluation(rule["key"], rule["label"], "fail", rule["weight"], 0.0, f"Activity is too old at {actual} days.") + return RuleEvaluation( + rule["key"], + rule["label"], + "partial", + rule["weight"], + 0.5, + f"Activity is aging at {actual} days.", + ) + return RuleEvaluation( + rule["key"], + rule["label"], + "fail", + rule["weight"], + 0.0, + f"Activity is too old at {actual} days.", + ) if check == "catalog_field_in": field = _safe_text(rule.get("catalog_field")) values = set(rule.get("values") or []) actual = _normalize_key(catalog.get(field)) if not values: - return RuleEvaluation(rule["key"], rule["label"], "not_applicable", rule["weight"], 0.0, "No catalog values are configured.") + return RuleEvaluation( + rule["key"], + rule["label"], + "not_applicable", + rule["weight"], + 0.0, + "No catalog values are configured.", + ) if not actual: - return RuleEvaluation(rule["key"], rule["label"], "not_applicable", rule["weight"], 0.0, f"Catalog field '{field}' is empty.") + return RuleEvaluation( + rule["key"], + rule["label"], + "not_applicable", + rule["weight"], + 0.0, + f"Catalog field '{field}' is empty.", + ) passed = actual in values return RuleEvaluation( rule["key"], @@ -448,19 +568,56 @@ def _evaluate_rule(audit: dict[str, Any], catalog: dict[str, Any], rule: dict[st 1.0 if passed else 0.0, f"Catalog field '{field}' is {actual}.", ) - return RuleEvaluation(rule["key"], rule["label"], "not_applicable", rule["weight"], 0.0, "Rule is unsupported.") + return RuleEvaluation( + rule["key"], + rule["label"], + "not_applicable", + rule["weight"], + 0.0, + "Rule is unsupported.", + ) -def _evaluate_at_least_rule(rule: dict[str, Any], *, actual: float | None, summary_label: str) -> RuleEvaluation: +def _evaluate_at_least_rule( + rule: dict[str, Any], *, actual: float | None, summary_label: str +) -> RuleEvaluation: threshold = _normalize_float(rule.get("threshold")) partial_threshold = _normalize_float(rule.get("partial_threshold")) if threshold is None or actual is None: - return RuleEvaluation(rule["key"], rule["label"], "not_applicable", rule["weight"], 0.0, f"No {summary_label} value is available.") + return RuleEvaluation( + rule["key"], + rule["label"], + "not_applicable", + rule["weight"], + 0.0, + f"No {summary_label} value is available.", + ) if actual >= threshold: - return RuleEvaluation(rule["key"], rule["label"], "pass", rule["weight"], 1.0, f"{summary_label} is {actual:.2f}.") + return RuleEvaluation( + rule["key"], + rule["label"], + "pass", + rule["weight"], + 1.0, + f"{summary_label} is {actual:.2f}.", + ) if partial_threshold is not None and actual >= partial_threshold: - return RuleEvaluation(rule["key"], rule["label"], "partial", rule["weight"], 0.5, f"{summary_label} is only {actual:.2f}.") - return RuleEvaluation(rule["key"], rule["label"], "fail", rule["weight"], 0.0, f"{summary_label} is only {actual:.2f}.") + return RuleEvaluation( + rule["key"], + rule["label"], + "partial", + rule["weight"], + 0.5, + f"{summary_label} is only {actual:.2f}.", + ) + return RuleEvaluation( + rule["key"], + rule["label"], + "fail", + rule["weight"], + 0.0, + f"{summary_label} is only {actual:.2f}.", + ) def _dimension_score(audit: dict[str, Any], dimension: str) -> float | None: @@ -524,20 +681,30 @@ def _build_scorecard_summary( return f"{program_label} is at {level_label} and still below the {target_label} target." -def build_scorecards_summary(scorecard_results: list[dict[str, Any]], scorecards_data: dict[str, Any]) -> dict[str, Any]: +def build_scorecards_summary( + scorecard_results: list[dict[str, Any]], scorecards_data: dict[str, Any] +) -> dict[str, Any]: by_program: dict[str, int] = {} by_maturity: dict[str, int] = {} by_status: dict[str, int] = {} below_target = [] for result in scorecard_results: - by_program[result.get("program", "default")] = by_program.get(result.get("program", "default"), 0) + 1 - by_maturity[result.get("maturity_level", "missing-basics")] = by_maturity.get(result.get("maturity_level", "missing-basics"), 0) + 1 - by_status[result.get("status", "missing-program")] = by_status.get(result.get("status", "missing-program"), 0) + 1 + by_program[result.get("program", "default")] = ( + by_program.get(result.get("program", "default"), 0) + 1 + ) + by_maturity[result.get("maturity_level", "missing-basics")] = ( + by_maturity.get(result.get("maturity_level", "missing-basics"), 0) + 1 + ) + by_status[result.get("status", "missing-program")] = ( + by_status.get(result.get("status", "missing-program"), 0) + 1 + ) if result.get("status") == "below-target": below_target.append( { "repo": result.get("repo", ""), - "program": result.get("program_label", result.get("program", "default")), + "program": result.get( + "program_label", result.get("program", "default") + ), "summary": result.get("summary", ""), } ) diff --git a/src/scorer.py b/src/scorer.py index c8fbe59..152aeb1 100644 --- a/src/scorer.py +++ b/src/scorer.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from src.badges import compute_badges, suggest_next_badges from src.models import AnalyzerResult, RepoAudit, RepoMetadata @@ -45,11 +45,21 @@ STALE_THRESHOLD_DAYS = 730 # 2 years GRADE_THRESHOLDS = [(0.80, "A"), (0.70, "B"), (0.55, "C"), (0.35, "D"), (0.0, "F")] +PARTIAL_RUN_BASIS_THRESHOLD = 0.85 +UNSCORED_DIMENSIONS = frozenset({"description"}) -def letter_grade(score: float) -> str: + +def display_dimension(dimension: str) -> str: + """Return the operator-facing label for a scored or advisory dimension.""" + return f"{dimension} (unscored)" if dimension in UNSCORED_DIMENSIONS else dimension + + +def letter_grade( + score: float, grade_thresholds: list[tuple[float, str]] | None = None +) -> str: """Map a 0.0-1.0 score to a letter grade A-F.""" - for threshold, grade in GRADE_THRESHOLDS: + for threshold, grade in grade_thresholds or GRADE_THRESHOLDS: if score >= threshold: return grade return "F" @@ -61,13 +71,22 @@ def score_repo( repo_path: Path | None = None, portfolio_lang_freq: dict[str, float] | None = None, custom_weights: dict[str, float] | None = None, + scoring_profile: dict[str, Any] | None = None, github_client: GitHubClient | None = None, *, scorecard_enabled: bool = False, security_offline: bool = False, ) -> RepoAudit: """Compute dual-axis scores: completeness + interest.""" - weights = dict(custom_weights) if custom_weights else dict(WEIGHTS) + profile = scoring_profile or {} + weights = dict(custom_weights or WEIGHTS) + completeness_tiers = [ + tuple(tier) for tier in profile.get("completeness_tiers", COMPLETENESS_TIERS) + ] + grade_thresholds = [ + tuple(threshold) for threshold in profile.get("grade_thresholds", GRADE_THRESHOLDS) + ] + stale_threshold_days = profile.get("stale_threshold_days", STALE_THRESHOLD_DAYS) flags: list[str] = [] # Fork override: reduce activity weight @@ -91,6 +110,7 @@ def score_repo( weight_sum += weight overall_score = weighted_sum / weight_sum if weight_sum > 0 else 0.0 + scored_dimensions = [dimension for dimension in weights if dimension in score_map] # Compute interest score (separate axis, from interest analyzer) interest_score = score_map.get("interest", 0.0) @@ -111,7 +131,7 @@ def score_repo( # Classify completeness tier tier = "abandoned" - for tier_name, threshold in COMPLETENESS_TIERS: + for tier_name, threshold in completeness_tiers: if overall_score >= threshold: tier = tier_name break @@ -141,7 +161,7 @@ def score_repo( # Override: stale >2 years capped at "wip" if metadata.pushed_at: days_since = (datetime.now(timezone.utc) - metadata.pushed_at).days - if days_since > STALE_THRESHOLD_DAYS: + if days_since > stale_threshold_days: flags.append("stale-2yr") if tier in ("shipped", "functional"): tier = "wip" @@ -152,6 +172,8 @@ def score_repo( tier = "skeleton" flags.append("readme-only") + grade = letter_grade(overall_score, grade_thresholds) + audit = RepoAudit( metadata=metadata, analyzer_results=results, @@ -159,8 +181,10 @@ def score_repo( interest_score=interest_score, completeness_tier=tier, interest_tier=interest_tier, - grade=letter_grade(overall_score), - interest_grade=letter_grade(interest_score), + grade=grade, + scored_dimensions=scored_dimensions, + scored_weight_sum=weight_sum, + interest_grade=letter_grade(interest_score, grade_thresholds), flags=flags, ) diff --git a/src/serve/routes.py b/src/serve/routes.py index 7298c6d..0e2cf39 100644 --- a/src/serve/routes.py +++ b/src/serve/routes.py @@ -15,6 +15,7 @@ from fastapi.templating import Jinja2Templates from src.portfolio_truth_types import truth_latest_path +from src.scorer import display_dimension from src.serve.runner import SAFE_FLAG_NAMES, get_session, spawn_run, validate_flags router = APIRouter() @@ -194,7 +195,10 @@ async def repo_detail(request: Request, name: str) -> HTMLResponse: """, (name, name, name), ).fetchall() - dimension_scores = [dict(r) for r in rows2] + dimension_scores = [ + {**dict(row), "dimension": display_dimension(str(row["dimension"]))} + for row in rows2 + ] except sqlite3.Error: # Optional dimension breakdown should not block the repo detail page. pass diff --git a/src/web_export.py b/src/web_export.py index e00c562..3cacce8 100644 --- a/src/web_export.py +++ b/src/web_export.py @@ -58,6 +58,7 @@ no_baseline_summary, no_linked_artifact_summary, ) +from src.scorer import PARTIAL_RUN_BASIS_THRESHOLD, WEIGHTS from src.sparkline import sparkline as render_sparkline from src.terminology import ACTION_SYNC_CANONICAL_LABELS from src.weekly_scheduling_overlay import resolve_weekly_story_value @@ -180,6 +181,8 @@ def _render_html( { "name": entry["name"], "grade": a.get("grade", "F"), + "scored_dimensions": a.get("scored_dimensions", []), + "scored_weight_sum": a.get("scored_weight_sum", 0), "score": round(a.get("overall_score", 0), 3), "interest": round(a.get("interest_score", 0), 3), "tier": a.get("completeness_tier", ""), @@ -243,7 +246,7 @@ def _render_html( # ── KPI and header sections ─────────────────────────────────────────── def _header_section(username: str, date: str, repos: int, grade: str) -> str: - color = GRADE_COLORS_CSS.get(grade, "#6B7280") + color = GRADE_COLORS_CSS.get(grade.split(" ", 1)[0], "#6B7280") return f"""

Portfolio Dashboard: {escape(username)}

@@ -251,6 +254,23 @@ def _header_section(username: str, date: str, repos: int, grade: str) -> str:
""" +def _build_display_grade(audit: dict) -> str: + """Render partial-run basis disclosure without changing the model grade.""" + grade = str(audit.get("grade", "F")) + if " on " in grade: + return grade + + scored_dimensions = audit.get("scored_dimensions") or [] + scored_weight_sum = audit.get("scored_weight_sum") + if ( + scored_dimensions + and isinstance(scored_weight_sum, (int, float)) + and scored_weight_sum < sum(WEIGHTS.values()) * PARTIAL_RUN_BASIS_THRESHOLD + ): + return f"{grade} on {len(scored_dimensions)}/{len(WEIGHTS)} dimensions" + return grade + + def _kpi_section(data: dict) -> str: tiers = data.get("tier_distribution", {}) avg = data.get("average_score", 0) @@ -923,6 +943,8 @@ def _repo_table( explanation = a.get("score_explanation") or build_score_explanation(a) or {} name = m.get("name", "") grade = a.get("grade", "F") + grade_letter = grade.split(" ", 1)[0] + display_grade = _build_display_grade(a) score = a.get("overall_score", 0) interest = a.get("interest_score", 0) profile_score = entry["profile_score"] @@ -930,7 +952,7 @@ def _repo_table( lang = m.get("language") or "" desc = (m.get("description") or "")[:60] collections = ", ".join(entry["collections"]) - gc = GRADE_COLORS_CSS.get(grade, "#6B7280") + gc = GRADE_COLORS_CSS.get(grade_letter, "#6B7280") tc = TIER_COLORS_CSS.get(tier, "#6B7280") spark = "" @@ -974,14 +996,14 @@ def _repo_table( rows.append( f'' f"{link}" f'{profile_score:.3f}' - f'{escape(grade)}' + f'{escape(display_grade)}' f'{score:.3f}' f'{interest:.3f}' f'{safe_tier}' diff --git a/src/weekly_command_center.py b/src/weekly_command_center.py index 55fd17e..c14769c 100644 --- a/src/weekly_command_center.py +++ b/src/weekly_command_center.py @@ -7,7 +7,11 @@ from src.portfolio_automation import select_automation_candidates from src.portfolio_decision_queue import build_decision_queue, summarize_decision_queue -from src.portfolio_truth_types import truth_latest_path +from src.portfolio_truth_types import display_activity_status, truth_latest_path +from src.portfolio_truth_trends import ( + build_verdict_transition_ledger, + render_movement_summary, +) from src.report_enrichment import build_weekly_review_pack CONTRACT_VERSION = "weekly_command_center_digest_v1" @@ -43,12 +47,18 @@ def _parse_datetime(value: Any) -> datetime | None: return parsed.astimezone(timezone.utc) -def _source_freshness(report_data: dict[str, Any], portfolio_truth: dict[str, Any]) -> dict[str, Any]: +def _source_freshness( + report_data: dict[str, Any], portfolio_truth: dict[str, Any] +) -> dict[str, Any]: report_generated_at = _parse_datetime(report_data.get("generated_at")) truth_generated_at = _parse_datetime(portfolio_truth.get("generated_at")) status = "current" summary = "Control-center source report and portfolio truth are aligned enough to read together." - if truth_generated_at and report_generated_at and truth_generated_at > report_generated_at: + if ( + truth_generated_at + and report_generated_at + and truth_generated_at > report_generated_at + ): status = "portfolio-truth-newer" summary = ( "Portfolio truth is newer than the audit report feeding the control-center queue; " @@ -63,12 +73,18 @@ def _source_freshness(report_data: dict[str, Any], portfolio_truth: dict[str, An return { "status": status, "summary": summary, - "report_generated_at": report_generated_at.isoformat() if report_generated_at else "", - "portfolio_truth_generated_at": truth_generated_at.isoformat() if truth_generated_at else "", + "report_generated_at": report_generated_at.isoformat() + if report_generated_at + else "", + "portfolio_truth_generated_at": truth_generated_at.isoformat() + if truth_generated_at + else "", } -def _weekly_pack_source(report_data: dict[str, Any], snapshot: dict[str, Any]) -> dict[str, Any]: +def _weekly_pack_source( + report_data: dict[str, Any], snapshot: dict[str, Any] +) -> dict[str, Any]: source = dict(report_data) snapshot_summary = _mapping(snapshot.get("operator_summary")) if snapshot_summary: @@ -118,31 +134,50 @@ def build_weekly_command_center_digest( *, diff_data: dict[str, Any] | None = None, portfolio_truth: dict[str, Any] | None = None, + portfolio_truth_history_dir: Path | None = None, portfolio_truth_reference: str = "", control_center_reference: str = "", report_reference: str = "", generated_at: str = "", ) -> dict[str, Any]: - weekly_pack = build_weekly_review_pack(_weekly_pack_source(report_data, snapshot), diff_data) + weekly_pack = build_weekly_review_pack( + _weekly_pack_source(report_data, snapshot), diff_data + ) weekly_story = _mapping(weekly_pack.get("weekly_story_v1")) operator_summary = _mapping(snapshot.get("operator_summary")) operator_queue = list(snapshot.get("operator_queue") or []) repo_briefings = list(weekly_pack.get("repo_briefings") or []) decision_quality = _mapping(operator_summary.get("decision_quality_v1")) decision_quality_status = ( - _safe_text(decision_quality.get("decision_quality_status")) or "insufficient-data" + _safe_text(decision_quality.get("decision_quality_status")) + or "insufficient-data" ) truth = portfolio_truth or {} freshness = _source_freshness(report_data, truth) source_is_stale = freshness["status"] != "current" truth_summary = _build_truth_summary(truth) + movement = ( + build_verdict_transition_ledger( + portfolio_truth_history_dir, + max_snapshots=8, + current_snapshot=truth, + current_path=Path(portfolio_truth_reference) + if portfolio_truth_reference + else None, + ) + if portfolio_truth_history_dir + else build_verdict_transition_ledger(max_snapshots=0) + ) + movement["summary_text"] = render_movement_summary(movement) decision_queue = build_decision_queue(truth) decision_queue_summary = summarize_decision_queue(decision_queue) operator_decision = _operator_decision(operator_summary, operator_queue) operator_why = _safe_text(operator_summary.get("trend_summary")) or _safe_text( operator_summary.get("why_it_matters") ) - queue_pressure_summary = operator_why or _safe_text(weekly_pack.get("queue_pressure_summary")) + queue_pressure_summary = operator_why or _safe_text( + weekly_pack.get("queue_pressure_summary") + ) headline = ( "Refresh the audit report before acting on control-center queue pressure." @@ -193,7 +228,8 @@ def build_weekly_command_center_digest( "workbook_first": True, "generated_at": generated_at or _safe_text(report_data.get("generated_at")), "username": _safe_text(report_data.get("username")) or "unknown", - "report_reference": report_reference or _safe_text(report_data.get("latest_report_path")), + "report_reference": report_reference + or _safe_text(report_data.get("latest_report_path")), "control_center_reference": control_center_reference, "portfolio_truth_reference": portfolio_truth_reference, "source_freshness": freshness, @@ -202,19 +238,27 @@ def build_weekly_command_center_digest( "why_this_week": why_this_week, "next_step": _safe_text(weekly_story.get("next_step")) or "Open the workbook first, then use the read-only control center.", - "queue_pressure_summary": freshness["summary"] if source_is_stale else queue_pressure_summary, - "operating_paths_summary": _safe_text(weekly_pack.get("operating_paths_summary")), + "queue_pressure_summary": freshness["summary"] + if source_is_stale + else queue_pressure_summary, + "operating_paths_summary": _safe_text( + weekly_pack.get("operating_paths_summary") + ), "decision_quality": { "status": decision_quality_status, "human_skepticism_required": bool( decision_quality.get("human_skepticism_required", True) ), - "summary": _safe_text(decision_quality.get("recommendation_quality_summary")) + "summary": _safe_text( + decision_quality.get("recommendation_quality_summary") + ) or _safe_text(decision_quality.get("confidence_calibration_summary")) or "Decision quality is not available yet.", - "authority_cap": _safe_text(decision_quality.get("authority_cap")) or AUTHORITY_CAP, + "authority_cap": _safe_text(decision_quality.get("authority_cap")) + or AUTHORITY_CAP, }, "portfolio_truth": {**truth_summary, **decision_queue_summary}, + "movement": movement, "decision_queue": decision_queue, "path_attention": _build_path_attention_items(truth), "automation_candidates": [ @@ -260,6 +304,7 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: risk_posture = _mapping(digest.get("risk_posture")) tier_counts = _mapping(risk_posture.get("risk_tier_counts")) security_posture = _mapping(digest.get("security_posture")) + movement = _mapping(digest.get("movement")) lines = [ f"# Weekly Command Center: {_safe_text(digest.get('username')) or 'unknown'}", "", @@ -303,7 +348,7 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: else: for item in path_attention: lines.append( - f"- {item['repo']} — {item['headline']} ({item['attention_state']}, {item['registry_status']} registry, {item['context_quality']} context)" + f"- {item['repo']} — {item['headline']} ({item['attention_state']}, {item['activity_status']} registry, {item['context_quality']} context)" ) lines.extend(["", "## Automation Candidates"]) @@ -313,7 +358,7 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: else: for item in automation_candidates: lines.append( - f"- {item['repo']} ({item['registry_status']}, " + f"- {item['repo']} ({item['activity_status']}, " f"{item['path_confidence']} path confidence, {item['context_quality']} context)" ) @@ -323,7 +368,9 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: lines.append("- No elevated risk items are currently surfaced.") else: for item in risk_items: - lines.append(f"- **{item['repo']}** [{item['risk_tier']}]: {item['risk_summary']}") + lines.append( + f"- **{item['repo']}** [{item['risk_tier']}]: {item['risk_summary']}" + ) lines.extend(["", "## Security Posture"]) security_items = list(security_posture.get("top_alerts") or []) @@ -345,6 +392,19 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: "(re-run with `--portfolio-truth-include-security`)." ) + lines.extend(["", "## Movement"]) + lines.append( + f"- {_safe_text(movement.get('summary_text')) or 'No verdict movement is recorded across the available truth snapshots.'}" + ) + for item in list(movement.get("transitions") or []): + if item.get("kind") == "repo_lifecycle": + continue + lines.append( + f"- **{_safe_text(item.get('repo'))}** [{_safe_text(item.get('kind'))}]: " + f"{_safe_text(item.get('from')) or 'unknown'}→{_safe_text(item.get('to')) or 'unknown'} " + f"({_safe_text(item.get('to_date')) or 'undated'})" + ) + lines.extend(["", "## Weekly Sections"]) for section in list(digest.get("section_digest") or []): lines.append( @@ -355,9 +415,13 @@ def render_weekly_command_center_markdown(digest: dict[str, Any]) -> str: if repo_briefings: lines.extend(["", "## Top Repo Briefings"]) for item in repo_briefings: - lines.append(f"- {item['repo']}: {item['why_it_matters']} Next: {item['next_step']}") + lines.append( + f"- {item['repo']}: {item['why_it_matters']} Next: {item['next_step']}" + ) - lines.extend(["", f"_Guardrail: {_safe_text(digest.get('report_only_guardrail'))}_", ""]) + lines.extend( + ["", f"_Guardrail: {_safe_text(digest.get('report_only_guardrail'))}_", ""] + ) return "\n".join(lines) @@ -397,10 +461,12 @@ def _build_truth_summary(portfolio_truth: dict[str, Any]) -> dict[str, Any]: override = _safe_text(derived.get("path_override")) or "none" override_counts[override] = override_counts.get(override, 0) + 1 attention_state = _safe_text(derived.get("attention_state")) or "manual-only" - attention_state_counts[attention_state] = attention_state_counts.get(attention_state, 0) + 1 + attention_state_counts[attention_state] = ( + attention_state_counts.get(attention_state, 0) + 1 + ) tier = _safe_text(risk.get("risk_tier")) or "baseline" risk_tier_counts[tier] = risk_tier_counts.get(tier, 0) + 1 - if _safe_text(derived.get("registry_status")) == "active": + if _safe_text(derived.get("activity_status")) == "active": active_project_count += 1 if _safe_text(derived.get("path_confidence")) == "low": low_confidence_path_count += 1 @@ -424,17 +490,28 @@ def _build_truth_summary(portfolio_truth: dict[str, Any]) -> dict[str, Any]: } -def _build_path_attention_items(portfolio_truth: dict[str, Any]) -> list[dict[str, Any]]: +def _build_path_attention_items( + portfolio_truth: dict[str, Any], +) -> list[dict[str, Any]]: projects = list(portfolio_truth.get("projects") or []) candidates: list[dict[str, Any]] = [] status_rank = {"decision-needed": 0, "active-product": 1, "active-infra": 2} - context_rank = {"none": 0, "boilerplate": 1, "minimum-viable": 2, "standard": 3, "full": 4} + context_rank = { + "none": 0, + "boilerplate": 1, + "minimum-viable": 2, + "standard": 3, + "full": 4, + } for project in projects: identity = _mapping(project.get("identity")) declared = _mapping(project.get("declared")) derived = _mapping(project.get("derived")) - registry_status = _safe_text(derived.get("registry_status")) + activity_status = display_activity_status( + _safe_text(derived.get("activity_status")), + archived=bool(derived.get("archived")), + ) attention_state = _safe_text(derived.get("attention_state")) if attention_state != "decision-needed": continue @@ -444,7 +521,8 @@ def _build_path_attention_items(portfolio_truth: dict[str, Any]) -> list[dict[st continue context_quality = _safe_text(derived.get("context_quality")) or "unknown" rationale = ( - _safe_text(derived.get("path_rationale")) or "Path guidance still needs clarification." + _safe_text(derived.get("path_rationale")) + or "Path guidance still needs clarification." ) headline = ( "Unspecified stable path" @@ -456,9 +534,10 @@ def _build_path_attention_items(portfolio_truth: dict[str, Any]) -> list[dict[st "repo": _safe_text(identity.get("display_name")) or "Repo", "headline": headline, "attention_state": attention_state, - "registry_status": registry_status or "unknown", + "activity_status": activity_status or "unknown", "context_quality": context_quality, - "path_confidence": _safe_text(derived.get("path_confidence")) or "legacy", + "path_confidence": _safe_text(derived.get("path_confidence")) + or "legacy", "rationale": rationale, "operating_path": operating_path or "unspecified", "path_override": override or "none", @@ -477,7 +556,9 @@ def _build_path_attention_items(portfolio_truth: dict[str, Any]) -> list[dict[st return candidates[:MAX_PATH_ATTENTION_ITEMS] -def _build_risk_attention_items(portfolio_truth: dict[str, Any]) -> list[dict[str, Any]]: +def _build_risk_attention_items( + portfolio_truth: dict[str, Any], +) -> list[dict[str, Any]]: projects = list(portfolio_truth.get("projects") or []) items: list[dict[str, Any]] = [] status_rank = {"active": 0, "recent": 1, "stale": 2, "archived": 3} @@ -533,7 +614,9 @@ def _build_security_summary(portfolio_truth: dict[str, Any]) -> dict[str, Any]: } -def _build_security_attention_items(portfolio_truth: dict[str, Any]) -> list[dict[str, Any]]: +def _build_security_attention_items( + portfolio_truth: dict[str, Any], +) -> list[dict[str, Any]]: """Top scanned repos carrying open high/critical Dependabot alerts, critical-first.""" projects = list(portfolio_truth.get("projects") or []) items: list[dict[str, Any]] = [] @@ -573,8 +656,14 @@ def _build_section_digest( digest = [] for section in sections: section_id = _safe_text(section.get("id")) - headline = _safe_text(section.get("headline")) or "No section headline is recorded yet." - next_step = _safe_text(section.get("next_step")) or "No section next step is recorded yet." + headline = ( + _safe_text(section.get("headline")) + or "No section headline is recorded yet." + ) + next_step = ( + _safe_text(section.get("next_step")) + or "No section next step is recorded yet." + ) if section_id == "weekly-priority": headline = operator_why or headline next_step = operator_decision or next_step diff --git a/tests/test_automation_proposals.py b/tests/test_automation_proposals.py index 7672a76..71f70c8 100644 --- a/tests/test_automation_proposals.py +++ b/tests/test_automation_proposals.py @@ -44,7 +44,7 @@ def _candidate( return AutomationCandidate( display_name=display_name, repo_full_name=repo_full_name, - registry_status="active", + activity_status="active", path_confidence="high", context_quality="standard", ) @@ -84,7 +84,10 @@ def test_build_creates_pending_proposals_for_each_candidate() -> None: proposals = build_automation_proposals( candidates, action_type=ACTION_CONTEXT_PR, created_at=NOW ) - assert [p.proposal_id for p in proposals] == ["context-pr:o/Alpha", "context-pr:o/Beta"] + assert [p.proposal_id for p in proposals] == [ + "context-pr:o/Alpha", + "context-pr:o/Beta", + ] assert all(p.status == STATUS_PENDING for p in proposals) @@ -248,7 +251,9 @@ def test_approve_non_pending_raises() -> None: ) ] with pytest.raises(ProposalApprovalError): - approve_proposal(proposals, "context-pr:o/Alpha", approved_by="x", approved_at=LATER) + approve_proposal( + proposals, "context-pr:o/Alpha", approved_by="x", approved_at=LATER + ) def test_reject_pending_sets_status_and_rejected_at_only() -> None: @@ -298,6 +303,8 @@ def test_require_approved_passes_for_approved() -> None: @pytest.mark.parametrize("status", [STATUS_PENDING, STATUS_REJECTED, STATUS_EXECUTED]) def test_require_approved_blocks_non_approved(status: str) -> None: - proposal = AutomationProposal("context-pr:b", ACTION_CONTEXT_PR, "B", "o/b", "d", status=status) + proposal = AutomationProposal( + "context-pr:b", ACTION_CONTEXT_PR, "B", "o/b", "d", status=status + ) with pytest.raises(ProposalApprovalError): require_approved(proposal) diff --git a/tests/test_automation_workflow.py b/tests/test_automation_workflow.py index cf12b4b..8da8f50 100644 --- a/tests/test_automation_workflow.py +++ b/tests/test_automation_workflow.py @@ -47,7 +47,9 @@ class FakeRunner: """Records every command, returns canned results keyed by the first 2 args.""" - def __init__(self, results: dict[tuple[str, ...], CommandResult] | None = None) -> None: + def __init__( + self, results: dict[tuple[str, ...], CommandResult] | None = None + ) -> None: self.calls: list[tuple[list[str], Path]] = [] self._results = results or {} @@ -88,7 +90,7 @@ def _project( context_quality="minimum-viable", primary_context_file=primary_context_file, activity_status="active", - registry_status="active", + archived=False, path_confidence="high", ), ) @@ -206,7 +208,9 @@ def test_context_pr_plan_apply_change_preserves_existing_text(tmp_path: Path) -> def test_catalog_seeds_keyed_by_display_name() -> None: - project = _project(display_name="Signal & Noise", repo_full_name="owner/signal-noise") + project = _project( + display_name="Signal & Noise", repo_full_name="owner/signal-noise" + ) seeds = build_catalog_seeds_for(project) assert set(seeds) == {"Signal & Noise"} @@ -336,7 +340,9 @@ def test_execute_catalog_seed_applies_and_persists_executed(tmp_path: Path) -> N def test_execute_resolves_spaced_repo_by_slug(tmp_path: Path) -> None: project = _project( - display_name="Signal & Noise", path="Signal & Noise", repo_full_name="owner/signal-noise" + display_name="Signal & Noise", + path="Signal & Noise", + repo_full_name="owner/signal-noise", ) (tmp_path / "Signal & Noise").mkdir() proposals_path = tmp_path / "pending-proposals.json" @@ -381,7 +387,9 @@ def test_execute_skips_context_pr_without_slug(tmp_path: Path) -> None: def test_execute_skips_proposal_with_no_matching_project(tmp_path: Path) -> None: proposals_path = tmp_path / "pending-proposals.json" - _write(proposals_path, _proposal(display_name="Ghost", repo_full_name="owner/ghost")) + _write( + proposals_path, _proposal(display_name="Ghost", repo_full_name="owner/ghost") + ) results = execute_approved_proposals( proposals_path=proposals_path, @@ -397,7 +405,9 @@ def test_execute_skips_proposal_with_no_matching_project(tmp_path: Path) -> None assert results[0].detail == "project-not-found" -def test_execute_isolates_one_failure_from_the_rest_of_the_batch(tmp_path: Path) -> None: +def test_execute_isolates_one_failure_from_the_rest_of_the_batch( + tmp_path: Path, +) -> None: # A catalog-seed pointed at a path whose parent is missing raises OSError # inside the merge; that must become a `failed` result for THAT proposal and # leave the following context-PR proposal free to apply. @@ -407,7 +417,11 @@ def test_execute_isolates_one_failure_from_the_rest_of_the_batch(tmp_path: Path) proposals_path = tmp_path / "pending-proposals.json" _write( proposals_path, - _proposal(action_type=ACTION_CATALOG_SEED, display_name="Cat", repo_full_name="owner/cat"), + _proposal( + action_type=ACTION_CATALOG_SEED, + display_name="Cat", + repo_full_name="owner/cat", + ), _proposal(), # context-pr for MyRepo ) @@ -415,7 +429,9 @@ def test_execute_isolates_one_failure_from_the_rest_of_the_batch(tmp_path: Path) proposals_path=proposals_path, snapshot=_snapshot(catalog, pr), workspace_root=tmp_path, - catalog_path=tmp_path / "missing-dir" / "catalog.yaml", # parent absent -> OSError + catalog_path=tmp_path + / "missing-dir" + / "catalog.yaml", # parent absent -> OSError executed_at=NOW, dry_run=False, runner=FakeRunner(), @@ -423,5 +439,7 @@ def test_execute_isolates_one_failure_from_the_rest_of_the_batch(tmp_path: Path) assert [r.outcome for r in results] == ["failed", "applied"] by_id = {p.proposal_id: p for p in load_proposals(proposals_path)} - assert by_id["catalog-seed:owner/cat"].status == STATUS_APPROVED # failed -> retryable + assert ( + by_id["catalog-seed:owner/cat"].status == STATUS_APPROVED + ) # failed -> retryable assert by_id["context-pr:owner/MyRepo"].status == STATUS_EXECUTED diff --git a/tests/test_cli_hardening.py b/tests/test_cli_hardening.py index 4b6bf78..3ba60b9 100644 --- a/tests/test_cli_hardening.py +++ b/tests/test_cli_hardening.py @@ -13,7 +13,8 @@ from src.baseline_context import build_baseline_context from src.models import AnalyzerResult, AuditReport, RepoAudit from src.report_scorecards import apply_scorecards -from src.report_state import report_from_dict +from src.report_state import audit_from_dict, report_from_dict +from src.scorer import WEIGHTS def _make_args(**overrides) -> Namespace: @@ -1738,3 +1739,62 @@ def _record_score_repo(*a, **kwargs): assert captured[0]["scorecard_enabled"] is True assert captured[0]["security_offline"] is True + + +def test_analyze_repos_forwards_real_scoring_profile_overrides(monkeypatch, sample_metadata): + args = _make_args() + captured: list[dict[str, object]] = [] + + class _CloneContext: + def __enter__(self): + return {sample_metadata.name: Path("/tmp/repo")} + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(run_audit, "clone_workspace", lambda *a, **k: _CloneContext()) + monkeypatch.setattr(run_audit, "create_progress", lambda: None) + monkeypatch.setattr(run_audit, "run_all_analyzers", lambda *a, **k: []) + + def _record_score_repo(*a, **kwargs): + captured.append(kwargs) + return RepoAudit( + metadata=sample_metadata, + analyzer_results=[], + overall_score=0.5, + completeness_tier="functional", + ) + + monkeypatch.setattr(run_audit, "score_repo", _record_score_repo) + profile = run_audit.ScoringProfile( + dict(WEIGHTS), overrides={"stale_threshold_days": 1} + ) + + run_audit._analyze_repos( + [sample_metadata], + args=args, + client=run_audit.GitHubClient(token=None, cache=None), + portfolio_lang_freq={}, + custom_weights=profile, + ) + + assert captured[0]["scoring_profile"] == profile.overrides + + +def test_partial_run_basis_survives_report_readback(sample_metadata): + audit = RepoAudit( + metadata=sample_metadata, + analyzer_results=[], + overall_score=0.72, + completeness_tier="functional", + grade="B", + scored_dimensions=["readme", "testing"], + scored_weight_sum=0.30, + ) + + restored = audit_from_dict(audit.to_dict()) + + assert restored.grade == "B" + assert restored.to_dict()["grade"] == "B" + assert restored.scored_dimensions == ["readme", "testing"] + assert restored.scored_weight_sum == 0.30 diff --git a/tests/test_excel_enhanced.py b/tests/test_excel_enhanced.py index 54738f4..96bcaae 100644 --- a/tests/test_excel_enhanced.py +++ b/tests/test_excel_enhanced.py @@ -891,6 +891,44 @@ def test_headers_include_interest_breakdown(self): for label in ("Tech Novelty", "Burst", "Ambition", "Storytelling"): assert label in header_values, f"Missing header: {label}" + def test_partial_run_basis_is_exported(self): + report = _make_report() + report["audits"][0].update( + { + "grade": "B", + "scored_dimensions": ["readme", "testing"], + "scored_weight_sum": 0.3, + } + ) + wb = Workbook() + _build_all_repos(wb, report) + ws = wb["All Repos"] + headers = {ws.cell(row=1, column=column).value: column for column in range(1, 50)} + repo_row = next(row for row in range(2, 10) if ws.cell(row=row, column=1).value == "RepoA") + + assert "Scored Dimensions" in headers + assert "Scored Weight Sum" in headers + assert ws.cell(row=repo_row, column=headers["Grade"]).value == "B on 2/10 dimensions" + assert ws.cell(row=repo_row, column=headers["Scored Dimensions"]).value == "readme, testing" + assert ws.cell(row=repo_row, column=headers["Scored Weight Sum"]).value == 0.3 + + def test_dashboard_grade_distribution_uses_qualified_grade_letter(self): + report = _make_report() + report["audits"][0].update( + { + "grade": "B", + "scored_dimensions": ["readme", "testing"], + "scored_weight_sum": 0.3, + } + ) + + wb = Workbook() + _build_dashboard(wb, report) + ws = wb["Dashboard"] + + assert ws.cell(row=11, column=27).value == "B" + assert ws.cell(row=11, column=28).value == 1 + def test_trend_is_last_column(self): ws = self._build() # Trend is dynamically the last header column (currently 36) diff --git a/tests/test_operator_os_seam_linter.py b/tests/test_operator_os_seam_linter.py index 881712b..6d0cf15 100644 --- a/tests/test_operator_os_seam_linter.py +++ b/tests/test_operator_os_seam_linter.py @@ -326,7 +326,7 @@ def test_contract_shadow_fails_when_excluded_backup_leaks_into_projects( "source_summary": {"attention_state_counts": {"decision-needed": 1}}, "rollups": {"decision": {"decision_needed_count": 1}}, "exclusions": { - "policy_version": "workspace_discovery.v1", + "policy_version": "workspace_discovery.v2", "counts": {}, }, "projects": [ diff --git a/tests/test_portfolio_automation.py b/tests/test_portfolio_automation.py index 467c603..2919338 100644 --- a/tests/test_portfolio_automation.py +++ b/tests/test_portfolio_automation.py @@ -2,7 +2,7 @@ This layer is strictly advisory/read-only: it identifies which repos clear the automation trust bar (high path confidence + trusted portfolio decision quality -+ non-trivial context + eligible registry status). It opens no PRs and applies ++ non-trivial context + eligible activity status). It opens no PRs and applies no changes — proposal creation and execution land in later Arc D phases. """ @@ -24,7 +24,7 @@ def _project( *, display_name: str = "Repo", repo_full_name: str = "owner/Repo", - registry_status: str = "active", + activity_status: str = "active", path_confidence: str = "high", context_quality: str = "standard", ) -> dict: @@ -32,7 +32,7 @@ def _project( "identity": {"display_name": display_name, "repo_full_name": repo_full_name}, "declared": {"operating_path": "maintain"}, "derived": { - "registry_status": registry_status, + "activity_status": activity_status, "path_confidence": path_confidence, "context_quality": context_quality, }, @@ -43,14 +43,18 @@ def _project( def test_fully_eligible_repo_has_no_blockers() -> None: - result = evaluate_automation_eligibility(_project(), decision_quality_status="trusted") + result = evaluate_automation_eligibility( + _project(), decision_quality_status="trusted" + ) assert isinstance(result, AutomationEligibility) assert result.eligible is True assert result.blockers == () def test_non_trusted_decision_quality_blocks() -> None: - result = evaluate_automation_eligibility(_project(), decision_quality_status="needs-skepticism") + result = evaluate_automation_eligibility( + _project(), decision_quality_status="needs-skepticism" + ) assert result.eligible is False assert "decision-quality-not-trusted" in result.blockers @@ -95,17 +99,17 @@ def test_unknown_context_blocks_conservatively() -> None: assert "context-quality-too-weak" in result.blockers -def test_archived_registry_status_blocks() -> None: +def test_archived_activity_status_blocks() -> None: result = evaluate_automation_eligibility( - _project(registry_status="archived"), decision_quality_status="trusted" + _project(activity_status="archived"), decision_quality_status="trusted" ) assert result.eligible is False - assert "registry-status-not-eligible" in result.blockers + assert "activity-status-not-eligible" in result.blockers -def test_candidate_registry_status_is_eligible() -> None: +def test_candidate_activity_status_is_eligible() -> None: result = evaluate_automation_eligibility( - _project(registry_status="candidate"), decision_quality_status="trusted" + _project(activity_status="candidate"), decision_quality_status="trusted" ) assert result.eligible is True @@ -119,13 +123,15 @@ def test_minimum_viable_context_is_eligible() -> None: def test_multiple_blockers_accumulate() -> None: result = evaluate_automation_eligibility( - _project(registry_status="archived", path_confidence="low", context_quality="none"), + _project( + activity_status="archived", path_confidence="low", context_quality="none" + ), decision_quality_status="needs-skepticism", ) assert result.eligible is False assert set(result.blockers) == { "decision-quality-not-trusted", - "registry-status-not-eligible", + "activity-status-not-eligible", "path-confidence-not-high", "context-quality-too-weak", } @@ -141,7 +147,7 @@ def test_selects_only_eligible_repos_sorted_by_name() -> None: _project(display_name="Alpha", repo_full_name="o/Alpha"), _project(display_name="LowPath", path_confidence="low"), _project(display_name="Boiler", context_quality="boilerplate"), - _project(display_name="Archived", registry_status="archived"), + _project(display_name="Archived", activity_status="archived"), ] } candidates = select_automation_candidates(truth, decision_quality_status="trusted") @@ -151,8 +157,16 @@ def test_selects_only_eligible_repos_sorted_by_name() -> None: def test_non_trusted_decision_quality_yields_no_candidates() -> None: # The portfolio-level gate kills every candidate regardless of per-repo state. - truth = {"projects": [_project(), _project(display_name="Other", repo_full_name="o/Other")]} - assert select_automation_candidates(truth, decision_quality_status="needs-skepticism") == [] + truth = { + "projects": [ + _project(), + _project(display_name="Other", repo_full_name="o/Other"), + ] + } + assert ( + select_automation_candidates(truth, decision_quality_status="needs-skepticism") + == [] + ) def test_candidate_to_dict_shape() -> None: @@ -161,7 +175,7 @@ def test_candidate_to_dict_shape() -> None: assert candidate.to_dict() == { "repo": "Solo", "repo_full_name": "acme/Solo", - "registry_status": "active", + "activity_status": "active", "path_confidence": "high", "context_quality": "standard", } @@ -169,11 +183,21 @@ def test_candidate_to_dict_shape() -> None: def test_empty_or_missing_projects_is_safe() -> None: assert select_automation_candidates({}, decision_quality_status="trusted") == [] - assert select_automation_candidates({"projects": None}, decision_quality_status="trusted") == [] + assert ( + select_automation_candidates( + {"projects": None}, decision_quality_status="trusted" + ) + == [] + ) def test_non_dict_projects_are_skipped() -> None: - truth = {"projects": ["not-a-dict", _project(display_name="Real", repo_full_name="o/Real")]} + truth = { + "projects": [ + "not-a-dict", + _project(display_name="Real", repo_full_name="o/Real"), + ] + } candidates = select_automation_candidates(truth, decision_quality_status="trusted") assert [c.display_name for c in candidates] == ["Real"] @@ -195,7 +219,9 @@ def test_eligible_repo_with_missing_identity_falls_back_safely() -> None: def _digest_for(portfolio_truth: dict, decision_quality_status: str) -> dict: - operator_summary = {"decision_quality_v1": {"decision_quality_status": decision_quality_status}} + operator_summary = { + "decision_quality_v1": {"decision_quality_status": decision_quality_status} + } report_data = { "username": "testuser", "generated_at": "2026-04-14T12:00:00+00:00", @@ -228,7 +254,11 @@ def test_digest_surfaces_automation_candidates_when_trusted() -> None: def test_digest_has_no_automation_candidates_when_not_trusted() -> None: - truth = {"projects": [_project(display_name="EligibleOne", repo_full_name="o/EligibleOne")]} + truth = { + "projects": [ + _project(display_name="EligibleOne", repo_full_name="o/EligibleOne") + ] + } digest = _digest_for(truth, "needs-skepticism") assert digest["automation_candidates"] == [] diff --git a/tests/test_portfolio_catalog.py b/tests/test_portfolio_catalog.py index 622f8b2..25b95a4 100644 --- a/tests/test_portfolio_catalog.py +++ b/tests/test_portfolio_catalog.py @@ -1,5 +1,8 @@ from __future__ import annotations +import re +import tempfile + from pathlib import Path from src.portfolio_catalog import ( @@ -11,6 +14,7 @@ group_entry_for_path, load_portfolio_catalog, ) +from src.portfolio_pathing import resolve_declared_operating_path def test_load_portfolio_catalog_accepts_defaults_and_repo_entries(tmp_path: Path): @@ -83,7 +87,11 @@ def test_load_portfolio_catalog_indexes_repo_aliases(tmp_path: Path): catalog = load_portfolio_catalog(path) entry = catalog_entry_for_repo( - {"name": "signal-noise", "full_name": "saagpatel/signal-noise", "path": "signal-noise"}, + { + "name": "signal-noise", + "full_name": "saagpatel/signal-noise", + "path": "signal-noise", + }, catalog, ) @@ -155,11 +163,50 @@ def test_live_catalog_keeps_settled_recovery_exclusions_archived() -> None: for repo_name in ("engraph", "reliability-vault"): entry = catalog["repos"][repo_name] assert entry["lifecycle_state"] == "archived" - assert entry["intended_disposition"] == "archive" + assert entry["operating_path"] == "archive" + # Migrated off the deprecated field; read-compat fallback is covered + # separately by test_load_portfolio_catalog_warns_on_deprecated_disposition. + assert entry["intended_disposition"] == "" assert entry["maturity_program"] == "archive" assert entry["automation_eligible"] is False +def test_live_catalog_keeps_settled_infrastructure_manual_only() -> None: + catalog_path = Path(__file__).parents[1] / "config" / "portfolio-catalog.yaml" + catalog = load_portfolio_catalog(catalog_path) + + manual_only = { + "_machine/machine-control-tower", + "agent-bridge", + "MCPAudit", + "knowledgecore", + "operant-public", + "mcpforge", + "notification-hub", + "GPT_RAG", + "cross-provider-egress-guard", + "cost-tracker", + "portfolio-health", + "portfolio-mcp", + "Lazarus", + "continuity", + "peer-agent-tools", + } + for repo_name in manual_only: + assert catalog["repos"][repo_name.lower()]["lifecycle_state"] == "manual-only" + + assert catalog["repos"]["afterimage"]["category"] == "commercial" + for repo_name in ( + "AIGCCore", + "bridge-db", + "GithubRepoAuditor", + "mcp-trust", + "cross-system-smoke", + "PortfolioCommandCenter", + ): + assert catalog["repos"][repo_name.lower()]["lifecycle_state"] == "active" + + def test_catalog_entry_matches_full_name_then_bare_name(): catalog = { "repos": { @@ -200,8 +247,12 @@ def test_catalog_entry_matches_full_name_then_bare_name(): } } - repo_a = catalog_entry_for_repo({"name": "RepoA", "full_name": "user/RepoA"}, catalog) - repo_b = catalog_entry_for_repo({"name": "RepoB", "full_name": "user/RepoB"}, catalog) + repo_a = catalog_entry_for_repo( + {"name": "RepoA", "full_name": "user/RepoA"}, catalog + ) + repo_b = catalog_entry_for_repo( + {"name": "RepoB", "full_name": "user/RepoB"}, catalog + ) assert repo_a["matched_by"] == "full-name" assert repo_b["matched_by"] == "bare-name" @@ -401,3 +452,173 @@ def test_catalog_line_and_summaries_cover_missing_contracts(): assert catalog_summary["missing_contract_count"] == 1 assert alignment_summary["counts"]["aligned"] == 1 assert alignment_summary["counts"]["missing-contract"] == 1 + + +def test_load_portfolio_catalog_normalizes_operating_path(tmp_path: Path): + path = tmp_path / "portfolio-catalog.yaml" + path.write_text( + """ +repos: + RepoA: + owner: d + lifecycle_state: active + review_cadence: weekly + operating_path: finish +""" + ) + + catalog = load_portfolio_catalog(path) + + assert catalog["errors"] == [] + assert catalog["warnings"] == [] + assert catalog["repos"]["repoa"]["operating_path"] == "finish" + assert catalog["repos"]["repoa"]["intended_disposition"] == "" + + +def test_load_portfolio_catalog_warns_on_deprecated_disposition(tmp_path: Path): + path = tmp_path / "portfolio-catalog.yaml" + path.write_text( + """ +repos: + RepoA: + owner: d + lifecycle_state: active + review_cadence: weekly + intended_disposition: maintain +groups: + legacy: + path_prefixes: + - legacy + intended_disposition: archive +""" + ) + + catalog = load_portfolio_catalog(path) + + assert catalog["errors"] == [] + assert catalog["repos"]["repoa"]["intended_disposition"] == "maintain" + assert catalog["repos"]["repoa"]["operating_path"] == "" + assert catalog["groups"]["legacy"]["intended_disposition"] == "archive" + assert any( + "RepoA" in w and "deprecated" in w and "intended_disposition" in w + for w in catalog["warnings"] + ) + assert any( + "legacy" in w and "deprecated" in w and "intended_disposition" in w + for w in catalog["warnings"] + ) + + +def test_evaluate_intent_alignment_falls_back_to_legacy_disposition(): + legacy_entry = {"has_explicit_entry": True, "intended_disposition": "archive"} + migrated_entry = {"has_explicit_entry": True, "operating_path": "archive"} + + legacy_result = evaluate_intent_alignment( + legacy_entry, completeness_tier="abandoned", archived=False, operator_focus="" + ) + migrated_result = evaluate_intent_alignment( + migrated_entry, completeness_tier="abandoned", archived=False, operator_focus="" + ) + + assert ( + legacy_result + == migrated_result + == ( + "aligned", + "The repo posture already matches the plan to archive or let it stay dormant.", + ) + ) + + +def test_operating_path_takes_precedence_over_legacy_disposition_in_alignment(): + conflicting_entry = { + "has_explicit_entry": True, + "operating_path": "maintain", + "intended_disposition": "archive", + } + + alignment, _reason = evaluate_intent_alignment( + conflicting_entry, + completeness_tier="functional", + archived=False, + operator_focus="Watch Closely", + ) + + assert alignment == "aligned" + + +def test_live_catalog_migration_is_lossless_for_every_entry() -> None: + """Whole-catalog parity proof for the intended_disposition -> operating_path + migration: reconstruct the pre-migration catalog by mechanically inverting the + rename on the live (migrated) file, then assert every repo and group entry + resolves to the identical *stable path* either way. This runs against the real, + full catalog (179 repo entries), not a toy sample. + + `path_source` is deliberately NOT required to match: moving a value from the + deprecated `intended_disposition` fallback to the canonical `operating_path` + field is supposed to flip the provenance label from "intended-disposition" to + "explicit-operating-path" -- that label change is the correct, intended + signal that the entry now declares its path explicitly rather than relying on + the fallback. What must not change is the routing decision itself, since risk + tier and attention state are pure functions of the resolved path value.""" + live_path = Path(__file__).parents[1] / "config" / "portfolio-catalog.yaml" + migrated_text = live_path.read_text() + assert "intended_disposition:" not in migrated_text, ( + "fixture assumption: the live catalog has fully migrated to operating_path" + ) + + pre_migration_text = re.sub( + r"^(\s*)operating_path:", + r"\1intended_disposition:", + migrated_text, + flags=re.MULTILINE, + ) + + migrated = load_portfolio_catalog(live_path) + + with tempfile.TemporaryDirectory() as tmp_dir: + pre_migration_path = Path(tmp_dir) / "portfolio-catalog.yaml" + pre_migration_path.write_text(pre_migration_text) + pre_migration = load_portfolio_catalog(pre_migration_path) + + assert pre_migration["errors"] == [] + assert migrated["errors"] == [] + + repo_keys = set(migrated["repos"]) | set(pre_migration["repos"]) + assert repo_keys, "live catalog must have at least one repo entry to prove parity" + + mismatches = [] + migrated_source_counts: dict[str, int] = {} + for repo_key in sorted(repo_keys): + before_path, _before_source = resolve_declared_operating_path( + pre_migration["repos"][repo_key] + ) + after_path, after_source = resolve_declared_operating_path( + migrated["repos"][repo_key] + ) + if before_path != after_path: + mismatches.append((repo_key, before_path, after_path)) + migrated_source_counts[after_source] = ( + migrated_source_counts.get(after_source, 0) + 1 + ) + + assert mismatches == [], ( + f"{len(mismatches)} repo(s) resolve to a different path after migration: {mismatches}" + ) + # Every migrated entry with a declared path now resolves via the canonical + # field, not the deprecated fallback -- the whole point of the migration. + assert migrated_source_counts.get("intended-disposition", 0) == 0 + assert migrated_source_counts.get("explicit-operating-path", 0) == len(repo_keys) + + group_keys = set(migrated["groups"]) | set(pre_migration["groups"]) + for group_key in sorted(group_keys): + before_path, _before_source = resolve_declared_operating_path( + pre_migration["groups"][group_key] + ) + after_path, _after_source = resolve_declared_operating_path( + migrated["groups"][group_key] + ) + assert before_path == after_path, ( + f"group '{group_key}' resolves to a different path: " + f"{before_path} vs {after_path}" + ) diff --git a/tests/test_portfolio_decision_queue.py b/tests/test_portfolio_decision_queue.py index 11463c7..7db3992 100644 --- a/tests/test_portfolio_decision_queue.py +++ b/tests/test_portfolio_decision_queue.py @@ -26,7 +26,6 @@ def _project( "identity": {"display_name": name, "path": name}, "derived": { "attention_state": attention_state, - "registry_status": "active", "activity_status": "active", }, "risk": { @@ -156,7 +155,9 @@ def test_cli_json_and_markdown_are_deterministic( { "schema_version": "0.8.0", "generated_at": "2026-07-11T09:00:10+00:00", - "projects": [_project("NeedsDecision", attention_state="decision-needed")], + "projects": [ + _project("NeedsDecision", attention_state="decision-needed") + ], } ), encoding="utf-8", diff --git a/tests/test_portfolio_pathing.py b/tests/test_portfolio_pathing.py index 9696c19..2380dc3 100644 --- a/tests/test_portfolio_pathing.py +++ b/tests/test_portfolio_pathing.py @@ -5,6 +5,7 @@ build_operating_path_entry, build_operating_path_line, build_operating_paths_summary, + resolve_declared_operating_path, ) @@ -64,10 +65,41 @@ def test_operating_path_prefers_intended_disposition_over_defaulted_program() -> assert entry["operating_path_source"] == "intended-disposition" +def test_intended_disposition_and_rewritten_operating_path_resolve_identically() -> ( + None +): + """Per-entry parity proof for the catalog migration: an entry declared the old + way (intended_disposition only) and the same entry rewritten the new way + (operating_path only, identity value mapping) must resolve to the same stable + path — only the provenance label differs.""" + legacy_entry = { + "has_explicit_entry": True, + "maturity_program": "maintain", + "intended_disposition": "archive", + } + rewritten_entry = { + "has_explicit_entry": True, + "maturity_program": "maintain", + "operating_path": "archive", + } + + legacy_path, legacy_source = resolve_declared_operating_path(legacy_entry) + rewritten_path, rewritten_source = resolve_declared_operating_path(rewritten_entry) + + assert legacy_path == rewritten_path == "archive" + assert legacy_source == "intended-disposition" + assert rewritten_source == "explicit-operating-path" + + def test_operating_paths_summary_counts_paths_and_overrides() -> None: summary = build_operating_paths_summary( [ - {"portfolio_catalog": {"operating_path": "maintain", "path_confidence": "high"}}, + { + "portfolio_catalog": { + "operating_path": "maintain", + "path_confidence": "high", + } + }, { "portfolio_catalog": { "operating_path": "finish", diff --git a/tests/test_portfolio_repository_state.py b/tests/test_portfolio_repository_state.py new file mode 100644 index 0000000..e92ee25 --- /dev/null +++ b/tests/test_portfolio_repository_state.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import subprocess +from datetime import UTC, datetime +from pathlib import Path + +from src.portfolio_repository_state import observe_repository_state + + +def _git(path: Path, *args: str) -> str: + return subprocess.run( + ["git", "-C", str(path), *args], check=True, capture_output=True, text=True + ).stdout.strip() + + +def _repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-b", "main") + _git(repo, "config", "user.email", "tests@example.invalid") + _git(repo, "config", "user.name", "Tests") + (repo / "README.md").write_text("fixture\n") + _git(repo, "add", "README.md") + _git(repo, "commit", "-m", "fixture") + return repo + + +def test_observation_reports_dirty_no_upstream_and_unknown_remote(tmp_path: Path) -> None: + repo = _repo(tmp_path) + (repo / "dirty.txt").write_text("dirty\n") + + state = observe_repository_state( + repo, observed_at=datetime(2026, 7, 12, tzinfo=UTC) + ) + + assert state["state"] == "observed" + assert state["local"]["dirty"] is True + assert state["local"]["dirty_path_count"] == 1 + assert state["local"]["upstream"] is None + assert state["remote_default_branch"]["state"] == "unknown" + + +def test_observation_reports_linked_worktree_without_file_names(tmp_path: Path) -> None: + repo = _repo(tmp_path) + linked = tmp_path / "linked" + _git(repo, "worktree", "add", "-b", "feature", str(linked), "HEAD") + (linked / "untracked.txt").write_text("preserve\n") + + state = observe_repository_state(repo, observed_at=datetime.now(UTC)) + + assert len(state["worktrees"]) == 2 + linked_state = next(item for item in state["worktrees"] if item["path"] == str(linked)) + assert linked_state["dirty"] is True + assert linked_state["dirty_path_count"] == 1 + assert "untracked.txt" not in str(state) diff --git a/tests/test_portfolio_risk.py b/tests/test_portfolio_risk.py index 6cdd12e..cdec18c 100644 --- a/tests/test_portfolio_risk.py +++ b/tests/test_portfolio_risk.py @@ -6,10 +6,9 @@ def _baseline_kwargs(**overrides): display_name="SomeRepo", operating_path="maintain", path_override="", - path_confidence="high", context_quality="standard", activity_status="active", - registry_status="active", + archived=False, criticality="medium", doctor_standard="", known_risks_present=True, @@ -20,7 +19,7 @@ def _baseline_kwargs(**overrides): def test_risk_tier_deferred_for_archived_registry(): - result = build_risk_entry(**_baseline_kwargs(registry_status="archived")) + result = build_risk_entry(**_baseline_kwargs(archived=True)) assert result["risk_tier"] == "deferred" assert result["risk_factors"] == [] assert result["doctor_gap"] is False diff --git a/tests/test_portfolio_truth.py b/tests/test_portfolio_truth.py index 7cb9e8e..3b2a654 100644 --- a/tests/test_portfolio_truth.py +++ b/tests/test_portfolio_truth.py @@ -14,7 +14,10 @@ apply_context_recovery_plan, build_context_recovery_plan, ) -from src.portfolio_truth_publish import PortfolioTruthPublishError, publish_portfolio_truth +from src.portfolio_truth_publish import ( + PortfolioTruthPublishError, + publish_portfolio_truth, +) from src.portfolio_truth_reconcile import build_portfolio_truth_snapshot from src.portfolio_truth_render import ( render_portfolio_report_markdown, @@ -87,7 +90,9 @@ def _security_test_project( def test_extract_github_full_name_uses_exact_github_host() -> None: assert _extract_github_full_name("https://github.com/octo/repo.git") == "octo/repo" assert _extract_github_full_name("git@github.com:octo/repo.git") == "octo/repo" - assert _extract_github_full_name("https://evil.example/github.com/octo/repo.git") == "" + assert ( + _extract_github_full_name("https://evil.example/github.com/octo/repo.git") == "" + ) def test_git_remote_full_name_prefers_canonical_remote(tmp_path: Path) -> None: @@ -161,13 +166,25 @@ def test_git_remote_full_name_keeps_normal_origin(tmp_path: Path) -> None: repo.mkdir() subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) subprocess.run( - ["git", "remote", "add", "origin", "https://github.com/saagpatel/NormalProject.git"], + [ + "git", + "remote", + "add", + "origin", + "https://github.com/saagpatel/NormalProject.git", + ], cwd=repo, capture_output=True, check=True, ) subprocess.run( - ["git", "remote", "add", "mirror", "https://github.com/saagpatel/OtherProject.git"], + [ + "git", + "remote", + "add", + "mirror", + "https://github.com/saagpatel/OtherProject.git", + ], cwd=repo, capture_output=True, check=True, @@ -341,7 +358,9 @@ def test_truth_snapshot_respects_declared_and_derived_fields( now=now, ) - projects = {project.identity.display_name: project for project in result.snapshot.projects} + projects = { + project.identity.display_name: project for project in result.snapshot.projects + } alpha = projects["Alpha"] beta = projects["Beta"] gamma = projects["Calibrate"] @@ -349,8 +368,10 @@ def test_truth_snapshot_respects_declared_and_derived_fields( assert alpha.identity.project_key == "Alpha" assert alpha.declared.owner == "d" assert alpha.declared.category == "commercial" + assert alpha.declared.operating_path == "maintain" assert alpha.derived.context_quality == "full" - assert alpha.derived.registry_status == "active" + assert alpha.derived.activity_status == "active" + assert alpha.derived.archived is False assert alpha.derived.attention_state == "active-product" assert alpha.derived.primary_context_file == "CLAUDE.md" assert alpha.derived.project_summary_present is True @@ -364,36 +385,48 @@ def test_truth_snapshot_respects_declared_and_derived_fields( assert beta.identity.section_marker == "ITPRJsViaClaude/" assert beta.declared.category == "it-work" assert beta.derived.context_quality == "boilerplate" - assert beta.derived.registry_status == "parked" + assert beta.derived.activity_status == "stale" assert beta.derived.attention_state == "parked" assert gamma.identity.section_marker == "iOS Projects" assert gamma.derived.stack == ["Swift"] - assert result.snapshot.schema_version == "0.8.0" + assert result.snapshot.schema_version == "0.10.0" assert result.snapshot.derivation_policy_version == "portfolio_attention.v2" assert result.snapshot.inputs["catalog"]["sha256"] assert result.snapshot.inputs["notion"]["mode"] == "unavailable" assert result.snapshot.exclusions == { - "policy_version": "workspace_discovery.v1", + "policy_version": "workspace_discovery.v2", "counts": {}, } - assert result.snapshot.source_summary["attention_state_counts"]["active-product"] == 1 + assert ( + result.snapshot.source_summary["attention_state_counts"]["active-product"] == 1 + ) assert result.snapshot.source_summary["attention_state_counts"]["parked"] == 1 # Derived rollups are emitted so downstream consumers (command-center) read # them instead of re-deriving the auditor's risk/security logic. snapshot_dict = result.snapshot.to_dict() rollups = snapshot_dict["rollups"] - assert set(rollups["risk_tier_counts"]) == {"elevated", "moderate", "baseline", "deferred"} + assert set(rollups["risk_tier_counts"]) == { + "elevated", + "moderate", + "baseline", + "deferred", + } assert sum(rollups["risk_tier_counts"].values()) == len(result.snapshot.projects) assert set(rollups["security"]) == { "scanned_count", + "unavailable_count", + "coverage_state", "repos_with_open_high_critical", "total_open_high", "total_open_critical", } - assert set(rollups["decision"]) == {"decision_needed_count", "default_attention_count"} + assert set(rollups["decision"]) == { + "decision_needed_count", + "default_attention_count", + } assert ( rollups["decision"]["default_attention_count"] >= rollups["decision"]["decision_needed_count"] @@ -402,15 +435,83 @@ def test_truth_snapshot_respects_declared_and_derived_fields( assert "open_high_critical" in snapshot_dict["projects"][0]["security"] -def test_attention_state_classifier_separates_activity_from_operator_attention() -> None: +def test_truth_snapshot_operating_path_catalog_field_matches_legacy_disposition( + portfolio_workspace: Path, + legacy_registry: Path, + tmp_path: Path, +) -> None: + """Whole-pipeline parity proof for the catalog migration: a catalog declaring + `operating_path: maintain` directly must reconcile to the exact same declared + operating path, path source, risk tier, and attention state as the equivalent + catalog declaring `intended_disposition: maintain` (the fixture used by + test_truth_snapshot_respects_declared_and_derived_fields above). This exercises + the full build_portfolio_truth_snapshot pipeline, not just the resolver.""" + migrated_catalog_path = tmp_path / "portfolio-catalog.yaml" + migrated_catalog_path.write_text( + """ +defaults: + lifecycle_state: maintenance + criticality: medium + review_cadence: monthly + category: vanity + tool_provenance: unknown + +groups: + it: + section_marker: ITPRJsViaClaude/ + section_label: IT Tools + path_prefixes: + - ITPRJsViaClaude + category: it-work + tool_provenance: claude-code + +repos: + Alpha: + owner: d + lifecycle_state: active + review_cadence: weekly + operating_path: maintain + category: commercial + tool_provenance: claude-code +""" + ) + + now = datetime.fromtimestamp(1_700_200_000, tz=timezone.utc) + result = build_portfolio_truth_snapshot( + workspace_root=portfolio_workspace, + catalog_path=migrated_catalog_path, + legacy_registry_path=legacy_registry, + include_notion=False, + now=now, + ) + + alpha = { + project.identity.display_name: project for project in result.snapshot.projects + }["Alpha"] + + assert alpha.declared.operating_path == "maintain" + assert alpha.declared.intended_disposition == "" + assert alpha.derived.attention_state == "active-product" + assert alpha.risk.risk_tier in {"elevated", "moderate", "baseline", "deferred"} + assert alpha.provenance["declared.operating_path"]["detail"] == ( + "explicit-operating-path" + ) + + +def test_attention_state_classifier_separates_activity_from_operator_attention() -> ( + None +): from src.portfolio_truth_reconcile import _attention_state_for + from src.portfolio_truth_types import VALID_LIFECYCLE_STATES + + assert "manual-only" in VALID_LIFECYCLE_STATES assert ( _attention_state_for( - registry_status="active", + activity_status="active", + archived=False, lifecycle_state="active", operating_path="maintain", - intended_disposition="maintain", category="commercial", path_override="", risk_entry={"security_risk": False}, @@ -419,10 +520,10 @@ def test_attention_state_classifier_separates_activity_from_operator_attention() ) assert ( _attention_state_for( - registry_status="active", + activity_status="active", + archived=False, lifecycle_state="active", operating_path="maintain", - intended_disposition="maintain", category="infrastructure", path_override="", risk_entry={"security_risk": False}, @@ -431,22 +532,46 @@ def test_attention_state_classifier_separates_activity_from_operator_attention() ) assert ( _attention_state_for( - registry_status="active", + activity_status="active", + archived=False, lifecycle_state="active", operating_path="maintain", - intended_disposition="maintain", category="vanity", path_override="investigate", risk_entry={"security_risk": False}, ) - == "decision-needed" + == "manual-only" + ) + assert ( + _attention_state_for( + activity_status="active", + archived=False, + lifecycle_state="active", + operating_path="", + category="infrastructure", + path_override="investigate", + risk_entry={"security_risk": False}, + ) + == "manual-only" + ) + assert ( + _attention_state_for( + activity_status="active", + archived=False, + lifecycle_state="manual-only", + operating_path="maintain", + category="infrastructure", + path_override="", + risk_entry={"security_risk": False}, + ) + == "manual-only" ) assert ( _attention_state_for( - registry_status="active", + activity_status="active", + archived=False, lifecycle_state="active", operating_path="maintain", - intended_disposition="maintain", category="fun", path_override="", risk_entry={"security_risk": False}, @@ -455,10 +580,10 @@ def test_attention_state_classifier_separates_activity_from_operator_attention() ) assert ( _attention_state_for( - registry_status="active", + activity_status="active", + archived=False, lifecycle_state="active", operating_path="experiment", - intended_disposition="experiment", category="vanity", path_override="investigate", risk_entry={"security_risk": True}, @@ -467,16 +592,80 @@ def test_attention_state_classifier_separates_activity_from_operator_attention() ) assert ( _attention_state_for( - registry_status="archived", + activity_status="active", + archived=True, lifecycle_state="archived", operating_path="archive", - intended_disposition="archive", category="commercial", path_override="investigate", risk_entry={"security_risk": True}, ) == "archived" ) + assert ( + _attention_state_for( + activity_status="stale", + archived=False, + lifecycle_state="active", + operating_path="finish", + category="commercial", + path_override="", + risk_entry={"security_risk": False}, + ) + == "decision-needed" + ) + assert ( + _attention_state_for( + activity_status="stale", + archived=False, + lifecycle_state="active", + operating_path="maintain", + category="commercial", + path_override="", + risk_entry={"security_risk": False}, + ) + == "parked" + ) + + +@pytest.mark.parametrize( + ("operating_path", "intended_disposition", "expected_attention"), + [ + ("maintain", "experiment", "active-product"), + ("maintain", "maintain", "active-product"), + ("experiment", "experiment", "experiment"), + ], +) +def test_attention_state_uses_resolved_catalog_operating_path( + operating_path: str, + intended_disposition: str, + expected_attention: str, +) -> None: + from src.portfolio_pathing import build_operating_path_entry + from src.portfolio_truth_reconcile import _attention_state_for + + catalog_entry = build_operating_path_entry( + { + "has_explicit_entry": True, + "operating_path": operating_path, + "intended_disposition": intended_disposition, + }, + context_quality="full", + ) + + assert catalog_entry["operating_path"] == operating_path + assert ( + _attention_state_for( + activity_status="active", + archived=False, + lifecycle_state="active", + operating_path=catalog_entry["operating_path"], + category="commercial", + path_override=catalog_entry["path_override"], + risk_entry={"security_risk": False}, + ) + == expected_attention + ) def test_github_archived_status_reconciles_to_archived_attention( @@ -506,25 +695,47 @@ def test_github_archived_status_reconciles_to_archived_attention( }, ) - projects = {project.identity.display_name: project for project in result.snapshot.projects} + projects = { + project.identity.display_name: project for project in result.snapshot.projects + } alpha = projects["Alpha"] assert alpha.declared.lifecycle_state == "active" assert alpha.declared.operating_path == "maintain" assert alpha.derived.path_confidence == "low" - assert alpha.derived.activity_status == "archived" - assert alpha.derived.registry_status == "archived" + # activity_status is pure recency now — Alpha is genuinely recently active by git + # history; `archived` is the orthogonal lifecycle fact that routes attention. + assert alpha.derived.activity_status == "active" + assert alpha.derived.archived is True assert alpha.derived.attention_state == "archived" + from src.portfolio_truth_types import display_activity_status + + assert ( + display_activity_status( + alpha.derived.activity_status, archived=alpha.derived.archived + ) + == "archived" + ) assert alpha.provenance["github.archived"] == { "source": "audit_report", "detail": "true", } assert result.snapshot.source_summary["github_archived_count"] == 1 - assert baseline.snapshot.source_summary["attention_state_counts"]["active-product"] == 1 - assert result.snapshot.source_summary["attention_state_counts"].get("active-product", 0) == 0 + assert ( + baseline.snapshot.source_summary["attention_state_counts"]["active-product"] + == 1 + ) + assert ( + result.snapshot.source_summary["attention_state_counts"].get( + "active-product", 0 + ) + == 0 + ) assert result.snapshot.source_summary["attention_state_counts"].get( "decision-needed", 0 - ) == baseline.snapshot.source_summary["attention_state_counts"].get("decision-needed", 0) + ) == baseline.snapshot.source_summary["attention_state_counts"].get( + "decision-needed", 0 + ) def test_build_security_fields_maps_ghas_entry() -> None: @@ -532,7 +743,13 @@ def test_build_security_fields_maps_ghas_entry() -> None: fields = _build_security_fields( { - "dependabot": {"critical": 2, "high": 3, "medium": 4, "low": 5, "available": True}, + "dependabot": { + "critical": 2, + "high": 3, + "medium": 4, + "low": 5, + "available": True, + }, "code_scanning": {"critical": 1, "high": 6, "available": True}, "secret_scanning": {"open": 7, "available": True}, } @@ -561,7 +778,10 @@ def test_build_security_fields_unavailable_dependabot_is_not_available() -> None from src.portfolio_truth_reconcile import _build_security_fields fields = _build_security_fields( - {"dependabot": {"available": False}, "secret_scanning": {"open": 0, "available": False}} + { + "dependabot": {"available": False}, + "secret_scanning": {"open": 0, "available": False}, + } ) assert fields.alerts_available is False assert fields.dependabot_high == 0 @@ -587,7 +807,13 @@ def test_security_overlay_populates_and_force_elevates( now = datetime.fromtimestamp(1_700_200_000, tz=timezone.utc) security = { "Alpha": { - "dependabot": {"critical": 1, "high": 0, "medium": 0, "low": 2, "available": True}, + "dependabot": { + "critical": 1, + "high": 0, + "medium": 0, + "low": 2, + "available": True, + }, "code_scanning": {"critical": 0, "high": 0, "available": True}, "secret_scanning": {"open": 0, "available": True}, } @@ -645,7 +871,10 @@ def test_select_security_entry_joins_by_repo_name_when_display_differs() -> None entry = {"dependabot": {"high": 9, "available": True}} lookup = {"signal-noise": entry} - assert _select_security_entry(lookup, "saagpatel/signal-noise", "Signal & Noise") is entry + assert ( + _select_security_entry(lookup, "saagpatel/signal-noise", "Signal & Noise") + is entry + ) def test_select_security_entry_falls_back_to_display_name() -> None: @@ -701,12 +930,16 @@ def _fake_git_facts(project_path: Path) -> dict[str, object]: if project_path.name == "Alpha": return { "has_git": True, - "last_commit_at": datetime.fromtimestamp(1_700_100_000, tz=timezone.utc), + "last_commit_at": datetime.fromtimestamp( + 1_700_100_000, tz=timezone.utc + ), "repo_full_name": "d/Alpha", } return {"has_git": False, "last_commit_at": None, "repo_full_name": ""} - monkeypatch.setattr("src.portfolio_truth_sources._gather_git_facts", _fake_git_facts) + monkeypatch.setattr( + "src.portfolio_truth_sources._gather_git_facts", _fake_git_facts + ) result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -717,7 +950,9 @@ def _fake_git_facts(project_path: Path) -> dict[str, object]: ) alpha = next( - project for project in result.snapshot.projects if project.identity.display_name == "Alpha" + project + for project in result.snapshot.projects + if project.identity.display_name == "Alpha" ) assert alpha.declared.owner == "portfolio-owner" assert alpha.declared.review_cadence == "weekly" @@ -725,7 +960,9 @@ def _fake_git_facts(project_path: Path) -> dict[str, object]: assert alpha.provenance["declared.owner"]["source"] == "catalog_repo" -def test_agent_file_without_real_project_markers_stays_boilerplate(tmp_path: Path) -> None: +def test_agent_file_without_real_project_markers_stays_boilerplate( + tmp_path: Path, +) -> None: project = tmp_path / "WeakContext" project.mkdir() _write(project / "AGENTS.md", "# AGENTS\n\nProject specific rules.\n") @@ -733,7 +970,9 @@ def test_agent_file_without_real_project_markers_stays_boilerplate(tmp_path: Pat assert _classify_context_quality(project, ["AGENTS.md"]) == "boilerplate" -def test_primary_context_with_required_sections_becomes_minimum_viable(tmp_path: Path) -> None: +def test_primary_context_with_required_sections_becomes_minimum_viable( + tmp_path: Path, +) -> None: project = tmp_path / "MinimumViable" project.mkdir() _write( @@ -803,7 +1042,10 @@ def _substantive_readme(project_name: str) -> str: f"# {project_name}\n\n" f"{project_name} is a durable operator infrastructure repo with separate " "agent guidance and README-level workflow documentation.\n\n" - + ("It documents commands, boundaries, recovery paths, examples, and operator usage. " * 35) + + ( + "It documents commands, boundaries, recovery paths, examples, and operator usage. " + * 35 + ) ) @@ -817,7 +1059,7 @@ def test_catalog_backed_high_criticality_infra_readme_support_promotes_to_standa subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) _write(project / "AGENTS.md", _complete_agent_context("InfraRepo")) _write(project / "README.md", _substantive_readme("InfraRepo")) - _write(project / "pyproject.toml", "[project]\nname = \"infra-repo\"\n") + _write(project / "pyproject.toml", '[project]\nname = "infra-repo"\n') catalog_path = tmp_path / "portfolio-catalog.yaml" catalog_path.write_text( """ @@ -838,13 +1080,22 @@ def test_catalog_backed_high_criticality_infra_readme_support_promotes_to_standa include_notion=False, ) - infra = next(project for project in result.snapshot.projects if project.identity.project_key == "InfraRepo") + infra = next( + project + for project in result.snapshot.projects + if project.identity.project_key == "InfraRepo" + ) assert infra.derived.context_quality == "standard" assert infra.provenance["derived.context_quality"]["source"] == "workspace+catalog" - assert infra.provenance["derived.context_quality"]["detail"] == "minimum-viable->standard" + assert ( + infra.provenance["derived.context_quality"]["detail"] + == "minimum-viable->standard" + ) -def test_substantive_readme_support_does_not_promote_non_infra_repo(tmp_path: Path) -> None: +def test_substantive_readme_support_does_not_promote_non_infra_repo( + tmp_path: Path, +) -> None: workspace = tmp_path / "workspace" workspace.mkdir() project = workspace / "ProductRepo" @@ -874,7 +1125,9 @@ def test_substantive_readme_support_does_not_promote_non_infra_repo(tmp_path: Pa ) product = next( - project for project in result.snapshot.projects if project.identity.project_key == "ProductRepo" + project + for project in result.snapshot.projects + if project.identity.project_key == "ProductRepo" ) assert product.derived.context_quality == "minimum-viable" assert product.provenance["derived.context_quality"]["source"] == "workspace" @@ -890,7 +1143,7 @@ def test_legacy_registry_infra_category_does_not_promote_context_quality( subprocess.run(["git", "init"], cwd=project, capture_output=True, check=True) _write(project / "AGENTS.md", _complete_agent_context("LegacyInfra")) _write(project / "README.md", _substantive_readme("LegacyInfra")) - _write(project / "pyproject.toml", "[project]\nname = \"legacy-infra\"\n") + _write(project / "pyproject.toml", '[project]\nname = "legacy-infra"\n') catalog_path = tmp_path / "portfolio-catalog.yaml" catalog_path.write_text( """ @@ -924,7 +1177,9 @@ def test_legacy_registry_infra_category_does_not_promote_context_quality( ) infra = next( - project for project in result.snapshot.projects if project.identity.project_key == "LegacyInfra" + project + for project in result.snapshot.projects + if project.identity.project_key == "LegacyInfra" ) assert infra.declared.category == "infrastructure" assert infra.provenance["declared.category"]["source"] == "legacy_registry" @@ -963,7 +1218,13 @@ def test_registry_render_surfaces_security_and_round_trips( ) -> None: security = { "Alpha": { - "dependabot": {"critical": 2, "high": 1, "medium": 0, "low": 0, "available": True}, + "dependabot": { + "critical": 2, + "high": 1, + "medium": 0, + "low": 0, + "available": True, + }, "code_scanning": {"available": True}, "secret_scanning": {"open": 0, "available": True}, } @@ -1018,7 +1279,13 @@ def test_portfolio_report_security_posture_lists_open_alerts( ) -> None: security = { "Alpha": { - "dependabot": {"critical": 1, "high": 2, "medium": 0, "low": 0, "available": True}, + "dependabot": { + "critical": 1, + "high": 2, + "medium": 0, + "low": 0, + "available": True, + }, "code_scanning": {"available": True}, "secret_scanning": {"open": 0, "available": True}, } @@ -1034,9 +1301,12 @@ def test_portfolio_report_security_posture_lists_open_alerts( assert "## Security Posture" in markdown assert "[Security Posture](#security-posture)" in markdown - assert "- **Alpha** [elevated]: 1 critical, 2 high open Dependabot alerts" in markdown assert ( - "- Security posture: scanned `1`, with open high/critical Dependabot alerts `1`" in markdown + "- **Alpha** [elevated]: 1 critical, 2 high open Dependabot alerts" in markdown + ) + assert ( + "- Security posture: scanned `1`, with open high/critical Dependabot alerts `1`" + in markdown ) # The new section keeps the report validator green. validate_portfolio_report_markdown(markdown) @@ -1050,7 +1320,13 @@ def test_portfolio_report_security_posture_scanned_clear( # Scanned with zero open high/critical reads as "all clear", distinct from "not run". security = { "Alpha": { - "dependabot": {"critical": 0, "high": 0, "medium": 3, "low": 0, "available": True}, + "dependabot": { + "critical": 0, + "high": 0, + "medium": 3, + "low": 0, + "available": True, + }, "code_scanning": {"available": True}, "secret_scanning": {"open": 0, "available": True}, } @@ -1063,7 +1339,10 @@ def test_portfolio_report_security_posture_scanned_clear( security_alerts_by_name=security, ) markdown = render_portfolio_report_markdown(result.snapshot, "output/x.json") - assert "All 1 scanned repos are clear of open high/critical Dependabot alerts." in markdown + assert ( + "All 1 scanned repos are clear of open high/critical Dependabot alerts." + in markdown + ) validate_portfolio_report_markdown(markdown) # Same guard governs the registry: a scanned repo with only medium alerts gets no @@ -1085,7 +1364,9 @@ def test_security_attention_items_caps_at_five_and_sorts_critical_first() -> Non _security_test_project("mid-crit", critical=2, high=0), _security_test_project("top-crit", critical=5, high=0), _security_test_project("clean", critical=0, high=0), # excluded: nothing open - _security_test_project("unscanned", critical=9, high=9, available=False), # excluded + _security_test_project( + "unscanned", critical=9, high=9, available=False + ), # excluded _security_test_project("a-high", critical=0, high=3), _security_test_project("b-high", critical=0, high=3), _security_test_project("c-crit", critical=1, high=0), @@ -1158,7 +1439,9 @@ def test_duplicate_display_names_are_disambiguated_in_registry(tmp_path: Path) - assert "OrbitForge [Labs/OrbitForge]" in parsed -def test_path_catalog_contracts_clear_duplicate_display_name_warning(tmp_path: Path) -> None: +def test_path_catalog_contracts_clear_duplicate_display_name_warning( + tmp_path: Path, +) -> None: workspace = tmp_path / "workspace" workspace.mkdir() orbit_a = workspace / "Arcade" / "OrbitForge" @@ -1194,9 +1477,12 @@ def test_path_catalog_contracts_clear_duplicate_display_name_warning(tmp_path: P assert result.snapshot.source_summary["duplicate_display_names"] == ["OrbitForge"] assert result.snapshot.source_summary["unresolved_duplicate_display_names"] == [] assert not any( - "Duplicate project display names" in warning for warning in result.snapshot.warnings + "Duplicate project display names" in warning + for warning in result.snapshot.warnings ) - projects_by_path = {project.identity.path: project for project in result.snapshot.projects} + projects_by_path = { + project.identity.path: project for project in result.snapshot.projects + } assert projects_by_path["Arcade/OrbitForge"].declared.lifecycle_state == "active" assert projects_by_path["Labs/OrbitForge"].declared.lifecycle_state == "archived" @@ -1357,7 +1643,9 @@ def test_publish_failure_leaves_live_files_untouched( def _boom(_snapshot, _latest_json_path): raise RuntimeError("renderer exploded") - monkeypatch.setattr("src.portfolio_truth_publish.render_portfolio_report_markdown", _boom) + monkeypatch.setattr( + "src.portfolio_truth_publish.render_portfolio_report_markdown", _boom + ) with pytest.raises(RuntimeError): publish_portfolio_truth( @@ -1443,7 +1731,10 @@ def test_publish_refuses_to_drop_existing_notion_context( include_notion=True, ) - assert json.loads(latest_path.read_text())["source_summary"]["notion_context_rows"] == 137 + assert ( + json.loads(latest_path.read_text())["source_summary"]["notion_context_rows"] + == 137 + ) def test_load_prior_notion_context_rebuilds_from_artifact(tmp_path: Path) -> None: @@ -1489,7 +1780,9 @@ def test_load_prior_notion_context_rebuilds_from_artifact(tmp_path: Path) -> Non assert len(context) == 1 -def test_load_prior_notion_context_missing_or_malformed_returns_empty(tmp_path: Path) -> None: +def test_load_prior_notion_context_missing_or_malformed_returns_empty( + tmp_path: Path, +) -> None: from src.portfolio_truth_reconcile import load_prior_notion_context assert load_prior_notion_context(tmp_path / "absent.json") == {} @@ -1616,7 +1909,9 @@ def test_publish_allow_empty_notion_carries_forward_prior_context( assert summary["notion_context_rows"] == 1 assert summary["notion_context_carried_forward"] is True target = next( - item for item in published["projects"] if item["identity"]["display_name"] == target_name + item + for item in published["projects"] + if item["identity"]["display_name"] == target_name ) assert target["advisory"]["notion_portfolio_call"] == "Ship" assert target["advisory"]["notion_current_state"] == "Building" @@ -1664,7 +1959,12 @@ def test_report_subcommand_parses_allow_empty_notion_flag() -> None: parser = build_subcommand_parser() enabled = parser.parse_args( - ["report", "testuser", "--portfolio-truth", "--portfolio-truth-allow-empty-notion"] + [ + "report", + "testuser", + "--portfolio-truth", + "--portfolio-truth-allow-empty-notion", + ] ) assert enabled.portfolio_truth_allow_empty_notion is True default = parser.parse_args(["report", "testuser", "--portfolio-truth"]) @@ -1690,8 +1990,11 @@ def test_portfolio_truth_app_passes_validated_producer_receipt_to_publisher( "commit": "a" * 40, "ref": "refs/remotes/origin/main", "checkout_role": "canonical-automation", + "checkout_path": str(tmp_path / "producer-repo"), "worktree_clean": True, + "dirty_path_count": 0, "verified_at": "2026-07-10T12:00:00Z", + "receipt_id": "sha256:" + "a" * 64, "checks": {}, } ) @@ -1751,6 +2054,7 @@ def test_cli_portfolio_truth_allow_empty_notion_carries_forward( monkeypatch: pytest.MonkeyPatch, ) -> None: output_dir = tmp_path / "output" + monkeypatch.setenv("GHRA_REQUIRE_PRODUCER_EVIDENCE", "0") output_dir.mkdir() discovered = build_portfolio_truth_snapshot( @@ -1824,7 +2128,8 @@ def test_publish_allow_empty_notion_without_prior_context_publishes_zero( output_dir = tmp_path / "output" output_dir.mkdir() (output_dir / "portfolio-truth-latest.json").write_text( - json.dumps({"source_summary": {"notion_context_rows": 137}, "projects": []}) + "\n" + json.dumps({"source_summary": {"notion_context_rows": 137}, "projects": []}) + + "\n" ) monkeypatch.setattr( "src.portfolio_truth_sources.load_notion_project_context", @@ -1872,7 +2177,9 @@ def test_context_recovery_plan_freezes_and_filters_targets( include_notion=False, now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), ) - plan = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) + plan = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) targets = {target.project_key: target for target in plan.projects} assert targets["Fresh"].status == "eligible" @@ -1887,8 +2194,14 @@ def test_context_recovery_apply_writes_primary_context_and_catalog_seed( ) -> None: target_repo = portfolio_workspace / "FreshRecover" target_repo.mkdir() - _write(target_repo / "README.md", "# FreshRecover\n\nFreshRecover is a small active repo.\n") - _write(target_repo / "package.json", '{"dependencies":{"next":"14.2.0","react":"19.0.0"}}') + _write( + target_repo / "README.md", + "# FreshRecover\n\nFreshRecover is a small active repo.\n", + ) + _write( + target_repo / "package.json", + '{"dependencies":{"next":"14.2.0","react":"19.0.0"}}', + ) result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -1897,7 +2210,9 @@ def test_context_recovery_apply_writes_primary_context_and_catalog_seed( include_notion=False, now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), ) - plan = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) + plan = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) apply_result = apply_context_recovery_plan( result.snapshot, plan, @@ -1920,7 +2235,10 @@ def test_context_recovery_preserves_fenced_run_commands( ) -> None: target_repo = portfolio_workspace / "FenceRecover" target_repo.mkdir() - _write(target_repo / "README.md", "# FenceRecover\n\nFenceRecover is a small active repo.\n") + _write( + target_repo / "README.md", + "# FenceRecover\n\nFenceRecover is a small active repo.\n", + ) _write( target_repo / "CLAUDE.md", """# FenceRecover @@ -1934,7 +2252,10 @@ def test_context_recovery_preserves_fenced_run_commands( ``` """, ) - _write(target_repo / "package.json", '{"dependencies":{"vite":"6.0.0","react":"19.0.0"}}') + _write( + target_repo / "package.json", + '{"dependencies":{"vite":"6.0.0","react":"19.0.0"}}', + ) result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -1943,7 +2264,9 @@ def test_context_recovery_preserves_fenced_run_commands( include_notion=False, now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), ) - plan = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) + plan = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) apply_context_recovery_plan( result.snapshot, plan, @@ -1966,7 +2289,10 @@ def test_context_recovery_ignores_development_conventions_for_run_commands( ) -> None: target_repo = portfolio_workspace / "ConventionsRecover" target_repo.mkdir() - _write(target_repo / "README.md", "# ConventionsRecover\n\nConventionsRecover is active.\n") + _write( + target_repo / "README.md", + "# ConventionsRecover\n\nConventionsRecover is active.\n", + ) _write( target_repo / "CLAUDE.md", """# ConventionsRecover @@ -1977,7 +2303,10 @@ def test_context_recovery_ignores_development_conventions_for_run_commands( - Conventional commits only. """, ) - _write(target_repo / "package.json", '{"dependencies":{"vite":"6.0.0","react":"19.0.0"}}') + _write( + target_repo / "package.json", + '{"dependencies":{"vite":"6.0.0","react":"19.0.0"}}', + ) result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -1986,7 +2315,9 @@ def test_context_recovery_ignores_development_conventions_for_run_commands( include_notion=False, now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), ) - plan = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) + plan = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) apply_context_recovery_plan( result.snapshot, plan, @@ -1996,7 +2327,9 @@ def test_context_recovery_ignores_development_conventions_for_run_commands( context_text = (target_repo / "CLAUDE.md").read_text() managed_block = context_text.split("", 1)[1] - how_to_run = managed_block.split("## How To Run", 1)[1].split("## Known Risks", 1)[0] + how_to_run = managed_block.split("## How To Run", 1)[1].split("## Known Risks", 1)[ + 0 + ] assert "npm run dev" in how_to_run assert "File naming" not in how_to_run assert _classify_context_quality(target_repo, ["CLAUDE.md"]) == "minimum-viable" @@ -2027,7 +2360,10 @@ def test_context_recovery_skips_pointer_preamble_for_project_summary( This fixture only models pointer preambles. """, ) - _write(target_repo / "package.json", '{"dependencies":{"vite":"6.0.0","react":"19.0.0"}}') + _write( + target_repo / "package.json", + '{"dependencies":{"vite":"6.0.0","react":"19.0.0"}}', + ) result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -2036,7 +2372,9 @@ def test_context_recovery_skips_pointer_preamble_for_project_summary( include_notion=False, now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), ) - plan = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) + plan = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) apply_context_recovery_plan( result.snapshot, plan, @@ -2061,7 +2399,10 @@ def test_context_recovery_does_not_use_unknown_as_stack( ) -> None: target_repo = portfolio_workspace / "UnknownStackRecover" target_repo.mkdir() - _write(target_repo / "README.md", "# UnknownStackRecover\n\nUnknownStackRecover is active.\n") + _write( + target_repo / "README.md", + "# UnknownStackRecover\n\nUnknownStackRecover is active.\n", + ) result = build_portfolio_truth_snapshot( workspace_root=portfolio_workspace, @@ -2070,7 +2411,9 @@ def test_context_recovery_does_not_use_unknown_as_stack( include_notion=False, now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), ) - plan = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) + plan = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) apply_context_recovery_plan( result.snapshot, plan, @@ -2094,6 +2437,7 @@ def test_cli_portfolio_truth_respects_path_overrides( monkeypatch: pytest.MonkeyPatch, ) -> None: output_dir = tmp_path / "output" + monkeypatch.setenv("GHRA_REQUIRE_PRODUCER_EVIDENCE", "0") registry_output = portfolio_workspace / "compat-registry.md" report_output = portfolio_workspace / "compat-report.md" argv = [ @@ -2206,8 +2550,12 @@ def test_allow_dirty_worktree_makes_dirty_repos_eligible( ) # Without allow_dirty: should be skipped - plan_strict = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) - strict_target = next((t for t in plan_strict.projects if t.project_key == "DirtyRepo"), None) + plan_strict = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) + strict_target = next( + (t for t in plan_strict.projects if t.project_key == "DirtyRepo"), None + ) assert strict_target is not None assert strict_target.status == "skipped" assert strict_target.reason == "dirty-worktree" @@ -2216,7 +2564,9 @@ def test_allow_dirty_worktree_makes_dirty_repos_eligible( plan_dirty = build_context_recovery_plan( result.snapshot, workspace_root=portfolio_workspace, allow_dirty=True ) - dirty_target = next((t for t in plan_dirty.projects if t.project_key == "DirtyRepo"), None) + dirty_target = next( + (t for t in plan_dirty.projects if t.project_key == "DirtyRepo"), None + ) assert dirty_target is not None assert dirty_target.status == "eligible" @@ -2235,7 +2585,9 @@ def _apply_recovery_for( include_notion=False, now=datetime.fromtimestamp(1_700_000_100, tz=timezone.utc), ) - plan = build_context_recovery_plan(result.snapshot, workspace_root=portfolio_workspace) + plan = build_context_recovery_plan( + result.snapshot, workspace_root=portfolio_workspace + ) apply_context_recovery_plan( result.snapshot, plan, @@ -2257,7 +2609,9 @@ def test_context_recovery_overrides_npm_text_when_pnpm_lockfile_present( ) -> None: target_repo = portfolio_workspace / "PnpmOverride" target_repo.mkdir() - _write(target_repo / "README.md", "# PnpmOverride\n\nPnpmOverride is an active repo.\n") + _write( + target_repo / "README.md", "# PnpmOverride\n\nPnpmOverride is an active repo.\n" + ) _write( target_repo / "CLAUDE.md", """# PnpmOverride @@ -2271,7 +2625,9 @@ def test_context_recovery_overrides_npm_text_when_pnpm_lockfile_present( _write(target_repo / "package.json", '{"dependencies":{"vite":"6.0.0"}}') _write(target_repo / "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") - text = _apply_recovery_for(target_repo, portfolio_workspace, portfolio_catalog, legacy_registry) + text = _apply_recovery_for( + target_repo, portfolio_workspace, portfolio_catalog, legacy_registry + ) how_to_run = _how_to_run_section(text) assert "pnpm" in how_to_run.lower() assert "Use `npm` only" not in how_to_run @@ -2284,7 +2640,10 @@ def test_context_recovery_detects_package_manager_from_ci_workflow( ) -> None: target_repo = portfolio_workspace / "CiWorkflowPnpm" target_repo.mkdir() - _write(target_repo / "README.md", "# CiWorkflowPnpm\n\nCiWorkflowPnpm is an active repo.\n") + _write( + target_repo / "README.md", + "# CiWorkflowPnpm\n\nCiWorkflowPnpm is an active repo.\n", + ) _write( target_repo / "CLAUDE.md", """# CiWorkflowPnpm @@ -2300,7 +2659,9 @@ def test_context_recovery_detects_package_manager_from_ci_workflow( "name: test\njobs:\n test:\n steps:\n - run: pnpm install --frozen-lockfile\n - run: pnpm test\n", ) - text = _apply_recovery_for(target_repo, portfolio_workspace, portfolio_catalog, legacy_registry) + text = _apply_recovery_for( + target_repo, portfolio_workspace, portfolio_catalog, legacy_registry + ) how_to_run = _how_to_run_section(text) assert "pnpm" in how_to_run.lower() @@ -2312,7 +2673,9 @@ def test_context_recovery_respects_manual_override_marker( ) -> None: target_repo = portfolio_workspace / "ManualMarker" target_repo.mkdir() - _write(target_repo / "README.md", "# ManualMarker\n\nManualMarker is an active repo.\n") + _write( + target_repo / "README.md", "# ManualMarker\n\nManualMarker is an active repo.\n" + ) _write( target_repo / "CLAUDE.md", """# ManualMarker @@ -2327,7 +2690,9 @@ def test_context_recovery_respects_manual_override_marker( _write(target_repo / "package.json", '{"dependencies":{"vite":"6.0.0"}}') _write(target_repo / "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") - text = _apply_recovery_for(target_repo, portfolio_workspace, portfolio_catalog, legacy_registry) + text = _apply_recovery_for( + target_repo, portfolio_workspace, portfolio_catalog, legacy_registry + ) how_to_run = _how_to_run_section(text) assert "Use `npm` only" in how_to_run assert "manual:run-instructions" in how_to_run @@ -2353,7 +2718,9 @@ def test_context_recovery_emits_drift_note_when_correcting( _write(target_repo / "package.json", '{"dependencies":{"vite":"6.0.0"}}') _write(target_repo / "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") - text = _apply_recovery_for(target_repo, portfolio_workspace, portfolio_catalog, legacy_registry) + text = _apply_recovery_for( + target_repo, portfolio_workspace, portfolio_catalog, legacy_registry + ) how_to_run = _how_to_run_section(text) assert "corrected" in how_to_run.lower() or "detected" in how_to_run.lower() @@ -2369,7 +2736,12 @@ def test_git_default_branch_reads_local_origin_head(tmp_path: Path) -> None: subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) # Point the local origin/HEAD ref at a non-"main" branch (no network/clone). subprocess.run( - ["git", "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/develop"], + [ + "git", + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/develop", + ], cwd=repo, capture_output=True, check=True, @@ -2385,7 +2757,12 @@ def test_git_default_branch_keeps_multi_segment_branch(tmp_path: Path) -> None: repo.mkdir() subprocess.run(["git", "init"], cwd=repo, capture_output=True, check=True) subprocess.run( - ["git", "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/release/v1"], + [ + "git", + "symbolic-ref", + "refs/remotes/origin/HEAD", + "refs/remotes/origin/release/v1", + ], cwd=repo, capture_output=True, check=True, diff --git a/tests/test_portfolio_truth_sources.py b/tests/test_portfolio_truth_sources.py index b224d76..f18ca9e 100644 --- a/tests/test_portfolio_truth_sources.py +++ b/tests/test_portfolio_truth_sources.py @@ -99,6 +99,24 @@ def test_ignore_predicate_matches_transient_dirs() -> None: assert _is_ignored_project_dir("resume-evolver-tmp-1776063720") assert _is_ignored_project_dir("Codex Backups") assert workspace_exclusion_reason("Codex Backups") == "backup-container" + assert workspace_exclusion_reason("scratch") == "scratch-container" + assert workspace_exclusion_reason("_backups") == "backup-container" + assert ( + workspace_exclusion_reason("_preserved-local-artifacts") + == "preserved-artifacts" + ) + assert workspace_exclusion_reason("sweep-reports") == "generated-reports" + assert ( + workspace_exclusion_reason("_fable-worktrees") + == "linked-worktree-container" + ) + assert ( + workspace_exclusion_reason("_codex-worktrees") + == "linked-worktree-container" + ) + assert workspace_exclusion_reason("packets") is None + assert workspace_exclusion_reason("packets", nested=True) == "nested-content" + assert workspace_exclusion_reason("prompts", nested=True) == "nested-content" def test_ignore_predicate_keeps_real_projects() -> None: @@ -128,6 +146,14 @@ def _project(*parts: str) -> None: _project("resume-evolver-tmp-1776063720") # top-level tmp clone -> skipped _project("Documents", "Codex Backups", "Wave 2R Post-Update", "README-fixture") _project("Documents", "RealNestedProject") + _project("scratch", "README-fixture") + _project("_backups", "old-repo") + _project("_preserved-local-artifacts", "saved-repo") + _project("sweep-reports", "branch-hygiene-2026-07-03") + _project("_fable-worktrees", "personal-ops-worklist-phase1") + _project("_codex-worktrees", "personal-ops-truth-authority") + _project("Campaign", "packets") + _project("Campaign", "prompts") exclusion_counts: dict[str, int] = {} result = discover_workspace_projects( @@ -138,8 +164,13 @@ def _project(*parts: str) -> None: ) assert {p["name"] for p in result} == {"LegitProject", "RealNestedProject"} assert exclusion_counts == { - "backup-container": 1, + "backup-container": 2, "generated-evidence": 1, + "generated-reports": 1, + "linked-worktree-container": 2, + "nested-content": 2, "operator-excluded": 1, + "preserved-artifacts": 1, + "scratch-container": 1, "temporary-checkout": 1, } diff --git a/tests/test_portfolio_truth_trends.py b/tests/test_portfolio_truth_trends.py new file mode 100644 index 0000000..791122d --- /dev/null +++ b/tests/test_portfolio_truth_trends.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from src.portfolio_truth_trends import ( + build_verdict_transition_ledger, + render_movement_summary, +) +from src.weekly_command_center import ( + build_weekly_command_center_digest, + render_weekly_command_center_markdown, +) + + +def _project( + name: str, + *, + attention: str = "active-product", + activity: str = "active", + risk: str = "baseline", +) -> dict: + return { + "identity": {"project_key": name.lower(), "display_name": name}, + "derived": { + "attention_state": attention, + "activity_status": activity, + }, + "risk": {"risk_tier": risk}, + } + + +def _write_snapshot(directory: Path, stamp: str, projects: list[dict]) -> dict: + snapshot = { + "generated_at": f"{stamp}T12:00:00+00:00", + "projects": projects, + } + (directory / f"portfolio-truth-{stamp.replace('-', '')}T120000Z.json").write_text( + json.dumps(snapshot) + ) + return snapshot + + +def test_transition_ledger_reports_no_movement_and_activity_streaks(tmp_path: Path) -> None: + _write_snapshot(tmp_path, "2026-07-10", [_project("Steady")]) + _write_snapshot(tmp_path, "2026-07-11", [_project("Steady")]) + + ledger = build_verdict_transition_ledger(tmp_path) + + assert ledger["snapshot_count"] == 2 + assert ledger["transitions"] == [] + assert ledger["summary"]["transition_count"] == 0 + assert ledger["activity_status_streaks"] == [ + { + "repo": "Steady", + "project_key": "steady", + "status": "active", + "run_count": 2, + "consecutive_snapshots": 2, + "since_generated_at": "2026-07-10T12:00:00+00:00", + "through_generated_at": "2026-07-11T12:00:00+00:00", + } + ] + assert render_movement_summary(ledger).startswith("No verdict movement") + + +def test_transition_ledger_reports_attention_activity_and_risk_changes(tmp_path: Path) -> None: + _write_snapshot( + tmp_path, + "2026-07-10", + [_project("Moving", attention="decision-needed", activity="stale", risk="elevated")], + ) + current = _write_snapshot( + tmp_path, + "2026-07-11", + [_project("Moving", attention="active-infra", activity="active", risk="baseline")], + ) + + ledger = build_verdict_transition_ledger( + tmp_path, + current_snapshot=current, + current_path=tmp_path / "portfolio-truth-latest.json", + ) + + assert [(item["kind"], item["from"], item["to"]) for item in ledger["transitions"]] == [ + ("activity_status", "stale", "active"), + ("attention_state", "decision-needed", "active-infra"), + ("risk_tier", "elevated", "baseline"), + ] + assert all(item["from_date"] == "2026-07-10" for item in ledger["transitions"]) + assert all(item["to_date"] == "2026-07-11" for item in ledger["transitions"]) + assert "recovered stale→active" in render_movement_summary(ledger) + assert "recovered decision-needed→active-infra" in render_movement_summary(ledger) + + +def test_transition_ledger_reports_repos_appearing_and_disappearing(tmp_path: Path) -> None: + _write_snapshot(tmp_path, "2026-07-10", [_project("Stays"), _project("Leaves")]) + _write_snapshot(tmp_path, "2026-07-11", [_project("Stays")]) + _write_snapshot(tmp_path, "2026-07-12", [_project("Stays"), _project("Arrives")]) + + ledger = build_verdict_transition_ledger(tmp_path) + + lifecycle = { + (item["repo"], item["from"], item["to"]) + for item in ledger["repo_lifecycle_events"] + } + assert lifecycle == { + ("Leaves", "present", "absent"), + ("Arrives", "absent", "present"), + } + streaks = {item["repo"]: item for item in ledger["activity_status_streaks"]} + assert streaks["Leaves"]["status"] == "disappeared" + assert streaks["Arrives"]["run_count"] == 1 + assert ledger["summary"]["repo_appeared_count"] == 1 + assert ledger["summary"]["repo_disappeared_count"] == 1 + + +def test_weekly_digest_surfaces_movement_section(tmp_path: Path) -> None: + _write_snapshot( + tmp_path, + "2026-07-10", + [_project("Moving", attention="decision-needed", activity="stale")], + ) + current = _write_snapshot( + tmp_path, + "2026-07-11", + [_project("Moving", attention="active-infra", activity="active")], + ) + report_data = { + "username": "testuser", + "generated_at": "2026-07-11T12:00:00+00:00", + "operator_summary": {"decision_quality_v1": {}}, + "audits": [], + } + snapshot = {"operator_summary": report_data["operator_summary"], "operator_queue": []} + + digest = build_weekly_command_center_digest( + report_data, + snapshot, + portfolio_truth=current, + portfolio_truth_history_dir=tmp_path, + portfolio_truth_reference=str(tmp_path / "portfolio-truth-latest.json"), + ) + rendered = render_weekly_command_center_markdown(digest) + + assert digest["movement"]["attention_state_transitions"][0]["repo"] == "Moving" + assert "recovered decision-needed→active-infra" in digest["movement"]["summary_text"] + assert "## Movement" in rendered + assert "Moving" in rendered + assert "2026-07-11" in rendered diff --git a/tests/test_producer_preflight.py b/tests/test_producer_preflight.py index 6ebb0c4..454ab92 100644 --- a/tests/test_producer_preflight.py +++ b/tests/test_producer_preflight.py @@ -85,8 +85,11 @@ def test_evidence_rejects_head_change(tmp_path: Path) -> None: commit=commit, ref="refs/remotes/origin/main", checkout_role="canonical-automation", + checkout_path=str(repo.resolve()), worktree_clean=True, + dirty_path_count=0, verified_at=datetime.now(UTC), + receipt_id="sha256:" + "a" * 64, ) (repo / "README.md").write_text("changed\n") _git(repo, "add", "README.md") @@ -106,8 +109,11 @@ def test_load_producer_evidence_accepts_passing_receipt(tmp_path: Path) -> None: "commit": "a" * 40, "ref": "refs/remotes/origin/main", "checkout_role": "canonical-automation", + "checkout_path": str(tmp_path / "producer-repo"), "worktree_clean": True, + "dirty_path_count": 0, "verified_at": "2026-07-10T12:00:00Z", + "receipt_id": "sha256:" + "a" * 64, "checks": {}, } ) diff --git a/tests/test_run_instructions_audit.py b/tests/test_run_instructions_audit.py index 357f9e0..c477601 100644 --- a/tests/test_run_instructions_audit.py +++ b/tests/test_run_instructions_audit.py @@ -29,8 +29,12 @@ def test_assign_bucket_truth_table(): assert assign_bucket(True, True, True) == "agree_present" assert assign_bucket(False, False, False) == "agree_absent" # false negatives (tool said absent, verifier found it) - assert assign_bucket(False, True, True) == "fn_alias_gap" # evidence in primary file - assert assign_bucket(False, True, False) == "fn_blind_spot" # evidence only in README/other + assert ( + assign_bucket(False, True, True) == "fn_alias_gap" + ) # evidence in primary file + assert ( + assign_bucket(False, True, False) == "fn_blind_spot" + ) # evidence only in README/other # false positive (tool over-claimed) assert assign_bucket(True, False, False) == "fp_overclaim" assert assign_bucket(True, False, True) == "fp_overclaim" @@ -46,11 +50,15 @@ def test_assign_drift_bucket(): assert assign_drift_bucket(False, True, False) == "claim_changed_nodrift" -def _project(key, quality, *, status="active", path=None): +def _project(key, quality, *, archived=False, path=None): return { - "identity": {"project_key": key, "path": path or key, "display_name": key.split("/")[-1]}, + "identity": { + "project_key": key, + "path": path or key, + "display_name": key.split("/")[-1], + }, "derived": { - "registry_status": status, + "archived": archived, "context_quality": quality, "context_files": ["CLAUDE.md"], "run_instructions_present": False, @@ -62,7 +70,7 @@ def test_select_pilot_stratifies_sorts_and_filters(): projects = ( [_project(f"b{i}", "boilerplate") for i in range(6)] + [_project("n1", "none"), _project("n2", "none")] - + [_project("arch", "full", status="archived")] + + [_project("arch", "full", archived=True)] + [_project("junk-security-fix", "full")] + [_project("z-full", "full"), _project("a-full", "full")] ) @@ -94,7 +102,9 @@ def test_build_record_resolves_path_and_all_six_claims(): } record = build_record(project, os.path.expanduser("~/Projects")) - assert record["abs_path"] == os.path.expanduser("~/Projects/Fun:GamePrjs/BattleGrid") + assert record["abs_path"] == os.path.expanduser( + "~/Projects/Fun:GamePrjs/BattleGrid" + ) assert record["primary_file_name"] == "AGENTS.md" # no CLAUDE.md → AGENTS.md assert record["context_files"] == ["AGENTS.md", "README.md"] assert record["project_key"] == "Fun:GamePrjs/BattleGrid" @@ -102,13 +112,18 @@ def test_build_record_resolves_path_and_all_six_claims(): assert set(record["snapshot_claims"]) == set(CLAIM_FIELDS) assert record["snapshot_claims"]["run_instructions_present"] is False assert record["snapshot_claims"]["project_summary_present"] is True - assert record["snapshot_claims"]["known_risks_present"] is False # absent in derived → False + assert ( + record["snapshot_claims"]["known_risks_present"] is False + ) # absent in derived → False def test_build_record_prefers_claude_md(): project = { "identity": {"project_key": "x", "path": "x", "display_name": "x"}, - "derived": {"context_files": ["AGENTS.md", "CLAUDE.md"], "run_instructions_present": True}, + "derived": { + "context_files": ["AGENTS.md", "CLAUDE.md"], + "run_instructions_present": True, + }, } assert build_record(project, "/w")["primary_file_name"] == "CLAUDE.md" @@ -149,7 +164,10 @@ def _git_commit_at(path, iso): } subprocess.run(["git", "init", "-q"], cwd=path, check=True) subprocess.run( - ["git", "commit", "-q", "--allow-empty", "-m", "x"], cwd=path, env=env, check=True + ["git", "commit", "-q", "--allow-empty", "-m", "x"], + cwd=path, + env=env, + check=True, ) @@ -171,7 +189,9 @@ def test_prepare_pilot_builds_records_and_reports_missing_dirs(tmp_path): workspace = tmp_path / "ws" real = workspace / "RealRepo" real.mkdir(parents=True) - (real / "CLAUDE.md").write_text("# R\n\n## Usage\n\nRun `npm run dev` to start the server.\n") + (real / "CLAUDE.md").write_text( + "# R\n\n## Usage\n\nRun `npm run dev` to start the server.\n" + ) snapshot = { "workspace_root": str(workspace), @@ -184,7 +204,7 @@ def test_prepare_pilot_builds_records_and_reports_missing_dirs(tmp_path): "display_name": "RealRepo", }, "derived": { - "registry_status": "active", + "archived": False, "context_quality": "full", "context_files": ["CLAUDE.md"], "run_instructions_present": True, @@ -197,7 +217,7 @@ def test_prepare_pilot_builds_records_and_reports_missing_dirs(tmp_path): "display_name": "GhostRepo", }, "derived": { - "registry_status": "active", + "archived": False, "context_quality": "full", "context_files": ["CLAUDE.md"], "run_instructions_present": False, @@ -216,7 +236,9 @@ def test_prepare_pilot_builds_records_and_reports_missing_dirs(tmp_path): record = result["records"][0] assert record["project_key"] == "RealRepo" # tool_today + snapshot_claims are now dicts over all 6 claims - assert record["tool_today"]["run_instructions_present"] is True # live recompute on fixture + assert ( + record["tool_today"]["run_instructions_present"] is True + ) # live recompute on fixture assert record["snapshot_claims"]["run_instructions_present"] is True assert set(record["tool_today"]) == set(CLAIM_FIELDS) assert record["drifted"] is False # no git repo → not drifted diff --git a/tests/test_scorecards.py b/tests/test_scorecards.py index 32837f9..d7063ca 100644 --- a/tests/test_scorecards.py +++ b/tests/test_scorecards.py @@ -47,9 +47,17 @@ def _make_audit( {"dimension": "testing", "score": testing, "details": {}}, {"dimension": "cicd", "score": cicd, "details": {}}, {"dimension": "dependencies", "score": dependencies, "details": {}}, - {"dimension": "community_profile", "score": community_profile, "details": {}}, + { + "dimension": "community_profile", + "score": community_profile, + "details": {}, + }, {"dimension": "build_readiness", "score": build_readiness, "details": {}}, - {"dimension": "activity", "score": 0.6, "details": {"days_since_push": activity_days}}, + { + "dimension": "activity", + "score": 0.6, + "details": {"days_since_push": activity_days}, + }, ], "lenses": { "ship_readiness": {"score": ship_readiness}, @@ -123,12 +131,16 @@ def test_load_scorecards_reports_invalid_checks_and_level_order(tmp_path: Path): scorecards = load_scorecards(path) assert any("unsupported check" in error for error in scorecards["errors"]) - assert any("ordered by ascending threshold" in error for error in scorecards["errors"]) + assert any( + "ordered by ascending threshold" in error for error in scorecards["errors"] + ) def test_evaluate_repo_scorecard_prefers_explicit_program_and_target(tmp_path: Path): pytest.importorskip("yaml") - scorecards = load_scorecards(Path(__file__).resolve().parents[1] / "config" / "scorecards.yaml") + scorecards = load_scorecards( + Path(__file__).resolve().parents[1] / "config" / "scorecards.yaml" + ) audit = _make_audit( maturity_program="maintain", target_maturity="strong", @@ -180,6 +192,62 @@ def test_evaluate_repo_scorecard_falls_back_to_disposition_program(): assert result["status"] == "on-track" +def test_evaluate_repo_scorecard_falls_back_to_legacy_disposition_program(): + """`resolve_program_key` must still honor the deprecated `intended_disposition` + fallback (read-compat), same as before the migration to `operating_path`.""" + programs = { + "experiment": { + "key": "experiment", + "label": "Experiment", + "description": "Lightweight bar", + "target_maturity": "foundation", + "levels": [ + {"key": "missing-basics", "label": "Missing Basics", "threshold": 0.0}, + {"key": "foundation", "label": "Foundation", "threshold": 0.35}, + ], + "rules": [ + { + "key": "readme", + "label": "README", + "check": "dimension_at_least", + "dimension": "readme", + "threshold": 0.35, + "partial_threshold": 0.20, + "weight": 1.0, + } + ], + } + } + audit = _make_audit(disposition="experiment", readme=0.4) + audit["portfolio_catalog"]["operating_path"] = "" + + result = evaluate_repo_scorecard(audit, programs) + + assert result["program"] == "experiment" + + +def test_live_archive_scorecard_reads_migrated_operating_path_field(): + """Integration check for the config/scorecards.yaml migration: the live + 'Archive Disposition' rule was repointed from `catalog_field: + intended_disposition` to `catalog_field: operating_path`. A catalog entry + declaring `operating_path: archive` (the post-migration shape) must still + pass that rule, not fall through to not_applicable.""" + live_path = Path(__file__).parents[1] / "config" / "scorecards.yaml" + scorecards = load_scorecards(live_path) + audit = _make_audit(name="ArchivedRepo", maturity_program="archive") + audit["portfolio_catalog"]["intended_disposition"] = "" + audit["portfolio_catalog"]["operating_path"] = "archive" + + result = evaluate_repo_scorecard(audit, scorecards["programs"]) + disposition_rules = [ + rule for rule in result["rule_results"] if rule["key"] == "disposition" + ] + + assert result["program"] == "archive" + assert disposition_rules + assert disposition_rules[0]["status"] == "pass" + + def test_evaluate_scorecards_for_report_builds_summary(): programs = { "maintain": { @@ -207,14 +275,30 @@ def test_evaluate_scorecards_for_report_builds_summary(): } report = SimpleNamespace( audits=[ - _make_audit(name="RepoOnTrack", maturity_program="maintain", target_maturity="strong", testing=0.9), - _make_audit(name="RepoBelow", maturity_program="maintain", target_maturity="strong", testing=0.4), + _make_audit( + name="RepoOnTrack", + maturity_program="maintain", + target_maturity="strong", + testing=0.9, + ), + _make_audit( + name="RepoBelow", + maturity_program="maintain", + target_maturity="strong", + testing=0.4, + ), ] ) repo_results, summary, programs_summary = evaluate_scorecards_for_report( report, - {"path": "config/scorecards.yaml", "exists": True, "errors": [], "warnings": [], "programs": programs}, + { + "path": "config/scorecards.yaml", + "exists": True, + "errors": [], + "warnings": [], + "programs": programs, + }, ) assert len(repo_results) == 2 @@ -226,8 +310,23 @@ def test_evaluate_scorecards_for_report_builds_summary(): def test_build_scorecards_summary_mentions_config_errors(): summary = build_scorecards_summary( - [{"repo": "RepoA", "program": "default", "program_label": "Default", "maturity_level": "foundation", "status": "below-target", "summary": "Below target."}], - {"path": "config/scorecards.yaml", "exists": True, "errors": ["bad rule"], "warnings": [], "programs": {}}, + [ + { + "repo": "RepoA", + "program": "default", + "program_label": "Default", + "maturity_level": "foundation", + "status": "below-target", + "summary": "Below target.", + } + ], + { + "path": "config/scorecards.yaml", + "exists": True, + "errors": ["bad rule"], + "warnings": [], + "programs": {}, + }, ) assert "below target" in summary["summary"] diff --git a/tests/test_scoring_profiles.py b/tests/test_scoring_profiles.py index 1cea698..a1df357 100644 --- a/tests/test_scoring_profiles.py +++ b/tests/test_scoring_profiles.py @@ -1,9 +1,11 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from pathlib import Path from src.models import AnalyzerResult, RepoMetadata +from src.app.run_audit import _load_scoring_profile from src.scorer import WEIGHTS, score_repo @@ -67,6 +69,28 @@ def test_custom_weights_none_uses_default(self): audit_default = score_repo(_make_metadata(), results) assert audit_none.overall_score == audit_default.overall_score + def test_partial_run_grade_discloses_scored_basis(self): + scored_dimensions = list(WEIGHTS)[:6] + ["documentation"] + results = _make_results({dim: 0.72 for dim in scored_dimensions}) + + audit = score_repo(_make_metadata(), results) + + assert audit.grade == "B" + assert audit.scored_dimensions == scored_dimensions + assert audit.scored_weight_sum == 0.75 + assert audit.to_dict()["scored_dimensions"] == scored_dimensions + assert audit.to_dict()["scored_weight_sum"] == 0.75 + assert audit.to_dict()["grade"] == "B" + + def test_full_run_grade_remains_unqualified(self): + results = _make_results({dim: 0.72 for dim in WEIGHTS}) + + audit = score_repo(_make_metadata(), results) + + assert audit.grade == "B" + assert audit.scored_dimensions == list(WEIGHTS) + assert audit.scored_weight_sum == 1.0 + def test_profile_json_valid(self, tmp_path): """Verify profile JSONs sum to ~1.0.""" from pathlib import Path @@ -77,3 +101,49 @@ def test_profile_json_valid(self, tmp_path): weights = json.loads(profile_path.read_text()) total = sum(weights.values()) assert abs(total - 1.0) < 0.01, f"{profile_path.name} weights sum to {total}" + + def test_flat_profile_loads_weights_without_overrides(self): + profile, profile_name = _load_scoring_profile("default") + + assert profile_name == "default" + assert profile is not None + assert dict(profile) == json.loads( + Path("config/scoring-profiles/default.json").read_text() + ) + assert profile.overrides == {} + + def test_profile_reserved_constants_override_scorer(self, tmp_path, monkeypatch): + profiles_dir = tmp_path / "config/scoring-profiles" + profiles_dir.mkdir(parents=True) + profile_path = profiles_dir / "override.json" + profile_path.write_text( + json.dumps( + { + **WEIGHTS, + "stale_threshold_days": 1, + "grade_thresholds": [[0.9, "A"], [0.0, "F"]], + "completeness_tiers": [["shipped", 0.8], ["functional", 0.7], ["draft", 0.0]], + } + ) + ) + monkeypatch.chdir(tmp_path) + profile, _ = _load_scoring_profile("override") + assert profile is not None + + metadata = _make_metadata( + pushed_at=datetime.now(timezone.utc) - timedelta(days=3) + ) + results = _make_results({dimension: 0.76 for dimension in WEIGHTS}) + default_audit = score_repo(metadata, results) + overridden_audit = score_repo( + metadata, + results, + custom_weights=profile, + scoring_profile=profile.overrides, + ) + + assert dict(profile) == WEIGHTS + assert default_audit.completeness_tier == "shipped" + assert overridden_audit.completeness_tier == "wip" + assert default_audit.grade == "B" + assert overridden_audit.grade == "F" diff --git a/tests/test_scoring_weights.py b/tests/test_scoring_weights.py new file mode 100644 index 0000000..f254eb4 --- /dev/null +++ b/tests/test_scoring_weights.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import importlib +import inspect +import pkgutil + +from src.analyzers import ALL_ANALYZERS +from src.analyzers.base import BaseAnalyzer +from src.scorer import WEIGHTS + + +def test_scoring_weights_sum_to_one() -> None: + assert abs(sum(WEIGHTS.values()) - 1.0) < 1e-9 + + +def test_scoring_weights_cover_exactly_the_composed_dimensions() -> None: + expected_scored_dimensions = { + "readme", + "structure", + "code_quality", + "testing", + "cicd", + "dependencies", + "activity", + "documentation", + "build_readiness", + "community_profile", + } + + assert set(WEIGHTS) == expected_scored_dimensions + assert {analyzer.name for analyzer in ALL_ANALYZERS if analyzer.name in WEIGHTS} == set(WEIGHTS) + + +def test_analyzer_classes_do_not_declare_dead_weight_attributes() -> None: + import src.analyzers as analyzers_package + + analyzer_classes = [BaseAnalyzer] + for module_info in pkgutil.iter_modules(analyzers_package.__path__): + module = importlib.import_module(f"{analyzers_package.__name__}.{module_info.name}") + analyzer_classes.extend( + cls + for _, cls in inspect.getmembers(module, inspect.isclass) + if cls.__module__ == module.__name__ + and issubclass(cls, BaseAnalyzer) + and cls is not BaseAnalyzer + ) + + assert all("weight" not in cls.__dict__ for cls in analyzer_classes) diff --git a/tests/test_web_export.py b/tests/test_web_export.py index 8a73ef6..aa9b855 100644 --- a/tests/test_web_export.py +++ b/tests/test_web_export.py @@ -656,6 +656,22 @@ def test_repo_table_includes_next_action_explainability(self): html = _render_html(_make_report()) assert "Next:" in html or "Gap:" in html + def test_partial_run_grade_is_displayed_but_filterable_by_letter(self): + report = _make_report() + report["audits"][0].update( + { + "grade": "B", + "scored_dimensions": ["readme", "testing"], + "scored_weight_sum": 0.3, + } + ) + + html = _render_html(report) + + assert "B on 2/10 dimensions" in html + assert 'data-grade="B"' in html + assert 'data-grade="B on 2/10 dimensions"' not in html + def test_run_changes_section_is_rendered(self): report = _make_report(run_change_summary="One repo improved and one regressed.") html = _render_html( diff --git a/tests/test_weekly_command_center.py b/tests/test_weekly_command_center.py index 6cbfcc3..d1e5700 100644 --- a/tests/test_weekly_command_center.py +++ b/tests/test_weekly_command_center.py @@ -14,7 +14,6 @@ def _make_portfolio_truth() -> dict: "identity": {"display_name": "GithubRepoAuditor"}, "declared": {"operating_path": "maintain"}, "derived": { - "registry_status": "active", "attention_state": "decision-needed", "activity_status": "active", "path_override": "investigate", @@ -35,7 +34,6 @@ def _make_portfolio_truth() -> dict: "identity": {"display_name": "JobCommandCenter"}, "declared": {"operating_path": ""}, "derived": { - "registry_status": "active", "attention_state": "decision-needed", "activity_status": "active", "path_override": "investigate", @@ -56,7 +54,6 @@ def _make_portfolio_truth() -> dict: "identity": {"display_name": "QuietActive"}, "declared": {"operating_path": "maintain"}, "derived": { - "registry_status": "active", "attention_state": "manual-only", "activity_status": "active", "path_override": "", @@ -77,7 +74,7 @@ def _make_portfolio_truth() -> dict: "identity": {"display_name": "ArchiveMe"}, "declared": {"operating_path": "archive"}, "derived": { - "registry_status": "archived", + "archived": True, "attention_state": "archived", "activity_status": "stale", "path_override": "", @@ -335,7 +332,6 @@ def _security_project(name: str, tier: str, security: dict, factors: list | None "identity": {"display_name": name}, "declared": {"operating_path": "maintain"}, "derived": { - "registry_status": "active", "activity_status": "active", "path_override": "", "path_confidence": "high", @@ -448,7 +444,6 @@ def test_default_attention_watch_set_does_not_create_decision_queue() -> None: "identity": {"display_name": "ActiveProduct"}, "declared": {"operating_path": "finish"}, "derived": { - "registry_status": "active", "attention_state": "active-product", "activity_status": "active", "path_override": "", @@ -466,7 +461,6 @@ def test_default_attention_watch_set_does_not_create_decision_queue() -> None: "identity": {"display_name": "ActiveInfra"}, "declared": {"operating_path": "maintain"}, "derived": { - "registry_status": "active", "attention_state": "active-infra", "activity_status": "active", "path_override": "",