@hunter-harness/workflow-harness 0.2.33 → 0.2.34

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 (243) hide show
  1. package/README.md +25 -25
  2. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  3. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +1 -0
  4. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +215 -106
  6. package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +8 -0
  7. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  10. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -3
  12. package/harness/bundles/general/claude-code/harness-review/SKILL.md +2 -2
  13. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +4 -4
  17. package/harness/bundles/general/claude-code/harness-test/checklist.md +1 -0
  18. package/harness/bundles/general/claude-code/harness-test/reference.md +2 -1
  19. package/harness/bundles/general/claude-code/protocols/ledger-protocol.md +2 -2
  20. package/harness/bundles/general/claude-code/scripts/harness_archive.py +404 -12
  21. package/harness/bundles/general/claude-code/scripts/harness_context.py +717 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_gate.py +1 -0
  23. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +77 -9
  24. package/harness/bundles/general/claude-code/scripts/harness_phase.py +1 -0
  25. package/harness/bundles/general/claude-code/scripts/harness_plan_aggregate.py +249 -0
  26. package/harness/bundles/general/claude-code/scripts/harness_profile.py +272 -15
  27. package/harness/bundles/general/claude-code/scripts/harness_report_model.py +420 -0
  28. package/harness/bundles/general/claude-code/scripts/harness_review.py +16 -1
  29. package/harness/bundles/general/claude-code/scripts/harness_verification.py +114 -0
  30. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  31. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +1 -0
  32. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +215 -106
  34. package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +8 -0
  35. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  38. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -3
  40. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  41. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  42. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  43. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  44. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +4 -4
  45. package/harness/bundles/general/codebuddy/harness-test/checklist.md +1 -0
  46. package/harness/bundles/general/codebuddy/harness-test/reference.md +2 -1
  47. package/harness/bundles/general/codebuddy/protocols/ledger-protocol.md +2 -2
  48. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +404 -12
  49. package/harness/bundles/general/codebuddy/scripts/harness_context.py +717 -0
  50. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1 -0
  51. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +77 -9
  52. package/harness/bundles/general/codebuddy/scripts/harness_phase.py +1 -0
  53. package/harness/bundles/general/codebuddy/scripts/harness_plan_aggregate.py +249 -0
  54. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +272 -15
  55. package/harness/bundles/general/codebuddy/scripts/harness_report_model.py +420 -0
  56. package/harness/bundles/general/codebuddy/scripts/harness_review.py +16 -1
  57. package/harness/bundles/general/codebuddy/scripts/harness_verification.py +114 -0
  58. package/harness/bundles/general/codex/.harness-build.json +1 -1
  59. package/harness/bundles/general/codex/contracts/workflow-policy.json +1 -0
  60. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  61. package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +215 -106
  62. package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +8 -0
  63. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  65. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  66. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  67. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -3
  68. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  69. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  70. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  71. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  72. package/harness/bundles/general/codex/harness-test/SKILL.md +4 -4
  73. package/harness/bundles/general/codex/harness-test/checklist.md +1 -0
  74. package/harness/bundles/general/codex/harness-test/reference.md +2 -1
  75. package/harness/bundles/general/codex/protocols/ledger-protocol.md +2 -2
  76. package/harness/bundles/general/codex/scripts/harness_archive.py +404 -12
  77. package/harness/bundles/general/codex/scripts/harness_context.py +717 -0
  78. package/harness/bundles/general/codex/scripts/harness_gate.py +1 -0
  79. package/harness/bundles/general/codex/scripts/harness_ledger.py +77 -9
  80. package/harness/bundles/general/codex/scripts/harness_phase.py +1 -0
  81. package/harness/bundles/general/codex/scripts/harness_plan_aggregate.py +249 -0
  82. package/harness/bundles/general/codex/scripts/harness_profile.py +272 -15
  83. package/harness/bundles/general/codex/scripts/harness_report_model.py +420 -0
  84. package/harness/bundles/general/codex/scripts/harness_review.py +16 -1
  85. package/harness/bundles/general/codex/scripts/harness_verification.py +114 -0
  86. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  87. package/harness/bundles/general/cursor/contracts/workflow-policy.json +1 -0
  88. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  89. package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +215 -106
  90. package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +8 -0
  91. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  92. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  93. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  94. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -3
  96. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  97. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  98. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  99. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  100. package/harness/bundles/general/cursor/harness-test/SKILL.md +4 -4
  101. package/harness/bundles/general/cursor/harness-test/checklist.md +1 -0
  102. package/harness/bundles/general/cursor/harness-test/reference.md +2 -1
  103. package/harness/bundles/general/cursor/protocols/ledger-protocol.md +2 -2
  104. package/harness/bundles/general/cursor/scripts/harness_archive.py +404 -12
  105. package/harness/bundles/general/cursor/scripts/harness_context.py +717 -0
  106. package/harness/bundles/general/cursor/scripts/harness_gate.py +1 -0
  107. package/harness/bundles/general/cursor/scripts/harness_ledger.py +77 -9
  108. package/harness/bundles/general/cursor/scripts/harness_phase.py +1 -0
  109. package/harness/bundles/general/cursor/scripts/harness_plan_aggregate.py +249 -0
  110. package/harness/bundles/general/cursor/scripts/harness_profile.py +272 -15
  111. package/harness/bundles/general/cursor/scripts/harness_report_model.py +420 -0
  112. package/harness/bundles/general/cursor/scripts/harness_review.py +16 -1
  113. package/harness/bundles/general/cursor/scripts/harness_verification.py +114 -0
  114. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  115. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +1 -0
  116. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  117. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  118. package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +215 -106
  119. package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +8 -0
  120. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  121. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  122. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  123. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  124. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  125. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -3
  126. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  127. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  128. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  129. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  130. package/harness/bundles/java/claude-code/harness-test/SKILL.md +4 -4
  131. package/harness/bundles/java/claude-code/harness-test/checklist.md +1 -0
  132. package/harness/bundles/java/claude-code/harness-test/reference.md +2 -0
  133. package/harness/bundles/java/claude-code/protocols/ledger-protocol.md +2 -2
  134. package/harness/bundles/java/claude-code/scripts/harness_archive.py +404 -12
  135. package/harness/bundles/java/claude-code/scripts/harness_context.py +717 -0
  136. package/harness/bundles/java/claude-code/scripts/harness_gate.py +1 -0
  137. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +77 -9
  138. package/harness/bundles/java/claude-code/scripts/harness_phase.py +1 -0
  139. package/harness/bundles/java/claude-code/scripts/harness_plan_aggregate.py +249 -0
  140. package/harness/bundles/java/claude-code/scripts/harness_profile.py +272 -15
  141. package/harness/bundles/java/claude-code/scripts/harness_report_model.py +420 -0
  142. package/harness/bundles/java/claude-code/scripts/harness_review.py +16 -1
  143. package/harness/bundles/java/claude-code/scripts/harness_verification.py +114 -0
  144. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  145. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +1 -0
  146. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  147. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  148. package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +215 -106
  149. package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +8 -0
  150. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  151. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  152. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  153. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  154. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  155. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -3
  156. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  157. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  158. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  159. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  160. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +4 -4
  161. package/harness/bundles/java/codebuddy/harness-test/checklist.md +1 -0
  162. package/harness/bundles/java/codebuddy/harness-test/reference.md +2 -0
  163. package/harness/bundles/java/codebuddy/protocols/ledger-protocol.md +2 -2
  164. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +404 -12
  165. package/harness/bundles/java/codebuddy/scripts/harness_context.py +717 -0
  166. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1 -0
  167. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +77 -9
  168. package/harness/bundles/java/codebuddy/scripts/harness_phase.py +1 -0
  169. package/harness/bundles/java/codebuddy/scripts/harness_plan_aggregate.py +249 -0
  170. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +272 -15
  171. package/harness/bundles/java/codebuddy/scripts/harness_report_model.py +420 -0
  172. package/harness/bundles/java/codebuddy/scripts/harness_review.py +16 -1
  173. package/harness/bundles/java/codebuddy/scripts/harness_verification.py +114 -0
  174. package/harness/bundles/java/codex/.harness-build.json +1 -1
  175. package/harness/bundles/java/codex/contracts/workflow-policy.json +1 -0
  176. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  177. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  178. package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +215 -106
  179. package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +8 -0
  180. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  181. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  182. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  183. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  184. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  185. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -3
  186. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  187. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  188. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  189. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  190. package/harness/bundles/java/codex/harness-test/SKILL.md +4 -4
  191. package/harness/bundles/java/codex/harness-test/checklist.md +1 -0
  192. package/harness/bundles/java/codex/harness-test/reference.md +2 -0
  193. package/harness/bundles/java/codex/protocols/ledger-protocol.md +2 -2
  194. package/harness/bundles/java/codex/scripts/harness_archive.py +404 -12
  195. package/harness/bundles/java/codex/scripts/harness_context.py +717 -0
  196. package/harness/bundles/java/codex/scripts/harness_gate.py +1 -0
  197. package/harness/bundles/java/codex/scripts/harness_ledger.py +77 -9
  198. package/harness/bundles/java/codex/scripts/harness_phase.py +1 -0
  199. package/harness/bundles/java/codex/scripts/harness_plan_aggregate.py +249 -0
  200. package/harness/bundles/java/codex/scripts/harness_profile.py +272 -15
  201. package/harness/bundles/java/codex/scripts/harness_report_model.py +420 -0
  202. package/harness/bundles/java/codex/scripts/harness_review.py +16 -1
  203. package/harness/bundles/java/codex/scripts/harness_verification.py +114 -0
  204. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  205. package/harness/bundles/java/cursor/contracts/workflow-policy.json +1 -0
  206. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  207. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  208. package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +215 -106
  209. package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +8 -0
  210. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  211. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  212. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +389 -117
  213. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  214. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  215. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -3
  216. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  217. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  218. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  219. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  220. package/harness/bundles/java/cursor/harness-test/SKILL.md +4 -4
  221. package/harness/bundles/java/cursor/harness-test/checklist.md +1 -0
  222. package/harness/bundles/java/cursor/harness-test/reference.md +2 -0
  223. package/harness/bundles/java/cursor/protocols/ledger-protocol.md +2 -2
  224. package/harness/bundles/java/cursor/scripts/harness_archive.py +404 -12
  225. package/harness/bundles/java/cursor/scripts/harness_context.py +717 -0
  226. package/harness/bundles/java/cursor/scripts/harness_gate.py +1 -0
  227. package/harness/bundles/java/cursor/scripts/harness_ledger.py +77 -9
  228. package/harness/bundles/java/cursor/scripts/harness_phase.py +1 -0
  229. package/harness/bundles/java/cursor/scripts/harness_plan_aggregate.py +249 -0
  230. package/harness/bundles/java/cursor/scripts/harness_profile.py +272 -15
  231. package/harness/bundles/java/cursor/scripts/harness_report_model.py +420 -0
  232. package/harness/bundles/java/cursor/scripts/harness_review.py +16 -1
  233. package/harness/bundles/java/cursor/scripts/harness_verification.py +114 -0
  234. package/harness/manifests/general/claude-code.json +42 -26
  235. package/harness/manifests/general/codebuddy.json +42 -26
  236. package/harness/manifests/general/codex.json +42 -26
  237. package/harness/manifests/general/cursor.json +42 -26
  238. package/harness/manifests/java/claude-code.json +44 -28
  239. package/harness/manifests/java/codebuddy.json +44 -28
  240. package/harness/manifests/java/codex.json +44 -28
  241. package/harness/manifests/java/cursor.json +44 -28
  242. package/hunter-workflow-family.json +4 -4
  243. package/package.json +1 -1
@@ -346,6 +346,26 @@ def run_git(project: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
346
346
  return subprocess.CompletedProcess(args, 1, "", str(exc))
347
347
 
348
348
 
349
+ def run_git_with_input(
350
+ project: Path,
351
+ args: list[str],
352
+ input_text: str,
353
+ ) -> subprocess.CompletedProcess[str]:
354
+ global _GIT_SUBPROCESS_COUNT
355
+ _GIT_SUBPROCESS_COUNT += 1
356
+ try:
357
+ return subprocess.run(
358
+ ["git", "-C", str(project), *args],
359
+ input=input_text,
360
+ text=True,
361
+ stdout=subprocess.PIPE,
362
+ stderr=subprocess.PIPE,
363
+ timeout=20,
364
+ )
365
+ except (OSError, subprocess.TimeoutExpired) as exc:
366
+ return subprocess.CompletedProcess(args, 1, "", str(exc))
367
+
368
+
349
369
  def archive_name(summary_path: Path) -> str:
350
370
  parts = summary_path.parts
351
371
  try:
@@ -592,17 +612,12 @@ def discover_archive_summary_paths(project: Path) -> list[Path]:
592
612
  return selected
593
613
 
594
614
 
595
- def archive_publication_status(project: Path, archive_rel: str) -> dict[str, Any]:
596
- """Knowledge publication gate for one archive (API-006/RET-40)."""
615
+ def _publication_status_from_resolution(
616
+ project: Path,
617
+ archive_dir: Path,
618
+ resolution: dict[str, Any],
619
+ ) -> dict[str, Any]:
597
620
  result: dict[str, Any] = {"status": "missing", "allowed": False, "reasons": []}
598
- archive_root = (Path(project) / ".harness" / "archive").resolve()
599
- archive_dir = (Path(project) / archive_rel).resolve()
600
- if not _path_is_within(archive_dir, archive_root) or archive_dir.parent != archive_root:
601
- result["reasons"].append("archive path escapes archive root")
602
- return result
603
-
604
- original_path = archive_dir / "reports" / "final" / "summary-data.json"
605
- resolution = resolve_archive_summary(original_path)
606
621
  if resolution.get("version"):
607
622
  result["authoritativeVersion"] = resolution["version"]
608
623
 
@@ -615,11 +630,13 @@ def archive_publication_status(project: Path, archive_rel: str) -> dict[str, Any
615
630
  result["status"] = "degraded"
616
631
  result["reasons"].append("resolved summary escapes archive root")
617
632
  return result
618
- try:
619
- summary_hash = sha256_file(summary_path)
620
- except OSError:
621
- result["reasons"].append("summary-data.json unreadable")
622
- return result
633
+ summary_hash = str(resolution.get("summarySha256") or "")
634
+ if not summary_hash:
635
+ try:
636
+ summary_hash = sha256_file(summary_path)
637
+ except OSError:
638
+ result["reasons"].append("summary-data.json unreadable")
639
+ return result
623
640
  result["summaryData"] = rel_to_project(project, summary_path)
624
641
  result["summarySha256"] = summary_hash
625
642
  if not resolution.get("allowed"):
@@ -665,6 +682,20 @@ def archive_publication_status(project: Path, archive_rel: str) -> dict[str, Any
665
682
  return result
666
683
 
667
684
 
685
+ def archive_publication_status(project: Path, archive_rel: str) -> dict[str, Any]:
686
+ """Knowledge publication gate for one archive (API-006/RET-40)."""
687
+ result: dict[str, Any] = {"status": "missing", "allowed": False, "reasons": []}
688
+ archive_root = (Path(project) / ".harness" / "archive").resolve()
689
+ archive_dir = (Path(project) / archive_rel).resolve()
690
+ if not _path_is_within(archive_dir, archive_root) or archive_dir.parent != archive_root:
691
+ result["reasons"].append("archive path escapes archive root")
692
+ return result
693
+ resolution = resolve_archive_summary(
694
+ archive_dir / "reports" / "final" / "summary-data.json"
695
+ )
696
+ return _publication_status_from_resolution(project, archive_dir, resolution)
697
+
698
+
668
699
  def archive_publication_statuses(project: Path) -> dict[str, dict[str, Any]]:
669
700
  """Resolve publication state for every safe immediate archive directory."""
670
701
  archive_root = Path(project) / ".harness" / "archive"
@@ -733,6 +764,74 @@ def archive_summary_records(project: Path, summary_paths: list[Path]) -> list[di
733
764
  return records
734
765
 
735
766
 
767
+ def discover_archive_inputs(
768
+ project: Path,
769
+ ) -> tuple[list[Path], list[dict[str, Any]], dict[str, dict[str, Any]]]:
770
+ """Resolve, hash and publication-check every archive in a single pass."""
771
+ archive_root = Path(project) / ".harness" / "archive"
772
+ try:
773
+ resolved_archive_root = archive_root.resolve(strict=True)
774
+ children = sorted(archive_root.iterdir())
775
+ except OSError:
776
+ return [], [], {}
777
+
778
+ summary_paths: list[Path] = []
779
+ records: list[dict[str, Any]] = []
780
+ publications: dict[str, dict[str, Any]] = {}
781
+ for child in children:
782
+ try:
783
+ archive_dir = child.resolve(strict=True)
784
+ except OSError:
785
+ continue
786
+ if (
787
+ not archive_dir.is_dir()
788
+ or not _path_is_within(archive_dir, resolved_archive_root)
789
+ or archive_dir.parent != resolved_archive_root
790
+ ):
791
+ continue
792
+ archive_rel = rel_to_project(project, archive_dir)
793
+ resolution = resolve_archive_summary(
794
+ archive_dir / "reports" / "final" / "summary-data.json"
795
+ )
796
+ publication = _publication_status_from_resolution(
797
+ project, archive_dir, resolution
798
+ )
799
+ publications[archive_rel] = publication
800
+ original_state = (resolution.get("fingerprint") or {}).get("original") or {}
801
+ if original_state.get("state") != "file":
802
+ continue
803
+ try:
804
+ selected_path = Path(resolution["summaryPath"]).resolve(strict=True)
805
+ except (KeyError, OSError):
806
+ continue
807
+ if (
808
+ not _path_is_within(selected_path, archive_dir)
809
+ or not selected_path.is_file()
810
+ or not publication.get("summarySha256")
811
+ ):
812
+ continue
813
+ stat = selected_path.stat()
814
+ summary_paths.append(selected_path)
815
+ records.append(
816
+ {
817
+ "archive": archive_rel,
818
+ "summaryData": rel_to_project(project, selected_path),
819
+ "summarySha256": publication["summarySha256"],
820
+ "mtime": dt.datetime.fromtimestamp(
821
+ stat.st_mtime, dt.timezone.utc
822
+ ).isoformat(timespec="seconds"),
823
+ "_authority": {
824
+ "status": resolution["status"],
825
+ "allowed": resolution["allowed"],
826
+ "version": resolution["version"],
827
+ "reasons": list(resolution["reasons"]),
828
+ "fingerprint": resolution["fingerprint"],
829
+ },
830
+ }
831
+ )
832
+ return summary_paths, records, publications
833
+
834
+
736
835
  def make_entry(
737
836
  *,
738
837
  project: Path,
@@ -756,48 +855,9 @@ def make_entry(
756
855
  status = "candidate"
757
856
  stale_reasons: list[str] = []
758
857
 
759
- if final_commit and is_git_repo(project):
760
- if not git_commit_exists(project, final_commit):
761
- stale_reasons.append(
762
- "source commit missing from local git history: " + final_commit[:12]
763
- )
764
- elif source_files:
765
- diff_key = (
766
- str(project.resolve()),
767
- final_commit,
768
- tuple(sorted(source_files)),
769
- )
770
- changed = _GIT_DIFF_CACHE.get(diff_key)
771
- if changed is None and diff_key not in _GIT_DIFF_CACHE:
772
- diff = run_git(
773
- project,
774
- [
775
- "diff",
776
- "--name-only",
777
- f"{final_commit}..HEAD",
778
- "--",
779
- *sorted(source_files),
780
- ],
781
- )
782
- changed = (
783
- [
784
- line.strip()
785
- for line in diff.stdout.splitlines()
786
- if line.strip()
787
- ]
788
- if diff.returncode == 0
789
- else None
790
- )
791
- _GIT_DIFF_CACHE[diff_key] = changed
792
- if changed is not None:
793
- if changed:
794
- stale_reasons.append(
795
- "source files changed after source commit: " + ", ".join(changed[:8])
796
- )
797
- else:
798
- stale_reasons.append(
799
- "source commit could not be compared with current HEAD"
800
- )
858
+ # Git freshness is evaluated for the full entry batch after extraction.
859
+ # Keeping the candidate status here preserves extraction as a pure operation
860
+ # and gives the batch evaluator one opportunity to inspect each source commit.
801
861
 
802
862
  if stale_reasons:
803
863
  status = "stale"
@@ -859,6 +919,114 @@ def make_entry(
859
919
  }
860
920
 
861
921
 
922
+ def apply_batch_git_freshness(
923
+ project: Path,
924
+ entries: list[dict[str, Any]],
925
+ ) -> dict[str, int]:
926
+ """Refresh generated-entry Git freshness using bounded, grouped probes."""
927
+ stats = {
928
+ "gitCommitBatches": 0,
929
+ "gitDiffBatches": 0,
930
+ "gitEntriesChecked": 0,
931
+ }
932
+ grouped: dict[str, list[dict[str, Any]]] = {}
933
+ for entry in entries:
934
+ commit = str(entry.get("source", {}).get("sourceCommit") or "").strip()
935
+ if commit:
936
+ grouped.setdefault(commit, []).append(entry)
937
+ if not grouped or not is_git_repo(project):
938
+ return stats
939
+
940
+ commits = sorted(grouped)
941
+ batch = run_git_with_input(
942
+ project,
943
+ ["cat-file", "--batch-check=%(objectname) %(objecttype)"],
944
+ "".join(f"{commit}^{{commit}}\n" for commit in commits),
945
+ )
946
+ stats["gitCommitBatches"] = 1
947
+ commit_exists: dict[str, bool] = {}
948
+ output_lines = batch.stdout.splitlines() if batch.returncode == 0 else []
949
+ for index, commit in enumerate(commits):
950
+ line = output_lines[index] if index < len(output_lines) else ""
951
+ commit_exists[commit] = bool(line and not line.endswith(" missing") and line.endswith(" commit"))
952
+ _GIT_COMMIT_CACHE[(str(project.resolve()), commit)] = commit_exists[commit]
953
+
954
+ generated_prefixes = (
955
+ "source commit missing from local git history:",
956
+ "source files changed after source commit:",
957
+ "source commit could not be compared with current HEAD",
958
+ )
959
+ checked_at = now_iso()
960
+ for commit in commits:
961
+ commit_entries = grouped[commit]
962
+ stats["gitEntriesChecked"] += len(commit_entries)
963
+ union_files = sorted(
964
+ {
965
+ str(path).replace("\\", "/")
966
+ for entry in commit_entries
967
+ for path in entry.get("scope", {}).get("sourceFiles", [])
968
+ if str(path).strip()
969
+ }
970
+ )
971
+ changed: set[str] | None = set()
972
+ if commit_exists.get(commit) and union_files:
973
+ result = run_git(
974
+ project,
975
+ ["diff", "--name-only", f"{commit}..HEAD", "--", *union_files],
976
+ )
977
+ stats["gitDiffBatches"] += 1
978
+ changed = (
979
+ {
980
+ line.strip().replace("\\", "/")
981
+ for line in result.stdout.splitlines()
982
+ if line.strip()
983
+ }
984
+ if result.returncode == 0
985
+ else None
986
+ )
987
+
988
+ for entry in commit_entries:
989
+ lifecycle = entry.setdefault("lifecycle", {})
990
+ previous_reasons = [
991
+ str(reason)
992
+ for reason in lifecycle.get("staleReasons", [])
993
+ if str(reason)
994
+ ]
995
+ had_generated_reason = any(
996
+ reason.startswith(generated_prefixes) for reason in previous_reasons
997
+ )
998
+ reasons = [
999
+ reason
1000
+ for reason in previous_reasons
1001
+ if not reason.startswith(generated_prefixes)
1002
+ ]
1003
+ entry_files = {
1004
+ str(path).replace("\\", "/")
1005
+ for path in entry.get("scope", {}).get("sourceFiles", [])
1006
+ if str(path).strip()
1007
+ }
1008
+ if not commit_exists.get(commit):
1009
+ reasons.append(
1010
+ "source commit missing from local git history: " + commit[:12]
1011
+ )
1012
+ elif changed is None:
1013
+ reasons.append("source commit could not be compared with current HEAD")
1014
+ else:
1015
+ changed_for_entry = sorted(changed.intersection(entry_files))
1016
+ if changed_for_entry:
1017
+ reasons.append(
1018
+ "source files changed after source commit: "
1019
+ + ", ".join(changed_for_entry[:8])
1020
+ )
1021
+ lifecycle["staleReasons"] = reasons
1022
+ lifecycle["lastCheckedAt"] = checked_at
1023
+ if reasons:
1024
+ entry["status"] = "stale"
1025
+ elif entry.get("status") == "stale" and had_generated_reason:
1026
+ entry["status"] = "candidate"
1027
+ return stats
1028
+
1029
+
862
1030
  def first_sentence(text: str) -> str:
863
1031
  clean = re.sub(r"\s+", " ", text).strip()
864
1032
  if len(clean) <= 180:
@@ -2379,6 +2547,7 @@ class KnowledgeSnapshot:
2379
2547
  "config",
2380
2548
  "summary_paths",
2381
2549
  "archive_records",
2550
+ "publications",
2382
2551
  "inputs_hash",
2383
2552
  )
2384
2553
 
@@ -2390,6 +2559,7 @@ class KnowledgeSnapshot:
2390
2559
  config: dict[str, Any],
2391
2560
  summary_paths: list[Path],
2392
2561
  archive_records: list[dict[str, Any]],
2562
+ publications: dict[str, dict[str, Any]],
2393
2563
  inputs_hash: str,
2394
2564
  ) -> None:
2395
2565
  self.project = project
@@ -2398,6 +2568,7 @@ class KnowledgeSnapshot:
2398
2568
  self.config = config
2399
2569
  self.summary_paths = summary_paths
2400
2570
  self.archive_records = archive_records
2571
+ self.publications = publications
2401
2572
  self.inputs_hash = inputs_hash
2402
2573
 
2403
2574
 
@@ -2446,11 +2617,17 @@ def build_snapshot(project: Path) -> KnowledgeSnapshot:
2446
2617
  knowledge = project / ".harness" / "knowledge"
2447
2618
  pname = project_id(project)
2448
2619
  config = load_config(knowledge)
2449
- summary_paths = discover_archive_summary_paths(project)
2450
- archive_records = archive_summary_records(project, summary_paths)
2620
+ summary_paths, archive_records, publications = discover_archive_inputs(project)
2451
2621
  inputs_hash = compute_inputs_hash(archive_records, config, knowledge)
2452
2622
  return KnowledgeSnapshot(
2453
- project, knowledge, pname, config, summary_paths, archive_records, inputs_hash
2623
+ project,
2624
+ knowledge,
2625
+ pname,
2626
+ config,
2627
+ summary_paths,
2628
+ archive_records,
2629
+ publications,
2630
+ inputs_hash,
2454
2631
  )
2455
2632
 
2456
2633
 
@@ -2473,13 +2650,13 @@ def build_index(
2473
2650
  config = snapshot.config
2474
2651
  summary_paths = snapshot.summary_paths
2475
2652
  archive_records = snapshot.archive_records
2653
+ publications = snapshot.publications
2476
2654
  inputs_hash = snapshot.inputs_hash
2477
2655
  else:
2478
2656
  knowledge = project / ".harness" / "knowledge"
2479
2657
  pname = project_id(project)
2480
2658
  config = load_config(knowledge)
2481
- summary_paths = discover_archive_summary_paths(project)
2482
- archive_records = archive_summary_records(project, summary_paths)
2659
+ summary_paths, archive_records, publications = discover_archive_inputs(project)
2483
2660
  inputs_hash = compute_inputs_hash(archive_records, config, knowledge)
2484
2661
  ensure_knowledge_dirs(knowledge)
2485
2662
  reporter.emit("snapshot", len(summary_paths), len(summary_paths), force=True)
@@ -2526,7 +2703,11 @@ def build_index(
2526
2703
  current_head = git_head(project) # recorded in manifest only; not an invalidation key
2527
2704
  entries: list[dict[str, Any]] = []
2528
2705
  failures: list[dict[str, str]] = []
2529
- publications = archive_publication_statuses(project)
2706
+ archive_records_by_summary = {
2707
+ str(record.get("summaryData") or ""): record
2708
+ for record in archive_records
2709
+ if record.get("summaryData")
2710
+ }
2530
2711
  ingest_mode: dict[str, Any] = {
2531
2712
  "incremental": incremental,
2532
2713
  "mode": mode,
@@ -2545,11 +2726,22 @@ def build_index(
2545
2726
  "sqliteRebuild": 0,
2546
2727
  "sqliteUpsert": 0,
2547
2728
  "sqliteDelete": 0,
2729
+ "summaryHashesReused": 0,
2730
+ "summaryHashesComputed": 0,
2548
2731
  }
2549
2732
 
2550
2733
  for summary_index, summary_path in enumerate(summary_paths, start=1):
2551
2734
  summary_rel = rel_to_project(project, summary_path)
2552
- summary_hash = sha256_file(summary_path)
2735
+ summary_record = archive_records_by_summary.get(summary_rel)
2736
+ summary_hash = (
2737
+ str(summary_record.get("summarySha256"))
2738
+ if summary_record and summary_record.get("summarySha256")
2739
+ else sha256_file(summary_path)
2740
+ )
2741
+ if summary_record and summary_record.get("summarySha256"):
2742
+ ingest_mode["summaryHashesReused"] += 1
2743
+ else:
2744
+ ingest_mode["summaryHashesComputed"] += 1
2553
2745
  cache_path = archive_entry_cache_path(project, knowledge, summary_path, summary_hash)
2554
2746
  try:
2555
2747
  archive_entries = None
@@ -2618,11 +2810,19 @@ def build_index(
2618
2810
  seen.add(fp)
2619
2811
  deduped.append(entry)
2620
2812
 
2813
+ ingest_mode.update(apply_batch_git_freshness(project, deduped))
2621
2814
  reporter.emit("near-deduplicate", 0, len(deduped), force=True)
2622
2815
  near_dedupe = dedupe_near_duplicates(deduped, progress=reporter)
2623
2816
  ingest_mode["nearDuplicatesMerged"] = near_dedupe["merged"]
2624
2817
  ingest_mode["similarityCandidates"] = near_dedupe["similarityCandidates"]
2625
2818
  ingest_mode["exactSimilarityComparisons"] = near_dedupe["exactSimilarityComparisons"]
2819
+ ingest_mode["similarityCandidatePairs"] = near_dedupe["candidatePairs"]
2820
+ ingest_mode["similarityBucketCount"] = near_dedupe["bucketCount"]
2821
+ ingest_mode["similarityComparisonBudget"] = near_dedupe["comparisonBudget"]
2822
+ ingest_mode["similarityComparisonBudgetExceeded"] = near_dedupe[
2823
+ "comparisonBudgetExceeded"
2824
+ ]
2825
+ ingest_mode["similarityComparisonsSkipped"] = near_dedupe["comparisonsSkipped"]
2626
2826
 
2627
2827
  mark_conflicting_generated_entries(deduped)
2628
2828
  mark_degraded_test_evidence(deduped)
@@ -4299,26 +4499,68 @@ def dedupe_near_duplicates(
4299
4499
  threshold: float = NEAR_DUPLICATE_THRESHOLD,
4300
4500
  *,
4301
4501
  progress: ProgressReporter | None = None,
4502
+ max_exact_comparisons: int = 50_000,
4503
+ time_budget_ms: int = 2_000,
4302
4504
  ) -> dict[str, Any]:
4303
- """Merge near-duplicate entries within the same archive (in-place)."""
4304
- by_archive: dict[str, list[dict[str, Any]]] = {}
4305
- for entry in entries:
4306
- if entry.get("status") == "superseded":
4307
- continue
4308
- archive = str(entry.get("source", {}).get("archive") or "")
4309
- by_archive.setdefault(archive, []).append(entry)
4310
-
4505
+ """Merge near duplicates through sparse buckets and an explicit work budget."""
4506
+ started = time.monotonic()
4507
+ deadline = started + max(0, time_budget_ms) / 1_000
4311
4508
  merges: list[dict[str, Any]] = []
4312
- normalized = {
4313
- id(entry): entry_compare_text(entry)
4314
- for entry in entries
4315
- }
4509
+ active_entries = [
4510
+ entry for entry in entries if entry.get("status") != "superseded"
4511
+ ]
4512
+ normalized = {id(entry): entry_compare_text(entry) for entry in active_entries}
4316
4513
  similarity_cache: dict[tuple[int, int], float] = {}
4317
4514
  similarity_candidates = 0
4318
4515
  exact_comparisons = 0
4516
+ comparison_budget_exceeded = False
4517
+
4518
+ grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
4519
+ for entry in active_entries:
4520
+ key = (
4521
+ str(entry.get("source", {}).get("archive") or ""),
4522
+ str(entry.get("type") or ""),
4523
+ )
4524
+ grouped.setdefault(key, []).append(entry)
4525
+
4526
+ candidate_pairs: set[tuple[int, int]] = set()
4527
+ entry_by_identity = {id(entry): entry for entry in active_entries}
4528
+ bucket_count = 0
4529
+ token_pattern = re.compile(r"[\w-]{3,}", flags=re.UNICODE)
4530
+ for group in grouped.values():
4531
+ token_sets = {
4532
+ id(entry): set(token_pattern.findall(normalized[id(entry)]))
4533
+ for entry in group
4534
+ }
4535
+ frequencies: dict[str, int] = {}
4536
+ for tokens in token_sets.values():
4537
+ for token in tokens:
4538
+ frequencies[token] = frequencies.get(token, 0) + 1
4539
+ buckets: dict[str, list[int]] = {}
4540
+ rare_cutoff = max(2, len(group) // 10)
4541
+ for entry in group:
4542
+ identity = id(entry)
4543
+ tokens = token_sets[identity]
4544
+ selected = sorted(
4545
+ (token for token in tokens if frequencies[token] <= rare_cutoff),
4546
+ key=lambda token: (frequencies[token], token),
4547
+ )[:4]
4548
+ if not selected:
4549
+ selected = [f"length:{len(normalized[identity]) // 40}"]
4550
+ for token in selected:
4551
+ buckets.setdefault(token, []).append(identity)
4552
+ bucket_count += len(buckets)
4553
+ for members in buckets.values():
4554
+ if len(members) < 2:
4555
+ continue
4556
+ for left_index, left_identity in enumerate(members):
4557
+ for right_identity in members[left_index + 1 :]:
4558
+ if left_identity == right_identity:
4559
+ continue
4560
+ candidate_pairs.add(tuple(sorted((left_identity, right_identity))))
4319
4561
 
4320
4562
  def similarity(left: dict[str, Any], right: dict[str, Any]) -> float:
4321
- nonlocal similarity_candidates, exact_comparisons
4563
+ nonlocal similarity_candidates, exact_comparisons, comparison_budget_exceeded
4322
4564
  pair = tuple(sorted((id(left), id(right))))
4323
4565
  if pair in similarity_cache:
4324
4566
  return similarity_cache[pair]
@@ -4338,6 +4580,9 @@ def dedupe_near_duplicates(
4338
4580
  if matcher.real_quick_ratio() < threshold or matcher.quick_ratio() < threshold:
4339
4581
  similarity_cache[pair] = 0.0
4340
4582
  return 0.0
4583
+ if exact_comparisons >= max(0, max_exact_comparisons) or time.monotonic() >= deadline:
4584
+ comparison_budget_exceeded = True
4585
+ return 0.0
4341
4586
  exact_comparisons += 1
4342
4587
  value = matcher.ratio()
4343
4588
  similarity_cache[pair] = value
@@ -4350,52 +4595,79 @@ def dedupe_near_duplicates(
4350
4595
  )
4351
4596
  return value
4352
4597
 
4353
- for archive, group in by_archive.items():
4354
- remaining = list(group)
4355
- while remaining:
4356
- current = remaining.pop(0)
4357
- if current.get("status") == "superseded":
4358
- continue
4359
- cluster = [current]
4360
- still: list[dict[str, Any]] = []
4361
- for other in remaining:
4362
- if other.get("status") == "superseded":
4363
- continue
4364
- if other.get("type") != current.get("type"):
4365
- still.append(other)
4366
- continue
4367
- if similarity(current, other) >= threshold:
4368
- cluster.append(other)
4369
- else:
4370
- still.append(other)
4371
- remaining = still
4372
- if len(cluster) < 2:
4598
+ parent = {identity: identity for identity in entry_by_identity}
4599
+
4600
+ def find(identity: int) -> int:
4601
+ while parent[identity] != identity:
4602
+ parent[identity] = parent[parent[identity]]
4603
+ identity = parent[identity]
4604
+ return identity
4605
+
4606
+ def union(left_identity: int, right_identity: int) -> None:
4607
+ left_root = find(left_identity)
4608
+ right_root = find(right_identity)
4609
+ if left_root != right_root:
4610
+ parent[right_root] = left_root
4611
+
4612
+ ordered_pairs = sorted(candidate_pairs)
4613
+ processed_pairs = 0
4614
+ pair_scores: dict[tuple[int, int], float] = {}
4615
+ for left_identity, right_identity in ordered_pairs:
4616
+ if (
4617
+ exact_comparisons >= max(0, max_exact_comparisons)
4618
+ or time.monotonic() >= deadline
4619
+ ):
4620
+ comparison_budget_exceeded = True
4621
+ break
4622
+ left = entry_by_identity[left_identity]
4623
+ right = entry_by_identity[right_identity]
4624
+ score = similarity(left, right)
4625
+ pair_scores[(left_identity, right_identity)] = score
4626
+ processed_pairs += 1
4627
+ if score >= threshold:
4628
+ union(left_identity, right_identity)
4629
+
4630
+ clusters: dict[int, list[dict[str, Any]]] = {}
4631
+ for identity, entry in entry_by_identity.items():
4632
+ clusters.setdefault(find(identity), []).append(entry)
4633
+ for cluster in clusters.values():
4634
+ if len(cluster) < 2:
4635
+ continue
4636
+ keeper = cluster[0]
4637
+ for candidate in cluster[1:]:
4638
+ keeper = prefer_entry(keeper, candidate)
4639
+ for absorbed in cluster:
4640
+ if absorbed is keeper or absorbed.get("id") == keeper.get("id"):
4373
4641
  continue
4374
- keeper = cluster[0]
4375
- for candidate in cluster[1:]:
4376
- keeper = prefer_entry(keeper, candidate)
4377
- for absorbed in cluster:
4378
- if absorbed is keeper or absorbed.get("id") == keeper.get("id"):
4379
- continue
4380
- merge_entry_provenance(keeper, absorbed)
4381
- supersede_entry(
4382
- absorbed,
4383
- str(keeper["id"]),
4384
- "near-duplicate merged into: " + str(keeper["id"]),
4385
- )
4386
- merges.append(
4387
- {
4388
- "keptId": keeper["id"],
4389
- "mergedId": absorbed["id"],
4390
- "archive": archive,
4391
- "similarity": round(similarity(keeper, absorbed), 3),
4392
- }
4393
- )
4642
+ merge_entry_provenance(keeper, absorbed)
4643
+ supersede_entry(
4644
+ absorbed,
4645
+ str(keeper["id"]),
4646
+ "near-duplicate merged into: " + str(keeper["id"]),
4647
+ )
4648
+ pair = tuple(sorted((id(keeper), id(absorbed))))
4649
+ merges.append(
4650
+ {
4651
+ "keptId": keeper["id"],
4652
+ "mergedId": absorbed["id"],
4653
+ "archive": str(keeper.get("source", {}).get("archive") or ""),
4654
+ "similarity": round(pair_scores.get(pair, threshold), 3),
4655
+ }
4656
+ )
4657
+ comparisons_skipped = max(0, len(ordered_pairs) - processed_pairs)
4394
4658
  return {
4395
4659
  "merged": len(merges),
4396
4660
  "merges": merges,
4397
4661
  "similarityCandidates": similarity_candidates,
4398
4662
  "exactSimilarityComparisons": exact_comparisons,
4663
+ "candidatePairs": len(candidate_pairs),
4664
+ "bucketCount": bucket_count,
4665
+ "comparisonBudget": {
4666
+ "maxExactComparisons": max(0, max_exact_comparisons),
4667
+ "timeBudgetMs": max(0, time_budget_ms),
4668
+ },
4669
+ "comparisonBudgetExceeded": comparison_budget_exceeded,
4670
+ "comparisonsSkipped": comparisons_skipped,
4399
4671
  }
4400
4672
 
4401
4673
 
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=5e077a99a90502cb; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=7eb7481f06ed7f41; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-knowledge-query
25
25
 
26
26
  并行 change 中查询必须传 `--change <change-id>`;成功后以该 change 的 `meta/knowledge-context.json` 为稳定指针。全局 `context-packs/latest.json` 仅向后兼容,不得用于判断多个 active change 的上下文归属。
@@ -20,7 +20,7 @@ disallowed-tools:
20
20
  - Bash(awk *)
21
21
  - Bash(curl *)
22
22
  ---
23
- <!-- generated by harness_deploy.py; core=5e077a99a90502cb; overlay=java; agent=claude-code; do not edit -->
23
+ <!-- generated by harness_deploy.py; core=7eb7481f06ed7f41; overlay=java; agent=claude-code; do not edit -->
24
24
  # harness-package — 增量模块打包(Java overlay)
25
25
 
26
26
  ## Purpose