katalyst-forge 5.2.1__tar.gz → 5.4.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.4.0}/CHANGELOG.md +45 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/PKG-INFO +1 -1
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/pyproject.toml +1 -1
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/core.py +56 -5
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/dashboard.py +129 -8
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/errors.py +20 -0
- katalyst_forge-5.4.0/src/katalyst_forge/metrics/forge_stages.py +61 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/generate_dashboard.py +181 -16
- katalyst_forge-5.4.0/src/katalyst_forge/metrics/html_report.py +799 -0
- katalyst_forge-5.4.0/src/katalyst_forge/metrics/measurement.py +90 -0
- katalyst_forge-5.4.0/src/katalyst_forge/metrics/providers/opencode.py +480 -0
- katalyst_forge-5.4.0/src/katalyst_forge/metrics/run_health.py +561 -0
- katalyst_forge-5.4.0/src/katalyst_forge/metrics/tiles.py +260 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_cost_observability_wing.py +72 -2
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_billing_isolation.py +47 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_content_contract.py +8 -1
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_dashboard.py +126 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_dashboard_end_to_end.py +65 -0
- katalyst_forge-5.4.0/tests/test_metrics_html_report.py +428 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_providers.py +245 -4
- katalyst_forge-5.4.0/tests/test_metrics_run_health.py +435 -0
- katalyst_forge-5.4.0/tests/test_writer_kind_mapping.py +140 -0
- katalyst_forge-5.2.1/src/katalyst_forge/metrics/html_report.py +0 -383
- katalyst_forge-5.2.1/src/katalyst_forge/metrics/providers/opencode.py +0 -269
- katalyst_forge-5.2.1/tests/test_metrics_html_report.py +0 -182
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/.gitignore +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/LICENSE +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/CHANGELOG.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/agents/agent.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/agents/config.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/agents/manifest.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/agents/skill.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/environment.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/hooks/hook-config.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/katalyst-lock.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/nodes/common.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/nodes/design.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/nodes/organizational.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/nodes/portfolio.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/nodes/technical.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/schemas/taxonomy-node.schema.json +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/bundle.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/batch.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/billing.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/contracts.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/money.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/protocols.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/providers/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/providers/forge_model_router.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/providers/llm_gateway.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/redaction.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/registry.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/metrics/routing_policy.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/py.typed +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/relationships.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/creation/_base.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/creation/layer.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/creation/project.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/creation/stack.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/creation/system.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/creation/team.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/creation/team_member.yaml.j2 +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/.dockerignore +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/.env.example +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/base/Dockerfile +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/docker-compose.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/Dockerfile +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/docker-compose.override.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/environment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/app-docker/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/main.tf +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/outputs.tf +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/variables.tf +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/environment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/terragrunt.hcl +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/context.hcl +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/dev.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/prod.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/staging.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/version.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/app/argocd-application.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/namespace.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/argocd-application.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/patch-argocd-application.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-argocd/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/README.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/deployment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/namespace.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/service.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/patch-deployment.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/namespace.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/kustomization.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/layer.yaml +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/__init__.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/_practice_refs.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/agentic.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/delivery.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/design.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/metrics.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/org.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/pipeline.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/practices.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/src/katalyst_forge/wings/system.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/templates/CHANGELOG.md +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/conftest.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/fake_providers_opencode.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/fake_providers_router.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/forge-reconcile/fixtures/incident-ric1580/expected-modified.txt +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/forge-reconcile/fixtures/incident-ric1580/expected-new.txt +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/forge-reconcile/fixtures/incident-ric1580/modified-inplace.patch +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.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.4.0}/tests/forge-reconcile/reconcile.test.mjs +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_agent_run.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_delivery_doc_consistency.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_delivery_traceability.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_entry_point_discovery.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_forge.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_batch_hygiene.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_contracts.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_lazy_providers.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_money_and_redaction.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_resolve_compute.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_routing_policy.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_metrics_wing.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_practice_assignment.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_relationships.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_storage_contract.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_validate_templates_gates.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_wing_schemas.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_wings.py +0 -0
- {katalyst_forge-5.2.1 → katalyst_forge-5.4.0}/tests/test_writer_bundle_schema.py +0 -0
|
@@ -16,6 +16,51 @@ PyPI package version. A breaking node-type/model change bumps `schema_version`.
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## [5.4.0] -- 2026-08-09
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **Per-dispatch model tier routing** (RIC-1580). A Forge dispatch can name the model tier it wants, and the orchestrator honours it through SDK child sessions rather than running every stage on the orchestrator model. Ships per-stage model and token telemetry alongside it, plus a containment and portability gate (`scripts/dispatch/check-dispatch.ts`) that reds on committed vendor model ids so a routing target cannot be hardcoded into the taxonomy.
|
|
24
|
+
- **`quantity_kind` on every cost metric definition** (RIC-1645). The audited catalogue now declares what each figure IS (`spend`, `saving`, `counterfactual`, `ratio`), not only which catalogue priced it. Read by both renderers so presentation cannot disagree with the document the figure was computed from.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **The run report no longer presents savings as money that was billed** (RIC-1645). Every USD metric was captioned `Metered spend` and charted under "Metered spend per metric, in dollars actually billed", including three that the report itself described as savings: `cache-savings` ("Net spend avoided"), `routing-headroom` ("not yet captured this run") and `routing-savings-realized` ("Saving actually captured"). One figure put $14.17 of bars on an axis labelled dollars actually billed for a run that billed $1.66. `BillingMode` answers which catalogue priced a figure, not whether it was billed; for `agent-run-cost` and `cost-per-stage` the two coincide, which is why rendering the basis tag as the label read correctly and the conflation survived the whole RIC-1615..1629 series. Billed spend now has an axis of its own, savings and modelled figures have theirs, and neither is scaled against or totalled with the other.
|
|
29
|
+
- **The run report survives an unreadable message list instead of writing nothing** (RIC-1634). The cost path read each session's messages through an unguarded fetch while run health read the same endpoint through a degrading reader, so a persistent 500 on one session killed the whole report at the same moment run health was recording that session as degraded. Both halves now read through one path: the session degrades to its provider rollup, the run still reports, and the degraded-session panel names it. A transport failure is named (`ProviderTransportError` carries the endpoint) rather than surfacing as a traceback ending in a bare status code, and deliberately is not a `MetricsAdapterError`, so a host-level failure cannot be downgraded into a quietly missing source.
|
|
30
|
+
- **A rate no longer borrows an endpoint it never reached** (RIC-1635). A 99.998% cache hit rate rendered as `100.00%`, which reads as every token served from cache. A rate strictly inside its range keeps a comparator, and the denominator it is computed over is stated in the metric definition the report renders.
|
|
31
|
+
- **An absent measurement states itself** (RIC-1636). An unmeasured metric rendered as a numbered card with an empty body, which reads as a render bug, while `$0.00` would have read as measured and zero. It now carries the same explicit "not measured" marker the tiles use, and a billing-mode caption only ever appears under a money value, never under a ratio.
|
|
32
|
+
- **The report self-identifies** (RIC-1637). It carries a UTC generation timestamp, the installed generator version, and the pricing catalogue the figures were priced against, the last only once a price was actually looked up.
|
|
33
|
+
- **Rollup spend from a session that named no model is stated rather than left to vanish** (RIC-1638). It still gets no per-model row, because a fabricated model beside measured ones is worse than a gap, but the total is carried and both the tile and the detail note say so out loud.
|
|
34
|
+
- **Money-mixing guards hardened** (RIC-1639). The billing-mode tag used to fall away the moment a value left `MetricResult`, so a one-line sum over `items()` produced exactly the cross-mode total the type exists to prevent. Values now leave tagged and refuse addition, `combine()` refuses to merge a result with itself, and a threshold band judges one billing mode instead of any figure that happens to share its unit.
|
|
35
|
+
- **Message truncation is detected rather than silently shortening a run** (RIC-1631), and the RIC-1624 feature files are committed.
|
|
36
|
+
- **`opencode.json` points at agents that exist** (RIC-1643). Every agent prompt referenced `./.opencode/agents/<name>.md`, but nothing is tracked under `.opencode/` and all 40 agents live in `agents/`. OpenCode validates `{file:...}` references when it loads a config, so it refused to start from the repo root and the repository was unusable as an OpenCode working directory. `scripts/validate.sh` had been checking that each declared agent KEY had a matching file, never the prompt reference OpenCode actually resolves, so it stayed green throughout; it now resolves every `{file:...}` reference.
|
|
37
|
+
- **`kata-tax-validator-fixer` uses real kata commands** (RIC-1567). It invoked `kata tax json` and a `--path` flag, neither of which exists.
|
|
38
|
+
- **The practice resolver probe fails loud** (#41). A failed probe returned the same empty result as a genuine zero, so a gate whose probe never ran reported a clean pass.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Minor rather than patch: RIC-1580 is additive and RIC-1645 adds a metric-definition field with a backward-compatible default. No node-type or model contract broke, so the bundle `schema_version` is unchanged.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## [5.3.0] -- 2026-08-08
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **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`.
|
|
51
|
+
- **`optional_fields` awareness for provider-synthesized values**, already shipped in 5.2.0, is now exercised by the run-health source.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **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.
|
|
56
|
+
- **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.
|
|
57
|
+
- **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.
|
|
58
|
+
- **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.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **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.
|
|
63
|
+
|
|
19
64
|
## [5.2.1] -- 2026-08-08
|
|
20
65
|
|
|
21
66
|
### Fixed
|
|
@@ -31,6 +31,44 @@ from katalyst_forge.metrics.protocols import (
|
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
class ModeTaggedMoney(Decimal):
|
|
35
|
+
"""A figure that remembers the billing mode it came from, and refuses addition.
|
|
36
|
+
|
|
37
|
+
MetricResult refuses to total across modes, but the tag used to disappear the
|
|
38
|
+
moment a value left it, so a plain `sum()` over `items()` produced exactly the
|
|
39
|
+
cross-mode total the class exists to prevent. Tagging the leaf keeps the
|
|
40
|
+
guarantee where the numbers actually travel.
|
|
41
|
+
|
|
42
|
+
Only addition is blocked. Comparison, division, quantizing and formatting all
|
|
43
|
+
still work, because addition is the single operation that silently merges two
|
|
44
|
+
quantities into a third one that describes neither.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("billing_mode",)
|
|
48
|
+
|
|
49
|
+
def __new__(cls, value: Decimal, mode: BillingMode) -> ModeTaggedMoney:
|
|
50
|
+
instance = super().__new__(cls, value)
|
|
51
|
+
instance.billing_mode = mode
|
|
52
|
+
return instance
|
|
53
|
+
|
|
54
|
+
def _refuse(self, other: object) -> ModeTaggedMoney:
|
|
55
|
+
other_mode = getattr(other, "billing_mode", None)
|
|
56
|
+
detail = (
|
|
57
|
+
f"{self.billing_mode.value} + {other_mode.value}"
|
|
58
|
+
if isinstance(other_mode, BillingMode)
|
|
59
|
+
else f"a {self.billing_mode.value} figure with {type(other).__name__}"
|
|
60
|
+
)
|
|
61
|
+
raise CrossBillingModeError(
|
|
62
|
+
f"cannot add {detail}; read one mode at a time, or combine() within a mode"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def __add__(self, other: object) -> ModeTaggedMoney:
|
|
66
|
+
return self._refuse(other)
|
|
67
|
+
|
|
68
|
+
def __radd__(self, other: object) -> ModeTaggedMoney:
|
|
69
|
+
return self._refuse(other)
|
|
70
|
+
|
|
71
|
+
|
|
34
72
|
class MetricResult:
|
|
35
73
|
"""An immutable, mode-partitioned metric value. No cross-mode total exists."""
|
|
36
74
|
|
|
@@ -38,16 +76,21 @@ class MetricResult:
|
|
|
38
76
|
|
|
39
77
|
def __init__(self, metric_name: str, by_mode: Mapping[BillingMode, Decimal]) -> None:
|
|
40
78
|
self.metric_name = metric_name
|
|
41
|
-
|
|
79
|
+
# Stored untagged so internal within-mode arithmetic (combine) stays
|
|
80
|
+
# ordinary; the tag is applied on the way out, where values escape.
|
|
81
|
+
self._by_mode: Mapping[BillingMode, Decimal] = MappingProxyType(
|
|
82
|
+
{mode: Decimal(value) for mode, value in by_mode.items()}
|
|
83
|
+
)
|
|
42
84
|
|
|
43
85
|
def modes(self) -> tuple[BillingMode, ...]:
|
|
44
86
|
return tuple(self._by_mode.keys())
|
|
45
87
|
|
|
46
|
-
def for_mode(self, mode: BillingMode) ->
|
|
47
|
-
|
|
88
|
+
def for_mode(self, mode: BillingMode) -> ModeTaggedMoney | None:
|
|
89
|
+
value = self._by_mode.get(mode)
|
|
90
|
+
return None if value is None else ModeTaggedMoney(value, mode)
|
|
48
91
|
|
|
49
|
-
def items(self) -> tuple[tuple[BillingMode,
|
|
50
|
-
return tuple(self._by_mode.items())
|
|
92
|
+
def items(self) -> tuple[tuple[BillingMode, ModeTaggedMoney], ...]:
|
|
93
|
+
return tuple((mode, ModeTaggedMoney(value, mode)) for mode, value in self._by_mode.items())
|
|
51
94
|
|
|
52
95
|
def combine(self, other: MetricResult) -> MetricResult:
|
|
53
96
|
"""Merge two results of the same metric, within each billing mode only."""
|
|
@@ -55,6 +98,14 @@ class MetricResult:
|
|
|
55
98
|
raise CrossBillingModeError(
|
|
56
99
|
f"cannot combine different metrics {self.metric_name!r} and {other.metric_name!r}"
|
|
57
100
|
)
|
|
101
|
+
if self is other:
|
|
102
|
+
# Merging a result with itself doubles the run inside each mode, which
|
|
103
|
+
# is the same double count the cross-mode guard blocks, just wearing a
|
|
104
|
+
# different hat. Nothing legitimate combines a figure with itself.
|
|
105
|
+
raise CrossBillingModeError(
|
|
106
|
+
f"cannot combine metric {self.metric_name!r} with itself; "
|
|
107
|
+
"that would count the same spend twice"
|
|
108
|
+
)
|
|
58
109
|
merged: dict[BillingMode, Decimal] = dict(self._by_mode)
|
|
59
110
|
for mode, value in other._by_mode.items(): # noqa: SLF001 - same class
|
|
60
111
|
merged[mode] = merged.get(mode, ZERO_USD) + value
|
|
@@ -16,10 +16,12 @@ from collections.abc import Mapping, Sequence
|
|
|
16
16
|
from decimal import ROUND_HALF_UP, Decimal
|
|
17
17
|
from typing import Final, cast
|
|
18
18
|
|
|
19
|
-
from katalyst_forge.metrics.billing import BillingMode
|
|
19
|
+
from katalyst_forge.metrics.billing import BillingMode, parse_billing_mode
|
|
20
20
|
from katalyst_forge.metrics.core import MetricResult
|
|
21
21
|
|
|
22
22
|
CENTS: Final[Decimal] = Decimal("0.01")
|
|
23
|
+
ZERO_PERCENTAGE: Final[Decimal] = Decimal("0")
|
|
24
|
+
FULL_PERCENTAGE: Final[Decimal] = Decimal("100")
|
|
23
25
|
|
|
24
26
|
METERED_LABEL: Final[str] = "Metered spend"
|
|
25
27
|
NOTIONAL_LABEL: Final[str] = "Subscription notional (not money spent)"
|
|
@@ -29,16 +31,77 @@ _MODE_LABELS: Final[Mapping[BillingMode, str]] = {
|
|
|
29
31
|
BillingMode.SUBSCRIPTION_NOTIONAL: NOTIONAL_LABEL,
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
# What a figure IS, as distinct from the price basis it was computed on
|
|
35
|
+
# (RIC-1645). BillingMode answers "which catalogue priced this"; it does not
|
|
36
|
+
# answer "was this money billed". For a spend metric the two coincide, so the
|
|
37
|
+
# basis label reads correctly. For a saving it does not: cache-savings is spend
|
|
38
|
+
# avoided and routing-headroom was never incurred at all, yet both were captioned
|
|
39
|
+
# "Metered spend" and charted under "dollars actually billed".
|
|
40
|
+
QUANTITY_SPEND: Final[str] = "spend"
|
|
41
|
+
QUANTITY_SAVING: Final[str] = "saving"
|
|
42
|
+
QUANTITY_COUNTERFACTUAL: Final[str] = "counterfactual"
|
|
43
|
+
QUANTITY_RATIO: Final[str] = "ratio"
|
|
44
|
+
|
|
45
|
+
# An undeclared metric keeps the old caption, so a third-party bundle does not
|
|
46
|
+
# silently change meaning on upgrade. The shipped catalogue cannot rely on that:
|
|
47
|
+
# a test asserts every metric it ships declares a kind.
|
|
48
|
+
DEFAULT_QUANTITY_KIND: Final[str] = QUANTITY_SPEND
|
|
49
|
+
|
|
50
|
+
QUANTITIES_NOT_BILLED: Final[frozenset[str]] = frozenset({QUANTITY_SAVING, QUANTITY_COUNTERFACTUAL})
|
|
51
|
+
|
|
52
|
+
_QUANTITY_LABELS: Final[Mapping[tuple[str, BillingMode], str]] = {
|
|
53
|
+
(QUANTITY_SPEND, BillingMode.METERED): METERED_LABEL,
|
|
54
|
+
(QUANTITY_SPEND, BillingMode.SUBSCRIPTION_NOTIONAL): NOTIONAL_LABEL,
|
|
55
|
+
(QUANTITY_SAVING, BillingMode.METERED): "Metered basis, spend avoided (not billed)",
|
|
56
|
+
(QUANTITY_SAVING, BillingMode.SUBSCRIPTION_NOTIONAL): (
|
|
57
|
+
"Notional basis, allocation avoided (not money spent)"
|
|
58
|
+
),
|
|
59
|
+
(QUANTITY_COUNTERFACTUAL, BillingMode.METERED): (
|
|
60
|
+
"Metered basis, modelled and not captured (not billed)"
|
|
61
|
+
),
|
|
62
|
+
(QUANTITY_COUNTERFACTUAL, BillingMode.SUBSCRIPTION_NOTIONAL): (
|
|
63
|
+
"Notional basis, modelled and not captured (not money spent)"
|
|
64
|
+
),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_billed(quantity_kind: str | None) -> bool:
|
|
69
|
+
"""Whether a figure of this kind is money that actually left the account."""
|
|
70
|
+
return (quantity_kind or DEFAULT_QUANTITY_KIND) not in QUANTITIES_NOT_BILLED
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mode_label(mode: BillingMode, quantity_kind: str | None = None) -> str:
|
|
74
|
+
"""Label a figure by what it is, not only by the catalogue that priced it.
|
|
75
|
+
|
|
76
|
+
Falls back to the bare basis label for any mode/kind pair not enumerated, so
|
|
77
|
+
a new billing mode degrades to naming its basis rather than asserting spend.
|
|
78
|
+
"""
|
|
79
|
+
kind = quantity_kind or DEFAULT_QUANTITY_KIND
|
|
80
|
+
fallback = _MODE_LABELS.get(mode, str(mode))
|
|
81
|
+
return _QUANTITY_LABELS.get((kind, mode), fallback)
|
|
82
|
+
|
|
83
|
+
|
|
32
84
|
_OPERATORS: Final[Mapping[str, str]] = {"<": "<", "<=": "<=", ">": ">", ">=": ">="}
|
|
33
85
|
|
|
86
|
+
# A band with no declared mode is a spend threshold, because that is the only
|
|
87
|
+
# quantity a dollar ceiling can sensibly police.
|
|
88
|
+
_DEFAULT_BAND_MODE: Final[BillingMode] = BillingMode.METERED
|
|
89
|
+
|
|
34
90
|
|
|
35
91
|
UNIT_USD: Final[str] = "usd"
|
|
36
92
|
UNIT_PERCENTAGE: Final[str] = "percentage"
|
|
37
93
|
|
|
38
94
|
|
|
39
95
|
def to_cents(value: Decimal) -> Decimal:
|
|
40
|
-
"""Round to the cent, once, at the point of display.
|
|
41
|
-
|
|
96
|
+
"""Round to the cent, once, at the point of display.
|
|
97
|
+
|
|
98
|
+
A value that rounds away to nothing is returned unsigned. Without this, a
|
|
99
|
+
drift of -2E-16 formats as "-0.00" and a dashboard shows "$-0.00", which reads
|
|
100
|
+
as a broken report rather than as two figures agreeing. The sign only survives
|
|
101
|
+
when there is a magnitude for it to describe.
|
|
102
|
+
"""
|
|
103
|
+
cents = value.quantize(CENTS, rounding=ROUND_HALF_UP)
|
|
104
|
+
return abs(cents) if cents == 0 else cents
|
|
42
105
|
|
|
43
106
|
|
|
44
107
|
def format_usd(value: Decimal) -> str:
|
|
@@ -46,7 +109,20 @@ def format_usd(value: Decimal) -> str:
|
|
|
46
109
|
|
|
47
110
|
|
|
48
111
|
def format_percentage(value: Decimal) -> str:
|
|
49
|
-
|
|
112
|
+
"""Render a rate without letting rounding claim an endpoint it never reached.
|
|
113
|
+
|
|
114
|
+
A 99.998% cache hit rate quantizes to 100.00, which reads as "every billable
|
|
115
|
+
input token came from cache". The gap between nearly all and all is the whole
|
|
116
|
+
reason the rate is reported, so a value strictly inside the range keeps a
|
|
117
|
+
comparator instead of borrowing the endpoint. An exact 0 or 100 still prints
|
|
118
|
+
as itself.
|
|
119
|
+
"""
|
|
120
|
+
rounded = to_cents(value)
|
|
121
|
+
if rounded >= FULL_PERCENTAGE > value:
|
|
122
|
+
return f">{FULL_PERCENTAGE - CENTS}%"
|
|
123
|
+
if rounded <= ZERO_PERCENTAGE < value:
|
|
124
|
+
return f"<{CENTS}%"
|
|
125
|
+
return f"{rounded}%"
|
|
50
126
|
|
|
51
127
|
|
|
52
128
|
def format_value(value: Decimal, unit: str | None) -> str:
|
|
@@ -111,11 +187,27 @@ def _as_sequence(value: object) -> Sequence[object]:
|
|
|
111
187
|
return ()
|
|
112
188
|
|
|
113
189
|
|
|
190
|
+
def _entry_judges_mode(entry: Mapping[str, object], billing_mode: BillingMode | None) -> bool:
|
|
191
|
+
"""A band judges one billing mode, and by default that mode is real money.
|
|
192
|
+
|
|
193
|
+
A threshold written for dollars actually billed must not stamp a verdict on a
|
|
194
|
+
subscription-notional allocation, which is a different quantity that happens
|
|
195
|
+
to share a unit. A caller that does not say which mode it is judging is not
|
|
196
|
+
filtered, so text callers keep their existing behaviour.
|
|
197
|
+
"""
|
|
198
|
+
if billing_mode is None:
|
|
199
|
+
return True
|
|
200
|
+
declared = entry.get("billing_mode")
|
|
201
|
+
expected = parse_billing_mode(declared) if isinstance(declared, str) else _DEFAULT_BAND_MODE
|
|
202
|
+
return expected is billing_mode
|
|
203
|
+
|
|
204
|
+
|
|
114
205
|
def evaluate_band(
|
|
115
206
|
*,
|
|
116
207
|
metric_name: str,
|
|
117
208
|
value: Decimal,
|
|
118
209
|
threshold_profile_doc: Mapping[str, object],
|
|
210
|
+
billing_mode: BillingMode | None = None,
|
|
119
211
|
breach_bands: frozenset[str] = frozenset({"amber", "red"}),
|
|
120
212
|
) -> BandVerdict:
|
|
121
213
|
"""Find the band a value falls in and flag a breach (AC5).
|
|
@@ -130,6 +222,8 @@ def evaluate_band(
|
|
|
130
222
|
entry_mapping = _as_mapping(entry)
|
|
131
223
|
if entry_mapping.get("metric") != metric_name:
|
|
132
224
|
continue
|
|
225
|
+
if not _entry_judges_mode(entry_mapping, billing_mode):
|
|
226
|
+
continue
|
|
133
227
|
for band in _as_sequence(entry_mapping.get("bands")):
|
|
134
228
|
band_mapping = _as_mapping(band)
|
|
135
229
|
operator = band_mapping.get("operator")
|
|
@@ -153,7 +247,11 @@ def evaluate_band(
|
|
|
153
247
|
)
|
|
154
248
|
|
|
155
249
|
|
|
156
|
-
def render_savings_panel(
|
|
250
|
+
def render_savings_panel(
|
|
251
|
+
result: MetricResult,
|
|
252
|
+
unit: str | None = UNIT_USD,
|
|
253
|
+
quantity_kind: str | None = None,
|
|
254
|
+
) -> tuple[str, ...]:
|
|
157
255
|
"""Render one line per billing mode, never a combined figure (AC10).
|
|
158
256
|
|
|
159
257
|
Returns a line per mode rather than a total. There is deliberately no
|
|
@@ -162,7 +260,7 @@ def render_savings_panel(result: MetricResult, unit: str | None = UNIT_USD) -> t
|
|
|
162
260
|
"""
|
|
163
261
|
lines: list[str] = []
|
|
164
262
|
for mode, value in result.items():
|
|
165
|
-
label =
|
|
263
|
+
label = mode_label(mode, quantity_kind)
|
|
166
264
|
lines.append(f"{label}: {format_value(value, unit)}")
|
|
167
265
|
return tuple(lines)
|
|
168
266
|
|
|
@@ -173,24 +271,29 @@ def render_dashboard(
|
|
|
173
271
|
results: Sequence[MetricResult],
|
|
174
272
|
threshold_profile_doc: Mapping[str, object],
|
|
175
273
|
units: Mapping[str, str] | None = None,
|
|
274
|
+
quantity_kinds: Mapping[str, str] | None = None,
|
|
176
275
|
) -> str:
|
|
177
276
|
"""Render the whole dashboard as text, per metric and per billing mode.
|
|
178
277
|
|
|
179
278
|
`units` maps a metric name to its declared unit so a ratio is never
|
|
180
|
-
rendered with a dollar sign.
|
|
279
|
+
rendered with a dollar sign. `quantity_kinds` maps a metric name to what its
|
|
280
|
+
figure is, so a saving is not captioned as spend.
|
|
181
281
|
"""
|
|
182
282
|
unit_by_metric = units or {}
|
|
283
|
+
kind_by_metric = quantity_kinds or {}
|
|
183
284
|
lines: list[str] = [f"Run: {run_id}", ""]
|
|
184
285
|
|
|
185
286
|
for result in results:
|
|
186
287
|
unit = unit_by_metric.get(result.metric_name, UNIT_USD)
|
|
288
|
+
kind = kind_by_metric.get(result.metric_name)
|
|
187
289
|
lines.append(result.metric_name)
|
|
188
290
|
for mode, value in result.items():
|
|
189
|
-
label =
|
|
291
|
+
label = mode_label(mode, kind)
|
|
190
292
|
verdict = evaluate_band(
|
|
191
293
|
metric_name=result.metric_name,
|
|
192
294
|
value=value,
|
|
193
295
|
threshold_profile_doc=threshold_profile_doc,
|
|
296
|
+
billing_mode=mode,
|
|
194
297
|
)
|
|
195
298
|
suffix = ""
|
|
196
299
|
if verdict.band_name is not None:
|
|
@@ -212,3 +315,21 @@ def units_from_metric_definitions(
|
|
|
212
315
|
if isinstance(unit, str):
|
|
213
316
|
units[name] = unit
|
|
214
317
|
return units
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def quantity_kinds_from_metric_definitions(
|
|
321
|
+
metric_definitions: Mapping[str, Mapping[str, object]],
|
|
322
|
+
) -> dict[str, str]:
|
|
323
|
+
"""Read each metric's declared quantity kind from the audited catalogue.
|
|
324
|
+
|
|
325
|
+
Kept in the definitions rather than the renderer for the same reason units
|
|
326
|
+
are: whether a figure is spend or a saving is a property of the metric, and a
|
|
327
|
+
renderer that decided it locally could disagree with the document the figure
|
|
328
|
+
was computed from.
|
|
329
|
+
"""
|
|
330
|
+
kinds: dict[str, str] = {}
|
|
331
|
+
for name, doc in metric_definitions.items():
|
|
332
|
+
kind = _as_mapping(doc.get("spec")).get("quantity_kind")
|
|
333
|
+
if isinstance(kind, str):
|
|
334
|
+
kinds[name] = kind
|
|
335
|
+
return kinds
|
|
@@ -120,3 +120,23 @@ class ProviderPayloadError(MetricsAdapterError):
|
|
|
120
120
|
|
|
121
121
|
def __init__(self, *, field: str, value: object) -> None:
|
|
122
122
|
super().__init__(f"unparseable provider payload for {field!r}: {redact(value)}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ProviderTransportError(Exception):
|
|
126
|
+
"""Raised when a provider request fails before any payload exists.
|
|
127
|
+
|
|
128
|
+
Deliberately NOT a MetricsAdapterError. The per-source and per-metric
|
|
129
|
+
handlers skip a MetricsAdapterError and carry on, which is right for a
|
|
130
|
+
source that cannot be computed but wrong for a host that cannot be reached:
|
|
131
|
+
that leaves a hole of unknown size, so it has to stay a hard failure and
|
|
132
|
+
reach the entry point.
|
|
133
|
+
|
|
134
|
+
Carries the endpoint rather than a bare status line, because an operator
|
|
135
|
+
reading the failure needs to know which request died. Only the URL and the
|
|
136
|
+
exception type are used; no response body reaches the message.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, *, url: str, detail: str) -> None:
|
|
140
|
+
self.url = url
|
|
141
|
+
self.detail = detail
|
|
142
|
+
super().__init__(f"request to {url} failed: {detail}")
|
|
@@ -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
|