@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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +28 -2
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-push/SKILL.md +30 -6
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +518 -46
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +6 -2
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +28 -2
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-push/SKILL.md +29 -5
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +518 -46
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +6 -2
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +28 -2
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-push/SKILL.md +29 -5
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +518 -46
- package/harness/bundles/general/codex/scripts/harness_runtime.py +6 -2
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +28 -2
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-push/SKILL.md +29 -5
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +518 -46
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +6 -2
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +28 -2
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-push/SKILL.md +30 -6
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +518 -46
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +6 -2
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +28 -2
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-push/SKILL.md +29 -5
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +518 -46
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +6 -2
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +28 -2
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-push/SKILL.md +29 -5
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +518 -46
- package/harness/bundles/java/codex/scripts/harness_runtime.py +6 -2
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +28 -2
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-push/SKILL.md +29 -5
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/protocols/sensitive-info-protocol.md +5 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +518 -46
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +6 -2
- package/harness/manifests/general/claude-code.json +18 -18
- package/harness/manifests/general/codebuddy.json +18 -18
- package/harness/manifests/general/codex.json +18 -18
- package/harness/manifests/general/cursor.json +18 -18
- package/harness/manifests/java/claude-code.json +20 -20
- package/harness/manifests/java/codebuddy.json +20 -20
- package/harness/manifests/java/codex.json +20 -20
- package/harness/manifests/java/cursor.json +20 -20
- package/hunter-workflow-family.json +5 -5
- 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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
2134
|
-
|
|
2188
|
+
if policy == "off":
|
|
2189
|
+
# Skipping the scan also skips a full-tree byte read of the change dir.
|
|
2135
2190
|
return {
|
|
2136
|
-
"ok":
|
|
2137
|
-
"reasonCode": "
|
|
2191
|
+
"ok": True,
|
|
2192
|
+
"reasonCode": "SECRET_SCAN_DISABLED",
|
|
2193
|
+
"policy": policy,
|
|
2138
2194
|
"receiptPath": str(receipt_path),
|
|
2139
|
-
"unresolvedFailures":
|
|
2140
|
-
"nextAction": "Quarantine the original bytes before archive publication.",
|
|
2195
|
+
"unresolvedFailures": [],
|
|
2141
2196
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2197
|
+
candidates = hruntime.sensitive_evidence_candidates(change_dir)
|
|
2198
|
+
if candidates:
|
|
2199
|
+
return _advisory_sensitive_gate(
|
|
2200
|
+
{
|
|
2145
2201
|
"ok": False,
|
|
2146
|
-
"reasonCode": "
|
|
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
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
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
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
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":
|
|
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 =
|
|
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 =
|
|
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
|
|