@hunter-harness/workflow-harness 0.2.78 → 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 (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 +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/sensitive-info-protocol.md +5 -0
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +404 -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 +17 -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 +15 -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 +404 -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 +17 -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 +15 -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 +404 -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 +17 -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 +15 -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 +404 -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 +17 -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 +16 -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 +404 -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 +17 -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 +15 -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 +404 -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 +17 -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 +15 -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 +404 -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 +17 -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 +15 -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 +404 -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,186 @@ def cmd_repair_cli(args: argparse.Namespace) -> int:
10076
10232
  return code
10077
10233
 
10078
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
+
10079
10415
  def build_parser() -> argparse.ArgumentParser:
10080
10416
  parser = argparse.ArgumentParser(
10081
10417
  prog="harness_archive.py",
@@ -10235,6 +10571,28 @@ def build_parser() -> argparse.ArgumentParser:
10235
10571
  p_repair.add_argument("--json", action="store_true", default=True)
10236
10572
  p_repair.set_defaults(func=cmd_repair_cli)
10237
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
+
10238
10596
  return parser
10239
10597
 
10240
10598
 
@@ -217,10 +217,14 @@ def _secure_private_path(path: Path, *, directory: bool) -> str:
217
217
 
218
218
  def _sensitive_candidates(root: Path) -> list[dict[str, Any]]:
219
219
  """Find high-confidence plaintext assignments without treating prose as a secret."""
220
- receipt = _sensitive_evidence_receipt_path(root)
220
+ # Compare by relative path, not by resolve() per file: this loop covers the
221
+ # whole change tree, and on Windows each resolve() is a realpath syscall.
222
+ receipt_rel = SECRET_SCAN_RECEIPT_REL.as_posix()
221
223
  candidates: list[dict[str, Any]] = []
222
224
  for path in sorted(root.rglob("*")):
223
- if not path.is_file() or path.resolve() == receipt.resolve():
225
+ if not path.is_file():
226
+ continue
227
+ if path.relative_to(root).as_posix() == receipt_rel:
224
228
  continue
225
229
  try:
226
230
  raw = path.read_bytes()
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "java",
4
4
  "agent": "cursor",
5
- "coreHash": "afb56ded8f2dc0df"
5
+ "coreHash": "89d99fc9f903d46d"
6
6
  }
@@ -3,7 +3,7 @@ name: harness-apidoc
3
3
  description: 从 Java 后端代码生成结构化前端 API 文档(Controller/VO/ErrorCode)。仅当用户显式调用
4
4
  /harness-apidoc 时使用,不得自动触发。
5
5
  ---
6
- <!-- generated by harness_deploy.py; core=afb56ded8f2dc0df; overlay=java; agent=cursor; do not edit -->
6
+ <!-- generated by harness_deploy.py; core=89d99fc9f903d46d; overlay=java; agent=cursor; do not edit -->
7
7
  # harness-apidoc — 前端接口文档(Java overlay)
8
8
 
9
9
  ## Purpose