okstra 0.1.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.
Files changed (106) hide show
  1. package/README.md +36 -0
  2. package/bin/okstra +62 -0
  3. package/package.json +30 -0
  4. package/runtime/.gitkeep +0 -0
  5. package/runtime/BUILD.json +5 -0
  6. package/runtime/agents/SKILL.md +243 -0
  7. package/runtime/agents/TODO.md +168 -0
  8. package/runtime/agents/workers/claude-worker.md +106 -0
  9. package/runtime/agents/workers/codex-worker.md +179 -0
  10. package/runtime/agents/workers/gemini-worker.md +179 -0
  11. package/runtime/agents/workers/report-writer-worker.md +116 -0
  12. package/runtime/bin/okstra-central.sh +152 -0
  13. package/runtime/bin/okstra-codex-exec.sh +53 -0
  14. package/runtime/bin/okstra-error-log.py +295 -0
  15. package/runtime/bin/okstra-gemini-exec.sh +55 -0
  16. package/runtime/bin/okstra-token-usage.py +46 -0
  17. package/runtime/bin/okstra.sh +162 -0
  18. package/runtime/prompts/launch.template.md +52 -0
  19. package/runtime/prompts/profiles/error-analysis.md +43 -0
  20. package/runtime/prompts/profiles/final-verification.md +37 -0
  21. package/runtime/prompts/profiles/implementation-planning.md +85 -0
  22. package/runtime/prompts/profiles/implementation.md +71 -0
  23. package/runtime/prompts/profiles/requirements-discovery.md +43 -0
  24. package/runtime/python/lib/okstra/cli.sh +227 -0
  25. package/runtime/python/lib/okstra/globals.sh +157 -0
  26. package/runtime/python/lib/okstra/interactive.sh +411 -0
  27. package/runtime/python/lib/okstra/project-resolver.sh +57 -0
  28. package/runtime/python/lib/okstra/usage.sh +98 -0
  29. package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
  30. package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
  31. package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
  32. package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
  33. package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
  34. package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
  35. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
  36. package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
  37. package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
  38. package/runtime/python/lib/okstra-ctl/main.sh +41 -0
  39. package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
  40. package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
  41. package/runtime/python/okstra_ctl/__init__.py +125 -0
  42. package/runtime/python/okstra_ctl/backfill.py +253 -0
  43. package/runtime/python/okstra_ctl/batch.py +62 -0
  44. package/runtime/python/okstra_ctl/ids.py +84 -0
  45. package/runtime/python/okstra_ctl/index.py +216 -0
  46. package/runtime/python/okstra_ctl/invocation.py +49 -0
  47. package/runtime/python/okstra_ctl/jsonl.py +84 -0
  48. package/runtime/python/okstra_ctl/listing.py +156 -0
  49. package/runtime/python/okstra_ctl/locks.py +42 -0
  50. package/runtime/python/okstra_ctl/material.py +62 -0
  51. package/runtime/python/okstra_ctl/models.py +63 -0
  52. package/runtime/python/okstra_ctl/path_resolve.py +40 -0
  53. package/runtime/python/okstra_ctl/paths.py +251 -0
  54. package/runtime/python/okstra_ctl/project_meta.py +51 -0
  55. package/runtime/python/okstra_ctl/reconcile.py +166 -0
  56. package/runtime/python/okstra_ctl/render.py +1065 -0
  57. package/runtime/python/okstra_ctl/resolver.py +54 -0
  58. package/runtime/python/okstra_ctl/run.py +674 -0
  59. package/runtime/python/okstra_ctl/run_context.py +166 -0
  60. package/runtime/python/okstra_ctl/seeding.py +97 -0
  61. package/runtime/python/okstra_ctl/sequence.py +53 -0
  62. package/runtime/python/okstra_ctl/session.py +33 -0
  63. package/runtime/python/okstra_ctl/tmux.py +27 -0
  64. package/runtime/python/okstra_ctl/workers.py +64 -0
  65. package/runtime/python/okstra_ctl/workflow.py +182 -0
  66. package/runtime/python/okstra_project/__init__.py +41 -0
  67. package/runtime/python/okstra_project/resolver.py +126 -0
  68. package/runtime/python/okstra_project/state.py +170 -0
  69. package/runtime/python/okstra_token_usage/__init__.py +26 -0
  70. package/runtime/python/okstra_token_usage/blocks.py +62 -0
  71. package/runtime/python/okstra_token_usage/claude.py +97 -0
  72. package/runtime/python/okstra_token_usage/cli.py +84 -0
  73. package/runtime/python/okstra_token_usage/codex.py +80 -0
  74. package/runtime/python/okstra_token_usage/collect.py +161 -0
  75. package/runtime/python/okstra_token_usage/gemini.py +77 -0
  76. package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
  77. package/runtime/python/okstra_token_usage/paths.py +22 -0
  78. package/runtime/python/okstra_token_usage/pricing.py +71 -0
  79. package/runtime/python/okstra_token_usage/report.py +64 -0
  80. package/runtime/templates/prd/brief.template.md +273 -0
  81. package/runtime/templates/project-docs/task-index.template.md +65 -0
  82. package/runtime/templates/reports/error-analysis-input.template.md +80 -0
  83. package/runtime/templates/reports/final-report.template.md +167 -0
  84. package/runtime/templates/reports/final-verification-input.template.md +67 -0
  85. package/runtime/templates/reports/implementation-input.template.md +81 -0
  86. package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
  87. package/runtime/templates/reports/quick-input.template.md +64 -0
  88. package/runtime/templates/reports/schedule.template.md +168 -0
  89. package/runtime/templates/reports/settings.template.json +101 -0
  90. package/runtime/templates/reports/task-brief.template.md +165 -0
  91. package/runtime/validators/lib/common.sh +44 -0
  92. package/runtime/validators/lib/fixtures.sh +322 -0
  93. package/runtime/validators/lib/paths.sh +44 -0
  94. package/runtime/validators/lib/runners.sh +140 -0
  95. package/runtime/validators/lib/summary.sh +15 -0
  96. package/runtime/validators/lib/validate-assets.sh +44 -0
  97. package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
  98. package/runtime/validators/lib/validate-tasks.sh +335 -0
  99. package/runtime/validators/validate-run.py +568 -0
  100. package/runtime/validators/validate-schedule.py +665 -0
  101. package/runtime/validators/validate-workflow.sh +190 -0
  102. package/src/doctor.mjs +127 -0
  103. package/src/install.mjs +355 -0
  104. package/src/paths.mjs +132 -0
  105. package/src/uninstall.mjs +122 -0
  106. package/src/version.mjs +20 -0
@@ -0,0 +1,568 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
12
+ ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
13
+
14
+
15
+ def utc_now() -> str:
16
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
17
+
18
+
19
+ def load_json(path: Path) -> dict:
20
+ try:
21
+ return json.loads(path.read_text())
22
+ except FileNotFoundError:
23
+ raise
24
+ except Exception as exc:
25
+ raise ValueError(f"failed to parse JSON: {path}") from exc
26
+
27
+
28
+ def write_json(path: Path, payload: dict) -> None:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
31
+
32
+
33
+ def default_next_phase(task_type: str) -> str:
34
+ mapping = {
35
+ "requirements-discovery": "pending-routing-decision",
36
+ "error-analysis": "implementation-planning",
37
+ "implementation-planning": "implementation",
38
+ "final-verification": "done-or-follow-up",
39
+ }
40
+ return mapping.get(task_type, "unknown")
41
+
42
+
43
+ def update_workflow_metadata(
44
+ run_manifest: dict,
45
+ task_manifest: dict,
46
+ validation_status: str,
47
+ ) -> None:
48
+ workflow = task_manifest.get("workflow", {})
49
+ if not isinstance(workflow, dict):
50
+ workflow = {}
51
+
52
+ current_phase = (
53
+ workflow.get("currentPhase")
54
+ or task_manifest.get("taskType")
55
+ or run_manifest.get("taskType")
56
+ or ""
57
+ )
58
+ phase_sequence = workflow.get("phaseSequence", [])
59
+ if not isinstance(phase_sequence, list) or not phase_sequence:
60
+ phase_sequence = [
61
+ "requirements-discovery",
62
+ "error-analysis",
63
+ "implementation-planning",
64
+ "implementation",
65
+ "final-verification",
66
+ ]
67
+
68
+ phase_states = workflow.get("phaseStates", {})
69
+ if not isinstance(phase_states, dict):
70
+ phase_states = {}
71
+ for phase in phase_sequence:
72
+ phase_states.setdefault(phase, "not-started")
73
+
74
+ if validation_status == "passed":
75
+ current_phase_state = "completed"
76
+ if current_phase:
77
+ phase_states[current_phase] = current_phase_state
78
+ last_completed_phase = current_phase or workflow.get("lastCompletedPhase", "")
79
+ next_recommended_phase = (
80
+ workflow.get("nextRecommendedPhase") or default_next_phase(current_phase)
81
+ )
82
+ else:
83
+ current_phase_state = "blocked"
84
+ if current_phase:
85
+ phase_states[current_phase] = current_phase_state
86
+ last_completed_phase = workflow.get("lastCompletedPhase", "")
87
+ next_recommended_phase = current_phase or workflow.get(
88
+ "nextRecommendedPhase", ""
89
+ )
90
+
91
+ routing_status = workflow.get("routingStatus")
92
+ if not isinstance(routing_status, str) or not routing_status.strip():
93
+ routing_status = (
94
+ "pending" if current_phase == "requirements-discovery" else "not-applicable"
95
+ )
96
+
97
+ awaiting_approval = workflow.get("awaitingApproval")
98
+ if not isinstance(awaiting_approval, bool):
99
+ awaiting_approval = False
100
+
101
+ last_safe_checkpoint = workflow.get("lastSafeCheckpoint", {})
102
+ if not isinstance(last_safe_checkpoint, dict):
103
+ last_safe_checkpoint = {}
104
+ last_safe_checkpoint.update(
105
+ {
106
+ "label": (
107
+ "validation-passed"
108
+ if validation_status == "passed"
109
+ else "validation-failed"
110
+ ),
111
+ "taskManifestPath": task_manifest.get(
112
+ "taskManifestPath", ""
113
+ ),
114
+ "taskIndexPath": task_manifest.get(
115
+ "taskIndexPath", ""
116
+ ),
117
+ "latestRunPath": task_manifest.get(
118
+ "latestRunPath", ""
119
+ ),
120
+ "latestRunManifestPath": run_manifest.get(
121
+ "runManifestPath", ""
122
+ ),
123
+ "latestTeamStatePath": run_manifest.get(
124
+ "teamStatePath", ""
125
+ ),
126
+ "latestReportPath": task_manifest.get(
127
+ "latestReportPath", ""
128
+ ),
129
+ "latestResumeCommandPath": task_manifest.get(
130
+ "latestResumeCommandPath", ""
131
+ ),
132
+ }
133
+ )
134
+
135
+ workflow.update(
136
+ {
137
+ "phaseSequence": phase_sequence,
138
+ "currentPhase": current_phase,
139
+ "currentPhaseState": current_phase_state,
140
+ "phaseStates": phase_states,
141
+ "lastCompletedPhase": last_completed_phase,
142
+ "nextRecommendedPhase": next_recommended_phase,
143
+ "awaitingApproval": awaiting_approval,
144
+ "routingStatus": routing_status,
145
+ "lastSafeCheckpoint": last_safe_checkpoint,
146
+ }
147
+ )
148
+ task_manifest["workflow"] = workflow
149
+
150
+ workflow_snapshot = run_manifest.get("workflowSnapshot", {})
151
+ if not isinstance(workflow_snapshot, dict):
152
+ workflow_snapshot = {}
153
+ workflow_snapshot.update(
154
+ {
155
+ "phaseSequence": workflow["phaseSequence"],
156
+ "currentPhase": workflow["currentPhase"],
157
+ "currentPhaseState": workflow["currentPhaseState"],
158
+ "phaseStates": workflow["phaseStates"],
159
+ "lastCompletedPhase": workflow["lastCompletedPhase"],
160
+ "nextRecommendedPhase": workflow["nextRecommendedPhase"],
161
+ "awaitingApproval": workflow["awaitingApproval"],
162
+ "routingStatus": workflow["routingStatus"],
163
+ "lastSafeCheckpoint": workflow["lastSafeCheckpoint"],
164
+ }
165
+ )
166
+ run_manifest["workflowSnapshot"] = workflow_snapshot
167
+
168
+
169
+ def update_validation_metadata(
170
+ team_state: dict,
171
+ run_manifest: dict,
172
+ task_manifest: dict,
173
+ validation_status: str,
174
+ failures: list[str],
175
+ ) -> None:
176
+ checked_at = utc_now()
177
+
178
+ team_state.setdefault("validator", {})
179
+ team_state["validator"]["status"] = validation_status
180
+ team_state["validator"]["lastValidatedAt"] = checked_at
181
+ team_state["validator"]["failures"] = failures
182
+
183
+ run_manifest.setdefault("validation", {})
184
+ run_manifest["validation"]["required"] = True
185
+ run_manifest["validation"]["status"] = validation_status
186
+ run_manifest["validation"]["lastCheckedAt"] = checked_at
187
+ run_manifest["validation"]["passed"] = validation_status == "passed"
188
+ run_manifest["validation"]["failures"] = failures
189
+ run_manifest["status"] = (
190
+ "completed" if validation_status == "passed" else "contract-violated"
191
+ )
192
+
193
+ task_manifest.setdefault("contractValidation", {})
194
+ task_manifest["contractValidation"]["required"] = True
195
+ task_manifest["contractValidation"]["status"] = validation_status
196
+ task_manifest["contractValidation"]["lastCheckedAt"] = checked_at
197
+ task_manifest["contractValidation"]["passed"] = validation_status == "passed"
198
+ task_manifest["contractValidation"]["failures"] = failures
199
+ task_manifest["latestRunStatus"] = run_manifest["status"]
200
+ task_manifest["currentStatus"] = (
201
+ "completed" if validation_status == "passed" else "contract-violated"
202
+ )
203
+ update_workflow_metadata(run_manifest, task_manifest, validation_status)
204
+
205
+
206
+ def extract_contract(
207
+ run_manifest: dict, task_manifest: dict, failures: list[str]
208
+ ) -> dict:
209
+ run_contract = run_manifest.get("teamContract")
210
+ task_contract = task_manifest.get("resultContract")
211
+
212
+ if not isinstance(run_contract, dict):
213
+ run_contract = {}
214
+ if not isinstance(task_contract, dict):
215
+ task_contract = {}
216
+
217
+ required_worker_roles = run_contract.get("requiredWorkerRoles")
218
+ if not isinstance(required_worker_roles, list):
219
+ required_worker_roles = task_contract.get("requiredWorkerRoles")
220
+ if not isinstance(required_worker_roles, list):
221
+ required_worker_roles = []
222
+ failures.append("requiredWorkerRoles is missing from run/task manifest")
223
+
224
+ required_agent_status_entries = run_contract.get("requiredAgentStatusEntries")
225
+ if not isinstance(required_agent_status_entries, list):
226
+ required_agent_status_entries = task_contract.get("requiredAgentStatusEntries")
227
+ if not isinstance(required_agent_status_entries, list):
228
+ required_agent_status_entries = ["Claude lead"] + [
229
+ item.get("role", "")
230
+ for item in required_worker_roles
231
+ if isinstance(item, dict) and item.get("role")
232
+ ]
233
+
234
+ return {
235
+ "lead_role": run_contract.get("leadRole")
236
+ or task_contract.get("leadRole")
237
+ or "Claude lead",
238
+ "lead_agent": run_contract.get("leadAgent")
239
+ or task_contract.get("leadAgent")
240
+ or "claude",
241
+ "lead_model": run_contract.get("leadModel")
242
+ or task_contract.get("leadModel")
243
+ or "",
244
+ "lead_model_execution_value": (
245
+ run_contract.get("leadModelExecutionValue")
246
+ or task_contract.get("leadModelExecutionValue")
247
+ or ""
248
+ ),
249
+ "required_worker_roles": required_worker_roles,
250
+ "required_agent_status_entries": [
251
+ item
252
+ for item in required_agent_status_entries
253
+ if isinstance(item, str) and item.strip()
254
+ ],
255
+ }
256
+
257
+
258
+ def validate_team_state(
259
+ team_state: dict, project_root: Path, contract: dict, failures: list[str]
260
+ ) -> None:
261
+ artifacts = team_state.get("artifacts")
262
+ if not isinstance(artifacts, dict):
263
+ failures.append("team-state.artifacts must be an object")
264
+ elif not str(artifacts.get("workerPromptsDirectoryPath", "")).strip():
265
+ failures.append(
266
+ "team-state.artifacts.workerPromptsDirectoryPath is missing"
267
+ )
268
+ lead = team_state.get("lead")
269
+ if not isinstance(lead, dict):
270
+ failures.append("team-state.lead is missing")
271
+ else:
272
+ if lead.get("role") != contract["lead_role"]:
273
+ failures.append(f"team-state.lead.role must be `{contract['lead_role']}`")
274
+ if lead.get("agent") != contract["lead_agent"]:
275
+ failures.append(f"team-state.lead.agent must be `{contract['lead_agent']}`")
276
+ expected_lead_model = contract.get("lead_model")
277
+ if expected_lead_model and lead.get("model") != expected_lead_model:
278
+ failures.append(f"team-state.lead.model must be `{expected_lead_model}`")
279
+ expected_lead_model_execution_value = contract.get("lead_model_execution_value")
280
+ if (
281
+ expected_lead_model_execution_value
282
+ and lead.get("modelExecutionValue") != expected_lead_model_execution_value
283
+ ):
284
+ failures.append(
285
+ "team-state.lead.modelExecutionValue must be "
286
+ f"`{expected_lead_model_execution_value}`"
287
+ )
288
+
289
+ workers = team_state.get("workers")
290
+ if not isinstance(workers, list):
291
+ failures.append("team-state.workers must be a list")
292
+ return
293
+
294
+ by_role: dict[str, dict] = {}
295
+ for worker in workers:
296
+ if not isinstance(worker, dict):
297
+ failures.append("team-state.workers contains a non-object entry")
298
+ continue
299
+ role = str(worker.get("role", "")).strip()
300
+ if not role:
301
+ failures.append("team-state.workers contains an entry without role")
302
+ continue
303
+ if role in by_role:
304
+ failures.append(f"duplicate worker role detected: {role}")
305
+ continue
306
+ by_role[role] = worker
307
+
308
+ expected_workers: dict[str, dict] = {}
309
+ for worker in contract["required_worker_roles"]:
310
+ if not isinstance(worker, dict):
311
+ failures.append("requiredWorkerRoles contains a non-object entry")
312
+ continue
313
+ role = str(worker.get("role", "")).strip()
314
+ if not role:
315
+ failures.append("requiredWorkerRoles contains an entry without role")
316
+ continue
317
+ expected_workers[role] = worker
318
+
319
+ for role, expected in expected_workers.items():
320
+ worker = by_role.get(role)
321
+ if worker is None:
322
+ failures.append(f"missing required worker role: {role}")
323
+ continue
324
+
325
+ expected_worker_id = expected.get("workerId")
326
+ if expected_worker_id and worker.get("workerId") != expected_worker_id:
327
+ failures.append(f"{role} must use workerId `{expected_worker_id}`")
328
+
329
+ expected_agent = expected.get("agent")
330
+ if expected_agent and worker.get("agent") != expected_agent:
331
+ failures.append(f"{role} must use agent `{expected_agent}`")
332
+
333
+ expected_model = expected.get("model")
334
+ if expected_model and worker.get("model") != expected_model:
335
+ failures.append(f"{role} must use model `{expected_model}`")
336
+
337
+ expected_model_execution_value = expected.get("modelExecutionValue")
338
+ if (
339
+ expected_model_execution_value
340
+ and worker.get("modelExecutionValue") != expected_model_execution_value
341
+ ):
342
+ failures.append(
343
+ f"{role} must use modelExecutionValue `{expected_model_execution_value}`"
344
+ )
345
+
346
+ expected_result_relative = expected.get("resultPath")
347
+ result_relative = worker.get("resultPath", "")
348
+ if expected_result_relative and result_relative != expected_result_relative:
349
+ failures.append(
350
+ f"{role} must use resultPath `{expected_result_relative}`"
351
+ )
352
+ expected_prompt_relative = expected.get("promptPath")
353
+ prompt_relative = worker.get("promptPath", "")
354
+ if expected_prompt_relative and prompt_relative != expected_prompt_relative:
355
+ failures.append(
356
+ f"{role} must use promptPath `{expected_prompt_relative}`"
357
+ )
358
+
359
+ status = worker.get("status")
360
+ if status not in TERMINAL_STATUSES:
361
+ failures.append(f"{role} has invalid terminal status: {status}")
362
+ continue
363
+
364
+ reason = str(worker.get("reason", "")).strip()
365
+ prompt_exists = bool(prompt_relative) and (project_root / prompt_relative).exists()
366
+ result_exists = (
367
+ bool(result_relative) and (project_root / result_relative).exists()
368
+ )
369
+ if status in ATTEMPTED_STATUSES and not prompt_relative:
370
+ failures.append(
371
+ f"{role} with status `{status}` must include promptPath"
372
+ )
373
+ if status in ATTEMPTED_STATUSES and prompt_relative and not prompt_exists:
374
+ failures.append(
375
+ f"{role} with status `{status}` is missing worker prompt history file: {prompt_relative}"
376
+ )
377
+
378
+ if status == "completed" and not result_exists:
379
+ failures.append(
380
+ f"{role} is completed but worker result file is missing: {result_relative}"
381
+ )
382
+ if status != "completed" and not reason:
383
+ failures.append(f"{role} with status `{status}` must include a reason")
384
+
385
+ unexpected_roles = set(by_role) - set(expected_workers)
386
+ for role in sorted(unexpected_roles):
387
+ failures.append(f"unexpected worker role detected: {role}")
388
+
389
+ generic_roles = [
390
+ role
391
+ for role in by_role
392
+ if "generic" in role.lower() or "parallel worker" in role.lower()
393
+ ]
394
+ for role in generic_roles:
395
+ failures.append(f"generic worker role is not allowed: {role}")
396
+
397
+
398
+ TOKEN_PLACEHOLDERS = (
399
+ "{{LEAD_TOTAL_TOKENS}}",
400
+ "{{LEAD_BILLABLE_TOKENS}}",
401
+ "{{LEAD_COST_USD}}",
402
+ "{{WORKER_TOTAL_TOKENS}}",
403
+ "{{WORKER_BILLABLE_TOKENS}}",
404
+ "{{WORKER_COST_USD}}",
405
+ "{{GRAND_TOTAL_TOKENS}}",
406
+ "{{GRAND_BILLABLE_TOKENS}}",
407
+ "{{GRAND_COST_USD}}",
408
+ "{{CLI_COST_USD}}",
409
+ )
410
+
411
+
412
+ def validate_report(
413
+ report_path: Path, required_agent_status_entries: list[str], failures: list[str]
414
+ ) -> None:
415
+ if not report_path.exists():
416
+ failures.append(f"final report is missing: {report_path}")
417
+ return
418
+
419
+ content = report_path.read_text()
420
+ for label in required_agent_status_entries:
421
+ if label not in content:
422
+ failures.append(
423
+ f"final report does not include required agent status entry: {label}"
424
+ )
425
+
426
+ for placeholder in TOKEN_PLACEHOLDERS:
427
+ if placeholder in content:
428
+ failures.append(
429
+ f"final report contains unsubstituted token placeholder `{placeholder}` — "
430
+ "run `okstra-token-usage.py ... --substitute-final-report <report-path>` during Phase 7"
431
+ )
432
+
433
+
434
+ def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
435
+ summary = team_state.get("usageSummary") or {}
436
+ if not summary or not summary.get("collectedAt"):
437
+ failures.append(
438
+ "team-state.usageSummary is empty — Phase 7 token-usage collection was skipped. "
439
+ "Run `python3 scripts/okstra-token-usage.py <team-state> --write --summary "
440
+ "--substitute-final-report <final-report>`."
441
+ )
442
+
443
+
444
+ PLANNING_REQUIRED_SECTIONS = (
445
+ "Option Candidates",
446
+ "Trade-off",
447
+ "Recommended Option",
448
+ "Stepwise Execution Order",
449
+ "Dependency",
450
+ "Validation Checklist",
451
+ "Rollback",
452
+ "User Approval Request",
453
+ )
454
+
455
+
456
+ def validate_phase_boundary(
457
+ task_type: str,
458
+ report_path: Path,
459
+ failures: list[str],
460
+ ) -> None:
461
+ """Phase-specific contract checks.
462
+
463
+ For `implementation-planning` runs, the final report must contain the
464
+ required deliverable sections; absence indicates a planning run that
465
+ skipped its core outputs (or an implementation run that ran under the
466
+ wrong task type).
467
+ """
468
+ if task_type != "implementation-planning":
469
+ return
470
+ if not report_path.exists():
471
+ return
472
+ content = report_path.read_text()
473
+ for needle in PLANNING_REQUIRED_SECTIONS:
474
+ if needle not in content:
475
+ failures.append(
476
+ "implementation-planning report is missing required section: "
477
+ f"`{needle}`"
478
+ )
479
+
480
+
481
+ def main() -> int:
482
+ parser = argparse.ArgumentParser(
483
+ description="Validate okstra run contract artifacts."
484
+ )
485
+ parser.add_argument(
486
+ "--team-state",
487
+ required=True,
488
+ help="Project-relative or absolute path to the team state JSON.",
489
+ )
490
+ parser.add_argument(
491
+ "--report",
492
+ required=True,
493
+ help="Project-relative or absolute path to the final report Markdown file.",
494
+ )
495
+ parser.add_argument(
496
+ "--run-manifest",
497
+ required=True,
498
+ help="Project-relative or absolute path to the run manifest JSON.",
499
+ )
500
+ parser.add_argument(
501
+ "--task-manifest",
502
+ required=True,
503
+ help="Project-relative or absolute path to the task manifest JSON.",
504
+ )
505
+ parser.add_argument(
506
+ "--final-status", required=False, help="Optional final status file to write."
507
+ )
508
+ args = parser.parse_args()
509
+
510
+ run_manifest_path = Path(args.run_manifest).resolve()
511
+ run_manifest = load_json(run_manifest_path)
512
+ task_manifest_path = Path(args.task_manifest).resolve()
513
+ task_manifest = load_json(task_manifest_path)
514
+
515
+ project_root_raw = str(task_manifest.get("projectRoot") or "").strip()
516
+ if not project_root_raw:
517
+ raise ValueError("projectRoot is missing from task manifest")
518
+ project_root = Path(project_root_raw)
519
+
520
+ def resolve_input(raw_path: str) -> Path:
521
+ path = Path(raw_path)
522
+ if path.is_absolute():
523
+ return path
524
+ return (project_root / raw_path).resolve()
525
+
526
+ team_state_path = resolve_input(args.team_state)
527
+ report_path = resolve_input(args.report)
528
+ team_state = load_json(team_state_path)
529
+
530
+ failures: list[str] = []
531
+ contract = extract_contract(run_manifest, task_manifest, failures)
532
+ validate_team_state(team_state, project_root, contract, failures)
533
+ validate_report(report_path, contract["required_agent_status_entries"], failures)
534
+ validate_team_state_usage(team_state, failures)
535
+
536
+ task_type = str(task_manifest.get("taskType") or run_manifest.get("taskType") or "").strip()
537
+ validate_phase_boundary(task_type, report_path, failures)
538
+
539
+ validation_status = "passed" if not failures else "failed"
540
+ update_validation_metadata(
541
+ team_state, run_manifest, task_manifest, validation_status, failures
542
+ )
543
+
544
+ write_json(team_state_path, team_state)
545
+ write_json(run_manifest_path, run_manifest)
546
+ write_json(task_manifest_path, task_manifest)
547
+
548
+ if args.final_status:
549
+ final_status_path = resolve_input(args.final_status)
550
+ final_status_path.parent.mkdir(parents=True, exist_ok=True)
551
+ final_status_path.write_text(
552
+ ("completed" if validation_status == "passed" else "contract-violated")
553
+ + "\n"
554
+ )
555
+
556
+ result = {
557
+ "validationStatus": validation_status,
558
+ "finalRunStatus": run_manifest.get("status"),
559
+ "failures": failures,
560
+ "teamStatePath": str(team_state_path),
561
+ "reportPath": str(report_path),
562
+ }
563
+ print(json.dumps(result, ensure_ascii=False, indent=2))
564
+ return 0 if validation_status == "passed" else 1
565
+
566
+
567
+ if __name__ == "__main__":
568
+ sys.exit(main())