okstra 0.122.0 → 0.123.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 (53) hide show
  1. package/README.md +4 -2
  2. package/docs/architecture/storage-model.md +14 -0
  3. package/docs/architecture.md +34 -6
  4. package/docs/cli.md +45 -5
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-rollup.md +1 -1
  7. package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
  8. package/docs/project-structure-overview.md +3 -2
  9. package/docs/task-process/implementation-planning.md +33 -9
  10. package/docs/task-process/implementation.md +21 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/bin/lib/okstra/usage.sh +3 -3
  14. package/runtime/prompts/launch.template.md +5 -2
  15. package/runtime/prompts/lead/convergence.md +1 -1
  16. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  17. package/runtime/prompts/lead/plan-body-verification.md +29 -0
  18. package/runtime/prompts/lead/report-writer.md +9 -3
  19. package/runtime/prompts/profiles/_common-contract.md +1 -1
  20. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
  21. package/runtime/prompts/profiles/_implementation-executor.md +5 -0
  22. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  23. package/runtime/prompts/profiles/final-verification.md +2 -0
  24. package/runtime/prompts/profiles/implementation-planning.md +11 -1
  25. package/runtime/prompts/wizard/prompts.ko.json +44 -0
  26. package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
  27. package/runtime/python/okstra_ctl/design_prep.py +1462 -0
  28. package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
  29. package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
  30. package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
  31. package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
  32. package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
  33. package/runtime/python/okstra_ctl/material.py +1 -1
  34. package/runtime/python/okstra_ctl/model_discovery.py +98 -0
  35. package/runtime/python/okstra_ctl/models.py +8 -3
  36. package/runtime/python/okstra_ctl/render.py +5 -0
  37. package/runtime/python/okstra_ctl/run.py +53 -5
  38. package/runtime/python/okstra_ctl/user_response.py +67 -2
  39. package/runtime/python/okstra_ctl/wizard.py +283 -3
  40. package/runtime/python/okstra_token_usage/report.py +11 -0
  41. package/runtime/schemas/final-report-v1.0.schema.json +336 -0
  42. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  43. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  44. package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +2 -2
  45. package/runtime/skills/okstra-user-response/SKILL.md +8 -6
  46. package/runtime/templates/reports/final-report.template.md +67 -0
  47. package/runtime/templates/reports/i18n/en.json +31 -0
  48. package/runtime/templates/reports/i18n/ko.json +31 -0
  49. package/runtime/validators/validate-run.py +426 -5
  50. package/runtime/validators/validate-schedule.py +4 -4
  51. package/src/cli-registry.mjs +7 -0
  52. package/src/commands/inspect/design-prep.mjs +23 -0
  53. package/src/lib/skill-catalog.mjs +2 -1
@@ -1,53 +1,326 @@
1
- """Merge carried-forward plan-item verdicts from the prior run into the
2
- current incremental re-run's data.json. Plan items the current run did not
3
- re-verify keep their prior verdict, tagged so the report shows they are
4
- carried forward and unchanged.
5
- """
1
+ """Merge safe carried-forward planning artifacts into an incremental re-run."""
6
2
  from __future__ import annotations
7
3
 
8
4
  import argparse
5
+ import copy
9
6
  import json
7
+ import re
10
8
  import sys
11
9
  from pathlib import Path
12
10
 
13
11
 
12
+ PREP_VERDICT_RE = re.compile(r"^P-Prep-S([1-9][0-9]*)-")
13
+
14
+
14
15
  class CarryError(Exception):
15
16
  """Carry merge refused — schema drift or structural mismatch."""
16
17
 
17
18
 
18
- def merge_carried_forward(prev: dict, cur: dict, prev_seq: str = "previous") -> dict:
19
+ def _planning(data: dict) -> dict:
20
+ planning = data.get("implementationPlanning", {})
21
+ if not isinstance(planning, dict):
22
+ raise CarryError("implementationPlanning must be an object")
23
+ return planning
24
+
25
+
26
+ def _indexed_rows(rows: object, *, key: str, label: str) -> dict:
27
+ if not isinstance(rows, list):
28
+ raise CarryError(f"{label} must be an array")
29
+ indexed = {}
30
+ for row in rows:
31
+ if not isinstance(row, dict) or key not in row:
32
+ raise CarryError(f"{label} contains a row without {key}")
33
+ identity = row[key]
34
+ if identity in indexed:
35
+ raise CarryError(f"{label} contains duplicate {key} {identity!r}")
36
+ indexed[identity] = row
37
+ return indexed
38
+
39
+
40
+ def _stage_rows(planning: dict, *, snapshot: str) -> dict[int, dict]:
41
+ rows = _indexed_rows(
42
+ planning.get("stages", []), key="stage", label=f"{snapshot} stages",
43
+ )
44
+ try:
45
+ return {int(stage): row for stage, row in rows.items()}
46
+ except (TypeError, ValueError) as exc:
47
+ raise CarryError(f"{snapshot} stages contains an invalid stage number") from exc
48
+
49
+
50
+ def _prep_items(planning: dict, *, snapshot: str) -> dict[str, dict]:
51
+ preparation = planning.get("designPreparation", {})
52
+ if not isinstance(preparation, dict):
53
+ raise CarryError(f"{snapshot} designPreparation must be an object")
54
+ return _indexed_rows(
55
+ preparation.get("items", []),
56
+ key="id",
57
+ label=f"{snapshot} designPreparation.items",
58
+ )
59
+
60
+
61
+ def _stage_refs(item: dict, *, label: str) -> set[int]:
62
+ refs = item.get("stageRefs")
63
+ if not isinstance(refs, list) or not refs:
64
+ raise CarryError(f"{label} has invalid stageRefs")
65
+ try:
66
+ parsed = {int(stage) for stage in refs}
67
+ except (TypeError, ValueError) as exc:
68
+ raise CarryError(f"{label} has invalid stageRefs") from exc
69
+ if any(stage < 1 for stage in parsed):
70
+ raise CarryError(f"{label} has invalid stageRefs")
71
+ return parsed
72
+
73
+
74
+ def _canonical(value: dict, *, ignore_carry_marker: bool = False) -> str:
75
+ comparable = copy.deepcopy(value)
76
+ if ignore_carry_marker:
77
+ comparable.pop("carriedForwardFromSeq", None)
78
+ return json.dumps(comparable, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
79
+
80
+
81
+ def _prep_verdict_stage(item: dict) -> int | None:
82
+ match = PREP_VERDICT_RE.match(str(item.get("id", "")))
83
+ return int(match.group(1)) if match else None
84
+
85
+
86
+ def _merge_legacy_plan_items(prev_planning: dict, cur_planning: dict, prev_seq: str) -> None:
87
+ prev_items = prev_planning.get("planBodyVerification", {}).get("planItems", [])
88
+ cur_pbv = cur_planning.setdefault("planBodyVerification", {})
89
+ cur_items = cur_pbv.setdefault("planItems", [])
90
+ current_ids = {item["id"] for item in cur_items}
91
+ for item in prev_items:
92
+ if item["id"] not in current_ids:
93
+ carried = copy.deepcopy(item)
94
+ carried["carriedForwardFromSeq"] = str(prev_seq)
95
+ cur_items.append(carried)
96
+ cur_items.sort(key=lambda item: item["id"])
97
+
98
+
99
+ def _validate_stage_sets(
100
+ prev_stages: dict[int, dict],
101
+ cur_stages: dict[int, dict],
102
+ carry_stages: set[int],
103
+ reverify_stages: set[int],
104
+ ) -> None:
105
+ overlap = carry_stages & reverify_stages
106
+ if overlap:
107
+ raise CarryError(f"carry/reverify stage sets overlap: {sorted(overlap)}")
108
+ missing_prior = carry_stages - set(prev_stages)
109
+ if missing_prior:
110
+ raise CarryError(f"carry stage(s) missing from prior snapshot: {sorted(missing_prior)}")
111
+ missing_current = reverify_stages - set(cur_stages)
112
+ if missing_current:
113
+ raise CarryError(
114
+ f"reverify stage(s) missing from current snapshot: {sorted(missing_current)}"
115
+ )
116
+
117
+
118
+ def _merge_stage_rows(
119
+ cur_planning: dict,
120
+ prev_stages: dict[int, dict],
121
+ cur_stages: dict[int, dict],
122
+ carry_stages: set[int],
123
+ reverify_stages: set[int],
124
+ ) -> None:
125
+ merged = [copy.deepcopy(prev_stages[stage]) for stage in carry_stages]
126
+ merged.extend(copy.deepcopy(cur_stages[stage]) for stage in reverify_stages)
127
+ merged.sort(key=lambda row: int(row["stage"]))
128
+ cur_planning["stages"] = merged
129
+
130
+
131
+ def _merge_prep_items(
132
+ prev_planning: dict,
133
+ cur_planning: dict,
134
+ carry_stages: set[int],
135
+ reverify_stages: set[int],
136
+ ) -> None:
137
+ prev_items = _prep_items(prev_planning, snapshot="prior")
138
+ cur_items = _prep_items(cur_planning, snapshot="current")
139
+ scoped_stages = carry_stages | reverify_stages
140
+ carried: dict[str, dict] = {}
141
+ for item_id, item in prev_items.items():
142
+ refs = _stage_refs(item, label=f"prior PREP item {item_id}")
143
+ if refs & carry_stages and refs & reverify_stages:
144
+ raise CarryError(f"prior PREP item {item_id} crosses carry and reverify stages")
145
+ if not refs <= scoped_stages:
146
+ raise CarryError(f"prior PREP item {item_id} falls outside carry/reverify scope")
147
+ if refs <= carry_stages:
148
+ duplicate = cur_items.get(item_id)
149
+ if duplicate is not None and _canonical(duplicate) != _canonical(item):
150
+ raise CarryError(f"carry-owned PREP item {item_id} conflicts with current")
151
+ carried[item_id] = copy.deepcopy(item)
152
+
153
+ retained: dict[str, dict] = {}
154
+ for item_id, item in cur_items.items():
155
+ refs = _stage_refs(item, label=f"current PREP item {item_id}")
156
+ if refs & carry_stages and refs & reverify_stages:
157
+ raise CarryError(f"current PREP item {item_id} crosses carry and reverify stages")
158
+ if not refs <= scoped_stages:
159
+ raise CarryError(f"current PREP item {item_id} falls outside carry/reverify scope")
160
+ if refs & carry_stages and not refs & reverify_stages:
161
+ previous = prev_items.get(item_id)
162
+ if previous is None:
163
+ raise CarryError(f"current PREP item {item_id} is a carry-stage scope leak")
164
+ previous_refs = _stage_refs(
165
+ previous,
166
+ label=f"prior PREP item {item_id}",
167
+ )
168
+ if previous_refs != refs:
169
+ raise CarryError(
170
+ f"current PREP item {item_id} changes stage ownership"
171
+ )
172
+ continue
173
+ retained[item_id] = copy.deepcopy(item)
174
+ retained.update(carried)
175
+
176
+ preparation = cur_planning.setdefault("designPreparation", {})
177
+ preparation["items"] = [retained[item_id] for item_id in sorted(retained)]
178
+
179
+
180
+ def _merge_non_prep_verdicts(
181
+ prev_items: dict[str, dict],
182
+ cur_items: dict[str, dict],
183
+ prev_seq: str,
184
+ ) -> list[dict]:
185
+ merged = {
186
+ item_id: copy.deepcopy(item)
187
+ for item_id, item in cur_items.items()
188
+ if _prep_verdict_stage(item) is None
189
+ }
190
+ for item_id, item in prev_items.items():
191
+ if _prep_verdict_stage(item) is None and item_id not in merged:
192
+ carried = copy.deepcopy(item)
193
+ carried["carriedForwardFromSeq"] = str(prev_seq)
194
+ merged[item_id] = carried
195
+ return list(merged.values())
196
+
197
+
198
+ def _merge_prep_verdicts(
199
+ prev_items: dict[str, dict],
200
+ cur_items: dict[str, dict],
201
+ carry_stages: set[int],
202
+ reverify_stages: set[int],
203
+ prev_seq: str,
204
+ ) -> list[dict]:
205
+ merged = []
206
+ scoped_stages = carry_stages | reverify_stages
207
+ for item_id, item in cur_items.items():
208
+ stage = _prep_verdict_stage(item)
209
+ if stage is None:
210
+ continue
211
+ if stage not in scoped_stages:
212
+ raise CarryError(f"current PREP verdict {item_id} falls outside carry/reverify scope")
213
+ if stage in carry_stages:
214
+ previous = prev_items.get(item_id)
215
+ if previous is None:
216
+ raise CarryError(f"current PREP verdict {item_id} is a carry-stage scope leak")
217
+ if _canonical(previous, ignore_carry_marker=True) != _canonical(
218
+ item, ignore_carry_marker=True,
219
+ ):
220
+ raise CarryError(f"carry-owned PREP verdict {item_id} conflicts with current")
221
+ else:
222
+ merged.append(copy.deepcopy(item))
223
+
224
+ for item_id, item in prev_items.items():
225
+ stage = _prep_verdict_stage(item)
226
+ if stage is None:
227
+ continue
228
+ if stage not in scoped_stages:
229
+ raise CarryError(f"prior PREP verdict {item_id} falls outside carry/reverify scope")
230
+ if stage in carry_stages:
231
+ carried = copy.deepcopy(item)
232
+ carried["carriedForwardFromSeq"] = str(prev_seq)
233
+ merged.append(carried)
234
+ return merged
235
+
236
+
237
+ def _merge_stage_aware(
238
+ prev_planning: dict,
239
+ cur_planning: dict,
240
+ prev_seq: str,
241
+ carry_stages: set[int],
242
+ reverify_stages: set[int],
243
+ ) -> None:
244
+ prev_stages = _stage_rows(prev_planning, snapshot="prior")
245
+ cur_stages = _stage_rows(cur_planning, snapshot="current")
246
+ _validate_stage_sets(prev_stages, cur_stages, carry_stages, reverify_stages)
247
+ _merge_stage_rows(
248
+ cur_planning, prev_stages, cur_stages, carry_stages, reverify_stages,
249
+ )
250
+ _merge_prep_items(prev_planning, cur_planning, carry_stages, reverify_stages)
251
+
252
+ prev_pbv = prev_planning.get("planBodyVerification", {})
253
+ cur_pbv = cur_planning.setdefault("planBodyVerification", {})
254
+ prev_items = _indexed_rows(
255
+ prev_pbv.get("planItems", []), key="id", label="prior planItems",
256
+ )
257
+ cur_items = _indexed_rows(
258
+ cur_pbv.get("planItems", []), key="id", label="current planItems",
259
+ )
260
+ merged = _merge_non_prep_verdicts(prev_items, cur_items, prev_seq)
261
+ merged.extend(_merge_prep_verdicts(
262
+ prev_items, cur_items, carry_stages, reverify_stages, prev_seq,
263
+ ))
264
+ merged.sort(key=lambda item: item["id"])
265
+ cur_pbv["planItems"] = merged
266
+
267
+
268
+ def merge_carried_forward(
269
+ prev: dict,
270
+ cur: dict,
271
+ prev_seq: str = "previous",
272
+ carry_stages: set[int] | None = None,
273
+ reverify_stages: set[int] | None = None,
274
+ ) -> dict:
19
275
  if prev.get("schemaVersion") != cur.get("schemaVersion"):
20
276
  raise CarryError(
21
277
  f"schemaVersion drift {prev.get('schemaVersion')!r} != {cur.get('schemaVersion')!r}; "
22
278
  "cannot carry forward — caller must fall back to full"
23
279
  )
24
- prev_pbv = prev.get("implementationPlanning", {}).get("planBodyVerification", {})
25
- cur_ip = cur.setdefault("implementationPlanning", {})
26
- cur_pbv = cur_ip.setdefault("planBodyVerification", {})
27
- cur_items = cur_pbv.setdefault("planItems", [])
28
- reverified_ids = {i["id"] for i in cur_items}
29
- for item in prev_pbv.get("planItems", []):
30
- if item["id"] in reverified_ids:
31
- continue
32
- carried = dict(item)
33
- carried["carriedForwardFromSeq"] = str(prev_seq)
34
- cur_items.append(carried)
35
- cur_items.sort(key=lambda i: i["id"])
280
+ prev_planning = _planning(prev)
281
+ cur_planning = cur.setdefault("implementationPlanning", {})
282
+ if (carry_stages is None) != (reverify_stages is None):
283
+ raise CarryError("carry_stages and reverify_stages must be provided together")
284
+ if carry_stages is None:
285
+ _merge_legacy_plan_items(prev_planning, cur_planning, prev_seq)
286
+ else:
287
+ _merge_stage_aware(
288
+ prev_planning, cur_planning, prev_seq, carry_stages, reverify_stages,
289
+ )
36
290
  return cur
37
291
 
38
292
 
293
+ def _parse_stage_csv(value: str | None, *, option: str) -> set[int] | None:
294
+ if value is None:
295
+ return None
296
+ try:
297
+ return {int(token.strip()) for token in value.split(",") if token.strip()}
298
+ except ValueError as exc:
299
+ raise CarryError(f"{option} contains an invalid stage number") from exc
300
+
301
+
39
302
  def main(argv: list[str]) -> int:
40
303
  ap = argparse.ArgumentParser(prog="okstra incremental-carry")
41
304
  ap.add_argument("--prev-data", required=True, help="prior run final-report data.json")
42
305
  ap.add_argument("--cur-data", required=True, help="current incremental re-run data.json")
43
306
  ap.add_argument("--prev-seq", required=True, help="prior run seq, tagged onto carried items")
307
+ ap.add_argument("--carry-stages", default=None, help="comma-separated carried stage numbers")
308
+ ap.add_argument("--reverify-stages", default=None, help="comma-separated reverified stage numbers")
44
309
  ap.add_argument("--out", required=True, help="path to write the merged data.json")
45
310
  args = ap.parse_args(argv)
46
311
 
47
312
  prev = json.loads(Path(args.prev_data).read_text(encoding="utf-8"))
48
313
  cur = json.loads(Path(args.cur_data).read_text(encoding="utf-8"))
49
314
  try:
50
- merged = merge_carried_forward(prev, cur, prev_seq=args.prev_seq)
315
+ merged = merge_carried_forward(
316
+ prev,
317
+ cur,
318
+ prev_seq=args.prev_seq,
319
+ carry_stages=_parse_stage_csv(args.carry_stages, option="--carry-stages"),
320
+ reverify_stages=_parse_stage_csv(
321
+ args.reverify_stages, option="--reverify-stages",
322
+ ),
323
+ )
51
324
  except CarryError as exc:
52
325
  print(f"carry refused: {exc}", file=sys.stderr)
53
326
  return 1
@@ -57,10 +330,10 @@ def main(argv: list[str]) -> int:
57
330
  )
58
331
  carried = sum(
59
332
  1
60
- for i in merged.get("implementationPlanning", {})
333
+ for item in merged.get("implementationPlanning", {})
61
334
  .get("planBodyVerification", {})
62
335
  .get("planItems", [])
63
- if i.get("carriedForwardFromSeq") == str(args.prev_seq)
336
+ if item.get("carriedForwardFromSeq") == str(args.prev_seq)
64
337
  )
65
338
  print(f"merged {carried} carried-forward plan item(s) from seq {args.prev_seq} -> {args.out}")
66
339
  return 0
@@ -39,6 +39,40 @@ def parse_stage_graph(data: dict) -> list[tuple[int, list[int]]]:
39
39
  return [(int(row["stage"]), _parse_depends_on(row.get("dependsOn", ""))) for row in stage_map]
40
40
 
41
41
 
42
+ def design_prep_impacted_stages(data: dict, item_ids: set[str]) -> set[int]:
43
+ items = (
44
+ data.get("implementationPlanning", {})
45
+ .get("designPreparation", {})
46
+ .get("items", [])
47
+ )
48
+ if not isinstance(items, list):
49
+ raise ValueError("designPreparation.items must be an array")
50
+ matched = {
51
+ str(item.get("id")): item
52
+ for item in items
53
+ if isinstance(item, dict) and str(item.get("id")) in item_ids
54
+ }
55
+ missing = item_ids - set(matched)
56
+ if missing:
57
+ raise ValueError(f"unknown design-prep item(s): {', '.join(sorted(missing))}")
58
+
59
+ impacted: set[int] = set()
60
+ for item_id, item in matched.items():
61
+ stage_refs = item.get("stageRefs")
62
+ if not isinstance(stage_refs, list) or not stage_refs:
63
+ raise ValueError(f"design-prep item {item_id} has invalid stageRefs")
64
+ try:
65
+ parsed_refs = {int(stage) for stage in stage_refs}
66
+ except (TypeError, ValueError) as exc:
67
+ raise ValueError(
68
+ f"design-prep item {item_id} has invalid stageRefs"
69
+ ) from exc
70
+ if any(stage < 1 for stage in parsed_refs):
71
+ raise ValueError(f"design-prep item {item_id} has invalid stageRefs")
72
+ impacted.update(parsed_refs)
73
+ return impacted
74
+
75
+
42
76
  def decide_scope(
43
77
  *,
44
78
  stages: list[tuple[int, list[int]]],
@@ -72,15 +106,27 @@ def main(argv: list[str]) -> int:
72
106
  ap.add_argument("--cur-base-sha", required=True)
73
107
  ap.add_argument("--prev-base-sha", required=True)
74
108
  ap.add_argument("--impacted", default="", help="comma-separated impacted stage numbers")
109
+ ap.add_argument("--prep-items", default="", help="comma-separated changed PREP item IDs")
75
110
  args = ap.parse_args(argv)
76
111
 
77
112
  data = json.loads(Path(args.prev_data).read_text(encoding="utf-8"))
78
113
  stages = parse_stage_graph(data)
79
- impacted = {int(t.strip()) for t in args.impacted.split(",") if t.strip()}
80
- decision = decide_scope(
81
- stages=stages, impacted_stages=impacted,
82
- prev_base_sha=args.prev_base_sha, cur_base_sha=args.cur_base_sha,
83
- )
114
+ try:
115
+ impacted = {int(t.strip()) for t in args.impacted.split(",") if t.strip()}
116
+ prep_ids = {t.strip() for t in args.prep_items.split(",") if t.strip()}
117
+ impacted.update(design_prep_impacted_stages(data, prep_ids))
118
+ unknown_stages = impacted - {stage for stage, _ in stages}
119
+ if unknown_stages:
120
+ unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
121
+ raise ValueError(f"impacted stage(s) absent from Stage Map: {unknown}")
122
+ decision = decide_scope(
123
+ stages=stages, impacted_stages=impacted,
124
+ prev_base_sha=args.prev_base_sha, cur_base_sha=args.cur_base_sha,
125
+ )
126
+ except ValueError as exc:
127
+ decision = IncrementalDecision(
128
+ "full", [], [], f"invalid incremental-scope input: {exc}",
129
+ )
84
130
  print(json.dumps(asdict(decision), ensure_ascii=False))
85
131
  return 0
86
132
 
@@ -21,7 +21,7 @@ def build_analysis_material(brief_path: Path, directive: str = "") -> str:
21
21
  parts.append("## Directive")
22
22
  parts.append("")
23
23
  parts.append(
24
- "> Free-form directive supplied via `--directive`. Treat as a hard hint that may override default heuristics in lead, workers, and downstream skills (e.g. okstra-schedule's Gantt skip gate)."
24
+ "> Free-form directive supplied via `--directive`. Treat as a hard hint that may override default heuristics in lead, workers, and downstream skills (e.g. okstra-schedule-gen's Gantt skip gate)."
25
25
  )
26
26
  parts.append("")
27
27
  parts.append(directive)
@@ -0,0 +1,98 @@
1
+ """Pre-dispatch model-identity normalization for CLI workers.
2
+
3
+ The catalog (models.py) carries okstra's model identity. Some worker CLIs
4
+ (antigravity `agy`, codex) accept their own spelling of that identity, and it
5
+ drifts across CLI versions / accounts. This module maps a catalog execution
6
+ value + role to the CLI's exact accepted identifier, or hard-fails with the
7
+ CLI's real list. It never substitutes a different model — spelling/effort only.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+
15
+ # Per-role reasoning-effort policy for CLIs that bake effort into the model
16
+ # name (agy). Deterministic — no per-run improvisation.
17
+ # `critic` is intentionally absent: an opt-in critic reuses its worker's
18
+ # execution value (render.py maps critic_choice -> *_WORKER_MODEL_EXECUTION_VALUE),
19
+ # so it inherits the worker's normalized identity + effort. role_effort("critic")
20
+ # returns _DEFAULT_EFFORT, matching the worker it mirrors.
21
+ ROLE_EFFORT = {"worker": "High", "executor": "High"}
22
+ _DEFAULT_EFFORT = "High"
23
+
24
+
25
+ class ModelUnavailableError(RuntimeError):
26
+ """Requested model identity is not offered by the worker CLI on this host."""
27
+
28
+
29
+ def role_effort(role: str) -> str:
30
+ return ROLE_EFFORT.get(role, _DEFAULT_EFFORT)
31
+
32
+
33
+ @lru_cache(maxsize=1)
34
+ def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
35
+ """Live `agy models` list, or () when agy is unavailable (non-blocking)."""
36
+ try:
37
+ proc = subprocess.run(
38
+ [agy_bin, "models"],
39
+ capture_output=True, text=True, timeout=20,
40
+ )
41
+ except (FileNotFoundError, OSError, subprocess.SubprocessError):
42
+ return ()
43
+ if proc.returncode != 0:
44
+ return ()
45
+ return tuple(line.strip() for line in proc.stdout.splitlines() if line.strip())
46
+
47
+
48
+ def _codex_config_path() -> Path:
49
+ return Path.home() / ".codex" / "config.toml"
50
+
51
+
52
+ def codex_availability(config_path: Path | None = None) -> tuple[str, ...]:
53
+ """Keys under [tui.model_availability_nux] in codex config, or ()."""
54
+ path = config_path or _codex_config_path()
55
+ try:
56
+ lines = path.read_text(encoding="utf-8").splitlines()
57
+ except OSError:
58
+ return ()
59
+ out: list[str] = []
60
+ in_section = False
61
+ for raw in lines:
62
+ line = raw.strip()
63
+ if line.startswith("[") and line.endswith("]"):
64
+ in_section = line == "[tui.model_availability_nux]"
65
+ continue
66
+ if in_section and "=" in line:
67
+ key = line.split("=", 1)[0].strip().strip('"')
68
+ if key:
69
+ out.append(key)
70
+ return tuple(out)
71
+
72
+
73
+ def normalize_execution_for_dispatch(
74
+ *, provider: str, execution: str, display: str, role: str,
75
+ ) -> str:
76
+ """Return the CLI-accepted spelling of `execution`, or the value unchanged
77
+ when discovery is unavailable. Raise ModelUnavailableError when the identity
78
+ is definitely not offered (never substitute a different model)."""
79
+ if provider == "codex":
80
+ available = codex_availability()
81
+ if not available or execution in available:
82
+ return execution # no NUX list → pass through; listed → accepted
83
+ raise ModelUnavailableError(
84
+ f"codex model {execution!r} is not available for this account. "
85
+ f"Available: {', '.join(available)} (no auto-rename — update the catalog)"
86
+ )
87
+ if provider != "antigravity":
88
+ return execution # claude runs via the Agent tool
89
+ available = agy_models()
90
+ if not available:
91
+ return execution # discovery unavailable → status quo, wrapper still guards
92
+ want = f"{display} ({role_effort(role)})"
93
+ if want in available:
94
+ return want
95
+ raise ModelUnavailableError(
96
+ f"antigravity model {want!r} (from catalog {execution!r}, role {role!r}) "
97
+ f"is not offered by this agy install. Available: {', '.join(available)}"
98
+ )
@@ -36,8 +36,11 @@ CLAUDE = {
36
36
  "claude-haiku-4-5": ModelSpec("haiku-4-5", "claude-haiku-4-5"),
37
37
  "claude-haiku-4-5-20251001": ModelSpec("haiku-4-5", "claude-haiku-4-5-20251001"),
38
38
  }
39
- # Execution values stay `gemini-3.x`: the `agy` CLI runs Gemini-family models and
40
- # accepts those slugs. Only the okstra provider/worker identity is "antigravity".
39
+ # The `agy` CLI accepts DISPLAY names with an effort suffix (e.g.
40
+ # "Gemini 3.1 Pro (High)"), not `gemini-3.x` slugs, and its list is
41
+ # host/version-specific. model_discovery.normalize_execution_for_dispatch maps
42
+ # each ModelSpec.display + role effort to the exact `agy models` identifier at
43
+ # dispatch time. agy also serves Claude/GPT-OSS models on some installs.
41
44
  ANTIGRAVITY = {
42
45
  "gemini-3.1-pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro", in_picker=True),
43
46
  "gemini 3.1 pro": ModelSpec("Gemini 3.1 Pro", "gemini-3.1-pro"),
@@ -45,6 +48,8 @@ ANTIGRAVITY = {
45
48
  "gemini 3.5 flash": ModelSpec("Gemini 3.5 Flash", "gemini-3.5-flash"),
46
49
  }
47
50
  CODEX = {
51
+ # ChatGPT-account host (agy/codex) serves 5.6 without per-token billing → pricing=None.
52
+ "gpt-5.6-sol": ModelSpec("gpt-5.6-sol", "gpt-5.6-sol", None, in_picker=True),
48
53
  "gpt-5.6": ModelSpec("gpt-5.6", "gpt-5.6", (5.00, 0.50, 30.0), in_picker=True),
49
54
  "gpt-5.5": ModelSpec("gpt-5.5", "gpt-5.5", (5.00, 0.50, 30.0), in_picker=True),
50
55
  "gpt-5.4": ModelSpec("gpt-5.4", "gpt-5.4", (2.50, 0.25, 15.0), in_picker=True),
@@ -58,7 +63,7 @@ PROVIDER_MAPPINGS = {"claude": CLAUDE, "antigravity": ANTIGRAVITY, "codex": CODE
58
63
  # ROLE-keyed (not provider): 1:1 with run.py recommended_role_models. "lead" uses
59
64
  # the claude provider; "report-writer" is role-only (None → caller uses lead's).
60
65
  ROLE_DEFAULTS = {
61
- "lead": "opus", "claude": "opus", "codex": "gpt-5.6",
66
+ "lead": "opus", "claude": "opus", "codex": "gpt-5.6-sol",
62
67
  "antigravity": "gemini-3.1-pro", "report-writer": None,
63
68
  }
64
69
 
@@ -1351,6 +1351,11 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1351
1351
  }
1352
1352
  if ctx.get("FIX_CYCLE_ID"):
1353
1353
  payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1354
+ payload["reportContracts"] = (
1355
+ ["implementation-design-prep-v1"]
1356
+ if ctx.get("TASK_TYPE") == "implementation-planning"
1357
+ else []
1358
+ )
1354
1359
  _write_json(Path(run_manifest_path), payload)
1355
1360
 
1356
1361