agentic-project-kit 1.0.1__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 (310) hide show
  1. agentic_project_kit/__init__.py +1 -0
  2. agentic_project_kit/absolute_path_portability_audit.py +241 -0
  3. agentic_project_kit/access_level_policy.py +14 -0
  4. agentic_project_kit/access_levels.py +22 -0
  5. agentic_project_kit/action_registry.py +195 -0
  6. agentic_project_kit/action_specs.py +286 -0
  7. agentic_project_kit/agent_command_runner.py +516 -0
  8. agentic_project_kit/audit_test_matrix.py +36 -0
  9. agentic_project_kit/audit_test_matrix_cli.py +43 -0
  10. agentic_project_kit/chat_bootloader.py +257 -0
  11. agentic_project_kit/chat_entrypoint_contract.py +297 -0
  12. agentic_project_kit/checks.py +511 -0
  13. agentic_project_kit/ci_readiness.py +211 -0
  14. agentic_project_kit/cli.py +147 -0
  15. agentic_project_kit/cli_commands/__init__.py +1 -0
  16. agentic_project_kit/cli_commands/absolute_path_audit.py +25 -0
  17. agentic_project_kit/cli_commands/actions.py +21 -0
  18. agentic_project_kit/cli_commands/artifact_gc.py +240 -0
  19. agentic_project_kit/cli_commands/boot.py +55 -0
  20. agentic_project_kit/cli_commands/chat.py +34 -0
  21. agentic_project_kit/cli_commands/checks.py +305 -0
  22. agentic_project_kit/cli_commands/cockpit.py +103 -0
  23. agentic_project_kit/cli_commands/command_taxonomy.py +25 -0
  24. agentic_project_kit/cli_commands/commands.py +129 -0
  25. agentic_project_kit/cli_commands/dev.py +23 -0
  26. agentic_project_kit/cli_commands/direction.py +92 -0
  27. agentic_project_kit/cli_commands/doc_currency_audit.py +25 -0
  28. agentic_project_kit/cli_commands/doc_registry.py +126 -0
  29. agentic_project_kit/cli_commands/docs.py +223 -0
  30. agentic_project_kit/cli_commands/dpa.py +1338 -0
  31. agentic_project_kit/cli_commands/evidence.py +251 -0
  32. agentic_project_kit/cli_commands/github.py +34 -0
  33. agentic_project_kit/cli_commands/governance.py +14 -0
  34. agentic_project_kit/cli_commands/gui.py +23 -0
  35. agentic_project_kit/cli_commands/gui_readiness_gate.py +27 -0
  36. agentic_project_kit/cli_commands/handoff.py +183 -0
  37. agentic_project_kit/cli_commands/human_workflows.py +430 -0
  38. agentic_project_kit/cli_commands/init.py +132 -0
  39. agentic_project_kit/cli_commands/instruction.py +75 -0
  40. agentic_project_kit/cli_commands/mutation_lock_audit.py +24 -0
  41. agentic_project_kit/cli_commands/ns_legacy_audit.py +25 -0
  42. agentic_project_kit/cli_commands/pass_already_done.py +66 -0
  43. agentic_project_kit/cli_commands/patch_failure_discipline_audit.py +26 -0
  44. agentic_project_kit/cli_commands/patch_preflight.py +37 -0
  45. agentic_project_kit/cli_commands/path_literal_audit.py +39 -0
  46. agentic_project_kit/cli_commands/patterns.py +40 -0
  47. agentic_project_kit/cli_commands/planning_docs_audit.py +25 -0
  48. agentic_project_kit/cli_commands/pr.py +133 -0
  49. agentic_project_kit/cli_commands/pr_hygiene.py +27 -0
  50. agentic_project_kit/cli_commands/profiles.py +26 -0
  51. agentic_project_kit/cli_commands/program_redundancy_audit.py +25 -0
  52. agentic_project_kit/cli_commands/project_direction.py +25 -0
  53. agentic_project_kit/cli_commands/release.py +392 -0
  54. agentic_project_kit/cli_commands/release_publish_orchestration.py +36 -0
  55. agentic_project_kit/cli_commands/remote_branch_hygiene.py +152 -0
  56. agentic_project_kit/cli_commands/remote_next.py +57 -0
  57. agentic_project_kit/cli_commands/removed_ns_commands.py +65 -0
  58. agentic_project_kit/cli_commands/rule_registry.py +129 -0
  59. agentic_project_kit/cli_commands/rules.py +222 -0
  60. agentic_project_kit/cli_commands/scaffold.py +46 -0
  61. agentic_project_kit/cli_commands/slice.py +21 -0
  62. agentic_project_kit/cli_commands/standard_gates_audit_suite.py +27 -0
  63. agentic_project_kit/cli_commands/state.py +51 -0
  64. agentic_project_kit/cli_commands/status_current_state_audit.py +30 -0
  65. agentic_project_kit/cli_commands/todo.py +53 -0
  66. agentic_project_kit/cli_commands/transfer.py +19 -0
  67. agentic_project_kit/cli_commands/transfer_context_flow.py +398 -0
  68. agentic_project_kit/cli_commands/transfer_context_helpers.py +398 -0
  69. agentic_project_kit/cli_commands/transfer_diagnostics.py +767 -0
  70. agentic_project_kit/cli_commands/transfer_evidence_flow.py +368 -0
  71. agentic_project_kit/cli_commands/transfer_handoff_flow.py +594 -0
  72. agentic_project_kit/cli_commands/transfer_order_contract.py +74 -0
  73. agentic_project_kit/cli_commands/transfer_post_merge_complete.py +442 -0
  74. agentic_project_kit/cli_commands/transfer_post_merge_settle.py +171 -0
  75. agentic_project_kit/cli_commands/transfer_pr_closeout_complete.py +124 -0
  76. agentic_project_kit/cli_commands/transfer_pr_create_flow.py +743 -0
  77. agentic_project_kit/cli_commands/transfer_pr_merge_flow.py +547 -0
  78. agentic_project_kit/cli_commands/transfer_repo_after_pr.py +160 -0
  79. agentic_project_kit/cli_commands/transfer_repo_pre_pr.py +664 -0
  80. agentic_project_kit/cli_commands/transfer_runner_flow.py +131 -0
  81. agentic_project_kit/cli_commands/transfer_runtime_initial.py +194 -0
  82. agentic_project_kit/cli_commands/transfer_shared.py +274 -0
  83. agentic_project_kit/cli_commands/validation.py +200 -0
  84. agentic_project_kit/cli_commands/version.py +27 -0
  85. agentic_project_kit/cli_commands/work_orders.py +219 -0
  86. agentic_project_kit/cli_commands/workflow.py +471 -0
  87. agentic_project_kit/cli_commands/workflow_guard.py +26 -0
  88. agentic_project_kit/cli_commands/workspace.py +252 -0
  89. agentic_project_kit/cockpit.py +608 -0
  90. agentic_project_kit/cockpit_readiness.py +67 -0
  91. agentic_project_kit/command_authority_audit.py +192 -0
  92. agentic_project_kit/command_inbox_check.py +103 -0
  93. agentic_project_kit/command_manifest.py +591 -0
  94. agentic_project_kit/command_queue_contract.py +161 -0
  95. agentic_project_kit/command_selector.py +245 -0
  96. agentic_project_kit/command_taxonomy.py +264 -0
  97. agentic_project_kit/commit_guard.py +67 -0
  98. agentic_project_kit/communication_artifact_gc.py +495 -0
  99. agentic_project_kit/communication_rule_context.py +437 -0
  100. agentic_project_kit/communication_state.py +115 -0
  101. agentic_project_kit/composed_wrapper_invariants.py +97 -0
  102. agentic_project_kit/composite_short_circuit.py +140 -0
  103. agentic_project_kit/contract.py +251 -0
  104. agentic_project_kit/control_state.py +83 -0
  105. agentic_project_kit/doc_currency_audit.py +536 -0
  106. agentic_project_kit/doc_lifecycle.py +1179 -0
  107. agentic_project_kit/doc_lifecycle_signals.py +446 -0
  108. agentic_project_kit/doc_lifecycle_sweep.py +639 -0
  109. agentic_project_kit/doc_mesh.py +491 -0
  110. agentic_project_kit/doctor.py +379 -0
  111. agentic_project_kit/documentation_registry.py +1122 -0
  112. agentic_project_kit/documentation_system_audit.py +310 -0
  113. agentic_project_kit/dpa_current_handoff_lifecycle.py +549 -0
  114. agentic_project_kit/dpa_dp2_decision_readiness.py +362 -0
  115. agentic_project_kit/dpa_dp3_dp4_adjudication.py +604 -0
  116. agentic_project_kit/dpa_dp5_block_new_gate.py +294 -0
  117. agentic_project_kit/dpa_dp5_stage_adoption.py +615 -0
  118. agentic_project_kit/dpa_dp5_strict_gate.py +187 -0
  119. agentic_project_kit/dpa_final_closeout.py +627 -0
  120. agentic_project_kit/dpa_fixture_evidence.py +680 -0
  121. agentic_project_kit/dpa_maintainer_record_check.py +711 -0
  122. agentic_project_kit/dpa_post_dp2_scope_assessment.py +734 -0
  123. agentic_project_kit/dpa_probe_002_readiness.py +509 -0
  124. agentic_project_kit/dpa_probe_003_readiness.py +417 -0
  125. agentic_project_kit/dpa_probe_004_readiness.py +437 -0
  126. agentic_project_kit/dpa_readiness.py +505 -0
  127. agentic_project_kit/dpa_readonly_probe_execution.py +500 -0
  128. agentic_project_kit/dpa_registry_contracts.py +312 -0
  129. agentic_project_kit/dpa_renderer_readiness.py +407 -0
  130. agentic_project_kit/dpa_repo_adoption_assessment.py +574 -0
  131. agentic_project_kit/dpa_stable_readiness.py +743 -0
  132. agentic_project_kit/dpa_successor_projection.py +168 -0
  133. agentic_project_kit/dpa_workspace_init_projection.py +93 -0
  134. agentic_project_kit/dpa_wrt_ch001_evidence.py +327 -0
  135. agentic_project_kit/entrypoint_slice_runner.py +153 -0
  136. agentic_project_kit/evidence_clean.py +152 -0
  137. agentic_project_kit/evidence_commit_paths.py +214 -0
  138. agentic_project_kit/evidence_finalize_log.py +204 -0
  139. agentic_project_kit/evidence_guard.py +114 -0
  140. agentic_project_kit/evidence_inspector.py +507 -0
  141. agentic_project_kit/evidence_state_contract.py +99 -0
  142. agentic_project_kit/execution_mode_state.py +148 -0
  143. agentic_project_kit/final_summary_contract.py +85 -0
  144. agentic_project_kit/finalize_guard.py +139 -0
  145. agentic_project_kit/gatekeeper_core.py +232 -0
  146. agentic_project_kit/github.py +25 -0
  147. agentic_project_kit/governance.py +59 -0
  148. agentic_project_kit/gui_action_execution.py +105 -0
  149. agentic_project_kit/gui_action_renderer.py +81 -0
  150. agentic_project_kit/gui_action_run_intent.py +98 -0
  151. agentic_project_kit/gui_action_views.py +195 -0
  152. agentic_project_kit/gui_activity_log.py +76 -0
  153. agentic_project_kit/gui_button_catalog.py +605 -0
  154. agentic_project_kit/gui_cockpit.py +365 -0
  155. agentic_project_kit/gui_cockpit_actions.py +695 -0
  156. agentic_project_kit/gui_cockpit_common.py +172 -0
  157. agentic_project_kit/gui_cockpit_header.py +501 -0
  158. agentic_project_kit/gui_cockpit_sidebar.py +387 -0
  159. agentic_project_kit/gui_cockpit_task.py +748 -0
  160. agentic_project_kit/gui_command_projection.py +355 -0
  161. agentic_project_kit/gui_communication_modes.py +130 -0
  162. agentic_project_kit/gui_dry_run.py +219 -0
  163. agentic_project_kit/gui_gatekeeper_status.py +259 -0
  164. agentic_project_kit/gui_layout_plan.py +148 -0
  165. agentic_project_kit/gui_open_folder.py +18 -0
  166. agentic_project_kit/gui_output_status_panel.py +61 -0
  167. agentic_project_kit/gui_panel_state.py +47 -0
  168. agentic_project_kit/gui_presenter.py +62 -0
  169. agentic_project_kit/gui_readiness_gate.py +206 -0
  170. agentic_project_kit/gui_release_flow.py +59 -0
  171. agentic_project_kit/gui_task_editor.py +1077 -0
  172. agentic_project_kit/gui_tk_widgets.py +177 -0
  173. agentic_project_kit/gui_tkinter_renderer.py +87 -0
  174. agentic_project_kit/gui_tkinter_shell.py +1176 -0
  175. agentic_project_kit/gui_transfer_contract.py +28 -0
  176. agentic_project_kit/gui_viewmodel.py +698 -0
  177. agentic_project_kit/gui_window_guard.py +46 -0
  178. agentic_project_kit/handoff_freshness.py +390 -0
  179. agentic_project_kit/handoff_prompt.py +180 -0
  180. agentic_project_kit/handoff_state.py +254 -0
  181. agentic_project_kit/instruction_lint.py +455 -0
  182. agentic_project_kit/llm_context_carriers.py +66 -0
  183. agentic_project_kit/llm_execution_context.py +285 -0
  184. agentic_project_kit/local_command_stack.py +133 -0
  185. agentic_project_kit/local_feature_gate.py +82 -0
  186. agentic_project_kit/local_garbage_collector.py +269 -0
  187. agentic_project_kit/models.py +18 -0
  188. agentic_project_kit/mutation_lock_audit.py +421 -0
  189. agentic_project_kit/next_turn_evidence.py +253 -0
  190. agentic_project_kit/next_turn_merge_if_green.py +459 -0
  191. agentic_project_kit/next_turn_pr_status.py +255 -0
  192. agentic_project_kit/next_turn_result.py +183 -0
  193. agentic_project_kit/next_turn_runner.py +159 -0
  194. agentic_project_kit/next_turn_slot.py +107 -0
  195. agentic_project_kit/next_turn_status.py +274 -0
  196. agentic_project_kit/ns_legacy_reference_audit.py +258 -0
  197. agentic_project_kit/ns_slice_runner.py +16 -0
  198. agentic_project_kit/operational_handoff_projection.py +124 -0
  199. agentic_project_kit/output_contract.py +80 -0
  200. agentic_project_kit/output_repair.py +61 -0
  201. agentic_project_kit/pass_already_done.py +216 -0
  202. agentic_project_kit/patch_artifact_preflight.py +147 -0
  203. agentic_project_kit/patch_cycle_workflow.py +440 -0
  204. agentic_project_kit/patch_failure_discipline_audit.py +223 -0
  205. agentic_project_kit/patch_preflight.py +215 -0
  206. agentic_project_kit/path_literal_audit.py +612 -0
  207. agentic_project_kit/patterns.py +99 -0
  208. agentic_project_kit/planning_docs_consolidation_audit.py +313 -0
  209. agentic_project_kit/portability_shell_gate.py +40 -0
  210. agentic_project_kit/post_merge_handoff_refresh.py +56 -0
  211. agentic_project_kit/post_release.py +252 -0
  212. agentic_project_kit/post_release_closeout.py +616 -0
  213. agentic_project_kit/pr_cleanup.py +172 -0
  214. agentic_project_kit/pr_closeout.py +50 -0
  215. agentic_project_kit/pr_closeout_complete.py +344 -0
  216. agentic_project_kit/pr_create_or_skip.py +71 -0
  217. agentic_project_kit/pr_hygiene.py +210 -0
  218. agentic_project_kit/program_redundancy_audit.py +251 -0
  219. agentic_project_kit/project_direction.py +840 -0
  220. agentic_project_kit/protected_change_planner.py +194 -0
  221. agentic_project_kit/release.py +401 -0
  222. agentic_project_kit/release_doi_safety.py +24 -0
  223. agentic_project_kit/release_gate_core.py +174 -0
  224. agentic_project_kit/release_metadata_authority_gate.py +328 -0
  225. agentic_project_kit/release_metadata_prep.py +43 -0
  226. agentic_project_kit/release_notes.py +823 -0
  227. agentic_project_kit/release_prep_core.py +204 -0
  228. agentic_project_kit/release_prepare.py +369 -0
  229. agentic_project_kit/release_process_guardrails.py +247 -0
  230. agentic_project_kit/release_publish_core.py +114 -0
  231. agentic_project_kit/release_publish_orchestration.py +469 -0
  232. agentic_project_kit/release_state.py +552 -0
  233. agentic_project_kit/release_verify_core.py +175 -0
  234. agentic_project_kit/remote_branch_hygiene.py +223 -0
  235. agentic_project_kit/remote_next.py +89 -0
  236. agentic_project_kit/remote_next_closeout.py +101 -0
  237. agentic_project_kit/remote_next_order_contract.py +391 -0
  238. agentic_project_kit/removed_ns_commands.py +458 -0
  239. agentic_project_kit/removed_source_audit.py +225 -0
  240. agentic_project_kit/rendering.py +13 -0
  241. agentic_project_kit/repair_report.py +79 -0
  242. agentic_project_kit/repo_identity.py +59 -0
  243. agentic_project_kit/result_report_classifier.py +57 -0
  244. agentic_project_kit/rule_ack.py +105 -0
  245. agentic_project_kit/rule_preservation.py +58 -0
  246. agentic_project_kit/rule_refresh.py +174 -0
  247. agentic_project_kit/rule_registry_registration.py +322 -0
  248. agentic_project_kit/rule_registry_report.py +206 -0
  249. agentic_project_kit/rule_registry_validator.py +704 -0
  250. agentic_project_kit/rule_snapshot.py +98 -0
  251. agentic_project_kit/rule_source_validator.py +133 -0
  252. agentic_project_kit/run_summary.py +68 -0
  253. agentic_project_kit/run_summary_renderer.py +238 -0
  254. agentic_project_kit/runtime_validator.py +68 -0
  255. agentic_project_kit/safe_file_replace.py +35 -0
  256. agentic_project_kit/safe_patch.py +47 -0
  257. agentic_project_kit/safe_push.py +218 -0
  258. agentic_project_kit/safe_remove_diagnostic.py +51 -0
  259. agentic_project_kit/scaffold.py +69 -0
  260. agentic_project_kit/site_claims.py +444 -0
  261. agentic_project_kit/site_generator.py +1140 -0
  262. agentic_project_kit/slice_gate.py +225 -0
  263. agentic_project_kit/slice_runner.py +75 -0
  264. agentic_project_kit/standard_gates_audit_suite.py +167 -0
  265. agentic_project_kit/state_freshness.py +135 -0
  266. agentic_project_kit/status_current_state_audit.py +762 -0
  267. agentic_project_kit/successor_handoff_package.py +1763 -0
  268. agentic_project_kit/templates.py +292 -0
  269. agentic_project_kit/terminal_block_guard.py +166 -0
  270. agentic_project_kit/terminal_logging.py +275 -0
  271. agentic_project_kit/todo.py +97 -0
  272. agentic_project_kit/transfer_closeout.py +136 -0
  273. agentic_project_kit/transfer_continue.py +215 -0
  274. agentic_project_kit/transfer_local_runner.py +101 -0
  275. agentic_project_kit/transfer_operation_monitor.py +236 -0
  276. agentic_project_kit/transfer_post_merge_lifecycle.py +356 -0
  277. agentic_project_kit/transfer_post_merge_settle.py +383 -0
  278. agentic_project_kit/transfer_pr_actions.py +164 -0
  279. agentic_project_kit/transfer_remote_next.py +959 -0
  280. agentic_project_kit/transfer_repo_actions.py +2253 -0
  281. agentic_project_kit/transfer_runner.py +347 -0
  282. agentic_project_kit/transfer_safety_context.py +291 -0
  283. agentic_project_kit/transfer_state.py +483 -0
  284. agentic_project_kit/transfer_uplink.py +471 -0
  285. agentic_project_kit/transfer_workflow_next.py +148 -0
  286. agentic_project_kit/typed_work_order_evidence.py +58 -0
  287. agentic_project_kit/typed_work_order_queue.py +166 -0
  288. agentic_project_kit/typed_work_order_runner.py +168 -0
  289. agentic_project_kit/work_cycle.py +317 -0
  290. agentic_project_kit/work_discard_changes.py +129 -0
  291. agentic_project_kit/work_order_patch.py +199 -0
  292. agentic_project_kit/work_order_runner.py +120 -0
  293. agentic_project_kit/work_order_uploader.py +220 -0
  294. agentic_project_kit/work_order_validator.py +185 -0
  295. agentic_project_kit/work_orders.py +246 -0
  296. agentic_project_kit/workflow_guard.py +262 -0
  297. agentic_project_kit/workflow_summary_runner.py +124 -0
  298. agentic_project_kit/workspace.py +665 -0
  299. agentic_project_kit/workspace_adopt.py +471 -0
  300. agentic_project_kit/workspace_dpa_intake.py +406 -0
  301. agentic_project_kit/workspace_init.py +487 -0
  302. agentic_project_kit/workspace_lock.py +129 -0
  303. agentic_project_kit/workspace_remove.py +381 -0
  304. agentic_project_kit/workspace_upgrade.py +324 -0
  305. agentic_project_kit/wrapper_live_status.py +104 -0
  306. agentic_project_kit-1.0.1.dist-info/METADATA +724 -0
  307. agentic_project_kit-1.0.1.dist-info/RECORD +310 -0
  308. agentic_project_kit-1.0.1.dist-info/WHEEL +4 -0
  309. agentic_project_kit-1.0.1.dist-info/entry_points.txt +3 -0
  310. agentic_project_kit-1.0.1.dist-info/licenses/LICENSE +10 -0
@@ -0,0 +1,743 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from pathlib import Path
6
+ import subprocess
7
+ from typing import Any
8
+
9
+ from agentic_project_kit.dpa_dp5_strict_gate import evaluate_dp5_strict_gate
10
+ from agentic_project_kit.dpa_final_closeout import (
11
+ DEFAULT_DPA_FINAL_CLOSEOUT_RECORD_PATH,
12
+ VALID_STATUS as VALID_FINAL_CLOSEOUT_STATUS,
13
+ evaluate_dpa_final_closeout_record,
14
+ )
15
+ from agentic_project_kit.dpa_post_dp2_scope_assessment import (
16
+ evaluate_post_dp2_scope_assessment,
17
+ )
18
+ from agentic_project_kit.dpa_probe_002_readiness import (
19
+ evaluate_probe_002_lifecycle_readiness,
20
+ )
21
+ from agentic_project_kit.dpa_probe_003_readiness import (
22
+ evaluate_probe_003_workflow_readiness,
23
+ )
24
+ from agentic_project_kit.dpa_probe_004_readiness import (
25
+ evaluate_probe_004_migration_readiness,
26
+ )
27
+ from agentic_project_kit.dpa_readiness import evaluate_dpa_readiness
28
+ from agentic_project_kit.dpa_renderer_readiness import evaluate_renderer_probe_readiness
29
+ from agentic_project_kit.workspace import load_workspace
30
+
31
+ DEFAULT_DPA_STABLE_PROMOTION_RECORD_PATH = Path(
32
+ "docs/architecture/evidence/dpa/assessment/DPA_STABLE_PROMOTION_RECORD_20260801.json"
33
+ )
34
+ DPA_STABLE_PROMOTION_MODEL = "dpa-stable-promotion-v1"
35
+ READY_FOR_STABLE_PROMOTION_STATUS = "READY_FOR_STABLE_DPA_PROMOTION"
36
+ VALID_STABLE_PROMOTION_STATUS = "VALID_DPA_STABLE_PROMOTION_RECORD"
37
+ BLOCKED_STATUS = "BLOCKED_FOR_STABLE_DPA_PROMOTION"
38
+ INVALID_STATUS = "INVALID_DPA_STABLE_PROMOTION_RECORD"
39
+ PROMOTION_RECORD_STATUS = "DPA_STABLE_PROMOTION_RECORDED"
40
+ PROMOTION_DECISION_TOKEN = "DPA_STABLE_PROMOTION_AUTHORIZED"
41
+ STABLE_SCOPE_ID = "DPA_STABLE_ACCEPTED_KIT_SCOPE"
42
+ STABLE_CLAIM_SCOPE = "accepted-kit-dpa-dp1-dp5-stable-implementation-scope"
43
+ EXTERNAL_REPO_STATUS = "DPA_CAPABLE_WITH_FRESH_PER_REPO_ASSESSMENT"
44
+ EVIDENCE_OUTPUT_ROOT_PARTS = ("evidence", "dpa", "assessment")
45
+
46
+ SPEC_FILES: tuple[tuple[str, str], ...] = (
47
+ ("DPA-000", "docs/architecture/dpa/specs/DPA-000-VISION.md"),
48
+ ("DPA-100", "docs/architecture/dpa/specs/DPA-100-FOUNDATIONS.md"),
49
+ ("DPA-200", "docs/architecture/dpa/specs/DPA-200-DOCUMENT-MODEL.md"),
50
+ ("DPA-200-FORM-MATRIX", "docs/architecture/dpa/specs/DPA-200-DOCUMENT-FORM-MATRIX.md"),
51
+ ("DPA-300", "docs/architecture/dpa/specs/DPA-300-REGISTRY-LIFECYCLE-INTEGRATION.md"),
52
+ ("DPA-400", "docs/architecture/dpa/specs/DPA-400-RENDERER-CONTRACT.md"),
53
+ ("DPA-500", "docs/architecture/dpa/specs/DPA-500-FRESHNESS-AND-GATES.md"),
54
+ ("DPA-600", "docs/architecture/dpa/specs/DPA-600-CONCURRENCY.md"),
55
+ ("DPA-700", "docs/architecture/dpa/specs/DPA-700-MIGRATION.md"),
56
+ ("DPA-800", "docs/architecture/dpa/specs/DPA-800-DP1-DP5.md"),
57
+ ("DPA-900", "docs/architecture/dpa/specs/DPA-900-FUTURE.md"),
58
+ )
59
+ BASELINE_STABLE_SPEC_IDS = frozenset({"DPA-000", "DPA-100"})
60
+ PROMOTED_SPEC_IDS = tuple(spec_id for spec_id, _path in SPEC_FILES)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class DpaStableReadinessFinding:
65
+ code: str
66
+ message: str
67
+ path: str
68
+
69
+ def as_dict(self) -> dict[str, str]:
70
+ return {"code": self.code, "message": self.message, "path": self.path}
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class DpaSpecStatus:
75
+ spec_id: str
76
+ path: str
77
+ status: str
78
+ present: bool
79
+
80
+ @property
81
+ def stable(self) -> bool:
82
+ return self.present and self.status == "stable"
83
+
84
+ @property
85
+ def promotable(self) -> bool:
86
+ return self.present and self.status in {"review-ready", "stable"}
87
+
88
+ def as_dict(self) -> dict[str, Any]:
89
+ return {
90
+ "id": self.spec_id,
91
+ "path": self.path,
92
+ "status": self.status,
93
+ "present": self.present,
94
+ "stable": self.stable,
95
+ "promotable": self.promotable,
96
+ }
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class DpaStableReadinessResult:
101
+ root: str
102
+ current_validation_ref: str
103
+ promotion_record_path: str
104
+ promotion_record_present: bool
105
+ record_validation_ref: str
106
+ record_status: str
107
+ decision_token: str
108
+ dp2_status: str
109
+ dp2_implementation_percent: int
110
+ post_dp2_status: str
111
+ strict_gate_status: str
112
+ final_closeout_status: str
113
+ kit_wide_dpa_conformance_claimed: bool
114
+ stable_dpa_claimed: bool
115
+ external_repo_conformance_claimed: bool
116
+ probe_statuses: dict[str, str]
117
+ spec_statuses: tuple[DpaSpecStatus, ...]
118
+ findings: tuple[DpaStableReadinessFinding, ...]
119
+
120
+ @property
121
+ def ready_for_promotion(self) -> bool:
122
+ return not self.promotion_record_present and not self.findings
123
+
124
+ @property
125
+ def stable_promoted(self) -> bool:
126
+ return self.promotion_record_present and not self.findings
127
+
128
+ @property
129
+ def ok(self) -> bool:
130
+ return self.ready_for_promotion or self.stable_promoted
131
+
132
+ @property
133
+ def result_status(self) -> str:
134
+ if self.promotion_record_present:
135
+ return VALID_STABLE_PROMOTION_STATUS if not self.findings else INVALID_STATUS
136
+ return READY_FOR_STABLE_PROMOTION_STATUS if not self.findings else BLOCKED_STATUS
137
+
138
+ def as_dict(self) -> dict[str, Any]:
139
+ return {
140
+ "schema_version": 1,
141
+ "kind": "dpa_stable_readiness_check",
142
+ "promotion_model": DPA_STABLE_PROMOTION_MODEL,
143
+ "result_status": self.result_status,
144
+ "current_validation_ref": self.current_validation_ref,
145
+ "promotion_record_path": self.promotion_record_path,
146
+ "promotion_record_present": self.promotion_record_present,
147
+ "record_validation_ref": self.record_validation_ref,
148
+ "record_status": self.record_status,
149
+ "decision_token": self.decision_token,
150
+ "dp2_status": self.dp2_status,
151
+ "dp2_implementation_percent": self.dp2_implementation_percent,
152
+ "post_dp2_status": self.post_dp2_status,
153
+ "strict_gate_status": self.strict_gate_status,
154
+ "final_closeout_status": self.final_closeout_status,
155
+ "probe_statuses": self.probe_statuses,
156
+ "spec_statuses": [item.as_dict() for item in self.spec_statuses],
157
+ "finding_count": len(self.findings),
158
+ "findings": [finding.as_dict() for finding in self.findings],
159
+ "foreign_repo_management": {
160
+ "status": EXTERNAL_REPO_STATUS,
161
+ "automatic_external_repo_conformance_claimed": (
162
+ self.external_repo_conformance_claimed
163
+ ),
164
+ "requires_fresh_per_repo_inventory": True,
165
+ "requires_fresh_per_repo_dpa_600_700_evidence": True,
166
+ "requires_maintainer_authorized_scope": True,
167
+ },
168
+ "claims": {
169
+ "kit_wide_dpa_conformance_claimed": self.kit_wide_dpa_conformance_claimed,
170
+ "stable_dpa_claimed": self.stable_dpa_claimed,
171
+ "production_mutation_performed": False,
172
+ "generated_outputs_manually_patched": False,
173
+ "external_repo_conformance_claimed": self.external_repo_conformance_claimed,
174
+ },
175
+ }
176
+
177
+
178
+ def evaluate_dpa_stable_readiness(
179
+ root: Path | str = ".",
180
+ *,
181
+ record_path: Path | str = DEFAULT_DPA_STABLE_PROMOTION_RECORD_PATH,
182
+ validation_ref: str | None = None,
183
+ ) -> DpaStableReadinessResult:
184
+ base = Path(root).resolve()
185
+ current_ref = validation_ref or _git_head(base)
186
+ promotion_path = _resolve_under_root(base, record_path)
187
+ display_path = _display_path(promotion_path, base)
188
+ spec_statuses = tuple(_spec_status(base, spec_id, path) for spec_id, path in SPEC_FILES)
189
+ findings: list[DpaStableReadinessFinding] = []
190
+
191
+ _validate_spec_prerequisites(spec_statuses, findings)
192
+
193
+ dp2 = evaluate_dpa_readiness(base)
194
+ if not dp2.dp2_ready:
195
+ _finding(findings, "dp2-not-authorized", "DP2 readiness must remain authorized", dp2.path)
196
+ if dp2.implementation_percent != 100:
197
+ _finding(
198
+ findings,
199
+ "dp2-percent-not-100",
200
+ "DP2 selected-scope implementation percent must remain 100",
201
+ dp2.path,
202
+ )
203
+
204
+ post_dp2 = evaluate_post_dp2_scope_assessment(base, validation_ref=current_ref)
205
+ if not post_dp2.final_closeout_ready or post_dp2.blocker_count != 0:
206
+ _finding(
207
+ findings,
208
+ "post-dp2-not-closeout-ready",
209
+ "Post-DP2 DP3-DP5 assessment must remain ready for final closeout",
210
+ post_dp2.readiness_record,
211
+ )
212
+
213
+ strict_gate = evaluate_dp5_strict_gate(base, validation_ref=current_ref)
214
+ if not strict_gate.ok:
215
+ _finding(
216
+ findings,
217
+ "dp5-strict-gate-not-pass",
218
+ "DP5 strict gate must pass with zero configured blockers",
219
+ display_path,
220
+ )
221
+
222
+ final_closeout = evaluate_dpa_final_closeout_record(base, validation_ref=current_ref)
223
+ if not final_closeout.ok:
224
+ _finding(
225
+ findings,
226
+ "final-closeout-invalid",
227
+ "Final DP1-DP5 closeout record must be structurally valid",
228
+ final_closeout.record_path,
229
+ )
230
+ if final_closeout.kit_wide_dpa_conformance_claimed is not True:
231
+ _finding(
232
+ findings,
233
+ "kit-wide-conformance-not-claimed-by-closeout",
234
+ "Final closeout must own the bounded Kit-wide DPA conformance claim",
235
+ final_closeout.record_path,
236
+ )
237
+ if final_closeout.stable_dpa_claimed is not False:
238
+ _finding(
239
+ findings,
240
+ "final-closeout-overclaims-stable",
241
+ "Final closeout must leave stable DPA to the Stable Promotion record",
242
+ final_closeout.record_path,
243
+ )
244
+
245
+ probe_statuses = _probe_statuses(base, current_ref, findings)
246
+
247
+ record_data: dict[str, Any] = {}
248
+ if promotion_path.exists():
249
+ record_data = _load_promotion_record(promotion_path, base, findings)
250
+ if record_data:
251
+ _validate_promotion_record(record_data, base, display_path, spec_statuses, findings)
252
+
253
+ claims = _mapping(record_data.get("claims"))
254
+ external_scope = _mapping(record_data.get("external_repo_management"))
255
+
256
+ return DpaStableReadinessResult(
257
+ root=base.as_posix(),
258
+ current_validation_ref=current_ref,
259
+ promotion_record_path=display_path,
260
+ promotion_record_present=promotion_path.exists(),
261
+ record_validation_ref=str(record_data.get("validation_ref", "")),
262
+ record_status=str(record_data.get("status", "")),
263
+ decision_token=str(record_data.get("decision_token", "")),
264
+ dp2_status=dp2.status,
265
+ dp2_implementation_percent=dp2.implementation_percent,
266
+ post_dp2_status=post_dp2.kit_wide_dpa_status,
267
+ strict_gate_status=strict_gate.result_status,
268
+ final_closeout_status=final_closeout.result_status,
269
+ kit_wide_dpa_conformance_claimed=claims.get("kit_wide_dpa_conformance_claimed") is True,
270
+ stable_dpa_claimed=claims.get("stable_dpa_claimed") is True,
271
+ external_repo_conformance_claimed=(
272
+ claims.get("external_repo_conformance_claimed") is True
273
+ or external_scope.get("automatic_external_repo_conformance_claimed") is True
274
+ ),
275
+ probe_statuses=probe_statuses,
276
+ spec_statuses=spec_statuses,
277
+ findings=tuple(findings),
278
+ )
279
+
280
+
281
+ def render_dpa_stable_readiness(result: DpaStableReadinessResult) -> str:
282
+ payload = result.as_dict()
283
+ lines = [
284
+ "DPA_STABLE_READINESS_CHECK",
285
+ f"STATUS={payload['result_status']}",
286
+ f"CURRENT_VALIDATION_REF={payload['current_validation_ref']}",
287
+ f"PROMOTION_RECORD={payload['promotion_record_path']}",
288
+ f"PROMOTION_RECORD_PRESENT={str(payload['promotion_record_present']).lower()}",
289
+ f"RECORD_VALIDATION_REF={payload['record_validation_ref']}",
290
+ f"RECORD_STATUS={payload['record_status']}",
291
+ f"DECISION_TOKEN={payload['decision_token']}",
292
+ f"DP2_STATUS={payload['dp2_status']}",
293
+ f"DP2_IMPLEMENTATION_PERCENT={payload['dp2_implementation_percent']}",
294
+ f"POST_DP2_STATUS={payload['post_dp2_status']}",
295
+ f"STRICT_GATE_STATUS={payload['strict_gate_status']}",
296
+ f"FINAL_CLOSEOUT_STATUS={payload['final_closeout_status']}",
297
+ f"KIT_WIDE_DPA_CONFORMANCE_CLAIMED={str(payload['claims']['kit_wide_dpa_conformance_claimed']).lower()}",
298
+ f"STABLE_DPA_CLAIMED={str(payload['claims']['stable_dpa_claimed']).lower()}",
299
+ f"EXTERNAL_REPO_CONFORMANCE_CLAIMED={str(payload['claims']['external_repo_conformance_claimed']).lower()}",
300
+ f"FINDINGS={payload['finding_count']}",
301
+ ]
302
+ for spec in payload["spec_statuses"]:
303
+ lines.append(f"SPEC={spec['id']}|status={spec['status']}|stable={str(spec['stable']).lower()}")
304
+ for probe_id, status in payload["probe_statuses"].items():
305
+ lines.append(f"PROBE={probe_id}|status={status}")
306
+ for finding in result.findings:
307
+ lines.append(f"FINDING={finding.code}|path={finding.path}|{finding.message}")
308
+ return "\n".join(lines) + "\n"
309
+
310
+
311
+ def write_dpa_stable_readiness_json(
312
+ result: DpaStableReadinessResult,
313
+ root: Path | str,
314
+ output: Path | str,
315
+ *,
316
+ execute: bool,
317
+ ) -> dict[str, Any]:
318
+ base = Path(root).resolve()
319
+ output_path = _resolve_under_root(base, output)
320
+ relative = output_path.relative_to(base)
321
+ evidence_root = _evidence_output_root(base)
322
+ if evidence_root not in (output_path, *output_path.parents):
323
+ return {
324
+ "result_status": "BLOCK",
325
+ "reason": "output_outside_dpa_assessment_evidence_root",
326
+ "output_path": relative.as_posix(),
327
+ "written": False,
328
+ }
329
+ rendered = json.dumps(result.as_dict(), indent=2, sort_keys=True) + "\n"
330
+ changed = True
331
+ if output_path.exists():
332
+ changed = output_path.read_text(encoding="utf-8") != rendered
333
+ if execute:
334
+ output_path.parent.mkdir(parents=True, exist_ok=True)
335
+ output_path.write_text(rendered, encoding="utf-8")
336
+ return {
337
+ "result_status": "PASS",
338
+ "output_path": relative.as_posix(),
339
+ "changed": changed,
340
+ "written": bool(execute),
341
+ }
342
+
343
+
344
+ def _validate_spec_prerequisites(
345
+ specs: tuple[DpaSpecStatus, ...],
346
+ findings: list[DpaStableReadinessFinding],
347
+ ) -> None:
348
+ for spec in specs:
349
+ if not spec.present:
350
+ _finding(findings, "spec-missing", f"{spec.spec_id} spec file is missing", spec.path)
351
+ continue
352
+ if spec.spec_id in BASELINE_STABLE_SPEC_IDS and not spec.stable:
353
+ _finding(
354
+ findings,
355
+ "baseline-spec-not-stable",
356
+ f"{spec.spec_id} must already be stable before DPA stable promotion",
357
+ spec.path,
358
+ )
359
+ elif not spec.promotable:
360
+ _finding(
361
+ findings,
362
+ "spec-not-promotable",
363
+ f"{spec.spec_id} must be review-ready or stable before DPA stable promotion",
364
+ spec.path,
365
+ )
366
+
367
+
368
+ def _validate_promotion_record(
369
+ data: dict[str, Any],
370
+ root: Path,
371
+ path: str,
372
+ specs: tuple[DpaSpecStatus, ...],
373
+ findings: list[DpaStableReadinessFinding],
374
+ ) -> None:
375
+ if data.get("schema_version") != 1:
376
+ _finding(findings, "schema-version-invalid", "schema_version must be 1", path)
377
+ if data.get("kind") != "dpa_stable_promotion_record":
378
+ _finding(findings, "kind-invalid", "kind must be dpa_stable_promotion_record", path)
379
+ if data.get("status") != PROMOTION_RECORD_STATUS:
380
+ _finding(findings, "status-invalid", f"status must be {PROMOTION_RECORD_STATUS}", path)
381
+ if data.get("decision_token") != PROMOTION_DECISION_TOKEN:
382
+ _finding(
383
+ findings,
384
+ "decision-token-invalid",
385
+ f"decision_token must be {PROMOTION_DECISION_TOKEN}",
386
+ path,
387
+ )
388
+ validation_ref = str(data.get("validation_ref", "")).strip()
389
+ if not validation_ref:
390
+ _finding(findings, "validation-ref-missing", "validation_ref must record an exact ref", path)
391
+ elif _git_ref_exists(root, validation_ref) is False:
392
+ _finding(
393
+ findings,
394
+ "validation-ref-unresolvable",
395
+ f"validation_ref is not a known commit: {validation_ref}",
396
+ path,
397
+ )
398
+ if not str(data.get("maintainer", "")).strip():
399
+ _finding(findings, "maintainer-missing", "maintainer authorization text must be recorded", path)
400
+
401
+ scope = _mapping(data.get("stable_scope"))
402
+ criteria = _mapping(data.get("criteria"))
403
+ rollback = _mapping(data.get("rollback"))
404
+ claims = _mapping(data.get("claims"))
405
+ external_scope = _mapping(data.get("external_repo_management"))
406
+
407
+ _validate_scope(scope, root, path, specs, findings)
408
+ _validate_criteria(criteria, path, findings)
409
+ _validate_rollback(rollback, root, path, findings)
410
+ _validate_claims(claims, path, findings)
411
+ _validate_external_repo_scope(external_scope, path, findings)
412
+
413
+
414
+ def _validate_scope(
415
+ scope: dict[str, Any],
416
+ root: Path,
417
+ path: str,
418
+ specs: tuple[DpaSpecStatus, ...],
419
+ findings: list[DpaStableReadinessFinding],
420
+ ) -> None:
421
+ if scope.get("id") != STABLE_SCOPE_ID:
422
+ _finding(findings, "stable-scope-id-invalid", f"stable_scope.id must be {STABLE_SCOPE_ID}", path)
423
+ _require_existing_paths(scope.get("evidence"), root, findings, path, "stable-scope-evidence-missing")
424
+ final_closeout_record = str(scope.get("final_closeout_record", "")).strip()
425
+ if final_closeout_record != DEFAULT_DPA_FINAL_CLOSEOUT_RECORD_PATH.as_posix():
426
+ _finding(
427
+ findings,
428
+ "final-closeout-record-path-invalid",
429
+ "stable_scope.final_closeout_record must point at the final closeout record",
430
+ path,
431
+ )
432
+ _load_stable_readiness_evidence(scope.get("stable_readiness_evidence"), root, path, findings)
433
+
434
+ spec_map = {spec.spec_id: spec for spec in specs}
435
+ promoted_specs = _promoted_spec_map(scope.get("promoted_specs"), path, findings)
436
+ for spec_id in PROMOTED_SPEC_IDS:
437
+ promoted = promoted_specs.get(spec_id)
438
+ actual = spec_map.get(spec_id)
439
+ if promoted is None:
440
+ _finding(findings, "promoted-spec-missing", f"stable_scope.promoted_specs missing {spec_id}", path)
441
+ continue
442
+ if promoted.get("path") != actual.path:
443
+ _finding(
444
+ findings,
445
+ "promoted-spec-path-invalid",
446
+ f"stable_scope.promoted_specs path mismatch for {spec_id}",
447
+ f"{path}:stable_scope.promoted_specs.{spec_id}",
448
+ )
449
+ if promoted.get("to_status") != "stable":
450
+ _finding(
451
+ findings,
452
+ "promoted-spec-to-status-invalid",
453
+ f"{spec_id} to_status must be stable",
454
+ f"{path}:stable_scope.promoted_specs.{spec_id}",
455
+ )
456
+ if not _string_list(promoted.get("evidence")):
457
+ _finding(
458
+ findings,
459
+ "promoted-spec-evidence-missing",
460
+ f"{spec_id} promotion evidence must be listed",
461
+ f"{path}:stable_scope.promoted_specs.{spec_id}",
462
+ )
463
+ if actual and not actual.stable:
464
+ _finding(
465
+ findings,
466
+ "spec-not-stable-after-promotion",
467
+ f"{spec_id} header status must be stable after promotion",
468
+ actual.path,
469
+ )
470
+
471
+
472
+ def _validate_criteria(
473
+ criteria: dict[str, Any],
474
+ path: str,
475
+ findings: list[DpaStableReadinessFinding],
476
+ ) -> None:
477
+ expected = {
478
+ "dpa_000_to_dpa_900_stable": True,
479
+ "dp2_selected_scope_implementation_percent": 100,
480
+ "final_closeout_result_status": VALID_FINAL_CLOSEOUT_STATUS,
481
+ "post_dp2_kit_wide_dpa_status": "READY_FOR_FINAL_CLOSEOUT_RECORD",
482
+ "strict_gate_result_status": "PASS",
483
+ "stable_readiness_result_status": READY_FOR_STABLE_PROMOTION_STATUS,
484
+ "blocker_count": 0,
485
+ "warning_count": 0,
486
+ }
487
+ for key, value in expected.items():
488
+ if criteria.get(key) != value:
489
+ _finding(findings, f"criteria-{key.replace('_', '-')}-invalid", f"criteria.{key} must be {value!r}", path)
490
+
491
+
492
+ def _validate_rollback(
493
+ rollback: dict[str, Any],
494
+ root: Path,
495
+ path: str,
496
+ findings: list[DpaStableReadinessFinding],
497
+ ) -> None:
498
+ if rollback.get("tested_or_adjudicated") is not True:
499
+ _finding(findings, "rollback-not-adjudicated", "rollback must be tested or adjudicated", path)
500
+ if rollback.get("production_mutation_required") is not False:
501
+ _finding(findings, "rollback-production-mutation-invalid", "rollback must not require production mutation", path)
502
+ _require_existing_paths(rollback.get("evidence"), root, findings, path, "rollback-evidence-missing")
503
+
504
+
505
+ def _validate_claims(
506
+ claims: dict[str, Any],
507
+ path: str,
508
+ findings: list[DpaStableReadinessFinding],
509
+ ) -> None:
510
+ required_true = ("kit_wide_dpa_conformance_claimed", "stable_dpa_claimed")
511
+ for field in required_true:
512
+ if claims.get(field) is not True:
513
+ _finding(findings, "claim-invalid", f"claims.{field} must be true", path)
514
+ required_false = (
515
+ "production_mutation_performed",
516
+ "generated_outputs_manually_patched",
517
+ "external_repo_conformance_claimed",
518
+ )
519
+ for field in required_false:
520
+ if claims.get(field) is not False:
521
+ _finding(findings, "false-claim-invalid", f"claims.{field} must be false", path)
522
+ if claims.get("claim_scope") != STABLE_CLAIM_SCOPE:
523
+ _finding(findings, "claim-scope-invalid", f"claims.claim_scope must be {STABLE_CLAIM_SCOPE}", path)
524
+
525
+
526
+ def _validate_external_repo_scope(
527
+ external_scope: dict[str, Any],
528
+ path: str,
529
+ findings: list[DpaStableReadinessFinding],
530
+ ) -> None:
531
+ if external_scope.get("status") != EXTERNAL_REPO_STATUS:
532
+ _finding(
533
+ findings,
534
+ "external-repo-status-invalid",
535
+ f"external_repo_management.status must be {EXTERNAL_REPO_STATUS}",
536
+ path,
537
+ )
538
+ if external_scope.get("automatic_external_repo_conformance_claimed") is not False:
539
+ _finding(
540
+ findings,
541
+ "external-repo-overclaim",
542
+ "external repo conformance must not be claimed without per-repo evidence",
543
+ path,
544
+ )
545
+ for key in (
546
+ "requires_fresh_per_repo_inventory",
547
+ "requires_fresh_per_repo_dpa_600_700_evidence",
548
+ "requires_maintainer_authorized_scope",
549
+ ):
550
+ if external_scope.get(key) is not True:
551
+ _finding(findings, "external-repo-guardrail-missing", f"{key} must be true", path)
552
+
553
+
554
+ def _probe_statuses(
555
+ root: Path,
556
+ validation_ref: str,
557
+ findings: list[DpaStableReadinessFinding],
558
+ ) -> dict[str, str]:
559
+ probes = {
560
+ "PROBE-002": evaluate_probe_002_lifecycle_readiness(root, validation_ref=validation_ref),
561
+ "RENDERER": evaluate_renderer_probe_readiness(root, validation_ref=validation_ref),
562
+ "PROBE-003": evaluate_probe_003_workflow_readiness(root, validation_ref=validation_ref),
563
+ "PROBE-004": evaluate_probe_004_migration_readiness(root, validation_ref=validation_ref),
564
+ }
565
+ statuses: dict[str, str] = {}
566
+ for probe_id, result in probes.items():
567
+ statuses[probe_id] = str(result.result_status)
568
+ if not result.full_evidence_satisfied:
569
+ _finding(
570
+ findings,
571
+ "probe-full-evidence-not-satisfied",
572
+ f"{probe_id} full evidence must remain satisfied for stable promotion",
573
+ result.readiness_record,
574
+ )
575
+ return statuses
576
+
577
+
578
+ def _load_promotion_record(
579
+ path: Path,
580
+ root: Path,
581
+ findings: list[DpaStableReadinessFinding],
582
+ ) -> dict[str, Any]:
583
+ display_path = _display_path(path, root)
584
+ try:
585
+ data = json.loads(path.read_text(encoding="utf-8"))
586
+ except json.JSONDecodeError as exc:
587
+ _finding(findings, "promotion-record-invalid-json", f"record JSON is invalid: {exc}", display_path)
588
+ return {}
589
+ if not isinstance(data, dict):
590
+ _finding(findings, "promotion-record-not-object", "record root must be a JSON object", display_path)
591
+ return {}
592
+ return data
593
+
594
+
595
+ def _load_stable_readiness_evidence(
596
+ value: Any,
597
+ root: Path,
598
+ path: str,
599
+ findings: list[DpaStableReadinessFinding],
600
+ ) -> None:
601
+ candidate = str(value or "").strip()
602
+ if not candidate:
603
+ _finding(findings, "stable-readiness-evidence-missing", "stable readiness evidence path is missing", path)
604
+ return
605
+ evidence_path = root / candidate
606
+ if not evidence_path.exists():
607
+ _finding(findings, "stable-readiness-evidence-missing", f"missing evidence path: {candidate}", path)
608
+ return
609
+ try:
610
+ payload = json.loads(evidence_path.read_text(encoding="utf-8"))
611
+ except json.JSONDecodeError as exc:
612
+ _finding(findings, "stable-readiness-evidence-invalid-json", f"evidence JSON is invalid: {exc}", candidate)
613
+ return
614
+ if not isinstance(payload, dict):
615
+ _finding(findings, "stable-readiness-evidence-invalid", "evidence JSON must be an object", candidate)
616
+ return
617
+ if payload.get("kind") != "dpa_stable_readiness_check":
618
+ _finding(findings, "stable-readiness-evidence-kind-invalid", "evidence kind is invalid", candidate)
619
+ if payload.get("result_status") != READY_FOR_STABLE_PROMOTION_STATUS:
620
+ _finding(
621
+ findings,
622
+ "stable-readiness-evidence-status-invalid",
623
+ f"evidence result_status must be {READY_FOR_STABLE_PROMOTION_STATUS}",
624
+ candidate,
625
+ )
626
+ if payload.get("finding_count") != 0:
627
+ _finding(findings, "stable-readiness-evidence-findings-present", "evidence must have zero findings", candidate)
628
+
629
+
630
+ def _promoted_spec_map(
631
+ value: Any,
632
+ path: str,
633
+ findings: list[DpaStableReadinessFinding],
634
+ ) -> dict[str, dict[str, Any]]:
635
+ if not isinstance(value, list) or not value:
636
+ _finding(findings, "promoted-specs-missing", "stable_scope.promoted_specs must be a non-empty list", path)
637
+ return {}
638
+ promoted: dict[str, dict[str, Any]] = {}
639
+ for index, item in enumerate(value):
640
+ entry = _mapping(item)
641
+ spec_id = str(entry.get("id", "")).strip()
642
+ if not spec_id:
643
+ _finding(findings, "promoted-spec-id-missing", "promoted spec id is missing", f"{path}:promoted_specs[{index}]")
644
+ continue
645
+ if spec_id in promoted:
646
+ _finding(findings, "promoted-spec-duplicate", f"promoted spec is duplicated: {spec_id}", path)
647
+ promoted[spec_id] = entry
648
+ return promoted
649
+
650
+
651
+ def _spec_status(root: Path, spec_id: str, path: str) -> DpaSpecStatus:
652
+ candidate = root / path
653
+ if not candidate.exists():
654
+ return DpaSpecStatus(spec_id=spec_id, path=path, status="MISSING", present=False)
655
+ for line in candidate.read_text(encoding="utf-8").splitlines()[:40]:
656
+ if line.startswith("Status:"):
657
+ return DpaSpecStatus(
658
+ spec_id=spec_id,
659
+ path=path,
660
+ status=line.removeprefix("Status:").strip(),
661
+ present=True,
662
+ )
663
+ return DpaSpecStatus(spec_id=spec_id, path=path, status="MISSING_STATUS", present=True)
664
+
665
+
666
+ def _require_existing_paths(
667
+ value: Any,
668
+ root: Path,
669
+ findings: list[DpaStableReadinessFinding],
670
+ path: str,
671
+ code: str,
672
+ ) -> None:
673
+ paths = _string_list(value)
674
+ if not paths:
675
+ _finding(findings, code, "evidence paths must be a non-empty string list", path)
676
+ return
677
+ for evidence_path in paths:
678
+ if not (root / evidence_path).exists():
679
+ _finding(findings, code, f"missing evidence path: {evidence_path}", path)
680
+
681
+
682
+ def _mapping(value: Any) -> dict[str, Any]:
683
+ return value if isinstance(value, dict) else {}
684
+
685
+
686
+ def _string_list(value: Any) -> list[str]:
687
+ if not isinstance(value, list):
688
+ return []
689
+ return [item for item in value if isinstance(item, str) and item.strip()]
690
+
691
+
692
+ def _finding(
693
+ findings: list[DpaStableReadinessFinding],
694
+ code: str,
695
+ message: str,
696
+ path: str,
697
+ ) -> None:
698
+ findings.append(DpaStableReadinessFinding(code=code, message=message, path=path))
699
+
700
+
701
+ def _git_head(root: Path) -> str:
702
+ completed = subprocess.run(
703
+ ["git", "rev-parse", "HEAD"],
704
+ cwd=root,
705
+ text=True,
706
+ capture_output=True,
707
+ check=False,
708
+ )
709
+ if completed.returncode != 0:
710
+ return "UNKNOWN"
711
+ return completed.stdout.strip()
712
+
713
+
714
+ def _git_ref_exists(root: Path, ref: str) -> bool | None:
715
+ if not (root / ".git").exists():
716
+ return None
717
+ completed = subprocess.run(
718
+ ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"],
719
+ cwd=root,
720
+ text=True,
721
+ capture_output=True,
722
+ check=False,
723
+ )
724
+ return completed.returncode == 0
725
+
726
+
727
+ def _resolve_under_root(root: Path, path: Path | str) -> Path:
728
+ candidate = Path(path)
729
+ if candidate.is_absolute():
730
+ return candidate
731
+ return root / candidate
732
+
733
+
734
+ def _display_path(path: Path, root: Path) -> str:
735
+ try:
736
+ return path.relative_to(root).as_posix()
737
+ except ValueError:
738
+ return path.as_posix()
739
+
740
+
741
+ def _evidence_output_root(root: Path) -> Path:
742
+ ws = load_workspace(root, suppress_legacy_profile_warning=True)
743
+ return ws.architecture_file(Path(*EVIDENCE_OUTPUT_ROOT_PARTS)).resolve()