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.
- package/README.md +36 -0
- package/bin/okstra +62 -0
- package/package.json +30 -0
- package/runtime/.gitkeep +0 -0
- package/runtime/BUILD.json +5 -0
- package/runtime/agents/SKILL.md +243 -0
- package/runtime/agents/TODO.md +168 -0
- package/runtime/agents/workers/claude-worker.md +106 -0
- package/runtime/agents/workers/codex-worker.md +179 -0
- package/runtime/agents/workers/gemini-worker.md +179 -0
- package/runtime/agents/workers/report-writer-worker.md +116 -0
- package/runtime/bin/okstra-central.sh +152 -0
- package/runtime/bin/okstra-codex-exec.sh +53 -0
- package/runtime/bin/okstra-error-log.py +295 -0
- package/runtime/bin/okstra-gemini-exec.sh +55 -0
- package/runtime/bin/okstra-token-usage.py +46 -0
- package/runtime/bin/okstra.sh +162 -0
- package/runtime/prompts/launch.template.md +52 -0
- package/runtime/prompts/profiles/error-analysis.md +43 -0
- package/runtime/prompts/profiles/final-verification.md +37 -0
- package/runtime/prompts/profiles/implementation-planning.md +85 -0
- package/runtime/prompts/profiles/implementation.md +71 -0
- package/runtime/prompts/profiles/requirements-discovery.md +43 -0
- package/runtime/python/lib/okstra/cli.sh +227 -0
- package/runtime/python/lib/okstra/globals.sh +157 -0
- package/runtime/python/lib/okstra/interactive.sh +411 -0
- package/runtime/python/lib/okstra/project-resolver.sh +57 -0
- package/runtime/python/lib/okstra/usage.sh +98 -0
- package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
- package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
- package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
- package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
- package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
- package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
- package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
- package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
- package/runtime/python/lib/okstra-ctl/main.sh +41 -0
- package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
- package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
- package/runtime/python/okstra_ctl/__init__.py +125 -0
- package/runtime/python/okstra_ctl/backfill.py +253 -0
- package/runtime/python/okstra_ctl/batch.py +62 -0
- package/runtime/python/okstra_ctl/ids.py +84 -0
- package/runtime/python/okstra_ctl/index.py +216 -0
- package/runtime/python/okstra_ctl/invocation.py +49 -0
- package/runtime/python/okstra_ctl/jsonl.py +84 -0
- package/runtime/python/okstra_ctl/listing.py +156 -0
- package/runtime/python/okstra_ctl/locks.py +42 -0
- package/runtime/python/okstra_ctl/material.py +62 -0
- package/runtime/python/okstra_ctl/models.py +63 -0
- package/runtime/python/okstra_ctl/path_resolve.py +40 -0
- package/runtime/python/okstra_ctl/paths.py +251 -0
- package/runtime/python/okstra_ctl/project_meta.py +51 -0
- package/runtime/python/okstra_ctl/reconcile.py +166 -0
- package/runtime/python/okstra_ctl/render.py +1065 -0
- package/runtime/python/okstra_ctl/resolver.py +54 -0
- package/runtime/python/okstra_ctl/run.py +674 -0
- package/runtime/python/okstra_ctl/run_context.py +166 -0
- package/runtime/python/okstra_ctl/seeding.py +97 -0
- package/runtime/python/okstra_ctl/sequence.py +53 -0
- package/runtime/python/okstra_ctl/session.py +33 -0
- package/runtime/python/okstra_ctl/tmux.py +27 -0
- package/runtime/python/okstra_ctl/workers.py +64 -0
- package/runtime/python/okstra_ctl/workflow.py +182 -0
- package/runtime/python/okstra_project/__init__.py +41 -0
- package/runtime/python/okstra_project/resolver.py +126 -0
- package/runtime/python/okstra_project/state.py +170 -0
- package/runtime/python/okstra_token_usage/__init__.py +26 -0
- package/runtime/python/okstra_token_usage/blocks.py +62 -0
- package/runtime/python/okstra_token_usage/claude.py +97 -0
- package/runtime/python/okstra_token_usage/cli.py +84 -0
- package/runtime/python/okstra_token_usage/codex.py +80 -0
- package/runtime/python/okstra_token_usage/collect.py +161 -0
- package/runtime/python/okstra_token_usage/gemini.py +77 -0
- package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
- package/runtime/python/okstra_token_usage/paths.py +22 -0
- package/runtime/python/okstra_token_usage/pricing.py +71 -0
- package/runtime/python/okstra_token_usage/report.py +64 -0
- package/runtime/templates/prd/brief.template.md +273 -0
- package/runtime/templates/project-docs/task-index.template.md +65 -0
- package/runtime/templates/reports/error-analysis-input.template.md +80 -0
- package/runtime/templates/reports/final-report.template.md +167 -0
- package/runtime/templates/reports/final-verification-input.template.md +67 -0
- package/runtime/templates/reports/implementation-input.template.md +81 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
- package/runtime/templates/reports/quick-input.template.md +64 -0
- package/runtime/templates/reports/schedule.template.md +168 -0
- package/runtime/templates/reports/settings.template.json +101 -0
- package/runtime/templates/reports/task-brief.template.md +165 -0
- package/runtime/validators/lib/common.sh +44 -0
- package/runtime/validators/lib/fixtures.sh +322 -0
- package/runtime/validators/lib/paths.sh +44 -0
- package/runtime/validators/lib/runners.sh +140 -0
- package/runtime/validators/lib/summary.sh +15 -0
- package/runtime/validators/lib/validate-assets.sh +44 -0
- package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
- package/runtime/validators/lib/validate-tasks.sh +335 -0
- package/runtime/validators/validate-run.py +568 -0
- package/runtime/validators/validate-schedule.py +665 -0
- package/runtime/validators/validate-workflow.sh +190 -0
- package/src/doctor.mjs +127 -0
- package/src/install.mjs +355 -0
- package/src/paths.mjs +132 -0
- package/src/uninstall.mjs +122 -0
- 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())
|