@hunter-harness/workflow-harness 0.2.77 → 0.2.79

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 (194) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +17 -2
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
  7. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-push/SKILL.md +16 -6
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/protocols/powershell-protocol.md +20 -0
  15. package/harness/bundles/general/claude-code/protocols/sensitive-info-protocol.md +5 -0
  16. package/harness/bundles/general/claude-code/scripts/harness_archive.py +459 -46
  17. package/harness/bundles/general/claude-code/scripts/harness_check_gate.py +2 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_context.py +4 -0
  19. package/harness/bundles/general/claude-code/scripts/harness_fixback.py +102 -3
  20. package/harness/bundles/general/claude-code/scripts/harness_knowledge_candidates.py +262 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +4 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +82 -4
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +17 -2
  25. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +15 -5
  31. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  32. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  36. package/harness/bundles/general/codebuddy/protocols/powershell-protocol.md +20 -0
  37. package/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +5 -0
  38. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +459 -46
  39. package/harness/bundles/general/codebuddy/scripts/harness_check_gate.py +2 -0
  40. package/harness/bundles/general/codebuddy/scripts/harness_context.py +4 -0
  41. package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +102 -3
  42. package/harness/bundles/general/codebuddy/scripts/harness_knowledge_candidates.py +262 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +4 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +82 -4
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +17 -2
  47. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-push/SKILL.md +15 -5
  53. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  55. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/protocols/powershell-protocol.md +20 -0
  59. package/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +5 -0
  60. package/harness/bundles/general/codex/scripts/harness_archive.py +459 -46
  61. package/harness/bundles/general/codex/scripts/harness_check_gate.py +2 -0
  62. package/harness/bundles/general/codex/scripts/harness_context.py +4 -0
  63. package/harness/bundles/general/codex/scripts/harness_fixback.py +102 -3
  64. package/harness/bundles/general/codex/scripts/harness_knowledge_candidates.py +262 -0
  65. package/harness/bundles/general/codex/scripts/harness_ledger.py +4 -0
  66. package/harness/bundles/general/codex/scripts/harness_runtime.py +82 -4
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +17 -2
  69. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  73. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  74. package/harness/bundles/general/cursor/harness-push/SKILL.md +15 -5
  75. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  80. package/harness/bundles/general/cursor/protocols/powershell-protocol.md +20 -0
  81. package/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +5 -0
  82. package/harness/bundles/general/cursor/scripts/harness_archive.py +459 -46
  83. package/harness/bundles/general/cursor/scripts/harness_check_gate.py +2 -0
  84. package/harness/bundles/general/cursor/scripts/harness_context.py +4 -0
  85. package/harness/bundles/general/cursor/scripts/harness_fixback.py +102 -3
  86. package/harness/bundles/general/cursor/scripts/harness_knowledge_candidates.py +262 -0
  87. package/harness/bundles/general/cursor/scripts/harness_ledger.py +4 -0
  88. package/harness/bundles/general/cursor/scripts/harness_runtime.py +82 -4
  89. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  90. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +17 -2
  92. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  96. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  97. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  98. package/harness/bundles/java/claude-code/harness-push/SKILL.md +16 -6
  99. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/protocols/powershell-protocol.md +20 -0
  105. package/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +5 -0
  106. package/harness/bundles/java/claude-code/scripts/harness_archive.py +459 -46
  107. package/harness/bundles/java/claude-code/scripts/harness_check_gate.py +2 -0
  108. package/harness/bundles/java/claude-code/scripts/harness_context.py +4 -0
  109. package/harness/bundles/java/claude-code/scripts/harness_fixback.py +102 -3
  110. package/harness/bundles/java/claude-code/scripts/harness_knowledge_candidates.py +262 -0
  111. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +4 -0
  112. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +82 -4
  113. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  114. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +17 -2
  116. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  117. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  118. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  119. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  120. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  121. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  122. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +15 -5
  123. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/protocols/powershell-protocol.md +20 -0
  129. package/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +5 -0
  130. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +459 -46
  131. package/harness/bundles/java/codebuddy/scripts/harness_check_gate.py +2 -0
  132. package/harness/bundles/java/codebuddy/scripts/harness_context.py +4 -0
  133. package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +102 -3
  134. package/harness/bundles/java/codebuddy/scripts/harness_knowledge_candidates.py +262 -0
  135. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +4 -0
  136. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +82 -4
  137. package/harness/bundles/java/codex/.harness-build.json +1 -1
  138. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  139. package/harness/bundles/java/codex/harness-archive/SKILL.md +17 -2
  140. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  141. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  142. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  143. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  144. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  145. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-push/SKILL.md +15 -5
  147. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  149. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/protocols/powershell-protocol.md +20 -0
  153. package/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +5 -0
  154. package/harness/bundles/java/codex/scripts/harness_archive.py +459 -46
  155. package/harness/bundles/java/codex/scripts/harness_check_gate.py +2 -0
  156. package/harness/bundles/java/codex/scripts/harness_context.py +4 -0
  157. package/harness/bundles/java/codex/scripts/harness_fixback.py +102 -3
  158. package/harness/bundles/java/codex/scripts/harness_knowledge_candidates.py +262 -0
  159. package/harness/bundles/java/codex/scripts/harness_ledger.py +4 -0
  160. package/harness/bundles/java/codex/scripts/harness_runtime.py +82 -4
  161. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  162. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  163. package/harness/bundles/java/cursor/harness-archive/SKILL.md +17 -2
  164. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  165. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  166. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  168. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  169. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  170. package/harness/bundles/java/cursor/harness-push/SKILL.md +15 -5
  171. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  172. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  173. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  175. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/protocols/powershell-protocol.md +20 -0
  177. package/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +5 -0
  178. package/harness/bundles/java/cursor/scripts/harness_archive.py +459 -46
  179. package/harness/bundles/java/cursor/scripts/harness_check_gate.py +2 -0
  180. package/harness/bundles/java/cursor/scripts/harness_context.py +4 -0
  181. package/harness/bundles/java/cursor/scripts/harness_fixback.py +102 -3
  182. package/harness/bundles/java/cursor/scripts/harness_knowledge_candidates.py +262 -0
  183. package/harness/bundles/java/cursor/scripts/harness_ledger.py +4 -0
  184. package/harness/bundles/java/cursor/scripts/harness_runtime.py +82 -4
  185. package/harness/manifests/general/claude-code.json +27 -23
  186. package/harness/manifests/general/codebuddy.json +27 -23
  187. package/harness/manifests/general/codex.json +27 -23
  188. package/harness/manifests/general/cursor.json +27 -23
  189. package/harness/manifests/java/claude-code.json +29 -25
  190. package/harness/manifests/java/codebuddy.json +29 -25
  191. package/harness/manifests/java/codex.json +29 -25
  192. package/harness/manifests/java/cursor.json +29 -25
  193. package/hunter-workflow-family.json +5 -5
  194. package/package.json +1 -1
@@ -91,6 +91,7 @@ import harness_events as he # noqa: E402
91
91
  import harness_events_sync as hes # noqa: E402
92
92
  import harness_efficiency as heff # noqa: E402
93
93
  import harness_gate as hgate # noqa: E402
94
+ import harness_knowledge_candidates as hkc # noqa: E402
94
95
  import harness_ledger as hl # noqa: E402
95
96
  import harness_paths as hp # noqa: E402
96
97
  import harness_phase as hphase # noqa: E402
@@ -258,9 +259,13 @@ def append_event(
258
259
  def generate_manifest(root: Path, output_path: Path) -> dict[str, Any]:
259
260
  """Build path/size/sha256 manifest; exclude the output file itself."""
260
261
  root = root.resolve()
261
- exclude: Path | None = None
262
- if output_path.exists():
263
- exclude = output_path.resolve()
262
+ # Compare by relative path, not by resolve() per file: on Windows every
263
+ # resolve() is a realpath syscall, and this loop runs over the whole tree.
264
+ exclude_rel: str | None = None
265
+ try:
266
+ exclude_rel = output_path.resolve().relative_to(root).as_posix()
267
+ except (OSError, ValueError):
268
+ exclude_rel = None
264
269
 
265
270
  files: list[dict[str, Any]] = []
266
271
  total_bytes = 0
@@ -269,9 +274,9 @@ def generate_manifest(root: Path, output_path: Path) -> dict[str, Any]:
269
274
  continue
270
275
  if path.name.endswith(".lock"):
271
276
  continue
272
- if exclude is not None and path.resolve() == exclude:
273
- continue
274
277
  rel = path.relative_to(root).as_posix()
278
+ if exclude_rel is not None and rel == exclude_rel:
279
+ continue
275
280
  size = path.stat().st_size
276
281
  total_bytes += size
277
282
  files.append(
@@ -2112,6 +2117,56 @@ def check_archive_exact_byte_policy(project_root: Path) -> dict[str, Any]:
2112
2117
  }
2113
2118
 
2114
2119
 
2120
+ SENSITIVE_SCAN_POLICY_ENV = "HUNTER_HARNESS_SENSITIVE_SCAN"
2121
+ DEFAULT_SENSITIVE_SCAN_POLICY = "warn"
2122
+
2123
+
2124
+ def resolve_sensitive_scan_policy(env: dict[str, str] | None = None) -> str:
2125
+ """Publication-gate policy: ``off`` | ``warn`` | ``block`` (default warn).
2126
+
2127
+ Mirrors resolveSensitiveScanPolicy() in packages/core so the archive gate
2128
+ and the push gate answer to the same switch.
2129
+ """
2130
+ source = os.environ if env is None else env
2131
+ raw = str(source.get(SENSITIVE_SCAN_POLICY_ENV) or "").strip().lower()
2132
+ if raw in {"off", "false", "0", "disabled"}:
2133
+ return "off"
2134
+ if raw in {"warn", "advisory"}:
2135
+ return "warn"
2136
+ if raw in {"block", "true", "1", "enforce"}:
2137
+ return "block"
2138
+ return DEFAULT_SENSITIVE_SCAN_POLICY
2139
+
2140
+
2141
+ def _advisory_sensitive_gate(result: dict[str, Any], policy: str) -> dict[str, Any]:
2142
+ """Downgrade a failed gate to advisory under a non-blocking policy.
2143
+
2144
+ The facts survive verbatim under ``advisory`` so the archive report still
2145
+ names every plaintext file; only the veto is dropped. A fail-closed gate
2146
+ with no proportionate setting gets worked around rather than respected.
2147
+ """
2148
+ if result.get("ok") or policy == "block":
2149
+ return result
2150
+ return {
2151
+ "ok": True,
2152
+ "reasonCode": "SENSITIVE_EVIDENCE_ADVISORY",
2153
+ "policy": policy,
2154
+ "receiptPath": result.get("receiptPath"),
2155
+ "unresolvedFailures": [],
2156
+ "advisory": {
2157
+ key: value
2158
+ for key, value in result.items()
2159
+ if key not in {"ok", "policy"}
2160
+ },
2161
+ "nextAction": (
2162
+ "Findings are advisory under "
2163
+ f"{SENSITIVE_SCAN_POLICY_ENV}={policy}; set it to `block` to veto "
2164
+ "publication, or quarantine with `harness_runtime.py "
2165
+ "quarantine-evidence`."
2166
+ ),
2167
+ }
2168
+
2169
+
2115
2170
  def validate_sensitive_evidence_publication_gate(
2116
2171
  change_dir: Path,
2117
2172
  *,
@@ -2128,24 +2183,42 @@ def validate_sensitive_evidence_publication_gate(
2128
2183
  """
2129
2184
  change_dir = change_dir.expanduser().resolve()
2130
2185
  copy_root = (copy_root or change_dir).expanduser().resolve()
2186
+ policy = resolve_sensitive_scan_policy()
2131
2187
  receipt_path = hruntime.sensitive_evidence_receipt_path(change_dir)
2132
- candidates = hruntime.sensitive_evidence_candidates(change_dir)
2133
- if candidates:
2188
+ if policy == "off":
2189
+ # Skipping the scan also skips a full-tree byte read of the change dir.
2134
2190
  return {
2135
- "ok": False,
2136
- "reasonCode": "SENSITIVE_EVIDENCE_UNQUARANTINED",
2191
+ "ok": True,
2192
+ "reasonCode": "SECRET_SCAN_DISABLED",
2193
+ "policy": policy,
2137
2194
  "receiptPath": str(receipt_path),
2138
- "unresolvedFailures": candidates,
2139
- "nextAction": "Quarantine the original bytes before archive publication.",
2195
+ "unresolvedFailures": [],
2140
2196
  }
2141
- if receipt_override is None and not receipt_path.is_file():
2142
- if require_receipt:
2143
- return {
2197
+ candidates = hruntime.sensitive_evidence_candidates(change_dir)
2198
+ if candidates:
2199
+ return _advisory_sensitive_gate(
2200
+ {
2144
2201
  "ok": False,
2145
- "reasonCode": "SECRET_SCAN_RECEIPT_MISSING",
2202
+ "reasonCode": "SENSITIVE_EVIDENCE_UNQUARANTINED",
2146
2203
  "receiptPath": str(receipt_path),
2147
- "unresolvedFailures": [],
2148
- }
2204
+ "unresolvedFailures": candidates,
2205
+ "nextAction": (
2206
+ "Quarantine the original bytes before archive publication."
2207
+ ),
2208
+ },
2209
+ policy,
2210
+ )
2211
+ if receipt_override is None and not receipt_path.is_file():
2212
+ if require_receipt:
2213
+ return _advisory_sensitive_gate(
2214
+ {
2215
+ "ok": False,
2216
+ "reasonCode": "SECRET_SCAN_RECEIPT_MISSING",
2217
+ "receiptPath": str(receipt_path),
2218
+ "unresolvedFailures": [],
2219
+ },
2220
+ policy,
2221
+ )
2149
2222
  return {
2150
2223
  "ok": True,
2151
2224
  "reasonCode": "SECRET_SCAN_NOT_APPLICABLE",
@@ -2157,21 +2230,27 @@ def validate_sensitive_evidence_publication_gate(
2157
2230
  try:
2158
2231
  receipt = read_json(receipt_path)
2159
2232
  except (OSError, json.JSONDecodeError, TypeError) as exc:
2160
- return {
2161
- "ok": False,
2162
- "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
2163
- "receiptPath": str(receipt_path),
2164
- "error": str(exc),
2165
- }
2233
+ return _advisory_sensitive_gate(
2234
+ {
2235
+ "ok": False,
2236
+ "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
2237
+ "receiptPath": str(receipt_path),
2238
+ "error": str(exc),
2239
+ },
2240
+ policy,
2241
+ )
2166
2242
  else:
2167
2243
  receipt = receipt_override
2168
2244
  if not isinstance(receipt, dict):
2169
- return {
2170
- "ok": False,
2171
- "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
2172
- "receiptPath": str(receipt_path),
2173
- "error": "receipt must be an object",
2174
- }
2245
+ return _advisory_sensitive_gate(
2246
+ {
2247
+ "ok": False,
2248
+ "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
2249
+ "receiptPath": str(receipt_path),
2250
+ "error": "receipt must be an object",
2251
+ },
2252
+ policy,
2253
+ )
2175
2254
  issues: list[dict[str, Any]] = []
2176
2255
  if receipt.get("schemaVersion") != 1:
2177
2256
  issues.append({"code": "SECRET_SCAN_SCHEMA_INVALID"})
@@ -2231,14 +2310,19 @@ def validate_sensitive_evidence_publication_gate(
2231
2310
  "code": "SENSITIVE_EVIDENCE_SOURCE_REMAINS",
2232
2311
  "sourcePath": source_raw,
2233
2312
  })
2234
- return {
2235
- "ok": not issues,
2236
- "reasonCode": "SECRET_SCAN_GATE_SATISFIED" if not issues else "SECRET_SCAN_GATE_BLOCKED",
2237
- "receiptPath": str(receipt_path),
2238
- "receipt": receipt,
2239
- "issues": issues,
2240
- "treeDigest": actual_digest,
2241
- }
2313
+ return _advisory_sensitive_gate(
2314
+ {
2315
+ "ok": not issues,
2316
+ "reasonCode": (
2317
+ "SECRET_SCAN_GATE_SATISFIED" if not issues else "SECRET_SCAN_GATE_BLOCKED"
2318
+ ),
2319
+ "receiptPath": str(receipt_path),
2320
+ "receipt": receipt,
2321
+ "issues": issues,
2322
+ "treeDigest": actual_digest,
2323
+ },
2324
+ policy,
2325
+ )
2242
2326
 
2243
2327
 
2244
2328
  # ---------------------------------------------------------------------------
@@ -4173,6 +4257,33 @@ def write_archive_meta(work_dir: Path, summary: dict[str, Any]) -> Path:
4173
4257
  return out
4174
4258
 
4175
4259
 
4260
+ def write_knowledge_candidates(work_dir: Path, summary: dict[str, Any]) -> Path:
4261
+ """Generate candidates/knowledge.json from summary-data (single ownership).
4262
+
4263
+ Mirrors write_archive_meta: derived from the same summary, written before the
4264
+ after-manifest so its bytes are covered. The archive directory name is the
4265
+ archive id, matching write_archive_meta's `archive-id` field.
4266
+ """
4267
+ archive_id = work_dir.name
4268
+ candidates = hkc.build_knowledge_candidates(
4269
+ summary,
4270
+ change_key=str(summary.get("changeName") or archive_id),
4271
+ archive_id=archive_id,
4272
+ # The archive schema version identifies the producing format; there is
4273
+ # no separate harness version constant to borrow here.
4274
+ producer_version=SCHEMA_VERSION,
4275
+ created_at=now_iso(),
4276
+ )
4277
+ out = work_dir / "candidates" / "knowledge.json"
4278
+ out.parent.mkdir(parents=True, exist_ok=True)
4279
+ out.write_text(
4280
+ hkc.render_knowledge_candidates_json(candidates),
4281
+ encoding="utf-8",
4282
+ newline="\n",
4283
+ )
4284
+ return out
4285
+
4286
+
4176
4287
 
4177
4288
 
4178
4289
  def _changed_files_from_git(
@@ -8269,6 +8380,21 @@ def cmd_finalize(
8269
8380
  warnings.append(f"archive-meta write failed: {exc}")
8270
8381
  payload["steps"]["archive_meta"] = {"ok": False, "error": str(exc)}
8271
8382
 
8383
+ # --- 8b. knowledge candidates (also before the after-manifest) ---
8384
+ # Soft-fail like archive-meta: an archive must never be rolled back because
8385
+ # knowledge extraction found nothing. An empty array is a valid outcome.
8386
+ try:
8387
+ summary = read_json(summary_path)
8388
+ candidates_path = write_knowledge_candidates(work_dir, summary)
8389
+ payload["steps"]["knowledge_candidates"] = {
8390
+ "ok": True,
8391
+ "path": str(candidates_path),
8392
+ "count": len(read_json(candidates_path)),
8393
+ }
8394
+ except Exception as exc: # noqa: BLE001 — candidates soft-fail
8395
+ warnings.append(f"knowledge candidates write failed: {exc}")
8396
+ payload["steps"]["knowledge_candidates"] = {"ok": False, "error": str(exc)}
8397
+
8272
8398
  # --- 9/10. final summary stats, then LAST manifest (IA-7) ---
8273
8399
  # Post-manifest rewrites of covered bytes are forbidden. We update the
8274
8400
  # summary first, regenerate after-manifest last, then verify on-disk hashes.
@@ -8296,14 +8422,16 @@ def cmd_finalize(
8296
8422
  if before_in_archive.is_file():
8297
8423
  before_manifest = read_json(before_in_archive)
8298
8424
  compare_result = compare_manifests(before_manifest, after_manifest)
8299
- coverage = verify_manifest_byte_coverage(work_dir, after_manifest)
8300
8425
  # Embed compare stats into summary AFTER manifest → must exclude those paths.
8426
+ # Only one coverage pass runs: an earlier build verified here as well,
8427
+ # but that result was overwritten a few lines below by the pass that
8428
+ # excludes summary-data.json, so it only cost a full extra tree hash.
8301
8429
  summary = read_json(summary_path)
8302
8430
  summary["archiveManifest"] = {
8303
8431
  "movedFiles": compare_result.get("movedFiles", 0),
8304
8432
  "generatedFiles": compare_result.get("generatedFiles", 0),
8305
8433
  "totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
8306
- "checksumStatus": coverage.get("checksumStatus", "FAIL"),
8434
+ "checksumStatus": "PENDING",
8307
8435
  "exclusionReasons": {
8308
8436
  "reports/final/summary-data.json": (
8309
8437
  "archiveManifest stats written after coverage snapshot"
@@ -8577,6 +8705,7 @@ def cmd_finalize(
8577
8705
  "archiveId",
8578
8706
  "archiveStatus",
8579
8707
  "knowledgeStatus",
8708
+ "knowledgeCandidateCount",
8580
8709
  "uploadStatus",
8581
8710
  "packageSha256",
8582
8711
  "manifestSha256",
@@ -8885,6 +9014,12 @@ def _archive_core_file_specs(
8885
9014
  file_specs.append(
8886
9015
  (source, f"{folder}/{relative}", role, "text/markdown")
8887
9016
  )
9017
+ add_if_file(
9018
+ archive / "candidates" / "knowledge.json",
9019
+ "candidates/knowledge.json",
9020
+ "knowledge_candidates",
9021
+ "application/json",
9022
+ )
8888
9023
  add_if_file(
8889
9024
  archive / "meta" / "archive-meta.md",
8890
9025
  "archive-meta.md",
@@ -8913,6 +9048,8 @@ def _archive_source_identity(project_root: Path) -> dict[str, str | None]:
8913
9048
  ["git", *args],
8914
9049
  cwd=str(project_root),
8915
9050
  text=True,
9051
+ encoding="utf-8",
9052
+ errors="replace",
8916
9053
  stdout=subprocess.PIPE,
8917
9054
  stderr=subprocess.DEVNULL,
8918
9055
  timeout=10,
@@ -9002,14 +9139,50 @@ def resolve_npx_launcher() -> list[str]:
9002
9139
  return [node, str(npx_cli)]
9003
9140
 
9004
9141
 
9142
+ def _installed_cli_entry(project_root: Path) -> Path | None:
9143
+ """Find an installed hunter-harness entry script, walking up from the project.
9144
+
9145
+ npx costs a full npm resolution (and a registry round-trip when the package
9146
+ is not a local dependency) on every call, and archive publishes it twice.
9147
+ Running the entry script under node directly skips all of that.
9148
+ """
9149
+ seen: set[Path] = set()
9150
+ for base in (project_root, *project_root.parents):
9151
+ if base in seen:
9152
+ continue
9153
+ seen.add(base)
9154
+ candidate = base / "node_modules" / "hunter-harness" / "dist" / "bin.js"
9155
+ if candidate.is_file():
9156
+ return candidate
9157
+ return None
9158
+
9159
+
9160
+ def resolve_hunter_cli_command(project_root: Path) -> list[str]:
9161
+ """Command prefix that runs the hunter-harness CLI, cheapest route first."""
9162
+ node = shutil.which("node")
9163
+ if node:
9164
+ entry = _installed_cli_entry(project_root)
9165
+ if entry is not None:
9166
+ return [node, str(entry)]
9167
+ return [*resolve_npx_launcher(), "--yes", "hunter-harness"]
9168
+
9169
+
9005
9170
  def build_archive_package(
9006
9171
  project_root: Path,
9007
9172
  archive_dir: Path,
9008
9173
  change_key: str,
9009
9174
  *,
9010
9175
  output_path: Path | None = None,
9176
+ extra_entries: dict[str, tuple[str, str, bytes]] | None = None,
9011
9177
  ) -> dict[str, Any]:
9012
- """Build the deterministic core-v1 ZIP uploaded to hunter-platform."""
9178
+ """Build the deterministic core-v1 ZIP uploaded to hunter-platform.
9179
+
9180
+ ``extra_entries`` maps a package path to ``(role, media_type, bytes)`` for
9181
+ content that is *not* on disk in the archive. Republishing an older archive
9182
+ uses it to supply knowledge candidates that predate the candidates step,
9183
+ without writing into a sealed archive directory whose manifest already
9184
+ covers every byte in it.
9185
+ """
9013
9186
  if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,159}", change_key) is None:
9014
9187
  raise ValueError("change_key must be a portable path segment")
9015
9188
  project_root, archive_dir, file_specs = _archive_core_file_specs(
@@ -9019,8 +9192,29 @@ def build_archive_package(
9019
9192
  if not any(spec[1] == "reports/final/summary-data.json" for spec in file_specs):
9020
9193
  raise ValueError("archive final summary-data.json is required")
9021
9194
 
9195
+ on_disk_paths = {spec[1] for spec in file_specs}
9196
+ injected = {
9197
+ package_path: value
9198
+ for package_path, value in (extra_entries or {}).items()
9199
+ if package_path not in on_disk_paths
9200
+ }
9201
+
9022
9202
  entries: list[dict[str, Any]] = []
9023
9203
  content_by_path: dict[str, bytes] = {}
9204
+ for package_path, (role, media_type, raw) in sorted(injected.items()):
9205
+ raw.decode("utf-8")
9206
+ if media_type == "application/json":
9207
+ raw = _archive_remote_json_bytes(raw)
9208
+ content_by_path[package_path] = raw
9209
+ entries.append(
9210
+ {
9211
+ "path": package_path,
9212
+ "role": role,
9213
+ "media_type": media_type,
9214
+ "content_sha256": "sha256:" + hashlib.sha256(raw).hexdigest(),
9215
+ "size_bytes": len(raw),
9216
+ }
9217
+ )
9024
9218
  for source, package_path, role, media_type in sorted(file_specs, key=lambda item: item[1]):
9025
9219
  source = _require_archive_project_path(
9026
9220
  project_root,
@@ -9044,6 +9238,7 @@ def build_archive_package(
9044
9238
  }
9045
9239
  )
9046
9240
 
9241
+ entries.sort(key=lambda item: str(item["path"]))
9047
9242
  manifest = {
9048
9243
  "schema_version": 1,
9049
9244
  "profile": "core-v1",
@@ -9117,6 +9312,17 @@ def build_archive_package(
9117
9312
  temporary.unlink(missing_ok=True)
9118
9313
 
9119
9314
  package_raw = output_path.read_bytes()
9315
+ # An upload can report knowledge_status=ready and still add nothing to the
9316
+ # platform when the package carries zero candidates. Counting them here is
9317
+ # what separates "server did not index" from "there was nothing to index".
9318
+ candidate_count = 0
9319
+ candidate_bytes = content_by_path.get("candidates/knowledge.json")
9320
+ if candidate_bytes is not None:
9321
+ try:
9322
+ parsed = json.loads(candidate_bytes)
9323
+ candidate_count = len(parsed) if isinstance(parsed, list) else 0
9324
+ except (json.JSONDecodeError, TypeError, ValueError):
9325
+ candidate_count = 0
9120
9326
  return {
9121
9327
  "schemaVersion": 1,
9122
9328
  "profile": "core-v1",
@@ -9127,6 +9333,7 @@ def build_archive_package(
9127
9333
  "fileCount": len(entries),
9128
9334
  "sizeBytes": len(package_raw),
9129
9335
  "paths": [entry["path"] for entry in entries],
9336
+ "knowledgeCandidateCount": candidate_count,
9130
9337
  }
9131
9338
 
9132
9339
 
@@ -9191,7 +9398,7 @@ def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9191
9398
  "missingCredentials": credentials["missing"],
9192
9399
  }
9193
9400
  try:
9194
- launcher = resolve_npx_launcher()
9401
+ launcher = resolve_hunter_cli_command(project_root)
9195
9402
  except OSError as exc:
9196
9403
  return {
9197
9404
  "ok": False,
@@ -9204,8 +9411,6 @@ def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9204
9411
  # 规则、架构地图与指令入口。
9205
9412
  command = [
9206
9413
  *launcher,
9207
- "--yes",
9208
- "hunter-harness",
9209
9414
  "harness-push",
9210
9415
  "--scope",
9211
9416
  "config,rules,architecture,instructions,branch_files",
@@ -9282,6 +9487,7 @@ def auto_push_archive_core(
9282
9487
  archive_dir: Path,
9283
9488
  *,
9284
9489
  change_key: str | None = None,
9490
+ extra_entries: dict[str, tuple[str, str, bytes]] | None = None,
9285
9491
  ) -> dict[str, Any]:
9286
9492
  """Build and upload one deterministic core ZIP after finalize.
9287
9493
 
@@ -9296,6 +9502,7 @@ def auto_push_archive_core(
9296
9502
  project_root,
9297
9503
  archive_dir,
9298
9504
  effective_change_key,
9505
+ extra_entries=extra_entries,
9299
9506
  )
9300
9507
  except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
9301
9508
  return {
@@ -9345,6 +9552,7 @@ def auto_push_archive_core(
9345
9552
  "packageSha256": package["packageSha256"],
9346
9553
  "manifestSha256": package["manifestSha256"],
9347
9554
  "fileCount": package["fileCount"],
9555
+ "knowledgeCandidateCount": package["knowledgeCandidateCount"],
9348
9556
  "paths": core_paths,
9349
9557
  "uploadStatus": "pending",
9350
9558
  "reasonCode": initial_reason_code,
@@ -9399,6 +9607,7 @@ def auto_push_archive_core(
9399
9607
  "manifestSha256": package["manifestSha256"],
9400
9608
  "fileCount": package["fileCount"],
9401
9609
  "sizeBytes": package["sizeBytes"],
9610
+ "knowledgeCandidateCount": package["knowledgeCandidateCount"],
9402
9611
  "pending": str(pending_path),
9403
9612
  }
9404
9613
 
@@ -9415,7 +9624,7 @@ def auto_push_archive_core(
9415
9624
  # The published CLI owns credentials, project binding, durable receipt checks,
9416
9625
  # and retries. Generic `push` is intentionally not used for archive bytes.
9417
9626
  try:
9418
- launcher = resolve_npx_launcher()
9627
+ launcher = resolve_hunter_cli_command(project_root)
9419
9628
  except OSError as exc:
9420
9629
  reason_code = "ARCHIVE_UPLOAD_DEFERRED"
9421
9630
  try:
@@ -9429,8 +9638,6 @@ def auto_push_archive_core(
9429
9638
  }
9430
9639
  command = [
9431
9640
  *launcher,
9432
- "--yes",
9433
- "hunter-harness",
9434
9641
  "archive",
9435
9642
  "upload",
9436
9643
  "--file",
@@ -9449,6 +9656,10 @@ def auto_push_archive_core(
9449
9656
  command,
9450
9657
  cwd=str(project_root),
9451
9658
  text=True,
9659
+ # 不指定 encoding 时中文 Windows 按 cp936 解码 UTF-8,
9660
+ # CLI 的中文回执会被损坏,随后的 json.loads 也可能失败。
9661
+ encoding="utf-8",
9662
+ errors="replace",
9452
9663
  stdout=subprocess.PIPE,
9453
9664
  stderr=subprocess.PIPE,
9454
9665
  timeout=180,
@@ -10021,6 +10232,186 @@ def cmd_repair_cli(args: argparse.Namespace) -> int:
10021
10232
  return code
10022
10233
 
10023
10234
 
10235
+ def resolve_archive_dir_for_change(
10236
+ project_root: Path,
10237
+ change_key: str,
10238
+ ) -> tuple[Path | None, list[str]]:
10239
+ """Locate the sealed archive directory for a change key.
10240
+
10241
+ Directories are named ``<YYYY-MM-DD>-<change-key>``; older layouts use the
10242
+ bare key. Returns the newest match plus every candidate name so an ambiguous
10243
+ key is reported rather than silently resolved.
10244
+ """
10245
+ archive_root = project_root / ".harness" / "archive"
10246
+ if not archive_root.is_dir():
10247
+ return None, []
10248
+ matches = sorted(
10249
+ entry.name
10250
+ for entry in archive_root.iterdir()
10251
+ if entry.is_dir()
10252
+ and (entry.name == change_key or entry.name.endswith(f"-{change_key}"))
10253
+ )
10254
+ if not matches:
10255
+ return None, []
10256
+ return archive_root / matches[-1], matches
10257
+
10258
+
10259
+ def _republish_knowledge_entry(archive_dir: Path) -> dict[str, tuple[str, str, bytes]]:
10260
+ """Knowledge candidates for archives sealed before the candidates step.
10261
+
10262
+ Generated in memory only. The archive directory is sealed: its after-manifest
10263
+ covers every byte in it, so writing a new file there would break byte
10264
+ coverage for every later reader.
10265
+ """
10266
+ if (archive_dir / "candidates" / "knowledge.json").is_file():
10267
+ return {}
10268
+ summary_path = archive_dir / "reports" / "final" / "summary-data.json"
10269
+ summary = read_json(summary_path)
10270
+ candidates = hkc.build_knowledge_candidates(
10271
+ summary,
10272
+ change_key=str(summary.get("changeName") or archive_dir.name),
10273
+ archive_id=archive_dir.name,
10274
+ producer_version=SCHEMA_VERSION,
10275
+ created_at=now_iso(),
10276
+ )
10277
+ if not candidates:
10278
+ return {}
10279
+ return {
10280
+ "candidates/knowledge.json": (
10281
+ "knowledge_candidates",
10282
+ "application/json",
10283
+ hkc.render_knowledge_candidates_json(candidates).encode("utf-8"),
10284
+ )
10285
+ }
10286
+
10287
+
10288
+ def cmd_republish(
10289
+ *,
10290
+ change_key: str,
10291
+ archive_dir: Path | None,
10292
+ project_root: Path | None,
10293
+ dry_run: bool,
10294
+ ) -> tuple[int, dict[str, Any]]:
10295
+ """Rebuild and re-upload the core ZIP for an already sealed archive.
10296
+
10297
+ The normal publish path deletes the ZIP and its receipt once the server
10298
+ reports durable+ready, and archives produced before the outbox existed never
10299
+ had one, so nothing is left to retry from. Rebuilding from the sealed
10300
+ directory is the only route that can re-deliver an archive, and it is also
10301
+ how archives sealed before knowledge candidates existed get them.
10302
+ """
10303
+ payload: dict[str, Any] = {
10304
+ "ok": False,
10305
+ "action": "republish",
10306
+ "changeKey": change_key,
10307
+ "dryRun": dry_run,
10308
+ }
10309
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,159}", change_key) is None:
10310
+ payload["error"] = "change key must be a portable path segment"
10311
+ payload["reasonCode"] = "ARCHIVE_CHANGE_KEY_INVALID"
10312
+ return 2, payload
10313
+
10314
+ root = (project_root or find_project_root(archive_dir or Path.cwd())).resolve()
10315
+ payload["projectRoot"] = str(root)
10316
+ matches: list[str] = []
10317
+ if archive_dir is None:
10318
+ archive_dir, matches = resolve_archive_dir_for_change(root, change_key)
10319
+ payload["matchedArchives"] = matches
10320
+ if archive_dir is None or not archive_dir.is_dir():
10321
+ payload["error"] = (
10322
+ f"no archive directory for change key {change_key} under "
10323
+ f"{root / '.harness' / 'archive'}"
10324
+ )
10325
+ payload["reasonCode"] = "ARCHIVE_DIR_NOT_FOUND"
10326
+ return 1, payload
10327
+ archive_dir = archive_dir.resolve()
10328
+ payload["archiveDir"] = str(archive_dir)
10329
+ if len(matches) > 1:
10330
+ payload["warning"] = (
10331
+ "多个归档目录匹配该 change key,已选用最新的一个:"
10332
+ + ", ".join(matches)
10333
+ )
10334
+
10335
+ summary_path = archive_dir / "reports" / "final" / "summary-data.json"
10336
+ if not summary_path.is_file():
10337
+ payload["error"] = f"archive final summary-data.json missing: {summary_path}"
10338
+ payload["reasonCode"] = "ARCHIVE_SUMMARY_MISSING"
10339
+ return 1, payload
10340
+
10341
+ try:
10342
+ extra_entries = _republish_knowledge_entry(archive_dir)
10343
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
10344
+ # Knowledge is additive: never fail a re-delivery over it.
10345
+ payload.setdefault("warnings", []).append(
10346
+ f"knowledge candidate regeneration skipped: {exc}"
10347
+ )
10348
+ extra_entries = {}
10349
+ payload["knowledgeCandidatesInjected"] = "candidates/knowledge.json" in extra_entries
10350
+
10351
+ if dry_run:
10352
+ # The package builder refuses to write outside the project, so the
10353
+ # preview lands beside the real packages under a distinct name and is
10354
+ # removed again; it must never be mistaken for a pending upload.
10355
+ preview_path = (
10356
+ root / ".harness" / "state" / "local" / "archive-packages"
10357
+ / f"{change_key}.preview.zip"
10358
+ )
10359
+ try:
10360
+ package = build_archive_package(
10361
+ root,
10362
+ archive_dir,
10363
+ change_key,
10364
+ output_path=preview_path,
10365
+ extra_entries=extra_entries,
10366
+ )
10367
+ except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
10368
+ payload["error"] = f"archive package build failed: {exc}"
10369
+ payload["reasonCode"] = "ARCHIVE_PACKAGE_BUILD_FAILED"
10370
+ return 1, payload
10371
+ finally:
10372
+ try:
10373
+ preview_path.unlink(missing_ok=True)
10374
+ except OSError:
10375
+ pass
10376
+ payload.update(
10377
+ {
10378
+ "ok": True,
10379
+ "reasonCode": "ARCHIVE_REPUBLISH_PREVIEW",
10380
+ "fileCount": package["fileCount"],
10381
+ "sizeBytes": package["sizeBytes"],
10382
+ "knowledgeCandidateCount": package["knowledgeCandidateCount"],
10383
+ "packageSha256": package["packageSha256"],
10384
+ "manifestSha256": package["manifestSha256"],
10385
+ "files": package["paths"],
10386
+ }
10387
+ )
10388
+ return 0, payload
10389
+
10390
+ result = auto_push_archive_core(
10391
+ root,
10392
+ archive_dir,
10393
+ change_key=change_key,
10394
+ extra_entries=extra_entries,
10395
+ )
10396
+ payload["push"] = result
10397
+ payload["ok"] = bool(result.get("ok"))
10398
+ payload["reasonCode"] = result.get("reasonCode")
10399
+ if result.get("warning"):
10400
+ payload.setdefault("warnings", []).append(str(result["warning"]))
10401
+ return 0 if payload["ok"] else 1, payload
10402
+
10403
+
10404
+ def cmd_republish_cli(args: argparse.Namespace) -> int:
10405
+ code, payload = cmd_republish(
10406
+ change_key=str(args.change).strip(),
10407
+ archive_dir=resolve_path(args.archive_dir) if args.archive_dir else None,
10408
+ project_root=resolve_path(args.project) if args.project else None,
10409
+ dry_run=bool(args.dry_run),
10410
+ )
10411
+ emit_json(payload)
10412
+ return code
10413
+
10414
+
10024
10415
  def build_parser() -> argparse.ArgumentParser:
10025
10416
  parser = argparse.ArgumentParser(
10026
10417
  prog="harness_archive.py",
@@ -10180,6 +10571,28 @@ def build_parser() -> argparse.ArgumentParser:
10180
10571
  p_repair.add_argument("--json", action="store_true", default=True)
10181
10572
  p_repair.set_defaults(func=cmd_repair_cli)
10182
10573
 
10574
+ p_republish = sub.add_parser(
10575
+ "republish",
10576
+ help=(
10577
+ "rebuild and re-upload the core ZIP for an already sealed archive "
10578
+ "(the normal path deletes the ZIP once the server confirms it)"
10579
+ ),
10580
+ )
10581
+ p_republish.add_argument("--change", required=True, help="change key")
10582
+ p_republish.add_argument(
10583
+ "--archive-dir",
10584
+ default=None,
10585
+ help="explicit archive directory; resolved from --change when omitted",
10586
+ )
10587
+ p_republish.add_argument("--project", default=None)
10588
+ p_republish.add_argument(
10589
+ "--dry-run",
10590
+ action="store_true",
10591
+ help="build the package and report its contents without uploading",
10592
+ )
10593
+ p_republish.add_argument("--json", action="store_true", default=True)
10594
+ p_republish.set_defaults(func=cmd_republish_cli)
10595
+
10183
10596
  return parser
10184
10597
 
10185
10598