@hunter-harness/workflow-harness 0.4.0 → 0.4.1

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 +158 -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 +175 -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 +158 -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 +175 -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,2234 @@
1
+ #!/usr/bin/env python3
2
+ """Harness events.ndjson writer and execution-log renderer (D2).
3
+
4
+ Subcommands:
5
+ append — append one schema_version 3 event, then auto-render execution-log.md
6
+ render — full re-render of logs/execution-log.md from events.ndjson
7
+ summary — phase durations, event counts, and issue list (JSON)
8
+
9
+ Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import copy
16
+ import contextlib
17
+ import datetime as dt
18
+ import hashlib
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import tempfile
24
+ import time
25
+ import uuid
26
+ from pathlib import Path
27
+ from typing import Any, Sequence
28
+
29
+ SCRIPTS_DIR = Path(__file__).resolve().parent
30
+ if str(SCRIPTS_DIR) not in sys.path:
31
+ sys.path.insert(0, str(SCRIPTS_DIR))
32
+
33
+ import harness_paths # noqa: E402
34
+
35
+
36
+ def _project_root_for_change(change_dir: Path) -> Path | None:
37
+ resolved = change_dir.resolve()
38
+ for candidate in (resolved, *resolved.parents):
39
+ if candidate.name == "changes" and candidate.parent.name == ".harness":
40
+ return candidate.parent.parent
41
+ return None
42
+
43
+
44
+ def nudge_remote_sync(change_dir: Path) -> None:
45
+ """Best-effort wake-up after the event append has been flushed to disk."""
46
+ project_root = _project_root_for_change(change_dir)
47
+ if project_root is None:
48
+ return
49
+ try:
50
+ from harness_events_sync import schedule_events_sync
51
+
52
+ schedule_events_sync(project_root, change_dir)
53
+ except Exception:
54
+ # Local event durability is authoritative; remote outages never roll it back.
55
+ return
56
+
57
+
58
+ def _nudge_remote_sync(change_dir: Path) -> None:
59
+ """Compatibility wrapper for older callers and focused tests."""
60
+ nudge_remote_sync(change_dir)
61
+
62
+
63
+ if hasattr(sys.stdout, "reconfigure"):
64
+ sys.stdout.reconfigure(encoding="utf-8")
65
+ if hasattr(sys.stderr, "reconfigure"):
66
+ sys.stderr.reconfigure(encoding="utf-8")
67
+
68
+
69
+ EVENT_TYPES = frozenset(
70
+ {
71
+ "phase.start",
72
+ "phase.end",
73
+ "phase.auto_sealed",
74
+ "phase.prepare.start",
75
+ "phase.prepare.end",
76
+ "gate.blocked",
77
+ "gate.recovered",
78
+ "command",
79
+ "verification",
80
+ "artifact",
81
+ "issue",
82
+ "issue.resolve",
83
+ "decision",
84
+ "correction",
85
+ "change.rename",
86
+ }
87
+ )
88
+
89
+ SCHEMA_VERSION = 3
90
+ _NOTE_FALLBACK_MAXLEN = 60
91
+ HEADER_LINE = (
92
+ "本文件由 harness_events.py 自动渲染,请勿手工编辑;事实源为 events.ndjson"
93
+ )
94
+
95
+ # Optional fields accepted on append; only non-None values are written.
96
+ OPTIONAL_FIELDS = (
97
+ "command",
98
+ "exit_code",
99
+ "duration_ms",
100
+ "note",
101
+ "name",
102
+ "status",
103
+ "path",
104
+ "kind",
105
+ "code",
106
+ "severity",
107
+ "message",
108
+ "decision",
109
+ "reason",
110
+ "issue_id",
111
+ "scope",
112
+ "target_event_id",
113
+ "target_field",
114
+ "old_value_hash",
115
+ "new_value_json",
116
+ "run_id",
117
+ "attempt",
118
+ "executor_tool",
119
+ "executor_agent",
120
+ "executor_model",
121
+ "handoff_from_tool",
122
+ "handoff_reason",
123
+ "trace_id",
124
+ "span_id",
125
+ "parent_span_id",
126
+ "runner_ms",
127
+ "orchestration_active_ms",
128
+ "wall_clock_ms",
129
+ "user_wait_ms",
130
+ "renamed_from",
131
+ "renamed_to",
132
+ "change_uuid",
133
+ "execution_mode",
134
+ "decision_reason_code",
135
+ "fallback_reason_code",
136
+ "trigger",
137
+ "from_phase",
138
+ "result_status",
139
+ )
140
+
141
+ _PROVENANCE_FIELDS = frozenset(
142
+ {
143
+ "run_id",
144
+ "attempt",
145
+ "executor_tool",
146
+ "executor_agent",
147
+ "executor_model",
148
+ "handoff_from_tool",
149
+ "handoff_reason",
150
+ "trace_id",
151
+ "span_id",
152
+ "parent_span_id",
153
+ "runner_ms",
154
+ "orchestration_active_ms",
155
+ "wall_clock_ms",
156
+ "user_wait_ms",
157
+ "execution_mode",
158
+ "decision_reason_code",
159
+ "fallback_reason_code",
160
+ "trigger",
161
+ "from_phase",
162
+ "result_status",
163
+ }
164
+ )
165
+ _REVIEW_DECISION_REASON_CODES = frozenset({"REVIEW_DELEGATED"})
166
+ _REVIEW_FALLBACK_REASON_CODES = frozenset(
167
+ {
168
+ "REVIEW_INLINE_UNAVAILABLE",
169
+ "REVIEW_INLINE_SPAWN_FAILED",
170
+ "REVIEW_INLINE_INVALID_RESULT",
171
+ }
172
+ )
173
+ _EVENT_ALLOWED_FIELDS = {
174
+ "phase.start": frozenset({"note"}) | _PROVENANCE_FIELDS,
175
+ "phase.prepare.start": frozenset({"note"}) | _PROVENANCE_FIELDS,
176
+ "phase.prepare.end": frozenset(
177
+ {"status", "code", "message", "reason", "note", "duration_ms"}
178
+ )
179
+ | _PROVENANCE_FIELDS,
180
+ "phase.end": frozenset(
181
+ {"status", "duration_ms", "note", "reason", "issue_id"}
182
+ )
183
+ | _PROVENANCE_FIELDS,
184
+ "phase.auto_sealed": frozenset({"status", "reason", "note"}) | _PROVENANCE_FIELDS,
185
+ "gate.blocked": frozenset({"status", "code", "reason", "note"})
186
+ | _PROVENANCE_FIELDS,
187
+ "gate.recovered": frozenset({"status", "code", "reason", "note"})
188
+ | _PROVENANCE_FIELDS,
189
+ "command": frozenset({"command", "exit_code", "duration_ms", "note"})
190
+ | _PROVENANCE_FIELDS,
191
+ "verification": frozenset(
192
+ {"name", "status", "reason", "command", "exit_code", "duration_ms", "note"}
193
+ )
194
+ | _PROVENANCE_FIELDS,
195
+ "artifact": frozenset({"path", "kind", "note"}) | _PROVENANCE_FIELDS,
196
+ "issue": frozenset({"code", "scope", "severity", "message", "reason", "note"})
197
+ | frozenset({"issue_id"})
198
+ | _PROVENANCE_FIELDS,
199
+ "issue.resolve": frozenset({"issue_id", "reason", "note"})
200
+ | _PROVENANCE_FIELDS,
201
+ "decision": frozenset({"decision", "reason", "note"}) | _PROVENANCE_FIELDS,
202
+ "correction": frozenset(
203
+ {
204
+ "target_event_id",
205
+ "target_field",
206
+ "old_value_hash",
207
+ "new_value_json",
208
+ "reason",
209
+ "note",
210
+ }
211
+ )
212
+ | _PROVENANCE_FIELDS,
213
+ "change.rename": frozenset(
214
+ {"renamed_from", "renamed_to", "change_uuid", "note"}
215
+ )
216
+ | _PROVENANCE_FIELDS,
217
+ }
218
+ _EVENT_REQUIRED_FIELDS = {
219
+ "issue": ("severity",),
220
+ "issue.resolve": ("issue_id", "reason"),
221
+ "verification": ("name", "status"),
222
+ "correction": (
223
+ "target_event_id",
224
+ "target_field",
225
+ "old_value_hash",
226
+ "new_value_json",
227
+ "reason",
228
+ ),
229
+ "phase.auto_sealed": ("reason",),
230
+ "phase.prepare.end": ("status",),
231
+ }
232
+ _CORRECTION_PROTECTED_FIELDS = frozenset(
233
+ {"schema_version", "id", "timestamp", "phase", "type"}
234
+ )
235
+
236
+ # HH-WF-20260730-001: reasons a write-path auto-seal may attribute to an
237
+ # attempt that was still open when it was closed on its behalf.
238
+ _AUTO_SEAL_REASONS = frozenset(
239
+ {"executor_lost", "user_wait", "external_wait", "superseded", "unknown"}
240
+ )
241
+
242
+ # Best-effort mapping from wait-style metadata events (seen inside an open
243
+ # attempt's own event stream) to the auto-seal reason they imply.
244
+ _WAIT_EVENT_REASON_MAP = {
245
+ "user.wait": "user_wait",
246
+ "environment.wait": "external_wait",
247
+ "env.wait": "external_wait",
248
+ "external.wait": "external_wait",
249
+ "ci.wait": "external_wait",
250
+ }
251
+ _RECOVERY_EVENT_TYPES = frozenset({"recovery", "phase.recovery", "attempt.recovery"})
252
+
253
+ # Terminal event types that close a phase attempt (RET/HH-WF-20260730-001).
254
+ # ``phase.end`` is an explicit human/automation-reported terminal; the
255
+ # write path also inserts ``phase.auto_sealed`` when a new ``phase.start``
256
+ # (or another terminal path) finds a still-open prior attempt.
257
+ TERMINAL_PHASE_EVENT_TYPES = frozenset({"phase.end", "phase.auto_sealed"})
258
+
259
+
260
+ def now_iso() -> str:
261
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
262
+
263
+
264
+ def emit_json(payload: dict[str, Any], *, as_json: bool) -> None:
265
+ if as_json:
266
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
267
+ else:
268
+ ok = payload.get("ok", True)
269
+ msg = payload.get("message") or payload.get("path") or ("ok" if ok else "error")
270
+ sys.stdout.write(f"{msg}\n")
271
+
272
+
273
+ def emit_error(
274
+ message: str,
275
+ *,
276
+ as_json: bool,
277
+ code: int = 1,
278
+ error_code: str | None = None,
279
+ ) -> int:
280
+ payload = {"ok": False, "error": message}
281
+ if error_code:
282
+ payload["code"] = error_code
283
+ if as_json:
284
+ sys.stderr.write(json.dumps(payload, ensure_ascii=False) + "\n")
285
+ else:
286
+ sys.stderr.write(f"error: {message}\n")
287
+ return code
288
+
289
+
290
+ def resolve_change_dir(raw: str) -> Path:
291
+ return Path(raw).expanduser().resolve()
292
+
293
+
294
+ def _state_dir(change_dir: Path) -> Path:
295
+ return Path(harness_paths.resolve_state_dir_for_contract(change_dir))
296
+
297
+
298
+ def events_path(change_dir: Path) -> Path:
299
+ return _state_dir(change_dir) / "events.ndjson"
300
+
301
+
302
+ def execution_log_path(change_dir: Path) -> Path:
303
+ return _state_dir(change_dir) / "logs" / "execution-log.md"
304
+
305
+
306
+ def archived_change_dir(change_dir: Path) -> Path | None:
307
+ """Return the matching archive when change_dir follows the project layout."""
308
+ changes_root = change_dir.parent
309
+ harness_root = changes_root.parent
310
+ if changes_root.name != "changes" or harness_root.name != ".harness":
311
+ return None
312
+ archive_root = harness_root / "archive"
313
+ if not archive_root.is_dir():
314
+ return None
315
+ suffix = "-" + change_dir.name
316
+ matches = sorted(
317
+ candidate for candidate in archive_root.iterdir()
318
+ if candidate.is_dir() and (
319
+ candidate.name == change_dir.name or candidate.name.endswith(suffix)
320
+ )
321
+ )
322
+ return matches[-1] if matches else None
323
+
324
+
325
+ def parse_timestamp(value: Any) -> dt.datetime | None:
326
+ if not isinstance(value, str) or not value.strip():
327
+ return None
328
+ text = value.strip()
329
+ if text.endswith("Z"):
330
+ text = text[:-1] + "+00:00"
331
+ try:
332
+ return dt.datetime.fromisoformat(text)
333
+ except ValueError:
334
+ return None
335
+
336
+
337
+ def duration_ms_between(start: Any, end: Any) -> int | None:
338
+ start_dt = parse_timestamp(start)
339
+ end_dt = parse_timestamp(end)
340
+ if start_dt is None or end_dt is None:
341
+ return None
342
+ return max(0, int((end_dt - start_dt).total_seconds() * 1000))
343
+
344
+
345
+ def normalize_event(raw: dict[str, Any]) -> dict[str, Any]:
346
+ """Normalize schema_version 1/2/3 events for rendering and summary."""
347
+ event = dict(raw)
348
+ version = event.get("schema_version", 1)
349
+ try:
350
+ version_int = int(version)
351
+ except (TypeError, ValueError):
352
+ version_int = 1
353
+ event["schema_version"] = version_int
354
+ if version_int < SCHEMA_VERSION and "schemaValidation" not in event:
355
+ event["schemaValidation"] = "legacy"
356
+ if "note" not in event or event["note"] is None:
357
+ event["note"] = ""
358
+ return event
359
+
360
+
361
+ def canonical_value_hash(value: Any) -> str:
362
+ """Return the stable optimistic-concurrency hash for a corrected value."""
363
+ payload = json.dumps(
364
+ value,
365
+ ensure_ascii=False,
366
+ sort_keys=True,
367
+ separators=(",", ":"),
368
+ ).encode("utf-8")
369
+ return "sha256:" + hashlib.sha256(payload).hexdigest()
370
+
371
+
372
+ def apply_event_corrections(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
373
+ """Project append-only correction events without mutating event history.
374
+
375
+ Corrections may target only earlier events and use an old-value hash as an
376
+ optimistic-concurrency guard. The correction records remain in the raw
377
+ stream; projections return only the corrected domain events.
378
+ """
379
+ projected: list[dict[str, Any]] = []
380
+ by_id: dict[str, dict[str, Any]] = {}
381
+ for raw in events:
382
+ event = copy.deepcopy(raw)
383
+ if event.get("type") != "correction":
384
+ projected.append(event)
385
+ event_id = str(event.get("id") or "").strip()
386
+ if event_id:
387
+ by_id[event_id] = event
388
+ continue
389
+
390
+ correction_id = str(event.get("id") or "<unknown>")
391
+ target_id = str(event.get("target_event_id") or "").strip()
392
+ target = by_id.get(target_id)
393
+ if target is None:
394
+ raise ValueError(
395
+ f"CORRECTION_TARGET_NOT_FOUND: {correction_id} targets {target_id}"
396
+ )
397
+ field = str(event.get("target_field") or "").strip()
398
+ if not field or field in _CORRECTION_PROTECTED_FIELDS:
399
+ raise ValueError(
400
+ f"CORRECTION_FIELD_NOT_ALLOWED: {correction_id} targets {field}"
401
+ )
402
+ actual_hash = canonical_value_hash(target.get(field))
403
+ expected_hash = str(event.get("old_value_hash") or "").strip()
404
+ if actual_hash != expected_hash:
405
+ raise ValueError(
406
+ "CORRECTION_OLD_VALUE_MISMATCH: "
407
+ f"{correction_id} expected {expected_hash}, found {actual_hash}"
408
+ )
409
+ target[field] = copy.deepcopy(event.get("new_value"))
410
+ return projected
411
+
412
+
413
+ def _issue_identity(event: dict[str, Any]) -> str:
414
+ explicit = str(event.get("issue_id") or "").strip()
415
+ if explicit:
416
+ return explicit
417
+ code = str(event.get("code") or "").strip()
418
+ scope = str(event.get("scope") or "").strip()
419
+ if code:
420
+ return f"code:{code}|scope:{scope}"
421
+ return str(event.get("id") or "").strip()
422
+
423
+
424
+ def current_issues(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
425
+ """Return unresolved issues while preserving the full append-only history."""
426
+ active: dict[str, dict[str, Any]] = {}
427
+ for event in apply_event_corrections(events):
428
+ event_type = event.get("type")
429
+ if event_type == "issue":
430
+ identity = _issue_identity(event)
431
+ if identity:
432
+ item = copy.deepcopy(event)
433
+ item["issue_id"] = identity
434
+ active[identity] = item
435
+ elif event_type == "issue.resolve":
436
+ active.pop(_issue_identity(event), None)
437
+ elif event_type == "phase.end" and str(event.get("status") or "").upper() == "OK":
438
+ identity = str(event.get("issue_id") or "").strip()
439
+ if not identity:
440
+ continue
441
+ phase = event.get("phase")
442
+ attempt = event.get("attempt")
443
+ issue = active.get(identity)
444
+ if issue is None or issue.get("phase") != phase:
445
+ continue
446
+ issue_attempt = issue.get("attempt")
447
+ if (
448
+ isinstance(attempt, int)
449
+ and isinstance(issue_attempt, int)
450
+ and issue_attempt >= attempt
451
+ ):
452
+ continue
453
+ active.pop(identity, None)
454
+ return list(active.values())
455
+
456
+
457
+ def load_events(path: Path) -> list[dict[str, Any]]:
458
+ if not path.exists():
459
+ return []
460
+ events: list[dict[str, Any]] = []
461
+ text = path.read_text(encoding="utf-8-sig")
462
+ for line_no, line in enumerate(text.splitlines(), start=1):
463
+ stripped = line.strip()
464
+ if not stripped:
465
+ continue
466
+ try:
467
+ obj = json.loads(stripped)
468
+ except json.JSONDecodeError as exc:
469
+ raise ValueError(f"invalid JSON at {path} line {line_no}: {exc}") from exc
470
+ if not isinstance(obj, dict):
471
+ raise ValueError(f"event at {path} line {line_no} is not an object")
472
+ events.append(normalize_event(obj))
473
+ return events
474
+
475
+
476
+ def merge_event_files(paths: list[Path]) -> list[dict[str, Any]]:
477
+ """Union events from multiple NDJSON files by event ID (UT-001/RET-05).
478
+
479
+ Each event ID appears exactly once; first-seen copy wins. Missing IDs are
480
+ kept keyed by (file index, line number) so unidentified events are never
481
+ silently dropped.
482
+ """
483
+ merged: list[dict[str, Any]] = []
484
+ seen: set[str] = set()
485
+ for path in paths:
486
+ for event in load_events(Path(path)):
487
+ event_id = str(event.get("id") or "").strip()
488
+ if event_id:
489
+ if event_id in seen:
490
+ continue
491
+ seen.add(event_id)
492
+ merged.append(event)
493
+ return merged
494
+
495
+
496
+ def atomic_append_line(path: Path, line: str) -> None:
497
+ """Write line to a temp file first, then append to the target (append-only)."""
498
+ path.parent.mkdir(parents=True, exist_ok=True)
499
+ payload = line if line.endswith("\n") else line + "\n"
500
+ # Ensure UTF-8 without BOM for the temp payload.
501
+ data = payload.encode("utf-8")
502
+ fd, tmp_name = tempfile.mkstemp(
503
+ prefix=f".{path.name}.",
504
+ suffix=".tmp",
505
+ dir=str(path.parent),
506
+ )
507
+ tmp_path = Path(tmp_name)
508
+ try:
509
+ with os.fdopen(fd, "wb") as tmp_f:
510
+ tmp_f.write(data)
511
+ tmp_f.flush()
512
+ os.fsync(tmp_f.fileno())
513
+ with path.open("ab") as out_f:
514
+ out_f.write(data)
515
+ out_f.flush()
516
+ os.fsync(out_f.fileno())
517
+ finally:
518
+ try:
519
+ tmp_path.unlink(missing_ok=True)
520
+ except OSError:
521
+ pass
522
+
523
+
524
+ @contextlib.contextmanager
525
+ def event_file_lock(lock_path: Path, timeout_seconds: float = 10.0):
526
+ """Acquire a cross-process exclusive lock or raise TimeoutError.
527
+
528
+ §6.2: Windows uses msvcrt.locking; POSIX uses fcntl.flock. The lock file is
529
+ ``<change-dir>/events.ndjson.lock``. On timeout the caller must fail non-zero
530
+ -- never continue without a lock. ``finally`` always unlocks and closes.
531
+ """
532
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
533
+ # Ensure at least 1 byte exists; msvcrt.locking on an empty file can misbehave
534
+ # on some Windows versions.
535
+ if not lock_path.exists() or lock_path.stat().st_size == 0:
536
+ with open(lock_path, "ab") as seed_f:
537
+ seed_f.write(b"\0")
538
+ handle = open(lock_path, "r+b")
539
+ try:
540
+ handle.seek(0)
541
+ _acquire_file_lock(handle, lock_path, timeout_seconds)
542
+ try:
543
+ yield
544
+ finally:
545
+ _release_file_lock(handle)
546
+ finally:
547
+ handle.close()
548
+
549
+
550
+ def _acquire_file_lock(handle, lock_path: Path, timeout_seconds: float) -> None:
551
+ deadline = time.monotonic() + timeout_seconds
552
+ if os.name == "nt":
553
+ import msvcrt
554
+
555
+ while True:
556
+ try:
557
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
558
+ return
559
+ except OSError:
560
+ if time.monotonic() >= deadline:
561
+ raise TimeoutError(
562
+ f"timeout acquiring event lock {lock_path} after {timeout_seconds}s"
563
+ )
564
+ time.sleep(0.02)
565
+ else:
566
+ import fcntl
567
+
568
+ while True:
569
+ try:
570
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
571
+ return
572
+ except OSError:
573
+ if time.monotonic() >= deadline:
574
+ raise TimeoutError(
575
+ f"timeout acquiring event lock {lock_path} after {timeout_seconds}s"
576
+ )
577
+ time.sleep(0.02)
578
+
579
+
580
+ def _release_file_lock(handle) -> None:
581
+ try:
582
+ if os.name == "nt":
583
+ import msvcrt
584
+
585
+ handle.seek(0)
586
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
587
+ else:
588
+ import fcntl
589
+
590
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
591
+ except OSError:
592
+ pass
593
+
594
+
595
+ def new_event_id(existing: list[dict[str, Any]] | None = None) -> str:
596
+ """Return a full-entropy event id.
597
+
598
+ §6.2: UUID 不需要扫描历史去重;直接使用完整 ``uuid.uuid4().hex``。
599
+ ``existing`` is accepted for backward-compat callers but intentionally unused.
600
+ """
601
+ return f"evt-{uuid.uuid4().hex}"
602
+
603
+
604
+ def append_event(
605
+ change_dir: Path,
606
+ *,
607
+ phase: str,
608
+ type_: str,
609
+ note: str = "",
610
+ status: str | None = None,
611
+ code: str | None = None,
612
+ message: str | None = None,
613
+ reason: str | None = None,
614
+ duration_ms: int | None = None,
615
+ kind: str | None = None,
616
+ path: str | None = None,
617
+ run_id: str | None = None,
618
+ attempt: int | None = None,
619
+ executor_tool: str | None = None,
620
+ executor_agent: str | None = None,
621
+ executor_model: str | None = None,
622
+ execution_mode: str | None = None,
623
+ decision_reason_code: str | None = None,
624
+ fallback_reason_code: str | None = None,
625
+ trigger: str | None = None,
626
+ from_phase: str | None = None,
627
+ result_status: str | None = None,
628
+ runner_ms: int | None = None,
629
+ orchestration_active_ms: int | None = None,
630
+ wall_clock_ms: int | None = None,
631
+ user_wait_ms: int | None = None,
632
+ renamed_from: str | None = None,
633
+ renamed_to: str | None = None,
634
+ change_uuid: str | None = None,
635
+ ) -> dict[str, Any]:
636
+ """Programmatic append API (retro §5.31 C5/T16).
637
+
638
+ Validates and appends an event without going through argparse. Returns
639
+ a payload dict with ``ok``/``code``/``event``.
640
+ """
641
+ args = argparse.Namespace(
642
+ change_dir=str(change_dir),
643
+ phase=phase,
644
+ type=type_,
645
+ note=note,
646
+ kind=kind,
647
+ path=path,
648
+ run_id=run_id,
649
+ executor_tool=executor_tool,
650
+ command=None,
651
+ exit_code=None,
652
+ duration_ms=duration_ms,
653
+ status=status,
654
+ name=None,
655
+ code=code,
656
+ severity=None,
657
+ message=message,
658
+ decision=None,
659
+ reason=reason,
660
+ issue_id=None,
661
+ scope=None,
662
+ target_event_id=None,
663
+ target_field=None,
664
+ old_value_hash=None,
665
+ new_value_json=None,
666
+ renamed_from=renamed_from,
667
+ renamed_to=renamed_to,
668
+ change_uuid=change_uuid,
669
+ attempt=attempt,
670
+ executor_agent=executor_agent,
671
+ executor_model=executor_model,
672
+ handoff_from_tool=None,
673
+ handoff_reason=None,
674
+ trace_id=None,
675
+ span_id=None,
676
+ parent_span_id=None,
677
+ runner_ms=runner_ms,
678
+ orchestration_active_ms=orchestration_active_ms,
679
+ wall_clock_ms=wall_clock_ms,
680
+ user_wait_ms=user_wait_ms,
681
+ execution_mode=execution_mode,
682
+ decision_reason_code=decision_reason_code,
683
+ fallback_reason_code=fallback_reason_code,
684
+ trigger=trigger,
685
+ from_phase=from_phase,
686
+ result_status=result_status,
687
+ legacy_lenient=False,
688
+ json=True,
689
+ )
690
+ as_json = True
691
+ if type_ not in EVENT_TYPES:
692
+ return {"ok": False, "code": "EVENT_TYPE_INVALID", "message": f"unsupported type: {type_}"}
693
+ validation = validate_append_event(args)
694
+ if validation:
695
+ error_code, message = validation
696
+ return {"ok": False, "code": error_code, "message": message}
697
+ archived = archived_change_dir(change_dir)
698
+ if archived is not None:
699
+ return {"ok": False, "code": "ARCHIVED_CHANGE_IMMUTABLE", "message": str(archived)}
700
+ events_path_obj = events_path(change_dir)
701
+ event = build_event(args, [])
702
+ result = append_with_auto_seal(events_path_obj, event)
703
+ if result.get("phaseAlreadyClosed"):
704
+ return {
705
+ "ok": False,
706
+ "code": "PHASE_ALREADY_CLOSED",
707
+ "message": (
708
+ "PHASE_ALREADY_CLOSED: refusing a second phase.end for the "
709
+ "same attempt. A retry needs a fresh run id AND the next "
710
+ "attempt number (attempts are global per phase, not per run id)."
711
+ ),
712
+ "event": event,
713
+ "autoSealed": result.get("autoSealed") or [],
714
+ }
715
+ _nudge_remote_sync(change_dir)
716
+ return {
717
+ "ok": True,
718
+ "event": event,
719
+ "events_path": str(events_path_obj),
720
+ "rendered": False,
721
+ "autoSealed": result.get("autoSealed") or [],
722
+ }
723
+
724
+
725
+ def batch_append_events(
726
+ change_dir: Path,
727
+ events: Sequence[dict[str, Any]],
728
+ ) -> dict[str, Any]:
729
+ """Validate then append many events under one lock (Wave-2 H-15).
730
+
731
+ Any invalid item aborts the whole batch before mutation. ``phase.end`` items
732
+ are rejected here — use single ``append`` so render semantics stay explicit.
733
+ """
734
+ if not isinstance(events, (list, tuple)) or not events:
735
+ return {
736
+ "ok": False,
737
+ "code": "BATCH_EMPTY",
738
+ "message": "batch-append requires a non-empty events array",
739
+ }
740
+ archived = archived_change_dir(change_dir)
741
+ if archived is not None:
742
+ return {
743
+ "ok": False,
744
+ "code": "ARCHIVED_CHANGE_IMMUTABLE",
745
+ "message": str(archived),
746
+ }
747
+
748
+ built: list[dict[str, Any]] = []
749
+ for index, raw in enumerate(events):
750
+ if not isinstance(raw, dict):
751
+ return {
752
+ "ok": False,
753
+ "code": "BATCH_ITEM_INVALID",
754
+ "message": f"events[{index}] must be an object",
755
+ "index": index,
756
+ }
757
+ type_ = str(raw.get("type") or "").strip()
758
+ phase = str(raw.get("phase") or "").strip()
759
+ if not type_ or type_ not in EVENT_TYPES:
760
+ return {
761
+ "ok": False,
762
+ "code": "EVENT_TYPE_INVALID",
763
+ "message": f"events[{index}]: unsupported type: {type_}",
764
+ "index": index,
765
+ }
766
+ if not phase:
767
+ return {
768
+ "ok": False,
769
+ "code": "EVENT_REQUIRED_FIELD",
770
+ "message": f"events[{index}]: phase required",
771
+ "index": index,
772
+ }
773
+ if type_ == "phase.end":
774
+ return {
775
+ "ok": False,
776
+ "code": "BATCH_PHASE_END_FORBIDDEN",
777
+ "message": f"events[{index}]: phase.end must use single append",
778
+ "index": index,
779
+ }
780
+ args = argparse.Namespace(
781
+ change_dir=str(change_dir),
782
+ phase=phase,
783
+ type=type_,
784
+ note=raw.get("note"),
785
+ kind=raw.get("kind"),
786
+ path=raw.get("path"),
787
+ run_id=raw.get("run_id"),
788
+ executor_tool=raw.get("executor_tool"),
789
+ command=raw.get("command"),
790
+ exit_code=raw.get("exit_code"),
791
+ duration_ms=raw.get("duration_ms"),
792
+ status=raw.get("status"),
793
+ name=raw.get("name"),
794
+ code=raw.get("code"),
795
+ severity=raw.get("severity"),
796
+ message=raw.get("message"),
797
+ decision=raw.get("decision"),
798
+ reason=raw.get("reason"),
799
+ issue_id=raw.get("issue_id"),
800
+ scope=raw.get("scope"),
801
+ target_event_id=raw.get("target_event_id"),
802
+ target_field=raw.get("target_field"),
803
+ old_value_hash=raw.get("old_value_hash"),
804
+ new_value_json=raw.get("new_value_json"),
805
+ renamed_from=raw.get("renamed_from"),
806
+ renamed_to=raw.get("renamed_to"),
807
+ change_uuid=raw.get("change_uuid"),
808
+ attempt=raw.get("attempt"),
809
+ executor_agent=raw.get("executor_agent"),
810
+ executor_model=raw.get("executor_model"),
811
+ handoff_from_tool=raw.get("handoff_from_tool"),
812
+ handoff_reason=raw.get("handoff_reason"),
813
+ trace_id=raw.get("trace_id"),
814
+ span_id=raw.get("span_id"),
815
+ parent_span_id=raw.get("parent_span_id"),
816
+ runner_ms=raw.get("runner_ms"),
817
+ orchestration_active_ms=raw.get("orchestration_active_ms"),
818
+ wall_clock_ms=raw.get("wall_clock_ms"),
819
+ user_wait_ms=raw.get("user_wait_ms"),
820
+ legacy_lenient=False,
821
+ json=True,
822
+ )
823
+ validation = validate_append_event(args)
824
+ if validation:
825
+ error_code, message = validation
826
+ return {
827
+ "ok": False,
828
+ "code": error_code,
829
+ "message": f"events[{index}]: {message}",
830
+ "index": index,
831
+ }
832
+ built.append(build_event(args, []))
833
+
834
+ events_path_obj = events_path(change_dir)
835
+ lock_path = events_path_obj.with_name(events_path_obj.name + ".lock")
836
+ with event_file_lock(lock_path):
837
+ for event in built:
838
+ atomic_append_line(
839
+ events_path_obj, json.dumps(event, ensure_ascii=False, separators=(",", ":"))
840
+ )
841
+ _nudge_remote_sync(change_dir)
842
+ return {
843
+ "ok": True,
844
+ "action": "batch-append",
845
+ "count": len(built),
846
+ "events": built,
847
+ "events_path": str(events_path_obj),
848
+ "rendered": False,
849
+ }
850
+
851
+
852
+ def build_event(args: argparse.Namespace, existing: list[dict[str, Any]]) -> dict[str, Any]:
853
+ event: dict[str, Any] = {
854
+ "schema_version": SCHEMA_VERSION,
855
+ "id": new_event_id(existing),
856
+ "timestamp": now_iso(),
857
+ "phase": args.phase,
858
+ "type": args.type,
859
+ }
860
+ for field in OPTIONAL_FIELDS:
861
+ value = getattr(args, field, None)
862
+ if value is None:
863
+ continue
864
+ if field == "new_value_json":
865
+ event["new_value"] = json.loads(value)
866
+ else:
867
+ event[field] = value
868
+ if args.type == "issue" and not event.get("issue_id"):
869
+ event["issue_id"] = _issue_identity(event)
870
+ environment_defaults = {
871
+ "run_id": "HUNTER_HARNESS_RUN_ID",
872
+ "executor_tool": "HUNTER_HARNESS_TOOL",
873
+ "executor_agent": "HUNTER_HARNESS_AGENT",
874
+ "executor_model": "HUNTER_HARNESS_MODEL",
875
+ }
876
+ for field, env_name in environment_defaults.items():
877
+ if field not in event and os.environ.get(env_name):
878
+ event[field] = os.environ[env_name]
879
+ if "note" not in event:
880
+ event["note"] = ""
881
+ return event
882
+
883
+
884
+ def validate_append_event(args: argparse.Namespace) -> tuple[str, str] | None:
885
+ """Validate type-specific append fields before any file is mutated."""
886
+ event_type = str(args.type)
887
+ for field in _EVENT_REQUIRED_FIELDS.get(event_type, ()):
888
+ value = getattr(args, field, None)
889
+ if value is None or (isinstance(value, str) and not value.strip()):
890
+ return (
891
+ "EVENT_REQUIRED_FIELD",
892
+ f"EVENT_REQUIRED_FIELD: {event_type} requires --{field.replace('_', '-')}",
893
+ )
894
+ allowed = _EVENT_ALLOWED_FIELDS[event_type]
895
+ for field in OPTIONAL_FIELDS:
896
+ value = getattr(args, field, None)
897
+ if value is not None and field not in allowed:
898
+ return (
899
+ "EVENT_FIELD_NOT_ALLOWED",
900
+ "EVENT_FIELD_NOT_ALLOWED: "
901
+ f"{event_type} does not accept --{field.replace('_', '-')}",
902
+ )
903
+ for field, length in (("trace_id", 32), ("span_id", 16), ("parent_span_id", 16)):
904
+ value = getattr(args, field, None)
905
+ if value is not None and not re.fullmatch(rf"[0-9a-f]{{{length}}}", str(value)):
906
+ return (
907
+ "EVENT_TRACE_FIELD_INVALID",
908
+ f"EVENT_TRACE_FIELD_INVALID: --{field.replace('_', '-')} must be {length} lowercase hex characters",
909
+ )
910
+ for field in (
911
+ "runner_ms",
912
+ "orchestration_active_ms",
913
+ "wall_clock_ms",
914
+ "user_wait_ms",
915
+ ):
916
+ value = getattr(args, field, None)
917
+ if value is not None and (not isinstance(value, int) or value < 0):
918
+ return (
919
+ "EVENT_TIMING_FIELD_INVALID",
920
+ f"EVENT_TIMING_FIELD_INVALID: --{field.replace('_', '-')} must be a nonnegative integer",
921
+ )
922
+ if event_type == "decision" and str(getattr(args, "phase", "")) == "review":
923
+ body = "\n".join(
924
+ str(getattr(args, field, "") or "")
925
+ for field in ("decision", "reason", "note")
926
+ )
927
+ if re.search(r"\bREVIEW_[A-Z0-9_]+\b", body):
928
+ return (
929
+ "EVENT_REVIEW_REASON_IN_BODY",
930
+ "EVENT_REVIEW_REASON_IN_BODY: 评审原因码只能写入结构化字段,正文请使用中文说明",
931
+ )
932
+ execution_mode = str(getattr(args, "execution_mode", "") or "").strip()
933
+ decision_code = str(
934
+ getattr(args, "decision_reason_code", "") or ""
935
+ ).strip()
936
+ fallback_code = str(
937
+ getattr(args, "fallback_reason_code", "") or ""
938
+ ).strip()
939
+ structured = bool(execution_mode or decision_code or fallback_code)
940
+ if structured and execution_mode not in {"delegated", "inline"}:
941
+ return (
942
+ "EVENT_REVIEW_EXECUTION_MODE_INVALID",
943
+ "EVENT_REVIEW_EXECUTION_MODE_INVALID: execution-mode 必须为 delegated 或 inline",
944
+ )
945
+ if decision_code and decision_code not in _REVIEW_DECISION_REASON_CODES:
946
+ return (
947
+ "EVENT_REVIEW_REASON_INVALID",
948
+ "EVENT_REVIEW_REASON_INVALID: 未知的评审委派原因码",
949
+ )
950
+ if fallback_code and fallback_code not in _REVIEW_FALLBACK_REASON_CODES:
951
+ return (
952
+ "EVENT_REVIEW_REASON_INVALID",
953
+ "EVENT_REVIEW_REASON_INVALID: 未知的评审回退原因码",
954
+ )
955
+ if execution_mode == "delegated":
956
+ if (
957
+ decision_code != "REVIEW_DELEGATED"
958
+ or fallback_code
959
+ or not str(getattr(args, "executor_agent", "") or "").strip()
960
+ ):
961
+ return (
962
+ "EVENT_REVIEW_REASON_INVALID",
963
+ "EVENT_REVIEW_REASON_INVALID: 委派评审必须记录执行 Agent 和 REVIEW_DELEGATED",
964
+ )
965
+ if execution_mode == "inline" and (
966
+ decision_code or fallback_code not in _REVIEW_FALLBACK_REASON_CODES
967
+ ):
968
+ return (
969
+ "EVENT_REVIEW_REASON_INVALID",
970
+ "EVENT_REVIEW_REASON_INVALID: 主会话评审必须记录一个稳定的回退原因码",
971
+ )
972
+ if event_type == "phase.auto_sealed":
973
+ reason_value = str(getattr(args, "reason", "") or "").strip()
974
+ if reason_value and reason_value not in _AUTO_SEAL_REASONS:
975
+ return (
976
+ "EVENT_REASON_INVALID",
977
+ "EVENT_REASON_INVALID: phase.auto_sealed --reason must be one of "
978
+ f"{sorted(_AUTO_SEAL_REASONS)}",
979
+ )
980
+ if event_type == "correction":
981
+ target_field = str(getattr(args, "target_field", "") or "").strip()
982
+ if target_field in _CORRECTION_PROTECTED_FIELDS:
983
+ return (
984
+ "CORRECTION_FIELD_NOT_ALLOWED",
985
+ f"CORRECTION_FIELD_NOT_ALLOWED: cannot correct {target_field}",
986
+ )
987
+ try:
988
+ json.loads(str(getattr(args, "new_value_json", "")))
989
+ except json.JSONDecodeError as exc:
990
+ return (
991
+ "CORRECTION_VALUE_INVALID_JSON",
992
+ f"CORRECTION_VALUE_INVALID_JSON: {exc}",
993
+ )
994
+ # Retro 2026-07-21 H-8: artifact events always require a non-empty path.
995
+ # Knowledge/exploration notes must use issue/decision, not pathless artifacts.
996
+ if event_type == "artifact":
997
+ path = str(getattr(args, "path", "") or "").strip()
998
+ if not path:
999
+ return (
1000
+ "ARTIFACT_PATH_REQUIRED",
1001
+ "ARTIFACT_PATH_REQUIRED: artifact requires --path "
1002
+ "(use issue/decision for informational notes)",
1003
+ )
1004
+ return None
1005
+
1006
+
1007
+ def status_symbol(status: Any, reason: Any = None) -> str:
1008
+ text = str(status or "").strip().lower()
1009
+ reason_text = str(reason or "").strip()
1010
+ if not text:
1011
+ return "—"
1012
+ ok_set = {"ok", "passed", "pass", "success", "green", "✅", "✅ok"}
1013
+ warn_set = {"warn", "warning", "yellow", "skipped", "skip", "🟡", "🟡warn"}
1014
+ fail_set = {"fail", "failed", "error", "red", "blocked", "❌", "❌fail"}
1015
+ if text in ok_set or text.startswith("ok") or "✅" in text:
1016
+ return "✅OK"
1017
+ if text in warn_set or "warn" in text or "🟡" in text:
1018
+ return f"🟡WARN({reason_text})" if reason_text else "🟡WARN"
1019
+ if text in fail_set or "fail" in text or "error" in text or "❌" in text:
1020
+ return f"❌FAIL({reason_text})" if reason_text else "❌FAIL"
1021
+ if reason_text:
1022
+ return f"{status}({reason_text})"
1023
+ return str(status)
1024
+
1025
+
1026
+ def severity_symbol(severity: Any, message: Any = None) -> str:
1027
+ text = str(severity or "").strip().lower()
1028
+ msg = str(message or "").strip()
1029
+ if text in {"error", "fail", "failed", "critical"}:
1030
+ return f"❌FAIL({msg})" if msg else "❌FAIL"
1031
+ if text in {"warn", "warning"}:
1032
+ return f"🟡WARN({msg})" if msg else "🟡WARN"
1033
+ if text in {"info", "ok", "note"}:
1034
+ return f"✅OK({msg})" if msg else "✅OK"
1035
+ # Empty/unknown severity: never emit literal "issue"/"None".
1036
+ if not text:
1037
+ return ""
1038
+ return f"{severity}: {msg}" if msg else str(severity)
1039
+
1040
+
1041
+ def format_duration(ms: int | None) -> str:
1042
+ if ms is None:
1043
+ return "—"
1044
+ if ms < 1000:
1045
+ return f"{ms}ms"
1046
+ seconds = ms / 1000
1047
+ if seconds < 60:
1048
+ return f"{seconds:.1f}s"
1049
+ minutes = int(seconds // 60)
1050
+ rem = seconds - minutes * 60
1051
+ return f"{minutes}m{rem:04.1f}s"
1052
+
1053
+
1054
+ def group_events_by_phase(events: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
1055
+ """Preserve first-seen phase order; keep events in file order within each phase."""
1056
+ order: list[str] = []
1057
+ buckets: dict[str, list[dict[str, Any]]] = {}
1058
+ for event in events:
1059
+ phase = str(event.get("phase") or "unknown")
1060
+ if phase not in buckets:
1061
+ order.append(phase)
1062
+ buckets[phase] = []
1063
+ buckets[phase].append(event)
1064
+ return [(phase, buckets[phase]) for phase in order]
1065
+
1066
+
1067
+ def split_phase_attempts(phase_events: list[dict[str, Any]]) -> list[dict[str, Any]]:
1068
+ """Split repeated starts into attempts and flag legacy events written after end.
1069
+
1070
+ ``phase.end`` and ``phase.auto_sealed`` are both terminal: either one closes
1071
+ the current attempt (HH-WF-20260730-001).
1072
+ """
1073
+ attempts: list[dict[str, Any]] = []
1074
+ current: dict[str, Any] | None = None
1075
+ next_attempt = 1
1076
+ pre_start_metadata_types = {
1077
+ "correction",
1078
+ "decision",
1079
+ "environment.wait",
1080
+ "env.wait",
1081
+ "external.wait",
1082
+ "issue",
1083
+ "ci.wait",
1084
+ # Preparation is intentionally outside the formal phase lifecycle.
1085
+ # A blocked preflight must remain visible in the raw timeline without
1086
+ # manufacturing an attempt or inflating the phase duration/count.
1087
+ "phase.prepare.start",
1088
+ "phase.prepare.end",
1089
+ }
1090
+ for event in phase_events:
1091
+ if event.get("type") == "phase.start":
1092
+ if current is not None:
1093
+ current["warnings"].append("new phase.start before prior phase.end")
1094
+ attempts.append(current)
1095
+ raw_attempt = event.get("attempt")
1096
+ attempt = raw_attempt if isinstance(raw_attempt, int) and raw_attempt > 0 else next_attempt
1097
+ next_attempt = max(next_attempt, attempt + 1)
1098
+ current = {"attempt": attempt, "events": [event], "warnings": []}
1099
+ continue
1100
+ if current is None:
1101
+ if attempts:
1102
+ prior_type = attempts[-1]["events"][-1].get("type")
1103
+ attempts[-1]["events"].append(event)
1104
+ if prior_type in TERMINAL_PHASE_EVENT_TYPES:
1105
+ attempts[-1]["warnings"].append("event recorded after phase.end")
1106
+ continue
1107
+ if event.get("type") in pre_start_metadata_types:
1108
+ # Decisions and diagnostics may be emitted while selecting or
1109
+ # recovering a phase. They describe workflow context, not an
1110
+ # execution attempt, so they must not manufacture an orphan.
1111
+ continue
1112
+ raw_attempt = event.get("attempt")
1113
+ attempt = raw_attempt if isinstance(raw_attempt, int) and raw_attempt > 0 else next_attempt
1114
+ next_attempt = max(next_attempt, attempt + 1)
1115
+ current = {
1116
+ "attempt": attempt,
1117
+ "events": [event],
1118
+ "warnings": ["missing phase.start"],
1119
+ }
1120
+ else:
1121
+ current["events"].append(event)
1122
+ if event.get("type") in TERMINAL_PHASE_EVENT_TYPES:
1123
+ attempts.append(current)
1124
+ current = None
1125
+ if current is not None:
1126
+ attempts.append(current)
1127
+ return attempts
1128
+
1129
+
1130
+ def phase_start_time(phase_events: list[dict[str, Any]]) -> str:
1131
+ for event in phase_events:
1132
+ if event.get("type") == "phase.start":
1133
+ return str(event.get("timestamp") or "")
1134
+ if phase_events:
1135
+ return str(phase_events[0].get("timestamp") or "")
1136
+ return ""
1137
+
1138
+
1139
+ def phase_duration_ms(phase_events: list[dict[str, Any]]) -> int | None:
1140
+ start_ts = None
1141
+ end_ts = None
1142
+ for event in phase_events:
1143
+ etype = event.get("type")
1144
+ if etype == "phase.start" and start_ts is None:
1145
+ start_ts = event.get("timestamp")
1146
+ elif etype in TERMINAL_PHASE_EVENT_TYPES:
1147
+ end_ts = event.get("timestamp")
1148
+ if start_ts and end_ts:
1149
+ # Closed phases end at the matching phase.end. Late events appended
1150
+ # after closure are reported separately (late_event_stats) and never
1151
+ # extend the closed duration (RET-21).
1152
+ return duration_ms_between(start_ts, end_ts)
1153
+ # Fallback: first to last timestamp in the phase bucket.
1154
+ stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
1155
+ if len(stamps) >= 2:
1156
+ return duration_ms_between(stamps[0], stamps[-1])
1157
+ return None
1158
+
1159
+
1160
+ def late_event_stats(phase_events: list[dict[str, Any]]) -> dict[str, int]:
1161
+ """Count events recorded after the final closing phase.end (RET-21)."""
1162
+ final_end_index = None
1163
+ for index, event in enumerate(phase_events):
1164
+ if event.get("type") == "phase.end":
1165
+ final_end_index = index
1166
+ if final_end_index is None:
1167
+ return {"lateEventCount": 0, "lateEventSpanMs": 0}
1168
+ end_ts = phase_events[final_end_index].get("timestamp")
1169
+ late_stamps = [
1170
+ event["timestamp"]
1171
+ for event in phase_events[final_end_index + 1 :]
1172
+ if event.get("timestamp")
1173
+ ]
1174
+ if not late_stamps:
1175
+ return {"lateEventCount": 0, "lateEventSpanMs": 0}
1176
+ span = duration_ms_between(end_ts, late_stamps[-1]) or 0
1177
+ return {"lateEventCount": len(late_stamps), "lateEventSpanMs": span}
1178
+
1179
+
1180
+ def _seal_timestamp_for_attempt(
1181
+ events_in: list[dict[str, Any]],
1182
+ *,
1183
+ cutoff_ts: str | None,
1184
+ next_attempt_start: Any = None,
1185
+ ) -> tuple[Any, str | None]:
1186
+ """Pick a typed terminal and seal time for an unclosed attempt."""
1187
+ recovery_ts = None
1188
+ explicit_terminal: tuple[Any, str] | None = None
1189
+ for event in events_in:
1190
+ event_type = str(event.get("type") or "").lower()
1191
+ if event_type in {
1192
+ "recovery",
1193
+ "phase.recovery",
1194
+ "attempt.recovery",
1195
+ }:
1196
+ recovery_ts = event.get("timestamp") or recovery_ts
1197
+ terminal = {
1198
+ "attempt.abandoned": "ABANDONED",
1199
+ "phase.abandoned": "ABANDONED",
1200
+ "attempt.interrupted": "INTERRUPTED",
1201
+ "phase.interrupted": "INTERRUPTED",
1202
+ "attempt.orphaned": "ORPHANED",
1203
+ "phase.orphaned": "ORPHANED",
1204
+ }.get(event_type)
1205
+ if terminal and event.get("timestamp"):
1206
+ explicit_terminal = (event.get("timestamp"), terminal)
1207
+ if explicit_terminal is not None:
1208
+ return explicit_terminal
1209
+ if next_attempt_start:
1210
+ return next_attempt_start, "RECOVERED"
1211
+ if recovery_ts:
1212
+ return recovery_ts, "RECOVERED"
1213
+ if cutoff_ts:
1214
+ return cutoff_ts, "INCOMPLETE_AT_CUTOFF"
1215
+ # Fall back to last event timestamp so wall clock is not silently dropped.
1216
+ for event in reversed(events_in):
1217
+ if event.get("timestamp"):
1218
+ return event.get("timestamp"), "INTERRUPTED"
1219
+ return None, "ORPHANED"
1220
+
1221
+
1222
+ def attempt_invocations(
1223
+ phase_events: list[dict[str, Any]],
1224
+ *,
1225
+ cutoff_ts: str | None = None,
1226
+ ) -> list[dict[str, Any]]:
1227
+ """Per-attempt invocation view: attempt, status, durationMs (RET-22 / IA-2).
1228
+
1229
+ Closed attempts keep phase.end status. Attempts closed by a write-path
1230
+ ``phase.auto_sealed`` event (HH-WF-20260730-001) keep that event's status
1231
+ (typically RECOVERED) but are never activeEligible: recovered/superseded
1232
+ time must not count as active execution. Unclosed attempts (no terminal
1233
+ event at all yet) are sealed as INCOMPLETE at the next attempt start, an
1234
+ explicit recovery event, or cutoff.
1235
+ """
1236
+ attempts = split_phase_attempts(phase_events)
1237
+ invocations: list[dict[str, Any]] = []
1238
+ for index, attempt in enumerate(attempts):
1239
+ events_in = attempt.get("events") or []
1240
+ start_ts = None
1241
+ end_ts = None
1242
+ status = None
1243
+ terminal_status = None
1244
+ closed_by_end = False
1245
+ closed_by_auto_seal = False
1246
+ for event in events_in:
1247
+ if event.get("type") == "phase.start" and start_ts is None:
1248
+ start_ts = event.get("timestamp")
1249
+ elif event.get("type") == "phase.end":
1250
+ end_ts = event.get("timestamp")
1251
+ if "status" in event:
1252
+ status = event.get("status")
1253
+ terminal_status = str(event.get("status") or "").upper() or None
1254
+ closed_by_end = True
1255
+ elif event.get("type") == "phase.auto_sealed":
1256
+ end_ts = event.get("timestamp")
1257
+ seal_status = event.get("status") or "RECOVERED"
1258
+ status = seal_status
1259
+ terminal_status = str(seal_status or "").upper() or "RECOVERED"
1260
+ closed_by_auto_seal = True
1261
+ missing_start = start_ts is None
1262
+ sealed_incomplete = False
1263
+ if not closed_by_end and not closed_by_auto_seal:
1264
+ next_start = None
1265
+ if index + 1 < len(attempts):
1266
+ for event in attempts[index + 1].get("events") or []:
1267
+ if event.get("type") == "phase.start" and event.get("timestamp"):
1268
+ next_start = event.get("timestamp")
1269
+ break
1270
+ seal_ts, seal_status = _seal_timestamp_for_attempt(
1271
+ events_in,
1272
+ cutoff_ts=cutoff_ts,
1273
+ next_attempt_start=next_start,
1274
+ )
1275
+ if seal_ts is not None:
1276
+ end_ts = seal_ts
1277
+ status = seal_status
1278
+ terminal_status = seal_status
1279
+ sealed_incomplete = True
1280
+ elif missing_start:
1281
+ terminal_status = "ORPHANED"
1282
+ status = "ORPHANED"
1283
+ sealed_incomplete = True
1284
+ duration = None
1285
+ if start_ts and end_ts:
1286
+ duration = duration_ms_between(start_ts, end_ts)
1287
+ # Active execution counts phase.end-closed attempts only. Both
1288
+ # cutoff/INCOMPLETE seals and auto-sealed (recovered/superseded)
1289
+ # attempts are excluded (HH-WF-20260730-001).
1290
+ active_eligible = closed_by_end and not sealed_incomplete
1291
+ invocations.append(
1292
+ {
1293
+ "attempt": attempt.get("attempt"),
1294
+ "status": status,
1295
+ "terminalStatus": terminal_status,
1296
+ "startedAt": start_ts,
1297
+ "endedAt": end_ts,
1298
+ "durationMs": duration,
1299
+ "activeEligible": active_eligible,
1300
+ "sealedIncomplete": sealed_incomplete,
1301
+ "closedByAutoSeal": closed_by_auto_seal,
1302
+ "warnings": list(attempt.get("warnings") or []),
1303
+ }
1304
+ )
1305
+ return invocations
1306
+
1307
+
1308
+ def phase_final_state(phase_events: list[dict[str, Any]]) -> dict[str, Any]:
1309
+ """Reduce a phase to the state of its latest attempt."""
1310
+ invocations = attempt_invocations(phase_events)
1311
+ if not invocations:
1312
+ return {"attempt": None, "status": None, "durationMs": None, "closed": False}
1313
+ latest = invocations[-1]
1314
+ return {
1315
+ "attempt": latest.get("attempt"),
1316
+ "status": latest.get("status"),
1317
+ "durationMs": latest.get("durationMs"),
1318
+ "closed": latest.get("terminalStatus") not in {
1319
+ None,
1320
+ "INCOMPLETE",
1321
+ "INCOMPLETE_AT_CUTOFF",
1322
+ "ORPHANED",
1323
+ "INTERRUPTED",
1324
+ },
1325
+ }
1326
+
1327
+
1328
+ def canonical_phase_timing(
1329
+ phase_events: list[dict[str, Any]],
1330
+ *,
1331
+ cutoff_ts: str | None = None,
1332
+ ) -> dict[str, Any]:
1333
+ """Single reducer for every duration view (RET-20 / IA-2).
1334
+
1335
+ activeExecutionMs: sum of closed attempt start→end only (INCOMPLETE excluded).
1336
+ recoveredMs: sum of durations for attempts terminated via write-path
1337
+ auto-seal (HH-WF-20260730-001) — always disjoint from activeExecutionMs.
1338
+ wallClockSpanMs: first → last/cutoff timestamp; unclosed attempts retain span.
1339
+ """
1340
+ invocations = attempt_invocations(phase_events, cutoff_ts=cutoff_ts)
1341
+ stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
1342
+ # Cutoff seals INCOMPLETE attempts but must not inflate stage wall by itself.
1343
+ # Include cutoff in the wall span only when it sealed an otherwise-open attempt.
1344
+ if cutoff_ts and any(inv.get("sealedIncomplete") for inv in invocations):
1345
+ stamps = list(stamps) + [cutoff_ts]
1346
+ closed_durations = [
1347
+ invocation["durationMs"]
1348
+ for invocation in invocations
1349
+ if invocation.get("activeEligible") and invocation.get("durationMs") is not None
1350
+ ]
1351
+ active = sum(closed_durations) if closed_durations else (0 if invocations else None)
1352
+ recovered_durations = [
1353
+ invocation["durationMs"]
1354
+ for invocation in invocations
1355
+ if invocation.get("closedByAutoSeal") and invocation.get("durationMs") is not None
1356
+ ]
1357
+ recovered = sum(recovered_durations) if recovered_durations else (0 if invocations else None)
1358
+ late = late_event_stats(phase_events)
1359
+ if len(stamps) >= 2:
1360
+ wall = duration_ms_between(stamps[0], stamps[-1])
1361
+ else:
1362
+ wall = active
1363
+ unclosed = sum(
1364
+ 1
1365
+ for invocation in invocations
1366
+ if invocation.get("terminalStatus")
1367
+ in {"INCOMPLETE", "INCOMPLETE_AT_CUTOFF", "ORPHANED", "INTERRUPTED"}
1368
+ )
1369
+ return {
1370
+ "activeExecutionMs": active,
1371
+ "recoveredMs": recovered,
1372
+ "wallClockSpanMs": wall,
1373
+ "lateEventCount": late["lateEventCount"],
1374
+ "lateEventSpanMs": late["lateEventSpanMs"],
1375
+ "unclosedAttemptCount": unclosed,
1376
+ "attempts": invocations,
1377
+ }
1378
+
1379
+
1380
+ def render_command_block(commands: list[dict[str, Any]]) -> list[str]:
1381
+ if not commands:
1382
+ return []
1383
+
1384
+ def command_display(event: dict[str, Any]) -> str:
1385
+ cmd = str(event.get("command") or "").strip()
1386
+ if cmd:
1387
+ return cmd
1388
+ return str(event.get("note") or "").strip()
1389
+
1390
+ if len(commands) == 1:
1391
+ event = commands[0]
1392
+ display = command_display(event)
1393
+ if not display:
1394
+ return []
1395
+ exit_code = event.get("exit_code")
1396
+ exit_text = "?" if exit_code is None else str(exit_code)
1397
+ duration = format_duration(
1398
+ int(event["duration_ms"]) if isinstance(event.get("duration_ms"), int) else None
1399
+ )
1400
+ note = str(event.get("note") or "").strip()
1401
+ parts = [f"- command: `{display}`", f"exit={exit_text}", f"duration={duration}"]
1402
+ # Avoid duplicating note when it was already used as the display text.
1403
+ if note and note != display:
1404
+ parts.append(f"note={note}")
1405
+ return [" · ".join(parts)]
1406
+
1407
+ rows: list[str] = []
1408
+ for event in commands:
1409
+ display = command_display(event)
1410
+ if not display:
1411
+ continue
1412
+ cmd = display.replace("|", "\\|")
1413
+ exit_code = event.get("exit_code")
1414
+ exit_text = "?" if exit_code is None else str(exit_code)
1415
+ duration = format_duration(
1416
+ int(event["duration_ms"]) if isinstance(event.get("duration_ms"), int) else None
1417
+ )
1418
+ note = str(event.get("note") or "").replace("|", "\\|")
1419
+ # If note was promoted to the command cell, leave the note column empty.
1420
+ note_cell = "" if note == display else note
1421
+ rows.append(f"| `{cmd}` | {exit_text} | {duration} | {note_cell} |")
1422
+ if not rows:
1423
+ return []
1424
+ lines = [
1425
+ "",
1426
+ "| 命令 | exit | duration | note |",
1427
+ "| --- | ---: | ---: | --- |",
1428
+ *rows,
1429
+ "",
1430
+ ]
1431
+ return lines
1432
+
1433
+
1434
+ def render_event_line(event: dict[str, Any]) -> list[str]:
1435
+ etype = event.get("type")
1436
+ if etype == "phase.start":
1437
+ note = str(event.get("note") or "").strip()
1438
+ suffix = f" — {note}" if note else ""
1439
+ lines = [f"- phase.start @ {event.get('timestamp', '')}{suffix}"]
1440
+ tool = str(event.get("executor_tool") or "").strip()
1441
+ agent = str(event.get("executor_agent") or "").strip()
1442
+ handoff = str(event.get("handoff_from_tool") or "").strip()
1443
+ if handoff and tool:
1444
+ lines.append(f"- 工具交接: {handoff} → {tool}")
1445
+ elif tool:
1446
+ lines.append(f"- 执行来源: {tool}" + (f" / {agent}" if agent else ""))
1447
+ return lines
1448
+ if etype == "phase.end":
1449
+ note = str(event.get("note") or "").strip()
1450
+ suffix = f" — {note}" if note else ""
1451
+ return [f"- phase.end @ {event.get('timestamp', '')}{suffix}"]
1452
+ if etype == "phase.auto_sealed":
1453
+ reason = str(event.get("reason") or "").strip() or "unknown"
1454
+ status = str(event.get("status") or "RECOVERED").strip()
1455
+ note = str(event.get("note") or "").strip()
1456
+ suffix = f" — {note}" if note else ""
1457
+ return [
1458
+ f"- phase.auto_sealed ({status}/{reason}) @ {event.get('timestamp', '')}{suffix}"
1459
+ ]
1460
+ if etype == "verification":
1461
+ note = str(event.get("note") or "").strip()
1462
+ name = str(event.get("name") or "").strip() or (
1463
+ note[:_NOTE_FALLBACK_MAXLEN] if note else ""
1464
+ )
1465
+ if not name:
1466
+ return []
1467
+ symbol = status_symbol(event.get("status"), event.get("reason"))
1468
+ return [f"- verification: {name} → {symbol}"]
1469
+ if etype == "decision":
1470
+ decision = str(event.get("decision") or "").strip()
1471
+ reason = str(event.get("reason") or event.get("note") or "").strip()
1472
+ if not decision:
1473
+ return [f"- decision: {reason}"] if reason else []
1474
+ if reason:
1475
+ return [f"- decision: {decision} — {reason}"]
1476
+ return [f"- decision: {decision}"]
1477
+ if etype == "issue":
1478
+ note = str(event.get("note") or "").strip()
1479
+ message = str(event.get("message") or "").strip() or note
1480
+ symbol = severity_symbol(event.get("severity"), message)
1481
+ if not symbol:
1482
+ return [f"- issue: {note}"] if note else []
1483
+ code = event.get("code")
1484
+ prefix = f"[{code}] " if code else ""
1485
+ return [f"- issue: {prefix}{symbol}"]
1486
+ if etype == "issue.resolve":
1487
+ issue_id = str(event.get("issue_id") or "").strip()
1488
+ reason = str(event.get("reason") or event.get("note") or "").strip()
1489
+ suffix = f" — {reason}" if reason else ""
1490
+ return [f"- issue.resolve: {issue_id}{suffix}"] if issue_id else []
1491
+ if etype == "correction":
1492
+ target = str(event.get("target_event_id") or "").strip()
1493
+ field = str(event.get("target_field") or "").strip()
1494
+ reason = str(event.get("reason") or event.get("note") or "").strip()
1495
+ suffix = f" — {reason}" if reason else ""
1496
+ label = f"{target}.{field}".strip(".")
1497
+ return [f"- correction: {label}{suffix}"] if label else []
1498
+ if etype == "artifact":
1499
+ path = str(event.get("path") or "").strip()
1500
+ note = str(event.get("note") or "").strip()
1501
+ kind = event.get("kind")
1502
+ if not path:
1503
+ return [f"- artifact: {note}"] if note else []
1504
+ if kind:
1505
+ return [f"- artifact: `{path}` ({kind})"]
1506
+ return [f"- artifact: `{path}`"]
1507
+ if etype == "command":
1508
+ return render_command_block([event])
1509
+ # Unknown types: skip from human log to keep size down.
1510
+ return []
1511
+
1512
+
1513
+ def render_execution_log(events: list[dict[str, Any]]) -> str:
1514
+ lines: list[str] = [
1515
+ f"> [!warning] {HEADER_LINE}",
1516
+ "",
1517
+ "# Execution Log",
1518
+ "",
1519
+ ]
1520
+ if not events:
1521
+ lines.append("_(暂无事件)_")
1522
+ lines.append("")
1523
+ return "\n".join(lines)
1524
+
1525
+ projected_domain_events = iter(apply_event_corrections(events))
1526
+ projected_events = [
1527
+ copy.deepcopy(event)
1528
+ if event.get("type") == "correction"
1529
+ else next(projected_domain_events)
1530
+ for event in events
1531
+ ]
1532
+ for phase, phase_events in group_events_by_phase(projected_events):
1533
+ attempts = split_phase_attempts(phase_events)
1534
+ for attempt_record in attempts:
1535
+ attempt_events = attempt_record["events"]
1536
+ start = phase_start_time(attempt_events) or "—"
1537
+ duration = phase_duration_ms(attempt_events)
1538
+ label = f"{phase}(尝试 {attempt_record['attempt']})" if len(attempts) > 1 else phase
1539
+ lines.append(f"## {label} — {start}")
1540
+ lines.append("")
1541
+ if duration is not None:
1542
+ lines.append(f"- 阶段耗时: {format_duration(duration)}")
1543
+ for warning in attempt_record["warnings"]:
1544
+ lines.append(f"- 生命周期警告: {warning}")
1545
+ buffer: list[dict[str, Any]] = []
1546
+
1547
+ def flush_commands() -> None:
1548
+ nonlocal buffer
1549
+ if buffer:
1550
+ lines.extend(render_command_block(buffer))
1551
+ buffer = []
1552
+
1553
+ for event in attempt_events:
1554
+ if event.get("type") == "command":
1555
+ buffer.append(event)
1556
+ continue
1557
+ flush_commands()
1558
+ lines.extend(render_event_line(event))
1559
+ flush_commands()
1560
+ lines.append("")
1561
+
1562
+ return "\n".join(lines).rstrip() + "\n"
1563
+
1564
+
1565
+ def write_execution_log(change_dir: Path, content: str) -> Path:
1566
+ path = execution_log_path(change_dir)
1567
+ path.parent.mkdir(parents=True, exist_ok=True)
1568
+ # UTF-8 without BOM
1569
+ path.write_text(content, encoding="utf-8", newline="\n")
1570
+ return path
1571
+
1572
+
1573
+ def execution_log_render_enabled(change_dir: Path) -> bool:
1574
+ """P1 slim-files: execution-log.md auto-render policy.
1575
+
1576
+ `.harness/config/render-policy.json` with ``{"executionLog": "on-demand"}``
1577
+ disables the automatic render on ``phase.end``/auto-seal; the explicit
1578
+ ``harness_events.py render`` subcommand rebuilds the projection at any
1579
+ time. Default stays "auto" (render on phase.end).
1580
+ """
1581
+ current = change_dir.resolve()
1582
+ for candidate in [current, *current.parents]:
1583
+ if candidate.name == ".harness":
1584
+ config = candidate / "config" / "render-policy.json"
1585
+ else:
1586
+ config = candidate / ".harness" / "config" / "render-policy.json"
1587
+ if not config.is_file():
1588
+ continue
1589
+ try:
1590
+ document = json.loads(config.read_text(encoding="utf-8-sig"))
1591
+ except (OSError, json.JSONDecodeError):
1592
+ return True
1593
+ if isinstance(document, dict):
1594
+ mode = str(document.get("executionLog") or "").strip().lower()
1595
+ if mode == "on-demand":
1596
+ return False
1597
+ return True
1598
+ return True
1599
+
1600
+
1601
+ def build_summary(change_dir: Path, events: list[dict[str, Any]]) -> dict[str, Any]:
1602
+ phases: dict[str, Any] = {}
1603
+ issues: list[dict[str, Any]] = []
1604
+ projected_events = apply_event_corrections(events)
1605
+
1606
+ for phase, phase_events in group_events_by_phase(projected_events):
1607
+ attempt_records: list[dict[str, Any]] = []
1608
+ for record in split_phase_attempts(phase_events):
1609
+ attempt_events = record["events"]
1610
+ starts = [e for e in attempt_events if e.get("type") == "phase.start"]
1611
+ ends = [e for e in attempt_events if e.get("type") == "phase.end"]
1612
+ start_ts = starts[0].get("timestamp") if starts else None
1613
+ end_ts = ends[-1].get("timestamp") if ends else None
1614
+ provenance = starts[0] if starts else (attempt_events[0] if attempt_events else {})
1615
+ attempt_records.append({
1616
+ "attempt": record["attempt"],
1617
+ "event_count": len(attempt_events),
1618
+ "started_at": start_ts,
1619
+ "ended_at": end_ts,
1620
+ "duration_ms": phase_duration_ms(attempt_events),
1621
+ "status": ends[-1].get("status") if ends else None,
1622
+ "run_id": provenance.get("run_id"),
1623
+ "executor_tool": provenance.get("executor_tool"),
1624
+ "executor_agent": provenance.get("executor_agent"),
1625
+ "handoff_from_tool": provenance.get("handoff_from_tool"),
1626
+ "warnings": record["warnings"],
1627
+ })
1628
+ durations = [a["duration_ms"] for a in attempt_records if a["duration_ms"] is not None]
1629
+ first = attempt_records[0] if attempt_records else {}
1630
+ last = attempt_records[-1] if attempt_records else {}
1631
+ phases[phase] = {
1632
+ "event_count": len(phase_events),
1633
+ "started_at": first.get("started_at"),
1634
+ "ended_at": last.get("ended_at"),
1635
+ "duration_ms": sum(durations) if durations else None,
1636
+ "status": last.get("status"),
1637
+ "attempts": attempt_records,
1638
+ }
1639
+
1640
+ for event in projected_events:
1641
+ if event.get("type") != "issue":
1642
+ continue
1643
+ issues.append(
1644
+ {
1645
+ "id": event.get("id"),
1646
+ "issue_id": _issue_identity(event),
1647
+ "timestamp": event.get("timestamp"),
1648
+ "phase": event.get("phase"),
1649
+ "code": event.get("code"),
1650
+ "severity": event.get("severity"),
1651
+ "message": event.get("message"),
1652
+ }
1653
+ )
1654
+
1655
+ return {
1656
+ "ok": True,
1657
+ "change_dir": str(change_dir),
1658
+ "event_count": len(events),
1659
+ "phases": phases,
1660
+ "issues": issues,
1661
+ "current_issues": [
1662
+ {
1663
+ "id": event.get("id"),
1664
+ "issue_id": _issue_identity(event),
1665
+ "timestamp": event.get("timestamp"),
1666
+ "phase": event.get("phase"),
1667
+ "code": event.get("code"),
1668
+ "severity": event.get("severity"),
1669
+ "message": event.get("message"),
1670
+ }
1671
+ for event in current_issues(events)
1672
+ ],
1673
+ }
1674
+
1675
+
1676
+ def phase_end_already_recorded(
1677
+ events: list[dict[str, Any]], candidate: dict[str, Any]
1678
+ ) -> bool:
1679
+ """Return whether the candidate attempt already has a terminal event.
1680
+
1681
+ ``phase.auto_sealed`` counts as a terminal here too (HH-WF-20260730-001):
1682
+ a stale ``phase.end`` for an attempt that was already auto-sealed (e.g.
1683
+ superseded by a later ``phase.start``) must not close it a second time.
1684
+ """
1685
+ phase_events = [
1686
+ event for event in events if event.get("phase") == candidate.get("phase")
1687
+ ]
1688
+ candidate_attempt = candidate.get("attempt")
1689
+ if isinstance(candidate_attempt, int):
1690
+ return any(
1691
+ event.get("type") in TERMINAL_PHASE_EVENT_TYPES
1692
+ and event.get("attempt") == candidate_attempt
1693
+ for event in phase_events
1694
+ )
1695
+ attempts = split_phase_attempts(phase_events)
1696
+ if not attempts:
1697
+ return False
1698
+ latest_events = attempts[-1].get("events") or []
1699
+ return any(event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in latest_events)
1700
+
1701
+
1702
+ def infer_auto_seal_reason(attempt_events: list[dict[str, Any]]) -> str:
1703
+ """Best-effort ``phase.auto_sealed`` reason from an open attempt's own events.
1704
+
1705
+ HH-WF-20260730-001: prefer an explicit wait signal recorded while the
1706
+ attempt was open, then an explicit recovery marker, else fall back to
1707
+ ``superseded`` (the default meaning: a new phase.start arrived).
1708
+ """
1709
+ for event in attempt_events:
1710
+ mapped = _WAIT_EVENT_REASON_MAP.get(str(event.get("type") or "").lower())
1711
+ if mapped:
1712
+ return mapped
1713
+ for event in attempt_events:
1714
+ if str(event.get("type") or "").lower() in _RECOVERY_EVENT_TYPES:
1715
+ return "executor_lost"
1716
+ return "superseded"
1717
+
1718
+
1719
+ def open_attempts_for_phase(
1720
+ existing_events: list[dict[str, Any]], phase: str
1721
+ ) -> list[dict[str, Any]]:
1722
+ """Return ``split_phase_attempts`` records for ``phase`` that started but
1723
+ have no terminal (``phase.end``/``phase.auto_sealed``) event yet.
1724
+ """
1725
+ phase_events = [event for event in existing_events if event.get("phase") == phase]
1726
+ if not phase_events:
1727
+ return []
1728
+ open_attempts: list[dict[str, Any]] = []
1729
+ for attempt in split_phase_attempts(phase_events):
1730
+ events_in = attempt.get("events") or []
1731
+ if not events_in:
1732
+ continue
1733
+ if not any(event.get("type") == "phase.start" for event in events_in):
1734
+ # No actual start recorded (e.g. pure metadata bucket) — nothing
1735
+ # to auto-seal; sealing it would manufacture a phantom attempt.
1736
+ continue
1737
+ if any(
1738
+ event.get("type") in TERMINAL_PHASE_EVENT_TYPES for event in events_in
1739
+ ):
1740
+ # Prefer "any terminal" over "last event is terminal": late events
1741
+ # may be appended onto a closed attempt after phase.end/auto_sealed.
1742
+ continue
1743
+ open_attempts.append(attempt)
1744
+ return open_attempts
1745
+
1746
+
1747
+ def seal_open_phase_attempts(
1748
+ existing_events: list[dict[str, Any]],
1749
+ *,
1750
+ phase: str,
1751
+ seal_reason: str | None = None,
1752
+ seal_ts: str | None = None,
1753
+ ) -> list[dict[str, Any]]:
1754
+ """Build ``phase.auto_sealed`` events for still-open attempts of ``phase``.
1755
+
1756
+ HH-WF-20260730-001: write-path auto-seal. Returns the seal event dicts —
1757
+ callers append them (then the triggering event, e.g. the new
1758
+ ``phase.start``) under the same lock so the whole sequence is atomic from
1759
+ other processes' point of view.
1760
+
1761
+ When ``seal_reason`` is omitted, each open attempt gets a best-effort
1762
+ inferred reason (wait / recovery / superseded).
1763
+ """
1764
+ open_attempts = open_attempts_for_phase(existing_events, phase)
1765
+ if not open_attempts:
1766
+ return []
1767
+ ts = seal_ts or now_iso()
1768
+ seal_events: list[dict[str, Any]] = []
1769
+ for attempt in open_attempts:
1770
+ events_in = attempt.get("events") or []
1771
+ provenance_source = events_in[0] if events_in else {}
1772
+ if seal_reason is None:
1773
+ reason = infer_auto_seal_reason(events_in)
1774
+ elif seal_reason in _AUTO_SEAL_REASONS:
1775
+ reason = seal_reason
1776
+ else:
1777
+ reason = "unknown"
1778
+ seal_event: dict[str, Any] = {
1779
+ "schema_version": SCHEMA_VERSION,
1780
+ "id": new_event_id(),
1781
+ "timestamp": ts,
1782
+ "phase": phase,
1783
+ "type": "phase.auto_sealed",
1784
+ "status": "RECOVERED",
1785
+ "reason": reason,
1786
+ "note": "",
1787
+ "attempt": attempt.get("attempt"),
1788
+ }
1789
+ for field in ("run_id", "executor_tool", "executor_agent", "executor_model"):
1790
+ value = provenance_source.get(field)
1791
+ if value is not None:
1792
+ seal_event[field] = value
1793
+ seal_events.append(seal_event)
1794
+ return seal_events
1795
+
1796
+
1797
+ def append_with_auto_seal(
1798
+ events_file: Path,
1799
+ event: dict[str, Any],
1800
+ *,
1801
+ existing_events: list[dict[str, Any]] | None = None,
1802
+ ) -> dict[str, Any]:
1803
+ """Append ``event`` under lock; auto-seal open attempts when type is phase.start.
1804
+
1805
+ Returns ``{ok, event, autoSealed, phaseAlreadyClosed, events_path}``.
1806
+ Caller must NOT hold the events lock — this function acquires it.
1807
+ """
1808
+ lock_path = events_file.with_name(events_file.name + ".lock")
1809
+ event_type = str(event.get("type") or "")
1810
+ phase = str(event.get("phase") or "")
1811
+ auto_sealed: list[dict[str, Any]] = []
1812
+ phase_already_closed = False
1813
+ with event_file_lock(lock_path):
1814
+ loaded = (
1815
+ existing_events
1816
+ if existing_events is not None
1817
+ else (
1818
+ load_events(events_file)
1819
+ if event_type in {"phase.start", "phase.end", "correction"}
1820
+ else []
1821
+ )
1822
+ )
1823
+ if event_type in {"phase.start", "phase.end", "phase.auto_sealed"} and phase:
1824
+ phase_attempts = split_phase_attempts(
1825
+ [item for item in loaded if item.get("phase") == phase]
1826
+ )
1827
+ latest_attempt = max(
1828
+ (
1829
+ int(item.get("attempt"))
1830
+ for item in phase_attempts
1831
+ if isinstance(item.get("attempt"), int)
1832
+ ),
1833
+ default=0,
1834
+ )
1835
+ if not isinstance(event.get("attempt"), int) or int(event["attempt"]) <= 0:
1836
+ event["attempt"] = (
1837
+ latest_attempt + 1 if event_type == "phase.start" else latest_attempt or 1
1838
+ )
1839
+ if event_type == "phase.start" and phase:
1840
+ auto_sealed = seal_open_phase_attempts(loaded, phase=phase)
1841
+ for seal_event in auto_sealed:
1842
+ atomic_append_line(
1843
+ events_file,
1844
+ json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
1845
+ )
1846
+ if event_type == "phase.end":
1847
+ phase_already_closed = phase_end_already_recorded(loaded, event)
1848
+ if not phase_already_closed:
1849
+ atomic_append_line(
1850
+ events_file,
1851
+ json.dumps(event, ensure_ascii=False, separators=(",", ":")),
1852
+ )
1853
+ return {
1854
+ "ok": not phase_already_closed,
1855
+ "event": event,
1856
+ "autoSealed": auto_sealed,
1857
+ "phaseAlreadyClosed": phase_already_closed,
1858
+ "events_path": str(events_file),
1859
+ }
1860
+
1861
+
1862
+ def cmd_append(args: argparse.Namespace) -> int:
1863
+ as_json = bool(args.json)
1864
+ if args.type not in EVENT_TYPES:
1865
+ return emit_error(
1866
+ f"unsupported type: {args.type}; expected one of {sorted(EVENT_TYPES)}",
1867
+ as_json=as_json,
1868
+ )
1869
+ legacy_lenient = bool(getattr(args, "legacy_lenient", False))
1870
+ validation = validate_append_event(args)
1871
+ if validation:
1872
+ error_code, message = validation
1873
+ legacy_compatible = legacy_lenient and args.type in {"issue", "verification"}
1874
+ if not legacy_compatible:
1875
+ return emit_error(message, as_json=as_json, error_code=error_code)
1876
+ change_dir = resolve_change_dir(args.change_dir)
1877
+ archived = archived_change_dir(change_dir)
1878
+ if archived is not None:
1879
+ return emit_error(
1880
+ "ARCHIVED_CHANGE_IMMUTABLE: refusing to append to archived change "
1881
+ f"{change_dir.name} ({archived})",
1882
+ as_json=as_json,
1883
+ )
1884
+ path = events_path(change_dir)
1885
+ lock_path = path.with_name(path.name + ".lock")
1886
+
1887
+ # §6.1/§6.2: 普通 append = 加锁 -> 追加一行 -> fsync -> 解锁,不 load 历史、不渲染。
1888
+ # new_event_id 用完整 uuid,无需扫描去重。锁覆盖 atomic_append_line 的
1889
+ # open/write/flush/fsync 全过程。
1890
+ event = build_event(args, [])
1891
+ # Explicit compatibility mode preserves old append behavior and marks the
1892
+ # resulting event so strict consumers can distinguish it from valid v3.
1893
+ if validation:
1894
+ event["schemaValidation"] = "legacy"
1895
+ if legacy_lenient and args.type == "issue" and not args.severity:
1896
+ event["severity"] = "info"
1897
+ print(
1898
+ "warning: issue without --severity, defaulted to info",
1899
+ file=sys.stderr,
1900
+ )
1901
+ if legacy_lenient and args.type == "verification" and (not args.name or not args.status):
1902
+ print(
1903
+ "warning: verification missing --name or --status",
1904
+ file=sys.stderr,
1905
+ )
1906
+ phase_closed = False
1907
+ phase_start_duplicate = False
1908
+ projection_error: str | None = None
1909
+ attempt_error: str | None = None
1910
+ auto_sealed_events: list[dict[str, Any]] = []
1911
+ try:
1912
+ with event_file_lock(lock_path):
1913
+ existing_events = (
1914
+ load_events(path)
1915
+ if args.type in {"phase.end", "correction", "phase.start"}
1916
+ or event.get("run_id") is not None
1917
+ or event.get("attempt") is not None
1918
+ else []
1919
+ )
1920
+ run_id = event.get("run_id")
1921
+ if run_id is not None:
1922
+ run_attempts = {
1923
+ int(item["attempt"])
1924
+ for item in existing_events
1925
+ if item.get("phase") == event.get("phase")
1926
+ and item.get("run_id") == run_id
1927
+ and isinstance(item.get("attempt"), int)
1928
+ and int(item["attempt"]) > 0
1929
+ }
1930
+ if run_attempts:
1931
+ expected_attempt = max(run_attempts)
1932
+ explicit_attempt = event.get("attempt")
1933
+ if (
1934
+ isinstance(explicit_attempt, int)
1935
+ and explicit_attempt != expected_attempt
1936
+ ):
1937
+ attempt_error = (
1938
+ "EVENT_ATTEMPT_CONFLICT: run_id is already bound to "
1939
+ f"attempt {expected_attempt}, received "
1940
+ f"{explicit_attempt}. One run id maps to exactly one "
1941
+ "attempt — allocate a fresh run id for the next attempt."
1942
+ )
1943
+ else:
1944
+ event["attempt"] = expected_attempt
1945
+ if attempt_error is not None:
1946
+ pass
1947
+ elif args.type == "phase.end":
1948
+ phase_closed = phase_end_already_recorded(existing_events, event)
1949
+ elif args.type == "correction":
1950
+ try:
1951
+ apply_event_corrections([*existing_events, event])
1952
+ except ValueError as exc:
1953
+ projection_error = str(exc)
1954
+ elif args.type == "phase.start":
1955
+ # Re-appending the same (phase, run_id) is the caller restating a
1956
+ # start that already exists — `gate begin` writes one, and the
1957
+ # logging protocol used to ask for a second. Treat it as a no-op
1958
+ # rather than a duplicate; two identical phase.start events make
1959
+ # `plan finalize` unrecoverable via PHASE_START_DUPLICATE.
1960
+ if run_id is not None and any(
1961
+ item.get("phase") == args.phase
1962
+ and item.get("type") == "phase.start"
1963
+ and item.get("run_id") == run_id
1964
+ for item in existing_events
1965
+ ):
1966
+ phase_start_duplicate = True
1967
+ # HH-WF-20260730-001: a new phase.start must not be appended
1968
+ # while a prior attempt for the same phase is still open —
1969
+ # auto-seal it first so timing never has two open attempts.
1970
+ open_attempts = (
1971
+ [] if phase_start_duplicate
1972
+ else open_attempts_for_phase(existing_events, args.phase)
1973
+ )
1974
+ if open_attempts:
1975
+ inferred_reason = infer_auto_seal_reason(
1976
+ open_attempts[-1].get("events") or []
1977
+ )
1978
+ auto_sealed_events = seal_open_phase_attempts(
1979
+ existing_events,
1980
+ phase=args.phase,
1981
+ seal_reason=inferred_reason,
1982
+ )
1983
+ if (
1984
+ not phase_closed
1985
+ and not phase_start_duplicate
1986
+ and projection_error is None
1987
+ and attempt_error is None
1988
+ ):
1989
+ for seal_event in auto_sealed_events:
1990
+ atomic_append_line(
1991
+ path,
1992
+ json.dumps(seal_event, ensure_ascii=False, separators=(",", ":")),
1993
+ )
1994
+ atomic_append_line(
1995
+ path,
1996
+ json.dumps(event, ensure_ascii=False, separators=(",", ":")),
1997
+ )
1998
+ except (OSError, TimeoutError, ValueError) as exc:
1999
+ return emit_error(f"append failed: {exc}", as_json=as_json)
2000
+ if phase_start_duplicate:
2001
+ if as_json:
2002
+ print(json.dumps({
2003
+ "ok": True,
2004
+ "skipped": True,
2005
+ "reason": "phase-start-already-recorded",
2006
+ "phase": args.phase,
2007
+ "run_id": event.get("run_id"),
2008
+ }, ensure_ascii=False))
2009
+ else:
2010
+ print("ok (phase.start already recorded for this run id)")
2011
+ return 0
2012
+ if phase_closed:
2013
+ return emit_error(
2014
+ "PHASE_ALREADY_CLOSED: refusing a second phase.end for the same "
2015
+ "attempt. A retry needs a fresh run id AND the next attempt number "
2016
+ "(attempts are global per phase, not per run id). For a published "
2017
+ "plan rerun plan evidence-pack + plan finalize with the next attempt.",
2018
+ as_json=as_json,
2019
+ error_code="PHASE_ALREADY_CLOSED",
2020
+ )
2021
+ if projection_error is not None:
2022
+ error_code = projection_error.split(":", 1)[0]
2023
+ return emit_error(
2024
+ projection_error,
2025
+ as_json=as_json,
2026
+ error_code=error_code,
2027
+ )
2028
+ if attempt_error is not None:
2029
+ return emit_error(
2030
+ attempt_error,
2031
+ as_json=as_json,
2032
+ error_code="EVENT_ATTEMPT_CONFLICT",
2033
+ )
2034
+
2035
+ _nudge_remote_sync(change_dir)
2036
+
2037
+ # §6.1: phase.end append(或写路径产生了 auto-seal)-> 追加成功后执行一次
2038
+ # render(从完整 events 重建 log)。普通 command/issue 等 append 不渲染
2039
+ # (O(1));显式 `render` 子命令随时重建。
2040
+ rendered = False
2041
+ log_path = None
2042
+ log_lines = None
2043
+ if (args.type == "phase.end" or auto_sealed_events) and \
2044
+ execution_log_render_enabled(change_dir):
2045
+ try:
2046
+ events = load_events(path)
2047
+ content = render_execution_log(events)
2048
+ log_path = write_execution_log(change_dir, content)
2049
+ log_lines = len(content.splitlines())
2050
+ rendered = True
2051
+ except (OSError, ValueError) as exc:
2052
+ return emit_error(f"phase.end render failed: {exc}", as_json=as_json)
2053
+
2054
+ payload: dict[str, Any] = {
2055
+ "ok": True,
2056
+ "action": "append",
2057
+ "event": event,
2058
+ "events_path": str(path),
2059
+ "rendered": rendered,
2060
+ }
2061
+ if args.type == "phase.start":
2062
+ payload["autoSealed"] = auto_sealed_events
2063
+ if log_path is not None:
2064
+ payload["execution_log_path"] = str(log_path)
2065
+ payload["execution_log_lines"] = log_lines
2066
+ emit_json(payload, as_json=as_json)
2067
+ return 0
2068
+
2069
+
2070
+ def cmd_batch_append(args: argparse.Namespace) -> int:
2071
+ as_json = bool(args.json)
2072
+ change_dir = resolve_change_dir(args.change_dir)
2073
+ batch_path = Path(str(args.file)).expanduser()
2074
+ try:
2075
+ raw = json.loads(batch_path.read_text(encoding="utf-8-sig"))
2076
+ except (OSError, json.JSONDecodeError) as exc:
2077
+ return emit_error(f"batch-append failed to read --file: {exc}", as_json=as_json)
2078
+ result = batch_append_events(change_dir, raw if isinstance(raw, list) else [])
2079
+ if not result.get("ok"):
2080
+ return emit_error(
2081
+ str(result.get("message") or "batch-append failed"),
2082
+ as_json=as_json,
2083
+ error_code=str(result.get("code") or "BATCH_APPEND_FAILED"),
2084
+ )
2085
+ emit_json(result, as_json=as_json)
2086
+ return 0
2087
+
2088
+
2089
+ def cmd_render(args: argparse.Namespace) -> int:
2090
+ as_json = bool(args.json)
2091
+ change_dir = resolve_change_dir(args.change_dir)
2092
+ path = events_path(change_dir)
2093
+ try:
2094
+ events = load_events(path)
2095
+ content = render_execution_log(events)
2096
+ log_path = write_execution_log(change_dir, content)
2097
+ except (OSError, ValueError) as exc:
2098
+ return emit_error(f"render failed: {exc}", as_json=as_json)
2099
+
2100
+ payload = {
2101
+ "ok": True,
2102
+ "action": "render",
2103
+ "events_path": str(path),
2104
+ "execution_log_path": str(log_path),
2105
+ "event_count": len(events),
2106
+ "execution_log_lines": len(content.splitlines()),
2107
+ }
2108
+ emit_json(payload, as_json=as_json)
2109
+ return 0
2110
+
2111
+
2112
+ def cmd_summary(args: argparse.Namespace) -> int:
2113
+ # Structured summary is always JSON (task card: summary --json).
2114
+ change_dir = resolve_change_dir(args.change_dir)
2115
+ path = events_path(change_dir)
2116
+ try:
2117
+ events = load_events(path)
2118
+ payload = build_summary(change_dir, events)
2119
+ except (OSError, ValueError) as exc:
2120
+ return emit_error(f"summary failed: {exc}", as_json=True)
2121
+
2122
+ emit_json(payload, as_json=True)
2123
+ return 0
2124
+
2125
+
2126
+ def build_parser() -> argparse.ArgumentParser:
2127
+ common = argparse.ArgumentParser(add_help=False)
2128
+ common.add_argument(
2129
+ "--json",
2130
+ action="store_true",
2131
+ help="emit machine-readable JSON on stdout",
2132
+ )
2133
+
2134
+ parser = argparse.ArgumentParser(
2135
+ prog="harness_events.py",
2136
+ description="Append/render/summarize harness change events.ndjson",
2137
+ parents=[common],
2138
+ )
2139
+ sub = parser.add_subparsers(dest="command_name", required=True)
2140
+
2141
+ p_append = sub.add_parser(
2142
+ "append",
2143
+ parents=[common],
2144
+ help="append one event and auto-render execution-log",
2145
+ )
2146
+ p_append.add_argument("--change-dir", required=True)
2147
+ p_append.add_argument("--phase", required=True)
2148
+ p_append.add_argument("--type", required=True)
2149
+ p_append.add_argument("--command", default=None)
2150
+ p_append.add_argument("--exit-code", type=int, default=None)
2151
+ p_append.add_argument("--duration-ms", type=int, default=None)
2152
+ p_append.add_argument("--note", default=None)
2153
+ p_append.add_argument("--name", default=None)
2154
+ p_append.add_argument("--status", default=None)
2155
+ p_append.add_argument("--path", default=None)
2156
+ p_append.add_argument("--kind", default=None)
2157
+ p_append.add_argument("--code", default=None)
2158
+ p_append.add_argument("--severity", default=None)
2159
+ p_append.add_argument("--message", default=None)
2160
+ p_append.add_argument("--decision", default=None)
2161
+ p_append.add_argument("--reason", default=None)
2162
+ p_append.add_argument("--issue-id", default=None)
2163
+ p_append.add_argument("--scope", default=None)
2164
+ p_append.add_argument("--target-event-id", default=None)
2165
+ p_append.add_argument("--target-field", default=None)
2166
+ p_append.add_argument("--old-value-hash", default=None)
2167
+ p_append.add_argument("--new-value-json", default=None)
2168
+ p_append.add_argument("--run-id", default=None)
2169
+ p_append.add_argument("--attempt", type=int, default=None)
2170
+ p_append.add_argument("--executor-tool", default=None)
2171
+ p_append.add_argument("--executor-agent", default=None)
2172
+ p_append.add_argument("--executor-model", default=None)
2173
+ p_append.add_argument("--handoff-from-tool", default=None)
2174
+ p_append.add_argument("--handoff-reason", default=None)
2175
+ p_append.add_argument("--trace-id", default=None)
2176
+ p_append.add_argument("--span-id", default=None)
2177
+ p_append.add_argument("--parent-span-id", default=None)
2178
+ p_append.add_argument("--runner-ms", type=int, default=None)
2179
+ p_append.add_argument("--orchestration-active-ms", type=int, default=None)
2180
+ p_append.add_argument("--wall-clock-ms", type=int, default=None)
2181
+ p_append.add_argument("--user-wait-ms", type=int, default=None)
2182
+ p_append.add_argument("--execution-mode", default=None)
2183
+ p_append.add_argument("--decision-reason-code", default=None)
2184
+ p_append.add_argument("--fallback-reason-code", default=None)
2185
+ p_append.add_argument("--trigger", default=None)
2186
+ p_append.add_argument("--from-phase", default=None)
2187
+ p_append.add_argument("--result-status", default=None)
2188
+ p_append.add_argument(
2189
+ "--legacy-lenient",
2190
+ action="store_true",
2191
+ help="accept legacy incomplete/type-mismatched events and mark them explicitly",
2192
+ )
2193
+ p_append.set_defaults(func=cmd_append)
2194
+
2195
+ p_batch = sub.add_parser(
2196
+ "batch-append",
2197
+ parents=[common],
2198
+ help="validate and append many events under one lock (H-15)",
2199
+ )
2200
+ p_batch.add_argument("--change-dir", required=True)
2201
+ p_batch.add_argument(
2202
+ "--file",
2203
+ required=True,
2204
+ help="JSON file containing a non-empty array of event objects",
2205
+ )
2206
+ p_batch.set_defaults(func=cmd_batch_append)
2207
+
2208
+ p_render = sub.add_parser(
2209
+ "render",
2210
+ parents=[common],
2211
+ help="re-render execution-log.md from events.ndjson",
2212
+ )
2213
+ p_render.add_argument("--change-dir", required=True)
2214
+ p_render.set_defaults(func=cmd_render)
2215
+
2216
+ p_summary = sub.add_parser(
2217
+ "summary",
2218
+ parents=[common],
2219
+ help="summarize phases/issues from events.ndjson",
2220
+ )
2221
+ p_summary.add_argument("--change-dir", required=True)
2222
+ p_summary.set_defaults(func=cmd_summary)
2223
+
2224
+ return parser
2225
+
2226
+
2227
+ def main(argv: list[str] | None = None) -> int:
2228
+ parser = build_parser()
2229
+ args = parser.parse_args(argv)
2230
+ return int(args.func(args))
2231
+
2232
+
2233
+ if __name__ == "__main__":
2234
+ raise SystemExit(main())