@hunter-harness/workflow-harness 0.2.50 → 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 (218) 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 +6 -6
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +42 -258
  6. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +38 -120
  7. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +4 -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 +2 -2
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +2 -2
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +7 -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 +2 -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 +748 -200
  18. package/harness/bundles/general/claude-code/scripts/harness_profile.py +0 -1
  19. package/harness/bundles/general/claude-code/scripts/harness_state.py +3 -5
  20. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  21. package/harness/bundles/general/codebuddy/README.md +4 -4
  22. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +6 -6
  23. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +39 -244
  25. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +35 -106
  26. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +4 -4
  27. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +2 -2
  28. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +2 -2
  29. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +2 -2
  30. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +8 -8
  32. package/harness/bundles/general/codebuddy/harness-sync/reference.md +18 -11
  33. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +2 -2
  34. package/harness/bundles/general/codebuddy/protocols/archive-report-protocol.md +5 -7
  35. package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +17 -11
  36. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +748 -200
  37. package/harness/bundles/general/codebuddy/scripts/harness_profile.py +0 -1
  38. package/harness/bundles/general/codebuddy/scripts/harness_state.py +3 -5
  39. package/harness/bundles/general/codex/.harness-build.json +1 -1
  40. package/harness/bundles/general/codex/README.md +4 -4
  41. package/harness/bundles/general/codex/harness-archive/SKILL.md +6 -6
  42. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +39 -244
  44. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +35 -106
  45. package/harness/bundles/general/codex/harness-plan/SKILL.md +4 -4
  46. package/harness/bundles/general/codex/harness-plan/checklist.md +2 -2
  47. package/harness/bundles/general/codex/harness-review/SKILL.md +2 -2
  48. package/harness/bundles/general/codex/harness-run/SKILL.md +2 -2
  49. package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
  50. package/harness/bundles/general/codex/harness-sync/SKILL.md +8 -8
  51. package/harness/bundles/general/codex/harness-sync/reference.md +18 -11
  52. package/harness/bundles/general/codex/harness-test/SKILL.md +2 -2
  53. package/harness/bundles/general/codex/protocols/archive-report-protocol.md +5 -7
  54. package/harness/bundles/general/codex/protocols/state-layout-protocol.md +17 -11
  55. package/harness/bundles/general/codex/scripts/harness_archive.py +748 -200
  56. package/harness/bundles/general/codex/scripts/harness_profile.py +0 -1
  57. package/harness/bundles/general/codex/scripts/harness_state.py +3 -5
  58. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  59. package/harness/bundles/general/cursor/README.md +4 -4
  60. package/harness/bundles/general/cursor/harness-archive/SKILL.md +6 -6
  61. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +39 -244
  63. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +35 -106
  64. package/harness/bundles/general/cursor/harness-plan/SKILL.md +4 -4
  65. package/harness/bundles/general/cursor/harness-plan/checklist.md +2 -2
  66. package/harness/bundles/general/cursor/harness-review/SKILL.md +2 -2
  67. package/harness/bundles/general/cursor/harness-run/SKILL.md +2 -2
  68. package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
  69. package/harness/bundles/general/cursor/harness-sync/SKILL.md +8 -8
  70. package/harness/bundles/general/cursor/harness-sync/reference.md +18 -11
  71. package/harness/bundles/general/cursor/harness-test/SKILL.md +2 -2
  72. package/harness/bundles/general/cursor/protocols/archive-report-protocol.md +5 -7
  73. package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +17 -11
  74. package/harness/bundles/general/cursor/scripts/harness_archive.py +748 -200
  75. package/harness/bundles/general/cursor/scripts/harness_profile.py +0 -1
  76. package/harness/bundles/general/cursor/scripts/harness_state.py +3 -5
  77. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  78. package/harness/bundles/java/claude-code/README.md +4 -4
  79. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +2 -2
  80. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +6 -6
  81. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  82. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +42 -258
  83. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +38 -120
  84. package/harness/bundles/java/claude-code/harness-package/SKILL.md +2 -2
  85. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +4 -4
  86. package/harness/bundles/java/claude-code/harness-plan/checklist.md +2 -2
  87. package/harness/bundles/java/claude-code/harness-review/SKILL.md +2 -2
  88. package/harness/bundles/java/claude-code/harness-run/SKILL.md +2 -2
  89. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +2 -2
  90. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +7 -7
  91. package/harness/bundles/java/claude-code/harness-sync/reference.md +18 -11
  92. package/harness/bundles/java/claude-code/harness-test/SKILL.md +2 -2
  93. package/harness/bundles/java/claude-code/protocols/archive-report-protocol.md +5 -7
  94. package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +17 -11
  95. package/harness/bundles/java/claude-code/scripts/harness_archive.py +748 -200
  96. package/harness/bundles/java/claude-code/scripts/harness_profile.py +0 -1
  97. package/harness/bundles/java/claude-code/scripts/harness_state.py +3 -5
  98. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  99. package/harness/bundles/java/codebuddy/README.md +4 -4
  100. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +2 -2
  101. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +6 -6
  102. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  103. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +39 -244
  104. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +35 -106
  105. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
  106. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +4 -4
  107. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +2 -2
  108. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +2 -2
  109. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +2 -2
  110. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +2 -2
  111. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +8 -8
  112. package/harness/bundles/java/codebuddy/harness-sync/reference.md +18 -11
  113. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +2 -2
  114. package/harness/bundles/java/codebuddy/protocols/archive-report-protocol.md +5 -7
  115. package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +17 -11
  116. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +748 -200
  117. package/harness/bundles/java/codebuddy/scripts/harness_profile.py +0 -1
  118. package/harness/bundles/java/codebuddy/scripts/harness_state.py +3 -5
  119. package/harness/bundles/java/codex/.harness-build.json +1 -1
  120. package/harness/bundles/java/codex/README.md +4 -4
  121. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +2 -2
  122. package/harness/bundles/java/codex/harness-archive/SKILL.md +6 -6
  123. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  124. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +39 -244
  125. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +35 -106
  126. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
  127. package/harness/bundles/java/codex/harness-plan/SKILL.md +4 -4
  128. package/harness/bundles/java/codex/harness-plan/checklist.md +2 -2
  129. package/harness/bundles/java/codex/harness-review/SKILL.md +2 -2
  130. package/harness/bundles/java/codex/harness-run/SKILL.md +2 -2
  131. package/harness/bundles/java/codex/harness-submit/SKILL.md +2 -2
  132. package/harness/bundles/java/codex/harness-sync/SKILL.md +8 -8
  133. package/harness/bundles/java/codex/harness-sync/reference.md +18 -11
  134. package/harness/bundles/java/codex/harness-test/SKILL.md +2 -2
  135. package/harness/bundles/java/codex/protocols/archive-report-protocol.md +5 -7
  136. package/harness/bundles/java/codex/protocols/state-layout-protocol.md +17 -11
  137. package/harness/bundles/java/codex/scripts/harness_archive.py +748 -200
  138. package/harness/bundles/java/codex/scripts/harness_profile.py +0 -1
  139. package/harness/bundles/java/codex/scripts/harness_state.py +3 -5
  140. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  141. package/harness/bundles/java/cursor/README.md +4 -4
  142. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +2 -2
  143. package/harness/bundles/java/cursor/harness-archive/SKILL.md +6 -6
  144. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  145. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +39 -244
  146. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +35 -106
  147. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
  148. package/harness/bundles/java/cursor/harness-plan/SKILL.md +4 -4
  149. package/harness/bundles/java/cursor/harness-plan/checklist.md +2 -2
  150. package/harness/bundles/java/cursor/harness-review/SKILL.md +2 -2
  151. package/harness/bundles/java/cursor/harness-run/SKILL.md +2 -2
  152. package/harness/bundles/java/cursor/harness-submit/SKILL.md +2 -2
  153. package/harness/bundles/java/cursor/harness-sync/SKILL.md +8 -8
  154. package/harness/bundles/java/cursor/harness-sync/reference.md +18 -11
  155. package/harness/bundles/java/cursor/harness-test/SKILL.md +2 -2
  156. package/harness/bundles/java/cursor/protocols/archive-report-protocol.md +5 -7
  157. package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +17 -11
  158. package/harness/bundles/java/cursor/scripts/harness_archive.py +748 -200
  159. package/harness/bundles/java/cursor/scripts/harness_profile.py +0 -1
  160. package/harness/bundles/java/cursor/scripts/harness_state.py +3 -5
  161. package/harness/manifests/general/claude-code.json +21 -45
  162. package/harness/manifests/general/codebuddy.json +21 -45
  163. package/harness/manifests/general/codex.json +21 -45
  164. package/harness/manifests/general/cursor.json +21 -45
  165. package/harness/manifests/java/claude-code.json +23 -47
  166. package/harness/manifests/java/codebuddy.json +23 -47
  167. package/harness/manifests/java/codex.json +23 -47
  168. package/harness/manifests/java/cursor.json +23 -47
  169. package/hunter-workflow-family.json +4 -4
  170. package/package.json +1 -1
  171. package/harness/bundles/general/claude-code/harness-knowledge-ingest/design.md +0 -842
  172. package/harness/bundles/general/claude-code/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  173. package/harness/bundles/general/claude-code/harness-knowledge-ingest/mcp-config.example.json +0 -10
  174. package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +0 -413
  175. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  176. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  177. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/design.md +0 -842
  178. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  179. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/mcp-config.example.json +0 -10
  180. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +0 -413
  181. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  182. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  183. package/harness/bundles/general/codex/harness-knowledge-ingest/design.md +0 -842
  184. package/harness/bundles/general/codex/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  185. package/harness/bundles/general/codex/harness-knowledge-ingest/mcp-config.example.json +0 -10
  186. package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +0 -413
  187. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  188. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  189. package/harness/bundles/general/cursor/harness-knowledge-ingest/design.md +0 -842
  190. package/harness/bundles/general/cursor/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  191. package/harness/bundles/general/cursor/harness-knowledge-ingest/mcp-config.example.json +0 -10
  192. package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +0 -413
  193. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  194. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  195. package/harness/bundles/java/claude-code/harness-knowledge-ingest/design.md +0 -842
  196. package/harness/bundles/java/claude-code/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  197. package/harness/bundles/java/claude-code/harness-knowledge-ingest/mcp-config.example.json +0 -10
  198. package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +0 -413
  199. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  200. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  201. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/design.md +0 -842
  202. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  203. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/mcp-config.example.json +0 -10
  204. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +0 -413
  205. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  206. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  207. package/harness/bundles/java/codex/harness-knowledge-ingest/design.md +0 -842
  208. package/harness/bundles/java/codex/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  209. package/harness/bundles/java/codex/harness-knowledge-ingest/mcp-config.example.json +0 -10
  210. package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +0 -413
  211. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  212. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +0 -275
  213. package/harness/bundles/java/cursor/harness-knowledge-ingest/design.md +0 -842
  214. package/harness/bundles/java/cursor/harness-knowledge-ingest/evaluations/harness_knowledge_evaluation.xml +0 -42
  215. package/harness/bundles/java/cursor/harness-knowledge-ingest/mcp-config.example.json +0 -10
  216. package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +0 -413
  217. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +0 -7263
  218. 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.
@@ -1924,38 +1924,12 @@ def worktree_requested(change_dir: Path) -> bool:
1924
1924
 
1925
1925
 
1926
1926
  def check_archive_exact_byte_policy(project_root: Path) -> dict[str, Any]:
1927
- """Verify frozen archive paths are exempt from Git text conversion."""
1928
- required_rule = ".harness/archive/** -text"
1929
- attributes_path = project_root / ".gitattributes"
1930
- rules: list[str] = []
1931
- if attributes_path.is_file():
1932
- try:
1933
- rules = [
1934
- line.strip()
1935
- for line in attributes_path.read_text(encoding="utf-8-sig").splitlines()
1936
- if line.strip() and not line.lstrip().startswith("#")
1937
- ]
1938
- except OSError:
1939
- rules = []
1940
- matching = []
1941
- for rule in rules:
1942
- fields = rule.split()
1943
- if not fields or fields[0] != ".harness/archive/**":
1944
- continue
1945
- matching.append(rule)
1946
- if "-text" in fields[1:] or "binary" in fields[1:]:
1947
- return {
1948
- "ok": True,
1949
- "path": str(attributes_path),
1950
- "requiredRule": required_rule,
1951
- "matchedRule": rule,
1952
- }
1927
+ """Exact bytes are guaranteed by ZIP entry hashes, independent of Git."""
1953
1928
  return {
1954
- "ok": False,
1955
- "path": str(attributes_path),
1956
- "requiredRule": required_rule,
1957
- "matchedRules": matching,
1958
- "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",
1959
1933
  }
1960
1934
 
1961
1935
 
@@ -7001,79 +6975,6 @@ def materialize_repository_artifacts(change_dir: Path) -> dict[str, Any]:
7001
6975
  return result
7002
6976
 
7003
6977
 
7004
- def run_knowledge_poststeps(project_root: Path) -> dict[str, Any]:
7005
- results: dict[str, Any] = {"ran": False, "steps": [], "warnings": []}
7006
- if not KNOWLEDGE_SCRIPT.is_file():
7007
- results["warnings"].append(f"harness_knowledge.py not found: {KNOWLEDGE_SCRIPT}")
7008
- return results
7009
- results["ran"] = True
7010
- for cmd in ("ingest", "dedupe", "auto-supersede", "reverify-stale"):
7011
- try:
7012
- proc = subprocess.run(
7013
- [
7014
- sys.executable,
7015
- str(KNOWLEDGE_SCRIPT),
7016
- cmd,
7017
- "--project",
7018
- str(project_root),
7019
- "--json",
7020
- ],
7021
- capture_output=True,
7022
- text=True,
7023
- encoding="utf-8",
7024
- errors="replace",
7025
- timeout=300,
7026
- check=False,
7027
- )
7028
- step = {
7029
- "command": cmd,
7030
- "exit_code": proc.returncode,
7031
- "ok": proc.returncode == 0,
7032
- }
7033
- if proc.returncode != 0:
7034
- step["stderr"] = (proc.stderr or proc.stdout or "")[:500]
7035
- results["warnings"].append(f"knowledge {cmd} exit {proc.returncode}")
7036
- results["steps"].append(step)
7037
- except (OSError, subprocess.TimeoutExpired) as exc:
7038
- results["steps"].append({"command": cmd, "ok": False, "error": str(exc)})
7039
- results["warnings"].append(f"knowledge {cmd} failed: {exc}")
7040
- return results
7041
-
7042
-
7043
- def enqueue_maintenance_outbox(project_root: Path, archive_dir: Path) -> dict[str, Any]:
7044
- """§8.2: write a pending maintenance-outbox item instead of synchronously
7045
- running the four knowledge subprocesses. Never rolls back the archive."""
7046
- pending_dir = (
7047
- project_root / ".harness" / "knowledge" / "maintenance-outbox" / "pending"
7048
- )
7049
- pending_dir.mkdir(parents=True, exist_ok=True)
7050
- archive_id = archive_dir.name
7051
- manifest = archive_dir / "evidence" / "archive-manifest-after.json"
7052
- manifest_hash = "sha256:" + sha256_file(manifest) if manifest.is_file() else ""
7053
- try:
7054
- rel = archive_dir.resolve().relative_to(project_root.resolve()).as_posix()
7055
- except ValueError:
7056
- rel = str(archive_dir)
7057
- item = {
7058
- "schemaVersion": 1,
7059
- "archiveId": archive_id,
7060
- "archivePath": rel,
7061
- "archiveManifestHash": manifest_hash,
7062
- "status": "pending",
7063
- "attempts": 0,
7064
- "createdAt": now_iso(),
7065
- "lastError": None,
7066
- }
7067
- item_path = pending_dir / f"{archive_id}.json"
7068
- write_json(item_path, item)
7069
- return {
7070
- "queued": True,
7071
- "outboxPath": str(item_path),
7072
- "archiveId": archive_id,
7073
- "status": "pending",
7074
- }
7075
-
7076
-
7077
6978
  def run_service_stop(change_dir: Path) -> dict[str, Any]:
7078
6979
  if not SERVICE_SCRIPT.is_file():
7079
6980
  return {
@@ -8325,19 +8226,15 @@ def cmd_finalize(
8325
8226
  warnings.append(f"could not remove archived runtime state: {exc}")
8326
8227
  payload["steps"]["delete_runtime_state"] = {"ok": False, "error": str(exc)}
8327
8228
 
8328
- # --- 12. maintenance outbox + service ---
8329
- if skip_ingest:
8330
- payload["steps"]["knowledge"] = {"skipped": True, "reason": "--skip-ingest"}
8331
- payload["knowledgeMaintenance"] = "SKIPPED"
8332
- else:
8333
- try:
8334
- enqueue = enqueue_maintenance_outbox(project_root, work_dir)
8335
- payload["steps"]["knowledge"] = enqueue
8336
- payload["knowledgeMaintenance"] = "QUEUED"
8337
- except OSError as exc:
8338
- warnings.append(f"maintenance outbox enqueue failed: {exc}")
8339
- payload["steps"]["knowledge"] = {"queued": False, "error": str(exc)}
8340
- 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"
8341
8238
 
8342
8239
  service_result = run_service_stop(work_dir)
8343
8240
  payload["steps"]["service_stop"] = service_result
@@ -8365,10 +8262,16 @@ def cmd_finalize(
8365
8262
  except OSError as exc:
8366
8263
  warnings.append(f"could not persist completed archive operation: {exc}")
8367
8264
 
8368
- # P4: when remote credentials exist, auto-push the core four
8369
- # (design/plan/summary-data/knowledge) via the existing push channel.
8370
- 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
+ )
8371
8271
  payload["steps"]["archive_push"] = push_result
8272
+ payload["knowledgeMaintenance"] = _knowledge_maintenance_from_archive_push(
8273
+ push_result
8274
+ )
8372
8275
  if push_result.get("warning"):
8373
8276
  warnings.append(str(push_result["warning"]))
8374
8277
 
@@ -8397,89 +8300,597 @@ def cmd_finalize(
8397
8300
  return 0, payload
8398
8301
 
8399
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
+
8400
8421
  def _remote_credentials_configured(project_root: Path) -> bool:
8401
- creds = project_root / ".harness" / "credentials.local.yaml"
8402
- 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:
8403
8442
  return False
8404
- text = creds.read_text(encoding="utf-8", errors="ignore")
8405
- has_url = bool(re.search(r"(?m)^[ \t]*server_url:\s*\S+", text))
8406
- has_token = bool(re.search(r"(?m)^[ \t]*token:\s*\S+", text))
8407
- 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
8408
8567
 
8409
8568
 
8410
8569
  def collect_archive_core_paths(project_root: Path, archive_dir: Path) -> list[str]:
8411
- """Relative paths for the archive push allowlist (core + optional supporting)."""
8412
- root = project_root.resolve()
8413
- archive = archive_dir.resolve()
8414
- paths: list[str] = []
8415
-
8416
- def add_if_exists(absolute: Path) -> None:
8417
- if absolute.is_file():
8418
- paths.append(absolute.relative_to(root).as_posix())
8419
-
8420
- add_if_exists(archive / "reports" / "final" / "summary-data.json")
8421
- for folder in ("spec", "plans"):
8422
- base = archive / folder
8423
- if base.is_dir():
8424
- for path in sorted(base.rglob("*")):
8425
- if path.is_file():
8426
- paths.append(path.relative_to(root).as_posix())
8427
- # C1 optional supporting files.
8428
- for folder in (("reports", "review"), ("reports", "test")):
8429
- base = archive.joinpath(*folder)
8430
- if base.is_dir():
8431
- for path in sorted(base.rglob("*")):
8432
- if path.is_file():
8433
- paths.append(path.relative_to(root).as_posix())
8434
- add_if_exists(archive / "meta" / "archive-meta.md")
8435
- add_if_exists(archive / "meta" / "change-context.json")
8436
- knowledge = root / ".harness" / "knowledge" / "entries"
8437
- for status in ("active", "candidate"):
8438
- folder = knowledge / status
8439
- if folder.is_dir():
8440
- for path in sorted(folder.glob("*.json")):
8441
- paths.append(path.relative_to(root).as_posix())
8442
- return paths
8443
-
8444
-
8445
- def auto_push_archive_core(project_root: Path, archive_dir: Path) -> dict[str, Any]:
8446
- """Best-effort remote push of archive core artifacts after finalize.
8447
-
8448
- Skips silently when credentials are absent. Failures become warnings so
8449
- 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.
8450
8757
  """
8451
8758
  project_root = project_root.resolve()
8452
- if not _remote_credentials_configured(project_root):
8453
- return {"skipped": True, "reason": "no remote credentials"}
8454
- core_paths = collect_archive_core_paths(project_root, archive_dir)
8455
- 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] = {
8456
8799
  "schemaVersion": 1,
8457
8800
  "generatedAt": now_iso(),
8801
+ "updatedAt": now_iso(),
8458
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"],
8459
8808
  "paths": core_paths,
8809
+ "uploadStatus": "pending",
8810
+ "reasonCode": initial_reason_code,
8811
+ "archiveStatus": None,
8812
+ "knowledgeStatus": None,
8460
8813
  }
8461
- 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
+
8462
8835
  try:
8463
8836
  pending_path.parent.mkdir(parents=True, exist_ok=True)
8464
- write_json(pending_path, pending)
8837
+ persist_receipt(
8838
+ upload_status="pending",
8839
+ reason_code=initial_reason_code,
8840
+ )
8465
8841
  except OSError as exc:
8466
8842
  return {
8467
8843
  "skipped": False,
8468
8844
  "ok": False,
8469
8845
  "paths": core_paths,
8470
- "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}",
8471
8850
  }
8472
8851
 
8473
- # 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.
8474
8877
  command = [
8475
8878
  "npx",
8476
8879
  "--yes",
8477
8880
  "hunter-harness",
8478
- "push",
8881
+ "archive",
8882
+ "upload",
8883
+ "--file",
8884
+ str(package["packagePath"]),
8885
+ "--change-key",
8886
+ effective_change_key,
8479
8887
  "--yes",
8480
8888
  "--non-interactive",
8481
8889
  "--json",
8482
8890
  ]
8891
+ command.extend(["--server-url", str(credentials["serverUrl"])])
8892
+ if credentials["tokenEnv"] is not None:
8893
+ command.extend(["--token-env", str(credentials["tokenEnv"])])
8483
8894
  try:
8484
8895
  completed = subprocess.run(
8485
8896
  command,
@@ -8491,37 +8902,174 @@ def auto_push_archive_core(project_root: Path, archive_dir: Path) -> dict[str, A
8491
8902
  shell=False,
8492
8903
  )
8493
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
8494
8910
  return {
8495
- "skipped": False,
8496
- "ok": False,
8497
- "paths": core_paths,
8498
- "pending": str(pending_path),
8499
- "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
+ ),
8500
8948
  }
8501
- ok = completed.returncode == 0
8502
- result: dict[str, Any] = {
8503
- "skipped": False,
8504
- "ok": ok,
8505
- "paths": core_paths,
8506
- "pending": str(pending_path),
8507
- "exitCode": completed.returncode,
8508
- }
8509
- if not ok:
8510
- result["warning"] = (
8511
- "archive auto-push failed (exit "
8512
- + str(completed.returncode)
8513
- + "); pending receipt kept for retry via `hunter-harness push`"
8514
- )
8515
8949
  if completed.stderr.strip():
8516
8950
  result["stderr"] = completed.stderr.strip()[:500]
8517
- else:
8951
+ return result
8952
+
8953
+ if not isinstance(cli_payload, dict):
8954
+ reason_code = "ARCHIVE_UPLOAD_RECEIPT_INVALID"
8955
+ try:
8956
+ persist_receipt(
8957
+ upload_status="pending",
8958
+ reason_code=reason_code,
8959
+ exit_code=completed.returncode,
8960
+ )
8961
+ except OSError:
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"
8518
8978
  try:
8519
- pending_path.unlink(missing_ok=True)
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
+ )
8520
8990
  except OSError:
8521
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
+ )
8522
9059
  return result
8523
9060
 
8524
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
+
8525
9073
  def _merge_runtime_state(state_dir: Path, contract_dir: Path) -> None:
8526
9074
  """Materialize split-v1 dynamic state into the archive contract tree."""
8527
9075
  for source in sorted(state_dir.iterdir()):