okstra 0.128.0 → 0.129.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/docs/architecture/storage-model.md +15 -2
  2. package/docs/architecture.md +21 -5
  3. package/docs/cli.md +13 -2
  4. package/docs/project-structure-overview.md +22 -7
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -3
  8. package/runtime/agents/workers/claude-worker.md +4 -4
  9. package/runtime/agents/workers/codex-worker.md +3 -3
  10. package/runtime/agents/workers/report-writer-worker.md +6 -6
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -1
  12. package/runtime/prompts/lead/adapters/codex.md +1 -0
  13. package/runtime/prompts/lead/adapters/external.md +1 -0
  14. package/runtime/prompts/lead/convergence.md +42 -84
  15. package/runtime/prompts/lead/okstra-lead-contract.md +10 -8
  16. package/runtime/prompts/lead/report-writer.md +22 -28
  17. package/runtime/prompts/lead/team-contract.md +28 -11
  18. package/runtime/prompts/profiles/_common-contract.md +1 -1
  19. package/runtime/prompts/profiles/error-analysis.md +1 -1
  20. package/runtime/prompts/profiles/implementation-planning.md +5 -0
  21. package/runtime/prompts/profiles/improvement-discovery.md +11 -1
  22. package/runtime/prompts/profiles/requirements-discovery.md +8 -1
  23. package/runtime/python/okstra_ctl/analysis_packet.py +7 -13
  24. package/runtime/python/okstra_ctl/codex_dispatch.py +70 -319
  25. package/runtime/python/okstra_ctl/context_cost.py +82 -7
  26. package/runtime/python/okstra_ctl/convergence.py +223 -0
  27. package/runtime/python/okstra_ctl/convergence_engine.py +1152 -0
  28. package/runtime/python/okstra_ctl/convergence_migration.py +184 -0
  29. package/runtime/python/okstra_ctl/convergence_store.py +85 -0
  30. package/runtime/python/okstra_ctl/dispatch_core.py +53 -14
  31. package/runtime/python/okstra_ctl/improvement_assignment.py +61 -0
  32. package/runtime/python/okstra_ctl/path_hints.py +23 -1
  33. package/runtime/python/okstra_ctl/paths.py +10 -1
  34. package/runtime/python/okstra_ctl/render.py +56 -11
  35. package/runtime/python/okstra_ctl/report_finalize.py +394 -0
  36. package/runtime/python/okstra_ctl/worker_liveness.py +6 -2
  37. package/runtime/python/okstra_ctl/worker_prompt_body.py +107 -0
  38. package/runtime/python/okstra_ctl/worker_prompt_contract.py +102 -9
  39. package/runtime/python/okstra_ctl/worker_prompt_headers.py +79 -9
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +158 -0
  41. package/runtime/templates/implementation-worker-preamble.md +51 -0
  42. package/runtime/templates/operating-standard.md +4 -4
  43. package/runtime/templates/report-writer-prompt-preamble.md +37 -0
  44. package/runtime/templates/worker-error-contract.md +46 -0
  45. package/runtime/templates/worker-prompt-preamble.md +28 -234
  46. package/runtime/validators/lib/fixtures.sh +27 -12
  47. package/runtime/validators/validate-run.py +201 -72
  48. package/src/cli-registry.mjs +18 -0
  49. package/src/commands/execute/convergence.mjs +34 -0
  50. package/src/commands/report/finalize.mjs +65 -0
@@ -0,0 +1,394 @@
1
+ """Phase 7 report post-processing — the single reference point.
2
+
3
+ Phase 7 turns a Phase 6 final-report data.json into shippable artifacts through
4
+ four ordered steps: usage substitution, html view rendering, follow-up task
5
+ spawning, and run validation. The order is load-bearing — rendering before
6
+ substitution ships `--` token cells, and validating before rendering trips the
7
+ report-views contract.
8
+
9
+ Every lead adapter drives Phase 7 through this module: the Codex adapter calls
10
+ it in-process (``codex_dispatch``), and a Claude-led run reaches the same code
11
+ through the ``okstra report-finalize`` CLI. Neither reimplements the sequence.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import subprocess
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Mapping, Sequence
22
+
23
+ from .final_report_paths import final_report_data_path, final_report_markdown_path
24
+ from .paths import task_dir
25
+
26
+
27
+ STEP_TOKEN_USAGE = "token-usage"
28
+ STEP_RENDER_VIEWS = "render-views"
29
+ STEP_SPAWN_FOLLOWUPS = "spawn-followups"
30
+ STEP_VALIDATE_RUN = "validate-run"
31
+
32
+ STEP_ORDER = (
33
+ STEP_TOKEN_USAGE,
34
+ STEP_RENDER_VIEWS,
35
+ STEP_SPAWN_FOLLOWUPS,
36
+ STEP_VALIDATE_RUN,
37
+ )
38
+
39
+
40
+ class FinalizeError(Exception):
41
+ """Raised when the Phase 7 sequence cannot be assembled."""
42
+
43
+
44
+ def string_value(value: Any) -> str:
45
+ if not isinstance(value, str):
46
+ return ""
47
+ return value.strip()
48
+
49
+
50
+ def require_string(payload: Mapping[str, Any], key: str) -> str:
51
+ value = payload.get(key)
52
+ if not isinstance(value, str) or not value.strip():
53
+ raise FinalizeError(f"missing required string field: {key}")
54
+ return value
55
+
56
+
57
+ def resolve_project_path(project_root: Path, value: str) -> Path:
58
+ path = Path(value)
59
+ return path if path.is_absolute() else project_root / path
60
+
61
+
62
+ def resolve_optional_path(project_root: Path, value: Any) -> Path | None:
63
+ if not isinstance(value, str) or not value.strip():
64
+ return None
65
+ return resolve_project_path(project_root, value)
66
+
67
+
68
+ def project_relative_path(project_root: Path, path: Path) -> str:
69
+ try:
70
+ return path.relative_to(project_root).as_posix()
71
+ except ValueError:
72
+ return str(path)
73
+
74
+
75
+ def task_group(manifest: Mapping[str, Any]) -> str:
76
+ value = string_value(manifest.get("taskGroup"))
77
+ if value:
78
+ return value
79
+ task_key = require_string(manifest, "taskKey")
80
+ colon_parts = task_key.split(":")
81
+ if len(colon_parts) >= 3 and colon_parts[-2].strip():
82
+ return colon_parts[-2].strip()
83
+ slash_parts = task_key.split("/")
84
+ if len(slash_parts) >= 2 and slash_parts[0].strip():
85
+ return slash_parts[0].strip()
86
+ raise FinalizeError(f"cannot infer task group from taskKey: {task_key}")
87
+
88
+
89
+ def task_id(manifest: Mapping[str, Any]) -> str:
90
+ value = string_value(manifest.get("taskId"))
91
+ if value:
92
+ return value
93
+ task_key = require_string(manifest, "taskKey")
94
+ colon_parts = task_key.split(":")
95
+ if len(colon_parts) >= 3 and colon_parts[-1].strip():
96
+ return colon_parts[-1].strip()
97
+ slash_parts = task_key.split("/")
98
+ if len(slash_parts) >= 2 and slash_parts[-1].strip():
99
+ return slash_parts[-1].strip()
100
+ raise FinalizeError(f"cannot infer task id from taskKey: {task_key}")
101
+
102
+
103
+ def run_seq(manifest: Mapping[str, Any]) -> str:
104
+ seqs = manifest.get("runSequencesByCategory")
105
+ if not isinstance(seqs, Mapping):
106
+ raise FinalizeError("run manifest has no runSequencesByCategory object")
107
+ return require_string(seqs, "manifests")
108
+
109
+
110
+ def task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
111
+ value = string_value(manifest.get("taskManifestPath"))
112
+ if value:
113
+ return resolve_project_path(project_root, value)
114
+ task_root = string_value(manifest.get("taskRootPath"))
115
+ if task_root:
116
+ return resolve_project_path(project_root, task_root) / "task-manifest.json"
117
+ return (
118
+ task_dir(project_root, task_group(manifest), task_id(manifest))
119
+ / "task-manifest.json"
120
+ )
121
+
122
+
123
+ def resolve_workspace_script(workspace_root: Path, script_name: str) -> Path:
124
+ return _resolve_workspace_file(workspace_root, "scripts", script_name)
125
+
126
+
127
+ def resolve_workspace_validator(workspace_root: Path, validator_name: str) -> Path:
128
+ return _resolve_workspace_file(workspace_root, "validators", validator_name)
129
+
130
+
131
+ def _resolve_workspace_file(
132
+ workspace_root: Path,
133
+ subdir: str,
134
+ name: str,
135
+ ) -> Path:
136
+ candidates = [workspace_root / subdir / name, workspace_root / name]
137
+ for candidate in candidates:
138
+ if candidate.is_file():
139
+ return candidate.resolve()
140
+ searched = ", ".join(str(candidate) for candidate in candidates)
141
+ raise FinalizeError(f"{name} not found (searched: {searched})")
142
+
143
+
144
+ def tail(text: str, *, limit: int = 4000) -> str:
145
+ if len(text) <= limit:
146
+ return text
147
+ return text[-limit:]
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class FinalizeContext:
152
+ """Everything the Phase 7 sequence needs, resolved from a run manifest."""
153
+
154
+ project_root: Path
155
+ workspace_root: Path
156
+ manifest_path: Path
157
+ team_state_path: Path
158
+ data_path: Path
159
+ task_manifest_path: Path
160
+ task_key: str
161
+ task_type: str
162
+ task_group: str
163
+ seq: str
164
+ final_status_path: Path | None
165
+
166
+ @property
167
+ def markdown_path(self) -> Path:
168
+ return final_report_markdown_path(self.data_path)
169
+
170
+ @classmethod
171
+ def from_manifest(
172
+ cls,
173
+ *,
174
+ project_root: Path,
175
+ workspace_root: Path,
176
+ manifest_path: Path,
177
+ team_state_path: Path,
178
+ data_path: Path,
179
+ manifest: Mapping[str, Any],
180
+ ) -> "FinalizeContext":
181
+ return cls(
182
+ project_root=project_root,
183
+ workspace_root=workspace_root,
184
+ manifest_path=manifest_path,
185
+ team_state_path=team_state_path,
186
+ data_path=data_path,
187
+ task_manifest_path=task_manifest_path(project_root, manifest),
188
+ task_key=require_string(manifest, "taskKey"),
189
+ task_type=require_string(manifest, "taskType"),
190
+ task_group=task_group(manifest),
191
+ seq=run_seq(manifest),
192
+ final_status_path=resolve_optional_path(
193
+ project_root, manifest.get("finalStatusPath")
194
+ ),
195
+ )
196
+
197
+
198
+ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
199
+ """Assemble the ordered Phase 7 argv list. Order is contractual."""
200
+ markdown_path = ctx.markdown_path
201
+ return [
202
+ (
203
+ STEP_TOKEN_USAGE,
204
+ [
205
+ sys.executable,
206
+ str(resolve_workspace_script(ctx.workspace_root, "okstra-token-usage.py")),
207
+ str(ctx.team_state_path),
208
+ "--project-root",
209
+ str(ctx.project_root),
210
+ "--write",
211
+ "--substitute-data",
212
+ str(ctx.data_path),
213
+ ],
214
+ ),
215
+ (
216
+ STEP_RENDER_VIEWS,
217
+ [
218
+ sys.executable,
219
+ str(
220
+ resolve_workspace_script(
221
+ ctx.workspace_root, "okstra-render-report-views.py"
222
+ )
223
+ ),
224
+ str(markdown_path),
225
+ "--task-key",
226
+ ctx.task_key,
227
+ "--task-type",
228
+ ctx.task_type,
229
+ "--seq",
230
+ ctx.seq,
231
+ "--source-report",
232
+ project_relative_path(ctx.project_root, markdown_path),
233
+ ],
234
+ ),
235
+ (
236
+ STEP_SPAWN_FOLLOWUPS,
237
+ [
238
+ sys.executable,
239
+ str(
240
+ resolve_workspace_script(
241
+ ctx.workspace_root, "okstra-spawn-followups.py"
242
+ )
243
+ ),
244
+ str(ctx.data_path),
245
+ "--project-root",
246
+ str(ctx.project_root),
247
+ "--task-group",
248
+ ctx.task_group,
249
+ "--parent-task-key",
250
+ ctx.task_key,
251
+ ],
252
+ ),
253
+ (STEP_VALIDATE_RUN, _validate_run_command(ctx, markdown_path)),
254
+ ]
255
+
256
+
257
+ def _validate_run_command(ctx: FinalizeContext, markdown_path: Path) -> list[str]:
258
+ command = [
259
+ sys.executable,
260
+ str(resolve_workspace_validator(ctx.workspace_root, "validate-run.py")),
261
+ "--team-state",
262
+ str(ctx.team_state_path),
263
+ "--report",
264
+ str(markdown_path),
265
+ "--run-manifest",
266
+ str(ctx.manifest_path),
267
+ "--task-manifest",
268
+ str(ctx.task_manifest_path),
269
+ ]
270
+ if ctx.final_status_path is not None:
271
+ command.extend(["--final-status", str(ctx.final_status_path)])
272
+ return command
273
+
274
+
275
+ def step_payload(
276
+ name: str,
277
+ command: Sequence[str],
278
+ result: subprocess.CompletedProcess[str],
279
+ ) -> dict[str, Any]:
280
+ return {
281
+ "name": name,
282
+ "command": list(command),
283
+ "exitCode": result.returncode,
284
+ "stdoutTail": tail(result.stdout),
285
+ "stderrTail": tail(result.stderr),
286
+ }
287
+
288
+
289
+ def run_finalize(
290
+ ctx: FinalizeContext,
291
+ *,
292
+ before_step: Callable[[str], None] | None = None,
293
+ ) -> dict[str, Any]:
294
+ """Run the Phase 7 steps in order, stopping at the first non-zero exit.
295
+
296
+ ``before_step`` fires immediately before each step is spawned, letting an
297
+ adapter settle state the step will read (the Codex adapter marks the report
298
+ writer `completed` before `validate-run` inspects team-state).
299
+ """
300
+ steps: list[dict[str, Any]] = []
301
+ try:
302
+ commands = build_commands(ctx)
303
+ except FinalizeError as exc:
304
+ return {"ok": False, "reason": str(exc), "steps": steps}
305
+
306
+ for name, command in commands:
307
+ if before_step is not None:
308
+ before_step(name)
309
+ result = subprocess.run(
310
+ command,
311
+ cwd=ctx.project_root,
312
+ text=True,
313
+ capture_output=True,
314
+ )
315
+ steps.append(step_payload(name, command, result))
316
+ if result.returncode != 0:
317
+ return {
318
+ "ok": False,
319
+ "reason": f"{name} failed with exit code {result.returncode}",
320
+ "steps": steps,
321
+ }
322
+ return {"ok": True, "reason": "", "steps": steps}
323
+
324
+
325
+ def _load_manifest(path: Path) -> Mapping[str, Any]:
326
+ try:
327
+ payload = json.loads(path.read_text(encoding="utf-8"))
328
+ except FileNotFoundError as exc:
329
+ raise FinalizeError(f"run manifest not found: {path}") from exc
330
+ except json.JSONDecodeError as exc:
331
+ raise FinalizeError(f"run manifest is not valid JSON: {path} ({exc})") from exc
332
+ if not isinstance(payload, Mapping):
333
+ raise FinalizeError(f"run manifest is not a JSON object: {path}")
334
+ return payload
335
+
336
+
337
+ def _context_from_args(args: argparse.Namespace) -> FinalizeContext:
338
+ project_root = Path(args.project_root).resolve()
339
+ manifest_path = resolve_project_path(project_root, args.run_manifest)
340
+ manifest = _load_manifest(manifest_path)
341
+ if args.team_state:
342
+ team_state_path = resolve_project_path(project_root, args.team_state)
343
+ else:
344
+ team_state_path = resolve_project_path(
345
+ project_root, require_string(manifest, "teamStatePath")
346
+ )
347
+ report_path = resolve_project_path(project_root, args.report)
348
+ return FinalizeContext.from_manifest(
349
+ project_root=project_root,
350
+ workspace_root=Path(args.workspace_root).resolve(),
351
+ manifest_path=manifest_path,
352
+ team_state_path=team_state_path,
353
+ data_path=final_report_data_path(report_path),
354
+ manifest=manifest,
355
+ )
356
+
357
+
358
+ def _parser() -> argparse.ArgumentParser:
359
+ parser = argparse.ArgumentParser(
360
+ description="Run the Phase 7 report post-processing sequence.",
361
+ )
362
+ parser.add_argument("--project-root", required=True)
363
+ parser.add_argument("--run-manifest", required=True)
364
+ parser.add_argument(
365
+ "--report",
366
+ required=True,
367
+ help="final-report markdown path (its .data.json sibling is derived)",
368
+ )
369
+ parser.add_argument("--workspace-root", required=True)
370
+ parser.add_argument(
371
+ "--team-state",
372
+ default="",
373
+ help="defaults to the run manifest's teamStatePath",
374
+ )
375
+ return parser
376
+
377
+
378
+ def main(argv: Sequence[str] | None = None) -> int:
379
+ args = _parser().parse_args(argv)
380
+ try:
381
+ ctx = _context_from_args(args)
382
+ except FinalizeError as exc:
383
+ print(f"error: {exc}", file=sys.stderr)
384
+ return 2
385
+ result = run_finalize(ctx)
386
+ print(json.dumps(result, indent=2, ensure_ascii=False))
387
+ if not result["ok"]:
388
+ print(f"error: {result['reason']}", file=sys.stderr)
389
+ return 1
390
+ return 0
391
+
392
+
393
+ if __name__ == "__main__":
394
+ raise SystemExit(main())
@@ -110,9 +110,13 @@ def main(argv: list[str] | None = None) -> int:
110
110
  description="Report whether pending workers are still alive (read-only).",
111
111
  )
112
112
  parser.add_argument("--audit", action="append", default=[],
113
- help="claude-worker audit sidecar path (repeatable)")
113
+ help="in-process claude-worker audit sidecar path (repeatable)")
114
114
  parser.add_argument("--prompt", action="append", default=[],
115
- help="CLI-wrapper prompt-history path (repeatable)")
115
+ help="CLI-wrapper (codex/antigravity) prompt-history path "
116
+ "(repeatable). Only the wrappers write the artifacts "
117
+ "this probe looks for, so passing an in-process "
118
+ "claude-worker path here always reports "
119
+ "did-not-launch — probe those with --audit")
116
120
  parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
117
121
  help="heartbeat staleness budget in seconds")
118
122
  parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
@@ -0,0 +1,107 @@
1
+ """Shared initial analysis-worker prompt body rendering."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Mapping, Sequence
5
+
6
+ from .worker_prompt_policy import PromptPlan
7
+
8
+
9
+ ANALYSIS_WORKER_LABELS = {
10
+ "claude": "Claude worker",
11
+ "codex": "Codex worker",
12
+ "antigravity": "Antigravity worker",
13
+ }
14
+
15
+
16
+ def analysis_prompt_body(
17
+ manifest: Mapping[str, Any],
18
+ active_context: Mapping[str, Any],
19
+ worker_id: str,
20
+ model: str,
21
+ role: str,
22
+ plan: PromptPlan,
23
+ ) -> list[str]:
24
+ """Render the provider-neutral body for an initial analysis audience."""
25
+ label = ANALYSIS_WORKER_LABELS.get(worker_id, f"{worker_id} worker")
26
+ return [
27
+ f"**Model:** {label}, {model}",
28
+ f"**Pane role:** {role}",
29
+ "",
30
+ f"# {label} Dispatch",
31
+ "",
32
+ "## Role",
33
+ (
34
+ f"You are the {label} for okstra cross-verification. "
35
+ "Produce an independent worker result."
36
+ ),
37
+ "",
38
+ "## Task",
39
+ f"- Task key: `{_require_string(manifest, 'taskKey')}`",
40
+ f"- Task type: `{_require_string(manifest, 'taskType')}`",
41
+ "",
42
+ "## Inputs",
43
+ *analysis_input_lines(manifest, active_context, plan),
44
+ "",
45
+ mcp_pointer_line(),
46
+ "",
47
+ "## Output Contract",
48
+ "- Read the Worker Preamble Path end-to-end before analysis.",
49
+ "- Write the worker result to Result Path and no other canonical result path.",
50
+ "- Write the audit sidecar and any tool-failure entries as the preamble requires.",
51
+ "- Cite evidence with file paths and line numbers whenever you make a claim.",
52
+ ]
53
+
54
+
55
+ def analysis_input_lines(
56
+ manifest: Mapping[str, Any],
57
+ active_context: Mapping[str, Any],
58
+ plan: PromptPlan,
59
+ ) -> list[str]:
60
+ if plan.packet_only:
61
+ packet_path = instruction_path(manifest, active_context, "analysisPacketPath")
62
+ return existing_input_lines((("Primary analysis packet", packet_path),))
63
+ inputs = [
64
+ ("Primary analysis packet", instruction_path(manifest, active_context, "analysisPacketPath")),
65
+ ("Task brief", instruction_path(manifest, active_context, "taskBriefPath")),
66
+ ("Analysis profile", instruction_path(manifest, active_context, "analysisProfilePath")),
67
+ ("Analysis material", instruction_path(manifest, active_context, "analysisMaterialPath")),
68
+ ("Reference expectations", instruction_path(manifest, active_context, "referenceExpectationsPath")),
69
+ ("Clarification response", instruction_path(manifest, active_context, "clarificationResponsePath")),
70
+ ]
71
+ return existing_input_lines(inputs)
72
+
73
+
74
+ def existing_input_lines(inputs: Sequence[tuple[str, str]]) -> list[str]:
75
+ lines = [f"- {label}: `{path}`" for label, path in inputs if path]
76
+ return lines or ["- No input paths were recorded in active-run-context."]
77
+
78
+
79
+ def instruction_path(
80
+ manifest: Mapping[str, Any],
81
+ active_context: Mapping[str, Any],
82
+ key: str,
83
+ ) -> str:
84
+ instruction_set = active_context.get("instructionSet")
85
+ if isinstance(instruction_set, Mapping):
86
+ value = _string_value(instruction_set.get(key))
87
+ if value:
88
+ return value
89
+ return _string_value(manifest.get(key))
90
+
91
+
92
+ def mcp_pointer_line() -> str:
93
+ return (
94
+ '**MCP servers:** follow the task brief\'s "## Available MCP Servers" '
95
+ "section (already in your Required reading)."
96
+ )
97
+
98
+
99
+ def _require_string(payload: Mapping[str, Any], key: str) -> str:
100
+ value = _string_value(payload.get(key))
101
+ if not value:
102
+ raise ValueError(f"missing required string field: {key}")
103
+ return value
104
+
105
+
106
+ def _string_value(value: Any) -> str:
107
+ return value.strip() if isinstance(value, str) else ""
@@ -2,14 +2,18 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import re
5
+ from dataclasses import dataclass
5
6
  from pathlib import Path
6
- from typing import Mapping
7
+ from typing import Any, Mapping, Sequence
8
+
9
+ from .worker_prompt_policy import PromptPlan, resolve_prompt_plan_for_manifest
7
10
 
8
11
 
9
12
  MAX_FINAL_VERIFICATION_DIRECTIVE_LINES = 40
10
13
  MAX_FINAL_VERIFICATION_BODY_LINES = 96
11
14
 
12
15
  _DIRECTIVE_HEADING = "## Run-specific directive"
16
+ _WORKER_ERROR_CONTRACT_HEADER = "**Worker Error Contract Path:**"
13
17
  _PRIMARY_PACKET_RE = re.compile(
14
18
  r"(?im)^-\s+Primary analysis packet:\s+`[^`\n]*analysis-packet\.md`\s*$"
15
19
  )
@@ -63,6 +67,13 @@ _WORKER_LABEL_RE = re.compile(
63
67
  )
64
68
 
65
69
 
70
+ @dataclass(frozen=True)
71
+ class PromptRecord:
72
+ worker_id: str
73
+ dispatch_kind: str
74
+ path: Path
75
+
76
+
66
77
  def validate_final_verification_initial_prompt(text: str) -> list[str]:
67
78
  """Return deterministic compact-prompt contract violations."""
68
79
  errors: list[str] = []
@@ -155,23 +166,105 @@ def validate_final_verification_prompt_paths(
155
166
  prompt_paths: Mapping[str, Path],
156
167
  ) -> list[str]:
157
168
  """Validate persisted initial prompts and their normalized equality."""
158
- prompts: dict[str, str] = {}
169
+ records = [
170
+ PromptRecord(worker_id, "initial", path)
171
+ for worker_id, path in sorted(prompt_paths.items())
172
+ ]
173
+ return validate_initial_prompt_records(
174
+ manifest={"taskType": "final-verification"},
175
+ records=records,
176
+ )
177
+
178
+
179
+ def validate_initial_prompt_records(
180
+ *,
181
+ manifest: Mapping[str, Any],
182
+ records: Sequence[PromptRecord],
183
+ ) -> list[str]:
184
+ """Validate prompt audiences and compare their normalized equality groups."""
159
185
  errors: list[str] = []
160
- for worker_id, path in sorted(prompt_paths.items()):
186
+ equality_groups: dict[str, dict[str, str]] = {}
187
+ for record in records:
188
+ plan = _resolve_record_plan(manifest, record, errors)
189
+ if plan is None or plan.audience in {"lead-only", "reverify"}:
190
+ continue
161
191
  try:
162
- text = path.read_text(encoding="utf-8")
192
+ text = record.path.read_text(encoding="utf-8")
163
193
  except OSError as exc:
164
- errors.append(f"{worker_id}: cannot read prompt {path}: {exc}")
194
+ errors.append(
195
+ f"{record.worker_id}: cannot read prompt {record.path}: {exc}"
196
+ )
165
197
  continue
166
- prompts[worker_id] = text
167
198
  errors.extend(
168
- f"{worker_id}: {error}"
169
- for error in validate_final_verification_initial_prompt(text)
199
+ f"{record.worker_id}: {error}"
200
+ for error in _validate_prompt_for_plan(text, plan, manifest)
170
201
  )
171
- errors.extend(validate_analysis_prompt_set(prompts))
202
+ if plan.equality_group:
203
+ group = equality_groups.setdefault(plan.equality_group, {})
204
+ group[record.worker_id] = text
205
+ for prompts in equality_groups.values():
206
+ errors.extend(validate_analysis_prompt_set(prompts))
172
207
  return errors
173
208
 
174
209
 
210
+ def _resolve_record_plan(
211
+ manifest: Mapping[str, Any],
212
+ record: PromptRecord,
213
+ errors: list[str],
214
+ ) -> PromptPlan | None:
215
+ try:
216
+ return resolve_prompt_plan_for_manifest(
217
+ manifest=manifest,
218
+ worker_id=record.worker_id,
219
+ dispatch_kind=record.dispatch_kind,
220
+ )
221
+ except ValueError as exc:
222
+ errors.append(f"{record.worker_id}: {exc}")
223
+ return None
224
+
225
+
226
+ def _validate_prompt_for_plan(
227
+ text: str,
228
+ plan: PromptPlan,
229
+ manifest: Mapping[str, Any],
230
+ ) -> list[str]:
231
+ errors: list[str] = []
232
+ _require_non_empty_header(text, _WORKER_ERROR_CONTRACT_HEADER, errors)
233
+ if manifest.get("taskType") == "final-verification" and plan.audience == "analysis":
234
+ return [*errors, *validate_final_verification_initial_prompt(text)]
235
+ if not plan.allow_coding_preflight:
236
+ _reject_literal(
237
+ text,
238
+ "**Coding preflight pack:**",
239
+ "Coding preflight pack is forbidden for this prompt audience",
240
+ errors,
241
+ )
242
+ for prefix in plan.required_headers:
243
+ _require_non_empty_header(text, prefix, errors)
244
+ if plan.audience == "analysis":
245
+ packet_count = len(_PRIMARY_PACKET_RE.findall(text))
246
+ if packet_count != 1:
247
+ errors.append(
248
+ "exactly one Primary analysis packet path is required "
249
+ f"(found {packet_count})"
250
+ )
251
+ return errors
252
+
253
+
254
+ def _require_non_empty_header(
255
+ text: str,
256
+ prefix: str,
257
+ errors: list[str],
258
+ ) -> None:
259
+ matches = [
260
+ line.strip()
261
+ for line in text.splitlines()
262
+ if line.strip().startswith(prefix)
263
+ ]
264
+ if len(matches) != 1 or not matches[0][len(prefix):].strip():
265
+ errors.append(f"exactly one non-empty {prefix} header is required")
266
+
267
+
175
268
  def _reject_literal(
176
269
  text: str,
177
270
  literal: str,