platformforge 0.1.0__py3-none-any.whl

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 (659) hide show
  1. platformforge/__init__.py +7 -0
  2. platformforge/__main__.py +5 -0
  3. platformforge/_forge_portal.py +913 -0
  4. platformforge/_graphstudio.py +376 -0
  5. platformforge/_graphview.py +181 -0
  6. platformforge/_installkit.py +1706 -0
  7. platformforge/agents/__init__.py +156 -0
  8. platformforge/agents/bench.py +61 -0
  9. platformforge/agents/contextpack.py +137 -0
  10. platformforge/agents/contracts.py +394 -0
  11. platformforge/agents/coordinators.py +98 -0
  12. platformforge/agents/critic.py +113 -0
  13. platformforge/agents/debate.py +161 -0
  14. platformforge/agents/executors.py +160 -0
  15. platformforge/agents/guardian.py +48 -0
  16. platformforge/agents/mirrors.py +240 -0
  17. platformforge/agents/orchestrator.py +295 -0
  18. platformforge/agents/planner.py +116 -0
  19. platformforge/agents/playbook.py +51 -0
  20. platformforge/agents/referee.py +132 -0
  21. platformforge/agents/reviewers.py +113 -0
  22. platformforge/agents/roster.py +893 -0
  23. platformforge/agents/runledger.py +85 -0
  24. platformforge/agents/runstore.py +74 -0
  25. platformforge/agents/specialists.py +73 -0
  26. platformforge/agents/taskspec.py +94 -0
  27. platformforge/agents/uniqueness.py +115 -0
  28. platformforge/agents/verifier.py +106 -0
  29. platformforge/aiplat/__init__.py +4 -0
  30. platformforge/aiplat/models.py +175 -0
  31. platformforge/analytics/__init__.py +6 -0
  32. platformforge/analytics/capacity.py +109 -0
  33. platformforge/analytics/debt.py +92 -0
  34. platformforge/analytics/dx.py +69 -0
  35. platformforge/analytics/finops_v4.py +171 -0
  36. platformforge/analytics/goldenpath.py +131 -0
  37. platformforge/analytics/history.py +233 -0
  38. platformforge/analytics/measurement.py +149 -0
  39. platformforge/analytics/models.py +127 -0
  40. platformforge/analytics/opsanalytics.py +119 -0
  41. platformforge/analytics/policyintel.py +139 -0
  42. platformforge/analytics/reliability.py +106 -0
  43. platformforge/analytics/soak.py +135 -0
  44. platformforge/analytics/store.py +254 -0
  45. platformforge/bench.py +174 -0
  46. platformforge/cases/__init__.py +13 -0
  47. platformforge/cases/casefile.py +106 -0
  48. platformforge/cases/context_audit.py +134 -0
  49. platformforge/cases/ledgers.py +174 -0
  50. platformforge/cases/replay.py +142 -0
  51. platformforge/cases/routing_audit.py +162 -0
  52. platformforge/caveman/__init__.py +11 -0
  53. platformforge/caveman/compress.py +156 -0
  54. platformforge/cicd/__init__.py +16 -0
  55. platformforge/cicd/backstage.py +51 -0
  56. platformforge/cicd/dockerfile.py +64 -0
  57. platformforge/cicd/github_actions.py +122 -0
  58. platformforge/cicd/gitlab_ci.py +56 -0
  59. platformforge/cicd/gitops.py +130 -0
  60. platformforge/cli/__init__.py +0 -0
  61. platformforge/cli/fleetcmd.py +457 -0
  62. platformforge/cli/main.py +3832 -0
  63. platformforge/cloud/__init__.py +8 -0
  64. platformforge/cloud/aws.py +254 -0
  65. platformforge/cloud/common.py +69 -0
  66. platformforge/cloud/multicloud.py +143 -0
  67. platformforge/collect/__init__.py +11 -0
  68. platformforge/collect/detect.py +254 -0
  69. platformforge/context/__init__.py +10 -0
  70. platformforge/context/capsule.py +60 -0
  71. platformforge/context/gateway.py +227 -0
  72. platformforge/context/refs.py +49 -0
  73. platformforge/context/sufficiency.py +50 -0
  74. platformforge/core/__init__.py +1 -0
  75. platformforge/core/hashing.py +39 -0
  76. platformforge/core/ownership.py +220 -0
  77. platformforge/core/receipts.py +74 -0
  78. platformforge/core/redaction.py +97 -0
  79. platformforge/core/store.py +119 -0
  80. platformforge/core/workspace.py +89 -0
  81. platformforge/data/contracts/agent-handoff.schema.json +26 -0
  82. platformforge/data/contracts/agent-run.schema.json +27 -0
  83. platformforge/data/contracts/agent-spec.schema.json +43 -0
  84. platformforge/data/contracts/capability.schema.json +23 -0
  85. platformforge/data/contracts/change-event.schema.json +22 -0
  86. platformforge/data/contracts/change-intent.schema.json +62 -0
  87. platformforge/data/contracts/change-plan.schema.json +43 -0
  88. platformforge/data/contracts/collector-receipt.schema.json +36 -0
  89. platformforge/data/contracts/context-pack.schema.json +29 -0
  90. platformforge/data/contracts/debate.schema.json +31 -0
  91. platformforge/data/contracts/distribution-manifest-v1.schema.json +81 -0
  92. platformforge/data/contracts/drift-event.schema.json +21 -0
  93. platformforge/data/contracts/fact.schema.json +35 -0
  94. platformforge/data/contracts/finding.schema.json +20 -0
  95. platformforge/data/contracts/graph-v2.schema.json +67 -0
  96. platformforge/data/contracts/graph.schema.json +40 -0
  97. platformforge/data/contracts/install-receipt-v1.schema.json +49 -0
  98. platformforge/data/contracts/observation.schema.json +93 -0
  99. platformforge/data/contracts/receipt.schema.json +21 -0
  100. platformforge/data/contracts/recommendation.schema.json +32 -0
  101. platformforge/data/contracts/refusal.schema.json +16 -0
  102. platformforge/data/contracts/slo-contract.schema.json +25 -0
  103. platformforge/data/contracts/task-spec.schema.json +37 -0
  104. platformforge/data/contracts/workspace-manifest-v1.schema.json +62 -0
  105. platformforge/data/evals/cases/agent-budget-exhaustion/case.yaml +6 -0
  106. platformforge/data/evals/cases/agent-conflicting-specialists/case.yaml +11 -0
  107. platformforge/data/evals/cases/agent-fleet-optimization/case.yaml +13 -0
  108. platformforge/data/evals/cases/agent-host-no-subagents/case.yaml +6 -0
  109. platformforge/data/evals/cases/agent-incident-multi/case.yaml +13 -0
  110. platformforge/data/evals/cases/agent-missing-evidence/case.yaml +6 -0
  111. platformforge/data/evals/cases/agent-multi-domain-audit/case.yaml +14 -0
  112. platformforge/data/evals/cases/agent-production-change/case.yaml +15 -0
  113. platformforge/data/evals/cases/agent-security-mandatory/case.yaml +12 -0
  114. platformforge/data/evals/cases/agent-simple-iac/case.yaml +14 -0
  115. platformforge/data/evals/cases/approval-integrity-seal/case.yaml +6 -0
  116. platformforge/data/evals/cases/capacity-hot/case.yaml +7 -0
  117. platformforge/data/evals/cases/capacity-hot/fixture/capacity.json +5 -0
  118. platformforge/data/evals/cases/catalog-noowner/case.yaml +7 -0
  119. platformforge/data/evals/cases/catalog-noowner/fixture/catalog-info.yaml +6 -0
  120. platformforge/data/evals/cases/contract-fact/case.yaml +5 -0
  121. platformforge/data/evals/cases/contract-fact/fixture/doc.json +2 -0
  122. platformforge/data/evals/cases/cosign-unverified/case.yaml +8 -0
  123. platformforge/data/evals/cases/cosign-unverified/fixture/sig.json +33 -0
  124. platformforge/data/evals/cases/expected-delta-refusal/case.yaml +6 -0
  125. platformforge/data/evals/cases/finops-noattr/case.yaml +9 -0
  126. platformforge/data/evals/cases/finops-noattr/fixture/costs.json +7 -0
  127. platformforge/data/evals/cases/fleet-inv-absent-allocation-unknown/case.yaml +7 -0
  128. platformforge/data/evals/cases/fleet-inv-centrality-not-criticality/case.yaml +7 -0
  129. platformforge/data/evals/cases/fleet-inv-correlation-not-causality/case.yaml +7 -0
  130. platformforge/data/evals/cases/fleet-inv-dx-no-person-metrics/case.yaml +7 -0
  131. platformforge/data/evals/cases/fleet-inv-federation-restricted-denied/case.yaml +7 -0
  132. platformforge/data/evals/cases/fleet-inv-federation-secret-denied/case.yaml +7 -0
  133. platformforge/data/evals/cases/fleet-inv-history-window-deterministic/case.yaml +7 -0
  134. platformforge/data/evals/cases/fleet-inv-low-util-not-deletable/case.yaml +7 -0
  135. platformforge/data/evals/cases/fleet-inv-member-key-deterministic/case.yaml +7 -0
  136. platformforge/data/evals/cases/fleet-inv-missing-denominator/case.yaml +7 -0
  137. platformforge/data/evals/cases/fleet-inv-partial-coverage-visible/case.yaml +7 -0
  138. platformforge/data/evals/cases/fleet-inv-plan-changeintent-only/case.yaml +7 -0
  139. platformforge/data/evals/cases/fleet-inv-report-all-cited/case.yaml +7 -0
  140. platformforge/data/evals/cases/fleet-inv-small-sample-confidence/case.yaml +7 -0
  141. platformforge/data/evals/cases/fleet-inv-uncertain-optimization-suppressed/case.yaml +7 -0
  142. platformforge/data/evals/cases/gha-dangerous/case.yaml +12 -0
  143. platformforge/data/evals/cases/gha-dangerous/fixture/.github/workflows/ci.yaml +11 -0
  144. platformforge/data/evals/cases/gitops-v2/case.yaml +11 -0
  145. platformforge/data/evals/cases/gitops-v2/fixture/argo.yaml +41 -0
  146. platformforge/data/evals/cases/graph-k8s-blast/case.yaml +7 -0
  147. platformforge/data/evals/cases/graph-k8s-blast/fixture/deploy.yaml +15 -0
  148. platformforge/data/evals/cases/iac-module-nopin/case.yaml +8 -0
  149. platformforge/data/evals/cases/iac-module-nopin/fixture/main.tf +3 -0
  150. platformforge/data/evals/cases/iac-plan-replace/case.yaml +8 -0
  151. platformforge/data/evals/cases/iac-plan-replace/fixture/plan.json +12 -0
  152. platformforge/data/evals/cases/iac-sg-open/case.yaml +8 -0
  153. platformforge/data/evals/cases/iac-sg-open/fixture/main.tf +9 -0
  154. platformforge/data/evals/cases/iam-oidc/case.yaml +8 -0
  155. platformforge/data/evals/cases/iam-oidc/fixture/policy.json +11 -0
  156. platformforge/data/evals/cases/iam-wildcard-positive/case.yaml +8 -0
  157. platformforge/data/evals/cases/iam-wildcard-positive/fixture/policy.json +3 -0
  158. platformforge/data/evals/cases/k8s-bad-actor/case.yaml +19 -0
  159. platformforge/data/evals/cases/k8s-bad-actor/fixture/app.yaml +97 -0
  160. platformforge/data/evals/cases/k8s-beta-api/case.yaml +8 -0
  161. platformforge/data/evals/cases/k8s-beta-api/fixture/cr.yaml +7 -0
  162. platformforge/data/evals/cases/k8s-dep-api-boundary/case.yaml +15 -0
  163. platformforge/data/evals/cases/k8s-dep-api-boundary/fixture/app.yaml +25 -0
  164. platformforge/data/evals/cases/k8s-dep-api-clean/case.yaml +12 -0
  165. platformforge/data/evals/cases/k8s-dep-api-clean/fixture/app.yaml +29 -0
  166. platformforge/data/evals/cases/k8s-dep-api-unknown/case.yaml +11 -0
  167. platformforge/data/evals/cases/k8s-dep-api-unknown/fixture/app.yaml +25 -0
  168. platformforge/data/evals/cases/k8s-dep-api-v120/case.yaml +12 -0
  169. platformforge/data/evals/cases/k8s-dep-api-v120/fixture/app.yaml +25 -0
  170. platformforge/data/evals/cases/k8s-dep-api-v129/case.yaml +9 -0
  171. platformforge/data/evals/cases/k8s-dep-api-v129/fixture/app.yaml +25 -0
  172. platformforge/data/evals/cases/k8s-hpa-noreq/case.yaml +8 -0
  173. platformforge/data/evals/cases/k8s-hpa-noreq/fixture/app.yaml +28 -0
  174. platformforge/data/evals/cases/k8s-limits-boundary/case.yaml +10 -0
  175. platformforge/data/evals/cases/k8s-limits-boundary/fixture/app.yaml +15 -0
  176. platformforge/data/evals/cases/k8s-limits-negative/case.yaml +8 -0
  177. platformforge/data/evals/cases/k8s-limits-negative/fixture/deploy.yaml +15 -0
  178. platformforge/data/evals/cases/k8s-limits-positive/case.yaml +8 -0
  179. platformforge/data/evals/cases/k8s-limits-positive/fixture/deploy.yaml +14 -0
  180. platformforge/data/evals/cases/k8s-nopdb/case.yaml +7 -0
  181. platformforge/data/evals/cases/k8s-nopdb/fixture/app.yaml +34 -0
  182. platformforge/data/evals/cases/knowledge-registry/case.yaml +10 -0
  183. platformforge/data/evals/cases/kyverno-dep/case.yaml +10 -0
  184. platformforge/data/evals/cases/kyverno-dep/fixture/pol.yaml +18 -0
  185. platformforge/data/evals/cases/kyverno-legacy/case.yaml +8 -0
  186. platformforge/data/evals/cases/kyverno-legacy/fixture/pol.yaml +7 -0
  187. platformforge/data/evals/cases/live-aws-collect/case.yaml +11 -0
  188. platformforge/data/evals/cases/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
  189. platformforge/data/evals/cases/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
  190. platformforge/data/evals/cases/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
  191. platformforge/data/evals/cases/live-drift/case.yaml +8 -0
  192. platformforge/data/evals/cases/live-drift/fixture/after.json +22 -0
  193. platformforge/data/evals/cases/live-drift/fixture/before.json +22 -0
  194. platformforge/data/evals/cases/live-incident/case.yaml +7 -0
  195. platformforge/data/evals/cases/live-incident/fixture/changes.json +8 -0
  196. platformforge/data/evals/cases/live-incident/fixture/incident.json +7 -0
  197. platformforge/data/evals/cases/live-k8s-collect/case.yaml +10 -0
  198. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
  199. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
  200. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
  201. platformforge/data/evals/cases/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
  202. platformforge/data/evals/cases/live-multicluster/case.yaml +7 -0
  203. platformforge/data/evals/cases/live-multicluster/fixture/clusters.json +6 -0
  204. platformforge/data/evals/cases/live-plan/case.yaml +7 -0
  205. platformforge/data/evals/cases/live-plan/fixture/drift.json +8 -0
  206. platformforge/data/evals/cases/live-reconcile/case.yaml +7 -0
  207. platformforge/data/evals/cases/live-reconcile/fixture/desired.json +7 -0
  208. platformforge/data/evals/cases/live-reconcile/fixture/observed.json +18 -0
  209. platformforge/data/evals/cases/live-secrets-boundary/case.yaml +8 -0
  210. platformforge/data/evals/cases/live-secrets-boundary/fixture/envelope.json +16 -0
  211. platformforge/data/evals/cases/live-temporal-graph/case.yaml +10 -0
  212. platformforge/data/evals/cases/live-temporal-graph/fixture/graph.json +22 -0
  213. platformforge/data/evals/cases/ops-approval/case.yaml +6 -0
  214. platformforge/data/evals/cases/ops-convergence/case.yaml +6 -0
  215. platformforge/data/evals/cases/ops-execution-safety/case.yaml +6 -0
  216. platformforge/data/evals/cases/ops-intent/case.yaml +6 -0
  217. platformforge/data/evals/cases/ops-planning/case.yaml +6 -0
  218. platformforge/data/evals/cases/ops-policy/case.yaml +6 -0
  219. platformforge/data/evals/cases/ops-rollback/case.yaml +6 -0
  220. platformforge/data/evals/cases/ops-verification/case.yaml +6 -0
  221. platformforge/data/evals/cases/rollback-argocd-revision/case.yaml +6 -0
  222. platformforge/data/evals/cases/rollback-blind-inverse/case.yaml +6 -0
  223. platformforge/data/evals/cases/rollback-git-commit/case.yaml +6 -0
  224. platformforge/data/evals/cases/rollback-impossible/case.yaml +6 -0
  225. platformforge/data/evals/cases/rollback-k8s-annotation-restore/case.yaml +6 -0
  226. platformforge/data/evals/cases/rollback-k8s-scale/case.yaml +6 -0
  227. platformforge/data/evals/cases/rollback-material-tamper/case.yaml +6 -0
  228. platformforge/data/evals/cases/rollback-terraform-replan/case.yaml +6 -0
  229. platformforge/data/evals/cases/rollback-unknown/case.yaml +6 -0
  230. platformforge/data/evals/cases/routing-specialist/case.yaml +9 -0
  231. platformforge/data/evals/cases/rtk-roundtrip/case.yaml +5 -0
  232. platformforge/data/evals/cases/rtk-roundtrip/fixture/verbose.txt +300 -0
  233. platformforge/data/evals/cases/sbom-vuln/case.yaml +9 -0
  234. platformforge/data/evals/cases/sbom-vuln/fixture/sbom.json +9 -0
  235. platformforge/data/evals/cases/secret-prose-mixed/fixture/app.env +1 -0
  236. platformforge/data/evals/cases/secret-prose-mixed/fixture/policy-notes.md +7 -0
  237. platformforge/data/evals/cases/secrets-noleak/case.yaml +5 -0
  238. platformforge/data/evals/cases/secrets-noleak/fixture/app.env +2 -0
  239. platformforge/data/evals/cases/slo-fastburn/case.yaml +8 -0
  240. platformforge/data/evals/cases/slo-fastburn/fixture/events.yaml +2 -0
  241. platformforge/data/evals/cases/slo-fastburn/fixture/slo.yaml +4 -0
  242. platformforge/data/evals/cases/slo-noinput/case.yaml +8 -0
  243. platformforge/data/evals/cases/slo-noinput/fixture/events.yaml +1 -0
  244. platformforge/data/evals/cases/slo-noinput/fixture/slo.yaml +4 -0
  245. platformforge/data/evals/cases/sot-conflict/case.yaml +6 -0
  246. platformforge/data/evals/cases/token-budget/case.yaml +6 -0
  247. platformforge/data/evals/cases/token-budget/fixture/big.py +50 -0
  248. platformforge/data/evals/cases/xr-delete/case.yaml +8 -0
  249. platformforge/data/evals/cases/xr-delete/fixture/xr.yaml +10 -0
  250. platformforge/data/evals/cases/xr-graph-chain/case.yaml +8 -0
  251. platformforge/data/evals/cases/xr-graph-chain/fixture/xrd.yaml +90 -0
  252. platformforge/data/evals/cases/xr-negative-crd/case.yaml +8 -0
  253. platformforge/data/evals/cases/xr-negative-crd/fixture/widget.yaml +19 -0
  254. platformforge/data/evals/cases/xr-upbound-mr/case.yaml +8 -0
  255. platformforge/data/evals/cases/xr-upbound-mr/fixture/bucket.yaml +12 -0
  256. platformforge/data/evals/qpt-bench/change-review/case.yaml +5 -0
  257. platformforge/data/evals/qpt-bench/change-review/fixture/plan.json +9 -0
  258. platformforge/data/evals/qpt-bench/gha-security/case.yaml +5 -0
  259. platformforge/data/evals/qpt-bench/gha-security/fixture/ci.yml +10 -0
  260. platformforge/data/evals/qpt-bench/iam-investigate/case.yaml +5 -0
  261. platformforge/data/evals/qpt-bench/iam-investigate/fixture/policy.json +6 -0
  262. platformforge/data/evals/qpt-bench/incident-analysis/case.yaml +5 -0
  263. platformforge/data/evals/qpt-bench/incident-analysis/fixture/query.json +5 -0
  264. platformforge/data/evals/qpt-bench/incident-analysis/fixture/rules.yaml +6 -0
  265. platformforge/data/evals/qpt-bench/k8s-review/case.yaml +6 -0
  266. platformforge/data/evals/qpt-bench/k8s-review/fixture/deploy.yaml +15 -0
  267. platformforge/data/evals/qpt-bench/tf-security/case.yaml +5 -0
  268. platformforge/data/evals/qpt-bench/tf-security/fixture/main.tf +17 -0
  269. platformforge/data/knowledge/aws/eks.yaml +20 -0
  270. platformforge/data/knowledge/aws/iam.yaml +28 -0
  271. platformforge/data/knowledge/aws/network-exposure.yaml +19 -0
  272. platformforge/data/knowledge/aws/organizations.yaml +19 -0
  273. platformforge/data/knowledge/crossplane/composition-providers.yaml +20 -0
  274. platformforge/data/knowledge/crossplane/managed-resources.yaml +26 -0
  275. platformforge/data/knowledge/crossplane/v1-v2.yaml +25 -0
  276. platformforge/data/knowledge/kubernetes/api-deprecations.yaml +33 -0
  277. platformforge/data/knowledge/kubernetes/autoscaling-rbac.yaml +27 -0
  278. platformforge/data/knowledge/kubernetes/gateway-api.yaml +20 -0
  279. platformforge/data/knowledge/kubernetes/pod-security.yaml +26 -0
  280. platformforge/data/knowledge/sources.yaml +516 -0
  281. platformforge/data/knowledge/terraform/lifecycle.yaml +18 -0
  282. platformforge/data/knowledge/terraform/plan-semantics.yaml +26 -0
  283. platformforge/data/lab/chaos-pod-kill/chaos.yaml +3 -0
  284. platformforge/data/lab/chaos-pod-kill/facts.json +13 -0
  285. platformforge/data/lab/fleets/acme/capacity.yaml +35 -0
  286. platformforge/data/lab/fleets/acme/costs.yaml +25 -0
  287. platformforge/data/lab/fleets/acme/events.yaml +41 -0
  288. platformforge/data/lab/fleets/acme/fleet.yaml +51 -0
  289. platformforge/data/lab/fleets/acme/graph.yaml +45 -0
  290. platformforge/data/lab/fleets/acme/policies.yaml +28 -0
  291. platformforge/data/lab/fleets/acme/requests.yaml +24 -0
  292. platformforge/data/lab/scenarios/argo-bad-rollout/expected.yaml +6 -0
  293. platformforge/data/lab/scenarios/argo-bad-rollout/fixture/app.yaml +9 -0
  294. platformforge/data/lab/scenarios/aws-dump/expected.yaml +19 -0
  295. platformforge/data/lab/scenarios/aws-dump/fixture/compute.json +4 -0
  296. platformforge/data/lab/scenarios/aws-dump/fixture/iam.json +7 -0
  297. platformforge/data/lab/scenarios/aws-dump/fixture/network.json +6 -0
  298. platformforge/data/lab/scenarios/aws-dump/fixture/org.json +4 -0
  299. platformforge/data/lab/scenarios/aws-dump/fixture/storage.json +4 -0
  300. platformforge/data/lab/scenarios/container-unsigned/expected.yaml +5 -0
  301. platformforge/data/lab/scenarios/container-unsigned/fixture/supply.json +11 -0
  302. platformforge/data/lab/scenarios/cost-idle-workload/expected.yaml +5 -0
  303. platformforge/data/lab/scenarios/cost-idle-workload/fixture/costs.json +12 -0
  304. platformforge/data/lab/scenarios/crossplane-platform/expected.yaml +16 -0
  305. platformforge/data/lab/scenarios/crossplane-platform/fixture/negative.yaml +18 -0
  306. platformforge/data/lab/scenarios/crossplane-platform/fixture/xrd.yaml +90 -0
  307. platformforge/data/lab/scenarios/demo-platform/expected.yaml +16 -0
  308. platformforge/data/lab/scenarios/demo-platform/fixture/.github/workflows/ci.yml +10 -0
  309. platformforge/data/lab/scenarios/demo-platform/fixture/deploy.yaml +16 -0
  310. platformforge/data/lab/scenarios/demo-platform/fixture/infra.tf +5 -0
  311. platformforge/data/lab/scenarios/fleet-ai/expected.yaml +12 -0
  312. platformforge/data/lab/scenarios/fleet-capacity/expected.yaml +12 -0
  313. platformforge/data/lab/scenarios/fleet-cost/expected.yaml +18 -0
  314. platformforge/data/lab/scenarios/fleet-federation/expected.yaml +11 -0
  315. platformforge/data/lab/scenarios/fleet-golden-path/expected.yaml +18 -0
  316. platformforge/data/lab/scenarios/fleet-op-hotspot/expected.yaml +11 -0
  317. platformforge/data/lab/scenarios/fleet-ownership/expected.yaml +17 -0
  318. platformforge/data/lab/scenarios/fleet-partial/expected.yaml +10 -0
  319. platformforge/data/lab/scenarios/fleet-policy-friction/expected.yaml +10 -0
  320. platformforge/data/lab/scenarios/fleet-recurring-incident/expected.yaml +12 -0
  321. platformforge/data/lab/scenarios/fleet-report/expected.yaml +13 -0
  322. platformforge/data/lab/scenarios/fleet-risk/expected.yaml +18 -0
  323. platformforge/data/lab/scenarios/gitops-drift/expected.yaml +6 -0
  324. platformforge/data/lab/scenarios/gitops-drift/fixture/app.yaml +12 -0
  325. platformforge/data/lab/scenarios/k8s-bad-readiness/expected.yaml +6 -0
  326. platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/deploy.yaml +23 -0
  327. platformforge/data/lab/scenarios/k8s-bad-readiness/fixture/netpol.yaml +9 -0
  328. platformforge/data/lab/scenarios/k8s-missing-limits/expected.yaml +6 -0
  329. platformforge/data/lab/scenarios/k8s-missing-limits/fixture/deploy.yaml +26 -0
  330. platformforge/data/lab/scenarios/k8s-missing-limits/fixture/netpol.yaml +9 -0
  331. platformforge/data/lab/scenarios/k8s-single-replica/expected.yaml +5 -0
  332. platformforge/data/lab/scenarios/k8s-single-replica/fixture/deploy.yaml +26 -0
  333. platformforge/data/lab/scenarios/k8s-single-replica/fixture/netpol.yaml +4 -0
  334. platformforge/data/lab/scenarios/live-aws-collect/expected.yaml +4 -0
  335. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/ec2_describe-vpcs.json +15 -0
  336. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/iam_list-users.denied.txt +1 -0
  337. platformforge/data/lab/scenarios/live-aws-collect/fixture/aws/sts_get-caller-identity.json +5 -0
  338. platformforge/data/lab/scenarios/live-k8s-collect/expected.yaml +4 -0
  339. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_configmaps.denied.txt +1 -0
  340. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_deployments.apps.json +25 -0
  341. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_pods.json +17 -0
  342. platformforge/data/lab/scenarios/live-k8s-collect/fixture/kubectl/get_secrets.json +13 -0
  343. platformforge/data/lab/scenarios/live-kind/expected.yaml +14 -0
  344. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_configmaps.denied.txt +1 -0
  345. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_deployments.apps.json +25 -0
  346. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_pods.json +17 -0
  347. platformforge/data/lab/scenarios/live-kind/fixture/kubectl/get_secrets.json +13 -0
  348. platformforge/data/lab/scenarios/network-policy-missing/expected.yaml +6 -0
  349. platformforge/data/lab/scenarios/network-policy-missing/fixture/deploy.yaml +26 -0
  350. platformforge/data/lab/scenarios/ops-bad-rollout/expected.yaml +3 -0
  351. platformforge/data/lab/scenarios/ops-bad-rollout/fixture/ops.yaml +33 -0
  352. platformforge/data/lab/scenarios/ops-break-glass/expected.yaml +3 -0
  353. platformforge/data/lab/scenarios/ops-break-glass/fixture/ops.yaml +21 -0
  354. platformforge/data/lab/scenarios/ops-broken-selector/expected.yaml +3 -0
  355. platformforge/data/lab/scenarios/ops-broken-selector/fixture/ops.yaml +21 -0
  356. platformforge/data/lab/scenarios/ops-changed-plan-hash/expected.yaml +3 -0
  357. platformforge/data/lab/scenarios/ops-changed-plan-hash/fixture/ops.yaml +20 -0
  358. platformforge/data/lab/scenarios/ops-conflicting-operation/expected.yaml +3 -0
  359. platformforge/data/lab/scenarios/ops-conflicting-operation/fixture/ops.yaml +21 -0
  360. platformforge/data/lab/scenarios/ops-expired-approval/expected.yaml +3 -0
  361. platformforge/data/lab/scenarios/ops-expired-approval/fixture/ops.yaml +20 -0
  362. platformforge/data/lab/scenarios/ops-partial-failure/expected.yaml +3 -0
  363. platformforge/data/lab/scenarios/ops-partial-failure/fixture/ops.yaml +24 -0
  364. platformforge/data/lab/scenarios/ops-policy-denial/expected.yaml +3 -0
  365. platformforge/data/lab/scenarios/ops-policy-denial/fixture/ops.yaml +17 -0
  366. platformforge/data/lab/scenarios/ops-replica-drift/expected.yaml +3 -0
  367. platformforge/data/lab/scenarios/ops-replica-drift/fixture/ops.yaml +28 -0
  368. platformforge/data/lab/scenarios/ops-rollback-annotate/expected.yaml +3 -0
  369. platformforge/data/lab/scenarios/ops-rollback-annotate/fixture/ops.yaml +41 -0
  370. platformforge/data/lab/scenarios/ops-rollback-gitops/expected.yaml +3 -0
  371. platformforge/data/lab/scenarios/ops-rollback-gitops/fixture/ops.yaml +38 -0
  372. platformforge/data/lab/scenarios/ops-rollback-impossible/expected.yaml +3 -0
  373. platformforge/data/lab/scenarios/ops-rollback-impossible/fixture/ops.yaml +34 -0
  374. platformforge/data/lab/scenarios/ops-rollback-scale/expected.yaml +3 -0
  375. platformforge/data/lab/scenarios/ops-rollback-scale/fixture/ops.yaml +40 -0
  376. platformforge/data/lab/scenarios/ops-rollback-terraform/expected.yaml +3 -0
  377. platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/ops.yaml +38 -0
  378. platformforge/data/lab/scenarios/ops-rollback-terraform/fixture/tf/tfplan.bin +1 -0
  379. platformforge/data/lab/scenarios/ops-stale-observation/expected.yaml +3 -0
  380. platformforge/data/lab/scenarios/ops-stale-observation/fixture/ops.yaml +20 -0
  381. platformforge/data/lab/scenarios/secret-exposure/expected.yaml +5 -0
  382. platformforge/data/lab/scenarios/secret-exposure/fixture/app.env +2 -0
  383. platformforge/data/lab/scenarios/slo-burn/expected.yaml +5 -0
  384. platformforge/data/lab/scenarios/slo-burn/fixture/events.yaml +2 -0
  385. platformforge/data/lab/scenarios/slo-burn/fixture/slo.yaml +4 -0
  386. platformforge/data/lab/scenarios/terraform-iam-wildcard/expected.yaml +5 -0
  387. platformforge/data/lab/scenarios/terraform-iam-wildcard/fixture/policy.json +14 -0
  388. platformforge/data/lab/scenarios/terraform-public-bucket/expected.yaml +6 -0
  389. platformforge/data/lab/scenarios/terraform-public-bucket/fixture/main.tf +5 -0
  390. platformforge/data/portable_skills/platformforge-agents/SKILL.md +47 -0
  391. platformforge/data/portable_skills/platformforge-change/SKILL.md +41 -0
  392. platformforge/data/portable_skills/platformforge-core/SKILL.md +62 -0
  393. platformforge/data/portable_skills/platformforge-economy/SKILL.md +42 -0
  394. platformforge/data/portable_skills/platformforge-finops/SKILL.md +34 -0
  395. platformforge/data/portable_skills/platformforge-fleet/SKILL.md +39 -0
  396. platformforge/data/portable_skills/platformforge-governance/SKILL.md +76 -0
  397. platformforge/data/portable_skills/platformforge-graph/SKILL.md +46 -0
  398. platformforge/data/portable_skills/platformforge-lab/SKILL.md +39 -0
  399. platformforge/data/portable_skills/platformforge-sdd/SKILL.md +39 -0
  400. platformforge/data/portable_skills/platformforge-security/SKILL.md +38 -0
  401. platformforge/data/portable_skills/platformforge-sre/SKILL.md +41 -0
  402. platformforge/data/rules/catalog/finops.yaml +26 -0
  403. platformforge/data/rules/catalog/gitops.yaml +161 -0
  404. platformforge/data/rules/catalog/iac.yaml +84 -0
  405. platformforge/data/rules/catalog/k8s.yaml +322 -0
  406. platformforge/data/rules/catalog/orchestration.yaml +99 -0
  407. platformforge/data/rules/catalog/product.yaml +42 -0
  408. platformforge/data/rules/catalog/routing.yaml +122 -0
  409. platformforge/data/rules/catalog/security.yaml +159 -0
  410. platformforge/data/rules/catalog/sre.yaml +67 -0
  411. platformforge/diagnose/__init__.py +75 -0
  412. platformforge/distribution/__init__.py +18 -0
  413. platformforge/distribution/bundle.py +127 -0
  414. platformforge/distribution/hostflow.py +247 -0
  415. platformforge/distribution/models.py +80 -0
  416. platformforge/distribution/service.py +225 -0
  417. platformforge/economy/__init__.py +27 -0
  418. platformforge/economy/bench.py +66 -0
  419. platformforge/economy/budget.py +198 -0
  420. platformforge/economy/cache.py +287 -0
  421. platformforge/economy/checkpoint.py +112 -0
  422. platformforge/economy/collection.py +142 -0
  423. platformforge/economy/doctor.py +207 -0
  424. platformforge/economy/engine.py +158 -0
  425. platformforge/economy/envelope.py +61 -0
  426. platformforge/economy/fleetpack.py +86 -0
  427. platformforge/economy/ledger.py +232 -0
  428. platformforge/economy/plan.py +131 -0
  429. platformforge/economy/pricing.py +101 -0
  430. platformforge/economy/qpt.py +286 -0
  431. platformforge/economy/reconcile.py +86 -0
  432. platformforge/economy/verifyplan.py +107 -0
  433. platformforge/economy/waste.py +123 -0
  434. platformforge/evals/__init__.py +16 -0
  435. platformforge/evals/coverage.py +140 -0
  436. platformforge/evals/fleet_invariants.py +260 -0
  437. platformforge/evals/ops_invariants.py +158 -0
  438. platformforge/evals/runner.py +541 -0
  439. platformforge/federation/__init__.py +4 -0
  440. platformforge/federation/node.py +135 -0
  441. platformforge/finops/__init__.py +14 -0
  442. platformforge/finops/alloc.py +31 -0
  443. platformforge/finops/costs.py +79 -0
  444. platformforge/finops/focus.py +100 -0
  445. platformforge/finops/graph_cost.py +51 -0
  446. platformforge/finops/ingest.py +144 -0
  447. platformforge/finops/insights.py +193 -0
  448. platformforge/finops/unit.py +36 -0
  449. platformforge/fleet/__init__.py +5 -0
  450. platformforge/fleet/loader.py +73 -0
  451. platformforge/fleet/models.py +155 -0
  452. platformforge/fleet/orggraph.py +172 -0
  453. platformforge/fleet/query.py +154 -0
  454. platformforge/forge/__init__.py +9 -0
  455. platformforge/forge/collect.py +88 -0
  456. platformforge/forge/delegate.py +77 -0
  457. platformforge/forge/manifest.py +106 -0
  458. platformforge/freeze/__init__.py +22 -0
  459. platformforge/freeze/exceptions.py +56 -0
  460. platformforge/freeze/manifest.py +167 -0
  461. platformforge/freeze/snapshots.py +108 -0
  462. platformforge/graph/__init__.py +39 -0
  463. platformforge/graph/backend.py +174 -0
  464. platformforge/graph/bench.py +245 -0
  465. platformforge/graph/build.py +130 -0
  466. platformforge/graph/delivery.py +183 -0
  467. platformforge/graph/diff.py +292 -0
  468. platformforge/graph/events.py +116 -0
  469. platformforge/graph/migrate.py +44 -0
  470. platformforge/graph/model.py +264 -0
  471. platformforge/graph/persist.py +97 -0
  472. platformforge/graph/query.py +290 -0
  473. platformforge/graph/temporal.py +78 -0
  474. platformforge/graph/vocab.py +63 -0
  475. platformforge/graphview.py +94 -0
  476. platformforge/iac/__init__.py +7 -0
  477. platformforge/iac/drift.py +53 -0
  478. platformforge/iac/plan.py +84 -0
  479. platformforge/iac/terraform.py +123 -0
  480. platformforge/install/__init__.py +1 -0
  481. platformforge/install/service.py +421 -0
  482. platformforge/k8s/__init__.py +8 -0
  483. platformforge/k8s/helm.py +152 -0
  484. platformforge/k8s/hubble.py +87 -0
  485. platformforge/k8s/manifests.py +513 -0
  486. platformforge/knowledge/__init__.py +1 -0
  487. platformforge/knowledge/packs.py +157 -0
  488. platformforge/knowledge/registry.py +249 -0
  489. platformforge/knowledge/select.py +127 -0
  490. platformforge/lab/__init__.py +5 -0
  491. platformforge/lab/chaos.py +71 -0
  492. platformforge/lab/fleetlab.py +351 -0
  493. platformforge/lab/opslab.py +271 -0
  494. platformforge/lab/runner.py +219 -0
  495. platformforge/live/__init__.py +36 -0
  496. platformforge/live/budget.py +92 -0
  497. platformforge/live/capability.py +80 -0
  498. platformforge/live/collectors/__init__.py +6 -0
  499. platformforge/live/collectors/aws.py +370 -0
  500. platformforge/live/collectors/aws_transport.py +98 -0
  501. platformforge/live/collectors/k8s.py +389 -0
  502. platformforge/live/collectors/k8s_transport.py +85 -0
  503. platformforge/live/cursors.py +48 -0
  504. platformforge/live/drift.py +130 -0
  505. platformforge/live/envelope.py +85 -0
  506. platformforge/live/federation.py +113 -0
  507. platformforge/live/fixtures.py +168 -0
  508. platformforge/live/identity.py +258 -0
  509. platformforge/live/incident.py +200 -0
  510. platformforge/live/models.py +435 -0
  511. platformforge/live/reconcile.py +382 -0
  512. platformforge/live/remediate.py +169 -0
  513. platformforge/live/store.py +256 -0
  514. platformforge/live/topology.py +249 -0
  515. platformforge/mcp/__init__.py +7 -0
  516. platformforge/mcp/parity.py +69 -0
  517. platformforge/mcp/registry.py +427 -0
  518. platformforge/mcp/server.py +84 -0
  519. platformforge/mcp/tools.py +732 -0
  520. platformforge/models/__init__.py +21 -0
  521. platformforge/models/base.py +214 -0
  522. platformforge/observe/__init__.py +26 -0
  523. platformforge/observe/capacity.py +39 -0
  524. platformforge/observe/dr.py +67 -0
  525. platformforge/observe/grafana.py +76 -0
  526. platformforge/observe/incident.py +58 -0
  527. platformforge/observe/otel.py +135 -0
  528. platformforge/observe/postmortem.py +72 -0
  529. platformforge/observe/prometheus.py +88 -0
  530. platformforge/observe/slo.py +125 -0
  531. platformforge/ops/__init__.py +8 -0
  532. platformforge/ops/actions.py +215 -0
  533. platformforge/ops/analytics.py +83 -0
  534. platformforge/ops/approval.py +275 -0
  535. platformforge/ops/autorem.py +101 -0
  536. platformforge/ops/config.py +175 -0
  537. platformforge/ops/engine.py +758 -0
  538. platformforge/ops/envelope.py +135 -0
  539. platformforge/ops/executors/__init__.py +1 -0
  540. platformforge/ops/executors/base.py +211 -0
  541. platformforge/ops/executors/git.py +77 -0
  542. platformforge/ops/executors/gitops.py +134 -0
  543. platformforge/ops/executors/terraform.py +84 -0
  544. platformforge/ops/gates.py +135 -0
  545. platformforge/ops/goldenpath.py +180 -0
  546. platformforge/ops/material.py +181 -0
  547. platformforge/ops/models.py +421 -0
  548. platformforge/ops/operation.py +335 -0
  549. platformforge/ops/opgraph.py +233 -0
  550. platformforge/ops/policy.py +275 -0
  551. platformforge/ops/preconditions.py +109 -0
  552. platformforge/ops/registry.py +101 -0
  553. platformforge/ops/risk.py +216 -0
  554. platformforge/ops/rollback.py +287 -0
  555. platformforge/ops/rollback_builders.py +163 -0
  556. platformforge/ops/runbook.py +269 -0
  557. platformforge/ops/simulate.py +125 -0
  558. platformforge/ops/source_of_truth.py +198 -0
  559. platformforge/ops/store.py +157 -0
  560. platformforge/ops/verify.py +190 -0
  561. platformforge/optimize/__init__.py +4 -0
  562. platformforge/optimize/engine.py +109 -0
  563. platformforge/optimize/models.py +68 -0
  564. platformforge/optimize/scan.py +196 -0
  565. platformforge/plan/__init__.py +107 -0
  566. platformforge/portable_cli.py +199 -0
  567. platformforge/portal_adapter.py +740 -0
  568. platformforge/portal_assets/chunk-7_l-b4Tr.js +1 -0
  569. platformforge/portal_assets/chunk-B0Iad6pD.js +1 -0
  570. platformforge/portal_assets/chunk-B605XnCF.js +1 -0
  571. platformforge/portal_assets/chunk-B7N3TXcn.js +1 -0
  572. platformforge/portal_assets/chunk-BAj6yGem.js +1 -0
  573. platformforge/portal_assets/chunk-BGUitn2r.js +1 -0
  574. platformforge/portal_assets/chunk-BVKnCv85.js +1 -0
  575. platformforge/portal_assets/chunk-BYyM9V_w.js +1 -0
  576. platformforge/portal_assets/chunk-BaQMhq-G.js +1 -0
  577. platformforge/portal_assets/chunk-BavOrxVm.js +1 -0
  578. platformforge/portal_assets/chunk-Bb0C24YV.js +1 -0
  579. platformforge/portal_assets/chunk-BfCFGPzs.js +1 -0
  580. platformforge/portal_assets/chunk-C2t4OUVu.js +1 -0
  581. platformforge/portal_assets/chunk-C3pI4QjU.js +1 -0
  582. platformforge/portal_assets/chunk-C4gpbtbP.js +1 -0
  583. platformforge/portal_assets/chunk-CGWeHhKY.js +1 -0
  584. platformforge/portal_assets/chunk-CJCT86Dr.js +1 -0
  585. platformforge/portal_assets/chunk-CXcsLDPJ.js +1 -0
  586. platformforge/portal_assets/chunk-CcjxoLxw.js +1 -0
  587. platformforge/portal_assets/chunk-D-gBPXHa.js +1 -0
  588. platformforge/portal_assets/chunk-D3eZogIe.js +1 -0
  589. platformforge/portal_assets/chunk-DQU0VyoQ.js +1 -0
  590. platformforge/portal_assets/chunk-DqWNkF1O.js +1 -0
  591. platformforge/portal_assets/chunk-DuPPkSIE.js +3 -0
  592. platformforge/portal_assets/chunk-DyIHOW_a.js +1 -0
  593. platformforge/portal_assets/chunk-Fx4zSGL5.js +1 -0
  594. platformforge/portal_assets/chunk-UBKt05Rg.js +1 -0
  595. platformforge/portal_assets/chunk-htnJDQEY.js +1 -0
  596. platformforge/portal_assets/chunk-kXRFMGqe.js +1 -0
  597. platformforge/portal_assets/chunk-qe3u763a.js +1 -0
  598. platformforge/portal_assets/favicon.ico +0 -0
  599. platformforge/portal_assets/index.html +13 -0
  600. platformforge/portal_assets/main-QDFVZK3T.js +5 -0
  601. platformforge/portal_assets/styles-MQEBDFDX.css +1 -0
  602. platformforge/product/__init__.py +10 -0
  603. platformforge/product/backstage.py +49 -0
  604. platformforge/product/catalog.py +74 -0
  605. platformforge/product/crossplane.py +341 -0
  606. platformforge/product/golden_paths/__init__.py +5 -0
  607. platformforge/product/golden_paths/engine.py +97 -0
  608. platformforge/product/golden_paths/library.yaml +198 -0
  609. platformforge/product/golden_paths/model.py +84 -0
  610. platformforge/product/maturity.py +107 -0
  611. platformforge/product/scorecards.py +84 -0
  612. platformforge/resources.py +36 -0
  613. platformforge/risk/__init__.py +5 -0
  614. platformforge/risk/engine.py +95 -0
  615. platformforge/risk/signals.py +49 -0
  616. platformforge/routing/__init__.py +13 -0
  617. platformforge/routing/decision.py +191 -0
  618. platformforge/routing/router.py +328 -0
  619. platformforge/rtk/__init__.py +9 -0
  620. platformforge/rtk/compact.py +127 -0
  621. platformforge/rtk/parsers.py +199 -0
  622. platformforge/rules/__init__.py +5 -0
  623. platformforge/rules/engine.py +345 -0
  624. platformforge/sandbox/__init__.py +7 -0
  625. platformforge/sandbox/env.py +151 -0
  626. platformforge/sandbox/review.py +92 -0
  627. platformforge/sdd/__init__.py +8 -0
  628. platformforge/sdd/lifecycle.py +239 -0
  629. platformforge/security/__init__.py +19 -0
  630. platformforge/security/cosign.py +67 -0
  631. platformforge/security/iam.py +112 -0
  632. platformforge/security/identity.py +84 -0
  633. platformforge/security/kyverno.py +106 -0
  634. platformforge/security/sbom.py +59 -0
  635. platformforge/security/scan.py +63 -0
  636. platformforge/security/slsa.py +86 -0
  637. platformforge/security/supply.py +62 -0
  638. platformforge/tokensave/__init__.py +20 -0
  639. platformforge/tokensave/budget.py +62 -0
  640. platformforge/tokensave/estimate.py +21 -0
  641. platformforge/tokensave/index.py +172 -0
  642. platformforge/tokensave/ledger.py +121 -0
  643. platformforge/tokensave/packs.py +285 -0
  644. platformforge/ui/__init__.py +28 -0
  645. platformforge/ui/app.py +465 -0
  646. platformforge/ui/home.py +113 -0
  647. platformforge/ui/i18n.py +143 -0
  648. platformforge/ui/kit.py +689 -0
  649. platformforge/ui/screen.py +628 -0
  650. platformforge/ui/tui.py +94 -0
  651. platformforge/ui/wizard.py +309 -0
  652. platformforge/workspace/__init__.py +13 -0
  653. platformforge/workspace/models.py +35 -0
  654. platformforge/workspace/service.py +69 -0
  655. platformforge-0.1.0.dist-info/METADATA +212 -0
  656. platformforge-0.1.0.dist-info/RECORD +659 -0
  657. platformforge-0.1.0.dist-info/WHEEL +4 -0
  658. platformforge-0.1.0.dist-info/entry_points.txt +3 -0
  659. platformforge-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3832 @@
1
+ """platformforge CLI — thin adapter over the core. No logic lives here.
2
+
3
+ Every verb supports: --json --output FILE --detail-level summary|normal|full
4
+ --offline --strict. Flags have real contracts: --strict turns unresolved into
5
+ exit 2; --offline forbids adapter calls that need network.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from platformforge import __version__
18
+
19
+ if TYPE_CHECKING:
20
+ from platformforge.tokensave.index import SearchIndex
21
+
22
+ DETAIL_LEVELS = ("summary", "normal", "full")
23
+
24
+ from platformforge.resources import data_path
25
+
26
+ REPO_ROOT = data_path()
27
+
28
+
29
+ _DETAIL_LIST_CAP = {"summary": 3, "normal": 50, "full": None}
30
+ _SUMMARY_KEYS = {
31
+ "status",
32
+ "verdict",
33
+ "ok",
34
+ "passed",
35
+ "failed",
36
+ "exit_code",
37
+ "severity",
38
+ "rule_id",
39
+ "finding_id",
40
+ "level",
41
+ "risk",
42
+ "counts",
43
+ "coverage",
44
+ "summary",
45
+ "unresolved",
46
+ "refused",
47
+ "refusals",
48
+ "skipped",
49
+ }
50
+
51
+
52
+ def _detail_bound(obj: Any, level: str, depth: int = 0) -> Any:
53
+ """§108 — detail levels bound payload shape, deterministically.
54
+ summary: scalar summary fields + counts + ≤3 list items; normal: ≤50
55
+ list items; full: unbounded."""
56
+ cap = _DETAIL_LIST_CAP.get(level)
57
+ if cap is None:
58
+ return obj
59
+ if isinstance(obj, list):
60
+ if len(obj) > cap:
61
+ return [_detail_bound(x, level, depth + 1) for x in obj[:cap]] + [{"_truncated": len(obj) - cap}]
62
+ return [_detail_bound(x, level, depth + 1) for x in obj]
63
+ if isinstance(obj, dict):
64
+ if level == "summary" and depth > 0:
65
+ keep = {k: v for k, v in obj.items() if k in _SUMMARY_KEYS}
66
+ extra = {k for k in obj if k not in _SUMMARY_KEYS}
67
+ if extra:
68
+ keep["_elided_keys"] = sorted(extra)
69
+ return {k: _detail_bound(v, level, depth + 1) for k, v in keep.items()}
70
+ return {k: _detail_bound(v, level, depth + 1) for k, v in obj.items()}
71
+ return obj
72
+
73
+
74
+ def _has_unresolved(obj: Any) -> bool:
75
+ if isinstance(obj, dict):
76
+ for k, v in obj.items():
77
+ if k in ("unresolved", "refused", "refusals") and v:
78
+ return True
79
+ # findings/refusals carry status fields — strict must catch them
80
+ if k == "status" and v in ("unresolved", "refused"):
81
+ return True
82
+ if _has_unresolved(v):
83
+ return True
84
+ elif isinstance(obj, list):
85
+ return any(_has_unresolved(x) for x in obj)
86
+ return False
87
+
88
+
89
+ _RUN_STARTED = 0.0
90
+
91
+
92
+ def _pf_code(code: str) -> str:
93
+ """Canonical PF-* alias for a legacy `platform.*` refusal code —
94
+ the repo contract requires a PF-* code on every refusal; dotted
95
+ codes are preserved for compat and aliased deterministically."""
96
+ if not isinstance(code, str) or not code.startswith("platform."):
97
+ return code
98
+ body = code[len("platform.") :].replace("_", "-").replace(".", "-")
99
+ return "PF-" + body.upper()
100
+
101
+
102
+ def _annotate_pf(obj: Any) -> Any:
103
+ """Attach pf_code next to every legacy refusal code in a payload."""
104
+ if isinstance(obj, dict):
105
+ out = {}
106
+ for k, v in obj.items():
107
+ if k in ("refusal", "unresolved") and isinstance(v, str) and v.startswith("platform."):
108
+ out[k] = v
109
+ out["pf_code"] = _pf_code(v)
110
+ elif k in ("refusals",) and isinstance(v, list):
111
+ out[k] = [
112
+ (
113
+ {**e, "pf_code": _pf_code(e.get("code", ""))}
114
+ if isinstance(e, dict) and str(e.get("code", "")).startswith("platform.")
115
+ else e
116
+ )
117
+ for e in v
118
+ ]
119
+ else:
120
+ out[k] = _annotate_pf(v)
121
+ return out
122
+ if isinstance(obj, list):
123
+ return [_annotate_pf(x) for x in obj]
124
+ return obj
125
+
126
+
127
+ def _emit(result: Any, args: argparse.Namespace, exit_code: int = 0) -> int:
128
+ level = getattr(args, "detail_level", "normal")
129
+ shown = result if level == "full" else _detail_bound(result, level)
130
+ if isinstance(shown, dict):
131
+ shown = _annotate_pf(shown)
132
+ if getattr(args, "offline", False) and isinstance(shown, dict):
133
+ shown = {**shown, "offline": True}
134
+ # §110 — strict: any unresolved/refused in the payload exits 2.
135
+ if getattr(args, "strict", False) and exit_code == 0 and _has_unresolved(result):
136
+ exit_code = 2
137
+ text = json.dumps(shown, indent=2, sort_keys=True, default=str)
138
+ if getattr(args, "output", None):
139
+ Path(args.output).write_text(text + "\n")
140
+ _write_receipt(result, text, args, exit_code)
141
+ if _wants_human_doc(shown):
142
+ from platformforge.ui.kit import UIContext, format_status_doc
143
+
144
+ print(format_status_doc(shown, ctx=UIContext.detect()))
145
+ return exit_code
146
+ print(text)
147
+ return exit_code
148
+
149
+
150
+ def _wants_human_doc(shown: Any) -> bool:
151
+ """forge/* install docs render through the shared kit for humans —
152
+ on a real TTY, or in a captured TUI subprocess via FORGE_TEXT_OUTPUT.
153
+ Everything else (and all pipes/CI) keeps the JSON contract."""
154
+ import os
155
+
156
+ if not isinstance(shown, dict):
157
+ return False
158
+ if not (str(shown.get("schema", "")).startswith("forge/") or "checks" in shown or "drift" in shown):
159
+ return False
160
+ try:
161
+ return sys.stdout.isatty() or bool(os.environ.get("FORGE_TEXT_OUTPUT"))
162
+ except OSError:
163
+ return False
164
+
165
+
166
+ def _write_receipt(result: Any, text: str, args: argparse.Namespace, exit_code: int) -> None:
167
+ """§152/§156 — every operation emits an auditable receipt under
168
+ .platformforge/receipts/. Never breaks the command on failure."""
169
+ try:
170
+ repo = getattr(args, "repo", "") or ""
171
+ if not repo:
172
+ return
173
+ import hashlib
174
+
175
+ from platformforge import __version__
176
+ from platformforge.core.receipts import Receipt, ReceiptWriter
177
+
178
+ cmd = getattr(args, "command", "")
179
+ sub = getattr(args, f"{cmd}_cmd", "") if cmd else ""
180
+ facts = result.get("facts", []) if isinstance(result, dict) else []
181
+ findings = result.get("findings", []) if isinstance(result, dict) else []
182
+ rec = Receipt(
183
+ operation=f"cli.{cmd}.{sub}" if sub else f"cli.{cmd}",
184
+ inputs=[
185
+ str(v)
186
+ for a, v in sorted(vars(args).items())
187
+ if a
188
+ in (
189
+ "path",
190
+ "spec",
191
+ "intent",
192
+ "plan",
193
+ "policies",
194
+ "desired",
195
+ "observed",
196
+ "planned",
197
+ "facts",
198
+ "output",
199
+ )
200
+ and v
201
+ ],
202
+ hashes={"output": "sha256:" + hashlib.sha256(text.encode()).hexdigest()},
203
+ versions={"platformforge": __version__},
204
+ facts=[f.get("fact_id", "") for f in facts if isinstance(f, dict)][:200],
205
+ findings=[f.get("finding_id", "") for f in findings if isinstance(f, dict)][:200],
206
+ cost={"output_bytes": len(text)},
207
+ started_at=_RUN_STARTED or None,
208
+ ended_at=time.time(),
209
+ extra={
210
+ "exit_code": exit_code,
211
+ "refusal": (result or {}).get("refusal") if isinstance(result, dict) else None,
212
+ },
213
+ )
214
+ ReceiptWriter(repo).emit(rec)
215
+ except OSError:
216
+ pass # receipts must never break the verb they observe
217
+
218
+
219
+ def _add_common(p: argparse.ArgumentParser) -> None:
220
+ p.add_argument("--json", action="store_true", default=True, help="structured output (default)")
221
+ p.add_argument("--output", metavar="FILE", help="write result to FILE")
222
+ p.add_argument(
223
+ "--detail-level", choices=DETAIL_LEVELS, default="normal", help="summary|normal|full payload bounding"
224
+ )
225
+ p.add_argument("--offline", action="store_true", help="forbid any network-capable adapter")
226
+ p.add_argument("--strict", action="store_true", help="unresolved/refusals -> exit code 2")
227
+ p.add_argument("--repo", default=".", help="workspace/repo root")
228
+
229
+
230
+ def cmd_init(args: argparse.Namespace) -> int:
231
+ from platformforge.core.workspace import init_workspace
232
+
233
+ pf = init_workspace(args.repo, name=args.name)
234
+ return _emit({"initialized": str(pf)}, args)
235
+
236
+
237
+ def cmd_doctor(args: argparse.Namespace) -> int:
238
+ """§141 — Forge-Doctor-ready health surface.
239
+
240
+ `--deep` validates the contracts a Forge Doctor could check: install,
241
+ config, capability manifest, knowledge freshness, host parity, graph
242
+ integrity, index health and the artifact store — all read-only."""
243
+ import platform
244
+
245
+ checks: dict[str, Any] = {
246
+ "python": platform.python_version(),
247
+ "platformforge": __version__,
248
+ "pyyaml": _mod_version("yaml"),
249
+ "jsonschema": _mod_version("jsonschema"),
250
+ "python-hcl2": _mod_version("hcl2"),
251
+ "mcp_adapter": _mod_version("mcp", optional=True),
252
+ "boto3": _mod_version("boto3", optional=True),
253
+ }
254
+ ok = all(v != "missing" for k, v in checks.items() if k in ("pyyaml", "jsonschema", "python-hcl2"))
255
+ detail: dict[str, Any] = {}
256
+ if getattr(args, "deep", False):
257
+ detail = _doctor_deep(args)
258
+ ok = ok and all(v.get("ok", True) for v in detail.values())
259
+ return _emit({"ok": ok, "checks": checks, "deep": detail}, args, 0 if ok else 1)
260
+
261
+
262
+ def _doctor_deep(args: argparse.Namespace) -> dict[str, Any]:
263
+ out: dict[str, Any] = {}
264
+ from platformforge.core.workspace import load_workspace
265
+
266
+ ws = load_workspace(getattr(args, "repo", "."))
267
+ # config
268
+ out["config"] = {
269
+ "ok": True,
270
+ "workspace": ws.name,
271
+ "multi_repo": ws.is_multi_repo,
272
+ "members": len(ws.members) or 1,
273
+ }
274
+ # capability manifest
275
+ try:
276
+ from platformforge.forge import capability_manifest
277
+
278
+ m = capability_manifest(args.repo)
279
+ out["capability_manifest"] = {
280
+ "ok": bool(m.get("tools")),
281
+ "tools": len(m.get("tools", [])),
282
+ "rules": m.get("rule_count"),
283
+ }
284
+ except (OSError, ValueError, KeyError) as e:
285
+ out["capability_manifest"] = {"ok": False, "error": str(e)}
286
+ # knowledge freshness
287
+ try:
288
+ from platformforge.knowledge.registry import SourceRegistry
289
+
290
+ reg = SourceRegistry.default()
291
+ check = reg.check()
292
+ stale = [c["id"] for c in check if c["status"] in ("stale", "unresolved", "conflicted")]
293
+ out["knowledge_freshness"] = {
294
+ "ok": True,
295
+ "entries": len(check),
296
+ "attention": stale,
297
+ "contract": reg.contract_check(),
298
+ }
299
+ except (OSError, ValueError, KeyError) as e:
300
+ out["knowledge_freshness"] = {"ok": False, "error": str(e)}
301
+ # host parity (host adapter files present?)
302
+ parity = {}
303
+ for host, marker in (("claude", ".claude"), ("devin", ".devin"), ("agents", ".agents")):
304
+ parity[host] = (ws.root / marker).exists()
305
+ out["host_parity"] = {"ok": True, "adapters": parity}
306
+ # index health (tokensave db)
307
+ idx = ws.pf_dir / "index.db"
308
+ out["index_health"] = {
309
+ "ok": True,
310
+ "present": idx.is_file(),
311
+ "bytes": idx.stat().st_size if idx.is_file() else 0,
312
+ }
313
+ # artifact store
314
+ try:
315
+ from platformforge.core.store import ArtifactStore
316
+
317
+ st = ArtifactStore(ws.root).stats()
318
+ out["artifact_store"] = {"ok": True, **st}
319
+ except (OSError, ValueError, KeyError) as e:
320
+ out["artifact_store"] = {"ok": False, "error": str(e)}
321
+ return out
322
+
323
+
324
+ _DIST_NAMES = {"yaml": "PyYAML", "hcl2": "python-hcl2"}
325
+
326
+
327
+ def _mod_version(name: str, optional: bool = False) -> str:
328
+ import importlib.metadata
329
+
330
+ try:
331
+ __import__(name)
332
+ except ImportError:
333
+ return "not-installed" if optional else "missing"
334
+ try:
335
+ return importlib.metadata.version(_DIST_NAMES.get(name, name))
336
+ except importlib.metadata.PackageNotFoundError:
337
+ return "installed"
338
+
339
+
340
+ def cmd_status(args: argparse.Namespace) -> int:
341
+ from platformforge.core.store import ArtifactStore
342
+ from platformforge.core.workspace import load_workspace
343
+
344
+ ws = load_workspace(args.repo)
345
+ store = ArtifactStore(ws.root)
346
+ return _emit(
347
+ {
348
+ "workspace": ws.name,
349
+ "root": str(ws.root),
350
+ "multi_repo": ws.is_multi_repo,
351
+ "members": [m.name for m in ws.members] or [ws.name],
352
+ "store": store.stats(),
353
+ },
354
+ args,
355
+ )
356
+
357
+
358
+ def _member_dirs(path: str | Path) -> list[tuple[str, Path]]:
359
+ """§126 — a workspace.yaml root expands to its member repos."""
360
+ from platformforge.core.workspace import load_workspace
361
+
362
+ ws = load_workspace(path)
363
+ if ws.is_multi_repo:
364
+ return [(m.name, p) for m, p in zip(ws.members, ws.member_paths(), strict=True) if p.is_dir()]
365
+ return [("", Path(path))]
366
+
367
+
368
+ def _run_over_members(fn, path: str | Path) -> dict[str, Any]:
369
+ """Run an analyzer per workspace member; facts get `member` tagged."""
370
+ members = _member_dirs(path)
371
+ if len(members) == 1 and not members[0][0]:
372
+ return fn(str(members[0][1]))
373
+ facts, errors = [], []
374
+ for name, p in members:
375
+ try:
376
+ for f in fn(str(p))["facts"]:
377
+ f.setdefault("attrs", {})["workspace_member"] = name
378
+ facts.append(f)
379
+ except Exception as e: # noqa: BLE001 — member failure is data
380
+ errors.append(f"{name or p.name}: {e}")
381
+ out: dict[str, Any] = {"facts": facts, "workspace": {n: str(p) for n, p in members}}
382
+ if errors:
383
+ out["errors"] = errors
384
+ return out
385
+
386
+
387
+ def cmd_inspect(args: argparse.Namespace) -> int:
388
+ """Inventory analyzable artifacts under a root — no content parsing."""
389
+ root = Path(args.repo).resolve()
390
+ members = _member_dirs(root)
391
+ kinds = {
392
+ "terraform": ("*.tf", "*.tf.json", "*.tfvars"),
393
+ "kubernetes": ("*.yaml", "*.yml"),
394
+ "helm": ("Chart.yaml", "values*.yaml"),
395
+ "kustomize": ("kustomization.yaml", "kustomization.yml"),
396
+ "github_actions": (".github/workflows/*.yml", ".github/workflows/*.yaml"),
397
+ "gitlab_ci": (".gitlab-ci.yml",),
398
+ "argocd": ("*.yaml",), # kind-filtered at analyze time
399
+ "dockerfile": ("Dockerfile*",),
400
+ "backstage": ("catalog-info.yaml", "catalog-info.yml"),
401
+ "crossplane": ("*.yaml",),
402
+ }
403
+ inventory: dict[str, list[str]] = {k: [] for k in kinds}
404
+ ignore = {".git", ".venv", "node_modules", ".platformforge", "vendor", "__pycache__", "dist", "build"}
405
+ for mname, mroot in members:
406
+ for p in sorted(mroot.rglob("*")):
407
+ if not p.is_file() or any(part in ignore for part in p.parts):
408
+ continue
409
+ rel = f"{mname}/{p.relative_to(mroot)}" if mname else str(p.relative_to(mroot))
410
+ for kind, pats in kinds.items():
411
+ if any(p.match(pat) or rel.endswith(pat.lstrip("*")) for pat in pats):
412
+ inventory[kind].append(rel)
413
+ inventory = {k: v for k, v in inventory.items() if v}
414
+ return _emit(
415
+ {
416
+ "root": str(root),
417
+ "artifacts": inventory,
418
+ "members": [n or str(p) for n, p in members],
419
+ "counts": {k: len(v) for k, v in inventory.items()},
420
+ },
421
+ args,
422
+ )
423
+
424
+
425
+ def cmd_judge(args: argparse.Namespace) -> int:
426
+ """Apply rule catalog to a facts JSON document (or analyzed facts file)."""
427
+ from platformforge.models import Fact
428
+ from platformforge.rules import RuleEngine, load_catalog
429
+
430
+ facts_doc = json.loads(Path(args.facts).read_text())
431
+ facts, skipped_inputs = [], []
432
+ for f in facts_doc.get("facts", facts_doc):
433
+ try:
434
+ facts.append(Fact.from_dict(f))
435
+ except (TypeError, ValueError, KeyError):
436
+ skipped_inputs.append(f)
437
+ versions = facts_doc.get("versions", {}) if isinstance(facts_doc, dict) else {}
438
+ if getattr(args, "versions", ""):
439
+ versions = {**versions, **json.loads(args.versions)}
440
+ catalog_dirs = args.catalog or [data_path("rules", "catalog")]
441
+ rules = load_catalog(*catalog_dirs)
442
+ findings, skipped = RuleEngine(rules, versions).evaluate(facts)
443
+ out = {
444
+ "findings": [f.to_dict() for f in findings],
445
+ "skipped": skipped,
446
+ "counts": {
447
+ "facts": len(facts),
448
+ "rules": len(rules),
449
+ "violated": sum(1 for f in findings if f.status == "violated"),
450
+ "skipped_inputs": len(skipped_inputs),
451
+ },
452
+ "skipped_facts": skipped_inputs,
453
+ }
454
+ unresolved = any(f.status == "unresolved" for f in findings)
455
+ return _emit(out, args, 2 if (args.strict and unresolved) else 0)
456
+
457
+
458
+ def cmd_knowledge(args: argparse.Namespace) -> int:
459
+ from platformforge.knowledge.registry import SourceRegistry
460
+
461
+ reg = SourceRegistry.default()
462
+ sub = getattr(args, "knowledge_cmd", "check") or "check"
463
+ if sub == "contract":
464
+ out = reg.contract_check()
465
+ return _emit(out, args, 2 if (args.strict and not out["ok"]) else 0)
466
+ if sub == "drift":
467
+ out = reg.drift_report(data_path("rules", "catalog"))
468
+ return _emit(out, args)
469
+ if sub == "packs":
470
+ from platformforge.knowledge.packs import PackRegistry
471
+
472
+ packs = PackRegistry.default()
473
+ out = {
474
+ "packs": [p.to_dict() for p in sorted(packs.packs.values(), key=lambda x: x.pack_id)],
475
+ "contract": packs.contract_check(),
476
+ }
477
+ return _emit(out, args, 2 if (args.strict and not out["contract"]["ok"]) else 0)
478
+ report = reg.check()
479
+ bad = [
480
+ r for r in report if r["status"] in ("stale", "unresolved", "conflicted", "deprecated", "superseded")
481
+ ]
482
+ return _emit(
483
+ {"sources": report, "attention": bad, "counts": {"total": len(report), "attention": len(bad)}},
484
+ args,
485
+ 2 if (args.strict and bad) else 0,
486
+ )
487
+
488
+
489
+ def _index(args) -> SearchIndex:
490
+ from platformforge.tokensave.index import SearchIndex
491
+
492
+ return SearchIndex(Path(args.repo) / ".platformforge" / "index.db")
493
+
494
+
495
+ def cmd_tokens(args: argparse.Namespace) -> int:
496
+ from platformforge.tokensave.budget import Budget
497
+ from platformforge.tokensave.ledger import TokenLedger
498
+ from platformforge.tokensave.packs import ContextPackBuilder
499
+
500
+ idx = _index(args)
501
+ out = {}
502
+ if args.tokens_cmd == "index":
503
+ out = {"index": idx.index_workspace(args.repo)}
504
+ elif args.tokens_cmd == "search":
505
+ mode = getattr(args, "mode", "literal") or "literal"
506
+ if mode == "regex":
507
+ out = {"hits": idx.search_regex(args.query, args.limit)}
508
+ elif mode == "symbol":
509
+ out = {"hits": idx.symbol(args.query)}
510
+ elif mode == "path":
511
+ out = {"hits": [{"path": p} for p in idx.by_path(args.query)]}
512
+ elif mode == "kind":
513
+ out = {"hits": [{"path": p} for p in idx.by_kind(args.query)]}
514
+ else:
515
+ out = {"hits": idx.search(args.query, args.limit)}
516
+ out["mode"] = mode
517
+ elif args.tokens_cmd == "pack":
518
+ ledger = TokenLedger(args.repo)
519
+ prev_files = None
520
+ if getattr(args, "prev_pack", None):
521
+ try:
522
+ prev = json.loads(Path(args.prev_pack).read_text())
523
+ prev_files = {
524
+ f["path"]: f.get("sha256", "")
525
+ for f in prev.get("relevant_files", [])
526
+ if isinstance(f, dict)
527
+ }
528
+ except (OSError, json.JSONDecodeError):
529
+ prev_files = None
530
+ pack = ContextPackBuilder(idx, ledger).build(
531
+ task=args.task,
532
+ budget=Budget(input_budget=args.input_budget),
533
+ changed_files=args.changed or [],
534
+ graph_neighborhood=getattr(args, "graph_nodes", None) or None,
535
+ risk=getattr(args, "risk", None) or None,
536
+ previous_pack_hash=getattr(args, "prev_pack", None) or None,
537
+ previous_files=prev_files,
538
+ allow_escalate=getattr(args, "allow_escalate", False),
539
+ )
540
+ out = pack
541
+ elif args.tokens_cmd == "delta":
542
+ ledger = TokenLedger(args.repo)
543
+ out = ContextPackBuilder(idx, ledger).delta_for_change(
544
+ changed_files=args.changed or [],
545
+ budget=Budget(input_budget=args.input_budget),
546
+ previous_pack_hash=getattr(args, "prev_pack", None) or None,
547
+ risk=getattr(args, "risk", None) or None,
548
+ )
549
+ elif args.tokens_cmd == "stats":
550
+ out = idx.stats() | {"fingerprint": idx.fingerprint()}
551
+ elif args.tokens_cmd == "ledger":
552
+ out = TokenLedger(args.repo).report()
553
+ return _emit(out, args)
554
+
555
+
556
+ def cmd_rtk(args: argparse.Namespace) -> int:
557
+ """Compact a command output file (or stdin) — never run unless asked."""
558
+ from platformforge.core.store import ArtifactStore
559
+ from platformforge.rtk import compact_output
560
+
561
+ if args.file == "-":
562
+ output = sys.stdin.read()
563
+ else:
564
+ output = Path(args.file).read_text(errors="replace")
565
+ store = ArtifactStore(args.repo)
566
+ res = compact_output(args.command or "", output, exit_code=args.exit_code, store=store)
567
+ return _emit(res.to_dict(), args)
568
+
569
+
570
+ def cmd_rtk_expand(args: argparse.Namespace) -> int:
571
+ from platformforge.core.store import ArtifactStore
572
+ from platformforge.rtk.compact import expand
573
+
574
+ out = expand(
575
+ ArtifactStore(args.repo), args.artifact, start=args.start, end=args.end, pattern=args.pattern
576
+ )
577
+ return _emit(out, args)
578
+
579
+
580
+ def cmd_caveman(args: argparse.Namespace) -> int:
581
+ from platformforge.caveman import compress
582
+
583
+ text = Path(args.file).read_text() if args.file != "-" else sys.stdin.read()
584
+ out, receipt = compress(text, mode=args.mode, context_risk=args.context_risk)
585
+ return _emit({"compressed": out, "receipt": receipt.to_dict()}, args)
586
+
587
+
588
+ def cmd_store(args: argparse.Namespace) -> int:
589
+ """§151 — store stats / gc. GC is dry-run unless --execute."""
590
+ import re
591
+
592
+ from platformforge.core.store import ArtifactStore
593
+
594
+ store = ArtifactStore(args.repo)
595
+ if args.store_cmd == "stats":
596
+ return _emit({"store": store.stats()}, args)
597
+ # collect hashes referenced anywhere under .platformforge/ (receipts,
598
+ # packs, ledger) — referenced artifacts are never GC candidates
599
+ referenced: set[str] = set()
600
+ root = Path(args.repo) / ".platformforge"
601
+ pat = re.compile(r"artifact://sha256/([0-9a-f]{64})")
602
+ for doc in root.rglob("*.json") if root.exists() else []:
603
+ if "store" in doc.parts:
604
+ continue
605
+ referenced.update(pat.findall(doc.read_text(errors="replace")))
606
+ out = store.gc(keep_days=args.keep_days, referenced=referenced, dry_run=not args.execute)
607
+ return _emit(out, args)
608
+
609
+
610
+ def cmd_bench(args: argparse.Namespace) -> int:
611
+ """§148–150 — measured benchmarks over the eval corpus fixtures."""
612
+ from platformforge import bench
613
+
614
+ if args.bench_cmd == "scale":
615
+ # cycle5 §317 — controlled-size graph + analytics store bench
616
+ from platformforge.graph.bench import run_scale_benchmarks
617
+
618
+ sizes = tuple(int(s) for s in (args.sizes or "50,200,800").split(","))
619
+ edges = tuple(int(s) for s in (args.edges or "100000,250000,500000").split(","))
620
+ return _emit(run_scale_benchmarks(sizes, edge_targets=edges, store_sweep=args.events), args)
621
+ fn = {"run": bench.run_benchmark, "tokens": bench.token_benchmark}[args.bench_cmd]
622
+ return _emit(fn(repeat=args.repeat), args)
623
+
624
+
625
+ def cmd_freeze(args: argparse.Namespace) -> int:
626
+ """Freeze governance — manifest, snapshots, drift check, exceptions."""
627
+ import pathlib
628
+
629
+ from platformforge import freeze as fz
630
+ from platformforge.freeze.manifest import render_markdown
631
+
632
+ if args.freeze_cmd == "manifest":
633
+ out = args.out or "docs/freeze/FREEZE-MANIFEST.md"
634
+ m = fz.build_manifest()
635
+ if args.out == "-" or (not args.out and args.path == "-"):
636
+ return _emit(m, args)
637
+ pathlib.Path(out).parent.mkdir(parents=True, exist_ok=True)
638
+ pathlib.Path(out).write_text(render_markdown(m), encoding="utf-8")
639
+ return _emit(
640
+ {"written": out, "sha": m["freeze_start_sha"], "agents": m["agent_contracts"]["count"]}, args
641
+ )
642
+ if args.freeze_cmd == "snapshot":
643
+ out = fz.write_snapshots(repo=args.path or ".", out=args.out or "docs/freeze/snapshots")
644
+ return _emit({"written": str(out)}, args)
645
+ if args.freeze_cmd == "check":
646
+ r = fz.check_snapshots(repo=args.path or ".", snap_dir=args.out or "docs/freeze/snapshots")
647
+ return _emit(r, args, 0 if r["verdict"] == "pass" else 1)
648
+ if args.freeze_cmd == "exception":
649
+ import json as _json
650
+
651
+ if not args.json_file:
652
+ return _emit(
653
+ {"refusal": "PF-FREEZE-EXCEPTION-INPUT", "unlock": "freeze exception --spec <file>"}, args, 2
654
+ )
655
+ data = _json.loads(pathlib.Path(args.json_file).read_text())
656
+ errs = fz.exception_errors(data)
657
+ return _emit({"valid": not errs, "errors": errs}, args, 0 if not errs else 1)
658
+ return 2
659
+
660
+
661
+ def cmd_cases(args: argparse.Namespace) -> int:
662
+ """§52–§68 — real-world case corpus: list/validate/replay/template."""
663
+ from platformforge.cases.casefile import discover_cases
664
+ from platformforge.cases.replay import replay_all, validate_corpus, write_case_template
665
+
666
+ root = args.path or ".platformforge/cases"
667
+ if args.cases_cmd == "list":
668
+ return _emit({"cases": [c.to_dict() for c in discover_cases(root)]}, args)
669
+ if args.cases_cmd == "validate":
670
+ r = validate_corpus(root)
671
+ return _emit(r, args, 0 if r["verdict"] == "pass" else 1)
672
+ if args.cases_cmd == "replay":
673
+ r = replay_all(root, tier=args.tier or None)
674
+ return _emit(r, args, 0 if r["verdict"] == "pass" else 1)
675
+ if args.cases_cmd == "template":
676
+ out = args.out or "case.yaml"
677
+ write_case_template(out)
678
+ return _emit({"written": out}, args)
679
+ if args.cases_cmd == "ledger":
680
+ from platformforge.cases.ledgers import ledger_report
681
+
682
+ return _emit(ledger_report(), args)
683
+ if args.cases_cmd == "ledger-check":
684
+ from platformforge.cases.ledgers import validate_ledgers
685
+
686
+ r = validate_ledgers()
687
+ return _emit(r, args, 0 if r["verdict"] == "pass" else 1)
688
+ if args.cases_cmd == "route-audit":
689
+ from platformforge.cases.routing_audit import audit_corpus
690
+
691
+ return _emit(audit_corpus(root), args)
692
+ if args.cases_cmd == "route-bench":
693
+ from platformforge.cases.routing_audit import champion_challenger
694
+
695
+ return _emit(champion_challenger(root, challenger=args.out or None), args)
696
+ if args.cases_cmd == "context-audit":
697
+ from platformforge.cases.context_audit import audit_case, write_back
698
+ from platformforge.cases.context_audit import audit_corpus as ctx_corpus
699
+
700
+ if args.stamp:
701
+ from platformforge.cases.casefile import discover_cases as dc
702
+
703
+ for c in dc(root):
704
+ write_back(c, audit_case(c))
705
+ return _emit(ctx_corpus(root), args)
706
+ return 2
707
+
708
+
709
+ def cmd_economy(args: argparse.Namespace) -> int:
710
+ from platformforge.economy import EconomyEngine
711
+
712
+ eng = EconomyEngine(args.repo)
713
+ sub = getattr(args, "economy_cmd", "report") or "report"
714
+ if sub == "strategy":
715
+ sig = (
716
+ json.loads(args.signal)
717
+ if args.signal.strip().startswith("{")
718
+ else {"task_type": args.signal or "analysis"}
719
+ )
720
+ return _emit(eng.strategy(sig), args)
721
+ if sub == "compare":
722
+ sig = (
723
+ json.loads(args.signal)
724
+ if args.signal.strip().startswith("{")
725
+ else {"task_type": args.signal or "analysis"}
726
+ )
727
+ return _emit(eng.compare(sig), args)
728
+ if sub == "qpt":
729
+ # §30–32 — measured quality-per-token over a facts doc
730
+ import json as _json
731
+
732
+ from platformforge.economy.qpt import quality_per_token
733
+ from platformforge.tokensave.budget import Budget
734
+
735
+ facts_doc = _json.loads(Path(args.path).read_text())
736
+ facts = facts_doc.get("facts", facts_doc)
737
+ out = quality_per_token(
738
+ facts_full=facts,
739
+ findings_full=[],
740
+ index=_index(args),
741
+ task=args.task or "analysis",
742
+ budget=Budget(input_budget=args.input_budget),
743
+ versions=json.loads(args.versions) if getattr(args, "versions", "") else None,
744
+ )
745
+ return _emit(out, args, 2 if out.get("quality_gate") == "fail" else 0)
746
+ if sub == "qpt-bench":
747
+ # §43–46 — fixed corpus: full vs packed envelopes, same judge,
748
+ # receipts per task with quality-before-economy verdicts
749
+ from platformforge.economy.bench import run_qpt_bench
750
+
751
+ out = run_qpt_bench()
752
+ bad = [c["id"] for c in out["cases"] if c["verdict"] == "optimization_not_beneficial"]
753
+ return _emit(out, args, 2 if (args.strict and bad) else 0)
754
+ if sub == "checkpoint":
755
+ from platformforge.economy.checkpoint import CheckpointStore, EconomyCheckpoint
756
+
757
+ st = CheckpointStore(Path(args.repo) / ".platformforge" / "checkpoints")
758
+ cp = EconomyCheckpoint(
759
+ run_id=args.run_id or f"run-{int(time.time())}",
760
+ spent_budget=json.loads(args.spent or "{}"),
761
+ remaining_budget=json.loads(args.remaining or "{}"),
762
+ routing_profile=args.profile or "balanced",
763
+ deps=json.loads(args.deps or "{}"),
764
+ )
765
+ st.save(cp)
766
+ return _emit({"checkpoint": cp.to_dict()}, args)
767
+ if sub == "resume":
768
+ from platformforge.economy.checkpoint import CheckpointStore
769
+
770
+ st = CheckpointStore(Path(args.repo) / ".platformforge" / "checkpoints")
771
+ out = st.resume(
772
+ args.run_id, current_deps=json.loads(args.deps or "{}"), requested_profile=args.profile or ""
773
+ )
774
+ return _emit(out, args, 2 if "refusal" in out else 0)
775
+ if sub == "reconcile":
776
+ from platformforge.economy.reconcile import reconcile
777
+
778
+ out = reconcile(
779
+ planned=json.loads(args.planned or "{}"),
780
+ observed=json.loads(args.observed or "{}"),
781
+ run_id=args.run_id or "",
782
+ )
783
+ return _emit(out.to_dict(), args)
784
+ if sub == "doctor":
785
+ from platformforge.economy.doctor import doctor
786
+
787
+ out = doctor(args.repo)
788
+ return _emit(out, args, 2 if (args.strict and out["overall"] in ("warn", "fail")) else 0)
789
+ if sub == "explain":
790
+ from platformforge.economy.checkpoint import CheckpointStore
791
+
792
+ st = CheckpointStore(Path(args.repo) / ".platformforge" / "checkpoints")
793
+ cp = st.load(args.run_id or "")
794
+ if cp is None:
795
+ return _emit(
796
+ {
797
+ "refusal": "PF-CHECKPOINT-MISSING",
798
+ "reason": f"no checkpoint for {args.run_id}",
799
+ "unlock": f"economy checkpoint --run-id {args.run_id} ...",
800
+ },
801
+ args,
802
+ 2,
803
+ )
804
+ from platformforge.economy.ledger import unified_view
805
+
806
+ return _emit(
807
+ {"run_id": args.run_id, "checkpoint": cp.to_dict(), "ledger": unified_view(args.repo)}, args
808
+ )
809
+ return _emit(eng.report(), args)
810
+
811
+
812
+ def cmd_route(args: argparse.Namespace) -> int:
813
+ from platformforge.routing import TaskSignal, route
814
+
815
+ sig = TaskSignal.from_dict(json.loads(args.signal))
816
+ return _emit(route(sig), args)
817
+
818
+
819
+ def cmd_sdd(args: argparse.Namespace) -> int:
820
+ from platformforge.sdd import SDDProject
821
+
822
+ proj = SDDProject(args.repo)
823
+ sub = args.sdd_cmd
824
+ if sub == "init":
825
+ return _emit({"initialized": str(proj._feature_dir(args.feature))}, args)
826
+ if sub in ("discover", "define", "design", "contract", "plan", "review", "learn", "build", "verify"):
827
+ body: object = (
828
+ json.loads(args.body)
829
+ if args.body.strip().startswith("{")
830
+ else (args.body or f"# {sub} for {args.feature}\n")
831
+ )
832
+ art = proj.write_artifact(args.feature, sub, body)
833
+ return _emit({"wrote": art.path.name, "phase": sub, "upstream": art.upstream}, args)
834
+ if sub == "status":
835
+ return _emit(proj.status(args.feature), args)
836
+ if sub == "check":
837
+ out = proj.check(args.feature)
838
+ return _emit(out, args, 2 if (args.strict and not out["ok"]) else 0)
839
+ if sub == "stamp":
840
+ return _emit(proj.stamp(args.feature, args.phase), args)
841
+ if sub == "ship":
842
+ verify = json.loads(args.verify) if args.verify else None
843
+ out = proj.ship(
844
+ args.feature,
845
+ verify=verify,
846
+ risk_accepted=args.risk_accepted,
847
+ override=args.override,
848
+ override_reason=args.override_reason,
849
+ )
850
+ return _emit(out, args, 0 if out["shipped"] else 2)
851
+ return _emit({"error": f"unknown sdd verb {sub}"}, args, 1)
852
+
853
+
854
+ def _load_graph_or_refuse(repo: str):
855
+ from platformforge.graph import load
856
+
857
+ try:
858
+ return load(repo)
859
+ except FileNotFoundError:
860
+ return None
861
+
862
+
863
+ def cmd_graph(args: argparse.Namespace) -> int:
864
+ from platformforge import graph as G
865
+
866
+ sub = args.graph_cmd
867
+ if sub == "build":
868
+ doc = json.loads(Path(args.facts).read_text())
869
+ facts = doc.get("facts", doc)
870
+ builder = G.GraphBuilder().from_facts(facts)
871
+ p = G.save(
872
+ builder.graph, args.repo, source=args.facts, source_type=getattr(args, "source_type", "desired")
873
+ )
874
+ return _emit({"wrote": str(p), **builder.graph.stats()}, args)
875
+ if sub == "snapshots":
876
+ return _emit({"snapshots": G.snapshots(args.repo)}, args)
877
+ g = _load_graph_or_refuse(args.repo)
878
+ if g is None:
879
+ return _emit(
880
+ {"refusal": "PF-GRAPH-NOGRAPH", "unlock": "platformforge graph build <facts.json>"}, args, 2
881
+ )
882
+ if sub == "stats":
883
+ return _emit(g.stats(), args)
884
+ if sub == "gaps":
885
+ return _emit(G.gaps(g), args)
886
+ if sub == "cycles":
887
+ return _emit({"cycles": G.cycles(g)}, args)
888
+ if sub == "deps":
889
+ return _emit({args.node: G.dependencies(g, args.node)}, args)
890
+ if sub == "dependents":
891
+ return _emit({args.node: G.dependents(g, args.node)}, args)
892
+ if sub == "blast":
893
+ return _emit(G.blast_radius(g, args.node), args)
894
+ if sub == "paths":
895
+ return _emit({"paths": G.paths(g, args.src, args.dst)}, args)
896
+ if sub.startswith("identity-"):
897
+ # §100–101 — identity path queries over the graph
898
+ from platformforge.security.identity import (
899
+ compromise_blast,
900
+ who_can_access,
901
+ who_can_become,
902
+ workloads_using_identity,
903
+ )
904
+
905
+ target = args.node or args.dst or args.src
906
+ fn = {
907
+ "identity-become": who_can_become,
908
+ "identity-access": who_can_access,
909
+ "identity-workloads": workloads_using_identity,
910
+ "identity-blast": compromise_blast,
911
+ }[sub]
912
+ return _emit(fn(g, target), args)
913
+ if sub == "diff":
914
+
915
+ def _resolve(ref: str):
916
+ if (Path(args.repo) / ".platformforge/graph" / f"{ref}.json").exists():
917
+ return G.load(args.repo, ref)
918
+ return G.load_snapshot(args.repo, ref)
919
+
920
+ return _emit(G.diff(_resolve(args.before), _resolve(args.after)), args)
921
+ if sub == "at":
922
+ # §82 — graph as of timestamp T (nearest snapshot ≤ T)
923
+ from platformforge.graph import temporal as T
924
+
925
+ if not args.at:
926
+ return _emit({"refusal": "PF-GRAPH-NO-TS", "unlock": "pass --at <ISO ts>"}, args, 2)
927
+ snap = T.snapshot_at(args.repo, args.at)
928
+ if snap is None:
929
+ return _emit(
930
+ {"refusal": "PF-GRAPH-NO-SNAPSHOT", "unlock": "platformforge graph build <facts> first"},
931
+ args,
932
+ 2,
933
+ )
934
+ g2 = T.graph_at(args.repo, args.at)
935
+ return _emit({"snapshot": snap, "stats": g2.stats() if g2 else None}, args)
936
+ if sub == "timeline":
937
+ # §82 — temporal provenance: first-seen / last-observed
938
+ from platformforge.graph import temporal as T
939
+
940
+ out: dict[str, Any] = {}
941
+ if args.edge_id:
942
+ out["edge_first_seen"] = T.edge_first_seen(args.repo, args.edge_id)
943
+ if args.node:
944
+ out["node_last_observed"] = T.node_last_observed(args.repo, args.node)
945
+ if not out:
946
+ return _emit({"refusal": "PF-GRAPH-NO-TARGET", "unlock": "pass --edge-id and/or --node"}, args, 2)
947
+ return _emit(out, args)
948
+ if sub == "view":
949
+ # Graph Studio contract: ForgeGraphView/v1 of the latest graph,
950
+ # or of a named snapshot (declared `snapshots` capability).
951
+ from platformforge import graphview
952
+
953
+ view = graphview.build_view(args.repo, snapshot=args.snapshot or None)
954
+ if view is None:
955
+ code = "PF-GRAPH-NO-SNAPSHOT" if args.snapshot else "PF-GRAPH-NOGRAPH"
956
+ unlock = (
957
+ "platformforge graph snapshots — pick a saved hash"
958
+ if args.snapshot
959
+ else "platformforge graph build <facts.json>"
960
+ )
961
+ return _emit({"refusal": code, "unlock": unlock}, args, 2)
962
+ return _emit(view.to_dict(), args)
963
+ if sub == "ui":
964
+ from platformforge import graphview
965
+ from platformforge._graphstudio import graph_studio_enabled, open_studio
966
+
967
+ if not graph_studio_enabled(
968
+ args.repo,
969
+ state_rel=".platformforge",
970
+ user_state_rel="~/.platformforge",
971
+ ):
972
+ return _emit(
973
+ {
974
+ "refusal": "PF-GRAPH-STUDIO-DISABLED",
975
+ "detail": "Graph Studio declined at install (components.json: graph_studio=false)",
976
+ "unlock": "reinstall with the graph-studio component",
977
+ },
978
+ args,
979
+ 2,
980
+ )
981
+ view = graphview.build_view(args.repo)
982
+ if view is None:
983
+ return _emit(
984
+ {"refusal": "PF-GRAPH-NOGRAPH", "unlock": "platformforge graph build <facts.json>"}, args, 2
985
+ )
986
+ return open_studio(
987
+ [view],
988
+ open_browser=not getattr(args, "no_browser", False),
989
+ port=getattr(args, "port", 0),
990
+ )
991
+ return _emit({"error": f"unknown graph verb {sub}"}, args, 1)
992
+
993
+
994
+ def cmd_analyze(args: argparse.Namespace) -> int:
995
+ """Domain analyzers → fact documents (feed judge/graph).
996
+
997
+ Tree-walking domains are workspace-aware: a root with a multi-member
998
+ workspace.yaml fans out per member repo and tags facts (§126)."""
999
+ sub = args.analyze_cmd
1000
+ if sub == "plan":
1001
+ from platformforge.iac import analyze_plan
1002
+
1003
+ return _emit(analyze_plan(args.path), args)
1004
+ if sub == "state":
1005
+ from platformforge.iac import analyze_state
1006
+
1007
+ return _emit(analyze_state(args.path), args)
1008
+ if sub == "drift":
1009
+ from platformforge.iac import analyze_hcl, analyze_state, drift
1010
+
1011
+ desired = analyze_hcl(args.config)["facts"]
1012
+ observed = analyze_state(args.state)["facts"]
1013
+ return _emit(drift(desired, observed), args)
1014
+ if sub == "iam":
1015
+ from platformforge.security import analyze_iam_policy
1016
+
1017
+ return _emit(analyze_iam_policy(args.path), args)
1018
+ if sub.startswith("cloud-"):
1019
+ from platformforge.cloud import analyze_aws_dump, analyze_azure_dump, analyze_gcp_dump
1020
+
1021
+ fn = {
1022
+ "cloud-aws": analyze_aws_dump,
1023
+ "cloud-azure": analyze_azure_dump,
1024
+ "cloud-gcp": analyze_gcp_dump,
1025
+ }[sub]
1026
+ return _emit(fn(args.path), args)
1027
+ if sub == "helm":
1028
+ from platformforge.k8s.helm import analyze_helm
1029
+
1030
+ return _emit(analyze_helm(args.path), args)
1031
+ if sub == "kustomize":
1032
+ from platformforge.k8s.helm import analyze_kustomize
1033
+
1034
+ return _emit(analyze_kustomize(args.path), args)
1035
+ if sub == "hubble":
1036
+ from platformforge.k8s.hubble import analyze_hubble
1037
+
1038
+ return _emit(analyze_hubble(args.path), args)
1039
+ if sub == "kyverno":
1040
+ from platformforge.security.kyverno import analyze_kyverno
1041
+
1042
+ return _emit(analyze_kyverno(args.path, kyverno_version=args.kyverno_version or None), args)
1043
+ if sub == "cosign":
1044
+ from platformforge.security.cosign import analyze_cosign
1045
+
1046
+ return _emit(analyze_cosign(args.path), args)
1047
+ if sub == "slsa":
1048
+ from platformforge.security.slsa import slsa_assess
1049
+
1050
+ doc = json.loads(Path(args.path).read_text())
1051
+ prov = doc.get("provenance", doc)
1052
+ ev = doc.get("evidence") if isinstance(doc, dict) else None
1053
+ return _emit(slsa_assess(prov, evidence=ev), args)
1054
+ if sub == "sbom":
1055
+ from platformforge.security import analyze_sbom
1056
+
1057
+ vulns = json.loads(Path(args.vulns).read_text()) if args.vulns else None
1058
+ return _emit(analyze_sbom(args.path, vuln_db=vulns), args)
1059
+ if sub == "ownership":
1060
+ from platformforge.core.ownership import analyze_ownership
1061
+
1062
+ facts = _json_doc(args.facts, "facts") if getattr(args, "facts", "") else None
1063
+ return _emit(analyze_ownership(args.path, facts), args)
1064
+ if sub == "contradictions":
1065
+ from platformforge.core.ownership import detect_contradictions
1066
+
1067
+ facts = [f for f in _json_doc(args.path, "facts")]
1068
+ out = detect_contradictions(facts)
1069
+ return _emit(
1070
+ {
1071
+ "facts": out,
1072
+ "contradictions": len(out),
1073
+ "boundary": "declared-vs-observed diffs are named, never resolved silently",
1074
+ },
1075
+ args,
1076
+ )
1077
+ tree_analyzers = {
1078
+ "iac": "platformforge.iac:analyze_hcl",
1079
+ "k8s": "platformforge.k8s:analyze_k8s",
1080
+ "gitops": "platformforge.cicd:analyze_gitops",
1081
+ "gha": "platformforge.cicd:analyze_gha",
1082
+ "secrets": "platformforge.security:scan_secrets",
1083
+ "supply": "platformforge.security:analyze_supply",
1084
+ "catalog": "platformforge.product:analyze_catalog",
1085
+ "crossplane": "platformforge.product:analyze_crossplane",
1086
+ }
1087
+ if sub in tree_analyzers:
1088
+ mod, fn = tree_analyzers[sub].split(":")
1089
+ import importlib
1090
+
1091
+ return _emit(_run_over_members(getattr(importlib.import_module(mod), fn), args.path), args)
1092
+ return _emit({"error": f"unknown analyze domain {sub}"}, args, 1)
1093
+
1094
+
1095
+ def cmd_product(args: argparse.Namespace) -> int:
1096
+ from platformforge import product as P
1097
+
1098
+ sub = args.product_cmd
1099
+ if sub == "maturity":
1100
+ sig = json.loads(Path(args.signals).read_text())
1101
+ return _emit(P.maturity_report(sig.get("signals", sig), sig.get("evidence")), args)
1102
+ if sub == "scorecard":
1103
+ doc = json.loads(Path(args.findings).read_text())
1104
+ findings = doc.get("findings", doc)
1105
+ from platformforge.product.scorecards import scorecard
1106
+
1107
+ return _emit(scorecard(findings), args)
1108
+ if sub == "backstage":
1109
+ g = _load_graph_or_refuse(args.repo)
1110
+ if g is None:
1111
+ return _emit(
1112
+ {"refusal": "PF-GRAPH-NOGRAPH", "unlock": "platformforge graph build <facts.json>"}, args, 2
1113
+ )
1114
+ return _emit(P.to_backstage(g), args)
1115
+ if sub == "paths":
1116
+ from platformforge.product.golden_paths import load_library
1117
+
1118
+ lib = load_library()
1119
+ return _emit(
1120
+ {
1121
+ "paths": [
1122
+ {"id": p.id, "name": p.name, "version": p.version, "use_case": p.use_case}
1123
+ for p in lib["paths"]
1124
+ ],
1125
+ "invalid": lib["invalid"],
1126
+ },
1127
+ args,
1128
+ )
1129
+ if sub == "path":
1130
+ from platformforge.product.golden_paths import describe
1131
+
1132
+ return _emit(describe(args.id), args)
1133
+ if sub == "capabilities":
1134
+ from platformforge.product.golden_paths import capabilities
1135
+
1136
+ return _emit(capabilities(), args)
1137
+ if sub == "path-analyze":
1138
+ if args.facts:
1139
+ doc = json.loads(Path(args.facts).read_text())
1140
+ facts = doc.get("facts", doc)
1141
+ else:
1142
+ from platformforge.collect import collect
1143
+
1144
+ facts = collect(args.repo)["facts"]
1145
+ from platformforge.product.golden_paths import analyze_paths
1146
+
1147
+ return _emit(analyze_paths(facts), args)
1148
+ return _emit({"error": f"unknown product verb {sub}"}, args, 1)
1149
+
1150
+
1151
+ def cmd_agents(args: argparse.Namespace) -> int:
1152
+ from platformforge import agents as A
1153
+ from platformforge.agents.mirrors import check, lint, sync
1154
+
1155
+ sub = args.agents_cmd
1156
+ if sub == "list":
1157
+ return _emit({"agents": [a.to_dict() for a in A.AGENTS.values()]}, args)
1158
+ if sub == "lint":
1159
+ out = lint()
1160
+ return _emit(out, args, 0 if out["ok"] else 2)
1161
+ if sub == "check":
1162
+ out = check(args.repo)
1163
+ return _emit(out, args, 0 if out["ok"] else 2)
1164
+ if sub == "sync":
1165
+ return _emit({"written": sync(args.repo)}, args)
1166
+ if sub == "playbook":
1167
+ return _emit(A.playbook(args.name or "platform-orchestrator", domain=args.domain), args)
1168
+ if sub == "bench":
1169
+ return _emit(A.run_agent_bench(), args)
1170
+ if sub == "referee":
1171
+ positions = json.loads(Path(args.path).read_text())
1172
+ return _emit(A.referee(positions), args)
1173
+ return _emit({"error": f"unknown agents verb {sub}"}, args, 1)
1174
+
1175
+
1176
+ def cmd_mcp(args: argparse.Namespace) -> int:
1177
+ from platformforge import mcp as M
1178
+ from platformforge.mcp.parity import detach, integrate
1179
+ from platformforge.mcp.registry import tool_descriptors
1180
+
1181
+ sub = args.mcp_cmd
1182
+ if sub == "tools":
1183
+ return _emit({"tools": tool_descriptors()}, args)
1184
+ if sub == "call":
1185
+ inp = json.loads(args.input) if args.input else {}
1186
+ out = M.call_tool(args.name, inp, repo=args.repo)
1187
+ return _emit(out, args, 2 if "error" in out or "refusal" in out else 0)
1188
+ if sub == "serve":
1189
+ from platformforge.mcp.server import serve
1190
+
1191
+ return serve(repo=args.repo)
1192
+ if sub == "integrate":
1193
+ return _emit(integrate(args.host, args.repo), args)
1194
+ if sub == "detach":
1195
+ return _emit(detach(args.host, args.repo), args)
1196
+ return _emit({"error": f"unknown mcp verb {sub}"}, args, 1)
1197
+
1198
+
1199
+ def cmd_lab(args: argparse.Namespace) -> int:
1200
+ from platformforge import lab
1201
+
1202
+ if args.lab_cmd == "list":
1203
+ return _emit({"scenarios": lab.list_scenarios()}, args)
1204
+ if args.lab_cmd == "run-all":
1205
+ out = lab.runner.run_all(profile=args.profile or None, allow_profile=args.allow_profile)
1206
+ return _emit(out, args, 0 if not out["failed"] else 2)
1207
+ if args.lab_cmd == "chaos":
1208
+ from platformforge.lab.chaos import run_scenario
1209
+
1210
+ return _emit(run_scenario(args.path or "", allow_prod=args.allow_prod), args)
1211
+ out = lab.run(args.path or "", allow_profile=args.allow_profile)
1212
+ return _emit(out, args, 2 if "refusal" in out else 0)
1213
+
1214
+
1215
+ def cmd_collect(args: argparse.Namespace) -> int:
1216
+ """§7 collect — sniff a dumps dir/file, run matching analyzers."""
1217
+ from platformforge.collect import collect
1218
+
1219
+ return _emit(collect(args.path or args.repo), args)
1220
+
1221
+
1222
+ def _json_doc(path: str, key: str) -> list:
1223
+ doc = json.loads(Path(path).read_text())
1224
+ if isinstance(doc, dict):
1225
+ return doc.get(key, [])
1226
+ return doc if isinstance(doc, list) else []
1227
+
1228
+
1229
+ def cmd_diagnose(args: argparse.Namespace) -> int:
1230
+ """§7 diagnose — compose graph + findings for one node."""
1231
+ g = _load_graph_or_refuse(args.repo)
1232
+ if g is None:
1233
+ return _emit(
1234
+ {"refusal": "PF-GRAPH-NOGRAPH", "unlock": "platformforge graph build <facts.json>"}, args, 2
1235
+ )
1236
+ from platformforge.diagnose import diagnose
1237
+
1238
+ findings = _json_doc(args.findings, "findings") if args.findings else []
1239
+ facts = _json_doc(args.facts, "facts") if args.facts else []
1240
+ return _emit(diagnose(g, args.node, findings, facts), args)
1241
+
1242
+
1243
+ def cmd_plan(args: argparse.Namespace) -> int:
1244
+ """§7 plan — findings → ordered remediation plan."""
1245
+ from platformforge.plan import remediation_plan
1246
+
1247
+ findings = _json_doc(args.path, "findings")
1248
+ facts = _json_doc(args.facts, "facts") if args.facts else []
1249
+ g = _load_graph_or_refuse(args.repo) # optional enrichment
1250
+ return _emit(remediation_plan(findings, g, facts), args)
1251
+
1252
+
1253
+ def cmd_correlate(args: argparse.Namespace) -> int:
1254
+ """§7 correlate — OTel/telemetry correlation (observe otel)."""
1255
+ from platformforge import observe as O
1256
+
1257
+ return _emit(O.correlate_spans(args.path), args)
1258
+
1259
+
1260
+ def _build_graph_from(path: str):
1261
+ from platformforge.graph import GraphBuilder
1262
+
1263
+ doc = json.loads(Path(path).read_text())
1264
+ return GraphBuilder().from_facts(doc.get("facts", doc if isinstance(doc, list) else [])).graph
1265
+
1266
+
1267
+ def cmd_diff(args: argparse.Namespace) -> int:
1268
+ """§7 diff — graph diff between two facts docs."""
1269
+ from platformforge.graph.diff import diff as graph_diff
1270
+
1271
+ return _emit(graph_diff(_build_graph_from(args.before), _build_graph_from(args.after)), args)
1272
+
1273
+
1274
+ def cmd_drift(args: argparse.Namespace) -> int:
1275
+ """§7 drift — desired (HCL) vs observed (state)."""
1276
+ from platformforge.iac import analyze_hcl, analyze_state, drift
1277
+
1278
+ desired = analyze_hcl(args.config)["facts"]
1279
+ observed = analyze_state(args.state)["facts"]
1280
+ return _emit(drift(desired, observed), args)
1281
+
1282
+
1283
+ def cmd_impact(args: argparse.Namespace) -> int:
1284
+ """§7 impact — blast radius for a graph node."""
1285
+ g = _load_graph_or_refuse(args.repo)
1286
+ if g is None:
1287
+ return _emit(
1288
+ {"refusal": "PF-GRAPH-NOGRAPH", "unlock": "platformforge graph build <facts.json>"}, args, 2
1289
+ )
1290
+ from platformforge.graph.query import blast_radius
1291
+
1292
+ return _emit(blast_radius(g, args.node), args)
1293
+
1294
+
1295
+ def cmd_context(args: argparse.Namespace) -> int:
1296
+ """§7 context — pack for a task + ContextGateway capsule verbs."""
1297
+ sub = getattr(args, "context_cmd", "pack") or "pack"
1298
+ if sub == "pack":
1299
+ from platformforge.tokensave.budget import Budget
1300
+ from platformforge.tokensave.ledger import TokenLedger
1301
+ from platformforge.tokensave.packs import ContextPackBuilder
1302
+
1303
+ pack = ContextPackBuilder(_index(args), TokenLedger(args.repo)).build(
1304
+ task=args.task, budget=Budget(input_budget=args.input_budget), changed_files=args.changed or []
1305
+ )
1306
+ return _emit(pack, args)
1307
+
1308
+ from platformforge.context.gateway import ContextGateway, ContextRequest
1309
+
1310
+ gw = ContextGateway(args.repo)
1311
+ if sub == "capsule":
1312
+ res = gw.build(
1313
+ ContextRequest(
1314
+ task=args.task,
1315
+ scope=args.scope,
1316
+ budget_bytes=args.budget_bytes,
1317
+ essential_bytes=args.essential_bytes,
1318
+ required_sections=args.required or [],
1319
+ role=args.role,
1320
+ facts=json.loads(args.facts or "[]"),
1321
+ findings=json.loads(args.findings or "[]"),
1322
+ rules=args.rules or [],
1323
+ knowledge_refs=args.knowledge or [],
1324
+ graph_refs=args.graph_refs or [],
1325
+ open_questions=args.questions or [],
1326
+ )
1327
+ )
1328
+ return _emit(res, args, 2 if "refusal" in res else 0)
1329
+ if sub == "inspect":
1330
+ return _emit(gw.inspect(args.ref), args, 2 if "refusal" in gw.inspect(args.ref) else 0)
1331
+ if sub == "expand":
1332
+ return _emit(gw.expand(args.ref, args.section or ""), args)
1333
+ if sub == "delta":
1334
+ return _emit(gw.delta(args.ref, None), args)
1335
+ if sub == "gc":
1336
+ return _emit(gw.cache.gc(), args)
1337
+ return _emit({"error": f"unknown context verb {sub}"}, args, 1)
1338
+
1339
+
1340
+ def cmd_cache(args: argparse.Namespace) -> int:
1341
+ """cache — multilayer economy cache (stats/inspect/invalidate/gc)."""
1342
+ from platformforge.economy.cache import CacheStore
1343
+
1344
+ store = CacheStore(Path(args.repo) / ".platformforge" / "cache")
1345
+ sub = args.cache_cmd
1346
+ if sub == "stats":
1347
+ return _emit(store.stats(), args)
1348
+ if sub == "inspect":
1349
+ dec, payload = store.get(args.layer, args.scope or args.key, json.loads(args.deps or "{}"))
1350
+ return _emit({"decision": dec.to_dict(), "payload": payload}, args)
1351
+ if sub == "invalidate":
1352
+ return _emit(store.invalidate_dep(args.dep, args.value or ""), args)
1353
+ if sub == "gc":
1354
+ return _emit(store.gc(), args)
1355
+ return _emit({"error": f"unknown cache verb {sub}"}, args, 1)
1356
+
1357
+
1358
+ def cmd_routing(args: argparse.Namespace) -> int:
1359
+ """routing — control-plane verbs (explain/compare/scorecard)."""
1360
+ from platformforge.routing.decision import RoutingRequest, promote_verdict, receipt
1361
+
1362
+ sub = args.routing_cmd
1363
+ if sub == "explain":
1364
+ from platformforge.routing.decision import decide
1365
+
1366
+ req = RoutingRequest(
1367
+ task=args.task, profile=args.profile, risk=args.risk, signal=json.loads(args.signal or "{}")
1368
+ )
1369
+ rec = receipt(req, decide(req), json.loads(args.estimated or "{}"))
1370
+ return _emit(
1371
+ {
1372
+ "request": {
1373
+ "task": req.task,
1374
+ "profile": req.profile,
1375
+ "effective_profile": req.effective_profile(),
1376
+ "risk": req.risk,
1377
+ },
1378
+ "receipt": rec,
1379
+ },
1380
+ args,
1381
+ )
1382
+ if sub == "compare":
1383
+ out = promote_verdict(
1384
+ champion=json.loads(args.champion or "{}"),
1385
+ challenger=json.loads(args.challenger or "{}"),
1386
+ human_approved=args.approved,
1387
+ )
1388
+ return _emit(out, args, 2 if out["verdict"] == "rejected" else 0)
1389
+ if sub == "scorecard":
1390
+ from platformforge.routing.decision import RoutingScorecard
1391
+
1392
+ sc = RoutingScorecard(
1393
+ run_id=args.run_id or "",
1394
+ mode=args.mode,
1395
+ correctness=args.correctness,
1396
+ tokens=args.tokens,
1397
+ agents=args.agents,
1398
+ provider_calls=args.provider_calls,
1399
+ )
1400
+ return _emit(sc.to_dict(), args)
1401
+ return _emit({"error": f"unknown routing verb {sub}"}, args, 1)
1402
+
1403
+
1404
+ def cmd_policy(args: argparse.Namespace) -> int:
1405
+ """§7 policy — the rule catalog IS the policy layer."""
1406
+ from platformforge.rules import load_catalog
1407
+
1408
+ cat_dir = data_path("rules", "catalog")
1409
+ rules = load_catalog(cat_dir)
1410
+ if args.policy_cmd == "list":
1411
+ return _emit(
1412
+ {"rules": [{"rule_id": r.rule_id, "domain": r.domain, "severity": r.severity} for r in rules]},
1413
+ args,
1414
+ )
1415
+ from platformforge.models import Fact
1416
+ from platformforge.rules import RuleEngine
1417
+
1418
+ if not Path(args.path).is_file():
1419
+ return _emit(
1420
+ {"refusal": "PF-POLICY-NO-FACTS", "unlock": "platformforge policy check <facts.json>"}, args, 2
1421
+ )
1422
+ facts = [Fact.from_dict(f) for f in _json_doc(args.path, "facts")]
1423
+ findings, skipped = RuleEngine(rules).evaluate(facts)
1424
+ out = {
1425
+ "findings": [f.to_dict() for f in findings],
1426
+ "skipped": skipped,
1427
+ "violated": [f.rule_id for f in findings if f.status == "violated"],
1428
+ }
1429
+ return _emit(out, args)
1430
+
1431
+
1432
+ def cmd_security(args: argparse.Namespace) -> int:
1433
+ """§7 security — scan bundle: secrets + iam + sbom + supply → judge."""
1434
+ from platformforge.models import Fact
1435
+ from platformforge.rules import RuleEngine, load_catalog
1436
+ from platformforge.security import analyze_iam_policy, analyze_sbom, analyze_supply, scan_secrets
1437
+
1438
+ root = Path(args.path or args.repo)
1439
+ facts = scan_secrets(root)["facts"]
1440
+ extras = {
1441
+ "iam": ("policy.json", analyze_iam_policy),
1442
+ "sbom": ("sbom.json", analyze_sbom),
1443
+ "supply": ("supply.json", analyze_supply),
1444
+ }
1445
+ present = []
1446
+ for name, (fname, fn) in extras.items():
1447
+ p = root / fname
1448
+ if p.exists():
1449
+ facts += fn(str(p))["facts"]
1450
+ present.append(name)
1451
+ cat_dir = data_path("rules", "catalog")
1452
+ cat = [r for r in load_catalog(cat_dir) if r.domain == "security"]
1453
+ findings, skipped = RuleEngine(cat).evaluate([Fact.from_dict(f) for f in facts])
1454
+ unresolved = [f for f in findings if f.status != "violated"]
1455
+ return _emit(
1456
+ {
1457
+ "facts": facts,
1458
+ "analyzers_run": ["secrets", *present],
1459
+ "findings": [f.to_dict() for f in findings if f.status == "violated"],
1460
+ "unresolved": [f.to_dict() for f in unresolved],
1461
+ "skipped": skipped,
1462
+ },
1463
+ args,
1464
+ )
1465
+
1466
+
1467
+ def cmd_reliability(args: argparse.Namespace) -> int:
1468
+ """§7 reliability — SRE+K8S rules over a facts doc."""
1469
+ from platformforge.models import Fact
1470
+ from platformforge.rules import RuleEngine, load_catalog
1471
+
1472
+ cat_dir = data_path("rules", "catalog")
1473
+ rules = [r for r in load_catalog(cat_dir) if r.domain in ("sre", "k8s")]
1474
+ facts = [Fact.from_dict(f) for f in _json_doc(args.path, "facts")]
1475
+ findings, skipped = RuleEngine(rules).evaluate(facts)
1476
+ violated = [f for f in findings if f.status == "violated"]
1477
+ unresolved = [f for f in findings if f.status == "unresolved"]
1478
+ return _emit(
1479
+ {
1480
+ "findings": [f.to_dict() for f in violated],
1481
+ "unresolved": [f.to_dict() for f in unresolved],
1482
+ "skipped": skipped,
1483
+ "counts": {"violated": len(violated), "unresolved": len(unresolved)},
1484
+ },
1485
+ args,
1486
+ )
1487
+
1488
+
1489
+ def cmd_integrate(args: argparse.Namespace) -> int:
1490
+ """§7 integrate — host integration via mcp parity."""
1491
+ from platformforge.mcp.parity import detach, integrate
1492
+
1493
+ fn = detach if args.detach else integrate
1494
+ return _emit(fn(args.host, args.repo), args)
1495
+
1496
+
1497
+ def cmd_hosts(args: argparse.Namespace) -> int:
1498
+ """Hosts observe/launch — see ``distribution.hostflow`` for the contract."""
1499
+ from platformforge.distribution import hostflow
1500
+
1501
+ receipt = hostflow.hosts_flow(args.host, repo=Path(args.repo), launch=args.launch)
1502
+ return _emit(receipt, args, 2 if receipt["hosts"][0].get("state") == "refused" else 0)
1503
+
1504
+
1505
+ def cmd_portal(args: argparse.Namespace) -> int:
1506
+ """Optional local web portal over the same services as this CLI.
1507
+
1508
+ Why: one local screen with the install dashboard, AI hosts, MCP
1509
+ handshake, skills/agents catalog, safe tasks and graph — same engine
1510
+ and receipts, no flags to learn. When to use: first onboarding, host
1511
+ configuration, state audit. The portal is a human boundary — it never
1512
+ runs inside MCP, and CLI/TUI/MCP keep working without it.
1513
+ """
1514
+ from platformforge import portal_adapter
1515
+
1516
+ doc = portal_adapter._portal(args)
1517
+ return _emit(doc, args, exit_code=0 if doc.get("status") == "completed" else 2)
1518
+
1519
+
1520
+ def cmd_evals(args: argparse.Namespace) -> int:
1521
+ """§94–95 eval framework."""
1522
+ from platformforge.evals import run_all
1523
+
1524
+ cases = Path(args.cases) if args.cases else None
1525
+ if args.evals_cmd == "list":
1526
+ d = cases or __import__("platformforge.evals.runner", fromlist=["CASES_DIR"]).CASES_DIR
1527
+ return _emit(
1528
+ {"cases": [c.parent.name for c in sorted(d.glob("*/case.yaml"))] if Path(d).is_dir() else []},
1529
+ args,
1530
+ )
1531
+ if args.evals_cmd == "coverage":
1532
+ # §122 — rule → variant coverage matrix
1533
+ from platformforge.evals.coverage import rule_coverage
1534
+
1535
+ out = rule_coverage(cases) if cases else rule_coverage()
1536
+ return _emit(out, args)
1537
+ if args.evals_cmd == "precision":
1538
+ # §127 — measured FP rate over negative/boundary cases
1539
+ from platformforge.evals.coverage import precision_report
1540
+
1541
+ out = precision_report(cases) if cases else precision_report()
1542
+ return _emit(out, args)
1543
+ out = run_all(cases, type_filter=args.type or None) if cases else run_all(type_filter=args.type or None)
1544
+ return _emit(out, args, 2 if out["counts"]["fail"] else 0)
1545
+
1546
+
1547
+ def cmd_risk(args: argparse.Namespace) -> int:
1548
+ """§130–131 risk engine + criticality."""
1549
+ from platformforge.risk.engine import assess_change
1550
+
1551
+ signals: dict[str, Any] = {}
1552
+ if args.signals:
1553
+ signals = json.loads(Path(args.signals).read_text() if Path(args.signals).exists() else args.signals)
1554
+ if args.node:
1555
+ g = _load_graph_or_refuse(args.repo)
1556
+ if g is None:
1557
+ return _emit({"refusal": "PF-GRAPH-NOGRAPH"}, args, 2)
1558
+ from platformforge.graph import blast_radius
1559
+ from platformforge.risk.signals import signals_from_graph
1560
+
1561
+ blast = blast_radius(g, args.node)
1562
+ signals = {**signals_from_graph(g, [args.node], blast), **signals}
1563
+ signals.setdefault("blast_radius_nodes", blast.get("impacted"))
1564
+ return _emit(assess_change(signals), args)
1565
+
1566
+
1567
+ def cmd_change(args: argparse.Namespace) -> int:
1568
+ """§85–86 change lifecycle: propose → sandbox → verify (read-only)."""
1569
+ from platformforge.sandbox import sandbox_analyze
1570
+
1571
+ sub = args.change_cmd
1572
+ if sub == "review":
1573
+ # §104 — full review pipeline over the sandbox delta
1574
+ from platformforge.sandbox.review import review_change
1575
+
1576
+ patch = Path(args.patch).read_text() if args.patch else None
1577
+ files = {}
1578
+ for spec in args.file or []:
1579
+ rel, _, src = spec.partition("=")
1580
+ files[rel] = Path(src).read_text() if Path(src).exists() else src
1581
+ signals = {}
1582
+ if getattr(args, "signals", ""):
1583
+ signals = json.loads(Path(args.signals).read_text())
1584
+ out = review_change(args.repo, patch=patch, files=files, signals=signals)
1585
+ return _emit(out, args, 2 if "refusal" in out else 0)
1586
+ if sub in ("propose", "verify", "sandbox"):
1587
+ patch = Path(args.patch).read_text() if args.patch else None
1588
+ files = {}
1589
+ for spec in args.file or []:
1590
+ rel, _, src = spec.partition("=")
1591
+ files[rel] = Path(src).read_text() if Path(src).exists() else src
1592
+ out = sandbox_analyze(args.repo, patch=patch, files=files)
1593
+ return _emit(out, args, 2 if "refusal" in out else 0)
1594
+ if sub == "approve" or sub == "apply":
1595
+ # core never mutates — approval emits a receipt-bound refusal unless
1596
+ # a sandbox receipt with verified diff is provided
1597
+ return _emit(
1598
+ {
1599
+ "refusal": "platform.change.core_read_only",
1600
+ "detail": "apply/approve are host-side boundaries; core emits verified sandbox diffs only",
1601
+ "unlock": "change verify --patch <diff> first",
1602
+ },
1603
+ args,
1604
+ 2,
1605
+ )
1606
+ return _emit({"error": f"unknown change verb {sub}"}, args, 1)
1607
+
1608
+
1609
+ def cmd_explain(args: argparse.Namespace) -> int:
1610
+ """§154 — evidence chain for a finding: why, which fact, which rule,
1611
+ which source, which version, and what unlocks an unresolved state."""
1612
+ doc = json.loads(Path(args.path).read_text())
1613
+ findings = doc.get("findings", doc if isinstance(doc, list) else [])
1614
+ f = next((x for x in findings if x.get("finding_id") == args.name or x.get("rule_id") == args.name), None)
1615
+ if not f:
1616
+ return _emit({"refusal": "platform.finding.unresolved", "name": args.name}, args, 2)
1617
+ facts_doc = json.loads(Path(args.facts).read_text()) if args.facts else doc.get("facts", [])
1618
+ by_id = {x.get("fact_id"): x for x in facts_doc}
1619
+ chain = [{"fact_id": e, "fact": by_id.get(e, "unresolved")} for e in f.get("evidence", [])]
1620
+ # §154 — resolve the rule's provenance: source(s) + version gate
1621
+ rule_meta: dict[str, Any] = {"rule_id": f.get("rule_id")}
1622
+ try:
1623
+ from platformforge.rules import load_catalog
1624
+
1625
+ rule = next(
1626
+ (r for r in load_catalog(REPO_ROOT / "rules" / "catalog") if r.rule_id == f.get("rule_id")), None
1627
+ )
1628
+ if rule:
1629
+ rule_meta.update(
1630
+ {
1631
+ "sources": getattr(rule, "sources", []),
1632
+ "versions": getattr(rule, "versions", {}),
1633
+ "severity": getattr(rule, "severity", None),
1634
+ }
1635
+ )
1636
+ except (OSError, ValueError):
1637
+ pass
1638
+ # what unlocks an unresolved/verdict-weak state
1639
+ unlock = []
1640
+ if not f.get("evidence"):
1641
+ unlock.append("collect the facts named by the rule's applies_to and re-run judge")
1642
+ fa = f.get("attrs") or {}
1643
+ if fa.get("refusal_code") == "platform.version.unresolved":
1644
+ unlock.append(fa.get("unlock") or "declare the required product version and re-run judge")
1645
+ if f.get("status") == "skipped":
1646
+ unlock.append("rule skipped: " + f.get("reason", "?"))
1647
+ return _emit({"finding": f, "evidence_chain": chain, "rule": rule_meta, "unlock": unlock}, args)
1648
+
1649
+
1650
+ def cmd_recommend(args: argparse.Namespace) -> int:
1651
+ """findings → Recommendation objects with evidence (§recommendation)."""
1652
+ from platformforge.models import Recommendation
1653
+
1654
+ doc = json.loads(Path(args.path).read_text())
1655
+ findings = doc.get("findings", doc if isinstance(doc, list) else [])
1656
+ recs, skipped = [], []
1657
+ for f in findings:
1658
+ if f.get("status") != "violated":
1659
+ continue
1660
+ if not f.get("evidence"):
1661
+ skipped.append(
1662
+ {
1663
+ "rule_id": f["rule_id"],
1664
+ "reason": "finding without evidence — recommendation refused by contract",
1665
+ }
1666
+ )
1667
+ continue
1668
+ # §155 — qualitative expected direction only; quantified effect
1669
+ # would require a measured benchmark (model enforces this).
1670
+ sev = f.get("severity", "medium")
1671
+ recs.append(
1672
+ Recommendation(
1673
+ title=f.get("title") or f["rule_id"],
1674
+ severity=sev,
1675
+ confidence="declared",
1676
+ evidence=f["evidence"],
1677
+ root_cause=f.get("message", ""),
1678
+ proposed_change=[f.get("remediation", "resolve rule violation")],
1679
+ basis={"declared": f["evidence"]},
1680
+ risks=[f"change touches evidence {e}" for e in f["evidence"][:3]]
1681
+ or ["no evidence — refused upstream"],
1682
+ validation=[
1683
+ (f"re-run analyze+judge after change; finding {f.get('rule_id')} must flip to passed")
1684
+ ],
1685
+ rollback=["revert the diff; verify no new violations"],
1686
+ tradeoffs=[
1687
+ (
1688
+ "remediation may require downtime/coordination "
1689
+ "depending on blast radius — assess before "
1690
+ "apply"
1691
+ )
1692
+ ],
1693
+ expected_effect="direction: risk reduction (unquantified — no benchmark exists)",
1694
+ ).to_dict()
1695
+ )
1696
+ return _emit(
1697
+ {
1698
+ "recommendations": recs,
1699
+ "count": len(recs),
1700
+ "refused": skipped,
1701
+ "boundary": "expected_effect is directional; quantified claims require benchmark_ref",
1702
+ },
1703
+ args,
1704
+ )
1705
+
1706
+
1707
+ def cmd_capability(args: argparse.Namespace) -> int:
1708
+ """Capability registry projection — same source as the MCP adapter."""
1709
+ from platformforge.mcp.registry import CAPABILITIES
1710
+
1711
+ sub = args.capability_cmd
1712
+ if sub == "list":
1713
+ return _emit({"capabilities": [c.contract() for c in CAPABILITIES.values()]}, args)
1714
+ if sub == "describe":
1715
+ c = CAPABILITIES.get(args.name or "")
1716
+ if not c:
1717
+ return _emit(
1718
+ {
1719
+ "refusal": "platform.capability.unresolved",
1720
+ "name": args.name,
1721
+ "unlock": "platformforge capability list",
1722
+ },
1723
+ args,
1724
+ 2,
1725
+ )
1726
+ return _emit({**c.contract(), "description": c.description, "handler": c.handler}, args)
1727
+ if sub == "check":
1728
+ # §139 — capability negotiation: "can you analyze X at version Y?"
1729
+ dom, ver = (args.domain or ""), (args.version or "")
1730
+ matches = [
1731
+ c
1732
+ for c in CAPABILITIES.values()
1733
+ if c.domain == dom or dom in c.description.lower() or dom in c.name
1734
+ ]
1735
+ if not matches:
1736
+ return _emit(
1737
+ {
1738
+ "supported": False,
1739
+ "refusal": "platform.capability.unresolved",
1740
+ "domain": dom,
1741
+ "unlock": "platformforge capability list",
1742
+ },
1743
+ args,
1744
+ 2,
1745
+ )
1746
+ out = {
1747
+ "supported": True,
1748
+ "domain": dom,
1749
+ "version": ver,
1750
+ "capabilities": [c.contract() for c in matches],
1751
+ "version_note": None,
1752
+ }
1753
+ if ver:
1754
+ from platformforge.knowledge.registry import SourceRegistry
1755
+
1756
+ reg = SourceRegistry.default()
1757
+ srcs = [s for s in reg.entries.values() if dom and dom.split("-")[0] in (s.product or s.id)]
1758
+ out["version_note"] = {
1759
+ "declared": ver,
1760
+ "known_sources": [
1761
+ {"id": s.id, "version": s.version, "freshness": s.freshness()} for s in srcs
1762
+ ],
1763
+ "caveat": "version support is evidence-bound — unknown "
1764
+ "versions degrade to unresolved, not assumed",
1765
+ }
1766
+ return _emit(out, args)
1767
+ if sub == "manifest":
1768
+ from platformforge.forge import capability_manifest
1769
+
1770
+ return _emit(capability_manifest(args.repo), args)
1771
+ return _emit({"error": f"unknown capability verb {sub}"}, args, 1)
1772
+
1773
+
1774
+ def cmd_forge(args: argparse.Namespace) -> int:
1775
+ from platformforge import forge as FG
1776
+
1777
+ sub = args.forge_cmd
1778
+ if sub == "manifest":
1779
+ return _emit(FG.capability_manifest(args.repo), args)
1780
+ if sub == "delegate":
1781
+ d = FG.Delegation(
1782
+ from_forge=args.src or "platform-forge",
1783
+ to_forge=args.dst or "?",
1784
+ task=args.name or "",
1785
+ )
1786
+ return _emit(FG.build_envelope(d), args)
1787
+ if sub == "verify":
1788
+ env = json.loads(Path(args.path).read_text())
1789
+ return _emit(FG.verify_envelope(env), args)
1790
+ if sub == "discover":
1791
+ from platformforge.forge.collect import discover
1792
+
1793
+ return _emit(discover(args.path or args.repo), args)
1794
+ if sub == "collect":
1795
+ from platformforge.forge.collect import collect_manifest
1796
+
1797
+ out = collect_manifest(args.path or args.repo)
1798
+ return _emit(out, args, 2 if "refusal" in out else 0)
1799
+ return _emit({"error": f"unknown forge verb {sub}"}, args, 1)
1800
+
1801
+
1802
+ def cmd_observe(args: argparse.Namespace) -> int:
1803
+ from platformforge import observe as O
1804
+
1805
+ sub = args.observe_cmd
1806
+ if sub == "slo":
1807
+ contract = O.SloContract.load(args.path)
1808
+ sli = json.loads(args.sli) if args.sli else {}
1809
+ out = O.error_budget(
1810
+ contract, **{k: v for k, v in sli.items() if k in ("good_events", "bad_events", "total_events")}
1811
+ )
1812
+ # wrap as fact so `judge` can evaluate PF-SLO-* rules
1813
+ fact = {
1814
+ "fact_id": out.pop("fact_id", ""),
1815
+ "kind": "sre.error_budget",
1816
+ "source": args.path,
1817
+ "location": contract.service,
1818
+ "tier": 0,
1819
+ "attrs": out,
1820
+ }
1821
+ return _emit({"result": out, "facts": [fact]}, args)
1822
+ if sub == "otel":
1823
+ return _emit(O.correlate_spans(args.path), args)
1824
+ if sub == "incident":
1825
+ alerts = json.loads(Path(args.alerts).read_text())
1826
+ changes = json.loads(Path(args.changes).read_text())
1827
+ g = _load_graph_or_refuse(args.repo)
1828
+ return _emit(O.correlate(alerts, changes, graph=g, window_s=args.window), args)
1829
+ if sub == "slo-burn":
1830
+ contract = O.SloContract.load(args.path)
1831
+ wins = json.loads(args.windows) if args.windows else {}
1832
+ return _emit(O.multi_window_burn(contract, wins), args)
1833
+ if sub == "semconv":
1834
+ return _emit(O.analyze_semconv(args.path), args)
1835
+ if sub == "timeline":
1836
+ events = json.loads(Path(args.path).read_text())
1837
+ return _emit(O.timeline(events), args)
1838
+ if sub == "postmortem":
1839
+ inc = json.loads(Path(args.incident or args.path).read_text())
1840
+ return _emit(O.postmortem(inc, inc.get("hypotheses")), args)
1841
+ if sub == "dr":
1842
+ if args.facts:
1843
+ doc = json.loads(Path(args.facts).read_text())
1844
+ facts = doc.get("facts", doc)
1845
+ else:
1846
+ from platformforge.collect import collect
1847
+
1848
+ facts = collect(args.repo)["facts"]
1849
+ return _emit(O.dr_model(facts), args)
1850
+ if sub == "prometheus":
1851
+ return _emit(O.analyze_prometheus(args.path), args)
1852
+ if sub == "grafana":
1853
+ return _emit(O.analyze_grafana(args.path), args)
1854
+ if sub == "capacity":
1855
+ items = json.loads(Path(args.path).read_text())
1856
+ out = O.capacity(items)
1857
+ facts = [
1858
+ {
1859
+ "fact_id": "",
1860
+ "kind": "sre.capacity",
1861
+ "source": args.path,
1862
+ "location": c["resource"] or "?",
1863
+ "tier": 0,
1864
+ "attrs": c,
1865
+ }
1866
+ for c in out["capacity"]
1867
+ ]
1868
+ return _emit({**out, "facts": facts}, args)
1869
+ return _emit({"error": f"unknown observe verb {sub}"}, args, 1)
1870
+
1871
+
1872
+ def cmd_live(args: argparse.Namespace) -> int:
1873
+ """§7 live — read-only provider observation surface (Cycle 3)."""
1874
+ from platformforge.live.collectors import aws_transport, k8s_transport
1875
+ from platformforge.live.collectors.aws import AwsCollector, required_permissions
1876
+ from platformforge.live.collectors.k8s import K8sCollector, required_rbac
1877
+ from platformforge.live.cursors import CursorStore
1878
+ from platformforge.live.models import ObservationScope
1879
+ from platformforge.live.store import ChangeJournal, ObservationStore
1880
+
1881
+ sub = args.live_cmd
1882
+ # §25 — offline mode refuses live transports before any subprocess
1883
+ if getattr(args, "offline", False) and sub in ("snapshot", "doctor"):
1884
+ return _emit(
1885
+ {
1886
+ "refusal": "PF-LIVE-OFFLINE",
1887
+ "unlock": f"`live {sub}` spawns provider binaries — drop --offline or use replay fixtures",
1888
+ },
1889
+ args,
1890
+ 2,
1891
+ )
1892
+ if sub == "rbac":
1893
+ return _emit(required_rbac(namespaced_only=args.namespaced_only), args)
1894
+ if sub == "required-permissions":
1895
+ if args.provider == "aws":
1896
+ return _emit(required_permissions(), args)
1897
+ return _emit(required_rbac(namespaced_only=args.namespaced_only), args)
1898
+ if sub == "doctor":
1899
+ checks = {"kubernetes": k8s_transport.preflight(args.context)}
1900
+ if args.provider == "aws" or args.deep:
1901
+ checks["aws"] = aws_transport.preflight()
1902
+ store = ObservationStore(args.repo)
1903
+ checks["observation_store"] = {"ok": True, "path": str(store.root)}
1904
+ checks["cursors"] = {"cursors": len(CursorStore(args.repo).all())}
1905
+ out = {"checks": checks, "ok": all(c.get("ok", True) for c in checks.values())}
1906
+ return _emit(out, args, 0 if out["ok"] else 1)
1907
+ if sub == "status":
1908
+ store = ObservationStore(args.repo)
1909
+ out = store.status()
1910
+ out["cursors"] = CursorStore(args.repo).all()
1911
+ out["change_events"] = len(ChangeJournal(args.repo).read(limit=1_000_000))
1912
+ return _emit(out, args)
1913
+ if sub == "changes":
1914
+ # §38/§78 — CloudTrail-style change events (T1 evidence).
1915
+ # Read mode: no flags → emit journaled events. Collect mode:
1916
+ # --collect spawns the aws adapter (offline → PF-LIVE-OFFLINE).
1917
+ journal = ChangeJournal(args.repo)
1918
+ if args.gc:
1919
+ return _emit(journal.gc(), args)
1920
+ if args.events_file:
1921
+ evs = json.loads(Path(args.events_file).read_text())
1922
+ res = journal.append(evs)
1923
+ return _emit({"journal": res}, args)
1924
+ if not args.collect:
1925
+ return _emit({"events": journal.read(since=args.since or "", resource=args.resource or "")}, args)
1926
+ if getattr(args, "offline", False):
1927
+ return _emit(
1928
+ {
1929
+ "refusal": "PF-LIVE-OFFLINE",
1930
+ "unlock": "`live changes --collect` spawns the aws adapter — drop --offline",
1931
+ },
1932
+ args,
1933
+ 2,
1934
+ )
1935
+ if args.provider != "aws":
1936
+ return _emit(
1937
+ {
1938
+ "refusal": "PF-LIVE-UNKNOWN-PROVIDER",
1939
+ "unlock": "change-event collection is currently aws/cloudtrail only",
1940
+ },
1941
+ args,
1942
+ 2,
1943
+ )
1944
+ if not aws_transport.aws_available():
1945
+ return _emit(
1946
+ {"refusal": "PF-LIVE-NO-AWSCLI", "unlock": "install aws CLI + configure credentials"}, args, 2
1947
+ )
1948
+ collector = AwsCollector(transport=aws_transport.make_transport())
1949
+ pf = collector.preflight()
1950
+ if not pf.get("ok"):
1951
+ return _emit(
1952
+ {"refusal": "PF-LIVE-AWS-CREDS", "unlock": pf.get("hint", "configure creds")}, args, 2
1953
+ )
1954
+ events: list = []
1955
+ for region in args.region or [""]:
1956
+ events.extend(
1957
+ collector.lookup_events(
1958
+ region=region,
1959
+ account=collector.account,
1960
+ start_time=args.since or "",
1961
+ end_time=args.until or "",
1962
+ )
1963
+ )
1964
+ res = journal.append(events)
1965
+ return _emit({"collected": len(events), "journal": res, "regions": args.region or ["default"]}, args)
1966
+ if sub == "snapshot":
1967
+ scope = ObservationScope(namespaces=args.namespace or [], resource_types=args.resource_type or [])
1968
+ if args.selector:
1969
+ scope.selectors["labels"] = args.selector
1970
+ budget = _live_budget(args)
1971
+ if args.provider == "kubernetes":
1972
+ if not k8s_transport.kubectl_available():
1973
+ return _emit(
1974
+ {
1975
+ "refusal": "PF-LIVE-NO-KUBECTL",
1976
+ "unlock": "install kubectl + configure a kubeconfig context",
1977
+ },
1978
+ args,
1979
+ 2,
1980
+ )
1981
+ collector = K8sCollector(
1982
+ transport=k8s_transport.make_transport(args.context), context=args.context
1983
+ )
1984
+ env = collector.collect(scope, budget)
1985
+ elif args.provider == "aws":
1986
+ if not aws_transport.aws_available():
1987
+ return _emit(
1988
+ {
1989
+ "refusal": "PF-LIVE-NO-AWSCLI",
1990
+ "unlock": "install aws CLI + configure credentials (aws sso login)",
1991
+ },
1992
+ args,
1993
+ 2,
1994
+ )
1995
+ collector = AwsCollector(transport=aws_transport.make_transport())
1996
+ pf = collector.preflight()
1997
+ if not pf.get("ok"):
1998
+ return _emit(
1999
+ {
2000
+ "refusal": "PF-LIVE-AWS-CREDS",
2001
+ "unlock": pf.get("hint", "configure credentials"),
2002
+ "detail": pf.get("error", ""),
2003
+ },
2004
+ args,
2005
+ 2,
2006
+ )
2007
+ scope.regions = args.region or ["us-east-1"]
2008
+ scope.services = args.service or []
2009
+ env = collector.collect(scope, budget)
2010
+ else:
2011
+ return _emit(
2012
+ {
2013
+ "refusal": "PF-LIVE-UNKNOWN-PROVIDER",
2014
+ "unlock": "platformforge live snapshot --provider kubernetes|aws",
2015
+ },
2016
+ args,
2017
+ 2,
2018
+ )
2019
+ if args.no_store:
2020
+ return _emit({"envelope": env.to_dict()}, args)
2021
+ res = ObservationStore(args.repo).put(env)
2022
+ if "refusal" in res:
2023
+ return _emit(res, args, 2)
2024
+ return _emit(
2025
+ {
2026
+ **res,
2027
+ "provider": env.provider,
2028
+ "coverage": env.coverage.get("status"),
2029
+ "fresh_until": env.fresh_until,
2030
+ "scope": env.scope,
2031
+ },
2032
+ args,
2033
+ )
2034
+ if sub == "reconcile":
2035
+ from platformforge.live.reconcile import norm_facts, norm_observed, reconcile
2036
+
2037
+ desired = []
2038
+ if args.desired:
2039
+ doc = json.loads(Path(args.desired).read_text())
2040
+ desired = doc.get("facts", doc)
2041
+ else:
2042
+ from platformforge.collect import collect
2043
+
2044
+ desired = collect(args.repo).get("facts", [])
2045
+ obs_env = None
2046
+ if args.observed:
2047
+ p = Path(args.observed)
2048
+ if p.exists():
2049
+ from platformforge.live.envelope import loads
2050
+
2051
+ obs_env = loads(p.read_text())
2052
+ else:
2053
+ obs_env = ObservationStore(args.repo).get(args.observed)
2054
+ elif not args.no_observed:
2055
+ latest = ObservationStore(args.repo).latest()
2056
+ if latest:
2057
+ obs_env = ObservationStore(args.repo).get(latest["observation_id"])
2058
+ if obs_env is None and not args.no_observed:
2059
+ return _emit(
2060
+ {
2061
+ "refusal": "PF-LIVE-NO-OBSERVATION",
2062
+ "unlock": "platformforge live snapshot "
2063
+ "--provider kubernetes|aws, or pass "
2064
+ "--observed <envelope.json|obs-id>",
2065
+ },
2066
+ args,
2067
+ 2,
2068
+ )
2069
+ planned = []
2070
+ if args.planned:
2071
+ doc = json.loads(Path(args.planned).read_text())
2072
+ planned = doc.get("facts", doc)
2073
+ out = reconcile(
2074
+ desired=norm_facts(desired, "desired"),
2075
+ planned=norm_facts(planned, "planned"),
2076
+ observed=norm_observed(obs_env) if obs_env else None,
2077
+ )
2078
+ return _emit(out, args, 2 if args.strict and (out["totals"]["drift_events"] - out["accepted"]) else 0)
2079
+ if sub == "topology":
2080
+ from platformforge.live.topology import (
2081
+ apply_runtime_edges,
2082
+ classify_behavior,
2083
+ edges_from_endpointslices,
2084
+ edges_from_hubble,
2085
+ edges_from_otel,
2086
+ )
2087
+
2088
+ edges: list[dict] = []
2089
+ if args.otel:
2090
+ doc = json.loads(Path(args.otel).read_text())
2091
+ edges += edges_from_otel(doc if isinstance(doc, list) else [doc])
2092
+ if args.hubble:
2093
+ p = Path(args.hubble)
2094
+ flows = []
2095
+ for f in sorted(p.rglob("*.json*")) if p.is_dir() else [p]:
2096
+ doc = json.loads(f.read_text())
2097
+ flows += doc if isinstance(doc, list) else [doc]
2098
+ edges += edges_from_hubble(flows)
2099
+ if args.slices:
2100
+ edges += edges_from_endpointslices(json.loads(Path(args.slices).read_text()))
2101
+ declared = set()
2102
+ g = _load_graph_or_refuse(args.repo)
2103
+ if g is not None:
2104
+ declared = set(g.edges)
2105
+ out = {
2106
+ "edges": classify_behavior(edges, declared),
2107
+ "runtime_undeclared": sum(
2108
+ 1 for e in classify_behavior(edges, declared) if e["behavior"] == "observed-undeclared"
2109
+ ),
2110
+ }
2111
+ if g is not None and args.apply_to_graph:
2112
+ n = apply_runtime_edges(g, edges)
2113
+ from platformforge.graph import persist as gp
2114
+
2115
+ gp.save(g, args.repo, source_type="runtime")
2116
+ out["applied_to_graph"] = n
2117
+ return _emit(out, args)
2118
+ if sub == "clusters":
2119
+ from platformforge.live.federation import Cluster, ClusterRegistry
2120
+
2121
+ reg = ClusterRegistry(args.repo)
2122
+ if args.register:
2123
+ spec = json.loads(args.register)
2124
+ c = Cluster(spec.pop("cluster_id"), **spec)
2125
+ return _emit({"registered": reg.register(c)}, args)
2126
+ return _emit({"clusters": reg.list()}, args)
2127
+ if sub == "drift":
2128
+ from platformforge.graph.events import EventLedger
2129
+ from platformforge.live.drift import dedup_events, diff_observations
2130
+
2131
+ store = ObservationStore(args.repo)
2132
+
2133
+ def _env(ref: str):
2134
+ p = Path(ref)
2135
+ if p.exists():
2136
+ from platformforge.live.envelope import loads
2137
+
2138
+ return loads(p.read_text())
2139
+ return store.get(ref)
2140
+
2141
+ before_ref, after_ref = args.before, args.after
2142
+ if not (before_ref and after_ref):
2143
+ rows = store.list(limit=2)
2144
+ if len(rows) < 2:
2145
+ return _emit(
2146
+ {
2147
+ "refusal": "PF-LIVE-NEED-2-OBS",
2148
+ "unlock": "collect ≥2 observations or pass --before/--after",
2149
+ },
2150
+ args,
2151
+ 2,
2152
+ )
2153
+ after_ref, before_ref = (rows[0]["observation_id"], rows[1]["observation_id"])
2154
+ b, a = _env(before_ref), _env(after_ref)
2155
+ if b is None or a is None:
2156
+ return _emit(
2157
+ {"refusal": "PF-LIVE-NO-OBSERVATION", "unlock": "valid observation ids or files"}, args, 2
2158
+ )
2159
+ out = diff_observations(b, a)
2160
+ ledger = EventLedger(args.repo)
2161
+ prior = ledger.events(kind="drift")
2162
+ kept, suppressed = dedup_events(out["drift"], [e.get("event", e) for e in prior])
2163
+ for e in kept:
2164
+ ledger.append({"kind": "drift", "event": e, "observation_id": a.observation_id})
2165
+ out["journaled"] = len(kept)
2166
+ out["suppressed_dupes"] = suppressed
2167
+ return _emit(out, args, 2 if args.strict and out["drift"] else 0)
2168
+ if sub == "incident":
2169
+ if not args.incident:
2170
+ return _emit(
2171
+ {"refusal": "PF-LIVE-NO-INCIDENT", "unlock": "pass --incident <incident.json>"}, args, 2
2172
+ )
2173
+ from platformforge.live.incident import build_timeline, postmortem_v3, score_candidates
2174
+
2175
+ inc = json.loads(Path(args.incident).read_text())
2176
+ cands = json.loads(Path(args.changes).read_text()) if args.changes else []
2177
+ events = json.loads(Path(args.events).read_text()) if args.events else []
2178
+ graph = _load_graph_or_refuse(args.repo)
2179
+ tl = build_timeline(events + [inc])
2180
+ ranked = score_candidates(inc, cands, window_s=float(args.window), graph=graph)
2181
+ out = {
2182
+ **ranked,
2183
+ "postmortem": postmortem_v3(inc, tl, ranked)["postmortem"],
2184
+ "timeline_counts": tl["counts"],
2185
+ }
2186
+ return _emit(
2187
+ out,
2188
+ args,
2189
+ 2
2190
+ if args.strict and not any(h["status"] in ("confirmed", "supported") for h in ranked["ranked"])
2191
+ else 0,
2192
+ )
2193
+ if sub == "plan":
2194
+ from platformforge.live.remediate import remediate
2195
+
2196
+ if args.drift_events:
2197
+ events = json.loads(Path(args.drift_events).read_text())
2198
+ else:
2199
+ from platformforge.live.drift import diff_observations
2200
+
2201
+ store = ObservationStore(args.repo)
2202
+ rows = store.list(limit=2)
2203
+ if len(rows) < 2:
2204
+ return _emit(
2205
+ {
2206
+ "refusal": "PF-LIVE-NO-DRIFT-INPUT",
2207
+ "unlock": "pass --drift-events <file> or collect ≥2 observations",
2208
+ },
2209
+ args,
2210
+ 2,
2211
+ )
2212
+ events = diff_observations(
2213
+ store.get(rows[1]["observation_id"]), store.get(rows[0]["observation_id"])
2214
+ )["drift"]
2215
+ out = remediate(events)
2216
+ return _emit(out, args, 2 if args.strict and out["counts"]["mutating"] else 0)
2217
+ if sub == "capability":
2218
+ from platformforge.live.capability import availability
2219
+
2220
+ return _emit(availability(args.repo), args)
2221
+ return _emit({"error": f"unknown live verb {sub}"}, args, 1)
2222
+
2223
+
2224
+ def cmd_ops(args: argparse.Namespace) -> int:
2225
+ """cycle4 — governed operations surface. Dry-run is the default;
2226
+ real mutation requires --execute AND a valid hash-bound approval."""
2227
+ from platformforge.live.models import now_iso
2228
+ from platformforge.ops import engine
2229
+ from platformforge.ops.actions import spec_for
2230
+ from platformforge.ops.approval import Approval
2231
+ from platformforge.ops.config import load_config, validate_config
2232
+ from platformforge.ops.models import ChangeIntent, Reason
2233
+ from platformforge.ops.operation import LockTable
2234
+ from platformforge.ops.policy import Policy
2235
+ from platformforge.ops.registry import ops_capabilities, validate_delegate_request
2236
+ from platformforge.ops.store import OperationStore
2237
+
2238
+ sub = args.ops_cmd
2239
+
2240
+ if sub == "capabilities":
2241
+ return _emit(ops_capabilities(), args)
2242
+
2243
+ if sub == "config":
2244
+ r = load_config(root=args.repo)
2245
+ if "refusal" in r:
2246
+ return _emit(r, args, 2)
2247
+ r["violations"] = validate_config(r["config"])
2248
+ return _emit(r, args, 2 if r["violations"] else 0)
2249
+
2250
+ if sub == "delegate":
2251
+ req = json.loads(Path(args.request).read_text()) if args.request else {}
2252
+ out = validate_delegate_request(req)
2253
+ return _emit(out, args, 2 if "refusal" in out else 0)
2254
+
2255
+ if sub == "intent":
2256
+ doc = json.loads(Path(args.intent).read_text())
2257
+ intent = (
2258
+ ChangeIntent.from_dict(doc)
2259
+ if hasattr(ChangeIntent, "from_dict")
2260
+ else ChangeIntent(
2261
+ intent_id=doc.get("intent_id", ""),
2262
+ owner=doc.get("owner", ""),
2263
+ requested_by=doc.get("requested_by", ""),
2264
+ reason=Reason(**doc.get("reason", {"type": "manual"})),
2265
+ target_resources=doc.get("target_resources", []),
2266
+ desired_change=doc.get("desired_change", {}),
2267
+ )
2268
+ )
2269
+ out = engine.propose(intent, resources=doc.get("resources", []), sot_context=doc.get("sot_context"))
2270
+ return _emit(out, args, 2 if not out.get("ok") else 0)
2271
+
2272
+ if sub == "plan":
2273
+ doc = _load_plan_doc(args.plan)
2274
+ plan = engine.plan(doc["intent"], steps=doc["steps"], expected_delta=doc["expected_delta"])
2275
+ v = plan.validate()
2276
+ out = {"plan_id": plan.plan_id, "hash": plan.hash(), "steps": len(plan.steps), "violations": v}
2277
+ return _emit(out, args, 2 if v else 0)
2278
+
2279
+ if sub == "simulate":
2280
+ from platformforge.ops.simulate import simulate
2281
+
2282
+ doc = _load_plan_doc(args.plan)
2283
+ plan = engine.plan(doc["intent"], steps=doc["steps"], expected_delta=doc["expected_delta"])
2284
+ r = simulate(plan, level=args.level)
2285
+ return _emit(r.to_dict(), args, 2 if r.outcome == "fail" else 0)
2286
+
2287
+ if sub == "risk":
2288
+ doc = _load_plan_doc(args.plan)
2289
+ plan = engine.plan(doc["intent"], steps=doc["steps"], expected_delta=doc["expected_delta"])
2290
+ return _emit(engine.assess_risk(plan, {"environment": args.environment}), args)
2291
+
2292
+ if sub == "policy-eval":
2293
+ doc = _load_plan_doc(args.plan)
2294
+ plan = engine.plan(doc["intent"], steps=doc["steps"], expected_delta=doc["expected_delta"])
2295
+ pols = [Policy(**p) for p in _load_yaml_list(args.policies)]
2296
+ d = engine.decide(plan, pols, {"environment": args.environment})
2297
+ return _emit(d.to_dict(), args, 2 if d.decision == "deny" else 0)
2298
+
2299
+ if sub == "runbook":
2300
+ from platformforge.ops.runbook import BUILTIN_RUNBOOKS
2301
+
2302
+ name = args.name_pos or args.name or "list"
2303
+ if name == "list":
2304
+ return _emit(
2305
+ {
2306
+ "runbooks": [
2307
+ {
2308
+ "id": rb.runbook_id,
2309
+ "title": rb.title,
2310
+ "risk": rb.risk.get("class", "?"),
2311
+ "approval_required": rb.approval_required,
2312
+ "hash": rb.hash(),
2313
+ }
2314
+ for rb in BUILTIN_RUNBOOKS.values()
2315
+ ]
2316
+ },
2317
+ args,
2318
+ )
2319
+ rb = BUILTIN_RUNBOOKS.get(name)
2320
+ if rb is None:
2321
+ return _emit(
2322
+ {"refusal": "PF-OPS-RUNBOOK-UNKNOWN", "unlock": f"known: {sorted(BUILTIN_RUNBOOKS)}"}, args, 2
2323
+ )
2324
+ if args.bind:
2325
+ bound = rb.bind(json.loads(Path(args.bind).read_text()))
2326
+ return _emit(
2327
+ {**bound, "steps": [s.__dict__ for s in bound.get("steps", [])]}
2328
+ if bound.get("ok")
2329
+ else bound,
2330
+ args,
2331
+ 0 if bound.get("ok") else 2,
2332
+ )
2333
+ return _emit(rb.to_dict(), args)
2334
+
2335
+ if sub == "run":
2336
+ if getattr(args, "offline", False) and args.execute:
2337
+ return _emit(
2338
+ {
2339
+ "refusal": "PF-OPS-OFFLINE",
2340
+ "unlock": "--execute spawns host transports; drop --offline or run dry-run",
2341
+ },
2342
+ args,
2343
+ 2,
2344
+ )
2345
+ spec = _load_yaml_or_json(args.spec)
2346
+ return _ops_run(args, spec)
2347
+
2348
+ if sub == "approve":
2349
+ # Mint a signed, hash-bound Approval artifact — feeds the
2350
+ # `approvals:` list of an `ops run` spec. Never executes.
2351
+ ap = Approval(
2352
+ approval_id=args.approval_id or f"ap-{args.subject_hash[-12:]}",
2353
+ subject_hash=args.subject_hash,
2354
+ subject_kind=args.subject_kind,
2355
+ scope=[s for s in (args.scope or "").split(",") if s],
2356
+ actor=args.actor,
2357
+ actor_kind=args.actor_kind,
2358
+ role=args.role,
2359
+ type=args.approval_type,
2360
+ expires_at=args.expires_at,
2361
+ parameter_bounds=json.loads(Path(args.bounds).read_text()) if args.bounds else {},
2362
+ reason=args.reason,
2363
+ )
2364
+ ap.sign()
2365
+ return _emit(ap.to_dict(), args, 0 if args.subject_hash else 2)
2366
+
2367
+ if sub == "status":
2368
+ st = OperationStore(Path(args.repo) / ".platformforge/operations")
2369
+ op = st.load_operation(args.operation_id)
2370
+ if op is None:
2371
+ return _emit(
2372
+ {
2373
+ "refusal": "PF-OPS-UNKNOWN-OPERATION",
2374
+ "unlock": f"known: {st.list_operations()}",
2375
+ },
2376
+ args,
2377
+ 2,
2378
+ )
2379
+ return _emit({**op.to_dict(), "ledger": st.verify(args.operation_id)}, args)
2380
+
2381
+ if sub == "history":
2382
+ st = OperationStore(Path(args.repo) / ".platformforge/operations")
2383
+ rows = []
2384
+ ids = [args.operation_id] if args.operation_id else st.list_operations()
2385
+ for oid in ids:
2386
+ for e in st.load_ledger(oid).entries:
2387
+ if args.resource and args.resource not in json.dumps(e.data):
2388
+ continue
2389
+ rows.append(e.to_dict())
2390
+ rows.sort(key=lambda r: (r["at"], r["seq"]))
2391
+ return _emit({"entries": rows, "count": len(rows)}, args)
2392
+
2393
+ if sub == "rollback":
2394
+ st = OperationStore(Path(args.repo) / ".platformforge/operations")
2395
+ op = st.load_operation(args.operation_id)
2396
+ envd = st.load_envelope(args.operation_id)
2397
+ if op is None or envd is None:
2398
+ return _emit(
2399
+ {
2400
+ "refusal": "PF-OPS-UNKNOWN-OPERATION",
2401
+ "unlock": "rollback needs a stored op+envelope; run `ops run` first",
2402
+ },
2403
+ args,
2404
+ 2,
2405
+ )
2406
+ from platformforge.ops.envelope import ExecutionEnvelope
2407
+ from platformforge.ops.executors.base import host_transport
2408
+
2409
+ if getattr(args, "offline", False) and args.execute:
2410
+ return _emit(
2411
+ {
2412
+ "refusal": "PF-OPS-OFFLINE",
2413
+ "unlock": "--execute spawns host transports",
2414
+ },
2415
+ args,
2416
+ 2,
2417
+ )
2418
+ env = ExecutionEnvelope.from_dict(envd)
2419
+ transports = {}
2420
+ if args.execute:
2421
+ t = host_transport()
2422
+ transports = {s.executor: t for s in (spec_for(a["action"]) for a in env.actions) if s}
2423
+ ledger = st.load_ledger(args.operation_id)
2424
+ mats = st.load_materials(args.operation_id)
2425
+ out = engine.execute_rollback(
2426
+ op,
2427
+ env,
2428
+ transports=transports,
2429
+ ledger=ledger,
2430
+ locks=LockTable(),
2431
+ materials=mats,
2432
+ trigger={"type": "manual", "requested_by": getattr(args, "actor", "") or "cli", "at": now_iso()},
2433
+ post_rollback_state=json.loads(args.post_rollback)
2434
+ if getattr(args, "post_rollback", None)
2435
+ else None,
2436
+ dry_run=not args.execute,
2437
+ )
2438
+ st.save(op, ledger)
2439
+ return _emit(out, args, 0 if out.get("ok") else 2)
2440
+
2441
+ if sub == "autorem-eval":
2442
+ from platformforge.ops.autorem import evaluate_eligibility
2443
+ from platformforge.ops.risk import classify_reversibility
2444
+
2445
+ doc = _load_plan_doc(args.plan)
2446
+ plan = engine.plan(doc["intent"], steps=doc["steps"], expected_delta=doc["expected_delta"])
2447
+ risk = engine.assess_risk(plan, {"environment": args.environment})
2448
+ worst = risk["risk_class"]
2449
+ actions = {s.action for s in plan.steps}
2450
+ rev = {classify_reversibility(s.action, s.params, {}) for s in plan.steps}
2451
+ out = evaluate_eligibility(
2452
+ action=min(actions) if actions else "",
2453
+ risk_class=worst,
2454
+ capability_autonomy="A5",
2455
+ environment=args.environment,
2456
+ evidence_tier=args.evidence_tier,
2457
+ observation=None,
2458
+ max_observation_age_s=900,
2459
+ source_of_truth={"resolved": bool(doc["intent"].target_resources)},
2460
+ reversibility="fully-reversible" if rev == {"fully-reversible"} else "conditionally-reversible",
2461
+ simulation_outcome=None,
2462
+ policy_decision=None,
2463
+ unresolved_identities=0,
2464
+ conflicting_operations=0,
2465
+ verification_available=bool(doc["expected_delta"]),
2466
+ rollback_ready=True,
2467
+ )
2468
+ return _emit(out.to_dict(), args, 0 if out.eligible else 2)
2469
+
2470
+ if sub == "analytics":
2471
+ from platformforge.ops.analytics import operation_analytics
2472
+
2473
+ st = OperationStore(Path(args.repo) / ".platformforge/operations")
2474
+ return _emit(operation_analytics(st), args)
2475
+
2476
+ if sub == "graph":
2477
+ # Phase R wiring — rebuild the operational subgraph from
2478
+ # stored ledgers/envelopes (derived, never duplicated state).
2479
+ st = OperationStore(Path(args.repo) / ".platformforge/operations")
2480
+ ids = [args.operation_id] if args.operation_id else st.list_operations()
2481
+ nodes: dict = {}
2482
+ edges: list = []
2483
+ missing: list = []
2484
+ for oid in ids:
2485
+ proj = st.projection(oid)
2486
+ if proj is None:
2487
+ missing.append(oid)
2488
+ continue
2489
+ for n in proj["nodes"]:
2490
+ nodes[n["node_id"]] = n
2491
+ edges.extend(proj["edges"])
2492
+ return _emit(
2493
+ {
2494
+ "nodes": sorted(nodes.values(), key=lambda n: n["node_id"]),
2495
+ "edges": edges,
2496
+ "operations": [i for i in ids if i not in missing],
2497
+ "missing": missing,
2498
+ "counts": {"nodes": len(nodes), "edges": len(edges)},
2499
+ },
2500
+ args,
2501
+ 2 if missing and not nodes else 0,
2502
+ )
2503
+
2504
+ if sub in ("store-list", "store-verify"):
2505
+ st = OperationStore(Path(args.repo) / ".platformforge/operations")
2506
+ if sub == "store-list":
2507
+ return _emit({"operations": st.list_operations(), "audit_digest": st.audit_digest()}, args)
2508
+ out = st.verify(args.operation_id)
2509
+ return _emit(out, args, 0 if out["chain_valid"] else 2)
2510
+ return _emit({"error": f"unknown ops verb {sub}"}, args, 1)
2511
+
2512
+
2513
+ def _ops_run(args, spec: dict) -> int:
2514
+ """platformforge ops run --spec ops.yaml [--execute]
2515
+
2516
+ spec = {intent, steps, policies, approvals, observation, verify,
2517
+ environment, break_glass} — same shape as lab ops.yaml.
2518
+ Without --execute every step is dry-run; transports are the host's.
2519
+ """
2520
+ from platformforge.live.models import now_iso
2521
+ from platformforge.ops import engine
2522
+ from platformforge.ops.actions import spec_for
2523
+ from platformforge.ops.approval import Approval, BreakGlass
2524
+ from platformforge.ops.executors.base import host_transport
2525
+ from platformforge.ops.models import ChangeIntent, ExpectedDelta, PlanStep, Reason
2526
+ from platformforge.ops.operation import LockTable, Operation, OperationLedger
2527
+ from platformforge.ops.policy import Policy
2528
+ from platformforge.ops.rollback import derive_rollback
2529
+ from platformforge.ops.simulate import simulate
2530
+ from platformforge.ops.store import OperationStore
2531
+ from platformforge.ops.verify import verify as do_verify
2532
+
2533
+ i = spec.get("intent", {})
2534
+ intent = ChangeIntent(
2535
+ intent_id=i.get("intent_id", "cli-intent"),
2536
+ owner=i.get("owner", ""),
2537
+ requested_by=i.get("requested_by", ""),
2538
+ reason=Reason(**i.get("reason", {"type": "manual"})),
2539
+ target_resources=list(i.get("target_resources", [])),
2540
+ desired_change=dict(i.get("desired_change", {})),
2541
+ )
2542
+ if not spec.get("steps"):
2543
+ prop = engine.propose(intent, resources=i.get("resources", []), sot_context=i.get("sot_context"))
2544
+ if not prop["ok"]:
2545
+ return _emit(prop, args, 2)
2546
+
2547
+ steps = [
2548
+ PlanStep(step_id=s["step_id"], action=s["action"], params=dict(s.get("params", {})))
2549
+ for s in spec.get("steps", [])
2550
+ ]
2551
+ ed = spec.get("expected_delta") or {}
2552
+ plan = engine.plan(
2553
+ intent,
2554
+ steps=steps,
2555
+ expected_delta=ExpectedDelta(
2556
+ adds=ed.get("adds", {}), removes=ed.get("removes", {}), changes=ed.get("changes", {})
2557
+ ),
2558
+ )
2559
+ sim = simulate(plan, level=spec.get("simulation_level", "S1"))
2560
+ # rollback declaration precedes risk — the status feeds the risk
2561
+ # ceiling and policy inputs (cycle 4.1 §F)
2562
+ rb = derive_rollback(
2563
+ [{"step_id": s.step_id, "action": s.action, "params": s.params} for s in steps],
2564
+ {
2565
+ "environment": spec.get("environment"),
2566
+ "automatic_allowed": spec.get("auto_rollback", False),
2567
+ },
2568
+ )
2569
+ risk = engine.assess_risk(
2570
+ plan, {"environment": spec.get("environment", "unknown")}, rollback_status=rb.status
2571
+ )
2572
+
2573
+ # §GOVERN — cost/security gates evaluate declared change context
2574
+ # before policy/approval. fail blocks everywhere; unknown blocks
2575
+ # production (unknown ≠ clean).
2576
+ from platformforge.ops import gates as G
2577
+
2578
+ gspec = spec.get("gates") or {}
2579
+ environment = spec.get("environment", "unknown")
2580
+ gate_out: dict = {"verdict": "skipped", "gates": []}
2581
+ cost_gate: dict = {}
2582
+ if gspec:
2583
+ sec = G.security_gates(
2584
+ diff=gspec.get("diff") or ed, ctx=gspec.get("context") or {}, vuln_scan=gspec.get("vuln_scan")
2585
+ )
2586
+ gate_out = sec
2587
+ if gspec.get("cost"):
2588
+ cd = G.CostDelta(
2589
+ **{k: v for k, v in gspec["cost"].items() if k in G.CostDelta.__dataclass_fields__}
2590
+ )
2591
+ cost_gate = cd.gate(
2592
+ budget_increase_max=gspec.get("budget_increase_max"),
2593
+ increase_pct_max=gspec.get("increase_pct_max"),
2594
+ )
2595
+ gate_out["cost"] = cost_gate
2596
+ gate_out["cost_delta"] = cd.to_dict()
2597
+ rank = {"fail": 3, "unknown": 2, "skipped": 1, "pass": 0}
2598
+ if rank.get(cost_gate["status"], 2) > rank.get(gate_out["verdict"], 0):
2599
+ gate_out["verdict"] = cost_gate["status"]
2600
+ blocking = ("fail",) if environment not in ("prod", "production") else ("fail", "unknown")
2601
+ if gate_out["verdict"] in blocking:
2602
+ return _emit(
2603
+ {
2604
+ "ok": False,
2605
+ "stage": "govern",
2606
+ "refusal": {
2607
+ "refusal": "PF-OPS-GATE-FAIL",
2608
+ "unlock": "resolve failing gate or supply missing evidence",
2609
+ "verdict": gate_out["verdict"],
2610
+ },
2611
+ "gates": gate_out,
2612
+ "simulation": sim.to_dict(),
2613
+ "risk": risk,
2614
+ },
2615
+ args,
2616
+ 2,
2617
+ )
2618
+ policies = [Policy(**p) for p in spec.get("policies", [])]
2619
+ decisions = (
2620
+ [
2621
+ engine.decide(
2622
+ plan,
2623
+ policies,
2624
+ {"environment": spec.get("environment", "unknown"), "risk_class": risk["risk_class"]},
2625
+ )
2626
+ ]
2627
+ if policies
2628
+ else []
2629
+ )
2630
+ if not decisions:
2631
+ from platformforge.ops.policy import PolicyDecision
2632
+
2633
+ decisions = [
2634
+ PolicyDecision(
2635
+ policy_id="cli-default",
2636
+ decision="allow",
2637
+ reason="no policies in spec — governance passthrough",
2638
+ )
2639
+ ]
2640
+ env = engine.mint_envelope(
2641
+ plan,
2642
+ execution_id=spec.get("execution_id", "cli-ex"),
2643
+ decisions=decisions or None,
2644
+ approvals=[],
2645
+ risk=risk,
2646
+ rollback=rb,
2647
+ )
2648
+ if isinstance(env, dict):
2649
+ return _emit(
2650
+ {"ok": False, "stage": "mint", "refusal": env, "simulation": sim.to_dict(), "risk": risk}, args, 2
2651
+ )
2652
+ env_hash = env.freeze()
2653
+
2654
+ approvals = []
2655
+ for a in spec.get("approvals", []):
2656
+ approvals.append(
2657
+ Approval(
2658
+ approval_id=a.get("approval_id", "ap"),
2659
+ subject_hash=env_hash if a.get("binds", "envelope") == "envelope" else a.get("binds", ""),
2660
+ scope=list(a.get("scope", env.scope)),
2661
+ actor=a.get("actor", ""),
2662
+ role=a.get("role", ""),
2663
+ actor_kind=a.get("actor_kind", "human"),
2664
+ expires_at=a.get("expires_at", ""),
2665
+ )
2666
+ )
2667
+ bg = None
2668
+ if spec.get("break_glass"):
2669
+ b = spec["break_glass"]
2670
+ bg = BreakGlass(
2671
+ break_glass_id=b.get("ticket_id", ""),
2672
+ actor=b.get("actor", ""),
2673
+ invocation_reason=b.get("reason", ""),
2674
+ scope=list(b.get("scope", env.scope)),
2675
+ expires_at=b.get("expires_at", ""),
2676
+ )
2677
+
2678
+ def _stub(rc=0, out="", err=""):
2679
+ return lambda argv, cwd=None, timeout_s=None: (rc, out, err)
2680
+
2681
+ transports = {}
2682
+ # declared stubs (replayable, like lab fixtures)
2683
+ for name, st in (spec.get("transports") or {}).items():
2684
+ transports[name] = _stub(st.get("rc", 0), st.get("stdout", ""), st.get("stderr", ""))
2685
+ if args.execute:
2686
+ t = host_transport()
2687
+ for s in env.actions:
2688
+ sp = spec_for(s["action"])
2689
+ if sp:
2690
+ transports[sp.executor] = t
2691
+ else:
2692
+ # dry-run: executors need a transport handle, but it must never
2693
+ # reach the host — return a marker receipt instead
2694
+ for s in env.actions:
2695
+ sp = spec_for(s["action"])
2696
+ if sp and sp.executor not in transports:
2697
+ transports[sp.executor] = _stub(0, "dry-run — host transport not invoked", "")
2698
+
2699
+ op = Operation(
2700
+ operation_id=spec.get("operation_id", "cli-op"), intent_id=intent.intent_id, resources=list(env.scope)
2701
+ )
2702
+ ledger = OperationLedger()
2703
+ result = engine.execute(
2704
+ op,
2705
+ env,
2706
+ approvals=approvals,
2707
+ transports=transports,
2708
+ ledger=ledger,
2709
+ locks=LockTable(),
2710
+ dry_run=not args.execute,
2711
+ break_glass=bg,
2712
+ preconditions={
2713
+ "observation": spec.get("observation"),
2714
+ "pre_state": spec.get("pre_state"),
2715
+ "sot": spec.get("sot"),
2716
+ "current_plan_hash": env.change_plan_hash,
2717
+ "policy_decision": (decisions[0].decision if decisions else "allow"),
2718
+ "freeze_active": spec.get("freeze_active", False),
2719
+ "environment": spec.get("environment", ""),
2720
+ "maintenance_window": spec.get("maintenance_window"),
2721
+ "expected_resource_state": spec.get("expected_resource_state"),
2722
+ "current_resource_state": spec.get("current_resource_state"),
2723
+ "owner": spec.get("owner", ""),
2724
+ "current_owner": spec.get("current_owner", ""),
2725
+ "automatic_rollback": spec.get("auto_rollback", False),
2726
+ },
2727
+ )
2728
+
2729
+ if result.get("ok") and spec.get("verify"):
2730
+ v = spec["verify"]
2731
+ vr = do_verify(
2732
+ expected_delta=ed,
2733
+ observations=v.get("observations", {}),
2734
+ slo_contract=v.get("slo"),
2735
+ metrics=v.get("metrics"),
2736
+ mutating=any(
2737
+ (spec_for(a["action"]) or None) is not None and spec_for(a["action"]).mutating
2738
+ for a in env.actions
2739
+ ),
2740
+ )
2741
+ result["verify"] = vr.to_dict()
2742
+ result["final"] = engine.finalize_verify(op, ledger, {"convergence": vr.convergence})
2743
+ result["state"] = op.state
2744
+ # §119–127 — execute the MATERIAL-BUILT rollback when the plan
2745
+ # allows it (lab/non-prod only); otherwise the op stays
2746
+ # rollback-planned for a human `ops rollback` call.
2747
+ if op.state == "rollback-planned":
2748
+ rbp = result.get("rollback_plan") or env.rollback
2749
+ if (rbp or {}).get("status") == "executable" and (rbp or {}).get("automatic"):
2750
+ result["rollback"] = engine.execute_rollback(
2751
+ op,
2752
+ env,
2753
+ transports=transports,
2754
+ ledger=ledger,
2755
+ completed_results=result.get("results"),
2756
+ materials=result.get("materials"),
2757
+ rollback_plan=rbp,
2758
+ trigger={"type": "verification-failed", "observed_delta": ed, "at": now_iso()},
2759
+ post_rollback_state=(spec.get("post_rollback") or {}),
2760
+ dry_run=not args.execute,
2761
+ )
2762
+ result["state"] = op.state
2763
+
2764
+ st = OperationStore(Path(args.repo) / ".platformforge/operations")
2765
+ # §30 — persist the material-built rollback plan + immutable
2766
+ # materials so `ops rollback` replays against captured pre-state
2767
+ if result.get("rollback_plan"):
2768
+ env.rollback = result["rollback_plan"]
2769
+ st.save(op, ledger, envelope=env, materials=result.get("materials"))
2770
+ result["graph"] = st.projection(op.operation_id)
2771
+ result["receipts"] = {
2772
+ "ledger_tip": ledger.tip,
2773
+ "ledger_valid": ledger.verify_chain(),
2774
+ "envelope": env.to_dict(),
2775
+ "simulation": sim.to_dict(),
2776
+ "risk": risk,
2777
+ }
2778
+ result["gates"] = gate_out
2779
+ result["dry_run"] = not args.execute
2780
+ return _emit(result, args, 0 if result.get("ok") else 2)
2781
+
2782
+
2783
+ def _load_plan_doc(path: str) -> dict:
2784
+ doc = _load_yaml_or_json(path)
2785
+ from platformforge.ops.models import ChangeIntent, ExpectedDelta, PlanStep, Reason
2786
+
2787
+ i = doc.get("intent", {})
2788
+ intent = ChangeIntent(
2789
+ intent_id=i.get("intent_id", ""),
2790
+ owner=i.get("owner", ""),
2791
+ requested_by=i.get("requested_by", ""),
2792
+ reason=Reason(**i.get("reason", {"type": "manual"})),
2793
+ target_resources=list(i.get("target_resources", [])),
2794
+ desired_change=dict(i.get("desired_change", {})),
2795
+ )
2796
+ steps = [
2797
+ PlanStep(step_id=s["step_id"], action=s["action"], params=dict(s.get("params", {})))
2798
+ for s in doc.get("steps", [])
2799
+ ]
2800
+ ed = doc.get("expected_delta") or {}
2801
+ return {
2802
+ "intent": intent,
2803
+ "steps": steps,
2804
+ "expected_delta": ExpectedDelta(
2805
+ adds=ed.get("adds", {}), removes=ed.get("removes", {}), changes=ed.get("changes", {})
2806
+ ),
2807
+ }
2808
+
2809
+
2810
+ def _load_yaml_or_json(path: str):
2811
+ text = Path(path).read_text()
2812
+ if path.endswith((".yaml", ".yml")):
2813
+ import yaml
2814
+
2815
+ return yaml.safe_load(text)
2816
+ return json.loads(text)
2817
+
2818
+
2819
+ def _load_yaml_list(path: str) -> list:
2820
+ import yaml
2821
+
2822
+ doc = yaml.safe_load(Path(path).read_text())
2823
+ return doc if isinstance(doc, list) else doc.get("policies", [])
2824
+
2825
+
2826
+ def _live_budget(args: argparse.Namespace):
2827
+ from platformforge.live.budget import ObservationBudget
2828
+
2829
+ return ObservationBudget(
2830
+ max_objects=args.max_objects, max_api_calls=args.max_api_calls, max_bytes=args.max_bytes
2831
+ )
2832
+
2833
+
2834
+ def cmd_finops(args: argparse.Namespace) -> int:
2835
+ from platformforge import finops as F
2836
+
2837
+ sub = args.finops_cmd
2838
+ if (
2839
+ sub in {"costs", "allocate", "focus", "focus-validate", "unit", "ingest", "report"}
2840
+ and not Path(args.path).is_file()
2841
+ ):
2842
+ return _emit(
2843
+ {"refusal": "PF-FINOPS-NO-INPUT", "unlock": "platformforge finops <verb> <billing.json|csv>"},
2844
+ args,
2845
+ 2,
2846
+ )
2847
+ if sub == "costs":
2848
+ doc = F.cost_facts(args.path)
2849
+ return _emit({**doc, "summary": F.cost_summary(doc["facts"])}, args)
2850
+ if sub == "allocate":
2851
+ doc = F.cost_facts(args.path)
2852
+ return _emit(F.allocate(doc["facts"], by=args.by), args)
2853
+ if sub == "focus":
2854
+ rows = json.loads(Path(args.path).read_text())
2855
+ rows = rows if isinstance(rows, list) else rows.get("rows", [])
2856
+ return _emit(F.to_focus(rows), args)
2857
+ if sub == "focus-validate":
2858
+ # §92 — never call output FOCUS-compliant without schema check
2859
+ from platformforge.finops.focus import validate_focus
2860
+
2861
+ rows = json.loads(Path(args.path).read_text())
2862
+ rows = rows if isinstance(rows, list) else rows.get("rows", [])
2863
+ out = validate_focus(rows)
2864
+ return _emit(out, args, 2 if (args.strict and not out["focus_compliant"]) else 0)
2865
+ if sub == "unit":
2866
+ # §94 unit economics — denominators must be caller-measured
2867
+ from platformforge.finops.unit import unit_economics
2868
+
2869
+ doc = F.cost_facts(args.path)
2870
+ denoms = json.loads(Path(args.denominators).read_text()) if args.denominators else {}
2871
+ out = unit_economics(doc["facts"], denoms)
2872
+ return _emit(out, args, 2 if (args.strict and out["unresolved"]) else 0)
2873
+ if sub == "ingest":
2874
+ # §91 — provider billing export → normalized rows + format verdict
2875
+ from platformforge.finops.ingest import ingest_billing
2876
+
2877
+ return _emit(ingest_billing(args.path), args)
2878
+ if sub == "report":
2879
+ # §90 — composite; each dimension independently evidence-bound
2880
+ from platformforge.finops.insights import finops_report
2881
+
2882
+ doc = F.cost_facts(args.path)
2883
+ extra = json.loads(Path(args.denominators).read_text()) if args.denominators else {}
2884
+ return _emit(
2885
+ finops_report(
2886
+ doc["facts"],
2887
+ utilization=extra.get("utilization"),
2888
+ requested_vs_used=extra.get("requested_vs_used"),
2889
+ committed=extra.get("committed"),
2890
+ split=extra.get("split"),
2891
+ ),
2892
+ args,
2893
+ )
2894
+ if sub == "graph":
2895
+ g = _load_graph_or_refuse(args.repo)
2896
+ if g is None:
2897
+ return _emit(
2898
+ {"refusal": "PF-GRAPH-NOGRAPH", "unlock": "platformforge graph build <facts.json>"}, args, 2
2899
+ )
2900
+ doc = F.cost_facts(args.path)
2901
+ return _emit(F.graph_cost(g, doc["facts"]), args)
2902
+ return _emit({"error": f"unknown finops verb {sub}"}, args, 1)
2903
+
2904
+
2905
+ _TOP_COMMANDS: tuple[tuple[str, str], ...] = (
2906
+ ("init", "set up Platform Forge in this workspace"),
2907
+ ("doctor", "health-check installation and dependencies"),
2908
+ ("analyze", "extract facts from an artifact"),
2909
+ ("install", "install/manage host integrations"),
2910
+ ("agents", "discover agents and coordinators"),
2911
+ ("diagnose", "guided diagnosis entry point"),
2912
+ ("status", "workspace and installation status"),
2913
+ )
2914
+
2915
+
2916
+ class _PlatformParser(argparse.ArgumentParser):
2917
+ """DX contract: unknown verbs suggest, never just list."""
2918
+
2919
+ def error(self, message: str) -> None:
2920
+ import difflib
2921
+ import re
2922
+
2923
+ m = re.search(r"invalid choice: '([^']+)'", message)
2924
+ choices: list[str] = []
2925
+ for a in self._actions:
2926
+ if isinstance(a, argparse._SubParsersAction):
2927
+ choices += list(a.choices)
2928
+ if m and choices:
2929
+ close = difflib.get_close_matches(m.group(1), choices, n=3, cutoff=0.6)
2930
+ if close:
2931
+ message += "\n\ndid you mean: " + ", ".join(close) + "?"
2932
+ super().error(message)
2933
+
2934
+
2935
+ def _bare_summary(parser: argparse.ArgumentParser) -> int:
2936
+ print("platformforge - Agentic Platform Engineering intelligence.")
2937
+ print()
2938
+ print("most used:")
2939
+ for name, desc in _TOP_COMMANDS:
2940
+ print(f" {name:<14} {desc}")
2941
+ print()
2942
+ print("help: platformforge help <command> | platformforge --help")
2943
+ print("docs: docs/installation/quickstart.md | CLI-REFERENCE.md")
2944
+ return 0
2945
+
2946
+
2947
+ def _help_for(parser: argparse.ArgumentParser, argv: list[str]) -> int:
2948
+ target = argv[1] if len(argv) > 1 else None
2949
+ if not target:
2950
+ parser.print_help()
2951
+ return 0
2952
+ for a in parser._actions:
2953
+ if isinstance(a, argparse._SubParsersAction) and target in a.choices:
2954
+ a.choices[target].print_help()
2955
+ return 0
2956
+ print(f"platformforge help: unknown command {target!r}", file=sys.stderr)
2957
+ print("see: platformforge --help", file=sys.stderr)
2958
+ return 2
2959
+
2960
+
2961
+ def build_parser() -> argparse.ArgumentParser:
2962
+ p = _PlatformParser(prog="platformforge", description="Agentic Platform Engineering intelligence")
2963
+ p.add_argument("--version", action="version", version=f"platformforge {__version__}")
2964
+ sub = p.add_subparsers(dest="command", required=True)
2965
+
2966
+ def verb(name: str, fn, help_: str, extra=None) -> None:
2967
+ sp = sub.add_parser(name, help=help_)
2968
+ _add_common(sp)
2969
+ if extra:
2970
+ extra(sp)
2971
+ sp.set_defaults(func=fn)
2972
+
2973
+ # FE-003 — additive portable distribution/workspace surface.
2974
+ # Business logic stays in platformforge.distribution/workspace.
2975
+ from platformforge.portable_cli import register as register_portable_cli
2976
+
2977
+ register_portable_cli(sub, _add_common, _emit)
2978
+
2979
+ verb("init", cmd_init, "scaffold .platformforge/", lambda sp: sp.add_argument("--name", default=""))
2980
+ verb(
2981
+ "doctor",
2982
+ cmd_doctor,
2983
+ "environment health-check",
2984
+ lambda sp: sp.add_argument(
2985
+ "--deep",
2986
+ action="store_true",
2987
+ help="§141 contract checks (config, manifest, knowledge, parity, index, store)",
2988
+ ),
2989
+ )
2990
+ verb("status", cmd_status, "workspace + store status")
2991
+ verb("inspect", cmd_inspect, "inventory analyzable artifacts")
2992
+ verb(
2993
+ "judge",
2994
+ cmd_judge,
2995
+ "apply rule catalog to facts",
2996
+ lambda sp: (
2997
+ sp.add_argument("facts"),
2998
+ sp.add_argument("--catalog", nargs="*"),
2999
+ sp.add_argument(
3000
+ "--versions", default="", help='JSON product versions, e.g. \'{"kubernetes": "1.29"}\''
3001
+ ),
3002
+ ),
3003
+ )
3004
+ verb(
3005
+ "knowledge",
3006
+ cmd_knowledge,
3007
+ "knowledge freshness/drift check",
3008
+ lambda sp: sp.add_argument(
3009
+ "knowledge_cmd", nargs="?", default="check", choices=["check", "contract", "drift", "packs"]
3010
+ ),
3011
+ )
3012
+
3013
+ sp = sub.add_parser("tokens", help="tokensave: index/search/pack/delta/stats/ledger")
3014
+ _add_common(sp)
3015
+ sp.add_argument("tokens_cmd", choices=["index", "search", "pack", "delta", "stats", "ledger"])
3016
+ sp.add_argument("--task", default="")
3017
+ sp.add_argument("--query", default="")
3018
+ sp.add_argument("--mode", default="literal", choices=["literal", "regex", "symbol", "path", "kind"])
3019
+ sp.add_argument("--limit", type=int, default=20)
3020
+ sp.add_argument("--changed", nargs="*")
3021
+ sp.add_argument("--input-budget", type=int, default=None)
3022
+ sp.add_argument("--risk", default=None, choices=["low", "medium", "high", "critical"])
3023
+ sp.add_argument("--graph-nodes", nargs="*", help="seed nodes for graph-aware ranking")
3024
+ sp.add_argument("--prev-pack", default=None, help="previous pack hash — delta-aware dedup")
3025
+ sp.add_argument(
3026
+ "--allow-escalate", action="store_true", help="pack: escalate beyond budget instead of refusing"
3027
+ )
3028
+ sp.set_defaults(func=cmd_tokens)
3029
+
3030
+ sp = sub.add_parser("rtk", help="compact command output (rtk)")
3031
+ _add_common(sp)
3032
+ sp.add_argument("rtk_cmd", choices=["compact", "expand"], nargs="?", default="compact")
3033
+ sp.add_argument("file", nargs="?", default="-", help="output file to compact ('-' = stdin)")
3034
+ sp.add_argument("--command", default="", help="command that produced output")
3035
+ sp.add_argument("--exit-code", type=int, default=0)
3036
+ sp.add_argument("--artifact", default="")
3037
+ sp.add_argument("--start", type=int)
3038
+ sp.add_argument("--end", type=int)
3039
+ sp.add_argument("--pattern", default=None)
3040
+
3041
+ def _rtk_dispatch(a):
3042
+ return cmd_rtk_expand(a) if a.rtk_cmd == "expand" else cmd_rtk(a)
3043
+
3044
+ sp.set_defaults(func=_rtk_dispatch)
3045
+
3046
+ sp = sub.add_parser("caveman", help="compress text (caveman)")
3047
+ _add_common(sp)
3048
+ sp.add_argument("file", help="file to compress ('-' = stdin)")
3049
+ sp.add_argument("--mode", choices=["off", "lite", "full", "auto"], default="lite")
3050
+ sp.add_argument("--context-risk", default="normal")
3051
+ sp.set_defaults(func=cmd_caveman)
3052
+
3053
+ sp = sub.add_parser("economy", help="economy engine report/strategy/qpt")
3054
+ _add_common(sp)
3055
+ sp.add_argument(
3056
+ "economy_cmd",
3057
+ nargs="?",
3058
+ default="report",
3059
+ choices=[
3060
+ "report",
3061
+ "strategy",
3062
+ "compare",
3063
+ "qpt",
3064
+ "qpt-bench",
3065
+ "checkpoint",
3066
+ "resume",
3067
+ "reconcile",
3068
+ "doctor",
3069
+ "explain",
3070
+ ],
3071
+ )
3072
+ sp.add_argument("--signal", default="", help="JSON TaskSignal (strategy/compare)")
3073
+ sp.add_argument("--path", default="", help="facts doc for qpt")
3074
+ sp.add_argument("--task", default="analysis")
3075
+ sp.add_argument("--input-budget", type=int, default=None)
3076
+ sp.add_argument(
3077
+ "--versions", default="", help='JSON product versions for qpt, e.g. \'{"kubernetes": "1.29"}\''
3078
+ )
3079
+ sp.add_argument("--run-id", default="")
3080
+ sp.add_argument("--spent", default="", help="JSON spent budget (checkpoint)")
3081
+ sp.add_argument("--remaining", default="", help="JSON remaining budget")
3082
+ sp.add_argument("--deps", default="", help="JSON dep hashes (checkpoint)")
3083
+ sp.add_argument("--profile", default="", help="routing profile (resume)")
3084
+ sp.add_argument("--planned", default="", help="JSON planned usage (reconcile)")
3085
+ sp.add_argument("--observed", default="", help="JSON observed usage (reconcile)")
3086
+ sp.set_defaults(func=cmd_economy)
3087
+
3088
+ sp = sub.add_parser("route", help="adaptive routing decision")
3089
+ _add_common(sp)
3090
+ sp.add_argument("signal", help="JSON TaskSignal")
3091
+ sp.set_defaults(func=cmd_route)
3092
+
3093
+ sp = sub.add_parser("sdd", help="native SDD lifecycle")
3094
+ _add_common(sp)
3095
+ sp.add_argument(
3096
+ "sdd_cmd",
3097
+ choices=[
3098
+ "init",
3099
+ "discover",
3100
+ "define",
3101
+ "design",
3102
+ "contract",
3103
+ "plan",
3104
+ "build",
3105
+ "review",
3106
+ "verify",
3107
+ "ship",
3108
+ "learn",
3109
+ "status",
3110
+ "check",
3111
+ "stamp",
3112
+ ],
3113
+ )
3114
+ sp.add_argument("--feature", required=True)
3115
+ sp.add_argument("--body", default="", help="artifact body or JSON")
3116
+ sp.add_argument("--phase", default=None, help="stamp single phase")
3117
+ sp.add_argument("--verify", default="", help="verify JSON for ship gate")
3118
+ sp.add_argument("--risk-accepted", action="store_true")
3119
+ sp.add_argument("--override", action="store_true")
3120
+ sp.add_argument("--override-reason", default="")
3121
+ sp.set_defaults(func=cmd_sdd)
3122
+
3123
+ sp = sub.add_parser("graph", help="Graphfy platform graph")
3124
+ _add_common(sp)
3125
+ sp.add_argument(
3126
+ "graph_cmd",
3127
+ choices=[
3128
+ "build",
3129
+ "stats",
3130
+ "deps",
3131
+ "dependents",
3132
+ "blast",
3133
+ "paths",
3134
+ "gaps",
3135
+ "cycles",
3136
+ "diff",
3137
+ "snapshots",
3138
+ "identity-become",
3139
+ "identity-access",
3140
+ "identity-workloads",
3141
+ "identity-blast",
3142
+ "at",
3143
+ "timeline",
3144
+ "view",
3145
+ "ui",
3146
+ ],
3147
+ )
3148
+ sp.add_argument("facts", nargs="?", default="")
3149
+ sp.add_argument("--node", default="")
3150
+ sp.add_argument("--src", default="")
3151
+ sp.add_argument("--dst", default="")
3152
+ sp.add_argument("--before", default="")
3153
+ sp.add_argument("--after", default="")
3154
+ sp.add_argument("--at", default="", help="at: ISO timestamp")
3155
+ sp.add_argument("--edge-id", default="", help="timeline: edge id")
3156
+ sp.add_argument(
3157
+ "--source-type",
3158
+ default="desired",
3159
+ choices=["desired", "planned", "observed", "runtime"],
3160
+ help="§6 snapshot type for `graph build`",
3161
+ )
3162
+ sp.add_argument(
3163
+ "--no-browser",
3164
+ action="store_true",
3165
+ help="graph ui: serve without opening a browser (SSH/remote)",
3166
+ )
3167
+ sp.add_argument("--port", type=int, default=0, help="graph ui: port (default ephemeral)")
3168
+ sp.add_argument(
3169
+ "--snapshot",
3170
+ default="",
3171
+ help="graph view: emit the view of a saved snapshot hash",
3172
+ )
3173
+ sp.set_defaults(func=cmd_graph)
3174
+
3175
+ sp = sub.add_parser("analyze", help="domain analyzers → facts")
3176
+ _add_common(sp)
3177
+ sp.add_argument(
3178
+ "analyze_cmd",
3179
+ choices=[
3180
+ "iac",
3181
+ "plan",
3182
+ "state",
3183
+ "drift",
3184
+ "k8s",
3185
+ "gitops",
3186
+ "gha",
3187
+ "iam",
3188
+ "sbom",
3189
+ "secrets",
3190
+ "supply",
3191
+ "catalog",
3192
+ "crossplane",
3193
+ "cloud-aws",
3194
+ "cloud-azure",
3195
+ "cloud-gcp",
3196
+ "helm",
3197
+ "kustomize",
3198
+ "hubble",
3199
+ "kyverno",
3200
+ "cosign",
3201
+ "slsa",
3202
+ "ownership",
3203
+ "contradictions",
3204
+ ],
3205
+ )
3206
+ sp.add_argument("--facts", default="", help="facts doc for ownership/contradiction joins")
3207
+ sp.add_argument("path", nargs="?", default=".")
3208
+ sp.add_argument("--config", default="")
3209
+ sp.add_argument("--state", default="")
3210
+ sp.add_argument("--vulns", default="", help="offline vuln list JSON")
3211
+ sp.add_argument("--kyverno-version", default="", help="declared kyverno version for deprecation checks")
3212
+ sp.set_defaults(func=cmd_analyze)
3213
+
3214
+ sp = sub.add_parser("observe", help="SRE/observability verbs")
3215
+ _add_common(sp)
3216
+ sp.add_argument(
3217
+ "observe_cmd",
3218
+ choices=[
3219
+ "slo",
3220
+ "slo-burn",
3221
+ "otel",
3222
+ "semconv",
3223
+ "incident",
3224
+ "timeline",
3225
+ "postmortem",
3226
+ "capacity",
3227
+ "dr",
3228
+ "prometheus",
3229
+ "grafana",
3230
+ ],
3231
+ )
3232
+ sp.add_argument("path", nargs="?", default="")
3233
+ sp.add_argument("--sli", default="", help="JSON {good,bad,total}_events")
3234
+ sp.add_argument("--windows", default="", help="JSON {window: {good,bad}} for slo-burn")
3235
+ sp.add_argument("--alerts", default="")
3236
+ sp.add_argument("--changes", default="")
3237
+ sp.add_argument("--incident", default="", help="incident JSON for postmortem")
3238
+ sp.add_argument("--facts", default="", help="facts JSON for dr")
3239
+ sp.add_argument("--window", type=int, default=3600)
3240
+ sp.set_defaults(func=cmd_observe)
3241
+
3242
+ sp = sub.add_parser("live", help="live platform observation (cycle3)")
3243
+ _add_common(sp)
3244
+ sp.add_argument(
3245
+ "live_cmd",
3246
+ choices=[
3247
+ "snapshot",
3248
+ "status",
3249
+ "doctor",
3250
+ "reconcile",
3251
+ "rbac",
3252
+ "required-permissions",
3253
+ "topology",
3254
+ "clusters",
3255
+ "drift",
3256
+ "incident",
3257
+ "plan",
3258
+ "capability",
3259
+ "changes",
3260
+ ],
3261
+ )
3262
+ sp.add_argument("--provider", default="kubernetes", choices=["kubernetes", "aws"])
3263
+ sp.add_argument("--region", action="append", help="aws: restrict regions (repeatable)")
3264
+ sp.add_argument("--service", action="append", help="aws: restrict services (repeatable)")
3265
+ sp.add_argument("--deep", action="store_true", help="doctor: probe every provider adapter")
3266
+ sp.add_argument("--context", default="", help="kube context")
3267
+ sp.add_argument("--namespace", action="append", help="restrict to namespace (repeatable)")
3268
+ sp.add_argument("--resource-type", action="append", help="restrict resource types (repeatable)")
3269
+ sp.add_argument("--selector", default="", help="k8s label selector")
3270
+ sp.add_argument("--namespaced-only", action="store_true", help="rbac: emit Role instead of ClusterRole")
3271
+ sp.add_argument("--no-store", action="store_true", help="snapshot: emit envelope without persisting")
3272
+ sp.add_argument("--desired", default="", help="reconcile: facts.json path (default: repo scan)")
3273
+ sp.add_argument("--planned", default="", help="reconcile: planned facts.json path")
3274
+ sp.add_argument("--observed", default="", help="reconcile: observation_id or envelope.json")
3275
+ sp.add_argument("--no-observed", action="store_true", help="reconcile: desired↔planned only")
3276
+ sp.add_argument("--otel", default="", help="topology: OTLP spans json")
3277
+ sp.add_argument("--hubble", default="", help="topology: hubble flows")
3278
+ sp.add_argument("--slices", default="", help="topology: endpointslices json")
3279
+ sp.add_argument(
3280
+ "--apply-to-graph", action="store_true", help="topology: persist runtime edges into graph"
3281
+ )
3282
+ sp.add_argument("--register", default="", help="clusters: register cluster JSON spec")
3283
+ sp.add_argument("--before", default="", help="drift: obs id/file")
3284
+ sp.add_argument("--after", default="", help="drift: obs id/file")
3285
+ sp.add_argument("--incident", default="", help="incident: incident JSON {timestamp,resources}")
3286
+ sp.add_argument("--changes", default="", help="incident: candidate changes JSON list")
3287
+ sp.add_argument(
3288
+ "--events-file", default="", help="changes: append ChangeEvents from JSON file (offline-safe)"
3289
+ )
3290
+ sp.add_argument(
3291
+ "--collect", action="store_true", help="changes: collect via provider adapter (host-side)"
3292
+ )
3293
+ sp.add_argument("--gc", action="store_true", help="changes: garbage-collect the change journal")
3294
+ sp.add_argument("--since", default="", help="changes: events after ISO ts")
3295
+ sp.add_argument("--until", default="", help="changes: events before ISO ts")
3296
+ sp.add_argument("--resource", default="", help="changes: filter by resource id")
3297
+ sp.add_argument("--events", default="", help="incident: timeline events JSON list")
3298
+ sp.add_argument("--window", type=int, default=3600, help="incident: correlation window seconds")
3299
+ sp.add_argument(
3300
+ "--drift-events",
3301
+ default="",
3302
+ help="plan: drift events JSON (default: diff latest two stored observations)",
3303
+ )
3304
+ sp.add_argument("--max-objects", type=int, default=0)
3305
+ sp.add_argument("--max-api-calls", type=int, default=0)
3306
+ sp.add_argument("--max-bytes", type=int, default=0)
3307
+ sp.set_defaults(func=cmd_live)
3308
+
3309
+ sp = sub.add_parser("ops", help="governed operations (cycle4)")
3310
+ _add_common(sp)
3311
+ sp.add_argument(
3312
+ "ops_cmd",
3313
+ choices=[
3314
+ "capabilities",
3315
+ "config",
3316
+ "delegate",
3317
+ "intent",
3318
+ "plan",
3319
+ "simulate",
3320
+ "risk",
3321
+ "policy-eval",
3322
+ "runbook",
3323
+ "run",
3324
+ "approve",
3325
+ "status",
3326
+ "history",
3327
+ "rollback",
3328
+ "autorem-eval",
3329
+ "analytics",
3330
+ "graph",
3331
+ "store-list",
3332
+ "store-verify",
3333
+ ],
3334
+ )
3335
+ sp.add_argument("--intent", default="", help="intent JSON file")
3336
+ sp.add_argument("--plan", default="", help="plan JSON/YAML file")
3337
+ sp.add_argument("--spec", default="", help="ops run spec YAML/JSON")
3338
+ sp.add_argument("--policies", default="", help="policies YAML list")
3339
+ sp.add_argument("--environment", default="unknown")
3340
+ sp.add_argument("--level", default="S1", choices=["S0", "S1", "S2", "S3", "S4", "S5"])
3341
+ sp.add_argument(
3342
+ "--execute",
3343
+ action="store_true",
3344
+ help="real mutation — requires hash-bound approval + host transports; default is dry-run",
3345
+ )
3346
+ sp.add_argument("--request", default="", help="delegate request JSON")
3347
+ sp.add_argument("--name", default="", help="runbook id (positional name overrides)")
3348
+ sp.add_argument("--bind", default="", help="runbook bind params JSON")
3349
+ sp.add_argument("name_pos", nargs="?", default="", help="runbook id or 'list'")
3350
+ sp.add_argument("--operation-id", default="")
3351
+ sp.add_argument(
3352
+ "--post-rollback",
3353
+ default="",
3354
+ help="rollback: post-rollback observation JSON {step_id: {dim: value}} for verification",
3355
+ )
3356
+ sp.add_argument("--resource", default="", help="history filter")
3357
+ sp.add_argument("--evidence-tier", default="", help="autorem-eval")
3358
+ # ops approve — mint a hash-bound approval artifact (never executes)
3359
+ sp.add_argument("--approval-id", default="")
3360
+ sp.add_argument("--subject-hash", default="", help="envelope/plan hash being approved")
3361
+ sp.add_argument("--subject-kind", default="execution-envelope")
3362
+ sp.add_argument("--actor", default="", help="human identity")
3363
+ sp.add_argument("--actor-kind", default="human", choices=["human", "agent", "host", "system"])
3364
+ sp.add_argument("--role", default="")
3365
+ sp.add_argument(
3366
+ "--approval-type",
3367
+ default="single-human",
3368
+ choices=[
3369
+ "automatic-policy",
3370
+ "single-human",
3371
+ "resource-owner",
3372
+ "platform-owner",
3373
+ "security-review",
3374
+ "dual-human",
3375
+ ],
3376
+ )
3377
+ sp.add_argument("--expires-at", default="")
3378
+ sp.add_argument("--bounds", default="", help="parameter bounds JSON")
3379
+ sp.add_argument("--scope", default="", help="csv resource scope")
3380
+ sp.add_argument("--reason", default="")
3381
+ sp.set_defaults(func=cmd_ops)
3382
+
3383
+ sp = sub.add_parser("finops", help="FinOps cost analysis")
3384
+ _add_common(sp)
3385
+ sp.add_argument(
3386
+ "finops_cmd",
3387
+ choices=["costs", "allocate", "focus", "focus-validate", "unit", "graph", "ingest", "report"],
3388
+ )
3389
+ sp.add_argument("path", nargs="?", default="")
3390
+ sp.add_argument("--by", default="cost_center")
3391
+ sp.add_argument("--denominators", default="", help="JSON {unit: measured_count} for `finops unit`")
3392
+ sp.set_defaults(func=cmd_finops)
3393
+
3394
+ # --- cycle 5 namespaces (read-only, fleet-dir inputs) -----------
3395
+ from platformforge.cli.fleetcmd import (
3396
+ cmd_ai,
3397
+ cmd_analytics,
3398
+ cmd_federation,
3399
+ cmd_fleet,
3400
+ cmd_optimize,
3401
+ )
3402
+
3403
+ sp = sub.add_parser("fleet", help="fleet intelligence (dir input)")
3404
+ _add_common(sp)
3405
+ sp.add_argument(
3406
+ "fleet_cmd",
3407
+ choices=[
3408
+ "list",
3409
+ "status",
3410
+ "coverage",
3411
+ "graph",
3412
+ "risks",
3413
+ "costs",
3414
+ "capacity",
3415
+ "incidents",
3416
+ "operations",
3417
+ "drift",
3418
+ "golden-path",
3419
+ "policies",
3420
+ "recommendations",
3421
+ "report",
3422
+ ],
3423
+ )
3424
+ sp.add_argument("path", nargs="?", default="", help="fleet dir (lab/fleets/acme shape)")
3425
+ sp.add_argument("--question", default="", help="risks: run a single fleet question")
3426
+ sp.add_argument("--limit", type=int, default=0)
3427
+ sp.add_argument("--window", default="", help="drift window (24h/7d/30d/90d)")
3428
+ sp.set_defaults(func=lambda a: _emit(cmd_fleet(a), a))
3429
+
3430
+ sp = sub.add_parser("analytics", help="analytics summary/metric/maturity")
3431
+ _add_common(sp)
3432
+ sp.add_argument("analytics_cmd", choices=["summary", "metric", "maturity"])
3433
+ sp.add_argument("path", nargs="?", default="")
3434
+ sp.add_argument("--id", default="", help="metric id")
3435
+ sp.set_defaults(func=lambda a: _emit(cmd_analytics(a), a))
3436
+
3437
+ sp = sub.add_parser("optimize", help="optimization scan → ChangeIntent only")
3438
+ _add_common(sp)
3439
+ sp.add_argument("optimize_cmd", choices=["scan", "list", "explain", "plan", "portfolio"])
3440
+ sp.add_argument("path", nargs="?", default="")
3441
+ sp.add_argument("--id", default="", help="recommendation id")
3442
+ sp.add_argument("--out", default="", help="plan: write the ChangeIntent doc to this file")
3443
+ sp.add_argument("--limit", type=int, default=0)
3444
+ sp.set_defaults(func=lambda a: _emit(cmd_optimize(a), a))
3445
+
3446
+ sp = sub.add_parser("ai", help="AI platform awareness (bounded)")
3447
+ _add_common(sp)
3448
+ sp.add_argument("ai_cmd", choices=["workloads", "gpu", "economics"])
3449
+ sp.add_argument("path", nargs="?", default="", help="yaml input (resources / capacity doc)")
3450
+ sp.add_argument("--cost", type=float, default=None)
3451
+ sp.add_argument("--denominators", default="", help="JSON {tokens,inferences,gpu_hours}")
3452
+ sp.set_defaults(func=lambda a: _emit(cmd_ai(a), a))
3453
+
3454
+ sp = sub.add_parser("federation", help="intelligence exchange — no credentials")
3455
+ _add_common(sp)
3456
+ sp.add_argument("federation_cmd", choices=["manifest", "export", "query"])
3457
+ sp.add_argument("path", nargs="?", default="", help="payload yaml (export)")
3458
+ sp.add_argument("question", nargs="?", default="", help="fleet question (query)")
3459
+ sp.add_argument("--nodes", nargs="*", default=[], help="fleet dirs (query)")
3460
+ sp.add_argument("--classification", default="")
3461
+ sp.add_argument("--manifest", default="")
3462
+ sp.add_argument("--node-id", default="")
3463
+ sp.add_argument("--capabilities", default="")
3464
+ sp.add_argument("--freshness", default="")
3465
+ sp.add_argument("--limit", type=int, default=0)
3466
+ sp.set_defaults(func=lambda a: _emit(cmd_federation(a), a))
3467
+
3468
+ sp = sub.add_parser("product", help="platform product verbs")
3469
+ _add_common(sp)
3470
+ sp.add_argument(
3471
+ "product_cmd",
3472
+ choices=["maturity", "scorecard", "backstage", "paths", "path", "capabilities", "path-analyze"],
3473
+ )
3474
+ sp.add_argument("--signals", default="", help="JSON signals doc")
3475
+ sp.add_argument("--findings", default="", help="findings JSON doc")
3476
+ sp.add_argument("--facts", default="", help="facts JSON for analysis")
3477
+ sp.add_argument("--id", default="", help="golden path id")
3478
+ sp.set_defaults(func=cmd_product)
3479
+
3480
+ sp = sub.add_parser("agents", help="agent roster, mirrors, referee")
3481
+ _add_common(sp)
3482
+ sp.add_argument("agents_cmd", choices=["list", "lint", "sync", "check", "playbook", "referee", "bench"])
3483
+ sp.add_argument("path", nargs="?", default="")
3484
+ sp.add_argument("--name", default="")
3485
+ sp.add_argument("--domain", default="")
3486
+ sp.set_defaults(func=cmd_agents)
3487
+
3488
+ sp = sub.add_parser("mcp", help="MCP server + host parity")
3489
+ _add_common(sp)
3490
+ sp.add_argument("mcp_cmd", choices=["tools", "call", "serve", "integrate", "detach"])
3491
+ sp.add_argument("path", nargs="?", default="")
3492
+ sp.add_argument("--name", default="")
3493
+ sp.add_argument("--input", default="")
3494
+ sp.add_argument("--host", default="generic", choices=["claude", "codex", "devin", "copilot", "generic"])
3495
+ sp.set_defaults(func=cmd_mcp)
3496
+
3497
+ verb(
3498
+ "collect",
3499
+ cmd_collect,
3500
+ "ingest artifact dumps → facts",
3501
+ lambda sp: sp.add_argument("path", nargs="?", default=""),
3502
+ )
3503
+ verb(
3504
+ "diagnose",
3505
+ cmd_diagnose,
3506
+ "node diagnosis: facts+findings+blast",
3507
+ lambda sp: (
3508
+ sp.add_argument("node"),
3509
+ sp.add_argument("--findings", default=""),
3510
+ sp.add_argument("--facts", default=""),
3511
+ ),
3512
+ )
3513
+ verb(
3514
+ "plan",
3515
+ cmd_plan,
3516
+ "findings → ordered remediation plan",
3517
+ lambda sp: (sp.add_argument("path"), sp.add_argument("--facts", default="")),
3518
+ )
3519
+ verb("correlate", cmd_correlate, "OTel correlation (observe otel)", lambda sp: sp.add_argument("path"))
3520
+ verb(
3521
+ "diff",
3522
+ cmd_diff,
3523
+ "graph diff between two facts docs",
3524
+ lambda sp: (sp.add_argument("--before", required=True), sp.add_argument("--after", required=True)),
3525
+ )
3526
+ verb(
3527
+ "drift",
3528
+ cmd_drift,
3529
+ "IaC drift: --config dir vs --state file",
3530
+ lambda sp: (sp.add_argument("--config", required=True), sp.add_argument("--state", required=True)),
3531
+ )
3532
+ verb(
3533
+ "impact",
3534
+ cmd_impact,
3535
+ "blast radius for a graph node",
3536
+ lambda sp: sp.add_argument("--node", required=True),
3537
+ )
3538
+ verb(
3539
+ "context",
3540
+ cmd_context,
3541
+ "context pack for a task; capsule/inspect/expand/delta/gc",
3542
+ lambda sp: (
3543
+ sp.add_argument(
3544
+ "context_cmd",
3545
+ nargs="?",
3546
+ default="pack",
3547
+ choices=["pack", "capsule", "inspect", "expand", "delta", "gc"],
3548
+ ),
3549
+ sp.add_argument("--task", default=""),
3550
+ sp.add_argument("--input-budget", type=int, default=None),
3551
+ sp.add_argument("--changed", nargs="*"),
3552
+ sp.add_argument("--scope", default="repository"),
3553
+ sp.add_argument("--budget-bytes", type=int, default=None),
3554
+ sp.add_argument("--essential-bytes", type=int, default=None),
3555
+ sp.add_argument("--required", nargs="*"),
3556
+ sp.add_argument("--role", default="specialist"),
3557
+ sp.add_argument("--facts", default=""),
3558
+ sp.add_argument("--findings", default=""),
3559
+ sp.add_argument("--rules", nargs="*"),
3560
+ sp.add_argument("--knowledge", nargs="*"),
3561
+ sp.add_argument("--graph-refs", nargs="*"),
3562
+ sp.add_argument("--questions", nargs="*"),
3563
+ sp.add_argument("--ref", default=""),
3564
+ sp.add_argument("--section", default=""),
3565
+ ),
3566
+ )
3567
+ verb(
3568
+ "cache",
3569
+ cmd_cache,
3570
+ "multilayer cache stats/inspect/invalidate/gc",
3571
+ lambda sp: (
3572
+ sp.add_argument("cache_cmd", choices=["stats", "inspect", "invalidate", "gc"]),
3573
+ sp.add_argument("--layer", default="fact"),
3574
+ sp.add_argument("--key", default=""),
3575
+ sp.add_argument("--scope", default=""),
3576
+ sp.add_argument("--deps", default=""),
3577
+ sp.add_argument("--dep", default=""),
3578
+ sp.add_argument("--value", default=""),
3579
+ ),
3580
+ )
3581
+ verb(
3582
+ "routing",
3583
+ cmd_routing,
3584
+ "routing explain/compare/scorecard",
3585
+ lambda sp: (
3586
+ sp.add_argument("routing_cmd", choices=["explain", "compare", "scorecard"]),
3587
+ sp.add_argument("--task", default=""),
3588
+ sp.add_argument("--profile", default="balanced"),
3589
+ sp.add_argument("--risk", default="low"),
3590
+ sp.add_argument("--signal", default=""),
3591
+ sp.add_argument("--estimated", default=""),
3592
+ sp.add_argument("--champion", default=""),
3593
+ sp.add_argument("--challenger", default=""),
3594
+ sp.add_argument("--approved", action="store_true"),
3595
+ sp.add_argument("--run-id", default=""),
3596
+ sp.add_argument("--mode", default="deterministic"),
3597
+ sp.add_argument("--correctness", type=float, default=None),
3598
+ sp.add_argument("--tokens", type=int, default=None),
3599
+ sp.add_argument("--agents", type=int, default=0),
3600
+ sp.add_argument("--provider-calls", type=int, default=0),
3601
+ ),
3602
+ )
3603
+ verb(
3604
+ "policy",
3605
+ cmd_policy,
3606
+ "policy-as-code catalog check",
3607
+ lambda sp: (
3608
+ sp.add_argument("policy_cmd", choices=["check", "list"]),
3609
+ sp.add_argument("path", nargs="?", default=""),
3610
+ ),
3611
+ )
3612
+ verb(
3613
+ "security",
3614
+ cmd_security,
3615
+ "security bundle (secrets+iam+sbom+supply)",
3616
+ lambda sp: sp.add_argument("path", nargs="?", default=""),
3617
+ )
3618
+ verb(
3619
+ "reliability",
3620
+ cmd_reliability,
3621
+ "SRE+K8S rules over facts",
3622
+ lambda sp: sp.add_argument("path", nargs="?", default=""),
3623
+ )
3624
+ verb(
3625
+ "integrate",
3626
+ cmd_integrate,
3627
+ "host integration (mcp parity)",
3628
+ lambda sp: (
3629
+ sp.add_argument(
3630
+ "--host", required=True, choices=["claude", "codex", "devin", "copilot", "generic"]
3631
+ ),
3632
+ sp.add_argument("--detach", action="store_true"),
3633
+ ),
3634
+ )
3635
+ verb(
3636
+ "hosts",
3637
+ cmd_hosts,
3638
+ "AI host observe + optional launch (integrate is the setup path)",
3639
+ lambda sp: (
3640
+ sp.add_argument(
3641
+ "host",
3642
+ nargs="?",
3643
+ default="all",
3644
+ choices=["all", "claude", "codex", "devin", "copilot"],
3645
+ ),
3646
+ sp.add_argument(
3647
+ "--launch", action="store_true", help="launch the host REPL in the repo (TTY only)"
3648
+ ),
3649
+ ),
3650
+ )
3651
+ verb(
3652
+ "portal",
3653
+ cmd_portal,
3654
+ "optional local web portal (127.0.0.1, session token) — never inside MCP",
3655
+ lambda sp: (
3656
+ sp.add_argument(
3657
+ "--no-browser",
3658
+ action="store_true",
3659
+ help="only print the session URL — SSH/headless or manual open",
3660
+ ),
3661
+ sp.add_argument(
3662
+ "--port", type=int, default=0, help="fixed port (default: a free port of the system)"
3663
+ ),
3664
+ ),
3665
+ )
3666
+ verb(
3667
+ "evals",
3668
+ cmd_evals,
3669
+ "eval framework (§94–95)",
3670
+ lambda sp: (
3671
+ sp.add_argument(
3672
+ "evals_cmd", choices=["run", "list", "coverage", "precision"], nargs="?", default="run"
3673
+ ),
3674
+ sp.add_argument("--type", default=""),
3675
+ sp.add_argument("--cases", default=""),
3676
+ ),
3677
+ )
3678
+
3679
+ sp = sub.add_parser("lab", help="Forge Lab scenarios")
3680
+ _add_common(sp)
3681
+ sp.add_argument("lab_cmd", choices=["list", "run", "run-all", "chaos"])
3682
+ sp.add_argument("path", nargs="?", default="")
3683
+ sp.add_argument("--allow-prod", action="store_true", help="chaos: allow env:production targets")
3684
+ sp.add_argument(
3685
+ "--profile",
3686
+ default="",
3687
+ choices=["static", "container", "kubernetes", "cloud"],
3688
+ help="§118 lab profile filter",
3689
+ )
3690
+ sp.add_argument("--allow-profile", action="store_true", help="§119 — opt into non-static lab profiles")
3691
+ sp.set_defaults(func=cmd_lab)
3692
+
3693
+ sp = sub.add_parser("forge", help="Forge interop")
3694
+ _add_common(sp)
3695
+ sp.add_argument("forge_cmd", choices=["manifest", "delegate", "verify", "discover", "collect"])
3696
+ sp.add_argument("path", nargs="?", default="")
3697
+ sp.add_argument("--name", default="")
3698
+ sp.add_argument("--src", default="")
3699
+ sp.add_argument("--dst", default="")
3700
+ sp.set_defaults(func=cmd_forge)
3701
+
3702
+ sp = sub.add_parser("capability", help="capability registry")
3703
+ _add_common(sp)
3704
+ sp.add_argument("capability_cmd", choices=["list", "describe", "manifest", "check"])
3705
+ sp.add_argument("--name", default="")
3706
+ sp.add_argument("--domain", default="", help="§139 — domain to negotiate, e.g. k8s")
3707
+ sp.add_argument("--version", default="", help="§139 — product version to check support for")
3708
+ sp.set_defaults(func=cmd_capability)
3709
+
3710
+ sp = sub.add_parser("risk", help="§130 change-risk assessment")
3711
+ _add_common(sp)
3712
+ sp.add_argument("--node", default="", help="graph node id to assess")
3713
+ sp.add_argument("--signals", default="", help="JSON signals doc")
3714
+ sp.set_defaults(func=cmd_risk)
3715
+
3716
+ sp = sub.add_parser("change", help="§85–86 change lifecycle (sandboxed)")
3717
+ _add_common(sp)
3718
+ sp.add_argument("change_cmd", choices=["propose", "sandbox", "verify", "review", "approve", "apply"])
3719
+ sp.add_argument("--patch", default="", help="unified diff file")
3720
+ sp.add_argument("--file", action="append", help="rel/path=src-file (or literal content)")
3721
+ sp.add_argument("--signals", default="", help="JSON risk signals doc (review only)")
3722
+ sp.set_defaults(func=cmd_change)
3723
+
3724
+ sp = sub.add_parser("explain", help="evidence chain for a finding")
3725
+ _add_common(sp)
3726
+ sp.add_argument("path", help="findings doc (json)")
3727
+ sp.add_argument("--name", default="", help="finding_id or rule_id")
3728
+ sp.add_argument("--facts", default="", help="facts doc (json)")
3729
+ sp.set_defaults(func=cmd_explain)
3730
+
3731
+ sp = sub.add_parser("recommend", help="findings → recommendations")
3732
+ _add_common(sp)
3733
+ sp.add_argument("path", help="findings doc (json)")
3734
+ sp.set_defaults(func=cmd_recommend)
3735
+
3736
+ sp = sub.add_parser("store", help="§151 artifact store stats/gc")
3737
+ _add_common(sp)
3738
+ sp.add_argument("store_cmd", choices=["stats", "gc"])
3739
+ sp.add_argument("--keep-days", type=float, default=30.0)
3740
+ sp.add_argument("--execute", action="store_true", help="actually delete (default: dry-run)")
3741
+ sp.set_defaults(func=cmd_store)
3742
+
3743
+ sp = sub.add_parser("bench", help="§148–150 measured benchmarks")
3744
+ _add_common(sp)
3745
+ sp.add_argument("bench_cmd", choices=["run", "tokens", "scale"], nargs="?", default="run")
3746
+ sp.add_argument("--repeat", type=int, default=3)
3747
+ sp.add_argument("--sizes", help="comma list for scale bench (default 50,200,800)")
3748
+ sp.add_argument("--edges", help="comma list for edge sweep (default 100000,250000,500000)")
3749
+ sp.add_argument(
3750
+ "--events", action="store_true", help="also run the analytics store event sweep (10k/100k/1M)"
3751
+ )
3752
+ sp.set_defaults(func=cmd_bench)
3753
+
3754
+ sp = sub.add_parser("freeze", help="architecture freeze governance")
3755
+ _add_common(sp)
3756
+ sp.add_argument(
3757
+ "freeze_cmd",
3758
+ choices=["manifest", "snapshot", "check", "exception"],
3759
+ )
3760
+ sp.add_argument("path", nargs="?", default="")
3761
+ sp.add_argument("--out", default="")
3762
+ sp.add_argument(
3763
+ "--spec", dest="json_file", default="", help="FeatureException JSON for `freeze exception`"
3764
+ )
3765
+ sp.set_defaults(func=cmd_freeze)
3766
+
3767
+ sp = sub.add_parser("cases", help="real-world case corpus + replay")
3768
+ _add_common(sp)
3769
+ sp.add_argument(
3770
+ "cases_cmd",
3771
+ choices=[
3772
+ "list",
3773
+ "validate",
3774
+ "replay",
3775
+ "template",
3776
+ "ledger",
3777
+ "ledger-check",
3778
+ "route-audit",
3779
+ "route-bench",
3780
+ "context-audit",
3781
+ ],
3782
+ )
3783
+ sp.add_argument("path", nargs="?", default="")
3784
+ sp.add_argument("--tier", default="", choices=["golden", "holdout"])
3785
+ sp.add_argument("--out", default="", help="challenger routing.yaml for route-bench")
3786
+ sp.add_argument(
3787
+ "--stamp", action="store_true", help="context-audit: write measured context_cost back into case.yaml"
3788
+ )
3789
+ sp.set_defaults(func=cmd_cases)
3790
+ return p
3791
+
3792
+
3793
+ def _stdio_utf8() -> None:
3794
+ for stream in (sys.stdout, sys.stderr):
3795
+ reconfigure = getattr(stream, "reconfigure", None)
3796
+ if reconfigure is not None:
3797
+ reconfigure(encoding="utf-8", errors="replace")
3798
+
3799
+
3800
+ def main(argv: list[str] | None = None) -> int:
3801
+ _stdio_utf8()
3802
+ global _RUN_STARTED
3803
+ _RUN_STARTED = time.time()
3804
+ parser = build_parser()
3805
+ argv = list(sys.argv[1:] if argv is None else argv)
3806
+ if not argv:
3807
+ try:
3808
+ import sys as _sys
3809
+
3810
+ if _sys.stdin.isatty() and _sys.stdout.isatty():
3811
+ from platformforge.ui.home import run_home
3812
+ from platformforge.ui.kit import NonInteractive
3813
+
3814
+ try:
3815
+ return run_home()
3816
+ except NonInteractive:
3817
+ pass
3818
+ except ImportError:
3819
+ pass
3820
+ return _bare_summary(parser)
3821
+ if argv[0] == "help":
3822
+ return _help_for(parser, argv)
3823
+ if argv == ["install"] and sys.stdin.isatty() and sys.stdout.isatty():
3824
+ from platformforge.portable_cli import run_guided_install
3825
+
3826
+ return run_guided_install()
3827
+ args = parser.parse_args(argv)
3828
+ return args.func(args)
3829
+
3830
+
3831
+ if __name__ == "__main__":
3832
+ sys.exit(main())