@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,2243 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve Harness runtimes into a reusable, argv-based phase capsule."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import contextlib
8
+ import datetime as dt
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import threading
18
+ import time
19
+ import uuid
20
+ from pathlib import Path
21
+ import fnmatch
22
+ from collections.abc import Sequence
23
+ from typing import Any, Callable
24
+
25
+ SCRIPTS_DIR = Path(__file__).resolve().parent
26
+ if str(SCRIPTS_DIR) not in sys.path:
27
+ sys.path.insert(0, str(SCRIPTS_DIR))
28
+
29
+ if hasattr(sys.stdout, "reconfigure"):
30
+ sys.stdout.reconfigure(encoding="utf-8")
31
+ if hasattr(sys.stderr, "reconfigure"):
32
+ sys.stderr.reconfigure(encoding="utf-8")
33
+
34
+ SCHEMA_VERSION = 1
35
+ RUN_SESSION_SCHEMA_VERSION = 1
36
+ RUN_TERMINAL_STATUSES = {"OK", "FAIL", "INCOMPLETE", "CANCELLED"}
37
+ _CHANGE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
38
+ SECRET_SCAN_RULES_VERSION = "secret-scan-v2"
39
+ SECRET_SCAN_RECEIPT_REL = Path("meta") / "secret-scan-receipt.json"
40
+ # 变更目录里只有一部分内容会被发布:归档包成员是 reports/final/summary-data.json、
41
+ # spec/**.md、plans/**.md、candidates/knowledge.json、meta/archive-meta.md、
42
+ # meta/change-context.json。runtime/ 是各阶段的草稿(review 的 diff、临时校验
43
+ # 脚本、命令输出重定向),从不入包——拿它挡发布,挡的是永远不会离开本机的字节。
44
+ PUBLICATION_EXCLUDED_DIRS: tuple[str, ...] = ("runtime",)
45
+ _SENSITIVE_ASSIGNMENT = re.compile(
46
+ rb"(?i)[\"']?(?:password|passwd|token|secret|cookie|database[_-]?url)"
47
+ rb"[\"']?\s*[:=]\s*[\"']?(?P<value>[^\s\"']{4,})"
48
+ )
49
+ _AUTHORIZATION_ASSIGNMENT = re.compile(
50
+ rb"(?i)[\"']?authorization[\"']?\s*[:=]\s*[\"']?"
51
+ rb"(?:(?:bearer|basic)\s+)?(?P<value>[^\s\"']{4,})"
52
+ )
53
+ _ADAPTERS = {
54
+ "claude-code": (".worktrees", "harness/"),
55
+ "codex": (".worktrees", "harness/"),
56
+ "cursor": (".worktrees", "harness/"),
57
+ "codebuddy": (".worktrees", "harness/"),
58
+ }
59
+
60
+
61
+ def now_iso() -> str:
62
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
63
+
64
+
65
+ def _stage_transition(receipt: dict[str, Any], next_stage: str) -> None:
66
+ ended_at = now_iso()
67
+ started_at = str(receipt.get("stageStartedAt") or ended_at)
68
+ try:
69
+ started = dt.datetime.fromisoformat(started_at.replace("Z", "+00:00"))
70
+ ended = dt.datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
71
+ wall_ms = max(0, int((ended - started).total_seconds() * 1000))
72
+ except ValueError:
73
+ wall_ms = 0
74
+ stage = str(receipt.get("stage") or "unknown")
75
+ timings = receipt.setdefault("stageTimings", [])
76
+ if isinstance(timings, list):
77
+ timings.append(
78
+ {
79
+ "stage": stage,
80
+ "createdAt": started_at,
81
+ "endedAt": ended_at,
82
+ "wallClockMs": wall_ms,
83
+ "activeTimeMs": 0 if stage == "resource-wait" else wall_ms,
84
+ "resourceWaitMs": wall_ms if stage == "resource-wait" else 0,
85
+ }
86
+ )
87
+ receipt["stage"] = next_stage
88
+ receipt["stageStartedAt"] = ended_at
89
+
90
+
91
+ def _close_current_stage(receipt: dict[str, Any]) -> None:
92
+ current = str(receipt.get("stage") or "record")
93
+ _stage_transition(receipt, current)
94
+ timings = receipt.get("stageTimings")
95
+ if isinstance(timings, list) and timings:
96
+ receipt["stageStartedAt"] = timings[-1].get("endedAt")
97
+
98
+
99
+ def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
100
+ path.parent.mkdir(parents=True, exist_ok=True)
101
+ text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
102
+ fd, raw_tmp = tempfile.mkstemp(
103
+ prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
104
+ )
105
+ tmp = Path(raw_tmp)
106
+ try:
107
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
108
+ handle.write(text)
109
+ handle.flush()
110
+ os.fsync(handle.fileno())
111
+ # Windows can transiently deny replacement while another process has
112
+ # just finished reading the receipt. Keep the atomic replace contract,
113
+ # but retry the sharing violation within a small bounded window.
114
+ for attempt in range(20):
115
+ try:
116
+ os.replace(tmp, path)
117
+ break
118
+ except PermissionError:
119
+ if attempt == 19:
120
+ raise
121
+ time.sleep(0.01 * (attempt + 1))
122
+ finally:
123
+ tmp.unlink(missing_ok=True)
124
+
125
+
126
+ def _sha256_json(value: Any) -> str:
127
+ encoded = json.dumps(
128
+ value,
129
+ ensure_ascii=False,
130
+ sort_keys=True,
131
+ separators=(",", ":"),
132
+ ).encode("utf-8")
133
+ return "sha256:" + hashlib.sha256(encoded).hexdigest()
134
+
135
+
136
+ def _is_excluded(relative: str, exclude_dirs: Sequence[str]) -> bool:
137
+ """Path is inside one of the excluded top-level directories."""
138
+ head = relative.split("/", 1)[0]
139
+ return head in exclude_dirs
140
+
141
+
142
+ def _publishable_tree_digest(
143
+ root: Path, *, exclude_dirs: Sequence[str] = ()
144
+ ) -> str:
145
+ """Hash publishable bytes while excluding the self-referential scan receipt."""
146
+ root = root.expanduser().resolve()
147
+ digest = hashlib.sha256()
148
+ receipt = (root / SECRET_SCAN_RECEIPT_REL).resolve()
149
+ if not root.is_dir():
150
+ raise OSError(f"publishable evidence root not found: {root}")
151
+ for path in sorted(root.rglob("*")):
152
+ if not path.is_file() or path.resolve() == receipt:
153
+ continue
154
+ if exclude_dirs and _is_excluded(
155
+ path.relative_to(root).as_posix(), exclude_dirs
156
+ ):
157
+ continue
158
+ relative = path.relative_to(root).as_posix()
159
+ data = path.read_bytes()
160
+ digest.update(relative.encode("utf-8"))
161
+ digest.update(b"\0")
162
+ digest.update(str(len(data)).encode("ascii"))
163
+ digest.update(b"\0")
164
+ digest.update(hashlib.sha256(data).digest())
165
+ digest.update(b"\0")
166
+ return "sha256:" + digest.hexdigest()
167
+
168
+
169
+ def _sensitive_evidence_receipt_path(change_root: Path) -> Path:
170
+ return change_root.expanduser().resolve() / SECRET_SCAN_RECEIPT_REL
171
+
172
+
173
+ def _private_evidence_root(explicit: Path | None = None) -> Path:
174
+ configured = os.environ.get("HARNESS_PRIVATE_EVIDENCE_ROOT")
175
+ return (explicit if explicit is not None else Path(configured) if configured else
176
+ Path.home() / ".harness" / "private-evidence").expanduser().resolve()
177
+
178
+
179
+ def private_evidence_root_for(source: Path, explicit: Path | None = None) -> Path:
180
+ """私有隔离根:必须与源同一驱动器。
181
+
182
+ `os.replace` 跨驱动器会直接失败(Windows 上是 WinError 17)。默认根
183
+ `~/.harness/private-evidence` 在 Windows 落 C 盘,项目在别的盘时隔离必然失败
184
+ ——而隔离是归档的硬前置,等于整条归档路被堵死。同盘时沿用配置/默认值;不同盘
185
+ 时退到源所在驱动器根下的 `.harness-private-evidence`(天然在任何项目根之外)。
186
+ POSIX 上 drive 恒为空串,行为不变。
187
+ """
188
+ source = source.expanduser().resolve()
189
+ preferred = _private_evidence_root(explicit)
190
+ if preferred.drive.lower() == source.drive.lower():
191
+ return preferred
192
+ return (Path(source.anchor) / ".harness-private-evidence").resolve()
193
+
194
+
195
+ def _infer_project_root(change_root: Path) -> Path | None:
196
+ """change 目录形如 `<project>/.harness/changes/<name>`:取路径里 `.harness` 段的父目录。
197
+
198
+ 不做"向上找存在 .harness 的目录"式搜索——用户 HOME 下通常也有 `.harness`,
199
+ 那样会把 HOME 误判成项目根,把合法的临时隔离位置一并拒掉。
200
+ """
201
+ parts = change_root.parts
202
+ for index in range(len(parts) - 1, 0, -1):
203
+ if parts[index] == ".harness":
204
+ return Path(*parts[:index])
205
+ return None
206
+
207
+
208
+ def _secure_private_path(path: Path, *, directory: bool) -> str:
209
+ """Best-effort owner-only permissions, with an explicit ACL result on Windows."""
210
+ mode = 0o700 if directory else 0o600
211
+ try:
212
+ path.chmod(mode)
213
+ except OSError:
214
+ pass
215
+ if os.name != "nt":
216
+ return "POSIX_OWNER_ONLY"
217
+ try:
218
+ account = os.environ.get("USERNAME") or os.environ.get("USER")
219
+ if not account:
220
+ account = subprocess.check_output(
221
+ ["whoami"], stderr=subprocess.DEVNULL, text=True, encoding="utf-8"
222
+ ).strip()
223
+ result = subprocess.run(
224
+ ["icacls", str(path), "/inheritance:r", "/grant:r", f"{account}:F"],
225
+ capture_output=True,
226
+ text=True,
227
+ encoding="utf-8",
228
+ errors="replace",
229
+ check=False,
230
+ timeout=10,
231
+ )
232
+ return "WINDOWS_CURRENT_USER_ONLY" if result.returncode == 0 else "WINDOWS_ACL_UNVERIFIED"
233
+ except (OSError, subprocess.SubprocessError):
234
+ return "WINDOWS_ACL_UNVERIFIED"
235
+
236
+
237
+ # runtime/ 下哪些是**过程草稿**。白名单式判断:只删明确列举的形态,
238
+ # 其余一律保留——门禁、租约、恢复和证据都住在这棵树里,宁可留垃圾也不能误删。
239
+ SCRATCH_PATTERNS: tuple[str, ...] = (
240
+ "*.patch",
241
+ "*.diff",
242
+ "*-input.json",
243
+ "*-input[0-9].json",
244
+ "findings-rereview.json",
245
+ "archive-out*.json",
246
+ "fixback-*-check.*",
247
+ )
248
+ # 即便命中上面的形态也必须留下的名字:它们是运行态,不是草稿。
249
+ SCRATCH_KEEP: frozenset[str] = frozenset({
250
+ "context-lease.json",
251
+ "fixback-session.json",
252
+ "preflight.json",
253
+ })
254
+
255
+
256
+ def sweep_scratch(change_root: Path, *, dry_run: bool = False) -> dict[str, Any]:
257
+ """清掉 runtime/ 顶层的过程草稿,保留运行态、会话、证据与报告。
258
+
259
+ 一轮流程下来 runtime/ 会堆满 diff、临时校验脚本和命令输出重定向。没人清,
260
+ 而且它们会被其他门禁读到——2026-08-19 那次,review 自己生成的两个 diff
261
+ 直接把归档挡住了。
262
+
263
+ 只扫 runtime/ 顶层文件:run-sessions/ 等子目录是托管会话证据,不进扫描。
264
+ """
265
+ change_root = change_root.expanduser().resolve()
266
+ runtime_dir = change_root / "runtime"
267
+ removed: list[str] = []
268
+ if not runtime_dir.is_dir():
269
+ return {"ok": True, "code": "SCRATCH_SWEPT", "removed": [], "dryRun": dry_run}
270
+ errors: list[dict[str, str]] = []
271
+ for path in sorted(runtime_dir.iterdir()):
272
+ if not path.is_file() or path.name in SCRATCH_KEEP:
273
+ continue
274
+ if not any(fnmatch.fnmatch(path.name, pattern) for pattern in SCRATCH_PATTERNS):
275
+ continue
276
+ relative = path.relative_to(change_root).as_posix()
277
+ if dry_run:
278
+ removed.append(relative)
279
+ continue
280
+ try:
281
+ path.unlink()
282
+ except OSError as exc:
283
+ errors.append({"path": relative, "error": str(exc)})
284
+ continue
285
+ removed.append(relative)
286
+ return {
287
+ "ok": not errors,
288
+ "code": "SCRATCH_SWEPT" if not errors else "SCRATCH_SWEEP_PARTIAL",
289
+ "removed": removed,
290
+ "errors": errors,
291
+ "dryRun": dry_run,
292
+ }
293
+
294
+
295
+ def _sensitive_candidates(
296
+ root: Path, *, exclude_dirs: Sequence[str] = ()
297
+ ) -> list[dict[str, Any]]:
298
+ """Find high-confidence plaintext assignments without treating prose as a secret.
299
+
300
+ ``exclude_dirs`` scopes the scan to what a caller actually publishes. The
301
+ default stays whole-tree: the quarantine flow depends on seeing legacy
302
+ plaintext anywhere under the change, including scratch directories.
303
+ """
304
+ # Compare by relative path, not by resolve() per file: this loop covers the
305
+ # whole change tree, and on Windows each resolve() is a realpath syscall.
306
+ receipt_rel = SECRET_SCAN_RECEIPT_REL.as_posix()
307
+ candidates: list[dict[str, Any]] = []
308
+ for path in sorted(root.rglob("*")):
309
+ if not path.is_file():
310
+ continue
311
+ relative = path.relative_to(root).as_posix()
312
+ if relative == receipt_rel:
313
+ continue
314
+ if exclude_dirs and _is_excluded(relative, exclude_dirs):
315
+ continue
316
+ try:
317
+ raw = path.read_bytes()
318
+ except OSError:
319
+ continue
320
+ finding = next(
321
+ (
322
+ match
323
+ for pattern in (_SENSITIVE_ASSIGNMENT, _AUTHORIZATION_ASSIGNMENT)
324
+ for match in pattern.finditer(raw)
325
+ if not _is_sensitive_placeholder(match.group("value"))
326
+ ),
327
+ None,
328
+ )
329
+ if finding is None:
330
+ continue
331
+ candidates.append({
332
+ "path": path.relative_to(root).as_posix(),
333
+ "reasonCode": "PLAINTEXT_SENSITIVE_ASSIGNMENT",
334
+ "digest": "sha256:" + hashlib.sha256(raw).hexdigest(),
335
+ })
336
+ return candidates
337
+
338
+
339
+ def _is_sensitive_placeholder(value: bytes) -> bool:
340
+ text = value.decode("utf-8", errors="ignore").strip().strip("`'").strip()
341
+ return bool(
342
+ re.fullmatch(r"<[^<>]+>", text)
343
+ or re.fullmatch(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}", text)
344
+ or re.fullmatch(r"\{\{[^{}]+\}\}", text)
345
+ or re.fullmatch(
346
+ r"(?i)(?:example|placeholder|redacted|masked|bearer|basic|"
347
+ r"your[-_][A-Za-z0-9_-]+|x{4,})",
348
+ text,
349
+ )
350
+ )
351
+
352
+
353
+ def publishable_tree_digest(
354
+ root: Path, *, exclude_dirs: Sequence[str] = ()
355
+ ) -> str:
356
+ """Public digest helper shared by the archive publication gate."""
357
+ return _publishable_tree_digest(root, exclude_dirs=exclude_dirs)
358
+
359
+
360
+ def sensitive_evidence_receipt_path(change_root: Path) -> Path:
361
+ return _sensitive_evidence_receipt_path(change_root)
362
+
363
+
364
+ def sensitive_evidence_candidates(
365
+ root: Path, *, exclude_dirs: Sequence[str] = ()
366
+ ) -> list[dict[str, Any]]:
367
+ return _sensitive_candidates(root, exclude_dirs=exclude_dirs)
368
+
369
+
370
+ def _build_sensitive_scan_receipt(
371
+ change_root: Path,
372
+ *,
373
+ entries: list[dict[str, Any]],
374
+ unresolved: list[dict[str, Any]],
375
+ status: str,
376
+ exclude_dirs: Sequence[str] = (),
377
+ ) -> dict[str, Any]:
378
+ change_root = change_root.expanduser().resolve()
379
+ return {
380
+ "schemaVersion": 1,
381
+ "rulesVersion": SECRET_SCAN_RULES_VERSION,
382
+ "changeId": change_root.name,
383
+ "status": status,
384
+ "unresolvedFailures": unresolved,
385
+ "entries": entries,
386
+ "publishableTreeDigest": _publishable_tree_digest(
387
+ change_root, exclude_dirs=exclude_dirs
388
+ ),
389
+ # 收据自述扫描范围:复算摘要的一方据此排除同样的目录,
390
+ # 否则两侧口径不同会误报 digest 漂移。
391
+ "publicationExcludedDirs": list(exclude_dirs),
392
+ "publicationExcluded": True,
393
+ "recordedAt": now_iso(),
394
+ }
395
+
396
+
397
+ def _write_sensitive_scan_receipt(
398
+ change_root: Path,
399
+ *,
400
+ entries: list[dict[str, Any]],
401
+ unresolved: list[dict[str, Any]],
402
+ status: str,
403
+ exclude_dirs: Sequence[str] = (),
404
+ ) -> dict[str, Any]:
405
+ change_root = change_root.expanduser().resolve()
406
+ receipt = _build_sensitive_scan_receipt(
407
+ change_root,
408
+ entries=entries,
409
+ unresolved=unresolved,
410
+ status=status,
411
+ exclude_dirs=exclude_dirs,
412
+ )
413
+ atomic_write_json(_sensitive_evidence_receipt_path(change_root), receipt)
414
+ return receipt
415
+
416
+
417
+ def quarantine_sensitive_evidence(
418
+ source: Path,
419
+ *,
420
+ change_root: Path,
421
+ reason: str = "sensitive evidence",
422
+ private_root: Path | None = None,
423
+ project_root: Path | None = None,
424
+ ) -> dict[str, Any]:
425
+ """Atomically move legacy plaintext evidence outside any publishable tree."""
426
+ source = source.expanduser().resolve()
427
+ change_root = change_root.expanduser().resolve()
428
+ private = private_evidence_root_for(source, private_root)
429
+ if not source.is_file():
430
+ return {"ok": False, "reasonCode": "SENSITIVE_EVIDENCE_SOURCE_MISSING", "sourcePath": str(source)}
431
+ if private == change_root or private.is_relative_to(change_root):
432
+ return {
433
+ "ok": False,
434
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
435
+ "error": "private quarantine root must be outside the publishable change root",
436
+ }
437
+ # 归档的密钥扫描门禁按**项目根**判定(SECRET_SCAN_PRIVATE_PATH_IN_COPY_ROOT)。
438
+ # 这里此前只看 change_root,比门禁宽——于是隔离"成功"了,门禁再拒一次,调用方
439
+ # 得多试一轮才找到合法位置。对齐到同一条边界,错就错在当场。
440
+ resolved_project = (
441
+ project_root.expanduser().resolve()
442
+ if project_root is not None
443
+ else _infer_project_root(change_root)
444
+ )
445
+ if resolved_project is not None and (
446
+ private == resolved_project or private.is_relative_to(resolved_project)
447
+ ):
448
+ return {
449
+ "ok": False,
450
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
451
+ "error": (
452
+ "private quarantine root must be outside the project root "
453
+ f"({resolved_project}); archive's secret scan rejects paths inside it"
454
+ ),
455
+ }
456
+ try:
457
+ raw_digest = "sha256:" + hashlib.sha256(source.read_bytes()).hexdigest()
458
+ quarantine_dir = private / uuid.uuid4().hex
459
+ quarantine_dir.mkdir(parents=True, exist_ok=False)
460
+ acl_dir = _secure_private_path(quarantine_dir, directory=True)
461
+ target = quarantine_dir / "payload.bin"
462
+ os.replace(source, target)
463
+ acl_file = _secure_private_path(target, directory=False)
464
+ if os.name == "nt" and (
465
+ acl_dir != "WINDOWS_CURRENT_USER_ONLY"
466
+ or acl_file != "WINDOWS_CURRENT_USER_ONLY"
467
+ ):
468
+ os.replace(target, source)
469
+ shutil.rmtree(quarantine_dir, ignore_errors=True)
470
+ return {
471
+ "ok": False,
472
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
473
+ "error": "private quarantine ACL could not be restricted to the current user",
474
+ }
475
+ moved_digest = "sha256:" + hashlib.sha256(target.read_bytes()).hexdigest()
476
+ if moved_digest != raw_digest:
477
+ os.replace(target, source)
478
+ shutil.rmtree(quarantine_dir, ignore_errors=True)
479
+ return {
480
+ "ok": False,
481
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
482
+ "error": "quarantine digest verification failed",
483
+ }
484
+ try:
485
+ prior = json.loads(
486
+ _sensitive_evidence_receipt_path(change_root).read_text(
487
+ encoding="utf-8-sig"
488
+ )
489
+ )
490
+ except (OSError, json.JSONDecodeError):
491
+ prior = {}
492
+ entries = list(prior.get("entries") or []) if isinstance(prior, dict) else []
493
+ entries.append({
494
+ "sourcePath": (
495
+ source.relative_to(change_root).as_posix()
496
+ if source.is_relative_to(change_root)
497
+ else source.name
498
+ ),
499
+ "sourceDigest": raw_digest,
500
+ "privatePath": str(target),
501
+ "reason": reason[:160],
502
+ "status": "QUARANTINED",
503
+ "acl": {"directory": acl_dir, "file": acl_file},
504
+ "movedAt": now_iso(),
505
+ })
506
+ try:
507
+ receipt = _write_sensitive_scan_receipt(
508
+ change_root,
509
+ entries=entries,
510
+ unresolved=[],
511
+ status="QUARANTINED",
512
+ )
513
+ except BaseException as exc:
514
+ try:
515
+ os.replace(target, source)
516
+ except OSError:
517
+ pass
518
+ shutil.rmtree(quarantine_dir, ignore_errors=True)
519
+ return {
520
+ "ok": False,
521
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
522
+ "error": str(exc),
523
+ }
524
+ return {
525
+ "ok": True,
526
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINED",
527
+ "sourceDigest": raw_digest,
528
+ "privatePath": str(target),
529
+ "receiptPath": str(_sensitive_evidence_receipt_path(change_root)),
530
+ "receipt": receipt,
531
+ }
532
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
533
+ # No copy is retained on failure. If os.replace crossed filesystems it
534
+ # fails before removing the source, which is the required fail-closed path.
535
+ return {
536
+ "ok": False,
537
+ "reasonCode": "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
538
+ "error": str(exc),
539
+ }
540
+
541
+
542
+ def ensure_sensitive_evidence_scan_receipt(change_root: Path) -> dict[str, Any]:
543
+ """Create a digest-bound scan receipt for a tree with no plaintext findings."""
544
+ change_root = change_root.expanduser().resolve()
545
+ unresolved = _sensitive_candidates(change_root)
546
+ if unresolved:
547
+ return _write_sensitive_scan_receipt(
548
+ change_root,
549
+ entries=[],
550
+ unresolved=unresolved,
551
+ status="FAIL",
552
+ )
553
+ return _write_sensitive_scan_receipt(
554
+ change_root,
555
+ entries=[],
556
+ unresolved=[],
557
+ status="OK",
558
+ )
559
+
560
+
561
+ def refresh_sensitive_evidence_scan_receipt(
562
+ change_root: Path,
563
+ *,
564
+ persist: bool = True,
565
+ exclude_dirs: Sequence[str] = (),
566
+ ) -> dict[str, Any]:
567
+ """Rescan current bytes while preserving valid quarantine audit entries."""
568
+ change_root = change_root.expanduser().resolve()
569
+ receipt_path = _sensitive_evidence_receipt_path(change_root)
570
+ entries: list[dict[str, Any]] = []
571
+ if receipt_path.is_file():
572
+ try:
573
+ prior = json.loads(receipt_path.read_text(encoding="utf-8-sig"))
574
+ except (OSError, json.JSONDecodeError) as exc:
575
+ return {
576
+ "ok": False,
577
+ "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
578
+ "receiptPath": str(receipt_path),
579
+ "error": str(exc),
580
+ }
581
+ if not isinstance(prior, dict):
582
+ return {
583
+ "ok": False,
584
+ "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
585
+ "receiptPath": str(receipt_path),
586
+ "error": "receipt must be an object",
587
+ }
588
+ prior_entries = prior.get("entries")
589
+ prior_unresolved = prior.get("unresolvedFailures")
590
+ valid_identity = (
591
+ prior.get("schemaVersion") == 1
592
+ and prior.get("rulesVersion") == SECRET_SCAN_RULES_VERSION
593
+ and prior.get("changeId") == change_root.name
594
+ and prior.get("publicationExcluded") is True
595
+ and str(prior.get("status") or "").upper()
596
+ in {"OK", "QUARANTINED", "FAIL"}
597
+ )
598
+ if (
599
+ not valid_identity
600
+ or not isinstance(prior_entries, list)
601
+ or not isinstance(prior_unresolved, list)
602
+ ):
603
+ return {
604
+ "ok": False,
605
+ "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
606
+ "receiptPath": str(receipt_path),
607
+ "error": "receipt identity or collection fields are invalid",
608
+ }
609
+ entries = list(prior_entries)
610
+
611
+ unresolved = _sensitive_candidates(change_root, exclude_dirs=exclude_dirs)
612
+ status = "FAIL" if unresolved else "QUARANTINED" if entries else "OK"
613
+ if persist:
614
+ try:
615
+ receipt = _write_sensitive_scan_receipt(
616
+ change_root,
617
+ entries=entries,
618
+ unresolved=unresolved,
619
+ status=status,
620
+ exclude_dirs=exclude_dirs,
621
+ )
622
+ except OSError as exc:
623
+ return {
624
+ "ok": False,
625
+ "reasonCode": "SECRET_SCAN_RECEIPT_REFRESH_FAILED",
626
+ "receiptPath": str(receipt_path),
627
+ "error": str(exc),
628
+ }
629
+ else:
630
+ receipt = _build_sensitive_scan_receipt(
631
+ change_root,
632
+ entries=entries,
633
+ unresolved=unresolved,
634
+ status=status,
635
+ exclude_dirs=exclude_dirs,
636
+ )
637
+ return {
638
+ "ok": not unresolved,
639
+ "reasonCode": (
640
+ "SENSITIVE_EVIDENCE_UNQUARANTINED"
641
+ if unresolved
642
+ else "SECRET_SCAN_RECEIPT_REFRESHED"
643
+ ),
644
+ "receiptPath": str(receipt_path),
645
+ "receipt": receipt,
646
+ "persisted": persist,
647
+ "unresolvedFailures": unresolved,
648
+ }
649
+
650
+
651
+ def _run_sessions_root(state_root: Path) -> Path:
652
+ return state_root.expanduser().resolve() / "runtime" / "run-sessions"
653
+
654
+
655
+ def _run_session_root(state_root: Path, session_id: str) -> Path:
656
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", session_id):
657
+ raise ValueError(f"RUN_SESSION_ID_INVALID: {session_id}")
658
+ return _run_sessions_root(state_root) / session_id
659
+
660
+
661
+ def _run_receipt_path(state_root: Path, session_id: str) -> Path:
662
+ return _run_session_root(state_root, session_id) / "session.json"
663
+
664
+
665
+ def _resource_lock_secrets_path(state_root: Path, session_id: str) -> Path:
666
+ return _run_session_root(state_root, session_id) / "resource-lock-secrets.json"
667
+
668
+
669
+ def _load_run_receipt(state_root: Path, session_id: str) -> dict[str, Any]:
670
+ path = _run_receipt_path(state_root, session_id)
671
+ last_error: OSError | None = None
672
+ for attempt in range(20):
673
+ try:
674
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
675
+ break
676
+ except FileNotFoundError as exc:
677
+ raise ValueError(f"RUN_SESSION_NOT_FOUND: {session_id}") from exc
678
+ except PermissionError as exc:
679
+ last_error = exc
680
+ if attempt == 19:
681
+ raise ValueError(
682
+ f"RUN_SESSION_RECEIPT_BUSY: {session_id}"
683
+ ) from exc
684
+ time.sleep(0.01 * (attempt + 1))
685
+ else:
686
+ raise ValueError(f"RUN_SESSION_RECEIPT_BUSY: {session_id}") from last_error
687
+ if not isinstance(value, dict):
688
+ raise ValueError(f"RUN_SESSION_CORRUPT: {session_id}")
689
+ return value
690
+
691
+
692
+ def _load_resource_lock_tokens(
693
+ state_root: Path,
694
+ session_id: str,
695
+ receipt: dict[str, Any],
696
+ ) -> dict[str, str]:
697
+ """Load lock tokens from a private sidecar; legacy receipts may inline them."""
698
+ path = _resource_lock_secrets_path(state_root, session_id)
699
+ try:
700
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
701
+ if isinstance(value, dict) and isinstance(value.get("tokens"), dict):
702
+ return {
703
+ str(name): str(token)
704
+ for name, token in value["tokens"].items()
705
+ if isinstance(name, str) and isinstance(token, str)
706
+ }
707
+ except (OSError, json.JSONDecodeError, TypeError):
708
+ pass
709
+ legacy = receipt.get("resourceLockTokens")
710
+ if isinstance(legacy, dict):
711
+ return {
712
+ str(name): str(token)
713
+ for name, token in legacy.items()
714
+ if isinstance(name, str) and isinstance(token, str)
715
+ }
716
+ return {}
717
+
718
+
719
+ def _receipt_identity_state(
720
+ receipt: dict[str, Any],
721
+ *,
722
+ field: str = "workerIdentity",
723
+ ) -> bool | None:
724
+ """Verify a canonical provider attestation, with legacy facade fallback."""
725
+ identity = receipt.get(field)
726
+ pid = identity.get("pid") if isinstance(identity, dict) else receipt.get("workerPid")
727
+ if isinstance(identity, dict) and {
728
+ "schemaVersion",
729
+ "createdAt",
730
+ "fieldProvenance",
731
+ "capabilities",
732
+ } <= set(identity):
733
+ from harness_process import observe_process_identity, verify_process_identity
734
+
735
+ observed = observe_process_identity(pid)
736
+ decision = verify_process_identity(identity, observed)
737
+ if decision.get("ok") is True:
738
+ return True
739
+ if decision.get("reasonCode") == "IDENTITY_UNVERIFIABLE":
740
+ return None
741
+ return False
742
+ from harness_service import verify_process_identity
743
+
744
+ legacy = {
745
+ "pid": pid,
746
+ "startedAt": (
747
+ identity.get("startedAt")
748
+ if isinstance(identity, dict)
749
+ else None
750
+ ),
751
+ "processIdentity": {
752
+ "executable": (
753
+ identity.get("executable")
754
+ if isinstance(identity, dict)
755
+ else None
756
+ )
757
+ },
758
+ }
759
+ return verify_process_identity(legacy)
760
+
761
+
762
+ def _capture_provider_members(spawned: Any) -> None:
763
+ """Persist current provider-owned members before a later cleanup."""
764
+ proof = getattr(spawned, "ownershipProof", None)
765
+ leader = getattr(spawned, "attestation", None)
766
+ if not isinstance(proof, dict) or not isinstance(leader, dict):
767
+ return
768
+ from harness_process import capture_owned_members
769
+
770
+ capture_owned_members(spawned)
771
+
772
+
773
+ def _resource_lock_root() -> Path:
774
+ return Path(
775
+ os.environ.get(
776
+ "HARNESS_RESOURCE_LOCK_ROOT",
777
+ str(Path(tempfile.gettempdir()) / "hunter-harness-resource-locks"),
778
+ )
779
+ ).expanduser().resolve()
780
+
781
+
782
+ def _resource_lock_path(_state_root: Path, lock_name: str) -> Path:
783
+ digest = hashlib.sha256(lock_name.encode("utf-8")).hexdigest()
784
+ return _resource_lock_root() / f"{digest}.json"
785
+
786
+
787
+ def _lock_owner_identity(owner: Any) -> bool | None:
788
+ if not isinstance(owner, dict):
789
+ return None
790
+ pid = owner.get("pid")
791
+ started_at = owner.get("startedAt")
792
+ executable = owner.get("executable")
793
+ if (
794
+ not isinstance(pid, int)
795
+ or pid <= 0
796
+ or not isinstance(started_at, str)
797
+ or not started_at
798
+ or not isinstance(executable, str)
799
+ or not executable
800
+ ):
801
+ return None
802
+ from harness_service import verify_process_identity
803
+
804
+ return verify_process_identity(
805
+ {
806
+ "pid": pid,
807
+ "startedAt": started_at,
808
+ "processIdentity": {"executable": executable},
809
+ }
810
+ )
811
+
812
+
813
+ def _valid_resource_lock_record(
814
+ record: Any,
815
+ *,
816
+ lock_name: str,
817
+ ) -> bool:
818
+ return bool(
819
+ isinstance(record, dict)
820
+ and record.get("schemaVersion") == 2
821
+ and record.get("lockName") == lock_name
822
+ and isinstance(record.get("sessionId"), str)
823
+ and record.get("sessionId")
824
+ and isinstance(record.get("token"), str)
825
+ and record.get("token")
826
+ and isinstance(record.get("heartbeatAtUnix"), (int, float))
827
+ and isinstance(record.get("expiresAtUnix"), (int, float))
828
+ and _lock_owner_identity(record.get("owner")) is not None
829
+ )
830
+
831
+
832
+ def _write_resource_lock(
833
+ path: Path,
834
+ *,
835
+ lock_name: str,
836
+ session_id: str,
837
+ token: str,
838
+ owner: dict[str, Any],
839
+ acquired_at: str,
840
+ ) -> None:
841
+ now = time.time()
842
+ atomic_write_json(
843
+ path,
844
+ {
845
+ "schemaVersion": 2,
846
+ "lockName": lock_name,
847
+ "sessionId": session_id,
848
+ "token": token,
849
+ "acquiredAt": acquired_at,
850
+ "heartbeatAtUnix": now,
851
+ "expiresAtUnix": now + 300.0,
852
+ "owner": owner,
853
+ },
854
+ )
855
+
856
+
857
+ def _refresh_resource_locks(
858
+ state_root: Path,
859
+ session_id: str,
860
+ tokens: dict[str, str],
861
+ owner: dict[str, Any],
862
+ ) -> bool:
863
+ refreshed = True
864
+ for lock_name, token in sorted(tokens.items()):
865
+ path = _resource_lock_path(state_root, lock_name)
866
+ try:
867
+ record = json.loads(path.read_text(encoding="utf-8-sig"))
868
+ except (OSError, json.JSONDecodeError):
869
+ refreshed = False
870
+ continue
871
+ if (
872
+ not _valid_resource_lock_record(record, lock_name=lock_name)
873
+ or record.get("sessionId") != session_id
874
+ or record.get("token") != token
875
+ ):
876
+ refreshed = False
877
+ continue
878
+ _write_resource_lock(
879
+ path,
880
+ lock_name=lock_name,
881
+ session_id=session_id,
882
+ token=token,
883
+ owner=owner,
884
+ acquired_at=str(record["acquiredAt"]),
885
+ )
886
+ return refreshed
887
+
888
+
889
+ def _release_resource_locks(
890
+ state_root: Path,
891
+ session_id: str,
892
+ tokens: dict[str, str],
893
+ ) -> bool:
894
+ released = True
895
+ for lock_name, token in sorted(tokens.items()):
896
+ path = _resource_lock_path(state_root, lock_name)
897
+ try:
898
+ record = json.loads(path.read_text(encoding="utf-8-sig"))
899
+ except FileNotFoundError:
900
+ continue
901
+ except (OSError, json.JSONDecodeError):
902
+ released = False
903
+ continue
904
+ if (
905
+ not isinstance(record, dict)
906
+ or record.get("sessionId") != session_id
907
+ or record.get("token") != token
908
+ ):
909
+ released = False
910
+ continue
911
+ try:
912
+ path.unlink()
913
+ except OSError:
914
+ released = False
915
+ return released
916
+
917
+
918
+ def _acquire_resource_locks(
919
+ state_root: Path,
920
+ session_id: str,
921
+ lock_names: list[str],
922
+ ) -> tuple[dict[str, str], dict[str, Any] | None]:
923
+ tokens: dict[str, str] = {}
924
+ for lock_name in lock_names:
925
+ path = _resource_lock_path(state_root, lock_name)
926
+ path.parent.mkdir(parents=True, exist_ok=True)
927
+ token = uuid.uuid4().hex
928
+ owner_identity = _process_identity(os.getpid(), sys.executable)
929
+ now = time.time()
930
+ record = {
931
+ "schemaVersion": 2,
932
+ "lockName": lock_name,
933
+ "sessionId": session_id,
934
+ "token": token,
935
+ "acquiredAt": now_iso(),
936
+ "heartbeatAtUnix": now,
937
+ "expiresAtUnix": now + 300.0,
938
+ "owner": owner_identity,
939
+ }
940
+ encoded = (
941
+ json.dumps(record, ensure_ascii=False, indent=2) + "\n"
942
+ ).encode("utf-8")
943
+ for attempt in range(2):
944
+ try:
945
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
946
+ except FileExistsError:
947
+ owner: dict[str, Any] | None = None
948
+ try:
949
+ loaded = json.loads(path.read_text(encoding="utf-8-sig"))
950
+ owner = loaded if isinstance(loaded, dict) else None
951
+ except (OSError, json.JSONDecodeError):
952
+ owner = None
953
+ reclaimable = bool(
954
+ _valid_resource_lock_record(owner, lock_name=lock_name)
955
+ and _lock_owner_identity(owner.get("owner")) is False
956
+ )
957
+ if reclaimable and attempt == 0:
958
+ try:
959
+ current = json.loads(path.read_text(encoding="utf-8-sig"))
960
+ if current.get("token") == owner.get("token"):
961
+ path.unlink()
962
+ continue
963
+ except (OSError, json.JSONDecodeError):
964
+ pass
965
+ _release_resource_locks(state_root, session_id, tokens)
966
+ return {}, {
967
+ "lockName": lock_name,
968
+ "ownerSessionId": (
969
+ owner.get("sessionId") if owner is not None else None
970
+ ),
971
+ "lockRecordValid": bool(
972
+ _valid_resource_lock_record(owner, lock_name=lock_name)
973
+ ),
974
+ "ownerIdentity": (
975
+ _lock_owner_identity(owner.get("owner"))
976
+ if owner is not None
977
+ else None
978
+ ),
979
+ }
980
+ else:
981
+ break
982
+ else:
983
+ raise RuntimeError("RESOURCE_LOCK_ACQUIRE_RETRY_EXHAUSTED")
984
+ try:
985
+ with os.fdopen(fd, "wb") as handle:
986
+ handle.write(encoded)
987
+ handle.flush()
988
+ os.fsync(handle.fileno())
989
+ except BaseException:
990
+ path.unlink(missing_ok=True)
991
+ _release_resource_locks(state_root, session_id, tokens)
992
+ raise
993
+ tokens[lock_name] = token
994
+ return tokens, None
995
+
996
+
997
+ def _write_run_receipt(state_root: Path, receipt: dict[str, Any]) -> None:
998
+ _update_receipt_contract(receipt)
999
+ publishable = json.loads(json.dumps(receipt, ensure_ascii=False))
1000
+ raw_tokens = publishable.pop("resourceLockTokens", {})
1001
+ if isinstance(raw_tokens, dict):
1002
+ publishable["resourceLockTokenHashes"] = {
1003
+ str(name): _sha256_json(str(token))
1004
+ for name, token in raw_tokens.items()
1005
+ if isinstance(name, str) and isinstance(token, str)
1006
+ }
1007
+ atomic_write_json(
1008
+ _run_receipt_path(state_root, str(receipt["sessionId"])),
1009
+ publishable,
1010
+ )
1011
+
1012
+
1013
+ def _process_identity(pid: int, executable_hint: str) -> dict[str, Any]:
1014
+ from harness_process import observe_process_identity
1015
+
1016
+ observed = observe_process_identity(pid)
1017
+ if not observed.get("executable"):
1018
+ observed["executable"] = _absolute_executable(executable_hint)
1019
+ observed["fieldProvenance"]["executable"] = "ATTESTED"
1020
+ if not observed.get("createdAt"):
1021
+ observed["createdAt"] = now_iso()
1022
+ observed["fieldProvenance"]["createdAt"] = "ATTESTED"
1023
+ # ``startedAt`` is a compatibility alias used by legacy session readers;
1024
+ # canonical identity comparisons use ``createdAt`` and provenance.
1025
+ observed["startedAt"] = observed.get("createdAt")
1026
+ return observed
1027
+
1028
+
1029
+ def _log_contract(path: Path) -> dict[str, Any]:
1030
+ raw = b""
1031
+ if path.is_file():
1032
+ try:
1033
+ raw = path.read_bytes()
1034
+ except OSError:
1035
+ raw = b""
1036
+ try:
1037
+ raw.decode("utf-8")
1038
+ decode_status = "OK"
1039
+ except UnicodeDecodeError:
1040
+ decode_status = "LOG_DECODE_DEGRADED"
1041
+ return {
1042
+ "cursor": len(raw),
1043
+ "rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
1044
+ "decodeStatus": decode_status,
1045
+ }
1046
+
1047
+
1048
+ def _update_receipt_contract(receipt: dict[str, Any]) -> None:
1049
+ worker = receipt.get("workerIdentity")
1050
+ writer_source = worker if isinstance(worker, dict) else {
1051
+ "pid": receipt.get("workerPid") or receipt.get("launcherPid")
1052
+ }
1053
+ heartbeat_kind = "WORKER" if isinstance(worker, dict) else "SUPERVISOR"
1054
+ receipt["heartbeat"] = {
1055
+ "kind": heartbeat_kind,
1056
+ "writerIdentity": _sha256_json(writer_source),
1057
+ "lastSeenAt": str(receipt.get("lastHeartbeatAt") or now_iso()),
1058
+ "ttlSeconds": max(1, int(float(receipt.get("heartbeatSeconds") or 30))),
1059
+ "staleReason": (
1060
+ "SERVICE_HEARTBEAT_STALE"
1061
+ if receipt.get("reasonCode") == "HEARTBEAT_LOST"
1062
+ else None
1063
+ ),
1064
+ }
1065
+ receipt["logs"] = {
1066
+ "stdout": _log_contract(Path(str(receipt.get("stdoutPath") or ""))),
1067
+ "stderr": _log_contract(Path(str(receipt.get("stderrPath") or ""))),
1068
+ }
1069
+ cleanup_status = str(receipt.get("cleanupStatus") or "")
1070
+ complete = cleanup_status in {
1071
+ "NO_CHILD",
1072
+ "PROCESS_EXITED",
1073
+ "PROCESS_TREE_TERMINATED",
1074
+ }
1075
+ receipt["cleanup"] = {
1076
+ "complete": complete,
1077
+ "reasonCode": None if complete else receipt.get("reasonCode"),
1078
+ }
1079
+
1080
+
1081
+ def _spawn_detached_worker(
1082
+ argv: list[str],
1083
+ *,
1084
+ cwd: Path,
1085
+ env: dict[str, str],
1086
+ output: Any = subprocess.DEVNULL,
1087
+ ) -> subprocess.Popen[bytes]:
1088
+ kwargs: dict[str, Any] = {
1089
+ "cwd": str(cwd),
1090
+ "env": env,
1091
+ "stdin": subprocess.DEVNULL,
1092
+ "stdout": output,
1093
+ "stderr": output,
1094
+ "close_fds": True,
1095
+ }
1096
+ if os.name == "nt":
1097
+ detached = 0x00000008
1098
+ new_group = 0x00000200
1099
+ no_window = 0x08000000
1100
+ breakaway = 0x01000000
1101
+ try:
1102
+ return subprocess.Popen(
1103
+ argv,
1104
+ creationflags=detached | new_group | no_window | breakaway,
1105
+ **kwargs,
1106
+ )
1107
+ except OSError:
1108
+ return subprocess.Popen(
1109
+ argv,
1110
+ creationflags=detached | new_group | no_window,
1111
+ **kwargs,
1112
+ )
1113
+ return subprocess.Popen(argv, start_new_session=True, **kwargs)
1114
+
1115
+
1116
+ def start_run_session(
1117
+ *,
1118
+ state_root: Path,
1119
+ verification: str,
1120
+ argv: list[str],
1121
+ working_directory: Path,
1122
+ environment: dict[str, str] | None = None,
1123
+ timeout_seconds: float | None = None,
1124
+ heartbeat_seconds: float = 30.0,
1125
+ expected_duration_seconds: float | None = None,
1126
+ product_identity: str | None = None,
1127
+ resource_locks: list[str] | None = None,
1128
+ run_id: str | None = None,
1129
+ session_id: str | None = None,
1130
+ ) -> dict[str, Any]:
1131
+ """Start a re-attachable managed command without shell re-parsing.
1132
+
1133
+ The short-lived launch specification is removed by the worker immediately
1134
+ after spawn. Environment values are inherited by the worker and hashed in
1135
+ the durable receipt; they are never serialized.
1136
+ """
1137
+ if not verification.strip():
1138
+ raise ValueError("RUN_SESSION_VERIFICATION_REQUIRED")
1139
+ if not argv or not all(isinstance(item, str) and item for item in argv):
1140
+ raise ValueError("RUN_SESSION_ARGV_REQUIRED")
1141
+ if heartbeat_seconds <= 0:
1142
+ raise ValueError("RUN_SESSION_HEARTBEAT_INVALID")
1143
+ if timeout_seconds is not None and timeout_seconds <= 0:
1144
+ raise ValueError("RUN_SESSION_TIMEOUT_INVALID")
1145
+ normalized_resource_locks: list[str] = []
1146
+ for raw_lock in resource_locks or []:
1147
+ if (
1148
+ not isinstance(raw_lock, str)
1149
+ or not raw_lock.strip()
1150
+ or len(raw_lock.strip()) > 256
1151
+ ):
1152
+ raise ValueError("RUN_SESSION_RESOURCE_LOCK_INVALID")
1153
+ normalized_resource_locks.append(raw_lock.strip())
1154
+ normalized_resource_locks = sorted(set(normalized_resource_locks))
1155
+
1156
+ state_root = state_root.expanduser().resolve()
1157
+ working_directory = working_directory.expanduser().resolve()
1158
+ if not working_directory.is_dir():
1159
+ raise ValueError(f"RUN_SESSION_WORKDIR_MISSING: {working_directory}")
1160
+ session_id = session_id or f"run-{uuid.uuid4().hex}"
1161
+ run_id = run_id or session_id
1162
+ session_root = _run_session_root(state_root, session_id)
1163
+ session_root.mkdir(parents=True, exist_ok=False)
1164
+ created_at = now_iso()
1165
+ stdout_path = session_root / "stdout.log"
1166
+ stderr_path = session_root / "stderr.log"
1167
+ worker_log_path = session_root / "worker.log"
1168
+ spec_path = session_root / "launch-spec.json"
1169
+ overrides = dict(environment or {})
1170
+ command_hash = _sha256_json(argv)
1171
+ environment_hash = _sha256_json(overrides)
1172
+ receipt: dict[str, Any] = {
1173
+ "schemaVersion": RUN_SESSION_SCHEMA_VERSION,
1174
+ "sessionId": session_id,
1175
+ "runId": run_id,
1176
+ "verification": verification,
1177
+ "commandHash": command_hash,
1178
+ "workingDirectory": str(working_directory),
1179
+ "environmentHash": environment_hash,
1180
+ "launcherPid": os.getpid(),
1181
+ "workerPid": None,
1182
+ "servicePid": None,
1183
+ "processIdentity": None,
1184
+ "createdAt": created_at,
1185
+ "lastHeartbeatAt": created_at,
1186
+ "startedAt": None,
1187
+ "endedAt": None,
1188
+ "exitCode": None,
1189
+ "status": "STARTING",
1190
+ "reasonCode": "WORKER_STARTING",
1191
+ "stage": "prepare",
1192
+ "stageStartedAt": created_at,
1193
+ "stageTimings": [],
1194
+ "stdoutPath": str(stdout_path),
1195
+ "stderrPath": str(stderr_path),
1196
+ "workerLogPath": str(worker_log_path),
1197
+ "resultDigest": None,
1198
+ "cleanupStatus": "PENDING",
1199
+ "testProcessStarted": False,
1200
+ "timeoutSeconds": timeout_seconds,
1201
+ "heartbeatSeconds": heartbeat_seconds,
1202
+ "expectedDurationSeconds": expected_duration_seconds,
1203
+ "productIdentity": product_identity,
1204
+ "resourceLocks": normalized_resource_locks,
1205
+ "resourceLockTokens": {},
1206
+ "resourceWaitMs": 0,
1207
+ "activeTimeMs": 0,
1208
+ "wallClockMs": 0,
1209
+ "diagnosticPath": None,
1210
+ }
1211
+ _write_run_receipt(state_root, receipt)
1212
+ lock_tokens, lock_conflict = _acquire_resource_locks(
1213
+ state_root,
1214
+ session_id,
1215
+ normalized_resource_locks,
1216
+ )
1217
+ if lock_conflict is not None:
1218
+ receipt.update(
1219
+ {
1220
+ "status": "INCOMPLETE",
1221
+ "reasonCode": "RESOURCE_LOCK_BUSY",
1222
+ "endedAt": now_iso(),
1223
+ "cleanupStatus": "NO_CHILD",
1224
+ "resourceLockConflict": lock_conflict,
1225
+ }
1226
+ )
1227
+ _write_run_receipt(state_root, receipt)
1228
+ return receipt
1229
+ receipt["resourceLockTokens"] = lock_tokens
1230
+ atomic_write_json(
1231
+ _resource_lock_secrets_path(state_root, session_id),
1232
+ {"schemaVersion": 1, "tokens": lock_tokens},
1233
+ )
1234
+ try:
1235
+ _resource_lock_secrets_path(state_root, session_id).chmod(0o600)
1236
+ except OSError:
1237
+ pass
1238
+ _write_run_receipt(state_root, receipt)
1239
+ atomic_write_json(
1240
+ spec_path,
1241
+ {
1242
+ "schemaVersion": 1,
1243
+ "stateRoot": str(state_root),
1244
+ "sessionId": session_id,
1245
+ "argv": argv,
1246
+ "heartbeatSeconds": heartbeat_seconds,
1247
+ },
1248
+ )
1249
+ try:
1250
+ spec_path.chmod(0o600)
1251
+ except OSError:
1252
+ pass
1253
+
1254
+ worker_env = {**os.environ, **overrides}
1255
+ worker_env["PYTHONUTF8"] = "1"
1256
+ worker_env["PYTHONIOENCODING"] = "utf-8"
1257
+ worker_env["HARNESS_RUN_SESSION_WORKER"] = "1"
1258
+ worker_argv = [
1259
+ _absolute_executable(sys.executable),
1260
+ str(Path(__file__).resolve()),
1261
+ "_worker",
1262
+ "--state-root",
1263
+ str(state_root),
1264
+ "--session-id",
1265
+ session_id,
1266
+ ]
1267
+ try:
1268
+ worker_log_path.touch()
1269
+ worker = _spawn_detached_worker(
1270
+ worker_argv,
1271
+ cwd=working_directory,
1272
+ env=worker_env,
1273
+ )
1274
+ except OSError as exc:
1275
+ spec_path.unlink(missing_ok=True)
1276
+ _resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
1277
+ locks_released = _release_resource_locks(
1278
+ state_root,
1279
+ session_id,
1280
+ lock_tokens,
1281
+ )
1282
+ receipt.update(
1283
+ {
1284
+ "status": "INCOMPLETE",
1285
+ "reasonCode": "WORKER_LAUNCH_FAILED",
1286
+ "endedAt": now_iso(),
1287
+ "cleanupStatus": (
1288
+ "NO_CHILD"
1289
+ if locks_released
1290
+ else "RESOURCE_LOCK_RELEASE_FAILED"
1291
+ ),
1292
+ "error": str(exc),
1293
+ }
1294
+ )
1295
+ _write_run_receipt(state_root, receipt)
1296
+ return receipt
1297
+ receipt["workerPid"] = worker.pid
1298
+ receipt["workerIdentity"] = _process_identity(worker.pid, sys.executable)
1299
+ # Best-effort early handoff. The worker repeats and verifies this transfer
1300
+ # before spawning the managed command. Do not rewrite the session receipt
1301
+ # here: the detached worker may already have advanced it to RUNNING.
1302
+ _refresh_resource_locks(
1303
+ state_root,
1304
+ session_id,
1305
+ lock_tokens,
1306
+ dict(receipt["workerIdentity"]),
1307
+ )
1308
+ threading.Thread(target=worker.wait, daemon=True).start()
1309
+ return receipt
1310
+
1311
+
1312
+ def _terminate_managed_child(
1313
+ process: subprocess.Popen[bytes],
1314
+ job: Any,
1315
+ *,
1316
+ attestation: dict[str, Any] | None = None,
1317
+ ownership_proof: dict[str, Any] | None = None,
1318
+ ) -> bool:
1319
+ from harness_process import terminate_owned_tree
1320
+
1321
+ if isinstance(attestation, dict) and isinstance(ownership_proof, dict):
1322
+ result = terminate_owned_tree(
1323
+ attestation,
1324
+ ownership_proof,
1325
+ {"graceSeconds": 3.0},
1326
+ )
1327
+ if not result.get("cleanupComplete"):
1328
+ return False
1329
+ elif job is not None and getattr(job, "handle", None) is not None:
1330
+ # Compatibility for callers still passing the old Windows job object;
1331
+ # this branch is only used for an already-owned handle, never a PID.
1332
+ job.terminate_and_wait()
1333
+ else:
1334
+ # PID-only cleanup is intentionally forbidden by the process provider.
1335
+ return False
1336
+ try:
1337
+ process.wait(timeout=5)
1338
+ except subprocess.TimeoutExpired:
1339
+ return False
1340
+ return process.poll() is not None
1341
+
1342
+
1343
+ def _run_session_worker(state_root: Path, session_id: str) -> int:
1344
+ from harness_service import verify_process_identity
1345
+
1346
+ session_root = _run_session_root(state_root, session_id)
1347
+ spec_path = session_root / "launch-spec.json"
1348
+ receipt = _load_run_receipt(state_root, session_id)
1349
+ receipt["resourceLockTokens"] = _load_resource_lock_tokens(
1350
+ state_root,
1351
+ session_id,
1352
+ receipt,
1353
+ )
1354
+ try:
1355
+ spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
1356
+ except (OSError, json.JSONDecodeError) as exc:
1357
+ _release_resource_locks(
1358
+ state_root,
1359
+ session_id,
1360
+ dict(receipt.get("resourceLockTokens") or {}),
1361
+ )
1362
+ receipt.update(
1363
+ {
1364
+ "status": "INCOMPLETE",
1365
+ "reasonCode": "LAUNCH_SPEC_INVALID",
1366
+ "endedAt": now_iso(),
1367
+ "cleanupStatus": "NO_CHILD",
1368
+ "error": str(exc),
1369
+ }
1370
+ )
1371
+ _write_run_receipt(state_root, receipt)
1372
+ return 1
1373
+ argv = [str(item) for item in spec.get("argv", [])]
1374
+ heartbeat_seconds = max(float(spec.get("heartbeatSeconds") or 30.0), 0.01)
1375
+ spec_path.unlink(missing_ok=True)
1376
+ started_monotonic = time.monotonic()
1377
+ _stage_transition(receipt, "spawn")
1378
+ receipt.update(
1379
+ {
1380
+ "workerPid": os.getpid(),
1381
+ "workerIdentity": _process_identity(os.getpid(), sys.executable),
1382
+ "lastHeartbeatAt": now_iso(),
1383
+ }
1384
+ )
1385
+ if not _refresh_resource_locks(
1386
+ state_root,
1387
+ session_id,
1388
+ dict(receipt.get("resourceLockTokens") or {}),
1389
+ dict(receipt["workerIdentity"]),
1390
+ ):
1391
+ receipt.update(
1392
+ {
1393
+ "status": "INCOMPLETE",
1394
+ "reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
1395
+ "endedAt": now_iso(),
1396
+ "cleanupStatus": "NO_CHILD",
1397
+ }
1398
+ )
1399
+ _write_run_receipt(state_root, receipt)
1400
+ return 1
1401
+ _write_run_receipt(state_root, receipt)
1402
+
1403
+ stdout_path = Path(str(receipt["stdoutPath"]))
1404
+ stderr_path = Path(str(receipt["stderrPath"]))
1405
+ stdout_path.parent.mkdir(parents=True, exist_ok=True)
1406
+ job = None
1407
+ spawned = None
1408
+ ownership_proof: dict[str, Any] | None = None
1409
+ identity: dict[str, Any] | None = None
1410
+ process: subprocess.Popen[bytes] | None = None
1411
+ try:
1412
+ with stdout_path.open("wb", buffering=0) as stdout, stderr_path.open(
1413
+ "wb", buffering=0
1414
+ ) as stderr:
1415
+ try:
1416
+ from harness_process import spawn_structured_argv
1417
+
1418
+ spawned = spawn_structured_argv(
1419
+ argv,
1420
+ cwd=Path(str(receipt["workingDirectory"])),
1421
+ environment={
1422
+ "PYTHONUTF8": "1",
1423
+ "PYTHONIOENCODING": "utf-8",
1424
+ },
1425
+ owner_token=f"run:{session_id}",
1426
+ stdout=stdout,
1427
+ stderr=stderr,
1428
+ )
1429
+ process = spawned.process
1430
+ ownership_proof = spawned.ownershipProof
1431
+ _capture_provider_members(spawned)
1432
+ except (OSError, ValueError, RuntimeError) as exc:
1433
+ receipt.update(
1434
+ {
1435
+ "status": "INCOMPLETE",
1436
+ "reasonCode": "LAUNCHER_FAILED",
1437
+ "endedAt": now_iso(),
1438
+ "exitCode": None,
1439
+ "testProcessStarted": False,
1440
+ "cleanupStatus": "NO_CHILD",
1441
+ "error": str(exc),
1442
+ }
1443
+ )
1444
+ return 1
1445
+ assert spawned is not None and process is not None
1446
+ identity = spawned.attestation
1447
+ receipt["processOwnershipProof"] = ownership_proof
1448
+ _stage_transition(receipt, "execute")
1449
+ receipt.update(
1450
+ {
1451
+ "servicePid": process.pid,
1452
+ "processIdentity": identity,
1453
+ "startedAt": identity.get("createdAt"),
1454
+ "status": "RUNNING",
1455
+ "reasonCode": "CHILD_RUNNING",
1456
+ "testProcessStarted": True,
1457
+ "processTreeIsolated": ownership_proof is not None,
1458
+ }
1459
+ )
1460
+ _write_run_receipt(state_root, receipt)
1461
+
1462
+ timeout_seconds = receipt.get("timeoutSeconds")
1463
+ next_heartbeat = time.monotonic()
1464
+ termination_reason: str | None = None
1465
+ while process.poll() is None:
1466
+ now = time.monotonic()
1467
+ if timeout_seconds is not None and (
1468
+ now - started_monotonic >= float(timeout_seconds)
1469
+ ):
1470
+ termination_reason = "TIMEOUT"
1471
+ cancel_path = session_root / "cancel-request.json"
1472
+ if cancel_path.is_file():
1473
+ termination_reason = "CANCEL_REQUESTED"
1474
+ if termination_reason is not None:
1475
+ diagnostic_path = session_root / "diagnostic.json"
1476
+ from harness_process import (
1477
+ observe_process_identity,
1478
+ verify_process_identity as verify_attestation,
1479
+ )
1480
+
1481
+ verified = verify_attestation(
1482
+ identity,
1483
+ observe_process_identity(process.pid),
1484
+ ).get("ok") is True
1485
+ job_owned = ownership_proof is not None
1486
+ if spawned is not None:
1487
+ _capture_provider_members(spawned)
1488
+ diagnostic = {
1489
+ "schemaVersion": 1,
1490
+ "sessionId": session_id,
1491
+ "reasonCode": termination_reason,
1492
+ "observedAt": now_iso(),
1493
+ "processIdentityVerified": verified,
1494
+ "terminationAuthority": (
1495
+ "JOB_OBJECT_OWNED"
1496
+ if job_owned
1497
+ else (
1498
+ "PROCESS_IDENTITY_VERIFIED"
1499
+ if verified
1500
+ else "NONE"
1501
+ )
1502
+ ),
1503
+ "pid": process.pid,
1504
+ "stdoutBytes": (
1505
+ stdout_path.stat().st_size if stdout_path.exists() else 0
1506
+ ),
1507
+ "stderrBytes": (
1508
+ stderr_path.stat().st_size if stderr_path.exists() else 0
1509
+ ),
1510
+ }
1511
+ atomic_write_json(diagnostic_path, diagnostic)
1512
+ receipt["diagnosticPath"] = str(diagnostic_path)
1513
+ if job_owned and verified:
1514
+ terminated = _terminate_managed_child(
1515
+ process,
1516
+ job,
1517
+ attestation=identity,
1518
+ ownership_proof=ownership_proof,
1519
+ )
1520
+ job = None
1521
+ if not terminated:
1522
+ receipt.update(
1523
+ {
1524
+ "status": "INCOMPLETE",
1525
+ "reasonCode": "PROCESS_TERMINATION_UNCONFIRMED",
1526
+ "cleanupStatus": "PROCESS_TERMINATION_UNCONFIRMED",
1527
+ }
1528
+ )
1529
+ else:
1530
+ receipt.update(
1531
+ {
1532
+ "status": "INCOMPLETE",
1533
+ "reasonCode": "PROCESS_IDENTITY_UNVERIFIED",
1534
+ "cleanupStatus": "UNKNOWN_PROCESS_UNTOUCHED",
1535
+ }
1536
+ )
1537
+ break
1538
+ if now >= next_heartbeat:
1539
+ receipt["lastHeartbeatAt"] = now_iso()
1540
+ receipt["activeTimeMs"] = int(
1541
+ (now - started_monotonic) * 1000
1542
+ )
1543
+ receipt["wallClockMs"] = receipt["activeTimeMs"]
1544
+ if not _refresh_resource_locks(
1545
+ state_root,
1546
+ session_id,
1547
+ dict(receipt.get("resourceLockTokens") or {}),
1548
+ dict(receipt["workerIdentity"]),
1549
+ ):
1550
+ receipt.update(
1551
+ {
1552
+ "status": "INCOMPLETE",
1553
+ "reasonCode": "RESOURCE_LOCK_OWNERSHIP_LOST",
1554
+ "cleanupStatus": "PROCESS_TREE_TERMINATION_REQUIRED",
1555
+ }
1556
+ )
1557
+ if spawned is not None:
1558
+ _capture_provider_members(spawned)
1559
+ _terminate_managed_child(
1560
+ process,
1561
+ job,
1562
+ attestation=identity,
1563
+ ownership_proof=ownership_proof,
1564
+ )
1565
+ job = None
1566
+ break
1567
+ _write_run_receipt(state_root, receipt)
1568
+ next_heartbeat = now + heartbeat_seconds
1569
+ time.sleep(min(heartbeat_seconds, 0.05))
1570
+
1571
+ exit_code = process.poll()
1572
+ _stage_transition(receipt, "summarize")
1573
+ if termination_reason is not None and receipt["reasonCode"] not in {
1574
+ "PROCESS_IDENTITY_UNVERIFIED",
1575
+ "PROCESS_TERMINATION_UNCONFIRMED",
1576
+ "RESOURCE_LOCK_OWNERSHIP_LOST",
1577
+ }:
1578
+ receipt.update(
1579
+ {
1580
+ "status": "CANCELLED",
1581
+ "reasonCode": termination_reason,
1582
+ "exitCode": exit_code,
1583
+ "cleanupStatus": "PROCESS_TREE_TERMINATED",
1584
+ }
1585
+ )
1586
+ elif receipt["reasonCode"] not in {
1587
+ "PROCESS_IDENTITY_UNVERIFIED",
1588
+ "PROCESS_TERMINATION_UNCONFIRMED",
1589
+ "RESOURCE_LOCK_OWNERSHIP_LOST",
1590
+ }:
1591
+ receipt.update(
1592
+ {
1593
+ "status": "OK" if exit_code == 0 else "FAIL",
1594
+ "reasonCode": (
1595
+ "CHILD_EXIT_ZERO"
1596
+ if exit_code == 0
1597
+ else "CHILD_EXIT_NONZERO"
1598
+ ),
1599
+ "exitCode": exit_code,
1600
+ "cleanupStatus": "PROCESS_EXITED",
1601
+ }
1602
+ )
1603
+ except BaseException as exc:
1604
+ receipt.update(
1605
+ {
1606
+ "status": "INCOMPLETE",
1607
+ "reasonCode": "WORKER_FAILED",
1608
+ "exitCode": None if process is None else process.poll(),
1609
+ "cleanupStatus": "WORKER_EXCEPTION",
1610
+ "error": str(exc),
1611
+ }
1612
+ )
1613
+ finally:
1614
+ if process is not None and process.poll() is None and ownership_proof is not None:
1615
+ if spawned is not None:
1616
+ _capture_provider_members(spawned)
1617
+ tree_drained = _terminate_managed_child(
1618
+ process,
1619
+ job,
1620
+ attestation=identity if isinstance(identity, dict) else None,
1621
+ ownership_proof=ownership_proof,
1622
+ )
1623
+ if not tree_drained:
1624
+ receipt.update(
1625
+ {
1626
+ "status": "INCOMPLETE",
1627
+ "reasonCode": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
1628
+ "cleanupStatus": "PROCESS_TREE_TERMINATION_UNCONFIRMED",
1629
+ }
1630
+ )
1631
+ elif receipt.get("cleanupStatus") == "PROCESS_EXITED":
1632
+ receipt["cleanupStatus"] = "PROCESS_TREE_TERMINATED"
1633
+ ended = time.monotonic()
1634
+ _stage_transition(receipt, "record")
1635
+ receipt["endedAt"] = now_iso()
1636
+ receipt["lastHeartbeatAt"] = receipt["endedAt"]
1637
+ receipt["activeTimeMs"] = int((ended - started_monotonic) * 1000)
1638
+ receipt["wallClockMs"] = receipt["activeTimeMs"] + int(
1639
+ receipt.get("resourceWaitMs") or 0
1640
+ )
1641
+ digest = hashlib.sha256()
1642
+ for path in (stdout_path, stderr_path):
1643
+ if path.is_file():
1644
+ digest.update(path.read_bytes())
1645
+ receipt["resultDigest"] = "sha256:" + digest.hexdigest()
1646
+ locks_released = _release_resource_locks(
1647
+ state_root,
1648
+ session_id,
1649
+ dict(receipt.get("resourceLockTokens") or {}),
1650
+ )
1651
+ try:
1652
+ _resource_lock_secrets_path(state_root, session_id).unlink(missing_ok=True)
1653
+ except OSError:
1654
+ locks_released = False
1655
+ if not locks_released:
1656
+ receipt.update(
1657
+ {
1658
+ "status": "INCOMPLETE",
1659
+ "reasonCode": "RESOURCE_LOCK_RELEASE_FAILED",
1660
+ "cleanupStatus": "RESOURCE_LOCK_RELEASE_FAILED",
1661
+ }
1662
+ )
1663
+ _close_current_stage(receipt)
1664
+ _write_run_receipt(state_root, receipt)
1665
+ return 0 if receipt.get("status") == "OK" else 1
1666
+
1667
+
1668
+ def _run_session_worker_with_log(state_root: Path, session_id: str) -> int:
1669
+ worker_log_path = _run_session_root(state_root, session_id) / "worker.log"
1670
+ try:
1671
+ worker_log = worker_log_path.open(
1672
+ "a",
1673
+ encoding="utf-8",
1674
+ errors="replace",
1675
+ buffering=1,
1676
+ )
1677
+ except OSError:
1678
+ return _run_session_worker(state_root, session_id)
1679
+ with worker_log:
1680
+ with (
1681
+ contextlib.redirect_stdout(worker_log),
1682
+ contextlib.redirect_stderr(worker_log),
1683
+ ):
1684
+ return _run_session_worker(state_root, session_id)
1685
+
1686
+
1687
+ def _run_session_status_raw(state_root: Path, session_id: str) -> dict[str, Any]:
1688
+ from harness_service import is_pid_alive
1689
+
1690
+ receipt = _load_run_receipt(state_root, session_id)
1691
+ worker_pid = receipt.get("workerPid")
1692
+ worker_identity = receipt.get("workerIdentity")
1693
+ if receipt.get("status") in RUN_TERMINAL_STATUSES:
1694
+ if (
1695
+ isinstance(worker_pid, int)
1696
+ and worker_pid > 0
1697
+ and is_pid_alive(worker_pid)
1698
+ and _receipt_identity_state(receipt) is True
1699
+ ):
1700
+ finalizing = dict(receipt)
1701
+ finalizing.update(
1702
+ {
1703
+ "status": "FINALIZING",
1704
+ "reasonCode": "WORKER_FINALIZING",
1705
+ }
1706
+ )
1707
+ return finalizing
1708
+ return receipt
1709
+ heartbeat_text = str(receipt.get("lastHeartbeatAt") or "")
1710
+ try:
1711
+ heartbeat_at = dt.datetime.fromisoformat(
1712
+ heartbeat_text.replace("Z", "+00:00")
1713
+ )
1714
+ if heartbeat_at.tzinfo is None:
1715
+ heartbeat_at = heartbeat_at.astimezone()
1716
+ heartbeat_age = (
1717
+ dt.datetime.now().astimezone() - heartbeat_at
1718
+ ).total_seconds()
1719
+ except ValueError:
1720
+ heartbeat_age = float("inf")
1721
+ grace = max(float(receipt.get("heartbeatSeconds") or 30.0) * 4, 2.0)
1722
+ startup_grace = max(grace, 10.0)
1723
+ # The detached worker owns the durable identity record. The launcher can
1724
+ # return before the worker has published it, so STARTING is intentionally
1725
+ # treated as a bounded handshake window rather than an identity failure.
1726
+ if (
1727
+ receipt.get("status") == "STARTING"
1728
+ and receipt.get("workerIdentity") is None
1729
+ and heartbeat_age < startup_grace
1730
+ ):
1731
+ return receipt
1732
+ identity_state = _receipt_identity_state(receipt)
1733
+ worker_gone = (
1734
+ isinstance(worker_pid, int)
1735
+ and worker_pid > 0
1736
+ and not is_pid_alive(worker_pid)
1737
+ )
1738
+ if worker_gone or identity_state is False or heartbeat_age >= grace:
1739
+ latest = _load_run_receipt(state_root, session_id)
1740
+ if latest.get("status") in RUN_TERMINAL_STATUSES:
1741
+ return latest
1742
+ if heartbeat_age < grace and worker_gone and identity_state is not False:
1743
+ return latest
1744
+ receipt.update(
1745
+ {
1746
+ "status": "INCOMPLETE",
1747
+ "reasonCode": (
1748
+ "WORKER_IDENTITY_MISMATCH"
1749
+ if identity_state is False and not worker_gone
1750
+ else "HEARTBEAT_LOST"
1751
+ ),
1752
+ "endedAt": now_iso(),
1753
+ "cleanupStatus": "WORKER_EXITED_WITHOUT_FINAL_RECEIPT",
1754
+ }
1755
+ )
1756
+ _write_run_receipt(state_root, receipt)
1757
+ return receipt
1758
+
1759
+
1760
+ # 终态判定此前只活在脚本内部的 RUN_TERMINAL_STATUSES 里,从不出现在返回体中。
1761
+ # 调用方于是只能按状态名猜——而 "INCOMPLETE" 读起来最像"还没结束",实际是终态。
1762
+ # 2026-08-18 的一次 fixback 执行里,一个启动即失败(LAUNCHER_FAILED)的会话
1763
+ # 被连等 20s、60s,纯属这个缺口造成的浪费。
1764
+ _TERMINAL_HINTS = {
1765
+ "LAUNCHER_FAILED": (
1766
+ "被测进程未能启动(testProcessStarted=false),不是超时;"
1767
+ "先核对 argv 的可执行文件在该工作目录下能否直接运行,再重跑 run-start。"
1768
+ ),
1769
+ "HEARTBEAT_LOST": "worker 心跳丢失,会话已判定结束;日志可能不完整。",
1770
+ "WORKER_IDENTITY_MISMATCH": "worker 身份校验不通过,会话已终止且未采信其结果。",
1771
+ "WORKER_EXITED_WITHOUT_FINAL_RECEIPT": "worker 未写最终回执即退出。",
1772
+ }
1773
+
1774
+
1775
+ def _annotate_run_status(receipt: dict[str, Any]) -> dict[str, Any]:
1776
+ """给回执补上可判定的终态标记与可行动线索。
1777
+
1778
+ FINALIZING 刻意判为非终态:结果虽已确定,但 worker 尚未退出,
1779
+ 调用方此时取读数会与清理竞争。
1780
+ """
1781
+ status = receipt.get("status")
1782
+ terminal = status in RUN_TERMINAL_STATUSES and status != "FINALIZING"
1783
+ annotated = dict(receipt)
1784
+ annotated["terminal"] = bool(terminal)
1785
+ hint = _TERMINAL_HINTS.get(str(receipt.get("reasonCode") or ""))
1786
+ if terminal and hint is not None:
1787
+ annotated["terminalHint"] = hint
1788
+ return annotated
1789
+
1790
+
1791
+ def run_session_status(state_root: Path, session_id: str) -> dict[str, Any]:
1792
+ return _annotate_run_status(_run_session_status_raw(state_root, session_id))
1793
+
1794
+
1795
+ def await_run_session(
1796
+ state_root: Path,
1797
+ session_id: str,
1798
+ *,
1799
+ timeout_seconds: float = 600.0,
1800
+ poll_seconds: float = 2.0,
1801
+ ) -> dict[str, Any]:
1802
+ """阻塞到会话进入终态,或超时后带 waitTimedOut 标记返回。
1803
+
1804
+ 没有这个入口时,调用方只能 `sleep <猜一个时长>` 再查一次;上述执行日志里
1805
+ 连猜了 5s / 20s / 60s / 100s 四轮。等待逻辑属于会话语义,应由这里承担。
1806
+ """
1807
+ deadline = time.monotonic() + max(float(timeout_seconds), 0.0)
1808
+ while True:
1809
+ current = run_session_status(state_root, session_id)
1810
+ if current.get("terminal") is True:
1811
+ current["waitTimedOut"] = False
1812
+ return current
1813
+ if time.monotonic() >= deadline:
1814
+ current["waitTimedOut"] = True
1815
+ return current
1816
+ time.sleep(max(float(poll_seconds), 0.01))
1817
+
1818
+
1819
+ def read_run_session_log(
1820
+ state_root: Path,
1821
+ session_id: str,
1822
+ *,
1823
+ stream: str = "stdout",
1824
+ cursor: int = 0,
1825
+ max_bytes: int = 64 * 1024,
1826
+ ) -> dict[str, Any]:
1827
+ if stream not in {"stdout", "stderr"}:
1828
+ raise ValueError(f"RUN_SESSION_STREAM_INVALID: {stream}")
1829
+ if cursor < 0 or max_bytes <= 0:
1830
+ raise ValueError("RUN_SESSION_CURSOR_INVALID")
1831
+ receipt = _load_run_receipt(state_root, session_id)
1832
+ path = Path(str(receipt[f"{stream}Path"]))
1833
+ raw = b""
1834
+ decode_status = "OK"
1835
+ size = path.stat().st_size if path.is_file() else 0
1836
+ if path.is_file() and cursor < size:
1837
+ with path.open("rb") as handle:
1838
+ handle.seek(cursor)
1839
+ raw = handle.read(max_bytes)
1840
+ while raw:
1841
+ try:
1842
+ text = raw.decode("utf-8")
1843
+ break
1844
+ except UnicodeDecodeError as exc:
1845
+ if exc.end != len(raw):
1846
+ # An invalid sequence in the middle is durable evidence,
1847
+ # so consume it and expose replacement text instead of
1848
+ # silently dropping bytes from the cursor stream.
1849
+ text = raw.decode("utf-8", errors="replace")
1850
+ decode_status = "LOG_DECODE_DEGRADED"
1851
+ break
1852
+ if (
1853
+ receipt.get("status") in RUN_TERMINAL_STATUSES
1854
+ and cursor + len(raw) >= size
1855
+ ):
1856
+ # Once the producer is terminal, an incomplete trailing
1857
+ # sequence cannot be completed by a later write. Preserve
1858
+ # and consume it with an explicit degraded status.
1859
+ text = raw.decode("utf-8", errors="replace")
1860
+ decode_status = "LOG_DECODE_DEGRADED"
1861
+ break
1862
+ # A live writer may have split a multibyte character across
1863
+ # this read. Keep the incomplete suffix for the next cursor
1864
+ # request so callers never see duplicate replacement glyphs.
1865
+ raw = raw[: exc.start]
1866
+ else:
1867
+ text = ""
1868
+ else:
1869
+ text = ""
1870
+ next_cursor = cursor + len(raw)
1871
+ terminal = receipt.get("status") in RUN_TERMINAL_STATUSES
1872
+ return {
1873
+ "ok": True,
1874
+ "sessionId": session_id,
1875
+ "stream": stream,
1876
+ "cursor": cursor,
1877
+ "nextCursor": next_cursor,
1878
+ "text": text,
1879
+ "eof": terminal and next_cursor >= size,
1880
+ "status": receipt.get("status"),
1881
+ "rawDigest": "sha256:" + hashlib.sha256(raw).hexdigest(),
1882
+ "decodeStatus": decode_status,
1883
+ }
1884
+
1885
+
1886
+ def cancel_run_session(
1887
+ state_root: Path,
1888
+ session_id: str,
1889
+ *,
1890
+ reason: str = "CANCEL_REQUESTED",
1891
+ ) -> dict[str, Any]:
1892
+ receipt = _load_run_receipt(state_root, session_id)
1893
+ if receipt.get("status") in RUN_TERMINAL_STATUSES:
1894
+ return receipt
1895
+ if _receipt_identity_state(receipt) is not True:
1896
+ return {
1897
+ **receipt,
1898
+ "ok": False,
1899
+ "code": "WORKER_IDENTITY_UNVERIFIED",
1900
+ "unknownProcessUntouched": True,
1901
+ }
1902
+ atomic_write_json(
1903
+ _run_session_root(state_root, session_id) / "cancel-request.json",
1904
+ {"reasonCode": reason, "requestedAt": now_iso()},
1905
+ )
1906
+ return {**receipt, "ok": True, "action": "cancel-requested"}
1907
+
1908
+
1909
+ def adapter_worktree(agent: str, change_id: str) -> dict[str, str]:
1910
+ if agent not in _ADAPTERS:
1911
+ raise ValueError(f"ADAPTER_UNKNOWN: {agent}")
1912
+ if not _CHANGE_ID.fullmatch(change_id) or change_id in {".", ".."}:
1913
+ raise ValueError(f"ADAPTER_CHANGE_ID_INVALID: {change_id}")
1914
+ root, prefix = _ADAPTERS[agent]
1915
+ return {
1916
+ "agent": agent,
1917
+ "worktreeRoot": root,
1918
+ "path": f"{root}/{change_id}",
1919
+ "branchPrefix": prefix,
1920
+ "branch": f"{prefix}{change_id}",
1921
+ }
1922
+
1923
+
1924
+ def _absolute_executable(value: str | Path) -> str:
1925
+ return str(Path(value).expanduser().resolve())
1926
+
1927
+
1928
+ def _run_version(executable: str, *args: str) -> dict[str, Any]:
1929
+ try:
1930
+ proc = subprocess.run(
1931
+ [executable, *args],
1932
+ capture_output=True,
1933
+ text=True,
1934
+ encoding="utf-8",
1935
+ errors="replace",
1936
+ check=False,
1937
+ timeout=10,
1938
+ )
1939
+ except (OSError, subprocess.TimeoutExpired) as exc:
1940
+ return {"available": False, "error": str(exc)}
1941
+ version = (proc.stdout or proc.stderr).strip().splitlines()
1942
+ return {
1943
+ "available": proc.returncode == 0,
1944
+ "version": version[0] if version else "",
1945
+ "exitCode": proc.returncode,
1946
+ }
1947
+
1948
+
1949
+ def probe_powershell(
1950
+ executable: Path,
1951
+ *,
1952
+ runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
1953
+ ) -> dict[str, Any]:
1954
+ """Probe Windows PowerShell/Pwsh without relying on PowerShell 6 Test-Json."""
1955
+ script = (
1956
+ "$value = [ordered]@{"
1957
+ "edition = [string]$PSVersionTable.PSEdition;"
1958
+ "version = [string]$PSVersionTable.PSVersion.ToString()"
1959
+ "}; $value | ConvertTo-Json -Compress"
1960
+ )
1961
+ argv = [
1962
+ _absolute_executable(executable),
1963
+ "-NoLogo",
1964
+ "-NoProfile",
1965
+ "-NonInteractive",
1966
+ "-Command",
1967
+ script,
1968
+ ]
1969
+ try:
1970
+ proc = runner(
1971
+ argv,
1972
+ capture_output=True,
1973
+ text=True,
1974
+ encoding="utf-8",
1975
+ errors="replace",
1976
+ check=False,
1977
+ timeout=10,
1978
+ )
1979
+ if proc.returncode != 0:
1980
+ return {
1981
+ "available": False,
1982
+ "executable": argv[0],
1983
+ "argvPrefix": [argv[0], "-NoLogo", "-NoProfile", "-NonInteractive"],
1984
+ "error": proc.stderr.strip(),
1985
+ "jsonCapability": "convert-to-json",
1986
+ }
1987
+ raw = json.loads(proc.stdout.strip())
1988
+ return {
1989
+ "available": True,
1990
+ "executable": argv[0],
1991
+ "argvPrefix": [argv[0], "-NoLogo", "-NoProfile", "-NonInteractive"],
1992
+ "edition": str(raw.get("edition") or "Desktop"),
1993
+ "version": str(raw.get("version") or ""),
1994
+ "jsonCapability": "convert-to-json",
1995
+ }
1996
+ except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
1997
+ return {
1998
+ "available": False,
1999
+ "executable": argv[0],
2000
+ "argvPrefix": [argv[0], "-NoLogo", "-NoProfile", "-NonInteractive"],
2001
+ "error": str(exc),
2002
+ "jsonCapability": "convert-to-json",
2003
+ }
2004
+
2005
+
2006
+ def _optional_runtime(name: str, version_arg: str = "--version") -> dict[str, Any]:
2007
+ found = shutil.which(name)
2008
+ if not found:
2009
+ return {"available": False, "executable": None, "argvPrefix": []}
2010
+ executable = _absolute_executable(found)
2011
+ return {
2012
+ "executable": executable,
2013
+ "argvPrefix": [executable],
2014
+ **_run_version(executable, version_arg),
2015
+ }
2016
+
2017
+
2018
+ def doctor(project: Path, change_dir: Path, *, agent: str) -> dict[str, Any]:
2019
+ project = project.expanduser().resolve()
2020
+ change_dir = change_dir.expanduser().resolve()
2021
+ python_executable = _absolute_executable(sys.executable)
2022
+ python_version = _run_version(python_executable, "--version")
2023
+
2024
+ powershell_path = shutil.which("pwsh") or shutil.which("powershell")
2025
+ powershell = (
2026
+ probe_powershell(Path(powershell_path))
2027
+ if powershell_path
2028
+ else {
2029
+ "available": False,
2030
+ "executable": None,
2031
+ "argvPrefix": [],
2032
+ "edition": None,
2033
+ "version": None,
2034
+ "jsonCapability": "python-json",
2035
+ }
2036
+ )
2037
+ sample = {"path": "E:/示例/计划", "ok": True}
2038
+ json_round_trip = json.loads(json.dumps(sample, ensure_ascii=False)) == sample
2039
+ payload = {
2040
+ "schemaVersion": SCHEMA_VERSION,
2041
+ "generatedAt": now_iso(),
2042
+ "projectRoot": str(project),
2043
+ "changeDir": str(change_dir),
2044
+ "adapter": adapter_worktree(agent, change_dir.name),
2045
+ "runtimes": {
2046
+ "python": {
2047
+ "available": python_version.get("available", True),
2048
+ "executable": python_executable,
2049
+ "argvPrefix": [python_executable],
2050
+ "version": python_version.get("version") or sys.version.split()[0],
2051
+ "stdioEncoding": "utf-8",
2052
+ "filesystemEncoding": sys.getfilesystemencoding(),
2053
+ },
2054
+ "node": _optional_runtime("node"),
2055
+ "powershell": powershell,
2056
+ },
2057
+ "capabilities": {
2058
+ "jsonRoundTrip": json_round_trip,
2059
+ "argvArrays": True,
2060
+ "utf8NoBom": True,
2061
+ },
2062
+ }
2063
+ atomic_write_json(change_dir / "meta" / "runtime.json", payload)
2064
+ return {"ok": True, "action": "doctor", **payload}
2065
+
2066
+
2067
+ def cmd_quarantine_evidence(args: argparse.Namespace) -> dict[str, Any]:
2068
+ """隔离命令:把明文敏感证据原子移出可发布树,逐条返回结果。
2069
+
2070
+ 单条失败不吞掉其余条目——归档阻断项常常一次报多个文件,逐条报告才能一轮修完。
2071
+ """
2072
+ project = Path(args.project).expanduser().resolve()
2073
+ change_root = Path(args.change_dir).expanduser().resolve()
2074
+ private_root = Path(args.private_root) if args.private_root else None
2075
+ entries: list[dict[str, Any]] = []
2076
+ for raw in args.file:
2077
+ candidate = Path(raw)
2078
+ source = candidate if candidate.is_absolute() else change_root / candidate
2079
+ outcome = quarantine_sensitive_evidence(
2080
+ source,
2081
+ change_root=change_root,
2082
+ reason=args.reason,
2083
+ private_root=private_root,
2084
+ project_root=project,
2085
+ )
2086
+ entries.append({"file": raw, **outcome})
2087
+ failed = [item for item in entries if not item.get("ok")]
2088
+ return {
2089
+ "ok": not failed,
2090
+ "action": "quarantine-evidence",
2091
+ "code": "SENSITIVE_EVIDENCE_QUARANTINED" if not failed else "SENSITIVE_EVIDENCE_QUARANTINE_FAILED",
2092
+ "quarantined": len(entries) - len(failed),
2093
+ "failed": len(failed),
2094
+ "entries": entries,
2095
+ }
2096
+
2097
+
2098
+ def build_parser() -> argparse.ArgumentParser:
2099
+ parser = argparse.ArgumentParser(prog="harness_runtime.py")
2100
+ sub = parser.add_subparsers(dest="command", required=True)
2101
+ p_doctor = sub.add_parser("doctor")
2102
+ p_doctor.add_argument("--project", required=True)
2103
+ p_doctor.add_argument("--change-dir", required=True)
2104
+ p_doctor.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
2105
+ p_doctor.add_argument("--json", action="store_true")
2106
+ # 归档的 SENSITIVE_EVIDENCE_UNQUARANTINED 是硬阻断,此前却没有任何命令行入口
2107
+ # ——调用方只能写 python -c + sys.path hack 去调内部函数。
2108
+ p_quarantine = sub.add_parser(
2109
+ "quarantine-evidence",
2110
+ help="move plaintext sensitive evidence outside the publishable tree",
2111
+ )
2112
+ p_quarantine.add_argument("--project", required=True)
2113
+ p_quarantine.add_argument("--change-dir", required=True)
2114
+ p_quarantine.add_argument(
2115
+ "--file", required=True, action="append",
2116
+ help="path to quarantine, relative to --change-dir or absolute; repeatable",
2117
+ )
2118
+ p_quarantine.add_argument("--reason", default="sensitive evidence")
2119
+ p_quarantine.add_argument(
2120
+ "--private-root", default=None,
2121
+ help="override the private root; must be outside the project root and on its drive",
2122
+ )
2123
+ p_quarantine.add_argument("--json", action="store_true")
2124
+ p_sweep = sub.add_parser(
2125
+ "sweep-scratch",
2126
+ help="删除 runtime/ 顶层的过程草稿(diff、临时输入、命令输出)",
2127
+ )
2128
+ p_sweep.add_argument("--change-dir", required=True)
2129
+ p_sweep.add_argument(
2130
+ "--dry-run", action="store_true", help="只列出会删什么,不真删"
2131
+ )
2132
+ p_sweep.add_argument("--json", action="store_true")
2133
+ p_adapter = sub.add_parser("adapter")
2134
+ p_adapter.add_argument("--agent", choices=sorted(_ADAPTERS), required=True)
2135
+ p_adapter.add_argument("--change", required=True)
2136
+ p_adapter.add_argument("--json", action="store_true")
2137
+ p_start = sub.add_parser("run-start", help="start a managed background run")
2138
+ p_start.add_argument("--state-root", required=True)
2139
+ p_start.add_argument("--verification", required=True)
2140
+ p_start.add_argument("--working-directory", required=True)
2141
+ p_start.add_argument("--timeout-seconds", type=float)
2142
+ p_start.add_argument("--heartbeat-seconds", type=float, default=30.0)
2143
+ p_start.add_argument("--expected-duration-seconds", type=float)
2144
+ p_start.add_argument("--product-identity")
2145
+ p_start.add_argument("--resource-lock", action="append", default=[])
2146
+ p_start.add_argument("--json", action="store_true")
2147
+ p_start.add_argument("argv", nargs=argparse.REMAINDER)
2148
+ p_status = sub.add_parser("run-status", help="read a managed run receipt")
2149
+ p_status.add_argument("--state-root", required=True)
2150
+ p_status.add_argument("--session-id", required=True)
2151
+ p_status.add_argument(
2152
+ "--wait",
2153
+ action="store_true",
2154
+ help="阻塞到会话进入终态再返回,替代调用方自己 sleep 猜时长",
2155
+ )
2156
+ p_status.add_argument("--wait-timeout-seconds", type=float, default=600.0)
2157
+ p_status.add_argument("--poll-seconds", type=float, default=2.0)
2158
+ p_status.add_argument("--json", action="store_true")
2159
+ p_log = sub.add_parser("run-log", help="read an incremental log page")
2160
+ p_log.add_argument("--state-root", required=True)
2161
+ p_log.add_argument("--session-id", required=True)
2162
+ p_log.add_argument("--stream", choices=("stdout", "stderr"), default="stdout")
2163
+ p_log.add_argument("--cursor", type=int, default=0)
2164
+ p_log.add_argument("--max-bytes", type=int, default=64 * 1024)
2165
+ p_log.add_argument("--json", action="store_true")
2166
+ p_cancel = sub.add_parser("run-cancel", help="request identity-safe cancellation")
2167
+ p_cancel.add_argument("--state-root", required=True)
2168
+ p_cancel.add_argument("--session-id", required=True)
2169
+ p_cancel.add_argument("--json", action="store_true")
2170
+ p_worker = sub.add_parser("_worker", help=argparse.SUPPRESS)
2171
+ p_worker.add_argument("--state-root", required=True)
2172
+ p_worker.add_argument("--session-id", required=True)
2173
+ return parser
2174
+
2175
+
2176
+ def main(argv: list[str] | None = None) -> int:
2177
+ args = build_parser().parse_args(argv)
2178
+ try:
2179
+ if args.command == "doctor":
2180
+ result = doctor(Path(args.project), Path(args.change_dir), agent=args.agent)
2181
+ elif args.command == "quarantine-evidence":
2182
+ result = cmd_quarantine_evidence(args)
2183
+ elif args.command == "sweep-scratch":
2184
+ result = sweep_scratch(
2185
+ Path(args.change_dir), dry_run=bool(args.dry_run)
2186
+ )
2187
+ elif args.command == "adapter":
2188
+ result = {"ok": True, "action": "adapter", **adapter_worktree(args.agent, args.change)}
2189
+ elif args.command == "_worker":
2190
+ return _run_session_worker_with_log(
2191
+ Path(args.state_root),
2192
+ args.session_id,
2193
+ )
2194
+ elif args.command == "run-start":
2195
+ command_argv = list(args.argv)
2196
+ if command_argv and command_argv[0] == "--":
2197
+ command_argv = command_argv[1:]
2198
+ result = start_run_session(
2199
+ state_root=Path(args.state_root),
2200
+ verification=args.verification,
2201
+ argv=command_argv,
2202
+ working_directory=Path(args.working_directory),
2203
+ timeout_seconds=args.timeout_seconds,
2204
+ heartbeat_seconds=args.heartbeat_seconds,
2205
+ expected_duration_seconds=args.expected_duration_seconds,
2206
+ product_identity=args.product_identity,
2207
+ resource_locks=list(args.resource_lock),
2208
+ )
2209
+ elif args.command == "run-status":
2210
+ if getattr(args, "wait", False):
2211
+ result = await_run_session(
2212
+ Path(args.state_root),
2213
+ args.session_id,
2214
+ timeout_seconds=args.wait_timeout_seconds,
2215
+ poll_seconds=args.poll_seconds,
2216
+ )
2217
+ else:
2218
+ result = run_session_status(Path(args.state_root), args.session_id)
2219
+ elif args.command == "run-log":
2220
+ result = read_run_session_log(
2221
+ Path(args.state_root),
2222
+ args.session_id,
2223
+ stream=args.stream,
2224
+ cursor=args.cursor,
2225
+ max_bytes=args.max_bytes,
2226
+ )
2227
+ else:
2228
+ result = cancel_run_session(Path(args.state_root), args.session_id)
2229
+ except (OSError, ValueError) as exc:
2230
+ code = str(exc).split(":", 1)[0]
2231
+ result = {"ok": False, "code": code, "error": str(exc)}
2232
+ stream = sys.stderr
2233
+ stream.write(json.dumps(result, ensure_ascii=False) + "\n")
2234
+ return 1
2235
+ if getattr(args, "json", False):
2236
+ sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
2237
+ else:
2238
+ sys.stdout.write(str(result.get("action")) + "\n")
2239
+ return 0
2240
+
2241
+
2242
+ if __name__ == "__main__":
2243
+ raise SystemExit(main())