@hunter-harness/workflow-harness 0.2.30 → 0.2.32

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 (146) 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 +3 -3
  3. package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +3 -3
  4. package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +3 -3
  5. package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +3 -3
  6. package/harness/bundles/general/claude-code/harness-plan/SKILL.md +6 -6
  7. package/harness/bundles/general/claude-code/harness-plan/checklist.md +10 -4
  8. package/harness/bundles/general/claude-code/harness-plan/reference.md +17 -17
  9. package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
  10. package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
  11. package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
  12. package/harness/bundles/general/claude-code/harness-run/reference.md +3 -12
  13. package/harness/bundles/general/claude-code/harness-submit/SKILL.md +3 -3
  14. package/harness/bundles/general/claude-code/harness-sync/SKILL.md +3 -3
  15. package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -3
  16. package/harness/bundles/general/claude-code/scripts/harness_gate.py +92 -20
  17. package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +461 -82
  18. package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
  19. package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -3
  20. package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +3 -3
  21. package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -3
  22. package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +3 -3
  23. package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +6 -6
  24. package/harness/bundles/general/codebuddy/harness-plan/checklist.md +10 -4
  25. package/harness/bundles/general/codebuddy/harness-plan/reference.md +17 -17
  26. package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
  27. package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
  28. package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
  29. package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -12
  30. package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +3 -3
  31. package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +3 -3
  32. package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -3
  33. package/harness/bundles/general/codebuddy/scripts/harness_gate.py +92 -20
  34. package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +461 -82
  35. package/harness/bundles/general/codex/.harness-build.json +1 -1
  36. package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -3
  37. package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +3 -3
  38. package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -3
  39. package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +3 -3
  40. package/harness/bundles/general/codex/harness-plan/SKILL.md +6 -6
  41. package/harness/bundles/general/codex/harness-plan/checklist.md +10 -4
  42. package/harness/bundles/general/codex/harness-plan/reference.md +17 -17
  43. package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
  44. package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
  45. package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
  46. package/harness/bundles/general/codex/harness-run/reference.md +3 -12
  47. package/harness/bundles/general/codex/harness-submit/SKILL.md +3 -3
  48. package/harness/bundles/general/codex/harness-sync/SKILL.md +3 -3
  49. package/harness/bundles/general/codex/harness-test/SKILL.md +3 -3
  50. package/harness/bundles/general/codex/scripts/harness_gate.py +92 -20
  51. package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +461 -82
  52. package/harness/bundles/general/cursor/.harness-build.json +1 -1
  53. package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -3
  54. package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +3 -3
  55. package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -3
  56. package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +3 -3
  57. package/harness/bundles/general/cursor/harness-plan/SKILL.md +6 -6
  58. package/harness/bundles/general/cursor/harness-plan/checklist.md +10 -4
  59. package/harness/bundles/general/cursor/harness-plan/reference.md +17 -17
  60. package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
  61. package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
  62. package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
  63. package/harness/bundles/general/cursor/harness-run/reference.md +3 -12
  64. package/harness/bundles/general/cursor/harness-submit/SKILL.md +3 -3
  65. package/harness/bundles/general/cursor/harness-sync/SKILL.md +3 -3
  66. package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -3
  67. package/harness/bundles/general/cursor/scripts/harness_gate.py +92 -20
  68. package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +461 -82
  69. package/harness/bundles/java/claude-code/.harness-build.json +1 -1
  70. package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +3 -3
  71. package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -3
  72. package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +3 -3
  73. package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -3
  74. package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +3 -3
  75. package/harness/bundles/java/claude-code/harness-package/SKILL.md +3 -3
  76. package/harness/bundles/java/claude-code/harness-plan/SKILL.md +6 -6
  77. package/harness/bundles/java/claude-code/harness-plan/checklist.md +10 -4
  78. package/harness/bundles/java/claude-code/harness-plan/reference.md +17 -17
  79. package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
  80. package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
  81. package/harness/bundles/java/claude-code/harness-submit/SKILL.md +3 -3
  82. package/harness/bundles/java/claude-code/harness-sync/SKILL.md +3 -3
  83. package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -3
  84. package/harness/bundles/java/claude-code/scripts/harness_gate.py +92 -20
  85. package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +461 -82
  86. package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
  87. package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +3 -3
  88. package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -3
  89. package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +3 -3
  90. package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -3
  91. package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +3 -3
  92. package/harness/bundles/java/codebuddy/harness-package/SKILL.md +3 -3
  93. package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +6 -6
  94. package/harness/bundles/java/codebuddy/harness-plan/checklist.md +10 -4
  95. package/harness/bundles/java/codebuddy/harness-plan/reference.md +17 -17
  96. package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
  97. package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
  98. package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +3 -3
  99. package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +3 -3
  100. package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -3
  101. package/harness/bundles/java/codebuddy/scripts/harness_gate.py +92 -20
  102. package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +461 -82
  103. package/harness/bundles/java/codex/.harness-build.json +1 -1
  104. package/harness/bundles/java/codex/harness-apidoc/SKILL.md +3 -3
  105. package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -3
  106. package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +3 -3
  107. package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -3
  108. package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +3 -3
  109. package/harness/bundles/java/codex/harness-package/SKILL.md +3 -3
  110. package/harness/bundles/java/codex/harness-plan/SKILL.md +6 -6
  111. package/harness/bundles/java/codex/harness-plan/checklist.md +10 -4
  112. package/harness/bundles/java/codex/harness-plan/reference.md +17 -17
  113. package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
  114. package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
  115. package/harness/bundles/java/codex/harness-submit/SKILL.md +3 -3
  116. package/harness/bundles/java/codex/harness-sync/SKILL.md +3 -3
  117. package/harness/bundles/java/codex/harness-test/SKILL.md +3 -3
  118. package/harness/bundles/java/codex/scripts/harness_gate.py +92 -20
  119. package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +461 -82
  120. package/harness/bundles/java/cursor/.harness-build.json +1 -1
  121. package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +3 -3
  122. package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -3
  123. package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +3 -3
  124. package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -3
  125. package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +3 -3
  126. package/harness/bundles/java/cursor/harness-package/SKILL.md +3 -3
  127. package/harness/bundles/java/cursor/harness-plan/SKILL.md +6 -6
  128. package/harness/bundles/java/cursor/harness-plan/checklist.md +10 -4
  129. package/harness/bundles/java/cursor/harness-plan/reference.md +17 -17
  130. package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
  131. package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
  132. package/harness/bundles/java/cursor/harness-submit/SKILL.md +3 -3
  133. package/harness/bundles/java/cursor/harness-sync/SKILL.md +3 -3
  134. package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -3
  135. package/harness/bundles/java/cursor/scripts/harness_gate.py +92 -20
  136. package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +461 -82
  137. package/harness/manifests/general/claude-code.json +19 -19
  138. package/harness/manifests/general/codebuddy.json +19 -19
  139. package/harness/manifests/general/codex.json +19 -19
  140. package/harness/manifests/general/cursor.json +19 -19
  141. package/harness/manifests/java/claude-code.json +19 -19
  142. package/harness/manifests/java/codebuddy.json +19 -19
  143. package/harness/manifests/java/codex.json +19 -19
  144. package/harness/manifests/java/cursor.json +19 -19
  145. package/hunter-workflow-family.json +4 -4
  146. package/package.json +1 -1
@@ -13,7 +13,7 @@ import re
13
13
  import shutil
14
14
  import sys
15
15
  import tempfile
16
- from pathlib import Path
16
+ from pathlib import Path, PurePosixPath
17
17
  from typing import Any
18
18
 
19
19
  SCRIPTS_DIR = Path(__file__).resolve().parent
@@ -51,6 +51,34 @@ def _result_error(code: str, message: str) -> dict[str, Any]:
51
51
  return {"ok": False, "code": code, "error": message}
52
52
 
53
53
 
54
+ class PlanParseError(ValueError):
55
+ def __init__(self, code: str, message: str) -> None:
56
+ super().__init__(message)
57
+ self.code = code
58
+
59
+
60
+ def _normalize_header(value: str) -> str:
61
+ return re.sub(r"[\s_-]+", "", value).lower()
62
+
63
+
64
+ def _column_index(
65
+ headers: list[str],
66
+ *aliases: str,
67
+ ) -> int | None:
68
+ normalized = {
69
+ _normalize_header(header): index for index, header in enumerate(headers)
70
+ }
71
+ for alias in aliases:
72
+ index = normalized.get(_normalize_header(alias))
73
+ if index is not None:
74
+ return index
75
+ return None
76
+
77
+
78
+ def _table_cells(line: str) -> list[str]:
79
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
80
+
81
+
54
82
  def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
55
83
  """C9: parse test-scenarios.md tables, extracting scenario rows.
56
84
 
@@ -60,42 +88,110 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
60
88
  text = Path(scenarios_path).read_text(encoding="utf-8-sig")
61
89
  lines = text.splitlines()
62
90
  scenarios: list[dict[str, str]] = []
63
- # Find all tables whose header includes "ID" and "优先级" (or "priority").
64
91
  i = 0
65
92
  while i < len(lines):
66
93
  line = lines[i].strip()
67
- if line.startswith("|") and "ID" in line and ("优先级" in line or "priority" in line):
68
- header_line = line
69
- headers = [h.strip() for h in header_line.strip("|").split("|")]
70
- col_map: dict[str, int] = {}
71
- for idx, name in enumerate(headers):
72
- key = name.lower()
73
- col_map[key] = idx
74
- # Skip separator row
75
- i += 2
76
- while i < len(lines):
77
- row = lines[i].strip()
94
+ if line.startswith("|"):
95
+ headers = _table_cells(line)
96
+ id_index = _column_index(headers, "ID", "#", "编号", "场景 ID")
97
+ scenario_index = _column_index(
98
+ headers,
99
+ "场景",
100
+ "场景描述",
101
+ "scenario",
102
+ "description",
103
+ )
104
+ if id_index is None or scenario_index is None:
105
+ i += 1
106
+ continue
107
+ priority_index = _column_index(headers, "优先级", "priority")
108
+ category_index = _column_index(headers, "分类", "category")
109
+ verification_index = _column_index(
110
+ headers,
111
+ "验证方式",
112
+ "verification",
113
+ "可复用证据",
114
+ )
115
+ owner_phase_index = _column_index(
116
+ headers,
117
+ "owner phase",
118
+ "ownerPhase",
119
+ "负责阶段",
120
+ )
121
+ execution_tier_index = _column_index(
122
+ headers,
123
+ "执行层级",
124
+ "execution tier",
125
+ "tier",
126
+ )
127
+ expected_index = _column_index(headers, "预期", "expected")
128
+
129
+ row_index = i + 2
130
+ while row_index < len(lines):
131
+ row = lines[row_index].strip()
78
132
  if not row.startswith("|"):
79
133
  break
80
- cells = [c.strip() for c in row.strip("|").split("|")]
81
- if len(cells) < len(headers):
82
- i += 1
83
- continue
84
- scenario: dict[str, str] = {}
85
- for cn, en in (
86
- ("ID", "id"),
87
- ("优先级", "priority"),
88
- ("场景", "scenario"),
89
- ("验证方式", "verification"),
90
- ("owner phase", "ownerPhase"),
134
+ cells = _table_cells(row)
135
+ if max(id_index, scenario_index) >= len(cells):
136
+ raise PlanParseError(
137
+ "PLAN_SCENARIO_ROW_INVALID",
138
+ f"{scenarios_path.name}: line {row_index + 1} is missing scenario ID or description",
139
+ )
140
+ complete_row = len(cells) == len(headers)
141
+ scenario_id = cells[id_index].strip()
142
+ if not scenario_id or set(scenario_id) <= {"-", ":"}:
143
+ raise PlanParseError(
144
+ "PLAN_SCENARIO_ROW_INVALID",
145
+ f"{scenarios_path.name}: line {row_index + 1} has an empty scenario ID",
146
+ )
147
+ scenario_text = cells[scenario_index].strip()
148
+ if not scenario_text or set(scenario_text) <= {"-", ":"}:
149
+ raise PlanParseError(
150
+ "PLAN_SCENARIO_ROW_INVALID",
151
+ f"{scenarios_path.name}: line {row_index + 1} has an empty scenario description",
152
+ )
153
+ priority = (
154
+ cells[priority_index].strip().upper()
155
+ if priority_index is not None
156
+ and priority_index < len(cells)
157
+ and cells[priority_index].strip()
158
+ else "P1"
159
+ )
160
+ scenario = {
161
+ "id": scenario_id,
162
+ "priority": priority,
163
+ "scenario": scenario_text,
164
+ "ownerPhase": (
165
+ cells[owner_phase_index].strip()
166
+ if owner_phase_index is not None
167
+ and complete_row
168
+ and owner_phase_index < len(cells)
169
+ and cells[owner_phase_index].strip()
170
+ else "test"
171
+ ),
172
+ "requiredEvidenceKind": PRIORITY_EVIDENCE_KIND.get(
173
+ priority,
174
+ "advisory",
175
+ ),
176
+ }
177
+ if category_index is not None and category_index < len(cells):
178
+ if cells[category_index].strip():
179
+ scenario["category"] = cells[category_index].strip()
180
+ for key, index in (
181
+ ("verification", verification_index),
182
+ ("executionTier", execution_tier_index),
183
+ ("expected", expected_index),
91
184
  ):
92
- if cn.lower() in col_map:
93
- scenario[en] = cells[col_map[cn.lower()]]
94
- # Derive requiredEvidenceKind from priority
95
- priority = scenario.get("priority", "")
96
- scenario["requiredEvidenceKind"] = PRIORITY_EVIDENCE_KIND.get(priority, "advisory")
185
+ if (
186
+ complete_row
187
+ and index is not None
188
+ and index < len(cells)
189
+ and cells[index].strip()
190
+ ):
191
+ scenario[key] = cells[index].strip()
97
192
  scenarios.append(scenario)
98
- i += 1
193
+ row_index += 1
194
+ i = row_index
99
195
  continue
100
196
  i += 1
101
197
  return scenarios
@@ -110,40 +206,67 @@ def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
110
206
  """
111
207
  text = Path(plan_path).read_text(encoding="utf-8-sig")
112
208
  lines = text.splitlines()
113
- # Find the task table: a header row starting with "| #" followed by a separator row.
114
- header_idx = -1
115
- for i, line in enumerate(lines):
116
- stripped = line.strip()
117
- if stripped.startswith("|") and "#" in stripped and "任务" in stripped:
118
- header_idx = i
119
- break
120
- if header_idx < 0:
121
- return []
122
- header_line = lines[header_idx]
123
- headers = [h.strip() for h in header_line.strip().strip("|").split("|")]
124
- # Map header names to column indices (case-insensitive, English keys).
125
- col_map: dict[str, int] = {}
126
- for idx, name in enumerate(headers):
127
- key = name.lower()
128
- col_map[key] = idx
129
209
  tasks: list[dict[str, str]] = []
130
- for line in lines[header_idx + 2 :]: # skip header + separator
131
- stripped = line.strip()
132
- if not stripped.startswith("|"):
133
- break # end of table
134
- cells = [c.strip() for c in stripped.strip("|").split("|")]
135
- if len(cells) < len(headers):
210
+ i = 0
211
+ while i < len(lines):
212
+ line = lines[i].strip()
213
+ if not line.startswith("|"):
214
+ i += 1
215
+ continue
216
+ headers = _table_cells(line)
217
+ number_index = _column_index(headers, "#", "任务编号", "task ID")
218
+ task_index = _column_index(headers, "任务", "task")
219
+ if number_index is None or task_index is None:
220
+ i += 1
136
221
  continue
137
- task: dict[str, str] = {}
138
- # Chinese headers
139
- for cn, en in (("#", "num"), ("簇", "cluster"), ("任务", "task")):
140
- if cn in col_map:
141
- task[en] = cells[col_map[cn]]
142
- # English optional columns
143
- for en in ("ownerPhase", "implementationDoneWhen", "verificationPhase", "requiresExplicitAuthority"):
144
- if en.lower() in col_map:
145
- task[en] = cells[col_map[en.lower()]]
146
- tasks.append(task)
222
+ cluster_index = _column_index(headers, "簇", "cluster")
223
+ optional_indices = {
224
+ name: _column_index(headers, name)
225
+ for name in (
226
+ "ownerPhase",
227
+ "implementationDoneWhen",
228
+ "verificationPhase",
229
+ "requiresExplicitAuthority",
230
+ )
231
+ }
232
+ row_index = i + 2
233
+ while row_index < len(lines):
234
+ row = lines[row_index].strip()
235
+ if not row.startswith("|"):
236
+ break
237
+ cells = _table_cells(row)
238
+ if max(number_index, task_index) >= len(cells):
239
+ raise PlanParseError(
240
+ "PLAN_TASK_ROW_INVALID",
241
+ f"{plan_path.name}: line {row_index + 1} is missing task ID or description",
242
+ )
243
+ complete_row = len(cells) == len(headers)
244
+ number = cells[number_index].strip()
245
+ task_text = cells[task_index].strip()
246
+ if (
247
+ not number
248
+ or not task_text
249
+ or set(number) <= {"-", ":"}
250
+ or set(task_text) <= {"-", ":"}
251
+ ):
252
+ raise PlanParseError(
253
+ "PLAN_TASK_ROW_INVALID",
254
+ f"{plan_path.name}: line {row_index + 1} has an empty task ID or description",
255
+ )
256
+ task: dict[str, str] = {"num": number, "task": task_text}
257
+ if cluster_index is not None and cells[cluster_index].strip():
258
+ task["cluster"] = cells[cluster_index].strip()
259
+ for name, index in optional_indices.items():
260
+ if (
261
+ complete_row
262
+ and index is not None
263
+ and index < len(cells)
264
+ and cells[index].strip()
265
+ ):
266
+ task[name] = cells[index].strip()
267
+ tasks.append(task)
268
+ row_index += 1
269
+ i = row_index
147
270
  return tasks
148
271
 
149
272
 
@@ -324,7 +447,24 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
324
447
 
325
448
  # C8: validate ownerPhase values in plan.md task table.
326
449
  plan_path = staging / "plans" / f"{change_name}-plan.md"
327
- tasks = parse_plan_tasks(plan_path)
450
+ try:
451
+ tasks = parse_plan_tasks(plan_path)
452
+ except PlanParseError as exc:
453
+ return _result_error(exc.code, str(exc))
454
+ if not tasks:
455
+ return _result_error(
456
+ "PLAN_TASKS_EMPTY",
457
+ f"{plan_path.relative_to(staging).as_posix()}: no task rows were parsed",
458
+ )
459
+ task_ids = [str(task.get("num") or "").strip() for task in tasks]
460
+ duplicate_task_ids = sorted(
461
+ {task_id for task_id in task_ids if task_ids.count(task_id) > 1}
462
+ )
463
+ if duplicate_task_ids:
464
+ return _result_error(
465
+ "PLAN_TASK_ID_DUPLICATE",
466
+ "duplicate task IDs: " + ", ".join(duplicate_task_ids),
467
+ )
328
468
  for task in tasks:
329
469
  owner = task.get("ownerPhase")
330
470
  if owner is not None and owner != "" and owner not in VALID_OWNER_PHASES:
@@ -338,7 +478,41 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
338
478
 
339
479
  # C9: parse test-scenarios.md for scenario manifest.
340
480
  scenarios_path = staging / "plans" / f"{change_name}-test-scenarios.md"
341
- scenarios = parse_test_scenarios(scenarios_path)
481
+ try:
482
+ scenarios = parse_test_scenarios(scenarios_path)
483
+ except PlanParseError as exc:
484
+ return _result_error(exc.code, str(exc))
485
+ if not scenarios:
486
+ return _result_error(
487
+ "PLAN_SCENARIOS_EMPTY",
488
+ f"{scenarios_path.relative_to(staging).as_posix()}: no scenario rows were parsed",
489
+ )
490
+ scenario_ids = [str(item.get("id") or "").strip() for item in scenarios]
491
+ duplicate_scenario_ids = sorted(
492
+ {
493
+ scenario_id
494
+ for scenario_id in scenario_ids
495
+ if scenario_ids.count(scenario_id) > 1
496
+ }
497
+ )
498
+ if duplicate_scenario_ids:
499
+ return _result_error(
500
+ "PLAN_SCENARIO_ID_DUPLICATE",
501
+ "duplicate scenario IDs: " + ", ".join(duplicate_scenario_ids),
502
+ )
503
+ for scenario in scenarios:
504
+ priority = str(scenario.get("priority") or "").upper()
505
+ if priority not in PRIORITY_EVIDENCE_KIND:
506
+ return _result_error(
507
+ "PLAN_SCENARIO_PRIORITY_INVALID",
508
+ f"scenario {scenario.get('id', '?')}: unsupported priority '{priority}'",
509
+ )
510
+ owner = str(scenario.get("ownerPhase") or "")
511
+ if owner not in VALID_OWNER_PHASES:
512
+ return _result_error(
513
+ "PLAN_SCENARIO_OWNER_PHASE_INVALID",
514
+ f"scenario {scenario.get('id', '?')}: unsupported ownerPhase '{owner}'",
515
+ )
342
516
 
343
517
  digest = hashlib.sha256()
344
518
  artifact_names: list[str] = []
@@ -374,6 +548,89 @@ def _terminal_exists(change_dir: Path, run_id: str, attempt: int) -> bool:
374
548
  )
375
549
 
376
550
 
551
+ def _validate_plan_start(
552
+ change_dir: Path,
553
+ run_id: str,
554
+ attempt: int,
555
+ ) -> dict[str, Any]:
556
+ events_path = harness_events.events_path(change_dir)
557
+ try:
558
+ events = harness_events.load_events(events_path)
559
+ except (OSError, ValueError) as exc:
560
+ return _result_error("EVENTS_PARSE_ERROR", str(exc))
561
+ matching = [
562
+ event
563
+ for event in events
564
+ if event.get("phase") == "plan"
565
+ and event.get("type") == "phase.start"
566
+ and event.get("run_id") == run_id
567
+ and event.get("attempt") == attempt
568
+ ]
569
+ if not matching:
570
+ return _result_error(
571
+ "PHASE_START_MISSING",
572
+ "no matching plan phase.start event found for finalizer runId/attempt",
573
+ )
574
+ if len(matching) > 1:
575
+ return _result_error(
576
+ "PHASE_START_DUPLICATE",
577
+ f"found {len(matching)} matching plan phase.start events",
578
+ )
579
+ return {"ok": True, "phaseStartCount": 1}
580
+
581
+
582
+ def _receipt_artifact_targets(
583
+ change_dir: Path,
584
+ files_list: list[Any],
585
+ ) -> tuple[list[tuple[str, Path]] | None, dict[str, Any] | None]:
586
+ targets: list[tuple[str, Path]] = []
587
+ seen: set[str] = set()
588
+ for index, value in enumerate(files_list):
589
+ if not isinstance(value, str):
590
+ return None, _result_error(
591
+ "RECEIPT_FILE_PATH_INVALID",
592
+ f"receipt files[{index}] must be a string",
593
+ )
594
+ raw = value
595
+ segments = raw.split("/")
596
+ rel = PurePosixPath(raw)
597
+ invalid = (
598
+ not raw
599
+ or raw != raw.strip()
600
+ or "\\" in raw
601
+ or ":" in raw
602
+ or rel.is_absolute()
603
+ or any(segment in {"", ".", ".."} for segment in segments)
604
+ or any(segment.endswith((".", " ")) for segment in segments)
605
+ or not rel.parts
606
+ or rel.parts[0] not in {"spec", "plans", "meta"}
607
+ )
608
+ if invalid:
609
+ return None, _result_error(
610
+ "RECEIPT_FILE_PATH_INVALID",
611
+ f"receipt files[{index}] is not a safe artifact-relative path: {raw!r}",
612
+ )
613
+ normalized = rel.as_posix()
614
+ if normalized in seen:
615
+ return None, _result_error(
616
+ "RECEIPT_FILE_PATH_INVALID",
617
+ f"receipt contains duplicate artifact path: {normalized}",
618
+ )
619
+ seen.add(normalized)
620
+ target = change_dir.joinpath(*rel.parts)
621
+ cursor = change_dir
622
+ for part in rel.parts:
623
+ cursor = cursor / part
624
+ is_junction = getattr(cursor, "is_junction", lambda: False)
625
+ if cursor.is_symlink() or is_junction():
626
+ return None, _result_error(
627
+ "RECEIPT_FILE_PATH_INVALID",
628
+ f"receipt artifact path traverses a link: {normalized}",
629
+ )
630
+ targets.append((normalized, target))
631
+ return targets, None
632
+
633
+
377
634
  def verify_plan(change_dir: Path) -> dict[str, Any]:
378
635
  """Read-only verification of a finalized plan (retro §5.8).
379
636
 
@@ -397,6 +654,11 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
397
654
  change_name = str(receipt.get("changeName") or "").strip()
398
655
  if not change_name:
399
656
  return _result_error("RECEIPT_INVALID", "receipt missing changeName")
657
+ if change_name != change_dir.name:
658
+ return _result_error(
659
+ "RECEIPT_CHANGE_NAME_INVALID",
660
+ f"receipt changeName {change_name!r} does not match {change_dir.name!r}",
661
+ )
400
662
 
401
663
  expected_hash = str(receipt.get("artifactsHash") or "").strip()
402
664
  if not expected_hash.startswith("sha256:"):
@@ -405,13 +667,27 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
405
667
  files_list = receipt.get("files")
406
668
  if not isinstance(files_list, list) or not files_list:
407
669
  return _result_error("RECEIPT_INVALID", "receipt files list missing or empty")
670
+ artifact_targets, path_error = _receipt_artifact_targets(change_dir, files_list)
671
+ if path_error is not None:
672
+ return path_error
673
+ assert artifact_targets is not None
674
+ if receipt.get("status") != "finalized":
675
+ return _result_error(
676
+ "RECEIPT_NOT_FINALIZED",
677
+ f"receipt status is {receipt.get('status')!r}, expected 'finalized'",
678
+ )
679
+ receipt_run_id = str(receipt.get("runId") or "").strip()
680
+ receipt_attempt = receipt.get("attempt")
681
+ if not receipt_run_id or not isinstance(receipt_attempt, int):
682
+ return _result_error(
683
+ "RECEIPT_INVALID",
684
+ "receipt runId/attempt identity is missing or malformed",
685
+ )
408
686
 
409
687
  # Recompute artifacts hash from published files.
410
688
  digest = hashlib.sha256()
411
689
  artifact_names: list[str] = []
412
- for rel_text in files_list:
413
- rel = rel_text.as_posix() if hasattr(rel_text, "as_posix") else str(rel_text)
414
- target = change_dir / rel
690
+ for rel, target in artifact_targets:
415
691
  if not target.is_file():
416
692
  return _result_error(
417
693
  "ARTIFACT_MISSING", f"published artifact missing: {rel}"
@@ -453,19 +729,98 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
453
729
  "status": fm.get("status"),
454
730
  }
455
731
 
732
+ plan_path = change_dir / "plans" / f"{change_name}-plan.md"
733
+ try:
734
+ expected_tasks = parse_plan_tasks(plan_path)
735
+ except PlanParseError as exc:
736
+ return _result_error(exc.code, str(exc))
737
+ if not expected_tasks:
738
+ return _result_error(
739
+ "PLAN_TASKS_EMPTY",
740
+ f"no task rows parsed from {plan_path.relative_to(change_dir).as_posix()}",
741
+ )
742
+ checkpoints_path = change_dir / "meta" / "implementation-checkpoints.json"
743
+ if not checkpoints_path.is_file():
744
+ return _result_error(
745
+ "IMPLEMENTATION_CHECKPOINTS_MISSING",
746
+ f"derived task metadata missing: {checkpoints_path}",
747
+ )
748
+ try:
749
+ checkpoints = json.loads(
750
+ checkpoints_path.read_text(encoding="utf-8-sig")
751
+ )
752
+ except (OSError, json.JSONDecodeError) as exc:
753
+ return _result_error("IMPLEMENTATION_CHECKPOINTS_INVALID", str(exc))
754
+ expected_checkpoints = {
755
+ "schemaVersion": 1,
756
+ "changeName": change_name,
757
+ "tasks": expected_tasks,
758
+ "foundationGate": "approved",
759
+ }
760
+ if checkpoints != expected_checkpoints:
761
+ return _result_error(
762
+ "IMPLEMENTATION_CHECKPOINTS_DRIFT",
763
+ "derived implementation checkpoints do not match the finalized plan",
764
+ )
765
+
766
+ scenarios_path = (
767
+ change_dir / "plans" / f"{change_name}-test-scenarios.md"
768
+ )
769
+ try:
770
+ expected_scenarios = parse_test_scenarios(scenarios_path)
771
+ except PlanParseError as exc:
772
+ return _result_error(exc.code, str(exc))
773
+ if not expected_scenarios:
774
+ return _result_error(
775
+ "PLAN_SCENARIOS_EMPTY",
776
+ f"no scenario rows parsed from {scenarios_path.relative_to(change_dir).as_posix()}",
777
+ )
778
+ scenario_manifest_path = change_dir / "meta" / "scenario-manifest.json"
779
+ if not scenario_manifest_path.is_file():
780
+ return _result_error(
781
+ "SCENARIO_MANIFEST_MISSING",
782
+ f"derived scenario metadata missing: {scenario_manifest_path}",
783
+ )
784
+ try:
785
+ manifest = json.loads(
786
+ scenario_manifest_path.read_text(encoding="utf-8-sig")
787
+ )
788
+ except (OSError, json.JSONDecodeError) as exc:
789
+ return _result_error("SCENARIO_MANIFEST_INVALID", str(exc))
790
+ actual_scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
791
+ if not isinstance(actual_scenarios, list) or not actual_scenarios:
792
+ return _result_error(
793
+ "SCENARIO_MANIFEST_EMPTY",
794
+ "scenario-manifest.json must contain at least one scenario",
795
+ )
796
+
797
+ expected_manifest = {
798
+ "schemaVersion": 1,
799
+ "changeName": change_name,
800
+ "scenarios": expected_scenarios,
801
+ }
802
+ if manifest != expected_manifest:
803
+ return _result_error(
804
+ "SCENARIO_MANIFEST_DRIFT",
805
+ "derived scenario manifest does not match the finalized scenario table",
806
+ )
807
+
456
808
  # Validate gate-policy.json is parseable JSON.
457
809
  gate_policy_path = change_dir / "meta" / "gate-policy.json"
810
+ if not gate_policy_path.is_file():
811
+ return _result_error(
812
+ "GATE_POLICY_MISSING",
813
+ f"gate policy missing: {gate_policy_path}",
814
+ )
815
+ try:
816
+ json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
817
+ except (OSError, json.JSONDecodeError) as exc:
818
+ return _result_error("GATE_POLICY_INVALID", str(exc))
458
819
  gate_policy_consistent = True
459
- if gate_policy_path.is_file():
460
- try:
461
- json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
462
- except (OSError, json.JSONDecodeError):
463
- gate_policy_consistent = False
464
- else:
465
- gate_policy_consistent = False
466
820
 
467
- # Validate events.ndjson: count phase.end, check for parse errors.
468
- events_path = change_dir / "events.ndjson"
821
+ # Validate events.ndjson: require one matching start/end lifecycle.
822
+ events_path = harness_events.events_path(change_dir)
823
+ phase_start_count = 0
469
824
  phase_end_count = 0
470
825
  phase_end_status: str | None = None
471
826
  events_parse_errors: list[str] = []
@@ -481,10 +836,16 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
481
836
  if not isinstance(event, dict):
482
837
  events_parse_errors.append(f"line {line_no}: not an object")
483
838
  continue
839
+ if event.get("phase") != "plan":
840
+ continue
484
841
  if (
485
- event.get("phase") == "plan"
486
- and event.get("type") == "phase.end"
842
+ str(event.get("run_id") or "") != receipt_run_id
843
+ or event.get("attempt") != receipt_attempt
487
844
  ):
845
+ continue
846
+ if event.get("type") == "phase.start":
847
+ phase_start_count += 1
848
+ elif event.get("type") == "phase.end":
488
849
  phase_end_count += 1
489
850
  phase_end_status = str(event.get("status") or "").upper()
490
851
  else:
@@ -495,15 +856,27 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
495
856
  "EVENTS_PARSE_ERROR", "; ".join(events_parse_errors[:5])
496
857
  )
497
858
 
859
+ if phase_start_count == 0:
860
+ return _result_error(
861
+ "PHASE_START_MISSING",
862
+ "no matching plan phase.start event found",
863
+ )
864
+ if phase_start_count > 1:
865
+ return _result_error(
866
+ "PHASE_START_DUPLICATE",
867
+ f"found {phase_start_count} matching plan phase.start events",
868
+ )
498
869
  if phase_end_count == 0:
499
870
  return _result_error("PHASE_END_MISSING", "no plan phase.end event found")
500
871
  if phase_end_count > 1:
501
872
  return _result_error(
502
873
  "PHASE_END_DUPLICATE", f"found {phase_end_count} phase.end events"
503
874
  )
504
-
505
- # Receipt consistency: status should be finalized.
506
- receipt_consistent = receipt.get("status") == "finalized"
875
+ if phase_end_status != "OK":
876
+ return _result_error(
877
+ "PHASE_END_NOT_OK",
878
+ f"plan phase.end status is {phase_end_status!r}, expected 'OK'",
879
+ )
507
880
 
508
881
  return {
509
882
  "ok": True,
@@ -511,11 +884,14 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
511
884
  "changeDir": str(change_dir),
512
885
  "changeName": change_name,
513
886
  "artifactsHash": actual_hash,
887
+ "phaseStartCount": phase_start_count,
514
888
  "phaseEndCount": phase_end_count,
515
889
  "phaseEndStatus": phase_end_status,
516
890
  "frontmatter": frontmatter_results,
517
891
  "gatePolicyConsistent": gate_policy_consistent,
518
- "receiptConsistent": receipt_consistent,
892
+ "receiptConsistent": True,
893
+ "taskCount": len(expected_tasks),
894
+ "scenarioCount": len(expected_scenarios),
519
895
  "files": artifact_names,
520
896
  }
521
897
 
@@ -645,6 +1021,9 @@ def finalize_plan(
645
1021
  validation = validate_staging(staging, change_name)
646
1022
  if not validation["ok"]:
647
1023
  return validation
1024
+ start_validation = _validate_plan_start(change_dir, run_id, attempt)
1025
+ if not start_validation["ok"]:
1026
+ return start_validation
648
1027
 
649
1028
  receipt_path = change_dir / "meta" / "plan-finalization.json"
650
1029
  lock_path = change_dir / "meta" / "plan-finalize.lock"