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,1122 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+ from fnmatch import fnmatchcase
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from agentic_project_kit.dpa_registry_contracts import (
14
+ DPA_REGISTRY_OPTIONAL_DOCUMENT_FIELDS,
15
+ validate_dpa_registry_contracts,
16
+ )
17
+ from agentic_project_kit.workspace import LEGACY_DEFAULTS, load_workspace
18
+
19
+ REGISTRY_PATH = Path(LEGACY_DEFAULTS.docs_root) / LEGACY_DEFAULTS.documentation_registry_file
20
+ SCOPE_PATH = Path(LEGACY_DEFAULTS.docs_root) / "DOC_REGISTRY_SCOPE.yaml"
21
+ COMPILED_CONTEXT_PATH = Path(".agentic/compiled_agent_context.yaml")
22
+ REGISTRY_CONTRACT_PATH = Path(LEGACY_DEFAULTS.governance_root) / "DOCUMENTATION_REGISTRY_CONTRACT.md"
23
+ SCOPE_DECISION_PATH = Path(LEGACY_DEFAULTS.governance_root) / "DOC_REGISTRY_SCOPE_DECISION.md"
24
+ COMMUNICATION_ARTIFACTS_PATH = Path(".agentic/communication_artifacts.yaml")
25
+
26
+ DOCUMENT_CLASSES = (
27
+ "governance/system",
28
+ "planning",
29
+ "architecture",
30
+ "release",
31
+ "operational/automation",
32
+ "user-facing description",
33
+ "evidence/log",
34
+ "generated artifact",
35
+ "temporary artifact",
36
+ "historical archive",
37
+ )
38
+
39
+ REQUIRED_CLASS_RULE_FIELDS = (
40
+ "ownership",
41
+ "freshness",
42
+ "language_policy",
43
+ "redundancy_boundary",
44
+ "machine_readability",
45
+ "retention_gc_behavior",
46
+ "update_triggers",
47
+ "portability_local_path_scanning",
48
+ "gate_coverage",
49
+ )
50
+
51
+ REQUIRED_DOCUMENT_FIELDS = (
52
+ "path",
53
+ "class",
54
+ "owner",
55
+ )
56
+
57
+ OPTIONAL_DOCUMENT_FIELDS = (
58
+ "review_after",
59
+ "deferred_until",
60
+ *DPA_REGISTRY_OPTIONAL_DOCUMENT_FIELDS,
61
+ )
62
+
63
+ DOCUMENT_REGISTRATION_SCAN_SUFFIXES = frozenset({".md", ".yaml", ".yml"})
64
+ DOCUMENT_REGISTRATION_SCAN_ROOTS = (Path(LEGACY_DEFAULTS.docs_root),)
65
+ DOCUMENT_REGISTRATION_EXCLUDED_PREFIXES = (
66
+ "docs/reports/handoff-packages/",
67
+ "docs/reports/terminal/",
68
+ "docs/reports/transfer_runs/",
69
+ )
70
+ SCOPE_REQUIRED_SUFFIXES = frozenset({".md"})
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ScopeExemption:
75
+ path: str
76
+ reason: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class DocumentationRegistryScope:
81
+ present: bool
82
+ required_files: tuple[str, ...]
83
+ required_paths: tuple[str, ...]
84
+ exempt_paths: tuple[ScopeExemption, ...]
85
+ errors: tuple[str, ...] = ()
86
+
87
+
88
+ def load_documentation_registry(project_root: Path) -> dict[str, Any]:
89
+ workspace = load_workspace(project_root)
90
+ path = workspace.doc_registry_path()
91
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
92
+ if not isinstance(data, dict):
93
+ raise ValueError(f"{workspace.path_text(path)}: root must be a mapping")
94
+ return data
95
+
96
+
97
+ def load_documentation_registry_scope(project_root: Path) -> DocumentationRegistryScope:
98
+ workspace = load_workspace(project_root)
99
+ path = workspace.docs_file("DOC_REGISTRY_SCOPE.yaml")
100
+ if not path.exists():
101
+ return DocumentationRegistryScope(
102
+ present=False,
103
+ required_files=(),
104
+ required_paths=(),
105
+ exempt_paths=(),
106
+ )
107
+
108
+ errors: list[str] = []
109
+ try:
110
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
111
+ except (OSError, yaml.YAMLError) as exc:
112
+ return DocumentationRegistryScope(
113
+ present=True,
114
+ required_files=(),
115
+ required_paths=(),
116
+ exempt_paths=(),
117
+ errors=(f"{workspace.path_text(path)}: invalid scope ({exc})",),
118
+ )
119
+
120
+ if data is None:
121
+ data = {}
122
+ if not isinstance(data, dict):
123
+ return DocumentationRegistryScope(
124
+ present=True,
125
+ required_files=(),
126
+ required_paths=(),
127
+ exempt_paths=(),
128
+ errors=(f"{workspace.path_text(path)}: root must be a mapping",),
129
+ )
130
+
131
+ if data.get("schema_version") != 1:
132
+ errors.append(f"{workspace.path_text(path)}: schema_version must be 1")
133
+
134
+ required_files = _parse_scope_required_files(data.get("required_files"), errors)
135
+ required_paths = _parse_scope_required_paths(data.get("required_paths"), errors)
136
+ exempt_paths = _parse_scope_exempt_paths(data.get("exempt_paths"), errors)
137
+ return DocumentationRegistryScope(
138
+ present=True,
139
+ required_files=tuple(required_files),
140
+ required_paths=tuple(required_paths),
141
+ exempt_paths=tuple(exempt_paths),
142
+ errors=tuple(errors),
143
+ )
144
+
145
+
146
+ def check_documentation_registry_scope(project_root: Path) -> list[str]:
147
+ scope = load_documentation_registry_scope(project_root)
148
+ return list(scope.errors)
149
+
150
+
151
+ def load_communication_artifact_policy(project_root: Path) -> dict[str, Any]:
152
+ path = project_root / COMMUNICATION_ARTIFACTS_PATH
153
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
154
+ if not isinstance(data, dict):
155
+ raise ValueError(f"{COMMUNICATION_ARTIFACTS_PATH}: root must be a mapping")
156
+ return data
157
+
158
+
159
+ def build_artifact_policy_summary(project_root: Path) -> dict[str, Any]:
160
+ """Build a read-only summary of communication artifact policy.
161
+
162
+ This intentionally consumes the machine-readable artifact policy without
163
+ changing cleanup behavior. It is a small integration step between the
164
+ documentation registry and the artifact retention policy.
165
+ """
166
+ try:
167
+ policy = load_communication_artifact_policy(project_root)
168
+ except FileNotFoundError:
169
+ return {
170
+ "policy_path": str(COMMUNICATION_ARTIFACTS_PATH),
171
+ "present": False,
172
+ "rule_count": 0,
173
+ "delete_allowed_counts": {},
174
+ "default_action_counts": {},
175
+ "protected_rule_ids": [],
176
+ }
177
+
178
+ rules = policy.get("rules", [])
179
+ if not isinstance(rules, list):
180
+ rules = []
181
+
182
+ delete_allowed_counts: Counter[str] = Counter()
183
+ default_action_counts: Counter[str] = Counter()
184
+ protected_rule_ids: list[str] = []
185
+ for rule in rules:
186
+ if not isinstance(rule, dict):
187
+ continue
188
+ delete_allowed = str(rule.get("delete_allowed", "<missing>")).strip() or "<missing>"
189
+ default_action = str(rule.get("default_action", "<missing>")).strip() or "<missing>"
190
+ rule_id = str(rule.get("id", "")).strip()
191
+ delete_allowed_counts[delete_allowed] += 1
192
+ default_action_counts[default_action] += 1
193
+ if delete_allowed in {"false", "False"} or default_action in {"keep", "keep-or-repair"}:
194
+ if rule_id:
195
+ protected_rule_ids.append(rule_id)
196
+
197
+ return {
198
+ "policy_path": str(COMMUNICATION_ARTIFACTS_PATH),
199
+ "present": True,
200
+ "schema_version": policy.get("schema_version"),
201
+ "rule_count": len(rules),
202
+ "delete_allowed_counts": dict(sorted(delete_allowed_counts.items())),
203
+ "default_action_counts": dict(sorted(default_action_counts.items())),
204
+ "protected_rule_ids": sorted(protected_rule_ids),
205
+ }
206
+
207
+
208
+ def build_documentation_registry_summary(project_root: Path) -> dict[str, Any]:
209
+ """Build a read-only summary for the documentation registry.
210
+
211
+ This summary is intentionally non-mutating. It is a narrow second-slice
212
+ consumer that makes the registry inspectable without starting a broad
213
+ migration or changing any document lifecycle policy.
214
+ """
215
+ registry = load_documentation_registry(project_root)
216
+ workspace = load_workspace(project_root)
217
+ documents = registry.get("documents", [])
218
+ if not isinstance(documents, list):
219
+ documents = []
220
+
221
+ class_counts: Counter[str] = Counter()
222
+ owner_counts: Counter[str] = Counter()
223
+ registered_paths: set[str] = set()
224
+ for entry in documents:
225
+ if not isinstance(entry, dict):
226
+ continue
227
+ document_class = str(entry.get("class", "")).strip() or "<missing>"
228
+ owner = str(entry.get("owner", "")).strip() or "<missing>"
229
+ path = str(entry.get("path", "")).strip()
230
+ if path:
231
+ registered_paths.add(path)
232
+ class_counts[document_class] += 1
233
+ owner_counts[owner] += 1
234
+
235
+ unregistered_candidates = find_unregistered_document_candidates(
236
+ project_root,
237
+ registered_paths=registered_paths,
238
+ )
239
+
240
+ return {
241
+ "registry_path": workspace.path_text(workspace.doc_registry_path()),
242
+ "version": registry.get("version"),
243
+ "lifecycle": (registry.get("status") or {}).get("lifecycle")
244
+ if isinstance(registry.get("status"), dict)
245
+ else None,
246
+ "broad_migration_allowed": (registry.get("status") or {}).get(
247
+ "broad_migration_allowed"
248
+ )
249
+ if isinstance(registry.get("status"), dict)
250
+ else None,
251
+ "document_count": len(documents),
252
+ "class_counts": dict(sorted(class_counts.items())),
253
+ "owner_counts": dict(sorted(owner_counts.items())),
254
+ "registration_policy": {
255
+ "status": "reviewed_register_command_available",
256
+ "mutation_allowed": True,
257
+ "allowed_command": "agentic-kit doc-registry register",
258
+ "next_step": (
259
+ "Review unregistered candidates, classify them, then register one explicit "
260
+ "path/class entry through the normal PR lifecycle."
261
+ ),
262
+ },
263
+ "unregistered_candidate_count": len(unregistered_candidates),
264
+ "unregistered_candidates": unregistered_candidates,
265
+ "artifact_policy": build_artifact_policy_summary(project_root),
266
+ }
267
+
268
+
269
+ def render_documentation_registry_summary(summary: dict[str, Any]) -> str:
270
+ lines = [
271
+ "Documentation registry summary",
272
+ f"registry: {summary['registry_path']}",
273
+ f"version: {summary['version']}",
274
+ f"lifecycle: {summary['lifecycle']}",
275
+ f"broad_migration_allowed: {summary['broad_migration_allowed']}",
276
+ f"documents: {summary['document_count']}",
277
+ "classes:",
278
+ ]
279
+ class_counts = summary.get("class_counts", {})
280
+ if isinstance(class_counts, dict):
281
+ for class_name, count in class_counts.items():
282
+ lines.append(f"- {class_name}: {count}")
283
+ lines.append("owners:")
284
+ owner_counts = summary.get("owner_counts", {})
285
+ if isinstance(owner_counts, dict):
286
+ for owner, count in owner_counts.items():
287
+ lines.append(f"- {owner}: {count}")
288
+ lines.extend(
289
+ [
290
+ "registration policy:",
291
+ "- status: reviewed_register_command_available",
292
+ "- mutation_allowed: True",
293
+ "- allowed_command: agentic-kit doc-registry register",
294
+ f"- unregistered_candidates: {summary.get('unregistered_candidate_count', 0)}",
295
+ ]
296
+ )
297
+
298
+ artifact_policy = summary.get("artifact_policy", {})
299
+ if isinstance(artifact_policy, dict) and artifact_policy.get("present"):
300
+ lines.extend(
301
+ [
302
+ "artifact policy:",
303
+ f"- policy: {artifact_policy['policy_path']}",
304
+ f"- rules: {artifact_policy['rule_count']}",
305
+ "- delete_allowed:",
306
+ ]
307
+ )
308
+ delete_counts = artifact_policy.get("delete_allowed_counts", {})
309
+ if isinstance(delete_counts, dict):
310
+ for value, count in delete_counts.items():
311
+ lines.append(f" - {value}: {count}")
312
+ lines.append("- protected rules:")
313
+ protected = artifact_policy.get("protected_rule_ids", [])
314
+ if isinstance(protected, list):
315
+ for rule_id in protected:
316
+ lines.append(f" - {rule_id}")
317
+ return "\n".join(lines)
318
+
319
+
320
+ def find_unregistered_document_candidates(
321
+ project_root: Path,
322
+ *,
323
+ registered_paths: set[str] | None = None,
324
+ ) -> list[str]:
325
+ """Return document-like files that are not registered yet.
326
+
327
+ This is intentionally inventory-only. It does not mutate the protected
328
+ registry, because document classification still requires explicit review.
329
+ """
330
+ if registered_paths is None:
331
+ try:
332
+ registry = load_documentation_registry(project_root)
333
+ documents = registry.get("documents", [])
334
+ registered_paths = {
335
+ str(entry.get("path", "")).strip()
336
+ for entry in documents
337
+ if isinstance(entry, dict) and str(entry.get("path", "")).strip()
338
+ }
339
+ except (FileNotFoundError, OSError, ValueError, yaml.YAMLError):
340
+ registered_paths = set()
341
+ candidates: list[str] = []
342
+ for root in DOCUMENT_REGISTRATION_SCAN_ROOTS:
343
+ scan_root = project_root / root
344
+ if not scan_root.exists():
345
+ continue
346
+ for path in sorted(scan_root.rglob("*")):
347
+ if not path.is_file() or path.is_symlink():
348
+ continue
349
+ if path.suffix.lower() not in DOCUMENT_REGISTRATION_SCAN_SUFFIXES:
350
+ continue
351
+ rel = path.relative_to(project_root).as_posix()
352
+ if rel in registered_paths:
353
+ continue
354
+ if any(rel.startswith(prefix) for prefix in DOCUMENT_REGISTRATION_EXCLUDED_PREFIXES):
355
+ continue
356
+ candidates.append(rel)
357
+ return candidates
358
+
359
+
360
+
361
+ def _registered_document_paths_from_registry(registry: dict[str, Any]) -> set[str]:
362
+ documents = registry.get("documents", [])
363
+ if not isinstance(documents, list):
364
+ return set()
365
+ return {
366
+ str(entry.get("path", "")).strip()
367
+ for entry in documents
368
+ if isinstance(entry, dict) and str(entry.get("path", "")).strip()
369
+ }
370
+
371
+
372
+ def _scope_exempt_matches(scope: DocumentationRegistryScope, rel_path: str) -> bool:
373
+ return any(fnmatchcase(rel_path, exemption.path) for exemption in scope.exempt_paths)
374
+
375
+
376
+ def _markdown_files_under_scope(project_root: Path, scope: DocumentationRegistryScope) -> list[str]:
377
+ paths: set[str] = set()
378
+
379
+ for required_file in scope.required_files:
380
+ candidate = project_root / required_file
381
+ if candidate.is_file() and candidate.suffix.lower() in SCOPE_REQUIRED_SUFFIXES:
382
+ paths.add(required_file)
383
+
384
+ for required_path in scope.required_paths:
385
+ scan_root = project_root / required_path
386
+ if not scan_root.exists():
387
+ continue
388
+ if scan_root.is_file():
389
+ rel = scan_root.relative_to(project_root).as_posix()
390
+ if scan_root.suffix.lower() in SCOPE_REQUIRED_SUFFIXES:
391
+ paths.add(rel)
392
+ continue
393
+ for candidate in scan_root.rglob("*"):
394
+ if not candidate.is_file() or candidate.is_symlink():
395
+ continue
396
+ if candidate.suffix.lower() not in SCOPE_REQUIRED_SUFFIXES:
397
+ continue
398
+ rel = candidate.relative_to(project_root).as_posix()
399
+ if _scope_exempt_matches(scope, rel):
400
+ continue
401
+ paths.add(rel)
402
+
403
+ return sorted(paths)
404
+
405
+
406
+ def build_doc_registry_reconcile_scope_decision_rows(project_root: Path) -> list[dict[str, Any]]:
407
+ """Build deterministic docs-root rows for the scope decision table.
408
+
409
+ This is read-only and intentionally limited to Markdown files under docs/.
410
+ """
411
+ docs_root = project_root / "docs"
412
+ if not docs_root.exists():
413
+ return []
414
+
415
+ rows: list[dict[str, Any]] = []
416
+ registry = load_documentation_registry(project_root)
417
+ registered_paths = _registered_document_paths_from_registry(registry)
418
+
419
+ for docs_path in sorted(p for p in docs_root.iterdir() if p.is_dir()):
420
+ md_files = sorted(
421
+ candidate.relative_to(project_root).as_posix()
422
+ for candidate in docs_path.rglob("*.md")
423
+ if candidate.is_file() and not candidate.is_symlink()
424
+ )
425
+ registered = [path for path in md_files if path in registered_paths]
426
+ rows.append(
427
+ {
428
+ "docs_path": docs_path.relative_to(project_root).as_posix() + "/",
429
+ "md_files": len(md_files),
430
+ "registered": len(registered),
431
+ "unregistered": len(md_files) - len(registered),
432
+ }
433
+ )
434
+ return rows
435
+
436
+
437
+ def render_doc_registry_scope_decision_table(rows: list[dict[str, Any]]) -> str:
438
+ lines = [
439
+ "| docs path | md files | registered | unregistered | proposed: required / exempt / undecided |",
440
+ "|---|---:|---:|---:|---|",
441
+ ]
442
+ for row in rows:
443
+ lines.append(
444
+ f"| {row['docs_path']} | {row['md_files']} | {row['registered']} | "
445
+ f"{row['unregistered']} | |"
446
+ )
447
+ return "\n".join(lines)
448
+
449
+
450
+ def build_doc_registry_reconcile_report(project_root: Path) -> dict[str, Any]:
451
+ """Build a read-only reconciliation report for registry/scope state.
452
+
453
+ The report deliberately performs no writes. It centralizes diagnostics that
454
+ were previously assembled manually during documentation taxonomy cleanup.
455
+ """
456
+ workspace = load_workspace(project_root)
457
+ registry = load_documentation_registry(project_root)
458
+ scope = load_documentation_registry_scope(project_root)
459
+ registered_paths = _registered_document_paths_from_registry(registry)
460
+ scope_required_markdown = _markdown_files_under_scope(project_root, scope)
461
+ unregistered_candidates = find_unregistered_document_candidates(
462
+ project_root,
463
+ registered_paths=registered_paths,
464
+ )
465
+ strict_scope_violations = [
466
+ path for path in scope_required_markdown if path not in registered_paths
467
+ ]
468
+
469
+ rows = build_doc_registry_reconcile_scope_decision_rows(project_root)
470
+ rendered_scope_table = render_doc_registry_scope_decision_table(rows)
471
+
472
+ scope_path = workspace.docs_file("DOC_REGISTRY_SCOPE.yaml")
473
+ decision_path = workspace.governance_file("DOC_REGISTRY_SCOPE_DECISION.md")
474
+ decision_present = decision_path.exists()
475
+ decision_text = decision_path.read_text(encoding="utf-8") if decision_present else ""
476
+ decision_table_stale = (
477
+ decision_present
478
+ and rendered_scope_table not in decision_text
479
+ )
480
+
481
+ findings: list[dict[str, str]] = []
482
+ for error in scope.errors:
483
+ findings.append(
484
+ {
485
+ "severity": "BLOCK",
486
+ "kind": "scope_schema",
487
+ "path": workspace.path_text(scope_path),
488
+ "message": error,
489
+ "next_action": "Fix docs/DOC_REGISTRY_SCOPE.yaml before registry reconciliation.",
490
+ }
491
+ )
492
+
493
+ if strict_scope_violations:
494
+ findings.append(
495
+ {
496
+ "severity": "BLOCK",
497
+ "kind": "strict_scope_unregistered",
498
+ "path": workspace.path_text(scope_path),
499
+ "message": f"{len(strict_scope_violations)} required-scope Markdown files are unregistered.",
500
+ "next_action": "Register reviewed required-scope documents or narrow the declared scope.",
501
+ }
502
+ )
503
+
504
+ if decision_table_stale:
505
+ findings.append(
506
+ {
507
+ "severity": "WARN",
508
+ "kind": "scope_decision_table_stale",
509
+ "path": workspace.path_text(decision_path),
510
+ "message": "Rendered docs-root table differs from DOC_REGISTRY_SCOPE_DECISION.md.",
511
+ "next_action": "Refresh the decision table from the reconcile report before committing taxonomy changes.",
512
+ }
513
+ )
514
+
515
+ status = "PASS"
516
+ if any(finding["severity"] == "BLOCK" for finding in findings):
517
+ status = "BLOCK"
518
+ elif findings:
519
+ status = "WARN"
520
+
521
+ return {
522
+ "schema_version": 1,
523
+ "kind": "doc_registry_reconcile_report",
524
+ "result_status": status,
525
+ "mode": "dry-run",
526
+ "registry_path": workspace.path_text(workspace.doc_registry_path()),
527
+ "scope_path": workspace.path_text(scope_path),
528
+ "scope_decision_path": workspace.path_text(decision_path),
529
+ "scope_present": scope.present,
530
+ "registered_count": len(registered_paths),
531
+ "unregistered_candidate_count": len(unregistered_candidates),
532
+ "unregistered_candidates": unregistered_candidates,
533
+ "scope_required_markdown_count": len(scope_required_markdown),
534
+ "strict_scope_violation_count": len(strict_scope_violations),
535
+ "strict_scope_violations": strict_scope_violations,
536
+ "scope_decision_present": decision_present,
537
+ "scope_decision_table_stale": decision_table_stale,
538
+ "scope_decision_rows": rows,
539
+ "rendered_scope_decision_table": rendered_scope_table,
540
+ "findings": findings,
541
+ }
542
+
543
+
544
+ def render_doc_registry_reconcile_report(report: dict[str, Any]) -> str:
545
+ lines = [
546
+ "DOC_REGISTRY_RECONCILE",
547
+ f"STATE: {report['result_status']}",
548
+ f"MODE: {report['mode']}",
549
+ f"REGISTRY: {report['registry_path']}",
550
+ f"SCOPE: {report['scope_path']}",
551
+ f"SCOPE_DECISION: {report['scope_decision_path']}",
552
+ f"REGISTERED: {report['registered_count']}",
553
+ f"UNREGISTERED_CANDIDATES: {report['unregistered_candidate_count']}",
554
+ f"STRICT_SCOPE_VIOLATIONS: {report['strict_scope_violation_count']}",
555
+ f"SCOPE_DECISION_TABLE_STALE: {report['scope_decision_table_stale']}",
556
+ "",
557
+ "FINDINGS:",
558
+ ]
559
+ findings = report.get("findings", [])
560
+ if not findings:
561
+ lines.append("- none")
562
+ else:
563
+ for finding in findings:
564
+ lines.append(
565
+ f"- {finding['severity']} {finding['kind']} {finding['path']}: "
566
+ f"{finding['message']} NEXT={finding['next_action']}"
567
+ )
568
+
569
+ lines.extend(
570
+ [
571
+ "",
572
+ "RENDERED_SCOPE_DECISION_TABLE:",
573
+ report["rendered_scope_decision_table"],
574
+ ]
575
+ )
576
+ return "\n".join(lines)
577
+
578
+ def write_documentation_registry_summary_json(
579
+ summary: dict[str, Any], report_path: Path
580
+ ) -> None:
581
+ report_path.parent.mkdir(parents=True, exist_ok=True)
582
+ report_path.write_text(
583
+ json.dumps(summary, indent=2, sort_keys=True) + "\n",
584
+ encoding="utf-8",
585
+ )
586
+
587
+
588
+ def documentation_registry_findings_for_data(
589
+ project_root: Path,
590
+ registry: dict[str, Any],
591
+ ) -> list[str]:
592
+ errors: list[str] = []
593
+ if registry.get("version") != 1:
594
+ errors.append(f"{REGISTRY_PATH}: version must be 1")
595
+
596
+ errors.extend(_check_class_rules(registry))
597
+ errors.extend(_check_document_entries(project_root, registry))
598
+ return errors
599
+
600
+
601
+ def check_documentation_registry(project_root: Path) -> list[str]:
602
+ workspace = load_workspace(project_root)
603
+ registry_file = workspace.doc_registry_path()
604
+ if not registry_file.exists():
605
+ if _registry_required(project_root):
606
+ return [f"Missing documentation registry: {workspace.path_text(registry_file)}"]
607
+ return []
608
+
609
+ try:
610
+ registry = load_documentation_registry(project_root)
611
+ except (OSError, ValueError, yaml.YAMLError) as exc:
612
+ return [f"{workspace.path_text(registry_file)}: invalid registry ({exc})"]
613
+
614
+ return documentation_registry_findings_for_data(project_root, registry) + (
615
+ check_documentation_registry_scope(project_root)
616
+ )
617
+
618
+
619
+ def register_documentation_registry_entry(
620
+ project_root: Path,
621
+ *,
622
+ document_path: str,
623
+ document_class: str,
624
+ owner: str = "maintainers",
625
+ ) -> dict[str, Any]:
626
+ normalized_path = document_path.strip().lstrip("./")
627
+ normalized_class = document_class.strip()
628
+ normalized_owner = owner.strip() or "maintainers"
629
+ workspace = load_workspace(project_root)
630
+ registry_path = workspace.doc_registry_path()
631
+ base_payload: dict[str, Any] = {
632
+ "schema_version": 1,
633
+ "kind": "documentation_registry_register_result",
634
+ "registry_path": workspace.path_text(registry_path),
635
+ "path": normalized_path,
636
+ "class": normalized_class,
637
+ "owner": normalized_owner,
638
+ "written": False,
639
+ }
640
+
641
+ registry = load_documentation_registry(project_root)
642
+ existing_findings = documentation_registry_findings_for_data(project_root, registry)
643
+ if existing_findings:
644
+ return {
645
+ **base_payload,
646
+ "result_status": "FAIL",
647
+ "code": "FAIL_INVALID_REGISTRY",
648
+ "findings": existing_findings,
649
+ }
650
+
651
+ allowed_classes = set(DOCUMENT_CLASSES)
652
+ if normalized_class not in allowed_classes:
653
+ return {
654
+ **base_payload,
655
+ "result_status": "FAIL",
656
+ "code": "FAIL_UNKNOWN_CLASS",
657
+ "allowed_classes": sorted(allowed_classes),
658
+ }
659
+
660
+ if not normalized_path or not (project_root / normalized_path).exists():
661
+ return {
662
+ **base_payload,
663
+ "result_status": "FAIL",
664
+ "code": "FAIL_PATH_NOT_FOUND",
665
+ }
666
+
667
+ documents = registry.get("documents")
668
+ if not isinstance(documents, list):
669
+ return {
670
+ **base_payload,
671
+ "result_status": "FAIL",
672
+ "code": "FAIL_INVALID_REGISTRY",
673
+ "findings": [f"{REGISTRY_PATH}: documents must be a list"],
674
+ }
675
+
676
+ registered_paths = {
677
+ str(entry.get("path", "")).strip()
678
+ for entry in documents
679
+ if isinstance(entry, dict)
680
+ }
681
+ if normalized_path in registered_paths:
682
+ return {
683
+ **base_payload,
684
+ "result_status": "FAIL",
685
+ "code": "FAIL_DUPLICATE_PATH",
686
+ }
687
+
688
+ candidate_registry = dict(registry)
689
+ candidate_documents = list(documents)
690
+ candidate_documents.append(
691
+ {
692
+ "path": normalized_path,
693
+ "class": normalized_class,
694
+ "owner": normalized_owner,
695
+ }
696
+ )
697
+ candidate_registry["documents"] = candidate_documents
698
+ candidate_findings = documentation_registry_findings_for_data(
699
+ project_root,
700
+ candidate_registry,
701
+ )
702
+ if candidate_findings:
703
+ return {
704
+ **base_payload,
705
+ "result_status": "FAIL",
706
+ "code": "FAIL_VALIDATION",
707
+ "findings": candidate_findings,
708
+ }
709
+
710
+ registry_path.write_text(
711
+ yaml.safe_dump(candidate_registry, sort_keys=False, allow_unicode=True),
712
+ encoding="utf-8",
713
+ )
714
+ return {
715
+ **base_payload,
716
+ "result_status": "PASS",
717
+ "code": "PASS",
718
+ "written": True,
719
+ }
720
+
721
+
722
+ def build_unregistered_document_candidates_report(
723
+ project_root: Path,
724
+ *,
725
+ strict_scope: bool = False,
726
+ ) -> dict[str, Any]:
727
+ scope = load_documentation_registry_scope(project_root)
728
+ workspace = load_workspace(project_root)
729
+ candidates = find_unregistered_document_candidates(project_root)
730
+ candidates, exempted_count = _filter_exempt_candidates(candidates, scope)
731
+ scope_violations = _find_scope_violations(project_root, scope)
732
+ if scope.errors:
733
+ result_status = "FAIL"
734
+ next_action = "Repair docs/DOC_REGISTRY_SCOPE.yaml before checking registry scope."
735
+ elif strict_scope and scope_violations:
736
+ result_status = "FAIL"
737
+ next_action = "Register or exempt unregistered files in required documentation scope paths."
738
+ elif candidates or scope_violations:
739
+ result_status = "WARN"
740
+ next_action = "Review and classify candidates before registering explicit entries."
741
+ else:
742
+ result_status = "PASS"
743
+ next_action = "No unregistered document candidates found."
744
+ return {
745
+ "schema_version": 1,
746
+ "kind": "documentation_registry_unregistered_candidates",
747
+ "registry_path": workspace.path_text(workspace.doc_registry_path()),
748
+ "scope_path": workspace.path_text(workspace.docs_file("DOC_REGISTRY_SCOPE.yaml")),
749
+ "result_status": result_status,
750
+ "final_signal": "d",
751
+ "strict_scope": strict_scope,
752
+ "scope_present": scope.present,
753
+ "scope_required_file_count": len(scope.required_files),
754
+ "scope_required_path_count": len(scope.required_paths),
755
+ "scope_exempt_path_count": len(scope.exempt_paths),
756
+ "scope_errors": list(scope.errors),
757
+ "exempted_count": exempted_count,
758
+ "scope_violation_count": len(scope_violations),
759
+ "scope_violations": scope_violations,
760
+ "candidate_count": len(candidates),
761
+ "candidates": candidates,
762
+ "next_action": next_action,
763
+ }
764
+
765
+
766
+ def build_doc_registry_scope_decision_rows(project_root: Path) -> list[dict[str, int | str]]:
767
+ registered_paths = _registered_document_paths(project_root)
768
+ counters: dict[str, Counter[str]] = {}
769
+ for rel in _iter_docs_markdown_files(
770
+ project_root,
771
+ excluded_prefixes=DOCUMENT_REGISTRATION_EXCLUDED_PREFIXES,
772
+ ):
773
+ group = _docs_subdirectory_group(rel)
774
+ bucket = counters.setdefault(group, Counter())
775
+ bucket["md_files"] += 1
776
+ if rel in registered_paths:
777
+ bucket["registered"] += 1
778
+ else:
779
+ bucket["unregistered"] += 1
780
+
781
+ return [
782
+ {
783
+ "docs_path": group,
784
+ "md_files": counters[group]["md_files"],
785
+ "registered": counters[group]["registered"],
786
+ "unregistered": counters[group]["unregistered"],
787
+ }
788
+ for group in sorted(counters, key=lambda value: ("0" if value == "docs/" else value))
789
+ ]
790
+
791
+
792
+ def render_doc_registry_scope_decision_template(project_root: Path) -> str:
793
+ rows = build_doc_registry_scope_decision_rows(project_root)
794
+ lines = [
795
+ "# Documentation Registry Scope Decision Template",
796
+ "",
797
+ "Status: active",
798
+ "Decision status: undecided",
799
+ "Review policy: required",
800
+ "",
801
+ "Generated from the current repository filesystem and `docs/DOCUMENTATION_REGISTRY.yaml`.",
802
+ "Counts exclude generated report prefixes that are already outside the registry candidate scan.",
803
+ "No scope recommendation is encoded here; maintainers fill the proposed column after review.",
804
+ "",
805
+ "| docs path | md files | registered | unregistered | proposed: required / exempt / undecided |",
806
+ "|---|---:|---:|---:|---|",
807
+ ]
808
+ for row in rows:
809
+ lines.append(
810
+ "| {docs_path} | {md_files} | {registered} | {unregistered} | |".format(
811
+ **row
812
+ )
813
+ )
814
+ lines.extend(
815
+ [
816
+ "",
817
+ "Notes:",
818
+ "- `required_files` means each declared file must have a registry entry.",
819
+ "- `required_paths` means every Markdown file in that declared path must be registered.",
820
+ "- `exempt_paths` means a declared path is intentionally registration-free and must carry a reason.",
821
+ "- This template is evidence for a maintainer decision; it does not modify scope by itself.",
822
+ ]
823
+ )
824
+ return "\n".join(lines) + "\n"
825
+
826
+
827
+ def _parse_scope_required_paths(value: object, errors: list[str]) -> list[str]:
828
+ if value in (None, []):
829
+ return []
830
+ if not isinstance(value, list):
831
+ errors.append(f"{SCOPE_PATH}: required_paths must be a list")
832
+ return []
833
+
834
+ required_paths: list[str] = []
835
+ for index, item in enumerate(value, start=1):
836
+ if not isinstance(item, str):
837
+ errors.append(f"{SCOPE_PATH}: required_paths entry {index} must be a string")
838
+ continue
839
+ normalized = _normalize_scope_pattern(item)
840
+ if not normalized:
841
+ errors.append(f"{SCOPE_PATH}: required_paths entry {index} must not be empty")
842
+ continue
843
+ if _scope_pattern_is_unsafe(normalized):
844
+ errors.append(f"{SCOPE_PATH}: required_paths entry {index} is not repository-relative")
845
+ continue
846
+ required_paths.append(normalized)
847
+ return required_paths
848
+
849
+
850
+ def _parse_scope_required_files(value: object, errors: list[str]) -> list[str]:
851
+ if value in (None, []):
852
+ return []
853
+ if not isinstance(value, list):
854
+ errors.append(f"{SCOPE_PATH}: required_files must be a list")
855
+ return []
856
+ required_files: list[str] = []
857
+ for index, entry in enumerate(value, start=1):
858
+ if not isinstance(entry, str):
859
+ errors.append(f"{SCOPE_PATH}: required_files entry {index} must be a string")
860
+ continue
861
+ normalized = _normalize_scope_pattern(entry)
862
+ if not normalized:
863
+ errors.append(f"{SCOPE_PATH}: required_files entry {index} must not be empty")
864
+ continue
865
+ if _scope_pattern_is_unsafe(normalized):
866
+ errors.append(f"{SCOPE_PATH}: required_files entry {index} is not repository-relative")
867
+ continue
868
+ if normalized.endswith("/"):
869
+ errors.append(f"{SCOPE_PATH}: required_files entry {index} must be a file path")
870
+ continue
871
+ required_files.append(normalized)
872
+ return required_files
873
+
874
+
875
+ def _parse_scope_exempt_paths(value: object, errors: list[str]) -> list[ScopeExemption]:
876
+ if value in (None, []):
877
+ return []
878
+ if not isinstance(value, list):
879
+ errors.append(f"{SCOPE_PATH}: exempt_paths must be a list")
880
+ return []
881
+
882
+ exempt_paths: list[ScopeExemption] = []
883
+ for index, item in enumerate(value, start=1):
884
+ if not isinstance(item, dict):
885
+ errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} must be a mapping")
886
+ continue
887
+ raw_path = item.get("path")
888
+ raw_reason = item.get("reason")
889
+ path = _normalize_scope_pattern(raw_path) if isinstance(raw_path, str) else ""
890
+ reason = raw_reason.strip() if isinstance(raw_reason, str) else ""
891
+ if not path:
892
+ errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} missing path")
893
+ continue
894
+ if _scope_pattern_is_unsafe(path):
895
+ errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} path is not repository-relative")
896
+ continue
897
+ if not reason:
898
+ errors.append(f"{SCOPE_PATH}: exempt_paths entry {index} missing reason")
899
+ continue
900
+ exempt_paths.append(ScopeExemption(path=path, reason=reason))
901
+ return exempt_paths
902
+
903
+
904
+ def _normalize_scope_pattern(value: str) -> str:
905
+ return value.strip().replace("\\", "/").lstrip("./")
906
+
907
+
908
+ def _scope_pattern_is_unsafe(pattern: str) -> bool:
909
+ return pattern.startswith("/") or any(part == ".." for part in pattern.split("/"))
910
+
911
+
912
+ def _registered_document_paths(project_root: Path) -> set[str]:
913
+ try:
914
+ registry = load_documentation_registry(project_root)
915
+ except (FileNotFoundError, OSError, ValueError, yaml.YAMLError):
916
+ return set()
917
+ documents = registry.get("documents", [])
918
+ if not isinstance(documents, list):
919
+ return set()
920
+ return {
921
+ str(entry.get("path", "")).strip()
922
+ for entry in documents
923
+ if isinstance(entry, dict) and str(entry.get("path", "")).strip()
924
+ }
925
+
926
+
927
+ def _filter_exempt_candidates(
928
+ candidates: list[str],
929
+ scope: DocumentationRegistryScope,
930
+ ) -> tuple[list[str], int]:
931
+ if scope.errors or not scope.exempt_paths:
932
+ return candidates, 0
933
+ filtered = [
934
+ candidate
935
+ for candidate in candidates
936
+ if not _matches_any_scope_pattern(candidate, [exemption.path for exemption in scope.exempt_paths])
937
+ ]
938
+ return filtered, len(candidates) - len(filtered)
939
+
940
+
941
+ def _find_scope_violations(
942
+ project_root: Path,
943
+ scope: DocumentationRegistryScope,
944
+ ) -> list[str]:
945
+ if scope.errors or (not scope.required_files and not scope.required_paths):
946
+ return []
947
+ registered_paths = _registered_document_paths(project_root)
948
+ exempt_patterns = [exemption.path for exemption in scope.exempt_paths]
949
+ violations = []
950
+ for rel in scope.required_files:
951
+ if rel in registered_paths:
952
+ continue
953
+ if exempt_patterns and _matches_any_scope_pattern(rel, exempt_patterns):
954
+ continue
955
+ violations.append(rel)
956
+ for rel in _iter_docs_markdown_files(project_root):
957
+ if rel in registered_paths:
958
+ continue
959
+ if exempt_patterns and _matches_any_scope_pattern(rel, exempt_patterns):
960
+ continue
961
+ if _matches_any_scope_pattern(rel, scope.required_paths):
962
+ violations.append(rel)
963
+ return sorted(set(violations))
964
+
965
+
966
+ def _iter_docs_markdown_files(
967
+ project_root: Path,
968
+ *,
969
+ excluded_prefixes: tuple[str, ...] = (),
970
+ ) -> list[str]:
971
+ docs_root = project_root / "docs"
972
+ if not docs_root.exists():
973
+ return []
974
+ result: list[str] = []
975
+ for path in sorted(docs_root.rglob("*")):
976
+ if not path.is_file() or path.is_symlink():
977
+ continue
978
+ if path.suffix.lower() not in SCOPE_REQUIRED_SUFFIXES:
979
+ continue
980
+ rel = path.relative_to(project_root).as_posix()
981
+ if any(rel.startswith(prefix) for prefix in excluded_prefixes):
982
+ continue
983
+ result.append(rel)
984
+ return result
985
+
986
+
987
+ def _docs_subdirectory_group(relative_path: str) -> str:
988
+ parts = relative_path.split("/")
989
+ if len(parts) < 3:
990
+ return "docs/"
991
+ return f"docs/{parts[1]}/"
992
+
993
+
994
+ def _matches_any_scope_pattern(path: str, patterns: list[str] | tuple[str, ...]) -> bool:
995
+ return any(_matches_scope_pattern(path, pattern) for pattern in patterns)
996
+
997
+
998
+ def _matches_scope_pattern(path: str, pattern: str) -> bool:
999
+ if any(token in pattern for token in "*?["):
1000
+ return fnmatchcase(path, pattern)
1001
+ if pattern.endswith("/"):
1002
+ return path.startswith(pattern)
1003
+ return path == pattern or path.startswith(f"{pattern}/")
1004
+
1005
+
1006
+ def _registry_required(project_root: Path) -> bool:
1007
+ if load_workspace(project_root).governance_file("DOCUMENTATION_REGISTRY_CONTRACT.md").exists():
1008
+ return True
1009
+ compiled_context = project_root / COMPILED_CONTEXT_PATH
1010
+ if not compiled_context.exists():
1011
+ return False
1012
+ try:
1013
+ context = yaml.safe_load(compiled_context.read_text(encoding="utf-8")) or {}
1014
+ except (OSError, yaml.YAMLError):
1015
+ return True
1016
+ return str(REGISTRY_PATH) in str(context)
1017
+
1018
+
1019
+ def _check_class_rules(registry: dict[str, Any]) -> list[str]:
1020
+ class_rules = registry.get("class_rules")
1021
+ if not isinstance(class_rules, dict):
1022
+ return [f"{REGISTRY_PATH}: class_rules must be a mapping"]
1023
+
1024
+ errors: list[str] = []
1025
+ allowed = set(DOCUMENT_CLASSES)
1026
+ present = set(str(name) for name in class_rules)
1027
+ for missing in sorted(allowed - present):
1028
+ errors.append(f"{REGISTRY_PATH}: missing class rule for {missing!r}")
1029
+ for unknown in sorted(present - allowed):
1030
+ errors.append(f"{REGISTRY_PATH}: unknown document class {unknown!r}")
1031
+
1032
+ for class_name in DOCUMENT_CLASSES:
1033
+ rules = class_rules.get(class_name)
1034
+ if not isinstance(rules, dict):
1035
+ continue
1036
+ for field in REQUIRED_CLASS_RULE_FIELDS:
1037
+ value = rules.get(field)
1038
+ if value in (None, "", []):
1039
+ errors.append(
1040
+ f"{REGISTRY_PATH}: {class_name!r} "
1041
+ f"missing class rule field {field!r}"
1042
+ )
1043
+
1044
+ return errors
1045
+
1046
+
1047
+ def _check_document_entries(project_root: Path, registry: dict[str, Any]) -> list[str]:
1048
+ documents = registry.get("documents")
1049
+ if not isinstance(documents, list) or not documents:
1050
+ return [f"{REGISTRY_PATH}: documents must be a non-empty list"]
1051
+
1052
+ errors: list[str] = []
1053
+ seen_paths: set[str] = set()
1054
+ allowed = set(DOCUMENT_CLASSES)
1055
+
1056
+ for index, entry in enumerate(documents, start=1):
1057
+ if not isinstance(entry, dict):
1058
+ errors.append(f"{REGISTRY_PATH}: document entry {index} must be a mapping")
1059
+ continue
1060
+
1061
+ for field in REQUIRED_DOCUMENT_FIELDS:
1062
+ if not str(entry.get(field, "")).strip():
1063
+ errors.append(f"{REGISTRY_PATH}: document entry {index} missing field {field!r}")
1064
+
1065
+ relative_path = str(entry.get("path", "")).strip()
1066
+ document_class = str(entry.get("class", "")).strip()
1067
+ if document_class and document_class not in allowed:
1068
+ errors.append(
1069
+ f"{REGISTRY_PATH}: {relative_path or index} "
1070
+ f"has unknown class {document_class!r}"
1071
+ )
1072
+
1073
+ if relative_path:
1074
+ if relative_path in seen_paths:
1075
+ errors.append(f"{REGISTRY_PATH}: duplicate document path {relative_path!r}")
1076
+ seen_paths.add(relative_path)
1077
+ if not (project_root / relative_path).exists():
1078
+ errors.append(
1079
+ f"{REGISTRY_PATH}: registered document does not exist: {relative_path}"
1080
+ )
1081
+
1082
+ review_after = entry.get("review_after")
1083
+ if review_after is not None and not _is_coarse_review_after(review_after):
1084
+ errors.append(
1085
+ f"{REGISTRY_PATH}: {relative_path or index} "
1086
+ "field 'review_after' must be a non-empty date:, release:, or direction: string"
1087
+ )
1088
+
1089
+ deferred_until = entry.get("deferred_until")
1090
+ if deferred_until is not None and not _is_iso_date_string(deferred_until):
1091
+ errors.append(
1092
+ f"{REGISTRY_PATH}: {relative_path or index} "
1093
+ "field 'deferred_until' must be an ISO date string"
1094
+ )
1095
+
1096
+ errors.extend(
1097
+ validate_dpa_registry_contracts(
1098
+ project_root,
1099
+ documents,
1100
+ registry_path=REGISTRY_PATH,
1101
+ )
1102
+ )
1103
+ return errors
1104
+
1105
+
1106
+ def _is_coarse_review_after(value: Any) -> bool:
1107
+ if not isinstance(value, str):
1108
+ return False
1109
+ stripped = value.strip()
1110
+ if not stripped or "\n" in stripped:
1111
+ return False
1112
+ return stripped.startswith(("date:", "release:", "direction:"))
1113
+
1114
+
1115
+ def _is_iso_date_string(value: Any) -> bool:
1116
+ if not isinstance(value, str):
1117
+ return False
1118
+ try:
1119
+ date.fromisoformat(value)
1120
+ except ValueError:
1121
+ return False
1122
+ return True