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.
Files changed (123) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +24 -3
  3. package/docs/architecture.md +21 -35
  4. package/docs/cli.md +39 -7
  5. package/docs/container.md +1 -1
  6. package/docs/contributor-change-matrix.md +1 -1
  7. package/docs/performance-improvement-plan-v2.md +6 -5
  8. package/docs/project-structure-overview.md +33 -25
  9. package/docs/task-process/README.md +6 -4
  10. package/docs/task-process/error-analysis.md +2 -2
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation-option-selection.md +70 -0
  13. package/docs/task-process/implementation-planning.md +24 -16
  14. package/docs/task-process/requirements-discovery.md +2 -2
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/report-writer-worker.md +30 -6
  19. package/runtime/bin/lib/okstra/cli.sh +5 -1
  20. package/runtime/bin/lib/okstra/globals.sh +2 -1
  21. package/runtime/bin/lib/okstra/usage.sh +3 -0
  22. package/runtime/bin/okstra-provider-exec.py +29 -12
  23. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  24. package/runtime/bin/okstra.sh +2 -0
  25. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  26. package/runtime/prompts/duties/planning-worker.md +12 -4
  27. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  28. package/runtime/prompts/lead/context-loader.md +1 -1
  29. package/runtime/prompts/lead/convergence.md +5 -5
  30. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  31. package/runtime/prompts/lead/plan-body-verification.md +23 -6
  32. package/runtime/prompts/lead/report-writer.md +33 -11
  33. package/runtime/prompts/profiles/_common-contract.md +3 -3
  34. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  35. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  36. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  37. package/runtime/prompts/profiles/error-analysis.md +4 -4
  38. package/runtime/prompts/profiles/final-verification.md +3 -3
  39. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  40. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  41. package/runtime/prompts/profiles/implementation-planning.md +61 -46
  42. package/runtime/prompts/profiles/implementation.md +4 -2
  43. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  44. package/runtime/prompts/profiles/release-handoff.md +1 -1
  45. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  46. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  47. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  48. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  49. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
  50. package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
  51. package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
  52. package/runtime/python/okstra_ctl/conformance.py +68 -0
  53. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  54. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  55. package/runtime/python/okstra_ctl/doctor.py +2 -2
  56. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  57. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  58. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  59. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  60. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  61. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  62. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  63. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  64. package/runtime/python/okstra_ctl/render.py +1 -0
  65. package/runtime/python/okstra_ctl/render_final_report.py +16 -19
  66. package/runtime/python/okstra_ctl/report_contract.py +45 -14
  67. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  68. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  69. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  70. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  71. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
  72. package/runtime/python/okstra_ctl/report_views.py +148 -12
  73. package/runtime/python/okstra_ctl/run.py +393 -4
  74. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  75. package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
  76. package/runtime/python/okstra_ctl/session.py +69 -12
  77. package/runtime/python/okstra_ctl/team.py +51 -25
  78. package/runtime/python/okstra_ctl/tmux.py +19 -149
  79. package/runtime/python/okstra_ctl/user_response.py +75 -0
  80. package/runtime/python/okstra_ctl/wizard.py +144 -0
  81. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  82. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  83. package/runtime/python/okstra_ctl/workflow.py +29 -7
  84. package/runtime/python/okstra_ctl/worktree.py +69 -3
  85. package/runtime/python/okstra_token_usage/cli.py +1 -1
  86. package/runtime/python/okstra_token_usage/collect.py +66 -6
  87. package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
  88. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  89. package/runtime/templates/reports/final-report-v2.template.md +4 -0
  90. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  91. package/runtime/templates/reports/html/base.template.html +3 -2
  92. package/runtime/templates/reports/html/i18n/en.json +21 -1
  93. package/runtime/templates/reports/html/i18n/ko.json +21 -1
  94. package/runtime/templates/reports/html/macros/forms.html +21 -2
  95. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  96. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
  97. package/runtime/templates/reports/i18n/en.json +13 -0
  98. package/runtime/templates/reports/implementation-input.template.md +4 -2
  99. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  100. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  101. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  102. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  103. package/runtime/templates/reports/report.js +111 -4
  104. package/runtime/templates/reports/settings.template.json +0 -24
  105. package/runtime/templates/reports/task-brief.template.md +9 -3
  106. package/runtime/templates/reports/user-response.template.md +25 -4
  107. package/runtime/templates/worker-prompt-preamble.md +8 -0
  108. package/runtime/validators/lib/fixtures.sh +49 -17
  109. package/runtime/validators/validate-implementation-plan-stages.py +169 -4
  110. package/runtime/validators/validate-report-views.py +2 -2
  111. package/runtime/validators/validate-run.py +149 -498
  112. package/runtime/validators/validate_improvement_report.py +5 -1
  113. package/runtime/validators/validate_session_conformance.py +1 -1
  114. package/src/cli-registry.mjs +8 -1
  115. package/src/commands/execute/codex-run.mjs +1 -0
  116. package/src/commands/execute/render-bundle.mjs +1 -0
  117. package/src/commands/execute/team.mjs +3 -3
  118. package/src/commands/execute/worktree-status.mjs +109 -0
  119. package/src/commands/lifecycle/install.mjs +0 -2
  120. package/src/commands/report/finalize.mjs +13 -6
  121. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  122. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  123. 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