@hunter-harness/workflow-harness 0.2.78 → 0.2.80

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 (146) 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 +28 -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 +30 -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/sensitive-info-protocol.md +5 -0
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +518 -46
  16. package/harness/bundles/general/claude-code/scripts/harness_runtime.py +6 -2
  17. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  18. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +28 -2
  19. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  20. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  21. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  24. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +29 -5
  25. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +5 -0
  31. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +518 -46
  32. package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +6 -2
  33. package/harness/bundles/general/codex/.harness-build.json +1 -1
  34. package/harness/bundles/general/codex/harness-archive/SKILL.md +28 -2
  35. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  36. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  37. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  38. package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-push/SKILL.md +29 -5
  41. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
  43. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  44. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  45. package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
  46. package/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +5 -0
  47. package/harness/bundles/general/codex/scripts/harness_archive.py +518 -46
  48. package/harness/bundles/general/codex/scripts/harness_runtime.py +6 -2
  49. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  50. package/harness/bundles/general/cursor/harness-archive/SKILL.md +28 -2
  51. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  52. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  53. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  54. package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
  55. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-push/SKILL.md +29 -5
  57. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  58. package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  60. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  61. package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
  62. package/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +5 -0
  63. package/harness/bundles/general/cursor/scripts/harness_archive.py +518 -46
  64. package/harness/bundles/general/cursor/scripts/harness_runtime.py +6 -2
  65. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  66. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  67. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +28 -2
  68. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  69. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  70. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  71. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  72. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
  73. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  74. package/harness/bundles/java/claude-code/harness-push/SKILL.md +30 -6
  75. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  76. package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
  77. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  78. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  79. package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
  80. package/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +5 -0
  81. package/harness/bundles/java/claude-code/scripts/harness_archive.py +518 -46
  82. package/harness/bundles/java/claude-code/scripts/harness_runtime.py +6 -2
  83. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  84. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  85. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +28 -2
  86. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  87. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  88. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  89. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  90. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
  91. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  92. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +29 -5
  93. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  94. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
  95. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  96. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  97. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
  98. package/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +5 -0
  99. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +518 -46
  100. package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +6 -2
  101. package/harness/bundles/java/codex/.harness-build.json +1 -1
  102. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  103. package/harness/bundles/java/codex/harness-archive/SKILL.md +28 -2
  104. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  105. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  106. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  107. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  108. package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
  109. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  110. package/harness/bundles/java/codex/harness-push/SKILL.md +29 -5
  111. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  112. package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
  113. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  114. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  115. package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
  116. package/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +5 -0
  117. package/harness/bundles/java/codex/scripts/harness_archive.py +518 -46
  118. package/harness/bundles/java/codex/scripts/harness_runtime.py +6 -2
  119. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  120. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  121. package/harness/bundles/java/cursor/harness-archive/SKILL.md +28 -2
  122. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  123. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  124. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  125. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  126. package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
  127. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  128. package/harness/bundles/java/cursor/harness-push/SKILL.md +29 -5
  129. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  130. package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
  131. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  132. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  133. package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
  134. package/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +5 -0
  135. package/harness/bundles/java/cursor/scripts/harness_archive.py +518 -46
  136. package/harness/bundles/java/cursor/scripts/harness_runtime.py +6 -2
  137. package/harness/manifests/general/claude-code.json +18 -18
  138. package/harness/manifests/general/codebuddy.json +18 -18
  139. package/harness/manifests/general/codex.json +18 -18
  140. package/harness/manifests/general/cursor.json +18 -18
  141. package/harness/manifests/java/claude-code.json +20 -20
  142. package/harness/manifests/java/codebuddy.json +20 -20
  143. package/harness/manifests/java/codex.json +20 -20
  144. package/harness/manifests/java/cursor.json +20 -20
  145. package/hunter-workflow-family.json +5 -5
  146. package/package.json +1 -1
@@ -259,9 +259,13 @@ def append_event(
259
259
  def generate_manifest(root: Path, output_path: Path) -> dict[str, Any]:
260
260
  """Build path/size/sha256 manifest; exclude the output file itself."""
261
261
  root = root.resolve()
262
- exclude: Path | None = None
263
- if output_path.exists():
264
- 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
265
269
 
266
270
  files: list[dict[str, Any]] = []
267
271
  total_bytes = 0
@@ -270,9 +274,9 @@ def generate_manifest(root: Path, output_path: Path) -> dict[str, Any]:
270
274
  continue
271
275
  if path.name.endswith(".lock"):
272
276
  continue
273
- if exclude is not None and path.resolve() == exclude:
274
- continue
275
277
  rel = path.relative_to(root).as_posix()
278
+ if exclude_rel is not None and rel == exclude_rel:
279
+ continue
276
280
  size = path.stat().st_size
277
281
  total_bytes += size
278
282
  files.append(
@@ -2113,6 +2117,56 @@ def check_archive_exact_byte_policy(project_root: Path) -> dict[str, Any]:
2113
2117
  }
2114
2118
 
2115
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
+
2116
2170
  def validate_sensitive_evidence_publication_gate(
2117
2171
  change_dir: Path,
2118
2172
  *,
@@ -2129,24 +2183,42 @@ def validate_sensitive_evidence_publication_gate(
2129
2183
  """
2130
2184
  change_dir = change_dir.expanduser().resolve()
2131
2185
  copy_root = (copy_root or change_dir).expanduser().resolve()
2186
+ policy = resolve_sensitive_scan_policy()
2132
2187
  receipt_path = hruntime.sensitive_evidence_receipt_path(change_dir)
2133
- candidates = hruntime.sensitive_evidence_candidates(change_dir)
2134
- if candidates:
2188
+ if policy == "off":
2189
+ # Skipping the scan also skips a full-tree byte read of the change dir.
2135
2190
  return {
2136
- "ok": False,
2137
- "reasonCode": "SENSITIVE_EVIDENCE_UNQUARANTINED",
2191
+ "ok": True,
2192
+ "reasonCode": "SECRET_SCAN_DISABLED",
2193
+ "policy": policy,
2138
2194
  "receiptPath": str(receipt_path),
2139
- "unresolvedFailures": candidates,
2140
- "nextAction": "Quarantine the original bytes before archive publication.",
2195
+ "unresolvedFailures": [],
2141
2196
  }
2142
- if receipt_override is None and not receipt_path.is_file():
2143
- if require_receipt:
2144
- return {
2197
+ candidates = hruntime.sensitive_evidence_candidates(change_dir)
2198
+ if candidates:
2199
+ return _advisory_sensitive_gate(
2200
+ {
2145
2201
  "ok": False,
2146
- "reasonCode": "SECRET_SCAN_RECEIPT_MISSING",
2202
+ "reasonCode": "SENSITIVE_EVIDENCE_UNQUARANTINED",
2147
2203
  "receiptPath": str(receipt_path),
2148
- "unresolvedFailures": [],
2149
- }
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
+ )
2150
2222
  return {
2151
2223
  "ok": True,
2152
2224
  "reasonCode": "SECRET_SCAN_NOT_APPLICABLE",
@@ -2158,21 +2230,27 @@ def validate_sensitive_evidence_publication_gate(
2158
2230
  try:
2159
2231
  receipt = read_json(receipt_path)
2160
2232
  except (OSError, json.JSONDecodeError, TypeError) as exc:
2161
- return {
2162
- "ok": False,
2163
- "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
2164
- "receiptPath": str(receipt_path),
2165
- "error": str(exc),
2166
- }
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
+ )
2167
2242
  else:
2168
2243
  receipt = receipt_override
2169
2244
  if not isinstance(receipt, dict):
2170
- return {
2171
- "ok": False,
2172
- "reasonCode": "SECRET_SCAN_RECEIPT_INVALID",
2173
- "receiptPath": str(receipt_path),
2174
- "error": "receipt must be an object",
2175
- }
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
+ )
2176
2254
  issues: list[dict[str, Any]] = []
2177
2255
  if receipt.get("schemaVersion") != 1:
2178
2256
  issues.append({"code": "SECRET_SCAN_SCHEMA_INVALID"})
@@ -2232,14 +2310,19 @@ def validate_sensitive_evidence_publication_gate(
2232
2310
  "code": "SENSITIVE_EVIDENCE_SOURCE_REMAINS",
2233
2311
  "sourcePath": source_raw,
2234
2312
  })
2235
- return {
2236
- "ok": not issues,
2237
- "reasonCode": "SECRET_SCAN_GATE_SATISFIED" if not issues else "SECRET_SCAN_GATE_BLOCKED",
2238
- "receiptPath": str(receipt_path),
2239
- "receipt": receipt,
2240
- "issues": issues,
2241
- "treeDigest": actual_digest,
2242
- }
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
+ )
2243
2326
 
2244
2327
 
2245
2328
  # ---------------------------------------------------------------------------
@@ -8339,14 +8422,16 @@ def cmd_finalize(
8339
8422
  if before_in_archive.is_file():
8340
8423
  before_manifest = read_json(before_in_archive)
8341
8424
  compare_result = compare_manifests(before_manifest, after_manifest)
8342
- coverage = verify_manifest_byte_coverage(work_dir, after_manifest)
8343
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.
8344
8429
  summary = read_json(summary_path)
8345
8430
  summary["archiveManifest"] = {
8346
8431
  "movedFiles": compare_result.get("movedFiles", 0),
8347
8432
  "generatedFiles": compare_result.get("generatedFiles", 0),
8348
8433
  "totalArchiveFiles": compare_result.get("totalArchiveFiles", 0),
8349
- "checksumStatus": coverage.get("checksumStatus", "FAIL"),
8434
+ "checksumStatus": "PENDING",
8350
8435
  "exclusionReasons": {
8351
8436
  "reports/final/summary-data.json": (
8352
8437
  "archiveManifest stats written after coverage snapshot"
@@ -8620,6 +8705,7 @@ def cmd_finalize(
8620
8705
  "archiveId",
8621
8706
  "archiveStatus",
8622
8707
  "knowledgeStatus",
8708
+ "knowledgeCandidateCount",
8623
8709
  "uploadStatus",
8624
8710
  "packageSha256",
8625
8711
  "manifestSha256",
@@ -9053,14 +9139,50 @@ def resolve_npx_launcher() -> list[str]:
9053
9139
  return [node, str(npx_cli)]
9054
9140
 
9055
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
+
9056
9170
  def build_archive_package(
9057
9171
  project_root: Path,
9058
9172
  archive_dir: Path,
9059
9173
  change_key: str,
9060
9174
  *,
9061
9175
  output_path: Path | None = None,
9176
+ extra_entries: dict[str, tuple[str, str, bytes]] | None = None,
9062
9177
  ) -> dict[str, Any]:
9063
- """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
+ """
9064
9186
  if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,159}", change_key) is None:
9065
9187
  raise ValueError("change_key must be a portable path segment")
9066
9188
  project_root, archive_dir, file_specs = _archive_core_file_specs(
@@ -9070,8 +9192,29 @@ def build_archive_package(
9070
9192
  if not any(spec[1] == "reports/final/summary-data.json" for spec in file_specs):
9071
9193
  raise ValueError("archive final summary-data.json is required")
9072
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
+
9073
9202
  entries: list[dict[str, Any]] = []
9074
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
+ )
9075
9218
  for source, package_path, role, media_type in sorted(file_specs, key=lambda item: item[1]):
9076
9219
  source = _require_archive_project_path(
9077
9220
  project_root,
@@ -9095,6 +9238,7 @@ def build_archive_package(
9095
9238
  }
9096
9239
  )
9097
9240
 
9241
+ entries.sort(key=lambda item: str(item["path"]))
9098
9242
  manifest = {
9099
9243
  "schema_version": 1,
9100
9244
  "profile": "core-v1",
@@ -9168,6 +9312,17 @@ def build_archive_package(
9168
9312
  temporary.unlink(missing_ok=True)
9169
9313
 
9170
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
9171
9326
  return {
9172
9327
  "schemaVersion": 1,
9173
9328
  "profile": "core-v1",
@@ -9178,6 +9333,7 @@ def build_archive_package(
9178
9333
  "fileCount": len(entries),
9179
9334
  "sizeBytes": len(package_raw),
9180
9335
  "paths": [entry["path"] for entry in entries],
9336
+ "knowledgeCandidateCount": candidate_count,
9181
9337
  }
9182
9338
 
9183
9339
 
@@ -9242,7 +9398,7 @@ def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9242
9398
  "missingCredentials": credentials["missing"],
9243
9399
  }
9244
9400
  try:
9245
- launcher = resolve_npx_launcher()
9401
+ launcher = resolve_hunter_cli_command(project_root)
9246
9402
  except OSError as exc:
9247
9403
  return {
9248
9404
  "ok": False,
@@ -9255,8 +9411,6 @@ def auto_push_managed_snapshot(project_root: Path) -> dict[str, Any]:
9255
9411
  # 规则、架构地图与指令入口。
9256
9412
  command = [
9257
9413
  *launcher,
9258
- "--yes",
9259
- "hunter-harness",
9260
9414
  "harness-push",
9261
9415
  "--scope",
9262
9416
  "config,rules,architecture,instructions,branch_files",
@@ -9333,6 +9487,7 @@ def auto_push_archive_core(
9333
9487
  archive_dir: Path,
9334
9488
  *,
9335
9489
  change_key: str | None = None,
9490
+ extra_entries: dict[str, tuple[str, str, bytes]] | None = None,
9336
9491
  ) -> dict[str, Any]:
9337
9492
  """Build and upload one deterministic core ZIP after finalize.
9338
9493
 
@@ -9347,6 +9502,7 @@ def auto_push_archive_core(
9347
9502
  project_root,
9348
9503
  archive_dir,
9349
9504
  effective_change_key,
9505
+ extra_entries=extra_entries,
9350
9506
  )
9351
9507
  except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
9352
9508
  return {
@@ -9396,6 +9552,7 @@ def auto_push_archive_core(
9396
9552
  "packageSha256": package["packageSha256"],
9397
9553
  "manifestSha256": package["manifestSha256"],
9398
9554
  "fileCount": package["fileCount"],
9555
+ "knowledgeCandidateCount": package["knowledgeCandidateCount"],
9399
9556
  "paths": core_paths,
9400
9557
  "uploadStatus": "pending",
9401
9558
  "reasonCode": initial_reason_code,
@@ -9450,6 +9607,7 @@ def auto_push_archive_core(
9450
9607
  "manifestSha256": package["manifestSha256"],
9451
9608
  "fileCount": package["fileCount"],
9452
9609
  "sizeBytes": package["sizeBytes"],
9610
+ "knowledgeCandidateCount": package["knowledgeCandidateCount"],
9453
9611
  "pending": str(pending_path),
9454
9612
  }
9455
9613
 
@@ -9466,7 +9624,7 @@ def auto_push_archive_core(
9466
9624
  # The published CLI owns credentials, project binding, durable receipt checks,
9467
9625
  # and retries. Generic `push` is intentionally not used for archive bytes.
9468
9626
  try:
9469
- launcher = resolve_npx_launcher()
9627
+ launcher = resolve_hunter_cli_command(project_root)
9470
9628
  except OSError as exc:
9471
9629
  reason_code = "ARCHIVE_UPLOAD_DEFERRED"
9472
9630
  try:
@@ -9480,8 +9638,6 @@ def auto_push_archive_core(
9480
9638
  }
9481
9639
  command = [
9482
9640
  *launcher,
9483
- "--yes",
9484
- "hunter-harness",
9485
9641
  "archive",
9486
9642
  "upload",
9487
9643
  "--file",
@@ -10076,6 +10232,292 @@ def cmd_repair_cli(args: argparse.Namespace) -> int:
10076
10232
  return code
10077
10233
 
10078
10234
 
10235
+ def resolve_republish_project_root(explicit: Path | None, archive_dir: Path | None) -> Path:
10236
+ """Locate the project root for a republish without requiring --project.
10237
+
10238
+ ``find_project_root`` walks ``p.parents``, which excludes ``p`` itself; it is
10239
+ built for a change/archive subdirectory. Handing it the cwd therefore skips
10240
+ the very directory the operator is standing in and keeps climbing — in a real
10241
+ run that resolved ``E:/WorkProject/kb-sdd`` to its parent and made the command
10242
+ look for archives that were never there.
10243
+
10244
+ Order: explicit flag, then the archive's own location, then cwd and its
10245
+ parents (cwd included), then the deployed script's location — the script
10246
+ lives at ``<project>/.claude|.codebuddy/skills/scripts/`` after install.
10247
+ """
10248
+ if explicit is not None:
10249
+ return explicit.resolve()
10250
+ if archive_dir is not None:
10251
+ return find_project_root(archive_dir)
10252
+
10253
+ def first_with_archive(start: Path) -> Path | None:
10254
+ start = start.resolve()
10255
+ for candidate in (start, *start.parents):
10256
+ if (candidate / ".harness" / "archive").is_dir():
10257
+ return candidate
10258
+ return None
10259
+
10260
+ found = first_with_archive(Path.cwd())
10261
+ if found is not None:
10262
+ return found
10263
+ found = first_with_archive(Path(__file__).resolve().parent)
10264
+ if found is not None:
10265
+ return found
10266
+ # Nothing carries an archive directory; report against cwd so the error names
10267
+ # a path the operator recognises instead of some ancestor.
10268
+ return Path.cwd().resolve()
10269
+
10270
+
10271
+ def resolve_archive_dir_for_change(
10272
+ project_root: Path,
10273
+ change_key: str,
10274
+ ) -> tuple[Path | None, list[str]]:
10275
+ """Locate the sealed archive directory for a change key.
10276
+
10277
+ Directories are named ``<YYYY-MM-DD>-<change-key>``; older layouts use the
10278
+ bare key. Returns the newest match plus every candidate name so an ambiguous
10279
+ key is reported rather than silently resolved.
10280
+ """
10281
+ archive_root = project_root / ".harness" / "archive"
10282
+ if not archive_root.is_dir():
10283
+ return None, []
10284
+ matches = sorted(
10285
+ entry.name
10286
+ for entry in archive_root.iterdir()
10287
+ if entry.is_dir()
10288
+ and (entry.name == change_key or entry.name.endswith(f"-{change_key}"))
10289
+ )
10290
+ if not matches:
10291
+ return None, []
10292
+ return archive_root / matches[-1], matches
10293
+
10294
+
10295
+ def _republish_knowledge_entry(archive_dir: Path) -> dict[str, tuple[str, str, bytes]]:
10296
+ """Knowledge candidates for archives sealed before the candidates step.
10297
+
10298
+ Generated in memory only. The archive directory is sealed: its after-manifest
10299
+ covers every byte in it, so writing a new file there would break byte
10300
+ coverage for every later reader.
10301
+ """
10302
+ if (archive_dir / "candidates" / "knowledge.json").is_file():
10303
+ return {}
10304
+ summary_path = archive_dir / "reports" / "final" / "summary-data.json"
10305
+ summary = read_json(summary_path)
10306
+ candidates = hkc.build_knowledge_candidates(
10307
+ summary,
10308
+ change_key=str(summary.get("changeName") or archive_dir.name),
10309
+ archive_id=archive_dir.name,
10310
+ producer_version=SCHEMA_VERSION,
10311
+ # Derived from the archive, never wall-clock: `now_iso()` here made every
10312
+ # rebuild produce different bytes, so a package could never be compared
10313
+ # against a stored one and the "deterministic package" claim was false.
10314
+ created_at=_archive_created_at(summary_path),
10315
+ )
10316
+ if not candidates:
10317
+ return {}
10318
+ return {
10319
+ "candidates/knowledge.json": (
10320
+ "knowledge_candidates",
10321
+ "application/json",
10322
+ hkc.render_knowledge_candidates_json(candidates).encode("utf-8"),
10323
+ )
10324
+ }
10325
+
10326
+
10327
+ def read_durable_archive_receipt(project_root: Path, change_key: str) -> dict[str, Any] | None:
10328
+ """The `<key>.remote.json` written after a confirmed durable upload, if any."""
10329
+ receipt_path = (
10330
+ project_root / ".harness" / "state" / "local" / "archive-packages"
10331
+ / f"{change_key}.remote.json"
10332
+ )
10333
+ if not receipt_path.is_file():
10334
+ return None
10335
+ try:
10336
+ receipt = read_json(receipt_path)
10337
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
10338
+ return None
10339
+ return receipt if isinstance(receipt, dict) else None
10340
+
10341
+
10342
+ def cmd_republish(
10343
+ *,
10344
+ change_key: str,
10345
+ archive_dir: Path | None,
10346
+ project_root: Path | None,
10347
+ dry_run: bool,
10348
+ inject_knowledge: bool = True,
10349
+ ) -> tuple[int, dict[str, Any]]:
10350
+ """Rebuild and re-upload the core ZIP for an already sealed archive.
10351
+
10352
+ The normal publish path deletes the ZIP and its receipt once the server
10353
+ reports durable+ready, and archives produced before the outbox existed never
10354
+ had one, so nothing is left to retry from. Rebuilding from the sealed
10355
+ directory is the only route that can re-deliver an archive, and it is also
10356
+ how archives sealed before knowledge candidates existed get them.
10357
+ """
10358
+ payload: dict[str, Any] = {
10359
+ "ok": False,
10360
+ "action": "republish",
10361
+ "changeKey": change_key,
10362
+ "dryRun": dry_run,
10363
+ }
10364
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,159}", change_key) is None:
10365
+ payload["error"] = "change key must be a portable path segment"
10366
+ payload["reasonCode"] = "ARCHIVE_CHANGE_KEY_INVALID"
10367
+ return 2, payload
10368
+
10369
+ root = resolve_republish_project_root(project_root, archive_dir)
10370
+ payload["projectRoot"] = str(root)
10371
+ matches: list[str] = []
10372
+ if archive_dir is None:
10373
+ archive_dir, matches = resolve_archive_dir_for_change(root, change_key)
10374
+ payload["matchedArchives"] = matches
10375
+ if archive_dir is None or not archive_dir.is_dir():
10376
+ payload["error"] = (
10377
+ f"no archive directory for change key {change_key} under "
10378
+ f"{root / '.harness' / 'archive'}"
10379
+ )
10380
+ payload["reasonCode"] = "ARCHIVE_DIR_NOT_FOUND"
10381
+ return 1, payload
10382
+ archive_dir = archive_dir.resolve()
10383
+ payload["archiveDir"] = str(archive_dir)
10384
+ if len(matches) > 1:
10385
+ payload["warning"] = (
10386
+ "多个归档目录匹配该 change key,已选用最新的一个:"
10387
+ + ", ".join(matches)
10388
+ )
10389
+
10390
+ summary_path = archive_dir / "reports" / "final" / "summary-data.json"
10391
+ if not summary_path.is_file():
10392
+ payload["error"] = f"archive final summary-data.json missing: {summary_path}"
10393
+ payload["reasonCode"] = "ARCHIVE_SUMMARY_MISSING"
10394
+ return 1, payload
10395
+
10396
+ extra_entries: dict[str, tuple[str, str, bytes]] = {}
10397
+ if inject_knowledge:
10398
+ try:
10399
+ extra_entries = _republish_knowledge_entry(archive_dir)
10400
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
10401
+ # Knowledge is additive: never fail a re-delivery over it.
10402
+ payload.setdefault("warnings", []).append(
10403
+ f"knowledge candidate regeneration skipped: {exc}"
10404
+ )
10405
+ extra_entries = {}
10406
+ payload["knowledgeCandidatesInjected"] = "candidates/knowledge.json" in extra_entries
10407
+
10408
+ # Build once, into a preview path, so the bytes can be compared against any
10409
+ # durable receipt before an upload is attempted. The builder refuses to write
10410
+ # outside the project, so the preview lands beside the real packages under a
10411
+ # distinct name; it must never be mistaken for a pending upload.
10412
+ preview_path = (
10413
+ root / ".harness" / "state" / "local" / "archive-packages"
10414
+ / f"{change_key}.preview.zip"
10415
+ )
10416
+ try:
10417
+ package = build_archive_package(
10418
+ root,
10419
+ archive_dir,
10420
+ change_key,
10421
+ output_path=preview_path,
10422
+ extra_entries=extra_entries,
10423
+ )
10424
+ except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
10425
+ payload["error"] = f"archive package build failed: {exc}"
10426
+ payload["reasonCode"] = "ARCHIVE_PACKAGE_BUILD_FAILED"
10427
+ return 1, payload
10428
+ finally:
10429
+ try:
10430
+ preview_path.unlink(missing_ok=True)
10431
+ except OSError:
10432
+ pass
10433
+
10434
+ payload.update(
10435
+ {
10436
+ "fileCount": package["fileCount"],
10437
+ "sizeBytes": package["sizeBytes"],
10438
+ "knowledgeCandidateCount": package["knowledgeCandidateCount"],
10439
+ "packageSha256": package["packageSha256"],
10440
+ "manifestSha256": package["manifestSha256"],
10441
+ "files": package["paths"],
10442
+ }
10443
+ )
10444
+
10445
+ # The platform stores exactly one immutable package per change key. A rebuild
10446
+ # that differs by even one byte is refused server-side, so decide here rather
10447
+ # than spending an upload to learn it.
10448
+ durable = read_durable_archive_receipt(root, change_key)
10449
+ stored_sha = str((durable or {}).get("packageSha256") or "")
10450
+ if durable is not None and str(durable.get("archiveStatus") or "") == "durable":
10451
+ payload["remoteReceipt"] = {
10452
+ "archiveId": durable.get("archiveId"),
10453
+ "archiveStatus": durable.get("archiveStatus"),
10454
+ "knowledgeStatus": durable.get("knowledgeStatus"),
10455
+ "packageSha256": stored_sha,
10456
+ "recordedAt": durable.get("recordedAt"),
10457
+ }
10458
+ if stored_sha and stored_sha == package["packageSha256"]:
10459
+ payload.update({
10460
+ "ok": True,
10461
+ "reasonCode": "ARCHIVE_ALREADY_PUBLISHED",
10462
+ "message": (
10463
+ "远端已存有字节一致的同一个包,无需重传"
10464
+ f"(archiveId={durable.get('archiveId')})。"
10465
+ ),
10466
+ })
10467
+ return 0, payload
10468
+ payload.update({
10469
+ "ok": False,
10470
+ "reasonCode": "ARCHIVE_REMOTE_IMMUTABLE_CONFLICT",
10471
+ "error": (
10472
+ "该 change 在远端已是 durable 归档,服务端对同一 change key 只保存一个"
10473
+ "不可变包,不接受不同字节的替换。"
10474
+ ),
10475
+ "nextAction": (
10476
+ "补传只适用于『从未成功上传』或『字节完全一致的重试』。"
10477
+ + (
10478
+ "本次重建注入了 "
10479
+ f"{package['knowledgeCandidateCount']} 条知识候选"
10480
+ "(原包上传时还没有 candidates/knowledge.json),因此字节必然不同——"
10481
+ "已发布归档无法从客户端追加知识条目,需要平台侧提供重新索引或归档版本化能力。"
10482
+ "若只是想重试同一个包(例如知识索引失败),加 --no-knowledge-injection "
10483
+ "可重建与封存目录完全一致的字节。"
10484
+ if payload.get("knowledgeCandidatesInjected")
10485
+ else "本地归档内容与已上传的包不一致,请先确认哪一份才是应保留的事实。"
10486
+ )
10487
+ ),
10488
+ })
10489
+ return 1, payload
10490
+
10491
+ if dry_run:
10492
+ payload.update({"ok": True, "reasonCode": "ARCHIVE_REPUBLISH_PREVIEW"})
10493
+ return 0, payload
10494
+
10495
+ result = auto_push_archive_core(
10496
+ root,
10497
+ archive_dir,
10498
+ change_key=change_key,
10499
+ extra_entries=extra_entries,
10500
+ )
10501
+ payload["push"] = result
10502
+ payload["ok"] = bool(result.get("ok"))
10503
+ payload["reasonCode"] = result.get("reasonCode")
10504
+ if result.get("warning"):
10505
+ payload.setdefault("warnings", []).append(str(result["warning"]))
10506
+ return 0 if payload["ok"] else 1, payload
10507
+
10508
+
10509
+ def cmd_republish_cli(args: argparse.Namespace) -> int:
10510
+ code, payload = cmd_republish(
10511
+ change_key=str(args.change).strip(),
10512
+ archive_dir=resolve_path(args.archive_dir) if args.archive_dir else None,
10513
+ project_root=resolve_path(args.project) if args.project else None,
10514
+ dry_run=bool(args.dry_run),
10515
+ inject_knowledge=not bool(args.no_knowledge_injection),
10516
+ )
10517
+ emit_json(payload)
10518
+ return code
10519
+
10520
+
10079
10521
  def build_parser() -> argparse.ArgumentParser:
10080
10522
  parser = argparse.ArgumentParser(
10081
10523
  prog="harness_archive.py",
@@ -10235,6 +10677,36 @@ def build_parser() -> argparse.ArgumentParser:
10235
10677
  p_repair.add_argument("--json", action="store_true", default=True)
10236
10678
  p_repair.set_defaults(func=cmd_repair_cli)
10237
10679
 
10680
+ p_republish = sub.add_parser(
10681
+ "republish",
10682
+ help=(
10683
+ "rebuild and re-upload the core ZIP for an already sealed archive "
10684
+ "(the normal path deletes the ZIP once the server confirms it)"
10685
+ ),
10686
+ )
10687
+ p_republish.add_argument("--change", required=True, help="change key")
10688
+ p_republish.add_argument(
10689
+ "--archive-dir",
10690
+ default=None,
10691
+ help="explicit archive directory; resolved from --change when omitted",
10692
+ )
10693
+ p_republish.add_argument("--project", default=None)
10694
+ p_republish.add_argument(
10695
+ "--dry-run",
10696
+ action="store_true",
10697
+ help="build the package and report its contents without uploading",
10698
+ )
10699
+ p_republish.add_argument(
10700
+ "--no-knowledge-injection",
10701
+ action="store_true",
10702
+ help=(
10703
+ "rebuild exactly what the sealed archive holds; needed to retry the "
10704
+ "same bytes the server already stored"
10705
+ ),
10706
+ )
10707
+ p_republish.add_argument("--json", action="store_true", default=True)
10708
+ p_republish.set_defaults(func=cmd_republish_cli)
10709
+
10238
10710
  return parser
10239
10711
 
10240
10712