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,639 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date
5
+ import json
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from agentic_project_kit.doc_lifecycle import build_doc_lifecycle_report
14
+ from agentic_project_kit.documentation_registry import load_documentation_registry
15
+ from agentic_project_kit.project_direction import audit_project_direction_drift
16
+ from agentic_project_kit.workspace import load_workspace
17
+ from agentic_project_kit.workspace_lock import workspace_mutation_lock
18
+
19
+ ARCHIVE_CLASS = "historical archive"
20
+ ARCHIVE_PREFIX = "docs/archive/"
21
+ LIFECYCLE_EXEMPT_PREFIXES = ("docs/archive/", "docs/reports/", "docs/examples/")
22
+ REFERENCE_SUFFIXES = {".md", ".py", ".yaml", ".yml"}
23
+ REFERENCE_EXCLUDED_PARTS = {".git", ".venv", "node_modules", "__pycache__"}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SweepCandidate:
28
+ id: str
29
+ path: str
30
+ finding_code: str
31
+ action: str
32
+ reason: str
33
+ target_path: str | None = None
34
+ superseded_by: str | None = None
35
+
36
+ def to_dict(self) -> dict[str, Any]:
37
+ return {
38
+ "id": self.id,
39
+ "path": self.path,
40
+ "finding_code": self.finding_code,
41
+ "action": self.action,
42
+ "reason": self.reason,
43
+ "target_path": self.target_path,
44
+ "superseded_by": self.superseded_by,
45
+ }
46
+
47
+
48
+ def build_doc_lifecycle_sweep_payload(
49
+ project_root: Path,
50
+ *,
51
+ execute: bool = False,
52
+ only: str = "",
53
+ until: str | None = None,
54
+ review_after: str | None = None,
55
+ today: date | None = None,
56
+ ) -> dict[str, Any]:
57
+ root = project_root.resolve()
58
+ run_date = today or date.today()
59
+ candidates = build_doc_lifecycle_sweep_candidates(root, today=run_date)
60
+ selected_ids = _parse_only(only)
61
+ mode = "execute" if execute else "dry-run"
62
+ selected = [candidate for candidate in candidates if not selected_ids or candidate.id in selected_ids]
63
+ unknown_ids = sorted(selected_ids - {candidate.id for candidate in candidates})
64
+
65
+ if execute and not selected_ids:
66
+ return _blocked_sweep_payload(mode, candidates, "missing_only", selected_ids, unknown_ids)
67
+ if unknown_ids:
68
+ return _blocked_sweep_payload(mode, candidates, "unknown_finding_id", selected_ids, unknown_ids)
69
+ if execute and any(candidate.action == "defer" for candidate in selected) and not until:
70
+ return _blocked_sweep_payload(mode, candidates, "missing_until_for_defer", selected_ids, unknown_ids)
71
+ if execute and until and not _is_iso_date(until):
72
+ return _blocked_sweep_payload(mode, candidates, "invalid_until_date", selected_ids, unknown_ids)
73
+
74
+ applied: list[dict[str, Any]] = []
75
+ if execute:
76
+ with workspace_mutation_lock(root, "docs-lifecycle-sweep"):
77
+ for candidate in selected:
78
+ applied.append(
79
+ _execute_candidate(
80
+ root,
81
+ candidate,
82
+ today=run_date,
83
+ until=until,
84
+ review_after=review_after,
85
+ )
86
+ )
87
+
88
+ return {
89
+ "schema_version": 1,
90
+ "kind": "doc_lifecycle_sweep",
91
+ "mode": mode,
92
+ "result_status": "PASS",
93
+ "summary": _sweep_summary(candidates, selected, applied),
94
+ "actions": [candidate.to_dict() for candidate in candidates],
95
+ "selected_ids": sorted(selected_ids),
96
+ "applied": applied,
97
+ "mutation": "workspace" if applied else "none",
98
+ }
99
+
100
+
101
+ def build_doc_lifecycle_sweep_candidates(project_root: Path, *, today: date | None = None) -> tuple[SweepCandidate, ...]:
102
+ root = project_root.resolve()
103
+ run_date = today or date.today()
104
+ report = build_doc_lifecycle_report(root, now=run_date)
105
+ registry_by_path = _registry_entries_by_path(root)
106
+ candidates: dict[str, SweepCandidate] = {}
107
+
108
+ for finding in report.findings:
109
+ action = _action_for_lifecycle_finding(finding.code, finding.path, registry_by_path)
110
+ candidate = SweepCandidate(
111
+ id=_finding_id(finding.path, finding.code),
112
+ path=finding.path,
113
+ finding_code=finding.code,
114
+ action=action,
115
+ reason=finding.message,
116
+ target_path=_archive_target_for(root, finding.path) if action == "archive" else None,
117
+ superseded_by=_superseded_context(root, finding.path, finding.code),
118
+ )
119
+ candidates[candidate.id] = candidate
120
+
121
+ try:
122
+ direction_audit = audit_project_direction_drift(root)
123
+ except (OSError, ValueError, yaml.YAMLError):
124
+ direction_audit = None
125
+ if direction_audit is not None:
126
+ for record in direction_audit.records:
127
+ if record.classification != "SOURCE_OF_CLOSED_ITEM_STILL_ACTIVE":
128
+ continue
129
+ code = record.classification
130
+ target = _archive_target_for(root, record.path)
131
+ candidates[_finding_id(record.path, code)] = SweepCandidate(
132
+ id=_finding_id(record.path, code),
133
+ path=record.path,
134
+ finding_code=code,
135
+ action="archive",
136
+ reason=f"source file belongs to closed Direction item {record.item_id}",
137
+ target_path=target,
138
+ superseded_by=f"docs/planning/PROJECT_DIRECTION.yaml#{record.item_id}",
139
+ )
140
+
141
+ return tuple(sorted(candidates.values(), key=lambda item: (item.path, item.finding_code)))
142
+
143
+
144
+ def render_doc_lifecycle_sweep(payload: dict[str, Any]) -> str:
145
+ summary = payload["summary"]
146
+ lines = [
147
+ "DOC_LIFECYCLE_SWEEP",
148
+ f"STATUS={payload['result_status']}",
149
+ f"MODE={payload['mode']}",
150
+ f"ACTION_COUNT={summary['actions']}",
151
+ f"SELECTED_COUNT={summary['selected']}",
152
+ f"APPLIED_COUNT={summary['applied']}",
153
+ ]
154
+ if payload["result_status"] == "BLOCK":
155
+ lines.append(f"REASON={payload['reason']}")
156
+ if payload.get("unknown_ids"):
157
+ lines.append("UNKNOWN_IDS=" + ",".join(payload["unknown_ids"]))
158
+ actions = payload.get("actions", [])
159
+ if not actions:
160
+ lines.append("ACTION=none")
161
+ for action in actions:
162
+ target = f"|target={action['target_path']}" if action.get("target_path") else ""
163
+ lines.append(
164
+ f"ACTION={action['id']}|{action['action']}|path={action['path']}|finding={action['finding_code']}{target}"
165
+ )
166
+ for applied in payload.get("applied", []):
167
+ lines.append(f"APPLIED={applied['id']}|{applied['action']}|path={applied['path']}")
168
+ return "\n".join(lines) + "\n"
169
+
170
+
171
+ def build_doc_lifecycle_bootstrap_payload(
172
+ project_root: Path,
173
+ *,
174
+ execute: bool = False,
175
+ today: date | None = None,
176
+ ) -> dict[str, Any]:
177
+ root = project_root.resolve()
178
+ candidates = _bootstrap_candidates(root, today=today or date.today())
179
+ applied: list[dict[str, Any]] = []
180
+ if execute:
181
+ with workspace_mutation_lock(root, "docs-lifecycle-bootstrap"):
182
+ for candidate in candidates:
183
+ path = root / candidate["path"]
184
+ text = path.read_text(encoding="utf-8")
185
+ path.write_text(
186
+ _insert_or_update_header(
187
+ text,
188
+ {
189
+ "Status": "unreviewed",
190
+ "Status-date": candidate["status_date"],
191
+ },
192
+ ),
193
+ encoding="utf-8",
194
+ )
195
+ applied.append(candidate)
196
+ return {
197
+ "schema_version": 1,
198
+ "kind": "doc_lifecycle_bootstrap",
199
+ "mode": "execute" if execute else "dry-run",
200
+ "result_status": "PASS",
201
+ "candidate_count": len(candidates),
202
+ "applied_count": len(applied),
203
+ "candidates": candidates,
204
+ "applied": applied,
205
+ "mutation": "workspace" if applied else "none",
206
+ }
207
+
208
+
209
+ def render_doc_lifecycle_bootstrap(payload: dict[str, Any]) -> str:
210
+ lines = [
211
+ "DOC_LIFECYCLE_BOOTSTRAP",
212
+ f"STATUS={payload['result_status']}",
213
+ f"MODE={payload['mode']}",
214
+ f"CANDIDATE_COUNT={payload['candidate_count']}",
215
+ f"APPLIED_COUNT={payload['applied_count']}",
216
+ ]
217
+ if not payload["candidates"]:
218
+ lines.append("CANDIDATE=none")
219
+ for candidate in payload["candidates"]:
220
+ lines.append(f"CANDIDATE={candidate['path']}|status_date={candidate['status_date']}")
221
+ return "\n".join(lines) + "\n"
222
+
223
+
224
+ def build_doc_lifecycle_propose_delete_payload(project_root: Path, *, today: date | None = None) -> dict[str, Any]:
225
+ root = project_root.resolve()
226
+ run_date = today or date.today()
227
+ references = _reference_texts(root)
228
+ candidates: list[dict[str, Any]] = []
229
+ for path in sorted((root / "docs" / "archive").rglob("*.md")):
230
+ relative = path.relative_to(root).as_posix()
231
+ status_date = _first_header_value(path.read_text(encoding="utf-8"), "Status-date")
232
+ age_days = _age_days(status_date, run_date)
233
+ if age_days is None or age_days <= 365:
234
+ continue
235
+ if any(relative in text for source, text in references.items() if source != relative):
236
+ continue
237
+ candidates.append(
238
+ {
239
+ "path": relative,
240
+ "status_date": status_date,
241
+ "age_days": age_days,
242
+ "action": "manual-delete-review",
243
+ }
244
+ )
245
+ return {
246
+ "schema_version": 1,
247
+ "kind": "doc_lifecycle_propose_delete",
248
+ "mode": "report-only",
249
+ "result_status": "PASS",
250
+ "candidate_count": len(candidates),
251
+ "candidates": candidates,
252
+ "mutation": "none",
253
+ }
254
+
255
+
256
+ def render_doc_lifecycle_propose_delete(payload: dict[str, Any]) -> str:
257
+ lines = [
258
+ "DOC_LIFECYCLE_PROPOSE_DELETE",
259
+ f"STATUS={payload['result_status']}",
260
+ f"CANDIDATE_COUNT={payload['candidate_count']}",
261
+ ]
262
+ if not payload["candidates"]:
263
+ lines.append("CANDIDATE=none")
264
+ for candidate in payload["candidates"]:
265
+ lines.append(
266
+ f"CANDIDATE={candidate['path']}|age_days={candidate['age_days']}|status_date={candidate['status_date']}"
267
+ )
268
+ return "\n".join(lines) + "\n"
269
+
270
+
271
+ def lifecycle_badge_from_audit_payload(payload: dict[str, Any]) -> str:
272
+ findings = payload.get("findings", [])
273
+ warn = len(findings) if isinstance(findings, list) else 0
274
+ due_codes = {"REVIEW_DUE_RELEASE", "REVIEW_DUE_DIRECTION", "REVIEW_DUE_DATE", "SOURCE_OF_CLOSED_ITEM_STILL_ACTIVE"}
275
+ due = 0
276
+ if isinstance(findings, list):
277
+ due = sum(1 for finding in findings if isinstance(finding, dict) and finding.get("code") in due_codes)
278
+ return f"lifecycle: {warn} warn / {due} due"
279
+
280
+
281
+ def _blocked_sweep_payload(
282
+ mode: str,
283
+ candidates: tuple[SweepCandidate, ...],
284
+ reason: str,
285
+ selected_ids: set[str],
286
+ unknown_ids: list[str],
287
+ ) -> dict[str, Any]:
288
+ return {
289
+ "schema_version": 1,
290
+ "kind": "doc_lifecycle_sweep",
291
+ "mode": mode,
292
+ "result_status": "BLOCK",
293
+ "reason": reason,
294
+ "summary": _sweep_summary(candidates, (), ()),
295
+ "actions": [candidate.to_dict() for candidate in candidates],
296
+ "selected_ids": sorted(selected_ids),
297
+ "unknown_ids": unknown_ids,
298
+ "applied": [],
299
+ "mutation": "none",
300
+ }
301
+
302
+
303
+ def _sweep_summary(
304
+ candidates: tuple[SweepCandidate, ...],
305
+ selected: list[SweepCandidate] | tuple[SweepCandidate, ...],
306
+ applied: list[dict[str, Any]] | tuple[dict[str, Any], ...],
307
+ ) -> dict[str, int]:
308
+ return {
309
+ "actions": len(candidates),
310
+ "selected": len(selected),
311
+ "applied": len(applied),
312
+ "archive": sum(1 for item in candidates if item.action == "archive"),
313
+ "confirm_current": sum(1 for item in candidates if item.action == "confirm-current"),
314
+ "defer": sum(1 for item in candidates if item.action == "defer"),
315
+ }
316
+
317
+
318
+ def _execute_candidate(
319
+ root: Path,
320
+ candidate: SweepCandidate,
321
+ *,
322
+ today: date,
323
+ until: str | None,
324
+ review_after: str | None,
325
+ ) -> dict[str, Any]:
326
+ if candidate.action == "archive":
327
+ return _execute_archive(root, candidate, today=today)
328
+ if candidate.action == "confirm-current":
329
+ return _execute_confirm_current(root, candidate, today=today, review_after=review_after)
330
+ if candidate.action == "defer":
331
+ assert until is not None
332
+ return _execute_defer(root, candidate, until=until)
333
+ raise ValueError(f"unsupported sweep action: {candidate.action}")
334
+
335
+
336
+ def _execute_archive(root: Path, candidate: SweepCandidate, *, today: date) -> dict[str, Any]:
337
+ if candidate.target_path is None:
338
+ raise ValueError("archive candidate missing target_path")
339
+ source = root / candidate.path
340
+ target = root / candidate.target_path
341
+ target.parent.mkdir(parents=True, exist_ok=True)
342
+ moved_with_git = _git_mv_or_rename(root, source, target)
343
+ text = target.read_text(encoding="utf-8")
344
+ target.write_text(
345
+ _insert_or_update_header(
346
+ text,
347
+ {
348
+ "Status": "superseded",
349
+ "Status-date": today.isoformat(),
350
+ "Superseded-by": candidate.superseded_by or "manual-maintainer-review",
351
+ },
352
+ ),
353
+ encoding="utf-8",
354
+ )
355
+ _update_registry_path(root, candidate.path, candidate.target_path, archived=True)
356
+ _replace_direction_source_file(root, candidate.path, candidate.target_path)
357
+ return {
358
+ "id": candidate.id,
359
+ "path": candidate.path,
360
+ "target_path": candidate.target_path,
361
+ "action": "archive",
362
+ "moved_with_git": moved_with_git,
363
+ "header_updated": True,
364
+ "registry_updated": True,
365
+ "direction_updated": True,
366
+ }
367
+
368
+
369
+ def _execute_confirm_current(
370
+ root: Path,
371
+ candidate: SweepCandidate,
372
+ *,
373
+ today: date,
374
+ review_after: str | None,
375
+ ) -> dict[str, Any]:
376
+ path = root / candidate.path
377
+ text = path.read_text(encoding="utf-8")
378
+ updates = {"Status-date": today.isoformat()}
379
+ if _first_header_value(text, "Status") is None:
380
+ registry_status = _registry_entries_by_path(root).get(candidate.path, {}).get("status")
381
+ if isinstance(registry_status, str) and registry_status:
382
+ updates = {"Status": registry_status, **updates}
383
+ path.write_text(_insert_or_update_header(text, updates), encoding="utf-8")
384
+ if review_after:
385
+ _set_registry_field(root, candidate.path, "review_after", review_after)
386
+ return {
387
+ "id": candidate.id,
388
+ "path": candidate.path,
389
+ "action": "confirm-current",
390
+ "header_updated": True,
391
+ "registry_review_after": review_after,
392
+ }
393
+
394
+
395
+ def _execute_defer(root: Path, candidate: SweepCandidate, *, until: str) -> dict[str, Any]:
396
+ date.fromisoformat(until)
397
+ _set_registry_field(root, candidate.path, "deferred_until", until)
398
+ return {
399
+ "id": candidate.id,
400
+ "path": candidate.path,
401
+ "action": "defer",
402
+ "deferred_until": until,
403
+ "registry_updated": True,
404
+ }
405
+
406
+
407
+ def _action_for_lifecycle_finding(code: str, path: str, registry_by_path: dict[str, dict[str, Any]]) -> str:
408
+ if code in {"STALE_BY_BUDGET", "REVIEW_DUE_DATE", "REVIEW_DUE_RELEASE", "REVIEW_DUE_DIRECTION"}:
409
+ return "confirm-current"
410
+ if code == "HEADER_MISSING":
411
+ text_status = str(registry_by_path.get(path, {}).get("status") or "")
412
+ return "confirm-current" if text_status in {"active", "accepted", "implemented"} else "defer"
413
+ if code in {"HEADER_REGISTRY_MISMATCH", "SUPERSEDED_TARGET_MISSING"}:
414
+ status = str(registry_by_path.get(path, {}).get("status") or "")
415
+ return "archive" if status in {"superseded", "archived", "rejected"} else "defer"
416
+ return "defer"
417
+
418
+
419
+ def _finding_id(path: str, code: str) -> str:
420
+ return f"{path}:{code}"
421
+
422
+
423
+ def _parse_only(value: str) -> set[str]:
424
+ if not value:
425
+ return set()
426
+ return {item.strip() for item in value.split(",") if item.strip()}
427
+
428
+
429
+ def _is_iso_date(value: str) -> bool:
430
+ try:
431
+ date.fromisoformat(value)
432
+ except ValueError:
433
+ return False
434
+ return True
435
+
436
+
437
+ def _registry_entries_by_path(project_root: Path) -> dict[str, dict[str, Any]]:
438
+ try:
439
+ registry = load_documentation_registry(project_root)
440
+ except (OSError, ValueError, yaml.YAMLError):
441
+ return {}
442
+ documents = registry.get("documents", [])
443
+ if not isinstance(documents, list):
444
+ return {}
445
+ return {
446
+ entry["path"]: entry
447
+ for entry in documents
448
+ if isinstance(entry, dict) and isinstance(entry.get("path"), str)
449
+ }
450
+
451
+
452
+ def _write_registry(project_root: Path, registry: dict[str, Any]) -> None:
453
+ path = load_workspace(project_root).doc_registry_path()
454
+ path.write_text(yaml.safe_dump(registry, sort_keys=False, allow_unicode=True), encoding="utf-8")
455
+
456
+
457
+ def _update_registry_path(project_root: Path, old_path: str, new_path: str, *, archived: bool) -> None:
458
+ registry = load_documentation_registry(project_root)
459
+ documents = registry.get("documents", [])
460
+ if not isinstance(documents, list):
461
+ return
462
+ for entry in documents:
463
+ if isinstance(entry, dict) and entry.get("path") == old_path:
464
+ entry["path"] = new_path
465
+ if archived:
466
+ entry["class"] = ARCHIVE_CLASS
467
+ entry["status"] = "superseded"
468
+ entry["archived_from"] = old_path
469
+ break
470
+ _write_registry(project_root, registry)
471
+
472
+
473
+ def _set_registry_field(project_root: Path, path: str, key: str, value: str) -> None:
474
+ registry = load_documentation_registry(project_root)
475
+ documents = registry.get("documents", [])
476
+ if not isinstance(documents, list):
477
+ return
478
+ for entry in documents:
479
+ if isinstance(entry, dict) and entry.get("path") == path:
480
+ entry[key] = value
481
+ break
482
+ _write_registry(project_root, registry)
483
+
484
+
485
+ def _replace_direction_source_file(project_root: Path, old_path: str, new_path: str) -> None:
486
+ path = load_workspace(project_root).project_direction_path()
487
+ if not path.exists():
488
+ return
489
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
490
+ changed = _replace_source_file_entries(data, old_path, new_path)
491
+ if changed:
492
+ path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
493
+
494
+
495
+ def _replace_source_file_entries(value: Any, old_path: str, new_path: str) -> bool:
496
+ changed = False
497
+ if isinstance(value, dict):
498
+ for key, item in value.items():
499
+ if key == "source_files" and isinstance(item, list):
500
+ for index, source in enumerate(item):
501
+ if source == old_path:
502
+ item[index] = new_path
503
+ changed = True
504
+ else:
505
+ changed = _replace_source_file_entries(item, old_path, new_path) or changed
506
+ elif isinstance(value, list):
507
+ for item in value:
508
+ changed = _replace_source_file_entries(item, old_path, new_path) or changed
509
+ return changed
510
+
511
+
512
+ def _git_mv_or_rename(root: Path, source: Path, target: Path) -> bool:
513
+ completed = subprocess.run(
514
+ ["git", "mv", source.relative_to(root).as_posix(), target.relative_to(root).as_posix()],
515
+ cwd=root,
516
+ text=True,
517
+ capture_output=True,
518
+ check=False,
519
+ )
520
+ if completed.returncode == 0:
521
+ return True
522
+ shutil.move(str(source), str(target))
523
+ return False
524
+
525
+
526
+ def _archive_target_for(root: Path, path: str) -> str:
527
+ source = Path(path)
528
+ stem = source.stem
529
+ suffix = source.suffix or ".md"
530
+ candidate = Path(ARCHIVE_PREFIX) / f"{stem}{suffix}"
531
+ index = 2
532
+ while (root / candidate).exists():
533
+ candidate = Path(ARCHIVE_PREFIX) / f"{stem}-{index}{suffix}"
534
+ index += 1
535
+ return candidate.as_posix()
536
+
537
+
538
+ def _superseded_context(root: Path, path: str, code: str) -> str:
539
+ try:
540
+ text = (root / path).read_text(encoding="utf-8")
541
+ except OSError:
542
+ return "manual-maintainer-review"
543
+ existing = _first_header_value(text, "Superseded-by")
544
+ if existing:
545
+ return existing
546
+ if code == "HEADER_REGISTRY_MISMATCH":
547
+ return "registry-status-mismatch"
548
+ return "manual-maintainer-review"
549
+
550
+
551
+ def _insert_or_update_header(text: str, updates: dict[str, str]) -> str:
552
+ lines = text.splitlines()
553
+ positions: dict[str, int] = {}
554
+ for index, line in enumerate(lines):
555
+ for key in updates:
556
+ if line.startswith(f"{key}:"):
557
+ positions[key] = index
558
+ for key, value in updates.items():
559
+ if key in positions:
560
+ lines[positions[key]] = f"{key}: {value}"
561
+ missing = [(key, value) for key, value in updates.items() if key not in positions]
562
+ if missing:
563
+ insert_at = 1 if lines and lines[0].startswith("#") else 0
564
+ header_lines = [f"{key}: {value}" for key, value in missing]
565
+ if insert_at == 1 and len(lines) > 1 and lines[1].strip():
566
+ header_lines.append("")
567
+ lines[insert_at:insert_at] = header_lines
568
+ return "\n".join(lines) + "\n"
569
+
570
+
571
+ def _first_header_value(text: str, key: str) -> str | None:
572
+ prefix = f"{key}:"
573
+ for line in text.splitlines():
574
+ if line.startswith(prefix):
575
+ return line.removeprefix(prefix).strip() or None
576
+ return None
577
+
578
+
579
+ def _bootstrap_candidates(project_root: Path, *, today: date) -> list[dict[str, Any]]:
580
+ candidates: list[dict[str, Any]] = []
581
+ for path in sorted((project_root / "docs").rglob("*.md")):
582
+ relative = path.relative_to(project_root).as_posix()
583
+ if _is_lifecycle_exempt(relative):
584
+ continue
585
+ text = path.read_text(encoding="utf-8")
586
+ if _first_header_value(text, "Status") is not None:
587
+ continue
588
+ candidates.append(
589
+ {
590
+ "path": relative,
591
+ "status_date": _last_commit_date(project_root, relative) or today.isoformat(),
592
+ }
593
+ )
594
+ return candidates
595
+
596
+
597
+ def _is_lifecycle_exempt(path: str) -> bool:
598
+ return any(path.startswith(prefix) for prefix in LIFECYCLE_EXEMPT_PREFIXES)
599
+
600
+
601
+ def _last_commit_date(project_root: Path, path: str) -> str | None:
602
+ completed = subprocess.run(
603
+ ["git", "log", "-1", "--format=%cs", "--", path],
604
+ cwd=project_root,
605
+ text=True,
606
+ capture_output=True,
607
+ check=False,
608
+ )
609
+ value = completed.stdout.strip()
610
+ return value or None
611
+
612
+
613
+ def _reference_texts(project_root: Path) -> dict[str, str]:
614
+ texts: dict[str, str] = {}
615
+ for path in project_root.rglob("*"):
616
+ if not path.is_file() or path.suffix not in REFERENCE_SUFFIXES:
617
+ continue
618
+ if REFERENCE_EXCLUDED_PARTS & set(path.parts):
619
+ continue
620
+ relative = path.relative_to(project_root).as_posix()
621
+ try:
622
+ texts[relative] = path.read_text(encoding="utf-8")
623
+ except (OSError, UnicodeDecodeError):
624
+ continue
625
+ return texts
626
+
627
+
628
+ def _age_days(status_date: str | None, today: date) -> int | None:
629
+ if not status_date:
630
+ return None
631
+ try:
632
+ parsed = date.fromisoformat(status_date)
633
+ except ValueError:
634
+ return None
635
+ return (today - parsed).days
636
+
637
+
638
+ def json_dumps(data: dict[str, Any]) -> str:
639
+ return json.dumps(data, indent=2, sort_keys=True) + "\n"