@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.
- 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 +92 -20
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +461 -82
- 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 +92 -20
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +461 -82
- 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 +92 -20
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +461 -82
- 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 +92 -20
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +461 -82
- 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 +92 -20
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +461 -82
- 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 +92 -20
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +461 -82
- 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 +92 -20
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +461 -82
- 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 +92 -20
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +461 -82
- 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
|
@@ -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("|")
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
81
|
-
if
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
(
|
|
90
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
468
|
-
events_path = change_dir
|
|
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("
|
|
486
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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":
|
|
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"
|