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.
- package/docs/architecture.md +2 -2
- package/docs/cli.md +5 -2
- package/docs/project-structure-overview.md +8 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +7 -0
- package/runtime/agents/workers/codex-worker.md +7 -0
- package/runtime/agents/workers/report-writer-worker.md +2 -1
- package/runtime/bin/okstra-error-log.py +91 -8
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -0
- package/runtime/prompts/lead/report-writer.md +19 -26
- package/runtime/prompts/lead/team-contract.md +17 -2
- package/runtime/prompts/profiles/implementation-planning.md +10 -2
- package/runtime/prompts/profiles/requirements-discovery.md +2 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +28 -222
- package/runtime/python/okstra_ctl/report_finalize.py +394 -0
- package/runtime/python/okstra_ctl/scope_provenance.py +129 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +6 -2
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +2 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +4 -1
- package/runtime/templates/reports/fan-out-unit.template.md +11 -0
- package/runtime/templates/worker-error-contract.md +11 -1
- package/runtime/validators/lib/common.sh +0 -9
- package/runtime/validators/validate-run.py +154 -4
- package/runtime/validators/validate_fanout.py +62 -2
- package/src/cli-registry.mjs +11 -0
- package/src/commands/report/finalize.mjs +65 -0
|
@@ -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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
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())
|