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,98 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from agentic_project_kit.rule_source_validator import (
9
+ RuleSourceValidationResult,
10
+ validate_rule_sources,
11
+ )
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RuleSourceDigest:
16
+ path: str
17
+ sha256: str
18
+ size_bytes: int
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DerivedRuleSnapshot:
23
+ schema_version: int
24
+ snapshot_id: str
25
+ sources_total: int
26
+ source_digests: tuple[RuleSourceDigest, ...]
27
+ validation: RuleSourceValidationResult
28
+
29
+ @property
30
+ def is_valid(self) -> bool:
31
+ return self.validation.is_valid
32
+
33
+ @property
34
+ def fail_closed(self) -> bool:
35
+ return self.validation.fail_closed
36
+
37
+ def as_json_data(self) -> dict[str, object]:
38
+ return {
39
+ "schema_version": self.schema_version,
40
+ "snapshot_id": self.snapshot_id,
41
+ "sources_total": self.sources_total,
42
+ "is_valid": self.is_valid,
43
+ "fail_closed": self.fail_closed,
44
+ "source_digests": [
45
+ {
46
+ "path": digest.path,
47
+ "sha256": digest.sha256,
48
+ "size_bytes": digest.size_bytes,
49
+ }
50
+ for digest in self.source_digests
51
+ ],
52
+ "validation": self.validation.as_json_data(),
53
+ }
54
+
55
+
56
+ def _file_digest(path: Path) -> tuple[str, int]:
57
+ data = path.read_bytes()
58
+ return hashlib.sha256(data).hexdigest(), len(data)
59
+
60
+
61
+ def _snapshot_id(
62
+ source_digests: tuple[RuleSourceDigest, ...], validation: RuleSourceValidationResult
63
+ ) -> str:
64
+ payload = {
65
+ "schema_version": 1,
66
+ "source_digests": [
67
+ {
68
+ "path": digest.path,
69
+ "sha256": digest.sha256,
70
+ "size_bytes": digest.size_bytes,
71
+ }
72
+ for digest in source_digests
73
+ ],
74
+ "blocking_reasons": list(validation.blocking_reasons),
75
+ }
76
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
77
+ return hashlib.sha256(encoded).hexdigest()
78
+
79
+
80
+ def build_derived_rule_snapshot(root: str | Path = ".") -> DerivedRuleSnapshot:
81
+ root_path = Path(root)
82
+ validation = validate_rule_sources(root_path)
83
+
84
+ digests: list[RuleSourceDigest] = []
85
+ for source in validation.source_paths:
86
+ path = root_path / source
87
+ if path.exists() and path.is_file():
88
+ digest, size_bytes = _file_digest(path)
89
+ digests.append(RuleSourceDigest(path=source, sha256=digest, size_bytes=size_bytes))
90
+
91
+ source_digests = tuple(digests)
92
+ return DerivedRuleSnapshot(
93
+ schema_version=1,
94
+ snapshot_id=_snapshot_id(source_digests, validation),
95
+ sources_total=validation.sources_total,
96
+ source_digests=source_digests,
97
+ validation=validation,
98
+ )
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from agentic_project_kit.chat_bootloader import MANDATORY_BOOT_SOURCES
9
+ from agentic_project_kit.handoff_prompt import MANDATORY_SUCCESSOR_CHAT_SOURCES
10
+ from agentic_project_kit.handoff_state import load_handoff_state, validate_handoff_state
11
+ from agentic_project_kit.rule_refresh import COMMUNICATION_RULE_SOURCES, HANDOFF_RULE_SOURCES
12
+
13
+
14
+ CANONICAL_YAML_RULE_SOURCES = (
15
+ ".agentic/compiled_agent_context.yaml",
16
+ ".agentic/handoff_state.yaml",
17
+ ".agentic/rule_mechanism_inventory.yaml",
18
+ ".agentic/rule_migrations.yaml",
19
+ ".agentic/rule_preservation.yaml",
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class RuleSourceValidationResult:
25
+ source_paths: tuple[str, ...]
26
+ missing_required_paths: tuple[str, ...]
27
+ yaml_parse_error_paths: tuple[str, ...]
28
+ handoff_state_errors: tuple[str, ...]
29
+ blocking_reasons: tuple[str, ...]
30
+
31
+ @property
32
+ def sources_total(self) -> int:
33
+ return len(self.source_paths)
34
+
35
+ @property
36
+ def is_valid(self) -> bool:
37
+ return not self.blocking_reasons
38
+
39
+ @property
40
+ def fail_closed(self) -> bool:
41
+ return bool(self.blocking_reasons)
42
+
43
+ def as_json_data(self) -> dict[str, object]:
44
+ return {
45
+ "schema_version": 1,
46
+ "sources_total": self.sources_total,
47
+ "is_valid": self.is_valid,
48
+ "fail_closed": self.fail_closed,
49
+ "source_paths": list(self.source_paths),
50
+ "missing_required_paths": list(self.missing_required_paths),
51
+ "yaml_parse_error_paths": list(self.yaml_parse_error_paths),
52
+ "handoff_state_errors": list(self.handoff_state_errors),
53
+ "blocking_reasons": list(self.blocking_reasons),
54
+ }
55
+
56
+
57
+ def canonical_rule_source_paths() -> tuple[str, ...]:
58
+ """Return the de-duplicated canonical rule-source set used for read-only validation."""
59
+
60
+ ordered: list[str] = []
61
+ for collection in (
62
+ MANDATORY_BOOT_SOURCES,
63
+ tuple(MANDATORY_SUCCESSOR_CHAT_SOURCES),
64
+ COMMUNICATION_RULE_SOURCES,
65
+ HANDOFF_RULE_SOURCES,
66
+ CANONICAL_YAML_RULE_SOURCES,
67
+ ):
68
+ for source in collection:
69
+ if source == "relevant source files and tests for the requested slice":
70
+ continue
71
+ if source not in ordered:
72
+ ordered.append(source)
73
+ return tuple(ordered)
74
+
75
+
76
+ def validate_rule_sources(root: str | Path = ".") -> RuleSourceValidationResult:
77
+ """Validate canonical rule sources without writing files or generating snapshots."""
78
+
79
+ root_path = Path(root)
80
+ source_paths = canonical_rule_source_paths()
81
+ missing_required_paths: list[str] = []
82
+ yaml_parse_error_paths: list[str] = []
83
+ handoff_state_errors: list[str] = []
84
+ blocking_reasons: list[str] = []
85
+
86
+ for source in source_paths:
87
+ path = root_path / source
88
+ if not path.exists() or not path.is_file():
89
+ missing_required_paths.append(source)
90
+ blocking_reasons.append(f"missing required rule source: {source}")
91
+ continue
92
+
93
+ if path.suffix in {".yaml", ".yml"}:
94
+ try:
95
+ yaml.safe_load(path.read_text(encoding="utf-8"))
96
+ except yaml.YAMLError as exc:
97
+ yaml_parse_error_paths.append(source)
98
+ blocking_reasons.append(f"yaml parse error in rule source: {source}: {exc}")
99
+
100
+ handoff_path = root_path / ".agentic/handoff_state.yaml"
101
+ if handoff_path.exists() and handoff_path.is_file():
102
+ try:
103
+ handoff_state_errors.extend(validate_handoff_state(load_handoff_state(handoff_path)))
104
+ except (OSError, ValueError, yaml.YAMLError) as exc:
105
+ handoff_state_errors.append(str(exc))
106
+ for error in handoff_state_errors:
107
+ blocking_reasons.append(f"handoff_state invalid: {error}")
108
+
109
+ return RuleSourceValidationResult(
110
+ source_paths=source_paths,
111
+ missing_required_paths=tuple(missing_required_paths),
112
+ yaml_parse_error_paths=tuple(yaml_parse_error_paths),
113
+ handoff_state_errors=tuple(handoff_state_errors),
114
+ blocking_reasons=tuple(blocking_reasons),
115
+ )
116
+
117
+
118
+ def render_rule_source_validation(result: RuleSourceValidationResult) -> str:
119
+ lines = [
120
+ "RULE_SOURCE_VALIDATION",
121
+ f"sources_total={result.sources_total}",
122
+ f"is_valid={result.is_valid}",
123
+ f"fail_closed={result.fail_closed}",
124
+ ]
125
+ for path in result.missing_required_paths:
126
+ lines.append(f"missing_required_path={path}")
127
+ for path in result.yaml_parse_error_paths:
128
+ lines.append(f"yaml_parse_error_path={path}")
129
+ for error in result.handoff_state_errors:
130
+ lines.append(f"handoff_state_error={error}")
131
+ for reason in result.blocking_reasons:
132
+ lines.append(f"blocking_reason={reason}")
133
+ return "\n".join(lines)
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+
7
+ class WorkResult(StrEnum):
8
+ PASS = "PASS"
9
+ FAIL = "FAIL"
10
+ PENDING = "PENDING"
11
+ HARD_FAIL = "HARD-FAIL"
12
+
13
+
14
+ class EvidenceResult(StrEnum):
15
+ PASS = "PASS"
16
+ FAIL = "FAIL"
17
+ PARTIAL = "PARTIAL"
18
+ CHAT_ONLY = "CHAT_ONLY"
19
+ NOT_REQUIRED = "NOT_REQUIRED"
20
+
21
+
22
+ class RemoteEvidence(StrEnum):
23
+ PASS = "PASS"
24
+ FAIL = "FAIL"
25
+ PARTIAL = "PARTIAL"
26
+ NOT_REQUIRED = "NOT_REQUIRED"
27
+
28
+
29
+ class NextChatReply(StrEnum):
30
+ D = "d"
31
+ F = "f"
32
+ PASTE_OUTPUT = "paste-output"
33
+ CONTINUE = "continue"
34
+ STOP = "stop"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class RunSummary:
39
+ work_result: WorkResult
40
+ evidence_result: EvidenceResult
41
+ overall_result: WorkResult
42
+ remote_evidence: RemoteEvidence
43
+ terminal_log: str = "NONE"
44
+ command_report: str = "NONE"
45
+ next_chat_reply: NextChatReply = NextChatReply.D
46
+
47
+
48
+ def render_run_summary(summary: RunSummary) -> str:
49
+ lines = [
50
+ "================================================================",
51
+ "SUMMARY",
52
+ f"WORK RESULT: {summary.work_result.value}",
53
+ f"EVIDENCE RESULT: {summary.evidence_result.value}",
54
+ f"OVERALL RESULT: {summary.overall_result.value}",
55
+ f"REMOTE_EVIDENCE: {summary.remote_evidence.value}",
56
+ f"terminal_log={summary.terminal_log}",
57
+ f"command_report={summary.command_report}",
58
+ f"NEXT_CHAT_REPLY: {summary.next_chat_reply.value}",
59
+ f"### RESULT: {summary.overall_result.value} ###",
60
+ "================================================================",
61
+ ]
62
+ return "\n".join(lines)
63
+
64
+
65
+ def summary_for_status(status: int, terminal_log: str = "NONE", command_report: str = "NONE", remote_evidence: RemoteEvidence = RemoteEvidence.PARTIAL) -> RunSummary:
66
+ if status == 0:
67
+ return RunSummary(WorkResult.PASS, EvidenceResult.PASS, WorkResult.PASS, remote_evidence, terminal_log, command_report, NextChatReply.D)
68
+ return RunSummary(WorkResult.FAIL, EvidenceResult.PARTIAL, WorkResult.FAIL, remote_evidence, terminal_log, command_report, NextChatReply.F)
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ VALID_RESULTS = {"PASS", "FAIL", "PENDING", "HARD-FAIL", "NO-COMMAND"}
12
+ VALID_EVIDENCE_RESULTS = {"PASS", "FAIL", "PARTIAL", "CHAT_ONLY", "NOT_REQUIRED"}
13
+ VALID_REMOTE_EVIDENCE_RESULTS = {"PASS", "FAIL", "PARTIAL", "NOT_REQUIRED"}
14
+ VALID_CHAT_REPLIES = {"d", "f", "w", "paste-output", "stop"}
15
+ LEGACY_SUMMARY_TOKENS = ("WORK RESULT:", "EVIDENCE RESULT:", "OVERALL RESULT:", "NEXT_CHAT_REPLY:")
16
+ REQUIRED_RENDERED_TOKENS = (
17
+ "SUMMARY COMM-",
18
+ "SLICE\n",
19
+ "EXECUTION\n",
20
+ "RESULT\n",
21
+ "REMOTE\n",
22
+ "EVIDENCE FILES\n",
23
+ "INTERPRETATION\n",
24
+ "NEXT\n",
25
+ " WORK: ",
26
+ " EVIDENCE: ",
27
+ " OVERALL: ",
28
+ " REMOTE_EVIDENCE: ",
29
+ " terminal_log: ",
30
+ " command_report: ",
31
+ " CHAT_REPLY: ",
32
+ "### RESULT: ",
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SummaryPayload:
38
+ comm_id: str = "COMM-PENDING"
39
+ comm_header: str = ""
40
+ slice: str = ""
41
+ slice_name: str = ""
42
+ scope: str = ""
43
+ branch: str = "current"
44
+ origin: str = "local"
45
+ state_mode: str = "local"
46
+ mode_check: str = "not_recorded"
47
+ work: str = "PENDING"
48
+ evidence: str = "CHAT_ONLY"
49
+ overall: str = "PENDING"
50
+ remote_evidence: str = "NOT_REQUIRED"
51
+ pr: str = "NONE"
52
+ head_sha: str = "NONE"
53
+ ci: str = "NONE"
54
+ merge: str = "NONE"
55
+ terminal_log: str = "NONE"
56
+ terminal_log_remote: str = "NONE"
57
+ terminal_log_local: str = "NONE"
58
+ command_report: str = "NONE"
59
+ interpretation: str = "No interpretation recorded."
60
+ safe_step: str = "not_recorded"
61
+ next: str = ""
62
+ chat_reply: str = ""
63
+ timestamp: str = ""
64
+
65
+
66
+ RunSummaryPayload = SummaryPayload
67
+
68
+
69
+ def _payload_from_mapping(data: dict[str, Any]) -> SummaryPayload:
70
+ normalized = {str(key).replace("-", "_"): value for key, value in data.items()}
71
+ if "next_safe_step" in normalized and "safe_step" not in normalized:
72
+ normalized["safe_step"] = normalized["next_safe_step"]
73
+ allowed = SummaryPayload.__dataclass_fields__.keys()
74
+ return SummaryPayload(**{key: str(normalized[key]) for key in allowed if key in normalized and normalized[key] is not None})
75
+
76
+
77
+ def _selected_chat_reply(payload: SummaryPayload) -> str:
78
+ return payload.chat_reply or payload.next or ""
79
+
80
+
81
+ def validate_summary_data(data: SummaryPayload | dict[str, Any]) -> list[str]:
82
+ payload = data if isinstance(data, SummaryPayload) else _payload_from_mapping(data)
83
+ findings: list[str] = []
84
+ terminal_log = payload.terminal_log.strip()
85
+ if not terminal_log or terminal_log == "NONE":
86
+ findings.append("missing field: terminal_log")
87
+ if payload.work not in VALID_RESULTS:
88
+ findings.append("invalid field: work")
89
+ if payload.overall not in VALID_RESULTS:
90
+ findings.append("invalid field: overall")
91
+ if payload.evidence not in VALID_EVIDENCE_RESULTS:
92
+ findings.append("invalid field: evidence")
93
+ if payload.remote_evidence not in VALID_REMOTE_EVIDENCE_RESULTS:
94
+ findings.append("invalid field: remote_evidence")
95
+ next_reply = _selected_chat_reply(payload)
96
+ if next_reply not in VALID_CHAT_REPLIES:
97
+ findings.append("invalid field: chat_reply")
98
+ if payload.overall == "PASS" and payload.work != "PASS":
99
+ findings.append("invalid pass: work is not PASS")
100
+ if payload.overall == "PASS" and payload.evidence in {"FAIL", "PARTIAL", "CHAT_ONLY"}:
101
+ findings.append("invalid pass: evidence is not complete")
102
+ if payload.overall == "PASS" and payload.remote_evidence not in {"PASS", "NOT_REQUIRED"}:
103
+ findings.append("invalid pass: remote evidence is not complete")
104
+ if payload.overall == "PASS" and next_reply != "d":
105
+ findings.append("invalid pass: chat_reply must be d")
106
+ if payload.overall in {"FAIL", "HARD-FAIL"} and next_reply == "d":
107
+ findings.append("invalid failure: chat_reply must not be d")
108
+ if payload.overall == "PENDING" and next_reply in {"d", "f"}:
109
+ findings.append("invalid pending: chat_reply must be w, paste-output, or stop")
110
+ if payload.remote_evidence == "PASS" and not payload.terminal_log_remote.startswith("docs/reports/terminal/"):
111
+ findings.append("invalid remote evidence: missing remote terminal log")
112
+ return findings
113
+
114
+
115
+ def validate_rendered_summary_text(text: str) -> list[str]:
116
+ findings: list[str] = []
117
+ for token in LEGACY_SUMMARY_TOKENS:
118
+ if token in text:
119
+ findings.append(f"legacy summary token: {token}")
120
+ for token in REQUIRED_RENDERED_TOKENS:
121
+ if token not in text:
122
+ findings.append(f"missing summary token: {token.strip()}")
123
+ summary_headers = [line for line in text.splitlines() if line.startswith("SUMMARY COMM-")]
124
+ if len(summary_headers) != 1:
125
+ findings.append(f"invalid summary count: {len(summary_headers)}")
126
+ result_values = [line.split(":", 1)[1].strip() for line in text.splitlines() if line.strip().startswith("OVERALL:")]
127
+ marker_values = [line.replace("### RESULT:", "").replace("###", "").strip() for line in text.splitlines() if line.startswith("### RESULT:")]
128
+ if len(marker_values) != 1:
129
+ findings.append(f"invalid result marker count: {len(marker_values)}")
130
+ if result_values and marker_values and result_values[-1] != marker_values[-1]:
131
+ findings.append("contradictory summary marker")
132
+ for bad in (" REMOTE_EVIDENCE: NONE", " REMOTE_EVIDENCE: CHAT_ONLY", " REMOTE_EVIDENCE: PENDING"):
133
+ if bad in text:
134
+ findings.append("invalid rendered remote evidence value")
135
+ return findings
136
+
137
+
138
+ def _header(payload: SummaryPayload) -> str:
139
+ if payload.comm_header.strip():
140
+ return payload.comm_header
141
+ timestamp = payload.timestamp or datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
142
+ return f"SUMMARY {payload.comm_id} | {timestamp}"
143
+
144
+
145
+ def render_summary(payload: SummaryPayload | dict[str, Any]) -> str:
146
+ if isinstance(payload, dict):
147
+ payload = _payload_from_mapping(payload)
148
+ findings = validate_summary_data(payload)
149
+ if findings:
150
+ raise ValueError("; ".join(findings))
151
+ slice_name = payload.slice or payload.slice_name or "UNKNOWN"
152
+ next_reply = _selected_chat_reply(payload)
153
+ lines = [
154
+ "================================================================",
155
+ _header(payload),
156
+ "",
157
+ "SLICE",
158
+ f" NAME: {slice_name}",
159
+ f" SCOPE: {payload.scope}",
160
+ f" BRANCH: {payload.branch}",
161
+ "",
162
+ "EXECUTION",
163
+ f" ORIGIN: {payload.origin}",
164
+ f" STATE_MODE: {payload.state_mode}",
165
+ f" MODE_CHECK: {payload.mode_check}",
166
+ " SWITCH_HINT: ./ns mode-write local|remote && ./ns mode-check local|remote",
167
+ "",
168
+ "RESULT",
169
+ f" WORK: {payload.work}",
170
+ f" EVIDENCE: {payload.evidence}",
171
+ f" OVERALL: {payload.overall}",
172
+ "",
173
+ "REMOTE",
174
+ f" REMOTE_EVIDENCE: {payload.remote_evidence}",
175
+ f" PR: {payload.pr}",
176
+ f" HEAD_SHA: {payload.head_sha}",
177
+ f" CI: {payload.ci}",
178
+ f" MERGE: {payload.merge}",
179
+ "",
180
+ "EVIDENCE FILES",
181
+ f" terminal_log: {payload.terminal_log}",
182
+ f" terminal_log_remote: {payload.terminal_log_remote}",
183
+ f" terminal_log_local: {payload.terminal_log_local}",
184
+ f" command_report: {payload.command_report}",
185
+ "",
186
+ "INTERPRETATION",
187
+ f" {payload.interpretation}",
188
+ "",
189
+ "NEXT",
190
+ f" SAFE_STEP: {payload.safe_step}",
191
+ f" CHAT_REPLY: {next_reply}",
192
+ "",
193
+ f"### RESULT: {payload.overall} ###",
194
+ "================================================================",
195
+ ]
196
+ rendered = "\n".join(lines)
197
+ rendered_findings = validate_rendered_summary_text(rendered)
198
+ if rendered_findings:
199
+ raise ValueError("; ".join(rendered_findings))
200
+ return rendered
201
+
202
+
203
+ render = render_summary
204
+
205
+
206
+ def _load_json_argument(value: str) -> dict[str, Any]:
207
+ candidate = Path(value)
208
+ if candidate.exists():
209
+ return json.loads(candidate.read_text(encoding="utf-8"))
210
+ return json.loads(value)
211
+
212
+
213
+ def build_parser() -> argparse.ArgumentParser:
214
+ parser = argparse.ArgumentParser(prog="run-summary-renderer")
215
+ parser.add_argument("--json", default=None)
216
+ for field in SummaryPayload.__dataclass_fields__:
217
+ parser.add_argument("--" + field.replace("_", "-"), dest=field, default=None)
218
+ return parser
219
+
220
+
221
+ def main(argv: list[str] | None = None) -> int:
222
+ parser = build_parser()
223
+ args = parser.parse_args(argv)
224
+ if args.json:
225
+ data = _load_json_argument(args.json)
226
+ else:
227
+ data = {field: getattr(args, field) for field in SummaryPayload.__dataclass_fields__ if getattr(args, field) is not None}
228
+ if data.get("terminal_log", "NONE") == "NONE":
229
+ data["terminal_log"] = data.get("terminal_log_remote") or data.get("terminal_log_local") or "NONE"
230
+ try:
231
+ print(render_summary(_payload_from_mapping(data)))
232
+ except ValueError as exc:
233
+ parser.error(str(exc))
234
+ return 0
235
+
236
+
237
+ if __name__ == "__main__":
238
+ raise SystemExit(main())
@@ -0,0 +1,68 @@
1
+ """Runtime validation primitives for generated governance artifacts.
2
+
3
+ This module intentionally starts small: it provides deterministic, auditable
4
+ validation results without performing repair or invoking any model.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+
14
+ class ValidationSeverity(str, Enum):
15
+ """Severity for runtime validation findings."""
16
+
17
+ ERROR = "error"
18
+ WARNING = "warning"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ValidationFinding:
23
+ """Single validation finding with stable machine-readable fields."""
24
+
25
+ code: str
26
+ message: str
27
+ severity: ValidationSeverity = ValidationSeverity.ERROR
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ValidationReport:
32
+ findings: tuple[ValidationFinding, ...] = ()
33
+
34
+ @property
35
+ def ok(self) -> bool:
36
+ return not any(finding.severity is ValidationSeverity.ERROR for finding in self.findings)
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ """Return a deterministic JSON-safe representation."""
40
+ return {
41
+ "ok": self.ok,
42
+ "findings": [
43
+ {
44
+ "severity": finding.severity.value,
45
+ "code": finding.code,
46
+ "message": finding.message,
47
+ }
48
+ for finding in self.findings
49
+ ],
50
+ }
51
+
52
+ def validate_required_sections(text: str, required_sections: tuple[str, ...]) -> ValidationReport:
53
+ """Validate that required section markers are present in text.
54
+
55
+ The check is intentionally literal and deterministic. This makes it suitable
56
+ as a first runtime skeleton for generated output contracts and governance
57
+ wrapper artifacts.
58
+ """
59
+ findings: list[ValidationFinding] = []
60
+ for section in required_sections:
61
+ if section not in text:
62
+ findings.append(
63
+ ValidationFinding(
64
+ code="missing_required_section",
65
+ message=f"Missing required section: {section}",
66
+ )
67
+ )
68
+ return ValidationReport(findings=tuple(findings))
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import py_compile
5
+ from pathlib import Path
6
+
7
+
8
+ class SafeReplaceError(RuntimeError):
9
+ pass
10
+
11
+
12
+ def validate_replacement(target: Path, replacement: Path) -> None:
13
+ if not replacement.exists():
14
+ raise SafeReplaceError(f"replacement does not exist: {replacement}")
15
+ if not replacement.is_file():
16
+ raise SafeReplaceError(f"replacement is not a file: {replacement}")
17
+ if replacement.is_symlink():
18
+ raise SafeReplaceError(f"replacement must not be a symlink: {replacement}")
19
+ if target.suffix == ".py":
20
+ try:
21
+ py_compile.compile(str(replacement), doraise=True)
22
+ except py_compile.PyCompileError as exc:
23
+ raise SafeReplaceError(f"replacement failed py_compile: {replacement}") from exc
24
+
25
+
26
+ def safe_replace(target: Path | str, replacement: Path | str) -> Path:
27
+ target_path = Path(target)
28
+ replacement_path = Path(replacement)
29
+ if target_path.is_symlink():
30
+ raise SafeReplaceError(f"target must not be a symlink: {target_path}")
31
+ if not target_path.parent.exists():
32
+ raise SafeReplaceError(f"target parent does not exist: {target_path.parent}")
33
+ validate_replacement(target_path, replacement_path)
34
+ os.replace(replacement_path, target_path)
35
+ return target_path
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import shlex
6
+
7
+
8
+ FORBIDDEN_FRAGMENTS = (
9
+ "<<",
10
+ "python -c",
11
+ "python3 -c",
12
+ "${",
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SafePatchScript:
18
+ """A small shell script that writes a file line by line with printf."""
19
+
20
+ target: Path
21
+ content: str
22
+
23
+ def render(self) -> str:
24
+ target = shlex.quote(str(self.target))
25
+ lines = ["#!/usr/bin/env sh", "set -eu", f": > {target}"]
26
+ for line in self.content.splitlines():
27
+ quoted = shlex.quote(line)
28
+ lines.append(f"printf '%s\\n' {quoted} >> {target}")
29
+ return "\n".join(lines) + "\n"
30
+
31
+
32
+ def render_safe_file_writer(target: str | Path, content: str) -> str:
33
+ """Render a quoting-safe shell script for deterministic file replacement."""
34
+
35
+ script = SafePatchScript(Path(target), content).render()
36
+ validate_safe_patch_script(script)
37
+ return script
38
+
39
+
40
+ def validate_safe_patch_script(script: str) -> None:
41
+ """Reject recurring unsafe patch-script patterns before they reach the terminal."""
42
+
43
+ for fragment in FORBIDDEN_FRAGMENTS:
44
+ if fragment in script:
45
+ raise ValueError(f"unsafe patch script fragment detected: {fragment}")
46
+ if "cat >" in script or "cat << " in script:
47
+ raise ValueError("unsafe cat-based patch script detected")