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,959 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import subprocess
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ from agentic_project_kit.safe_push import SafePushResult
12
+ from agentic_project_kit.safe_push import safe_push
13
+ from agentic_project_kit.safe_push import validate_branch_name
14
+ from agentic_project_kit.transfer_local_runner import TransferLocalRun, run_local_transfer
15
+ from agentic_project_kit.transfer_runner import DEFAULT_INBOX
16
+ from agentic_project_kit.transfer_safety_context import build_local_to_llm_payload
17
+ from agentic_project_kit.workspace import LEGACY_DEFAULTS, Workspace, load_workspace
18
+
19
+ REMOTE_NEXT_LATEST_JSON_NAME = "latest-remote-next-report.json"
20
+ REMOTE_NEXT_LATEST_LOG_NAME = "latest-remote-next-report.log"
21
+ PUBLISHED_LATEST_JSON_NAME = "latest-transfer-handoff-report.json"
22
+ PUBLISHED_LATEST_LOG_NAME = "latest-transfer-handoff-report.log"
23
+
24
+ REMOTE_NEXT_REPORT_DIR = Path(LEGACY_DEFAULTS.transfer_runs_root)
25
+ REMOTE_NEXT_LATEST_JSON = REMOTE_NEXT_REPORT_DIR / "latest-remote-next-report.json"
26
+ REMOTE_NEXT_LATEST_LOG = REMOTE_NEXT_REPORT_DIR / "latest-remote-next-report.log"
27
+ PUBLISHED_REPORT_DIR = Path(LEGACY_DEFAULTS.transfer_handoff_reports_root)
28
+ PUBLISHED_LATEST_JSON = PUBLISHED_REPORT_DIR / "latest-transfer-handoff-report.json"
29
+ PUBLISHED_LATEST_LOG = PUBLISHED_REPORT_DIR / "latest-transfer-handoff-report.log"
30
+ COMMAND_RUN_LATEST = Path(LEGACY_DEFAULTS.command_runs_root) / "LATEST_COMMAND_RUN.txt"
31
+ RULE_ACK_PATH = Path(".agentic/rule_ack/current.json")
32
+ EXECUTABLE_TRANSFER_ORDER_KIND = "llm_to_local_transfer_order"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class GitSnapshot:
37
+ current_branch: str
38
+ head: str
39
+ remote_tracking: str
40
+ status_short: str
41
+ staged_changes: tuple[str, ...] = field(default_factory=tuple)
42
+ unstaged_changes: tuple[str, ...] = field(default_factory=tuple)
43
+ untracked_files: tuple[str, ...] = field(default_factory=tuple)
44
+
45
+ @property
46
+ def clean(self) -> bool:
47
+ return not self.status_short.strip()
48
+
49
+ def as_json_data(self) -> dict[str, object]:
50
+ return {
51
+ "current_branch": self.current_branch,
52
+ "head": self.head,
53
+ "remote_tracking": self.remote_tracking,
54
+ "dirty_state": {
55
+ "clean": self.clean,
56
+ "status_short": self.status_short,
57
+ "staged_changes": list(self.staged_changes),
58
+ "unstaged_changes": list(self.unstaged_changes),
59
+ "untracked_files": list(self.untracked_files),
60
+ },
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class RuleAckSnapshot:
66
+ present: bool
67
+ confirmed: bool
68
+ path: str = str(RULE_ACK_PATH)
69
+ head: str = ""
70
+ blocking_reasons: tuple[str, ...] = field(default_factory=tuple)
71
+
72
+ def as_json_data(self) -> dict[str, object]:
73
+ return {
74
+ "path": self.path,
75
+ "present": self.present,
76
+ "confirmed": self.confirmed,
77
+ "head": self.head,
78
+ "blocking_reasons": list(self.blocking_reasons),
79
+ }
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class TransferRemoteNextRun:
84
+ branch: str | None
85
+ local_run: TransferLocalRun
86
+ head: str
87
+ result_status: str
88
+ returncode: int
89
+ next_action: str
90
+ report_path: str
91
+ published_report_path: str
92
+ reasons: tuple[str, ...] = field(default_factory=tuple)
93
+ preflight: dict[str, object] = field(default_factory=dict)
94
+ rule_ack: RuleAckSnapshot | None = None
95
+ blocked_message: str = ""
96
+ post_report_actions: dict[str, object] = field(default_factory=dict)
97
+
98
+ def as_json_data(self) -> dict[str, object]:
99
+ primary_state = _remote_next_primary_state(self.result_status, self.reasons)
100
+ command_id = self.local_run.transfer_id
101
+ return {
102
+ "schema_version": 1,
103
+ "artifact_type": "transfer_remote_next_execution_report",
104
+ "command_id": command_id,
105
+ "transfer_id": command_id,
106
+ "branch": self.branch,
107
+ "head": self.head,
108
+ "result_status": self.result_status,
109
+ "primary_state": primary_state,
110
+ "state": primary_state,
111
+ "returncode": self.returncode,
112
+ "next_action": self.next_action,
113
+ "next": self.next_action,
114
+ "chat_reply": "g",
115
+ "report_path": self.report_path,
116
+ "published_report_path": self.published_report_path,
117
+ "reasons": list(self.reasons),
118
+ "blocked_message": self.blocked_message,
119
+ "preflight": self.preflight,
120
+ "rule_ack": self.rule_ack.as_json_data() if self.rule_ack else None,
121
+ "post_report_actions": self.post_report_actions,
122
+ "local_run": self.local_run.as_json_data(),
123
+ }
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class _CommandResult:
128
+ argv: tuple[str, ...]
129
+ returncode: int
130
+ stdout: str
131
+ stderr: str
132
+
133
+ def as_json_data(self) -> dict[str, object]:
134
+ return {
135
+ "argv": list(self.argv),
136
+ "returncode": self.returncode,
137
+ "stdout": self.stdout,
138
+ "stderr": self.stderr,
139
+ }
140
+
141
+
142
+ def _remote_next_primary_state(result_status: str, reasons: tuple[str, ...]) -> str:
143
+ if "no_current_transfer_order" in reasons:
144
+ return "NEW_ORDER_REQUIRED"
145
+ if "order_consumed" in reasons:
146
+ return "ORDER_CONSUMED"
147
+ if any(
148
+ reason in reasons
149
+ for reason in (
150
+ "stale_transfer_order_status",
151
+ "stale_transfer_order_branch_mismatch",
152
+ "stale_order_missing_freshness_anchor",
153
+ "stale_transfer_order_head_mismatch",
154
+ )
155
+ ):
156
+ return "STALE_ORDER"
157
+ if result_status == "BLOCKED":
158
+ return "BLOCKED"
159
+ return result_status
160
+
161
+
162
+ def _remote_next_next_action(reasons: tuple[str, ...]) -> str:
163
+ if "no_current_transfer_order" in reasons:
164
+ return "Create or queue a fresh remote-next transfer order, then rerun the canonical command."
165
+ if "order_consumed" in reasons:
166
+ return "Queue a fresh remote-next transfer order; the previous order has already been consumed."
167
+ if any(
168
+ reason in reasons
169
+ for reason in (
170
+ "stale_transfer_order_status",
171
+ "stale_transfer_order_branch_mismatch",
172
+ "stale_order_missing_freshness_anchor",
173
+ "stale_transfer_order_head_mismatch",
174
+ )
175
+ ):
176
+ return "Supersede the stale remote-next transfer order with a fresh head-anchored order."
177
+ return "Inspect the published remote-next diagnostic report before retrying."
178
+
179
+
180
+ class RemoteNextBlocked(RuntimeError):
181
+ def __init__(
182
+ self,
183
+ message: str,
184
+ *,
185
+ reasons: tuple[str, ...],
186
+ branch: str | None = None,
187
+ preflight: dict[str, object] | None = None,
188
+ ):
189
+ super().__init__(message)
190
+ self.reasons = reasons
191
+ self.branch = branch
192
+ self.preflight = preflight or {}
193
+
194
+
195
+ def _command(argv: list[str], cwd: Path) -> _CommandResult:
196
+ try:
197
+ process = subprocess.run(
198
+ argv,
199
+ cwd=cwd,
200
+ text=True,
201
+ stdout=subprocess.PIPE,
202
+ stderr=subprocess.PIPE,
203
+ check=False,
204
+ )
205
+ except FileNotFoundError as exc:
206
+ return _CommandResult(tuple(argv), 127, "", str(exc))
207
+ return _CommandResult(tuple(argv), process.returncode, process.stdout.strip(), process.stderr.strip())
208
+
209
+
210
+ def _run(argv: list[str], cwd: Path) -> str:
211
+ result = _command(argv, cwd)
212
+ if result.returncode != 0:
213
+ raise RuntimeError(f"command failed: {' '.join(argv)}\nstdout={result.stdout}\nstderr={result.stderr}")
214
+ return result.stdout.strip()
215
+
216
+
217
+ def _status_path(line: str) -> str:
218
+ if len(line) <= 3:
219
+ return ""
220
+ if line.startswith("?? "):
221
+ return line[3:]
222
+ if len(line) > 2 and line[2] == " ":
223
+ return line[3:]
224
+ return line[2:].strip()
225
+
226
+
227
+ def _status_paths(status_short: str) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
228
+ staged: list[str] = []
229
+ unstaged: list[str] = []
230
+ untracked: list[str] = []
231
+ for line in status_short.splitlines():
232
+ if not line:
233
+ continue
234
+ status = line[:2]
235
+ path = _status_path(line)
236
+ if status == "??":
237
+ untracked.append(path)
238
+ continue
239
+ if status[0] != " ":
240
+ staged.append(path)
241
+ if status[1] != " ":
242
+ unstaged.append(path)
243
+ return tuple(staged), tuple(unstaged), tuple(untracked)
244
+
245
+
246
+ def _git_snapshot(project_root: Path) -> GitSnapshot:
247
+ status = _command(["git", "status", "--porcelain=v1"], project_root).stdout
248
+ staged, unstaged, untracked = _status_paths(status)
249
+ return GitSnapshot(
250
+ current_branch=_command(["git", "branch", "--show-current"], project_root).stdout or "UNKNOWN",
251
+ head=_command(["git", "rev-parse", "--short", "HEAD"], project_root).stdout or "UNKNOWN",
252
+ remote_tracking=_command(
253
+ ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], project_root
254
+ ).stdout,
255
+ status_short=status,
256
+ staged_changes=staged,
257
+ unstaged_changes=unstaged,
258
+ untracked_files=untracked,
259
+ )
260
+
261
+
262
+ def _full_head(project_root: Path) -> str:
263
+ return _command(["git", "rev-parse", "HEAD"], project_root).stdout.strip()
264
+
265
+
266
+ def _head_matches(expected: str, *, short_head: str, full_head: str) -> bool:
267
+ value = expected.strip()
268
+ if not value:
269
+ return True
270
+ return short_head.startswith(value) or full_head.startswith(value) or value.startswith(short_head)
271
+
272
+
273
+ def _rule_ack_snapshot(project_root: Path, *, repo_head: str) -> RuleAckSnapshot:
274
+ path = project_root / RULE_ACK_PATH
275
+ if not path.exists():
276
+ return RuleAckSnapshot(False, False, blocking_reasons=("missing_rule_ack",))
277
+ try:
278
+ data = json.loads(path.read_text(encoding="utf-8"))
279
+ except json.JSONDecodeError:
280
+ return RuleAckSnapshot(True, False, blocking_reasons=("invalid_rule_ack_json",))
281
+ if not isinstance(data, dict):
282
+ return RuleAckSnapshot(True, False, blocking_reasons=("invalid_rule_ack_shape",))
283
+ ack_head = str(data.get("repo_head") or data.get("head") or "")
284
+ reasons: list[str] = []
285
+ if ack_head and ack_head != repo_head:
286
+ reasons.append("stale_rule_ack")
287
+ return RuleAckSnapshot(True, not reasons, head=ack_head, blocking_reasons=tuple(reasons))
288
+
289
+
290
+ def _refresh_rule_ack(root: Path) -> _CommandResult:
291
+ result = _command(["./.venv/bin/agentic-kit", "rules", "acknowledge"], root)
292
+ if result.returncode == 127:
293
+ return _command(["agentic-kit", "rules", "acknowledge"], root)
294
+ return result
295
+
296
+
297
+ def _current_transfer_report_path(root: Path) -> str:
298
+ workspace = load_workspace(root)
299
+ try:
300
+ data = yaml.safe_load((root / DEFAULT_INBOX).read_text(encoding="utf-8")) or {}
301
+ except FileNotFoundError:
302
+ return ""
303
+ if not isinstance(data, dict):
304
+ return ""
305
+ report_path = data.get("report_path")
306
+ if not isinstance(report_path, str):
307
+ return ""
308
+ value = report_path.strip()
309
+ if not value.startswith(workspace.path_text(workspace.command_runs_dir()) + "/"):
310
+ return ""
311
+ if ".." in Path(value).parts:
312
+ return ""
313
+ return value
314
+
315
+
316
+ def _remote_next_latest_json(workspace: Workspace) -> Path:
317
+ return workspace.transfer_run_file(REMOTE_NEXT_LATEST_JSON_NAME)
318
+
319
+
320
+ def _remote_next_latest_log(workspace: Workspace) -> Path:
321
+ return workspace.transfer_run_file(REMOTE_NEXT_LATEST_LOG_NAME)
322
+
323
+
324
+ def _published_latest_json(workspace: Workspace) -> Path:
325
+ return workspace.transfer_handoff_report_file(PUBLISHED_LATEST_JSON_NAME)
326
+
327
+
328
+ def _published_latest_log(workspace: Workspace) -> Path:
329
+ return workspace.transfer_handoff_report_file(PUBLISHED_LATEST_LOG_NAME)
330
+
331
+
332
+ def _is_owned_remote_next_report_path(path: str, *, workspace: Workspace, current_order_report: str = "") -> bool:
333
+ value = path.strip()
334
+ if value in {
335
+ workspace.path_text(_remote_next_latest_json(workspace)),
336
+ workspace.path_text(_remote_next_latest_log(workspace)),
337
+ workspace.path_text(_published_latest_json(workspace)),
338
+ workspace.path_text(_published_latest_log(workspace)),
339
+ workspace.path_text(workspace.latest_command_run_pointer()),
340
+ current_order_report,
341
+ }:
342
+ return True
343
+ transfer_runs_prefix = workspace.path_text(workspace.transfer_runs_dir()) + "/"
344
+ published_prefix = workspace.path_text(workspace.transfer_handoff_reports_dir()) + "/"
345
+ if value.startswith(transfer_runs_prefix) and value.endswith(("-remote-next.json", "-remote-next.log")):
346
+ return True
347
+ return value.startswith(published_prefix) and value.endswith(("-remote-next.json", "-remote-next.log"))
348
+
349
+
350
+ def _recover_owned_report_artifacts(root: Path) -> dict[str, object]:
351
+ snapshot = _git_snapshot(root)
352
+ workspace = load_workspace(root)
353
+ current_order_report = _current_transfer_report_path(root)
354
+ dirty_paths = tuple(dict.fromkeys((*snapshot.staged_changes, *snapshot.unstaged_changes, *snapshot.untracked_files)))
355
+ owned = tuple(
356
+ path
357
+ for path in dirty_paths
358
+ if _is_owned_remote_next_report_path(path, workspace=workspace, current_order_report=current_order_report)
359
+ )
360
+ foreign = tuple(path for path in dirty_paths if path not in owned)
361
+ actions: list[dict[str, object]] = []
362
+ result: dict[str, object] = {
363
+ "schema_version": 1,
364
+ "attempted": bool(owned),
365
+ "current_order_report": current_order_report,
366
+ "owned_report_artifacts": list(owned),
367
+ "foreign_dirty_paths": list(foreign),
368
+ "actions": actions,
369
+ }
370
+ if foreign or not owned:
371
+ return result
372
+ tracked_owned = tuple(path for path in owned if path not in snapshot.untracked_files)
373
+ untracked_owned = tuple(path for path in owned if path in snapshot.untracked_files)
374
+ if tracked_owned:
375
+ restore = _command(["git", "restore", "--staged", "--worktree", "--", *tracked_owned], root)
376
+ actions.append({"name": "restore_tracked_owned_report_artifacts", **restore.as_json_data()})
377
+ if restore.returncode != 0:
378
+ result["blocked_reason"] = "restore_owned_report_artifacts_failed"
379
+ return result
380
+ for relative in untracked_owned:
381
+ target = root / relative
382
+ try:
383
+ target.unlink(missing_ok=True)
384
+ actions.append({"name": "remove_untracked_owned_report_artifact", "path": relative, "returncode": 0})
385
+ except OSError as exc:
386
+ actions.append({"name": "remove_untracked_owned_report_artifact", "path": relative, "returncode": 1, "stderr": str(exc)})
387
+ result["blocked_reason"] = "remove_owned_report_artifacts_failed"
388
+ return result
389
+ result["final_status_short"] = _command(["git", "status", "--porcelain=v1"], root).stdout
390
+ return result
391
+
392
+
393
+ def _ensure_clean(project_root: Path, *, branch: str | None) -> None:
394
+ snapshot = _git_snapshot(project_root)
395
+ if snapshot.status_short:
396
+ raise RemoteNextBlocked(
397
+ f"worktree must be clean before transfer remote-next:\n{snapshot.status_short}",
398
+ reasons=("dirty_worktree",),
399
+ branch=branch,
400
+ )
401
+
402
+
403
+ def _validate_branch_name(branch: str) -> str:
404
+ return validate_branch_name(branch)
405
+
406
+
407
+ def _safe_push_runner(argv: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
408
+ result = _command(argv, cwd)
409
+ return subprocess.CompletedProcess(list(result.argv), result.returncode, result.stdout, result.stderr)
410
+
411
+
412
+ def _safe_push(
413
+ root: Path,
414
+ *,
415
+ branch: str | None,
416
+ purpose: str,
417
+ expected_current_branch: str | None = None,
418
+ dry_run: bool = False,
419
+ ) -> SafePushResult:
420
+ return safe_push(
421
+ root,
422
+ target_branch=branch,
423
+ purpose=purpose,
424
+ expected_current_branch=expected_current_branch,
425
+ dry_run=dry_run,
426
+ run=_safe_push_runner,
427
+ )
428
+
429
+
430
+ def _ensure_safe_publish_target(root: Path, *, branch: str) -> dict[str, object]:
431
+ result = _safe_push(
432
+ root,
433
+ branch=branch,
434
+ purpose="transfer remote-next publish target validation",
435
+ expected_current_branch="",
436
+ dry_run=True,
437
+ )
438
+ if not result.ok:
439
+ raise RemoteNextBlocked(
440
+ "remote-next transfer publish target is not safe: " + "; ".join(result.reasons),
441
+ reasons=("unsafe_remote_next_publish_branch", *result.reasons),
442
+ branch=branch,
443
+ preflight={"safe_push_target": result.as_json_data()},
444
+ )
445
+ return result.as_json_data()
446
+
447
+
448
+ def _read_transfer_order_data(path: Path) -> dict[str, Any]:
449
+ try:
450
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
451
+ except FileNotFoundError as exc:
452
+ raise FileNotFoundError(f"transfer order not found: {path}") from exc
453
+ if not isinstance(data, dict):
454
+ raise ValueError(f"transfer order must be a mapping: {path}")
455
+ return data
456
+
457
+
458
+ def _order_identity(data: dict[str, Any]) -> dict[str, object]:
459
+ return {
460
+ "schema_version": 1,
461
+ "kind": data.get("kind", ""),
462
+ "id": data.get("id", ""),
463
+ "status": data.get("status", ""),
464
+ "branch": data.get("branch", ""),
465
+ "expected_current_branch": data.get("expected_current_branch", ""),
466
+ "expected_current_head": data.get("expected_current_head", ""),
467
+ "expected_head": data.get("expected_head", ""),
468
+ "created_for_head": data.get("created_for_head", ""),
469
+ }
470
+
471
+
472
+ def _freshness_anchor(data: dict[str, Any]) -> str:
473
+ for key in ("expected_current_head", "expected_head", "created_for_head"):
474
+ value = data.get(key)
475
+ if isinstance(value, str) and value.strip():
476
+ return value.strip()
477
+ return ""
478
+
479
+
480
+ def _block_inactive_transfer_order_before_branch_resolution(root: Path, *, branch: str | None = None) -> None:
481
+ data = _read_transfer_order_data(root / DEFAULT_INBOX)
482
+ status = str(data.get("status") or "active").strip().lower()
483
+ if status in {"inactive", "none", "no-current-order", "no_current_order", "consumed", "superseded", "stale"}:
484
+ _ensure_transfer_order_is_fresh(root, branch=branch)
485
+
486
+
487
+ def _ensure_transfer_order_is_fresh(root: Path, *, branch: str | None = None) -> dict[str, object]:
488
+ data = _read_transfer_order_data(root / DEFAULT_INBOX)
489
+ snapshot = _git_snapshot(root)
490
+ full_head = _full_head(root)
491
+ order = _order_identity(data)
492
+ preflight = {
493
+ "schema_version": 1,
494
+ "transfer_order_guard": {
495
+ **order,
496
+ "current_branch": snapshot.current_branch,
497
+ "current_head": snapshot.head,
498
+ "current_full_head": full_head,
499
+ },
500
+ }
501
+ status = str(data.get("status") or "active").strip().lower()
502
+ if status in {"inactive", "none", "no-current-order", "no_current_order"}:
503
+ raise RemoteNextBlocked(
504
+ f"remote-next transfer order is not active: {status}",
505
+ reasons=("no_current_transfer_order",),
506
+ branch=branch,
507
+ preflight=preflight,
508
+ )
509
+ if status == "consumed":
510
+ raise RemoteNextBlocked(
511
+ "remote-next transfer order has already been consumed",
512
+ reasons=("order_consumed",),
513
+ branch=branch,
514
+ preflight=preflight,
515
+ )
516
+ if status in {"superseded", "stale"}:
517
+ raise RemoteNextBlocked(
518
+ f"remote-next transfer order is stale: {status}",
519
+ reasons=("stale_transfer_order_status",),
520
+ branch=branch,
521
+ preflight=preflight,
522
+ )
523
+ if status not in {"active", "pending", "ready"}:
524
+ raise RemoteNextBlocked(
525
+ f"remote-next transfer order has unsupported status: {status}",
526
+ reasons=("invalid_transfer_order_status",),
527
+ branch=branch,
528
+ preflight=preflight,
529
+ )
530
+ kind = str(data.get("kind") or "").strip()
531
+ if kind != EXECUTABLE_TRANSFER_ORDER_KIND:
532
+ raise RemoteNextBlocked(
533
+ "remote-next transfer order kind is not executable",
534
+ reasons=("invalid_transfer_order_kind",),
535
+ branch=branch,
536
+ preflight=preflight,
537
+ )
538
+ expected_branch = data.get("expected_current_branch")
539
+ if isinstance(expected_branch, str) and expected_branch.strip() and expected_branch.strip() != snapshot.current_branch:
540
+ raise RemoteNextBlocked(
541
+ "remote-next transfer order expected a different current branch",
542
+ reasons=("stale_transfer_order_branch_mismatch",),
543
+ branch=branch,
544
+ preflight=preflight,
545
+ )
546
+ anchor = _freshness_anchor(data)
547
+ if not anchor:
548
+ raise RemoteNextBlocked(
549
+ "remote-next transfer order is missing a freshness head anchor",
550
+ reasons=("stale_order_missing_freshness_anchor",),
551
+ branch=branch,
552
+ preflight=preflight,
553
+ )
554
+ if not _head_matches(anchor, short_head=snapshot.head, full_head=full_head):
555
+ raise RemoteNextBlocked(
556
+ "remote-next transfer order freshness anchor does not match current HEAD",
557
+ reasons=("stale_transfer_order_head_mismatch",),
558
+ branch=branch,
559
+ preflight=preflight,
560
+ )
561
+ return preflight["transfer_order_guard"]
562
+
563
+
564
+ def resolve_remote_next_branch(project_root: Path, branch: str | None = None, path: Path = DEFAULT_INBOX) -> str:
565
+ if branch is not None and branch.strip():
566
+ return _validate_branch_name(branch)
567
+ root = project_root.resolve()
568
+ order_path = path if path.is_absolute() else root / path
569
+ data = _read_transfer_order_data(order_path)
570
+ order_branch = data.get("branch")
571
+ if not isinstance(order_branch, str) or not order_branch.strip():
572
+ raise ValueError(f"transfer order must define a non-empty branch: {path}")
573
+ return _validate_branch_name(order_branch)
574
+
575
+
576
+ def _sync_current_branch_before_order(root: Path) -> dict[str, object]:
577
+ snapshot = _git_snapshot(root)
578
+ steps: list[dict[str, object]] = []
579
+ result: dict[str, object] = {
580
+ "schema_version": 1,
581
+ "attempted": False,
582
+ "current_branch": snapshot.current_branch,
583
+ "head": snapshot.head,
584
+ "steps": steps,
585
+ }
586
+ if not snapshot.clean:
587
+ result["skipped_reason"] = "dirty_worktree_before_order_sync"
588
+ return result
589
+ if not snapshot.current_branch or snapshot.current_branch == "UNKNOWN":
590
+ result["skipped_reason"] = "unknown_current_branch"
591
+ return result
592
+ result["attempted"] = True
593
+ fetch = _command(["git", "fetch", "origin", snapshot.current_branch], root)
594
+ steps.append({"name": "git_fetch_current_branch", **fetch.as_json_data()})
595
+ if fetch.returncode != 0:
596
+ result["blocked_reason"] = "fetch_current_branch_failed"
597
+ return result
598
+ pull = _command(["git", "pull", "--ff-only", "origin", snapshot.current_branch], root)
599
+ steps.append({"name": "git_pull_current_branch", **pull.as_json_data()})
600
+ if pull.returncode != 0:
601
+ result["blocked_reason"] = "pull_current_branch_failed"
602
+ result["final_head"] = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
603
+ return result
604
+
605
+
606
+ def _blocked_local_run(message: str, reasons: tuple[str, ...], snapshot: GitSnapshot) -> TransferLocalRun:
607
+ next_action = _remote_next_next_action(reasons)
608
+ state = {
609
+ "schema_version": 1,
610
+ "primary_state": _remote_next_primary_state("BLOCKED", reasons),
611
+ "reasons": list(reasons),
612
+ "next_action": next_action,
613
+ "branch": snapshot.current_branch,
614
+ "head": snapshot.head,
615
+ "dirty_state": snapshot.as_json_data()["dirty_state"],
616
+ }
617
+ inspect = {"schema_version": 1, "result_status": "BLOCKED", "returncode": 2, "message": message, "reasons": list(reasons)}
618
+ return TransferLocalRun(
619
+ schema_version=1,
620
+ transfer_id="remote-next-blocked",
621
+ inspect=inspect,
622
+ apply=None,
623
+ state=state,
624
+ result_status="BLOCKED",
625
+ returncode=2,
626
+ next_action=_remote_next_next_action(reasons),
627
+ )
628
+
629
+
630
+ def _remote_next_payload(root: Path, data: dict[str, object]) -> dict[str, object]:
631
+ payload = build_local_to_llm_payload(root, data, kind="local_to_llm_remote_next_report")
632
+ payload["remote_next_report"] = data
633
+ return payload
634
+
635
+
636
+ def _report_paths(workspace: Workspace) -> tuple[str, ...]:
637
+ return (
638
+ workspace.path_text(_remote_next_latest_json(workspace)),
639
+ workspace.path_text(_remote_next_latest_log(workspace)),
640
+ workspace.path_text(_published_latest_json(workspace)),
641
+ workspace.path_text(_published_latest_log(workspace)),
642
+ )
643
+
644
+
645
+ def _build_log_text(data: dict[str, object], payload_text: str) -> str:
646
+ return "\n".join(
647
+ (
648
+ "TRANSFER_REMOTE_NEXT_REPORT",
649
+ f"COMMAND_ID={data.get('command_id') or data.get('transfer_id') or ''}",
650
+ f"STATE={data.get('primary_state') or data['result_status']}",
651
+ f"RESULT_STATUS={data['result_status']}",
652
+ f"RETURNCODE={data['returncode']}",
653
+ f"BRANCH={data.get('branch') or ''}",
654
+ f"HEAD={data.get('head') or ''}",
655
+ f"REMOTE_REPORT={data['published_report_path']}",
656
+ "CHAT_REPLY=g",
657
+ f"NEXT={data['next_action']}",
658
+ "",
659
+ "### JSON RESULT ###",
660
+ payload_text.rstrip(),
661
+ "",
662
+ )
663
+ )
664
+
665
+
666
+ def _write_payload_files(root: Path, paths_and_content: tuple[tuple[Path, str], ...]) -> None:
667
+ for path, content in paths_and_content:
668
+ target = path if path.is_absolute() else root / path
669
+ target.parent.mkdir(parents=True, exist_ok=True)
670
+ target.write_text(content, encoding="utf-8")
671
+
672
+
673
+ def _write_report_payloads(root: Path, data: dict[str, object]) -> None:
674
+ workspace = load_workspace(root)
675
+ payload = _remote_next_payload(root, data)
676
+ payload_text = json.dumps(payload, indent=2, sort_keys=True) + "\n"
677
+ log_text = _build_log_text(data, payload_text)
678
+ _write_payload_files(
679
+ root,
680
+ (
681
+ (_remote_next_latest_json(workspace), payload_text),
682
+ (_remote_next_latest_log(workspace), log_text),
683
+ (_published_latest_json(workspace), payload_text),
684
+ (_published_latest_log(workspace), log_text),
685
+ ),
686
+ )
687
+
688
+
689
+ def _attempt_report_commit_ack_push(root: Path, *, branch: str | None, paths: tuple[str, ...]) -> dict[str, object]:
690
+ steps: list[dict[str, object]] = []
691
+ actions: dict[str, object] = {
692
+ "schema_version": 1,
693
+ "attempted": True,
694
+ "branch": branch,
695
+ "paths": list(paths),
696
+ "steps": steps,
697
+ "committed": False,
698
+ "rule_ack_refreshed_after_commit": False,
699
+ "pushed": False,
700
+ }
701
+ preflight = _safe_push(
702
+ root,
703
+ branch=branch,
704
+ purpose="publish remote-next transfer report",
705
+ dry_run=True,
706
+ )
707
+ steps.append({"name": "safe_push_preflight_report_commit", **preflight.as_json_data()})
708
+ if not preflight.ok:
709
+ actions["blocked_reason"] = "safe_push_preflight_failed"
710
+ actions["safe_push_reasons"] = list(preflight.reasons)
711
+ actions["final_head"] = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
712
+ actions["final_status_short"] = _command(["git", "status", "--porcelain=v1"], root).stdout
713
+ return actions
714
+ add = _command(["git", "add", "-f", *paths], root)
715
+ steps.append({"name": "git_add_force_report_paths", **add.as_json_data()})
716
+ if add.returncode != 0:
717
+ actions["blocked_reason"] = "git_add_failed"
718
+ return actions
719
+ staged = _command(["git", "diff", "--cached", "--quiet"], root)
720
+ if staged.returncode == 0:
721
+ actions["skipped_reason"] = "no_report_changes_to_commit"
722
+ else:
723
+ commit = _command(["git", "commit", "-m", "Publish remote-next transfer report"], root)
724
+ steps.append({"name": "git_commit_report_paths", **commit.as_json_data()})
725
+ if commit.returncode != 0:
726
+ actions["blocked_reason"] = "git_commit_failed"
727
+ return actions
728
+ actions["committed"] = True
729
+ actions["commit_head"] = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
730
+ ack = _refresh_rule_ack(root)
731
+ steps.append({"name": "rules_acknowledge_after_report_commit", **ack.as_json_data()})
732
+ actions["rule_ack_refreshed_after_commit"] = ack.returncode == 0
733
+ post_ack_head = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
734
+ actions["rule_ack_after_report_commit"] = _rule_ack_snapshot(root, repo_head=post_ack_head).as_json_data()
735
+ push = _safe_push(root, branch=branch, purpose="publish remote-next transfer report")
736
+ steps.append({"name": "safe_push_report_commit", **push.as_json_data()})
737
+ actions["pushed"] = push.pushed
738
+ if not push.ok:
739
+ actions["blocked_reason"] = "git_push_failed"
740
+ actions["final_head"] = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
741
+ actions["final_status_short"] = _command(["git", "status", "--porcelain=v1"], root).stdout
742
+ return actions
743
+
744
+
745
+ def _attempt_final_report_projection_commit_push(
746
+ root: Path,
747
+ *,
748
+ branch: str | None,
749
+ paths: tuple[str, ...],
750
+ ) -> dict[str, object]:
751
+ steps: list[dict[str, object]] = []
752
+ actions: dict[str, object] = {
753
+ "schema_version": 1,
754
+ "attempted": True,
755
+ "branch": branch,
756
+ "paths": list(paths),
757
+ "steps": steps,
758
+ "committed": False,
759
+ "pushed": False,
760
+ }
761
+ preflight = _safe_push(
762
+ root,
763
+ branch=branch,
764
+ purpose="publish final remote-next report projection",
765
+ dry_run=True,
766
+ )
767
+ steps.append({"name": "safe_push_preflight_final_report_projection", **preflight.as_json_data()})
768
+ if not preflight.ok:
769
+ actions["blocked_reason"] = "safe_push_preflight_failed"
770
+ actions["safe_push_reasons"] = list(preflight.reasons)
771
+ actions["final_head"] = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
772
+ actions["final_status_short"] = _command(["git", "status", "--porcelain=v1"], root).stdout
773
+ return actions
774
+ add = _command(["git", "add", "-f", *paths], root)
775
+ steps.append({"name": "git_add_force_final_report_projection", **add.as_json_data()})
776
+ if add.returncode != 0:
777
+ actions["blocked_reason"] = "git_add_failed"
778
+ return actions
779
+
780
+ staged = _command(["git", "diff", "--cached", "--quiet"], root)
781
+ if staged.returncode == 0:
782
+ actions["skipped_reason"] = "no_final_projection_changes_to_commit"
783
+ else:
784
+ commit = _command(["git", "commit", "-m", "Publish final remote-next report projection"], root)
785
+ steps.append({"name": "git_commit_final_report_projection", **commit.as_json_data()})
786
+ if commit.returncode != 0:
787
+ actions["blocked_reason"] = "git_commit_failed"
788
+ return actions
789
+ actions["committed"] = True
790
+ actions["commit_head"] = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
791
+
792
+ push = _safe_push(root, branch=branch, purpose="publish final remote-next report projection")
793
+ steps.append({"name": "safe_push_final_report_projection", **push.as_json_data()})
794
+ actions["pushed"] = push.pushed
795
+ if not push.ok:
796
+ actions["blocked_reason"] = "git_push_failed"
797
+ actions["final_head"] = _command(["git", "rev-parse", "--short", "HEAD"], root).stdout
798
+ actions["final_status_short"] = _command(["git", "status", "--porcelain=v1"], root).stdout
799
+ return actions
800
+
801
+
802
+ def _write_remote_next_report(root: Path, result: TransferRemoteNextRun) -> TransferRemoteNextRun:
803
+ workspace = load_workspace(root)
804
+ latest_report = workspace.path_text(_remote_next_latest_json(workspace))
805
+ latest_published_report = workspace.path_text(_published_latest_json(workspace))
806
+ paths = _report_paths(workspace)
807
+ provisional_data = result.as_json_data()
808
+ provisional_data.update(
809
+ {
810
+ "report_path": latest_report,
811
+ "published_report_path": latest_published_report,
812
+ "latest_report_path": latest_report,
813
+ "latest_published_report_path": latest_published_report,
814
+ "post_report_actions": {"schema_version": 1, "attempted": True, "status": "pending_until_report_files_are_written"},
815
+ }
816
+ )
817
+ _write_report_payloads(root, provisional_data)
818
+ post_report_actions = _attempt_report_commit_ack_push(root, branch=result.branch, paths=paths)
819
+
820
+ final_result = TransferRemoteNextRun(
821
+ branch=result.branch,
822
+ local_run=result.local_run,
823
+ head=result.head,
824
+ result_status=result.result_status,
825
+ returncode=result.returncode,
826
+ next_action=result.next_action,
827
+ report_path=latest_report,
828
+ published_report_path=latest_published_report,
829
+ reasons=result.reasons,
830
+ preflight=result.preflight,
831
+ rule_ack=result.rule_ack,
832
+ blocked_message=result.blocked_message,
833
+ post_report_actions=post_report_actions,
834
+ )
835
+
836
+ final_data = final_result.as_json_data()
837
+ final_data.update(
838
+ {
839
+ "report_path": latest_report,
840
+ "published_report_path": latest_published_report,
841
+ "latest_report_path": latest_report,
842
+ "latest_published_report_path": latest_published_report,
843
+ }
844
+ )
845
+ _write_report_payloads(root, final_data)
846
+ final_projection = _attempt_final_report_projection_commit_push(root, branch=result.branch, paths=paths)
847
+ post_report_actions["final_projection"] = final_projection
848
+ if final_projection.get("commit_head"):
849
+ post_report_actions["final_projection_commit_head"] = final_projection["commit_head"]
850
+ post_report_actions["final_head"] = final_projection.get("final_head", final_projection["commit_head"])
851
+ if final_projection.get("blocked_reason"):
852
+ post_report_actions["final_projection_blocked_reason"] = final_projection["blocked_reason"]
853
+ post_report_actions["pushed"] = False
854
+ elif final_projection.get("pushed") is False:
855
+ post_report_actions["pushed"] = False
856
+
857
+ return TransferRemoteNextRun(
858
+ branch=result.branch,
859
+ local_run=result.local_run,
860
+ head=result.head,
861
+ result_status=result.result_status,
862
+ returncode=result.returncode,
863
+ next_action=result.next_action,
864
+ report_path=latest_report,
865
+ published_report_path=latest_published_report,
866
+ reasons=result.reasons,
867
+ preflight=result.preflight,
868
+ rule_ack=result.rule_ack,
869
+ blocked_message=result.blocked_message,
870
+ post_report_actions=post_report_actions,
871
+ )
872
+
873
+
874
+ def _blocked_result(root: Path, exc: Exception, *, branch: str | None = None) -> TransferRemoteNextRun:
875
+ snapshot = _git_snapshot(root)
876
+ ack = _rule_ack_snapshot(root, repo_head=snapshot.head)
877
+ extra_preflight: dict[str, object] = {}
878
+ if isinstance(exc, RemoteNextBlocked):
879
+ reasons = exc.reasons
880
+ branch = exc.branch or branch
881
+ extra_preflight = exc.preflight
882
+ elif isinstance(exc, FileNotFoundError):
883
+ reasons = ("missing_transfer_order",)
884
+ elif isinstance(exc, ValueError):
885
+ reasons = ("invalid_transfer_order",)
886
+ else:
887
+ reasons = ("remote_next_blocked",)
888
+ preflight = snapshot.as_json_data()
889
+ preflight.update(extra_preflight)
890
+ return _write_remote_next_report(
891
+ root,
892
+ TransferRemoteNextRun(
893
+ branch=branch,
894
+ local_run=_blocked_local_run(str(exc), reasons, snapshot),
895
+ head=snapshot.head,
896
+ result_status="BLOCKED",
897
+ returncode=2,
898
+ next_action=_remote_next_next_action(reasons),
899
+ report_path="",
900
+ published_report_path="",
901
+ reasons=reasons,
902
+ preflight=preflight,
903
+ rule_ack=ack,
904
+ blocked_message=str(exc),
905
+ ),
906
+ )
907
+
908
+
909
+ def run_remote_next_transfer(project_root: Path, branch: str | None = None) -> TransferRemoteNextRun:
910
+ root = project_root.resolve()
911
+ try:
912
+ cleanup = _recover_owned_report_artifacts(root)
913
+ order_sync = _sync_current_branch_before_order(root)
914
+ if branch is None:
915
+ _block_inactive_transfer_order_before_branch_resolution(root)
916
+ safe_branch = resolve_remote_next_branch(root, branch)
917
+ if branch is None:
918
+ order_guard = _ensure_transfer_order_is_fresh(root, branch=safe_branch)
919
+ else:
920
+ order_guard = {
921
+ "schema_version": 1,
922
+ "skipped_reason": "explicit_branch_argument",
923
+ "branch": safe_branch,
924
+ }
925
+ _ensure_clean(root, branch=safe_branch)
926
+ publish_target_guard = _ensure_safe_publish_target(root, branch=safe_branch)
927
+ _run(["git", "fetch", "origin", safe_branch], root)
928
+ _run(["git", "switch", safe_branch], root)
929
+ _run(["git", "pull", "--ff-only", "origin", safe_branch], root)
930
+ head = _run(["git", "rev-parse", "--short", "HEAD"], root)
931
+ ack_before = _rule_ack_snapshot(root, repo_head=head)
932
+ ack_refresh: dict[str, object] | None = None
933
+ if not ack_before.confirmed:
934
+ ack_result = _refresh_rule_ack(root)
935
+ ack_refresh = ack_result.as_json_data()
936
+ preflight = _git_snapshot(root).as_json_data()
937
+ preflight["owned_report_artifact_recovery"] = cleanup
938
+ preflight["order_sync"] = order_sync
939
+ preflight["transfer_order_guard"] = order_guard
940
+ preflight["safe_push_target"] = publish_target_guard
941
+ if ack_refresh is not None:
942
+ preflight["rule_ack_refresh_before_transfer"] = ack_refresh
943
+ local_run = run_local_transfer(root)
944
+ head = _run(["git", "rev-parse", "--short", "HEAD"], root)
945
+ result = TransferRemoteNextRun(
946
+ branch=safe_branch,
947
+ local_run=local_run,
948
+ head=head,
949
+ result_status=local_run.result_status,
950
+ returncode=local_run.returncode,
951
+ next_action=local_run.next_action,
952
+ report_path="",
953
+ published_report_path="",
954
+ preflight=preflight,
955
+ rule_ack=_rule_ack_snapshot(root, repo_head=head),
956
+ )
957
+ return _write_remote_next_report(root, result)
958
+ except (RuntimeError, ValueError, FileNotFoundError) as exc:
959
+ return _blocked_result(root, exc, branch=branch)