okstra 0.121.1 → 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.
- package/README.md +4 -2
- package/docs/architecture/storage-model.md +14 -0
- package/docs/architecture.md +38 -8
- package/docs/cli.md +45 -5
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-rollup.md +1 -1
- package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
- package/docs/project-structure-overview.md +4 -3
- package/docs/task-process/implementation-planning.md +33 -9
- package/docs/task-process/implementation.md +21 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -3
- package/runtime/prompts/launch.template.md +9 -6
- package/runtime/prompts/lead/adapters/claude-code.md +104 -0
- package/runtime/prompts/lead/adapters/codex.md +45 -0
- package/runtime/prompts/lead/adapters/external.md +46 -0
- package/runtime/prompts/lead/context-loader.md +5 -5
- package/runtime/prompts/lead/convergence.md +11 -28
- package/runtime/prompts/lead/okstra-lead-contract.md +44 -94
- package/runtime/prompts/lead/plan-body-verification.md +50 -5
- package/runtime/prompts/lead/report-writer.md +59 -54
- package/runtime/prompts/lead/team-contract.md +33 -106
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
- package/runtime/prompts/profiles/_implementation-executor.md +6 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/final-verification.md +2 -0
- package/runtime/prompts/profiles/implementation-planning.md +14 -4
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +44 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
- package/runtime/python/okstra_ctl/design_prep.py +1462 -0
- package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
- package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
- package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
- package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
- package/runtime/python/okstra_ctl/lead_runtime.py +4 -0
- package/runtime/python/okstra_ctl/material.py +1 -1
- package/runtime/python/okstra_ctl/model_discovery.py +98 -0
- package/runtime/python/okstra_ctl/models.py +8 -3
- package/runtime/python/okstra_ctl/render.py +81 -194
- package/runtime/python/okstra_ctl/report_views.py +22 -7
- package/runtime/python/okstra_ctl/run.py +53 -5
- package/runtime/python/okstra_ctl/team_reconcile.py +3 -1
- package/runtime/python/okstra_ctl/user_response.py +67 -2
- package/runtime/python/okstra_ctl/wizard.py +283 -3
- package/runtime/python/okstra_token_usage/report.py +11 -0
- package/runtime/schemas/final-report-v1.0.schema.json +339 -0
- package/runtime/skills/okstra-inspect/SKILL.md +2 -2
- package/runtime/skills/okstra-rollup/SKILL.md +1 -1
- package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +62 -20
- package/runtime/skills/okstra-user-response/SKILL.md +8 -6
- package/runtime/templates/reports/final-report.template.md +71 -4
- package/runtime/templates/reports/i18n/en.json +32 -1
- package/runtime/templates/reports/i18n/ko.json +32 -1
- package/runtime/validators/validate-run.py +515 -5
- package/runtime/validators/validate-schedule.py +11 -7
- package/runtime/validators/validate_session_conformance.py +58 -33
- package/src/cli-registry.mjs +14 -0
- package/src/commands/inspect/design-prep.mjs +23 -0
- package/src/commands/inspect/stage-map.mjs +100 -0
- package/src/commands/lifecycle/install.mjs +3 -0
- package/src/commands/lifecycle/uninstall.mjs +8 -23
- package/src/lib/skill-catalog.mjs +10 -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:]))
|