okstra 0.172.0 → 0.174.0
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/README.md +8 -6
- package/docs/architecture/storage-model.md +24 -3
- package/docs/architecture.md +21 -35
- package/docs/cli.md +39 -7
- package/docs/container.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +33 -25
- package/docs/task-process/README.md +6 -4
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation-option-selection.md +70 -0
- package/docs/task-process/implementation-planning.md +24 -16
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/report-writer-worker.md +30 -6
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +2 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra-provider-exec.py +29 -12
- package/runtime/bin/okstra-trace-cleanup.sh +58 -129
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/direction-selection-worker.md +44 -0
- package/runtime/prompts/duties/planning-worker.md +12 -4
- package/runtime/prompts/lead/adapters/cmux.md +2 -0
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +5 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
- package/runtime/prompts/lead/plan-body-verification.md +23 -6
- package/runtime/prompts/lead/report-writer.md +33 -11
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +4 -4
- package/runtime/prompts/profiles/final-verification.md +3 -3
- package/runtime/prompts/profiles/forbidden-actions.json +7 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
- package/runtime/prompts/profiles/implementation-planning.md +61 -46
- package/runtime/prompts/profiles/implementation.md +4 -2
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
- package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
- package/runtime/python/okstra_ctl/conformance.py +68 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
- package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
- package/runtime/python/okstra_ctl/doctor.py +2 -2
- package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
- package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
- package/runtime/python/okstra_ctl/implementation_options.py +479 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +1 -0
- package/runtime/python/okstra_ctl/render_final_report.py +16 -19
- package/runtime/python/okstra_ctl/report_contract.py +45 -14
- package/runtime/python/okstra_ctl/report_finalize.py +68 -9
- package/runtime/python/okstra_ctl/report_html/render.py +4 -2
- package/runtime/python/okstra_ctl/report_html/router.py +4 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
- package/runtime/python/okstra_ctl/report_views.py +148 -12
- package/runtime/python/okstra_ctl/run.py +393 -4
- package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
- package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
- package/runtime/python/okstra_ctl/session.py +69 -12
- package/runtime/python/okstra_ctl/team.py +51 -25
- package/runtime/python/okstra_ctl/tmux.py +19 -149
- package/runtime/python/okstra_ctl/user_response.py +75 -0
- package/runtime/python/okstra_ctl/wizard.py +144 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/worker_request.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +29 -7
- package/runtime/python/okstra_ctl/worktree.py +69 -3
- package/runtime/python/okstra_token_usage/cli.py +1 -1
- package/runtime/python/okstra_token_usage/collect.py +66 -6
- package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
- package/runtime/skills/okstra-setup/references/project-config.md +11 -0
- package/runtime/templates/reports/final-report-v2.template.md +4 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +21 -1
- package/runtime/templates/reports/html/i18n/ko.json +21 -1
- package/runtime/templates/reports/html/macros/forms.html +21 -2
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
- package/runtime/templates/reports/i18n/en.json +13 -0
- package/runtime/templates/reports/implementation-input.template.md +4 -2
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
- package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
- package/runtime/templates/reports/report.js +111 -4
- package/runtime/templates/reports/settings.template.json +0 -24
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +25 -4
- package/runtime/templates/worker-prompt-preamble.md +8 -0
- package/runtime/validators/lib/fixtures.sh +49 -17
- package/runtime/validators/validate-implementation-plan-stages.py +169 -4
- package/runtime/validators/validate-report-views.py +2 -2
- package/runtime/validators/validate-run.py +149 -498
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/src/cli-registry.mjs +8 -1
- package/src/commands/execute/codex-run.mjs +1 -0
- package/src/commands/execute/render-bundle.mjs +1 -0
- package/src/commands/execute/team.mjs +3 -3
- package/src/commands/execute/worktree-status.mjs +109 -0
- package/src/commands/lifecycle/install.mjs +0 -2
- package/src/commands/report/finalize.mjs +13 -6
- package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
- package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
- package/runtime/templates/reports/final-report.template.md +0 -1258
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""Validate one selected implementation direction before planning starts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Mapping
|
|
15
|
+
|
|
16
|
+
from .final_report_paths import final_report_data_path
|
|
17
|
+
from .exact_coverage import ExactCoverageError, calculate_plan_exact_coverage
|
|
18
|
+
from .final_report_schema import (
|
|
19
|
+
SchemaError,
|
|
20
|
+
load_schema_for_data,
|
|
21
|
+
validate as validate_schema,
|
|
22
|
+
)
|
|
23
|
+
from .report_views import normalize_direction_selection_identity
|
|
24
|
+
from .scope_provenance import brief_end_state_id_sequence
|
|
25
|
+
from .user_response import UserResponseError, parse_direction_selection
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_TASK_TYPE = "implementation-option-selection"
|
|
29
|
+
_REPORT_RE = re.compile(
|
|
30
|
+
r"^final-report-implementation-option-selection-(?P<seq>\d{3,})\.md$"
|
|
31
|
+
)
|
|
32
|
+
_FRONTMATTER_RE = re.compile(
|
|
33
|
+
r"\A---[ \t]*\r?\n(?P<body>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
|
|
34
|
+
re.DOTALL,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DirectionSelectionError(ValueError):
|
|
39
|
+
"""Raised when a direction report cannot authorize planning."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SelectedDirection:
|
|
44
|
+
mode: str
|
|
45
|
+
task_key: str
|
|
46
|
+
source_report: str
|
|
47
|
+
source_data_sha256: str
|
|
48
|
+
selection_evidence: str
|
|
49
|
+
option_id: str
|
|
50
|
+
option_name: str
|
|
51
|
+
selection_note: str
|
|
52
|
+
user_constraints: tuple[str, ...]
|
|
53
|
+
direction: dict[str, Any]
|
|
54
|
+
requirement_coverage: tuple[dict[str, Any], ...]
|
|
55
|
+
planning_invariants: tuple[dict[str, Any], ...]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class SelectedDirectionSnapshot:
|
|
60
|
+
data: Mapping[str, Any]
|
|
61
|
+
path: Path
|
|
62
|
+
relative_path: str
|
|
63
|
+
sha256: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def lexical_absolute_path(path: Path) -> Path:
|
|
67
|
+
"""Make a user path absolute without following symbolic links."""
|
|
68
|
+
return Path(os.path.abspath(os.fspath(Path(path).expanduser())))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _strict_regular_file(path: Path, label: str) -> Path:
|
|
72
|
+
path = lexical_absolute_path(path)
|
|
73
|
+
try:
|
|
74
|
+
mode = path.lstat().st_mode
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
raise DirectionSelectionError(f"{label} file not found: {path}") from exc
|
|
77
|
+
if stat.S_ISLNK(mode):
|
|
78
|
+
raise DirectionSelectionError(f"{label} path contains a symlink: {path}")
|
|
79
|
+
if not stat.S_ISREG(mode):
|
|
80
|
+
raise DirectionSelectionError(f"{label} must be a real regular file: {path}")
|
|
81
|
+
return path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_task_artifact_path(
|
|
85
|
+
path: Path, task_root: Path, label: str
|
|
86
|
+
) -> Path:
|
|
87
|
+
"""Reject links from the task root through one regular artifact file."""
|
|
88
|
+
path = lexical_absolute_path(path)
|
|
89
|
+
task_root = lexical_absolute_path(task_root)
|
|
90
|
+
try:
|
|
91
|
+
relative = path.relative_to(task_root)
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise DirectionSelectionError(
|
|
94
|
+
f"{label} must stay under the selected task root"
|
|
95
|
+
) from exc
|
|
96
|
+
current = task_root
|
|
97
|
+
for part in ("", *relative.parts):
|
|
98
|
+
current = current if not part else current / part
|
|
99
|
+
try:
|
|
100
|
+
mode = current.lstat().st_mode
|
|
101
|
+
except OSError as exc:
|
|
102
|
+
raise DirectionSelectionError(
|
|
103
|
+
f"{label} file not found: {path}"
|
|
104
|
+
) from exc
|
|
105
|
+
if stat.S_ISLNK(mode):
|
|
106
|
+
raise DirectionSelectionError(
|
|
107
|
+
f"{label} path contains a symlink: {current}"
|
|
108
|
+
)
|
|
109
|
+
if not stat.S_ISREG(mode):
|
|
110
|
+
raise DirectionSelectionError(f"{label} must be a real regular file: {path}")
|
|
111
|
+
try:
|
|
112
|
+
path.resolve(strict=True).relative_to(task_root.resolve(strict=True))
|
|
113
|
+
except (OSError, ValueError) as exc:
|
|
114
|
+
raise DirectionSelectionError(
|
|
115
|
+
f"{label} must resolve inside the selected task root"
|
|
116
|
+
) from exc
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _selection_layout(report: Path) -> tuple[Path, str]:
|
|
121
|
+
match = _REPORT_RE.fullmatch(report.name)
|
|
122
|
+
if (
|
|
123
|
+
match is None
|
|
124
|
+
or report.parent.name != "reports"
|
|
125
|
+
or report.parent.parent.name != _TASK_TYPE
|
|
126
|
+
or report.parent.parent.parent.name != "runs"
|
|
127
|
+
):
|
|
128
|
+
raise DirectionSelectionError(
|
|
129
|
+
"report path task type must be implementation-option-selection"
|
|
130
|
+
)
|
|
131
|
+
task_root = report.parents[3]
|
|
132
|
+
return task_root, match.group("seq")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sibling_data_path(report: Path) -> Path:
|
|
136
|
+
data_path = _strict_regular_file(final_report_data_path(report), "data")
|
|
137
|
+
if data_path.parent != report.parent:
|
|
138
|
+
raise DirectionSelectionError("data file must be a sibling of the report")
|
|
139
|
+
return data_path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_data(data_path: Path, data_bytes: bytes) -> dict[str, Any]:
|
|
143
|
+
try:
|
|
144
|
+
data = json.loads(data_bytes)
|
|
145
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
146
|
+
raise DirectionSelectionError(f"data JSON is invalid: {data_path}") from exc
|
|
147
|
+
if not isinstance(data, dict):
|
|
148
|
+
raise DirectionSelectionError("data JSON must contain an object")
|
|
149
|
+
return data
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _validate_report_identity(data: Mapping[str, Any], expected_task_key: str) -> None:
|
|
153
|
+
header = data.get("header")
|
|
154
|
+
frontmatter = data.get("frontmatter")
|
|
155
|
+
if not isinstance(header, Mapping) or not isinstance(frontmatter, Mapping):
|
|
156
|
+
raise DirectionSelectionError("report data is missing taskType metadata")
|
|
157
|
+
if (
|
|
158
|
+
header.get("taskType") != _TASK_TYPE
|
|
159
|
+
or frontmatter.get("taskType") != _TASK_TYPE
|
|
160
|
+
):
|
|
161
|
+
raise DirectionSelectionError(
|
|
162
|
+
"report taskType must be implementation-option-selection"
|
|
163
|
+
)
|
|
164
|
+
if header.get("taskKey") != expected_task_key:
|
|
165
|
+
raise DirectionSelectionError(
|
|
166
|
+
"report taskKey does not match the requested planning task"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _validate_schema(data: dict[str, Any]) -> None:
|
|
171
|
+
try:
|
|
172
|
+
errors = validate_schema(data, load_schema_for_data(data))
|
|
173
|
+
except SchemaError as exc:
|
|
174
|
+
raise DirectionSelectionError(
|
|
175
|
+
f"selection report schema validation failed: {exc}"
|
|
176
|
+
) from exc
|
|
177
|
+
if errors:
|
|
178
|
+
raise DirectionSelectionError(
|
|
179
|
+
"selection report schema validation failed: " + "; ".join(errors)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _relative_source(path: Path, task_root: Path, label: str) -> str:
|
|
184
|
+
try:
|
|
185
|
+
return path.relative_to(task_root).as_posix()
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
raise DirectionSelectionError(
|
|
188
|
+
f"{label} must stay under the selected task root"
|
|
189
|
+
) from exc
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _sidecar_metadata(sidecar_text: str, key: str) -> str:
|
|
193
|
+
frontmatter = _FRONTMATTER_RE.match(sidecar_text)
|
|
194
|
+
if frontmatter is None:
|
|
195
|
+
return ""
|
|
196
|
+
match = re.search(
|
|
197
|
+
rf"^{re.escape(key)}:[ \t]*(\S.*?)[ \t]*$",
|
|
198
|
+
frontmatter.group("body"),
|
|
199
|
+
re.MULTILINE,
|
|
200
|
+
)
|
|
201
|
+
return match.group(1) if match else ""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _comparison_selection(
|
|
205
|
+
*,
|
|
206
|
+
report: Path,
|
|
207
|
+
data_path: Path,
|
|
208
|
+
data_bytes: bytes,
|
|
209
|
+
task_root: Path,
|
|
210
|
+
seq: str,
|
|
211
|
+
expected_task_key: str,
|
|
212
|
+
) -> tuple[str, str, str, str, tuple[str, ...]]:
|
|
213
|
+
sidecar = _strict_regular_file(
|
|
214
|
+
report.parent.parent
|
|
215
|
+
/ "user-responses"
|
|
216
|
+
/ f"user-response-implementation-option-selection-{seq}.md",
|
|
217
|
+
"sidecar",
|
|
218
|
+
)
|
|
219
|
+
sidecar = validate_task_artifact_path(sidecar, task_root, "sidecar")
|
|
220
|
+
try:
|
|
221
|
+
sidecar_text = sidecar.read_text(encoding="utf-8")
|
|
222
|
+
parsed = parse_direction_selection(sidecar_text)
|
|
223
|
+
except (OSError, UnicodeError, UserResponseError) as exc:
|
|
224
|
+
raise DirectionSelectionError(str(exc)) from exc
|
|
225
|
+
if parsed is None:
|
|
226
|
+
raise DirectionSelectionError("sidecar has no DIRECTION SELECTION block")
|
|
227
|
+
if _sidecar_metadata(sidecar_text, "task-key") != expected_task_key:
|
|
228
|
+
raise DirectionSelectionError("sidecar taskKey does not match the planning task")
|
|
229
|
+
if _sidecar_metadata(sidecar_text, "task-type") != _TASK_TYPE:
|
|
230
|
+
raise DirectionSelectionError(
|
|
231
|
+
"sidecar taskType must be implementation-option-selection"
|
|
232
|
+
)
|
|
233
|
+
expected_report = _relative_source(report, task_root, "source report")
|
|
234
|
+
expected_data = _relative_source(data_path, task_root, "source data")
|
|
235
|
+
if parsed.source_report != expected_report:
|
|
236
|
+
raise DirectionSelectionError("sidecar source report does not match report path")
|
|
237
|
+
if parsed.source_data != expected_data:
|
|
238
|
+
raise DirectionSelectionError("sidecar source data does not match data path")
|
|
239
|
+
digest = hashlib.sha256(data_bytes).hexdigest()
|
|
240
|
+
if parsed.source_data_sha256 != digest:
|
|
241
|
+
raise DirectionSelectionError("sidecar source data digest does not match bytes")
|
|
242
|
+
if parsed.seq != seq:
|
|
243
|
+
raise DirectionSelectionError("sidecar sequence does not match report sequence")
|
|
244
|
+
constraints = tuple(
|
|
245
|
+
line.strip() for line in parsed.constraints.splitlines() if line.strip()
|
|
246
|
+
)
|
|
247
|
+
return (
|
|
248
|
+
_relative_source(sidecar, task_root, "selection evidence"),
|
|
249
|
+
parsed.option_id,
|
|
250
|
+
parsed.option_name,
|
|
251
|
+
parsed.selection_note,
|
|
252
|
+
constraints,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _preselected_selection(
|
|
257
|
+
selection: Mapping[str, Any],
|
|
258
|
+
) -> tuple[str, str, str, str, tuple[str, ...]]:
|
|
259
|
+
if selection.get("routing") != "implementation-planning":
|
|
260
|
+
raise DirectionSelectionError(
|
|
261
|
+
"preselected direction routing must be implementation-planning"
|
|
262
|
+
)
|
|
263
|
+
evidence = selection.get("preselectedDirection")
|
|
264
|
+
if not isinstance(evidence, Mapping):
|
|
265
|
+
raise DirectionSelectionError("preselected direction evidence is missing")
|
|
266
|
+
confirmation = str(evidence.get("confirmationEvidence") or "").strip()
|
|
267
|
+
citation = str(evidence.get("citation") or "").strip()
|
|
268
|
+
option_id = str(evidence.get("optionId") or "").strip()
|
|
269
|
+
if not confirmation or not citation:
|
|
270
|
+
raise DirectionSelectionError(
|
|
271
|
+
"preselected direction evidence and citation are required"
|
|
272
|
+
)
|
|
273
|
+
return f"{confirmation} ({citation})", option_id, "", "", ()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _selected_option(
|
|
277
|
+
selection: Mapping[str, Any], option_id: str, option_name: str
|
|
278
|
+
) -> tuple[Mapping[str, Any], str]:
|
|
279
|
+
options = selection.get("rankedOptions")
|
|
280
|
+
if not isinstance(options, list):
|
|
281
|
+
raise DirectionSelectionError("ranked candidate list is missing")
|
|
282
|
+
option = next(
|
|
283
|
+
(row for row in options if isinstance(row, Mapping) and row.get("id") == option_id),
|
|
284
|
+
None,
|
|
285
|
+
)
|
|
286
|
+
if option is None:
|
|
287
|
+
raise DirectionSelectionError("selected option is not a ranked candidate")
|
|
288
|
+
try:
|
|
289
|
+
report_option_id, report_option_name = normalize_direction_selection_identity(
|
|
290
|
+
str(option.get("id") or ""), str(option.get("name") or "")
|
|
291
|
+
)
|
|
292
|
+
except ValueError as exc:
|
|
293
|
+
raise DirectionSelectionError(str(exc)) from exc
|
|
294
|
+
if report_option_id != option_id or (
|
|
295
|
+
option_name and report_option_name != option_name
|
|
296
|
+
):
|
|
297
|
+
raise DirectionSelectionError("selected candidate name does not match report")
|
|
298
|
+
coverage = option.get("coverageSummary")
|
|
299
|
+
if not isinstance(coverage, Mapping) or coverage.get("coverageVerdict") != "exact":
|
|
300
|
+
raise DirectionSelectionError("selected candidate must have exact coverage")
|
|
301
|
+
return option, report_option_name
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _validate_no_blockers(data: Mapping[str, Any], option: Mapping[str, Any]) -> None:
|
|
305
|
+
if option.get("safetyBlockers") or option.get("unresolvedFeasibilityFacts"):
|
|
306
|
+
raise DirectionSelectionError("selected candidate has a safety blocker")
|
|
307
|
+
for row in data.get("clarificationItems") or ():
|
|
308
|
+
if (
|
|
309
|
+
isinstance(row, Mapping)
|
|
310
|
+
and row.get("blocks") == "next-phase"
|
|
311
|
+
and row.get("status") not in {"resolved", "obsolete"}
|
|
312
|
+
):
|
|
313
|
+
raise DirectionSelectionError(
|
|
314
|
+
"selection report has an unresolved next-phase blocker"
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _direction_payload(option: Mapping[str, Any]) -> dict[str, Any]:
|
|
319
|
+
fields = (
|
|
320
|
+
"goal",
|
|
321
|
+
"coreMechanism",
|
|
322
|
+
"architectureBoundaries",
|
|
323
|
+
"expectedChangeAreas",
|
|
324
|
+
"codeEvidence",
|
|
325
|
+
"scopeCommitments",
|
|
326
|
+
)
|
|
327
|
+
return _copy_json({field: option.get(field) for field in fields if field in option})
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _copy_json(value: Any) -> Any:
|
|
331
|
+
"""Return a JSON-only deep copy without sharing mutable report data."""
|
|
332
|
+
return json.loads(json.dumps(value, ensure_ascii=False))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def resolve_selected_direction(
|
|
336
|
+
report_path: Path,
|
|
337
|
+
expected_task_key: str,
|
|
338
|
+
) -> SelectedDirection:
|
|
339
|
+
"""Validate a selection report in the documented fail-closed order."""
|
|
340
|
+
report = _strict_regular_file(Path(report_path), "report")
|
|
341
|
+
data_path = _sibling_data_path(report)
|
|
342
|
+
task_root, seq = _selection_layout(report)
|
|
343
|
+
report = validate_task_artifact_path(report, task_root, "report")
|
|
344
|
+
data_path = validate_task_artifact_path(data_path, task_root, "data")
|
|
345
|
+
data_bytes = data_path.read_bytes()
|
|
346
|
+
data = _parse_data(data_path, data_bytes)
|
|
347
|
+
_validate_report_identity(data, expected_task_key)
|
|
348
|
+
_validate_schema(data)
|
|
349
|
+
selection = data.get("implementationOptionSelection")
|
|
350
|
+
if not isinstance(selection, Mapping):
|
|
351
|
+
raise DirectionSelectionError("implementation option selection data is missing")
|
|
352
|
+
mode = str(selection.get("mode") or "")
|
|
353
|
+
if mode == "candidate-comparison":
|
|
354
|
+
evidence, option_id, option_name, note, constraints = _comparison_selection(
|
|
355
|
+
report=report,
|
|
356
|
+
data_path=data_path,
|
|
357
|
+
data_bytes=data_bytes,
|
|
358
|
+
task_root=task_root,
|
|
359
|
+
seq=seq,
|
|
360
|
+
expected_task_key=expected_task_key,
|
|
361
|
+
)
|
|
362
|
+
elif mode == "preselected-validation":
|
|
363
|
+
evidence, option_id, option_name, note, constraints = _preselected_selection(
|
|
364
|
+
selection
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
raise DirectionSelectionError(f"unsupported selection mode: {mode}")
|
|
368
|
+
option, normalized_option_name = _selected_option(
|
|
369
|
+
selection, option_id, option_name
|
|
370
|
+
)
|
|
371
|
+
_validate_no_blockers(data, option)
|
|
372
|
+
return SelectedDirection(
|
|
373
|
+
mode=mode,
|
|
374
|
+
task_key=expected_task_key,
|
|
375
|
+
source_report=_relative_source(report, task_root, "source report"),
|
|
376
|
+
source_data_sha256=hashlib.sha256(data_bytes).hexdigest(),
|
|
377
|
+
selection_evidence=evidence,
|
|
378
|
+
option_id=option_id,
|
|
379
|
+
option_name=normalized_option_name,
|
|
380
|
+
selection_note=note,
|
|
381
|
+
user_constraints=constraints,
|
|
382
|
+
direction=_direction_payload(option),
|
|
383
|
+
requirement_coverage=tuple(_copy_json(option.get("requirementCoverage") or ())),
|
|
384
|
+
planning_invariants=tuple(_copy_json(option.get("planningInvariants") or ())),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def write_selected_direction_snapshot(
|
|
389
|
+
direction: SelectedDirection,
|
|
390
|
+
destination: Path,
|
|
391
|
+
) -> Path:
|
|
392
|
+
"""Write the normalized planning input using stable JSON bytes."""
|
|
393
|
+
destination = Path(destination)
|
|
394
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
payload = {
|
|
396
|
+
"schemaVersion": "1.0",
|
|
397
|
+
"taskKey": direction.task_key,
|
|
398
|
+
"sourceReport": direction.source_report,
|
|
399
|
+
"sourceDataSha256": direction.source_data_sha256,
|
|
400
|
+
"selectionEvidence": direction.selection_evidence,
|
|
401
|
+
"optionId": direction.option_id,
|
|
402
|
+
"optionName": direction.option_name,
|
|
403
|
+
"selectionNote": direction.selection_note,
|
|
404
|
+
"userConstraints": list(direction.user_constraints),
|
|
405
|
+
"direction": direction.direction,
|
|
406
|
+
"requirementCoverage": list(direction.requirement_coverage),
|
|
407
|
+
"planningInvariants": list(direction.planning_invariants),
|
|
408
|
+
}
|
|
409
|
+
destination.write_text(
|
|
410
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
411
|
+
encoding="utf-8",
|
|
412
|
+
)
|
|
413
|
+
return destination
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
_PLAN_EXECUTION_FIELDS = (
|
|
417
|
+
"directionRealization",
|
|
418
|
+
"stageMap",
|
|
419
|
+
"stages",
|
|
420
|
+
"designPreparation",
|
|
421
|
+
"dependencyMigrationRisk",
|
|
422
|
+
"validationChecklist",
|
|
423
|
+
"rollbackStrategy",
|
|
424
|
+
"requirementCoverage",
|
|
425
|
+
"coverageSummary",
|
|
426
|
+
"variationPointAnalysis",
|
|
427
|
+
"planBodyVerification",
|
|
428
|
+
"stepwiseExecution",
|
|
429
|
+
"supersessionLedger",
|
|
430
|
+
"crossProjectDependencies",
|
|
431
|
+
"decisionDrafts",
|
|
432
|
+
"skippedAdrCandidates",
|
|
433
|
+
"incrementalDecision",
|
|
434
|
+
"userNarrative",
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _load_selected_direction_snapshot(
|
|
439
|
+
snapshot_path: Path,
|
|
440
|
+
) -> tuple[SelectedDirectionSnapshot | None, list[str]]:
|
|
441
|
+
path = lexical_absolute_path(Path(snapshot_path))
|
|
442
|
+
if path.name != "selected-direction.json" or path.parent.name != "instruction-set":
|
|
443
|
+
return None, [
|
|
444
|
+
"selectedDirectionRef.snapshotPath must name "
|
|
445
|
+
"instruction-set/selected-direction.json"
|
|
446
|
+
]
|
|
447
|
+
task_root = path.parent.parent
|
|
448
|
+
try:
|
|
449
|
+
path = validate_task_artifact_path(path, task_root, "selected direction snapshot")
|
|
450
|
+
content = path.read_bytes()
|
|
451
|
+
data = json.loads(content)
|
|
452
|
+
except DirectionSelectionError as exc:
|
|
453
|
+
return None, [str(exc)]
|
|
454
|
+
except OSError as exc:
|
|
455
|
+
return None, [f"selected direction snapshot is unreadable: {exc}"]
|
|
456
|
+
except (UnicodeError, json.JSONDecodeError) as exc:
|
|
457
|
+
return None, [f"selected direction snapshot JSON is invalid: {exc}"]
|
|
458
|
+
if not isinstance(data, Mapping):
|
|
459
|
+
return None, ["selected direction snapshot JSON must contain an object"]
|
|
460
|
+
return SelectedDirectionSnapshot(
|
|
461
|
+
data=data,
|
|
462
|
+
path=path,
|
|
463
|
+
relative_path=path.relative_to(task_root).as_posix(),
|
|
464
|
+
sha256=hashlib.sha256(content).hexdigest(),
|
|
465
|
+
), []
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def validate_selected_direction_ref(
|
|
469
|
+
ref: Mapping[str, Any], snapshot: SelectedDirectionSnapshot
|
|
470
|
+
) -> list[str]:
|
|
471
|
+
"""Match a planning reference to one byte-verified direction snapshot."""
|
|
472
|
+
failures: list[str] = []
|
|
473
|
+
for field in ("sourceReport", "sourceDataSha256", "optionId"):
|
|
474
|
+
if ref.get(field) != snapshot.data.get(field):
|
|
475
|
+
failures.append(
|
|
476
|
+
f"selectedDirectionRef.{field} is missing or does not match "
|
|
477
|
+
"the selected-direction snapshot"
|
|
478
|
+
)
|
|
479
|
+
if ref.get("snapshotPath") != snapshot.relative_path:
|
|
480
|
+
failures.append(
|
|
481
|
+
"selectedDirectionRef.snapshotPath is missing or does not match the "
|
|
482
|
+
"actual selected-direction snapshot path"
|
|
483
|
+
)
|
|
484
|
+
if ref.get("snapshotSha256") != snapshot.sha256:
|
|
485
|
+
failures.append(
|
|
486
|
+
"selectedDirectionRef.snapshotSha256 is missing or does not match "
|
|
487
|
+
"the actual selected-direction snapshot bytes"
|
|
488
|
+
)
|
|
489
|
+
return failures
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _direction_realization_errors(
|
|
493
|
+
realization: object, snapshot: SelectedDirectionSnapshot
|
|
494
|
+
) -> list[str]:
|
|
495
|
+
if not isinstance(realization, Mapping):
|
|
496
|
+
return ["directionRealization is missing from a plan-ready result"]
|
|
497
|
+
direction = snapshot.data.get("direction")
|
|
498
|
+
if not isinstance(direction, Mapping):
|
|
499
|
+
return ["selected direction snapshot is missing its direction object"]
|
|
500
|
+
comparisons = {
|
|
501
|
+
"coreMechanism": direction.get("coreMechanism"),
|
|
502
|
+
"architectureBoundaries": direction.get("architectureBoundaries"),
|
|
503
|
+
"planningInvariants": snapshot.data.get("planningInvariants"),
|
|
504
|
+
"userConstraints": snapshot.data.get("userConstraints"),
|
|
505
|
+
}
|
|
506
|
+
return [
|
|
507
|
+
f"directionRealization.{field} must exactly preserve the selected-direction snapshot"
|
|
508
|
+
for field, expected in comparisons.items()
|
|
509
|
+
if realization.get(field) != expected
|
|
510
|
+
]
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _plan_reference_sets(
|
|
514
|
+
planning: Mapping[str, Any],
|
|
515
|
+
) -> tuple[set[int], set[str], set[str], tuple[str, ...]]:
|
|
516
|
+
stages = [row for row in planning.get("stages") or () if isinstance(row, Mapping)]
|
|
517
|
+
stage_ids = {
|
|
518
|
+
row["stage"] for row in stages if isinstance(row.get("stage"), int)
|
|
519
|
+
}
|
|
520
|
+
step_ids = {
|
|
521
|
+
f"{stage['stage']}.{step['step']}"
|
|
522
|
+
for stage in stages
|
|
523
|
+
if isinstance(stage.get("stage"), int)
|
|
524
|
+
for step in stage.get("stepwiseExecution") or ()
|
|
525
|
+
if isinstance(step, Mapping) and isinstance(step.get("step"), int)
|
|
526
|
+
}
|
|
527
|
+
validation_ids = {
|
|
528
|
+
str(row.get("id"))
|
|
529
|
+
for row in planning.get("validationChecklist") or ()
|
|
530
|
+
if isinstance(row, Mapping) and row.get("id")
|
|
531
|
+
}
|
|
532
|
+
realization = planning.get("directionRealization")
|
|
533
|
+
file_rows = (
|
|
534
|
+
realization.get("fileStructure") or ()
|
|
535
|
+
if isinstance(realization, Mapping)
|
|
536
|
+
else ()
|
|
537
|
+
)
|
|
538
|
+
file_paths = tuple(
|
|
539
|
+
str(row.get("path"))
|
|
540
|
+
for row in file_rows
|
|
541
|
+
if isinstance(row, Mapping) and row.get("path")
|
|
542
|
+
)
|
|
543
|
+
return stage_ids, step_ids, validation_ids, file_paths
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _duplicate_values(values: list[object]) -> tuple[object, ...]:
|
|
547
|
+
return tuple(value for value, count in Counter(values).items() if count > 1)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _normalized_plan_file_path(value: object) -> str:
|
|
551
|
+
return posixpath.normpath(str(value).replace("\\", "/"))
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _plan_structure_errors(planning: Mapping[str, Any]) -> list[str]:
|
|
555
|
+
failures: list[str] = []
|
|
556
|
+
stage_map = [
|
|
557
|
+
row for row in planning.get("stageMap") or () if isinstance(row, Mapping)
|
|
558
|
+
]
|
|
559
|
+
stages = [
|
|
560
|
+
row for row in planning.get("stages") or () if isinstance(row, Mapping)
|
|
561
|
+
]
|
|
562
|
+
stage_map_numbers = [
|
|
563
|
+
row["stage"] for row in stage_map if isinstance(row.get("stage"), int)
|
|
564
|
+
]
|
|
565
|
+
stage_numbers = [
|
|
566
|
+
row["stage"] for row in stages if isinstance(row.get("stage"), int)
|
|
567
|
+
]
|
|
568
|
+
duplicate_stage_map = set(_duplicate_values(stage_map_numbers))
|
|
569
|
+
duplicate_stages = set(_duplicate_values(stage_numbers))
|
|
570
|
+
failures.extend(
|
|
571
|
+
f"stageMap contains duplicate stage {number}"
|
|
572
|
+
for number in sorted(duplicate_stage_map)
|
|
573
|
+
)
|
|
574
|
+
failures.extend(
|
|
575
|
+
f"stages contains duplicate stage {number}"
|
|
576
|
+
for number in sorted(duplicate_stages)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
stage_map_set = set(stage_map_numbers)
|
|
580
|
+
stage_set = set(stage_numbers)
|
|
581
|
+
failures.extend(
|
|
582
|
+
f"stageMap is missing stages stage {number}"
|
|
583
|
+
for number in sorted(stage_set - stage_map_set)
|
|
584
|
+
)
|
|
585
|
+
failures.extend(
|
|
586
|
+
f"stages is missing stageMap stage {number}"
|
|
587
|
+
for number in sorted(stage_map_set - stage_set)
|
|
588
|
+
)
|
|
589
|
+
unique_stage_map = {
|
|
590
|
+
row["stage"]: row
|
|
591
|
+
for row in stage_map
|
|
592
|
+
if isinstance(row.get("stage"), int)
|
|
593
|
+
and row["stage"] not in duplicate_stage_map
|
|
594
|
+
}
|
|
595
|
+
unique_stages = {
|
|
596
|
+
row["stage"]: row
|
|
597
|
+
for row in stages
|
|
598
|
+
if isinstance(row.get("stage"), int) and row["stage"] not in duplicate_stages
|
|
599
|
+
}
|
|
600
|
+
failures.extend(
|
|
601
|
+
f"stageMap and stages stage {number} title must match"
|
|
602
|
+
for number in sorted(unique_stage_map.keys() & unique_stages.keys())
|
|
603
|
+
if unique_stage_map[number].get("title")
|
|
604
|
+
!= unique_stages[number].get("title")
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
for stage in stages:
|
|
608
|
+
stage_number = stage.get("stage")
|
|
609
|
+
step_numbers = [
|
|
610
|
+
step["step"]
|
|
611
|
+
for step in stage.get("stepwiseExecution") or ()
|
|
612
|
+
if isinstance(step, Mapping) and isinstance(step.get("step"), int)
|
|
613
|
+
]
|
|
614
|
+
failures.extend(
|
|
615
|
+
f"stages stage {stage_number} step {step_number} is duplicate"
|
|
616
|
+
for step_number in sorted(_duplicate_values(step_numbers))
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
validation_ids = [
|
|
620
|
+
str(row["id"])
|
|
621
|
+
for row in planning.get("validationChecklist") or ()
|
|
622
|
+
if isinstance(row, Mapping) and row.get("id")
|
|
623
|
+
]
|
|
624
|
+
failures.extend(
|
|
625
|
+
f"validationChecklist id {validation_id} is duplicate"
|
|
626
|
+
for validation_id in sorted(_duplicate_values(validation_ids))
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
realization = planning.get("directionRealization")
|
|
630
|
+
file_rows = (
|
|
631
|
+
[row for row in realization.get("fileStructure") or () if isinstance(row, Mapping)]
|
|
632
|
+
if isinstance(realization, Mapping)
|
|
633
|
+
else []
|
|
634
|
+
)
|
|
635
|
+
file_ids = [str(row["id"]) for row in file_rows if row.get("id")]
|
|
636
|
+
failures.extend(
|
|
637
|
+
f"directionRealization.fileStructure id {file_id} is duplicate"
|
|
638
|
+
for file_id in sorted(_duplicate_values(file_ids))
|
|
639
|
+
)
|
|
640
|
+
normalized_paths = [
|
|
641
|
+
_normalized_plan_file_path(row["path"])
|
|
642
|
+
for row in file_rows
|
|
643
|
+
if row.get("path")
|
|
644
|
+
]
|
|
645
|
+
failures.extend(
|
|
646
|
+
f"directionRealization.fileStructure path {path} is duplicate after normalization"
|
|
647
|
+
for path in sorted(_duplicate_values(normalized_paths))
|
|
648
|
+
)
|
|
649
|
+
return failures
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _coverage_reference_errors(
|
|
653
|
+
rows: list[Mapping[str, Any]],
|
|
654
|
+
stage_ids: set[int],
|
|
655
|
+
step_ids: set[str],
|
|
656
|
+
validation_ids: set[str],
|
|
657
|
+
file_paths: tuple[str, ...],
|
|
658
|
+
) -> list[str]:
|
|
659
|
+
failures: list[str] = []
|
|
660
|
+
reference_sets = {
|
|
661
|
+
"stageRefs": stage_ids,
|
|
662
|
+
"stepRefs": step_ids,
|
|
663
|
+
"validationRefs": validation_ids,
|
|
664
|
+
"fileRefs": set(file_paths),
|
|
665
|
+
}
|
|
666
|
+
for row in rows:
|
|
667
|
+
requirement_id = str(row.get("originalRequirementId") or "<missing>")
|
|
668
|
+
for field, valid_values in reference_sets.items():
|
|
669
|
+
dangling = [value for value in row.get(field) or () if value not in valid_values]
|
|
670
|
+
if dangling:
|
|
671
|
+
failures.append(
|
|
672
|
+
f"requirementCoverage {requirement_id} {field} contains "
|
|
673
|
+
f"dangling reference(s): {dangling}"
|
|
674
|
+
)
|
|
675
|
+
return failures
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _requirement_ids_for_scope(
|
|
679
|
+
rows: list[Mapping[str, Any]], field: str, value: object
|
|
680
|
+
) -> tuple[str, ...]:
|
|
681
|
+
return tuple(
|
|
682
|
+
str(row.get("originalRequirementId"))
|
|
683
|
+
for row in rows
|
|
684
|
+
if value in (row.get(field) or ()) and row.get("originalRequirementId")
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _coverage_summary_errors(
|
|
689
|
+
planning: Mapping[str, Any], original_ids: tuple[str, ...]
|
|
690
|
+
) -> list[str]:
|
|
691
|
+
rows = [
|
|
692
|
+
row
|
|
693
|
+
for row in planning.get("requirementCoverage") or ()
|
|
694
|
+
if isinstance(row, Mapping)
|
|
695
|
+
]
|
|
696
|
+
row_ids = tuple(str(row.get("originalRequirementId") or "") for row in rows)
|
|
697
|
+
failures: list[str] = []
|
|
698
|
+
if not original_ids:
|
|
699
|
+
return ["original requirement denominator from taskBriefPath is empty"]
|
|
700
|
+
if row_ids != original_ids:
|
|
701
|
+
failures.append(
|
|
702
|
+
"original requirement rows must match the taskBriefPath requirement "
|
|
703
|
+
"sequence exactly once"
|
|
704
|
+
)
|
|
705
|
+
return failures
|
|
706
|
+
structure_failures = _plan_structure_errors(planning)
|
|
707
|
+
failures.extend(structure_failures)
|
|
708
|
+
stage_ids, step_ids, validation_ids, file_paths = _plan_reference_sets(planning)
|
|
709
|
+
failures.extend(
|
|
710
|
+
_coverage_reference_errors(
|
|
711
|
+
rows, stage_ids, step_ids, validation_ids, file_paths
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
if structure_failures:
|
|
715
|
+
return failures
|
|
716
|
+
statuses = {row_id: str(row.get("status") or "") for row_id, row in zip(row_ids, rows)}
|
|
717
|
+
stage_requirements = {
|
|
718
|
+
stage: _requirement_ids_for_scope(rows, "stageRefs", stage)
|
|
719
|
+
for stage in sorted(stage_ids)
|
|
720
|
+
}
|
|
721
|
+
file_requirements = {
|
|
722
|
+
path: _requirement_ids_for_scope(rows, "fileRefs", path)
|
|
723
|
+
for path in file_paths
|
|
724
|
+
}
|
|
725
|
+
try:
|
|
726
|
+
result, unmapped_stages, unmapped_files = calculate_plan_exact_coverage(
|
|
727
|
+
original_ids, statuses, stage_requirements, file_requirements
|
|
728
|
+
)
|
|
729
|
+
except ExactCoverageError as exc:
|
|
730
|
+
return [*failures, f"plan-ready requires exact 100% coverage: {exc}"]
|
|
731
|
+
failures.extend(
|
|
732
|
+
f"unmapped stage {stage} has no original requirement" for stage in unmapped_stages
|
|
733
|
+
)
|
|
734
|
+
failures.extend(
|
|
735
|
+
f"unmapped file change {path} has no original requirement"
|
|
736
|
+
for path in unmapped_files
|
|
737
|
+
)
|
|
738
|
+
failures.extend(
|
|
739
|
+
_declared_coverage_summary_errors(
|
|
740
|
+
planning.get("coverageSummary"), result, unmapped_stages, unmapped_files
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
if result.verdict != "exact":
|
|
744
|
+
failures.append("plan-ready requires exact 100% coverage")
|
|
745
|
+
return failures
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _declared_coverage_summary_errors(
|
|
749
|
+
summary: object,
|
|
750
|
+
result: Any,
|
|
751
|
+
unmapped_stages: tuple[int, ...],
|
|
752
|
+
unmapped_files: tuple[str, ...],
|
|
753
|
+
) -> list[str]:
|
|
754
|
+
if not isinstance(summary, Mapping):
|
|
755
|
+
return ["coverageSummary is missing from a plan-ready result"]
|
|
756
|
+
expected = {
|
|
757
|
+
"coveragePercent": result.coverage_percent,
|
|
758
|
+
"scopePrecisionPercent": result.scope_precision_percent,
|
|
759
|
+
"coverageVerdict": result.verdict,
|
|
760
|
+
"unmappedStages": list(unmapped_stages),
|
|
761
|
+
"unmappedFileChanges": list(unmapped_files),
|
|
762
|
+
}
|
|
763
|
+
return [
|
|
764
|
+
f"coverageSummary.{field} does not match recalculated plan coverage"
|
|
765
|
+
for field, value in expected.items()
|
|
766
|
+
if summary.get(field) != value
|
|
767
|
+
]
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _direction_invalidation_errors(planning: Mapping[str, Any]) -> list[str]:
|
|
771
|
+
failures: list[str] = []
|
|
772
|
+
invalidation = planning.get("directionInvalidation")
|
|
773
|
+
if not isinstance(invalidation, Mapping):
|
|
774
|
+
failures.append("direction-invalidated requires directionInvalidation evidence")
|
|
775
|
+
else:
|
|
776
|
+
for field in ("reasons", "codeEvidence"):
|
|
777
|
+
values = invalidation.get(field)
|
|
778
|
+
if not isinstance(values, list) or not values or any(
|
|
779
|
+
not isinstance(value, str) or not value.strip() for value in values
|
|
780
|
+
):
|
|
781
|
+
failures.append(f"directionInvalidation.{field} must contain evidence")
|
|
782
|
+
if planning.get("routing") != "implementation-option-selection":
|
|
783
|
+
failures.append(
|
|
784
|
+
"direction-invalidated routing must be implementation-option-selection"
|
|
785
|
+
)
|
|
786
|
+
for field in _PLAN_EXECUTION_FIELDS:
|
|
787
|
+
if field in planning:
|
|
788
|
+
failures.append(
|
|
789
|
+
f"direction-invalidated must not contain execution field {field}"
|
|
790
|
+
)
|
|
791
|
+
return failures
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def validate_selected_direction_plan(
|
|
795
|
+
data: Mapping[str, Any], brief_path: Path, snapshot_path: Path
|
|
796
|
+
) -> list[str]:
|
|
797
|
+
"""Validate selected-direction planning semantics against independent inputs."""
|
|
798
|
+
planning = data.get("implementationPlanning")
|
|
799
|
+
if not isinstance(planning, Mapping):
|
|
800
|
+
return ["implementationPlanning is missing"]
|
|
801
|
+
if planning.get("planningContract") != "selected-direction":
|
|
802
|
+
return ["implementationPlanning.planningContract must be selected-direction"]
|
|
803
|
+
snapshot, failures = _load_selected_direction_snapshot(snapshot_path)
|
|
804
|
+
if snapshot is None:
|
|
805
|
+
return failures
|
|
806
|
+
header = data.get("header")
|
|
807
|
+
plan_task_key = header.get("taskKey") if isinstance(header, Mapping) else None
|
|
808
|
+
snapshot_task_key = snapshot.data.get("taskKey")
|
|
809
|
+
if not isinstance(plan_task_key, str) or not plan_task_key:
|
|
810
|
+
failures.append("plan header.taskKey must be a non-empty string")
|
|
811
|
+
if not isinstance(snapshot_task_key, str) or not snapshot_task_key:
|
|
812
|
+
failures.append("selected direction snapshot taskKey must be a non-empty string")
|
|
813
|
+
elif isinstance(plan_task_key, str) and snapshot_task_key != plan_task_key:
|
|
814
|
+
failures.append(
|
|
815
|
+
"selected direction snapshot taskKey must exactly match plan header.taskKey"
|
|
816
|
+
)
|
|
817
|
+
ref = planning.get("selectedDirectionRef")
|
|
818
|
+
if not isinstance(ref, Mapping):
|
|
819
|
+
failures.append("selectedDirectionRef is missing")
|
|
820
|
+
else:
|
|
821
|
+
failures.extend(validate_selected_direction_ref(ref, snapshot))
|
|
822
|
+
outcome = planning.get("outcome")
|
|
823
|
+
if outcome == "direction-invalidated":
|
|
824
|
+
failures.extend(_direction_invalidation_errors(planning))
|
|
825
|
+
return failures
|
|
826
|
+
if outcome != "plan-ready":
|
|
827
|
+
return [*failures, f"selected-direction outcome is invalid: {outcome!r}"]
|
|
828
|
+
failures.extend(
|
|
829
|
+
_direction_realization_errors(planning.get("directionRealization"), snapshot)
|
|
830
|
+
)
|
|
831
|
+
failures.extend(
|
|
832
|
+
_coverage_summary_errors(
|
|
833
|
+
planning, brief_end_state_id_sequence(Path(brief_path))
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
return failures
|