@hunter-harness/workflow-harness 0.2.54 → 0.2.55

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 (186) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +2 -2
  7. package/harness/bundles/general/claude-code/harness-plan/checklist.md +2 -1
  8. package/harness/bundles/general/claude-code/harness-review/SKILL.md +6 -4
  9. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  10. package/harness/bundles/general/claude-code/harness-run/reference.md +2 -2
  11. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  12. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  13. package/harness/bundles/general/claude-code/harness-test/SKILL.md +5 -3
  14. package/harness/bundles/general/claude-code/harness-test/reference.md +1 -1
  15. package/harness/bundles/general/claude-code/scripts/harness_archive.py +46 -7
  16. package/harness/bundles/general/claude-code/scripts/harness_context.py +71 -22
  17. package/harness/bundles/general/claude-code/scripts/harness_events.py +40 -1
  18. package/harness/bundles/general/claude-code/scripts/harness_events_sync.py +93 -14
  19. package/harness/bundles/general/claude-code/scripts/harness_gate.py +165 -1
  20. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +2 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +2 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +37 -44
  23. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  24. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  25. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  26. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +2 -2
  29. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +2 -1
  30. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +6 -4
  31. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  32. package/harness/bundles/general/codebuddy/harness-run/reference.md +2 -2
  33. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +5 -3
  36. package/harness/bundles/general/codebuddy/harness-test/reference.md +1 -1
  37. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +46 -7
  38. package/harness/bundles/general/codebuddy/scripts/harness_context.py +71 -22
  39. package/harness/bundles/general/codebuddy/scripts/harness_events.py +40 -1
  40. package/harness/bundles/general/codebuddy/scripts/harness_events_sync.py +93 -14
  41. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +165 -1
  42. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +2 -0
  43. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +2 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +37 -44
  45. package/harness/bundles/general/codex/.harness-build.json +1 -1
  46. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  47. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  48. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  49. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  50. package/harness/bundles/general/codex/harness-plan/SKILL.md +2 -2
  51. package/harness/bundles/general/codex/harness-plan/checklist.md +2 -1
  52. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
  53. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  54. package/harness/bundles/general/codex/harness-run/reference.md +2 -2
  55. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  56. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-test/SKILL.md +5 -3
  58. package/harness/bundles/general/codex/harness-test/reference.md +1 -1
  59. package/harness/bundles/general/codex/scripts/harness_archive.py +46 -7
  60. package/harness/bundles/general/codex/scripts/harness_context.py +71 -22
  61. package/harness/bundles/general/codex/scripts/harness_events.py +40 -1
  62. package/harness/bundles/general/codex/scripts/harness_events_sync.py +93 -14
  63. package/harness/bundles/general/codex/scripts/harness_gate.py +165 -1
  64. package/harness/bundles/general/codex/scripts/harness_ledger.py +2 -0
  65. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +2 -0
  66. package/harness/bundles/general/codex/scripts/harness_test_guard.py +37 -44
  67. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  68. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  69. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  70. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  71. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  72. package/harness/bundles/general/cursor/harness-plan/SKILL.md +2 -2
  73. package/harness/bundles/general/cursor/harness-plan/checklist.md +2 -1
  74. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
  75. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  76. package/harness/bundles/general/cursor/harness-run/reference.md +2 -2
  77. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  79. package/harness/bundles/general/cursor/harness-test/SKILL.md +5 -3
  80. package/harness/bundles/general/cursor/harness-test/reference.md +1 -1
  81. package/harness/bundles/general/cursor/scripts/harness_archive.py +46 -7
  82. package/harness/bundles/general/cursor/scripts/harness_context.py +71 -22
  83. package/harness/bundles/general/cursor/scripts/harness_events.py +40 -1
  84. package/harness/bundles/general/cursor/scripts/harness_events_sync.py +93 -14
  85. package/harness/bundles/general/cursor/scripts/harness_gate.py +165 -1
  86. package/harness/bundles/general/cursor/scripts/harness_ledger.py +2 -0
  87. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +2 -0
  88. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +37 -44
  89. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  90. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  91. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  92. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  93. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  94. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  95. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  96. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +2 -2
  97. package/harness/bundles/java/claude-code/harness-plan/checklist.md +2 -1
  98. package/harness/bundles/java/claude-code/harness-review/SKILL.md +6 -4
  99. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  100. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-test/SKILL.md +5 -3
  103. package/harness/bundles/java/claude-code/scripts/harness_archive.py +46 -7
  104. package/harness/bundles/java/claude-code/scripts/harness_context.py +71 -22
  105. package/harness/bundles/java/claude-code/scripts/harness_events.py +40 -1
  106. package/harness/bundles/java/claude-code/scripts/harness_events_sync.py +93 -14
  107. package/harness/bundles/java/claude-code/scripts/harness_gate.py +165 -1
  108. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +2 -0
  109. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +2 -0
  110. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +37 -44
  111. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  112. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  113. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  114. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  115. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  116. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  117. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  118. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +2 -2
  119. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +2 -1
  120. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +6 -4
  121. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  122. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +5 -3
  125. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +46 -7
  126. package/harness/bundles/java/codebuddy/scripts/harness_context.py +71 -22
  127. package/harness/bundles/java/codebuddy/scripts/harness_events.py +40 -1
  128. package/harness/bundles/java/codebuddy/scripts/harness_events_sync.py +93 -14
  129. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +165 -1
  130. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +2 -0
  131. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +2 -0
  132. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +37 -44
  133. package/harness/bundles/java/codex/.harness-build.json +1 -1
  134. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  135. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  136. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  137. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  138. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  139. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  140. package/harness/bundles/java/codex/harness-plan/SKILL.md +2 -2
  141. package/harness/bundles/java/codex/harness-plan/checklist.md +2 -1
  142. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
  143. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  144. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  145. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  146. package/harness/bundles/java/codex/harness-test/SKILL.md +5 -3
  147. package/harness/bundles/java/codex/scripts/harness_archive.py +46 -7
  148. package/harness/bundles/java/codex/scripts/harness_context.py +71 -22
  149. package/harness/bundles/java/codex/scripts/harness_events.py +40 -1
  150. package/harness/bundles/java/codex/scripts/harness_events_sync.py +93 -14
  151. package/harness/bundles/java/codex/scripts/harness_gate.py +165 -1
  152. package/harness/bundles/java/codex/scripts/harness_ledger.py +2 -0
  153. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +2 -0
  154. package/harness/bundles/java/codex/scripts/harness_test_guard.py +37 -44
  155. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  156. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  157. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  158. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  159. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  160. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  161. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  162. package/harness/bundles/java/cursor/harness-plan/SKILL.md +2 -2
  163. package/harness/bundles/java/cursor/harness-plan/checklist.md +2 -1
  164. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
  165. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  166. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  167. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  168. package/harness/bundles/java/cursor/harness-test/SKILL.md +5 -3
  169. package/harness/bundles/java/cursor/scripts/harness_archive.py +46 -7
  170. package/harness/bundles/java/cursor/scripts/harness_context.py +71 -22
  171. package/harness/bundles/java/cursor/scripts/harness_events.py +40 -1
  172. package/harness/bundles/java/cursor/scripts/harness_events_sync.py +93 -14
  173. package/harness/bundles/java/cursor/scripts/harness_gate.py +165 -1
  174. package/harness/bundles/java/cursor/scripts/harness_ledger.py +2 -0
  175. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +2 -0
  176. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +37 -44
  177. package/harness/manifests/general/claude-code.json +24 -24
  178. package/harness/manifests/general/codebuddy.json +24 -24
  179. package/harness/manifests/general/codex.json +24 -24
  180. package/harness/manifests/general/cursor.json +24 -24
  181. package/harness/manifests/java/claude-code.json +24 -24
  182. package/harness/manifests/java/codebuddy.json +24 -24
  183. package/harness/manifests/java/codex.json +24 -24
  184. package/harness/manifests/java/cursor.json +24 -24
  185. package/hunter-workflow-family.json +5 -4
  186. package/package.json +1 -1
@@ -17,6 +17,7 @@ import argparse
17
17
  import datetime as dt
18
18
  import hashlib
19
19
  import io
20
+ import ipaddress
20
21
  import json
21
22
  import os
22
23
  import re
@@ -31,6 +32,7 @@ import zipfile
31
32
  from collections.abc import Mapping
32
33
  from pathlib import Path
33
34
  from typing import Any
35
+ from urllib.parse import urlsplit
34
36
 
35
37
 
36
38
  if hasattr(sys.stdout, "reconfigure"):
@@ -192,9 +194,9 @@ def append_event(
192
194
  for key, value in fields.items():
193
195
  if value is not None:
194
196
  event[key] = value
195
- line = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
196
- he.atomic_append_line(path, line)
197
- all_events = existing + [he.normalize_event(event)]
197
+ result = he.append_with_auto_seal(path, event, existing_events=existing)
198
+ event = result["event"]
199
+ all_events = he.load_events(path)
198
200
  he.write_execution_log(change_dir, he.render_execution_log(all_events))
199
201
  return event
200
202
 
@@ -3053,6 +3055,25 @@ def build_verification_projection(
3053
3055
  "apiTests": _ledger_api_tests(effective_ledger, change_dir=change_dir),
3054
3056
  }
3055
3057
  db_compatibility = _ledger_db_compat(effective_ledger)
3058
+ if db_compatibility["status"] == "NOT_RUN" and change_dir is not None:
3059
+ gate_policy_path = change_dir / "meta" / "gate-policy.json"
3060
+ try:
3061
+ gate_policy = read_json(gate_policy_path) if gate_policy_path.is_file() else None
3062
+ except (OSError, json.JSONDecodeError):
3063
+ gate_policy = None
3064
+ if isinstance(gate_policy, dict):
3065
+ capabilities = gate_policy.get("capabilities")
3066
+ required = gate_policy.get("requiredValidations")
3067
+ if (
3068
+ isinstance(capabilities, list)
3069
+ and "database" not in capabilities
3070
+ and (not isinstance(required, list) or "dbCompatibility" not in required)
3071
+ ):
3072
+ db_compatibility = {
3073
+ "status": "NOT_APPLICABLE",
3074
+ "reason": "项目能力画像未声明数据库能力",
3075
+ "source": "capability-profile",
3076
+ }
3056
3077
  projection["dbCompatibility"] = db_compatibility["status"]
3057
3078
  projection["dbCompatibilityEvidence"] = db_compatibility
3058
3079
  api_contract = validations.get("apiContract")
@@ -8377,6 +8398,27 @@ def _read_optional_text(path: Path) -> str:
8377
8398
  return ""
8378
8399
 
8379
8400
 
8401
+ def _archive_server_url_allowed(value: str | None) -> bool:
8402
+ """Require TLS remotely while allowing explicit loopback HTTP for local testing."""
8403
+ if value is None or not value.strip() or re.search(r"\s", value):
8404
+ return False
8405
+ try:
8406
+ parsed = urlsplit(value)
8407
+ host = (parsed.hostname or "").lower()
8408
+ except ValueError:
8409
+ return False
8410
+ if parsed.scheme.lower() == "https":
8411
+ return bool(host)
8412
+ if parsed.scheme.lower() != "http":
8413
+ return False
8414
+ if host == "localhost":
8415
+ return True
8416
+ try:
8417
+ return ipaddress.ip_address(host).is_loopback
8418
+ except ValueError:
8419
+ return False
8420
+
8421
+
8380
8422
  def _resolve_archive_remote_credentials(
8381
8423
  project_root: Path,
8382
8424
  environment: Mapping[str, str],
@@ -8403,10 +8445,7 @@ def _resolve_archive_remote_credentials(
8403
8445
  else ""
8404
8446
  )
8405
8447
  token_available = bool(env_token or local_token)
8406
- url_available = bool(
8407
- server_url
8408
- and re.fullmatch(r"https://[^\s]+", server_url, flags=re.IGNORECASE)
8409
- )
8448
+ url_available = _archive_server_url_allowed(server_url)
8410
8449
  return {
8411
8450
  "configured": bool(url_available and token_available),
8412
8451
  "serverUrl": server_url if url_available else None,
@@ -474,7 +474,7 @@ def close_transition(
474
474
  *,
475
475
  from_phase: str,
476
476
  to_phase: str,
477
- executor: str,
477
+ executor: str | None,
478
478
  artifacts: list[str] | None = None,
479
479
  status: str = "OK",
480
480
  ) -> dict[str, Any]:
@@ -491,13 +491,81 @@ def close_transition(
491
491
  except (OSError, ValueError, json.JSONDecodeError) as exc:
492
492
  return {"ok": False, "code": "CHANGE_NOT_FOUND", "error": str(exc)}
493
493
  paths = _paths(state_root)
494
+
495
+ artifact_entries: list[dict[str, Any]] = []
496
+ for raw in artifacts or []:
497
+ requested = Path(raw)
498
+ candidates = (
499
+ [requested]
500
+ if requested.is_absolute()
501
+ else [project / requested, contract_root / requested]
502
+ )
503
+ valid: list[Path] = []
504
+ for candidate in candidates:
505
+ try:
506
+ resolved_candidate = candidate.resolve()
507
+ except OSError:
508
+ continue
509
+ if resolved_candidate in valid:
510
+ continue
511
+ if resolved_candidate.is_relative_to(project) and resolved_candidate.is_file():
512
+ valid.append(resolved_candidate)
513
+ if len(valid) > 1:
514
+ return {
515
+ "ok": False,
516
+ "code": "TRANSITION_ARTIFACT_AMBIGUOUS",
517
+ "path": raw,
518
+ "candidates": [item.relative_to(project).as_posix() for item in valid],
519
+ "message": "relative artifact exists under both project and change roots",
520
+ }
521
+ if not valid:
522
+ return {
523
+ "ok": False,
524
+ "code": "TRANSITION_ARTIFACT_INVALID",
525
+ "path": raw,
526
+ "acceptedBases": [str(project), str(contract_root)],
527
+ "examples": [
528
+ str(contract_root / "meta" / "plan-finalization.json"),
529
+ f".harness/changes/{change}/meta/plan-finalization.json",
530
+ "meta/plan-finalization.json",
531
+ ],
532
+ }
533
+ resolved_artifact = valid[0]
534
+ artifact_entries.append(
535
+ {
536
+ "path": resolved_artifact.relative_to(project).as_posix(),
537
+ "sha256": _sha256(resolved_artifact),
538
+ }
539
+ )
540
+
541
+ transitions = _read_ndjson(paths["transitions"])
494
542
  if not paths["lease"].is_file():
543
+ latest = transitions[-1] if transitions else None
544
+ if (
545
+ isinstance(latest, dict)
546
+ and latest.get("fromPhase") == from_phase
547
+ and latest.get("toPhase") == to_phase
548
+ and latest.get("status") == status
549
+ and latest.get("artifacts") == artifact_entries
550
+ and (executor is None or latest.get("executor") == executor)
551
+ ):
552
+ return {
553
+ "ok": True,
554
+ "code": "TRANSITION_ALREADY_CLOSED",
555
+ "idempotent": True,
556
+ "receipt": latest,
557
+ "path": str(paths["transitions"]),
558
+ "invalidation": None,
559
+ }
495
560
  return {"ok": False, "code": "CONTEXT_LEASE_REQUIRED"}
496
561
  try:
497
562
  lease = _read_json(paths["lease"])
498
563
  except (OSError, ValueError, json.JSONDecodeError):
499
564
  return {"ok": False, "code": "CONTEXT_LEASE_INVALID"}
500
- if lease.get("owner") != executor or lease.get("phase") != from_phase:
565
+ effective_executor = executor or str(lease.get("owner") or "")
566
+ if not effective_executor:
567
+ return {"ok": False, "code": "CONTEXT_EXECUTOR_REQUIRED"}
568
+ if lease.get("owner") != effective_executor or lease.get("phase") != from_phase:
501
569
  return {
502
570
  "ok": False,
503
571
  "code": "CONTEXT_LEASE_MISMATCH",
@@ -508,25 +576,6 @@ def close_transition(
508
576
  if expiry is None or _now() >= expiry:
509
577
  return {"ok": False, "code": "CONTEXT_LEASE_EXPIRED"}
510
578
 
511
- artifact_entries: list[dict[str, Any]] = []
512
- for raw in artifacts or []:
513
- path = Path(raw)
514
- if not path.is_absolute():
515
- path = contract_root / path
516
- resolved = path.resolve()
517
- if not resolved.is_relative_to(project) or not resolved.is_file():
518
- return {
519
- "ok": False,
520
- "code": "TRANSITION_ARTIFACT_INVALID",
521
- "path": str(path),
522
- }
523
- artifact_entries.append(
524
- {
525
- "path": resolved.relative_to(project).as_posix(),
526
- "sha256": _sha256(resolved),
527
- }
528
- )
529
- transitions = _read_ndjson(paths["transitions"])
530
579
  previous_hash = transitions[-1].get("receiptHash") if transitions else None
531
580
  attempt = (
532
581
  1
@@ -543,7 +592,7 @@ def close_transition(
543
592
  "fromPhase": from_phase,
544
593
  "toPhase": to_phase,
545
594
  "status": status,
546
- "executor": executor,
595
+ "executor": effective_executor,
547
596
  "productCommit": _head(project),
548
597
  "artifacts": artifact_entries,
549
598
  "attempt": attempt,
@@ -41,7 +41,7 @@ def _project_root_for_change(change_dir: Path) -> Path | None:
41
41
  return None
42
42
 
43
43
 
44
- def _nudge_remote_sync(change_dir: Path) -> None:
44
+ def nudge_remote_sync(change_dir: Path) -> None:
45
45
  """Best-effort wake-up after the event append has been flushed to disk."""
46
46
  project_root = _project_root_for_change(change_dir)
47
47
  if project_root is None:
@@ -55,6 +55,11 @@ def _nudge_remote_sync(change_dir: Path) -> None:
55
55
  return
56
56
 
57
57
 
58
+ def _nudge_remote_sync(change_dir: Path) -> None:
59
+ """Compatibility wrapper for older callers and focused tests."""
60
+ nudge_remote_sync(change_dir)
61
+
62
+
58
63
  if hasattr(sys.stdout, "reconfigure"):
59
64
  sys.stdout.reconfigure(encoding="utf-8")
60
65
  if hasattr(sys.stderr, "reconfigure"):
@@ -121,6 +126,12 @@ OPTIONAL_FIELDS = (
121
126
  "renamed_from",
122
127
  "renamed_to",
123
128
  "change_uuid",
129
+ "execution_mode",
130
+ "decision_reason_code",
131
+ "fallback_reason_code",
132
+ "trigger",
133
+ "from_phase",
134
+ "result_status",
124
135
  )
125
136
 
126
137
  _PROVENANCE_FIELDS = frozenset(
@@ -139,6 +150,12 @@ _PROVENANCE_FIELDS = frozenset(
139
150
  "orchestration_active_ms",
140
151
  "wall_clock_ms",
141
152
  "user_wait_ms",
153
+ "execution_mode",
154
+ "decision_reason_code",
155
+ "fallback_reason_code",
156
+ "trigger",
157
+ "from_phase",
158
+ "result_status",
142
159
  }
143
160
  )
144
161
  _EVENT_ALLOWED_FIELDS = {
@@ -1698,6 +1715,22 @@ def append_with_auto_seal(
1698
1715
  else []
1699
1716
  )
1700
1717
  )
1718
+ if event_type in {"phase.start", "phase.end", "phase.auto_sealed"} and phase:
1719
+ phase_attempts = split_phase_attempts(
1720
+ [item for item in loaded if item.get("phase") == phase]
1721
+ )
1722
+ latest_attempt = max(
1723
+ (
1724
+ int(item.get("attempt"))
1725
+ for item in phase_attempts
1726
+ if isinstance(item.get("attempt"), int)
1727
+ ),
1728
+ default=0,
1729
+ )
1730
+ if not isinstance(event.get("attempt"), int) or int(event["attempt"]) <= 0:
1731
+ event["attempt"] = (
1732
+ latest_attempt + 1 if event_type == "phase.start" else latest_attempt or 1
1733
+ )
1701
1734
  if event_type == "phase.start" and phase:
1702
1735
  auto_sealed = seal_open_phase_attempts(loaded, phase=phase)
1703
1736
  for seal_event in auto_sealed:
@@ -1967,6 +2000,12 @@ def build_parser() -> argparse.ArgumentParser:
1967
2000
  p_append.add_argument("--orchestration-active-ms", type=int, default=None)
1968
2001
  p_append.add_argument("--wall-clock-ms", type=int, default=None)
1969
2002
  p_append.add_argument("--user-wait-ms", type=int, default=None)
2003
+ p_append.add_argument("--execution-mode", default=None)
2004
+ p_append.add_argument("--decision-reason-code", default=None)
2005
+ p_append.add_argument("--fallback-reason-code", default=None)
2006
+ p_append.add_argument("--trigger", default=None)
2007
+ p_append.add_argument("--from-phase", default=None)
2008
+ p_append.add_argument("--result-status", default=None)
1970
2009
  p_append.add_argument(
1971
2010
  "--legacy-lenient",
1972
2011
  action="store_true",
@@ -51,6 +51,15 @@ WHITELIST = {
51
51
  "duration_ms",
52
52
  "schemaVersion",
53
53
  "summary",
54
+ "executor_tool",
55
+ "executor_agent",
56
+ "executor_model",
57
+ "execution_mode",
58
+ "decision_reason_code",
59
+ "fallback_reason_code",
60
+ "trigger",
61
+ "from_phase",
62
+ "result_status",
54
63
  }
55
64
 
56
65
  MAX_BATCH_SIZE = 100
@@ -110,20 +119,43 @@ def _summary_text(value: Any) -> str:
110
119
  return text[:EVENT_SUMMARY_MAX_LENGTH].rstrip()
111
120
 
112
121
 
122
+ _MACHINE_DETAIL_RE = re.compile(
123
+ r"(?:\s*[;;,,]?\s*)"
124
+ r"(?:artifactsHash|artifactHash|receiptHash|content_sha256|contentSha256)"
125
+ r"\s*[:=]\s*(?:sha256:)?[0-9a-f]{32,64}",
126
+ re.IGNORECASE,
127
+ )
128
+
129
+
130
+ def _readable_summary_text(value: Any) -> str:
131
+ text = _summary_text(value)
132
+ if not text:
133
+ return ""
134
+ text = _MACHINE_DETAIL_RE.sub("", text)
135
+ text = re.sub(
136
+ r"\bfinalize\s+ok\b",
137
+ "规划产物已校验并发布",
138
+ text,
139
+ flags=re.IGNORECASE,
140
+ )
141
+ text = re.sub(r"\s*([;;,,])\s*([;;,,])", r"\1", text)
142
+ return _summary_text(text.strip(" ;;,,"))
143
+
144
+
113
145
  def build_event_summary(event: dict[str, Any]) -> str:
114
- explicit = _summary_text(event.get("summary"))
146
+ explicit = _readable_summary_text(event.get("summary"))
115
147
  if explicit:
116
148
  return explicit
117
149
 
118
150
  event_type = _summary_text(event.get("type"))
119
151
  phase = _summary_text(event.get("phase"))
120
152
  phase_label = PHASE_LABELS.get(phase, phase or "当前")
121
- note = _summary_text(event.get("note"))
122
- message = _summary_text(event.get("message"))
123
- decision = _summary_text(event.get("decision"))
124
- reason = _summary_text(event.get("reason"))
125
- name = _summary_text(event.get("name"))
126
- code = _summary_text(event.get("code"))
153
+ note = _readable_summary_text(event.get("note"))
154
+ message = _readable_summary_text(event.get("message"))
155
+ decision = _readable_summary_text(event.get("decision"))
156
+ reason = _readable_summary_text(event.get("reason"))
157
+ name = _readable_summary_text(event.get("name"))
158
+ code = _readable_summary_text(event.get("code"))
127
159
  status = _summary_text(event.get("status"))
128
160
 
129
161
  if event_type == "decision" and decision:
@@ -754,6 +786,39 @@ def _discover_changes(project_root: Path) -> list[Path]:
754
786
  return sorted(path for path in root.glob("*") if path.is_dir()) if root.is_dir() else []
755
787
 
756
788
 
789
+ def _change_has_open_phase(change_dir: Path) -> bool:
790
+ """Project the append-only stream into open/closed phase state."""
791
+ try:
792
+ path = Path(harness_paths.resolve_state_dir_for_contract(change_dir)) / "events.ndjson"
793
+ except (OSError, ValueError):
794
+ return False
795
+ if not path.is_file():
796
+ return False
797
+ open_phases: dict[str, bool] = {}
798
+ try:
799
+ for line in path.read_text(encoding="utf-8-sig").splitlines():
800
+ if not line.strip():
801
+ continue
802
+ event = json.loads(line)
803
+ if not isinstance(event, dict):
804
+ continue
805
+ phase = event.get("phase")
806
+ event_type = event.get("type")
807
+ if not isinstance(phase, str) or not phase:
808
+ continue
809
+ if event_type == "phase.start":
810
+ open_phases[phase] = True
811
+ elif event_type in {"phase.end", "phase.auto_sealed", "workflow.end", "run.end"}:
812
+ open_phases[phase] = False
813
+ except (OSError, json.JSONDecodeError):
814
+ return False
815
+ return any(open_phases.values())
816
+
817
+
818
+ def _project_has_open_phase(project_root: Path) -> bool:
819
+ return any(_change_has_open_phase(path) for path in _discover_changes(project_root))
820
+
821
+
757
822
  def _run_agent_session(project_root: Path) -> dict[str, Any]:
758
823
  attempts = 0
759
824
  uploaded = 0
@@ -766,14 +831,28 @@ def _run_agent_session(project_root: Path) -> dict[str, Any]:
766
831
  if all(item.get("ok") for item in results):
767
832
  time.sleep(0.6)
768
833
  if generation == _read_nudge_generation(project_root):
769
- return {
770
- "ok": True,
771
- "uploaded": uploaded,
772
- "quarantined": quarantined,
773
- "attempts": attempts + 1,
774
- }
834
+ if not _project_has_open_phase(project_root):
835
+ return {
836
+ "ok": True,
837
+ "uploaded": uploaded,
838
+ "quarantined": quarantined,
839
+ "attempts": attempts + 1,
840
+ }
841
+ # Keep a finite workflow lease alive while a phase is open.
842
+ # A phase.end nudge is observed on the next pass and releases
843
+ # the worker naturally; no terminal window is created.
844
+ time.sleep(10.0)
845
+ heartbeat_results = [
846
+ sync_change(project_root, path, heartbeat_only=True)
847
+ for path in _discover_changes(project_root)
848
+ ]
849
+ if all(item.get("ok") for item in heartbeat_results):
850
+ attempts = 0
851
+ continue
852
+ results = heartbeat_results
775
853
  attempts = 0
776
- continue
854
+ if generation != _read_nudge_generation(project_root):
855
+ continue
777
856
  time.sleep(min(16.0, float(2 ** attempts)))
778
857
  attempts += 1
779
858
  return {
@@ -30,6 +30,7 @@ if str(SCRIPTS_DIR) not in sys.path:
30
30
  sys.path.insert(0, str(SCRIPTS_DIR))
31
31
 
32
32
  import harness_change as hc # noqa: E402
33
+ import harness_context as hctx # noqa: E402
33
34
  import harness_events as he # noqa: E402
34
35
  import harness_events_sync as hes # noqa: E402
35
36
  import harness_ledger as hl # noqa: E402
@@ -1602,6 +1603,18 @@ def append_phase_event(
1602
1603
  auto_sealed: list[dict[str, Any]] = []
1603
1604
  with he.event_file_lock(lock_path):
1604
1605
  existing = he.load_events(events_file)
1606
+ phase_attempts = he.split_phase_attempts(
1607
+ [event for event in existing if event.get("phase") == phase]
1608
+ )
1609
+ latest_attempt = max(
1610
+ (
1611
+ int(item.get("attempt"))
1612
+ for item in phase_attempts
1613
+ if isinstance(item.get("attempt"), int)
1614
+ ),
1615
+ default=0,
1616
+ )
1617
+ event_attempt = latest_attempt + 1 if type_ == "phase.start" else latest_attempt or 1
1605
1618
  args = argparse.Namespace(
1606
1619
  phase=phase,
1607
1620
  type=type_,
@@ -1619,12 +1632,18 @@ def append_phase_event(
1619
1632
  decision=None,
1620
1633
  reason=None,
1621
1634
  run_id=run_id,
1622
- attempt=None,
1635
+ attempt=event_attempt,
1623
1636
  executor_tool=executor_tool,
1624
1637
  executor_agent=executor_agent,
1625
1638
  executor_model=executor_model,
1626
1639
  handoff_from_tool=None,
1627
1640
  handoff_reason=None,
1641
+ execution_mode=None,
1642
+ decision_reason_code=None,
1643
+ fallback_reason_code=None,
1644
+ trigger=("fixback" if "fixback" in note.lower() else None),
1645
+ from_phase=("review" if phase == "run" and "fixback" in note.lower() else None),
1646
+ result_status=None,
1628
1647
  )
1629
1648
  event = he.build_event(args, existing)
1630
1649
  if identity:
@@ -1655,6 +1674,7 @@ def append_phase_event(
1655
1674
  content = he.render_execution_log(events)
1656
1675
  log_path = he.write_execution_log(change_dir, content)
1657
1676
  rendered = True
1677
+ he.nudge_remote_sync(change_dir)
1658
1678
  return {
1659
1679
  "ok": True,
1660
1680
  "eventId": event.get("id"),
@@ -1702,6 +1722,80 @@ def _phase_event_exists(change_dir: Path, phase: str, type_: str, run_id: str) -
1702
1722
  )
1703
1723
 
1704
1724
 
1725
+ def _latest_phase_end(
1726
+ change_dir: Path,
1727
+ phase: str,
1728
+ run_id: str | None = None,
1729
+ ) -> dict[str, Any] | None:
1730
+ for event in reversed(he.load_events(he.events_path(change_dir))):
1731
+ if event.get("phase") != phase or event.get("type") != "phase.end":
1732
+ continue
1733
+ if run_id and event.get("run_id") != run_id:
1734
+ continue
1735
+ return event
1736
+ return None
1737
+
1738
+
1739
+ def _terminal_matches_context_session(
1740
+ project: Path,
1741
+ change_id: str,
1742
+ phase: str,
1743
+ terminal: dict[str, Any],
1744
+ ) -> bool:
1745
+ view = hctx.context_view(project, change_id)
1746
+ current = view.get("current") if isinstance(view, dict) else None
1747
+ if not isinstance(current, dict) or current.get("phase") != phase:
1748
+ return False
1749
+ prepared_at = he.parse_timestamp(current.get("preparedAt"))
1750
+ terminal_at = he.parse_timestamp(
1751
+ terminal.get("timestamp") or terminal.get("occurred_at")
1752
+ )
1753
+ return (
1754
+ prepared_at is not None
1755
+ and terminal_at is not None
1756
+ and terminal_at >= prepared_at
1757
+ )
1758
+
1759
+
1760
+ def _close_context_handoff(
1761
+ project: Path,
1762
+ change_id: str,
1763
+ args: argparse.Namespace,
1764
+ *,
1765
+ status: str,
1766
+ ) -> dict[str, Any] | None:
1767
+ to_phase = getattr(args, "to_phase", None)
1768
+ if not to_phase:
1769
+ return None
1770
+ try:
1771
+ return hctx.close_transition(
1772
+ project,
1773
+ change_id,
1774
+ from_phase=args.phase,
1775
+ to_phase=str(to_phase),
1776
+ executor=getattr(args, "executor", None),
1777
+ artifacts=list(getattr(args, "artifact", None) or []),
1778
+ status=status,
1779
+ )
1780
+ except Exception as exc: # noqa: BLE001 — phase close remains locally durable
1781
+ return {
1782
+ "ok": False,
1783
+ "code": "CONTEXT_HANDOFF_FAILED",
1784
+ "message": str(exc),
1785
+ }
1786
+
1787
+
1788
+ def _sync_after_phase_close(project: Path, change_dir: Path) -> dict[str, Any]:
1789
+ try:
1790
+ return hes.auto_events_sync(project, change_dir)
1791
+ except Exception as exc: # noqa: BLE001 — remote sync is recoverable
1792
+ return {
1793
+ "ok": False,
1794
+ "code": "PLATFORM_EVENTS_SYNC_WARN",
1795
+ "warning": f"events-sync hook failed: {exc}",
1796
+ }
1797
+
1798
+
1705
1799
  def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
1706
1800
  """C9: validate all ledger-required scenarios are covered by ledger entries.
1707
1801
 
@@ -2266,6 +2360,48 @@ def cmd_close(args: argparse.Namespace) -> int:
2266
2360
  explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
2267
2361
  current_lease = hc.inspect_lease(project, resolved["changeId"])
2268
2362
  if current_lease is None:
2363
+ terminal = _latest_phase_end(change_dir, args.phase, explicit_run_id)
2364
+ if (
2365
+ getattr(args, "to_phase", None)
2366
+ and terminal is not None
2367
+ and _terminal_matches_context_session(
2368
+ project, str(resolved["changeId"]), args.phase, terminal
2369
+ )
2370
+ ):
2371
+ terminal_status = str(terminal.get("status") or args.status)
2372
+ handoff = _close_context_handoff(
2373
+ project,
2374
+ str(resolved["changeId"]),
2375
+ args,
2376
+ status=terminal_status,
2377
+ )
2378
+ monitor = _sync_after_phase_close(project, change_dir)
2379
+ if not isinstance(handoff, dict) or not handoff.get("ok"):
2380
+ return emit_error(
2381
+ "PHASE_HANDOFF_PENDING",
2382
+ "phase gate is closed, but context handoff still needs attention",
2383
+ as_json=as_json,
2384
+ extra={
2385
+ "localCloseComplete": True,
2386
+ "retryable": True,
2387
+ "contextHandoff": handoff,
2388
+ "platformMonitor": monitor,
2389
+ },
2390
+ )
2391
+ emit(
2392
+ {
2393
+ "ok": True,
2394
+ "code": "PHASE_CLOSE_RESUMED",
2395
+ "phase": args.phase,
2396
+ "status": terminal_status,
2397
+ "changeId": resolved["changeId"],
2398
+ "localCloseComplete": True,
2399
+ "contextHandoff": handoff,
2400
+ "platformMonitor": monitor,
2401
+ },
2402
+ as_json=as_json,
2403
+ )
2404
+ return 0
2269
2405
  return emit_error("LEASE_ABSENT", "no active lease for phase close", as_json=as_json)
2270
2406
  run_id = explicit_run_id or str(current_lease.get("runId") or "")
2271
2407
  if str(current_lease.get("runId")) != run_id or str(current_lease.get("phase")) != args.phase:
@@ -2597,6 +2733,29 @@ def cmd_close(args: argparse.Namespace) -> int:
2597
2733
  capsule["closeStatus"] = close_status
2598
2734
  write_phase_capsule(change_dir, args.phase, run_id, capsule)
2599
2735
 
2736
+ context_handoff = _close_context_handoff(
2737
+ project,
2738
+ str(resolved["changeId"]),
2739
+ args,
2740
+ status=close_status,
2741
+ )
2742
+ platform_monitor = _sync_after_phase_close(project, change_dir)
2743
+ if isinstance(context_handoff, dict) and not context_handoff.get("ok"):
2744
+ return emit_error(
2745
+ "PHASE_HANDOFF_PENDING",
2746
+ "phase gate closed locally, but context handoff failed; retry the same close command",
2747
+ as_json=as_json,
2748
+ extra={
2749
+ "localCloseComplete": True,
2750
+ "retryable": True,
2751
+ "phase": args.phase,
2752
+ "status": close_status,
2753
+ "changeId": resolved["changeId"],
2754
+ "contextHandoff": context_handoff,
2755
+ "platformMonitor": platform_monitor,
2756
+ },
2757
+ )
2758
+
2600
2759
  payload = {
2601
2760
  "ok": True,
2602
2761
  "code": close_code,
@@ -2612,6 +2771,8 @@ def cmd_close(args: argparse.Namespace) -> int:
2612
2771
  "lease": release,
2613
2772
  "gateSeverityMode": severity_mode,
2614
2773
  "gateWarnings": gate_warnings,
2774
+ "contextHandoff": context_handoff,
2775
+ "platformMonitor": platform_monitor,
2615
2776
  }
2616
2777
  emit(payload, as_json=as_json)
2617
2778
  return 0
@@ -2806,6 +2967,9 @@ def build_parser() -> argparse.ArgumentParser:
2806
2967
  p_close.add_argument("--run-id", default=None)
2807
2968
  p_close.add_argument("--task", type=int, default=None)
2808
2969
  p_close.add_argument("--note", default="")
2970
+ p_close.add_argument("--to-phase", default=None)
2971
+ p_close.add_argument("--executor", default=None)
2972
+ p_close.add_argument("--artifact", action="append", default=[])
2809
2973
  p_close.set_defaults(func=cmd_close)
2810
2974
 
2811
2975
  p_classify = sub.add_parser("classify", parents=[shared])
@@ -1160,6 +1160,8 @@ _DYNAMIC_OWN_DIRS = ("events.ndjson", "logs", "evidence", "reports", "runtime",
1160
1160
 
1161
1161
  def _matches_ownership_path(rel: str, declared: Any) -> bool:
1162
1162
  scope = str(declared).replace("\\", "/").strip("/")
1163
+ if scope.endswith("/**"):
1164
+ scope = scope[:-3].rstrip("/")
1163
1165
  if not scope:
1164
1166
  return False
1165
1167
  normalized = rel.replace("\\", "/").strip("/")