@hunter-harness/workflow-harness 0.4.0 → 0.4.2

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 (386) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-execute/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +29 -6
  16. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +1 -1
  17. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-execute/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  31. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +29 -6
  33. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +1 -1
  34. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-execute/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  41. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/scripts/harness_archive.py +29 -6
  50. package/harness/bundles/general/codex/scripts/harness_deploy.py +1 -1
  51. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +398 -0
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-execute/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  63. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  65. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  66. package/harness/bundles/general/cursor/scripts/harness_archive.py +29 -6
  67. package/harness/bundles/general/cursor/scripts/harness_deploy.py +1 -1
  68. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +398 -0
  69. package/harness/bundles/general/pi/.harness-build.json +6 -0
  70. package/harness/bundles/general/pi/CONTEXT.md +98 -0
  71. package/harness/bundles/general/pi/README.md +478 -0
  72. package/harness/bundles/general/pi/contracts/build-profile-v3.schema.json +302 -0
  73. package/harness/bundles/general/pi/contracts/ci-metrics.schema.json +36 -0
  74. package/harness/bundles/general/pi/contracts/doctor-capability.schema.json +43 -0
  75. package/harness/bundles/general/pi/contracts/external-platform-observation.schema.json +62 -0
  76. package/harness/bundles/general/pi/contracts/fixtures/managed-execution.json +341 -0
  77. package/harness/bundles/general/pi/contracts/process-identity.schema.json +132 -0
  78. package/harness/bundles/general/pi/contracts/run-session.schema.json +142 -0
  79. package/harness/bundles/general/pi/contracts/service-retirement-receipt.schema.json +85 -0
  80. package/harness/bundles/general/pi/contracts/service-session.schema.json +147 -0
  81. package/harness/bundles/general/pi/contracts/workflow-policy.json +574 -0
  82. package/harness/bundles/general/pi/harness-archive/SKILL.md +322 -0
  83. package/harness/bundles/general/pi/harness-archive/checklist.md +71 -0
  84. package/harness/bundles/general/pi/harness-archive/reference.md +150 -0
  85. package/harness/bundles/general/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  86. package/harness/bundles/general/pi/harness-archive/templates/summary-data-template.json +218 -0
  87. package/harness/bundles/general/pi/harness-codebase-map/SKILL.md +116 -0
  88. package/harness/bundles/general/pi/harness-codebase-map/checklist.md +126 -0
  89. package/harness/bundles/general/pi/harness-codebase-map/reference.md +364 -0
  90. package/harness/bundles/general/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  91. package/harness/bundles/general/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  92. package/harness/bundles/general/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  93. package/harness/bundles/general/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  94. package/harness/bundles/general/pi/harness-codebase-map/templates/STACK.md +55 -0
  95. package/harness/bundles/general/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  96. package/harness/bundles/general/pi/harness-codebase-map/templates/TESTING.md +64 -0
  97. package/harness/bundles/general/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  98. package/harness/bundles/general/pi/harness-codebase-map/templates/map-summary.md +64 -0
  99. package/harness/bundles/general/pi/harness-execute/SKILL.md +170 -0
  100. package/harness/bundles/general/pi/harness-knowledge-ingest/SKILL.md +103 -0
  101. package/harness/bundles/general/pi/harness-knowledge-query/SKILL.md +97 -0
  102. package/harness/bundles/general/pi/harness-plan/SKILL.md +157 -0
  103. package/harness/bundles/general/pi/harness-plan/checklist.md +243 -0
  104. package/harness/bundles/general/pi/harness-plan/protocols.md +133 -0
  105. package/harness/bundles/general/pi/harness-plan/reference.md +468 -0
  106. package/harness/bundles/general/pi/harness-pull/SKILL.md +55 -0
  107. package/harness/bundles/general/pi/harness-push/SKILL.md +83 -0
  108. package/harness/bundles/general/pi/harness-review/SKILL.md +166 -0
  109. package/harness/bundles/general/pi/harness-review/checklist.md +124 -0
  110. package/harness/bundles/general/pi/harness-review/protocols.md +69 -0
  111. package/harness/bundles/general/pi/harness-review/reference.md +114 -0
  112. package/harness/bundles/general/pi/harness-run/SKILL.md +188 -0
  113. package/harness/bundles/general/pi/harness-run/checklist.md +279 -0
  114. package/harness/bundles/general/pi/harness-run/protocols.md +141 -0
  115. package/harness/bundles/general/pi/harness-run/reference.md +886 -0
  116. package/harness/bundles/general/pi/harness-submit/SKILL.md +182 -0
  117. package/harness/bundles/general/pi/harness-submit/checklist.md +452 -0
  118. package/harness/bundles/general/pi/harness-submit/reference.md +152 -0
  119. package/harness/bundles/general/pi/harness-sync/SKILL.md +72 -0
  120. package/harness/bundles/general/pi/harness-sync/reference.md +176 -0
  121. package/harness/bundles/general/pi/harness-test/SKILL.md +250 -0
  122. package/harness/bundles/general/pi/harness-test/checklist.md +359 -0
  123. package/harness/bundles/general/pi/harness-test/pitfalls-java.md +14 -0
  124. package/harness/bundles/general/pi/harness-test/pitfalls.md +307 -0
  125. package/harness/bundles/general/pi/harness-test/reference.md +880 -0
  126. package/harness/bundles/general/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  127. package/harness/bundles/general/pi/protocols/archive-report-protocol.md +196 -0
  128. package/harness/bundles/general/pi/protocols/ci-layering-protocol.md +22 -0
  129. package/harness/bundles/general/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  130. package/harness/bundles/general/pi/protocols/execution-session-protocol.md +52 -0
  131. package/harness/bundles/general/pi/protocols/external-platform-protocol.md +27 -0
  132. package/harness/bundles/general/pi/protocols/ledger-protocol.md +365 -0
  133. package/harness/bundles/general/pi/protocols/memory-closeout-protocol.md +21 -0
  134. package/harness/bundles/general/pi/protocols/model-routing-protocol.md +22 -0
  135. package/harness/bundles/general/pi/protocols/powershell-protocol.md +238 -0
  136. package/harness/bundles/general/pi/protocols/registry-governance-protocol.md +31 -0
  137. package/harness/bundles/general/pi/protocols/report-pipeline-protocol.md +172 -0
  138. package/harness/bundles/general/pi/protocols/sensitive-info-protocol.md +110 -0
  139. package/harness/bundles/general/pi/protocols/state-layout-protocol.md +209 -0
  140. package/harness/bundles/general/pi/protocols/submit-protocol.md +78 -0
  141. package/harness/bundles/general/pi/protocols/verification-graph-protocol.md +32 -0
  142. package/harness/bundles/general/pi/scripts/harness_adoption_metrics.py +391 -0
  143. package/harness/bundles/general/pi/scripts/harness_apidoc.py +53 -0
  144. package/harness/bundles/general/pi/scripts/harness_archive.py +11345 -0
  145. package/harness/bundles/general/pi/scripts/harness_change.py +1626 -0
  146. package/harness/bundles/general/pi/scripts/harness_check_gate.py +113 -0
  147. package/harness/bundles/general/pi/scripts/harness_context.py +1774 -0
  148. package/harness/bundles/general/pi/scripts/harness_deploy.py +968 -0
  149. package/harness/bundles/general/pi/scripts/harness_efficiency.py +443 -0
  150. package/harness/bundles/general/pi/scripts/harness_environment.py +1690 -0
  151. package/harness/bundles/general/pi/scripts/harness_events.py +2234 -0
  152. package/harness/bundles/general/pi/scripts/harness_events_sync.py +1425 -0
  153. package/harness/bundles/general/pi/scripts/harness_execution_contracts.py +596 -0
  154. package/harness/bundles/general/pi/scripts/harness_fixback.py +1975 -0
  155. package/harness/bundles/general/pi/scripts/harness_gate.py +3816 -0
  156. package/harness/bundles/general/pi/scripts/harness_headless.py +37 -0
  157. package/harness/bundles/general/pi/scripts/harness_integration.py +2564 -0
  158. package/harness/bundles/general/pi/scripts/harness_knowledge_candidates.py +796 -0
  159. package/harness/bundles/general/pi/scripts/harness_ledger.py +3556 -0
  160. package/harness/bundles/general/pi/scripts/harness_migration_head.py +220 -0
  161. package/harness/bundles/general/pi/scripts/harness_orchestration.py +344 -0
  162. package/harness/bundles/general/pi/scripts/harness_paths.py +676 -0
  163. package/harness/bundles/general/pi/scripts/harness_phase.py +1726 -0
  164. package/harness/bundles/general/pi/scripts/harness_plan_aggregate.py +249 -0
  165. package/harness/bundles/general/pi/scripts/harness_plan_finalize.py +1016 -0
  166. package/harness/bundles/general/pi/scripts/harness_preflight.py +650 -0
  167. package/harness/bundles/general/pi/scripts/harness_process.py +1065 -0
  168. package/harness/bundles/general/pi/scripts/harness_profile.py +1817 -0
  169. package/harness/bundles/general/pi/scripts/harness_report_model.py +442 -0
  170. package/harness/bundles/general/pi/scripts/harness_retry.py +175 -0
  171. package/harness/bundles/general/pi/scripts/harness_review.py +631 -0
  172. package/harness/bundles/general/pi/scripts/harness_runtime.py +2243 -0
  173. package/harness/bundles/general/pi/scripts/harness_service.py +3355 -0
  174. package/harness/bundles/general/pi/scripts/harness_state.py +365 -0
  175. package/harness/bundles/general/pi/scripts/harness_sync.py +407 -0
  176. package/harness/bundles/general/pi/scripts/harness_test_cleanup.py +175 -0
  177. package/harness/bundles/general/pi/scripts/harness_test_guard.py +1899 -0
  178. package/harness/bundles/general/pi/scripts/harness_test_runner.py +1586 -0
  179. package/harness/bundles/general/pi/scripts/harness_verification.py +593 -0
  180. package/harness/bundles/general/pi/scripts/harness_workflow_policy.py +266 -0
  181. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  182. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  183. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  184. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  185. package/harness/bundles/java/claude-code/harness-execute/SKILL.md +1 -1
  186. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  187. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  188. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  189. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  190. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  191. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  192. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  193. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  194. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  195. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  196. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  197. package/harness/bundles/java/claude-code/scripts/harness_archive.py +29 -6
  198. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +1 -1
  199. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +398 -0
  200. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  201. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  202. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  203. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  204. package/harness/bundles/java/codebuddy/harness-execute/SKILL.md +1 -1
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  206. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  207. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  208. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  209. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  210. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  211. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  212. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  213. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  214. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  215. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  216. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +29 -6
  217. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +1 -1
  218. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +398 -0
  219. package/harness/bundles/java/codex/.harness-build.json +1 -1
  220. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  221. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  222. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  223. package/harness/bundles/java/codex/harness-execute/SKILL.md +1 -1
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  225. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  226. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  227. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  228. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  229. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  230. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  231. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  232. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  233. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  234. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  235. package/harness/bundles/java/codex/scripts/harness_archive.py +29 -6
  236. package/harness/bundles/java/codex/scripts/harness_deploy.py +1 -1
  237. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +398 -0
  238. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  239. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  240. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  241. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  242. package/harness/bundles/java/cursor/harness-execute/SKILL.md +1 -1
  243. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  244. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  245. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  246. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  247. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  248. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  249. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  250. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  251. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  252. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  253. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  254. package/harness/bundles/java/cursor/scripts/harness_archive.py +29 -6
  255. package/harness/bundles/java/cursor/scripts/harness_deploy.py +1 -1
  256. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +398 -0
  257. package/harness/bundles/java/pi/.harness-build.json +6 -0
  258. package/harness/bundles/java/pi/CONTEXT.md +98 -0
  259. package/harness/bundles/java/pi/README.md +478 -0
  260. package/harness/bundles/java/pi/contracts/build-profile-v3.schema.json +302 -0
  261. package/harness/bundles/java/pi/contracts/ci-metrics.schema.json +36 -0
  262. package/harness/bundles/java/pi/contracts/doctor-capability.schema.json +43 -0
  263. package/harness/bundles/java/pi/contracts/external-platform-observation.schema.json +62 -0
  264. package/harness/bundles/java/pi/contracts/fixtures/managed-execution.json +341 -0
  265. package/harness/bundles/java/pi/contracts/process-identity.schema.json +132 -0
  266. package/harness/bundles/java/pi/contracts/run-session.schema.json +142 -0
  267. package/harness/bundles/java/pi/contracts/service-retirement-receipt.schema.json +85 -0
  268. package/harness/bundles/java/pi/contracts/service-session.schema.json +147 -0
  269. package/harness/bundles/java/pi/contracts/workflow-policy.json +574 -0
  270. package/harness/bundles/java/pi/harness-apidoc/SKILL.md +97 -0
  271. package/harness/bundles/java/pi/harness-apidoc/checklist.md +142 -0
  272. package/harness/bundles/java/pi/harness-apidoc/reference.md +233 -0
  273. package/harness/bundles/java/pi/harness-archive/SKILL.md +322 -0
  274. package/harness/bundles/java/pi/harness-archive/checklist.md +71 -0
  275. package/harness/bundles/java/pi/harness-archive/reference.md +150 -0
  276. package/harness/bundles/java/pi/harness-archive/scripts/gen-manifest.ps1 +31 -0
  277. package/harness/bundles/java/pi/harness-archive/templates/summary-data-template.json +218 -0
  278. package/harness/bundles/java/pi/harness-codebase-map/SKILL.md +116 -0
  279. package/harness/bundles/java/pi/harness-codebase-map/checklist.md +126 -0
  280. package/harness/bundles/java/pi/harness-codebase-map/reference.md +364 -0
  281. package/harness/bundles/java/pi/harness-codebase-map/templates/ARCHITECTURE.md +57 -0
  282. package/harness/bundles/java/pi/harness-codebase-map/templates/CONCERNS.md +49 -0
  283. package/harness/bundles/java/pi/harness-codebase-map/templates/CONVENTIONS.md +57 -0
  284. package/harness/bundles/java/pi/harness-codebase-map/templates/INTEGRATIONS.md +52 -0
  285. package/harness/bundles/java/pi/harness-codebase-map/templates/STACK.md +55 -0
  286. package/harness/bundles/java/pi/harness-codebase-map/templates/STRUCTURE.md +69 -0
  287. package/harness/bundles/java/pi/harness-codebase-map/templates/TESTING.md +64 -0
  288. package/harness/bundles/java/pi/harness-codebase-map/templates/map-manifest.schema.json +73 -0
  289. package/harness/bundles/java/pi/harness-codebase-map/templates/map-summary.md +64 -0
  290. package/harness/bundles/java/pi/harness-execute/SKILL.md +170 -0
  291. package/harness/bundles/java/pi/harness-knowledge-ingest/SKILL.md +103 -0
  292. package/harness/bundles/java/pi/harness-knowledge-query/SKILL.md +97 -0
  293. package/harness/bundles/java/pi/harness-package/SKILL.md +94 -0
  294. package/harness/bundles/java/pi/harness-package/checklist.md +322 -0
  295. package/harness/bundles/java/pi/harness-package/reference.md +210 -0
  296. package/harness/bundles/java/pi/harness-plan/SKILL.md +157 -0
  297. package/harness/bundles/java/pi/harness-plan/checklist.md +243 -0
  298. package/harness/bundles/java/pi/harness-plan/protocols.md +133 -0
  299. package/harness/bundles/java/pi/harness-plan/reference.md +468 -0
  300. package/harness/bundles/java/pi/harness-pull/SKILL.md +55 -0
  301. package/harness/bundles/java/pi/harness-push/SKILL.md +83 -0
  302. package/harness/bundles/java/pi/harness-review/SKILL.md +166 -0
  303. package/harness/bundles/java/pi/harness-review/checklist.md +124 -0
  304. package/harness/bundles/java/pi/harness-review/protocols.md +69 -0
  305. package/harness/bundles/java/pi/harness-review/reference.md +114 -0
  306. package/harness/bundles/java/pi/harness-run/SKILL.md +206 -0
  307. package/harness/bundles/java/pi/harness-run/checklist.md +252 -0
  308. package/harness/bundles/java/pi/harness-run/protocols.md +141 -0
  309. package/harness/bundles/java/pi/harness-run/reference.md +820 -0
  310. package/harness/bundles/java/pi/harness-submit/SKILL.md +189 -0
  311. package/harness/bundles/java/pi/harness-submit/checklist.md +452 -0
  312. package/harness/bundles/java/pi/harness-submit/reference.md +152 -0
  313. package/harness/bundles/java/pi/harness-sync/SKILL.md +72 -0
  314. package/harness/bundles/java/pi/harness-sync/reference.md +176 -0
  315. package/harness/bundles/java/pi/harness-test/SKILL.md +264 -0
  316. package/harness/bundles/java/pi/harness-test/checklist.md +320 -0
  317. package/harness/bundles/java/pi/harness-test/pitfalls-java.md +16 -0
  318. package/harness/bundles/java/pi/harness-test/pitfalls.md +307 -0
  319. package/harness/bundles/java/pi/harness-test/reference.md +792 -0
  320. package/harness/bundles/java/pi/harness-test/scripts/runtime-helpers.mjs +216 -0
  321. package/harness/bundles/java/pi/protocols/archive-report-protocol.md +196 -0
  322. package/harness/bundles/java/pi/protocols/ci-layering-protocol.md +22 -0
  323. package/harness/bundles/java/pi/protocols/evidence-based-reporting-protocol.md +134 -0
  324. package/harness/bundles/java/pi/protocols/execution-session-protocol.md +52 -0
  325. package/harness/bundles/java/pi/protocols/external-platform-protocol.md +27 -0
  326. package/harness/bundles/java/pi/protocols/ledger-protocol.md +365 -0
  327. package/harness/bundles/java/pi/protocols/memory-closeout-protocol.md +21 -0
  328. package/harness/bundles/java/pi/protocols/model-routing-protocol.md +22 -0
  329. package/harness/bundles/java/pi/protocols/powershell-protocol.md +238 -0
  330. package/harness/bundles/java/pi/protocols/registry-governance-protocol.md +31 -0
  331. package/harness/bundles/java/pi/protocols/report-pipeline-protocol.md +172 -0
  332. package/harness/bundles/java/pi/protocols/sensitive-info-protocol.md +110 -0
  333. package/harness/bundles/java/pi/protocols/state-layout-protocol.md +209 -0
  334. package/harness/bundles/java/pi/protocols/submit-protocol.md +78 -0
  335. package/harness/bundles/java/pi/protocols/verification-graph-protocol.md +32 -0
  336. package/harness/bundles/java/pi/scripts/harness_adoption_metrics.py +391 -0
  337. package/harness/bundles/java/pi/scripts/harness_apidoc.py +53 -0
  338. package/harness/bundles/java/pi/scripts/harness_archive.py +11345 -0
  339. package/harness/bundles/java/pi/scripts/harness_change.py +1626 -0
  340. package/harness/bundles/java/pi/scripts/harness_check_gate.py +113 -0
  341. package/harness/bundles/java/pi/scripts/harness_context.py +1774 -0
  342. package/harness/bundles/java/pi/scripts/harness_deploy.py +968 -0
  343. package/harness/bundles/java/pi/scripts/harness_efficiency.py +443 -0
  344. package/harness/bundles/java/pi/scripts/harness_environment.py +1690 -0
  345. package/harness/bundles/java/pi/scripts/harness_events.py +2234 -0
  346. package/harness/bundles/java/pi/scripts/harness_events_sync.py +1425 -0
  347. package/harness/bundles/java/pi/scripts/harness_execution_contracts.py +596 -0
  348. package/harness/bundles/java/pi/scripts/harness_fixback.py +1975 -0
  349. package/harness/bundles/java/pi/scripts/harness_gate.py +3816 -0
  350. package/harness/bundles/java/pi/scripts/harness_headless.py +37 -0
  351. package/harness/bundles/java/pi/scripts/harness_integration.py +2564 -0
  352. package/harness/bundles/java/pi/scripts/harness_knowledge_candidates.py +796 -0
  353. package/harness/bundles/java/pi/scripts/harness_ledger.py +3556 -0
  354. package/harness/bundles/java/pi/scripts/harness_migration_head.py +220 -0
  355. package/harness/bundles/java/pi/scripts/harness_orchestration.py +344 -0
  356. package/harness/bundles/java/pi/scripts/harness_paths.py +676 -0
  357. package/harness/bundles/java/pi/scripts/harness_phase.py +1726 -0
  358. package/harness/bundles/java/pi/scripts/harness_plan_aggregate.py +249 -0
  359. package/harness/bundles/java/pi/scripts/harness_plan_finalize.py +1016 -0
  360. package/harness/bundles/java/pi/scripts/harness_preflight.py +650 -0
  361. package/harness/bundles/java/pi/scripts/harness_process.py +1065 -0
  362. package/harness/bundles/java/pi/scripts/harness_profile.py +1817 -0
  363. package/harness/bundles/java/pi/scripts/harness_report_model.py +442 -0
  364. package/harness/bundles/java/pi/scripts/harness_retry.py +175 -0
  365. package/harness/bundles/java/pi/scripts/harness_review.py +631 -0
  366. package/harness/bundles/java/pi/scripts/harness_runtime.py +2243 -0
  367. package/harness/bundles/java/pi/scripts/harness_service.py +3355 -0
  368. package/harness/bundles/java/pi/scripts/harness_state.py +365 -0
  369. package/harness/bundles/java/pi/scripts/harness_sync.py +407 -0
  370. package/harness/bundles/java/pi/scripts/harness_test_cleanup.py +175 -0
  371. package/harness/bundles/java/pi/scripts/harness_test_guard.py +1899 -0
  372. package/harness/bundles/java/pi/scripts/harness_test_runner.py +1586 -0
  373. package/harness/bundles/java/pi/scripts/harness_verification.py +593 -0
  374. package/harness/bundles/java/pi/scripts/harness_workflow_policy.py +266 -0
  375. package/harness/manifests/general/claude-code.json +17 -17
  376. package/harness/manifests/general/codebuddy.json +17 -17
  377. package/harness/manifests/general/codex.json +17 -17
  378. package/harness/manifests/general/cursor.json +17 -17
  379. package/harness/manifests/general/pi.json +475 -0
  380. package/harness/manifests/java/claude-code.json +19 -19
  381. package/harness/manifests/java/codebuddy.json +19 -19
  382. package/harness/manifests/java/codex.json +19 -19
  383. package/harness/manifests/java/cursor.json +19 -19
  384. package/harness/manifests/java/pi.json +499 -0
  385. package/hunter-workflow-family.json +2 -2
  386. package/package.json +1 -1
@@ -0,0 +1,1425 @@
1
+ #!/usr/bin/env python3
2
+ """P4: sync local events.ndjson to the platform Run monitoring API.
3
+
4
+ Reads `.harness/credentials.local.yaml` + project_id, whitelists event fields,
5
+ POSTs batches with ACK cursor, and sends heartbeats. Offline-safe: cursor only
6
+ advances on accepted/duplicate_accepted responses.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import errno
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ import uuid
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ SCRIPTS_DIR = Path(__file__).resolve().parent
28
+ if str(SCRIPTS_DIR) not in sys.path:
29
+ sys.path.insert(0, str(SCRIPTS_DIR))
30
+
31
+ import harness_paths # noqa: E402
32
+
33
+ WHITELIST = {
34
+ "id",
35
+ "timestamp",
36
+ "schema_version",
37
+ # Legacy aliases remain readable while new writers use the canonical names.
38
+ "ts",
39
+ "type",
40
+ "phase",
41
+ "status",
42
+ "note",
43
+ "message",
44
+ "attempt",
45
+ "name",
46
+ "code",
47
+ "severity",
48
+ "decision",
49
+ "reason",
50
+ "exit_code",
51
+ "duration_ms",
52
+ "schemaVersion",
53
+ "summary",
54
+ "executor_tool",
55
+ "executor_agent",
56
+ "executor_model",
57
+ "execution_mode",
58
+ "decision_reason_code",
59
+ "fallback_reason_code",
60
+ "trigger",
61
+ "from_phase",
62
+ "result_status",
63
+ "planned_phases",
64
+ "skipped_phases",
65
+ "next_phase",
66
+ "phase_plan_source",
67
+ "closure_disposition",
68
+ "closure_reason",
69
+ }
70
+
71
+ MAX_BATCH_SIZE = 100
72
+ ACCEPTED_STATUSES = {"accepted", "duplicate_accepted"}
73
+ QUARANTINE_STATUSES = {"id_conflict", "rejected", "rejected_schema"}
74
+ DISPLAY_TITLE_MAX_LENGTH = 200
75
+ EVENT_SUMMARY_MAX_LENGTH = 500
76
+
77
+ PHASE_LABELS = {
78
+ "plan": "计划",
79
+ "execute": "执行",
80
+ "run": "编码",
81
+ "test": "测试",
82
+ "review": "评审",
83
+ "package": "打包",
84
+ "apidoc": "接口文档",
85
+ "submit": "提交",
86
+ "archive": "归档",
87
+ }
88
+
89
+ REVIEW_REASON_SUMMARIES = {
90
+ "REVIEW_DELEGATED": "已使用独立评审,主会话负责核验结果。",
91
+ "REVIEW_INLINE_UNAVAILABLE": "当前环境没有可用的隔离评审能力,已由主会话完成评审。",
92
+ "REVIEW_INLINE_SPAWN_FAILED": "独立评审启动失败,已由主会话完成评审。",
93
+ "REVIEW_INLINE_INVALID_RESULT": "独立评审未返回有效结果,已由主会话重新完成评审。",
94
+ "INLINE_BY_ADAPTER": "当前适配器未提供隔离评审,已由主会话完成评审。",
95
+ }
96
+
97
+
98
+ def _yaml_scalar(text: str, key: str) -> str | None:
99
+ match = re.search(rf"(?m)^[ \t]*{re.escape(key)}:\s*['\"]?([^'\"\n#]+)", text)
100
+ if match is None:
101
+ return None
102
+ value = match.group(1).strip()
103
+ return value or None
104
+
105
+
106
+ def load_endpoint(project: Path) -> dict[str, str] | None:
107
+ creds = project / ".harness" / "credentials.local.yaml"
108
+ if not creds.is_file():
109
+ return None
110
+ text = creds.read_text(encoding="utf-8", errors="ignore")
111
+ server_url = _yaml_scalar(text, "server_url")
112
+ token = _yaml_scalar(text, "token")
113
+ project_id = _yaml_scalar(text, "project_id")
114
+ if not project_id:
115
+ py = project / ".harness" / "project.yaml"
116
+ if py.is_file():
117
+ project_id = _yaml_scalar(py.read_text(encoding="utf-8", errors="ignore"), "project_id")
118
+ if not server_url or not token or not project_id:
119
+ return None
120
+ if project_id.lower() in {"null", "~", "none"}:
121
+ return None
122
+ return {
123
+ "server_url": server_url.rstrip("/"),
124
+ "token": token,
125
+ "project_id": project_id,
126
+ }
127
+
128
+
129
+ def _summary_text(value: Any) -> str:
130
+ if not isinstance(value, str):
131
+ return ""
132
+ text = re.sub(r"\s+", " ", value).strip()
133
+ text = "".join(character for character in text if ord(character) >= 32 and ord(character) != 127)
134
+ return text[:EVENT_SUMMARY_MAX_LENGTH].rstrip()
135
+
136
+
137
+ _MACHINE_DETAIL_RE = re.compile(
138
+ r"(?:\s*[;;,,]?\s*)"
139
+ r"(?:artifactsHash|artifactHash|receiptHash|content_sha256|contentSha256)"
140
+ r"\s*[:=]\s*(?:sha256:)?[0-9a-f]{32,64}",
141
+ re.IGNORECASE,
142
+ )
143
+
144
+
145
+ def _readable_summary_text(value: Any) -> str:
146
+ text = _summary_text(value)
147
+ if not text:
148
+ return ""
149
+ text = _MACHINE_DETAIL_RE.sub("", text)
150
+ text = re.sub(
151
+ r"\bfinalize\s+ok\b",
152
+ "规划产物已校验并发布",
153
+ text,
154
+ flags=re.IGNORECASE,
155
+ )
156
+ text = re.sub(r"\s*([;;,,])\s*([;;,,])", r"\1", text)
157
+ return _summary_text(text.strip(" ;;,,"))
158
+
159
+
160
+ def build_event_summary(event: dict[str, Any]) -> str:
161
+ reason_code = _summary_text(
162
+ event.get("fallback_reason_code") or event.get("decision_reason_code")
163
+ )
164
+ reason_summary = REVIEW_REASON_SUMMARIES.get(reason_code)
165
+ explicit = _readable_summary_text(event.get("summary"))
166
+ if reason_summary and (
167
+ not explicit
168
+ or explicit == reason_code
169
+ or re.search(r"REVIEW_[A-Z0-9_]+|INLINE_BY_ADAPTER", explicit)
170
+ ):
171
+ return reason_summary
172
+ if explicit and not re.search(
173
+ r"\b[A-Z][A-Z0-9_]{3,}\b|\b[a-z][\w.-]+\s*=\s*(?:true|false)\b",
174
+ explicit,
175
+ ):
176
+ return explicit
177
+
178
+ event_type = _summary_text(event.get("type"))
179
+ phase = _summary_text(event.get("phase"))
180
+ phase_label = PHASE_LABELS.get(phase, phase or "当前")
181
+ note = _readable_summary_text(event.get("note"))
182
+ message = _readable_summary_text(event.get("message"))
183
+ decision = _readable_summary_text(event.get("decision"))
184
+ reason = _readable_summary_text(event.get("reason"))
185
+ name = _readable_summary_text(event.get("name"))
186
+ code = _readable_summary_text(event.get("code"))
187
+ status = _summary_text(event.get("status"))
188
+
189
+ if event_type == "decision" and decision:
190
+ return _summary_text(f"{decision};原因:{reason}" if reason else decision)
191
+ if event_type == "issue":
192
+ detail = message or note or code
193
+ if detail and reason and reason != detail:
194
+ return _summary_text(f"{detail};原因:{reason}")
195
+ return detail or "发现一项需要处理的问题。"
196
+ if event_type == "issue.resolve":
197
+ detail = message or note or code
198
+ return _summary_text(f"问题已解决:{detail}" if detail else "已解决此前记录的问题。")
199
+ if event_type == "phase.start":
200
+ return _summary_text(
201
+ f"开始执行{phase_label}阶段:{note}" if note else f"开始执行{phase_label}阶段。"
202
+ )
203
+ if event_type in {"phase.end", "phase.auto_sealed"}:
204
+ detail = note or status
205
+ return _summary_text(
206
+ f"{phase_label}阶段已结束:{detail}" if detail else f"{phase_label}阶段已结束。"
207
+ )
208
+ if event_type == "gate.blocked":
209
+ return note or message or "阶段门禁暂未通过,现场已保留,可按提示恢复。"
210
+ if event_type == "gate.recovered":
211
+ return note or message or "阶段门禁已恢复,流程可以继续。"
212
+ if event_type == "command":
213
+ detail = name or note
214
+ return _summary_text(f"执行步骤:{detail}" if detail else "执行了一项工作步骤。")
215
+ if event_type == "verification":
216
+ detail = name or note or status
217
+ return _summary_text(f"完成验证:{detail}" if detail else "完成了一项结果验证。")
218
+ if event_type == "artifact":
219
+ detail = name or note
220
+ return _summary_text(f"生成或更新产物:{detail}" if detail else "生成或更新了一项交付内容。")
221
+ if event_type == "correction":
222
+ detail = note or reason or message
223
+ return _summary_text(f"调整执行方式:{detail}" if detail else "根据当前结果调整了执行方式。")
224
+ if event_type == "change.rename":
225
+ return note or message or "更新了变更名称。"
226
+ if event_type in {"recovery", "phase.recovery", "attempt.recovery"}:
227
+ return note or reason or message or "恢复了中断的运行状态。"
228
+ if event_type == "heartbeat":
229
+ return "客户端保持在线,并同步了最新运行状态。"
230
+
231
+ return note or message or name or reason or code or "记录了一项运行事件。"
232
+
233
+
234
+ def sanitize(event: dict[str, Any]) -> dict[str, Any]:
235
+ payload = {key: event[key] for key in WHITELIST if key in event}
236
+ payload["summary"] = build_event_summary(event)
237
+ return payload
238
+
239
+
240
+ def _workflow_plan(change_dir: Path) -> dict[str, Any] | None:
241
+ policy_path = change_dir / "meta" / "gate-policy.json"
242
+ if not policy_path.is_file():
243
+ return None
244
+ try:
245
+ policy = json.loads(policy_path.read_text(encoding="utf-8-sig"))
246
+ except (OSError, json.JSONDecodeError):
247
+ return None
248
+ if not isinstance(policy, dict):
249
+ return None
250
+ raw = policy.get("plannedPhases")
251
+ source = "change"
252
+ if not isinstance(raw, list) or not raw:
253
+ raw = policy.get("defaultPhases")
254
+ source = "policy-default"
255
+ if not isinstance(raw, list):
256
+ return None
257
+ phases = [str(item).strip() for item in raw if str(item).strip()]
258
+ if not phases:
259
+ return None
260
+ skipped = policy.get("skippedPhases")
261
+ return {
262
+ "planned_phases": list(dict.fromkeys(phases)),
263
+ "skipped_phases": skipped if isinstance(skipped, list) else [],
264
+ "phase_plan_source": source,
265
+ }
266
+
267
+
268
+ def _event_payload(event: dict[str, Any], phase_plan: dict[str, Any] | None) -> dict[str, Any]:
269
+ payload = sanitize(event)
270
+ if phase_plan is None:
271
+ return payload
272
+ payload.update(phase_plan)
273
+ if str(event.get("type") or "") in {"phase.end", "phase.auto_sealed"}:
274
+ phase = str(event.get("phase") or "").strip()
275
+ planned = phase_plan["planned_phases"]
276
+ if phase in planned:
277
+ index = planned.index(phase)
278
+ payload["next_phase"] = planned[index + 1] if index + 1 < len(planned) else None
279
+ return payload
280
+
281
+
282
+ def _valid_display_title(value: Any) -> str | None:
283
+ if not isinstance(value, str):
284
+ return None
285
+ title = value.strip()
286
+ if (
287
+ not title
288
+ or len(title) > DISPLAY_TITLE_MAX_LENGTH
289
+ or any(ord(character) < 32 or ord(character) == 127 for character in title)
290
+ ):
291
+ return None
292
+ return title
293
+
294
+
295
+ def _safe_file_within(change_dir: Path, path: Path) -> Path | None:
296
+ try:
297
+ root = change_dir.resolve()
298
+ resolved = path.resolve()
299
+ except OSError:
300
+ return None
301
+ if path.is_symlink() or not resolved.is_relative_to(root) or not resolved.is_file():
302
+ return None
303
+ return resolved
304
+
305
+
306
+ def load_display_title(change_dir: Path, change_key: str) -> str:
307
+ metadata_path = _safe_file_within(
308
+ change_dir, change_dir / "meta" / "change-title.json"
309
+ )
310
+ if metadata_path is not None:
311
+ try:
312
+ payload = json.loads(metadata_path.read_text(encoding="utf-8-sig"))
313
+ except (OSError, json.JSONDecodeError):
314
+ payload = None
315
+ if isinstance(payload, dict):
316
+ title = _valid_display_title(payload.get("displayTitle"))
317
+ if title is not None:
318
+ return title
319
+
320
+ # Legacy migration: older Plan runs already wrote a human title into the
321
+ # approved design H1 but had no structured title metadata.
322
+ design_path = _safe_file_within(
323
+ change_dir, change_dir / "spec" / f"{change_key}-design.md"
324
+ )
325
+ if design_path is not None:
326
+ try:
327
+ with design_path.open("r", encoding="utf-8-sig") as stream:
328
+ for _ in range(40):
329
+ line = stream.readline()
330
+ if not line:
331
+ break
332
+ if not line.startswith("# "):
333
+ continue
334
+ candidate = re.sub(
335
+ r"\s+(?:设计文档|设计方案|设计)$", "", line[2:].strip()
336
+ )
337
+ title = _valid_display_title(candidate)
338
+ if title is not None and title != change_key:
339
+ return title
340
+ break
341
+ except OSError:
342
+ pass
343
+ return change_key
344
+
345
+
346
+ def cursor_path(change_dir: Path) -> Path:
347
+ return _state_dir(change_dir) / "meta" / "events-sync-cursor.json"
348
+
349
+
350
+ def _state_dir(change_dir: Path) -> Path:
351
+ return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
352
+
353
+
354
+ def _cursor_values(path: Path) -> tuple[int, int, bool]:
355
+ if not path.is_file():
356
+ return 0, 0, True
357
+ try:
358
+ data = json.loads(path.read_text(encoding="utf-8"))
359
+ except (OSError, json.JSONDecodeError):
360
+ return 0, 0, False
361
+ raw_acked = data.get("acked_lines") if isinstance(data, dict) else None
362
+ raw_base = data.get("producer_seq_base", 0) if isinstance(data, dict) else None
363
+ acked_valid = (
364
+ not isinstance(raw_acked, bool)
365
+ and isinstance(raw_acked, int)
366
+ and raw_acked >= 0
367
+ )
368
+ base_valid = (
369
+ not isinstance(raw_base, bool)
370
+ and isinstance(raw_base, int)
371
+ and raw_base >= 0
372
+ )
373
+ return (
374
+ raw_acked if acked_valid else 0,
375
+ raw_base if base_valid else 0,
376
+ acked_valid and base_valid,
377
+ )
378
+
379
+
380
+ def _complete_line_count(path: Path) -> int:
381
+ if not path.is_file():
382
+ return 0
383
+ try:
384
+ data = path.read_bytes()
385
+ except OSError:
386
+ return 0
387
+ if not data:
388
+ return 0
389
+ return data.count(b"\n")
390
+
391
+
392
+ def _initial_producer_seq_base(change_dir: Path) -> int:
393
+ """Keep producer sequence monotonic when a legacy stream becomes split-v1."""
394
+ state_dir = _state_dir(change_dir).resolve()
395
+ contract_dir = change_dir.resolve()
396
+ if state_dir == contract_dir:
397
+ return 0
398
+ legacy_acked, legacy_base, _ = _cursor_values(
399
+ contract_dir / "meta" / "events-sync-cursor.json"
400
+ )
401
+ legacy_lines = _complete_line_count(contract_dir / "events.ndjson")
402
+ return legacy_base + max(legacy_acked, legacy_lines)
403
+
404
+
405
+ def load_cursor_state(
406
+ change_dir: Path,
407
+ *,
408
+ maximum_lines: int | None = None,
409
+ repair: bool = False,
410
+ ) -> dict[str, int]:
411
+ path = cursor_path(change_dir)
412
+ exists = path.is_file()
413
+ acked, producer_seq_base, valid = _cursor_values(path)
414
+ if not exists:
415
+ producer_seq_base = _initial_producer_seq_base(change_dir)
416
+ if maximum_lines is not None:
417
+ if maximum_lines < 0:
418
+ raise ValueError("maximum_lines must be non-negative")
419
+ if acked > maximum_lines:
420
+ acked = 0
421
+ valid = False
422
+ if (not valid or not exists) and repair:
423
+ save_cursor(
424
+ change_dir,
425
+ acked,
426
+ producer_seq_base=producer_seq_base,
427
+ )
428
+ return {
429
+ "acked_lines": acked,
430
+ "producer_seq_base": producer_seq_base,
431
+ }
432
+
433
+
434
+ def load_cursor(
435
+ change_dir: Path,
436
+ *,
437
+ maximum_lines: int | None = None,
438
+ repair: bool = False,
439
+ ) -> int:
440
+ return load_cursor_state(
441
+ change_dir,
442
+ maximum_lines=maximum_lines,
443
+ repair=repair,
444
+ )["acked_lines"]
445
+
446
+
447
+ def _fsync_parent(path: Path) -> None:
448
+ if os.name == "nt":
449
+ return
450
+ descriptor = os.open(str(path.parent), os.O_RDONLY)
451
+ try:
452
+ os.fsync(descriptor)
453
+ finally:
454
+ os.close(descriptor)
455
+
456
+
457
+ def save_cursor(
458
+ change_dir: Path,
459
+ acked_lines: int,
460
+ *,
461
+ producer_seq_base: int | None = None,
462
+ ) -> None:
463
+ if isinstance(acked_lines, bool) or not isinstance(acked_lines, int) or acked_lines < 0:
464
+ raise ValueError("acked_lines must be a non-negative integer")
465
+ path = cursor_path(change_dir)
466
+ if producer_seq_base is None:
467
+ _, producer_seq_base, _ = _cursor_values(path)
468
+ if (
469
+ isinstance(producer_seq_base, bool)
470
+ or not isinstance(producer_seq_base, int)
471
+ or producer_seq_base < 0
472
+ ):
473
+ raise ValueError("producer_seq_base must be a non-negative integer")
474
+ path.parent.mkdir(parents=True, exist_ok=True)
475
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
476
+ encoded = (
477
+ json.dumps({
478
+ "schemaVersion": 2,
479
+ "acked_lines": acked_lines,
480
+ "producer_seq_base": producer_seq_base,
481
+ }, indent=2) + "\n"
482
+ ).encode("utf-8")
483
+ descriptor = os.open(
484
+ str(temporary), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
485
+ )
486
+ try:
487
+ with os.fdopen(descriptor, "wb") as stream:
488
+ descriptor = -1
489
+ stream.write(encoded)
490
+ stream.flush()
491
+ os.fsync(stream.fileno())
492
+ os.replace(temporary, path)
493
+ _fsync_parent(path)
494
+ finally:
495
+ if descriptor >= 0:
496
+ os.close(descriptor)
497
+ try:
498
+ temporary.unlink()
499
+ except FileNotFoundError:
500
+ pass
501
+
502
+
503
+ def quarantine_path(change_dir: Path) -> Path:
504
+ return _state_dir(change_dir) / "meta" / "events-sync-quarantine.ndjson"
505
+
506
+
507
+ def append_quarantine(
508
+ change_dir: Path,
509
+ *,
510
+ event_id: str,
511
+ line_number: int,
512
+ error_code: str,
513
+ status: str,
514
+ ) -> None:
515
+ path = quarantine_path(change_dir)
516
+ path.parent.mkdir(parents=True, exist_ok=True)
517
+ record = {
518
+ "schema_version": 1,
519
+ "event_id": event_id,
520
+ "line_number": line_number,
521
+ "status": status,
522
+ "error_code": error_code,
523
+ "quarantined_at": datetime.now(timezone.utc).isoformat(),
524
+ }
525
+ with path.open("a", encoding="utf-8", newline="\n") as stream:
526
+ stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
527
+ stream.flush()
528
+ os.fsync(stream.fileno())
529
+
530
+
531
+ def run_id_for(change_dir: Path) -> str:
532
+ # Stable opaque id from absolute path — no hostname/username leakage.
533
+ digest = hashlib.sha256(str(change_dir.resolve()).encode("utf-8")).hexdigest()[:24]
534
+ return "run_" + digest
535
+
536
+
537
+ PLAN_EVENTS_STREAM_NAME = "plan-events.ndjson"
538
+ PLAN_EVENTS_CURSOR_NAME = "plan-events-sync-cursor.json"
539
+
540
+
541
+ def _plan_events_path(change_dir: Path) -> Path:
542
+ return _state_dir(change_dir) / "meta" / PLAN_EVENTS_STREAM_NAME
543
+
544
+
545
+ def _plan_events_cursor_path(change_dir: Path) -> Path:
546
+ return _state_dir(change_dir) / "meta" / PLAN_EVENTS_CURSOR_NAME
547
+
548
+
549
+ def _load_plan_cursor(change_dir: Path, *, initial_base: int) -> tuple[int, int]:
550
+ path = _plan_events_cursor_path(change_dir)
551
+ if not path.is_file():
552
+ return 0, initial_base
553
+ try:
554
+ data = json.loads(path.read_text(encoding="utf-8"))
555
+ except (OSError, json.JSONDecodeError):
556
+ return 0, initial_base
557
+ acked = data.get("acked_lines") if isinstance(data, dict) else None
558
+ base = data.get("producer_seq_base") if isinstance(data, dict) else None
559
+ if not isinstance(acked, int) or isinstance(acked, bool) or acked < 0:
560
+ acked = 0
561
+ if not isinstance(base, int) or isinstance(base, bool) or base < 0:
562
+ base = initial_base
563
+ return acked, base
564
+
565
+
566
+ def _save_plan_cursor(change_dir: Path, *, acked: int, base: int) -> None:
567
+ path = _plan_events_cursor_path(change_dir)
568
+ path.parent.mkdir(parents=True, exist_ok=True)
569
+ payload = {
570
+ "schema_version": 1,
571
+ "acked_lines": acked,
572
+ "producer_seq_base": base,
573
+ "updated_at": datetime.now(timezone.utc).isoformat(),
574
+ }
575
+ tmp = path.with_suffix(path.suffix + ".tmp")
576
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
577
+ os.replace(tmp, path)
578
+
579
+
580
+ def _sync_plan_events_stream(
581
+ change_dir: Path,
582
+ *,
583
+ endpoint: dict[str, str],
584
+ rid: str,
585
+ resolved_key: str,
586
+ display_title: str,
587
+ main_stream_total_lines: int,
588
+ ) -> dict[str, Any]:
589
+ """Upload the TS PlanEvent stream (meta/plan-events.ndjson) with its own ACK cursor.
590
+
591
+ producer_seq base starts after the Python stream's total line count, so the two
592
+ streams never collide in (run_id, producer_seq) space. 事件语义与转换边界见
593
+ 12-M3 文档:TS 事件按 hunter-progress-sync/v1 线上格式投影,event_type 保留
594
+ TS 原值(artifact_published 等)。
595
+ """
596
+ project_id = endpoint["project_id"]
597
+ plan_file = _plan_events_path(change_dir)
598
+ if not plan_file.is_file():
599
+ return {"ok": True, "uploaded": 0, "quarantined": 0, "reason": "no plan-events.ndjson"}
600
+ lines = _event_lines_snapshot(plan_file)
601
+ acked, base = _load_plan_cursor(change_dir, initial_base=main_stream_total_lines)
602
+ if acked > len(lines):
603
+ # 文件被截断重建:从头重放(event_id 服务端幂等去重)
604
+ acked = 0
605
+ pending = lines[acked:]
606
+ uploaded = 0
607
+ quarantined = 0
608
+ client_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
609
+ for chunk_start in range(0, len(pending), MAX_BATCH_SIZE):
610
+ raw_chunk = pending[chunk_start:chunk_start + MAX_BATCH_SIZE]
611
+ batch: list[dict[str, Any]] = []
612
+ line_by_event_id: dict[str, int] = {}
613
+ local_invalid: list[tuple[str, int, str]] = []
614
+ for offset, raw_line in enumerate(raw_chunk, start=1):
615
+ line_number = acked + chunk_start + offset
616
+ line = raw_line.strip()
617
+ if not line:
618
+ local_invalid.append((f"{resolved_key}:plan:{line_number}", line_number, "EMPTY_LINE"))
619
+ continue
620
+ try:
621
+ event = json.loads(line)
622
+ except json.JSONDecodeError:
623
+ local_invalid.append((f"{resolved_key}:plan:{line_number}", line_number, "INVALID_JSON"))
624
+ continue
625
+ if not isinstance(event, dict):
626
+ local_invalid.append((f"{resolved_key}:plan:{line_number}", line_number, "INVALID_EVENT"))
627
+ continue
628
+ event_id = str(event.get("event_id") or f"{resolved_key}:plan:{line_number}")
629
+ item: dict[str, Any] = {
630
+ "event_id": event_id,
631
+ "producer_seq": base + line_number,
632
+ "event_type": str(event.get("type") or "unknown"),
633
+ "occurred_at": str(event.get("occurred_at") or client_time),
634
+ "payload": sanitize(event),
635
+ }
636
+ if event.get("phase") is not None:
637
+ item["phase"] = event.get("phase")
638
+ batch.append(item)
639
+ line_by_event_id[event_id] = line_number
640
+
641
+ if batch:
642
+ try:
643
+ result = post_json(
644
+ endpoint,
645
+ f"/api/v1/projects/{project_id}/runs/events:batch",
646
+ {
647
+ "protocol_version": "hunter-progress-sync/v1",
648
+ "run_id": rid,
649
+ "change_key": resolved_key,
650
+ "title": display_title,
651
+ "events": batch,
652
+ },
653
+ )
654
+ except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
655
+ return {
656
+ "ok": False,
657
+ "error": f"plan-events batch failed: {exc}",
658
+ "uploaded": uploaded,
659
+ "quarantined": quarantined,
660
+ }
661
+ items = result.get("items") if isinstance(result, dict) else None
662
+ if not isinstance(items, list) or len(items) != len(batch):
663
+ return {
664
+ "ok": False,
665
+ "error": "plan-events batch response did not acknowledge every event",
666
+ "uploaded": uploaded,
667
+ "quarantined": quarantined,
668
+ }
669
+ response_by_id = {
670
+ str(item.get("id") or item.get("event_id") or ""): item
671
+ for item in items if isinstance(item, dict)
672
+ }
673
+ for event in batch:
674
+ event_id = str(event["event_id"])
675
+ item = response_by_id.get(event_id)
676
+ status = str((item or {}).get("status") or "")
677
+ if status in ACCEPTED_STATUSES:
678
+ uploaded += 1
679
+ continue
680
+ if status in QUARANTINE_STATUSES:
681
+ append_quarantine(
682
+ change_dir,
683
+ event_id=event_id,
684
+ line_number=line_by_event_id[event_id],
685
+ error_code=str((item or {}).get("error_code") or status.upper()),
686
+ status=status,
687
+ )
688
+ quarantined += 1
689
+ continue
690
+ return {
691
+ "ok": False,
692
+ "error": f"plan event {event_id} was not durably acknowledged",
693
+ "uploaded": uploaded,
694
+ "quarantined": quarantined,
695
+ "server": result,
696
+ }
697
+
698
+ for event_id, line_number, error_code in local_invalid:
699
+ append_quarantine(
700
+ change_dir,
701
+ event_id=event_id,
702
+ line_number=line_number,
703
+ error_code=error_code,
704
+ status="local_invalid",
705
+ )
706
+ quarantined += 1
707
+ _save_plan_cursor(change_dir, acked=acked + chunk_start + len(raw_chunk), base=base)
708
+
709
+ return {
710
+ "ok": True,
711
+ "uploaded": uploaded,
712
+ "quarantined": quarantined,
713
+ "acked_lines": len(lines),
714
+ "producer_seq_base": base,
715
+ }
716
+
717
+
718
+ def post_json(endpoint: dict[str, str], path: str, body: dict[str, Any]) -> dict[str, Any]:
719
+ url = endpoint["server_url"] + path
720
+ data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
721
+ request = urllib.request.Request(
722
+ url,
723
+ data=data,
724
+ method="POST",
725
+ headers={
726
+ "Accept": "application/json",
727
+ "Authorization": f"Bearer {endpoint['token']}",
728
+ "Content-Type": "application/json",
729
+ },
730
+ )
731
+ with urllib.request.urlopen(request, timeout=30) as response:
732
+ return json.loads(response.read().decode("utf-8"))
733
+
734
+
735
+ def _event_lines_snapshot(events_file: Path) -> list[str]:
736
+ """Read a complete-line snapshot under the writer's events.ndjson lock."""
737
+ lock = _CrossProcessLock(events_file.with_name(events_file.name + ".lock"))
738
+ lock.acquire(blocking=True)
739
+ try:
740
+ data = events_file.read_bytes()
741
+ finally:
742
+ lock.release()
743
+ # A non-cooperating/external writer may still leave a partial tail. Never
744
+ # quarantine or acknowledge that tail; the next nudge will retry it once a
745
+ # terminating newline makes the physical record durable.
746
+ if data and not data.endswith(b"\n"):
747
+ last_newline = data.rfind(b"\n")
748
+ data = b"" if last_newline < 0 else data[:last_newline + 1]
749
+ return data.decode("utf-8-sig").splitlines()
750
+
751
+
752
+ def _complete_stream_bytes(path: Path) -> bytes:
753
+ data = path.read_bytes() if path.is_file() else b""
754
+ if data and not data.endswith(b"\n"):
755
+ last_newline = data.rfind(b"\n")
756
+ data = b"" if last_newline < 0 else data[:last_newline + 1]
757
+ return data
758
+
759
+
760
+ def _atomic_replace_bytes(path: Path, data: bytes) -> None:
761
+ path.parent.mkdir(parents=True, exist_ok=True)
762
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
763
+ descriptor = os.open(
764
+ str(temporary), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
765
+ )
766
+ try:
767
+ with os.fdopen(descriptor, "wb") as stream:
768
+ descriptor = -1
769
+ stream.write(data)
770
+ stream.flush()
771
+ os.fsync(stream.fileno())
772
+ os.replace(temporary, path)
773
+ _fsync_parent(path)
774
+ finally:
775
+ if descriptor >= 0:
776
+ os.close(descriptor)
777
+ try:
778
+ temporary.unlink()
779
+ except FileNotFoundError:
780
+ pass
781
+
782
+
783
+ def _migrate_pending_legacy_stream(change_dir: Path) -> None:
784
+ """Prefix unsent colocated events before the first split-v1 state upload."""
785
+ contract_dir = change_dir.resolve()
786
+ state_dir = _state_dir(change_dir).resolve()
787
+ legacy_events = contract_dir / "events.ndjson"
788
+ target_events = state_dir / "events.ndjson"
789
+ target_cursor = state_dir / "meta" / "events-sync-cursor.json"
790
+ if state_dir == contract_dir or not legacy_events.is_file() or target_cursor.exists():
791
+ return
792
+ legacy_cursor = contract_dir / "meta" / "events-sync-cursor.json"
793
+ legacy_acked, _, _ = _cursor_values(legacy_cursor)
794
+ legacy_line_count = _complete_line_count(legacy_events)
795
+ if legacy_acked >= legacy_line_count:
796
+ return
797
+
798
+ locks = [
799
+ _CrossProcessLock(path.with_name(path.name + ".lock"))
800
+ for path in sorted((legacy_events, target_events), key=lambda item: str(item).casefold())
801
+ ]
802
+ for lock in locks:
803
+ lock.acquire(blocking=True)
804
+ try:
805
+ if target_cursor.exists() or not legacy_events.is_file():
806
+ return
807
+ legacy_data = _complete_stream_bytes(legacy_events)
808
+ target_data = _complete_stream_bytes(target_events)
809
+ if target_data.startswith(legacy_data):
810
+ merged = target_data
811
+ else:
812
+ merged = legacy_data + target_data
813
+ _atomic_replace_bytes(target_events, merged)
814
+
815
+ target_cursor.parent.mkdir(parents=True, exist_ok=True)
816
+ if legacy_cursor.is_file():
817
+ os.replace(legacy_cursor, target_cursor)
818
+ else:
819
+ save_cursor(change_dir, 0, producer_seq_base=0)
820
+
821
+ legacy_quarantine = contract_dir / "meta" / "events-sync-quarantine.ndjson"
822
+ target_quarantine = state_dir / "meta" / "events-sync-quarantine.ndjson"
823
+ if legacy_quarantine.is_file() and not target_quarantine.exists():
824
+ os.replace(legacy_quarantine, target_quarantine)
825
+ legacy_events.unlink()
826
+ finally:
827
+ for lock in reversed(locks):
828
+ lock.release()
829
+
830
+
831
+ def sync_change(
832
+ project: Path,
833
+ change_dir: Path,
834
+ *,
835
+ heartbeat_only: bool = False,
836
+ run_id: str | None = None,
837
+ change_key: str | None = None,
838
+ ) -> dict[str, Any]:
839
+ endpoint = load_endpoint(project)
840
+ if endpoint is None:
841
+ return {"ok": True, "skipped": True, "reason": "remote credentials not configured"}
842
+ resolved_key = change_key or change_dir.name
843
+ rid = run_id or run_id_for(change_dir)
844
+ project_id = endpoint["project_id"]
845
+ display_title = load_display_title(change_dir, resolved_key)
846
+
847
+ # Always heartbeat so the console can separate connection vs run status.
848
+ try:
849
+ client_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
850
+ post_json(
851
+ endpoint,
852
+ f"/api/v1/projects/{project_id}/runs/heartbeats",
853
+ {
854
+ "protocol_version": "hunter-progress-sync/v1",
855
+ "run_id": rid,
856
+ "change_key": resolved_key,
857
+ "client_time": client_time,
858
+ "title": display_title,
859
+ },
860
+ )
861
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
862
+ return {"ok": False, "error": f"heartbeat failed: {exc}", "run_id": rid}
863
+
864
+ if heartbeat_only:
865
+ return {"ok": True, "heartbeat": True, "run_id": rid, "change_key": resolved_key}
866
+
867
+ _migrate_pending_legacy_stream(change_dir)
868
+ events_file = _state_dir(change_dir) / "events.ndjson"
869
+ if not events_file.is_file():
870
+ plan_result = _sync_plan_events_stream(
871
+ change_dir,
872
+ endpoint=endpoint,
873
+ rid=rid,
874
+ resolved_key=resolved_key,
875
+ display_title=display_title,
876
+ main_stream_total_lines=0,
877
+ )
878
+ result: dict[str, Any] = {
879
+ "ok": bool(plan_result.get("ok")),
880
+ "run_id": rid,
881
+ "change_key": resolved_key,
882
+ "uploaded": int(plan_result.get("uploaded") or 0),
883
+ "quarantined": int(plan_result.get("quarantined") or 0),
884
+ "reason": "no events.ndjson",
885
+ "plan_events": {key: value for key, value in plan_result.items() if key != "ok"},
886
+ }
887
+ if not plan_result.get("ok"):
888
+ result["error"] = plan_result.get("error")
889
+ return result
890
+
891
+ lines = _event_lines_snapshot(events_file)
892
+ cursor = load_cursor_state(
893
+ change_dir,
894
+ maximum_lines=len(lines),
895
+ repair=True,
896
+ )
897
+ acked = cursor["acked_lines"]
898
+ producer_seq_base = cursor["producer_seq_base"]
899
+ pending_raw = lines[acked:]
900
+ phase_plan = _workflow_plan(change_dir)
901
+ uploaded = 0
902
+ quarantined = 0
903
+ last_result: dict[str, Any] | None = None
904
+
905
+ # Chunk physical lines, not only valid events, so a malformed line can never
906
+ # strand all later valid events behind the durable cursor.
907
+ for chunk_start in range(0, len(pending_raw), MAX_BATCH_SIZE):
908
+ raw_chunk = pending_raw[chunk_start:chunk_start + MAX_BATCH_SIZE]
909
+ batch: list[dict[str, Any]] = []
910
+ line_by_event_id: dict[str, int] = {}
911
+ local_invalid: list[tuple[str, int, str]] = []
912
+ for offset, raw_line in enumerate(raw_chunk, start=1):
913
+ line_number = acked + chunk_start + offset
914
+ line = raw_line.strip()
915
+ if not line:
916
+ local_invalid.append((f"{resolved_key}:{line_number}", line_number, "EMPTY_LINE"))
917
+ continue
918
+ try:
919
+ event = json.loads(line)
920
+ except json.JSONDecodeError:
921
+ local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_JSON"))
922
+ continue
923
+ if not isinstance(event, dict):
924
+ local_invalid.append((f"{resolved_key}:{line_number}", line_number, "INVALID_EVENT"))
925
+ continue
926
+ event_id = str(event.get("id") or f"{resolved_key}:{line_number}")
927
+ item: dict[str, Any] = {
928
+ "event_id": event_id,
929
+ "producer_seq": producer_seq_base + line_number,
930
+ "event_type": str(event.get("type") or "unknown"),
931
+ "occurred_at": str(event.get("timestamp") or event.get("ts") or client_time),
932
+ "payload": _event_payload(event, phase_plan),
933
+ }
934
+ if event.get("phase") is not None:
935
+ item["phase"] = event.get("phase")
936
+ batch.append(item)
937
+ line_by_event_id[event_id] = line_number
938
+
939
+ if batch:
940
+ try:
941
+ result = post_json(
942
+ endpoint,
943
+ f"/api/v1/projects/{project_id}/runs/events:batch",
944
+ {
945
+ "protocol_version": "hunter-progress-sync/v1",
946
+ "run_id": rid,
947
+ "change_key": resolved_key,
948
+ "title": display_title,
949
+ "events": batch,
950
+ },
951
+ )
952
+ except urllib.error.HTTPError as exc:
953
+ detail = exc.read().decode("utf-8", errors="replace")
954
+ return {
955
+ "ok": False,
956
+ "error": f"batch HTTP {exc.code}: {detail}",
957
+ "run_id": rid,
958
+ "uploaded": uploaded,
959
+ "quarantined": quarantined,
960
+ "acked_lines": load_cursor(change_dir),
961
+ }
962
+ except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
963
+ return {
964
+ "ok": False,
965
+ "error": f"batch failed: {exc}",
966
+ "run_id": rid,
967
+ "uploaded": uploaded,
968
+ "quarantined": quarantined,
969
+ "acked_lines": load_cursor(change_dir),
970
+ }
971
+ last_result = result
972
+ items = result.get("items") if isinstance(result, dict) else None
973
+ if not isinstance(items, list) or len(items) != len(batch):
974
+ return {
975
+ "ok": False,
976
+ "error": "batch response did not acknowledge every event",
977
+ "run_id": rid,
978
+ "uploaded": uploaded,
979
+ "quarantined": quarantined,
980
+ "acked_lines": load_cursor(change_dir),
981
+ }
982
+ response_by_id = {
983
+ str(item.get("id") or item.get("event_id") or ""): item
984
+ for item in items if isinstance(item, dict)
985
+ }
986
+ for event in batch:
987
+ event_id = str(event["event_id"])
988
+ item = response_by_id.get(event_id)
989
+ status = str((item or {}).get("status") or "")
990
+ if status in ACCEPTED_STATUSES:
991
+ uploaded += 1
992
+ continue
993
+ if status in QUARANTINE_STATUSES:
994
+ append_quarantine(
995
+ change_dir,
996
+ event_id=event_id,
997
+ line_number=line_by_event_id[event_id],
998
+ error_code=str((item or {}).get("error_code") or status.upper()),
999
+ status=status,
1000
+ )
1001
+ quarantined += 1
1002
+ continue
1003
+ return {
1004
+ "ok": False,
1005
+ "error": f"event {event_id} was not durably acknowledged",
1006
+ "run_id": rid,
1007
+ "uploaded": uploaded,
1008
+ "quarantined": quarantined,
1009
+ "acked_lines": load_cursor(change_dir),
1010
+ "server": result,
1011
+ }
1012
+
1013
+ for event_id, line_number, error_code in local_invalid:
1014
+ append_quarantine(
1015
+ change_dir,
1016
+ event_id=event_id,
1017
+ line_number=line_number,
1018
+ error_code=error_code,
1019
+ status="local_invalid",
1020
+ )
1021
+ quarantined += 1
1022
+ save_cursor(
1023
+ change_dir,
1024
+ acked + chunk_start + len(raw_chunk),
1025
+ producer_seq_base=producer_seq_base,
1026
+ )
1027
+
1028
+ main_result: dict[str, Any] = {
1029
+ "ok": True,
1030
+ "run_id": rid,
1031
+ "change_key": resolved_key,
1032
+ "uploaded": uploaded,
1033
+ "quarantined": quarantined,
1034
+ "batch_size": min(MAX_BATCH_SIZE, len(pending_raw)),
1035
+ "acked_lines": len(lines),
1036
+ "producer_seq_base": producer_seq_base,
1037
+ "server": last_result,
1038
+ }
1039
+ plan_result = _sync_plan_events_stream(
1040
+ change_dir,
1041
+ endpoint=endpoint,
1042
+ rid=rid,
1043
+ resolved_key=resolved_key,
1044
+ display_title=display_title,
1045
+ main_stream_total_lines=len(lines),
1046
+ )
1047
+ main_result["uploaded"] += int(plan_result.get("uploaded") or 0)
1048
+ main_result["quarantined"] += int(plan_result.get("quarantined") or 0)
1049
+ main_result["plan_events"] = {key: value for key, value in plan_result.items() if key != "ok"}
1050
+ if not plan_result.get("ok"):
1051
+ main_result["ok"] = False
1052
+ main_result["error"] = plan_result.get("error")
1053
+ return main_result
1054
+
1055
+
1056
+ def auto_events_sync(
1057
+ project_root: Path,
1058
+ change_dir: Path,
1059
+ *,
1060
+ heartbeat_only: bool = False,
1061
+ run_id: str | None = None,
1062
+ change_key: str | None = None,
1063
+ ) -> dict[str, Any]:
1064
+ """Best-effort platform events sync (same style as auto_push_archive_core).
1065
+
1066
+ Failures become warnings so the caller never rolls back the main workflow.
1067
+ """
1068
+ project_root = project_root.resolve()
1069
+ change_dir = change_dir.resolve()
1070
+ if load_endpoint(project_root) is None:
1071
+ return {"skipped": True, "reason": "no remote credentials"}
1072
+ try:
1073
+ result = sync_change(
1074
+ project_root,
1075
+ change_dir,
1076
+ heartbeat_only=heartbeat_only,
1077
+ run_id=run_id,
1078
+ change_key=change_key,
1079
+ )
1080
+ except Exception as exc: # noqa: BLE001 — best-effort never blocks caller
1081
+ return {
1082
+ "skipped": False,
1083
+ "ok": False,
1084
+ "warning": f"events-sync deferred: {exc}",
1085
+ }
1086
+ if result.get("skipped"):
1087
+ return {"skipped": True, "reason": result.get("reason") or "skipped"}
1088
+ if result.get("ok"):
1089
+ return {"skipped": False, "ok": True, **{
1090
+ key: result[key]
1091
+ for key in ("run_id", "change_key", "uploaded", "acked_lines", "heartbeat")
1092
+ if key in result
1093
+ }}
1094
+ return {
1095
+ "skipped": False,
1096
+ "ok": False,
1097
+ "warning": str(result.get("error") or "events-sync failed"),
1098
+ **{
1099
+ key: result[key]
1100
+ for key in ("run_id", "change_key")
1101
+ if key in result
1102
+ },
1103
+ }
1104
+
1105
+
1106
+ def _nudge_path(project_root: Path) -> Path:
1107
+ return project_root / ".harness" / "state" / "local" / "events-sync" / "nudge.json"
1108
+
1109
+
1110
+ def _agent_lock_path(project_root: Path) -> Path:
1111
+ return project_root / ".harness" / "state" / "local" / "events-sync" / "agent.lock"
1112
+
1113
+
1114
+ def _agent_waiter_lock_path(project_root: Path) -> Path:
1115
+ return project_root / ".harness" / "state" / "local" / "events-sync" / "agent-waiter.lock"
1116
+
1117
+
1118
+ class _CrossProcessLock:
1119
+ """Cross-platform advisory file lock held by an open descriptor.
1120
+
1121
+ Lock files are deliberately persistent: ownership belongs to the kernel lock,
1122
+ not mtime or file existence, so a slow live worker can never be stolen.
1123
+ """
1124
+
1125
+ def __init__(self, path: Path) -> None:
1126
+ self.path = path
1127
+ self._stream: Any | None = None
1128
+
1129
+ def acquire(self, *, blocking: bool) -> bool:
1130
+ if self._stream is not None:
1131
+ return True
1132
+ self.path.parent.mkdir(parents=True, exist_ok=True)
1133
+ stream = self.path.open("a+b", buffering=0)
1134
+ if stream.seek(0, os.SEEK_END) == 0:
1135
+ stream.write(b"\0")
1136
+ stream.flush()
1137
+ os.fsync(stream.fileno())
1138
+ while True:
1139
+ stream.seek(0)
1140
+ try:
1141
+ if os.name == "nt":
1142
+ import msvcrt
1143
+
1144
+ msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1)
1145
+ else:
1146
+ import fcntl
1147
+
1148
+ fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
1149
+ self._stream = stream
1150
+ return True
1151
+ except OSError as exc:
1152
+ if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
1153
+ stream.close()
1154
+ raise
1155
+ if not blocking:
1156
+ stream.close()
1157
+ return False
1158
+ time.sleep(0.05)
1159
+
1160
+ def release(self) -> None:
1161
+ stream = self._stream
1162
+ if stream is None:
1163
+ return
1164
+ try:
1165
+ stream.seek(0)
1166
+ if os.name == "nt":
1167
+ import msvcrt
1168
+
1169
+ msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
1170
+ else:
1171
+ import fcntl
1172
+
1173
+ fcntl.flock(stream.fileno(), fcntl.LOCK_UN)
1174
+ finally:
1175
+ stream.close()
1176
+ self._stream = None
1177
+
1178
+
1179
+ def _windowless_python_executable(
1180
+ executable: str, base_executable: str | None
1181
+ ) -> str:
1182
+ """Return the real Windows interpreter, bypassing venv forwarding launchers.
1183
+
1184
+ Virtual-environment ``python.exe`` and ``pythonw.exe`` files may both be
1185
+ forwarding launchers. The forwarding hop can start the real console
1186
+ interpreter without preserving ``CREATE_NO_WINDOW`` and make Windows
1187
+ Terminal appear. Prefer the base runtime directly; its ``pythonw.exe`` is
1188
+ ideal, while its console executable remains windowless under our explicit
1189
+ process creation flags.
1190
+ """
1191
+ executable_path = Path(executable)
1192
+ if base_executable:
1193
+ base_path = Path(base_executable)
1194
+ base_windowless = base_path.with_name("pythonw.exe")
1195
+ if base_windowless.is_file():
1196
+ return str(base_windowless)
1197
+ if base_path.is_file():
1198
+ return str(base_path)
1199
+ windowless = executable_path.with_name("pythonw.exe")
1200
+ if windowless.is_file():
1201
+ return str(windowless)
1202
+ return str(executable_path)
1203
+
1204
+
1205
+ def schedule_events_sync(project_root: Path, change_dir: Path) -> bool:
1206
+ """Wake a finite, project-local sync worker after an event is durable.
1207
+
1208
+ The wake-up record contains no credential or event payload. Multiple spawns are
1209
+ harmless: a project-local lock lets only one worker perform network requests.
1210
+ """
1211
+ project_root = project_root.resolve()
1212
+ change_dir = change_dir.resolve()
1213
+ if load_endpoint(project_root) is None:
1214
+ return False
1215
+ nudge = _nudge_path(project_root)
1216
+ nudge.parent.mkdir(parents=True, exist_ok=True)
1217
+ payload = {
1218
+ "schema_version": 1,
1219
+ "generation": uuid.uuid4().hex,
1220
+ "change": change_dir.name,
1221
+ "updated_at": datetime.now(timezone.utc).isoformat(),
1222
+ }
1223
+ temporary = nudge.with_name(f".{nudge.name}.{uuid.uuid4().hex}.tmp")
1224
+ temporary.write_text(json.dumps(payload, separators=(",", ":")) + "\n", encoding="utf-8")
1225
+ os.replace(temporary, nudge)
1226
+ interpreter = sys.executable
1227
+ if os.name == "nt":
1228
+ interpreter = _windowless_python_executable(
1229
+ sys.executable,
1230
+ getattr(sys, "_base_executable", None),
1231
+ )
1232
+ command = [
1233
+ interpreter,
1234
+ str(Path(__file__).resolve()),
1235
+ "--project",
1236
+ str(project_root),
1237
+ "--agent",
1238
+ "--json",
1239
+ ]
1240
+ kwargs: dict[str, Any] = {
1241
+ "stdin": subprocess.DEVNULL,
1242
+ "stdout": subprocess.DEVNULL,
1243
+ "stderr": subprocess.DEVNULL,
1244
+ "close_fds": True,
1245
+ }
1246
+ if os.name == "nt":
1247
+ kwargs["creationflags"] = (
1248
+ getattr(subprocess, "CREATE_NO_WINDOW", 0)
1249
+ | getattr(subprocess, "DETACHED_PROCESS", 0)
1250
+ )
1251
+ else:
1252
+ kwargs["start_new_session"] = True
1253
+ subprocess.Popen(command, **kwargs) # noqa: S603 — fixed local interpreter/script
1254
+ return True
1255
+
1256
+
1257
+ def _read_nudge_generation(project_root: Path) -> str:
1258
+ try:
1259
+ value = json.loads(_nudge_path(project_root).read_text(encoding="utf-8"))
1260
+ return str(value.get("generation") or "") if isinstance(value, dict) else ""
1261
+ except (OSError, json.JSONDecodeError):
1262
+ return ""
1263
+
1264
+
1265
+ def _discover_changes(project_root: Path) -> list[Path]:
1266
+ root = project_root / ".harness" / "changes"
1267
+ return sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
1268
+
1269
+
1270
+ def _change_has_open_phase(change_dir: Path) -> bool:
1271
+ """Project the append-only stream into open/closed phase state."""
1272
+ try:
1273
+ path = Path(harness_paths.resolve_state_dir_for_contract(change_dir)) / "events.ndjson"
1274
+ except (OSError, ValueError):
1275
+ return False
1276
+ if not path.is_file():
1277
+ return False
1278
+ open_phases: dict[str, bool] = {}
1279
+ try:
1280
+ for line in path.read_text(encoding="utf-8-sig").splitlines():
1281
+ if not line.strip():
1282
+ continue
1283
+ event = json.loads(line)
1284
+ if not isinstance(event, dict):
1285
+ continue
1286
+ phase = event.get("phase")
1287
+ event_type = event.get("type")
1288
+ if not isinstance(phase, str) or not phase:
1289
+ continue
1290
+ if event_type == "phase.start":
1291
+ open_phases[phase] = True
1292
+ elif event_type in {"phase.end", "phase.auto_sealed", "workflow.end", "run.end"}:
1293
+ open_phases[phase] = False
1294
+ except (OSError, json.JSONDecodeError):
1295
+ return False
1296
+ return any(open_phases.values())
1297
+
1298
+
1299
+ def _project_has_open_phase(project_root: Path) -> bool:
1300
+ return any(_change_has_open_phase(path) for path in _discover_changes(project_root))
1301
+
1302
+
1303
+ def _run_agent_session(project_root: Path) -> dict[str, Any]:
1304
+ attempts = 0
1305
+ uploaded = 0
1306
+ quarantined = 0
1307
+ while attempts < 6:
1308
+ generation = _read_nudge_generation(project_root)
1309
+ results = [sync_change(project_root, path) for path in _discover_changes(project_root)]
1310
+ uploaded += sum(int(item.get("uploaded") or 0) for item in results)
1311
+ quarantined += sum(int(item.get("quarantined") or 0) for item in results)
1312
+ if all(item.get("ok") for item in results):
1313
+ time.sleep(0.6)
1314
+ if generation == _read_nudge_generation(project_root):
1315
+ if not _project_has_open_phase(project_root):
1316
+ return {
1317
+ "ok": True,
1318
+ "uploaded": uploaded,
1319
+ "quarantined": quarantined,
1320
+ "attempts": attempts + 1,
1321
+ }
1322
+ # Keep a finite workflow lease alive while a phase is open.
1323
+ # A phase.end nudge is observed on the next pass and releases
1324
+ # the worker naturally; no terminal window is created.
1325
+ time.sleep(10.0)
1326
+ heartbeat_results = [
1327
+ sync_change(project_root, path, heartbeat_only=True)
1328
+ for path in _discover_changes(project_root)
1329
+ ]
1330
+ if all(item.get("ok") for item in heartbeat_results):
1331
+ attempts = 0
1332
+ continue
1333
+ results = heartbeat_results
1334
+ attempts = 0
1335
+ if generation != _read_nudge_generation(project_root):
1336
+ continue
1337
+ time.sleep(min(16.0, float(2 ** attempts)))
1338
+ attempts += 1
1339
+ return {
1340
+ "ok": False,
1341
+ "uploaded": uploaded,
1342
+ "quarantined": quarantined,
1343
+ "attempts": attempts,
1344
+ "error": "remote sync remains unavailable after bounded retries",
1345
+ }
1346
+
1347
+
1348
+ def run_agent(project_root: Path) -> dict[str, Any]:
1349
+ """Run a bounded retry session with a lossless one-worker handoff."""
1350
+ primary = _CrossProcessLock(_agent_lock_path(project_root))
1351
+ waiter: _CrossProcessLock | None = None
1352
+ if not primary.acquire(blocking=False):
1353
+ # At most one follower waits at the release boundary. Any later nudge is
1354
+ # represented by the same atomic generation record and that follower sees it.
1355
+ waiter = _CrossProcessLock(_agent_waiter_lock_path(project_root))
1356
+ if not waiter.acquire(blocking=False):
1357
+ return {"ok": True, "already_running": True}
1358
+ try:
1359
+ primary.acquire(blocking=True)
1360
+ finally:
1361
+ waiter.release()
1362
+ try:
1363
+ return _run_agent_session(project_root)
1364
+ finally:
1365
+ primary.release()
1366
+
1367
+
1368
+ def main(argv: list[str] | None = None) -> int:
1369
+ parser = argparse.ArgumentParser(description="Sync events.ndjson to platform Run monitoring")
1370
+ parser.add_argument("--project", default=".", help="Project root")
1371
+ parser.add_argument("--change-dir", help="Specific change directory")
1372
+ parser.add_argument("--run-id", help="Override stable run_id (archive finalize continuity)")
1373
+ parser.add_argument("--change-key", help="Override change_key sent to the platform")
1374
+ parser.add_argument("--heartbeat-only", action="store_true")
1375
+ parser.add_argument("--agent", action="store_true", help=argparse.SUPPRESS)
1376
+ parser.add_argument("--json", action="store_true")
1377
+ args = parser.parse_args(argv)
1378
+ project = Path(args.project).resolve()
1379
+ if args.agent:
1380
+ result = run_agent(project)
1381
+ payload = {
1382
+ "schema_version": 1,
1383
+ "ok": bool(result.get("ok")),
1384
+ "command": "events.sync.agent",
1385
+ "exit_code": 0 if result.get("ok") else 1,
1386
+ "result": result,
1387
+ }
1388
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1389
+ return int(payload["exit_code"])
1390
+ if args.change_dir:
1391
+ changes = [Path(args.change_dir).resolve()]
1392
+ else:
1393
+ root = project / ".harness" / "changes"
1394
+ changes = sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
1395
+ results = [
1396
+ sync_change(
1397
+ project,
1398
+ change_dir,
1399
+ heartbeat_only=args.heartbeat_only,
1400
+ run_id=args.run_id,
1401
+ change_key=args.change_key,
1402
+ )
1403
+ for change_dir in changes
1404
+ ]
1405
+ ok = all(item.get("ok") for item in results) if results else True
1406
+ # P5 headless envelope — stable for orchestrators.
1407
+ payload = {
1408
+ "schema_version": 1,
1409
+ "ok": ok,
1410
+ "command": "events.sync",
1411
+ "change": None,
1412
+ "phase": None,
1413
+ "exit_code": 0 if ok else 1,
1414
+ "warnings": [],
1415
+ "errors": [] if ok else [{"message": "one or more change syncs failed"}],
1416
+ "result": {"results": results},
1417
+ # Backward-compatible aliases used by early callers / tests.
1418
+ "results": results,
1419
+ }
1420
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1421
+ return 0 if ok else 1
1422
+
1423
+
1424
+ if __name__ == "__main__":
1425
+ raise SystemExit(main())