@hunter-harness/workflow-harness 0.2.30 → 0.2.31
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.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +6 -6
- package/harness/bundles/general/claude-code/harness-plan/checklist.md +10 -4
- package/harness/bundles/general/claude-code/harness-plan/reference.md +17 -17
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/claude-code/harness-run/checklist.md +2 -2
- package/harness/bundles/general/claude-code/harness-run/reference.md +3 -12
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +3 -3
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +97 -14
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +364 -74
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +6 -6
- package/harness/bundles/general/codebuddy/harness-plan/checklist.md +10 -4
- package/harness/bundles/general/codebuddy/harness-plan/reference.md +17 -17
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/codebuddy/harness-run/checklist.md +2 -2
- package/harness/bundles/general/codebuddy/harness-run/reference.md +3 -12
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +3 -3
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +97 -14
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +364 -74
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-plan/SKILL.md +6 -6
- package/harness/bundles/general/codex/harness-plan/checklist.md +10 -4
- package/harness/bundles/general/codex/harness-plan/reference.md +17 -17
- package/harness/bundles/general/codex/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/codex/harness-run/checklist.md +2 -2
- package/harness/bundles/general/codex/harness-run/reference.md +3 -12
- package/harness/bundles/general/codex/harness-submit/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-sync/SKILL.md +3 -3
- package/harness/bundles/general/codex/harness-test/SKILL.md +3 -3
- package/harness/bundles/general/codex/scripts/harness_gate.py +97 -14
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +364 -74
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +6 -6
- package/harness/bundles/general/cursor/harness-plan/checklist.md +10 -4
- package/harness/bundles/general/cursor/harness-plan/reference.md +17 -17
- package/harness/bundles/general/cursor/harness-review/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-run/SKILL.md +4 -4
- package/harness/bundles/general/cursor/harness-run/checklist.md +2 -2
- package/harness/bundles/general/cursor/harness-run/reference.md +3 -12
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +3 -3
- package/harness/bundles/general/cursor/harness-test/SKILL.md +3 -3
- package/harness/bundles/general/cursor/scripts/harness_gate.py +97 -14
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +364 -74
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +6 -6
- package/harness/bundles/java/claude-code/harness-plan/checklist.md +10 -4
- package/harness/bundles/java/claude-code/harness-plan/reference.md +17 -17
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +4 -4
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +3 -3
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +97 -14
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +364 -74
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +6 -6
- package/harness/bundles/java/codebuddy/harness-plan/checklist.md +10 -4
- package/harness/bundles/java/codebuddy/harness-plan/reference.md +17 -17
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +4 -4
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +3 -3
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +97 -14
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +364 -74
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-package/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-plan/SKILL.md +6 -6
- package/harness/bundles/java/codex/harness-plan/checklist.md +10 -4
- package/harness/bundles/java/codex/harness-plan/reference.md +17 -17
- package/harness/bundles/java/codex/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-run/SKILL.md +4 -4
- package/harness/bundles/java/codex/harness-submit/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-sync/SKILL.md +3 -3
- package/harness/bundles/java/codex/harness-test/SKILL.md +3 -3
- package/harness/bundles/java/codex/scripts/harness_gate.py +97 -14
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +364 -74
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-package/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +6 -6
- package/harness/bundles/java/cursor/harness-plan/checklist.md +10 -4
- package/harness/bundles/java/cursor/harness-plan/reference.md +17 -17
- package/harness/bundles/java/cursor/harness-review/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-run/SKILL.md +4 -4
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +3 -3
- package/harness/bundles/java/cursor/harness-test/SKILL.md +3 -3
- package/harness/bundles/java/cursor/scripts/harness_gate.py +97 -14
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +364 -74
- package/harness/manifests/general/claude-code.json +19 -19
- package/harness/manifests/general/codebuddy.json +19 -19
- package/harness/manifests/general/codex.json +19 -19
- package/harness/manifests/general/cursor.json +19 -19
- package/harness/manifests/java/claude-code.json +19 -19
- package/harness/manifests/java/codebuddy.json +19 -19
- package/harness/manifests/java/codex.json +19 -19
- package/harness/manifests/java/cursor.json +19 -19
- package/hunter-workflow-family.json +4 -4
- package/package.json +1 -1
|
@@ -51,6 +51,28 @@ def _result_error(code: str, message: str) -> dict[str, Any]:
|
|
|
51
51
|
return {"ok": False, "code": code, "error": message}
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def _normalize_header(value: str) -> str:
|
|
55
|
+
return re.sub(r"[\s_-]+", "", value).lower()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _column_index(
|
|
59
|
+
headers: list[str],
|
|
60
|
+
*aliases: str,
|
|
61
|
+
) -> int | None:
|
|
62
|
+
normalized = {
|
|
63
|
+
_normalize_header(header): index for index, header in enumerate(headers)
|
|
64
|
+
}
|
|
65
|
+
for alias in aliases:
|
|
66
|
+
index = normalized.get(_normalize_header(alias))
|
|
67
|
+
if index is not None:
|
|
68
|
+
return index
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _table_cells(line: str) -> list[str]:
|
|
73
|
+
return [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
74
|
+
|
|
75
|
+
|
|
54
76
|
def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
|
|
55
77
|
"""C9: parse test-scenarios.md tables, extracting scenario rows.
|
|
56
78
|
|
|
@@ -60,42 +82,100 @@ def parse_test_scenarios(scenarios_path: Path) -> list[dict[str, str]]:
|
|
|
60
82
|
text = Path(scenarios_path).read_text(encoding="utf-8-sig")
|
|
61
83
|
lines = text.splitlines()
|
|
62
84
|
scenarios: list[dict[str, str]] = []
|
|
63
|
-
# Find all tables whose header includes "ID" and "优先级" (or "priority").
|
|
64
85
|
i = 0
|
|
65
86
|
while i < len(lines):
|
|
66
87
|
line = lines[i].strip()
|
|
67
|
-
if line.startswith("|")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
if line.startswith("|"):
|
|
89
|
+
headers = _table_cells(line)
|
|
90
|
+
id_index = _column_index(headers, "ID", "#", "编号", "场景 ID")
|
|
91
|
+
scenario_index = _column_index(
|
|
92
|
+
headers,
|
|
93
|
+
"场景",
|
|
94
|
+
"场景描述",
|
|
95
|
+
"scenario",
|
|
96
|
+
"description",
|
|
97
|
+
)
|
|
98
|
+
if id_index is None or scenario_index is None:
|
|
99
|
+
i += 1
|
|
100
|
+
continue
|
|
101
|
+
priority_index = _column_index(headers, "优先级", "priority")
|
|
102
|
+
category_index = _column_index(headers, "分类", "category")
|
|
103
|
+
verification_index = _column_index(
|
|
104
|
+
headers,
|
|
105
|
+
"验证方式",
|
|
106
|
+
"verification",
|
|
107
|
+
"可复用证据",
|
|
108
|
+
)
|
|
109
|
+
owner_phase_index = _column_index(
|
|
110
|
+
headers,
|
|
111
|
+
"owner phase",
|
|
112
|
+
"ownerPhase",
|
|
113
|
+
"负责阶段",
|
|
114
|
+
)
|
|
115
|
+
execution_tier_index = _column_index(
|
|
116
|
+
headers,
|
|
117
|
+
"执行层级",
|
|
118
|
+
"execution tier",
|
|
119
|
+
"tier",
|
|
120
|
+
)
|
|
121
|
+
expected_index = _column_index(headers, "预期", "expected")
|
|
122
|
+
|
|
123
|
+
row_index = i + 2
|
|
124
|
+
while row_index < len(lines):
|
|
125
|
+
row = lines[row_index].strip()
|
|
78
126
|
if not row.startswith("|"):
|
|
79
127
|
break
|
|
80
|
-
cells =
|
|
81
|
-
if
|
|
82
|
-
|
|
128
|
+
cells = _table_cells(row)
|
|
129
|
+
if max(id_index, scenario_index) >= len(cells):
|
|
130
|
+
row_index += 1
|
|
83
131
|
continue
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
(
|
|
132
|
+
complete_row = len(cells) == len(headers)
|
|
133
|
+
scenario_id = cells[id_index].strip()
|
|
134
|
+
if not scenario_id or set(scenario_id) <= {"-", ":"}:
|
|
135
|
+
row_index += 1
|
|
136
|
+
continue
|
|
137
|
+
priority = (
|
|
138
|
+
cells[priority_index].strip().upper()
|
|
139
|
+
if priority_index is not None
|
|
140
|
+
and priority_index < len(cells)
|
|
141
|
+
and cells[priority_index].strip()
|
|
142
|
+
else "P1"
|
|
143
|
+
)
|
|
144
|
+
scenario = {
|
|
145
|
+
"id": scenario_id,
|
|
146
|
+
"priority": priority,
|
|
147
|
+
"scenario": cells[scenario_index].strip(),
|
|
148
|
+
"ownerPhase": (
|
|
149
|
+
cells[owner_phase_index].strip()
|
|
150
|
+
if owner_phase_index is not None
|
|
151
|
+
and complete_row
|
|
152
|
+
and owner_phase_index < len(cells)
|
|
153
|
+
and cells[owner_phase_index].strip()
|
|
154
|
+
else "test"
|
|
155
|
+
),
|
|
156
|
+
"requiredEvidenceKind": PRIORITY_EVIDENCE_KIND.get(
|
|
157
|
+
priority,
|
|
158
|
+
"advisory",
|
|
159
|
+
),
|
|
160
|
+
}
|
|
161
|
+
if category_index is not None and category_index < len(cells):
|
|
162
|
+
if cells[category_index].strip():
|
|
163
|
+
scenario["category"] = cells[category_index].strip()
|
|
164
|
+
for key, index in (
|
|
165
|
+
("verification", verification_index),
|
|
166
|
+
("executionTier", execution_tier_index),
|
|
167
|
+
("expected", expected_index),
|
|
91
168
|
):
|
|
92
|
-
if
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
169
|
+
if (
|
|
170
|
+
complete_row
|
|
171
|
+
and index is not None
|
|
172
|
+
and index < len(cells)
|
|
173
|
+
and cells[index].strip()
|
|
174
|
+
):
|
|
175
|
+
scenario[key] = cells[index].strip()
|
|
97
176
|
scenarios.append(scenario)
|
|
98
|
-
|
|
177
|
+
row_index += 1
|
|
178
|
+
i = row_index
|
|
99
179
|
continue
|
|
100
180
|
i += 1
|
|
101
181
|
return scenarios
|
|
@@ -110,40 +190,52 @@ def parse_plan_tasks(plan_path: Path) -> list[dict[str, str]]:
|
|
|
110
190
|
"""
|
|
111
191
|
text = Path(plan_path).read_text(encoding="utf-8-sig")
|
|
112
192
|
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
193
|
tasks: list[dict[str, str]] = []
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
194
|
+
i = 0
|
|
195
|
+
while i < len(lines):
|
|
196
|
+
line = lines[i].strip()
|
|
197
|
+
if not line.startswith("|"):
|
|
198
|
+
i += 1
|
|
199
|
+
continue
|
|
200
|
+
headers = _table_cells(line)
|
|
201
|
+
number_index = _column_index(headers, "#", "任务编号", "task ID")
|
|
202
|
+
task_index = _column_index(headers, "任务", "task")
|
|
203
|
+
if number_index is None or task_index is None:
|
|
204
|
+
i += 1
|
|
136
205
|
continue
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
206
|
+
cluster_index = _column_index(headers, "簇", "cluster")
|
|
207
|
+
optional_indices = {
|
|
208
|
+
name: _column_index(headers, name)
|
|
209
|
+
for name in (
|
|
210
|
+
"ownerPhase",
|
|
211
|
+
"implementationDoneWhen",
|
|
212
|
+
"verificationPhase",
|
|
213
|
+
"requiresExplicitAuthority",
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
row_index = i + 2
|
|
217
|
+
while row_index < len(lines):
|
|
218
|
+
row = lines[row_index].strip()
|
|
219
|
+
if not row.startswith("|"):
|
|
220
|
+
break
|
|
221
|
+
cells = _table_cells(row)
|
|
222
|
+
if len(cells) < len(headers):
|
|
223
|
+
row_index += 1
|
|
224
|
+
continue
|
|
225
|
+
number = cells[number_index].strip()
|
|
226
|
+
task_text = cells[task_index].strip()
|
|
227
|
+
if not number or not task_text or set(number) <= {"-", ":"}:
|
|
228
|
+
row_index += 1
|
|
229
|
+
continue
|
|
230
|
+
task: dict[str, str] = {"num": number, "task": task_text}
|
|
231
|
+
if cluster_index is not None and cells[cluster_index].strip():
|
|
232
|
+
task["cluster"] = cells[cluster_index].strip()
|
|
233
|
+
for name, index in optional_indices.items():
|
|
234
|
+
if index is not None and cells[index].strip():
|
|
235
|
+
task[name] = cells[index].strip()
|
|
236
|
+
tasks.append(task)
|
|
237
|
+
row_index += 1
|
|
238
|
+
i = row_index
|
|
147
239
|
return tasks
|
|
148
240
|
|
|
149
241
|
|
|
@@ -325,6 +417,20 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
|
|
|
325
417
|
# C8: validate ownerPhase values in plan.md task table.
|
|
326
418
|
plan_path = staging / "plans" / f"{change_name}-plan.md"
|
|
327
419
|
tasks = parse_plan_tasks(plan_path)
|
|
420
|
+
if not tasks:
|
|
421
|
+
return _result_error(
|
|
422
|
+
"PLAN_TASKS_EMPTY",
|
|
423
|
+
f"{plan_path.relative_to(staging).as_posix()}: no task rows were parsed",
|
|
424
|
+
)
|
|
425
|
+
task_ids = [str(task.get("num") or "").strip() for task in tasks]
|
|
426
|
+
duplicate_task_ids = sorted(
|
|
427
|
+
{task_id for task_id in task_ids if task_ids.count(task_id) > 1}
|
|
428
|
+
)
|
|
429
|
+
if duplicate_task_ids:
|
|
430
|
+
return _result_error(
|
|
431
|
+
"PLAN_TASK_ID_DUPLICATE",
|
|
432
|
+
"duplicate task IDs: " + ", ".join(duplicate_task_ids),
|
|
433
|
+
)
|
|
328
434
|
for task in tasks:
|
|
329
435
|
owner = task.get("ownerPhase")
|
|
330
436
|
if owner is not None and owner != "" and owner not in VALID_OWNER_PHASES:
|
|
@@ -339,6 +445,37 @@ def validate_staging(staging: Path, change_name: str) -> dict[str, Any]:
|
|
|
339
445
|
# C9: parse test-scenarios.md for scenario manifest.
|
|
340
446
|
scenarios_path = staging / "plans" / f"{change_name}-test-scenarios.md"
|
|
341
447
|
scenarios = parse_test_scenarios(scenarios_path)
|
|
448
|
+
if not scenarios:
|
|
449
|
+
return _result_error(
|
|
450
|
+
"PLAN_SCENARIOS_EMPTY",
|
|
451
|
+
f"{scenarios_path.relative_to(staging).as_posix()}: no scenario rows were parsed",
|
|
452
|
+
)
|
|
453
|
+
scenario_ids = [str(item.get("id") or "").strip() for item in scenarios]
|
|
454
|
+
duplicate_scenario_ids = sorted(
|
|
455
|
+
{
|
|
456
|
+
scenario_id
|
|
457
|
+
for scenario_id in scenario_ids
|
|
458
|
+
if scenario_ids.count(scenario_id) > 1
|
|
459
|
+
}
|
|
460
|
+
)
|
|
461
|
+
if duplicate_scenario_ids:
|
|
462
|
+
return _result_error(
|
|
463
|
+
"PLAN_SCENARIO_ID_DUPLICATE",
|
|
464
|
+
"duplicate scenario IDs: " + ", ".join(duplicate_scenario_ids),
|
|
465
|
+
)
|
|
466
|
+
for scenario in scenarios:
|
|
467
|
+
priority = str(scenario.get("priority") or "").upper()
|
|
468
|
+
if priority not in PRIORITY_EVIDENCE_KIND:
|
|
469
|
+
return _result_error(
|
|
470
|
+
"PLAN_SCENARIO_PRIORITY_INVALID",
|
|
471
|
+
f"scenario {scenario.get('id', '?')}: unsupported priority '{priority}'",
|
|
472
|
+
)
|
|
473
|
+
owner = str(scenario.get("ownerPhase") or "")
|
|
474
|
+
if owner not in VALID_OWNER_PHASES:
|
|
475
|
+
return _result_error(
|
|
476
|
+
"PLAN_SCENARIO_OWNER_PHASE_INVALID",
|
|
477
|
+
f"scenario {scenario.get('id', '?')}: unsupported ownerPhase '{owner}'",
|
|
478
|
+
)
|
|
342
479
|
|
|
343
480
|
digest = hashlib.sha256()
|
|
344
481
|
artifact_names: list[str] = []
|
|
@@ -374,6 +511,37 @@ def _terminal_exists(change_dir: Path, run_id: str, attempt: int) -> bool:
|
|
|
374
511
|
)
|
|
375
512
|
|
|
376
513
|
|
|
514
|
+
def _validate_plan_start(
|
|
515
|
+
change_dir: Path,
|
|
516
|
+
run_id: str,
|
|
517
|
+
attempt: int,
|
|
518
|
+
) -> dict[str, Any]:
|
|
519
|
+
events_path = harness_events.events_path(change_dir)
|
|
520
|
+
try:
|
|
521
|
+
events = harness_events.load_events(events_path)
|
|
522
|
+
except (OSError, ValueError) as exc:
|
|
523
|
+
return _result_error("EVENTS_PARSE_ERROR", str(exc))
|
|
524
|
+
matching = [
|
|
525
|
+
event
|
|
526
|
+
for event in events
|
|
527
|
+
if event.get("phase") == "plan"
|
|
528
|
+
and event.get("type") == "phase.start"
|
|
529
|
+
and event.get("run_id") == run_id
|
|
530
|
+
and event.get("attempt") == attempt
|
|
531
|
+
]
|
|
532
|
+
if not matching:
|
|
533
|
+
return _result_error(
|
|
534
|
+
"PHASE_START_MISSING",
|
|
535
|
+
"no matching plan phase.start event found for finalizer runId/attempt",
|
|
536
|
+
)
|
|
537
|
+
if len(matching) > 1:
|
|
538
|
+
return _result_error(
|
|
539
|
+
"PHASE_START_DUPLICATE",
|
|
540
|
+
f"found {len(matching)} matching plan phase.start events",
|
|
541
|
+
)
|
|
542
|
+
return {"ok": True, "phaseStartCount": 1}
|
|
543
|
+
|
|
544
|
+
|
|
377
545
|
def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
378
546
|
"""Read-only verification of a finalized plan (retro §5.8).
|
|
379
547
|
|
|
@@ -405,6 +573,18 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
|
405
573
|
files_list = receipt.get("files")
|
|
406
574
|
if not isinstance(files_list, list) or not files_list:
|
|
407
575
|
return _result_error("RECEIPT_INVALID", "receipt files list missing or empty")
|
|
576
|
+
if receipt.get("status") != "finalized":
|
|
577
|
+
return _result_error(
|
|
578
|
+
"RECEIPT_NOT_FINALIZED",
|
|
579
|
+
f"receipt status is {receipt.get('status')!r}, expected 'finalized'",
|
|
580
|
+
)
|
|
581
|
+
receipt_run_id = str(receipt.get("runId") or "").strip()
|
|
582
|
+
receipt_attempt = receipt.get("attempt")
|
|
583
|
+
if not receipt_run_id or not isinstance(receipt_attempt, int):
|
|
584
|
+
return _result_error(
|
|
585
|
+
"RECEIPT_INVALID",
|
|
586
|
+
"receipt runId/attempt identity is missing or malformed",
|
|
587
|
+
)
|
|
408
588
|
|
|
409
589
|
# Recompute artifacts hash from published files.
|
|
410
590
|
digest = hashlib.sha256()
|
|
@@ -453,19 +633,105 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
|
453
633
|
"status": fm.get("status"),
|
|
454
634
|
}
|
|
455
635
|
|
|
636
|
+
plan_path = change_dir / "plans" / f"{change_name}-plan.md"
|
|
637
|
+
expected_tasks = parse_plan_tasks(plan_path)
|
|
638
|
+
if not expected_tasks:
|
|
639
|
+
return _result_error(
|
|
640
|
+
"PLAN_TASKS_EMPTY",
|
|
641
|
+
f"no task rows parsed from {plan_path.relative_to(change_dir).as_posix()}",
|
|
642
|
+
)
|
|
643
|
+
checkpoints_path = change_dir / "meta" / "implementation-checkpoints.json"
|
|
644
|
+
if not checkpoints_path.is_file():
|
|
645
|
+
return _result_error(
|
|
646
|
+
"IMPLEMENTATION_CHECKPOINTS_MISSING",
|
|
647
|
+
f"derived task metadata missing: {checkpoints_path}",
|
|
648
|
+
)
|
|
649
|
+
try:
|
|
650
|
+
checkpoints = json.loads(
|
|
651
|
+
checkpoints_path.read_text(encoding="utf-8-sig")
|
|
652
|
+
)
|
|
653
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
654
|
+
return _result_error("IMPLEMENTATION_CHECKPOINTS_INVALID", str(exc))
|
|
655
|
+
actual_tasks = checkpoints.get("tasks") if isinstance(checkpoints, dict) else None
|
|
656
|
+
expected_task_ids = [str(item.get("num") or "") for item in expected_tasks]
|
|
657
|
+
actual_task_ids = (
|
|
658
|
+
[str(item.get("num") or "") for item in actual_tasks if isinstance(item, dict)]
|
|
659
|
+
if isinstance(actual_tasks, list)
|
|
660
|
+
else []
|
|
661
|
+
)
|
|
662
|
+
if expected_task_ids != actual_task_ids:
|
|
663
|
+
return _result_error(
|
|
664
|
+
"IMPLEMENTATION_CHECKPOINTS_DRIFT",
|
|
665
|
+
"derived task IDs do not match the finalized plan: "
|
|
666
|
+
f"expected={expected_task_ids}, actual={actual_task_ids}",
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
scenarios_path = (
|
|
670
|
+
change_dir / "plans" / f"{change_name}-test-scenarios.md"
|
|
671
|
+
)
|
|
672
|
+
expected_scenarios = parse_test_scenarios(scenarios_path)
|
|
673
|
+
if not expected_scenarios:
|
|
674
|
+
return _result_error(
|
|
675
|
+
"PLAN_SCENARIOS_EMPTY",
|
|
676
|
+
f"no scenario rows parsed from {scenarios_path.relative_to(change_dir).as_posix()}",
|
|
677
|
+
)
|
|
678
|
+
scenario_manifest_path = change_dir / "meta" / "scenario-manifest.json"
|
|
679
|
+
if not scenario_manifest_path.is_file():
|
|
680
|
+
return _result_error(
|
|
681
|
+
"SCENARIO_MANIFEST_MISSING",
|
|
682
|
+
f"derived scenario metadata missing: {scenario_manifest_path}",
|
|
683
|
+
)
|
|
684
|
+
try:
|
|
685
|
+
manifest = json.loads(
|
|
686
|
+
scenario_manifest_path.read_text(encoding="utf-8-sig")
|
|
687
|
+
)
|
|
688
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
689
|
+
return _result_error("SCENARIO_MANIFEST_INVALID", str(exc))
|
|
690
|
+
actual_scenarios = manifest.get("scenarios") if isinstance(manifest, dict) else None
|
|
691
|
+
if not isinstance(actual_scenarios, list) or not actual_scenarios:
|
|
692
|
+
return _result_error(
|
|
693
|
+
"SCENARIO_MANIFEST_EMPTY",
|
|
694
|
+
"scenario-manifest.json must contain at least one scenario",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
def scenario_identity(item: dict[str, Any]) -> tuple[str, str, str, str]:
|
|
698
|
+
return (
|
|
699
|
+
str(item.get("id") or ""),
|
|
700
|
+
str(item.get("priority") or ""),
|
|
701
|
+
str(item.get("ownerPhase") or ""),
|
|
702
|
+
str(item.get("requiredEvidenceKind") or ""),
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
expected_scenario_identities = [
|
|
706
|
+
scenario_identity(item) for item in expected_scenarios
|
|
707
|
+
]
|
|
708
|
+
actual_scenario_identities = [
|
|
709
|
+
scenario_identity(item)
|
|
710
|
+
for item in actual_scenarios
|
|
711
|
+
if isinstance(item, dict)
|
|
712
|
+
]
|
|
713
|
+
if expected_scenario_identities != actual_scenario_identities:
|
|
714
|
+
return _result_error(
|
|
715
|
+
"SCENARIO_MANIFEST_DRIFT",
|
|
716
|
+
"derived scenario identities do not match the finalized scenario table",
|
|
717
|
+
)
|
|
718
|
+
|
|
456
719
|
# Validate gate-policy.json is parseable JSON.
|
|
457
720
|
gate_policy_path = change_dir / "meta" / "gate-policy.json"
|
|
721
|
+
if not gate_policy_path.is_file():
|
|
722
|
+
return _result_error(
|
|
723
|
+
"GATE_POLICY_MISSING",
|
|
724
|
+
f"gate policy missing: {gate_policy_path}",
|
|
725
|
+
)
|
|
726
|
+
try:
|
|
727
|
+
json.loads(gate_policy_path.read_text(encoding="utf-8-sig"))
|
|
728
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
729
|
+
return _result_error("GATE_POLICY_INVALID", str(exc))
|
|
458
730
|
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
731
|
|
|
467
|
-
# Validate events.ndjson:
|
|
732
|
+
# Validate events.ndjson: require one matching start/end lifecycle.
|
|
468
733
|
events_path = change_dir / "events.ndjson"
|
|
734
|
+
phase_start_count = 0
|
|
469
735
|
phase_end_count = 0
|
|
470
736
|
phase_end_status: str | None = None
|
|
471
737
|
events_parse_errors: list[str] = []
|
|
@@ -481,10 +747,16 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
|
481
747
|
if not isinstance(event, dict):
|
|
482
748
|
events_parse_errors.append(f"line {line_no}: not an object")
|
|
483
749
|
continue
|
|
750
|
+
if event.get("phase") != "plan":
|
|
751
|
+
continue
|
|
484
752
|
if (
|
|
485
|
-
event.get("
|
|
486
|
-
|
|
753
|
+
str(event.get("run_id") or "") != receipt_run_id
|
|
754
|
+
or event.get("attempt") != receipt_attempt
|
|
487
755
|
):
|
|
756
|
+
continue
|
|
757
|
+
if event.get("type") == "phase.start":
|
|
758
|
+
phase_start_count += 1
|
|
759
|
+
elif event.get("type") == "phase.end":
|
|
488
760
|
phase_end_count += 1
|
|
489
761
|
phase_end_status = str(event.get("status") or "").upper()
|
|
490
762
|
else:
|
|
@@ -495,15 +767,27 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
|
495
767
|
"EVENTS_PARSE_ERROR", "; ".join(events_parse_errors[:5])
|
|
496
768
|
)
|
|
497
769
|
|
|
770
|
+
if phase_start_count == 0:
|
|
771
|
+
return _result_error(
|
|
772
|
+
"PHASE_START_MISSING",
|
|
773
|
+
"no matching plan phase.start event found",
|
|
774
|
+
)
|
|
775
|
+
if phase_start_count > 1:
|
|
776
|
+
return _result_error(
|
|
777
|
+
"PHASE_START_DUPLICATE",
|
|
778
|
+
f"found {phase_start_count} matching plan phase.start events",
|
|
779
|
+
)
|
|
498
780
|
if phase_end_count == 0:
|
|
499
781
|
return _result_error("PHASE_END_MISSING", "no plan phase.end event found")
|
|
500
782
|
if phase_end_count > 1:
|
|
501
783
|
return _result_error(
|
|
502
784
|
"PHASE_END_DUPLICATE", f"found {phase_end_count} phase.end events"
|
|
503
785
|
)
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
786
|
+
if phase_end_status != "OK":
|
|
787
|
+
return _result_error(
|
|
788
|
+
"PHASE_END_NOT_OK",
|
|
789
|
+
f"plan phase.end status is {phase_end_status!r}, expected 'OK'",
|
|
790
|
+
)
|
|
507
791
|
|
|
508
792
|
return {
|
|
509
793
|
"ok": True,
|
|
@@ -511,11 +795,14 @@ def verify_plan(change_dir: Path) -> dict[str, Any]:
|
|
|
511
795
|
"changeDir": str(change_dir),
|
|
512
796
|
"changeName": change_name,
|
|
513
797
|
"artifactsHash": actual_hash,
|
|
798
|
+
"phaseStartCount": phase_start_count,
|
|
514
799
|
"phaseEndCount": phase_end_count,
|
|
515
800
|
"phaseEndStatus": phase_end_status,
|
|
516
801
|
"frontmatter": frontmatter_results,
|
|
517
802
|
"gatePolicyConsistent": gate_policy_consistent,
|
|
518
|
-
"receiptConsistent":
|
|
803
|
+
"receiptConsistent": True,
|
|
804
|
+
"taskCount": len(expected_tasks),
|
|
805
|
+
"scenarioCount": len(expected_scenarios),
|
|
519
806
|
"files": artifact_names,
|
|
520
807
|
}
|
|
521
808
|
|
|
@@ -645,6 +932,9 @@ def finalize_plan(
|
|
|
645
932
|
validation = validate_staging(staging, change_name)
|
|
646
933
|
if not validation["ok"]:
|
|
647
934
|
return validation
|
|
935
|
+
start_validation = _validate_plan_start(change_dir, run_id, attempt)
|
|
936
|
+
if not start_validation["ok"]:
|
|
937
|
+
return start_validation
|
|
648
938
|
|
|
649
939
|
receipt_path = change_dir / "meta" / "plan-finalization.json"
|
|
650
940
|
lock_path = change_dir / "meta" / "plan-finalize.lock"
|