okstra 0.121.1 → 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.
- package/README.md +4 -2
- package/docs/architecture/storage-model.md +14 -0
- package/docs/architecture.md +38 -8
- package/docs/cli.md +45 -5
- package/docs/for-ai/README.md +2 -2
- package/docs/for-ai/skills/okstra-rollup.md +1 -1
- package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
- package/docs/project-structure-overview.md +4 -3
- package/docs/task-process/implementation-planning.md +33 -9
- package/docs/task-process/implementation.md +21 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -3
- package/runtime/prompts/launch.template.md +9 -6
- package/runtime/prompts/lead/adapters/claude-code.md +104 -0
- package/runtime/prompts/lead/adapters/codex.md +45 -0
- package/runtime/prompts/lead/adapters/external.md +46 -0
- package/runtime/prompts/lead/context-loader.md +5 -5
- package/runtime/prompts/lead/convergence.md +11 -28
- package/runtime/prompts/lead/okstra-lead-contract.md +44 -94
- package/runtime/prompts/lead/plan-body-verification.md +50 -5
- package/runtime/prompts/lead/report-writer.md +59 -54
- package/runtime/prompts/lead/team-contract.md +33 -106
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
- package/runtime/prompts/profiles/_implementation-executor.md +6 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/final-verification.md +2 -0
- package/runtime/prompts/profiles/implementation-planning.md +14 -4
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +44 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
- package/runtime/python/okstra_ctl/design_prep.py +1462 -0
- package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
- package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
- package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
- package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
- package/runtime/python/okstra_ctl/lead_runtime.py +4 -0
- package/runtime/python/okstra_ctl/material.py +1 -1
- package/runtime/python/okstra_ctl/model_discovery.py +98 -0
- package/runtime/python/okstra_ctl/models.py +8 -3
- package/runtime/python/okstra_ctl/render.py +81 -194
- package/runtime/python/okstra_ctl/report_views.py +22 -7
- package/runtime/python/okstra_ctl/run.py +53 -5
- package/runtime/python/okstra_ctl/team_reconcile.py +3 -1
- package/runtime/python/okstra_ctl/user_response.py +67 -2
- package/runtime/python/okstra_ctl/wizard.py +283 -3
- package/runtime/python/okstra_token_usage/report.py +11 -0
- package/runtime/schemas/final-report-v1.0.schema.json +339 -0
- package/runtime/skills/okstra-inspect/SKILL.md +2 -2
- package/runtime/skills/okstra-rollup/SKILL.md +1 -1
- package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +62 -20
- package/runtime/skills/okstra-user-response/SKILL.md +8 -6
- package/runtime/templates/reports/final-report.template.md +71 -4
- package/runtime/templates/reports/i18n/en.json +32 -1
- package/runtime/templates/reports/i18n/ko.json +32 -1
- package/runtime/validators/validate-run.py +515 -5
- package/runtime/validators/validate-schedule.py +11 -7
- package/runtime/validators/validate_session_conformance.py +58 -33
- package/src/cli-registry.mjs +14 -0
- package/src/commands/inspect/design-prep.mjs +23 -0
- package/src/commands/inspect/stage-map.mjs +100 -0
- package/src/commands/lifecycle/install.mjs +3 -0
- package/src/commands/lifecycle/uninstall.mjs +8 -23
- package/src/lib/skill-catalog.mjs +10 -1
|
@@ -1,53 +1,326 @@
|
|
|
1
|
-
"""Merge carried-forward
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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
|
|
333
|
+
for item in merged.get("implementationPlanning", {})
|
|
61
334
|
.get("planBodyVerification", {})
|
|
62
335
|
.get("planItems", [])
|
|
63
|
-
if
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
|
@@ -14,6 +14,7 @@ class LeadRuntimeInfo:
|
|
|
14
14
|
adapter_dispatch_mode: str
|
|
15
15
|
session_accounting: str
|
|
16
16
|
has_claude_session: bool
|
|
17
|
+
adapter_contract_relative_path: str
|
|
17
18
|
|
|
18
19
|
def adapter_payload(self) -> dict[str, str]:
|
|
19
20
|
return {
|
|
@@ -36,6 +37,7 @@ _LEAD_RUNTIMES = {
|
|
|
36
37
|
adapter_dispatch_mode="team",
|
|
37
38
|
session_accounting="claude-jsonl",
|
|
38
39
|
has_claude_session=True,
|
|
40
|
+
adapter_contract_relative_path="lead/adapters/claude-code.md",
|
|
39
41
|
),
|
|
40
42
|
"codex": LeadRuntimeInfo(
|
|
41
43
|
runtime="codex",
|
|
@@ -46,6 +48,7 @@ _LEAD_RUNTIMES = {
|
|
|
46
48
|
adapter_dispatch_mode="render-only",
|
|
47
49
|
session_accounting="artifact-only",
|
|
48
50
|
has_claude_session=False,
|
|
51
|
+
adapter_contract_relative_path="lead/adapters/codex.md",
|
|
49
52
|
),
|
|
50
53
|
"external": LeadRuntimeInfo(
|
|
51
54
|
runtime="external",
|
|
@@ -56,6 +59,7 @@ _LEAD_RUNTIMES = {
|
|
|
56
59
|
adapter_dispatch_mode="render-only",
|
|
57
60
|
session_accounting="artifact-only",
|
|
58
61
|
has_claude_session=False,
|
|
62
|
+
adapter_contract_relative_path="lead/adapters/external.md",
|
|
59
63
|
),
|
|
60
64
|
}
|
|
61
65
|
|
|
@@ -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
|
-
#
|
|
40
|
-
#
|
|
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
|
|