@hunter-harness/workflow-harness 0.2.49 → 0.2.51

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 (234) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/README.md +4 -4
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +11 -3
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +6 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +45 -244
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +41 -112
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +9 -4
  8. package/harness/bundles/general/claude-code/harness-plan/checklist.md +2 -2
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +7 -2
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +8 -3
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +7 -2
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +12 -7
  13. package/harness/bundles/general/claude-code/harness-sync/reference.md +18 -11
  14. package/harness/bundles/general/claude-code/harness-test/SKILL.md +7 -2
  15. package/harness/bundles/general/claude-code/protocols/archive-report-protocol.md +5 -7
  16. package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +17 -11
  17. package/harness/bundles/general/claude-code/scripts/harness_archive.py +769 -191
  18. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +90 -12
  19. package/harness/bundles/general/claude-code/scripts/harness_gate.py +17 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_profile.py +0 -1
  21. package/harness/bundles/general/claude-code/scripts/harness_state.py +3 -5
  22. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  23. package/harness/bundles/general/codebuddy/README.md +4 -4
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +11 -3
  25. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +6 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +42 -230
  27. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +38 -98
  28. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +9 -4
  29. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +2 -2
  30. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +7 -2
  31. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +8 -3
  32. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +7 -2
  33. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +13 -8
  34. package/harness/bundles/general/codebuddy/harness-sync/reference.md +18 -11
  35. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +7 -2
  36. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +5 -7
  37. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +17 -11
  38. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +769 -191
  39. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +90 -12
  40. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +17 -0
  41. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +0 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_state.py +3 -5
  43. package/harness/bundles/general/codex/.harness-build.json +1 -1
  44. package/harness/bundles/general/codex/README.md +4 -4
  45. package/harness/bundles/general/codex/harness-archive/SKILL.md +11 -3
  46. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +6 -1
  47. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +42 -230
  48. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +38 -98
  49. package/harness/bundles/general/codex/harness-plan/SKILL.md +9 -4
  50. package/harness/bundles/general/codex/harness-plan/checklist.md +2 -2
  51. package/harness/bundles/general/codex/harness-review/SKILL.md +7 -2
  52. package/harness/bundles/general/codex/harness-run/SKILL.md +8 -3
  53. package/harness/bundles/general/codex/harness-submit/SKILL.md +7 -2
  54. package/harness/bundles/general/codex/harness-sync/SKILL.md +13 -8
  55. package/harness/bundles/general/codex/harness-sync/reference.md +18 -11
  56. package/harness/bundles/general/codex/harness-test/SKILL.md +7 -2
  57. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +5 -7
  58. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +17 -11
  59. package/harness/bundles/general/codex/scripts/harness_archive.py +769 -191
  60. package/harness/bundles/general/codex/scripts/harness_events_sync.py +90 -12
  61. package/harness/bundles/general/codex/scripts/harness_gate.py +17 -0
  62. package/harness/bundles/general/codex/scripts/harness_profile.py +0 -1
  63. package/harness/bundles/general/codex/scripts/harness_state.py +3 -5
  64. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  65. package/harness/bundles/general/cursor/README.md +4 -4
  66. package/harness/bundles/general/cursor/harness-archive/SKILL.md +11 -3
  67. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +6 -1
  68. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +42 -230
  69. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +38 -98
  70. package/harness/bundles/general/cursor/harness-plan/SKILL.md +9 -4
  71. package/harness/bundles/general/cursor/harness-plan/checklist.md +2 -2
  72. package/harness/bundles/general/cursor/harness-review/SKILL.md +7 -2
  73. package/harness/bundles/general/cursor/harness-run/SKILL.md +8 -3
  74. package/harness/bundles/general/cursor/harness-submit/SKILL.md +7 -2
  75. package/harness/bundles/general/cursor/harness-sync/SKILL.md +13 -8
  76. package/harness/bundles/general/cursor/harness-sync/reference.md +18 -11
  77. package/harness/bundles/general/cursor/harness-test/SKILL.md +7 -2
  78. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +5 -7
  79. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +17 -11
  80. package/harness/bundles/general/cursor/scripts/harness_archive.py +769 -191
  81. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +90 -12
  82. package/harness/bundles/general/cursor/scripts/harness_gate.py +17 -0
  83. package/harness/bundles/general/cursor/scripts/harness_profile.py +0 -1
  84. package/harness/bundles/general/cursor/scripts/harness_state.py +3 -5
  85. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  86. package/harness/bundles/java/claude-code/README.md +4 -4
  87. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +7 -2
  88. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +11 -3
  89. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +6 -1
  90. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +45 -244
  91. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +41 -112
  92. package/harness/bundles/java/claude-code/harness-package/SKILL.md +7 -2
  93. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +9 -4
  94. package/harness/bundles/java/claude-code/harness-plan/checklist.md +2 -2
  95. package/harness/bundles/java/claude-code/harness-review/SKILL.md +7 -2
  96. package/harness/bundles/java/claude-code/harness-run/SKILL.md +8 -3
  97. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +7 -2
  98. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +12 -7
  99. package/harness/bundles/java/claude-code/harness-sync/reference.md +18 -11
  100. package/harness/bundles/java/claude-code/harness-test/SKILL.md +7 -2
  101. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +5 -7
  102. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +17 -11
  103. package/harness/bundles/java/claude-code/scripts/harness_archive.py +769 -191
  104. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +90 -12
  105. package/harness/bundles/java/claude-code/scripts/harness_gate.py +17 -0
  106. package/harness/bundles/java/claude-code/scripts/harness_profile.py +0 -1
  107. package/harness/bundles/java/claude-code/scripts/harness_state.py +3 -5
  108. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  109. package/harness/bundles/java/codebuddy/README.md +4 -4
  110. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +7 -2
  111. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +11 -3
  112. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +6 -1
  113. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +42 -230
  114. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +38 -98
  115. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +7 -2
  116. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +9 -4
  117. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +2 -2
  118. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +7 -2
  119. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +8 -3
  120. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +7 -2
  121. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +13 -8
  122. package/harness/bundles/java/codebuddy/harness-sync/reference.md +18 -11
  123. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +7 -2
  124. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +5 -7
  125. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +17 -11
  126. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +769 -191
  127. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +90 -12
  128. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +17 -0
  129. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +0 -1
  130. package/harness/bundles/java/codebuddy/scripts/harness_state.py +3 -5
  131. package/harness/bundles/java/codex/.harness-build.json +1 -1
  132. package/harness/bundles/java/codex/README.md +4 -4
  133. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +7 -2
  134. package/harness/bundles/java/codex/harness-archive/SKILL.md +11 -3
  135. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +6 -1
  136. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +42 -230
  137. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +38 -98
  138. package/harness/bundles/java/codex/harness-package/SKILL.md +7 -2
  139. package/harness/bundles/java/codex/harness-plan/SKILL.md +9 -4
  140. package/harness/bundles/java/codex/harness-plan/checklist.md +2 -2
  141. package/harness/bundles/java/codex/harness-review/SKILL.md +7 -2
  142. package/harness/bundles/java/codex/harness-run/SKILL.md +8 -3
  143. package/harness/bundles/java/codex/harness-submit/SKILL.md +7 -2
  144. package/harness/bundles/java/codex/harness-sync/SKILL.md +13 -8
  145. package/harness/bundles/java/codex/harness-sync/reference.md +18 -11
  146. package/harness/bundles/java/codex/harness-test/SKILL.md +7 -2
  147. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +5 -7
  148. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +17 -11
  149. package/harness/bundles/java/codex/scripts/harness_archive.py +769 -191
  150. package/harness/bundles/java/codex/scripts/harness_events_sync.py +90 -12
  151. package/harness/bundles/java/codex/scripts/harness_gate.py +17 -0
  152. package/harness/bundles/java/codex/scripts/harness_profile.py +0 -1
  153. package/harness/bundles/java/codex/scripts/harness_state.py +3 -5
  154. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  155. package/harness/bundles/java/cursor/README.md +4 -4
  156. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +7 -2
  157. package/harness/bundles/java/cursor/harness-archive/SKILL.md +11 -3
  158. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +6 -1
  159. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +42 -230
  160. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +38 -98
  161. package/harness/bundles/java/cursor/harness-package/SKILL.md +7 -2
  162. package/harness/bundles/java/cursor/harness-plan/SKILL.md +9 -4
  163. package/harness/bundles/java/cursor/harness-plan/checklist.md +2 -2
  164. package/harness/bundles/java/cursor/harness-review/SKILL.md +7 -2
  165. package/harness/bundles/java/cursor/harness-run/SKILL.md +8 -3
  166. package/harness/bundles/java/cursor/harness-submit/SKILL.md +7 -2
  167. package/harness/bundles/java/cursor/harness-sync/SKILL.md +13 -8
  168. package/harness/bundles/java/cursor/harness-sync/reference.md +18 -11
  169. package/harness/bundles/java/cursor/harness-test/SKILL.md +7 -2
  170. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +5 -7
  171. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +17 -11
  172. package/harness/bundles/java/cursor/scripts/harness_archive.py +769 -191
  173. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +90 -12
  174. package/harness/bundles/java/cursor/scripts/harness_gate.py +17 -0
  175. package/harness/bundles/java/cursor/scripts/harness_profile.py +0 -1
  176. package/harness/bundles/java/cursor/scripts/harness_state.py +3 -5
  177. package/harness/manifests/general/claude-code.json +23 -47
  178. package/harness/manifests/general/codebuddy.json +23 -47
  179. package/harness/manifests/general/codex.json +23 -47
  180. package/harness/manifests/general/cursor.json +23 -47
  181. package/harness/manifests/java/claude-code.json +25 -49
  182. package/harness/manifests/java/codebuddy.json +25 -49
  183. package/harness/manifests/java/codex.json +25 -49
  184. package/harness/manifests/java/cursor.json +25 -49
  185. package/hunter-workflow-family.json +4 -4
  186. package/package.json +1 -1
  187. package/harness/bundles/general/claude-code/harness-knowledge-ingest/design.md +0 -842
  188. package/harness/bundles/general/claude-code/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  189. package/harness/bundles/general/claude-code/harness-knowledge-ingest/mcp-config.example.json +0 -10
  190. package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +0 -413
  191. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  192. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  193. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/design.md +0 -842
  194. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  195. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/mcp-config.example.json +0 -10
  196. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +0 -413
  197. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  198. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  199. package/harness/bundles/general/codex/harness-knowledge-ingest/design.md +0 -842
  200. package/harness/bundles/general/codex/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  201. package/harness/bundles/general/codex/harness-knowledge-ingest/mcp-config.example.json +0 -10
  202. package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +0 -413
  203. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  204. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  205. package/harness/bundles/general/cursor/harness-knowledge-ingest/design.md +0 -842
  206. package/harness/bundles/general/cursor/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  207. package/harness/bundles/general/cursor/harness-knowledge-ingest/mcp-config.example.json +0 -10
  208. package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +0 -413
  209. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  210. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  211. package/harness/bundles/java/claude-code/harness-knowledge-ingest/design.md +0 -842
  212. package/harness/bundles/java/claude-code/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  213. package/harness/bundles/java/claude-code/harness-knowledge-ingest/mcp-config.example.json +0 -10
  214. package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +0 -413
  215. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  216. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  217. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/design.md +0 -842
  218. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  219. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/mcp-config.example.json +0 -10
  220. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +0 -413
  221. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  222. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  223. package/harness/bundles/java/codex/harness-knowledge-ingest/design.md +0 -842
  224. package/harness/bundles/java/codex/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  225. package/harness/bundles/java/codex/harness-knowledge-ingest/mcp-config.example.json +0 -10
  226. package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +0 -413
  227. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  228. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  229. package/harness/bundles/java/cursor/harness-knowledge-ingest/design.md +0 -842
  230. package/harness/bundles/java/cursor/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  231. package/harness/bundles/java/cursor/harness-knowledge-ingest/mcp-config.example.json +0 -10
  232. package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +0 -413
  233. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  234. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
@@ -4,11 +4,11 @@
4
4
  Subcommands:
5
5
  status — pre-archive gate checks (read-only)
6
6
  finalize — single-process archive: manifest → move → collect → render →
7
- validate → after-manifest → delete-or-keepknowledge/service
7
+ validate → after-manifest → publishZIP upload/service
8
8
  replay — read-only re-collect + validate for historical archives
9
9
 
10
10
  Python 3.10+, stdlib only. UTF-8 without BOM. Windows path safe.
11
- Depends on P0-1 harness_events.py; optionally P0-3 harness_knowledge.py.
11
+ Depends on P0-1 harness_events.py. Knowledge ingest is owned by Hunter Platform.
12
12
  """
13
13
 
14
14
  from __future__ import annotations
@@ -21,11 +21,14 @@ import json
21
21
  import os
22
22
  import re
23
23
  import shutil
24
+ import stat
24
25
  import subprocess
25
26
  import sys
26
27
  import tarfile
27
28
  import tempfile
28
29
  import uuid
30
+ import zipfile
31
+ from collections.abc import Mapping
29
32
  from pathlib import Path
30
33
  from typing import Any
31
34
 
@@ -42,9 +45,6 @@ RENDER_SCRIPT = SKILLS_ROOT / "harness-archive" / "templates" / "render-summary.
42
45
  SUMMARY_TEMPLATE = (
43
46
  SKILLS_ROOT / "harness-archive" / "templates" / "summary-data-template.json"
44
47
  )
45
- KNOWLEDGE_SCRIPT = (
46
- SKILLS_ROOT / "harness-knowledge-ingest" / "scripts" / "harness_knowledge.py"
47
- )
48
48
  SERVICE_SCRIPT = SCRIPTS_DIR / "harness_service.py"
49
49
 
50
50
  # Manifest compare must ignore self-mutating log files appended during finalize.
@@ -70,6 +70,7 @@ if str(SCRIPTS_DIR) not in sys.path:
70
70
  sys.path.insert(0, str(SCRIPTS_DIR))
71
71
 
72
72
  import harness_events as he # noqa: E402
73
+ import harness_events_sync as hes # noqa: E402
73
74
  import harness_efficiency as heff # noqa: E402
74
75
  import harness_gate as hgate # noqa: E402
75
76
  import harness_ledger as hl # noqa: E402
@@ -1923,38 +1924,12 @@ def worktree_requested(change_dir: Path) -> bool:
1923
1924
 
1924
1925
 
1925
1926
  def check_archive_exact_byte_policy(project_root: Path) -> dict[str, Any]:
1926
- """Verify frozen archive paths are exempt from Git text conversion."""
1927
- required_rule = ".harness/archive/** -text"
1928
- attributes_path = project_root / ".gitattributes"
1929
- rules: list[str] = []
1930
- if attributes_path.is_file():
1931
- try:
1932
- rules = [
1933
- line.strip()
1934
- for line in attributes_path.read_text(encoding="utf-8-sig").splitlines()
1935
- if line.strip() and not line.lstrip().startswith("#")
1936
- ]
1937
- except OSError:
1938
- rules = []
1939
- matching = []
1940
- for rule in rules:
1941
- fields = rule.split()
1942
- if not fields or fields[0] != ".harness/archive/**":
1943
- continue
1944
- matching.append(rule)
1945
- if "-text" in fields[1:] or "binary" in fields[1:]:
1946
- return {
1947
- "ok": True,
1948
- "path": str(attributes_path),
1949
- "requiredRule": required_rule,
1950
- "matchedRule": rule,
1951
- }
1927
+ """Exact bytes are guaranteed by ZIP entry hashes, independent of Git."""
1952
1928
  return {
1953
- "ok": False,
1954
- "path": str(attributes_path),
1955
- "requiredRule": required_rule,
1956
- "matchedRules": matching,
1957
- "remediation": f"add this exact-byte rule to .gitattributes: {required_rule}",
1929
+ "ok": True,
1930
+ "mode": "zip-content-hash",
1931
+ "gitattributesRequired": False,
1932
+ "note": "archive bytes are verified by manifest and package SHA-256",
1958
1933
  }
1959
1934
 
1960
1935
 
@@ -7000,79 +6975,6 @@ def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
7000
6975
  return result
7001
6976
 
7002
6977
 
7003
- def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
7004
- results: dict[str, Any] = {"ran": False, "steps": [], "warnings": []}
7005
- if not KNOWLEDGE_SCRIPT.is_file():
7006
- results["warnings"].append(f"harness_knowledge.py not found: {KNOWLEDGE_SCRIPT}")
7007
- return results
7008
- results["ran"] = True
7009
- for cmd in ("ingest", "dedupe", "auto-supersede", "reverify-stale"):
7010
- try:
7011
- proc = subprocess.run(
7012
- [
7013
- sys.executable,
7014
- str(KNOWLEDGE_SCRIPT),
7015
- cmd,
7016
- "--project",
7017
- str(project_root),
7018
- "--json",
7019
- ],
7020
- capture_output=True,
7021
- text=True,
7022
- encoding="utf-8",
7023
- errors="replace",
7024
- timeout=300,
7025
- check=False,
7026
- )
7027
- step = {
7028
- "command": cmd,
7029
- "exit_code": proc.returncode,
7030
- "ok": proc.returncode == 0,
7031
- }
7032
- if proc.returncode != 0:
7033
- step["stderr"] = (proc.stderr or proc.stdout or "")[:500]
7034
- results["warnings"].append(f"knowledge {cmd} exit {proc.returncode}")
7035
- results["steps"].append(step)
7036
- except (OSError, subprocess.TimeoutExpired) as exc:
7037
- results["steps"].append({"command": cmd, "ok": False, "error": str(exc)})
7038
- results["warnings"].append(f"knowledge {cmd} failed: {exc}")
7039
- return results
7040
-
7041
-
7042
- def enqueue_maintenance_outbox(project_root: Path, archive_dir: Path) -> dict[str, Any]:
7043
- """§8.2: write a pending maintenance-outbox item instead of synchronously
7044
- running the four knowledge subprocesses. Never rolls back the archive."""
7045
- pending_dir = (
7046
- project_root / ".harness" / "knowledge" / "maintenance-outbox" / "pending"
7047
- )
7048
- pending_dir.mkdir(parents=True, exist_ok=True)
7049
- archive_id = archive_dir.name
7050
- manifest = archive_dir / "evidence" / "archive-manifest-after.json"
7051
- manifest_hash = "sha256:" + sha256_file(manifest) if manifest.is_file() else ""
7052
- try:
7053
- rel = archive_dir.resolve().relative_to(project_root.resolve()).as_posix()
7054
- except ValueError:
7055
- rel = str(archive_dir)
7056
- item = {
7057
- "schemaVersion": 1,
7058
- "archiveId": archive_id,
7059
- "archivePath": rel,
7060
- "archiveManifestHash": manifest_hash,
7061
- "status": "pending",
7062
- "attempts": 0,
7063
- "createdAt": now_iso(),
7064
- "lastError": None,
7065
- }
7066
- item_path = pending_dir / f"{archive_id}.json"
7067
- write_json(item_path, item)
7068
- return {
7069
- "queued": True,
7070
- "outboxPath": str(item_path),
7071
- "archiveId": archive_id,
7072
- "status": "pending",
7073
- }
7074
-
7075
-
7076
6978
  def run_service_stop(change_dir: Path) -> dict[str, Any]:
7077
6979
  if not SERVICE_SCRIPT.is_file():
7078
6980
  return {
@@ -8284,6 +8186,9 @@ def cmd_finalize(
8284
8186
 
8285
8187
  # Publish only after every validator passes. The archive path is never used
8286
8188
  # as mutable staging, so a failed attempt cannot poison the next retry.
8189
+ # Capture monitoring run_id from the live change path before it is deleted
8190
+ # so archive finalize can report terminal status on the same platform run.
8191
+ monitoring_run_id = hes.run_id_for(original_change_dir)
8287
8192
  try:
8288
8193
  archive_root.mkdir(parents=True, exist_ok=True)
8289
8194
  shutil.move(str(operation_temp_dir), str(archive_dir))
@@ -8321,19 +8226,15 @@ def cmd_finalize(
8321
8226
  warnings.append(f"could not remove archived runtime state: {exc}")
8322
8227
  payload["steps"]["delete_runtime_state"] = {"ok": False, "error": str(exc)}
8323
8228
 
8324
- # --- 12. maintenance outbox + service ---
8325
- if skip_ingest:
8326
- payload["steps"]["knowledge"] = {"skipped": True, "reason": "--skip-ingest"}
8327
- payload["knowledgeMaintenance"] = "SKIPPED"
8328
- else:
8329
- try:
8330
- enqueue = enqueue_maintenance_outbox(project_root, work_dir)
8331
- payload["steps"]["knowledge"] = enqueue
8332
- payload["knowledgeMaintenance"] = "QUEUED"
8333
- except OSError as exc:
8334
- warnings.append(f"maintenance outbox enqueue failed: {exc}")
8335
- payload["steps"]["knowledge"] = {"queued": False, "error": str(exc)}
8336
- payload["knowledgeMaintenance"] = "NOT_QUEUED"
8229
+ # --- 12. remote knowledge ownership + service ---
8230
+ # Knowledge ingest is server-owned. The local archive never builds or keeps
8231
+ # a searchable knowledge index; upload status below is the ingest receipt.
8232
+ payload["steps"]["knowledge"] = {
8233
+ "mode": "remote-after-archive-upload",
8234
+ "localIngest": False,
8235
+ "legacySkipIngestFlag": bool(skip_ingest),
8236
+ }
8237
+ payload["knowledgeMaintenance"] = "REMOTE_PENDING"
8337
8238
 
8338
8239
  service_result = run_service_stop(work_dir)
8339
8240
  payload["steps"]["service_stop"] = service_result
@@ -8361,13 +8262,36 @@ def cmd_finalize(
8361
8262
  except OSError as exc:
8362
8263
  warnings.append(f"could not persist completed archive operation: {exc}")
8363
8264
 
8364
- # P4: when remote credentials exist, auto-push the core four
8365
- # (design/plan/summary-data/knowledge) via the existing push channel.
8366
- push_result = auto_push_archive_core(project_root, archive_dir)
8265
+ # When remote credentials exist, upload one deterministic core-only ZIP.
8266
+ push_result = auto_push_archive_core(
8267
+ project_root,
8268
+ archive_dir,
8269
+ change_key=change_name,
8270
+ )
8367
8271
  payload["steps"]["archive_push"] = push_result
8272
+ payload["knowledgeMaintenance"] = _knowledge_maintenance_from_archive_push(
8273
+ push_result
8274
+ )
8368
8275
  if push_result.get("warning"):
8369
8276
  warnings.append(str(push_result["warning"]))
8370
8277
 
8278
+ # C3: best-effort terminal events-sync so platform run ends (succeeded/failed).
8279
+ # Use the pre-delete change-path run_id + original change_key for continuity.
8280
+ try:
8281
+ monitor = hes.auto_events_sync(
8282
+ project_root,
8283
+ archive_dir,
8284
+ run_id=monitoring_run_id,
8285
+ change_key=change_name,
8286
+ )
8287
+ payload["steps"]["platform_events_sync"] = monitor
8288
+ if monitor.get("warning"):
8289
+ warnings.append(str(monitor["warning"]))
8290
+ except Exception as exc: # noqa: BLE001 — never roll back archive
8291
+ warn = f"events-sync terminal hook failed: {exc}"
8292
+ warnings.append(warn)
8293
+ payload["steps"]["platform_events_sync"] = {"ok": False, "warning": warn}
8294
+
8371
8295
  payload["ok"] = True
8372
8296
  payload["finalStatus"] = "OK"
8373
8297
  payload["warnings"] = warnings
@@ -8376,80 +8300,597 @@ def cmd_finalize(
8376
8300
  return 0, payload
8377
8301
 
8378
8302
 
8303
+ def _yaml_scalar(raw: str) -> str | None:
8304
+ """Decode the small scalar subset used by Harness-owned YAML files."""
8305
+ value = raw.strip()
8306
+ if not value:
8307
+ return None
8308
+ quote: str | None = None
8309
+ escaped = False
8310
+ end = len(value)
8311
+ for index, character in enumerate(value):
8312
+ if escaped:
8313
+ escaped = False
8314
+ continue
8315
+ if quote == '"' and character == "\\":
8316
+ escaped = True
8317
+ continue
8318
+ if character in {"'", '"'}:
8319
+ if quote is None:
8320
+ quote = character
8321
+ elif quote == character:
8322
+ quote = None
8323
+ continue
8324
+ if character == "#" and quote is None:
8325
+ end = index
8326
+ break
8327
+ value = value[:end].strip()
8328
+ if value.lower() in {"", "null", "none", "~"}:
8329
+ return None
8330
+ if len(value) >= 2 and value[0] == value[-1] == '"':
8331
+ try:
8332
+ decoded = json.loads(value)
8333
+ except json.JSONDecodeError:
8334
+ return None
8335
+ return decoded.strip() if isinstance(decoded, str) and decoded.strip() else None
8336
+ if len(value) >= 2 and value[0] == value[-1] == "'":
8337
+ decoded = value[1:-1].replace("''", "'").strip()
8338
+ return decoded or None
8339
+ return value
8340
+
8341
+
8342
+ def _yaml_mapping_value(
8343
+ text: str,
8344
+ key: str,
8345
+ *,
8346
+ section: str | None = None,
8347
+ ) -> str | None:
8348
+ """Read one root or one-level nested scalar without loading arbitrary YAML."""
8349
+ inside_section = section is None
8350
+ for line in text.splitlines():
8351
+ if not line.strip() or line.lstrip().startswith("#"):
8352
+ continue
8353
+ match = re.match(
8354
+ r"^(?P<indent> *)(?P<key>[A-Za-z_][A-Za-z0-9_-]*):(?P<raw>.*)$",
8355
+ line,
8356
+ )
8357
+ if match is None:
8358
+ continue
8359
+ indent = len(match.group("indent"))
8360
+ name = match.group("key")
8361
+ if section is None:
8362
+ if indent == 0 and name == key:
8363
+ return _yaml_scalar(match.group("raw"))
8364
+ continue
8365
+ if section is not None and indent == 0:
8366
+ inside_section = name == section
8367
+ continue
8368
+ if inside_section and name == key and indent > 0:
8369
+ return _yaml_scalar(match.group("raw"))
8370
+ return None
8371
+
8372
+
8373
+ def _read_optional_text(path: Path) -> str:
8374
+ try:
8375
+ return path.read_text(encoding="utf-8-sig")
8376
+ except OSError:
8377
+ return ""
8378
+
8379
+
8380
+ def _resolve_archive_remote_credentials(
8381
+ project_root: Path,
8382
+ environment: Mapping[str, str],
8383
+ ) -> dict[str, Any]:
8384
+ """Mirror CLI auth precedence without returning or persisting the token."""
8385
+ project_text = _read_optional_text(project_root / ".harness" / "project.yaml")
8386
+ local_text = _read_optional_text(
8387
+ project_root / ".harness" / "credentials.local.yaml"
8388
+ )
8389
+ project_url = _yaml_mapping_value(project_text, "url", section="server")
8390
+ token_env = _yaml_mapping_value(project_text, "token_env", section="server")
8391
+ local_url = _yaml_mapping_value(local_text, "server_url")
8392
+ local_token = _yaml_mapping_value(local_text, "token")
8393
+ server_url = local_url or project_url
8394
+ valid_token_env = (
8395
+ token_env
8396
+ if token_env is not None
8397
+ and re.fullmatch(r"[A-Z_][A-Z0-9_]*", token_env) is not None
8398
+ else None
8399
+ )
8400
+ env_token = (
8401
+ environment.get(valid_token_env, "").strip()
8402
+ if valid_token_env is not None
8403
+ else ""
8404
+ )
8405
+ token_available = bool(env_token or local_token)
8406
+ url_available = bool(
8407
+ server_url
8408
+ and re.fullmatch(r"https://[^\s]+", server_url, flags=re.IGNORECASE)
8409
+ )
8410
+ return {
8411
+ "configured": bool(url_available and token_available),
8412
+ "serverUrl": server_url if url_available else None,
8413
+ "tokenEnv": valid_token_env,
8414
+ "missing": [
8415
+ *([] if url_available else ["url"]),
8416
+ *([] if token_available else ["token"]),
8417
+ ],
8418
+ }
8419
+
8420
+
8379
8421
  def _remote_credentials_configured(project_root: Path) -> bool:
8380
- creds = project_root / ".harness" / "credentials.local.yaml"
8381
- if not creds.is_file():
8422
+ """Compatibility predicate backed by the same auth sources as the CLI."""
8423
+ return bool(
8424
+ _resolve_archive_remote_credentials(project_root.resolve(), os.environ)[
8425
+ "configured"
8426
+ ]
8427
+ )
8428
+
8429
+
8430
+ _FILE_ATTRIBUTE_REPARSE_POINT = getattr(
8431
+ stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400
8432
+ )
8433
+
8434
+
8435
+ def _archive_path_is_link_or_reparse(path: Path) -> bool:
8436
+ """Detect symlinks and Windows junction/reparse points without following them."""
8437
+ try:
8438
+ if path.is_symlink():
8439
+ return True
8440
+ attributes = getattr(os.lstat(path), "st_file_attributes", 0)
8441
+ except OSError:
8382
8442
  return False
8383
- text = creds.read_text(encoding="utf-8", errors="ignore")
8384
- has_url = bool(re.search(r"(?m)^[ \t]*server_url:\s*\S+", text))
8385
- has_token = bool(re.search(r"(?m)^[ \t]*token:\s*\S+", text))
8386
- return has_url and has_token
8443
+ return bool(attributes & _FILE_ATTRIBUTE_REPARSE_POINT)
8444
+
8445
+
8446
+ def _require_archive_project_path(
8447
+ project_root: Path,
8448
+ candidate: Path,
8449
+ *,
8450
+ label: str,
8451
+ must_exist: bool,
8452
+ ) -> Path:
8453
+ """Return a canonical project-contained path after rejecting link components."""
8454
+ lexical_root = Path(os.path.abspath(os.fspath(project_root)))
8455
+ lexical_candidate = Path(os.path.abspath(os.fspath(candidate)))
8456
+ try:
8457
+ relative = lexical_candidate.relative_to(lexical_root)
8458
+ except ValueError as exc:
8459
+ raise ValueError(f"{label} is outside project root: {candidate}") from exc
8460
+ if not relative.parts:
8461
+ raise ValueError(f"{label} must be strictly inside project root")
8462
+
8463
+ current = lexical_root
8464
+ for part in relative.parts:
8465
+ current = current / part
8466
+ if _archive_path_is_link_or_reparse(current):
8467
+ raise ValueError(f"{label} contains a link or reparse point: {current}")
8468
+
8469
+ try:
8470
+ canonical_root = lexical_root.resolve(strict=True)
8471
+ canonical_candidate = lexical_candidate.resolve(strict=must_exist)
8472
+ except OSError as exc:
8473
+ raise ValueError(f"{label} does not exist or cannot be resolved: {candidate}") from exc
8474
+ try:
8475
+ canonical_relative = canonical_candidate.relative_to(canonical_root)
8476
+ except ValueError as exc:
8477
+ raise ValueError(f"{label} resolves outside project root: {candidate}") from exc
8478
+ if not canonical_relative.parts:
8479
+ raise ValueError(f"{label} must be strictly inside project root")
8480
+ return canonical_candidate
8481
+
8482
+
8483
+ def _archive_core_file_specs(
8484
+ project_root: Path,
8485
+ archive_dir: Path,
8486
+ ) -> tuple[Path, Path, list[tuple[Path, str, str, str]]]:
8487
+ root = Path(os.path.abspath(os.fspath(project_root))).resolve(strict=True)
8488
+ archive = _require_archive_project_path(
8489
+ project_root,
8490
+ archive_dir,
8491
+ label="archive dir",
8492
+ must_exist=True,
8493
+ )
8494
+ if not archive.is_dir():
8495
+ raise ValueError(f"archive dir not found: {archive}")
8496
+
8497
+ file_specs: list[tuple[Path, str, str, str]] = []
8498
+
8499
+ def add_if_file(
8500
+ candidate: Path,
8501
+ package_path: str,
8502
+ role: str,
8503
+ media_type: str,
8504
+ ) -> None:
8505
+ safe = _require_archive_project_path(
8506
+ root,
8507
+ candidate,
8508
+ label=f"archive input {package_path}",
8509
+ must_exist=False,
8510
+ )
8511
+ if safe.is_file():
8512
+ file_specs.append((safe, package_path, role, media_type))
8513
+
8514
+ add_if_file(
8515
+ archive / "reports" / "final" / "summary-data.json",
8516
+ "reports/final/summary-data.json",
8517
+ "summary",
8518
+ "application/json",
8519
+ )
8520
+ for folder, role in (("spec", "spec"), ("plans", "plan")):
8521
+ base = _require_archive_project_path(
8522
+ root,
8523
+ archive / folder,
8524
+ label=f"archive input {folder}",
8525
+ must_exist=False,
8526
+ )
8527
+ if not base.is_dir():
8528
+ continue
8529
+ for directory, directory_names, file_names in os.walk(
8530
+ base, topdown=True, followlinks=False
8531
+ ):
8532
+ current = Path(directory)
8533
+ directory_names.sort()
8534
+ file_names.sort()
8535
+ for name in directory_names:
8536
+ _require_archive_project_path(
8537
+ root,
8538
+ current / name,
8539
+ label=f"archive input {folder}/{name}",
8540
+ must_exist=True,
8541
+ )
8542
+ for name in file_names:
8543
+ source = _require_archive_project_path(
8544
+ root,
8545
+ current / name,
8546
+ label=f"archive input {folder}/{name}",
8547
+ must_exist=True,
8548
+ )
8549
+ if source.is_file() and source.suffix.lower() == ".md":
8550
+ relative = source.relative_to(base).as_posix()
8551
+ file_specs.append(
8552
+ (source, f"{folder}/{relative}", role, "text/markdown")
8553
+ )
8554
+ add_if_file(
8555
+ archive / "meta" / "archive-meta.md",
8556
+ "archive-meta.md",
8557
+ "archive_meta",
8558
+ "text/markdown",
8559
+ )
8560
+ add_if_file(
8561
+ archive / "meta" / "change-context.json",
8562
+ "change-context.json",
8563
+ "change_context",
8564
+ "application/json",
8565
+ )
8566
+ return root, archive, file_specs
8387
8567
 
8388
8568
 
8389
8569
  def collect_archive_core_paths(project_root: Path, archive_dir: Path) -> list[str]:
8390
- """Relative paths for the P4 core four push set."""
8391
- root = project_root.resolve()
8392
- archive = archive_dir.resolve()
8393
- paths: list[str] = []
8394
-
8395
- def add_if_exists(absolute: Path) -> None:
8396
- if absolute.is_file():
8397
- paths.append(absolute.relative_to(root).as_posix())
8398
-
8399
- add_if_exists(archive / "reports" / "final" / "summary-data.json")
8400
- for folder in ("spec", "plans"):
8401
- base = archive / folder
8402
- if base.is_dir():
8403
- for path in sorted(base.rglob("*")):
8404
- if path.is_file():
8405
- paths.append(path.relative_to(root).as_posix())
8406
- knowledge = root / ".harness" / "knowledge" / "entries"
8407
- for status in ("active", "candidate"):
8408
- folder = knowledge / status
8409
- if folder.is_dir():
8410
- for path in sorted(folder.glob("*.json")):
8411
- paths.append(path.relative_to(root).as_posix())
8412
- return paths
8413
-
8414
-
8415
- def auto_push_archive_core(project_root: Path, archive_dir: Path) -> dict[str, Any]:
8416
- """Best-effort remote push of archive core artifacts after finalize.
8417
-
8418
- Skips silently when credentials are absent. Failures become warnings so
8419
- archive success is never rolled back by a push outage.
8570
+ """Return only durable core files; diagnostics and local knowledge stay local."""
8571
+ root, _, file_specs = _archive_core_file_specs(project_root, archive_dir)
8572
+ return [source.relative_to(root).as_posix() for source, *_ in file_specs]
8573
+
8574
+
8575
+ def _archive_source_identity(project_root: Path) -> dict[str, str | None]:
8576
+ def git_value(*args: str) -> str | None:
8577
+ try:
8578
+ completed = subprocess.run(
8579
+ ["git", *args],
8580
+ cwd=str(project_root),
8581
+ text=True,
8582
+ stdout=subprocess.PIPE,
8583
+ stderr=subprocess.DEVNULL,
8584
+ timeout=10,
8585
+ shell=False,
8586
+ )
8587
+ except (OSError, subprocess.TimeoutExpired):
8588
+ return None
8589
+ value = completed.stdout.strip()
8590
+ return value if completed.returncode == 0 and value else None
8591
+
8592
+ return {
8593
+ "commit": git_value("rev-parse", "HEAD"),
8594
+ "tree": git_value("rev-parse", "HEAD^{tree}"),
8595
+ }
8596
+
8597
+
8598
+ def _archive_created_at(summary_path: Path) -> str:
8599
+ try:
8600
+ summary = read_json(summary_path)
8601
+ except (OSError, json.JSONDecodeError):
8602
+ return "1980-01-01T00:00:00.000Z"
8603
+ for key in ("archivedAt", "archived_at", "generatedAt", "generated_at"):
8604
+ value = summary.get(key)
8605
+ if isinstance(value, str) and value.strip():
8606
+ return value.strip()
8607
+ return "1980-01-01T00:00:00.000Z"
8608
+
8609
+
8610
+ def _deterministic_zip_info(path: str) -> zipfile.ZipInfo:
8611
+ info = zipfile.ZipInfo(path, date_time=(1980, 1, 1, 0, 0, 0))
8612
+ info.compress_type = zipfile.ZIP_DEFLATED
8613
+ info.create_system = 3
8614
+ info.external_attr = (0o100644 & 0xFFFF) << 16
8615
+ return info
8616
+
8617
+
8618
+ def build_archive_package(
8619
+ project_root: Path,
8620
+ archive_dir: Path,
8621
+ change_key: str,
8622
+ *,
8623
+ output_path: Path | None = None,
8624
+ ) -> dict[str, Any]:
8625
+ """Build the deterministic core-v1 ZIP uploaded to hunter-platform."""
8626
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,159}", change_key) is None:
8627
+ raise ValueError("change_key must be a portable path segment")
8628
+ project_root, archive_dir, file_specs = _archive_core_file_specs(
8629
+ project_root, archive_dir
8630
+ )
8631
+ summary_path = archive_dir / "reports" / "final" / "summary-data.json"
8632
+ if not any(spec[1] == "reports/final/summary-data.json" for spec in file_specs):
8633
+ raise ValueError("archive final summary-data.json is required")
8634
+
8635
+ entries: list[dict[str, Any]] = []
8636
+ content_by_path: dict[str, bytes] = {}
8637
+ for source, package_path, role, media_type in sorted(file_specs, key=lambda item: item[1]):
8638
+ source = _require_archive_project_path(
8639
+ project_root,
8640
+ source,
8641
+ label=f"archive input {package_path}",
8642
+ must_exist=True,
8643
+ )
8644
+ raw = source.read_bytes()
8645
+ # All semantic archive inputs are text and must round-trip as UTF-8.
8646
+ raw.decode("utf-8")
8647
+ if media_type == "application/json":
8648
+ json.loads(raw)
8649
+ content_by_path[package_path] = raw
8650
+ entries.append(
8651
+ {
8652
+ "path": package_path,
8653
+ "role": role,
8654
+ "media_type": media_type,
8655
+ "content_sha256": "sha256:" + hashlib.sha256(raw).hexdigest(),
8656
+ "size_bytes": len(raw),
8657
+ }
8658
+ )
8659
+
8660
+ manifest = {
8661
+ "schema_version": 1,
8662
+ "profile": "core-v1",
8663
+ "change_key": change_key,
8664
+ "created_at": _archive_created_at(summary_path),
8665
+ "source": _archive_source_identity(project_root),
8666
+ "files": entries,
8667
+ }
8668
+ manifest_bytes = (
8669
+ json.dumps(
8670
+ manifest,
8671
+ ensure_ascii=False,
8672
+ sort_keys=True,
8673
+ separators=(",", ":"),
8674
+ )
8675
+ + "\n"
8676
+ ).encode("utf-8")
8677
+
8678
+ if output_path is None:
8679
+ output_path = (
8680
+ project_root
8681
+ / ".harness"
8682
+ / "state"
8683
+ / "local"
8684
+ / "archive-packages"
8685
+ / f"{change_key}.zip"
8686
+ )
8687
+ output_path = _require_archive_project_path(
8688
+ project_root,
8689
+ output_path,
8690
+ label="archive package output",
8691
+ must_exist=False,
8692
+ )
8693
+ output_path.parent.mkdir(parents=True, exist_ok=True)
8694
+ output_path = _require_archive_project_path(
8695
+ project_root,
8696
+ output_path,
8697
+ label="archive package output",
8698
+ must_exist=False,
8699
+ )
8700
+ temporary = output_path.with_name(output_path.name + f".{uuid.uuid4().hex}.tmp")
8701
+ try:
8702
+ with zipfile.ZipFile(
8703
+ temporary,
8704
+ "w",
8705
+ compression=zipfile.ZIP_DEFLATED,
8706
+ compresslevel=9,
8707
+ strict_timestamps=True,
8708
+ ) as package:
8709
+ package.writestr(
8710
+ _deterministic_zip_info("archive-manifest.json"),
8711
+ manifest_bytes,
8712
+ compress_type=zipfile.ZIP_DEFLATED,
8713
+ compresslevel=9,
8714
+ )
8715
+ for path in sorted(content_by_path):
8716
+ package.writestr(
8717
+ _deterministic_zip_info(path),
8718
+ content_by_path[path],
8719
+ compress_type=zipfile.ZIP_DEFLATED,
8720
+ compresslevel=9,
8721
+ )
8722
+ output_path = _require_archive_project_path(
8723
+ project_root,
8724
+ output_path,
8725
+ label="archive package output",
8726
+ must_exist=False,
8727
+ )
8728
+ os.replace(temporary, output_path)
8729
+ finally:
8730
+ temporary.unlink(missing_ok=True)
8731
+
8732
+ package_raw = output_path.read_bytes()
8733
+ return {
8734
+ "schemaVersion": 1,
8735
+ "profile": "core-v1",
8736
+ "changeKey": change_key,
8737
+ "packagePath": str(output_path),
8738
+ "packageSha256": "sha256:" + hashlib.sha256(package_raw).hexdigest(),
8739
+ "manifestSha256": "sha256:" + hashlib.sha256(manifest_bytes).hexdigest(),
8740
+ "fileCount": len(entries),
8741
+ "sizeBytes": len(package_raw),
8742
+ "paths": [entry["path"] for entry in entries],
8743
+ }
8744
+
8745
+
8746
+ def auto_push_archive_core(
8747
+ project_root: Path,
8748
+ archive_dir: Path,
8749
+ *,
8750
+ change_key: str | None = None,
8751
+ ) -> dict[str, Any]:
8752
+ """Build and upload one deterministic core ZIP after finalize.
8753
+
8754
+ The ZIP and its per-change receipt are always created before auth is
8755
+ evaluated. Failures become warnings so archive success is never rolled
8756
+ back by missing credentials or a remote outage.
8420
8757
  """
8421
8758
  project_root = project_root.resolve()
8422
- if not _remote_credentials_configured(project_root):
8423
- return {"skipped": True, "reason": "no remote credentials"}
8424
- core_paths = collect_archive_core_paths(project_root, archive_dir)
8425
- pending = {
8759
+ effective_change_key = change_key or archive_dir.name
8760
+ try:
8761
+ package = build_archive_package(
8762
+ project_root,
8763
+ archive_dir,
8764
+ effective_change_key,
8765
+ )
8766
+ except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
8767
+ return {
8768
+ "skipped": False,
8769
+ "ok": False,
8770
+ "reasonCode": "ARCHIVE_PACKAGE_BUILD_FAILED",
8771
+ "warning": f"核心归档 ZIP 生成失败:{exc}",
8772
+ }
8773
+ core_paths = list(package["paths"])
8774
+ package_path = Path(str(package["packagePath"]))
8775
+ try:
8776
+ pending_path = _require_archive_project_path(
8777
+ project_root,
8778
+ package_path.with_suffix(".upload.json"),
8779
+ label="archive upload receipt",
8780
+ must_exist=False,
8781
+ )
8782
+ except (OSError, ValueError) as exc:
8783
+ return {
8784
+ "skipped": False,
8785
+ "ok": False,
8786
+ "paths": core_paths,
8787
+ "packagePath": package["packagePath"],
8788
+ "packageSha256": package["packageSha256"],
8789
+ "reasonCode": "ARCHIVE_UPLOAD_RECEIPT_PATH_UNSAFE",
8790
+ "warning": f"归档上传回执路径不安全;已保留 ZIP:{exc}",
8791
+ }
8792
+ credentials = _resolve_archive_remote_credentials(project_root, os.environ)
8793
+ initial_reason_code = (
8794
+ "ARCHIVE_UPLOAD_PENDING"
8795
+ if credentials["configured"]
8796
+ else "ARCHIVE_UPLOAD_CREDENTIALS_MISSING"
8797
+ )
8798
+ pending: dict[str, Any] = {
8426
8799
  "schemaVersion": 1,
8427
8800
  "generatedAt": now_iso(),
8801
+ "updatedAt": now_iso(),
8428
8802
  "archiveDir": str(archive_dir),
8803
+ "changeKey": effective_change_key,
8804
+ "packagePath": package["packagePath"],
8805
+ "packageSha256": package["packageSha256"],
8806
+ "manifestSha256": package["manifestSha256"],
8807
+ "fileCount": package["fileCount"],
8429
8808
  "paths": core_paths,
8809
+ "uploadStatus": "pending",
8810
+ "reasonCode": initial_reason_code,
8811
+ "archiveStatus": None,
8812
+ "knowledgeStatus": None,
8430
8813
  }
8431
- pending_path = project_root / ".harness" / "state" / "archive-push-pending.json"
8814
+
8815
+ def persist_receipt(
8816
+ *,
8817
+ upload_status: str,
8818
+ reason_code: str | None,
8819
+ archive_status: str | None = None,
8820
+ knowledge_status: str | None = None,
8821
+ exit_code: int | None = None,
8822
+ ) -> None:
8823
+ pending.update(
8824
+ {
8825
+ "updatedAt": now_iso(),
8826
+ "uploadStatus": upload_status,
8827
+ "reasonCode": reason_code,
8828
+ "archiveStatus": archive_status,
8829
+ "knowledgeStatus": knowledge_status,
8830
+ "exitCode": exit_code,
8831
+ }
8832
+ )
8833
+ write_json(pending_path, pending)
8834
+
8432
8835
  try:
8433
8836
  pending_path.parent.mkdir(parents=True, exist_ok=True)
8434
- write_json(pending_path, pending)
8837
+ persist_receipt(
8838
+ upload_status="pending",
8839
+ reason_code=initial_reason_code,
8840
+ )
8435
8841
  except OSError as exc:
8436
8842
  return {
8437
8843
  "skipped": False,
8438
8844
  "ok": False,
8439
8845
  "paths": core_paths,
8440
- "warning": f"could not write archive-push-pending.json: {exc}",
8846
+ "packagePath": package["packagePath"],
8847
+ "packageSha256": package["packageSha256"],
8848
+ "reasonCode": "ARCHIVE_UPLOAD_RECEIPT_WRITE_FAILED",
8849
+ "warning": f"无法写入归档上传回执:{exc}",
8441
8850
  }
8442
8851
 
8443
- # Prefer the published CLI; fall back to recording the pending receipt only.
8852
+ base_result: dict[str, Any] = {
8853
+ "skipped": False,
8854
+ "ok": False,
8855
+ "uploadStatus": "pending",
8856
+ "paths": core_paths,
8857
+ "packagePath": package["packagePath"],
8858
+ "packageSha256": package["packageSha256"],
8859
+ "manifestSha256": package["manifestSha256"],
8860
+ "fileCount": package["fileCount"],
8861
+ "sizeBytes": package["sizeBytes"],
8862
+ "pending": str(pending_path),
8863
+ }
8864
+
8865
+ if not credentials["configured"]:
8866
+ reason_code = "ARCHIVE_UPLOAD_CREDENTIALS_MISSING"
8867
+ return {
8868
+ **base_result,
8869
+ "skipped": True,
8870
+ "reasonCode": reason_code,
8871
+ "missingCredentials": credentials["missing"],
8872
+ "reason": "未配置可用的远端地址或 API Token;已保留 ZIP 等待重试。",
8873
+ }
8874
+
8875
+ # The published CLI owns credentials, project binding, durable receipt checks,
8876
+ # and retries. Generic `push` is intentionally not used for archive bytes.
8444
8877
  command = [
8445
8878
  "npx",
8446
8879
  "--yes",
8447
8880
  "hunter-harness",
8448
- "push",
8881
+ "archive",
8882
+ "upload",
8883
+ "--file",
8884
+ str(package["packagePath"]),
8885
+ "--change-key",
8886
+ effective_change_key,
8449
8887
  "--yes",
8450
8888
  "--non-interactive",
8451
8889
  "--json",
8452
8890
  ]
8891
+ command.extend(["--server-url", str(credentials["serverUrl"])])
8892
+ if credentials["tokenEnv"] is not None:
8893
+ command.extend(["--token-env", str(credentials["tokenEnv"])])
8453
8894
  try:
8454
8895
  completed = subprocess.run(
8455
8896
  command,
@@ -8461,37 +8902,174 @@ def auto_push_archive_core(project_root: Path, archive_dir: Path) -> dict[str, A
8461
8902
  shell=False,
8462
8903
  )
8463
8904
  except (OSError, subprocess.TimeoutExpired) as exc:
8905
+ reason_code = "ARCHIVE_UPLOAD_DEFERRED"
8906
+ try:
8907
+ persist_receipt(upload_status="pending", reason_code=reason_code)
8908
+ except OSError:
8909
+ pass
8464
8910
  return {
8465
- "skipped": False,
8466
- "ok": False,
8467
- "paths": core_paths,
8468
- "pending": str(pending_path),
8469
- "warning": f"archive auto-push deferred: {exc}",
8911
+ **base_result,
8912
+ "reasonCode": reason_code,
8913
+ "warning": f"归档自动上传已延后:{exc}",
8914
+ }
8915
+
8916
+ try:
8917
+ cli_payload = json.loads(completed.stdout)
8918
+ except (json.JSONDecodeError, TypeError):
8919
+ cli_payload = None
8920
+ if completed.returncode != 0:
8921
+ reason_code = "ARCHIVE_UPLOAD_COMMAND_FAILED"
8922
+ if isinstance(cli_payload, dict):
8923
+ errors = cli_payload.get("errors")
8924
+ if isinstance(errors, list) and errors and isinstance(errors[0], dict):
8925
+ cli_code = errors[0].get("code")
8926
+ if (
8927
+ isinstance(cli_code, str)
8928
+ and re.fullmatch(r"[A-Z][A-Z0-9_]{0,79}", cli_code.strip())
8929
+ is not None
8930
+ ):
8931
+ reason_code = cli_code.strip()
8932
+ try:
8933
+ persist_receipt(
8934
+ upload_status="pending",
8935
+ reason_code=reason_code,
8936
+ exit_code=completed.returncode,
8937
+ )
8938
+ except OSError:
8939
+ pass
8940
+ result = {
8941
+ **base_result,
8942
+ "exitCode": completed.returncode,
8943
+ "reasonCode": reason_code,
8944
+ "warning": (
8945
+ f"归档自动上传失败(退出码 {completed.returncode});"
8946
+ "已保留 ZIP 与回执,可重试同一个包。"
8947
+ ),
8470
8948
  }
8471
- ok = completed.returncode == 0
8472
- result: dict[str, Any] = {
8473
- "skipped": False,
8474
- "ok": ok,
8475
- "paths": core_paths,
8476
- "pending": str(pending_path),
8477
- "exitCode": completed.returncode,
8478
- }
8479
- if not ok:
8480
- result["warning"] = (
8481
- "archive auto-push failed (exit "
8482
- + str(completed.returncode)
8483
- + "); pending receipt kept for retry via `hunter-harness push`"
8484
- )
8485
8949
  if completed.stderr.strip():
8486
8950
  result["stderr"] = completed.stderr.strip()[:500]
8487
- else:
8951
+ return result
8952
+
8953
+ if not isinstance(cli_payload, dict):
8954
+ reason_code = "ARCHIVE_UPLOAD_RECEIPT_INVALID"
8488
8955
  try:
8489
- pending_path.unlink(missing_ok=True)
8956
+ persist_receipt(
8957
+ upload_status="pending",
8958
+ reason_code=reason_code,
8959
+ exit_code=completed.returncode,
8960
+ )
8490
8961
  except OSError:
8491
8962
  pass
8963
+ return {
8964
+ **base_result,
8965
+ "exitCode": completed.returncode,
8966
+ "reasonCode": reason_code,
8967
+ "warning": "归档上传命令未返回可核验的 JSON 收据;已保留 ZIP 与回执。",
8968
+ }
8969
+
8970
+ archive_status = cli_payload.get("archive_status")
8971
+ knowledge_status = cli_payload.get("knowledge_status")
8972
+ if archive_status != "durable" or knowledge_status not in {
8973
+ "indexing",
8974
+ "ready",
8975
+ "failed",
8976
+ }:
8977
+ reason_code = "ARCHIVE_UPLOAD_RECEIPT_INVALID"
8978
+ try:
8979
+ persist_receipt(
8980
+ upload_status="pending",
8981
+ reason_code=reason_code,
8982
+ archive_status=(
8983
+ archive_status if isinstance(archive_status, str) else None
8984
+ ),
8985
+ knowledge_status=(
8986
+ knowledge_status if isinstance(knowledge_status, str) else None
8987
+ ),
8988
+ exit_code=completed.returncode,
8989
+ )
8990
+ except OSError:
8991
+ pass
8992
+ return {
8993
+ **base_result,
8994
+ "exitCode": completed.returncode,
8995
+ "reasonCode": reason_code,
8996
+ "warning": "归档上传收据缺少有效的耐久或知识状态;已保留 ZIP 与回执。",
8997
+ }
8998
+
8999
+ fully_ready = archive_status == "durable" and knowledge_status == "ready"
9000
+ if fully_ready:
9001
+ upload_status = "ready"
9002
+ reason_code = None
9003
+ elif knowledge_status == "failed":
9004
+ upload_status = "failed"
9005
+ reason_code = "ARCHIVE_KNOWLEDGE_INDEX_FAILED"
9006
+ else:
9007
+ upload_status = "pending"
9008
+ reason_code = "ARCHIVE_KNOWLEDGE_INDEXING"
9009
+ try:
9010
+ persist_receipt(
9011
+ upload_status=upload_status,
9012
+ reason_code=reason_code,
9013
+ archive_status=archive_status,
9014
+ knowledge_status=knowledge_status,
9015
+ exit_code=completed.returncode,
9016
+ )
9017
+ except OSError as exc:
9018
+ return {
9019
+ **base_result,
9020
+ "archiveId": cli_payload.get("archive_id"),
9021
+ "archiveStatus": archive_status,
9022
+ "knowledgeStatus": knowledge_status,
9023
+ "reasonCode": "ARCHIVE_UPLOAD_RECEIPT_WRITE_FAILED",
9024
+ "warning": f"服务端已返回状态,但本地上传回执更新失败:{exc}",
9025
+ }
9026
+
9027
+ result = {
9028
+ **base_result,
9029
+ "ok": True,
9030
+ "uploadStatus": upload_status,
9031
+ "exitCode": completed.returncode,
9032
+ "archiveId": cli_payload.get("archive_id"),
9033
+ "archiveStatus": archive_status,
9034
+ "knowledgeStatus": knowledge_status,
9035
+ "reasonCode": reason_code,
9036
+ }
9037
+ if fully_ready:
9038
+ cleanup_warnings: list[str] = []
9039
+ for path in (package_path, pending_path):
9040
+ try:
9041
+ path.unlink(missing_ok=True)
9042
+ except OSError as exc:
9043
+ cleanup_warnings.append(f"{path.name}: {exc}")
9044
+ if cleanup_warnings:
9045
+ result["warning"] = (
9046
+ "远端归档与知识均已就绪,但本地重试文件清理不完整:"
9047
+ + "; ".join(cleanup_warnings)
9048
+ )
9049
+ elif knowledge_status == "failed":
9050
+ result["warning"] = (
9051
+ "归档原包已在服务端持久化,但知识索引失败;"
9052
+ "已保留 ZIP 与上传回执,可重试同一个 ZIP。"
9053
+ )
9054
+ else:
9055
+ result["warning"] = (
9056
+ "归档原包已在服务端持久化,知识索引仍在进行;"
9057
+ "已保留 ZIP 与上传回执,可枚举后重试。"
9058
+ )
8492
9059
  return result
8493
9060
 
8494
9061
 
9062
+ def _knowledge_maintenance_from_archive_push(push_result: dict[str, Any]) -> str:
9063
+ if (
9064
+ push_result.get("archiveStatus") == "durable"
9065
+ and push_result.get("knowledgeStatus") == "ready"
9066
+ ):
9067
+ return "REMOTE_READY"
9068
+ if push_result.get("knowledgeStatus") == "failed":
9069
+ return "REMOTE_INDEX_FAILED"
9070
+ return "REMOTE_PENDING"
9071
+
9072
+
8495
9073
  def _merge_runtime_state(state_dir: Path, contract_dir: Path) -> None:
8496
9074
  """Materialize split-v1 dynamic state into the archive contract tree."""
8497
9075
  for source in sorted(state_dir.iterdir()):