@hunter-harness/workflow-harness 0.2.46 → 0.2.48
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/README.md +1 -1
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/README.md +9 -8
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +10 -11
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +6 -3
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +6 -7
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +6 -3
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +5 -7
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +6 -3
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +164 -17
- package/harness/bundles/general/claude-code/scripts/harness_events.py +30 -1
- package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +222 -64
- package/harness/bundles/general/claude-code/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/README.md +9 -8
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +10 -12
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +6 -3
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +6 -7
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +6 -3
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +5 -8
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +6 -3
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +164 -17
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +30 -1
- package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +222 -64
- package/harness/bundles/general/codebuddy/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/README.md +9 -8
- package/harness/bundles/general/codex/harness-archive/SKILL.md +10 -12
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +6 -3
- package/harness/bundles/general/codex/harness-review/SKILL.md +6 -7
- package/harness/bundles/general/codex/harness-run/SKILL.md +6 -3
- package/harness/bundles/general/codex/harness-submit/SKILL.md +5 -8
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +6 -3
- package/harness/bundles/general/codex/scripts/harness_archive.py +164 -17
- package/harness/bundles/general/codex/scripts/harness_events.py +30 -1
- package/harness/bundles/general/codex/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/general/codex/scripts/harness_gate.py +222 -64
- package/harness/bundles/general/codex/scripts/harness_headless.py +37 -0
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/README.md +9 -8
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +10 -12
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +6 -3
- package/harness/bundles/general/cursor/harness-review/SKILL.md +6 -7
- package/harness/bundles/general/cursor/harness-run/SKILL.md +6 -3
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +5 -8
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +6 -3
- package/harness/bundles/general/cursor/scripts/harness_archive.py +164 -17
- package/harness/bundles/general/cursor/scripts/harness_events.py +30 -1
- package/harness/bundles/general/cursor/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/general/cursor/scripts/harness_gate.py +222 -64
- package/harness/bundles/general/cursor/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/README.md +9 -8
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +3 -2
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +10 -11
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +3 -2
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +6 -3
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +6 -7
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +6 -3
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +5 -7
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +6 -3
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +164 -17
- package/harness/bundles/java/claude-code/scripts/harness_events.py +30 -1
- package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +222 -64
- package/harness/bundles/java/claude-code/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/README.md +9 -8
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +3 -2
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +10 -12
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +6 -3
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +6 -7
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +6 -3
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +5 -8
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +6 -3
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +164 -17
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +30 -1
- package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +222 -64
- package/harness/bundles/java/codebuddy/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/README.md +9 -8
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +3 -2
- package/harness/bundles/java/codex/harness-archive/SKILL.md +10 -12
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
- package/harness/bundles/java/codex/harness-plan/SKILL.md +6 -3
- package/harness/bundles/java/codex/harness-review/SKILL.md +6 -7
- package/harness/bundles/java/codex/harness-run/SKILL.md +6 -3
- package/harness/bundles/java/codex/harness-submit/SKILL.md +5 -8
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +6 -3
- package/harness/bundles/java/codex/scripts/harness_archive.py +164 -17
- package/harness/bundles/java/codex/scripts/harness_events.py +30 -1
- package/harness/bundles/java/codex/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/java/codex/scripts/harness_gate.py +222 -64
- package/harness/bundles/java/codex/scripts/harness_headless.py +37 -0
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/README.md +9 -8
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +3 -2
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +10 -12
- 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-ingest/scripts/harness_knowledge.py +347 -1
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +6 -3
- package/harness/bundles/java/cursor/harness-review/SKILL.md +6 -7
- package/harness/bundles/java/cursor/harness-run/SKILL.md +6 -3
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +5 -8
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +6 -3
- package/harness/bundles/java/cursor/scripts/harness_archive.py +164 -17
- package/harness/bundles/java/cursor/scripts/harness_events.py +30 -1
- package/harness/bundles/java/cursor/scripts/harness_events_sync.py +269 -0
- package/harness/bundles/java/cursor/scripts/harness_gate.py +222 -64
- package/harness/bundles/java/cursor/scripts/harness_headless.py +37 -0
- package/harness/manifests/general/claude-code.json +26 -18
- package/harness/manifests/general/codebuddy.json +26 -18
- package/harness/manifests/general/codex.json +26 -18
- package/harness/manifests/general/cursor.json +26 -18
- package/harness/manifests/java/claude-code.json +28 -20
- package/harness/manifests/java/codebuddy.json +28 -20
- package/harness/manifests/java/codex.json +28 -20
- package/harness/manifests/java/cursor.json +28 -20
- package/hunter-workflow-family.json +4 -4
- package/package.json +1 -1
|
@@ -5024,6 +5024,14 @@ def render_fallback_html(summary: dict[str, Any]) -> str:
|
|
|
5024
5024
|
"""
|
|
5025
5025
|
|
|
5026
5026
|
|
|
5027
|
+
|
|
5028
|
+
# P1 slim-files: final-summary.html is a regenerable presentation layer over
|
|
5029
|
+
# summary-data.json. `finalize --no-html` skips local HTML rendering entirely
|
|
5030
|
+
# (a remote platform can render from summary-data.json); render failures are
|
|
5031
|
+
# downgraded to warnings instead of rolling back the archive.
|
|
5032
|
+
RENDER_HTML_ENABLED = True
|
|
5033
|
+
|
|
5034
|
+
|
|
5027
5035
|
def render_final_summary(
|
|
5028
5036
|
change_dir: Path,
|
|
5029
5037
|
summary_path: Path,
|
|
@@ -5034,9 +5042,18 @@ def render_final_summary(
|
|
|
5034
5042
|
- Node success -> renderer="node".
|
|
5035
5043
|
- Node unavailable/timeout/non-zero/no-file -> Python fallback; success ->
|
|
5036
5044
|
renderer="python-fallback" (fallbackReason carries the node failure cause).
|
|
5037
|
-
- Both fail or produce no file -> ok=False (caller
|
|
5045
|
+
- Both fail or produce no file -> ok=False (caller downgrades to warning).
|
|
5046
|
+
- Rendering disabled (--no-html) -> ok=True with renderer="skipped".
|
|
5038
5047
|
"""
|
|
5039
5048
|
out_path = change_dir / "reports" / "final" / "final-summary.html"
|
|
5049
|
+
if not RENDER_HTML_ENABLED:
|
|
5050
|
+
return {
|
|
5051
|
+
"ok": True,
|
|
5052
|
+
"renderer": "skipped",
|
|
5053
|
+
"fallbackReason": "",
|
|
5054
|
+
"out_path": str(out_path),
|
|
5055
|
+
"skipped": True,
|
|
5056
|
+
}
|
|
5040
5057
|
project = find_project_root(change_dir)
|
|
5041
5058
|
node = resolve_node_path(project)
|
|
5042
5059
|
fallback_reason = ""
|
|
@@ -5418,12 +5435,14 @@ def validate_summary(
|
|
|
5418
5435
|
}
|
|
5419
5436
|
)
|
|
5420
5437
|
else:
|
|
5421
|
-
#
|
|
5422
|
-
#
|
|
5438
|
+
# P1 slim-files: final-summary.html 是 summary-data.json 的可再生展示
|
|
5439
|
+
# 投影,不再是硬产物(summary-data + manifest + adequacy 仍 fail-closed)。
|
|
5440
|
+
# 缺 HTML 恒为 warning,不阻断归档收尾;平台端可按 summary-data 渲染。
|
|
5441
|
+
_ = render_skipped # kept for call-site compatibility
|
|
5423
5442
|
issues.append(
|
|
5424
5443
|
{
|
|
5425
5444
|
"code": "missing-final-report",
|
|
5426
|
-
"severity": "
|
|
5445
|
+
"severity": "warning",
|
|
5427
5446
|
"message": "reports/final/final-summary.html not found",
|
|
5428
5447
|
}
|
|
5429
5448
|
)
|
|
@@ -7996,13 +8015,10 @@ def cmd_finalize(
|
|
|
7996
8015
|
render_result = render_final_summary(work_dir, summary_path)
|
|
7997
8016
|
payload["steps"]["render"] = render_result
|
|
7998
8017
|
if not render_result.get("ok"):
|
|
7999
|
-
#
|
|
8018
|
+
# final-summary.html 是 summary-data.json 的可再生展示层:渲染失败
|
|
8019
|
+
# 降级为 warning,归档继续(平台端可按 summary-data 重新渲染)。
|
|
8000
8020
|
msg = str(render_result.get("fallbackReason") or "render failed")
|
|
8001
|
-
|
|
8002
|
-
_restore_finalize_failure()
|
|
8003
|
-
payload["warnings"] = warnings
|
|
8004
|
-
payload["ok"] = False
|
|
8005
|
-
return 1, payload
|
|
8021
|
+
warnings.append(f"final-summary render failed (archive continues): {msg}")
|
|
8006
8022
|
renderer = render_result.get("renderer")
|
|
8007
8023
|
if renderer == "python-fallback" and render_result.get("fallbackReason"):
|
|
8008
8024
|
warnings.append(
|
|
@@ -8053,9 +8069,10 @@ def cmd_finalize(
|
|
|
8053
8069
|
write_json(summary_path, summary)
|
|
8054
8070
|
render_result = render_final_summary(work_dir, summary_path)
|
|
8055
8071
|
if not render_result.get("ok"):
|
|
8056
|
-
|
|
8057
|
-
|
|
8058
|
-
|
|
8072
|
+
warnings.append(
|
|
8073
|
+
"final summary re-render failed (archive continues): "
|
|
8074
|
+
f"{render_result.get('fallbackReason') or render_result.get('error')}"
|
|
8075
|
+
)
|
|
8059
8076
|
summary = read_json(summary_path)
|
|
8060
8077
|
validate_result = validate_summary(summary, html_path if html_path.is_file() else None)
|
|
8061
8078
|
payload["steps"]["validate"] = validate_result
|
|
@@ -8092,11 +8109,10 @@ def cmd_finalize(
|
|
|
8092
8109
|
write_json(summary_path, summary)
|
|
8093
8110
|
render_result = render_final_summary(work_dir, summary_path)
|
|
8094
8111
|
if not render_result.get("ok"):
|
|
8095
|
-
|
|
8096
|
-
|
|
8112
|
+
warnings.append(
|
|
8113
|
+
"post-manifest summary render failed (archive continues): "
|
|
8114
|
+
f"{render_result.get('fallbackReason') or render_result.get('error')}"
|
|
8097
8115
|
)
|
|
8098
|
-
_restore_finalize_failure()
|
|
8099
|
-
return 1, payload
|
|
8100
8116
|
coverage = verify_manifest_byte_coverage(
|
|
8101
8117
|
work_dir,
|
|
8102
8118
|
after_manifest,
|
|
@@ -8345,6 +8361,13 @@ def cmd_finalize(
|
|
|
8345
8361
|
except OSError as exc:
|
|
8346
8362
|
warnings.append(f"could not persist completed archive operation: {exc}")
|
|
8347
8363
|
|
|
8364
|
+
# P4: when remote credentials exist, auto-push the core four
|
|
8365
|
+
# (design/plan/summary-data/knowledge) via the existing push channel.
|
|
8366
|
+
push_result = auto_push_archive_core(project_root, archive_dir)
|
|
8367
|
+
payload["steps"]["archive_push"] = push_result
|
|
8368
|
+
if push_result.get("warning"):
|
|
8369
|
+
warnings.append(str(push_result["warning"]))
|
|
8370
|
+
|
|
8348
8371
|
payload["ok"] = True
|
|
8349
8372
|
payload["finalStatus"] = "OK"
|
|
8350
8373
|
payload["warnings"] = warnings
|
|
@@ -8353,6 +8376,122 @@ def cmd_finalize(
|
|
|
8353
8376
|
return 0, payload
|
|
8354
8377
|
|
|
8355
8378
|
|
|
8379
|
+
def _remote_credentials_configured(project_root: Path) -> bool:
|
|
8380
|
+
creds = project_root / ".harness" / "credentials.local.yaml"
|
|
8381
|
+
if not creds.is_file():
|
|
8382
|
+
return False
|
|
8383
|
+
text = creds.read_text(encoding="utf-8", errors="ignore")
|
|
8384
|
+
has_url = bool(re.search(r"(?m)^[ \t]*server_url:\s*\S+", text))
|
|
8385
|
+
has_token = bool(re.search(r"(?m)^[ \t]*token:\s*\S+", text))
|
|
8386
|
+
return has_url and has_token
|
|
8387
|
+
|
|
8388
|
+
|
|
8389
|
+
def collect_archive_core_paths(project_root: Path, archive_dir: Path) -> list[str]:
|
|
8390
|
+
"""Relative paths for the P4 core four push set."""
|
|
8391
|
+
root = project_root.resolve()
|
|
8392
|
+
archive = archive_dir.resolve()
|
|
8393
|
+
paths: list[str] = []
|
|
8394
|
+
|
|
8395
|
+
def add_if_exists(absolute: Path) -> None:
|
|
8396
|
+
if absolute.is_file():
|
|
8397
|
+
paths.append(absolute.relative_to(root).as_posix())
|
|
8398
|
+
|
|
8399
|
+
add_if_exists(archive / "reports" / "final" / "summary-data.json")
|
|
8400
|
+
for folder in ("spec", "plans"):
|
|
8401
|
+
base = archive / folder
|
|
8402
|
+
if base.is_dir():
|
|
8403
|
+
for path in sorted(base.rglob("*")):
|
|
8404
|
+
if path.is_file():
|
|
8405
|
+
paths.append(path.relative_to(root).as_posix())
|
|
8406
|
+
knowledge = root / ".harness" / "knowledge" / "entries"
|
|
8407
|
+
for status in ("active", "candidate"):
|
|
8408
|
+
folder = knowledge / status
|
|
8409
|
+
if folder.is_dir():
|
|
8410
|
+
for path in sorted(folder.glob("*.json")):
|
|
8411
|
+
paths.append(path.relative_to(root).as_posix())
|
|
8412
|
+
return paths
|
|
8413
|
+
|
|
8414
|
+
|
|
8415
|
+
def auto_push_archive_core(project_root: Path, archive_dir: Path) -> dict[str, Any]:
|
|
8416
|
+
"""Best-effort remote push of archive core artifacts after finalize.
|
|
8417
|
+
|
|
8418
|
+
Skips silently when credentials are absent. Failures become warnings so
|
|
8419
|
+
archive success is never rolled back by a push outage.
|
|
8420
|
+
"""
|
|
8421
|
+
project_root = project_root.resolve()
|
|
8422
|
+
if not _remote_credentials_configured(project_root):
|
|
8423
|
+
return {"skipped": True, "reason": "no remote credentials"}
|
|
8424
|
+
core_paths = collect_archive_core_paths(project_root, archive_dir)
|
|
8425
|
+
pending = {
|
|
8426
|
+
"schemaVersion": 1,
|
|
8427
|
+
"generatedAt": now_iso(),
|
|
8428
|
+
"archiveDir": str(archive_dir),
|
|
8429
|
+
"paths": core_paths,
|
|
8430
|
+
}
|
|
8431
|
+
pending_path = project_root / ".harness" / "state" / "archive-push-pending.json"
|
|
8432
|
+
try:
|
|
8433
|
+
pending_path.parent.mkdir(parents=True, exist_ok=True)
|
|
8434
|
+
write_json(pending_path, pending)
|
|
8435
|
+
except OSError as exc:
|
|
8436
|
+
return {
|
|
8437
|
+
"skipped": False,
|
|
8438
|
+
"ok": False,
|
|
8439
|
+
"paths": core_paths,
|
|
8440
|
+
"warning": f"could not write archive-push-pending.json: {exc}",
|
|
8441
|
+
}
|
|
8442
|
+
|
|
8443
|
+
# Prefer the published CLI; fall back to recording the pending receipt only.
|
|
8444
|
+
command = [
|
|
8445
|
+
"npx",
|
|
8446
|
+
"--yes",
|
|
8447
|
+
"hunter-harness",
|
|
8448
|
+
"push",
|
|
8449
|
+
"--yes",
|
|
8450
|
+
"--non-interactive",
|
|
8451
|
+
"--json",
|
|
8452
|
+
]
|
|
8453
|
+
try:
|
|
8454
|
+
completed = subprocess.run(
|
|
8455
|
+
command,
|
|
8456
|
+
cwd=str(project_root),
|
|
8457
|
+
text=True,
|
|
8458
|
+
stdout=subprocess.PIPE,
|
|
8459
|
+
stderr=subprocess.PIPE,
|
|
8460
|
+
timeout=180,
|
|
8461
|
+
shell=False,
|
|
8462
|
+
)
|
|
8463
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
8464
|
+
return {
|
|
8465
|
+
"skipped": False,
|
|
8466
|
+
"ok": False,
|
|
8467
|
+
"paths": core_paths,
|
|
8468
|
+
"pending": str(pending_path),
|
|
8469
|
+
"warning": f"archive auto-push deferred: {exc}",
|
|
8470
|
+
}
|
|
8471
|
+
ok = completed.returncode == 0
|
|
8472
|
+
result: dict[str, Any] = {
|
|
8473
|
+
"skipped": False,
|
|
8474
|
+
"ok": ok,
|
|
8475
|
+
"paths": core_paths,
|
|
8476
|
+
"pending": str(pending_path),
|
|
8477
|
+
"exitCode": completed.returncode,
|
|
8478
|
+
}
|
|
8479
|
+
if not ok:
|
|
8480
|
+
result["warning"] = (
|
|
8481
|
+
"archive auto-push failed (exit "
|
|
8482
|
+
+ str(completed.returncode)
|
|
8483
|
+
+ "); pending receipt kept for retry via `hunter-harness push`"
|
|
8484
|
+
)
|
|
8485
|
+
if completed.stderr.strip():
|
|
8486
|
+
result["stderr"] = completed.stderr.strip()[:500]
|
|
8487
|
+
else:
|
|
8488
|
+
try:
|
|
8489
|
+
pending_path.unlink(missing_ok=True)
|
|
8490
|
+
except OSError:
|
|
8491
|
+
pass
|
|
8492
|
+
return result
|
|
8493
|
+
|
|
8494
|
+
|
|
8356
8495
|
def _merge_runtime_state(state_dir: Path, contract_dir: Path) -> None:
|
|
8357
8496
|
"""Materialize split-v1 dynamic state into the archive contract tree."""
|
|
8358
8497
|
for source in sorted(state_dir.iterdir()):
|
|
@@ -8513,6 +8652,9 @@ def cmd_certify_local_cli(args: argparse.Namespace) -> int:
|
|
|
8513
8652
|
|
|
8514
8653
|
|
|
8515
8654
|
def cmd_finalize_cli(args: argparse.Namespace) -> int:
|
|
8655
|
+
global RENDER_HTML_ENABLED
|
|
8656
|
+
if getattr(args, "no_html", False):
|
|
8657
|
+
RENDER_HTML_ENABLED = False
|
|
8516
8658
|
change_dir = resolve_path(args.change_dir)
|
|
8517
8659
|
archive_root = resolve_path(args.archive_root)
|
|
8518
8660
|
durable_root = (
|
|
@@ -8801,6 +8943,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
8801
8943
|
default="release-candidate",
|
|
8802
8944
|
help="release-candidate enforces candidate evidence; record-only archives facts without release eligibility",
|
|
8803
8945
|
)
|
|
8946
|
+
p_fin.add_argument(
|
|
8947
|
+
"--no-html",
|
|
8948
|
+
action="store_true",
|
|
8949
|
+
help="skip local final-summary.html rendering (summary-data.json stays canonical)",
|
|
8950
|
+
)
|
|
8804
8951
|
p_fin.add_argument("--json", action="store_true", default=True)
|
|
8805
8952
|
p_fin.set_defaults(func=cmd_finalize_cli)
|
|
8806
8953
|
|
|
@@ -1424,6 +1424,34 @@ def write_execution_log(change_dir: Path, content: str) -> Path:
|
|
|
1424
1424
|
return path
|
|
1425
1425
|
|
|
1426
1426
|
|
|
1427
|
+
def execution_log_render_enabled(change_dir: Path) -> bool:
|
|
1428
|
+
"""P1 slim-files: execution-log.md auto-render policy.
|
|
1429
|
+
|
|
1430
|
+
`.harness/config/render-policy.json` with ``{"executionLog": "on-demand"}``
|
|
1431
|
+
disables the automatic render on ``phase.end``/auto-seal; the explicit
|
|
1432
|
+
``harness_events.py render`` subcommand rebuilds the projection at any
|
|
1433
|
+
time. Default stays "auto" (render on phase.end).
|
|
1434
|
+
"""
|
|
1435
|
+
current = change_dir.resolve()
|
|
1436
|
+
for candidate in [current, *current.parents]:
|
|
1437
|
+
if candidate.name == ".harness":
|
|
1438
|
+
config = candidate / "config" / "render-policy.json"
|
|
1439
|
+
else:
|
|
1440
|
+
config = candidate / ".harness" / "config" / "render-policy.json"
|
|
1441
|
+
if not config.is_file():
|
|
1442
|
+
continue
|
|
1443
|
+
try:
|
|
1444
|
+
document = json.loads(config.read_text(encoding="utf-8-sig"))
|
|
1445
|
+
except (OSError, json.JSONDecodeError):
|
|
1446
|
+
return True
|
|
1447
|
+
if isinstance(document, dict):
|
|
1448
|
+
mode = str(document.get("executionLog") or "").strip().lower()
|
|
1449
|
+
if mode == "on-demand":
|
|
1450
|
+
return False
|
|
1451
|
+
return True
|
|
1452
|
+
return True
|
|
1453
|
+
|
|
1454
|
+
|
|
1427
1455
|
def build_summary(change_dir: Path, events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1428
1456
|
phases: dict[str, Any] = {}
|
|
1429
1457
|
issues: list[dict[str, Any]] = []
|
|
@@ -1774,7 +1802,8 @@ def cmd_append(args: argparse.Namespace) -> int:
|
|
|
1774
1802
|
rendered = False
|
|
1775
1803
|
log_path = None
|
|
1776
1804
|
log_lines = None
|
|
1777
|
-
if args.type == "phase.end" or auto_sealed_events
|
|
1805
|
+
if (args.type == "phase.end" or auto_sealed_events) and \
|
|
1806
|
+
execution_log_render_enabled(change_dir):
|
|
1778
1807
|
try:
|
|
1779
1808
|
events = load_events(path)
|
|
1780
1809
|
content = render_execution_log(events)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""P4: sync local events.ndjson to the platform Run monitoring API.
|
|
3
|
+
|
|
4
|
+
Reads `.harness/credentials.local.yaml` + project_id, whitelists event fields,
|
|
5
|
+
POSTs batches with ACK cursor, and sends heartbeats. Offline-safe: cursor only
|
|
6
|
+
advances on accepted/duplicate_accepted responses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
WHITELIST = {
|
|
22
|
+
"id",
|
|
23
|
+
"ts",
|
|
24
|
+
"type",
|
|
25
|
+
"phase",
|
|
26
|
+
"status",
|
|
27
|
+
"note",
|
|
28
|
+
"attempt",
|
|
29
|
+
"name",
|
|
30
|
+
"code",
|
|
31
|
+
"severity",
|
|
32
|
+
"decision",
|
|
33
|
+
"reason",
|
|
34
|
+
"exit_code",
|
|
35
|
+
"duration_ms",
|
|
36
|
+
"schemaVersion",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _yaml_scalar(text: str, key: str) -> str | None:
|
|
41
|
+
match = re.search(rf"(?m)^[ \t]*{re.escape(key)}:\s*['\"]?([^'\"\n#]+)", text)
|
|
42
|
+
if match is None:
|
|
43
|
+
return None
|
|
44
|
+
value = match.group(1).strip()
|
|
45
|
+
return value or None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_endpoint(project: Path) -> dict[str, str] | None:
|
|
49
|
+
creds = project / ".harness" / "credentials.local.yaml"
|
|
50
|
+
if not creds.is_file():
|
|
51
|
+
return None
|
|
52
|
+
text = creds.read_text(encoding="utf-8", errors="ignore")
|
|
53
|
+
server_url = _yaml_scalar(text, "server_url")
|
|
54
|
+
token = _yaml_scalar(text, "token")
|
|
55
|
+
project_id = _yaml_scalar(text, "project_id")
|
|
56
|
+
if not project_id:
|
|
57
|
+
py = project / ".harness" / "project.yaml"
|
|
58
|
+
if py.is_file():
|
|
59
|
+
project_id = _yaml_scalar(py.read_text(encoding="utf-8", errors="ignore"), "project_id")
|
|
60
|
+
if not server_url or not token or not project_id:
|
|
61
|
+
return None
|
|
62
|
+
if project_id.lower() in {"null", "~", "none"}:
|
|
63
|
+
return None
|
|
64
|
+
return {
|
|
65
|
+
"server_url": server_url.rstrip("/"),
|
|
66
|
+
"token": token,
|
|
67
|
+
"project_id": project_id,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sanitize(event: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
return {key: event[key] for key in WHITELIST if key in event}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cursor_path(change_dir: Path) -> Path:
|
|
76
|
+
return change_dir / "meta" / "events-sync-cursor.json"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def load_cursor(change_dir: Path) -> int:
|
|
80
|
+
path = cursor_path(change_dir)
|
|
81
|
+
if not path.is_file():
|
|
82
|
+
return 0
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
85
|
+
except (OSError, json.JSONDecodeError):
|
|
86
|
+
return 0
|
|
87
|
+
return int(data.get("acked_lines") or 0)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def save_cursor(change_dir: Path, acked_lines: int) -> None:
|
|
91
|
+
path = cursor_path(change_dir)
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
path.write_text(
|
|
94
|
+
json.dumps({"schemaVersion": 1, "acked_lines": acked_lines}, indent=2) + "\n",
|
|
95
|
+
encoding="utf-8",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_id_for(change_dir: Path) -> str:
|
|
100
|
+
# Stable opaque id from absolute path — no hostname/username leakage.
|
|
101
|
+
digest = hashlib.sha256(str(change_dir.resolve()).encode("utf-8")).hexdigest()[:24]
|
|
102
|
+
return "run_" + digest
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def post_json(endpoint: dict[str, str], path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
106
|
+
url = endpoint["server_url"] + path
|
|
107
|
+
data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
108
|
+
request = urllib.request.Request(
|
|
109
|
+
url,
|
|
110
|
+
data=data,
|
|
111
|
+
method="POST",
|
|
112
|
+
headers={
|
|
113
|
+
"Accept": "application/json",
|
|
114
|
+
"Authorization": f"Bearer {endpoint['token']}",
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
119
|
+
return json.loads(response.read().decode("utf-8"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def sync_change(project: Path, change_dir: Path, *, heartbeat_only: bool = False) -> dict[str, Any]:
|
|
123
|
+
endpoint = load_endpoint(project)
|
|
124
|
+
if endpoint is None:
|
|
125
|
+
return {"ok": True, "skipped": True, "reason": "remote credentials not configured"}
|
|
126
|
+
change_key = change_dir.name
|
|
127
|
+
rid = run_id_for(change_dir)
|
|
128
|
+
project_id = endpoint["project_id"]
|
|
129
|
+
|
|
130
|
+
# Always heartbeat so the console can separate connection vs run status.
|
|
131
|
+
try:
|
|
132
|
+
from datetime import datetime, timezone
|
|
133
|
+
|
|
134
|
+
client_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
135
|
+
post_json(
|
|
136
|
+
endpoint,
|
|
137
|
+
f"/api/v1/projects/{project_id}/runs/heartbeats",
|
|
138
|
+
{
|
|
139
|
+
"protocol_version": "hunter-progress-sync/v1",
|
|
140
|
+
"run_id": rid,
|
|
141
|
+
"change_key": change_key,
|
|
142
|
+
"client_time": client_time,
|
|
143
|
+
"title": change_key,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
147
|
+
return {"ok": False, "error": f"heartbeat failed: {exc}", "run_id": rid}
|
|
148
|
+
|
|
149
|
+
if heartbeat_only:
|
|
150
|
+
return {"ok": True, "heartbeat": True, "run_id": rid}
|
|
151
|
+
|
|
152
|
+
events_file = change_dir / "events.ndjson"
|
|
153
|
+
if not events_file.is_file():
|
|
154
|
+
return {"ok": True, "run_id": rid, "uploaded": 0, "reason": "no events.ndjson"}
|
|
155
|
+
|
|
156
|
+
lines = events_file.read_text(encoding="utf-8").splitlines()
|
|
157
|
+
acked = load_cursor(change_dir)
|
|
158
|
+
pending_raw = lines[acked:]
|
|
159
|
+
batch: list[dict[str, Any]] = []
|
|
160
|
+
for offset, line in enumerate(pending_raw, start=1):
|
|
161
|
+
line = line.strip()
|
|
162
|
+
if not line:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
event = json.loads(line)
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
continue
|
|
168
|
+
if not isinstance(event, dict):
|
|
169
|
+
continue
|
|
170
|
+
event_id = str(event.get("id") or f"{change_key}:{acked + offset}")
|
|
171
|
+
payload = sanitize(event)
|
|
172
|
+
batch.append(
|
|
173
|
+
{
|
|
174
|
+
"event_id": event_id,
|
|
175
|
+
"producer_seq": acked + offset,
|
|
176
|
+
"event_type": str(event.get("type") or "unknown"),
|
|
177
|
+
"phase": event.get("phase"),
|
|
178
|
+
"occurred_at": str(event.get("ts") or client_time),
|
|
179
|
+
"payload": payload,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
if not batch:
|
|
183
|
+
save_cursor(change_dir, len(lines))
|
|
184
|
+
return {"ok": True, "run_id": rid, "uploaded": 0, "acked_lines": len(lines)}
|
|
185
|
+
|
|
186
|
+
# Drop None phase keys for strict schema.
|
|
187
|
+
for item in batch:
|
|
188
|
+
if item.get("phase") is None:
|
|
189
|
+
item.pop("phase", None)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
result = post_json(
|
|
193
|
+
endpoint,
|
|
194
|
+
f"/api/v1/projects/{project_id}/runs/events:batch",
|
|
195
|
+
{
|
|
196
|
+
"protocol_version": "hunter-progress-sync/v1",
|
|
197
|
+
"run_id": rid,
|
|
198
|
+
"change_key": change_key,
|
|
199
|
+
"title": change_key,
|
|
200
|
+
"events": batch,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
except urllib.error.HTTPError as exc:
|
|
204
|
+
detail = exc.read().decode("utf-8", errors="replace")
|
|
205
|
+
return {"ok": False, "error": f"batch HTTP {exc.code}: {detail}", "run_id": rid}
|
|
206
|
+
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
|
207
|
+
return {"ok": False, "error": f"batch failed: {exc}", "run_id": rid}
|
|
208
|
+
|
|
209
|
+
items = result.get("items") if isinstance(result, dict) else None
|
|
210
|
+
accepted = 0
|
|
211
|
+
if isinstance(items, list):
|
|
212
|
+
for item in items:
|
|
213
|
+
status = str((item or {}).get("status") or "")
|
|
214
|
+
if status in {"accepted", "duplicate_accepted"}:
|
|
215
|
+
accepted += 1
|
|
216
|
+
# Advance cursor only when the whole batch was accepted/duplicated.
|
|
217
|
+
if accepted == len(batch):
|
|
218
|
+
save_cursor(change_dir, acked + len(pending_raw))
|
|
219
|
+
acked_lines = acked + len(pending_raw)
|
|
220
|
+
else:
|
|
221
|
+
acked_lines = acked
|
|
222
|
+
return {
|
|
223
|
+
"ok": accepted == len(batch),
|
|
224
|
+
"run_id": rid,
|
|
225
|
+
"uploaded": accepted,
|
|
226
|
+
"batch_size": len(batch),
|
|
227
|
+
"acked_lines": acked_lines,
|
|
228
|
+
"server": result,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main(argv: list[str] | None = None) -> int:
|
|
233
|
+
parser = argparse.ArgumentParser(description="Sync events.ndjson to platform Run monitoring")
|
|
234
|
+
parser.add_argument("--project", default=".", help="Project root")
|
|
235
|
+
parser.add_argument("--change-dir", help="Specific change directory")
|
|
236
|
+
parser.add_argument("--heartbeat-only", action="store_true")
|
|
237
|
+
parser.add_argument("--json", action="store_true")
|
|
238
|
+
args = parser.parse_args(argv)
|
|
239
|
+
project = Path(args.project).resolve()
|
|
240
|
+
if args.change_dir:
|
|
241
|
+
changes = [Path(args.change_dir).resolve()]
|
|
242
|
+
else:
|
|
243
|
+
root = project / ".harness" / "changes"
|
|
244
|
+
changes = sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
|
|
245
|
+
results = [
|
|
246
|
+
sync_change(project, change_dir, heartbeat_only=args.heartbeat_only)
|
|
247
|
+
for change_dir in changes
|
|
248
|
+
]
|
|
249
|
+
ok = all(item.get("ok") for item in results) if results else True
|
|
250
|
+
# P5 headless envelope — stable for orchestrators.
|
|
251
|
+
payload = {
|
|
252
|
+
"schema_version": 1,
|
|
253
|
+
"ok": ok,
|
|
254
|
+
"command": "events.sync",
|
|
255
|
+
"change": None,
|
|
256
|
+
"phase": None,
|
|
257
|
+
"exit_code": 0 if ok else 1,
|
|
258
|
+
"warnings": [],
|
|
259
|
+
"errors": [] if ok else [{"message": "one or more change syncs failed"}],
|
|
260
|
+
"result": {"results": results},
|
|
261
|
+
# Backward-compatible aliases used by early callers / tests.
|
|
262
|
+
"results": results,
|
|
263
|
+
}
|
|
264
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
265
|
+
return 0 if ok else 1
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
raise SystemExit(main())
|