@hunter-harness/workflow-harness 0.2.69 → 0.2.70

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/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 +3 -1
  7. package/harness/bundles/general/claude-code/harness-plan/reference.md +31 -1
  8. package/harness/bundles/general/claude-code/harness-pull/SKILL.md +1 -1
  9. package/harness/bundles/general/claude-code/harness-push/SKILL.md +1 -1
  10. package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
  11. package/harness/bundles/general/claude-code/harness-run/SKILL.md +3 -1
  12. package/harness/bundles/general/claude-code/harness-run/reference.md +20 -0
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -1
  16. package/harness/bundles/general/claude-code/harness-test/pitfalls.md +34 -3
  17. package/harness/bundles/general/claude-code/harness-test/reference.md +9 -0
  18. package/harness/bundles/general/claude-code/scripts/harness_context.py +27 -7
  19. package/harness/bundles/general/claude-code/scripts/harness_events.py +12 -3
  20. package/harness/bundles/general/claude-code/scripts/harness_gate.py +127 -11
  21. package/harness/bundles/general/claude-code/scripts/harness_ledger.py +244 -5
  22. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +254 -19
  23. package/harness/bundles/general/claude-code/scripts/harness_service.py +69 -15
  24. package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +48 -1
  25. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  26. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
  27. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
  28. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  29. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  30. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +3 -1
  31. package/harness/bundles/general/codebuddy/harness-plan/reference.md +31 -1
  32. package/harness/bundles/general/codebuddy/harness-pull/SKILL.md +1 -1
  33. package/harness/bundles/general/codebuddy/harness-push/SKILL.md +1 -1
  34. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
  35. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +3 -1
  36. package/harness/bundles/general/codebuddy/harness-run/reference.md +20 -0
  37. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
  38. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
  39. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -1
  40. package/harness/bundles/general/codebuddy/harness-test/pitfalls.md +34 -3
  41. package/harness/bundles/general/codebuddy/harness-test/reference.md +9 -0
  42. package/harness/bundles/general/codebuddy/scripts/harness_context.py +27 -7
  43. package/harness/bundles/general/codebuddy/scripts/harness_events.py +12 -3
  44. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +127 -11
  45. package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +244 -5
  46. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +254 -19
  47. package/harness/bundles/general/codebuddy/scripts/harness_service.py +69 -15
  48. package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +48 -1
  49. package/harness/bundles/general/codex/.harness-build.json +1 -1
  50. package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
  51. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
  52. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
  53. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
  54. package/harness/bundles/general/codex/harness-plan/SKILL.md +3 -1
  55. package/harness/bundles/general/codex/harness-plan/reference.md +31 -1
  56. package/harness/bundles/general/codex/harness-pull/SKILL.md +1 -1
  57. package/harness/bundles/general/codex/harness-push/SKILL.md +1 -1
  58. package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
  59. package/harness/bundles/general/codex/harness-run/SKILL.md +3 -1
  60. package/harness/bundles/general/codex/harness-run/reference.md +20 -0
  61. package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
  62. package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
  63. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -1
  64. package/harness/bundles/general/codex/harness-test/pitfalls.md +34 -3
  65. package/harness/bundles/general/codex/harness-test/reference.md +9 -0
  66. package/harness/bundles/general/codex/scripts/harness_context.py +27 -7
  67. package/harness/bundles/general/codex/scripts/harness_events.py +12 -3
  68. package/harness/bundles/general/codex/scripts/harness_gate.py +127 -11
  69. package/harness/bundles/general/codex/scripts/harness_ledger.py +244 -5
  70. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +254 -19
  71. package/harness/bundles/general/codex/scripts/harness_service.py +69 -15
  72. package/harness/bundles/general/codex/scripts/harness_test_guard.py +48 -1
  73. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  74. package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
  75. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
  76. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  77. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
  78. package/harness/bundles/general/cursor/harness-plan/SKILL.md +3 -1
  79. package/harness/bundles/general/cursor/harness-plan/reference.md +31 -1
  80. package/harness/bundles/general/cursor/harness-pull/SKILL.md +1 -1
  81. package/harness/bundles/general/cursor/harness-push/SKILL.md +1 -1
  82. package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
  83. package/harness/bundles/general/cursor/harness-run/SKILL.md +3 -1
  84. package/harness/bundles/general/cursor/harness-run/reference.md +20 -0
  85. package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
  86. package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
  87. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -1
  88. package/harness/bundles/general/cursor/harness-test/pitfalls.md +34 -3
  89. package/harness/bundles/general/cursor/harness-test/reference.md +9 -0
  90. package/harness/bundles/general/cursor/scripts/harness_context.py +27 -7
  91. package/harness/bundles/general/cursor/scripts/harness_events.py +12 -3
  92. package/harness/bundles/general/cursor/scripts/harness_gate.py +127 -11
  93. package/harness/bundles/general/cursor/scripts/harness_ledger.py +244 -5
  94. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +254 -19
  95. package/harness/bundles/general/cursor/scripts/harness_service.py +69 -15
  96. package/harness/bundles/general/cursor/scripts/harness_test_guard.py +48 -1
  97. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  98. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
  99. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
  100. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
  101. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
  102. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
  103. package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
  104. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +3 -1
  105. package/harness/bundles/java/claude-code/harness-plan/reference.md +31 -1
  106. package/harness/bundles/java/claude-code/harness-pull/SKILL.md +1 -1
  107. package/harness/bundles/java/claude-code/harness-push/SKILL.md +1 -1
  108. package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
  109. package/harness/bundles/java/claude-code/harness-run/SKILL.md +3 -1
  110. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
  111. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
  112. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -1
  113. package/harness/bundles/java/claude-code/harness-test/pitfalls.md +34 -3
  114. package/harness/bundles/java/claude-code/scripts/harness_context.py +27 -7
  115. package/harness/bundles/java/claude-code/scripts/harness_events.py +12 -3
  116. package/harness/bundles/java/claude-code/scripts/harness_gate.py +127 -11
  117. package/harness/bundles/java/claude-code/scripts/harness_ledger.py +244 -5
  118. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +254 -19
  119. package/harness/bundles/java/claude-code/scripts/harness_service.py +69 -15
  120. package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +48 -1
  121. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  122. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
  123. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
  124. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
  125. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
  126. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
  127. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
  128. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +3 -1
  129. package/harness/bundles/java/codebuddy/harness-plan/reference.md +31 -1
  130. package/harness/bundles/java/codebuddy/harness-pull/SKILL.md +1 -1
  131. package/harness/bundles/java/codebuddy/harness-push/SKILL.md +1 -1
  132. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
  133. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +3 -1
  134. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
  135. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
  136. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -1
  137. package/harness/bundles/java/codebuddy/harness-test/pitfalls.md +34 -3
  138. package/harness/bundles/java/codebuddy/scripts/harness_context.py +27 -7
  139. package/harness/bundles/java/codebuddy/scripts/harness_events.py +12 -3
  140. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +127 -11
  141. package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +244 -5
  142. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +254 -19
  143. package/harness/bundles/java/codebuddy/scripts/harness_service.py +69 -15
  144. package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +48 -1
  145. package/harness/bundles/java/codex/.harness-build.json +1 -1
  146. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
  147. package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
  148. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
  149. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
  150. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
  151. package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
  152. package/harness/bundles/java/codex/harness-plan/SKILL.md +3 -1
  153. package/harness/bundles/java/codex/harness-plan/reference.md +31 -1
  154. package/harness/bundles/java/codex/harness-pull/SKILL.md +1 -1
  155. package/harness/bundles/java/codex/harness-push/SKILL.md +1 -1
  156. package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
  157. package/harness/bundles/java/codex/harness-run/SKILL.md +3 -1
  158. package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
  159. package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
  160. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -1
  161. package/harness/bundles/java/codex/harness-test/pitfalls.md +34 -3
  162. package/harness/bundles/java/codex/scripts/harness_context.py +27 -7
  163. package/harness/bundles/java/codex/scripts/harness_events.py +12 -3
  164. package/harness/bundles/java/codex/scripts/harness_gate.py +127 -11
  165. package/harness/bundles/java/codex/scripts/harness_ledger.py +244 -5
  166. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +254 -19
  167. package/harness/bundles/java/codex/scripts/harness_service.py +69 -15
  168. package/harness/bundles/java/codex/scripts/harness_test_guard.py +48 -1
  169. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  170. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
  171. package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
  172. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
  173. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
  174. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
  175. package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
  176. package/harness/bundles/java/cursor/harness-plan/SKILL.md +3 -1
  177. package/harness/bundles/java/cursor/harness-plan/reference.md +31 -1
  178. package/harness/bundles/java/cursor/harness-pull/SKILL.md +1 -1
  179. package/harness/bundles/java/cursor/harness-push/SKILL.md +1 -1
  180. package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
  181. package/harness/bundles/java/cursor/harness-run/SKILL.md +3 -1
  182. package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
  183. package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
  184. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -1
  185. package/harness/bundles/java/cursor/harness-test/pitfalls.md +34 -3
  186. package/harness/bundles/java/cursor/scripts/harness_context.py +27 -7
  187. package/harness/bundles/java/cursor/scripts/harness_events.py +12 -3
  188. package/harness/bundles/java/cursor/scripts/harness_gate.py +127 -11
  189. package/harness/bundles/java/cursor/scripts/harness_ledger.py +244 -5
  190. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +254 -19
  191. package/harness/bundles/java/cursor/scripts/harness_service.py +69 -15
  192. package/harness/bundles/java/cursor/scripts/harness_test_guard.py +48 -1
  193. package/harness/manifests/general/claude-code.json +26 -26
  194. package/harness/manifests/general/codebuddy.json +26 -26
  195. package/harness/manifests/general/codex.json +26 -26
  196. package/harness/manifests/general/cursor.json +26 -26
  197. package/harness/manifests/java/claude-code.json +26 -26
  198. package/harness/manifests/java/codebuddy.json +26 -26
  199. package/harness/manifests/java/codex.json +26 -26
  200. package/harness/manifests/java/cursor.json +26 -26
  201. package/hunter-workflow-family.json +5 -5
  202. package/package.json +1 -1
@@ -151,6 +151,11 @@ SOFT_GATE_SITES = frozenset({
151
151
  })
152
152
  GATE_WARNINGS_REL = Path("evidence") / "gate-warnings.ndjson"
153
153
 
154
+ # Lifecycle order used to scope C9 scenario coverage by scenario ownerPhase.
155
+ # A scenario owned by a later phase is deferred, not missing, at an earlier
156
+ # phase close. Must stay a superset-ordering of hpf.VALID_OWNER_PHASES.
157
+ SCENARIO_OWNER_PHASE_ORDER = ("plan", "run", "test", "review", "submit")
158
+
154
159
 
155
160
  def gate_severity_mode(project: Path, change_dir: Path | None = None) -> str:
156
161
  """Resolve gate severity mode: env > change gate-policy > project config.
@@ -367,7 +372,11 @@ def evaluate_projection_gate(
367
372
  def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
368
373
  candidate = Path(raw).expanduser().resolve() if raw else main_project.resolve()
369
374
  if not candidate.is_dir():
370
- raise ValueError(f"execution root not found: {candidate}")
375
+ raise ValueError(
376
+ f"execution root not found: {candidate} — --project takes a "
377
+ "filesystem path to the execution root (use '.' when running from "
378
+ "it), not the project name"
379
+ )
371
380
  top = _git_text(candidate, "rev-parse", "--show-toplevel")
372
381
  if not top:
373
382
  raise ValueError(f"execution root is not a git worktree: {candidate}")
@@ -1957,12 +1966,78 @@ def _sync_after_phase_close(project: Path, change_dir: Path) -> dict[str, Any]:
1957
1966
  }
1958
1967
 
1959
1968
 
1960
- def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
1969
+ def _scenario_owner_phase_rank(owner_phase: str | None) -> int | None:
1970
+ """Rank a scenario ownerPhase against the lifecycle order, None when unknown."""
1971
+ if not owner_phase:
1972
+ return None
1973
+ normalized = str(owner_phase).strip().lower()
1974
+ if normalized not in hpf.VALID_OWNER_PHASES:
1975
+ return None
1976
+ try:
1977
+ return SCENARIO_OWNER_PHASE_ORDER.index(normalized)
1978
+ except ValueError:
1979
+ return None
1980
+
1981
+
1982
+ def _partition_scenarios_by_owner_phase(
1983
+ scenarios: list[Any],
1984
+ required_ids: set[str],
1985
+ phase: str | None,
1986
+ ) -> tuple[set[str], list[str]]:
1987
+ """Split required scenarios into (due now, deferred to a later phase).
1988
+
1989
+ A scenario is deferred only when it declares an `ownerPhase` that ranks
1990
+ strictly after the phase being closed. Scenarios without a usable
1991
+ `ownerPhase` (legacy/v1 manifests) stay due, preserving old behaviour.
1992
+ """
1993
+ closing_rank = _scenario_owner_phase_rank(phase)
1994
+ if closing_rank is None:
1995
+ return set(required_ids), []
1996
+ due: set[str] = set()
1997
+ deferred: set[str] = set()
1998
+ for scenario in scenarios:
1999
+ if not isinstance(scenario, dict):
2000
+ continue
2001
+ scenario_id = str(scenario.get("id") or "").strip()
2002
+ if scenario_id not in required_ids:
2003
+ continue
2004
+ owner_rank = _scenario_owner_phase_rank(scenario.get("ownerPhase"))
2005
+ if owner_rank is not None and owner_rank > closing_rank:
2006
+ deferred.add(scenario_id)
2007
+ else:
2008
+ due.add(scenario_id)
2009
+ # IDs present in required_ids but absent from the scan stay due (defensive).
2010
+ due |= required_ids - due - deferred
2011
+ # A duplicated ID declaring two ownerPhases must fail closed: if any
2012
+ # occurrence is due now, the scenario is due now.
2013
+ deferred -= due
2014
+ return due, sorted(deferred)
2015
+
2016
+
2017
+ def _deferred_hint(deferred_ids: list[str], phase: str | None) -> str:
2018
+ """Append a note so callers never mistake a deferred scenario for a blocker."""
2019
+ if not deferred_ids:
2020
+ return ""
2021
+ return (
2022
+ f"(另有 {len(deferred_ids)} 个场景按 ownerPhase 移交后续阶段,"
2023
+ f"未计入 {phase or '本'} 阶段要求: " + ", ".join(deferred_ids) + ")"
2024
+ )
2025
+
2026
+
2027
+ def _validate_scenario_coverage(
2028
+ change_dir: Path, phase: str | None = None
2029
+ ) -> dict[str, Any]:
1961
2030
  """C9: validate all ledger-required scenarios are covered by ledger entries.
1962
2031
 
1963
2032
  Reads meta/scenario-manifest.json and evidence/verification-ledger.json.
1964
2033
  Returns ok=True when a legacy manifest is missing or all required scenarios
1965
2034
  are covered. A present but empty manifest is always invalid.
2035
+
2036
+ Phase scoping: when `phase` is given, only scenarios whose `ownerPhase`
2037
+ is due by that phase are required to carry passing receipts. Scenarios
2038
+ owned by a later phase (e.g. `ownerPhase=test` at `run` close) are
2039
+ reported under `deferred` instead of blocking the close — this matches the
2040
+ documented hand-off rule in harness-run/SKILL.md ("ownerPhase=test 按计划移交").
1966
2041
  """
1967
2042
  manifest_path = change_dir / "meta" / "scenario-manifest.json"
1968
2043
  if not manifest_path.is_file():
@@ -2010,6 +2085,9 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2010
2085
  }
2011
2086
  if not required_ids:
2012
2087
  return {"ok": True, "code": "NO_LEDGER_REQUIRED_SCENARIOS"}
2088
+ due_ids, deferred_ids = _partition_scenarios_by_owner_phase(
2089
+ scenarios, required_ids, phase
2090
+ )
2013
2091
  raw_schema_version = (
2014
2092
  manifest.get("schemaVersion") if isinstance(manifest, dict) else None
2015
2093
  )
@@ -2043,6 +2121,18 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2043
2121
  "missingMappings": missing_mappings,
2044
2122
  }
2045
2123
 
2124
+ if not due_ids:
2125
+ # Every required scenario is owned by a later phase — nothing is due
2126
+ # at this close. Report the hand-off instead of blocking.
2127
+ return {
2128
+ "ok": True,
2129
+ "code": "SCENARIO_COVERAGE_DEFERRED",
2130
+ "covered": [],
2131
+ "deferred": deferred_ids,
2132
+ "ownerPhaseScope": phase,
2133
+ "schemaVersion": schema_version,
2134
+ }
2135
+
2046
2136
  try:
2047
2137
  ledger, ledger_path = hl.load_ledger(change_dir)
2048
2138
  except (OSError, ValueError, json.JSONDecodeError) as exc:
@@ -2050,14 +2140,16 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2050
2140
  "ok": False,
2051
2141
  "code": "SCENARIO_COVERAGE_FAILED",
2052
2142
  "message": f"ledger unreadable: {exc}",
2053
- "missing": sorted(required_ids),
2143
+ "missing": sorted(due_ids),
2144
+ "deferred": deferred_ids,
2054
2145
  }
2055
2146
  if ledger is None or ledger_path is None:
2056
2147
  return {
2057
2148
  "ok": False,
2058
2149
  "code": "SCENARIO_COVERAGE_FAILED",
2059
2150
  "message": "ledger missing; cannot verify required scenario coverage",
2060
- "missing": sorted(required_ids),
2151
+ "missing": sorted(due_ids),
2152
+ "deferred": deferred_ids,
2061
2153
  }
2062
2154
  if schema_version >= 2:
2063
2155
  coverage_sets: dict[str, set[str]] = {
@@ -2137,8 +2229,8 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2137
2229
  )
2138
2230
 
2139
2231
  passed = coverage_sets["passed"]
2140
- missing = sorted(required_ids - bound)
2141
- unexecuted = sorted(required_ids - passed)
2232
+ missing = sorted(due_ids - bound)
2233
+ unexecuted = sorted(due_ids - passed)
2142
2234
  detail = {
2143
2235
  key: sorted(values & required_ids)
2144
2236
  for key, values in coverage_sets.items()
@@ -2147,6 +2239,8 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2147
2239
  {
2148
2240
  "missing": missing,
2149
2241
  "unexecuted": unexecuted,
2242
+ "deferred": deferred_ids,
2243
+ "ownerPhaseScope": phase,
2150
2244
  "attempts": {
2151
2245
  scenario_id: sorted(values)
2152
2246
  for scenario_id, values in sorted(attempts.items())
@@ -2162,6 +2256,7 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2162
2256
  "message": (
2163
2257
  "required scenarios without exact passed execution receipts: "
2164
2258
  + ", ".join(unexecuted)
2259
+ + _deferred_hint(deferred_ids, phase)
2165
2260
  ),
2166
2261
  **detail,
2167
2262
  }
@@ -2180,7 +2275,7 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2180
2275
  ids = entry.get("scenarioIds")
2181
2276
  if isinstance(ids, list):
2182
2277
  covered.update(str(i) for i in ids)
2183
- missing = sorted(required_ids - covered)
2278
+ missing = sorted(due_ids - covered)
2184
2279
  if missing:
2185
2280
  return {
2186
2281
  "ok": False,
@@ -2188,13 +2283,18 @@ def _validate_scenario_coverage(change_dir: Path) -> dict[str, Any]:
2188
2283
  "message": (
2189
2284
  "ledger-required scenarios without ledger entry: "
2190
2285
  + ", ".join(missing)
2286
+ + _deferred_hint(deferred_ids, phase)
2191
2287
  ),
2192
2288
  "missing": missing,
2289
+ "deferred": deferred_ids,
2290
+ "ownerPhaseScope": phase,
2193
2291
  }
2194
2292
  return {
2195
2293
  "ok": True,
2196
2294
  "code": "SCENARIO_COVERAGE_OK",
2197
2295
  "covered": sorted(covered & required_ids),
2296
+ "deferred": deferred_ids,
2297
+ "ownerPhaseScope": phase,
2198
2298
  }
2199
2299
 
2200
2300
 
@@ -2888,9 +2988,11 @@ def cmd_close(args: argparse.Namespace) -> int:
2888
2988
  },
2889
2989
  )
2890
2990
 
2891
- # C9: scenario coverage check — all P0 scenarios must have a ledger entry.
2991
+ # C9: scenario coverage check — every P0/ledger scenario *due by this phase*
2992
+ # must have a ledger entry. Scenarios with a later ownerPhase are deferred,
2993
+ # not missing (see _validate_scenario_coverage).
2892
2994
  if args.phase in {"run", "test"}:
2893
- coverage = _validate_scenario_coverage(change_dir)
2995
+ coverage = _validate_scenario_coverage(change_dir, args.phase)
2894
2996
  if not coverage.get("ok"):
2895
2997
  if gate_soft_allowed(severity_mode, args.phase, "scenario-coverage"):
2896
2998
  gate_warnings.append(record_gate_warning(
@@ -3284,7 +3386,14 @@ def build_parser() -> argparse.ArgumentParser:
3284
3386
  p_begin = sub.add_parser("begin", parents=[shared])
3285
3387
  p_begin.add_argument("--phase", required=True)
3286
3388
  p_begin.add_argument("--change", default=None)
3287
- p_begin.add_argument("--project", default=None)
3389
+ p_begin.add_argument(
3390
+ "--project",
3391
+ default=None,
3392
+ help=(
3393
+ "execution root PATH (worktree) for this phase; defaults to the "
3394
+ "change's main project. Takes a path, not a project name."
3395
+ ),
3396
+ )
3288
3397
  p_begin.add_argument("--skills-root", default=None)
3289
3398
  p_begin.add_argument("--run-id", default=None)
3290
3399
  p_begin.add_argument("--ttl-seconds", type=int, default=3600)
@@ -3298,7 +3407,14 @@ def build_parser() -> argparse.ArgumentParser:
3298
3407
  p_close = sub.add_parser("close", parents=[shared])
3299
3408
  p_close.add_argument("--phase", required=True)
3300
3409
  p_close.add_argument("--change", default=None)
3301
- p_close.add_argument("--project", default=None)
3410
+ p_close.add_argument(
3411
+ "--project",
3412
+ default=None,
3413
+ help=(
3414
+ "execution root PATH (worktree) for this phase; defaults to the "
3415
+ "change's main project. Takes a path, not a project name."
3416
+ ),
3417
+ )
3302
3418
  p_close.add_argument("--status", required=True)
3303
3419
  p_close.add_argument("--run-id", default=None)
3304
3420
  p_close.add_argument("--task", type=int, default=None)
@@ -1614,6 +1614,35 @@ def _scenario_receipt_error(code: str, message: str) -> dict[str, Any]:
1614
1614
  return {"ok": False, "code": code, "error": message}
1615
1615
 
1616
1616
 
1617
+ def _resolve_receipt_path(
1618
+ raw: str, change_dir: Path
1619
+ ) -> tuple[Path | None, list[Path]]:
1620
+ """Resolve --scenario-receipt-file against CWD *and* the change dir.
1621
+
1622
+ Skills write receipts under ``<change-dir>/runtime/``, so a bare
1623
+ ``runtime/scenario-receipt-*.json`` is the natural thing to pass. Resolving
1624
+ only against the CWD made that fail with a bare ENOENT. Return the first
1625
+ existing candidate plus every path tried, so the error can name them.
1626
+ """
1627
+ candidate = Path(raw).expanduser()
1628
+ if candidate.is_absolute():
1629
+ resolved = candidate.resolve()
1630
+ return (resolved if resolved.is_file() else None), [resolved]
1631
+ candidates = [
1632
+ (Path.cwd() / candidate).resolve(),
1633
+ (change_dir / candidate).resolve(),
1634
+ ]
1635
+ # De-duplicate while preserving order (CWD == change_dir is common).
1636
+ unique: list[Path] = []
1637
+ for item in candidates:
1638
+ if item not in unique:
1639
+ unique.append(item)
1640
+ for item in unique:
1641
+ if item.is_file():
1642
+ return item, unique
1643
+ return None, unique
1644
+
1645
+
1617
1646
  def _canonical_string_list(
1618
1647
  receipt: dict[str, Any],
1619
1648
  field: str,
@@ -2786,17 +2815,37 @@ def cmd_record(args: argparse.Namespace) -> int:
2786
2815
  if manifest_schema >= 2 and not _nonempty_str(receipt_file):
2787
2816
  return emit_error(
2788
2817
  "scenario-manifest schemaVersion 2 requires "
2789
- "--scenario-receipt-file",
2818
+ "--scenario-receipt-file; generate a skeleton with: "
2819
+ "harness_ledger.py scenario-receipt-template "
2820
+ f"--change-dir {change_dir} --scenario-ids "
2821
+ f"{','.join(ids)} --runner <name> --out "
2822
+ "runtime/scenario-receipt-<verification>.json",
2790
2823
  as_json=as_json,
2791
2824
  error_code="SCENARIO_RECEIPT_REQUIRED",
2825
+ extra={"template": "scenario-receipt-template"},
2792
2826
  )
2793
2827
  if _nonempty_str(receipt_file):
2828
+ resolved_receipt, receipt_candidates = _resolve_receipt_path(
2829
+ str(receipt_file), change_dir
2830
+ )
2831
+ if resolved_receipt is None:
2832
+ return emit_error(
2833
+ "scenario receipt not found; tried: "
2834
+ + ", ".join(str(c) for c in receipt_candidates),
2835
+ as_json=as_json,
2836
+ error_code="SCENARIO_RECEIPT_NOT_FOUND",
2837
+ extra={
2838
+ "triedPaths": [str(c) for c in receipt_candidates],
2839
+ "hint": (
2840
+ "--scenario-receipt-file accepts an absolute "
2841
+ "path, a CWD-relative path, or a path relative "
2842
+ "to --change-dir"
2843
+ ),
2844
+ },
2845
+ )
2794
2846
  try:
2795
2847
  receipt_payload = json.loads(
2796
- Path(str(receipt_file))
2797
- .expanduser()
2798
- .resolve()
2799
- .read_text(encoding="utf-8-sig")
2848
+ resolved_receipt.read_text(encoding="utf-8-sig")
2800
2849
  )
2801
2850
  except (OSError, json.JSONDecodeError) as exc:
2802
2851
  return emit_error(
@@ -2953,6 +3002,160 @@ def cmd_record(args: argparse.Namespace) -> int:
2953
3002
  return 0
2954
3003
 
2955
3004
 
3005
+ def cmd_scenario_receipt_template(args: argparse.Namespace) -> int:
3006
+ """Emit a schema-v2 receipt skeleton built from the scenario manifest.
3007
+
3008
+ Without this, agents had to read harness_ledger.py source to learn the
3009
+ receipt shape. The skeleton is already valid for `record`; the caller only
3010
+ has to correct `status` for any test that did not pass.
3011
+ """
3012
+ as_json = bool(args.json)
3013
+ change_dir = resolve_path(args.change_dir)
3014
+ ids = [s.strip() for s in str(args.scenario_ids).split(",") if s.strip()]
3015
+ if not ids:
3016
+ return emit_error(
3017
+ "--scenario-ids must list at least one scenario ID",
3018
+ as_json=as_json,
3019
+ error_code="SCENARIO_IDS_REQUIRED",
3020
+ )
3021
+ if len(ids) != len(set(ids)):
3022
+ return emit_error(
3023
+ "scenario IDs must be unique",
3024
+ as_json=as_json,
3025
+ error_code="SCENARIO_ID_DUPLICATE",
3026
+ )
3027
+ manifest_path = change_dir / "meta" / "scenario-manifest.json"
3028
+ try:
3029
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
3030
+ except FileNotFoundError:
3031
+ return emit_error(
3032
+ f"scenario manifest does not exist: {manifest_path}",
3033
+ as_json=as_json,
3034
+ error_code="SCENARIO_MANIFEST_MISSING",
3035
+ )
3036
+ except (OSError, json.JSONDecodeError) as exc:
3037
+ return emit_error(
3038
+ f"scenario manifest unreadable: {exc}",
3039
+ as_json=as_json,
3040
+ error_code="SCENARIO_MANIFEST_INVALID",
3041
+ )
3042
+ scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
3043
+ if not isinstance(scenarios, list):
3044
+ return emit_error(
3045
+ "scenario-manifest.json must contain a scenarios array",
3046
+ as_json=as_json,
3047
+ error_code="SCENARIO_MANIFEST_INVALID",
3048
+ )
3049
+ by_id = {
3050
+ str(item.get("id") or "").strip(): item
3051
+ for item in scenarios
3052
+ if isinstance(item, dict) and _nonempty_str(item.get("id"))
3053
+ }
3054
+ unknown = sorted(set(ids) - set(by_id))
3055
+ if unknown:
3056
+ return emit_error(
3057
+ "scenario IDs are not declared in scenario-manifest.json: "
3058
+ + ", ".join(unknown),
3059
+ as_json=as_json,
3060
+ error_code="SCENARIO_ID_UNKNOWN",
3061
+ )
3062
+ status = str(args.status).strip().upper()
3063
+ if status not in {"PASSED", "FAILED", "SKIPPED"}:
3064
+ return emit_error(
3065
+ "--status must be one of PASSED|FAILED|SKIPPED",
3066
+ as_json=as_json,
3067
+ error_code="SCENARIO_RECEIPT_INVALID",
3068
+ )
3069
+ attempt = int(args.attempt)
3070
+ if attempt < 1:
3071
+ return emit_error(
3072
+ "--attempt must be a positive integer",
3073
+ as_json=as_json,
3074
+ error_code="SCENARIO_RECEIPT_INVALID",
3075
+ )
3076
+
3077
+ identities: list[tuple[str, str, str]] = []
3078
+ incomplete: list[str] = []
3079
+ for scenario_id in ids:
3080
+ scenario = by_id[scenario_id]
3081
+ if any(
3082
+ not _nonempty_str(scenario.get(field))
3083
+ for field in ("executableTestId", "testFile", "testTitle")
3084
+ ):
3085
+ incomplete.append(scenario_id)
3086
+ continue
3087
+ identities.append(
3088
+ (
3089
+ str(scenario["executableTestId"]).strip(),
3090
+ str(scenario["testFile"]).strip(),
3091
+ str(scenario["testTitle"]).strip(),
3092
+ )
3093
+ )
3094
+ if incomplete:
3095
+ return emit_error(
3096
+ "scenarios are missing executableTestId/testFile/testTitle in the "
3097
+ "manifest: " + ", ".join(incomplete),
3098
+ as_json=as_json,
3099
+ error_code="SCENARIO_MANIFEST_INVALID",
3100
+ extra={"incomplete": incomplete},
3101
+ )
3102
+
3103
+ runner: dict[str, Any] = {"name": str(args.runner).strip()}
3104
+ if _nonempty_str(getattr(args, "runner_version", None)):
3105
+ runner["version"] = str(args.runner_version).strip()
3106
+ receipt = {
3107
+ "schemaVersion": 1,
3108
+ "runner": runner,
3109
+ "attempt": attempt,
3110
+ "declared": [test_id for test_id, _, _ in identities],
3111
+ "selected": [test_id for test_id, _, _ in identities],
3112
+ "collected": [
3113
+ {"testId": test_id, "file": file, "title": title}
3114
+ for test_id, file, title in identities
3115
+ ],
3116
+ "executed": [
3117
+ {
3118
+ "testId": test_id,
3119
+ "file": file,
3120
+ "title": title,
3121
+ "attempt": attempt,
3122
+ "status": status,
3123
+ }
3124
+ for test_id, file, title in identities
3125
+ ],
3126
+ }
3127
+ body = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
3128
+
3129
+ out_raw = getattr(args, "out", None)
3130
+ if not _nonempty_str(out_raw):
3131
+ sys.stdout.write(body)
3132
+ return 0
3133
+ out_path = Path(str(out_raw)).expanduser()
3134
+ if not out_path.is_absolute():
3135
+ out_path = change_dir / out_path
3136
+ out_path = out_path.resolve()
3137
+ try:
3138
+ out_path.parent.mkdir(parents=True, exist_ok=True)
3139
+ out_path.write_text(body, encoding="utf-8", newline="\n")
3140
+ except OSError as exc:
3141
+ return emit_error(
3142
+ f"cannot write scenario receipt: {exc}",
3143
+ as_json=as_json,
3144
+ error_code="SCENARIO_RECEIPT_WRITE_FAILED",
3145
+ )
3146
+ payload = {
3147
+ "ok": True,
3148
+ "action": "scenario-receipt-template",
3149
+ "path": str(out_path),
3150
+ "scenarioIds": ids,
3151
+ }
3152
+ if as_json:
3153
+ sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
3154
+ else:
3155
+ sys.stdout.write(f"{out_path}\n")
3156
+ return 0
3157
+
3158
+
2956
3159
  def cmd_diff_hash(args: argparse.Namespace) -> int:
2957
3160
  as_json = bool(args.json)
2958
3161
  repo_raw = getattr(args, "repo", None)
@@ -3234,6 +3437,42 @@ def build_parser() -> argparse.ArgumentParser:
3234
3437
  )
3235
3438
  p_diff.set_defaults(func=cmd_diff_hash)
3236
3439
 
3440
+ p_receipt = sub.add_parser(
3441
+ "scenario-receipt-template",
3442
+ parents=[shared_json],
3443
+ help=(
3444
+ "emit a schema-v2 scenario execution receipt skeleton "
3445
+ "pre-filled from meta/scenario-manifest.json"
3446
+ ),
3447
+ )
3448
+ p_receipt.add_argument("--change-dir", required=True)
3449
+ p_receipt.add_argument(
3450
+ "--scenario-ids",
3451
+ required=True,
3452
+ help="comma-separated scenario IDs to include (must exist in the manifest)",
3453
+ )
3454
+ p_receipt.add_argument(
3455
+ "--runner",
3456
+ required=True,
3457
+ help="test runner name recorded in the receipt (e.g. vitest / maven-surefire)",
3458
+ )
3459
+ p_receipt.add_argument("--runner-version", default=None)
3460
+ p_receipt.add_argument("--attempt", type=int, default=1)
3461
+ p_receipt.add_argument(
3462
+ "--status",
3463
+ default="PASSED",
3464
+ help="per-test status written into executed[] (default: PASSED)",
3465
+ )
3466
+ p_receipt.add_argument(
3467
+ "--out",
3468
+ default=None,
3469
+ help=(
3470
+ "write the skeleton to this path instead of stdout; relative paths "
3471
+ "resolve against --change-dir (same rule as --scenario-receipt-file)"
3472
+ ),
3473
+ )
3474
+ p_receipt.set_defaults(func=cmd_scenario_receipt_template)
3475
+
3237
3476
  return parser
3238
3477
 
3239
3478