katalyst-forge 5.2.0__tar.gz → 5.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/CHANGELOG.md +28 -0
  2. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/PKG-INFO +2 -1
  3. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/pyproject.toml +4 -1
  4. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/dashboard.py +9 -2
  5. katalyst_forge-5.3.0/src/katalyst_forge/metrics/forge_stages.py +61 -0
  6. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/generate_dashboard.py +116 -4
  7. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/html_report.py +252 -2
  8. katalyst_forge-5.3.0/src/katalyst_forge/metrics/measurement.py +90 -0
  9. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/opencode.py +188 -11
  10. katalyst_forge-5.3.0/src/katalyst_forge/metrics/run_health.py +547 -0
  11. katalyst_forge-5.3.0/src/katalyst_forge/metrics/tiles.py +248 -0
  12. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_cost_observability_wing.py +72 -2
  13. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_content_contract.py +8 -1
  14. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_dashboard_end_to_end.py +65 -0
  15. katalyst_forge-5.3.0/tests/test_metrics_run_health.py +400 -0
  16. katalyst_forge-5.3.0/tests/test_writer_kind_mapping.py +140 -0
  17. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/.gitignore +0 -0
  18. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/LICENSE +0 -0
  19. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/CHANGELOG.md +0 -0
  20. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/README.md +0 -0
  21. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/agents/agent.schema.json +0 -0
  22. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/agents/config.schema.json +0 -0
  23. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/agents/manifest.json +0 -0
  24. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/agents/skill.schema.json +0 -0
  25. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/environment.schema.json +0 -0
  26. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/hooks/hook-config.schema.json +0 -0
  27. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/katalyst-lock.schema.json +0 -0
  28. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/nodes/common.schema.json +0 -0
  29. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/nodes/design.schema.json +0 -0
  30. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/nodes/organizational.schema.json +0 -0
  31. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/nodes/portfolio.schema.json +0 -0
  32. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/nodes/technical.schema.json +0 -0
  33. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/schemas/taxonomy-node.schema.json +0 -0
  34. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/__init__.py +0 -0
  35. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/bundle.py +0 -0
  36. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/__init__.py +0 -0
  37. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/batch.py +0 -0
  38. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/billing.py +0 -0
  39. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/contracts.py +0 -0
  40. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/core.py +0 -0
  41. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/errors.py +0 -0
  42. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/money.py +0 -0
  43. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/protocols.py +0 -0
  44. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/__init__.py +0 -0
  45. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/forge_model_router.py +0 -0
  46. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/llm_gateway.py +0 -0
  47. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/redaction.py +0 -0
  48. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/registry.py +0 -0
  49. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/routing_policy.py +0 -0
  50. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/py.typed +0 -0
  51. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/relationships.py +0 -0
  52. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/_base.yaml.j2 +0 -0
  53. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/layer.yaml.j2 +0 -0
  54. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/project.yaml.j2 +0 -0
  55. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/stack.yaml.j2 +0 -0
  56. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/system.yaml.j2 +0 -0
  57. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/team.yaml.j2 +0 -0
  58. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/team_member.yaml.j2 +0 -0
  59. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/.dockerignore +0 -0
  60. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/.env.example +0 -0
  61. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/README.md +0 -0
  62. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/base/Dockerfile +0 -0
  63. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/docker-compose.yaml +0 -0
  64. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/Dockerfile +0 -0
  65. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/docker-compose.override.yaml +0 -0
  66. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/environment.yaml +0 -0
  67. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/layer.yaml +0 -0
  68. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/README.md +0 -0
  69. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/main.tf +0 -0
  70. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/outputs.tf +0 -0
  71. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/variables.tf +0 -0
  72. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/environment.yaml +0 -0
  73. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/terragrunt.hcl +0 -0
  74. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/context.hcl +0 -0
  75. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/dev.yaml +0 -0
  76. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/prod.yaml +0 -0
  77. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/staging.yaml +0 -0
  78. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/layer.yaml +0 -0
  79. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/version.yaml +0 -0
  80. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/README.md +0 -0
  81. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/app/argocd-application.yaml +0 -0
  82. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/kustomization.yaml +0 -0
  83. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/namespace.yaml +0 -0
  84. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/argocd-application.yaml +0 -0
  85. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/kustomization.yaml +0 -0
  86. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/patch-argocd-application.yaml +0 -0
  87. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/layer.yaml +0 -0
  88. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/README.md +0 -0
  89. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/deployment.yaml +0 -0
  90. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/kustomization.yaml +0 -0
  91. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/namespace.yaml +0 -0
  92. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/service.yaml +0 -0
  93. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/kustomization.yaml +0 -0
  94. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/patch-deployment.yaml +0 -0
  95. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/kustomization.yaml +0 -0
  96. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/namespace.yaml +0 -0
  97. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/kustomization.yaml +0 -0
  98. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/layer.yaml +0 -0
  99. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/__init__.py +0 -0
  100. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/_practice_refs.py +0 -0
  101. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/agentic.py +0 -0
  102. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/delivery.py +0 -0
  103. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/design.py +0 -0
  104. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/metrics.py +0 -0
  105. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/org.py +0 -0
  106. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/pipeline.py +0 -0
  107. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/practices.py +0 -0
  108. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/system.py +0 -0
  109. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/templates/CHANGELOG.md +0 -0
  110. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/conftest.py +0 -0
  111. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/fake_providers_opencode.py +0 -0
  112. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/fake_providers_router.py +0 -0
  113. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/expected-modified.txt +0 -0
  114. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/expected-new.txt +0 -0
  115. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/modified-inplace.patch +0 -0
  116. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/delivery/work_items/ric-1580-forge-model-routing.yaml +0 -0
  117. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-011-per-dispatch-model-tier-routing.md +0 -0
  118. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-012-dispatch-parity-and-containment.md +0 -0
  119. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-013-per-stage-model-token-telemetry.md +0 -0
  120. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/dispatched-stage-executes-on-requested-tier.yaml +0 -0
  121. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/routed-dispatch-preserves-task-parity.yaml +0 -0
  122. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/tier-resolution-fails-before-any-child-session.yaml +0 -0
  123. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/zero-committed-vendor-model-identity.yaml +0 -0
  124. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-024-operator-selects-model-tier-per-forge-run.md +0 -0
  125. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-025-model-tiers-from-env-vars-no-vendor-pins.md +0 -0
  126. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-026-routing-tier-recorded-on-work-item.md +0 -0
  127. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-027-per-stage-model-and-token-telemetry.md +0 -0
  128. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-028-dispatch-tool-enforces-agent-allowlist.md +0 -0
  129. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-029-silent-or-aborted-dispatch-yields-blocked.md +0 -0
  130. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-030-dispatched-write-stage-stays-in-worktree.md +0 -0
  131. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-031-single-stage-spike-preserves-eleven-stage-rollout.md +0 -0
  132. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/forge-reconcile/reconcile.test.mjs +0 -0
  133. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_agent_run.py +0 -0
  134. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_delivery_doc_consistency.py +0 -0
  135. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_delivery_traceability.py +0 -0
  136. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_entry_point_discovery.py +0 -0
  137. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_forge.py +0 -0
  138. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_batch_hygiene.py +0 -0
  139. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_billing_isolation.py +0 -0
  140. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_contracts.py +0 -0
  141. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_dashboard.py +0 -0
  142. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_html_report.py +0 -0
  143. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_lazy_providers.py +0 -0
  144. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_money_and_redaction.py +0 -0
  145. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_providers.py +0 -0
  146. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_resolve_compute.py +0 -0
  147. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_routing_policy.py +0 -0
  148. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_metrics_wing.py +0 -0
  149. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_practice_assignment.py +0 -0
  150. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_relationships.py +0 -0
  151. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_storage_contract.py +0 -0
  152. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_validate_templates_gates.py +0 -0
  153. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_wing_schemas.py +0 -0
  154. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_wings.py +0 -0
  155. {katalyst_forge-5.2.0 → katalyst_forge-5.3.0}/tests/test_writer_bundle_schema.py +0 -0
@@ -16,6 +16,34 @@ PyPI package version. A breaking node-type/model change bumps `schema_version`.
16
16
 
17
17
  ---
18
18
 
19
+ ## [5.3.0] -- 2026-08-08
20
+
21
+ ### Added
22
+
23
+ - **The governance half of the Forge run report, as a tile dashboard** (RIC-1624). The pre-RIC-1590 report was described by its own commit as cost *and governance* observability; only the cost half survived the move into the metrics wing. Restored as six tiles, each summarising one concern and linking to a detail view: run spend, rollup agreement, stage coverage, degraded sessions, unpriced models, and spend per model. Adds `metrics/run_health.py` (a pure core computing the session tree, stage attribution, rollup drift, degradation and pricing coverage), `metrics/measurement.py` (a figure that was measured, or an explicit absence, which refuses addition and float coercion), `metrics/tiles.py`, and `metrics/forge_stages.py` (the eleven stage names, previously spelled out in two places). None of it enters `metric_definitions`: a new `opencode-run-health` signal source declares `signal_type: run_health` with no `billing_mode` and no `metric_ref`, and both pipelines filter on `signal_type`.
24
+ - **`optional_fields` awareness for provider-synthesized values**, already shipped in 5.2.0, is now exercised by the run-health source.
25
+
26
+ ### Fixed
27
+
28
+ - **Bundle content installed into a project is now discoverable** (RIC-1629). The only fallback understood the katalyst-forge working-copy layout, resolving through `__file__.parents[3]`, which inside `site-packages` points at the Python installation rather than at any project. So an installed package could never find content a project had installed correctly, and every invocation needed `FORGE_COST_CONTENT_DIR` set by hand. Resolution order is now explicit argument, environment override, the project's installed taxonomy, then the working-copy layout.
29
+ - **Five dashboard alignment defects** (RIC-1632). A CSS class collision on `.figure` stacked every tile label above its value, because the pre-existing SVG figure rule sets `flex-direction: column` and the tile rule never overrode it. Six tiles rendered as five across plus one orphan of a different height. Sublines floated at five different heights within a row. Numeric table columns sat right-aligned under left-aligned headers. And a drift of `-2E-16` rendered as `$-0.00`, now fixed at `dashboard.to_cents`, the single rounding point, so the text renderer and percentage formatter benefit too; a sign still survives wherever there is a magnitude to describe.
30
+ - **The `kata-tax-writer` Kind Mapping matches the registry** (RIC-1625). Every row paired a CamelCase kind with a `katalyst.taxonomy.plugins.*/v1alpha` apiVersion, and neither is recognised: loaders match lowercase kinds exactly and the parser skips an unknown apiVersion before a loader sees the document. Since the writer is the single writer, every artifact other agents create flowed through it. `plugin-stage` needed `cicd_stage`, the job and workflow templates needed the `cicd_` prefix, and `taxonomy-node` named a kind no creation template emits. Four artifact types were not mis-mapped but unsupported: bounded contexts, aggregates, domain events and glossary terms are fields on a single `business_domain_model` document, which the shared `BDM-` id prefix already implied.
31
+ - **The llm-gateway mapping declares itself unverified** (RIC-1622). Its eight field paths were written by analogy to the OpenCode source rather than read from live gateway output. A `verified_against_provider: false` flag now pairs with a test asserting the provider still raises while it is false, so an unverified mapping cannot quietly begin producing figures.
32
+
33
+ ### Changed
34
+
35
+ - **Scaffold drift between the authoritative agents and the bundled copies is gated, and the existing drift is resolved** (RIC-1626). Every agent and skill exists twice, and 19 of 52 pairs had diverged with neither copy signposted as authoritative. All twelve substantive divergences turned out to be the scaffold being stale: five carried Stellite guidance, including an instruction to run `kata tax stellite validate`, a command that exists nowhere, and the rest carried the abandoned user-story vocabulary. Three scaffold-only orphans were also removed, among them a whole agent for the deleted Stellite subsystem, which the original pair-based gate could not see.
36
+
37
+ ## [5.2.1] -- 2026-08-08
38
+
39
+ ### Fixed
40
+
41
+ - **PyYAML is now a declared runtime dependency** (RIC-1628). 5.2.0 shipped `katalyst_forge.metrics` but could not import `metrics.generate_dashboard` on a clean install, because that module reads its metric definitions, signal sources and threshold profiles from YAML while `pyyaml` was only ever present transitively in the development environment. An AST scan of `src/` confirms it was the only undeclared third-party runtime import.
42
+
43
+ ### Changed
44
+
45
+ - CI gains a **wheel-import** job that installs the built wheel into a venv with no dev group and imports every public entry point, including the dashboard CLI. Pytest runs inside the dev environment, where a transitively present package masks a missing runtime dependency, and the existing clean-machine smoke job installs agents and skills without ever installing the Python package, so nothing verified what a consumer actually gets. Verified the new job fails on the 5.2.0 dependency set and passes on this one.
46
+
19
47
  ## [5.2.0] -- 2026-08-08
20
48
 
21
49
  ### Added
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: katalyst-forge
3
- Version: 5.2.0
3
+ Version: 5.3.0
4
4
  Summary: Katalyst Forge: the delivery-lifecycle bundle (node kinds, wings, agents, templates, schemas)
5
5
  Author: eSimplicity
6
6
  License: Proprietary
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.12
9
9
  Requires-Dist: katalyst-engine<5,>=4.1
10
+ Requires-Dist: pyyaml<7,>=6
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-forge"
3
- version = "5.2.0"
3
+ version = "5.3.0"
4
4
  description = "Katalyst Forge: the delivery-lifecycle bundle (node kinds, wings, agents, templates, schemas)"
5
5
  license = { text = "Proprietary" }
6
6
  requires-python = ">=3.12"
@@ -9,6 +9,9 @@ authors = [
9
9
  ]
10
10
  dependencies = [
11
11
  "katalyst-engine>=4.1,<5",
12
+ # generate_dashboard reads its metric definitions, signal sources and
13
+ # threshold profiles from YAML, so this is a runtime need, not a dev tool.
14
+ "pyyaml>=6,<7",
12
15
  ]
13
16
 
14
17
  [project.entry-points."katalyst_engine.bundles"]
@@ -37,8 +37,15 @@ UNIT_PERCENTAGE: Final[str] = "percentage"
37
37
 
38
38
 
39
39
  def to_cents(value: Decimal) -> Decimal:
40
- """Round to the cent, once, at the point of display."""
41
- return value.quantize(CENTS, rounding=ROUND_HALF_UP)
40
+ """Round to the cent, once, at the point of display.
41
+
42
+ A value that rounds away to nothing is returned unsigned. Without this, a
43
+ drift of -2E-16 formats as "-0.00" and a dashboard shows "$-0.00", which reads
44
+ as a broken report rather than as two figures agreeing. The sign only survives
45
+ when there is a magnitude for it to describe.
46
+ """
47
+ cents = value.quantize(CENTS, rounding=ROUND_HALF_UP)
48
+ return abs(cents) if cents == 0 else cents
42
49
 
43
50
 
44
51
  def format_usd(value: Decimal) -> str:
@@ -0,0 +1,61 @@
1
+ """The canonical Forge lifecycle stages, defined once.
2
+
3
+ The eleven names were spelled out in the OpenCode stage-matching pattern and
4
+ again in the run report that preceded this module, which is how a stage list
5
+ drifts. Everything that needs them now reads them from here.
6
+
7
+ The distinction between attributed and executed matters and is kept in the
8
+ vocabulary: a stage is ATTRIBUTED when a session's agent name or title implies
9
+ it. That is a heuristic over names, not evidence that a gate ran, so nothing here
10
+ claims an unobserved stage did not happen.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Final
17
+
18
+ __all__ = ("FORGE_STAGES", "STAGE_PATTERN", "infer_stage_from_text")
19
+
20
+ FORGE_STAGES: Final[tuple[str, ...]] = (
21
+ "design",
22
+ "orchestrate",
23
+ "review",
24
+ "implement",
25
+ "qa",
26
+ "commit",
27
+ "deploy",
28
+ "verify",
29
+ "release",
30
+ "operate",
31
+ "feedback",
32
+ )
33
+
34
+ # The agent form is matched first and separately from the bare word. A session
35
+ # titled "review the PR" is a much weaker signal than an agent literally named
36
+ # kata-forge-review, and the caller is told which one matched so the report can
37
+ # say so instead of presenting both as equal evidence.
38
+ _AGENT_FORM: Final[str] = r"\bkata-forge-([a-z-]+)\b"
39
+ _BARE_WORD: Final[str] = r"\b(" + "|".join(FORGE_STAGES) + r")\b"
40
+
41
+ STAGE_PATTERN: Final[re.Pattern[str]] = re.compile(f"{_AGENT_FORM}|{_BARE_WORD}", re.IGNORECASE)
42
+
43
+
44
+ def infer_stage_from_text(text: str) -> tuple[str | None, bool]:
45
+ """Return (stage, matched_agent_form).
46
+
47
+ matched_agent_form is what lets the report distinguish a stage read off an
48
+ agent name from one guessed out of prose.
49
+ """
50
+ found = STAGE_PATTERN.search(text)
51
+ if found is None:
52
+ return None, False
53
+ agent_form = found.group(1)
54
+ if agent_form:
55
+ stage = agent_form.lower()
56
+ # kata-forge-qa-lead implies the qa stage; keep the longest known prefix.
57
+ for candidate in FORGE_STAGES:
58
+ if stage == candidate or stage.startswith(f"{candidate}-"):
59
+ return candidate, True
60
+ return None, True
61
+ return found.group(2).lower(), False
@@ -14,7 +14,8 @@ from __future__ import annotations
14
14
  import argparse
15
15
  import os
16
16
  import sys
17
- from collections.abc import Callable, Mapping, Sequence
17
+ import time
18
+ from collections.abc import Callable, Iterator, Mapping, Sequence
18
19
  from decimal import Decimal
19
20
  from pathlib import Path
20
21
  from typing import Final, cast
@@ -29,6 +30,10 @@ from katalyst_forge.metrics.html_report import render_html_report
29
30
  from katalyst_forge.metrics.protocols import PriceCatalogue, RoutingPolicy
30
31
  from katalyst_forge.metrics.registry import SignalProviderRegistry
31
32
  from katalyst_forge.metrics.routing_policy import build_policy
33
+ from katalyst_forge.metrics.run_health import RunHealth, compute_run_health, project_run_health
34
+
35
+ SIGNAL_TYPE_COST_EVENT: Final[str] = "cost_event"
36
+ SIGNAL_TYPE_RUN_HEALTH: Final[str] = "run_health"
32
37
 
33
38
  METRIC_DEFINITIONS_FILE: Final[str] = "metric-definitions.yaml"
34
39
  SIGNAL_SOURCES_FILE: Final[str] = "signal-sources.yaml"
@@ -71,18 +76,64 @@ def index_by_name(docs: Sequence[Document]) -> dict[str, Document]:
71
76
  return indexed
72
77
 
73
78
 
74
- def resolve_content_dir(explicit: str | None) -> Path:
79
+ def _taxonomy_roots(start: Path) -> Iterator[Path]:
80
+ """Yield each .global/taxonomy from start upward, nearest first."""
81
+ current = start.resolve()
82
+ for directory in (current, *current.parents):
83
+ candidate = directory / ".global" / "taxonomy"
84
+ if candidate.is_dir():
85
+ yield candidate
86
+
87
+
88
+ def _installed_content_dir(start: Path) -> Path | None:
89
+ """Find bundle content installed into a project's taxonomy.
90
+
91
+ The wheel ships templates/creation and templates/scaffolds but not
92
+ templates/bundles, because the bundle collection is released separately on
93
+ templates-v* tags. That split is fine; what was not fine is that the only
94
+ fallback understood the working-copy layout, so an installed package could
95
+ never find content a project had correctly installed and every invocation
96
+ needed FORGE_COST_CONTENT_DIR set by hand.
97
+
98
+ Matched on the presence of metric-definitions.yaml rather than on a directory
99
+ name, because the name is a bundle's choice and the file the loader needs is not.
100
+ """
101
+ for taxonomy_root in _taxonomy_roots(start):
102
+ if (taxonomy_root / METRIC_DEFINITIONS_FILE).is_file():
103
+ return taxonomy_root
104
+ # One level down covers a bundle installed into its own directory. Kept
105
+ # shallow on purpose: a deep walk would make the answer depend on unrelated
106
+ # content that happens to sit nearby.
107
+ for child in sorted(taxonomy_root.iterdir()):
108
+ if child.is_dir() and (child / METRIC_DEFINITIONS_FILE).is_file():
109
+ return child
110
+ return None
111
+
112
+
113
+ def resolve_content_dir(explicit: str | None, *, start: Path | None = None) -> Path:
114
+ """Resolve the cost-observability content directory.
115
+
116
+ Order is explicit argument, environment override, the project's installed
117
+ taxonomy, then the working-copy layout. The override stays ahead of discovery so
118
+ a caller can always point at content deliberately.
119
+ """
75
120
  if explicit:
76
121
  return Path(explicit)
77
122
  from_env = os.environ.get(FORGE_COST_CONTENT_DIR_ENV)
78
123
  if from_env:
79
124
  return Path(from_env)
125
+ installed = _installed_content_dir(start if start is not None else Path.cwd())
126
+ if installed is not None:
127
+ return installed
80
128
  candidate = Path(__file__).resolve().parents[3] / "templates" / "bundles" / "cost-observability"
81
129
  if candidate.is_dir():
82
130
  return candidate
83
131
  raise SystemExit(
84
- f"cost-observability content not found; set {FORGE_COST_CONTENT_DIR_ENV} to the "
85
- f"directory holding {METRIC_DEFINITIONS_FILE}"
132
+ f"cost-observability content not found. Looked for {METRIC_DEFINITIONS_FILE} "
133
+ f"in the nearest .global/taxonomy and one level below it, then in the "
134
+ f"katalyst-forge working copy. Install the cost-observability bundle into "
135
+ f"this project, or set {FORGE_COST_CONTENT_DIR_ENV} to the directory "
136
+ f"holding {METRIC_DEFINITIONS_FILE}"
86
137
  )
87
138
 
88
139
 
@@ -112,6 +163,12 @@ def build_results(
112
163
  provider_key = spec.get("provider")
113
164
  if not isinstance(provider_key, str):
114
165
  continue
166
+ # Filtered rather than skipped-with-a-warning. A run_health source declares
167
+ # no billing_mode, so resolve() would raise UnknownBillingModeError, which
168
+ # build_results would catch and print on every single run. The report would
169
+ # look fine while emitting a permanent false warning.
170
+ if spec.get("signal_type") != SIGNAL_TYPE_COST_EVENT:
171
+ continue
115
172
  try:
116
173
  factory = active_registry.resolve(provider_key)
117
174
  provider = factory(env=provider_env)
@@ -143,6 +200,52 @@ def build_results(
143
200
  return results
144
201
 
145
202
 
203
+ def build_run_health(
204
+ *,
205
+ run_id: str,
206
+ signal_sources: Mapping[str, Document],
207
+ provider_env: Mapping[str, str],
208
+ catalogue: PriceCatalogue,
209
+ now_ms: int | None = None,
210
+ registry: SignalProviderRegistry | None = None,
211
+ err: object = None,
212
+ ) -> RunHealth | None:
213
+ """Fetch and compute run health, or None when no run_health source is reachable.
214
+
215
+ Returns None rather than an empty RunHealth so the renderer can omit the
216
+ governance tiles entirely instead of showing six tiles of zeros, which would
217
+ read as a clean run rather than an unmeasured one.
218
+ """
219
+ active_registry = registry if registry is not None else SignalProviderRegistry()
220
+ stream = err if err is not None else sys.stderr
221
+
222
+ for source_name, source_doc in signal_sources.items():
223
+ spec = _as_mapping(source_doc.get("spec"))
224
+ if spec.get("signal_type") != SIGNAL_TYPE_RUN_HEALTH:
225
+ continue
226
+ provider_key = spec.get("provider")
227
+ if not isinstance(provider_key, str):
228
+ continue
229
+ try:
230
+ factory = active_registry.resolve(provider_key)
231
+ provider = factory(env=provider_env)
232
+ fetch = getattr(provider, "fetch_run_health", None) # noqa: B009
233
+ if fetch is None:
234
+ print(
235
+ f"skipping run health source {source_name}: provider "
236
+ f"'{provider_key}' does not report run health",
237
+ file=stream, # type: ignore[arg-type]
238
+ )
239
+ continue
240
+ records = fetch(_as_mapping(spec.get("query_config")), run_id=run_id)
241
+ batch = project_run_health(run_id=run_id, records=records)
242
+ return compute_run_health(batch, catalogue=catalogue, now_ms=now_ms)
243
+ except MetricsAdapterError as exc:
244
+ print(f"skipping run health source {source_name}: {exc}", file=stream) # type: ignore[arg-type]
245
+ continue
246
+ return None
247
+
248
+
146
249
  def _opencode_catalogue(
147
250
  *, signal_sources: Mapping[str, Document], provider_env: Mapping[str, str]
148
251
  ) -> PriceCatalogue:
@@ -207,6 +310,14 @@ def main(argv: Sequence[str] | None = None) -> int:
207
310
  lambda: _opencode_catalogue(signal_sources=signal_sources, provider_env=provider_env)
208
311
  )
209
312
 
313
+ health = build_run_health(
314
+ run_id=run_id,
315
+ signal_sources=signal_sources,
316
+ provider_env=provider_env,
317
+ catalogue=catalogue,
318
+ now_ms=int(time.time() * 1000),
319
+ )
320
+
210
321
  results = build_results(
211
322
  run_id=run_id,
212
323
  metric_definitions=metric_definitions,
@@ -223,6 +334,7 @@ def main(argv: Sequence[str] | None = None) -> int:
223
334
  results=results,
224
335
  threshold_profile_doc=threshold_profile,
225
336
  units=units,
337
+ health=health,
226
338
  )
227
339
  else:
228
340
  rendered = render_dashboard(
@@ -24,6 +24,8 @@ from katalyst_forge.metrics.dashboard import (
24
24
  evaluate_band,
25
25
  format_value,
26
26
  )
27
+ from katalyst_forge.metrics.run_health import RunHealth
28
+ from katalyst_forge.metrics.tiles import Tile, build_tiles
27
29
 
28
30
  SVG_MIN_WIDTH: Final[int] = 1450 # matches .figframe svg min-width in the house skeleton
29
31
  FONT_MONO: Final[str] = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
@@ -263,6 +265,8 @@ _STYLE: Final[str] = """
263
265
  --ok: #1c6b41; --ok-bg: #e6f4ea;
264
266
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
265
267
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
268
+ --ease: cubic-bezier(0.22, 1, 0.36, 1);
269
+ --shadow-lg: 0 16px 34px -10px rgba(16,16,24,0.16), 0 6px 14px -6px rgba(16,16,24,0.09);
266
270
  }
267
271
  @media (prefers-color-scheme: dark) {
268
272
  :root {
@@ -302,6 +306,42 @@ h2 { font-size: clamp(21px, 2.2vw, 28px); font-weight: 760; letter-spacing: -0.0
302
306
  .pill.warn { background: var(--warn-bg); color: var(--warn); }
303
307
  .pill.ok { background: var(--ok-bg); color: var(--ok); }
304
308
  footer { margin-top: 50px; border-top: 1px solid var(--rule); padding: 20px 0 48px; font-size: 14px; color: var(--ink-3); }
309
+
310
+ /* Tile grid. Card treatment follows the platform-briefs house style: mono
311
+ eyebrow, balanced heading, hover lift, focus-visible ring. Severity colours
312
+ reuse the ok/warn tokens already defined above, and `absent` deliberately gets
313
+ the neutral rule colour rather than green, because "not measured" must not
314
+ read as "measured and fine". */
315
+ .tilegrid{display:grid;grid-template-columns:repeat(3,1fr);grid-auto-rows:1fr;gap:16px;margin:12px 0 8px;}
316
+ @media (max-width:1000px){.tilegrid{grid-template-columns:repeat(2,1fr);}}
317
+ @media (max-width:640px){.tilegrid{grid-template-columns:1fr;}}
318
+ a.tile{display:flex;flex-direction:column;text-decoration:none;background:var(--surface);border:1px solid var(--rule);border-radius:10px;padding:18px 20px;transition:border-color .25s var(--ease),box-shadow .3s var(--ease),transform .3s var(--ease);}
319
+ a.tile:hover{border-color:var(--accent);box-shadow:var(--shadow-lg);transform:translateY(-3px);}
320
+ a.tile:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
321
+ .tile .k{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-bottom:6px;font-weight:500;}
322
+ .tile h3{font-size:17px;font-weight:600;letter-spacing:-0.01em;margin:0 0 10px;color:var(--ink);line-height:1.35;}
323
+ .tile:hover h3{color:var(--accent);}
324
+ .tile .tilefig{display:flex;flex-direction:row;justify-content:space-between;align-items:baseline;gap:10px;margin:5px 0;}
325
+ .tile .tilefig .tilefig-lbl{font-size:12px;color:var(--ink-3);flex:0 1 auto;}
326
+ .tile .tilefig .tilefig-val{font-family:var(--mono);font-size:15px;color:var(--ink);font-weight:600;flex:0 0 auto;white-space:nowrap;}
327
+ .tile .tilefig .tilefig-val.absent{color:var(--ink-3);font-weight:500;font-style:italic;font-size:13px;}
328
+ .tile .meta{font-family:var(--mono);font-size:11px;color:var(--ink-3);margin-top:auto;padding-top:12px;line-height:1.5;}
329
+ .tile.sev-ok{border-left:3px solid var(--ok);}
330
+ .tile.sev-warn{border-left:3px solid var(--warn);}
331
+ .tile.sev-absent{border-left:3px solid var(--rule);}
332
+ .tile.sev-info{border-left:3px solid var(--accent);}
333
+ .detail{margin-top:36px;padding-top:20px;border-top:1px solid var(--rule);scroll-margin-top:20px;}
334
+ .detail h2{font-size:20px;margin:0 0 4px;letter-spacing:-0.015em;}
335
+ .detail .note{font-size:14px;color:var(--ink-2);margin:0 0 14px;}
336
+ .tbl{width:100%;border-collapse:collapse;font-size:13px;margin-top:8px;}
337
+ .tbl th{text-align:left;font-family:var(--mono);font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-3);padding:6px 10px;border-bottom:1px solid var(--rule);font-weight:500;}
338
+ .tbl td{padding:7px 10px;border-bottom:1px solid var(--rule-2);color:var(--ink-2);}
339
+ .tbl td.num{font-family:var(--mono);color:var(--ink);text-align:right;}
340
+ .tbl th.num{text-align:right;}
341
+ .tbl tr:last-child td{border-bottom:none;}
342
+ .basis{font-family:var(--mono);font-size:10px;padding:1px 6px;border-radius:4px;background:var(--surface-2);color:var(--ink-3);}
343
+ .basis.rollup{background:var(--warn-bg);color:var(--warn);}
344
+ .tree{font-family:var(--mono);font-size:12px;line-height:1.9;color:var(--ink-2);}
305
345
  """
306
346
 
307
347
 
@@ -312,6 +352,7 @@ def render_html_report(
312
352
  threshold_profile_doc: Mapping[str, object],
313
353
  units: Mapping[str, str] | None = None,
314
354
  generated: str | None = None,
355
+ health: RunHealth | None = None,
315
356
  ) -> str:
316
357
  """Render a standalone HTML report from already-computed metric results.
317
358
 
@@ -322,6 +363,32 @@ def render_html_report(
322
363
  unit_by_metric = dict(units) if units is not None else {}
323
364
  results_by_name = {result.metric_name: result for result in results}
324
365
  when = generated if generated is not None else ""
366
+ # Composed from the parts that exist, so an absent timestamp does not leave a
367
+ # dangling separator or a sentence with a hole in it.
368
+ eyebrow = " \u00b7 ".join(
369
+ part for part in ("Cost observability", f"run {run_id}", when) if part
370
+ )
371
+ footer_note = (
372
+ f"Generated {when}. Internal working document." if when else "Internal working document."
373
+ )
374
+
375
+ # health is optional so a cost-only render stays byte-identical to before.
376
+ tiles = build_tiles(results=results, health=health) if health is not None else ()
377
+ tile_grid = _tile_grid(tiles) if tiles else ""
378
+ details = (
379
+ "\n".join(
380
+ (
381
+ _session_tree_detail(health),
382
+ _integrity_detail(health),
383
+ _stage_detail(health),
384
+ _degraded_detail(health),
385
+ _unpriced_detail(health),
386
+ _per_model_detail(health),
387
+ )
388
+ )
389
+ if health is not None
390
+ else ""
391
+ )
325
392
 
326
393
  cards = _metric_cards_section(results, threshold_profile_doc, unit_by_metric)
327
394
  metered_figure = _mode_figure(results, BillingMode.METERED, unit_by_metric, 1)
@@ -331,7 +398,7 @@ def render_html_report(
331
398
  body = f"""
332
399
  <div class="shell">
333
400
  <header class="masthead">
334
- <div class="eyebrow">Cost observability &middot; run {_escape(run_id)} &middot; {_escape(when)}</div>
401
+ <div class="eyebrow">{_escape(eyebrow)}</div>
335
402
  <h1>Metric results for this run, by billing mode</h1>
336
403
  <p class="standfirst">
337
404
  Metered spend and subscription-notional allocation are always shown as separate
@@ -339,6 +406,8 @@ def render_html_report(
339
406
  </p>
340
407
  </header>
341
408
 
409
+ {tile_grid}
410
+
342
411
  <section id="summary">
343
412
  <div class="sec-kicker">01 &middot; Summary</div>
344
413
  <h2>Every metric, split by the billing mode that produced it</h2>
@@ -367,8 +436,10 @@ def render_html_report(
367
436
  {routing}
368
437
  </section>
369
438
 
439
+ {details}
440
+
370
441
  <footer>
371
- <div>Generated {_escape(when)}. Internal working document.</div>
442
+ <div>{_escape(footer_note)}</div>
372
443
  </footer>
373
444
  </div>
374
445
  """
@@ -381,3 +452,182 @@ def render_html_report(
381
452
  f"<title>{_escape(title)}</title>\n"
382
453
  f"<style>{_STYLE}</style>\n</head>\n<body>\n{body}\n</body>\n</html>\n"
383
454
  )
455
+
456
+
457
+ # --- Tile grid and detail views (RIC-1624) -----------------------------------
458
+
459
+
460
+ def _headline_row(label: str, text: str) -> str:
461
+ absent = "not measured" in text
462
+ cls = "tilefig-val absent" if absent else "tilefig-val"
463
+ return (
464
+ f'<div class="tilefig"><span class="tilefig-lbl">{_escape(label)}</span>'
465
+ f'<span class="{cls}">{_escape(text)}</span></div>'
466
+ )
467
+
468
+
469
+ def _tile_html(tile: Tile) -> str:
470
+ figures = "".join(_headline_row(h.label, h.text) for h in tile.headlines)
471
+ return (
472
+ f' <a class="tile sev-{_escape(tile.severity)}" href="#{_escape(tile.detail_anchor)}">\n'
473
+ f' <div class="k">{_escape(tile.eyebrow)}</div>\n'
474
+ f" <h3>{_escape(tile.title)}</h3>\n"
475
+ f" {figures}\n"
476
+ f' <div class="meta">{_escape(tile.subline)}</div>\n'
477
+ f" </a>"
478
+ )
479
+
480
+
481
+ def _tile_grid(tiles: Sequence[Tile]) -> str:
482
+ cards = "\n".join(_tile_html(tile) for tile in tiles)
483
+ return f' <div class="tilegrid">\n{cards}\n </div>'
484
+
485
+
486
+ def _detail(anchor: str, title: str, note: str, body: str) -> str:
487
+ return (
488
+ f' <section class="detail" id="{_escape(anchor)}">\n'
489
+ f" <h2>{_escape(title)}</h2>\n"
490
+ f' <p class="note">{_escape(note)}</p>\n'
491
+ f"{body}\n"
492
+ f" </section>"
493
+ )
494
+
495
+
496
+ def _integrity_detail(health: RunHealth) -> str:
497
+ integrity = health.integrity
498
+ rows: list[str] = []
499
+ for stale in integrity.stale_rollups:
500
+ direction = "rollup under-reports" if stale.rollup_under_reports else "rollup over-reports"
501
+ rows.append(
502
+ f"<tr><td>{_escape(stale.session_id)}</td><td>{_escape(stale.agent or '')}</td>"
503
+ f'<td class="num">{stale.drift_usd}</td><td>{_escape(direction)}</td></tr>'
504
+ )
505
+ table = (
506
+ ' <table class="tbl"><thead><tr><th>Session</th><th>Agent</th>'
507
+ '<th class="num">Drift USD</th><th>Direction</th></tr></thead><tbody>'
508
+ + "".join(rows)
509
+ + "</tbody></table>"
510
+ if rows
511
+ else ' <p class="note">No session drifted by more than a cent.</p>'
512
+ )
513
+ note = (
514
+ "Two figures for the same money, by two routes. They are reported "
515
+ "separately and never summed, because adding them would double the run. "
516
+ f"Basis: {integrity.basis}. Messages checked: {integrity.messages_checked}."
517
+ )
518
+ return _detail("detail-run-integrity", "Rollup agreement", note, table)
519
+
520
+
521
+ def _stage_detail(health: RunHealth) -> str:
522
+ rows = "".join(
523
+ f"<tr><td>{_escape(stage)}</td><td>"
524
+ + ("attributed" if observed else "not attributed")
525
+ + "</td></tr>"
526
+ for stage, observed in health.stage_coverage.stages
527
+ )
528
+ note = (
529
+ "A stage is attributed when a session's agent name or title implies it, "
530
+ "which is a heuristic over names. An unattributed stage is not evidence "
531
+ "that the stage did not run."
532
+ )
533
+ return _detail(
534
+ "detail-stage-coverage",
535
+ "Stage coverage",
536
+ note,
537
+ ' <table class="tbl"><thead><tr><th>Stage</th><th>Status</th></tr></thead>'
538
+ f"<tbody>{rows}</tbody></table>",
539
+ )
540
+
541
+
542
+ def _degraded_detail(health: RunHealth) -> str:
543
+ if not health.degraded_sessions:
544
+ body = ' <p class="note">Every walked session returned its message list in full.</p>'
545
+ else:
546
+ rows = "".join(
547
+ f"<tr><td>{_escape(d.session_id)}</td><td>{_escape(d.agent or '')}</td>"
548
+ f"<td>{_escape(d.reason)}</td>"
549
+ f'<td><span class="basis rollup">{_escape(d.figures_from)}</span></td></tr>'
550
+ for d in health.degraded_sessions
551
+ )
552
+ body = (
553
+ ' <table class="tbl"><thead><tr><th>Session</th><th>Agent</th>'
554
+ "<th>Reason</th><th>Figures from</th></tr></thead>"
555
+ f"<tbody>{rows}</tbody></table>"
556
+ )
557
+ note = (
558
+ "A degraded session is one whose message list could not be read, so its "
559
+ "cost falls back to the provider's session rollup. Its figures are less "
560
+ "precise and are labelled accordingly."
561
+ )
562
+ return _detail("detail-degraded-sessions", "Degraded sessions", note, body)
563
+
564
+
565
+ def _unpriced_detail(health: RunHealth) -> str:
566
+ if not health.unpriced_models:
567
+ body = ' <p class="note">Every model used has a catalogue price.</p>'
568
+ else:
569
+ rows = "".join(f"<tr><td>{_escape(m)}</td></tr>" for m in health.unpriced_models)
570
+ body = (
571
+ ' <table class="tbl"><thead><tr><th>Model</th></tr></thead>'
572
+ f"<tbody>{rows}</tbody></table>"
573
+ )
574
+ note = (
575
+ "While any model the run used has no catalogue price, the cost figures are "
576
+ "a floor rather than the full spend. Reported here rather than raised, "
577
+ "because the cost path refuses to guess a price while this view exists to "
578
+ "show the gap."
579
+ )
580
+ return _detail("detail-unpriced-models", "Unpriced models", note, body)
581
+
582
+
583
+ def _per_model_detail(health: RunHealth) -> str:
584
+ rows = "".join(
585
+ f"<tr><td>{_escape(r.model_identifier)}</td>"
586
+ f"<td>{_escape(r.provider_identifier or '')}</td>"
587
+ f'<td class="num">{r.spend_usd.quantize(Decimal("0.01"))}</td>'
588
+ f'<td class="num">{r.message_count}</td>'
589
+ f'<td><span class="basis{" rollup" if r.basis == "rollup" else ""}">'
590
+ f"{_escape(r.basis)}</span></td></tr>"
591
+ for r in health.per_model
592
+ )
593
+ note = (
594
+ "A message-basis row is measured per message. A rollup-basis row attributes "
595
+ "a whole session's rollup to one model, which is a guess when that session "
596
+ "switched model, so the two bases are never mixed into one subtotal."
597
+ )
598
+ return _detail(
599
+ "detail-per-model-spend",
600
+ "Spend per model",
601
+ note,
602
+ ' <table class="tbl"><thead><tr><th>Model</th><th>Provider</th>'
603
+ '<th class="num">Spend USD</th><th class="num">Messages</th>'
604
+ "<th>Basis</th></tr></thead>"
605
+ f"<tbody>{rows}</tbody></table>",
606
+ )
607
+
608
+
609
+ def _session_tree_detail(health: RunHealth) -> str:
610
+ lines: list[str] = []
611
+ for session in health.sessions:
612
+ indent = "&nbsp;" * (session.depth * 4)
613
+ stage = f" [{session.stage}]" if session.stage else ""
614
+ source = "" if session.stage_from_agent or not session.stage else " (from title)"
615
+ label = session.agent or session.title or session.session_id
616
+ lines.append(
617
+ f"{indent}{_escape(label)}{_escape(stage)}{_escape(source)} "
618
+ f'<span class="basis{" rollup" if session.basis == "rollup" else ""}">'
619
+ f"{_escape(session.basis)}</span>"
620
+ )
621
+ body = ' <div class="tree">' + "<br/>".join(lines) + "</div>"
622
+ note = (
623
+ f"{len(health.sessions)} session(s) walked, maximum depth {health.max_depth}. "
624
+ "The run id names the root of the walked tree."
625
+ )
626
+ if health.root_is_not_a_root:
627
+ note = (
628
+ f"{note} The supplied run id has a parent of its own, so ancestor "
629
+ "sessions were not walked and every figure covers an incomplete run scope."
630
+ )
631
+ if health.orphan_ids:
632
+ note = f"{note} {len(health.orphan_ids)} session(s) had a parent outside the walked set."
633
+ return _detail("detail-cost", "Session tree", note, body)