katalyst-forge 5.2.1__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.
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/CHANGELOG.md +18 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/PKG-INFO +1 -1
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/pyproject.toml +1 -1
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/dashboard.py +9 -2
- katalyst_forge-5.3.0/src/katalyst_forge/metrics/forge_stages.py +61 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/generate_dashboard.py +116 -4
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/html_report.py +252 -2
- katalyst_forge-5.3.0/src/katalyst_forge/metrics/measurement.py +90 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/opencode.py +188 -11
- katalyst_forge-5.3.0/src/katalyst_forge/metrics/run_health.py +547 -0
- katalyst_forge-5.3.0/src/katalyst_forge/metrics/tiles.py +248 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_cost_observability_wing.py +72 -2
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_content_contract.py +8 -1
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_dashboard_end_to_end.py +65 -0
- katalyst_forge-5.3.0/tests/test_metrics_run_health.py +400 -0
- katalyst_forge-5.3.0/tests/test_writer_kind_mapping.py +140 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/.gitignore +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/LICENSE +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/CHANGELOG.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/agents/agent.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/agents/config.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/agents/manifest.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/agents/skill.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/environment.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/hooks/hook-config.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/katalyst-lock.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/nodes/common.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/nodes/design.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/nodes/organizational.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/nodes/portfolio.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/nodes/technical.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/schemas/taxonomy-node.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/bundle.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/batch.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/billing.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/contracts.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/core.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/errors.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/money.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/protocols.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/forge_model_router.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/providers/llm_gateway.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/redaction.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/registry.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/routing_policy.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/py.typed +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/relationships.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/_base.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/layer.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/project.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/stack.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/system.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/team.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/creation/team_member.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/.dockerignore +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/.env.example +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/base/Dockerfile +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/docker-compose.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/Dockerfile +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/docker-compose.override.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/environment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/app-docker/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/main.tf +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/outputs.tf +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/variables.tf +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/environment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/terragrunt.hcl +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/context.hcl +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/dev.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/prod.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/staging.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/version.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/app/argocd-application.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/namespace.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/argocd-application.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/patch-argocd-application.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/deployment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/namespace.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/service.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/patch-deployment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/namespace.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/_practice_refs.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/agentic.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/delivery.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/design.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/metrics.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/org.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/pipeline.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/practices.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/wings/system.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/templates/CHANGELOG.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/conftest.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/fake_providers_opencode.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/fake_providers_router.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/expected-modified.txt +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/expected-new.txt +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/modified-inplace.patch +0 -0
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/routed-dispatch-preserves-task-parity.yaml +0 -0
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/zero-committed-vendor-model-identity.yaml +0 -0
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → 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
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/forge-reconcile/reconcile.test.mjs +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_agent_run.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_delivery_doc_consistency.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_delivery_traceability.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_entry_point_discovery.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_forge.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_batch_hygiene.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_billing_isolation.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_contracts.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_dashboard.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_html_report.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_lazy_providers.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_money_and_redaction.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_providers.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_resolve_compute.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_routing_policy.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_metrics_wing.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_practice_assignment.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_relationships.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_storage_contract.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_validate_templates_gates.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_wing_schemas.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_wings.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/tests/test_writer_bundle_schema.py +0 -0
|
@@ -16,6 +16,24 @@ 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
|
+
|
|
19
37
|
## [5.2.1] -- 2026-08-08
|
|
20
38
|
|
|
21
39
|
### Fixed
|
|
@@ -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
|
-
|
|
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
|
{katalyst_forge-5.2.1 → katalyst_forge-5.3.0}/src/katalyst_forge/metrics/generate_dashboard.py
RENAMED
|
@@ -14,7 +14,8 @@ from __future__ import annotations
|
|
|
14
14
|
import argparse
|
|
15
15
|
import os
|
|
16
16
|
import sys
|
|
17
|
-
|
|
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
|
|
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
|
|
85
|
-
f"
|
|
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">
|
|
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 · 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>
|
|
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 = " " * (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)
|