@hunter-harness/workflow-harness 0.2.39 → 0.2.41

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 (230) 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-archive/checklist.md +3 -0
  4. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +22 -0
  5. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-plan/reference.md +16 -16
  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-run/reference.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -1
  16. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +10 -0
  17. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +12 -0
  18. package/harness/bundles/general/claude-code/protocols/powershell-protocol.md +21 -0
  19. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +11 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_archive.py +413 -2
  21. package/harness/bundles/general/claude-code/scripts/harness_deploy.py +10 -51
  22. package/harness/bundles/general/claude-code/scripts/harness_environment.py +556 -39
  23. package/harness/bundles/general/claude-code/scripts/harness_gate.py +145 -0
  24. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +581 -55
  25. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +85 -6
  26. package/harness/bundles/general/claude-code/scripts/harness_service.py +291 -22
  27. package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +405 -25
  28. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  29. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-archive/checklist.md +3 -0
  31. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +22 -0
  32. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-plan/reference.md +16 -16
  37. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-run/reference.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  41. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  42. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -1
  43. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +10 -0
  44. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +12 -0
  45. package/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +21 -0
  46. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +11 -0
  47. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +413 -2
  48. package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +10 -51
  49. package/harness/bundles/general/codebuddy/scripts/harness_environment.py +556 -39
  50. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +145 -0
  51. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +581 -55
  52. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +85 -6
  53. package/harness/bundles/general/codebuddy/scripts/harness_service.py +291 -22
  54. package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +405 -25
  55. package/harness/bundles/general/codex/.harness-build.json +1 -1
  56. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-archive/checklist.md +3 -0
  58. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +22 -0
  59. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  60. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-plan/reference.md +16 -16
  64. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  66. package/harness/bundles/general/codex/harness-run/reference.md +1 -1
  67. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  68. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  69. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -1
  70. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +10 -0
  71. package/harness/bundles/general/codex/protocols/ledger-protocol.md +12 -0
  72. package/harness/bundles/general/codex/protocols/powershell-protocol.md +21 -0
  73. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +11 -0
  74. package/harness/bundles/general/codex/scripts/harness_archive.py +413 -2
  75. package/harness/bundles/general/codex/scripts/harness_deploy.py +10 -51
  76. package/harness/bundles/general/codex/scripts/harness_environment.py +556 -39
  77. package/harness/bundles/general/codex/scripts/harness_gate.py +145 -0
  78. package/harness/bundles/general/codex/scripts/harness_ledger.py +581 -55
  79. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +85 -6
  80. package/harness/bundles/general/codex/scripts/harness_service.py +291 -22
  81. package/harness/bundles/general/codex/scripts/harness_test_runner.py +405 -25
  82. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  83. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  84. package/harness/bundles/general/cursor/harness-archive/checklist.md +3 -0
  85. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +22 -0
  86. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  87. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  90. package/harness/bundles/general/cursor/harness-plan/reference.md +16 -16
  91. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-run/reference.md +1 -1
  94. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  96. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -1
  97. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +10 -0
  98. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +12 -0
  99. package/harness/bundles/general/cursor/protocols/powershell-protocol.md +21 -0
  100. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +11 -0
  101. package/harness/bundles/general/cursor/scripts/harness_archive.py +413 -2
  102. package/harness/bundles/general/cursor/scripts/harness_deploy.py +10 -51
  103. package/harness/bundles/general/cursor/scripts/harness_environment.py +556 -39
  104. package/harness/bundles/general/cursor/scripts/harness_gate.py +145 -0
  105. package/harness/bundles/general/cursor/scripts/harness_ledger.py +581 -55
  106. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +85 -6
  107. package/harness/bundles/general/cursor/scripts/harness_service.py +291 -22
  108. package/harness/bundles/general/cursor/scripts/harness_test_runner.py +405 -25
  109. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  110. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-archive/checklist.md +3 -0
  113. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +22 -0
  114. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  115. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  116. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  118. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  119. package/harness/bundles/java/claude-code/harness-plan/reference.md +16 -16
  120. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  123. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
  125. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +10 -0
  126. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +12 -0
  127. package/harness/bundles/java/claude-code/protocols/powershell-protocol.md +21 -0
  128. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +11 -0
  129. package/harness/bundles/java/claude-code/scripts/harness_archive.py +413 -2
  130. package/harness/bundles/java/claude-code/scripts/harness_deploy.py +10 -51
  131. package/harness/bundles/java/claude-code/scripts/harness_environment.py +556 -39
  132. package/harness/bundles/java/claude-code/scripts/harness_gate.py +145 -0
  133. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +581 -55
  134. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +85 -6
  135. package/harness/bundles/java/claude-code/scripts/harness_service.py +291 -22
  136. package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +405 -25
  137. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  138. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  140. package/harness/bundles/java/codebuddy/harness-archive/checklist.md +3 -0
  141. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +22 -0
  142. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  143. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  144. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  145. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  146. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  147. package/harness/bundles/java/codebuddy/harness-plan/reference.md +16 -16
  148. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  149. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  150. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
  153. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +10 -0
  154. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +12 -0
  155. package/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +21 -0
  156. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +11 -0
  157. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +413 -2
  158. package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +10 -51
  159. package/harness/bundles/java/codebuddy/scripts/harness_environment.py +556 -39
  160. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +145 -0
  161. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +581 -55
  162. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +85 -6
  163. package/harness/bundles/java/codebuddy/scripts/harness_service.py +291 -22
  164. package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +405 -25
  165. package/harness/bundles/java/codex/.harness-build.json +1 -1
  166. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  167. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  168. package/harness/bundles/java/codex/harness-archive/checklist.md +3 -0
  169. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +22 -0
  170. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  171. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  172. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  173. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  174. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  175. package/harness/bundles/java/codex/harness-plan/reference.md +16 -16
  176. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  179. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  180. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
  181. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +10 -0
  182. package/harness/bundles/java/codex/protocols/ledger-protocol.md +12 -0
  183. package/harness/bundles/java/codex/protocols/powershell-protocol.md +21 -0
  184. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +11 -0
  185. package/harness/bundles/java/codex/scripts/harness_archive.py +413 -2
  186. package/harness/bundles/java/codex/scripts/harness_deploy.py +10 -51
  187. package/harness/bundles/java/codex/scripts/harness_environment.py +556 -39
  188. package/harness/bundles/java/codex/scripts/harness_gate.py +145 -0
  189. package/harness/bundles/java/codex/scripts/harness_ledger.py +581 -55
  190. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +85 -6
  191. package/harness/bundles/java/codex/scripts/harness_service.py +291 -22
  192. package/harness/bundles/java/codex/scripts/harness_test_runner.py +405 -25
  193. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  194. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  195. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  196. package/harness/bundles/java/cursor/harness-archive/checklist.md +3 -0
  197. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +22 -0
  198. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  199. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  200. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  201. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  202. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  203. package/harness/bundles/java/cursor/harness-plan/reference.md +16 -16
  204. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  205. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  206. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  207. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
  209. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +10 -0
  210. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +12 -0
  211. package/harness/bundles/java/cursor/protocols/powershell-protocol.md +21 -0
  212. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +11 -0
  213. package/harness/bundles/java/cursor/scripts/harness_archive.py +413 -2
  214. package/harness/bundles/java/cursor/scripts/harness_deploy.py +10 -51
  215. package/harness/bundles/java/cursor/scripts/harness_environment.py +556 -39
  216. package/harness/bundles/java/cursor/scripts/harness_gate.py +145 -0
  217. package/harness/bundles/java/cursor/scripts/harness_ledger.py +581 -55
  218. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +85 -6
  219. package/harness/bundles/java/cursor/scripts/harness_service.py +291 -22
  220. package/harness/bundles/java/cursor/scripts/harness_test_runner.py +405 -25
  221. package/harness/manifests/general/claude-code.json +29 -29
  222. package/harness/manifests/general/codebuddy.json +29 -29
  223. package/harness/manifests/general/codex.json +29 -29
  224. package/harness/manifests/general/cursor.json +29 -29
  225. package/harness/manifests/java/claude-code.json +30 -30
  226. package/harness/manifests/java/codebuddy.json +30 -30
  227. package/harness/manifests/java/codex.json +30 -30
  228. package/harness/manifests/java/cursor.json +30 -30
  229. package/hunter-workflow-family.json +4 -4
  230. package/package.json +1 -1
@@ -81,7 +81,7 @@ def _table_cells(line: str) -> list[str]:
81
81
  return [cell.strip() for cell in line.strip().strip("|").split("|")]
82
82
 
83
83
 
84
- def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
84
+ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, Any]]:
85
85
  """C9: parse test-scenarios.md tables, extracting scenario rows.
86
86
 
87
87
  Returns a list of dicts with keys: id / priority / scenario / verification /
@@ -89,7 +89,7 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
89
89
  """
90
90
  text = Path(scenarios_path).read_text(encoding="utf-8-sig")
91
91
  lines = text.splitlines()
92
- scenarios: list[dict[str, str]] = []
92
+ scenarios: list[dict[str, Any]] = []
93
93
  i = 0
94
94
  while i < len(lines):
95
95
  line = lines[i].strip()
@@ -127,6 +127,35 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
127
127
  "tier",
128
128
  )
129
129
  expected_index = _column_index(headers, "预期", "expected")
130
+ executable_id_index = _column_index(
131
+ headers,
132
+ "executable test ID",
133
+ "executableTestId",
134
+ "可执行测试 ID",
135
+ "测试 ID",
136
+ )
137
+ test_file_index = _column_index(
138
+ headers,
139
+ "test file",
140
+ "testFile",
141
+ "测试文件",
142
+ "spec path",
143
+ )
144
+ test_title_index = _column_index(
145
+ headers,
146
+ "test title",
147
+ "testTitle",
148
+ "测试标题",
149
+ "用例标题",
150
+ )
151
+ executable_mapping_declared = any(
152
+ index is not None
153
+ for index in (
154
+ executable_id_index,
155
+ test_file_index,
156
+ test_title_index,
157
+ )
158
+ )
130
159
 
131
160
  row_index = i + 2
132
161
  while row_index < len(lines):
@@ -175,6 +204,7 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
175
204
  priority,
176
205
  "advisory",
177
206
  ),
207
+ "executableMappingDeclared": executable_mapping_declared,
178
208
  }
179
209
  if category_index is not None and category_index < len(cells):
180
210
  if cells[category_index].strip():
@@ -183,6 +213,9 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
183
213
  ("verification", verification_index),
184
214
  ("executionTier", execution_tier_index),
185
215
  ("expected", expected_index),
216
+ ("executableTestId", executable_id_index),
217
+ ("testFile", test_file_index),
218
+ ("testTitle", test_title_index),
186
219
  ):
187
220
  if (
188
221
  complete_row
@@ -199,6 +232,23 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
199
232
  return scenarios
200
233
 
201
234
 
235
+ def scenario_manifest_schema_version(scenarios: list[dict[str, Any]]) -> int:
236
+ required = [
237
+ item
238
+ for item in scenarios
239
+ if str(item.get("requiredEvidenceKind") or "") == "ledger"
240
+ ]
241
+ if required and all(
242
+ all(
243
+ str(item.get(field) or "").strip()
244
+ for field in ("executableTestId", "testFile", "testTitle")
245
+ )
246
+ for item in required
247
+ ):
248
+ return 2
249
+ return 1
250
+
251
+
202
252
  def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
203
253
  """C8: parse plan.md task table rows, extracting optional ownerPhase/implementationDoneWhen/verificationPhase columns.
204
254
 
@@ -530,6 +580,21 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
530
580
  "PLAN_SCENARIO_OWNER_PHASE_INVALID",
531
581
  f"scenario {scenario.get('id', '?')}: unsupported ownerPhase '{owner}'",
532
582
  )
583
+ if (
584
+ scenario.get("requiredEvidenceKind") == "ledger"
585
+ and scenario.get("executableMappingDeclared")
586
+ ):
587
+ missing_mapping = [
588
+ field
589
+ for field in ("executableTestId", "testFile", "testTitle")
590
+ if not str(scenario.get(field) or "").strip()
591
+ ]
592
+ if missing_mapping:
593
+ return _result_error(
594
+ "PLAN_SCENARIO_EXECUTABLE_MAPPING_MISSING",
595
+ f"scenario {scenario.get('id', '?')}: missing executable mapping fields: "
596
+ + ", ".join(missing_mapping),
597
+ )
533
598
 
534
599
  digest = hashlib.sha256()
535
600
  artifact_names: list[str] = []
@@ -827,9 +892,16 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
827
892
  )
828
893
 
829
894
  expected_manifest = {
830
- "schemaVersion": 1,
895
+ "schemaVersion": scenario_manifest_schema_version(expected_scenarios),
831
896
  "changeName": change_name,
832
- "scenarios": expected_scenarios,
897
+ "scenarios": [
898
+ {
899
+ key: value
900
+ for key, value in scenario.items()
901
+ if key != "executableMappingDeclared"
902
+ }
903
+ for scenario in expected_scenarios
904
+ ],
833
905
  }
834
906
  if manifest != expected_manifest:
835
907
  return _result_error(
@@ -1138,9 +1210,16 @@ def finalize_plan(
1138
1210
  scenarios = validation.get("scenarios") or []
1139
1211
  manifest_path = change_dir / "meta" / "scenario-manifest.json"
1140
1212
  manifest_payload = {
1141
- "schemaVersion": 1,
1213
+ "schemaVersion": scenario_manifest_schema_version(scenarios),
1142
1214
  "changeName": change_name,
1143
- "scenarios": scenarios,
1215
+ "scenarios": [
1216
+ {
1217
+ key: value
1218
+ for key, value in scenario.items()
1219
+ if key != "executableMappingDeclared"
1220
+ }
1221
+ for scenario in scenarios
1222
+ ],
1144
1223
  }
1145
1224
  _atomic_write_json(manifest_path, manifest_payload)
1146
1225
  created.append(manifest_path)
@@ -27,6 +27,7 @@ import sys
27
27
  import time
28
28
  import urllib.error
29
29
  import urllib.request
30
+ import uuid
30
31
  from pathlib import Path
31
32
  from typing import Any
32
33
  from urllib.parse import urlparse
@@ -58,6 +59,7 @@ FATAL_KEYWORDS = (
58
59
 
59
60
  # Process create-time vs session.startedAt tolerance (seconds).
60
61
  IDENTITY_TOLERANCE_SEC = 5.0
62
+ STOP_CONFIRM_TIMEOUT_SEC = 5.0
61
63
 
62
64
  # Windows process flags
63
65
  _DETACHED_PROCESS = 0x00000008
@@ -344,6 +346,35 @@ def get_process_create_time(pid: int) -> dt.datetime | None:
344
346
  return _posix_process_create_time(pid)
345
347
 
346
348
 
349
+ def get_process_executable(pid: int) -> str | None:
350
+ """Best-effort executable identity for PID-reuse protection."""
351
+ if os.name == "nt":
352
+ try:
353
+ import ctypes
354
+ from ctypes import wintypes
355
+
356
+ kernel32 = ctypes.windll.kernel32
357
+ handle = kernel32.OpenProcess(0x1000, False, pid)
358
+ if not handle:
359
+ return None
360
+ try:
361
+ capacity = wintypes.DWORD(32768)
362
+ buffer = ctypes.create_unicode_buffer(capacity.value)
363
+ if not kernel32.QueryFullProcessImageNameW(
364
+ handle, 0, buffer, ctypes.byref(capacity)
365
+ ):
366
+ return None
367
+ return str(Path(buffer.value).resolve())
368
+ finally:
369
+ kernel32.CloseHandle(handle)
370
+ except Exception:
371
+ return None
372
+ try:
373
+ return str(Path(f"/proc/{pid}/exe").resolve(strict=True))
374
+ except OSError:
375
+ return None
376
+
377
+
347
378
  def _windows_process_create_time(pid: int) -> dt.datetime | None:
348
379
  try:
349
380
  import ctypes
@@ -454,7 +485,26 @@ def verify_process_identity(session: dict[str, Any]) -> bool | None:
454
485
  started_utc = started_at.astimezone(dt.timezone.utc)
455
486
  create_utc = create_time.astimezone(dt.timezone.utc)
456
487
  delta = abs((create_utc - started_utc).total_seconds())
457
- return delta <= IDENTITY_TOLERANCE_SEC
488
+ if delta > IDENTITY_TOLERANCE_SEC:
489
+ return False
490
+ process_identity = session.get("processIdentity")
491
+ if isinstance(process_identity, dict):
492
+ expected_executable = str(
493
+ process_identity.get("executable") or ""
494
+ ).strip()
495
+ if not expected_executable:
496
+ return None
497
+ actual_executable = get_process_executable(pid)
498
+ if actual_executable is None:
499
+ return None
500
+ try:
501
+ if Path(actual_executable).resolve() != Path(
502
+ expected_executable
503
+ ).resolve():
504
+ return False
505
+ except OSError:
506
+ return None
507
+ return True
458
508
 
459
509
 
460
510
  def terminate_process_tree(pid: int) -> None:
@@ -902,23 +952,83 @@ _WIN_LAUNCHER_SOURCE = """\
902
952
  \"\"\"Harness Windows service launcher (internal).\"\"\"
903
953
  from __future__ import annotations
904
954
 
955
+ import ctypes
905
956
  import subprocess
906
957
  import sys
907
958
  from pathlib import Path
908
959
 
909
960
  _CREATE_NEW_PROCESS_GROUP = 0x00000200
910
961
  _CREATE_NO_WINDOW = 0x08000000
911
- _CREATE_BREAKAWAY_FROM_JOB = 0x01000000
962
+ _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
963
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS = 9
964
+
965
+
966
+ class _IoCounters(ctypes.Structure):
967
+ _fields_ = [
968
+ ("ReadOperationCount", ctypes.c_uint64),
969
+ ("WriteOperationCount", ctypes.c_uint64),
970
+ ("OtherOperationCount", ctypes.c_uint64),
971
+ ("ReadTransferCount", ctypes.c_uint64),
972
+ ("WriteTransferCount", ctypes.c_uint64),
973
+ ("OtherTransferCount", ctypes.c_uint64),
974
+ ]
975
+
976
+
977
+ class _BasicLimitInformation(ctypes.Structure):
978
+ _fields_ = [
979
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
980
+ ("PerJobUserTimeLimit", ctypes.c_int64),
981
+ ("LimitFlags", ctypes.c_uint32),
982
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
983
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
984
+ ("ActiveProcessLimit", ctypes.c_uint32),
985
+ ("Affinity", ctypes.c_size_t),
986
+ ("PriorityClass", ctypes.c_uint32),
987
+ ("SchedulingClass", ctypes.c_uint32),
988
+ ]
989
+
990
+
991
+ class _ExtendedLimitInformation(ctypes.Structure):
992
+ _fields_ = [
993
+ ("BasicLimitInformation", _BasicLimitInformation),
994
+ ("IoInfo", _IoCounters),
995
+ ("ProcessMemoryLimit", ctypes.c_size_t),
996
+ ("JobMemoryLimit", ctypes.c_size_t),
997
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
998
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
999
+ ]
1000
+
1001
+
1002
+ def _create_job(name: str) -> int:
1003
+ kernel32 = ctypes.windll.kernel32
1004
+ kernel32.CreateJobObjectW.restype = ctypes.c_void_p
1005
+ handle = kernel32.CreateJobObjectW(None, name)
1006
+ if not handle:
1007
+ raise OSError("CreateJobObjectW failed")
1008
+ info = _ExtendedLimitInformation()
1009
+ info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
1010
+ configured = kernel32.SetInformationJobObject(
1011
+ ctypes.c_void_p(handle),
1012
+ _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_CLASS,
1013
+ ctypes.byref(info),
1014
+ ctypes.sizeof(info),
1015
+ )
1016
+ if not configured:
1017
+ kernel32.CloseHandle(ctypes.c_void_p(handle))
1018
+ raise OSError("SetInformationJobObject failed")
1019
+ return int(handle)
912
1020
 
913
1021
 
914
1022
  def main() -> int:
915
1023
  log_path = Path(sys.argv[1])
916
1024
  command_path = Path(sys.argv[2])
917
1025
  pid_path = Path(sys.argv[3])
1026
+ job_name = sys.argv[4]
918
1027
  command = command_path.read_text(encoding="utf-8").strip()
919
1028
  if not command:
920
1029
  raise SystemExit("empty service command")
921
1030
 
1031
+ job_handle = _create_job(job_name)
922
1032
  log_path.parent.mkdir(parents=True, exist_ok=True)
923
1033
  log_handle = log_path.open("w", encoding="utf-8", errors="replace")
924
1034
  try:
@@ -928,21 +1038,26 @@ def main() -> int:
928
1038
  stdout=log_handle,
929
1039
  stderr=subprocess.STDOUT,
930
1040
  stdin=subprocess.DEVNULL,
931
- creationflags=(
932
- _CREATE_NEW_PROCESS_GROUP
933
- | _CREATE_NO_WINDOW
934
- | _CREATE_BREAKAWAY_FROM_JOB
935
- ),
1041
+ creationflags=(_CREATE_NEW_PROCESS_GROUP | _CREATE_NO_WINDOW),
936
1042
  close_fds=False,
937
1043
  )
938
1044
  finally:
939
1045
  log_handle.close()
940
1046
 
1047
+ kernel32 = ctypes.windll.kernel32
1048
+ assigned = kernel32.AssignProcessToJobObject(
1049
+ ctypes.c_void_p(job_handle),
1050
+ ctypes.c_void_p(int(proc._handle)),
1051
+ )
1052
+ if not assigned:
1053
+ proc.terminate()
1054
+ kernel32.CloseHandle(ctypes.c_void_p(job_handle))
1055
+ raise OSError("AssignProcessToJobObject failed")
941
1056
  pid_path.write_text(str(proc.pid), encoding="utf-8")
942
- # Exit immediately: the detached service (shell) continues on its own.
943
- # Staying alive to wait() would keep the launcher process running and
944
- # trigger ResourceWarning when the harness_service Popen is GC'd.
945
- return 0
1057
+ try:
1058
+ return int(proc.wait())
1059
+ finally:
1060
+ kernel32.CloseHandle(ctypes.c_void_p(job_handle))
946
1061
 
947
1062
 
948
1063
  if __name__ == "__main__":
@@ -960,6 +1075,14 @@ def _launcher_pid_path(change_dir: Path) -> Path:
960
1075
  return _runtime_dir(change_dir) / "_harness_service.launcher.pid"
961
1076
 
962
1077
 
1078
+ def _job_id_path(change_dir: Path) -> Path:
1079
+ return _runtime_dir(change_dir) / "_harness_service.job.id"
1080
+
1081
+
1082
+ def _child_pid_path(change_dir: Path) -> Path:
1083
+ return _runtime_dir(change_dir) / "_harness_service.child.pid"
1084
+
1085
+
963
1086
  def _cleanup_windows_launcher(change_dir: Path) -> None:
964
1087
  if os.name != "nt":
965
1088
  return
@@ -994,6 +1117,22 @@ def _wait_for_child_pid(pid_path: Path, *, timeout_sec: float = 15.0) -> int:
994
1117
  raise TimeoutError(f"service child pid not recorded within {timeout_sec:.0f}s ({pid_path})")
995
1118
 
996
1119
 
1120
+ def _read_positive_int(path: Path) -> int | None:
1121
+ try:
1122
+ text = path.read_text(encoding="utf-8").strip()
1123
+ except OSError:
1124
+ return None
1125
+ return int(text) if text.isdigit() and int(text) > 0 else None
1126
+
1127
+
1128
+ def _read_optional_text(path: Path) -> str | None:
1129
+ try:
1130
+ text = path.read_text(encoding="utf-8").strip()
1131
+ except OSError:
1132
+ return None
1133
+ return text or None
1134
+
1135
+
997
1136
  def _start_detached_service_windows(
998
1137
  command: str,
999
1138
  *,
@@ -1004,8 +1143,10 @@ def _start_detached_service_windows(
1004
1143
  runtime = _runtime_dir(change_dir)
1005
1144
  launcher = _write_windows_launcher(change_dir)
1006
1145
  command_path = runtime / "_harness_service.command.txt"
1007
- pid_path = runtime / "_harness_service.child.pid"
1146
+ pid_path = _child_pid_path(change_dir)
1147
+ job_id = f"HunterHarnessService-{uuid.uuid4()}"
1008
1148
  command_path.write_text(command, encoding="utf-8", newline="\n")
1149
+ _job_id_path(change_dir).write_text(job_id, encoding="utf-8", newline="\n")
1009
1150
  if pid_path.exists():
1010
1151
  pid_path.unlink()
1011
1152
 
@@ -1015,6 +1156,7 @@ def _start_detached_service_windows(
1015
1156
  str(log_file),
1016
1157
  str(command_path),
1017
1158
  str(pid_path),
1159
+ job_id,
1018
1160
  ]
1019
1161
  win_flags = (
1020
1162
  _DETACHED_PROCESS
@@ -1032,13 +1174,15 @@ def _start_detached_service_windows(
1032
1174
  close_fds=False,
1033
1175
  )
1034
1176
  _launcher_pid_path(change_dir).write_text(str(launcher_proc.pid), encoding="utf-8")
1035
- # Reap the short-lived launcher (it exits right after recording the child
1036
- # pid) so its Popen does not ResourceWarning at GC.
1037
- try:
1038
- launcher_proc.wait(timeout=5.0)
1039
- except subprocess.TimeoutExpired:
1040
- pass
1041
- return _wait_for_child_pid(pid_path)
1177
+ _wait_for_child_pid(pid_path)
1178
+ root_pid = int(launcher_proc.pid)
1179
+ # The launcher intentionally remains alive as the Job Object owner. Close
1180
+ # only this controller's process handle so Popen does not warn at teardown.
1181
+ launcher_handle = getattr(launcher_proc, "_handle", None)
1182
+ if launcher_handle is not None:
1183
+ launcher_handle.Close()
1184
+ launcher_proc.returncode = 0
1185
+ return root_pid
1042
1186
 
1043
1187
 
1044
1188
  def start_detached_service(
@@ -1095,9 +1239,28 @@ def build_session(
1095
1239
  execution_root: Path | None = None,
1096
1240
  change_id: str | None = None,
1097
1241
  attempt_id: str | None = None,
1242
+ service_pid: int | None = None,
1243
+ job_id: str | None = None,
1098
1244
  ) -> dict[str, Any]:
1099
1245
  profile_name = service_start.get("profile") or "local-dev"
1100
1246
  overlay = service_start.get("overlayPath") or ""
1247
+ effective_started_at = started_at or now_iso()
1248
+ executable = get_process_executable(pid) or (
1249
+ sys.executable if os.name == "nt" else os.environ.get("SHELL", "/bin/sh")
1250
+ )
1251
+ owned_ports: set[int] = set()
1252
+ port = extract_port(service_start)
1253
+ if port is not None:
1254
+ owned_ports.add(port)
1255
+ configured_ports = service_start.get("ownedPorts")
1256
+ if isinstance(configured_ports, list):
1257
+ for item in configured_ports:
1258
+ try:
1259
+ candidate = int(item)
1260
+ except (TypeError, ValueError):
1261
+ continue
1262
+ if 0 < candidate < 65536:
1263
+ owned_ports.add(candidate)
1101
1264
  session = {
1102
1265
  "pid": pid,
1103
1266
  "startedBy": "AI",
@@ -1106,9 +1269,25 @@ def build_session(
1106
1269
  "profile": profile_name,
1107
1270
  "startCommandHash": sha256_text(command),
1108
1271
  "overlayPath": overlay,
1109
- "startedAt": started_at or now_iso(),
1272
+ "startedAt": effective_started_at,
1110
1273
  "command": command,
1274
+ "ownedPorts": sorted(owned_ports),
1275
+ "processIdentity": {
1276
+ "executable": str(executable),
1277
+ "commandHash": sha256_text(command),
1278
+ "startedAt": effective_started_at,
1279
+ "parentChain": [
1280
+ {
1281
+ "pid": os.getpid(),
1282
+ "executable": str(Path(sys.executable).resolve()),
1283
+ }
1284
+ ],
1285
+ },
1111
1286
  }
1287
+ if isinstance(service_pid, int) and service_pid > 0:
1288
+ session["servicePid"] = service_pid
1289
+ if job_id:
1290
+ session["jobId"] = str(job_id)
1112
1291
  if isinstance(service_start.get("leasedPort"), int):
1113
1292
  session["leasedPort"] = service_start["leasedPort"]
1114
1293
  session["leaseOwner"] = service_start.get("leaseOwner")
@@ -1155,6 +1334,57 @@ def needs_user_decision(
1155
1334
  return 0
1156
1335
 
1157
1336
 
1337
+ def _confirm_service_shutdown(session: dict[str, Any]) -> dict[str, Any]:
1338
+ tracked_pids = sorted(
1339
+ {
1340
+ value
1341
+ for value in (session.get("pid"), session.get("servicePid"))
1342
+ if isinstance(value, int) and value > 0
1343
+ }
1344
+ )
1345
+ ports = sorted(
1346
+ {
1347
+ int(value)
1348
+ for value in (session.get("ownedPorts") or [])
1349
+ if isinstance(value, int) and 0 < value < 65536
1350
+ }
1351
+ )
1352
+ deadline = time.monotonic() + STOP_CONFIRM_TIMEOUT_SEC
1353
+ while True:
1354
+ alive_pids = [pid for pid in tracked_pids if is_pid_alive(pid)]
1355
+ occupied_ports = [port for port in ports if is_port_in_use(port)]
1356
+ if not alive_pids and not occupied_ports:
1357
+ return {
1358
+ "ok": True,
1359
+ "alivePids": [],
1360
+ "occupiedPorts": [],
1361
+ }
1362
+ if time.monotonic() >= deadline:
1363
+ return {
1364
+ "ok": False,
1365
+ "alivePids": alive_pids,
1366
+ "occupiedPorts": occupied_ports,
1367
+ }
1368
+ time.sleep(0.05)
1369
+
1370
+
1371
+ def _remove_windows_service_metadata(change_dir: Path) -> None:
1372
+ if os.name != "nt":
1373
+ return
1374
+ runtime = change_dir / "runtime"
1375
+ for name in (
1376
+ "_harness_service.launcher.pid",
1377
+ "_harness_service.child.pid",
1378
+ "_harness_service.job.id",
1379
+ "_harness_service.command.txt",
1380
+ "_harness_service_launcher.py",
1381
+ ):
1382
+ try:
1383
+ (runtime / name).unlink(missing_ok=True)
1384
+ except OSError:
1385
+ pass
1386
+
1387
+
1158
1388
  def stop_ai_session(
1159
1389
  change_dir: Path,
1160
1390
  session: dict[str, Any],
@@ -1179,14 +1409,40 @@ def stop_ai_session(
1179
1409
  }
1180
1410
  if isinstance(pid, int) and is_pid_alive(pid):
1181
1411
  terminate_process_tree(pid)
1182
- _cleanup_windows_launcher(change_dir)
1412
+ confirmation = _confirm_service_shutdown(session)
1413
+ if confirmation["alivePids"]:
1414
+ return {
1415
+ "ok": False,
1416
+ "code": "PROCESS_TREE_EXIT_UNCONFIRMED",
1417
+ "action": "cleanup-unconfirmed",
1418
+ "pid": pid,
1419
+ "alivePids": confirmation["alivePids"],
1420
+ "occupiedPorts": confirmation["occupiedPorts"],
1421
+ "killed": True,
1422
+ "sessionCleared": False,
1423
+ }
1424
+ if confirmation["occupiedPorts"]:
1425
+ return {
1426
+ "ok": False,
1427
+ "code": "OWNED_PORT_RELEASE_UNCONFIRMED",
1428
+ "action": "cleanup-unconfirmed",
1429
+ "pid": pid,
1430
+ "alivePids": [],
1431
+ "occupiedPorts": confirmation["occupiedPorts"],
1432
+ "killed": True,
1433
+ "sessionCleared": False,
1434
+ }
1435
+ _remove_windows_service_metadata(change_dir)
1183
1436
  clear_session(change_dir)
1184
1437
  return {
1185
1438
  "ok": True,
1439
+ "code": "SERVICE_STOP_CONFIRMED",
1186
1440
  "action": "stopped",
1187
1441
  "pid": pid,
1188
1442
  "killed": True,
1189
1443
  "sessionCleared": True,
1444
+ "alivePids": [],
1445
+ "occupiedPorts": [],
1190
1446
  }
1191
1447
 
1192
1448
 
@@ -1313,6 +1569,9 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1313
1569
  if stop_result.get("action") == "needs-user-decision":
1314
1570
  emit_json(stop_result, as_json=as_json)
1315
1571
  return 0
1572
+ if not stop_result.get("ok"):
1573
+ emit_json(stop_result, as_json=as_json)
1574
+ return 1
1316
1575
 
1317
1576
  return _start_and_record(
1318
1577
  change_dir=change_dir,
@@ -1428,6 +1687,16 @@ def _start_and_record(
1428
1687
  execution_root=execution_root,
1429
1688
  change_id=change_id,
1430
1689
  attempt_id=attempt_id,
1690
+ service_pid=(
1691
+ _read_positive_int(_child_pid_path(change_dir))
1692
+ if os.name == "nt"
1693
+ else pid
1694
+ ),
1695
+ job_id=(
1696
+ _read_optional_text(_job_id_path(change_dir))
1697
+ if os.name == "nt"
1698
+ else None
1699
+ ),
1431
1700
  )
1432
1701
  write_session(change_dir, session)
1433
1702
 
@@ -1583,7 +1852,7 @@ def cmd_stop(args: argparse.Namespace) -> int:
1583
1852
 
1584
1853
  result = stop_ai_session(change_dir, session, require_identity=True)
1585
1854
  emit_json(result, as_json=as_json)
1586
- return 0
1855
+ return 0 if result.get("ok") else 1
1587
1856
 
1588
1857
 
1589
1858
  def build_parser() -> argparse.ArgumentParser: