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,267 @@
1
+ # shellcheck shell=bash
2
+
3
+ validate_worker_prompt_metadata() {
4
+ local task_group="$1"
5
+ local task_id="$2"
6
+ local expected_task_manifest_relative_path=""
7
+
8
+ expected_task_manifest_relative_path="$(task_manifest_relative_path "$task_group" "$task_id")"
9
+
10
+ python3 - "$PROJECT_ROOT" "$expected_task_manifest_relative_path" <<'PY'
11
+ from pathlib import Path
12
+ import json
13
+ import sys
14
+
15
+ project_root = Path(sys.argv[1])
16
+ task_manifest_path = project_root / sys.argv[2]
17
+ errors = []
18
+
19
+
20
+ def load_json(path: Path) -> dict:
21
+ return json.loads(path.read_text())
22
+
23
+
24
+ def validate_prompt_contract(
25
+ prefix: str,
26
+ worker_ids: list[str],
27
+ prompt_dir: str,
28
+ prompt_map: object,
29
+ required_worker_roles: object,
30
+ ) -> None:
31
+ if not prompt_dir:
32
+ errors.append(f"{prefix} is missing worker prompt directory metadata")
33
+ if not isinstance(prompt_map, dict):
34
+ errors.append(f"{prefix} worker prompt map is missing or invalid")
35
+ prompt_map = {}
36
+ if not isinstance(required_worker_roles, list):
37
+ errors.append(f"{prefix} requiredWorkerRoles is missing or invalid")
38
+ required_worker_roles = []
39
+
40
+ expected_dir_prefix = prompt_dir.rstrip("/") + "/" if prompt_dir else ""
41
+ for worker_id in worker_ids:
42
+ if worker_id not in prompt_map:
43
+ errors.append(f"{prefix} worker prompt map is missing selected worker: {worker_id}")
44
+
45
+ for worker in required_worker_roles:
46
+ if not isinstance(worker, dict):
47
+ errors.append(f"{prefix} requiredWorkerRoles contains a non-object entry")
48
+ continue
49
+ worker_id = str(worker.get("workerId", "")).strip()
50
+ prompt_relative = str(worker.get("promptPath", "")).strip()
51
+ if not worker_id:
52
+ errors.append(f"{prefix} requiredWorkerRoles contains an entry without workerId")
53
+ continue
54
+ if not prompt_relative:
55
+ errors.append(
56
+ f"{prefix} required worker is missing promptPath: {worker_id}"
57
+ )
58
+ continue
59
+ if prompt_map.get(worker_id) != prompt_relative:
60
+ errors.append(
61
+ f"{prefix} worker prompt map does not match requiredWorkerRoles for {worker_id}"
62
+ )
63
+ if expected_dir_prefix and not prompt_relative.startswith(expected_dir_prefix):
64
+ errors.append(
65
+ f"{prefix} worker prompt path is outside the prompt directory: {prompt_relative}"
66
+ )
67
+
68
+
69
+ if not task_manifest_path.is_file():
70
+ errors.append(f"task manifest is missing: {task_manifest_path}")
71
+ else:
72
+ task_manifest = load_json(task_manifest_path)
73
+ selected_workers = task_manifest.get("recommendedWorkers", [])
74
+ if not isinstance(selected_workers, list):
75
+ errors.append("task manifest recommendedWorkers is missing or invalid")
76
+ selected_workers = []
77
+
78
+ task_prompt_dir = str(task_manifest.get("latestRunPromptsPath", "")).strip()
79
+ if not task_prompt_dir:
80
+ errors.append("task manifest is missing latestRunPromptsPath")
81
+
82
+ task_artifacts = task_manifest.get("artifacts", {})
83
+ if not isinstance(task_artifacts, dict):
84
+ errors.append("task manifest artifacts is missing or invalid")
85
+ task_artifacts = {}
86
+
87
+ validate_prompt_contract(
88
+ "task manifest",
89
+ selected_workers,
90
+ str(task_artifacts.get("workerPromptsDirectoryPath", "")).strip(),
91
+ task_artifacts.get("workerPromptPathByWorkerId"),
92
+ task_manifest.get("resultContract", {}).get("requiredWorkerRoles"),
93
+ )
94
+
95
+ if (
96
+ task_prompt_dir
97
+ and str(task_artifacts.get("workerPromptsDirectoryPath", "")).strip()
98
+ and task_prompt_dir
99
+ != str(task_artifacts.get("workerPromptsDirectoryPath", "")).strip()
100
+ ):
101
+ errors.append(
102
+ "task manifest latestRunPromptsPath does not match artifacts.workerPromptsDirectoryPath"
103
+ )
104
+
105
+ timeline_relative_path = str(
106
+ task_manifest.get("historyTimelinePath", "")
107
+ ).strip()
108
+ if not timeline_relative_path:
109
+ errors.append("task manifest is missing historyTimelinePath")
110
+ else:
111
+ timeline_path = project_root / timeline_relative_path
112
+ if not timeline_path.is_file():
113
+ errors.append(f"timeline file is missing: {timeline_path}")
114
+ else:
115
+ timeline = load_json(timeline_path)
116
+ runs = timeline.get("runs", [])
117
+ latest_run = None
118
+ if isinstance(runs, list):
119
+ for item in reversed(runs):
120
+ if isinstance(item, dict):
121
+ latest_run = item
122
+ break
123
+ if latest_run is None:
124
+ errors.append("timeline does not contain a latest run entry")
125
+ else:
126
+ latest_run_prompt_dir = str(
127
+ latest_run.get("workerPromptDirectoryPath", "")
128
+ ).strip()
129
+ if not latest_run_prompt_dir:
130
+ errors.append(
131
+ "latest timeline entry is missing workerPromptDirectoryPath"
132
+ )
133
+ if task_prompt_dir and latest_run_prompt_dir and task_prompt_dir != latest_run_prompt_dir:
134
+ errors.append(
135
+ "timeline latest run prompt directory does not match task manifest latestRunPromptsPath"
136
+ )
137
+ prompt_map = latest_run.get("workerPromptPathByWorkerId")
138
+ if not isinstance(prompt_map, dict):
139
+ errors.append(
140
+ "latest timeline entry is missing workerPromptPathByWorkerId"
141
+ )
142
+ else:
143
+ for worker_id in selected_workers:
144
+ if worker_id not in prompt_map:
145
+ errors.append(
146
+ f"latest timeline entry is missing worker prompt path for {worker_id}"
147
+ )
148
+
149
+ run_manifest_relative_path = str(
150
+ latest_run.get("runManifestPath", "")
151
+ ).strip()
152
+ if not run_manifest_relative_path:
153
+ errors.append(
154
+ "latest timeline entry is missing runManifestPath"
155
+ )
156
+ else:
157
+ run_manifest_path = project_root / run_manifest_relative_path
158
+ if not run_manifest_path.is_file():
159
+ errors.append(f"latest run manifest is missing: {run_manifest_path}")
160
+ else:
161
+ run_manifest = load_json(run_manifest_path)
162
+ run_prompt_dir = str(
163
+ run_manifest.get("workerPromptsDirectoryPath", "")
164
+ ).strip()
165
+ if task_prompt_dir and run_prompt_dir and task_prompt_dir != run_prompt_dir:
166
+ errors.append(
167
+ "run manifest workerPromptsDirectoryPath does not match task manifest latestRunPromptsPath"
168
+ )
169
+ validate_prompt_contract(
170
+ "run manifest",
171
+ selected_workers,
172
+ run_prompt_dir,
173
+ run_manifest.get("workerPromptPathByWorkerId"),
174
+ run_manifest.get("teamContract", {}).get("requiredWorkerRoles"),
175
+ )
176
+
177
+ team_state_relative_path = str(
178
+ run_manifest.get("teamStatePath")
179
+ or task_manifest.get("teamStatePath", "")
180
+ ).strip()
181
+ if not team_state_relative_path:
182
+ errors.append(
183
+ "team state relative path is missing from run/task manifest"
184
+ )
185
+ else:
186
+ team_state_path = project_root / team_state_relative_path
187
+ if not team_state_path.is_file():
188
+ errors.append(f"team-state file is missing: {team_state_path}")
189
+ else:
190
+ team_state = load_json(team_state_path)
191
+ team_artifacts = team_state.get("artifacts", {})
192
+ if not isinstance(team_artifacts, dict):
193
+ errors.append(
194
+ "team-state artifacts is missing or invalid"
195
+ )
196
+ team_artifacts = {}
197
+ team_prompt_dir = str(
198
+ team_artifacts.get(
199
+ "workerPromptsDirectoryPath", ""
200
+ )
201
+ ).strip()
202
+ if run_prompt_dir and team_prompt_dir and run_prompt_dir != team_prompt_dir:
203
+ errors.append(
204
+ "team-state worker prompt directory does not match run manifest"
205
+ )
206
+ workers = team_state.get("workers", [])
207
+ if not isinstance(workers, list):
208
+ errors.append("team-state workers is missing or invalid")
209
+ else:
210
+ team_workers = {}
211
+ for worker in workers:
212
+ if not isinstance(worker, dict):
213
+ errors.append(
214
+ "team-state workers contains a non-object entry"
215
+ )
216
+ continue
217
+ worker_id = str(worker.get("workerId", "")).strip()
218
+ if not worker_id:
219
+ errors.append(
220
+ "team-state workers contains an entry without workerId"
221
+ )
222
+ continue
223
+ team_workers[worker_id] = worker
224
+
225
+ run_prompt_map = run_manifest.get(
226
+ "workerPromptPathByWorkerId", {}
227
+ )
228
+ if not isinstance(run_prompt_map, dict):
229
+ run_prompt_map = {}
230
+
231
+ for worker_id in selected_workers:
232
+ worker = team_workers.get(worker_id)
233
+ if worker is None:
234
+ errors.append(
235
+ f"team-state is missing selected worker entry: {worker_id}"
236
+ )
237
+ continue
238
+ prompt_relative = str(
239
+ worker.get("promptPath", "")
240
+ ).strip()
241
+ if not prompt_relative:
242
+ errors.append(
243
+ f"team-state worker is missing promptPath: {worker_id}"
244
+ )
245
+ continue
246
+ if run_prompt_map.get(worker_id) != prompt_relative:
247
+ errors.append(
248
+ f"team-state worker prompt path does not match run manifest for {worker_id}"
249
+ )
250
+ expected_dir_prefix = (
251
+ run_prompt_dir.rstrip("/") + "/"
252
+ if run_prompt_dir
253
+ else ""
254
+ )
255
+ if expected_dir_prefix and not prompt_relative.startswith(
256
+ expected_dir_prefix
257
+ ):
258
+ errors.append(
259
+ f"team-state worker prompt path is outside the run prompt directory: {prompt_relative}"
260
+ )
261
+
262
+ if errors:
263
+ for error in errors:
264
+ print(error, file=sys.stderr)
265
+ sys.exit(1)
266
+ PY
267
+ }
@@ -0,0 +1,335 @@
1
+ # shellcheck shell=bash
2
+
3
+ validate_reference_expectations() {
4
+ local brief_path="$1"
5
+ local reference_expectations_file="$2"
6
+ local expected_task_key="$3"
7
+
8
+ python3 - "$brief_path" "$reference_expectations_file" "$expected_task_key" <<'PY'
9
+ from pathlib import Path
10
+ import sys
11
+
12
+ brief_path = Path(sys.argv[1])
13
+ reference_path = Path(sys.argv[2])
14
+ expected_task_key = sys.argv[3]
15
+ brief_lines = brief_path.read_text().splitlines()
16
+ section_map = {
17
+ "Configuration References and Expected Values": "config",
18
+ "Deployment Manifests and Expected Values": "deployment",
19
+ }
20
+ captured = {"config": [], "deployment": []}
21
+ current_section = None
22
+
23
+ for line in brief_lines:
24
+ if line.startswith("## "):
25
+ current_section = section_map.get(line[3:].strip())
26
+ continue
27
+ if current_section and line.strip():
28
+ captured[current_section].append(line)
29
+
30
+ errors = []
31
+
32
+ if not reference_path.is_file():
33
+ errors.append(f"reference expectations file is missing: {reference_path}")
34
+ reference_content = ""
35
+ else:
36
+ reference_content = reference_path.read_text()
37
+
38
+ for line in captured["config"] + captured["deployment"]:
39
+ if line not in reference_content:
40
+ errors.append(f"reference expectations file is missing brief line: {line}")
41
+
42
+ if f"- Task Key: `{expected_task_key}`" not in reference_content:
43
+ errors.append("reference expectations file is missing the task key header")
44
+
45
+ if errors:
46
+ for error in errors:
47
+ print(error, file=sys.stderr)
48
+ sys.exit(1)
49
+ PY
50
+ }
51
+
52
+ validate_task_artifacts() {
53
+ local task_group="$1"
54
+ local task_id="$2"
55
+ local expected_task_key=""
56
+ local expected_task_manifest_relative_path=""
57
+ local expected_reference_relative_path=""
58
+
59
+ expected_task_key="$(task_key "$task_group" "$task_id")"
60
+ expected_task_manifest_relative_path="$(task_manifest_relative_path "$task_group" "$task_id")"
61
+ expected_reference_relative_path="$(reference_expectations_relative_path "$task_group" "$task_id")"
62
+
63
+ python3 - "$PROJECT_ROOT" "$expected_task_key" "$expected_task_manifest_relative_path" "$expected_reference_relative_path" "$TASK_CATALOG_RELATIVE_PATH" <<'PY'
64
+ from pathlib import Path
65
+ import json
66
+ import sys
67
+
68
+ project_root = Path(sys.argv[1])
69
+ expected_task_key = sys.argv[2]
70
+ task_manifest_relative_path = sys.argv[3]
71
+ expected_reference_relative_path = sys.argv[4]
72
+ expected_task_catalog_relative_path = sys.argv[5]
73
+ errors = []
74
+
75
+ task_manifest_path = project_root / task_manifest_relative_path
76
+ if not task_manifest_path.is_file():
77
+ errors.append(f"task manifest is missing: {task_manifest_path}")
78
+ else:
79
+ task_manifest = json.loads(task_manifest_path.read_text())
80
+
81
+ if task_manifest.get("taskKey") != expected_task_key:
82
+ errors.append("task manifest does not contain the expected task key")
83
+
84
+ if task_manifest.get("referenceExpectationsPath") != expected_reference_relative_path:
85
+ errors.append("task manifest does not expose referenceExpectationsPath")
86
+
87
+ if task_manifest.get("taskCatalogPath") != expected_task_catalog_relative_path:
88
+ errors.append("task manifest does not expose taskCatalogPath")
89
+
90
+ if (
91
+ task_manifest.get("artifacts", {}).get("referenceExpectationsPath")
92
+ != expected_reference_relative_path
93
+ ):
94
+ errors.append("task manifest artifacts do not expose referenceExpectationsPath")
95
+
96
+ timeline_relative_path = task_manifest.get("historyTimelinePath", "")
97
+ if not timeline_relative_path:
98
+ errors.append("task manifest is missing historyTimelinePath")
99
+ else:
100
+ timeline_path = project_root / timeline_relative_path
101
+ if not timeline_path.is_file():
102
+ errors.append(f"timeline file is missing: {timeline_path}")
103
+ else:
104
+ timeline = json.loads(timeline_path.read_text())
105
+ runs = timeline.get("runs", [])
106
+ latest_run = None
107
+ if isinstance(runs, list):
108
+ for item in reversed(runs):
109
+ if isinstance(item, dict):
110
+ latest_run = item
111
+ break
112
+ if latest_run is None:
113
+ errors.append("timeline does not contain a latest run entry")
114
+ else:
115
+ run_manifest_relative_path = latest_run.get("runManifestPath", "")
116
+ if not run_manifest_relative_path:
117
+ errors.append("latest timeline entry is missing runManifestPath")
118
+ else:
119
+ run_manifest_path = project_root / run_manifest_relative_path
120
+ if not run_manifest_path.is_file():
121
+ errors.append(f"latest run manifest is missing: {run_manifest_path}")
122
+ else:
123
+ run_manifest = json.loads(run_manifest_path.read_text())
124
+ if run_manifest.get("taskKey") != expected_task_key:
125
+ errors.append("run manifest does not contain the expected task key")
126
+ if (
127
+ run_manifest.get("referenceExpectationsPath")
128
+ != expected_reference_relative_path
129
+ ):
130
+ errors.append(
131
+ "run manifest does not expose referenceExpectationsPath"
132
+ )
133
+ if (
134
+ run_manifest.get("taskCatalogPath")
135
+ != expected_task_catalog_relative_path
136
+ ):
137
+ errors.append("run manifest does not expose taskCatalogPath")
138
+
139
+ if errors:
140
+ for error in errors:
141
+ print(error, file=sys.stderr)
142
+ sys.exit(1)
143
+ PY
144
+ }
145
+
146
+ validate_latest_task_pointer() {
147
+ local task_group="$1"
148
+ local task_id="$2"
149
+ local expected_task_key=""
150
+ local expected_task_manifest_relative_path=""
151
+ local expected_reference_relative_path=""
152
+
153
+ expected_task_key="$(task_key "$task_group" "$task_id")"
154
+ expected_task_manifest_relative_path="$(task_manifest_relative_path "$task_group" "$task_id")"
155
+ expected_reference_relative_path="$(reference_expectations_relative_path "$task_group" "$task_id")"
156
+
157
+ python3 - "$PROJECT_ROOT" "$DISCOVERY_FILE" "$expected_task_key" "$expected_task_manifest_relative_path" "$expected_reference_relative_path" "$TASK_CATALOG_RELATIVE_PATH" <<'PY'
158
+ from pathlib import Path
159
+ import json
160
+ import sys
161
+
162
+ project_root = Path(sys.argv[1])
163
+ discovery_path = Path(sys.argv[2])
164
+ expected_task_key = sys.argv[3]
165
+ expected_task_manifest_relative_path = sys.argv[4]
166
+ expected_reference_relative_path = sys.argv[5]
167
+ expected_task_catalog_relative_path = sys.argv[6]
168
+ errors = []
169
+
170
+ if not discovery_path.is_file():
171
+ errors.append(f"latest-task discovery file is missing: {discovery_path}")
172
+ else:
173
+ discovery = json.loads(discovery_path.read_text())
174
+
175
+ if discovery.get("taskKey") != expected_task_key:
176
+ errors.append("latest-task discovery does not point to the expected task key")
177
+
178
+ if discovery.get("taskManifestPath") != expected_task_manifest_relative_path:
179
+ errors.append("latest-task discovery does not point to the expected task manifest")
180
+
181
+ if discovery.get("referenceExpectationsPath") != expected_reference_relative_path:
182
+ errors.append("latest-task discovery does not expose the expected reference expectations path")
183
+
184
+ if discovery.get("taskCatalogPath") != expected_task_catalog_relative_path:
185
+ errors.append("latest-task discovery does not expose taskCatalogPath")
186
+
187
+ latest_run_prompts_relative_path = discovery.get("latestRunPromptsPath", "")
188
+ if not latest_run_prompts_relative_path:
189
+ errors.append("latest-task discovery is missing latestRunPromptsPath")
190
+
191
+ latest_run_manifest_relative_path = discovery.get("latestRunManifestPath", "")
192
+ if not latest_run_manifest_relative_path:
193
+ errors.append("latest-task discovery is missing latestRunManifestPath")
194
+ else:
195
+ latest_run_manifest_path = project_root / latest_run_manifest_relative_path
196
+ if not latest_run_manifest_path.is_file():
197
+ errors.append(f"latest run manifest is missing: {latest_run_manifest_path}")
198
+ else:
199
+ run_manifest = json.loads(latest_run_manifest_path.read_text())
200
+ if (
201
+ latest_run_prompts_relative_path
202
+ and run_manifest.get("workerPromptsDirectoryPath")
203
+ != latest_run_prompts_relative_path
204
+ ):
205
+ errors.append(
206
+ "latest-task discovery latestRunPromptsPath does not match the latest run manifest"
207
+ )
208
+
209
+ if errors:
210
+ for error in errors:
211
+ print(error, file=sys.stderr)
212
+ sys.exit(1)
213
+ PY
214
+ }
215
+
216
+ validate_task_catalog() {
217
+ local expected_latest_task_key="$1"
218
+ shift
219
+ local expected_task_keys=("$@")
220
+
221
+ python3 - "$PROJECT_ROOT" "$CATALOG_FILE" "$expected_latest_task_key" "$LATEST_TASK_RELATIVE_PATH" "${expected_task_keys[@]}" <<'PY'
222
+ from pathlib import Path
223
+ import json
224
+ import sys
225
+
226
+ project_root = Path(sys.argv[1])
227
+ catalog_path = Path(sys.argv[2])
228
+ expected_latest_task_key = sys.argv[3]
229
+ expected_latest_task_relative_path = sys.argv[4]
230
+ expected_task_keys = sys.argv[5:]
231
+ errors = []
232
+
233
+ if not catalog_path.is_file():
234
+ errors.append(f"task catalog is missing: {catalog_path}")
235
+ else:
236
+ catalog = json.loads(catalog_path.read_text())
237
+ tasks = catalog.get("tasks", [])
238
+
239
+ if catalog.get("latestTaskKey") != expected_latest_task_key:
240
+ errors.append("task catalog does not record the expected latestTaskKey")
241
+
242
+ if catalog.get("latestTaskDiscoveryPath") != expected_latest_task_relative_path:
243
+ errors.append("task catalog does not expose latestTaskDiscoveryPath")
244
+
245
+ if catalog.get("taskCount") != len(expected_task_keys):
246
+ errors.append("task catalog taskCount does not match the expected number of tasks")
247
+
248
+ if not isinstance(tasks, list):
249
+ errors.append("task catalog tasks entry is not a list")
250
+ else:
251
+ catalog_by_key = {}
252
+ for entry in tasks:
253
+ if not isinstance(entry, dict):
254
+ errors.append("task catalog contains a non-object entry")
255
+ continue
256
+ task_key = entry.get("taskKey", "")
257
+ if not task_key:
258
+ errors.append("task catalog contains an entry without taskKey")
259
+ continue
260
+ if task_key in catalog_by_key:
261
+ errors.append(f"task catalog contains a duplicate task key: {task_key}")
262
+ continue
263
+ catalog_by_key[task_key] = entry
264
+
265
+ for expected_task_key in expected_task_keys:
266
+ entry = catalog_by_key.get(expected_task_key)
267
+ if entry is None:
268
+ errors.append(f"task catalog is missing task key: {expected_task_key}")
269
+ continue
270
+ task_key_parts = expected_task_key.split(":", 2)
271
+ if len(task_key_parts) != 3:
272
+ errors.append(f"expected task key is malformed: {expected_task_key}")
273
+ continue
274
+ _, expected_task_group, expected_task_id = task_key_parts
275
+
276
+ if entry.get("taskGroup") != expected_task_group:
277
+ errors.append(f"task catalog entry is missing the expected taskGroup: {expected_task_key}")
278
+
279
+ if entry.get("taskId") != expected_task_id:
280
+ errors.append(f"task catalog entry is missing the expected taskId: {expected_task_key}")
281
+
282
+ task_manifest_relative_path = entry.get("taskManifestPath", "")
283
+ if not task_manifest_relative_path:
284
+ errors.append(f"task catalog entry is missing taskManifestPath: {expected_task_key}")
285
+ else:
286
+ task_manifest_path = project_root / task_manifest_relative_path
287
+ if not task_manifest_path.is_file():
288
+ errors.append(f"task catalog points to a missing task manifest: {task_manifest_path}")
289
+
290
+ reference_relative_path = entry.get("referenceExpectationsPath", "")
291
+ if not reference_relative_path:
292
+ errors.append(
293
+ f"task catalog entry is missing referenceExpectationsPath: {expected_task_key}"
294
+ )
295
+ else:
296
+ reference_path = project_root / reference_relative_path
297
+ if not reference_path.is_file():
298
+ errors.append(
299
+ f"task catalog points to a missing reference expectations file: {reference_path}"
300
+ )
301
+
302
+ latest_run_manifest_relative_path = entry.get("latestRunManifestPath", "")
303
+ if not latest_run_manifest_relative_path:
304
+ errors.append(
305
+ f"task catalog entry is missing latestRunManifestPath: {expected_task_key}"
306
+ )
307
+ else:
308
+ latest_run_manifest_path = project_root / latest_run_manifest_relative_path
309
+ if not latest_run_manifest_path.is_file():
310
+ errors.append(
311
+ f"task catalog points to a missing latest run manifest: {latest_run_manifest_path}"
312
+ )
313
+ else:
314
+ run_manifest = json.loads(latest_run_manifest_path.read_text())
315
+ latest_run_prompts_relative_path = entry.get(
316
+ "latestRunPromptsPath", ""
317
+ )
318
+ if not latest_run_prompts_relative_path:
319
+ errors.append(
320
+ f"task catalog entry is missing latestRunPromptsPath: {expected_task_key}"
321
+ )
322
+ elif (
323
+ run_manifest.get("workerPromptsDirectoryPath")
324
+ != latest_run_prompts_relative_path
325
+ ):
326
+ errors.append(
327
+ f"task catalog entry latestRunPromptsPath does not match the latest run manifest: {expected_task_key}"
328
+ )
329
+
330
+ if errors:
331
+ for error in errors:
332
+ print(error, file=sys.stderr)
333
+ sys.exit(1)
334
+ PY
335
+ }