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,711 @@
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_readiness import DEFAULT_READINESS_PATH
10
+ from agentic_project_kit.workspace import KitConfig, load_workspace
11
+
12
+ _DEFAULT_CONFIG = KitConfig()
13
+ DEFAULT_MAINTAINER_RECORD_PATH = (
14
+ Path(_DEFAULT_CONFIG.architecture_root)
15
+ / "evidence"
16
+ / "dpa"
17
+ / "assessment"
18
+ / "DP2_MAINTAINER_ASSESSMENT_RECORD_20260728.json"
19
+ )
20
+ EXPECTED_KIND = "dpa_dp2_maintainer_assessment_record"
21
+ EXPECTED_SCHEMA_VERSION = 1
22
+ STATUS_TEMPLATE = "TEMPLATE_NOT_ASSESSED"
23
+ STATUS_BLOCKED = "DP2_BLOCKED"
24
+ STATUS_AUTHORIZED = "DP2_AUTHORIZED"
25
+ ALLOWED_STATUSES = {STATUS_TEMPLATE, STATUS_BLOCKED, STATUS_AUTHORIZED}
26
+ REQUIRED_PROBE_DISPOSITIONS = ("PROBE-002", "RENDERER", "PROBE-003", "PROBE-004")
27
+ ALLOWED_PROBE_STATUSES = {
28
+ "BLOCKED",
29
+ "SATISFIED_FOR_CURRENT_KIT_REF",
30
+ "EXPLICITLY_NOT_APPLICABLE",
31
+ }
32
+ SELF_HOSTING_WRITERS = ("WRT-CH-001", "WRT-CH-002", "WRT-CH-003", "WRT-CH-004")
33
+ REQUIRED_FALSE_CLAIMS = (
34
+ "runtime_behavior_changed",
35
+ "production_mutation_performed",
36
+ "kit_conformance_claimed",
37
+ "generated_outputs_manually_patched",
38
+ )
39
+ ASSESSMENT_OUTPUT_ROOT_PARTS = ("evidence", "dpa", "assessment")
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class MaintainerRecordFinding:
44
+ code: str
45
+ message: str
46
+ path: str
47
+
48
+ def as_dict(self) -> dict[str, str]:
49
+ return {"code": self.code, "message": self.message, "path": self.path}
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class MaintainerRecordCheckResult:
54
+ root: str
55
+ validation_ref: str
56
+ record_path: str
57
+ data: dict[str, Any]
58
+ findings: tuple[MaintainerRecordFinding, ...]
59
+ action_items: tuple[dict[str, str], ...]
60
+
61
+ @property
62
+ def structural_ok(self) -> bool:
63
+ return not self.findings
64
+
65
+ @property
66
+ def record_status(self) -> str:
67
+ status = self.data.get("status")
68
+ return str(status) if isinstance(status, str) else "MISSING"
69
+
70
+ @property
71
+ def result_status(self) -> str:
72
+ if not self.structural_ok:
73
+ return "STRUCTURAL_BLOCK"
74
+ if self.record_status == STATUS_AUTHORIZED:
75
+ return "VALID_AUTHORIZATION_RECORD"
76
+ if self.record_status == STATUS_TEMPLATE:
77
+ return "TEMPLATE_READY_DP2_BLOCKED"
78
+ return "VALID_BLOCKED_RECORD"
79
+
80
+ def as_dict(self) -> dict[str, Any]:
81
+ return {
82
+ "schema_version": 1,
83
+ "kind": "dpa_dp2_maintainer_record_check",
84
+ "result_status": self.result_status,
85
+ "validation_ref": self.validation_ref,
86
+ "record_path": self.record_path,
87
+ "record_status": self.record_status,
88
+ "structural_ok": self.structural_ok,
89
+ "finding_count": len(self.findings),
90
+ "findings": [finding.as_dict() for finding in self.findings],
91
+ "action_item_count": len(self.action_items),
92
+ "action_items": list(self.action_items),
93
+ "record_summary": _record_summary(self.data),
94
+ "claims": self.data.get("claims", {}),
95
+ }
96
+
97
+
98
+ def evaluate_dp2_maintainer_record(
99
+ root: Path | str = ".",
100
+ *,
101
+ record_path: Path | str = DEFAULT_MAINTAINER_RECORD_PATH,
102
+ readiness_path: Path | str = DEFAULT_READINESS_PATH,
103
+ validation_ref: str | None = None,
104
+ ) -> MaintainerRecordCheckResult:
105
+ base = Path(root).resolve()
106
+ record = _resolve_under_root(base, record_path)
107
+ display_path = _display_path(record, base)
108
+ findings: list[MaintainerRecordFinding] = []
109
+
110
+ if not record.exists():
111
+ return MaintainerRecordCheckResult(
112
+ root=base.as_posix(),
113
+ validation_ref=validation_ref or _git_head(base),
114
+ record_path=display_path,
115
+ data={},
116
+ findings=(
117
+ MaintainerRecordFinding(
118
+ code="maintainer-record-missing",
119
+ message="DPA DP2 Maintainer Assessment record is missing",
120
+ path=display_path,
121
+ ),
122
+ ),
123
+ action_items=(
124
+ {
125
+ "id": "create-maintainer-record",
126
+ "message": "Create a Maintainer-owned record from the DP2 assessment template before DP2 authorization.",
127
+ },
128
+ ),
129
+ )
130
+
131
+ try:
132
+ data = json.loads(record.read_text(encoding="utf-8"))
133
+ except json.JSONDecodeError as exc:
134
+ return MaintainerRecordCheckResult(
135
+ root=base.as_posix(),
136
+ validation_ref=validation_ref or _git_head(base),
137
+ record_path=display_path,
138
+ data={},
139
+ findings=(
140
+ MaintainerRecordFinding(
141
+ code="maintainer-record-json-invalid",
142
+ message=f"Maintainer record is not valid JSON: {exc}",
143
+ path=display_path,
144
+ ),
145
+ ),
146
+ action_items=(),
147
+ )
148
+
149
+ if not isinstance(data, dict):
150
+ return MaintainerRecordCheckResult(
151
+ root=base.as_posix(),
152
+ validation_ref=validation_ref or _git_head(base),
153
+ record_path=display_path,
154
+ data={},
155
+ findings=(
156
+ MaintainerRecordFinding(
157
+ code="maintainer-record-not-object",
158
+ message="Maintainer record must contain a JSON object",
159
+ path=display_path,
160
+ ),
161
+ ),
162
+ action_items=(),
163
+ )
164
+
165
+ readiness = _resolve_under_root(base, readiness_path)
166
+ _validate_top_level(data, findings, display_path)
167
+ _validate_paths(data, findings, display_path, base, readiness)
168
+ _validate_probe_dispositions(data, findings, display_path)
169
+ _validate_scope(data, findings, display_path, base)
170
+ _validate_rollback(data, findings, display_path)
171
+ _validate_claims(data, findings, display_path)
172
+ _validate_authorization(data, findings, display_path)
173
+ validation = validation_ref or _git_head(base)
174
+ return MaintainerRecordCheckResult(
175
+ root=base.as_posix(),
176
+ validation_ref=validation,
177
+ record_path=display_path,
178
+ data=data,
179
+ findings=tuple(findings),
180
+ action_items=tuple(_action_items(data)),
181
+ )
182
+
183
+
184
+ def render_dp2_maintainer_record_check(result: MaintainerRecordCheckResult) -> str:
185
+ payload = result.as_dict()
186
+ lines = [
187
+ "DPA_DP2_MAINTAINER_RECORD_CHECK",
188
+ f"STATUS={payload['result_status']}",
189
+ f"VALIDATION_REF={payload['validation_ref']}",
190
+ f"RECORD={payload['record_path']}",
191
+ f"RECORD_STATUS={payload['record_status']}",
192
+ f"FINDINGS={payload['finding_count']}",
193
+ f"ACTION_ITEMS={payload['action_item_count']}",
194
+ ]
195
+ for item in payload["action_items"]:
196
+ lines.append(f"ACTION_ITEM={item['id']}|{item['message']}")
197
+ if payload["finding_count"]:
198
+ for finding in payload["findings"]:
199
+ lines.append(f"FINDING={finding['code']}|path={finding['path']}|{finding['message']}")
200
+ return "\n".join(lines) + "\n"
201
+
202
+
203
+ def write_dp2_maintainer_record_check_json(
204
+ result: MaintainerRecordCheckResult,
205
+ root: Path | str,
206
+ output: Path | str,
207
+ *,
208
+ execute: bool,
209
+ ) -> dict[str, Any]:
210
+ base = Path(root).resolve()
211
+ output_path = _resolve_under_root(base, output)
212
+ relative = output_path.relative_to(base)
213
+ evidence_root = _assessment_output_root(base)
214
+ if evidence_root not in (output_path, *output_path.parents):
215
+ return {
216
+ "result_status": "BLOCK",
217
+ "reason": "output_outside_dpa_assessment_evidence_root",
218
+ "output_path": relative.as_posix(),
219
+ "written": False,
220
+ }
221
+ changed = True
222
+ rendered = json.dumps(result.as_dict(), indent=2, sort_keys=True) + "\n"
223
+ if output_path.exists():
224
+ changed = output_path.read_text(encoding="utf-8") != rendered
225
+ if execute:
226
+ output_path.parent.mkdir(parents=True, exist_ok=True)
227
+ output_path.write_text(rendered, encoding="utf-8")
228
+ return {
229
+ "result_status": "PASS",
230
+ "output_path": relative.as_posix(),
231
+ "changed": changed,
232
+ "written": bool(execute),
233
+ }
234
+
235
+
236
+ def _resolve_under_root(root: Path, path: Path | str) -> Path:
237
+ candidate = Path(path)
238
+ if candidate.is_absolute():
239
+ return candidate
240
+ return root / candidate
241
+
242
+
243
+ def _display_path(path: Path, root: Path) -> str:
244
+ try:
245
+ return path.relative_to(root).as_posix()
246
+ except ValueError:
247
+ return path.as_posix()
248
+
249
+
250
+ def _assessment_output_root(root: Path) -> Path:
251
+ ws = load_workspace(root, suppress_legacy_profile_warning=True)
252
+ return ws.architecture_file(Path(*ASSESSMENT_OUTPUT_ROOT_PARTS)).resolve()
253
+
254
+
255
+ def _validate_top_level(
256
+ data: dict[str, Any],
257
+ findings: list[MaintainerRecordFinding],
258
+ path: str,
259
+ ) -> None:
260
+ if data.get("schema_version") != EXPECTED_SCHEMA_VERSION:
261
+ findings.append(
262
+ MaintainerRecordFinding(
263
+ code="invalid-schema-version",
264
+ message=f"schema_version must be {EXPECTED_SCHEMA_VERSION}",
265
+ path=path,
266
+ )
267
+ )
268
+ if data.get("kind") != EXPECTED_KIND:
269
+ findings.append(
270
+ MaintainerRecordFinding(
271
+ code="invalid-kind",
272
+ message=f"kind must be {EXPECTED_KIND!r}",
273
+ path=path,
274
+ )
275
+ )
276
+ status = data.get("status")
277
+ if status not in ALLOWED_STATUSES:
278
+ findings.append(
279
+ MaintainerRecordFinding(
280
+ code="invalid-status",
281
+ message=f"status must be one of {sorted(ALLOWED_STATUSES)}",
282
+ path=path,
283
+ )
284
+ )
285
+ if not isinstance(data.get("template"), bool):
286
+ findings.append(
287
+ MaintainerRecordFinding(
288
+ code="template-flag-missing",
289
+ message="template must be a boolean",
290
+ path=path,
291
+ )
292
+ )
293
+ if data.get("status") == STATUS_TEMPLATE and data.get("template") is not True:
294
+ findings.append(
295
+ MaintainerRecordFinding(
296
+ code="template-status-without-template-flag",
297
+ message="TEMPLATE_NOT_ASSESSED records must set template=true",
298
+ path=path,
299
+ )
300
+ )
301
+
302
+
303
+ def _validate_paths(
304
+ data: dict[str, Any],
305
+ findings: list[MaintainerRecordFinding],
306
+ path: str,
307
+ root: Path,
308
+ readiness_path: Path,
309
+ ) -> None:
310
+ for field in ("readiness_record", "decision_readiness_evidence"):
311
+ raw = data.get(field)
312
+ if not isinstance(raw, str) or not raw:
313
+ findings.append(
314
+ MaintainerRecordFinding(
315
+ code=f"{field.replace('_', '-')}-missing",
316
+ message=f"{field} must be a non-empty repository-relative path",
317
+ path=path,
318
+ )
319
+ )
320
+ continue
321
+ candidate = Path(raw)
322
+ if candidate.is_absolute():
323
+ findings.append(
324
+ MaintainerRecordFinding(
325
+ code=f"{field.replace('_', '-')}-absolute",
326
+ message=f"{field} must be repository-relative: {raw}",
327
+ path=path,
328
+ )
329
+ )
330
+ continue
331
+ if not (root / candidate).exists():
332
+ findings.append(
333
+ MaintainerRecordFinding(
334
+ code=f"{field.replace('_', '-')}-not-found",
335
+ message=f"{field} does not exist: {raw}",
336
+ path=path,
337
+ )
338
+ )
339
+ readiness_display = _display_path(readiness_path, root)
340
+ if data.get("readiness_record") != readiness_display:
341
+ findings.append(
342
+ MaintainerRecordFinding(
343
+ code="readiness-record-mismatch",
344
+ message=f"readiness_record must match inspected record {readiness_display}",
345
+ path=path,
346
+ )
347
+ )
348
+
349
+
350
+ def _validate_probe_dispositions(
351
+ data: dict[str, Any],
352
+ findings: list[MaintainerRecordFinding],
353
+ path: str,
354
+ ) -> None:
355
+ dispositions = data.get("probe_dispositions")
356
+ if not isinstance(dispositions, dict):
357
+ findings.append(
358
+ MaintainerRecordFinding(
359
+ code="probe-dispositions-missing",
360
+ message="probe_dispositions must be a mapping",
361
+ path=path,
362
+ )
363
+ )
364
+ return
365
+ for family in REQUIRED_PROBE_DISPOSITIONS:
366
+ record = dispositions.get(family)
367
+ if not isinstance(record, dict):
368
+ findings.append(
369
+ MaintainerRecordFinding(
370
+ code="probe-disposition-missing",
371
+ message=f"probe_dispositions.{family} must be a mapping",
372
+ path=path,
373
+ )
374
+ )
375
+ continue
376
+ status = record.get("status")
377
+ if status not in ALLOWED_PROBE_STATUSES:
378
+ findings.append(
379
+ MaintainerRecordFinding(
380
+ code="invalid-probe-disposition-status",
381
+ message=f"{family}.status must be one of {sorted(ALLOWED_PROBE_STATUSES)}",
382
+ path=path,
383
+ )
384
+ )
385
+ evidence = record.get("evidence")
386
+ if evidence is not None and not _string_list(evidence):
387
+ findings.append(
388
+ MaintainerRecordFinding(
389
+ code="invalid-probe-disposition-evidence",
390
+ message=f"{family}.evidence must be a list of strings when present",
391
+ path=path,
392
+ )
393
+ )
394
+ if status == "EXPLICITLY_NOT_APPLICABLE" and not str(record.get("rationale", "")).strip():
395
+ findings.append(
396
+ MaintainerRecordFinding(
397
+ code="not-applicable-rationale-missing",
398
+ message=f"{family} requires rationale when explicitly not applicable",
399
+ path=path,
400
+ )
401
+ )
402
+
403
+
404
+ def _validate_scope(
405
+ data: dict[str, Any],
406
+ findings: list[MaintainerRecordFinding],
407
+ path: str,
408
+ root: Path,
409
+ ) -> None:
410
+ scope = data.get("first_dp2_target_scope")
411
+ if not isinstance(scope, dict):
412
+ findings.append(
413
+ MaintainerRecordFinding(
414
+ code="first-dp2-target-scope-missing",
415
+ message="first_dp2_target_scope must be a mapping",
416
+ path=path,
417
+ )
418
+ )
419
+ return
420
+ if scope.get("status") not in {"UNSELECTED", "SELECTED"}:
421
+ findings.append(
422
+ MaintainerRecordFinding(
423
+ code="invalid-target-scope-status",
424
+ message="first_dp2_target_scope.status must be UNSELECTED or SELECTED",
425
+ path=path,
426
+ )
427
+ )
428
+ raw_target = scope.get("target_path")
429
+ if not isinstance(raw_target, str) or not raw_target:
430
+ findings.append(
431
+ MaintainerRecordFinding(
432
+ code="target-path-missing",
433
+ message="first_dp2_target_scope.target_path must be a non-empty path",
434
+ path=path,
435
+ )
436
+ )
437
+ elif Path(raw_target).is_absolute():
438
+ findings.append(
439
+ MaintainerRecordFinding(
440
+ code="target-path-absolute",
441
+ message="first_dp2_target_scope.target_path must be repository-relative",
442
+ path=path,
443
+ )
444
+ )
445
+ elif not (root / raw_target).exists():
446
+ findings.append(
447
+ MaintainerRecordFinding(
448
+ code="target-path-not-found",
449
+ message=f"first_dp2_target_scope.target_path does not exist: {raw_target}",
450
+ path=path,
451
+ )
452
+ )
453
+ for field in ("selected_writers", "deferred_writers", "excluded_writers"):
454
+ if not _string_list(scope.get(field)):
455
+ findings.append(
456
+ MaintainerRecordFinding(
457
+ code=f"{field.replace('_', '-')}-invalid",
458
+ message=f"first_dp2_target_scope.{field} must be a list of strings",
459
+ path=path,
460
+ )
461
+ )
462
+ if data.get("status") == STATUS_AUTHORIZED:
463
+ covered = set(scope.get("selected_writers", ())) | set(scope.get("deferred_writers", ()))
464
+ missing = sorted(set(SELF_HOSTING_WRITERS) - covered)
465
+ for writer in missing:
466
+ findings.append(
467
+ MaintainerRecordFinding(
468
+ code="self-hosting-writer-undisposed",
469
+ message=f"{writer} must be selected or deferred before DP2 authorization",
470
+ path=path,
471
+ )
472
+ )
473
+
474
+
475
+ def _validate_rollback(
476
+ data: dict[str, Any],
477
+ findings: list[MaintainerRecordFinding],
478
+ path: str,
479
+ ) -> None:
480
+ rollback = data.get("rollback_cleanup")
481
+ if not isinstance(rollback, dict):
482
+ findings.append(
483
+ MaintainerRecordFinding(
484
+ code="rollback-cleanup-missing",
485
+ message="rollback_cleanup must be a mapping",
486
+ path=path,
487
+ )
488
+ )
489
+ return
490
+ if rollback.get("status") not in {"NOT_PROVEN", "PROVEN"}:
491
+ findings.append(
492
+ MaintainerRecordFinding(
493
+ code="invalid-rollback-cleanup-status",
494
+ message="rollback_cleanup.status must be NOT_PROVEN or PROVEN",
495
+ path=path,
496
+ )
497
+ )
498
+ evidence = rollback.get("evidence")
499
+ if not _string_list(evidence):
500
+ findings.append(
501
+ MaintainerRecordFinding(
502
+ code="rollback-cleanup-evidence-invalid",
503
+ message="rollback_cleanup.evidence must be a list of strings",
504
+ path=path,
505
+ )
506
+ )
507
+
508
+
509
+ def _validate_claims(
510
+ data: dict[str, Any],
511
+ findings: list[MaintainerRecordFinding],
512
+ path: str,
513
+ ) -> None:
514
+ claims = data.get("claims")
515
+ if not isinstance(claims, dict):
516
+ findings.append(
517
+ MaintainerRecordFinding(code="claims-missing", message="claims must be a mapping", path=path)
518
+ )
519
+ return
520
+ for claim in REQUIRED_FALSE_CLAIMS:
521
+ if claims.get(claim) is not False:
522
+ findings.append(
523
+ MaintainerRecordFinding(
524
+ code="unsafe-runtime-claim",
525
+ message=f"claim {claim!r} must remain false in a DP2 assessment record",
526
+ path=path,
527
+ )
528
+ )
529
+ if data.get("status") != STATUS_AUTHORIZED:
530
+ for claim in ("maintainer_authorization_recorded", "dp2_authorized"):
531
+ if claims.get(claim) is not False:
532
+ findings.append(
533
+ MaintainerRecordFinding(
534
+ code="premature-authorization-claim",
535
+ message=f"claim {claim!r} must be false unless status is DP2_AUTHORIZED",
536
+ path=path,
537
+ )
538
+ )
539
+ if data.get("status") == STATUS_TEMPLATE and claims.get("maintainer_assessment_recorded") is not False:
540
+ findings.append(
541
+ MaintainerRecordFinding(
542
+ code="premature-assessment-claim",
543
+ message="claim 'maintainer_assessment_recorded' must be false for template records",
544
+ path=path,
545
+ )
546
+ )
547
+
548
+
549
+ def _validate_authorization(
550
+ data: dict[str, Any],
551
+ findings: list[MaintainerRecordFinding],
552
+ path: str,
553
+ ) -> None:
554
+ if data.get("status") != STATUS_AUTHORIZED:
555
+ return
556
+ if data.get("template") is not False:
557
+ findings.append(
558
+ MaintainerRecordFinding(
559
+ code="authorized-template-record",
560
+ message="DP2_AUTHORIZED records must set template=false",
561
+ path=path,
562
+ )
563
+ )
564
+ if data.get("decision_token") != "DPA_DP2_AUTHORIZED":
565
+ findings.append(
566
+ MaintainerRecordFinding(
567
+ code="authorization-token-missing",
568
+ message="DP2_AUTHORIZED records must set decision_token to DPA_DP2_AUTHORIZED",
569
+ path=path,
570
+ )
571
+ )
572
+ if not str(data.get("maintainer", "")).strip() or data.get("maintainer") == "PENDING_MAINTAINER":
573
+ findings.append(
574
+ MaintainerRecordFinding(
575
+ code="maintainer-missing",
576
+ message="DP2_AUTHORIZED records must name the Maintainer assessor",
577
+ path=path,
578
+ )
579
+ )
580
+ claims = data.get("claims")
581
+ if isinstance(claims, dict):
582
+ for claim in (
583
+ "maintainer_assessment_recorded",
584
+ "maintainer_authorization_recorded",
585
+ "dp2_authorized",
586
+ ):
587
+ if claims.get(claim) is not True:
588
+ findings.append(
589
+ MaintainerRecordFinding(
590
+ code="authorization-claim-missing",
591
+ message=f"DP2_AUTHORIZED records must set claim {claim!r} to true",
592
+ path=path,
593
+ )
594
+ )
595
+ dispositions = data.get("probe_dispositions")
596
+ if isinstance(dispositions, dict):
597
+ for family in REQUIRED_PROBE_DISPOSITIONS:
598
+ record = dispositions.get(family)
599
+ status = record.get("status") if isinstance(record, dict) else None
600
+ if status == "BLOCKED":
601
+ findings.append(
602
+ MaintainerRecordFinding(
603
+ code="authorized-with-blocked-probe",
604
+ message=f"{family} remains BLOCKED in a DP2_AUTHORIZED record",
605
+ path=path,
606
+ )
607
+ )
608
+ evidence = record.get("evidence") if isinstance(record, dict) else None
609
+ if status == "SATISFIED_FOR_CURRENT_KIT_REF" and not _string_list(evidence):
610
+ findings.append(
611
+ MaintainerRecordFinding(
612
+ code="authorized-probe-evidence-missing",
613
+ message=f"{family} requires evidence paths when satisfied for DP2 authorization",
614
+ path=path,
615
+ )
616
+ )
617
+ scope = data.get("first_dp2_target_scope")
618
+ if isinstance(scope, dict) and scope.get("status") != "SELECTED":
619
+ findings.append(
620
+ MaintainerRecordFinding(
621
+ code="authorized-target-scope-not-selected",
622
+ message="DP2_AUTHORIZED records must set first_dp2_target_scope.status to SELECTED",
623
+ path=path,
624
+ )
625
+ )
626
+ rollback = data.get("rollback_cleanup")
627
+ if isinstance(rollback, dict) and rollback.get("status") != "PROVEN":
628
+ findings.append(
629
+ MaintainerRecordFinding(
630
+ code="authorized-rollback-not-proven",
631
+ message="DP2_AUTHORIZED records must set rollback_cleanup.status to PROVEN",
632
+ path=path,
633
+ )
634
+ )
635
+
636
+
637
+ def _record_summary(data: dict[str, Any]) -> dict[str, Any]:
638
+ scope = data.get("first_dp2_target_scope") if isinstance(data, dict) else None
639
+ rollback = data.get("rollback_cleanup") if isinstance(data, dict) else None
640
+ return {
641
+ "template": data.get("template"),
642
+ "maintainer": data.get("maintainer"),
643
+ "decision_token": data.get("decision_token"),
644
+ "target_path": scope.get("target_path") if isinstance(scope, dict) else None,
645
+ "target_scope_status": scope.get("status") if isinstance(scope, dict) else None,
646
+ "rollback_cleanup_status": rollback.get("status") if isinstance(rollback, dict) else None,
647
+ }
648
+
649
+
650
+ def _action_items(data: dict[str, Any]) -> list[dict[str, str]]:
651
+ if data.get("status") == STATUS_AUTHORIZED:
652
+ return []
653
+ items: list[dict[str, str]] = []
654
+ dispositions = data.get("probe_dispositions")
655
+ if not isinstance(dispositions, dict) or any(
656
+ not isinstance(dispositions.get(family), dict)
657
+ or dispositions[family].get("status") == "BLOCKED"
658
+ for family in REQUIRED_PROBE_DISPOSITIONS
659
+ ):
660
+ items.append(
661
+ {
662
+ "id": "complete-probe-dispositions",
663
+ "message": "Record satisfied or explicitly-not-applicable dispositions for PROBE-002, RENDERER, PROBE-003 and PROBE-004.",
664
+ }
665
+ )
666
+ scope = data.get("first_dp2_target_scope")
667
+ scope_ready = False
668
+ if isinstance(scope, dict) and scope.get("status") == "SELECTED":
669
+ covered = set(scope.get("selected_writers", ())) | set(scope.get("deferred_writers", ()))
670
+ scope_ready = set(SELF_HOSTING_WRITERS) <= covered
671
+ if not scope_ready:
672
+ items.append(
673
+ {
674
+ "id": "select-or-defer-writers",
675
+ "message": "Select or defer WRT-CH-001 through WRT-CH-004 for the first DP2 target scope.",
676
+ }
677
+ )
678
+ rollback = data.get("rollback_cleanup")
679
+ if not isinstance(rollback, dict) or rollback.get("status") != "PROVEN":
680
+ items.append(
681
+ {
682
+ "id": "prove-rollback-cleanup",
683
+ "message": "Attach rollback and cleanup evidence for the selected target before authorization.",
684
+ }
685
+ )
686
+ items.append(
687
+ {
688
+ "id": "record-maintainer-authorization",
689
+ "message": "Only a Maintainer-owned non-template record with decision_token DPA_DP2_AUTHORIZED may authorize DP2.",
690
+ }
691
+ )
692
+ return items
693
+
694
+
695
+ def _string_list(value: Any) -> bool:
696
+ return isinstance(value, list) and all(isinstance(item, str) for item in value)
697
+
698
+
699
+ def _git_head(root: Path) -> str:
700
+ try:
701
+ completed = subprocess.run(
702
+ ["git", "rev-parse", "HEAD"],
703
+ cwd=root,
704
+ check=True,
705
+ text=True,
706
+ stdout=subprocess.PIPE,
707
+ stderr=subprocess.PIPE,
708
+ )
709
+ except (OSError, subprocess.CalledProcessError):
710
+ return "UNKNOWN"
711
+ return completed.stdout.strip() or "UNKNOWN"