okstra 0.122.0 → 0.123.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 (53) hide show
  1. package/README.md +4 -2
  2. package/docs/architecture/storage-model.md +14 -0
  3. package/docs/architecture.md +34 -6
  4. package/docs/cli.md +45 -5
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-rollup.md +1 -1
  7. package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
  8. package/docs/project-structure-overview.md +3 -2
  9. package/docs/task-process/implementation-planning.md +33 -9
  10. package/docs/task-process/implementation.md +21 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/bin/lib/okstra/usage.sh +3 -3
  14. package/runtime/prompts/launch.template.md +5 -2
  15. package/runtime/prompts/lead/convergence.md +1 -1
  16. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  17. package/runtime/prompts/lead/plan-body-verification.md +29 -0
  18. package/runtime/prompts/lead/report-writer.md +9 -3
  19. package/runtime/prompts/profiles/_common-contract.md +1 -1
  20. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
  21. package/runtime/prompts/profiles/_implementation-executor.md +5 -0
  22. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  23. package/runtime/prompts/profiles/final-verification.md +2 -0
  24. package/runtime/prompts/profiles/implementation-planning.md +11 -1
  25. package/runtime/prompts/wizard/prompts.ko.json +44 -0
  26. package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
  27. package/runtime/python/okstra_ctl/design_prep.py +1462 -0
  28. package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
  29. package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
  30. package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
  31. package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
  32. package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
  33. package/runtime/python/okstra_ctl/material.py +1 -1
  34. package/runtime/python/okstra_ctl/model_discovery.py +98 -0
  35. package/runtime/python/okstra_ctl/models.py +8 -3
  36. package/runtime/python/okstra_ctl/render.py +5 -0
  37. package/runtime/python/okstra_ctl/run.py +53 -5
  38. package/runtime/python/okstra_ctl/user_response.py +67 -2
  39. package/runtime/python/okstra_ctl/wizard.py +283 -3
  40. package/runtime/python/okstra_token_usage/report.py +11 -0
  41. package/runtime/schemas/final-report-v1.0.schema.json +336 -0
  42. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  43. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  44. package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +2 -2
  45. package/runtime/skills/okstra-user-response/SKILL.md +8 -6
  46. package/runtime/templates/reports/final-report.template.md +67 -0
  47. package/runtime/templates/reports/i18n/en.json +31 -0
  48. package/runtime/templates/reports/i18n/ko.json +31 -0
  49. package/runtime/validators/validate-run.py +426 -5
  50. package/runtime/validators/validate-schedule.py +4 -4
  51. package/src/cli-registry.mjs +7 -0
  52. package/src/commands/inspect/design-prep.mjs +23 -0
  53. package/src/lib/skill-catalog.mjs +2 -1
@@ -0,0 +1,1462 @@
1
+ """Fingerprint and materialize implementation design-preparation requests."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import fcntl
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+ import sys
12
+ import unicodedata
13
+ import uuid
14
+ from collections.abc import Mapping
15
+ from contextlib import contextmanager
16
+ from copy import deepcopy
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+ from fnmatch import fnmatchcase
20
+ from pathlib import Path
21
+ from typing import Any, Iterator
22
+
23
+ from .final_report_schema import load_schema, validate as validate_schema
24
+ from okstra_project import ResolverError, resolve_project_root
25
+
26
+
27
+ ASSESSMENT_FIELDS = (
28
+ "id",
29
+ "kind",
30
+ "title",
31
+ "stageRefs",
32
+ "status",
33
+ "need",
34
+ "knownFacts",
35
+ "openQuestions",
36
+ "aiProposal",
37
+ "humanConfirmation",
38
+ "workingAssumption",
39
+ "guardrails",
40
+ "reviewAt",
41
+ "ifStillOpen",
42
+ "replanTriggerFields",
43
+ "blockReason",
44
+ "requiredDecision",
45
+ "notApplicableReason",
46
+ )
47
+ ASSESSMENT_SCHEMA_VERSION = 1
48
+ REQUEST_TEMPLATE_VERSION = 1
49
+
50
+ _PLANNING_DATA_RE = re.compile(
51
+ r"^final-report-implementation-planning-(?P<seq>\d+)\.data\.json$"
52
+ )
53
+ _REQUEST_STATUSES = {"provisional", "blocked"}
54
+ _INPUT_DECISIONS = {"accept-draft", "modify-draft", "reject-draft", "defer"}
55
+ _INPUT_FILENAME_RE = re.compile(
56
+ r"^design-prep-input-(?P<seq>\d+)-(?P<item>PREP-\d{3})-"
57
+ r"r(?P<revision>\d{3,})-(?P<input_id>[0-9a-f-]{36})\.md$"
58
+ )
59
+ _ASSESSMENT_FINGERPRINT_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
60
+ _CAPTURED_BY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
61
+ _INPUT_FRONTMATTER_FIELDS = (
62
+ "schema-version",
63
+ "source-report",
64
+ "assessment-fingerprint",
65
+ "item-id",
66
+ "input-id",
67
+ "revision",
68
+ "created-at",
69
+ "created-by",
70
+ "captured-by",
71
+ )
72
+ _INPUT_BODY_RE = re.compile(
73
+ r"^# Design Preparation Input\n\n"
74
+ r"## Decision\n\n(?P<decision>[^\n]+)\n\n"
75
+ r"## Overrides\n\n```json\n(?P<overrides>[^\n]*)\n```\n\n"
76
+ r"## Notes\n\n(?P<notes>.*)\n$",
77
+ re.DOTALL,
78
+ )
79
+
80
+
81
+ class DesignPrepError(RuntimeError):
82
+ """A design-preparation snapshot or sidecar violates its artifact contract."""
83
+
84
+
85
+ class _NonRegularInputError(DesignPrepError):
86
+ pass
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ParsedDesignPrepInput:
91
+ path: Path
92
+ item_id: str
93
+ assessment_fingerprint: str
94
+ revision: int
95
+ input_id: str
96
+ decision: str
97
+ overrides: dict[str, Any]
98
+ notes: str
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class DesignPrepDecision:
103
+ outcome: str
104
+ effective_items: tuple[dict[str, Any], ...]
105
+ assumptions_to_inject: tuple[str, ...]
106
+ warnings: tuple[str, ...]
107
+ request_paths: tuple[str, ...]
108
+ reason: str
109
+
110
+ def as_prompt_markdown(self) -> str:
111
+ lines = ["## Effective Design Preparation", f"- Outcome: `{self.outcome}`"]
112
+ for item in self.effective_items:
113
+ lines.extend(
114
+ [
115
+ f"### {item['id']} — {item['kind']}",
116
+ f"- Assessment fingerprint: `{item['assessmentFingerprint']}`",
117
+ f"- Decision: `{item['decision']}`",
118
+ "- Effective proposal: `"
119
+ f"{json.dumps(item['effectiveProposal'], ensure_ascii=False, sort_keys=True)}`",
120
+ "- User overrides: `"
121
+ f"{json.dumps(item['overrides'], ensure_ascii=False, sort_keys=True)}`",
122
+ ]
123
+ )
124
+ lines.extend(f"- Guardrail: {value}" for value in item.get("guardrails") or [])
125
+ lines.extend(
126
+ "- Dependency carry evidence: "
127
+ f"{json.dumps(value, ensure_ascii=False, sort_keys=True)}"
128
+ for value in item.get("dependencyCarryEvidence") or []
129
+ )
130
+ lines.extend(f"- Assumption: {value}" for value in self.assumptions_to_inject)
131
+ lines.extend(f"- Warning: {value}" for value in self.warnings)
132
+ return "\n".join(lines) + "\n"
133
+
134
+
135
+ def _normalise_json(value: Any) -> Any:
136
+ if isinstance(value, str):
137
+ newlines = value.replace("\r\n", "\n").replace("\r", "\n")
138
+ return unicodedata.normalize("NFC", newlines)
139
+ if isinstance(value, list):
140
+ return [_normalise_json(item) for item in value]
141
+ if isinstance(value, Mapping):
142
+ return {key: _normalise_json(value[key]) for key in sorted(value)}
143
+ return value
144
+
145
+
146
+ def _sha256_json(payload: Mapping[str, Any]) -> str:
147
+ canonical = json.dumps(
148
+ _normalise_json(payload),
149
+ ensure_ascii=False,
150
+ sort_keys=True,
151
+ separators=(",", ":"),
152
+ ).encode("utf-8")
153
+ return "sha256:" + hashlib.sha256(canonical).hexdigest()
154
+
155
+
156
+ def assessment_fingerprint(item: Mapping[str, Any]) -> str:
157
+ """Hash only the versioned semantic fields of a design-preparation item."""
158
+ if not isinstance(item, Mapping):
159
+ raise DesignPrepError("design preparation item must be an object")
160
+ payload: dict[str, Any] = {
161
+ "assessmentSchemaVersion": ASSESSMENT_SCHEMA_VERSION
162
+ }
163
+ payload.update({field: item[field] for field in ASSESSMENT_FIELDS if field in item})
164
+ return _sha256_json(payload)
165
+
166
+
167
+ def _load_plan_data(data_path: Path) -> dict[str, Any]:
168
+ try:
169
+ data = json.loads(data_path.read_text(encoding="utf-8"))
170
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
171
+ raise DesignPrepError(f"cannot read planning data {data_path}: {exc}") from exc
172
+ if not isinstance(data, dict):
173
+ raise DesignPrepError(f"planning data must be an object: {data_path}")
174
+ return data
175
+
176
+
177
+ def _planning_seq(data_path: Path) -> str:
178
+ match = _PLANNING_DATA_RE.match(data_path.name)
179
+ if match is None:
180
+ raise DesignPrepError(f"not an implementation-planning data path: {data_path}")
181
+ return match.group("seq")
182
+
183
+
184
+ def _design_prep_item_schema() -> dict[str, Any]:
185
+ full_schema = load_schema()
186
+ return {
187
+ "$defs": full_schema["$defs"],
188
+ "$ref": "#/$defs/DesignPrepItem",
189
+ }
190
+
191
+
192
+ def _validate_design_prep_item(
193
+ item: Mapping[str, Any],
194
+ item_schema: dict[str, Any],
195
+ ) -> None:
196
+ errors = validate_schema(item, item_schema)
197
+ if errors:
198
+ item_id = item.get("id", "<missing>")
199
+ raise DesignPrepError(
200
+ f"design preparation item {item_id}: {errors[0]}"
201
+ )
202
+
203
+
204
+ def _design_preparation_items(data: Mapping[str, Any]) -> list[Mapping[str, Any]]:
205
+ planning = data.get("implementationPlanning")
206
+ if planning is None:
207
+ return []
208
+ if not isinstance(planning, Mapping):
209
+ raise DesignPrepError("implementationPlanning must be an object")
210
+ preparation = planning.get("designPreparation")
211
+ if preparation is None:
212
+ return []
213
+ if not isinstance(preparation, Mapping):
214
+ raise DesignPrepError("designPreparation must be an object")
215
+ items = preparation.get("items", [])
216
+ if not isinstance(items, list):
217
+ raise DesignPrepError("designPreparation.items must be an array")
218
+ item_schema = _design_prep_item_schema()
219
+ for item in items:
220
+ if not isinstance(item, Mapping):
221
+ raise DesignPrepError("design preparation item must be an object")
222
+ _validate_design_prep_item(item, item_schema)
223
+ return items
224
+
225
+
226
+ def _report_language(data: Mapping[str, Any]) -> str:
227
+ meta = data.get("meta") or {}
228
+ if not isinstance(meta, Mapping):
229
+ raise DesignPrepError("planning data meta must be an object")
230
+ return str(meta.get("reportLanguage") or "en")
231
+
232
+
233
+ def _pretty(value: Any) -> str:
234
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
235
+
236
+
237
+ def _render_request_body_without_fingerprints(
238
+ *,
239
+ data_path: Path,
240
+ item: Mapping[str, Any],
241
+ ) -> str:
242
+ source = f"reports/{data_path.name}"
243
+ proposal = item.get("aiProposal") or {}
244
+ default_when_unanswered = (
245
+ item["workingAssumption"]
246
+ if item["status"] == "provisional"
247
+ else item["blockReason"]
248
+ )
249
+ return (
250
+ "---\n"
251
+ "schema-version: 1\n"
252
+ f"source-report: {source}\n"
253
+ f"item-id: {item['id']}\n"
254
+ "---\n\n"
255
+ f"# Design Preparation Request — {item['id']}\n\n"
256
+ f"## 1. Why this is needed\n\n{_pretty(item['need'])}\n\n"
257
+ "## 2. Known constraints and evidence\n\n"
258
+ f"{_pretty(item.get('knownFacts') or [])}\n\n"
259
+ f"## 3. AI-prepared proposal\n\n{_pretty(proposal)}\n\n"
260
+ "## 4. Assumptions and confidence\n\n"
261
+ f"{_pretty(proposal.get('assumptions') or [])}\n\n"
262
+ "## 5. Open facts or decisions\n\n"
263
+ f"{_pretty(item.get('openQuestions') or [])}\n\n"
264
+ "## 6. Editable fields and replan triggers\n\n"
265
+ f"{_pretty(item.get('replanTriggerFields') or [])}\n\n"
266
+ "## 7. Default when unanswered\n\n"
267
+ f"{_pretty(default_when_unanswered)}\n\n"
268
+ "## 8. Guardrails and review point\n\n"
269
+ f"{_pretty({'guardrails': item.get('guardrails') or [], 'reviewAt': item.get('reviewAt')})}\n\n"
270
+ "## 9. Suggested evidence\n\n"
271
+ f"{_pretty(proposal.get('evidence') or [])}\n\n"
272
+ "## 10. Response examples\n\n"
273
+ "- Accept: `okstra design-prep write --decision accept-draft --confirmed`\n"
274
+ "- Modify: pass a JSON object with `--overrides`.\n"
275
+ )
276
+
277
+
278
+ def _request_content_fingerprint(
279
+ assessment: str,
280
+ report_language: str,
281
+ body_without_fingerprints: str,
282
+ ) -> str:
283
+ return _sha256_json(
284
+ {
285
+ "requestTemplateVersion": REQUEST_TEMPLATE_VERSION,
286
+ "assessmentFingerprint": assessment,
287
+ "reportLanguage": report_language,
288
+ "renderedRequestBodyWithoutFingerprintFields": body_without_fingerprints,
289
+ }
290
+ )
291
+
292
+
293
+ def _complete_request(
294
+ body_without_fingerprints: str,
295
+ assessment: str,
296
+ request_content: str,
297
+ ) -> str:
298
+ lines = body_without_fingerprints.splitlines()
299
+ insert_at = next(
300
+ index for index, line in enumerate(lines) if line.startswith("item-id:")
301
+ ) + 1
302
+ lines[insert_at:insert_at] = [
303
+ f"assessment-fingerprint: {assessment}",
304
+ f"request-content-fingerprint: {request_content}",
305
+ ]
306
+ return "\n".join(lines) + "\n"
307
+
308
+
309
+ def _request_target(
310
+ *,
311
+ data_path: Path,
312
+ planning_seq: str,
313
+ item: Mapping[str, Any],
314
+ ) -> Path:
315
+ item_id = item.get("id")
316
+ if not isinstance(item_id, str) or not item_id:
317
+ raise DesignPrepError("design preparation item id must be a non-empty string")
318
+ expected_relative = (
319
+ "design-prep-requests/"
320
+ f"design-prep-request-{planning_seq}-{item_id}.md"
321
+ )
322
+ if item.get("requestPath") != expected_relative:
323
+ raise DesignPrepError(
324
+ f"item {item_id} must use canonical requestPath {expected_relative}"
325
+ )
326
+ request_dir = data_path.parent.parent / "design-prep-requests"
327
+ target = (data_path.parent.parent / expected_relative).resolve()
328
+ if target.parent != request_dir:
329
+ raise DesignPrepError(f"requestPath escapes design-prep-requests: {target}")
330
+ return target
331
+
332
+
333
+ def _render_request(
334
+ *,
335
+ data_path: Path,
336
+ planning_seq: str,
337
+ report_language: str,
338
+ item: Mapping[str, Any],
339
+ ) -> tuple[Path, bytes]:
340
+ target = _request_target(
341
+ data_path=data_path,
342
+ planning_seq=planning_seq,
343
+ item=item,
344
+ )
345
+ assessment = assessment_fingerprint(item)
346
+ body = _render_request_body_without_fingerprints(
347
+ data_path=data_path,
348
+ item=item,
349
+ )
350
+ request_content = _request_content_fingerprint(
351
+ assessment,
352
+ report_language,
353
+ body,
354
+ )
355
+ return target, _complete_request(body, assessment, request_content).encode("utf-8")
356
+
357
+
358
+ def _require_matching_request(target: Path, expected: bytes, conflict: str) -> None:
359
+ try:
360
+ existing = target.read_bytes()
361
+ except OSError as exc:
362
+ raise DesignPrepError(f"cannot read existing request {target}: {exc}") from exc
363
+ if existing != expected:
364
+ raise DesignPrepError(f"refuses to overwrite {conflict}: {target}")
365
+
366
+
367
+ def _write_request_no_clobber(target: Path, encoded: bytes) -> None:
368
+ target.parent.mkdir(parents=True, exist_ok=True)
369
+ if target.exists():
370
+ _require_matching_request(target, encoded, "different request")
371
+ return
372
+ temp = target.with_name(f".{target.name}.tmp-{os.getpid()}-{uuid.uuid4()}")
373
+ try:
374
+ with temp.open("x", encoding="utf-8") as handle:
375
+ handle.write(encoded.decode("utf-8"))
376
+ handle.flush()
377
+ os.fsync(handle.fileno())
378
+ try:
379
+ os.link(temp, target)
380
+ except FileExistsError:
381
+ _require_matching_request(target, encoded, "raced request")
382
+ except DesignPrepError:
383
+ raise
384
+ except OSError as exc:
385
+ raise DesignPrepError(f"cannot materialize request {target}: {exc}") from exc
386
+ finally:
387
+ temp.unlink(missing_ok=True)
388
+
389
+
390
+ def materialize_design_prep_requests(plan_data_path: Path) -> list[Path]:
391
+ """Create deterministic request sidecars without overwriting other content."""
392
+ data_path = Path(plan_data_path).resolve()
393
+ data = _load_plan_data(data_path)
394
+ planning_seq = _planning_seq(data_path)
395
+ report_language = _report_language(data)
396
+ items = _design_preparation_items(data)
397
+ written: list[Path] = []
398
+ for item in items:
399
+ if item.get("status") not in _REQUEST_STATUSES:
400
+ continue
401
+ target, encoded = _render_request(
402
+ data_path=data_path,
403
+ planning_seq=planning_seq,
404
+ report_language=report_language,
405
+ item=item,
406
+ )
407
+ _write_request_no_clobber(target, encoded)
408
+ written.append(target)
409
+ return written
410
+
411
+
412
+ def load_design_prep_items(report_path: Path) -> tuple[dict[str, Any], ...]:
413
+ """Return validated snapshot items without exposing mutable report data."""
414
+ data_path = _canonical_data_path(report_path)
415
+ data = _load_plan_data(data_path)
416
+ return tuple(deepcopy(dict(item)) for item in _design_preparation_items(data))
417
+
418
+
419
+ def _canonical_data_path(report_path: Path) -> Path:
420
+ path = Path(report_path)
421
+ if _PLANNING_DATA_RE.match(path.name):
422
+ candidate = path
423
+ elif re.match(r"^final-report-implementation-planning-\d+\.md$", path.name):
424
+ candidate = path.with_name(path.name.removesuffix(".md") + ".data.json")
425
+ else:
426
+ raise DesignPrepError(f"not an implementation-planning report path: {path}")
427
+ try:
428
+ resolved = candidate.resolve()
429
+ except (OSError, RuntimeError) as exc:
430
+ raise DesignPrepError(f"cannot resolve planning data path {candidate}: {exc}") from exc
431
+ _planning_seq(resolved)
432
+ return resolved
433
+
434
+
435
+ def _legacy_decision() -> DesignPrepDecision:
436
+ return DesignPrepDecision(
437
+ outcome="proceed",
438
+ effective_items=(),
439
+ assumptions_to_inject=(),
440
+ warnings=("legacy-unassessed",),
441
+ request_paths=(),
442
+ reason="legacy plan has no design preparation assessment",
443
+ )
444
+
445
+
446
+ def _require_json_object_keys(value: Any) -> None:
447
+ if isinstance(value, Mapping):
448
+ for key, nested in value.items():
449
+ if not isinstance(key, str):
450
+ raise TypeError("JSON object keys must be strings")
451
+ _require_json_object_keys(nested)
452
+ elif isinstance(value, list):
453
+ for nested in value:
454
+ _require_json_object_keys(nested)
455
+
456
+
457
+ def _require_input_contract(
458
+ decision: Any,
459
+ overrides: Any,
460
+ notes: Any,
461
+ captured_by: Any,
462
+ ) -> None:
463
+ if not isinstance(decision, str) or decision not in _INPUT_DECISIONS:
464
+ raise DesignPrepError(f"unsupported design preparation decision: {decision}")
465
+ if not isinstance(overrides, Mapping):
466
+ raise DesignPrepError("design preparation overrides must be an object")
467
+ try:
468
+ _require_json_object_keys(overrides)
469
+ encoded_overrides = json.dumps(
470
+ _normalise_json(dict(overrides)),
471
+ ensure_ascii=False,
472
+ sort_keys=True,
473
+ allow_nan=False,
474
+ )
475
+ encoded_overrides.encode("utf-8")
476
+ except (TypeError, ValueError, RecursionError) as exc:
477
+ raise DesignPrepError(
478
+ "design preparation overrides must be a JSON object"
479
+ ) from exc
480
+ if decision != "modify-draft" and overrides:
481
+ raise DesignPrepError("only modify-draft may include overrides")
482
+ if not isinstance(notes, str):
483
+ raise DesignPrepError("design preparation notes must be a string")
484
+ try:
485
+ notes.encode("utf-8")
486
+ except UnicodeError as exc:
487
+ raise DesignPrepError("design preparation notes must be valid UTF-8") from exc
488
+ if decision == "reject-draft" and not notes.strip():
489
+ raise DesignPrepError("reject-draft notes must be non-empty")
490
+ if not isinstance(captured_by, str) or not _CAPTURED_BY_RE.fullmatch(captured_by):
491
+ raise DesignPrepError("captured_by must be a non-empty safe identifier")
492
+
493
+
494
+ def _find_design_prep_item(
495
+ data: Mapping[str, Any],
496
+ item_id: str,
497
+ ) -> Mapping[str, Any]:
498
+ if not isinstance(item_id, str) or not re.fullmatch(r"PREP-\d{3}", item_id):
499
+ raise DesignPrepError(f"invalid design preparation item id: {item_id}")
500
+ matches = [item for item in _design_preparation_items(data) if item.get("id") == item_id]
501
+ if len(matches) != 1:
502
+ raise DesignPrepError(f"expected one design preparation item {item_id}")
503
+ return matches[0]
504
+
505
+
506
+ def _plan_input_dir(data_path: Path) -> Path:
507
+ if data_path.parent.name != "reports":
508
+ raise DesignPrepError(f"planning data must be under a reports directory: {data_path}")
509
+ input_dir = data_path.parent.parent / "design-prep-inputs"
510
+ try:
511
+ plan_run_root = data_path.parent.parent.resolve()
512
+ resolved_input_dir = input_dir.resolve()
513
+ except (OSError, RuntimeError) as exc:
514
+ raise DesignPrepError(f"cannot resolve plan run input path: {exc}") from exc
515
+ if resolved_input_dir.parent != plan_run_root:
516
+ raise DesignPrepError(f"design preparation input path escapes plan run root: {input_dir}")
517
+ return input_dir
518
+
519
+
520
+ def _open_input_dir_fd(input_dir: Path) -> int:
521
+ return os.open(
522
+ input_dir,
523
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
524
+ )
525
+
526
+
527
+ def _require_input_dir_identity(input_dir: Path, input_dir_fd: int) -> None:
528
+ opened = os.fstat(input_dir_fd)
529
+ try:
530
+ current = os.stat(input_dir, follow_symlinks=False)
531
+ except OSError as exc:
532
+ raise DesignPrepError(
533
+ f"cannot verify design preparation input directory {input_dir}: {exc}"
534
+ ) from exc
535
+ if (
536
+ not stat.S_ISDIR(current.st_mode)
537
+ or (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino)
538
+ ):
539
+ raise DesignPrepError(
540
+ f"design preparation input directory changed during operation: {input_dir}"
541
+ )
542
+
543
+
544
+ @contextmanager
545
+ def _input_dir_flock(input_dir_fd: int) -> Iterator[None]:
546
+ flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
547
+ try:
548
+ lock_fd = os.open(
549
+ ".design-prep-input.lock",
550
+ flags,
551
+ 0o600,
552
+ dir_fd=input_dir_fd,
553
+ )
554
+ except FileNotFoundError:
555
+ # APFS can report ENOENT to one opener racing the initial lock creation.
556
+ lock_fd = os.open(
557
+ ".design-prep-input.lock",
558
+ flags,
559
+ 0o600,
560
+ dir_fd=input_dir_fd,
561
+ )
562
+ try:
563
+ fcntl.flock(lock_fd, fcntl.LOCK_EX)
564
+ try:
565
+ yield
566
+ finally:
567
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
568
+ finally:
569
+ os.close(lock_fd)
570
+
571
+
572
+ def _serialize_input(
573
+ *,
574
+ data_path: Path,
575
+ item: Mapping[str, Any],
576
+ decision: str,
577
+ overrides: dict[str, Any],
578
+ notes: str,
579
+ revision: int,
580
+ input_id: str,
581
+ created_at: str,
582
+ captured_by: str,
583
+ ) -> str:
584
+ encoded_overrides = json.dumps(
585
+ _normalise_json(overrides),
586
+ ensure_ascii=False,
587
+ sort_keys=True,
588
+ separators=(",", ":"),
589
+ )
590
+ return (
591
+ "---\n"
592
+ "schema-version: 1\n"
593
+ f"source-report: reports/{data_path.name}\n"
594
+ f"assessment-fingerprint: {assessment_fingerprint(item)}\n"
595
+ f"item-id: {item['id']}\n"
596
+ f"input-id: {input_id}\n"
597
+ f"revision: {revision}\n"
598
+ f"created-at: {created_at}\n"
599
+ "created-by: user\n"
600
+ f"captured-by: {captured_by}\n"
601
+ "---\n\n"
602
+ "# Design Preparation Input\n\n"
603
+ f"## Decision\n\n{decision}\n\n"
604
+ "## Overrides\n\n"
605
+ f"```json\n{encoded_overrides}\n```\n\n"
606
+ f"## Notes\n\n{notes.rstrip()}\n"
607
+ )
608
+
609
+
610
+ def _parse_flat_frontmatter(text: str, path: Path) -> tuple[dict[str, str], str]:
611
+ if not text.startswith("---\n"):
612
+ raise DesignPrepError(f"malformed input {path}: missing frontmatter")
613
+ closing = text.find("\n---\n", 4)
614
+ if closing < 0:
615
+ raise DesignPrepError(f"malformed input {path}: unterminated frontmatter")
616
+ fields: dict[str, str] = {}
617
+ ordered_fields: list[str] = []
618
+ for line in text[4:closing].splitlines():
619
+ if ": " not in line:
620
+ raise DesignPrepError(f"malformed input {path}: invalid frontmatter line")
621
+ key, value = line.split(": ", 1)
622
+ if key in fields or not value:
623
+ raise DesignPrepError(f"malformed input {path}: invalid frontmatter field {key}")
624
+ fields[key] = value
625
+ ordered_fields.append(key)
626
+ if tuple(ordered_fields) != _INPUT_FRONTMATTER_FIELDS:
627
+ raise DesignPrepError(f"malformed input {path}: unexpected frontmatter fields")
628
+ body = text[closing + 5 :]
629
+ if not body.startswith("\n"):
630
+ raise DesignPrepError(f"malformed input {path}: missing body separator")
631
+ return fields, body[1:]
632
+
633
+
634
+ def _require_uuid4(value: str, field: str, path: Path) -> None:
635
+ try:
636
+ parsed = uuid.UUID(value)
637
+ except (ValueError, AttributeError) as exc:
638
+ raise DesignPrepError(f"malformed input {path}: invalid {field}") from exc
639
+ if str(parsed) != value or parsed.version != 4:
640
+ raise DesignPrepError(f"malformed input {path}: invalid {field}")
641
+
642
+
643
+ def _parse_input_body(body: str, path: Path) -> tuple[str, dict[str, Any], str]:
644
+ match = _INPUT_BODY_RE.fullmatch(body)
645
+ if match is None:
646
+ raise DesignPrepError(f"malformed input {path}: invalid markdown body")
647
+ decision = match.group("decision")
648
+ if decision not in _INPUT_DECISIONS:
649
+ raise DesignPrepError(f"malformed input {path}: invalid decision")
650
+ try:
651
+ overrides = json.loads(match.group("overrides"))
652
+ except json.JSONDecodeError as exc:
653
+ raise DesignPrepError(f"malformed input {path}: invalid overrides JSON") from exc
654
+ notes = match.group("notes")
655
+ try:
656
+ _require_input_contract(decision, overrides, notes, "parsed-sidecar")
657
+ except DesignPrepError as exc:
658
+ raise DesignPrepError(f"malformed input {path}: {exc}") from exc
659
+ return decision, dict(overrides), notes
660
+
661
+
662
+ def _require_regular_input(metadata: os.stat_result, path: Path) -> None:
663
+ if not stat.S_ISREG(metadata.st_mode):
664
+ raise _NonRegularInputError(f"malformed input {path}: not a regular file")
665
+
666
+
667
+ def _parse_input_path(
668
+ path: Path,
669
+ input_dir_fd: int,
670
+ planning_seq: str,
671
+ item_id: str,
672
+ ) -> ParsedDesignPrepInput:
673
+ filename = _INPUT_FILENAME_RE.fullmatch(path.name)
674
+ if filename is None:
675
+ raise DesignPrepError(f"malformed input {path}: invalid filename")
676
+ try:
677
+ filename_revision = int(filename.group("revision"))
678
+ except ValueError as exc:
679
+ raise DesignPrepError(f"malformed input {path}: invalid filename revision") from exc
680
+ if filename_revision <= 0:
681
+ raise DesignPrepError(f"malformed input {path}: filename revision must be positive")
682
+ filename_input_id = filename.group("input_id")
683
+ if filename.group("seq") != planning_seq or filename.group("item") != item_id:
684
+ raise DesignPrepError(f"malformed input {path}: filename identity mismatch")
685
+ _require_uuid4(filename_input_id, "filename UUID", path)
686
+ try:
687
+ _require_regular_input(
688
+ os.stat(path.name, dir_fd=input_dir_fd, follow_symlinks=False),
689
+ path,
690
+ )
691
+ candidate_fd = os.open(
692
+ path.name,
693
+ os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
694
+ dir_fd=input_dir_fd,
695
+ )
696
+ try:
697
+ _require_regular_input(os.fstat(candidate_fd), path)
698
+ with os.fdopen(
699
+ candidate_fd,
700
+ "r",
701
+ encoding="utf-8",
702
+ closefd=False,
703
+ ) as handle:
704
+ text = handle.read()
705
+ finally:
706
+ os.close(candidate_fd)
707
+ except (OSError, UnicodeError) as exc:
708
+ raise DesignPrepError(f"malformed input {path}: cannot read: {exc}") from exc
709
+ fields, body = _parse_flat_frontmatter(text, path)
710
+ expected_source = (
711
+ "reports/"
712
+ f"final-report-implementation-planning-{planning_seq}.data.json"
713
+ )
714
+ if fields["schema-version"] != "1" or fields["source-report"] != expected_source:
715
+ raise DesignPrepError(f"malformed input {path}: source contract mismatch")
716
+ if fields["item-id"] != item_id or fields["input-id"] != filename_input_id:
717
+ raise DesignPrepError(f"malformed input {path}: frontmatter identity mismatch")
718
+ try:
719
+ revision = int(fields["revision"])
720
+ except ValueError as exc:
721
+ raise DesignPrepError(f"malformed input {path}: invalid revision") from exc
722
+ if revision <= 0:
723
+ raise DesignPrepError(f"malformed input {path}: revision must be positive")
724
+ if revision != filename_revision or filename.group("revision") != f"{revision:03d}":
725
+ raise DesignPrepError(f"malformed input {path}: revision mismatch")
726
+ _require_uuid4(fields["input-id"], "input-id", path)
727
+ if not _ASSESSMENT_FINGERPRINT_RE.fullmatch(fields["assessment-fingerprint"]):
728
+ raise DesignPrepError(f"malformed input {path}: invalid assessment fingerprint")
729
+ if fields["created-by"] != "user" or not _CAPTURED_BY_RE.fullmatch(fields["captured-by"]):
730
+ raise DesignPrepError(f"malformed input {path}: invalid input provenance")
731
+ try:
732
+ datetime.strptime(fields["created-at"], "%Y-%m-%dT%H:%M:%SZ")
733
+ except ValueError as exc:
734
+ raise DesignPrepError(f"malformed input {path}: invalid created-at") from exc
735
+ decision, overrides, notes = _parse_input_body(body, path)
736
+ return ParsedDesignPrepInput(
737
+ path=path,
738
+ item_id=item_id,
739
+ assessment_fingerprint=fields["assessment-fingerprint"],
740
+ revision=revision,
741
+ input_id=fields["input-id"],
742
+ decision=decision,
743
+ overrides=overrides,
744
+ notes=notes,
745
+ )
746
+
747
+
748
+ def _revision_from_candidate_name(path: Path) -> int | None:
749
+ match = re.search(r"-r(\d+)-", path.name)
750
+ try:
751
+ return int(match.group(1)) if match else None
752
+ except ValueError:
753
+ return None
754
+
755
+
756
+ def _parse_inputs_for_item(
757
+ input_dir: Path,
758
+ input_dir_fd: int,
759
+ planning_seq: str,
760
+ item_id: str,
761
+ *,
762
+ warnings: list[str] | None = None,
763
+ invalid_revisions: set[int] | None = None,
764
+ ) -> list[ParsedDesignPrepInput]:
765
+ try:
766
+ pattern = f"design-prep-input-{planning_seq}-{item_id}-r*-*.md"
767
+ candidates = sorted(
768
+ input_dir / name
769
+ for name in os.listdir(input_dir_fd)
770
+ if fnmatchcase(name, pattern)
771
+ )
772
+ except OSError as exc:
773
+ error = DesignPrepError(
774
+ f"cannot scan design preparation inputs {input_dir}: {exc}"
775
+ )
776
+ if warnings is None:
777
+ raise error from exc
778
+ warnings.append(str(error))
779
+ return []
780
+ parsed: list[ParsedDesignPrepInput] = []
781
+ for path in candidates:
782
+ try:
783
+ parsed.append(
784
+ _parse_input_path(path, input_dir_fd, planning_seq, item_id)
785
+ )
786
+ except DesignPrepError as exc:
787
+ if warnings is None and not isinstance(exc, _NonRegularInputError):
788
+ raise
789
+ if warnings is not None:
790
+ warnings.append(str(exc))
791
+ revision = _revision_from_candidate_name(path)
792
+ if revision is not None and invalid_revisions is not None:
793
+ invalid_revisions.add(revision)
794
+ return parsed
795
+
796
+
797
+ def _stat_identity(metadata: os.stat_result) -> tuple[int, int]:
798
+ return metadata.st_dev, metadata.st_ino
799
+
800
+
801
+ def _unlink_input_name(
802
+ input_dir_fd: int,
803
+ name: str,
804
+ expected_identity: tuple[int, int],
805
+ ) -> None:
806
+ try:
807
+ current = os.stat(name, dir_fd=input_dir_fd, follow_symlinks=False)
808
+ except FileNotFoundError:
809
+ return
810
+ if _stat_identity(current) == expected_identity:
811
+ os.unlink(name, dir_fd=input_dir_fd)
812
+
813
+
814
+ def _write_input_temp(
815
+ input_dir_fd: int,
816
+ temp_name: str,
817
+ body: str,
818
+ ) -> tuple[int, int]:
819
+ temp_fd = os.open(
820
+ temp_name,
821
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
822
+ 0o600,
823
+ dir_fd=input_dir_fd,
824
+ )
825
+ try:
826
+ identity = _stat_identity(os.fstat(temp_fd))
827
+ try:
828
+ with os.fdopen(temp_fd, "w", encoding="utf-8", closefd=False) as handle:
829
+ handle.write(body)
830
+ handle.flush()
831
+ os.fsync(temp_fd)
832
+ except BaseException:
833
+ _unlink_input_name(input_dir_fd, temp_name, identity)
834
+ raise
835
+ return identity
836
+ finally:
837
+ os.close(temp_fd)
838
+
839
+
840
+ def _publish_input(
841
+ input_dir_fd: int,
842
+ temp_name: str,
843
+ target_name: str,
844
+ body: str,
845
+ ) -> tuple[int, int]:
846
+ temp_identity = _write_input_temp(input_dir_fd, temp_name, body)
847
+ try:
848
+ os.link(
849
+ temp_name,
850
+ target_name,
851
+ src_dir_fd=input_dir_fd,
852
+ dst_dir_fd=input_dir_fd,
853
+ follow_symlinks=False,
854
+ )
855
+ return temp_identity
856
+ finally:
857
+ _unlink_input_name(input_dir_fd, temp_name, temp_identity)
858
+
859
+
860
+ def write_design_prep_input(
861
+ report_path: Path,
862
+ item_id: str,
863
+ decision: str,
864
+ overrides: Mapping[str, Any],
865
+ notes: str,
866
+ *,
867
+ captured_by: str,
868
+ ) -> Path:
869
+ """Append a uniquely-revisioned user input sidecar for one assessment item."""
870
+ _require_input_contract(decision, overrides, notes, captured_by)
871
+ data_path = _canonical_data_path(report_path)
872
+ data = _load_plan_data(data_path)
873
+ item = _find_design_prep_item(data, item_id)
874
+ planning_seq = _planning_seq(data_path)
875
+ input_dir = _plan_input_dir(data_path)
876
+ input_dir_fd: int | None = None
877
+ try:
878
+ input_dir.mkdir(parents=True, exist_ok=True)
879
+ input_dir_fd = _open_input_dir_fd(input_dir)
880
+ with _input_dir_flock(input_dir_fd):
881
+ invalid_revisions: set[int] = set()
882
+ existing = _parse_inputs_for_item(
883
+ input_dir,
884
+ input_dir_fd,
885
+ planning_seq,
886
+ item_id,
887
+ invalid_revisions=invalid_revisions,
888
+ )
889
+ revisions = {row.revision for row in existing}.union(invalid_revisions)
890
+ revision = max(revisions, default=0) + 1
891
+ input_id = str(uuid.uuid4())
892
+ created_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
893
+ body = _serialize_input(
894
+ data_path=data_path,
895
+ item=item,
896
+ decision=decision,
897
+ overrides=dict(overrides),
898
+ notes=notes,
899
+ revision=revision,
900
+ input_id=input_id,
901
+ created_at=created_at,
902
+ captured_by=captured_by,
903
+ )
904
+ target_name = (
905
+ f"design-prep-input-{planning_seq}-{item_id}-"
906
+ f"r{revision:03d}-{input_id}.md"
907
+ )
908
+ temp_name = f".{target_name}.tmp-{os.getpid()}"
909
+ target_identity = _publish_input(
910
+ input_dir_fd,
911
+ temp_name,
912
+ target_name,
913
+ body,
914
+ )
915
+ try:
916
+ _require_input_dir_identity(input_dir, input_dir_fd)
917
+ except DesignPrepError:
918
+ _unlink_input_name(input_dir_fd, target_name, target_identity)
919
+ raise
920
+ return input_dir / target_name
921
+ except DesignPrepError:
922
+ raise
923
+ except OSError as exc:
924
+ raise DesignPrepError(
925
+ f"cannot write design preparation input for {item_id}: {exc}"
926
+ ) from exc
927
+ finally:
928
+ if input_dir_fd is not None:
929
+ os.close(input_dir_fd)
930
+
931
+
932
+ def _override_paths(value: Mapping[str, Any], prefix: str = "") -> set[str]:
933
+ paths = set()
934
+ for key, nested in value.items():
935
+ path = f"{prefix}.{key}" if prefix else str(key)
936
+ paths.add(path)
937
+ if isinstance(nested, Mapping):
938
+ paths.update(_override_paths(nested, path))
939
+ return paths
940
+
941
+
942
+ def _effective_outcome(
943
+ item: Mapping[str, Any],
944
+ selected: ParsedDesignPrepInput | None,
945
+ ) -> str:
946
+ status = str(item.get("status") or "")
947
+ if status in {"ready", "not-applicable"}:
948
+ return "proceed"
949
+ if selected is None or selected.decision == "defer":
950
+ return "proceed" if status == "provisional" else "wait_for_input"
951
+ if selected.decision == "reject-draft":
952
+ return "wait_for_input"
953
+ if status == "blocked":
954
+ return "replan"
955
+ trigger_fields = set(item.get("replanTriggerFields") or [])
956
+ if selected.decision == "modify-draft" and trigger_fields.intersection(
957
+ _override_paths(selected.overrides)
958
+ ):
959
+ return "replan"
960
+ return "proceed"
961
+
962
+
963
+ def _select_latest_input(
964
+ rows: list[ParsedDesignPrepInput],
965
+ expected_fingerprint: str,
966
+ item_id: str,
967
+ warnings: list[str],
968
+ invalid_revisions: set[int],
969
+ ) -> ParsedDesignPrepInput | None:
970
+ by_revision: dict[int, list[ParsedDesignPrepInput]] = {}
971
+ for row in rows:
972
+ by_revision.setdefault(row.revision, []).append(row)
973
+ revisions = set(by_revision).union(invalid_revisions)
974
+ for revision in sorted(revisions, reverse=True):
975
+ candidates = by_revision.get(revision, [])
976
+ if revision in invalid_revisions:
977
+ continue
978
+ if len(candidates) != 1:
979
+ warnings.append(f"duplicate revision {revision} for {item_id}; skipped")
980
+ continue
981
+ selected = candidates[0]
982
+ if selected.assessment_fingerprint != expected_fingerprint:
983
+ warnings.append(f"stale revision {revision} for {item_id}; skipped")
984
+ continue
985
+ return selected
986
+ return None
987
+
988
+
989
+ def _recursive_merge(
990
+ proposal: Mapping[str, Any],
991
+ overrides: Mapping[str, Any],
992
+ ) -> dict[str, Any]:
993
+ merged = deepcopy(dict(proposal))
994
+ for key, override in overrides.items():
995
+ current = merged.get(key)
996
+ if isinstance(current, Mapping) and isinstance(override, Mapping):
997
+ merged[key] = _recursive_merge(current, override)
998
+ else:
999
+ merged[key] = deepcopy(override)
1000
+ return merged
1001
+
1002
+
1003
+ def _parse_depends_on(value: Any) -> set[int]:
1004
+ if value in (None, "", "(none)"):
1005
+ return set()
1006
+ if not isinstance(value, str):
1007
+ raise ValueError("dependsOn must be a string")
1008
+ parts = [part.strip() for part in value.split(",")]
1009
+ if any(not part.isdigit() for part in parts):
1010
+ raise ValueError("dependsOn must contain stage numbers")
1011
+ return {int(part) for part in parts}
1012
+
1013
+
1014
+ def _dependency_stages(
1015
+ planning: Mapping[str, Any],
1016
+ stage_number: int | None,
1017
+ warnings: list[str],
1018
+ ) -> set[int]:
1019
+ if stage_number is None:
1020
+ return set()
1021
+ stages = planning.get("stages") or []
1022
+ if not isinstance(stages, list):
1023
+ warnings.append("malformed implementationPlanning.stages; carry ignored")
1024
+ return set()
1025
+ graph: dict[int, set[int]] = {}
1026
+ for row in stages:
1027
+ try:
1028
+ if not isinstance(row, Mapping) or not isinstance(row.get("stage"), int):
1029
+ raise ValueError("stage row must contain an integer stage")
1030
+ graph[row["stage"]] = _parse_depends_on(row.get("dependsOn"))
1031
+ except ValueError as exc:
1032
+ warnings.append(f"malformed stage dependency; carry ignored: {exc}")
1033
+ dependencies: set[int] = set()
1034
+ pending = list(graph.get(stage_number, set()))
1035
+ while pending:
1036
+ dependency = pending.pop()
1037
+ if dependency in dependencies:
1038
+ continue
1039
+ dependencies.add(dependency)
1040
+ pending.extend(graph.get(dependency, set()) - dependencies)
1041
+ return dependencies
1042
+
1043
+
1044
+ def _read_carry(path: Path, stage: int, warnings: list[str]) -> Mapping[str, Any] | None:
1045
+ if not path.is_file():
1046
+ return None
1047
+ try:
1048
+ payload = json.loads(path.read_text(encoding="utf-8"))
1049
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
1050
+ warnings.append(f"malformed carry stage {stage}; ignored: {exc}")
1051
+ return None
1052
+ if not isinstance(payload, Mapping):
1053
+ warnings.append(f"malformed carry stage {stage}; ignored")
1054
+ return None
1055
+ if payload.get("stageNumber") != stage:
1056
+ warnings.append(f"stale carry stage {stage}; ignored")
1057
+ return None
1058
+ if payload.get("status") != "completed":
1059
+ return None
1060
+ return payload
1061
+
1062
+
1063
+ def _carry_evidence_for_item(
1064
+ *,
1065
+ data_path: Path,
1066
+ dependency_stages: set[int],
1067
+ item_id: str,
1068
+ fingerprint: str,
1069
+ warnings: list[str],
1070
+ ) -> tuple[dict[str, Any], ...]:
1071
+ carry_dir = data_path.parent.parent.parent / "implementation/carry"
1072
+ attached: list[dict[str, Any]] = []
1073
+ seen: set[str] = set()
1074
+ for stage in sorted(dependency_stages):
1075
+ carry = _read_carry(carry_dir / f"stage-{stage}.json", stage, warnings)
1076
+ if carry is None:
1077
+ continue
1078
+ rows = carry.get("designPrepEvidence") or []
1079
+ if not isinstance(rows, list):
1080
+ warnings.append(f"malformed carry evidence stage {stage}; ignored")
1081
+ continue
1082
+ for row in rows:
1083
+ if not isinstance(row, Mapping):
1084
+ warnings.append(f"malformed carry evidence stage {stage}; ignored")
1085
+ continue
1086
+ if row.get("itemId") != item_id:
1087
+ continue
1088
+ if row.get("assessmentFingerprint") != fingerprint:
1089
+ warnings.append(f"stale carry evidence stage {stage} for {item_id}; ignored")
1090
+ continue
1091
+ resolution = row.get("resolution")
1092
+ evidence_values = row.get("evidence")
1093
+ if (
1094
+ not isinstance(resolution, str)
1095
+ or not resolution.strip()
1096
+ or not isinstance(evidence_values, list)
1097
+ or not evidence_values
1098
+ or any(
1099
+ not isinstance(value, str) or not value.strip()
1100
+ for value in evidence_values
1101
+ )
1102
+ ):
1103
+ warnings.append(
1104
+ f"malformed carry evidence stage {stage} for {item_id}; ignored"
1105
+ )
1106
+ continue
1107
+ evidence = deepcopy(dict(row))
1108
+ identity = json.dumps(evidence, ensure_ascii=False, sort_keys=True)
1109
+ if identity in seen:
1110
+ warnings.append(f"duplicate carry evidence stage {stage} for {item_id}; ignored")
1111
+ continue
1112
+ seen.add(identity)
1113
+ evidence["sourceStage"] = stage
1114
+ attached.append(evidence)
1115
+ return tuple(attached)
1116
+
1117
+
1118
+ def _effective_item(
1119
+ item: Mapping[str, Any],
1120
+ selected: ParsedDesignPrepInput | None,
1121
+ outcome: str,
1122
+ carry: tuple[dict[str, Any], ...],
1123
+ ) -> dict[str, Any]:
1124
+ overrides = (
1125
+ selected.overrides
1126
+ if selected is not None and selected.decision == "modify-draft"
1127
+ else {}
1128
+ )
1129
+ proposal = deepcopy(dict(item.get("aiProposal") or {}))
1130
+ if selected is not None and selected.decision == "modify-draft" and outcome == "proceed":
1131
+ proposal = _recursive_merge(proposal, overrides)
1132
+ return {
1133
+ "id": item["id"],
1134
+ "kind": item["kind"],
1135
+ "assessmentFingerprint": assessment_fingerprint(item),
1136
+ "decision": selected.decision if selected is not None else "unanswered",
1137
+ "effectiveProposal": proposal,
1138
+ "overrides": deepcopy(overrides),
1139
+ "guardrails": tuple(deepcopy(item.get("guardrails") or [])),
1140
+ "reviewAt": deepcopy(item.get("reviewAt")),
1141
+ "ifStillOpen": item.get("ifStillOpen"),
1142
+ "sourceInput": str(selected.path) if selected is not None else None,
1143
+ "dependencyCarryEvidence": carry,
1144
+ }
1145
+
1146
+
1147
+ def _provisional_assumptions(
1148
+ item: Mapping[str, Any],
1149
+ effective: Mapping[str, Any],
1150
+ outcome: str,
1151
+ ) -> list[str]:
1152
+ if item.get("status") != "provisional" or outcome != "proceed":
1153
+ return []
1154
+ assumptions = [str(item["workingAssumption"])]
1155
+ if effective["overrides"]:
1156
+ assumptions.append(
1157
+ f"{item['id']} user overrides: "
1158
+ + json.dumps(effective["overrides"], ensure_ascii=False, sort_keys=True)
1159
+ )
1160
+ return assumptions
1161
+
1162
+
1163
+ def _aggregate_outcome(outcomes: list[str]) -> str:
1164
+ if "wait_for_input" in outcomes:
1165
+ return "wait_for_input"
1166
+ if "replan" in outcomes:
1167
+ return "replan"
1168
+ return "proceed"
1169
+
1170
+
1171
+ def _decision_reason(outcome: str) -> str:
1172
+ if outcome == "wait_for_input":
1173
+ return "design preparation requires user input"
1174
+ if outcome == "replan":
1175
+ return "design preparation changes require replanning"
1176
+ return "design preparation is effective"
1177
+
1178
+
1179
+ def _resolve_effective_entry(
1180
+ *,
1181
+ item: Mapping[str, Any],
1182
+ input_dir: Path | None,
1183
+ input_dir_fd: int | None,
1184
+ planning_seq: str,
1185
+ data_path: Path,
1186
+ dependency_stages: set[int],
1187
+ warnings: list[str],
1188
+ ) -> tuple[dict[str, Any], str, list[str]]:
1189
+ item_id = str(item["id"])
1190
+ fingerprint = assessment_fingerprint(item)
1191
+ invalid_revisions: set[int] = set()
1192
+ rows = (
1193
+ _parse_inputs_for_item(
1194
+ input_dir,
1195
+ input_dir_fd,
1196
+ planning_seq,
1197
+ item_id,
1198
+ warnings=warnings,
1199
+ invalid_revisions=invalid_revisions,
1200
+ )
1201
+ if input_dir is not None and input_dir_fd is not None
1202
+ else []
1203
+ )
1204
+ selected = _select_latest_input(
1205
+ rows,
1206
+ fingerprint,
1207
+ item_id,
1208
+ warnings,
1209
+ invalid_revisions,
1210
+ )
1211
+ outcome = _effective_outcome(item, selected)
1212
+ carry = _carry_evidence_for_item(
1213
+ data_path=data_path,
1214
+ dependency_stages=dependency_stages,
1215
+ item_id=item_id,
1216
+ fingerprint=fingerprint,
1217
+ warnings=warnings,
1218
+ )
1219
+ effective = _effective_item(item, selected, outcome, carry)
1220
+ return effective, outcome, _provisional_assumptions(item, effective, outcome)
1221
+
1222
+
1223
+ @contextmanager
1224
+ def _resolver_input_dir(
1225
+ data_path: Path,
1226
+ warnings: list[str],
1227
+ ) -> Iterator[tuple[Path | None, int | None]]:
1228
+ try:
1229
+ input_dir = _plan_input_dir(data_path)
1230
+ except DesignPrepError as exc:
1231
+ warnings.append(str(exc))
1232
+ yield None, None
1233
+ return
1234
+ try:
1235
+ input_dir_fd = _open_input_dir_fd(input_dir)
1236
+ except FileNotFoundError:
1237
+ yield input_dir, None
1238
+ return
1239
+ except OSError as exc:
1240
+ warnings.append(f"cannot scan design preparation inputs {input_dir}: {exc}")
1241
+ yield input_dir, None
1242
+ return
1243
+ try:
1244
+ yield input_dir, input_dir_fd
1245
+ finally:
1246
+ os.close(input_dir_fd)
1247
+
1248
+
1249
+ def _resolve_design_prep_items(
1250
+ *,
1251
+ items: list[Mapping[str, Any]],
1252
+ input_dir: Path | None,
1253
+ input_dir_fd: int | None,
1254
+ planning_seq: str,
1255
+ data_path: Path,
1256
+ dependency_stages: set[int],
1257
+ warnings: list[str],
1258
+ ) -> DesignPrepDecision:
1259
+ effective_items: list[dict[str, Any]] = []
1260
+ assumptions: list[str] = []
1261
+ outcomes: list[str] = []
1262
+ for item in items:
1263
+ effective, item_outcome, item_assumptions = _resolve_effective_entry(
1264
+ item=item,
1265
+ input_dir=input_dir,
1266
+ input_dir_fd=input_dir_fd,
1267
+ planning_seq=planning_seq,
1268
+ data_path=data_path,
1269
+ dependency_stages=dependency_stages,
1270
+ warnings=warnings,
1271
+ )
1272
+ effective_items.append(effective)
1273
+ assumptions.extend(item_assumptions)
1274
+ outcomes.append(item_outcome)
1275
+ outcome = _aggregate_outcome(outcomes)
1276
+ request_paths = tuple(
1277
+ str(item["requestPath"])
1278
+ for item in items
1279
+ if isinstance(item.get("requestPath"), str)
1280
+ )
1281
+ return DesignPrepDecision(
1282
+ outcome=outcome,
1283
+ effective_items=tuple(effective_items),
1284
+ assumptions_to_inject=tuple(assumptions),
1285
+ warnings=tuple(warnings),
1286
+ request_paths=request_paths,
1287
+ reason=_decision_reason(outcome),
1288
+ )
1289
+
1290
+
1291
+ def resolve_design_prep(
1292
+ plan_data_path: Path,
1293
+ stage_number: int | None = None,
1294
+ ) -> DesignPrepDecision:
1295
+ """Resolve effective design preparation without changing the approved snapshot."""
1296
+ data_path = _canonical_data_path(plan_data_path)
1297
+ if not data_path.is_file():
1298
+ return _legacy_decision()
1299
+ data = _load_plan_data(data_path)
1300
+ planning = data.get("implementationPlanning")
1301
+ if planning is None:
1302
+ return _legacy_decision()
1303
+ if not isinstance(planning, Mapping):
1304
+ raise DesignPrepError("implementationPlanning must be an object")
1305
+ if planning.get("designPreparation") is None:
1306
+ return _legacy_decision()
1307
+ items = _design_preparation_items(data)
1308
+ if stage_number is not None:
1309
+ items = [item for item in items if stage_number in item.get("stageRefs", [])]
1310
+ planning_seq = _planning_seq(data_path)
1311
+ warnings: list[str] = []
1312
+ dependency_stages = _dependency_stages(planning, stage_number, warnings)
1313
+ with _resolver_input_dir(data_path, warnings) as (input_dir, input_dir_fd):
1314
+ decision = _resolve_design_prep_items(
1315
+ items=items,
1316
+ input_dir=input_dir,
1317
+ input_dir_fd=input_dir_fd,
1318
+ planning_seq=planning_seq,
1319
+ data_path=data_path,
1320
+ dependency_stages=dependency_stages,
1321
+ warnings=warnings,
1322
+ )
1323
+ if input_dir is not None and input_dir_fd is not None:
1324
+ _require_input_dir_identity(input_dir, input_dir_fd)
1325
+ return decision
1326
+
1327
+
1328
+ def _design_prep_item_view(
1329
+ item: Mapping[str, Any],
1330
+ effective_by_id: Mapping[str, dict[str, Any]],
1331
+ ) -> dict[str, Any]:
1332
+ item_id = str(item["id"])
1333
+ return {
1334
+ "item": deepcopy(dict(item)),
1335
+ "effectiveInput": deepcopy(effective_by_id.get(item_id)),
1336
+ "assessmentFingerprint": assessment_fingerprint(item),
1337
+ "requestPath": item.get("requestPath"),
1338
+ }
1339
+
1340
+
1341
+ def _snapshot_summary(report_path: Path) -> dict[str, Any]:
1342
+ data_path = _canonical_data_path(report_path)
1343
+ items = load_design_prep_items(data_path)
1344
+ decision = resolve_design_prep(data_path)
1345
+ effective_by_id = {
1346
+ str(item["id"]): item for item in decision.effective_items
1347
+ }
1348
+ return {
1349
+ "report": str(data_path),
1350
+ "outcome": decision.outcome,
1351
+ "reason": decision.reason,
1352
+ "warnings": list(decision.warnings),
1353
+ "items": [
1354
+ _design_prep_item_view(item, effective_by_id) for item in items
1355
+ ],
1356
+ }
1357
+
1358
+
1359
+ def _discover_planning_reports(project_root: Path) -> list[Path]:
1360
+ pattern = (
1361
+ ".okstra/tasks/*/*/runs/implementation-planning/reports/"
1362
+ "final-report-implementation-planning-*.data.json"
1363
+ )
1364
+ return sorted(project_root.glob(pattern))
1365
+
1366
+
1367
+ def _build_parser() -> argparse.ArgumentParser:
1368
+ parser = argparse.ArgumentParser(prog="design-prep")
1369
+ subparsers = parser.add_subparsers(dest="command", required=True)
1370
+ list_parser = subparsers.add_parser("list")
1371
+ list_parser.add_argument("--project-root", default="")
1372
+ list_parser.add_argument("--report", default="")
1373
+ show_parser = subparsers.add_parser("show")
1374
+ show_parser.add_argument("--report", required=True)
1375
+ show_parser.add_argument("--item", default="")
1376
+ write_parser = subparsers.add_parser("write")
1377
+ write_parser.add_argument("--report", required=True)
1378
+ write_parser.add_argument("--item", required=True)
1379
+ write_parser.add_argument(
1380
+ "--decision",
1381
+ choices=sorted(_INPUT_DECISIONS),
1382
+ required=True,
1383
+ )
1384
+ write_parser.add_argument("--overrides", default="{}")
1385
+ write_parser.add_argument("--notes", default="")
1386
+ write_parser.add_argument("--confirmed", action="store_true")
1387
+ return parser
1388
+
1389
+
1390
+ def _run_list_command(args: argparse.Namespace) -> dict[str, Any]:
1391
+ if args.report:
1392
+ reports = [Path(args.report)]
1393
+ else:
1394
+ project_root = resolve_project_root(
1395
+ explicit_root=args.project_root or "",
1396
+ cwd=str(Path.cwd()),
1397
+ )
1398
+ reports = _discover_planning_reports(project_root)
1399
+ return {"reports": [_snapshot_summary(report) for report in reports]}
1400
+
1401
+
1402
+ def _run_show_command(args: argparse.Namespace) -> dict[str, Any]:
1403
+ summary = _snapshot_summary(Path(args.report))
1404
+ if not args.item:
1405
+ return summary
1406
+ matches = [row for row in summary["items"] if row["item"]["id"] == args.item]
1407
+ if len(matches) != 1:
1408
+ raise DesignPrepError(f"expected one design preparation item {args.item}")
1409
+ return {"report": summary["report"], **matches[0]}
1410
+
1411
+
1412
+ def _run_write_command(args: argparse.Namespace) -> dict[str, Any]:
1413
+ if not args.confirmed:
1414
+ raise DesignPrepError("write requires --confirmed")
1415
+ overrides = json.loads(args.overrides)
1416
+ if not isinstance(overrides, dict):
1417
+ raise DesignPrepError("--overrides must be a JSON object")
1418
+ items = load_design_prep_items(Path(args.report))
1419
+ item = next((row for row in items if row["id"] == args.item), None)
1420
+ if item is None:
1421
+ raise DesignPrepError(f"expected one design preparation item {args.item}")
1422
+ path = write_design_prep_input(
1423
+ Path(args.report),
1424
+ args.item,
1425
+ args.decision,
1426
+ overrides,
1427
+ args.notes,
1428
+ captured_by="okstra-cli",
1429
+ )
1430
+ match = _INPUT_FILENAME_RE.fullmatch(path.name)
1431
+ if match is None:
1432
+ raise DesignPrepError(f"writer returned a non-canonical input path: {path}")
1433
+ return {
1434
+ "path": str(path),
1435
+ "revision": int(match.group("revision")),
1436
+ "inputId": match.group("input_id"),
1437
+ "assessmentFingerprint": assessment_fingerprint(item),
1438
+ }
1439
+
1440
+
1441
+ def _run_cli_command(args: argparse.Namespace) -> dict[str, Any]:
1442
+ if args.command == "list":
1443
+ return _run_list_command(args)
1444
+ if args.command == "show":
1445
+ return _run_show_command(args)
1446
+ return _run_write_command(args)
1447
+
1448
+
1449
+ def main(argv: list[str]) -> int:
1450
+ parser = _build_parser()
1451
+ args = parser.parse_args(argv)
1452
+ try:
1453
+ payload = _run_cli_command(args)
1454
+ except (DesignPrepError, ResolverError, json.JSONDecodeError) as exc:
1455
+ print(f"design-prep: {exc}", file=sys.stderr)
1456
+ return 1
1457
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1458
+ return 0
1459
+
1460
+
1461
+ if __name__ == "__main__":
1462
+ raise SystemExit(main(sys.argv[1:]))