okstra 0.129.0 → 0.130.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.
@@ -24,8 +24,19 @@ from .final_report_paths import (
24
24
  )
25
25
  from .lead_events import LeadEvent, append_lead_event
26
26
  from .path_hints import hydrate_active_run_context
27
+ from .report_finalize import (
28
+ STEP_VALIDATE_RUN,
29
+ FinalizeContext,
30
+ FinalizeError,
31
+ project_relative_path as _project_relative_path,
32
+ require_string as _require_string,
33
+ resolve_optional_path as _resolve_optional_path,
34
+ resolve_project_path as _resolve_project_path,
35
+ run_finalize,
36
+ run_seq as _run_seq,
37
+ string_value as _string_value,
38
+ )
27
39
  from .wrapper_status import status_path_for_prompt
28
- from .paths import task_dir
29
40
  from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
30
41
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
31
42
  from .worker_prompt_body import (
@@ -348,8 +359,6 @@ def _worker_dispatch_record(
348
359
  }
349
360
 
350
361
 
351
-
352
-
353
362
  def main(argv: Sequence[str] | None = None) -> int:
354
363
  parser = _parser()
355
364
  args = parser.parse_args(argv)
@@ -371,7 +380,7 @@ def main(argv: Sequence[str] | None = None) -> int:
371
380
  code = dispatch_plan(plan)
372
381
  _print_json({**plan.to_payload(dry_run=False), "exitCode": code})
373
382
  return code
374
- except DispatchError as exc:
383
+ except (DispatchError, FinalizeError) as exc:
375
384
  print(f"error: {exc}", file=sys.stderr)
376
385
  return 2
377
386
 
@@ -820,13 +829,6 @@ def _implementation_executor_tail(
820
829
  return "\n\n".join(parts)
821
830
 
822
831
 
823
- def _project_relative_path(project_root: Path, path: Path) -> str:
824
- try:
825
- return path.relative_to(project_root).as_posix()
826
- except ValueError:
827
- return str(path)
828
-
829
-
830
832
  def _resolve_report_language(
831
833
  project_root: Path,
832
834
  manifest: Mapping[str, Any],
@@ -875,12 +877,6 @@ def _infer_report_language(
875
877
  return "ko" if hangul_count >= 10 else "en"
876
878
 
877
879
 
878
- def _string_value(value: Any) -> str:
879
- if not isinstance(value, str):
880
- return ""
881
- return value.strip()
882
-
883
-
884
880
  def _validate_codex_manifest(manifest: Mapping[str, Any], path: Path) -> None:
885
881
  if manifest.get("leadRuntime") != "codex":
886
882
  raise DispatchError(f"run manifest is not a Codex lead run: {path}")
@@ -1066,190 +1062,25 @@ def _post_process_report_writer_result(
1066
1062
  ) -> dict[str, Any]:
1067
1063
  if worker.worker_id != REPORT_WRITER_WORKER_ID:
1068
1064
  return {"ok": True, "reason": "", "steps": []}
1069
- steps = []
1070
1065
  try:
1071
- commands = _report_post_process_commands(plan, worker)
1072
- except DispatchError as exc:
1073
- return {"ok": False, "reason": str(exc), "steps": steps}
1074
- for name, command in commands:
1075
- if name == "validate-run":
1076
- _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
1077
- result = subprocess.run(
1078
- command,
1079
- cwd=plan.project_root,
1080
- text=True,
1081
- capture_output=True,
1066
+ ctx = FinalizeContext.from_manifest(
1067
+ project_root=plan.project_root,
1068
+ workspace_root=plan.workspace_root,
1069
+ manifest_path=plan.manifest_path,
1070
+ team_state_path=plan.team_state_path,
1071
+ data_path=worker.result_path,
1072
+ manifest=plan.manifest,
1082
1073
  )
1083
- step = _post_process_step_payload(name, command, result)
1084
- steps.append(step)
1085
- if result.returncode != 0:
1086
- return {
1087
- "ok": False,
1088
- "reason": f"{name} failed with exit code {result.returncode}",
1089
- "steps": steps,
1090
- }
1091
- return {"ok": True, "reason": "", "steps": steps}
1092
-
1093
-
1094
- def _report_post_process_commands(
1095
- plan: DispatchPlan,
1096
- worker: WorkerDispatch,
1097
- ) -> list[tuple[str, list[str]]]:
1098
- markdown_path = _final_report_markdown_path(worker.result_path)
1099
- commands = [
1100
- (
1101
- "token-usage",
1102
- [
1103
- sys.executable,
1104
- str(_resolve_workspace_script(plan.workspace_root, "okstra-token-usage.py")),
1105
- str(plan.team_state_path),
1106
- "--project-root",
1107
- str(plan.project_root),
1108
- "--write",
1109
- "--substitute-data",
1110
- str(worker.result_path),
1111
- ],
1112
- ),
1113
- (
1114
- "render-views",
1115
- [
1116
- sys.executable,
1117
- str(_resolve_workspace_script(plan.workspace_root, "okstra-render-report-views.py")),
1118
- str(markdown_path),
1119
- "--task-key",
1120
- _require_string(plan.manifest, "taskKey"),
1121
- "--task-type",
1122
- _require_string(plan.manifest, "taskType"),
1123
- "--seq",
1124
- _run_seq(plan.manifest),
1125
- "--source-report",
1126
- _project_relative_path(plan.project_root, markdown_path),
1127
- ],
1128
- ),
1129
- (
1130
- "spawn-followups",
1131
- [
1132
- sys.executable,
1133
- str(_resolve_workspace_script(plan.workspace_root, "okstra-spawn-followups.py")),
1134
- str(worker.result_path),
1135
- "--project-root",
1136
- str(plan.project_root),
1137
- "--task-group",
1138
- _task_group(plan.manifest),
1139
- "--parent-task-key",
1140
- _require_string(plan.manifest, "taskKey"),
1141
- ],
1142
- ),
1143
- ]
1144
- commands.append(("validate-run", _validate_run_command(plan, markdown_path)))
1145
- return commands
1146
-
1147
-
1148
- def _validate_run_command(plan: DispatchPlan, markdown_path: Path) -> list[str]:
1149
- task_manifest_path = _task_manifest_path(plan.project_root, plan.manifest)
1150
- command = [
1151
- sys.executable,
1152
- str(_resolve_workspace_validator(plan.workspace_root, "validate-run.py")),
1153
- "--team-state",
1154
- str(plan.team_state_path),
1155
- "--report",
1156
- str(markdown_path),
1157
- "--run-manifest",
1158
- str(plan.manifest_path),
1159
- "--task-manifest",
1160
- str(task_manifest_path),
1161
- ]
1162
- final_status_path = _resolve_optional_path(
1163
- plan.project_root, plan.manifest.get("finalStatusPath")
1164
- )
1165
- if final_status_path is not None:
1166
- command.extend(["--final-status", str(final_status_path)])
1167
- return command
1168
-
1169
-
1170
- def _task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
1171
- value = _string_value(manifest.get("taskManifestPath"))
1172
- if value:
1173
- return _resolve_project_path(project_root, value)
1174
- task_root = _string_value(manifest.get("taskRootPath"))
1175
- if task_root:
1176
- return _resolve_project_path(project_root, task_root) / "task-manifest.json"
1177
- return (
1178
- task_dir(project_root, _task_group(manifest), _task_id(manifest))
1179
- / "task-manifest.json"
1180
- )
1181
-
1182
-
1183
- def _post_process_step_payload(
1184
- name: str,
1185
- command: Sequence[str],
1186
- result: subprocess.CompletedProcess[str],
1187
- ) -> dict[str, Any]:
1188
- return {
1189
- "name": name,
1190
- "command": list(command),
1191
- "exitCode": result.returncode,
1192
- "stdoutTail": _tail(result.stdout),
1193
- "stderrTail": _tail(result.stderr),
1194
- }
1195
-
1196
-
1197
- def _resolve_workspace_script(workspace_root: Path, script_name: str) -> Path:
1198
- candidates = [
1199
- workspace_root / "scripts" / script_name,
1200
- workspace_root / script_name,
1201
- ]
1202
- for candidate in candidates:
1203
- if candidate.is_file():
1204
- return candidate.resolve()
1205
- searched = ", ".join(str(candidate) for candidate in candidates)
1206
- raise DispatchError(f"{script_name} not found (searched: {searched})")
1207
-
1208
-
1209
- def _resolve_workspace_validator(workspace_root: Path, validator_name: str) -> Path:
1210
- candidates = [
1211
- workspace_root / "validators" / validator_name,
1212
- workspace_root / validator_name,
1213
- ]
1214
- for candidate in candidates:
1215
- if candidate.is_file():
1216
- return candidate.resolve()
1217
- searched = ", ".join(str(candidate) for candidate in candidates)
1218
- raise DispatchError(f"{validator_name} not found (searched: {searched})")
1219
-
1220
-
1221
- def _task_group(manifest: Mapping[str, Any]) -> str:
1222
- value = _string_value(manifest.get("taskGroup"))
1223
- if value:
1224
- return value
1225
- task_key = _require_string(manifest, "taskKey")
1226
- colon_parts = task_key.split(":")
1227
- if len(colon_parts) >= 3 and colon_parts[-2].strip():
1228
- return colon_parts[-2].strip()
1229
- slash_parts = task_key.split("/")
1230
- if len(slash_parts) >= 2 and slash_parts[0].strip():
1231
- return slash_parts[0].strip()
1232
- raise DispatchError(f"cannot infer task group from taskKey: {task_key}")
1233
-
1234
-
1235
- def _task_id(manifest: Mapping[str, Any]) -> str:
1236
- value = _string_value(manifest.get("taskId"))
1237
- if value:
1238
- return value
1239
- task_key = _require_string(manifest, "taskKey")
1240
- colon_parts = task_key.split(":")
1241
- if len(colon_parts) >= 3 and colon_parts[-1].strip():
1242
- return colon_parts[-1].strip()
1243
- slash_parts = task_key.split("/")
1244
- if len(slash_parts) >= 2 and slash_parts[-1].strip():
1245
- return slash_parts[-1].strip()
1246
- raise DispatchError(f"cannot infer task id from taskKey: {task_key}")
1074
+ except FinalizeError as exc:
1075
+ return {"ok": False, "reason": str(exc), "steps": []}
1247
1076
 
1077
+ def settle_before(step_name: str) -> None:
1078
+ # validate-run reads team-state; the writer is done by the time its
1079
+ # artifacts pass the earlier steps.
1080
+ if step_name == STEP_VALIDATE_RUN:
1081
+ _set_worker_status(plan.team_state_path, worker.worker_id, "completed", "")
1248
1082
 
1249
- def _tail(text: str, *, limit: int = 4000) -> str:
1250
- if len(text) <= limit:
1251
- return text
1252
- return text[-limit:]
1083
+ return run_finalize(ctx, before_step=settle_before)
1253
1084
 
1254
1085
 
1255
1086
  def _append_event(
@@ -1411,37 +1242,12 @@ def _resolve_required_path(
1411
1242
  return _resolve_project_path(project_root, _require_string(manifest, key))
1412
1243
 
1413
1244
 
1414
- def _resolve_optional_path(project_root: Path, value: Any) -> Path | None:
1415
- if not isinstance(value, str) or not value.strip():
1416
- return None
1417
- return _resolve_project_path(project_root, value)
1418
-
1419
-
1420
- def _resolve_project_path(project_root: Path, value: str) -> Path:
1421
- path = Path(value)
1422
- return path if path.is_absolute() else project_root / path
1423
-
1424
-
1425
- def _require_string(payload: Mapping[str, Any], key: str) -> str:
1426
- value = payload.get(key)
1427
- if not isinstance(value, str) or not value.strip():
1428
- raise DispatchError(f"missing required string field: {key}")
1429
- return value
1430
-
1431
-
1432
1245
  def _string_list(value: Any) -> list[str]:
1433
1246
  if not isinstance(value, list):
1434
1247
  return []
1435
1248
  return [str(item).strip() for item in value if str(item).strip()]
1436
1249
 
1437
1250
 
1438
- def _run_seq(manifest: Mapping[str, Any]) -> str:
1439
- seqs = manifest.get("runSequencesByCategory")
1440
- if not isinstance(seqs, Mapping):
1441
- raise DispatchError("run manifest has no runSequencesByCategory object")
1442
- return _require_string(seqs, "manifests")
1443
-
1444
-
1445
1251
  def _failure_reason(exit_code: int, missing_paths: Sequence[Path]) -> str:
1446
1252
  if exit_code != 0:
1447
1253
  return f"wrapper exited with code {exit_code}"
@@ -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())