@hunter-harness/workflow-harness 0.2.46 → 0.2.47

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 (155) hide show
  1. package/README.md +1 -1
  2. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +9 -10
  4. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  8. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +6 -3
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +6 -7
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +6 -3
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +5 -7
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +6 -3
  14. package/harness/bundles/general/claude-code/scripts/harness_archive.py +164 -17
  15. package/harness/bundles/general/claude-code/scripts/harness_events.py +30 -1
  16. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +269 -0
  17. package/harness/bundles/general/claude-code/scripts/harness_gate.py +216 -64
  18. package/harness/bundles/general/claude-code/scripts/harness_headless.py +37 -0
  19. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  20. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +9 -11
  21. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  22. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  23. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  24. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +6 -3
  26. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +6 -7
  27. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +6 -3
  28. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +5 -8
  29. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +6 -3
  31. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +164 -17
  32. package/harness/bundles/general/codebuddy/scripts/harness_events.py +30 -1
  33. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +269 -0
  34. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +216 -64
  35. package/harness/bundles/general/codebuddy/scripts/harness_headless.py +37 -0
  36. package/harness/bundles/general/codex/.harness-build.json +1 -1
  37. package/harness/bundles/general/codex/harness-archive/SKILL.md +9 -11
  38. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  39. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  40. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  41. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  42. package/harness/bundles/general/codex/harness-plan/SKILL.md +6 -3
  43. package/harness/bundles/general/codex/harness-review/SKILL.md +6 -7
  44. package/harness/bundles/general/codex/harness-run/SKILL.md +6 -3
  45. package/harness/bundles/general/codex/harness-submit/SKILL.md +5 -8
  46. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-test/SKILL.md +6 -3
  48. package/harness/bundles/general/codex/scripts/harness_archive.py +164 -17
  49. package/harness/bundles/general/codex/scripts/harness_events.py +30 -1
  50. package/harness/bundles/general/codex/scripts/harness_events_sync.py +269 -0
  51. package/harness/bundles/general/codex/scripts/harness_gate.py +216 -64
  52. package/harness/bundles/general/codex/scripts/harness_headless.py +37 -0
  53. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  54. package/harness/bundles/general/cursor/harness-archive/SKILL.md +9 -11
  55. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  56. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  57. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  58. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  59. package/harness/bundles/general/cursor/harness-plan/SKILL.md +6 -3
  60. package/harness/bundles/general/cursor/harness-review/SKILL.md +6 -7
  61. package/harness/bundles/general/cursor/harness-run/SKILL.md +6 -3
  62. package/harness/bundles/general/cursor/harness-submit/SKILL.md +5 -8
  63. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  64. package/harness/bundles/general/cursor/harness-test/SKILL.md +6 -3
  65. package/harness/bundles/general/cursor/scripts/harness_archive.py +164 -17
  66. package/harness/bundles/general/cursor/scripts/harness_events.py +30 -1
  67. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +269 -0
  68. package/harness/bundles/general/cursor/scripts/harness_gate.py +216 -64
  69. package/harness/bundles/general/cursor/scripts/harness_headless.py +37 -0
  70. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  71. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +3 -2
  72. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +9 -10
  73. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  74. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  75. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  76. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  77. package/harness/bundles/java/claude-code/harness-package/SKILL.md +3 -2
  78. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +6 -3
  79. package/harness/bundles/java/claude-code/harness-review/SKILL.md +6 -7
  80. package/harness/bundles/java/claude-code/harness-run/SKILL.md +6 -3
  81. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +5 -7
  82. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  83. package/harness/bundles/java/claude-code/harness-test/SKILL.md +6 -3
  84. package/harness/bundles/java/claude-code/scripts/harness_archive.py +164 -17
  85. package/harness/bundles/java/claude-code/scripts/harness_events.py +30 -1
  86. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +269 -0
  87. package/harness/bundles/java/claude-code/scripts/harness_gate.py +216 -64
  88. package/harness/bundles/java/claude-code/scripts/harness_headless.py +37 -0
  89. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  90. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +3 -2
  91. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +9 -11
  92. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  95. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  96. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +2 -2
  97. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +6 -3
  98. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +6 -7
  99. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +6 -3
  100. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +5 -8
  101. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  102. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +6 -3
  103. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +164 -17
  104. package/harness/bundles/java/codebuddy/scripts/harness_events.py +30 -1
  105. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +269 -0
  106. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +216 -64
  107. package/harness/bundles/java/codebuddy/scripts/harness_headless.py +37 -0
  108. package/harness/bundles/java/codex/.harness-build.json +1 -1
  109. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +3 -2
  110. package/harness/bundles/java/codex/harness-archive/SKILL.md +9 -11
  111. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  112. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  113. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  114. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  115. package/harness/bundles/java/codex/harness-package/SKILL.md +2 -2
  116. package/harness/bundles/java/codex/harness-plan/SKILL.md +6 -3
  117. package/harness/bundles/java/codex/harness-review/SKILL.md +6 -7
  118. package/harness/bundles/java/codex/harness-run/SKILL.md +6 -3
  119. package/harness/bundles/java/codex/harness-submit/SKILL.md +5 -8
  120. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  121. package/harness/bundles/java/codex/harness-test/SKILL.md +6 -3
  122. package/harness/bundles/java/codex/scripts/harness_archive.py +164 -17
  123. package/harness/bundles/java/codex/scripts/harness_events.py +30 -1
  124. package/harness/bundles/java/codex/scripts/harness_events_sync.py +269 -0
  125. package/harness/bundles/java/codex/scripts/harness_gate.py +216 -64
  126. package/harness/bundles/java/codex/scripts/harness_headless.py +37 -0
  127. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  128. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +3 -2
  129. package/harness/bundles/java/cursor/harness-archive/SKILL.md +9 -11
  130. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  131. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  132. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +347 -1
  133. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  134. package/harness/bundles/java/cursor/harness-package/SKILL.md +2 -2
  135. package/harness/bundles/java/cursor/harness-plan/SKILL.md +6 -3
  136. package/harness/bundles/java/cursor/harness-review/SKILL.md +6 -7
  137. package/harness/bundles/java/cursor/harness-run/SKILL.md +6 -3
  138. package/harness/bundles/java/cursor/harness-submit/SKILL.md +5 -8
  139. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  140. package/harness/bundles/java/cursor/harness-test/SKILL.md +6 -3
  141. package/harness/bundles/java/cursor/scripts/harness_archive.py +164 -17
  142. package/harness/bundles/java/cursor/scripts/harness_events.py +30 -1
  143. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +269 -0
  144. package/harness/bundles/java/cursor/scripts/harness_gate.py +216 -64
  145. package/harness/bundles/java/cursor/scripts/harness_headless.py +37 -0
  146. package/harness/manifests/general/claude-code.json +25 -17
  147. package/harness/manifests/general/codebuddy.json +25 -17
  148. package/harness/manifests/general/codex.json +25 -17
  149. package/harness/manifests/general/cursor.json +25 -17
  150. package/harness/manifests/java/claude-code.json +27 -19
  151. package/harness/manifests/java/codebuddy.json +27 -19
  152. package/harness/manifests/java/codex.json +27 -19
  153. package/harness/manifests/java/cursor.json +27 -19
  154. package/hunter-workflow-family.json +4 -4
  155. 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 must restore + exit non-0).
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
- # Task 2 (§4.1 rule 5): 不再存在"没有 HTML 但只 warning"的分支。
5422
- # 缺 final-summary 恒为 error;finalize 会 restore + exit 0。
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": "error",
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
- # 永不关闭一个没有 final-summary 的归档。
8018
+ # final-summary.html 是 summary-data.json 的可再生展示层:渲染失败
8019
+ # 降级为 warning,归档继续(平台端可按 summary-data 重新渲染)。
8000
8020
  msg = str(render_result.get("fallbackReason") or "render failed")
8001
- payload["error"] = f"final-summary render failed: {msg}"
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
- payload["error"] = f"final summary re-render failed: {render_result.get('error')}"
8057
- _restore_finalize_failure()
8058
- return 1, payload
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
- payload["error"] = (
8096
- f"post-manifest summary render failed: {render_result.get('error')}"
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())