katalyst-forge 5.1.0__tar.gz → 5.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/.gitignore +8 -0
  2. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/CHANGELOG.md +34 -0
  3. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/PKG-INFO +2 -1
  4. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/pyproject.toml +9 -1
  5. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/nodes/organizational.schema.json +4 -0
  6. katalyst_forge-5.2.1/src/katalyst_forge/metrics/__init__.py +57 -0
  7. katalyst_forge-5.2.1/src/katalyst_forge/metrics/batch.py +174 -0
  8. katalyst_forge-5.2.1/src/katalyst_forge/metrics/billing.py +35 -0
  9. katalyst_forge-5.2.1/src/katalyst_forge/metrics/contracts.py +128 -0
  10. katalyst_forge-5.2.1/src/katalyst_forge/metrics/core.py +385 -0
  11. katalyst_forge-5.2.1/src/katalyst_forge/metrics/dashboard.py +214 -0
  12. katalyst_forge-5.2.1/src/katalyst_forge/metrics/errors.py +122 -0
  13. katalyst_forge-5.2.1/src/katalyst_forge/metrics/generate_dashboard.py +245 -0
  14. katalyst_forge-5.2.1/src/katalyst_forge/metrics/html_report.py +383 -0
  15. katalyst_forge-5.2.1/src/katalyst_forge/metrics/money.py +43 -0
  16. katalyst_forge-5.2.1/src/katalyst_forge/metrics/protocols.py +83 -0
  17. katalyst_forge-5.2.1/src/katalyst_forge/metrics/providers/__init__.py +9 -0
  18. katalyst_forge-5.2.1/src/katalyst_forge/metrics/providers/forge_model_router.py +100 -0
  19. katalyst_forge-5.2.1/src/katalyst_forge/metrics/providers/llm_gateway.py +20 -0
  20. katalyst_forge-5.2.1/src/katalyst_forge/metrics/providers/opencode.py +269 -0
  21. katalyst_forge-5.2.1/src/katalyst_forge/metrics/redaction.py +43 -0
  22. katalyst_forge-5.2.1/src/katalyst_forge/metrics/registry.py +57 -0
  23. katalyst_forge-5.2.1/src/katalyst_forge/metrics/routing_policy.py +97 -0
  24. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/relationships.py +13 -0
  25. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/metrics.py +2 -2
  26. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/practices.py +1 -1
  27. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/templates/CHANGELOG.md +147 -0
  28. katalyst_forge-5.2.1/tests/fake_providers_opencode.py +38 -0
  29. katalyst_forge-5.2.1/tests/fake_providers_router.py +31 -0
  30. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/expected-modified.txt +2 -0
  31. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/expected-new.txt +16 -0
  32. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/modified-inplace.patch +124 -0
  33. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/delivery/work_items/ric-1580-forge-model-routing.yaml +20 -0
  34. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-011-per-dispatch-model-tier-routing.md +110 -0
  35. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-012-dispatch-parity-and-containment.md +109 -0
  36. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/capabilities/CAP-013-per-stage-model-token-telemetry.md +99 -0
  37. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/dispatched-stage-executes-on-requested-tier.yaml +48 -0
  38. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/routed-dispatch-preserves-task-parity.yaml +46 -0
  39. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/tier-resolution-fails-before-any-child-session.yaml +48 -0
  40. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/nfrs/zero-committed-vendor-model-identity.yaml +45 -0
  41. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-024-operator-selects-model-tier-per-forge-run.md +81 -0
  42. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-025-model-tiers-from-env-vars-no-vendor-pins.md +85 -0
  43. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-026-routing-tier-recorded-on-work-item.md +73 -0
  44. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-027-per-stage-model-and-token-telemetry.md +80 -0
  45. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-028-dispatch-tool-enforces-agent-allowlist.md +76 -0
  46. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-029-silent-or-aborted-dispatch-yields-blocked.md +83 -0
  47. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-030-dispatched-write-stage-stays-in-worktree.md +73 -0
  48. katalyst_forge-5.2.1/tests/forge-reconcile/fixtures/incident-ric1580/new/.global/taxonomy/design/user-needs/UN-031-single-stage-spike-preserves-eleven-stage-rollout.md +76 -0
  49. katalyst_forge-5.2.1/tests/forge-reconcile/reconcile.test.mjs +182 -0
  50. katalyst_forge-5.2.1/tests/test_cost_observability_wing.py +224 -0
  51. katalyst_forge-5.2.1/tests/test_metrics_batch_hygiene.py +241 -0
  52. katalyst_forge-5.2.1/tests/test_metrics_billing_isolation.py +112 -0
  53. katalyst_forge-5.2.1/tests/test_metrics_content_contract.py +307 -0
  54. katalyst_forge-5.2.1/tests/test_metrics_contracts.py +95 -0
  55. katalyst_forge-5.2.1/tests/test_metrics_dashboard.py +228 -0
  56. katalyst_forge-5.2.1/tests/test_metrics_dashboard_end_to_end.py +203 -0
  57. katalyst_forge-5.2.1/tests/test_metrics_html_report.py +182 -0
  58. katalyst_forge-5.2.1/tests/test_metrics_lazy_providers.py +102 -0
  59. katalyst_forge-5.2.1/tests/test_metrics_money_and_redaction.py +182 -0
  60. katalyst_forge-5.2.1/tests/test_metrics_providers.py +419 -0
  61. katalyst_forge-5.2.1/tests/test_metrics_resolve_compute.py +477 -0
  62. katalyst_forge-5.2.1/tests/test_metrics_routing_policy.py +102 -0
  63. katalyst_forge-5.2.1/tests/test_metrics_wing.py +64 -0
  64. katalyst_forge-5.2.1/tests/test_relationships.py +34 -0
  65. katalyst_forge-5.2.1/tests/test_validate_templates_gates.py +259 -0
  66. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_wing_schemas.py +15 -0
  67. katalyst_forge-5.2.1/tests/test_writer_bundle_schema.py +117 -0
  68. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/LICENSE +0 -0
  69. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/CHANGELOG.md +0 -0
  70. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/README.md +0 -0
  71. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/agents/agent.schema.json +0 -0
  72. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/agents/config.schema.json +0 -0
  73. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/agents/manifest.json +0 -0
  74. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/agents/skill.schema.json +0 -0
  75. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/environment.schema.json +0 -0
  76. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/hooks/hook-config.schema.json +0 -0
  77. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/katalyst-lock.schema.json +0 -0
  78. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/nodes/common.schema.json +0 -0
  79. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/nodes/design.schema.json +0 -0
  80. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/nodes/portfolio.schema.json +0 -0
  81. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/nodes/technical.schema.json +0 -0
  82. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/schemas/taxonomy-node.schema.json +0 -0
  83. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/__init__.py +0 -0
  84. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/bundle.py +0 -0
  85. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/py.typed +0 -0
  86. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/creation/_base.yaml.j2 +0 -0
  87. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/creation/layer.yaml.j2 +0 -0
  88. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/creation/project.yaml.j2 +0 -0
  89. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/creation/stack.yaml.j2 +0 -0
  90. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/creation/system.yaml.j2 +0 -0
  91. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/creation/team.yaml.j2 +0 -0
  92. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/creation/team_member.yaml.j2 +0 -0
  93. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/.dockerignore +0 -0
  94. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/.env.example +0 -0
  95. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/README.md +0 -0
  96. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/base/Dockerfile +0 -0
  97. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/docker-compose.yaml +0 -0
  98. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/Dockerfile +0 -0
  99. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/docker-compose.override.yaml +0 -0
  100. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/env-template/environment.yaml +0 -0
  101. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/app-docker/layer.yaml +0 -0
  102. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/README.md +0 -0
  103. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/main.tf +0 -0
  104. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/outputs.tf +0 -0
  105. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/composite/terraform/variables.tf +0 -0
  106. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/environment.yaml +0 -0
  107. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/env-template/terragrunt.hcl +0 -0
  108. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/context.hcl +0 -0
  109. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/dev.yaml +0 -0
  110. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/prod.yaml +0 -0
  111. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/global/iac/staging.yaml +0 -0
  112. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/layer.yaml +0 -0
  113. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/iac-terragrunt/version.yaml +0 -0
  114. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/README.md +0 -0
  115. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/app/argocd-application.yaml +0 -0
  116. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/kustomization.yaml +0 -0
  117. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/base/namespace.yaml +0 -0
  118. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/argocd-application.yaml +0 -0
  119. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/kustomization.yaml +0 -0
  120. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/environment-template/patch-argocd-application.yaml +0 -0
  121. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-argocd/layer.yaml +0 -0
  122. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/README.md +0 -0
  123. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/deployment.yaml +0 -0
  124. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/kustomization.yaml +0 -0
  125. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/namespace.yaml +0 -0
  126. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/base/service.yaml +0 -0
  127. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/kustomization.yaml +0 -0
  128. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/environment-template/patch-deployment.yaml +0 -0
  129. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/kustomization.yaml +0 -0
  130. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/base/namespace.yaml +0 -0
  131. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/global/k8s/kustomization.yaml +0 -0
  132. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/templates/scaffolds/k8s-kustomize/layer.yaml +0 -0
  133. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/__init__.py +0 -0
  134. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/_practice_refs.py +0 -0
  135. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/agentic.py +0 -0
  136. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/delivery.py +0 -0
  137. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/design.py +0 -0
  138. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/org.py +0 -0
  139. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/pipeline.py +0 -0
  140. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/src/katalyst_forge/wings/system.py +0 -0
  141. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/conftest.py +0 -0
  142. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_agent_run.py +0 -0
  143. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_delivery_doc_consistency.py +0 -0
  144. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_delivery_traceability.py +0 -0
  145. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_entry_point_discovery.py +0 -0
  146. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_forge.py +0 -0
  147. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_practice_assignment.py +0 -0
  148. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_storage_contract.py +0 -0
  149. {katalyst_forge-5.1.0 → katalyst_forge-5.2.1}/tests/test_wings.py +0 -0
@@ -21,3 +21,11 @@ orientation/packet.json
21
21
  # Machine-local orientation fallback config (RIC-731): holds an absolute packetPath,
22
22
  # written beside the installed contract; never source, never commit.
23
23
  orientation-config.json
24
+
25
+ # bun.lock is the committed lockfile; npm's is not
26
+ package-lock.json
27
+
28
+ # Watchdog runtime state (RIC-1592): markers and ledger entries are produced at runtime
29
+ # outside any repo, under KATALYST_WATCHDOG_STATE_DIR. Ignored so a stray write can never
30
+ # pollute the working tree that RIC-1591 has to diff cleanly.
31
+ .katalyst-watchdog/
@@ -16,6 +16,40 @@ PyPI package version. A breaking node-type/model change bumps `schema_version`.
16
16
 
17
17
  ---
18
18
 
19
+ ## [5.2.1] -- 2026-08-08
20
+
21
+ ### Fixed
22
+
23
+ - **PyYAML is now a declared runtime dependency** (RIC-1628). 5.2.0 shipped `katalyst_forge.metrics` but could not import `metrics.generate_dashboard` on a clean install, because that module reads its metric definitions, signal sources and threshold profiles from YAML while `pyyaml` was only ever present transitively in the development environment. An AST scan of `src/` confirms it was the only undeclared third-party runtime import.
24
+
25
+ ### Changed
26
+
27
+ - CI gains a **wheel-import** job that installs the built wheel into a venv with no dev group and imports every public entry point, including the dashboard CLI. Pytest runs inside the dev environment, where a transitively present package masks a missing runtime dependency, and the existing clean-machine smoke job installs agents and skills without ever installing the Python package, so nothing verified what a consumer actually gets. Verified the new job fails on the 5.2.0 dependency set and passes on this one.
28
+
29
+ ## [5.2.0] -- 2026-08-08
30
+
31
+ ### Added
32
+
33
+ - **Cost observability as metrics-wing content** (RIC-1590). Run cost is modelled as `metric_definition` plus `signal_source` content rather than bespoke reporting code. Ships `src/katalyst_forge/metrics/` with a pure computation core (no I/O), a deny-by-default projection into `CanonicalSignalBatch`, `SignalProvider` / `PriceCatalogue` / `RoutingPolicy` protocols, lazily resolved providers for OpenCode and forge-model-router, a text dashboard, an HTML report, and the `cost-observability` template bundle with its global action and Just recipe. Metered and subscription-notional spend are kept apart by construction: `MetricResult` is keyed by billing mode and offers no total, and `__add__`, `__radd__` and `__float__` raise.
34
+ - **Routing comparison policy** (RIC-1616). `routing-headroom` and `routing-savings-realized` can now produce a figure. The comparison models come from the environment (`FORGE_COST_ORCHESTRATOR_MODEL`, `FORGE_COST_TARGET_MODEL`, `FORGE_COST_TARGET_MODEL_<STAGE>`), so no model identifier is hard-coded in code or content. An unconfigured environment yields no policy, leaving the previous behaviour of absent figures rather than zeros.
35
+ - **`optional_fields` on a signal source** (RIC-1617). A provider-synthesized value that may legitimately be absent, such as a Forge stage inferred from a session that is not a Forge stage, no longer discards the whole batch.
36
+ - **Bundle content and non-ownership in the writer reference** (RIC-1621). `kata-tax-writer` now has schema entries for template-bundle content, and explicitly disclaims `tests/`, `src/`, `scripts/` and `.github/workflows/`.
37
+
38
+ ### Fixed
39
+
40
+ - **The metered cost path never returned a record** (RIC-1615). The OpenCode adapter read its root sessions from a `query_config` key that only ever existed in a test fixture, so every real run walked an empty session list. Run identity is now an explicit argument on `SignalProvider.fetch`, interpreted per adapter.
41
+ - **The session walk dropped spend it should have counted** (RIC-1615). It began at the root's children, so an orchestrator's own tokens went uncounted and a single-session run reported nothing, and it descended only one level even though the children endpoint returns direct children only. It now yields the root and every transitive descendant once each.
42
+ - **Router telemetry was rejected outright** (RIC-1615). A real run writes `cost_usd` as a JSON number; decoding without `parse_float=Decimal` lost the exactness `parse_money` requires and aborted the dashboard.
43
+ - **The price catalogue was built eagerly** (RIC-1615), demanding OpenCode credentials from users who only wanted router figures. It is now built on first price lookup.
44
+ - **`routing-headroom` reported a fabricated zero** (RIC-1616) when no record had a resolvable comparison target. A rendered `$0.00` reads as routing already being optimal rather than as routing never having been measured, so it now yields no figure unless a comparison was made.
45
+ - **One uncomputable metric aborted the whole report** (RIC-1616). `compute` is now isolated per metric, matching the per-source isolation already in place.
46
+ - **The action schema documentation did not match the loader** (RIC-1619). `katalyst-action-builder` documented `kind: Action` on an unrecognised apiVersion with `spec.type`, `spec.timeoutSeconds` and `spec.tags`; following it produced zero actions and, for the kind and apiVersion errors, zero errors as well.
47
+ - **The writer's Kind Mapping carried the same defect** (RIC-1621). `plugin-action` mapped to `kind: Action` on `katalyst.taxonomy.plugins.actions/v1alpha`, so the single writer would have produced action files that no loader reads.
48
+
49
+ ### Changed
50
+
51
+ - CI now runs `ruff format --check` over `src`, `tests` and `scripts` (RIC-1620), with the pre-existing drift reformatted first so the gate did not turn open branches red.
52
+
19
53
  ## [5.1.0] -- 2026-07-28
20
54
 
21
55
  - **Canonical ADR/NFR storage** (RIC-1358) — ADR and NFR definitions now declare flat identity-derived storage under `.global/taxonomy/design/adrs/` and `.global/taxonomy/design/nfrs/`. Forge agents, skills, templates, fixtures, and documentation use the plural directories; legacy multi-document examples are labeled explicitly.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: katalyst-forge
3
- Version: 5.1.0
3
+ Version: 5.2.1
4
4
  Summary: Katalyst Forge: the delivery-lifecycle bundle (node kinds, wings, agents, templates, schemas)
5
5
  Author: eSimplicity
6
6
  License: Proprietary
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.12
9
9
  Requires-Dist: katalyst-engine<5,>=4.1
10
+ Requires-Dist: pyyaml<7,>=6
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-forge"
3
- version = "5.1.0"
3
+ version = "5.2.1"
4
4
  description = "Katalyst Forge: the delivery-lifecycle bundle (node kinds, wings, agents, templates, schemas)"
5
5
  license = { text = "Proprietary" }
6
6
  requires-python = ">=3.12"
@@ -9,6 +9,9 @@ authors = [
9
9
  ]
10
10
  dependencies = [
11
11
  "katalyst-engine>=4.1,<5",
12
+ # generate_dashboard reads its metric definitions, signal sources and
13
+ # threshold profiles from YAML, so this is a runtime need, not a dev tool.
14
+ "pyyaml>=6,<7",
12
15
  ]
13
16
 
14
17
  [project.entry-points."katalyst_engine.bundles"]
@@ -66,6 +69,11 @@ dev = [
66
69
  # test_wing_schemas.py validates the shipped JSON Schemas
67
70
  "jsonschema>=4.22",
68
71
  "referencing>=0.35",
72
+ # test_validate_templates_gates.py loads scripts/validate-templates.py, which
73
+ # imports these. CI's templates-validate job installs them ad hoc; the pytest
74
+ # job needs them in its own environment to exercise the gate logic.
75
+ "pyyaml>=6",
76
+ "jinja2>=3.1",
69
77
  ]
70
78
 
71
79
  [tool.ruff]
@@ -96,6 +96,10 @@
96
96
  "type": "string",
97
97
  "minLength": 1,
98
98
  "description": "Human-readable display name for the competency (e.g. 'Kubernetes Administration')."
99
+ },
100
+ "competency_type": {
101
+ "type": "string",
102
+ "description": "Optional free-form kind of competency: practice, system, or tools. No allowed_values enum is enforced."
99
103
  }
100
104
  }
101
105
  }
@@ -0,0 +1,57 @@
1
+ """Public API for the cost/observability metrics adapter.
2
+
3
+ Must NOT import anything from .providers, which would defeat the registry's
4
+ lazy provider resolution (see registry.py and providers/__init__.py).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from katalyst_forge.metrics.batch import CanonicalSignalBatch, project_batch
10
+ from katalyst_forge.metrics.billing import BillingMode
11
+ from katalyst_forge.metrics.core import MetricResult, compute, resolve
12
+ from katalyst_forge.metrics.errors import (
13
+ BatchConstructionError,
14
+ ContractViolationError,
15
+ CrossBillingModeError,
16
+ MetricsAdapterError,
17
+ MissingCredentialError,
18
+ MissingMappedFieldError,
19
+ MoneyPrecisionError,
20
+ PriceUnavailableError,
21
+ ProviderPayloadError,
22
+ ProviderUnavailableError,
23
+ UnknownBillingModeError,
24
+ UnknownProviderError,
25
+ UnmappedFieldError,
26
+ )
27
+ from katalyst_forge.metrics.money import parse_money
28
+ from katalyst_forge.metrics.protocols import PriceCatalogue, SignalProvider
29
+ from katalyst_forge.metrics.redaction import redact
30
+ from katalyst_forge.metrics.registry import SignalProviderRegistry
31
+
32
+ __all__ = (
33
+ "BillingMode",
34
+ "CanonicalSignalBatch",
35
+ "project_batch",
36
+ "MetricResult",
37
+ "resolve",
38
+ "compute",
39
+ "SignalProviderRegistry",
40
+ "SignalProvider",
41
+ "PriceCatalogue",
42
+ "parse_money",
43
+ "redact",
44
+ "MetricsAdapterError",
45
+ "BatchConstructionError",
46
+ "UnmappedFieldError",
47
+ "MissingMappedFieldError",
48
+ "ContractViolationError",
49
+ "MoneyPrecisionError",
50
+ "PriceUnavailableError",
51
+ "UnknownBillingModeError",
52
+ "CrossBillingModeError",
53
+ "UnknownProviderError",
54
+ "MissingCredentialError",
55
+ "ProviderUnavailableError",
56
+ "ProviderPayloadError",
57
+ )
@@ -0,0 +1,174 @@
1
+ """Canonical signal batch: deny-by-default projection and a non-bypassable type.
2
+
3
+ CanonicalSignalBatch can only be produced by project_batch. The constructor
4
+ requires a private guard sentinel that lives only in this module, so no
5
+ outside code, including copy.copy, copy.deepcopy, and pickle, can forge or
6
+ clone an instance. Projection itself is deny-by-default: only canonical
7
+ fields named in a source's field_mapping are ever copied out of a provider
8
+ record, so any field a provider returns but nobody mapped (including
9
+ prompt-shaped fields such as "instructions" or "system_prompt") is dropped,
10
+ never passed through.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping, Sequence
16
+ from types import MappingProxyType
17
+ from typing import Final, cast
18
+
19
+ from katalyst_forge.metrics.billing import BillingMode
20
+ from katalyst_forge.metrics.errors import BatchConstructionError, MissingMappedFieldError
21
+
22
+ __all__ = ("CanonicalSignalBatch", "project_batch")
23
+
24
+
25
+ class _ProjectionGuard:
26
+ """Sentinel type; only _GUARD below is a valid instance of it."""
27
+
28
+ __slots__ = ()
29
+
30
+
31
+ _GUARD: Final[_ProjectionGuard] = _ProjectionGuard()
32
+
33
+
34
+ class CanonicalSignalBatch:
35
+ """An immutable, already-projected batch of canonical signal records."""
36
+
37
+ __slots__ = ("_billing_mode", "_run_id", "_source_name", "_records")
38
+
39
+ def __init__(
40
+ self,
41
+ _guard: _ProjectionGuard,
42
+ *,
43
+ billing_mode: BillingMode,
44
+ run_id: str,
45
+ source_name: str,
46
+ records: tuple[Mapping[str, object], ...],
47
+ ) -> None:
48
+ if _guard is not _GUARD:
49
+ raise BatchConstructionError("only project_batch may construct a CanonicalSignalBatch")
50
+ self._billing_mode = billing_mode
51
+ self._run_id = run_id
52
+ self._source_name = source_name
53
+ self._records = records
54
+
55
+ @property
56
+ def billing_mode(self) -> BillingMode:
57
+ return self._billing_mode
58
+
59
+ @property
60
+ def run_id(self) -> str:
61
+ return self._run_id
62
+
63
+ @property
64
+ def source_name(self) -> str:
65
+ return self._source_name
66
+
67
+ @property
68
+ def records(self) -> tuple[Mapping[str, object], ...]:
69
+ return self._records
70
+
71
+ def __copy__(self) -> CanonicalSignalBatch:
72
+ raise BatchConstructionError("CanonicalSignalBatch cannot be copied")
73
+
74
+ def __deepcopy__(self, memo: dict[int, object]) -> CanonicalSignalBatch:
75
+ raise BatchConstructionError("CanonicalSignalBatch cannot be deepcopied")
76
+
77
+ def __reduce__(self) -> tuple[object, ...]:
78
+ raise BatchConstructionError("CanonicalSignalBatch cannot be pickled")
79
+
80
+
81
+ def _as_mapping(value: object) -> Mapping[str, object] | None:
82
+ """Narrow value to a concretely-typed Mapping, or None if it is not one."""
83
+ if isinstance(value, Mapping):
84
+ return cast("Mapping[str, object]", value)
85
+ return None
86
+
87
+
88
+ def _get_mapping(doc: Mapping[str, object], key: str) -> Mapping[str, object]:
89
+ """Read doc[key] as a Mapping, defaulting to empty rather than raising."""
90
+ mapped = _as_mapping(doc.get(key))
91
+ return mapped if mapped is not None else {}
92
+
93
+
94
+ def _str_frozenset(value: object) -> frozenset[str]:
95
+ """Read a YAML string list as a set, tolerating a bare string or absence."""
96
+ if isinstance(value, str):
97
+ return frozenset({value})
98
+ if isinstance(value, (list, tuple)):
99
+ items = cast("Sequence[object]", value)
100
+ return frozenset(item for item in items if isinstance(item, str))
101
+ return frozenset()
102
+
103
+
104
+ def _read_path(record: Mapping[str, object], path: str) -> object | None:
105
+ """Walk a dotted path through record, returning None on any non-Mapping hop."""
106
+ current: object = record
107
+ for segment in path.split("."):
108
+ current_mapping = _as_mapping(current)
109
+ if current_mapping is None:
110
+ return None
111
+ current = current_mapping.get(segment)
112
+ return current
113
+
114
+
115
+ def project_batch(
116
+ *,
117
+ signal_source_doc: Mapping[str, object],
118
+ records: Sequence[Mapping[str, object]],
119
+ billing_mode: BillingMode,
120
+ run_id: str,
121
+ source_name: str,
122
+ ) -> CanonicalSignalBatch:
123
+ """Deny-by-default projection: keep only canonical fields named in field_mapping.
124
+
125
+ field_mapping is canonical_input_name -> provider.dotted.path. For every
126
+ provider record, a new dict is built containing only those canonical
127
+ keys, each pulled from the mapped path. A field the provider returns but
128
+ no mapping references is never copied. A mapped path missing from one
129
+ record is simply omitted from that record's projection; a mapping
130
+ that resolves for zero records across the whole batch is an error, since
131
+ that indicates the mapping itself is wrong rather than sparse data, unless
132
+ the source names the field in optional_fields.
133
+
134
+ optional_fields exists for a value the provider synthesizes and may
135
+ legitimately not have, such as a Forge stage inferred from a session that is
136
+ not a Forge stage at all. Holding such a field to the zero-resolution guard
137
+ discarded a run's entire cost rather than only its stage attribution, so a
138
+ total vanished because one attribution dimension was absent. The trade is
139
+ that a typo in an optional field's path stops being caught, which is why it
140
+ is declared per source rather than assumed.
141
+ """
142
+ spec = _get_mapping(signal_source_doc, "spec")
143
+ field_mapping = _get_mapping(spec, "field_mapping")
144
+ optional_fields = _str_frozenset(spec.get("optional_fields"))
145
+
146
+ canonical_paths: tuple[tuple[str, str], ...] = tuple(
147
+ (canonical_key, path)
148
+ for canonical_key, path in field_mapping.items()
149
+ if isinstance(path, str)
150
+ )
151
+
152
+ resolved_counts: dict[str, int] = {canonical_key: 0 for canonical_key, _ in canonical_paths}
153
+ projected_records: list[Mapping[str, object]] = []
154
+
155
+ for record in records:
156
+ projected: dict[str, object] = {}
157
+ for canonical_key, path in canonical_paths:
158
+ value = _read_path(record, path)
159
+ if value is not None:
160
+ projected[canonical_key] = value
161
+ resolved_counts[canonical_key] += 1
162
+ projected_records.append(MappingProxyType(projected))
163
+
164
+ for canonical_key, count in resolved_counts.items():
165
+ if count == 0 and canonical_key not in optional_fields:
166
+ raise MissingMappedFieldError(canonical_field=canonical_key, source_name=source_name)
167
+
168
+ return CanonicalSignalBatch(
169
+ _GUARD,
170
+ billing_mode=billing_mode,
171
+ run_id=run_id,
172
+ source_name=source_name,
173
+ records=tuple(projected_records),
174
+ )
@@ -0,0 +1,35 @@
1
+ """Billing mode enum and strict parsing (metered vs. subscription-notional)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from katalyst_forge.metrics.errors import UnknownBillingModeError
8
+
9
+
10
+ class BillingMode(StrEnum):
11
+ """The two ways a signal source's cost figures are billed.
12
+
13
+ METERED means the provider charges per unit consumed. SUBSCRIPTION_NOTIONAL
14
+ means the spend is a flat subscription and the figure is a notional
15
+ allocation, not an invoice line. These are different quantities and must
16
+ never be combined (see core.MetricResult).
17
+ """
18
+
19
+ METERED = "metered"
20
+ SUBSCRIPTION_NOTIONAL = "subscription_notional"
21
+
22
+
23
+ def parse_billing_mode(raw: object) -> BillingMode:
24
+ """Parse a billing mode from a document value, never defaulting.
25
+
26
+ A missing or unrecognized billing mode is a contract error, not a hint to
27
+ guess metered as a safe default; guessing wrong silently mislabels cost.
28
+ """
29
+ if isinstance(raw, BillingMode):
30
+ return raw
31
+ if isinstance(raw, str):
32
+ for mode in BillingMode:
33
+ if mode.value == raw:
34
+ return mode
35
+ raise UnknownBillingModeError(raw)
@@ -0,0 +1,128 @@
1
+ """Contract validation: does a source's field_mapping cover a metric's inputs?
2
+
3
+ field_mapping is canonical_input_name -> provider.dotted.path, so "does this
4
+ source cover this metric's inputs" is checked against the mapping's KEYS
5
+ (the canonical side), never the provider paths on the right. unit_price_usd
6
+ is exempt from coverage because it is supplied by the injected price
7
+ catalogue at compute time, not projected out of a provider record. Inputs
8
+ listed in BILLING_MODE_SCOPED_INPUTS are exempt in any billing mode other
9
+ than the ones listed, because the quantity they describe (e.g. headroom
10
+ against a subscription cap) simply does not exist outside that mode.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping, Sequence
16
+ from typing import Final, cast
17
+
18
+ from katalyst_forge.metrics.billing import BillingMode
19
+ from katalyst_forge.metrics.errors import ContractViolationError
20
+
21
+ CATALOGUE_PROVIDED_INPUTS: Final[frozenset[str]] = frozenset({"unit_price_usd"})
22
+
23
+ # Supplied by the resolve() caller, not projected from a provider record.
24
+ # Neither provider carries a Forge run identifier in its payload, so requiring
25
+ # a field_mapping entry for run_id would mean naming a path that does not exist.
26
+ CONTEXT_PROVIDED_INPUTS: Final[frozenset[str]] = frozenset({"run_id"})
27
+
28
+ # Supplied by the injected RoutingPolicy. A routing counterfactual compares
29
+ # against a model nobody's payload reports, so these are policy, not data.
30
+ POLICY_PROVIDED_INPUTS: Final[frozenset[str]] = frozenset(
31
+ {"orchestrator_model", "routing_target_model"}
32
+ )
33
+
34
+ BILLING_MODE_SCOPED_INPUTS: Final[Mapping[str, frozenset[BillingMode]]] = {
35
+ "metered_cost_usd": frozenset({BillingMode.METERED}),
36
+ "notional_cost_usd": frozenset({BillingMode.SUBSCRIPTION_NOTIONAL}),
37
+ }
38
+
39
+
40
+ def _as_mapping(value: object) -> Mapping[str, object] | None:
41
+ if isinstance(value, Mapping):
42
+ return cast("Mapping[str, object]", value)
43
+ return None
44
+
45
+
46
+ def _get_mapping(doc: Mapping[str, object], key: str) -> Mapping[str, object]:
47
+ mapped = _as_mapping(doc.get(key))
48
+ return mapped if mapped is not None else {}
49
+
50
+
51
+ def _str_tuple(value: object) -> tuple[str, ...]:
52
+ """Normalise a scalar-or-list document field to a tuple of strings.
53
+
54
+ A bare string must normalise to a one-element tuple, not an empty one.
55
+ metric_ref widened from a scalar to a list in slice A and stayed
56
+ backwards compatible, so the two shipped scalar docs still reach here as
57
+ plain strings; returning () for them would silently skip contract
58
+ validation entirely rather than checking it.
59
+ """
60
+ if isinstance(value, str):
61
+ return (value,)
62
+ if isinstance(value, (list, tuple)):
63
+ items = cast("Sequence[object]", value)
64
+ return tuple(item for item in items if isinstance(item, str))
65
+ return ()
66
+
67
+
68
+ def required_inputs(
69
+ metric_definition_doc: Mapping[str, object], billing_mode: BillingMode
70
+ ) -> frozenset[str]:
71
+ """Return the metric's inputs a source must map, given a billing mode."""
72
+ spec = _get_mapping(metric_definition_doc, "spec")
73
+ inputs = frozenset(_str_tuple(spec.get("inputs")))
74
+
75
+ scoped_out = {
76
+ name
77
+ for name in inputs
78
+ if name in BILLING_MODE_SCOPED_INPUTS
79
+ and billing_mode not in BILLING_MODE_SCOPED_INPUTS[name]
80
+ }
81
+ return (
82
+ inputs
83
+ - CATALOGUE_PROVIDED_INPUTS
84
+ - CONTEXT_PROVIDED_INPUTS
85
+ - POLICY_PROVIDED_INPUTS
86
+ - scoped_out
87
+ )
88
+
89
+
90
+ def validate_source_contract(
91
+ *,
92
+ signal_source_doc: Mapping[str, object],
93
+ metric_definitions: Mapping[str, Mapping[str, object]],
94
+ billing_mode: BillingMode,
95
+ ) -> None:
96
+ """Raise ContractViolationError if source's field_mapping under-covers a metric_ref."""
97
+ spec = _get_mapping(signal_source_doc, "spec")
98
+ source_name = _doc_name(signal_source_doc)
99
+ metric_refs = _str_tuple(spec.get("metric_ref"))
100
+ field_mapping = _get_mapping(spec, "field_mapping")
101
+
102
+ mapped_keys = frozenset(field_mapping.keys())
103
+
104
+ for metric_name in metric_refs:
105
+ metric_doc = metric_definitions.get(metric_name)
106
+ if metric_doc is None:
107
+ raise ContractViolationError(
108
+ metric_name=metric_name,
109
+ source_name=source_name,
110
+ missing_inputs=("<unknown metric>",),
111
+ )
112
+ needed = required_inputs(metric_doc, billing_mode)
113
+ missing = needed - mapped_keys
114
+ if missing:
115
+ raise ContractViolationError(
116
+ metric_name=metric_name,
117
+ source_name=source_name,
118
+ missing_inputs=tuple(sorted(missing)),
119
+ )
120
+
121
+
122
+ def _doc_name(doc: Mapping[str, object]) -> str:
123
+ identity = _get_mapping(doc, "identity")
124
+ name = identity.get("name")
125
+ if isinstance(name, str):
126
+ return name
127
+ name = doc.get("name")
128
+ return name if isinstance(name, str) else "<unnamed>"