@hunter-harness/workflow-harness 0.2.6 → 0.2.8

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 (202) hide show
  1. package/harness/bundles/general/claude-code/.harness-build.json +1 -1
  2. package/harness/bundles/general/claude-code/contracts/workflow-policy.json +169 -0
  3. package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -1
  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 +26 -1
  7. package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  8. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +3 -1
  9. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +3 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +9 -6
  12. package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
  13. package/harness/bundles/general/claude-code/harness-run/reference.md +5 -2
  14. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +7 -5
  15. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  16. package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
  17. package/harness/bundles/general/claude-code/scripts/harness_apidoc.py +53 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_archive.py +41 -16
  19. package/harness/bundles/general/claude-code/scripts/harness_change.py +872 -0
  20. package/harness/bundles/general/claude-code/scripts/harness_events.py +26 -0
  21. package/harness/bundles/general/claude-code/scripts/harness_gate.py +1038 -0
  22. package/harness/bundles/general/claude-code/scripts/harness_service.py +34 -1
  23. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +210 -14
  24. package/harness/bundles/general/claude-code/scripts/harness_workflow_policy.py +160 -0
  25. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  26. package/harness/bundles/general/codebuddy/contracts/workflow-policy.json +169 -0
  27. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -1
  28. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  31. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  32. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +3 -1
  33. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -1
  34. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -1
  35. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +9 -6
  36. package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
  37. package/harness/bundles/general/codebuddy/harness-run/reference.md +5 -2
  38. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +7 -5
  39. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  40. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
  41. package/harness/bundles/general/codebuddy/scripts/harness_apidoc.py +53 -0
  42. package/harness/bundles/general/codebuddy/scripts/harness_archive.py +41 -16
  43. package/harness/bundles/general/codebuddy/scripts/harness_change.py +872 -0
  44. package/harness/bundles/general/codebuddy/scripts/harness_events.py +26 -0
  45. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +1038 -0
  46. package/harness/bundles/general/codebuddy/scripts/harness_service.py +34 -1
  47. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +210 -14
  48. package/harness/bundles/general/codebuddy/scripts/harness_workflow_policy.py +160 -0
  49. package/harness/bundles/general/codex/.harness-build.json +1 -1
  50. package/harness/bundles/general/codex/contracts/workflow-policy.json +169 -0
  51. package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -1
  52. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  55. package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  56. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +3 -1
  57. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -1
  58. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -1
  59. package/harness/bundles/general/codex/harness-run/SKILL.md +9 -6
  60. package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
  61. package/harness/bundles/general/codex/harness-run/reference.md +5 -2
  62. package/harness/bundles/general/codex/harness-submit/SKILL.md +7 -5
  63. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  64. package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
  65. package/harness/bundles/general/codex/scripts/harness_apidoc.py +53 -0
  66. package/harness/bundles/general/codex/scripts/harness_archive.py +41 -16
  67. package/harness/bundles/general/codex/scripts/harness_change.py +872 -0
  68. package/harness/bundles/general/codex/scripts/harness_events.py +26 -0
  69. package/harness/bundles/general/codex/scripts/harness_gate.py +1038 -0
  70. package/harness/bundles/general/codex/scripts/harness_service.py +34 -1
  71. package/harness/bundles/general/codex/scripts/harness_test_guard.py +210 -14
  72. package/harness/bundles/general/codex/scripts/harness_workflow_policy.py +160 -0
  73. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  74. package/harness/bundles/general/cursor/contracts/workflow-policy.json +169 -0
  75. package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -1
  76. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  79. package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  80. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +3 -1
  81. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -1
  82. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -1
  83. package/harness/bundles/general/cursor/harness-run/SKILL.md +9 -6
  84. package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
  85. package/harness/bundles/general/cursor/harness-run/reference.md +5 -2
  86. package/harness/bundles/general/cursor/harness-submit/SKILL.md +7 -5
  87. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  88. package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
  89. package/harness/bundles/general/cursor/scripts/harness_apidoc.py +53 -0
  90. package/harness/bundles/general/cursor/scripts/harness_archive.py +41 -16
  91. package/harness/bundles/general/cursor/scripts/harness_change.py +872 -0
  92. package/harness/bundles/general/cursor/scripts/harness_events.py +26 -0
  93. package/harness/bundles/general/cursor/scripts/harness_gate.py +1038 -0
  94. package/harness/bundles/general/cursor/scripts/harness_service.py +34 -1
  95. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +210 -14
  96. package/harness/bundles/general/cursor/scripts/harness_workflow_policy.py +160 -0
  97. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  98. package/harness/bundles/java/claude-code/contracts/workflow-policy.json +169 -0
  99. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +7 -1
  100. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -1
  101. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  104. package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  105. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +3 -1
  106. package/harness/bundles/java/claude-code/harness-package/SKILL.md +6 -3
  107. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -1
  108. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -1
  109. package/harness/bundles/java/claude-code/harness-run/SKILL.md +9 -6
  110. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +7 -5
  111. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
  113. package/harness/bundles/java/claude-code/scripts/harness_apidoc.py +53 -0
  114. package/harness/bundles/java/claude-code/scripts/harness_archive.py +41 -16
  115. package/harness/bundles/java/claude-code/scripts/harness_change.py +872 -0
  116. package/harness/bundles/java/claude-code/scripts/harness_events.py +26 -0
  117. package/harness/bundles/java/claude-code/scripts/harness_gate.py +1038 -0
  118. package/harness/bundles/java/claude-code/scripts/harness_service.py +34 -1
  119. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +210 -14
  120. package/harness/bundles/java/claude-code/scripts/harness_workflow_policy.py +160 -0
  121. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  122. package/harness/bundles/java/codebuddy/contracts/workflow-policy.json +169 -0
  123. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +7 -1
  124. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -1
  125. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  128. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  129. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +3 -1
  130. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +6 -3
  131. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -1
  132. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -1
  133. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +9 -6
  134. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +7 -5
  135. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  136. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
  137. package/harness/bundles/java/codebuddy/scripts/harness_apidoc.py +53 -0
  138. package/harness/bundles/java/codebuddy/scripts/harness_archive.py +41 -16
  139. package/harness/bundles/java/codebuddy/scripts/harness_change.py +872 -0
  140. package/harness/bundles/java/codebuddy/scripts/harness_events.py +26 -0
  141. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +1038 -0
  142. package/harness/bundles/java/codebuddy/scripts/harness_service.py +34 -1
  143. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +210 -14
  144. package/harness/bundles/java/codebuddy/scripts/harness_workflow_policy.py +160 -0
  145. package/harness/bundles/java/codex/.harness-build.json +1 -1
  146. package/harness/bundles/java/codex/contracts/workflow-policy.json +169 -0
  147. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +7 -1
  148. package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -1
  149. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  152. package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  153. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +3 -1
  154. package/harness/bundles/java/codex/harness-package/SKILL.md +6 -3
  155. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -1
  156. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -1
  157. package/harness/bundles/java/codex/harness-run/SKILL.md +9 -6
  158. package/harness/bundles/java/codex/harness-submit/SKILL.md +7 -5
  159. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  160. package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
  161. package/harness/bundles/java/codex/scripts/harness_apidoc.py +53 -0
  162. package/harness/bundles/java/codex/scripts/harness_archive.py +41 -16
  163. package/harness/bundles/java/codex/scripts/harness_change.py +872 -0
  164. package/harness/bundles/java/codex/scripts/harness_events.py +26 -0
  165. package/harness/bundles/java/codex/scripts/harness_gate.py +1038 -0
  166. package/harness/bundles/java/codex/scripts/harness_service.py +34 -1
  167. package/harness/bundles/java/codex/scripts/harness_test_guard.py +210 -14
  168. package/harness/bundles/java/codex/scripts/harness_workflow_policy.py +160 -0
  169. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  170. package/harness/bundles/java/cursor/contracts/workflow-policy.json +169 -0
  171. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +7 -1
  172. package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -1
  173. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  175. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +26 -1
  176. package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge_mcp.py +2 -0
  177. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +3 -1
  178. package/harness/bundles/java/cursor/harness-package/SKILL.md +6 -3
  179. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -1
  180. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -1
  181. package/harness/bundles/java/cursor/harness-run/SKILL.md +9 -6
  182. package/harness/bundles/java/cursor/harness-submit/SKILL.md +7 -5
  183. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
  185. package/harness/bundles/java/cursor/scripts/harness_apidoc.py +53 -0
  186. package/harness/bundles/java/cursor/scripts/harness_archive.py +41 -16
  187. package/harness/bundles/java/cursor/scripts/harness_change.py +872 -0
  188. package/harness/bundles/java/cursor/scripts/harness_events.py +26 -0
  189. package/harness/bundles/java/cursor/scripts/harness_gate.py +1038 -0
  190. package/harness/bundles/java/cursor/scripts/harness_service.py +34 -1
  191. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +210 -14
  192. package/harness/bundles/java/cursor/scripts/harness_workflow_policy.py +160 -0
  193. package/harness/manifests/general/claude-code.json +40 -20
  194. package/harness/manifests/general/codebuddy.json +40 -20
  195. package/harness/manifests/general/codex.json +40 -20
  196. package/harness/manifests/general/cursor.json +40 -20
  197. package/harness/manifests/java/claude-code.json +40 -20
  198. package/harness/manifests/java/codebuddy.json +40 -20
  199. package/harness/manifests/java/codex.json +40 -20
  200. package/harness/manifests/java/cursor.json +40 -20
  201. package/hunter-workflow-family.json +2 -2
  202. package/package.json +1 -1
@@ -194,6 +194,8 @@ def resolve_service_start(
194
194
  change_name: str | None = None,
195
195
  worktree_root: Path | None = None,
196
196
  overlay_path: str | None = None,
197
+ leased_port: int | None = None,
198
+ lease_owner: str | None = None,
197
199
  ) -> dict[str, Any]:
198
200
  """从模板 serviceStart + runtime context 生成 resolved serviceStart。
199
201
 
@@ -220,6 +222,16 @@ def resolve_service_start(
220
222
  # runtime profile 注入:持久 profile 留空时用 change_name;非空则保留
221
223
  if not str(resolved.get("profile") or "").strip():
222
224
  resolved["profile"] = change_name or "local-dev"
225
+ if leased_port is not None:
226
+ if not lease_owner:
227
+ raise ValueError("lease owner is required when leased port is provided")
228
+ resolved["leasedPort"] = leased_port
229
+ resolved["leaseOwner"] = lease_owner
230
+ resolved["port"] = leased_port
231
+ for field in ("command", "healthUrl", "healthFile", "overlayPath"):
232
+ value = resolved.get(field)
233
+ if isinstance(value, str):
234
+ resolved[field] = value.replace("{leasedPort}", str(leased_port))
223
235
  # worktree_root 预留给未来相对 overlay 路径解析(spec §3.6 state snapshot);
224
236
  # 不写入返回值,避免污染 session。
225
237
  _ = worktree_root
@@ -914,7 +926,7 @@ def build_session(
914
926
  ) -> dict[str, Any]:
915
927
  profile_name = service_start.get("profile") or "local-dev"
916
928
  overlay = service_start.get("overlayPath") or ""
917
- return {
929
+ session = {
918
930
  "pid": pid,
919
931
  "startedBy": "AI",
920
932
  "moduleInputsHash": module_inputs_hash,
@@ -925,6 +937,10 @@ def build_session(
925
937
  "startedAt": started_at or now_iso(),
926
938
  "command": command,
927
939
  }
940
+ if isinstance(service_start.get("leasedPort"), int):
941
+ session["leasedPort"] = service_start["leasedPort"]
942
+ session["leaseOwner"] = service_start.get("leaseOwner")
943
+ return session
928
944
 
929
945
 
930
946
  def compute_module_hash(
@@ -1015,12 +1031,16 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1015
1031
  # 含 worktree/change 陈旧持久值时拒绝(修复输入端陈旧 profile)。
1016
1032
  change_name = getattr(args, "change_name", None) or change_dir.name
1017
1033
  overlay = getattr(args, "overlay", None)
1034
+ leased_port = getattr(args, "leased_port", None)
1035
+ lease_owner = str(getattr(args, "lease_owner", None) or "").strip() or None
1018
1036
  try:
1019
1037
  service_start = resolve_service_start(
1020
1038
  profile,
1021
1039
  change_name=change_name,
1022
1040
  worktree_root=project,
1023
1041
  overlay_path=overlay,
1042
+ leased_port=leased_port,
1043
+ lease_owner=lease_owner,
1024
1044
  )
1025
1045
  except ValueError as exc:
1026
1046
  return emit_error(str(exc), as_json=as_json)
@@ -1084,6 +1104,8 @@ def cmd_ensure(args: argparse.Namespace) -> int:
1084
1104
  and session.get("startCommandHash") == current_cmd_hash
1085
1105
  and session.get("profile") == current_profile
1086
1106
  and session.get("overlayPath") == current_overlay
1107
+ and session.get("leasedPort") == service_start.get("leasedPort")
1108
+ and session.get("leaseOwner") == service_start.get("leaseOwner")
1087
1109
  )
1088
1110
  if fingerprint_match:
1089
1111
  payload = {
@@ -1389,6 +1411,17 @@ def build_parser() -> argparse.ArgumentParser:
1389
1411
  default=None,
1390
1412
  help="runtime overlay path injected into resolved serviceStart",
1391
1413
  )
1414
+ p_ensure.add_argument(
1415
+ "--leased-port",
1416
+ type=int,
1417
+ default=None,
1418
+ help="port allocated by harness_change.py lease-port",
1419
+ )
1420
+ p_ensure.add_argument(
1421
+ "--lease-owner",
1422
+ default=None,
1423
+ help="run id that owns --leased-port",
1424
+ )
1392
1425
  p_ensure.add_argument("--json", action="store_true")
1393
1426
  p_ensure.set_defaults(func=cmd_ensure)
1394
1427
 
@@ -24,6 +24,7 @@ from typing import Any
24
24
  SCHEMA_VERSION = 1
25
25
  MODE = "force-track-touched"
26
26
  MANIFEST_REL = Path("evidence") / "test-tracking.json"
27
+ SNAPSHOT_REL = Path("evidence") / "test-guard-snapshot.json"
27
28
  PROFILE_REL = Path(".harness") / "config" / "build-profile.json"
28
29
  REASONS = ("tdd-created", "stale-test-repair", "test-updated")
29
30
 
@@ -125,7 +126,7 @@ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
125
126
  while descriptor is None:
126
127
  try:
127
128
  descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
128
- except FileExistsError as exc:
129
+ except (FileExistsError, PermissionError) as exc:
129
130
  if time.monotonic() >= deadline:
130
131
  raise LockUnavailable(str(path)) from exc
131
132
  time.sleep(0.01)
@@ -137,22 +138,41 @@ def _exclusive_lock(path: Path, *, wait_seconds: float) -> Any:
137
138
  path.unlink(missing_ok=True)
138
139
 
139
140
 
141
+ def _state_project_root(project: Path) -> Path:
142
+ result = _git(project, "rev-parse", "--git-common-dir")
143
+ if result.returncode != 0 or not result.stdout.strip():
144
+ return project
145
+ common = Path(result.stdout.strip())
146
+ if not common.is_absolute():
147
+ common = project / common
148
+ resolved = common.resolve()
149
+ return resolved.parent if resolved.name == ".git" else project
150
+
151
+
140
152
  def _change_dir(project: Path, change_dir: Path | str) -> Path | None:
153
+ state_project = _state_project_root(project)
141
154
  candidate = Path(change_dir)
142
155
  if not candidate.is_absolute():
143
- candidate = project / candidate
156
+ candidate = (
157
+ state_project / candidate
158
+ if candidate.parts[:2] == (".harness", "changes")
159
+ else project / candidate
160
+ )
144
161
  resolved = candidate.resolve()
145
- return resolved if _inside(resolved, project) else None
162
+ allowed_roots = {
163
+ (project / ".harness" / "changes").resolve(),
164
+ (state_project / ".harness" / "changes").resolve(),
165
+ }
166
+ return resolved if any(_inside(resolved, root) for root in allowed_roots) else None
146
167
 
147
168
 
148
- def _manifest_path(project: Path, change_root: Path) -> Path | None:
169
+ def _manifest_path(change_root: Path) -> Path | None:
149
170
  evidence = change_root / MANIFEST_REL.parent
150
171
  manifest = change_root / MANIFEST_REL
151
172
  expected_evidence = evidence.absolute()
152
173
  resolved_evidence = evidence.resolve()
153
174
  if (
154
- not _inside(change_root.resolve(), project)
155
- or not _inside(resolved_evidence, project)
175
+ not _inside(resolved_evidence, change_root.resolve())
156
176
  or os.path.normcase(str(resolved_evidence))
157
177
  != os.path.normcase(str(expected_evidence))
158
178
  ):
@@ -160,9 +180,12 @@ def _manifest_path(project: Path, change_root: Path) -> Path | None:
160
180
  return manifest
161
181
 
162
182
 
163
- def _manifest_target_inside(project: Path, manifest: Path) -> bool:
183
+ def _manifest_target_inside(change_root: Path, manifest: Path) -> bool:
164
184
  resolved = manifest.resolve()
165
- return _inside(resolved, project) and _inside(resolved, manifest.parent.absolute())
185
+ return (
186
+ _inside(resolved, change_root.resolve())
187
+ and _inside(resolved, manifest.parent.absolute())
188
+ )
166
189
 
167
190
 
168
191
  def _profile_config(project: Path) -> tuple[list[str], list[str]] | None:
@@ -325,13 +348,13 @@ def record(
325
348
  assert path is not None and rel is not None
326
349
  validated.append((path, rel))
327
350
 
328
- manifest_path = _manifest_path(project_root, change_root)
351
+ manifest_path = _manifest_path(change_root)
329
352
  if manifest_path is None:
330
353
  return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
331
354
  lock_path = manifest_path.with_name(manifest_path.name + ".lock")
332
355
  try:
333
356
  with _exclusive_lock(lock_path, wait_seconds=5.0):
334
- if not _manifest_target_inside(project_root, manifest_path):
357
+ if not _manifest_target_inside(change_root, manifest_path):
335
358
  return _result(
336
359
  False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", []
337
360
  )
@@ -378,10 +401,10 @@ def record(
378
401
 
379
402
 
380
403
  def _stage_locked(
381
- project_root: Path, manifest_path: Path, index_path: Path
404
+ project_root: Path, change_root: Path, manifest_path: Path, index_path: Path
382
405
  ) -> dict[str, Any]:
383
406
  action = "stage"
384
- if not _manifest_target_inside(project_root, manifest_path):
407
+ if not _manifest_target_inside(change_root, manifest_path):
385
408
  return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
386
409
  try:
387
410
  manifest = _read_json(manifest_path)
@@ -444,13 +467,164 @@ def _stage_locked(
444
467
  temp_index.unlink(missing_ok=True)
445
468
 
446
469
 
470
+ def _glob_files_for_pattern(base: Path, pattern: str) -> list[Path]:
471
+ normalized = pattern.replace("\\", "/")
472
+ if normalized.endswith("/**"):
473
+ prefix = normalized[:-3].rstrip("/")
474
+ root = base / prefix
475
+ if not root.is_dir():
476
+ return []
477
+ return [path for path in root.rglob("*") if path.is_file()]
478
+ return [path for path in base.glob(normalized) if path.is_file()]
479
+
480
+
481
+ def _enumerate_allowed_test_files(project_root: Path) -> dict[str, str]:
482
+ """Map repo-relative test path -> sha256 for all allowed existing files."""
483
+ found: dict[str, str] = {}
484
+ config = _profile_config(project_root)
485
+ if config is None:
486
+ patterns = ["test/**", "tests/**", "src/test/**"]
487
+ else:
488
+ patterns, _excluded = config
489
+ base = project_root.resolve()
490
+ seen: set[str] = set()
491
+ for pattern in patterns:
492
+ for match in _glob_files_for_pattern(base, pattern):
493
+ resolved = match.resolve()
494
+ if not _inside(resolved, base):
495
+ continue
496
+ rel = resolved.relative_to(base).as_posix()
497
+ if rel in seen or not _allowed_test_path(project_root, rel):
498
+ continue
499
+ seen.add(rel)
500
+ found[rel] = _sha256(resolved)
501
+ return found
502
+
503
+
504
+ def begin(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
505
+ action = "begin"
506
+ project_root = Path(project).resolve()
507
+ change_root = _change_dir(project_root, change_dir)
508
+ if change_root is None:
509
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
510
+ manifest_path = _manifest_path(change_root)
511
+ if manifest_path is None:
512
+ return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
513
+ snapshot_path = change_root / SNAPSHOT_REL
514
+ if not _manifest_target_inside(change_root, snapshot_path):
515
+ return _result(False, action, "SNAPSHOT_PATH_OUTSIDE_PROJECT", [])
516
+
517
+ files = _enumerate_allowed_test_files(project_root)
518
+ snapshot = {
519
+ "schemaVersion": SCHEMA_VERSION,
520
+ "mode": MODE,
521
+ "projectRoot": str(project_root),
522
+ "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
523
+ "files": [
524
+ {"path": rel, "sha256": digest, "ignored": _is_ignored(project_root, rel)}
525
+ for rel, digest in sorted(files.items())
526
+ ],
527
+ }
528
+ _write_json(snapshot_path, snapshot)
529
+ return _result(
530
+ True,
531
+ action,
532
+ "SNAPSHOT_CAPTURED",
533
+ list(files),
534
+ snapshotPath=str(snapshot_path),
535
+ fileCount=len(files),
536
+ )
537
+
538
+
539
+ def close(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
540
+ action = "close"
541
+ project_root = Path(project).resolve()
542
+ change_root = _change_dir(project_root, change_dir)
543
+ if change_root is None:
544
+ return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
545
+ snapshot_path = change_root / SNAPSHOT_REL
546
+ if not snapshot_path.is_file():
547
+ return _result(False, action, "SNAPSHOT_MISSING", [])
548
+ try:
549
+ snapshot = _read_json(snapshot_path)
550
+ except (OSError, json.JSONDecodeError) as exc:
551
+ return _result(False, action, "SNAPSHOT_INVALID", [], error=str(exc))
552
+ if (
553
+ not isinstance(snapshot, dict)
554
+ or snapshot.get("schemaVersion") != SCHEMA_VERSION
555
+ or snapshot.get("projectRoot") != str(project_root)
556
+ ):
557
+ return _result(False, action, "SNAPSHOT_INVALID", [])
558
+
559
+ before_entries = snapshot.get("files")
560
+ if not isinstance(before_entries, list):
561
+ return _result(False, action, "SNAPSHOT_INVALID", [])
562
+ before: dict[str, dict[str, Any]] = {}
563
+ for item in before_entries:
564
+ if not isinstance(item, dict):
565
+ return _result(False, action, "SNAPSHOT_INVALID", [])
566
+ rel = item.get("path")
567
+ digest = item.get("sha256")
568
+ if not isinstance(rel, str) or not rel or not isinstance(digest, str):
569
+ return _result(False, action, "SNAPSHOT_INVALID", [])
570
+ path, normalized, error = _validate_file(project_root, rel)
571
+ if error:
572
+ return _result(False, action, error, [rel])
573
+ if normalized != rel or path is None:
574
+ return _result(False, action, "PATH_ESCAPE", [rel])
575
+ before[rel] = item
576
+
577
+ current = _enumerate_allowed_test_files(project_root)
578
+ for rel in current:
579
+ path, normalized, error = _validate_file(project_root, rel)
580
+ if error:
581
+ return _result(False, action, error, [rel])
582
+ if normalized != rel or path is None:
583
+ return _result(False, action, "PATH_ESCAPE", [rel])
584
+
585
+ touched: list[tuple[str, str]] = []
586
+ for rel, digest in current.items():
587
+ if rel not in before:
588
+ touched.append((rel, "tdd-created"))
589
+ continue
590
+ if before[rel]["sha256"] != digest:
591
+ touched.append((rel, "test-updated"))
592
+
593
+ recorded: list[str] = []
594
+ for rel, reason in touched:
595
+ result = record(project_root, change_root, [str(project_root / rel)], reason)
596
+ if not result.get("ok"):
597
+ code = result.get("code", "RECORD_FAILED")
598
+ if code == "TEST_PATH_NOT_ALLOWED":
599
+ return _result(False, action, "UNCLASSIFIABLE_TEST", [rel])
600
+ return result
601
+ recorded.append(rel)
602
+
603
+ return _result(
604
+ True,
605
+ action,
606
+ "CLOSED",
607
+ recorded,
608
+ recordedCount=len(recorded),
609
+ unchangedPreexisting=len(before) - sum(1 for rel, _ in touched if rel in before),
610
+ )
611
+
612
+
613
+ def mark(
614
+ project: Path | str,
615
+ change_dir: Path | str,
616
+ files: list[str],
617
+ ) -> dict[str, Any]:
618
+ return record(project, change_dir, files, "stale-test-repair")
619
+
620
+
447
621
  def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
448
622
  action = "stage"
449
623
  project_root = Path(project).resolve()
450
624
  change_root = _change_dir(project_root, change_dir)
451
625
  if change_root is None:
452
626
  return _result(False, action, "CHANGE_DIR_OUTSIDE_PROJECT", [])
453
- manifest_path = _manifest_path(project_root, change_root)
627
+ manifest_path = _manifest_path(change_root)
454
628
  if manifest_path is None:
455
629
  return _result(False, action, "MANIFEST_PATH_OUTSIDE_PROJECT", [])
456
630
 
@@ -468,7 +642,9 @@ def stage(project: Path | str, change_dir: Path | str) -> dict[str, Any]:
468
642
  manifest_lock = manifest_path.with_name(manifest_path.name + ".lock")
469
643
  try:
470
644
  with _exclusive_lock(manifest_lock, wait_seconds=0.0):
471
- return _stage_locked(project_root, manifest_path, index_path)
645
+ return _stage_locked(
646
+ project_root, change_root, manifest_path, index_path
647
+ )
472
648
  except LockUnavailable:
473
649
  return _result(False, action, "MANIFEST_LOCKED", [])
474
650
  except LockUnavailable:
@@ -488,10 +664,30 @@ def main(argv: list[str] | None = None) -> int:
488
664
  stage_parser.add_argument("--project", required=True)
489
665
  stage_parser.add_argument("--change-dir", required=True)
490
666
  stage_parser.add_argument("--json", action="store_true")
667
+ begin_parser = sub.add_parser("begin")
668
+ begin_parser.add_argument("--project", required=True)
669
+ begin_parser.add_argument("--change-dir", required=True)
670
+ begin_parser.add_argument("--json", action="store_true")
671
+ close_parser = sub.add_parser("close")
672
+ close_parser.add_argument("--project", required=True)
673
+ close_parser.add_argument("--change-dir", required=True)
674
+ close_parser.add_argument("--json", action="store_true")
675
+ mark_parser = sub.add_parser("mark")
676
+ mark_parser.add_argument("--project", required=True)
677
+ mark_parser.add_argument("--change-dir", required=True)
678
+ mark_parser.add_argument("--files", required=True)
679
+ mark_parser.add_argument("--json", action="store_true")
491
680
  args = parser.parse_args(argv)
492
681
  if args.action == "record":
493
682
  files = [item.strip() for item in args.files.split(",") if item.strip()]
494
683
  result = record(args.project, args.change_dir, files, args.reason)
684
+ elif args.action == "begin":
685
+ result = begin(args.project, args.change_dir)
686
+ elif args.action == "close":
687
+ result = close(args.project, args.change_dir)
688
+ elif args.action == "mark":
689
+ files = [item.strip() for item in args.files.split(",") if item.strip()]
690
+ result = mark(args.project, args.change_dir, files)
495
691
  else:
496
692
  result = stage(args.project, args.change_dir)
497
693
  print(json.dumps(result, ensure_ascii=False, indent=2 if args.json else None))
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Strict loader for harness/contracts/workflow-policy.json.
3
+
4
+ Unknown fields at any depth fail validation. Python 3.10+, stdlib only.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ POLICY_REL = Path("harness") / "contracts" / "workflow-policy.json"
14
+
15
+ TOP_LEVEL_KEYS = frozenset(
16
+ {
17
+ "schemaVersion",
18
+ "riskTiers",
19
+ "skills",
20
+ "requiredArtifacts",
21
+ "requiredValidations",
22
+ "conditionalStages",
23
+ "interactionWhitelist",
24
+ "checkpointRules",
25
+ }
26
+ )
27
+
28
+ RISK_TIER_KEYS = frozenset(
29
+ {
30
+ "description",
31
+ "defaultPhases",
32
+ "requiredValidations",
33
+ "conditionalStages",
34
+ "upgradeTriggers",
35
+ }
36
+ )
37
+
38
+ SKILL_KEYS = frozenset(
39
+ {
40
+ "phase",
41
+ "inputs",
42
+ "artifacts",
43
+ "events",
44
+ "allowedInteractions",
45
+ "capabilities",
46
+ "profiles",
47
+ }
48
+ )
49
+
50
+ CONDITIONAL_STAGE_KEYS = frozenset({"tiers", "signals"})
51
+ CHECKPOINT_RULE_KEYS = frozenset(
52
+ {
53
+ "afterTasks",
54
+ "beforeTasks",
55
+ "blocking",
56
+ "requiredReport",
57
+ "reviewerTool",
58
+ }
59
+ )
60
+
61
+
62
+ class PolicyValidationError(ValueError):
63
+ """Raised when workflow-policy.json contains unknown or invalid fields."""
64
+
65
+
66
+ def _reject_unknown(data: dict[str, Any], allowed: frozenset[str], path: str) -> None:
67
+ unknown = sorted(set(data) - allowed)
68
+ if unknown:
69
+ raise PolicyValidationError(
70
+ f"unknown field(s) at {path}: {', '.join(unknown)}"
71
+ )
72
+
73
+
74
+ def _require_type(value: Any, expected: type | tuple[type, ...], path: str) -> None:
75
+ if not isinstance(value, expected):
76
+ names = (
77
+ expected.__name__
78
+ if isinstance(expected, type)
79
+ else "|".join(t.__name__ for t in expected)
80
+ )
81
+ raise PolicyValidationError(f"{path} must be {names}, got {type(value).__name__}")
82
+
83
+
84
+ def validate_policy(data: Any) -> dict[str, Any]:
85
+ if not isinstance(data, dict):
86
+ raise PolicyValidationError("workflow policy root must be an object")
87
+ _reject_unknown(data, TOP_LEVEL_KEYS, "root")
88
+ _require_type(data.get("schemaVersion"), int, "schemaVersion")
89
+ if data["schemaVersion"] != 1:
90
+ raise PolicyValidationError("unsupported schemaVersion")
91
+
92
+ risk_tiers = data["riskTiers"]
93
+ _require_type(risk_tiers, dict, "riskTiers")
94
+ for tier_name, tier in risk_tiers.items():
95
+ path = f"riskTiers.{tier_name}"
96
+ _require_type(tier, dict, path)
97
+ _reject_unknown(tier, RISK_TIER_KEYS, path)
98
+ _require_type(tier["description"], str, f"{path}.description")
99
+ _require_type(tier["defaultPhases"], list, f"{path}.defaultPhases")
100
+ _require_type(tier["requiredValidations"], list, f"{path}.requiredValidations")
101
+ _require_type(tier["conditionalStages"], list, f"{path}.conditionalStages")
102
+ _require_type(tier["upgradeTriggers"], list, f"{path}.upgradeTriggers")
103
+
104
+ skills = data["skills"]
105
+ _require_type(skills, dict, "skills")
106
+ for skill_name, skill in skills.items():
107
+ path = f"skills.{skill_name}"
108
+ _require_type(skill, dict, path)
109
+ _reject_unknown(skill, SKILL_KEYS, path)
110
+ _require_type(skill["phase"], str, f"{path}.phase")
111
+ for key in ("inputs", "artifacts", "events", "allowedInteractions", "capabilities"):
112
+ _require_type(skill[key], list, f"{path}.{key}")
113
+ profiles = skill.get("profiles", ["general", "java"])
114
+ _require_type(profiles, list, f"{path}.profiles")
115
+ if not profiles or any(item not in {"general", "java"} for item in profiles):
116
+ raise PolicyValidationError(f"{path}.profiles must contain general and/or java")
117
+
118
+ for section in ("requiredArtifacts", "requiredValidations"):
119
+ section_data = data[section]
120
+ _require_type(section_data, dict, section)
121
+ for phase, items in section_data.items():
122
+ _require_type(items, list, f"{section}.{phase}")
123
+
124
+ conditional = data["conditionalStages"]
125
+ _require_type(conditional, dict, "conditionalStages")
126
+ for stage_name, stage in conditional.items():
127
+ path = f"conditionalStages.{stage_name}"
128
+ _require_type(stage, dict, path)
129
+ _reject_unknown(stage, CONDITIONAL_STAGE_KEYS, path)
130
+ _require_type(stage["tiers"], list, f"{path}.tiers")
131
+ _require_type(stage["signals"], list, f"{path}.signals")
132
+
133
+ interaction = data["interactionWhitelist"]
134
+ _require_type(interaction, dict, "interactionWhitelist")
135
+ for key, items in interaction.items():
136
+ _require_type(items, list, f"interactionWhitelist.{key}")
137
+
138
+ checkpoints = data["checkpointRules"]
139
+ _require_type(checkpoints, dict, "checkpointRules")
140
+ for checkpoint_id, rule in checkpoints.items():
141
+ path = f"checkpointRules.{checkpoint_id}"
142
+ _require_type(rule, dict, path)
143
+ _reject_unknown(rule, CHECKPOINT_RULE_KEYS, path)
144
+ _require_type(rule["afterTasks"], list, f"{path}.afterTasks")
145
+ _require_type(rule["beforeTasks"], list, f"{path}.beforeTasks")
146
+ _require_type(rule["blocking"], bool, f"{path}.blocking")
147
+
148
+ return data
149
+
150
+
151
+ def policy_path_for_repo(repo_root: Path) -> Path:
152
+ return repo_root / POLICY_REL
153
+
154
+
155
+ def load_policy(repo_root: Path) -> dict[str, Any]:
156
+ path = policy_path_for_repo(repo_root)
157
+ if not path.is_file():
158
+ raise FileNotFoundError(f"workflow policy missing: {path}")
159
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
160
+ return validate_policy(data)
@@ -2,5 +2,5 @@
2
2
  "schemaVersion": 1,
3
3
  "overlay": "java",
4
4
  "agent": "codex",
5
- "coreHash": "5be110134c0a5e82"
5
+ "coreHash": "0125e4c77a2c5c99"
6
6
  }