okstra 0.136.0 → 0.138.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/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +3 -2
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/project-structure-overview.md +32 -5
- package/package.json +2 -3
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-spawn-followups.py +4 -9
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/adapters/codex.md +1 -0
- package/runtime/prompts/lead/adapters/external.md +1 -0
- package/runtime/prompts/lead/plan-body-verification.md +2 -2
- package/runtime/prompts/lead/report-writer.md +11 -10
- package/runtime/prompts/lead/team-contract.md +4 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +199 -681
- package/runtime/python/okstra_ctl/consumers.py +7 -5
- package/runtime/python/okstra_ctl/context_cost.py +4 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +146 -287
- package/runtime/python/okstra_ctl/dispatch_state.py +231 -0
- package/runtime/python/okstra_ctl/error_report.py +2 -7
- package/runtime/python/okstra_ctl/implementation_outcome.py +12 -23
- package/runtime/python/okstra_ctl/implementation_stage.py +7 -2
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +1053 -0
- package/runtime/python/okstra_ctl/path_hints.py +3 -3
- package/runtime/python/okstra_ctl/paths.py +17 -2
- package/runtime/python/okstra_ctl/recap.py +5 -12
- package/runtime/python/okstra_ctl/render.py +18 -19
- package/runtime/python/okstra_ctl/report_finalize.py +8 -4
- package/runtime/python/okstra_ctl/report_language.py +83 -0
- package/runtime/python/okstra_ctl/run.py +262 -328
- package/runtime/python/okstra_ctl/set_work_status.py +2 -1
- package/runtime/python/okstra_ctl/stage_targets.py +330 -101
- package/runtime/python/okstra_ctl/time_report.py +4 -9
- package/runtime/python/okstra_ctl/wizard.py +47 -49
- package/runtime/python/okstra_ctl/work_categories.py +2 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +82 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +73 -13
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +7 -0
- package/runtime/python/okstra_ctl/worktree.py +37 -29
- package/runtime/python/okstra_project/__init__.py +4 -0
- package/runtime/python/okstra_project/dirs.py +7 -0
- package/runtime/python/okstra_project/state.py +78 -8
- package/runtime/validators/lib/fixtures.sh +2 -0
- package/runtime/validators/validate-run.py +3 -21
- package/src/commands/inspect/stage-map.mjs +12 -19
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
"""Render, validate, and immutably publish initial worker prompts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Literal, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from .final_report_paths import final_report_data_path
|
|
13
|
+
from .path_hints import hydrate_active_run_context
|
|
14
|
+
from .report_language import resolve_report_language
|
|
15
|
+
from .worker_prompt_body import (
|
|
16
|
+
REPORT_WRITER_WORKER_ID,
|
|
17
|
+
analysis_prompt_body,
|
|
18
|
+
instruction_path,
|
|
19
|
+
report_writer_prompt_body,
|
|
20
|
+
)
|
|
21
|
+
from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
|
|
22
|
+
from .worker_prompt_headers import worker_prompt_headers
|
|
23
|
+
from .worker_prompt_policy import PromptPlan, resolve_prompt_plan_for_manifest
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PromptDeliveryMode(str, Enum):
|
|
27
|
+
EAGER_INCLUDE = "eager-include"
|
|
28
|
+
LAZY_PATH_REFERENCE = "lazy-path-reference"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MaterializationReason = Literal[
|
|
32
|
+
"existing_prompt_invalid",
|
|
33
|
+
"existing_prompt_conflict",
|
|
34
|
+
"required_input_missing",
|
|
35
|
+
"render_failed",
|
|
36
|
+
"validation_failed",
|
|
37
|
+
"publication_failed",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class InitialPromptWorkerRequest:
|
|
43
|
+
worker_id: str
|
|
44
|
+
model: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class InitialPromptMaterializationRequest:
|
|
49
|
+
project_root: Path
|
|
50
|
+
run_manifest_path: Path
|
|
51
|
+
runtime_root: Path
|
|
52
|
+
delivery_mode: PromptDeliveryMode
|
|
53
|
+
workers: tuple[InitialPromptWorkerRequest, ...]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InitialPromptMaterializationError(RuntimeError):
|
|
57
|
+
reason: MaterializationReason
|
|
58
|
+
|
|
59
|
+
def __init__(self, reason: MaterializationReason, message: str) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.reason = reason
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
MaterializeInitialPrompts = Callable[
|
|
65
|
+
[InitialPromptMaterializationRequest],
|
|
66
|
+
tuple[Path, ...],
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
_MATERIALIZABLE_AUDIENCES = frozenset({
|
|
70
|
+
"analysis",
|
|
71
|
+
"implementation-executor",
|
|
72
|
+
"implementation-verifier",
|
|
73
|
+
"report-writer",
|
|
74
|
+
})
|
|
75
|
+
_CLARIFICATION_INPUT_PREFIX = "- Clarification response:"
|
|
76
|
+
_CLARIFICATION_AUTHORITY_HEADING = (
|
|
77
|
+
"# Clarification answers carried in (authoritative)"
|
|
78
|
+
)
|
|
79
|
+
_CLARIFICATION_AUTHORITY_HEADING_PREFIX = (
|
|
80
|
+
"# Clarification answers carried in"
|
|
81
|
+
)
|
|
82
|
+
_CLARIFICATION_AUTHORITY_INTRO = (
|
|
83
|
+
"The user answered these before this run. Where an answer conflicts "
|
|
84
|
+
"with the approved plan text, the answer wins — implement the answer "
|
|
85
|
+
"and say so in your result."
|
|
86
|
+
)
|
|
87
|
+
_CLARIFICATION_SOURCE_PREFIX = "Source:"
|
|
88
|
+
_REQUIRED_PROMPT_RESOURCES_HEADING = "## Required prompt resources"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class _MaterializationContext:
|
|
93
|
+
request: InitialPromptMaterializationRequest
|
|
94
|
+
project_root: Path
|
|
95
|
+
manifest: Mapping[str, Any]
|
|
96
|
+
team_state: Mapping[str, Any]
|
|
97
|
+
active_context: Mapping[str, Any]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class _PromptItem:
|
|
102
|
+
worker: InitialPromptWorkerRequest
|
|
103
|
+
final_path: Path
|
|
104
|
+
plan: PromptPlan
|
|
105
|
+
existed: bool
|
|
106
|
+
clarification_input: tuple[str, Path] | None = None
|
|
107
|
+
resources: tuple["_PromptResource", ...] = ()
|
|
108
|
+
temp_path: Path | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class _PromptResource:
|
|
113
|
+
path: Path
|
|
114
|
+
text: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class _PublishedPrompt:
|
|
119
|
+
worker: InitialPromptWorkerRequest
|
|
120
|
+
path: Path
|
|
121
|
+
plan: PromptPlan
|
|
122
|
+
existed: bool
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def materialize_initial_prompts(
|
|
126
|
+
request: InitialPromptMaterializationRequest,
|
|
127
|
+
) -> tuple[Path, ...]:
|
|
128
|
+
"""Materialize selected initial prompts in request order."""
|
|
129
|
+
try:
|
|
130
|
+
return _materialize_initial_prompts(request)
|
|
131
|
+
except InitialPromptMaterializationError:
|
|
132
|
+
raise
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
raise InitialPromptMaterializationError(
|
|
135
|
+
"render_failed",
|
|
136
|
+
f"unexpected initial prompt materialization failure: {exc}",
|
|
137
|
+
) from exc
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _materialize_initial_prompts(
|
|
141
|
+
request: InitialPromptMaterializationRequest,
|
|
142
|
+
) -> tuple[Path, ...]:
|
|
143
|
+
context = _load_materialization_context(request)
|
|
144
|
+
resolved_items = [
|
|
145
|
+
_resolve_prompt_item(context, worker)
|
|
146
|
+
for worker in request.workers
|
|
147
|
+
]
|
|
148
|
+
items: list[_PromptItem] = []
|
|
149
|
+
completed = False
|
|
150
|
+
try:
|
|
151
|
+
for item in resolved_items:
|
|
152
|
+
items.append(_prepare_prompt_item(context, item))
|
|
153
|
+
_validate_prepublication_set(context, items)
|
|
154
|
+
published = tuple(
|
|
155
|
+
_publish_or_reuse(context, item)
|
|
156
|
+
for item in items
|
|
157
|
+
)
|
|
158
|
+
_validate_published_set(context, published)
|
|
159
|
+
completed = True
|
|
160
|
+
return tuple(prompt.path for prompt in published)
|
|
161
|
+
finally:
|
|
162
|
+
cleanup_error = _remove_temp_files(items)
|
|
163
|
+
if completed and cleanup_error is not None:
|
|
164
|
+
raise InitialPromptMaterializationError(
|
|
165
|
+
"publication_failed",
|
|
166
|
+
f"cannot remove prompt staging file: {cleanup_error}",
|
|
167
|
+
) from cleanup_error
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _load_materialization_context(
|
|
171
|
+
request: InitialPromptMaterializationRequest,
|
|
172
|
+
) -> _MaterializationContext:
|
|
173
|
+
project_root = request.project_root.resolve()
|
|
174
|
+
if not isinstance(request.delivery_mode, PromptDeliveryMode):
|
|
175
|
+
raise InitialPromptMaterializationError(
|
|
176
|
+
"required_input_missing",
|
|
177
|
+
"delivery_mode must be a PromptDeliveryMode",
|
|
178
|
+
)
|
|
179
|
+
manifest_path = _resolve_input_path(project_root, request.run_manifest_path)
|
|
180
|
+
manifest = _load_json_object(manifest_path, "run manifest")
|
|
181
|
+
team_state = _load_referenced_json(project_root, manifest, "teamStatePath")
|
|
182
|
+
active_context = hydrate_active_run_context(
|
|
183
|
+
_load_referenced_json(
|
|
184
|
+
project_root,
|
|
185
|
+
manifest,
|
|
186
|
+
"activeRunContextPath",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return _MaterializationContext(
|
|
190
|
+
request=request,
|
|
191
|
+
project_root=project_root,
|
|
192
|
+
manifest=manifest,
|
|
193
|
+
team_state=team_state,
|
|
194
|
+
active_context=active_context,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resolve_prompt_item(
|
|
199
|
+
context: _MaterializationContext,
|
|
200
|
+
worker: InitialPromptWorkerRequest,
|
|
201
|
+
) -> _PromptItem:
|
|
202
|
+
_validate_worker_request(worker)
|
|
203
|
+
plan = _prompt_plan(context.manifest, worker.worker_id)
|
|
204
|
+
if plan.audience not in _MATERIALIZABLE_AUDIENCES:
|
|
205
|
+
raise InitialPromptMaterializationError(
|
|
206
|
+
"required_input_missing",
|
|
207
|
+
f"prompt audience is not materializable: {plan.audience}",
|
|
208
|
+
)
|
|
209
|
+
clarification_input = _resolve_clarification_input(context, plan)
|
|
210
|
+
final_path = _worker_prompt_path(context, worker.worker_id)
|
|
211
|
+
existed = final_path.exists()
|
|
212
|
+
return _PromptItem(
|
|
213
|
+
worker=worker,
|
|
214
|
+
final_path=final_path,
|
|
215
|
+
plan=plan,
|
|
216
|
+
existed=existed,
|
|
217
|
+
clarification_input=clarification_input,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _prepare_prompt_item(
|
|
222
|
+
context: _MaterializationContext,
|
|
223
|
+
item: _PromptItem,
|
|
224
|
+
) -> _PromptItem:
|
|
225
|
+
resources = _resolve_prompt_resources(
|
|
226
|
+
context,
|
|
227
|
+
item.plan,
|
|
228
|
+
existed=item.existed,
|
|
229
|
+
)
|
|
230
|
+
prepared_item = _with_prompt_resources(item, resources)
|
|
231
|
+
if item.existed:
|
|
232
|
+
_validate_existing_prompt(context, prepared_item)
|
|
233
|
+
return prepared_item
|
|
234
|
+
temp_path: Path | None = None
|
|
235
|
+
try:
|
|
236
|
+
item.final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
text = _render_prompt(context, prepared_item)
|
|
238
|
+
with tempfile.NamedTemporaryFile(
|
|
239
|
+
mode="w",
|
|
240
|
+
encoding="utf-8",
|
|
241
|
+
dir=item.final_path.parent,
|
|
242
|
+
prefix=f".{item.final_path.name}.",
|
|
243
|
+
suffix=".tmp",
|
|
244
|
+
delete=False,
|
|
245
|
+
) as handle:
|
|
246
|
+
temp_path = Path(handle.name)
|
|
247
|
+
handle.write(text)
|
|
248
|
+
except InitialPromptMaterializationError:
|
|
249
|
+
raise
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
if temp_path is not None:
|
|
252
|
+
try:
|
|
253
|
+
temp_path.unlink(missing_ok=True)
|
|
254
|
+
except OSError:
|
|
255
|
+
pass
|
|
256
|
+
raise InitialPromptMaterializationError(
|
|
257
|
+
"render_failed",
|
|
258
|
+
f"cannot render initial prompt for {item.worker.worker_id}: {exc}",
|
|
259
|
+
) from exc
|
|
260
|
+
if temp_path is None:
|
|
261
|
+
raise InitialPromptMaterializationError(
|
|
262
|
+
"render_failed",
|
|
263
|
+
f"no staging file was created for {item.worker.worker_id}",
|
|
264
|
+
)
|
|
265
|
+
return _PromptItem(
|
|
266
|
+
worker=item.worker,
|
|
267
|
+
final_path=item.final_path,
|
|
268
|
+
plan=item.plan,
|
|
269
|
+
existed=False,
|
|
270
|
+
clarification_input=item.clarification_input,
|
|
271
|
+
resources=resources,
|
|
272
|
+
temp_path=temp_path,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _with_prompt_resources(
|
|
277
|
+
item: _PromptItem,
|
|
278
|
+
resources: tuple[_PromptResource, ...],
|
|
279
|
+
) -> _PromptItem:
|
|
280
|
+
return _PromptItem(
|
|
281
|
+
worker=item.worker,
|
|
282
|
+
final_path=item.final_path,
|
|
283
|
+
plan=item.plan,
|
|
284
|
+
existed=item.existed,
|
|
285
|
+
clarification_input=item.clarification_input,
|
|
286
|
+
resources=resources,
|
|
287
|
+
temp_path=item.temp_path,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _render_prompt(
|
|
292
|
+
context: _MaterializationContext,
|
|
293
|
+
item: _PromptItem,
|
|
294
|
+
) -> str:
|
|
295
|
+
worker = item.worker
|
|
296
|
+
state = _worker_state(context.team_state, worker.worker_id)
|
|
297
|
+
prompt_rel = _project_relative(context.project_root, item.final_path)
|
|
298
|
+
result_rel, audit_source_rel = _result_paths(context, state, worker.worker_id)
|
|
299
|
+
try:
|
|
300
|
+
lines = worker_prompt_headers(
|
|
301
|
+
project_root=context.project_root,
|
|
302
|
+
prompt_rel=prompt_rel,
|
|
303
|
+
result_rel=result_rel,
|
|
304
|
+
audit_source_rel=audit_source_rel,
|
|
305
|
+
worker_id=worker.worker_id,
|
|
306
|
+
dispatch_kind="initial",
|
|
307
|
+
manifest=context.manifest,
|
|
308
|
+
active_context=context.active_context,
|
|
309
|
+
)
|
|
310
|
+
lines.append(
|
|
311
|
+
f"**Prompt Delivery Mode:** {context.request.delivery_mode.value}"
|
|
312
|
+
)
|
|
313
|
+
lines.extend(_worktree_lines(context, item.plan))
|
|
314
|
+
lines.extend(_prompt_body(context, state, worker, item.plan))
|
|
315
|
+
lines.extend(_resource_lines(context, item))
|
|
316
|
+
except InitialPromptMaterializationError:
|
|
317
|
+
raise
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
raise InitialPromptMaterializationError(
|
|
320
|
+
"render_failed",
|
|
321
|
+
f"cannot compose initial prompt for {worker.worker_id}: {exc}",
|
|
322
|
+
) from exc
|
|
323
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _prompt_body(
|
|
327
|
+
context: _MaterializationContext,
|
|
328
|
+
state: Mapping[str, Any],
|
|
329
|
+
worker: InitialPromptWorkerRequest,
|
|
330
|
+
plan: PromptPlan,
|
|
331
|
+
) -> list[str]:
|
|
332
|
+
if plan.audience == "report-writer":
|
|
333
|
+
return report_writer_prompt_body(
|
|
334
|
+
context.manifest,
|
|
335
|
+
context.active_context,
|
|
336
|
+
context.team_state,
|
|
337
|
+
worker.model,
|
|
338
|
+
resolve_report_language(
|
|
339
|
+
context.project_root,
|
|
340
|
+
context.manifest,
|
|
341
|
+
context.active_context,
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
return analysis_prompt_body(
|
|
345
|
+
context.manifest,
|
|
346
|
+
context.active_context,
|
|
347
|
+
worker.worker_id,
|
|
348
|
+
worker.model,
|
|
349
|
+
_worker_role(plan),
|
|
350
|
+
plan,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _resource_lines(
|
|
355
|
+
context: _MaterializationContext,
|
|
356
|
+
item: _PromptItem,
|
|
357
|
+
) -> list[str]:
|
|
358
|
+
if not item.resources:
|
|
359
|
+
return []
|
|
360
|
+
if context.request.delivery_mode is PromptDeliveryMode.LAZY_PATH_REFERENCE:
|
|
361
|
+
return [
|
|
362
|
+
"",
|
|
363
|
+
_REQUIRED_PROMPT_RESOURCES_HEADING,
|
|
364
|
+
"",
|
|
365
|
+
*(f"- `{resource.path}`" for resource in item.resources),
|
|
366
|
+
]
|
|
367
|
+
bodies = [resource.text.rstrip() for resource in item.resources]
|
|
368
|
+
clarification = (
|
|
369
|
+
_eager_executor_clarification(item.clarification_input)
|
|
370
|
+
if item.plan.audience == "implementation-executor"
|
|
371
|
+
else ""
|
|
372
|
+
)
|
|
373
|
+
if clarification:
|
|
374
|
+
bodies.append(clarification)
|
|
375
|
+
return ["", "\n\n".join(bodies)]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _resolve_clarification_input(
|
|
379
|
+
context: _MaterializationContext,
|
|
380
|
+
plan: PromptPlan,
|
|
381
|
+
) -> tuple[str, Path] | None:
|
|
382
|
+
relative = instruction_path(
|
|
383
|
+
context.manifest,
|
|
384
|
+
context.active_context,
|
|
385
|
+
"clarificationResponsePath",
|
|
386
|
+
)
|
|
387
|
+
if not relative:
|
|
388
|
+
return None
|
|
389
|
+
path = _resolve_owned_artifact_path(
|
|
390
|
+
context.project_root,
|
|
391
|
+
Path(relative),
|
|
392
|
+
"clarification response path",
|
|
393
|
+
)
|
|
394
|
+
return relative, path
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _eager_executor_clarification(
|
|
398
|
+
clarification_input: tuple[str, Path] | None,
|
|
399
|
+
) -> str:
|
|
400
|
+
if clarification_input is None:
|
|
401
|
+
return ""
|
|
402
|
+
relative, _ = clarification_input
|
|
403
|
+
body = _clarification_body(clarification_input)
|
|
404
|
+
if not body:
|
|
405
|
+
return ""
|
|
406
|
+
return (
|
|
407
|
+
f"{_CLARIFICATION_AUTHORITY_HEADING}\n\n"
|
|
408
|
+
f"{_CLARIFICATION_AUTHORITY_INTRO}\n\n"
|
|
409
|
+
f"Source: `{relative}`\n\n{body}"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _clarification_body(
|
|
414
|
+
clarification_input: tuple[str, Path] | None,
|
|
415
|
+
) -> str:
|
|
416
|
+
if clarification_input is None:
|
|
417
|
+
return ""
|
|
418
|
+
_, path = clarification_input
|
|
419
|
+
if not path.is_file():
|
|
420
|
+
return ""
|
|
421
|
+
return _read_required_text(path, "required clarification response").strip()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _read_required_nonempty_text(path: Path, label: str) -> str:
|
|
425
|
+
text = _read_required_text(path, label)
|
|
426
|
+
if text.strip():
|
|
427
|
+
return text
|
|
428
|
+
cause = ValueError(f"{label} is empty: {path}")
|
|
429
|
+
raise InitialPromptMaterializationError(
|
|
430
|
+
"required_input_missing",
|
|
431
|
+
str(cause),
|
|
432
|
+
) from cause
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _read_required_text(path: Path, label: str) -> str:
|
|
436
|
+
try:
|
|
437
|
+
return path.read_text(encoding="utf-8")
|
|
438
|
+
except (OSError, UnicodeError) as exc:
|
|
439
|
+
raise InitialPromptMaterializationError(
|
|
440
|
+
"required_input_missing",
|
|
441
|
+
f"{label} cannot be read: {path}",
|
|
442
|
+
) from exc
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _resolve_prompt_resources(
|
|
446
|
+
context: _MaterializationContext,
|
|
447
|
+
plan: PromptPlan,
|
|
448
|
+
*,
|
|
449
|
+
existed: bool,
|
|
450
|
+
) -> tuple[_PromptResource, ...]:
|
|
451
|
+
paths = _required_resource_path_candidates(context, plan)
|
|
452
|
+
if existed and context.request.delivery_mode is PromptDeliveryMode.EAGER_INCLUDE:
|
|
453
|
+
return ()
|
|
454
|
+
return tuple(
|
|
455
|
+
_PromptResource(
|
|
456
|
+
path=path.resolve(),
|
|
457
|
+
text=_read_required_nonempty_text(path, "required prompt resource"),
|
|
458
|
+
)
|
|
459
|
+
for path in paths
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _required_resource_path_candidates(
|
|
464
|
+
context: _MaterializationContext,
|
|
465
|
+
plan: PromptPlan,
|
|
466
|
+
) -> tuple[Path, ...]:
|
|
467
|
+
profiles = context.request.runtime_root.resolve() / "prompts" / "profiles"
|
|
468
|
+
if plan.audience == "implementation-executor":
|
|
469
|
+
paths = (
|
|
470
|
+
_executor_profile_path(context),
|
|
471
|
+
profiles / "_coding-conventions-preflight.md",
|
|
472
|
+
profiles / "_implementation-diff-review.md",
|
|
473
|
+
profiles / "_implementation-self-check.md",
|
|
474
|
+
)
|
|
475
|
+
elif plan.audience == "implementation-verifier":
|
|
476
|
+
paths = (
|
|
477
|
+
profiles / "_implementation-verifier.md",
|
|
478
|
+
profiles / "_implementation-self-check.md",
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
return ()
|
|
482
|
+
return paths
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _executor_profile_path(context: _MaterializationContext) -> Path:
|
|
486
|
+
instruction_set = context.active_context.get("instructionSet")
|
|
487
|
+
value = ""
|
|
488
|
+
if isinstance(instruction_set, Mapping):
|
|
489
|
+
value = _string_value(instruction_set.get("analysisProfilePath"))
|
|
490
|
+
value = value or _string_value(context.manifest.get("analysisProfilePath"))
|
|
491
|
+
if not value:
|
|
492
|
+
raise InitialPromptMaterializationError(
|
|
493
|
+
"required_input_missing",
|
|
494
|
+
"implementation executor profile path is missing",
|
|
495
|
+
)
|
|
496
|
+
return _resolve_owned_artifact_path(
|
|
497
|
+
context.project_root,
|
|
498
|
+
_resolve_input_path(context.project_root, Path(value)).parent
|
|
499
|
+
/ "implementation-executor.md",
|
|
500
|
+
"implementation executor profile path",
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _validate_prepublication_set(
|
|
505
|
+
context: _MaterializationContext,
|
|
506
|
+
items: Sequence[_PromptItem],
|
|
507
|
+
) -> None:
|
|
508
|
+
records = [_item_record(context, item) for item in items]
|
|
509
|
+
_validate_existing_record_subset(
|
|
510
|
+
context.manifest,
|
|
511
|
+
[
|
|
512
|
+
record
|
|
513
|
+
for item, record in zip(items, records, strict=True)
|
|
514
|
+
if item.existed
|
|
515
|
+
],
|
|
516
|
+
"prepublication",
|
|
517
|
+
)
|
|
518
|
+
for item, record in zip(items, records, strict=True):
|
|
519
|
+
if item.existed:
|
|
520
|
+
continue
|
|
521
|
+
errors = validate_initial_prompt_records(
|
|
522
|
+
manifest=context.manifest,
|
|
523
|
+
records=[record],
|
|
524
|
+
)
|
|
525
|
+
if errors:
|
|
526
|
+
raise InitialPromptMaterializationError(
|
|
527
|
+
"validation_failed",
|
|
528
|
+
"rendered initial prompt contract: " + "; ".join(errors),
|
|
529
|
+
)
|
|
530
|
+
errors = validate_initial_prompt_records(
|
|
531
|
+
manifest=context.manifest,
|
|
532
|
+
records=records,
|
|
533
|
+
)
|
|
534
|
+
if errors:
|
|
535
|
+
raise InitialPromptMaterializationError(
|
|
536
|
+
"validation_failed",
|
|
537
|
+
"prepublication initial prompt contract: " + "; ".join(errors),
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _publish_or_reuse(
|
|
542
|
+
context: _MaterializationContext,
|
|
543
|
+
item: _PromptItem,
|
|
544
|
+
) -> _PublishedPrompt:
|
|
545
|
+
if item.existed:
|
|
546
|
+
return _published_prompt(item, existed=True)
|
|
547
|
+
temp_path = _required_temp_path(item)
|
|
548
|
+
try:
|
|
549
|
+
os.link(temp_path, item.final_path)
|
|
550
|
+
except FileExistsError:
|
|
551
|
+
_validate_existing_prompt(context, item)
|
|
552
|
+
return _published_prompt(item, existed=True)
|
|
553
|
+
except OSError as exc:
|
|
554
|
+
raise InitialPromptMaterializationError(
|
|
555
|
+
"publication_failed",
|
|
556
|
+
f"cannot publish initial prompt {item.final_path}: {exc}",
|
|
557
|
+
) from exc
|
|
558
|
+
return _published_prompt(item, existed=False)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _validate_existing_prompt(
|
|
562
|
+
context: _MaterializationContext,
|
|
563
|
+
item: _PromptItem,
|
|
564
|
+
) -> None:
|
|
565
|
+
text = _require_readable_prompt(
|
|
566
|
+
item.final_path,
|
|
567
|
+
item.worker.worker_id,
|
|
568
|
+
"existing_prompt_invalid",
|
|
569
|
+
)
|
|
570
|
+
_validate_existing_clarification_metadata(context, item, text)
|
|
571
|
+
_validate_existing_resource_metadata(context, item, text)
|
|
572
|
+
basic_record = PromptRecord(
|
|
573
|
+
item.worker.worker_id,
|
|
574
|
+
"initial",
|
|
575
|
+
item.final_path,
|
|
576
|
+
)
|
|
577
|
+
basic_errors = validate_initial_prompt_records(
|
|
578
|
+
manifest=context.manifest,
|
|
579
|
+
records=[basic_record],
|
|
580
|
+
)
|
|
581
|
+
if basic_errors:
|
|
582
|
+
raise InitialPromptMaterializationError(
|
|
583
|
+
"existing_prompt_invalid",
|
|
584
|
+
"existing initial prompt contract: " + "; ".join(basic_errors),
|
|
585
|
+
)
|
|
586
|
+
expected_record = PromptRecord(
|
|
587
|
+
item.worker.worker_id,
|
|
588
|
+
"initial",
|
|
589
|
+
item.final_path,
|
|
590
|
+
expected_model=item.worker.model,
|
|
591
|
+
expected_delivery_mode=context.request.delivery_mode.value,
|
|
592
|
+
)
|
|
593
|
+
expected_errors = validate_initial_prompt_records(
|
|
594
|
+
manifest=context.manifest,
|
|
595
|
+
records=[expected_record],
|
|
596
|
+
)
|
|
597
|
+
if expected_errors:
|
|
598
|
+
reason = _existing_metadata_reason(expected_errors)
|
|
599
|
+
raise InitialPromptMaterializationError(
|
|
600
|
+
reason,
|
|
601
|
+
"existing initial prompt metadata: " + "; ".join(expected_errors),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _validate_existing_clarification_metadata(
|
|
606
|
+
context: _MaterializationContext,
|
|
607
|
+
item: _PromptItem,
|
|
608
|
+
text: str,
|
|
609
|
+
) -> None:
|
|
610
|
+
expected = (
|
|
611
|
+
item.clarification_input[0]
|
|
612
|
+
if item.clarification_input is not None
|
|
613
|
+
else None
|
|
614
|
+
)
|
|
615
|
+
enumerated = (
|
|
616
|
+
item.plan.audience in _MATERIALIZABLE_AUDIENCES
|
|
617
|
+
and not item.plan.packet_only
|
|
618
|
+
)
|
|
619
|
+
input_values = _backticked_line_values(
|
|
620
|
+
_markdown_section(text, "## Inputs"),
|
|
621
|
+
_CLARIFICATION_INPUT_PREFIX,
|
|
622
|
+
)
|
|
623
|
+
expected_inputs = [expected] if expected is not None and enumerated else []
|
|
624
|
+
if input_values != expected_inputs:
|
|
625
|
+
_raise_existing_clarification_invalid(
|
|
626
|
+
item,
|
|
627
|
+
"Clarification response",
|
|
628
|
+
)
|
|
629
|
+
_validate_eager_executor_clarification_authority(
|
|
630
|
+
context,
|
|
631
|
+
item,
|
|
632
|
+
text,
|
|
633
|
+
expected,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _validate_eager_executor_clarification_authority(
|
|
638
|
+
context: _MaterializationContext,
|
|
639
|
+
item: _PromptItem,
|
|
640
|
+
text: str,
|
|
641
|
+
expected: str | None,
|
|
642
|
+
) -> None:
|
|
643
|
+
authority_expected = (
|
|
644
|
+
item.plan.audience == "implementation-executor"
|
|
645
|
+
and context.request.delivery_mode is PromptDeliveryMode.EAGER_INCLUDE
|
|
646
|
+
and bool(_clarification_body(item.clarification_input))
|
|
647
|
+
)
|
|
648
|
+
lines = text.splitlines()
|
|
649
|
+
heading_indexes = [
|
|
650
|
+
index
|
|
651
|
+
for index, line in enumerate(lines)
|
|
652
|
+
if line.startswith(_CLARIFICATION_AUTHORITY_HEADING_PREFIX)
|
|
653
|
+
]
|
|
654
|
+
if not authority_expected and not heading_indexes:
|
|
655
|
+
return
|
|
656
|
+
if expected is None or len(heading_indexes) != 1:
|
|
657
|
+
_raise_existing_clarification_invalid(item, "clarification authority")
|
|
658
|
+
expected_lines = [
|
|
659
|
+
_CLARIFICATION_AUTHORITY_HEADING,
|
|
660
|
+
"",
|
|
661
|
+
_CLARIFICATION_AUTHORITY_INTRO,
|
|
662
|
+
"",
|
|
663
|
+
f"{_CLARIFICATION_SOURCE_PREFIX} `{expected}`",
|
|
664
|
+
"",
|
|
665
|
+
]
|
|
666
|
+
heading_index = heading_indexes[0]
|
|
667
|
+
authority_lines = lines[
|
|
668
|
+
heading_index:heading_index + len(expected_lines)
|
|
669
|
+
]
|
|
670
|
+
if not authority_expected or authority_lines != expected_lines:
|
|
671
|
+
_raise_existing_clarification_invalid(item, "clarification authority")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _validate_existing_resource_metadata(
|
|
675
|
+
context: _MaterializationContext,
|
|
676
|
+
item: _PromptItem,
|
|
677
|
+
text: str,
|
|
678
|
+
) -> None:
|
|
679
|
+
if (
|
|
680
|
+
context.request.delivery_mode is not PromptDeliveryMode.LAZY_PATH_REFERENCE
|
|
681
|
+
or not item.resources
|
|
682
|
+
):
|
|
683
|
+
return
|
|
684
|
+
expected_section = "\n".join([
|
|
685
|
+
"",
|
|
686
|
+
*(f"- `{resource.path}`" for resource in item.resources),
|
|
687
|
+
])
|
|
688
|
+
actual_section = _markdown_section(
|
|
689
|
+
text,
|
|
690
|
+
_REQUIRED_PROMPT_RESOURCES_HEADING,
|
|
691
|
+
)
|
|
692
|
+
if (
|
|
693
|
+
text.count(_REQUIRED_PROMPT_RESOURCES_HEADING) != 1
|
|
694
|
+
or actual_section != expected_section
|
|
695
|
+
):
|
|
696
|
+
raise InitialPromptMaterializationError(
|
|
697
|
+
"existing_prompt_invalid",
|
|
698
|
+
"existing required prompt resources do not match canonical "
|
|
699
|
+
f"paths for {item.worker.worker_id}",
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _backticked_line_values(text: str, prefix: str) -> list[str]:
|
|
704
|
+
values = []
|
|
705
|
+
for line in text.splitlines():
|
|
706
|
+
if not line.startswith(prefix):
|
|
707
|
+
continue
|
|
708
|
+
suffix = line[len(prefix):].strip()
|
|
709
|
+
if len(suffix) >= 2 and suffix.startswith("`") and suffix.endswith("`"):
|
|
710
|
+
values.append(suffix[1:-1])
|
|
711
|
+
else:
|
|
712
|
+
values.append("")
|
|
713
|
+
return values
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _markdown_section(text: str, heading: str) -> str:
|
|
717
|
+
lines = text.splitlines()
|
|
718
|
+
try:
|
|
719
|
+
start = lines.index(heading) + 1
|
|
720
|
+
except ValueError:
|
|
721
|
+
return ""
|
|
722
|
+
section = []
|
|
723
|
+
for line in lines[start:]:
|
|
724
|
+
if line.startswith("## "):
|
|
725
|
+
break
|
|
726
|
+
section.append(line)
|
|
727
|
+
return "\n".join(section)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _raise_existing_clarification_invalid(
|
|
731
|
+
item: _PromptItem,
|
|
732
|
+
field: str,
|
|
733
|
+
) -> None:
|
|
734
|
+
raise InitialPromptMaterializationError(
|
|
735
|
+
"existing_prompt_invalid",
|
|
736
|
+
f"existing {field} metadata does not match active context "
|
|
737
|
+
f"for {item.worker.worker_id}",
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _validate_published_set(
|
|
742
|
+
context: _MaterializationContext,
|
|
743
|
+
prompts: Sequence[_PublishedPrompt],
|
|
744
|
+
) -> None:
|
|
745
|
+
records = [_published_record(context, prompt) for prompt in prompts]
|
|
746
|
+
for prompt, record in zip(prompts, records, strict=True):
|
|
747
|
+
reason: MaterializationReason = (
|
|
748
|
+
"existing_prompt_invalid"
|
|
749
|
+
if prompt.existed
|
|
750
|
+
else "validation_failed"
|
|
751
|
+
)
|
|
752
|
+
_require_readable_prompt(prompt.path, prompt.worker.worker_id, reason)
|
|
753
|
+
_validate_existing_record_subset(
|
|
754
|
+
context.manifest,
|
|
755
|
+
[
|
|
756
|
+
record
|
|
757
|
+
for prompt, record in zip(prompts, records, strict=True)
|
|
758
|
+
if prompt.existed
|
|
759
|
+
],
|
|
760
|
+
"published",
|
|
761
|
+
)
|
|
762
|
+
for prompt, record in zip(prompts, records, strict=True):
|
|
763
|
+
if prompt.existed:
|
|
764
|
+
continue
|
|
765
|
+
errors = validate_initial_prompt_records(
|
|
766
|
+
manifest=context.manifest,
|
|
767
|
+
records=[record],
|
|
768
|
+
)
|
|
769
|
+
if errors:
|
|
770
|
+
raise InitialPromptMaterializationError(
|
|
771
|
+
"validation_failed",
|
|
772
|
+
"published initial prompt contract: " + "; ".join(errors),
|
|
773
|
+
)
|
|
774
|
+
errors = validate_initial_prompt_records(
|
|
775
|
+
manifest=context.manifest,
|
|
776
|
+
records=records,
|
|
777
|
+
)
|
|
778
|
+
if errors:
|
|
779
|
+
raise InitialPromptMaterializationError(
|
|
780
|
+
"validation_failed",
|
|
781
|
+
"published initial prompt contract: " + "; ".join(errors),
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _item_record(
|
|
786
|
+
context: _MaterializationContext,
|
|
787
|
+
item: _PromptItem,
|
|
788
|
+
) -> PromptRecord:
|
|
789
|
+
path = item.final_path if item.existed else _required_temp_path(item)
|
|
790
|
+
return PromptRecord(
|
|
791
|
+
item.worker.worker_id,
|
|
792
|
+
"initial",
|
|
793
|
+
path,
|
|
794
|
+
expected_model=item.worker.model,
|
|
795
|
+
expected_delivery_mode=context.request.delivery_mode.value,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _published_record(
|
|
800
|
+
context: _MaterializationContext,
|
|
801
|
+
prompt: _PublishedPrompt,
|
|
802
|
+
) -> PromptRecord:
|
|
803
|
+
return PromptRecord(
|
|
804
|
+
prompt.worker.worker_id,
|
|
805
|
+
"initial",
|
|
806
|
+
prompt.path,
|
|
807
|
+
expected_model=prompt.worker.model,
|
|
808
|
+
expected_delivery_mode=context.request.delivery_mode.value,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _worker_prompt_path(
|
|
813
|
+
context: _MaterializationContext,
|
|
814
|
+
worker_id: str,
|
|
815
|
+
) -> Path:
|
|
816
|
+
paths = context.manifest.get("workerPromptPathByWorkerId")
|
|
817
|
+
if not isinstance(paths, Mapping):
|
|
818
|
+
raise InitialPromptMaterializationError(
|
|
819
|
+
"required_input_missing",
|
|
820
|
+
"run manifest has no workerPromptPathByWorkerId object",
|
|
821
|
+
)
|
|
822
|
+
value = _required_string(paths, worker_id)
|
|
823
|
+
return _resolve_owned_artifact_path(
|
|
824
|
+
context.project_root,
|
|
825
|
+
Path(value),
|
|
826
|
+
"initial prompt path",
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _resolve_owned_artifact_path(
|
|
831
|
+
project_root: Path,
|
|
832
|
+
path: Path,
|
|
833
|
+
label: str,
|
|
834
|
+
) -> Path:
|
|
835
|
+
resolved = _resolve_input_path(project_root, path).resolve()
|
|
836
|
+
artifact_root = (project_root / ".okstra").resolve()
|
|
837
|
+
if resolved == artifact_root or artifact_root not in resolved.parents:
|
|
838
|
+
raise InitialPromptMaterializationError(
|
|
839
|
+
"required_input_missing",
|
|
840
|
+
f"{label} is outside .okstra: {resolved}",
|
|
841
|
+
)
|
|
842
|
+
return resolved
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _result_paths(
|
|
846
|
+
context: _MaterializationContext,
|
|
847
|
+
state: Mapping[str, Any],
|
|
848
|
+
worker_id: str,
|
|
849
|
+
) -> tuple[str, str | None]:
|
|
850
|
+
worker_result = _required_string(state, "resultPath")
|
|
851
|
+
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
852
|
+
return worker_result, None
|
|
853
|
+
expected_report = _required_string(context.manifest, "expectedReportPath")
|
|
854
|
+
return str(final_report_data_path(Path(expected_report))), worker_result
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _worktree_lines(
|
|
858
|
+
context: _MaterializationContext,
|
|
859
|
+
plan: PromptPlan,
|
|
860
|
+
) -> list[str]:
|
|
861
|
+
if plan.audience not in {
|
|
862
|
+
"implementation-executor",
|
|
863
|
+
"implementation-verifier",
|
|
864
|
+
}:
|
|
865
|
+
return []
|
|
866
|
+
executor_worktree = context.active_context.get("executorWorktree")
|
|
867
|
+
value = (
|
|
868
|
+
_string_value(executor_worktree.get("path"))
|
|
869
|
+
if isinstance(executor_worktree, Mapping)
|
|
870
|
+
else ""
|
|
871
|
+
)
|
|
872
|
+
if not value:
|
|
873
|
+
raise InitialPromptMaterializationError(
|
|
874
|
+
"required_input_missing",
|
|
875
|
+
"implementation prompt generation requires a worktree path",
|
|
876
|
+
)
|
|
877
|
+
lines = [f"**Worktree:** {value}"]
|
|
878
|
+
if plan.audience == "implementation-executor":
|
|
879
|
+
lines.append(f"cwd for every mutating command: {value}")
|
|
880
|
+
return lines
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _prompt_plan(
|
|
884
|
+
manifest: Mapping[str, Any],
|
|
885
|
+
worker_id: str,
|
|
886
|
+
) -> PromptPlan:
|
|
887
|
+
try:
|
|
888
|
+
return resolve_prompt_plan_for_manifest(
|
|
889
|
+
manifest=manifest,
|
|
890
|
+
worker_id=worker_id,
|
|
891
|
+
dispatch_kind="initial",
|
|
892
|
+
)
|
|
893
|
+
except ValueError as exc:
|
|
894
|
+
raise InitialPromptMaterializationError(
|
|
895
|
+
"required_input_missing",
|
|
896
|
+
str(exc),
|
|
897
|
+
) from exc
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _worker_state(
|
|
901
|
+
team_state: Mapping[str, Any],
|
|
902
|
+
worker_id: str,
|
|
903
|
+
) -> Mapping[str, Any]:
|
|
904
|
+
workers = team_state.get("workers")
|
|
905
|
+
if isinstance(workers, list):
|
|
906
|
+
for worker in workers:
|
|
907
|
+
if isinstance(worker, Mapping) and worker.get("workerId") == worker_id:
|
|
908
|
+
return worker
|
|
909
|
+
raise InitialPromptMaterializationError(
|
|
910
|
+
"required_input_missing",
|
|
911
|
+
f"team-state has no workerId={worker_id}",
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def _load_referenced_json(
|
|
916
|
+
project_root: Path,
|
|
917
|
+
payload: Mapping[str, Any],
|
|
918
|
+
key: str,
|
|
919
|
+
) -> Mapping[str, Any]:
|
|
920
|
+
value = _required_string(payload, key)
|
|
921
|
+
return _load_json_object(
|
|
922
|
+
_resolve_input_path(project_root, Path(value)),
|
|
923
|
+
key,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _load_json_object(path: Path, label: str) -> Mapping[str, Any]:
|
|
928
|
+
try:
|
|
929
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
930
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
931
|
+
raise InitialPromptMaterializationError(
|
|
932
|
+
"required_input_missing",
|
|
933
|
+
f"cannot load {label}: {path}: {exc}",
|
|
934
|
+
) from exc
|
|
935
|
+
if not isinstance(payload, dict):
|
|
936
|
+
raise InitialPromptMaterializationError(
|
|
937
|
+
"required_input_missing",
|
|
938
|
+
f"{label} must be a JSON object: {path}",
|
|
939
|
+
)
|
|
940
|
+
return payload
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _required_string(payload: Mapping[str, Any], key: str) -> str:
|
|
944
|
+
value = _string_value(payload.get(key))
|
|
945
|
+
if not value:
|
|
946
|
+
raise InitialPromptMaterializationError(
|
|
947
|
+
"required_input_missing",
|
|
948
|
+
f"missing required string field: {key}",
|
|
949
|
+
)
|
|
950
|
+
return value
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _validate_worker_request(worker: InitialPromptWorkerRequest) -> None:
|
|
954
|
+
if worker.worker_id.strip() and worker.model.strip():
|
|
955
|
+
return
|
|
956
|
+
raise InitialPromptMaterializationError(
|
|
957
|
+
"required_input_missing",
|
|
958
|
+
"worker_id and model are required",
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _worker_role(plan: PromptPlan) -> str:
|
|
963
|
+
if plan.audience == "implementation-executor":
|
|
964
|
+
return "executor"
|
|
965
|
+
if plan.audience == "implementation-verifier":
|
|
966
|
+
return "verifier"
|
|
967
|
+
return "worker"
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _existing_metadata_reason(
|
|
971
|
+
errors: Sequence[str],
|
|
972
|
+
) -> MaterializationReason:
|
|
973
|
+
if any("does not match requested" in error for error in errors):
|
|
974
|
+
return "existing_prompt_conflict"
|
|
975
|
+
return "existing_prompt_invalid"
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _validate_existing_record_subset(
|
|
979
|
+
manifest: Mapping[str, Any],
|
|
980
|
+
records: Sequence[PromptRecord],
|
|
981
|
+
stage: str,
|
|
982
|
+
) -> None:
|
|
983
|
+
errors = validate_initial_prompt_records(
|
|
984
|
+
manifest=manifest,
|
|
985
|
+
records=records,
|
|
986
|
+
)
|
|
987
|
+
if errors:
|
|
988
|
+
raise InitialPromptMaterializationError(
|
|
989
|
+
"existing_prompt_invalid",
|
|
990
|
+
f"{stage} existing initial prompt contract: " + "; ".join(errors),
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _published_prompt(
|
|
995
|
+
item: _PromptItem,
|
|
996
|
+
*,
|
|
997
|
+
existed: bool,
|
|
998
|
+
) -> _PublishedPrompt:
|
|
999
|
+
return _PublishedPrompt(
|
|
1000
|
+
worker=item.worker,
|
|
1001
|
+
path=item.final_path,
|
|
1002
|
+
plan=item.plan,
|
|
1003
|
+
existed=existed,
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _require_readable_prompt(
|
|
1008
|
+
path: Path,
|
|
1009
|
+
worker_id: str,
|
|
1010
|
+
reason: MaterializationReason,
|
|
1011
|
+
) -> str:
|
|
1012
|
+
try:
|
|
1013
|
+
return path.read_text(encoding="utf-8")
|
|
1014
|
+
except (OSError, UnicodeError) as exc:
|
|
1015
|
+
raise InitialPromptMaterializationError(
|
|
1016
|
+
reason,
|
|
1017
|
+
f"cannot read prompt for {worker_id}: {path}: {exc}",
|
|
1018
|
+
) from exc
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _required_temp_path(item: _PromptItem) -> Path:
|
|
1022
|
+
if item.temp_path is not None:
|
|
1023
|
+
return item.temp_path
|
|
1024
|
+
raise InitialPromptMaterializationError(
|
|
1025
|
+
"render_failed",
|
|
1026
|
+
f"no staging file exists for {item.worker.worker_id}",
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _project_relative(project_root: Path, path: Path) -> str:
|
|
1031
|
+
return path.relative_to(project_root).as_posix()
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _resolve_input_path(project_root: Path, path: Path) -> Path:
|
|
1035
|
+
return path if path.is_absolute() else project_root / path
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _remove_temp_files(
|
|
1039
|
+
items: Sequence[_PromptItem],
|
|
1040
|
+
) -> OSError | None:
|
|
1041
|
+
first_error = None
|
|
1042
|
+
for item in items:
|
|
1043
|
+
if item.temp_path is None:
|
|
1044
|
+
continue
|
|
1045
|
+
try:
|
|
1046
|
+
item.temp_path.unlink(missing_ok=True)
|
|
1047
|
+
except OSError as exc:
|
|
1048
|
+
first_error = first_error or exc
|
|
1049
|
+
return first_error
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _string_value(value: Any) -> str:
|
|
1053
|
+
return value.strip() if isinstance(value, str) else ""
|