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..cb0f85d 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 @@ -80,7 +80,7 @@ repos: lifecycle_state: active criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -93,7 +93,7 @@ repos: lifecycle_state: active 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 @@ -227,7 +227,7 @@ repos: purpose: local MCP security and drift audit tool lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -238,14 +238,14 @@ repos: owner: d lifecycle_state: active 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 @@ -279,7 +279,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 @@ -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,79 @@ 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 lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code RedditSentimentAnalyzer: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: vanity tool_provenance: claude-code SignalDecay: owner: d lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code 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,42 +454,42 @@ 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: @@ -498,7 +498,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 @@ -511,7 +511,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 +523,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 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 +557,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 +580,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 +592,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,19 +614,19 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain 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. @@ -634,71 +634,71 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain tool_provenance: claude-code 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 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 +707,7 @@ repos: path lifecycle_state: archived review_cadence: quarterly - intended_disposition: archive + operating_path: archive category: commercial tool_provenance: gpt IncidentWorkbench: @@ -716,20 +716,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 +738,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 +772,49 @@ 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 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.' 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 +822,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 +838,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 +905,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,22 +914,22 @@ 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 @@ -937,7 +937,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 @@ -949,13 +949,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 +965,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 +977,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 +990,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 +1003,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 +1016,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 +1029,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 +1042,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 +1054,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 +1065,70 @@ 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 lifecycle_state: active review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: fun 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 review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: claude-code portfolio-health: owner: d lifecycle_state: active 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 +1136,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 +1144,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 +1173,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 +1223,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 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 +1312,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: monthly - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: codex maturity_program: experiment @@ -1324,7 +1324,7 @@ repos: lifecycle_state: maintenance criticality: medium review_cadence: monthly - intended_disposition: maintain + operating_path: maintain category: infrastructure tool_provenance: codex maturity_program: maintain @@ -1336,7 +1336,7 @@ repos: lifecycle_state: experimental criticality: medium review_cadence: monthly - intended_disposition: experiment + operating_path: experiment category: infrastructure tool_provenance: codex maturity_program: experiment @@ -1346,7 +1346,7 @@ repos: owner: d lifecycle_state: active review_cadence: weekly - intended_disposition: maintain + operating_path: maintain category: infrastructure peer-agent-tools: owner: d @@ -1354,7 +1354,7 @@ repos: lifecycle_state: active criticality: high review_cadence: weekly - intended_disposition: maintain + operating_path: maintain maturity_program: maintain target_maturity: operating category: infrastructure @@ -1364,35 +1364,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 +1401,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/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/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..ac028fd 100644 --- a/src/portfolio_catalog.py +++ b/src/portfolio_catalog.py @@ -1,14 +1,32 @@ from __future__ import annotations from collections import Counter +from collections.abc import Set as AbstractSet 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", + "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"} @@ -23,11 +41,20 @@ def _normalize_key(key: str) -> str: return key.strip().lower() -def _normalize_enum(value: Any, allowed: set[str]) -> str: +def _normalize_enum(value: Any, allowed: AbstractSet[str]) -> str: normalized = _safe_text(value).lower() 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_risk.py b/src/portfolio_risk.py index 50fc1dd..5651bf8 100644 --- a/src/portfolio_risk.py +++ b/src/portfolio_risk.py @@ -58,7 +58,7 @@ def build_risk_entry( path_override: str, context_quality: str, activity_status: str, - registry_status: str, + archived: bool, criticality: str, doctor_standard: str, known_risks_present: bool, @@ -67,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 131fa6b..34d1fb9 100644 --- a/src/portfolio_truth_reconcile.py +++ b/src/portfolio_truth_reconcile.py @@ -35,6 +35,7 @@ PortfolioTruthSnapshot, RiskFields, SecurityFields, + display_activity_status, ) from src.registry_parser import _normalize @@ -52,7 +53,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 +72,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 +120,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 +142,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 +274,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 +291,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 +304,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( @@ -417,25 +435,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 @@ -531,9 +557,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 +598,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 +616,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 +639,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 +658,7 @@ def _build_truth_project( ), }, context_quality=context_quality, - archived=github_archived, - registry_status=registry_status, + archived=archived, ) provenance["declared.operating_path"] = { "source": "normalized", @@ -655,7 +693,7 @@ def _build_truth_project( path_override=path_entry.get("path_override", ""), 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,13 +702,13 @@ def _build_truth_project( security_critical_alerts=security.dependabot_critical, ) 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", ""), category=declared_values["category"], path_override=path_entry.get("path_override", ""), risk_entry=risk_entry, - github_archived=github_archived, ) declared = DeclaredFields( @@ -694,10 +732,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"])), @@ -719,16 +769,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 '{displayed_status}'." + ) + if not repo_entry.get("has_explicit_entry") and not group_entry.get( + "has_explicit_entry" + ): warnings.append( - f"Legacy registry status '{legacy['status']}' differs from derived registry status '{registry_status}'." + "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": @@ -755,10 +811,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"), @@ -787,7 +845,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(), @@ -810,12 +871,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": ""} @@ -835,7 +901,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 @@ -865,7 +934,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.", @@ -914,7 +985,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" @@ -930,7 +1004,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", []): @@ -948,7 +1024,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", []): @@ -956,15 +1034,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 @@ -975,44 +1048,37 @@ 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, 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 lifecycle_state == "experimental" - ): + if operating_path == "experiment" or lifecycle_state == "experimental": return "experiment" - if registry_status == "parked": + if activity_status == "stale": return "parked" - if path_override == "investigate" or not operating_path or risk_entry.get("security_risk"): + if ( + path_override == "investigate" + or not operating_path + or 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_types.py b/src/portfolio_truth_types.py index 734d7c2..cc13f6b 100644 --- a/src/portfolio_truth_types.py +++ b/src/portfolio_truth_types.py @@ -6,8 +6,11 @@ from pathlib import Path from typing import Any -SCHEMA_VERSION = "0.8.0" -LEGACY_SCHEMA_VERSIONS = {"0.7.0"} +SCHEMA_VERSION = "0.9.0" +# 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"} DERIVATION_POLICY_VERSION = "portfolio_attention.v2" # The published "latest" portfolio-truth artifact. The producer @@ -22,8 +25,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", @@ -61,6 +65,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 +109,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 +142,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" diff --git a/src/portfolio_truth_validate.py b/src/portfolio_truth_validate.py index d7bb52c..189eeca 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." ) @@ -113,13 +112,19 @@ def _validate_contract_envelope(snapshot: PortfolioTruthSnapshot) -> None: 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." + ) + 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/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/weekly_command_center.py b/src/weekly_command_center.py index bcaaf9d..c14769c 100644 --- a/src/weekly_command_center.py +++ b/src/weekly_command_center.py @@ -7,7 +7,7 @@ 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, @@ -47,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; " @@ -67,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: @@ -128,25 +140,34 @@ def build_weekly_command_center_digest( 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 = ( + 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) @@ -154,7 +175,9 @@ def build_weekly_command_center_digest( 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." @@ -205,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, @@ -214,17 +238,24 @@ 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, @@ -317,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"]) @@ -327,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)" ) @@ -337,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 []) @@ -382,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) @@ -424,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 @@ -451,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 @@ -471,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" @@ -483,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", @@ -504,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} @@ -560,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]] = [] @@ -600,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_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..c5831b6 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,7 +163,10 @@ 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 @@ -200,8 +211,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 +416,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_risk.py b/tests/test_portfolio_risk.py index 005afec..cdec18c 100644 --- a/tests/test_portfolio_risk.py +++ b/tests/test_portfolio_risk.py @@ -8,7 +8,7 @@ def _baseline_kwargs(**overrides): path_override="", context_quality="standard", activity_status="active", - registry_status="active", + archived=False, criticality="medium", doctor_standard="", known_risks_present=True, @@ -19,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 9081e96..871e092 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,13 +385,13 @@ 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.9.0" assert result.snapshot.derivation_policy_version == "portfolio_attention.v2" assert result.snapshot.inputs["catalog"]["sha256"] assert result.snapshot.inputs["notion"]["mode"] == "unavailable" @@ -378,14 +399,21 @@ def test_truth_snapshot_respects_declared_and_derived_fields( "policy_version": "workspace_discovery.v1", "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", @@ -393,7 +421,10 @@ def test_truth_snapshot_respects_declared_and_derived_fields( "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,12 +433,78 @@ 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 assert ( _attention_state_for( - registry_status="active", + activity_status="active", + archived=False, lifecycle_state="active", operating_path="maintain", category="commercial", @@ -418,7 +515,8 @@ 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", category="infrastructure", @@ -429,7 +527,8 @@ 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", category="vanity", @@ -440,7 +539,8 @@ 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", category="fun", @@ -451,7 +551,8 @@ 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", category="vanity", @@ -462,7 +563,8 @@ 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", category="commercial", @@ -496,13 +598,13 @@ def test_attention_state_uses_resolved_catalog_operating_path( "intended_disposition": intended_disposition, }, context_quality="full", - registry_status="active", ) assert catalog_entry["operating_path"] == operating_path assert ( _attention_state_for( - registry_status="active", + activity_status="active", + archived=False, lifecycle_state="active", operating_path=catalog_entry["operating_path"], category="commercial", @@ -540,25 +642,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: @@ -566,7 +690,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}, } @@ -595,7 +725,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 @@ -621,7 +754,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}, } @@ -679,7 +818,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: @@ -735,12 +877,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, @@ -751,7 +897,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" @@ -759,7 +907,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") @@ -767,7 +917,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( @@ -837,7 +989,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 + ) ) @@ -851,7 +1006,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( """ @@ -872,13 +1027,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" @@ -908,7 +1072,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" @@ -924,7 +1090,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( """ @@ -958,7 +1124,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" @@ -997,7 +1165,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}, } @@ -1052,7 +1226,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}, } @@ -1068,9 +1248,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) @@ -1084,7 +1267,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}, } @@ -1097,7 +1286,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 @@ -1119,7 +1311,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), @@ -1192,7 +1386,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" @@ -1228,9 +1424,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" @@ -1391,7 +1590,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( @@ -1477,7 +1678,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: @@ -1523,7 +1727,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") == {} @@ -1650,7 +1856,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" @@ -1698,7 +1906,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"]) @@ -1858,7 +2071,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", @@ -1906,7 +2120,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" @@ -1921,8 +2137,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, @@ -1931,7 +2153,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, @@ -1954,7 +2178,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 @@ -1968,7 +2195,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, @@ -1977,7 +2207,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, @@ -2000,7 +2232,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 @@ -2011,7 +2246,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, @@ -2020,7 +2258,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, @@ -2030,7 +2270,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" @@ -2061,7 +2303,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, @@ -2070,7 +2315,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, @@ -2095,7 +2342,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, @@ -2104,7 +2354,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, @@ -2240,8 +2492,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" @@ -2250,7 +2506,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" @@ -2269,7 +2527,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, @@ -2291,7 +2551,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 @@ -2305,7 +2567,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 @@ -2318,7 +2582,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 @@ -2334,7 +2601,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() @@ -2346,7 +2615,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 @@ -2361,7 +2632,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 @@ -2387,7 +2660,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() @@ -2403,7 +2678,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, @@ -2419,7 +2699,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_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_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": "",