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,1179 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from agentic_project_kit.documentation_registry import build_doc_registry_reconcile_report
14
+ from agentic_project_kit.documentation_registry import build_documentation_registry_summary
15
+ from agentic_project_kit.doc_lifecycle_signals import (
16
+ build_review_after_findings,
17
+ load_direction_statuses,
18
+ resolve_current_version,
19
+ )
20
+ from agentic_project_kit.project_direction import audit_project_direction_drift
21
+ from agentic_project_kit.workspace import (
22
+ DEFAULT_DOC_LIFECYCLE_MODE,
23
+ DEFAULT_REVIEW_BUDGETS,
24
+ default_review_budgets,
25
+ load_workspace,
26
+ )
27
+
28
+ ALLOWED_STATUS_VALUES = {
29
+ "idea-note",
30
+ "proposed",
31
+ "active",
32
+ "accepted",
33
+ "implemented",
34
+ "rejected",
35
+ "superseded",
36
+ "archived",
37
+ }
38
+
39
+ LIFECYCLE_DIRS = (
40
+ "docs/ideas",
41
+ "docs/planning",
42
+ "docs/roadmap",
43
+ "docs/strategy",
44
+ )
45
+
46
+ HEADER_AUDIT_EXEMPT_PREFIXES = (
47
+ "docs/archive/",
48
+ "docs/reports/",
49
+ "docs/examples/",
50
+ )
51
+
52
+ STALE_BUDGETS_DAYS = dict(DEFAULT_REVIEW_BUDGETS)
53
+
54
+ STALE_BUDGET_CLASS_GROUPS = {
55
+ "architecture": "governance",
56
+ "governance/system": "governance",
57
+ "operational/automation": "workflow",
58
+ "release": "workflow",
59
+ "user-facing description": "reference",
60
+ }
61
+
62
+ PLANNING_CLASS = "planning"
63
+ STRICT_BLOCKING_CODES = frozenset(
64
+ {
65
+ "HEADER_REGISTRY_MISMATCH",
66
+ "SUPERSEDED_TARGET_MISSING",
67
+ "REVIEW_DUE_RELEASE",
68
+ "REVIEW_DUE_DIRECTION",
69
+ "SOURCE_OF_CLOSED_ITEM_STILL_ACTIVE",
70
+ }
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class DocLifecycleDocument:
76
+ path: str
77
+ status: str | None
78
+ decision_status: str | None
79
+ status_date: str | None = None
80
+ superseded_by: str | None = None
81
+ document_class: str | None = None
82
+ registry_status: str | None = None
83
+ age_days: int | None = None
84
+
85
+ def to_dict(self) -> dict[str, Any]:
86
+ return {
87
+ "path": self.path,
88
+ "status": self.status,
89
+ "decision_status": self.decision_status,
90
+ "status_date": self.status_date,
91
+ "superseded_by": self.superseded_by,
92
+ "class": self.document_class,
93
+ "registry_status": self.registry_status,
94
+ "age_days": self.age_days,
95
+ }
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class DocLifecycleFinding:
100
+ code: str
101
+ path: str
102
+ message: str
103
+ severity: str = "FAIL"
104
+ document_class: str | None = None
105
+ age_days: int | None = None
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ data: dict[str, Any] = {
109
+ "code": self.code,
110
+ "path": self.path,
111
+ "message": self.message,
112
+ "severity": self.severity,
113
+ }
114
+ if self.document_class is not None:
115
+ data["class"] = self.document_class
116
+ if self.age_days is not None:
117
+ data["age_days"] = self.age_days
118
+ return data
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class DocLifecycleDeferred:
123
+ path: str
124
+ deferred_until: str
125
+ reason: str
126
+ document_class: str | None = None
127
+
128
+ def to_dict(self) -> dict[str, Any]:
129
+ data = {
130
+ "path": self.path,
131
+ "deferred_until": self.deferred_until,
132
+ "reason": self.reason,
133
+ }
134
+ if self.document_class is not None:
135
+ data["class"] = self.document_class
136
+ return data
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class DocLifecycleReport:
141
+ documents: tuple[DocLifecycleDocument, ...]
142
+ findings: tuple[DocLifecycleFinding, ...]
143
+ registry_summary: dict[str, Any] | None = None
144
+ deferred: tuple[DocLifecycleDeferred, ...] = ()
145
+ hygiene_mode: str = DEFAULT_DOC_LIFECYCLE_MODE
146
+ review_budgets: Mapping[str, int] = field(default_factory=default_review_budgets)
147
+
148
+ @property
149
+ def ok(self) -> bool:
150
+ return not any(finding.severity in {"FAIL", "BLOCK"} for finding in self.findings)
151
+
152
+ def to_dict(self) -> dict[str, Any]:
153
+ return {
154
+ "ok": self.ok,
155
+ "documents": [document.to_dict() for document in self.documents],
156
+ "findings": [finding.to_dict() for finding in self.findings],
157
+ "findings_by_code": _findings_by_code(self.findings),
158
+ "deferred": [item.to_dict() for item in self.deferred],
159
+ "registry_summary": self.registry_summary,
160
+ "hygiene": {
161
+ "doc_lifecycle": self.hygiene_mode,
162
+ "review_budgets": dict(self.review_budgets),
163
+ },
164
+ }
165
+
166
+
167
+ def build_doc_lifecycle_report(
168
+ project_root: Path,
169
+ *,
170
+ now: date | None = None,
171
+ current_version: str | None = None,
172
+ ) -> DocLifecycleReport:
173
+ today = now or date.today()
174
+ hygiene_mode, review_budgets = _workspace_lifecycle_hygiene(project_root)
175
+ documents: list[DocLifecycleDocument] = []
176
+ findings: list[DocLifecycleFinding] = []
177
+ deferred: list[DocLifecycleDeferred] = []
178
+ registry_entries = _load_documentation_registry_entries_by_path(project_root)
179
+ direction_statuses = load_direction_statuses(project_root)
180
+ resolved_current_version = resolve_current_version(project_root, current_version)
181
+ visited_paths: set[str] = set()
182
+ for relative_path in _iter_lifecycle_markdown_files(project_root):
183
+ text = (project_root / relative_path).read_text(encoding="utf-8")
184
+ status = _first_header_value(text, "Status")
185
+ decision_status = _first_header_value(text, "Decision status")
186
+ status_date = _first_header_value(text, "Status-date")
187
+ superseded_by = _first_header_value(text, "Superseded-by")
188
+ path_text = str(relative_path)
189
+ registry_entry = registry_entries.get(path_text, {})
190
+ age_days = _age_days(status_date, today)
191
+ documents.append(
192
+ DocLifecycleDocument(
193
+ path_text,
194
+ status,
195
+ decision_status,
196
+ status_date=status_date,
197
+ superseded_by=superseded_by,
198
+ document_class=_entry_class(registry_entry),
199
+ registry_status=_entry_status(registry_entry),
200
+ age_days=age_days,
201
+ )
202
+ )
203
+ visited_paths.add(path_text)
204
+ deferred_item = _deferred_lifecycle_entry(path_text, registry_entry, today)
205
+ if deferred_item is not None:
206
+ deferred.append(deferred_item)
207
+ elif hygiene_mode != "off":
208
+ findings.extend(_audit_document(relative_path, text, status, decision_status))
209
+ findings.extend(
210
+ _audit_registry_header_consistency(
211
+ project_root=project_root,
212
+ path_text=path_text,
213
+ text=text,
214
+ registry_entry=registry_entry,
215
+ status=status,
216
+ status_date=status_date,
217
+ superseded_by=superseded_by,
218
+ now=today,
219
+ current_version=resolved_current_version,
220
+ direction_statuses=direction_statuses,
221
+ review_budgets=review_budgets,
222
+ )
223
+ )
224
+ for path_text, registry_entry in sorted(registry_entries.items()):
225
+ if path_text in visited_paths or not path_text.endswith(".md"):
226
+ continue
227
+ document_path = project_root / path_text
228
+ if not document_path.exists() or not document_path.is_file():
229
+ continue
230
+ text = document_path.read_text(encoding="utf-8")
231
+ status = _first_header_value(text, "Status")
232
+ status_date = _first_header_value(text, "Status-date")
233
+ superseded_by = _first_header_value(text, "Superseded-by")
234
+ age_days = _age_days(status_date, today)
235
+ documents.append(
236
+ DocLifecycleDocument(
237
+ path_text,
238
+ status,
239
+ _first_header_value(text, "Decision status"),
240
+ status_date=status_date,
241
+ superseded_by=superseded_by,
242
+ document_class=_entry_class(registry_entry),
243
+ registry_status=_entry_status(registry_entry),
244
+ age_days=age_days,
245
+ )
246
+ )
247
+ deferred_item = _deferred_lifecycle_entry(path_text, registry_entry, today)
248
+ if deferred_item is not None:
249
+ deferred.append(deferred_item)
250
+ elif hygiene_mode != "off":
251
+ findings.extend(
252
+ _audit_registry_header_consistency(
253
+ project_root=project_root,
254
+ path_text=path_text,
255
+ text=text,
256
+ registry_entry=registry_entry,
257
+ status=status,
258
+ status_date=status_date,
259
+ superseded_by=superseded_by,
260
+ now=today,
261
+ current_version=resolved_current_version,
262
+ direction_statuses=direction_statuses,
263
+ review_budgets=review_budgets,
264
+ )
265
+ )
266
+ return DocLifecycleReport(
267
+ documents=tuple(sorted(documents, key=lambda document: document.path)),
268
+ findings=tuple(findings),
269
+ registry_summary=_load_registry_summary(project_root),
270
+ deferred=tuple(sorted(deferred, key=lambda item: item.path)),
271
+ hygiene_mode=hygiene_mode,
272
+ review_budgets=dict(review_budgets),
273
+ )
274
+
275
+
276
+ def write_doc_lifecycle_json_report(report: DocLifecycleReport, output_path: Path) -> None:
277
+ output_path.parent.mkdir(parents=True, exist_ok=True)
278
+ output_path.write_text(json.dumps(report.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
279
+
280
+
281
+ def build_doc_lifecycle_strict_findings(
282
+ project_root: Path,
283
+ *,
284
+ report: DocLifecycleReport | None = None,
285
+ now: date | None = None,
286
+ current_version: str | None = None,
287
+ ) -> tuple[DocLifecycleFinding, ...]:
288
+ lifecycle_report = report or build_doc_lifecycle_report(
289
+ project_root,
290
+ now=now,
291
+ current_version=current_version,
292
+ )
293
+ if lifecycle_report.hygiene_mode == "off":
294
+ return ()
295
+ blockers = [
296
+ finding
297
+ for finding in lifecycle_report.findings
298
+ if finding.code in STRICT_BLOCKING_CODES
299
+ ]
300
+ try:
301
+ direction_audit = audit_project_direction_drift(project_root)
302
+ except (OSError, ValueError, yaml.YAMLError):
303
+ direction_audit = None
304
+ if direction_audit is not None:
305
+ for record in direction_audit.records:
306
+ if record.classification != "SOURCE_OF_CLOSED_ITEM_STILL_ACTIVE":
307
+ continue
308
+ blockers.append(
309
+ DocLifecycleFinding(
310
+ "SOURCE_OF_CLOSED_ITEM_STILL_ACTIVE",
311
+ record.path,
312
+ f"source file belongs to closed Direction item {record.item_id}",
313
+ severity="FAIL",
314
+ )
315
+ )
316
+ return tuple(blockers)
317
+
318
+
319
+ def build_doc_lifecycle_release_blockers(
320
+ project_root: Path,
321
+ *,
322
+ version: str,
323
+ now: date | None = None,
324
+ ) -> tuple[DocLifecycleFinding, ...]:
325
+ report = build_doc_lifecycle_report(project_root, now=now, current_version=version)
326
+ return tuple(finding for finding in report.findings if finding.code == "REVIEW_DUE_RELEASE")
327
+
328
+
329
+ def render_doc_lifecycle_strict_findings(findings: tuple[DocLifecycleFinding, ...]) -> str:
330
+ lines = ["Strict lifecycle blockers:", f"- blocker_count: {len(findings)}"]
331
+ if not findings:
332
+ lines.append("- none")
333
+ for finding in findings:
334
+ lines.append(f"- [{finding.code}] {finding.path}: {finding.message}")
335
+ return "\n".join(lines)
336
+
337
+
338
+ def _findings_by_code(findings: tuple[DocLifecycleFinding, ...]) -> dict[str, list[dict[str, Any]]]:
339
+ grouped: dict[str, list[dict[str, Any]]] = {}
340
+ for finding in findings:
341
+ grouped.setdefault(finding.code, []).append(finding.to_dict())
342
+ return dict(sorted(grouped.items()))
343
+
344
+
345
+ def render_doc_lifecycle_report(report: DocLifecycleReport) -> str:
346
+ lines = ["Documentation lifecycle audit", "", "Documents:"]
347
+ if not report.documents:
348
+ lines.append("- none")
349
+ for document in report.documents:
350
+ status = document.status if document.status is not None else "MISSING"
351
+ class_suffix = f" | class={document.document_class}" if document.document_class else ""
352
+ age_suffix = f" | age_days={document.age_days}" if document.age_days is not None else ""
353
+ lines.append(f"- {document.path}: {status}{class_suffix}{age_suffix}")
354
+ if report.registry_summary is not None:
355
+ lines.extend([
356
+ "",
357
+ "Documentation registry:",
358
+ f"- registry: {report.registry_summary['registry_path']}",
359
+ f"- version: {report.registry_summary['version']}",
360
+ f"- documents: {report.registry_summary['document_count']}",
361
+ "- broad_migration_allowed: "
362
+ f"{report.registry_summary['broad_migration_allowed']}",
363
+ ])
364
+ class_counts = report.registry_summary.get("class_counts", {})
365
+ if isinstance(class_counts, dict):
366
+ for class_name, count in class_counts.items():
367
+ lines.append(f"- class:{class_name}: {count}")
368
+ if report.findings:
369
+ lines.extend(["", "Findings:"])
370
+ for finding in report.findings:
371
+ class_suffix = f" | class={finding.document_class}" if finding.document_class else ""
372
+ age_suffix = f" | age_days={finding.age_days}" if finding.age_days is not None else ""
373
+ lines.append(
374
+ f"- [{finding.severity}|{finding.code}] {finding.path}: "
375
+ f"{finding.message}{class_suffix}{age_suffix}"
376
+ )
377
+ if report.deferred:
378
+ lines.extend(["", "Deferred:"])
379
+ for item in report.deferred:
380
+ class_suffix = f" | class={item.document_class}" if item.document_class else ""
381
+ lines.append(f"- [DEFERRED] {item.path}: until {item.deferred_until}; {item.reason}{class_suffix}")
382
+ lines.extend(
383
+ [
384
+ "",
385
+ "Hygiene:",
386
+ f"- doc_lifecycle: {report.hygiene_mode}",
387
+ "- review_budgets: "
388
+ + ", ".join(f"{name}={days}" for name, days in sorted(report.review_budgets.items())),
389
+ ]
390
+ )
391
+ lines.extend(["", f"Overall: {'PASS' if report.ok else 'FAIL'}"])
392
+ return "\n".join(lines)
393
+
394
+
395
+ def _load_registry_summary(project_root: Path) -> dict[str, Any] | None:
396
+ try:
397
+ summary = build_documentation_registry_summary(project_root)
398
+ except (OSError, ValueError):
399
+ return None
400
+ summary["entries_by_path"] = _load_documentation_registry_entries_by_path(project_root)
401
+ return summary
402
+
403
+
404
+ def _load_documentation_registry_entries_by_path(project_root: Path) -> dict[str, dict[str, Any]]:
405
+ registry_path = project_root / "docs" / "DOCUMENTATION_REGISTRY.yaml"
406
+ if not registry_path.exists():
407
+ return {}
408
+ data = yaml.safe_load(registry_path.read_text(encoding="utf-8")) or {}
409
+ documents = data.get("documents", []) if isinstance(data, dict) else []
410
+ entries: dict[str, dict[str, Any]] = {}
411
+ for item in documents:
412
+ if not isinstance(item, dict):
413
+ continue
414
+ path = item.get("path")
415
+ if isinstance(path, str) and path:
416
+ entries[path] = item
417
+ return entries
418
+
419
+
420
+ def _iter_lifecycle_markdown_files(project_root: Path) -> tuple[Path, ...]:
421
+ paths: list[Path] = []
422
+ for directory in LIFECYCLE_DIRS:
423
+ root = project_root / directory
424
+ if not root.exists():
425
+ continue
426
+ paths.extend(path.relative_to(project_root) for path in root.rglob("*.md") if path.is_file())
427
+ return tuple(sorted(paths))
428
+
429
+
430
+ def _audit_document(
431
+ path: Path,
432
+ text: str,
433
+ status: str | None,
434
+ decision_status: str | None,
435
+ ) -> list[DocLifecycleFinding]:
436
+ findings: list[DocLifecycleFinding] = []
437
+ path_text = str(path)
438
+ if status is None:
439
+ findings.append(DocLifecycleFinding("missing-status", path_text, "document is missing a Status header"))
440
+ elif status not in ALLOWED_STATUS_VALUES:
441
+ findings.append(
442
+ DocLifecycleFinding(
443
+ "invalid-status",
444
+ path_text,
445
+ "status must be one of: " + ", ".join(sorted(ALLOWED_STATUS_VALUES)) + f"; found {status!r}",
446
+ )
447
+ )
448
+ if decision_status is None:
449
+ findings.append(DocLifecycleFinding("missing-decision-status", path_text, "document is missing a Decision status header"))
450
+ if status in {"idea-note", "active"} and "Review policy:" not in text:
451
+ findings.append(DocLifecycleFinding("missing-review-policy", path_text, "active and idea documents need a Review policy"))
452
+ if status in {"implemented", "superseded", "archived", "rejected"} and "Lifecycle note:" not in text:
453
+ findings.append(DocLifecycleFinding("missing-lifecycle-note", path_text, "closed lifecycle documents need a Lifecycle note"))
454
+ return findings
455
+
456
+
457
+ def _audit_registry_header_consistency(
458
+ *,
459
+ project_root: Path,
460
+ path_text: str,
461
+ text: str,
462
+ registry_entry: dict[str, Any],
463
+ status: str | None,
464
+ status_date: str | None,
465
+ superseded_by: str | None,
466
+ now: date,
467
+ current_version: str | None,
468
+ direction_statuses: dict[str, str],
469
+ review_budgets: Mapping[str, int],
470
+ ) -> list[DocLifecycleFinding]:
471
+ if not registry_entry or _is_lifecycle_header_exempt(path_text):
472
+ return []
473
+
474
+ findings: list[DocLifecycleFinding] = []
475
+ document_class = _entry_class(registry_entry)
476
+ for signal in build_review_after_findings(
477
+ path=path_text,
478
+ review_after=registry_entry.get("review_after"),
479
+ now=now,
480
+ current_version=current_version,
481
+ direction_statuses=direction_statuses,
482
+ document_class=document_class,
483
+ ):
484
+ findings.append(DocLifecycleFinding(**signal.to_finding_kwargs()))
485
+
486
+ if status is None or status_date is None:
487
+ missing = []
488
+ if status is None:
489
+ missing.append("Status")
490
+ if status_date is None:
491
+ missing.append("Status-date")
492
+ findings.append(
493
+ DocLifecycleFinding(
494
+ "HEADER_MISSING",
495
+ path_text,
496
+ "missing lifecycle header field(s): " + ", ".join(missing),
497
+ severity="WARN",
498
+ document_class=document_class,
499
+ )
500
+ )
501
+
502
+ registry_status = _entry_status(registry_entry)
503
+ if status is not None and registry_status is not None and status != registry_status:
504
+ findings.append(
505
+ DocLifecycleFinding(
506
+ "HEADER_REGISTRY_MISMATCH",
507
+ path_text,
508
+ f"header status {status!r} differs from registry status {registry_status!r}",
509
+ severity="WARN",
510
+ document_class=document_class,
511
+ )
512
+ )
513
+
514
+ if superseded_by and superseded_by != "n/a":
515
+ target = project_root / superseded_by
516
+ if not target.exists():
517
+ findings.append(
518
+ DocLifecycleFinding(
519
+ "SUPERSEDED_TARGET_MISSING",
520
+ path_text,
521
+ f"Superseded-by target does not exist: {superseded_by}",
522
+ severity="WARN",
523
+ document_class=document_class,
524
+ )
525
+ )
526
+
527
+ age_days = _age_days(status_date, now)
528
+ budget_group = _stale_budget_group(document_class)
529
+ if age_days is not None and budget_group is not None:
530
+ budget_days = review_budgets[budget_group]
531
+ if age_days > budget_days:
532
+ findings.append(
533
+ DocLifecycleFinding(
534
+ "STALE_BY_BUDGET",
535
+ path_text,
536
+ f"Status-date is {age_days} days old; {budget_group} budget is {budget_days} days",
537
+ severity="WARN",
538
+ document_class=document_class,
539
+ age_days=age_days,
540
+ )
541
+ )
542
+
543
+ return findings
544
+
545
+
546
+ def _workspace_lifecycle_hygiene(project_root: Path) -> tuple[str, Mapping[str, int]]:
547
+ workspace = load_workspace(project_root, suppress_legacy_profile_warning=True)
548
+ return workspace.hygiene_doc_lifecycle, workspace.hygiene_review_budgets
549
+
550
+
551
+ def _first_header_value(text: str, key: str) -> str | None:
552
+ prefix = f"{key}:"
553
+ for line in text.splitlines():
554
+ if line.startswith(prefix):
555
+ value = line[len(prefix):].strip()
556
+ return value or None
557
+ return None
558
+
559
+
560
+ def _entry_class(entry: dict[str, Any]) -> str | None:
561
+ value = entry.get("class")
562
+ return value if isinstance(value, str) and value else None
563
+
564
+
565
+ def _entry_status(entry: dict[str, Any]) -> str | None:
566
+ value = entry.get("status")
567
+ return value if isinstance(value, str) and value else None
568
+
569
+
570
+ def _deferred_lifecycle_entry(
571
+ path_text: str,
572
+ registry_entry: dict[str, Any],
573
+ now: date,
574
+ ) -> DocLifecycleDeferred | None:
575
+ deferred_until = registry_entry.get("deferred_until")
576
+ if not isinstance(deferred_until, str) or not deferred_until:
577
+ return None
578
+ try:
579
+ parsed_until = date.fromisoformat(deferred_until)
580
+ except ValueError:
581
+ return None
582
+ if now > parsed_until:
583
+ return None
584
+ return DocLifecycleDeferred(
585
+ path=path_text,
586
+ deferred_until=deferred_until,
587
+ reason="registry deferred_until suppresses lifecycle findings until this date",
588
+ document_class=_entry_class(registry_entry),
589
+ )
590
+
591
+
592
+ def _is_lifecycle_header_exempt(path_text: str) -> bool:
593
+ return any(path_text.startswith(prefix) for prefix in HEADER_AUDIT_EXEMPT_PREFIXES)
594
+
595
+
596
+ def _stale_budget_group(document_class: str | None) -> str | None:
597
+ if document_class == PLANNING_CLASS:
598
+ return None
599
+ return STALE_BUDGET_CLASS_GROUPS.get(document_class or "")
600
+
601
+
602
+ def _age_days(status_date: str | None, now: date) -> int | None:
603
+ if status_date is None:
604
+ return None
605
+ try:
606
+ parsed = date.fromisoformat(status_date)
607
+ except ValueError:
608
+ return None
609
+ return (now - parsed).days
610
+
611
+
612
+ def build_doc_lifecycle_triage_payload(project_root: Path) -> dict[str, Any]:
613
+ """Build a safe dry-run lifecycle triage report.
614
+
615
+ The triage layer is intentionally advisory. Missing lifecycle metadata is
616
+ WARN-only. Objective event conflicts, such as an invalid Status header, are
617
+ blocking because they make downstream lifecycle decisions unsafe.
618
+ """
619
+ report = build_doc_lifecycle_report(project_root)
620
+ reconcile = _load_reconcile_summary(project_root)
621
+ findings: list[dict[str, str]] = []
622
+ proposed_actions: list[dict[str, str]] = []
623
+ failures = 0
624
+ warnings = 0
625
+
626
+ for finding in report.findings:
627
+ severity = "FAIL" if finding.code == "invalid-status" else "WARN"
628
+ if severity == "FAIL":
629
+ failures += 1
630
+ else:
631
+ warnings += 1
632
+ findings.append(
633
+ {
634
+ "severity": severity,
635
+ "code": finding.code,
636
+ "path": finding.path,
637
+ "message": finding.message,
638
+ }
639
+ )
640
+ proposed_actions.append(
641
+ {
642
+ "id": finding.path + ":" + finding.code,
643
+ "path": finding.path,
644
+ "operation": "manual-review" if severity == "FAIL" else "defer",
645
+ "reason": finding.message,
646
+ "execute": "false",
647
+ "source": "doc-lifecycle-triage",
648
+ }
649
+ )
650
+
651
+ reconcile_actions = _reconcile_actions(reconcile)
652
+ finding_paths = {finding["path"] for finding in findings if finding["severity"] == "FAIL"}
653
+ for document in report.documents:
654
+ if document.path in finding_paths:
655
+ continue
656
+ operation = _triage_operation_for_status(document.status)
657
+ proposed_actions.append(
658
+ {
659
+ "id": document.path + ":" + operation,
660
+ "path": document.path,
661
+ "operation": operation,
662
+ "reason": _triage_reason_for_status(document.status),
663
+ "execute": "false",
664
+ "source": "doc-lifecycle-triage",
665
+ }
666
+ )
667
+
668
+ return {
669
+ "schema_version": 1,
670
+ "kind": "doc_lifecycle_triage_report",
671
+ "mode": "dry-run",
672
+ "auto_apply": False,
673
+ "result_status": "BLOCK" if failures else "PASS",
674
+ "summary": {
675
+ "documents": len(report.documents),
676
+ "findings": len(findings),
677
+ "warnings": warnings,
678
+ "failures": failures,
679
+ "proposed_actions": len(proposed_actions) + len(reconcile_actions),
680
+ "reconcile_findings": len(reconcile.get("findings", [])),
681
+ },
682
+ "documents": [_document_with_registry_metadata(document, report.registry_summary) for document in report.documents],
683
+ "findings": findings,
684
+ "proposed_actions": proposed_actions + reconcile_actions,
685
+ "registry_summary": _public_registry_summary(report.registry_summary),
686
+ "reconcile": reconcile,
687
+ }
688
+
689
+
690
+ def render_doc_lifecycle_triage_report(payload: dict[str, Any]) -> str:
691
+ summary = payload["summary"]
692
+ lines = [
693
+ "DOC_LIFECYCLE_TRIAGE",
694
+ f"STATUS: {payload['result_status']}",
695
+ f"MODE: {payload['mode']}",
696
+ "AUTO_APPLY: disabled",
697
+ f"DOCUMENTS: {summary['documents']}",
698
+ f"FINDINGS: {summary['findings']}",
699
+ f"WARNINGS: {summary['warnings']}",
700
+ f"FAILURES: {summary['failures']}",
701
+ "",
702
+ "PROPOSED_ACTIONS:",
703
+ ]
704
+ actions = payload.get("proposed_actions", [])
705
+ if not actions:
706
+ lines.append("- none")
707
+ for action in actions:
708
+ lines.append(
709
+ "- "
710
+ f"{action['operation']} | {action['path']} | "
711
+ f"{action['reason']} | execute={action['execute']}"
712
+ )
713
+ return "\n".join(lines) + "\n"
714
+
715
+
716
+ def _triage_operation_for_status(status: str | None) -> str:
717
+ if status in {"active", "accepted", "implemented"}:
718
+ return "confirm-current"
719
+ if status in {"superseded", "archived", "rejected"}:
720
+ return "historical"
721
+ if status == "idea-note":
722
+ return "defer"
723
+ return "manual-review"
724
+
725
+
726
+ def _triage_reason_for_status(status: str | None) -> str:
727
+ if status in {"active", "accepted", "implemented"}:
728
+ return "registered lifecycle metadata indicates current document"
729
+ if status in {"superseded", "archived", "rejected"}:
730
+ return "registered lifecycle metadata indicates historical document"
731
+ if status == "idea-note":
732
+ return "idea document remains advisory"
733
+ return "status requires manual review"
734
+
735
+
736
+ def build_doc_lifecycle_plan_payload(project_root: Path, scope: str) -> dict[str, Any]:
737
+ """Build a deterministic dry-run lifecycle plan for a repository scope."""
738
+ normalized_scope = scope.strip().strip("/")
739
+ triage = build_doc_lifecycle_triage_payload(project_root)
740
+ steps: list[dict[str, Any]] = []
741
+
742
+ for action in triage.get("proposed_actions", []):
743
+ path = action["path"]
744
+ if normalized_scope and not (
745
+ path == normalized_scope or path.startswith(normalized_scope + "/")
746
+ ):
747
+ continue
748
+ operation = action["operation"]
749
+ registry_by_path = _registry_metadata_by_path(triage)
750
+ steps.append(
751
+ {
752
+ "id": action["id"],
753
+ "path": path,
754
+ "operation": operation,
755
+ "reason": action["reason"],
756
+ "execute": False,
757
+ "safety_class": _plan_safety_class(operation),
758
+ "source": action.get("source", "doc-lifecycle-triage"),
759
+ "registry": registry_by_path.get(
760
+ path,
761
+ _registry_metadata_for_path(triage.get("registry_summary") or {}, path),
762
+ ),
763
+ }
764
+ )
765
+
766
+ failures = sum(1 for step in steps if step["safety_class"] == "human-decision-required")
767
+ result_status = "BLOCK" if triage["result_status"] == "BLOCK" and failures else "PASS"
768
+
769
+ return {
770
+ "schema_version": 1,
771
+ "kind": "doc_lifecycle_plan",
772
+ "mode": "dry-run",
773
+ "scope": normalized_scope,
774
+ "execution_enabled": False,
775
+ "result_status": result_status,
776
+ "summary": {
777
+ "steps": len(steps),
778
+ "failures": failures,
779
+ "warnings": sum(1 for step in steps if step["safety_class"] == "advisory"),
780
+ "safe_noops": sum(1 for step in steps if step["safety_class"] == "no-op-confirmation"),
781
+ },
782
+ "steps": steps,
783
+ "triage_summary": triage["summary"],
784
+ }
785
+
786
+
787
+ def render_doc_lifecycle_plan_report(payload: dict[str, Any]) -> str:
788
+ summary = payload["summary"]
789
+ lines = [
790
+ "DOC_LIFECYCLE_PLAN",
791
+ f"STATUS: {payload['result_status']}",
792
+ f"MODE: {payload['mode']}",
793
+ f"SCOPE: {payload['scope']}",
794
+ "EXECUTION: disabled",
795
+ f"STEPS: {summary['steps']}",
796
+ f"FAILURES: {summary['failures']}",
797
+ f"WARNINGS: {summary['warnings']}",
798
+ "",
799
+ "STEPS:",
800
+ ]
801
+ if not payload["steps"]:
802
+ lines.append("- none")
803
+ for step in payload["steps"]:
804
+ registry = step.get("registry", {})
805
+ registry_note = (
806
+ f" | class={registry.get('doc_class')} | owner={registry.get('owner')}"
807
+ if registry
808
+ else ""
809
+ )
810
+ lines.append(
811
+ "- "
812
+ f"{step['operation']} | {step['path']} | "
813
+ f"{step['safety_class']} | execute={str(step['execute']).lower()}"
814
+ f" | source={step.get('source', 'doc-lifecycle-triage')}"
815
+ f"{registry_note}"
816
+ )
817
+ return "\n".join(lines) + "\n"
818
+
819
+
820
+ def _plan_safety_class(operation: str) -> str:
821
+ if operation == "confirm-current":
822
+ return "no-op-confirmation"
823
+ if operation in {"defer", "historical"}:
824
+ return "advisory"
825
+ return "human-decision-required"
826
+
827
+
828
+
829
+ def _registry_metadata_for_path(registry_summary: dict[str, Any] | None, path: str) -> dict[str, Any]:
830
+ """Return stable lifecycle registry context for one repository-relative path."""
831
+ registry_summary = registry_summary or {}
832
+ entry = (registry_summary.get("entries_by_path") or {}).get(path)
833
+ if isinstance(entry, dict):
834
+ return {
835
+ "registered": True,
836
+ "doc_class": entry.get("class"),
837
+ "owner": entry.get("owner"),
838
+ "scope": "in-scope",
839
+ "status": entry.get("status"),
840
+ "review_policy": entry.get("review_policy"),
841
+ "summary": entry.get("summary"),
842
+ }
843
+ unregistered = set(registry_summary.get("unregistered_candidates", []))
844
+ if path in unregistered:
845
+ return {
846
+ "registered": False,
847
+ "doc_class": None,
848
+ "owner": None,
849
+ "scope": "unregistered-candidate",
850
+ "status": None,
851
+ "review_policy": None,
852
+ "summary": None,
853
+ }
854
+ return {
855
+ "registered": None,
856
+ "doc_class": None,
857
+ "owner": None,
858
+ "scope": "unknown",
859
+ "status": None,
860
+ "review_policy": None,
861
+ "summary": None,
862
+ }
863
+
864
+ def _registry_metadata_by_path(payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
865
+ registry_summary = payload.get("registry_summary") or {}
866
+ return {
867
+ document["path"]: document.get(
868
+ "registry",
869
+ _registry_metadata_for_path(registry_summary, document["path"]),
870
+ )
871
+ for document in payload.get("documents", [])
872
+ }
873
+
874
+
875
+
876
+ def _document_with_registry_metadata(document: Any, registry_summary: dict[str, Any]) -> dict[str, Any]:
877
+ data = document.to_dict()
878
+ data["registry"] = _registry_metadata_for_path(registry_summary or {}, document.path)
879
+ return data
880
+
881
+
882
+
883
+ def _public_registry_summary(registry_summary: dict[str, Any] | None) -> dict[str, Any] | None:
884
+ if registry_summary is None:
885
+ return None
886
+ public = dict(registry_summary)
887
+ public.pop("entries_by_path", None)
888
+ return public
889
+
890
+
891
+
892
+ def _load_reconcile_summary(project_root: Path) -> dict[str, Any]:
893
+ try:
894
+ report = build_doc_registry_reconcile_report(project_root)
895
+ except (OSError, ValueError):
896
+ return {
897
+ "schema_version": 1,
898
+ "kind": "doc_registry_reconcile_report",
899
+ "mode": "dry-run",
900
+ "result_status": "PASS",
901
+ "findings": [],
902
+ }
903
+ public = dict(report)
904
+ public.pop("rendered_scope_decision_table", None)
905
+ public.pop("scope_decision_rows", None)
906
+ return public
907
+
908
+
909
+ def _reconcile_actions(reconcile: dict[str, Any]) -> list[dict[str, Any]]:
910
+ actions: list[dict[str, Any]] = []
911
+ for index, finding in enumerate(reconcile.get("findings", []), start=1):
912
+ severity = finding.get("severity", "WARN")
913
+ operation = "manual-review" if severity == "BLOCK" else "defer"
914
+ actions.append(
915
+ {
916
+ "id": f"doc-registry-reconcile:{index}:{finding.get('kind', 'finding')}",
917
+ "path": finding.get("path") or "docs",
918
+ "operation": operation,
919
+ "reason": finding.get("message", "doc-registry reconcile finding"),
920
+ "execute": "false",
921
+ "source": "doc-registry-reconcile",
922
+ "finding_kind": finding.get("kind"),
923
+ "severity": severity,
924
+ "next_action": finding.get("next_action"),
925
+ }
926
+ )
927
+ return actions
928
+
929
+
930
+
931
+ def build_doc_lifecycle_apply_payload(
932
+ project_root: Path,
933
+ scope: str,
934
+ only: str,
935
+ *,
936
+ execute: bool,
937
+ ) -> dict[str, Any]:
938
+ """Apply one safe lifecycle plan step.
939
+
940
+ This first apply slice intentionally supports only no-op confirmations and
941
+ deferrals. It never edits, moves, archives, or deletes files.
942
+ """
943
+ if not execute:
944
+ return {
945
+ "schema_version": 1,
946
+ "kind": "doc_lifecycle_apply_result",
947
+ "mode": "blocked",
948
+ "result_status": "BLOCK",
949
+ "reason": "missing_execute_flag",
950
+ "requested_id": only,
951
+ "mutation": "none",
952
+ }
953
+
954
+ plan = build_doc_lifecycle_plan_payload(project_root, scope)
955
+ match = next((step for step in plan.get("steps", []) if step.get("id") == only), None)
956
+ if match is None:
957
+ return {
958
+ "schema_version": 1,
959
+ "kind": "doc_lifecycle_apply_result",
960
+ "mode": "blocked",
961
+ "result_status": "BLOCK",
962
+ "reason": "unknown_step_id",
963
+ "requested_id": only,
964
+ "mutation": "none",
965
+ }
966
+
967
+ operation = match.get("operation")
968
+ if operation not in {"confirm-current", "defer"}:
969
+ return {
970
+ "schema_version": 1,
971
+ "kind": "doc_lifecycle_apply_result",
972
+ "mode": "blocked",
973
+ "result_status": "BLOCK",
974
+ "reason": "unsafe_operation",
975
+ "requested_id": only,
976
+ "mutation": "none",
977
+ "step": match,
978
+ }
979
+
980
+ return {
981
+ "schema_version": 1,
982
+ "kind": "doc_lifecycle_apply_result",
983
+ "mode": "execute",
984
+ "result_status": "PASS",
985
+ "requested_id": only,
986
+ "mutation": "none",
987
+ "applied": {
988
+ "id": match["id"],
989
+ "path": match["path"],
990
+ "operation": operation,
991
+ "effect": "no-op",
992
+ "reason": match["reason"],
993
+ "source": match.get("source", "doc-lifecycle-triage"),
994
+ },
995
+ }
996
+
997
+
998
+ def render_doc_lifecycle_apply_report(payload: dict[str, Any]) -> str:
999
+ lines = [
1000
+ "DOC_LIFECYCLE_APPLY",
1001
+ f"STATUS: {payload['result_status']}",
1002
+ f"MODE: {payload['mode']}",
1003
+ "MUTATION: none",
1004
+ ]
1005
+ if payload["result_status"] == "BLOCK":
1006
+ lines.append(f"REASON: {payload['reason']}")
1007
+ lines.append(f"REQUESTED_ID: {payload['requested_id']}")
1008
+ else:
1009
+ applied = payload["applied"]
1010
+ lines.append(f"APPLIED: {applied['id']}")
1011
+ lines.append(f"OPERATION: {applied['operation']}")
1012
+ lines.append(f"EFFECT: {applied['effect']}")
1013
+ lines.append(f"SOURCE: {applied['source']}")
1014
+ return "\n".join(lines) + "\n"
1015
+
1016
+
1017
+
1018
+ def build_doc_lifecycle_evidence_report_payload(
1019
+ project_root: Path,
1020
+ scope: str,
1021
+ output_path: Path,
1022
+ *,
1023
+ execute: bool,
1024
+ ) -> dict[str, Any]:
1025
+ """Build or write one explicit documentation lifecycle evidence report."""
1026
+ output = output_path if output_path.is_absolute() else project_root / output_path
1027
+ allowed, relative_output = _is_allowed_lifecycle_evidence_output(project_root, output)
1028
+ if not allowed:
1029
+ return {
1030
+ "schema_version": 1,
1031
+ "kind": "doc_lifecycle_evidence_report_result",
1032
+ "mode": "blocked",
1033
+ "result_status": "BLOCK",
1034
+ "reason": "output_path_not_allowed",
1035
+ "output_path": str(output),
1036
+ "mutation": "none",
1037
+ }
1038
+
1039
+ triage = build_doc_lifecycle_triage_payload(project_root)
1040
+ plan = build_doc_lifecycle_plan_payload(project_root, scope)
1041
+ handoff_context = _load_handoff_context(project_root)
1042
+ report = {
1043
+ "schema_version": 1,
1044
+ "kind": "doc_lifecycle_evidence_report",
1045
+ "scope": scope.strip().strip("/"),
1046
+ "triage": triage,
1047
+ "plan": plan,
1048
+ "handoff_context": handoff_context,
1049
+ }
1050
+
1051
+ if not execute:
1052
+ return {
1053
+ "schema_version": 1,
1054
+ "kind": "doc_lifecycle_evidence_report_result",
1055
+ "mode": "dry-run",
1056
+ "result_status": "PASS",
1057
+ "scope": scope.strip().strip("/"),
1058
+ "output_path": relative_output,
1059
+ "would_write": True,
1060
+ "mutation": "none",
1061
+ "archive": "disabled",
1062
+ "delete": "disabled",
1063
+ "handoff_context": handoff_context,
1064
+ "report_summary": {
1065
+ "triage": triage["summary"],
1066
+ "plan": plan["summary"],
1067
+ },
1068
+ }
1069
+
1070
+ output.parent.mkdir(parents=True, exist_ok=True)
1071
+ output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1072
+ return {
1073
+ "schema_version": 1,
1074
+ "kind": "doc_lifecycle_evidence_report_result",
1075
+ "mode": "execute",
1076
+ "result_status": "PASS",
1077
+ "scope": scope.strip().strip("/"),
1078
+ "output_path": relative_output,
1079
+ "would_write": True,
1080
+ "mutation": "write-report",
1081
+ "archive": "disabled",
1082
+ "delete": "disabled",
1083
+ "handoff_context": handoff_context,
1084
+ "report_summary": {
1085
+ "triage": triage["summary"],
1086
+ "plan": plan["summary"],
1087
+ },
1088
+ }
1089
+
1090
+
1091
+ def render_doc_lifecycle_evidence_report_result(payload: dict[str, Any]) -> str:
1092
+ lines = [
1093
+ "DOC_LIFECYCLE_EVIDENCE_REPORT",
1094
+ f"STATUS: {payload['result_status']}",
1095
+ f"MODE: {payload['mode']}",
1096
+ f"MUTATION: {payload['mutation']}",
1097
+ "ARCHIVE: disabled",
1098
+ "DELETE: disabled",
1099
+ f"HANDOFF_CONTEXT: {payload.get('handoff_context', {}).get('severity', 'advisory')}",
1100
+ ]
1101
+ if payload["result_status"] == "BLOCK":
1102
+ lines.append(f"REASON: {payload['reason']}")
1103
+ else:
1104
+ lines.append(f"OUTPUT: {payload['output_path']}")
1105
+ lines.append(f"SCOPE: {payload['scope']}")
1106
+ return "\n".join(lines) + "\n"
1107
+
1108
+
1109
+ def _is_allowed_lifecycle_evidence_output(project_root: Path, output_path: Path) -> tuple[bool, str]:
1110
+ resolved_root = project_root.resolve()
1111
+ resolved_output = output_path.resolve()
1112
+ try:
1113
+ relative = resolved_output.relative_to(resolved_root)
1114
+ except ValueError:
1115
+ return False, str(resolved_output)
1116
+ relative_text = relative.as_posix()
1117
+ allowed_prefixes = (
1118
+ "docs/architecture/evidence/",
1119
+ "docs/reports/doc-lifecycle/",
1120
+ "tmp/",
1121
+ )
1122
+ return relative_text.startswith(allowed_prefixes), relative_text
1123
+
1124
+
1125
+
1126
+ def _load_handoff_context(project_root: Path) -> dict[str, Any]:
1127
+ """Load lightweight successor handoff context for lifecycle evidence.
1128
+
1129
+ This is advisory evidence only. It does not decide repository state and it
1130
+ does not replace post-merge-check.
1131
+ """
1132
+ validation_path = (
1133
+ project_root
1134
+ / "docs"
1135
+ / "reports"
1136
+ / "handoff-packages"
1137
+ / "latest"
1138
+ / "validation_report.json"
1139
+ )
1140
+ if not validation_path.exists():
1141
+ return {
1142
+ "available": False,
1143
+ "severity": "advisory",
1144
+ "safe_to_continue": None,
1145
+ "path": validation_path.as_posix(),
1146
+ }
1147
+ try:
1148
+ data = json.loads(validation_path.read_text(encoding="utf-8"))
1149
+ except (OSError, ValueError):
1150
+ return {
1151
+ "available": True,
1152
+ "severity": "advisory",
1153
+ "safe_to_continue": None,
1154
+ "path": validation_path.as_posix(),
1155
+ "parse_status": "unreadable",
1156
+ }
1157
+
1158
+ result_status = data.get("result_status") or data.get("status")
1159
+ head_status = (
1160
+ data.get("head_status")
1161
+ or data.get("successor_package_head_status")
1162
+ or data.get("generated_head_status")
1163
+ )
1164
+ safe_to_continue = result_status in {"PASS", "WARN"} and head_status in {
1165
+ None,
1166
+ "current",
1167
+ "matches_head",
1168
+ "refresh_only_descendant",
1169
+ }
1170
+ return {
1171
+ "available": True,
1172
+ "severity": "advisory",
1173
+ "safe_to_continue": safe_to_continue,
1174
+ "path": validation_path.as_posix(),
1175
+ "result_status": result_status,
1176
+ "head_status": head_status,
1177
+ "generated_head": data.get("generated_head"),
1178
+ "current_head": data.get("current_head"),
1179
+ }