okstra 0.152.0 → 0.153.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.
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """Split `agy --output-format stream-json` output for okstra-antigravity-exec.sh.
3
+
4
+ Everything agy emits is appended to the live log, so the log becomes an audit
5
+ trail of what the worker actually did — which tools it called, on which paths,
6
+ for how long. In the default `text` format agy prints only its closing summary,
7
+ so an antigravity dispatch left ~2KB of self-report against codex's ~487KB of
8
+ tool trace, and whether the worker ever opened the evidence it was asked to
9
+ verify could not be checked from outside.
10
+
11
+ stdout stays what the dispatcher's contract expects: the final response text
12
+ only, so the subagent's captured output is unchanged by the format switch.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+
19
+
20
+ def main() -> int:
21
+ if len(sys.argv) < 2:
22
+ print("usage: okstra-wrapper-agy-stream.py <log-path>", file=sys.stderr)
23
+ return 2
24
+ log_path = sys.argv[1]
25
+ final: str | None = None
26
+ # Lines agy emitted that are not stream-json events. Normally empty; kept so
27
+ # a non-JSON emitter (a stub, an older agy, a crash banner) still reaches
28
+ # stdout instead of being silently swallowed.
29
+ passthrough: list[str] = []
30
+ with open(log_path, "a", encoding="utf-8") as log:
31
+ for line in sys.stdin:
32
+ log.write(line)
33
+ log.flush()
34
+ stripped = line.strip()
35
+ if not stripped:
36
+ continue
37
+ try:
38
+ event = json.loads(stripped)
39
+ except json.JSONDecodeError:
40
+ passthrough.append(line)
41
+ continue
42
+ if isinstance(event, dict) and event.get("event") == "result":
43
+ result = event.get("result")
44
+ if isinstance(result, dict):
45
+ final = result.get("response") or ""
46
+ if final is not None:
47
+ sys.stdout.write(final)
48
+ elif passthrough:
49
+ sys.stdout.writelines(passthrough)
50
+ else:
51
+ print(
52
+ "okstra-wrapper-agy-stream: no result event in agy output — "
53
+ f"see {log_path}",
54
+ file=sys.stderr,
55
+ )
56
+ sys.stdout.flush()
57
+ return 0
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
@@ -204,7 +204,8 @@ hard_refutes = [v for v in disagrees if v.disagreeBasis == "counter-evidence"]
204
204
  all_others_disagree = (every non-discoverer non-error vote is "disagree")
205
205
 
206
206
  IF len(disagrees) == 0:
207
- resolve F as "full-consensus" (or "partial-consensus" if any SUPPLEMENT/caveat)
207
+ resolve F as "partial-consensus" if SUPPLEMENT/caveat votes are a majority of
208
+ non-error votes, otherwise "full-consensus"
208
209
  ELIF all_others_disagree:
209
210
  resolve F as "worker-unique" # only the discoverer still holds it
210
211
  ELIF len(hard_refutes) >= 1:
@@ -219,7 +220,7 @@ ELSE:
219
220
 
220
221
  `contested` remains a **final classification only** (per §"Scope and Terminology"): a disputed finding is carried forward through intermediate rounds and labelled `contested` only at the last executed round. For `requirements-discovery` (`effectiveMaxRounds = 1`) the single round IS the last round, so a split-with-hard-refute finding is labelled `contested` in that one round. The final-classifier block of §"Convergence Algorithm" honours this: its first branch classifies an adversarially carried-forward finding `contested` regardless of the AGREE tally, so the two sections cannot assign the same finding different labels.
221
222
 
222
- Design intent: one `counter-evidence` refute denies a claim consensus (it cannot rise above `contested` however many others AGREE); later-round agreement does not erase that refutation history. The only resolution that overrides prior `counter-evidence` is a later round where every non-discoverer non-error worker disagrees, producing `worker-unique`. A lone `burden-not-met` doubt does not sink an otherwise-surviving claim — only a majority of them does. When every non-discoverer refutes (all_others_disagree) the finding is worker-unique regardless of refute basis — only the discoverer still holds it. A SUPPLEMENT/caveat with zero disagrees lands partial-consensus, not full-consensus, because a caveat means the claim does not pass cleanly (unlike the collaborative classifier, where SUPPLEMENT counts as full agreement).
223
+ Design intent: one `counter-evidence` refute denies a claim consensus (it cannot rise above `contested` however many others AGREE); later-round agreement does not erase that refutation history. The only resolution that overrides prior `counter-evidence` is a later round where every non-discoverer non-error worker disagrees, producing `worker-unique`. A lone `burden-not-met` doubt does not sink an otherwise-surviving claim — only a majority of them does. When every non-discoverer refutes (all_others_disagree) the finding is worker-unique regardless of refute basis — only the discoverer still holds it. A caveat is weighed the same way a weak doubt is: with zero disagrees, SUPPLEMENT lands partial-consensus only when caveats are a **majority** of the non-error votes. A single verifier's scope note does not by itself deny a claim the rest of the roster passed cleanly the caveat is still recorded in the dissent log either way, so the majority rule changes the label, never the record. (The collaborative classifier is more permissive still: there SUPPLEMENT counts as full agreement at any count.)
223
224
 
224
225
  ## Re-verification Dispatch
225
226
 
@@ -194,7 +194,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
194
194
  **Record the cause, not just the outcome.** The gate value names the outcome; `planBodyVerification.gateBlockedBy` (array) names every input that blocked it — `majority-disagree`, `coverage-gap`, `non-result`. Two independent inputs can block: a `majority-disagree` plan item, and a Requirement Coverage `gap` / `blocked C-NNN` row (`prompts/profiles/implementation-planning.md` §"Requirement Coverage"). A coverage-only block still renders as `blocked-by-disagreement` because that is the only blocking non-abort value, so **without `gateBlockedBy` the report asserts a worker disagreement that never happened** and the reader hunts for a dissent that does not exist. Leave the array empty for a passing gate. **Enforced:** `validators/validate-run.py` `_validate_gate_blocked_by` cross-checks the declared causes against the recorded verdicts and coverage rows, and fails a passing gate that has a blocking coverage row — the coverage rule was prose-only before.
195
195
 
196
196
  **A coverage row citing this run's own `C-NNN` is not an independent blocker.** When a coverage row's `blocked C-NNN` points at a clarification that step 8 below promoted from a `majority-disagree` item in *this same run*, that blocker is already counted once as the plan item. Counting it again as a coverage gap makes the run block on a clarification it just authored, and the row carries into the next run as a fresh blocker — the Requirement Coverage ↔ Clarification cycle. Such rows are excluded from `coverage-gap`. **Enforced:** `validators/validate-run.py` `_independent_coverage_blockers`.
197
- 6. Lead records `planBodyVerification.participatingAnalysers` as `{rostered, voting}` — how many analysers the roster carried, and how many actually returned a non-error vote. The gate arithmetic is unchanged, but a shrunken roster loosens it silently: with two participating analysers a 1-AGREE / 1-DISAGREE split is a tie, so it never reaches `majority-disagree` and the dissent passes as `dissent-isolated`. A reader comparing two runs' gate values cannot see that without this pair. **Enforced:** `validators/validate-run.py` `_validate_participating_analysers` recomputes `voting` from the recorded verdicts and fails a declared figure the table denies. Then lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below), **appending this round** — one new `roundHistory[]` entry plus this round's votes on each verified item's `planItems[].rounds[]`. The file accumulates across rounds; it is never truncated to the latest one. Lead then populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
197
+ 6. Lead records `planBodyVerification.participatingAnalysers` as `{rostered, voting}` — how many analysers the roster carried, and how many actually returned a non-error vote. The gate arithmetic is unchanged, but a shrunken roster loosens it silently: with two participating analysers a 1-AGREE / 1-DISAGREE split is a tie, so it never reaches `majority-disagree` and the dissent passes as `dissent-isolated`. A reader comparing two runs' gate values cannot see that without this pair. **Enforced:** `validators/validate-run.py` `_validate_participating_analysers` recomputes `voting` from the recorded verdicts and fails a declared figure the table denies. That pair still counts only *whether* a worker voted: an analyser that answers the same verdict to every item is carried in `voting` as a third opinion while contributing no refutation signal, so the gate reads as a three-way cross-check backed by two. **Enforced (advisory):** `validators/validate-run.py` `_detect_uniform_verifier` reports any worker whose every vote in the round was one verdict, with its item count — it does not fail the run, because a unanimous round is also a legitimate outcome and no ratio separates the two reliably. Then lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below), **appending this round** — one new `roundHistory[]` entry plus this round's votes on each verified item's `planItems[].rounds[]`. The file accumulates across rounds; it is never truncated to the latest one. Lead then populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
198
198
  7. **Self-fix loop (up to `selfFixMaxRounds`, targeting planner-fixable defects).** After aggregation, while at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`, lead runs self-fix rounds **before** promoting anything to the user:
199
199
  - **Group the targets by cause before instructing (BLOCKING).** Blocked items are usually several derivatives of one defect — one constant declared twice, one responsibility given two owners — and the coverage rows that cite them fail as a consequence, not independently. Lead MUST partition this round's targets into cause groups and instruct each group as **"remove this cause"**, naming the derivatives it accounts for. **Handing report-writer a bare item list is forbidden**: patched one at a time, each correction leaves the sibling sections still asserting the old value, so the next round re-finds the same family and the budget drains without converging. Record the partition in `planBodyVerification.selfFixGroups[]` (`round`, `causeSummary`, `itemIds`). One group per item is a legitimate outcome only when the items genuinely share no cause — recorded that way, it is a visible diagnosis rather than a skipped one. **Enforced:** `validators/validate-run.py` `_validate_self_fix_grouping` requires the partition, ties `selfFixRoundsApplied` to the highest recorded round, and fails any corrected item that belongs to no group.
200
200
  - lead instructs report-writer to rewrite the items in each cause group (NOT a full draft regeneration; procedure in [report-writer](./report-writer.md) §"Self-fix rewrite").
@@ -430,6 +430,35 @@ When `config.adversarial == true`, the lead prepends the adversarial framing fro
430
430
 
431
431
  The "Reverify prompt: required-reading suppression" rule in [convergence](./convergence.md) (lightweight mode does NOT inject a `[Required reading]` clause) applies here as well.
432
432
 
433
+ ## Re-verification rounds (round 2+) — carry the dissent forward (BLOCKING)
434
+
435
+ The template above is the round-1 prompt. A round 2+ prompt exists to settle the *previous* round's dissent, so it MUST carry that dissent forward. Re-rendering the round-1 template alone is a contract violation: with no record of what was objected to, the dissenting worker restates its verdict unchanged and its peers re-judge from nothing, so the loop spends its whole `selfFixMaxRounds` budget re-deriving the same split. Telling the worker that a prior verdict "carries no weight" is the same defect stated as an instruction — prior dissent is evidence about the plan, and discarding it is what makes the round repeat.
436
+
437
+ Changes from the round-1 template:
438
+
439
+ - The heading reads `(round <N>)`.
440
+ - Every re-dispatched item carries a `**Prior round dissent**` block, built from the state file's `planItems[].rounds[].votes` and the item's `selfFixNote`:
441
+
442
+ ```text
443
+ **Prior round dissent** (round <N-1>):
444
+ - <worker>: DISAGREE(<kind>) — <that worker's verbatim explanation>
445
+ - <worker>: SUPPLEMENT — <verbatim explanation>
446
+ **What the planner changed**: <the item's selfFixNote, or "no correction — this item's
447
+ text shifted under a neighbouring rewrite">
448
+ ```
449
+
450
+ An item re-dispatched as `needs-reverify` (its peer returned a non-result, so nothing was objected to) carries the block with `- none — the peer vote was a non-result` in place of the verdict lines.
451
+
452
+ - The `## Instructions` block gains this paragraph, which supersedes any "judge the corrected text on its own merits" framing:
453
+
454
+ > Each item below was objected to in the previous round, or its text shifted under a neighbouring correction. Read the prior dissent and the planner's correction before judging the CURRENT text. If you raised the prior objection and still DISAGREE, you MUST state what you re-opened after the rewrite and why the correction is insufficient — restating your previous explanation without engaging the correction is not a valid verdict. If the correction resolves your objection, say so and AGREE.
455
+
456
+ - The response format gains one line per item, directly under `**Verdict**`:
457
+
458
+ `**Prior dissent**: resolved | unresolved | none — <which prior objection, and what the current text does about it>`
459
+
460
+ **Enforced:** `validators/validate-run.py` `_validate_reverify_prompt_carries_dissent` fails a run whose `plan-verify-r<N>` (N ≥ 2) prompt file lacks the `**Prior round dissent**` block.
461
+
433
462
  ## Worker non-result handling in plan-body round (BLOCKING)
434
463
 
435
464
  Mirrors finding convergence ([convergence](./convergence.md) §"Worker failure handling in reverify"). Concretely:
@@ -27,6 +27,7 @@ from pathlib import Path
27
27
  from typing import Callable, Iterable
28
28
 
29
29
  from .stage_targets import PrepareError # 단일 PrepareError 타입 재노출(run.py 와 공유)
30
+ from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
30
31
 
31
32
  OKSTRA_LABEL_KEYS = ("okstra.task-key", "okstra.project-name", "okstra.run-trace")
32
33
 
@@ -530,16 +531,14 @@ def _resolve_up_inputs(project_root: Path, task_key: str) -> dict:
530
531
 
531
532
 
532
533
  def _plan_stage_map(approved_plan_path: str) -> list[dict]:
533
- """approved-plan(implementation-planning final-report)에서 전체 stage 목록 파싱.
534
-
535
- done 행만으로 합성하면 whole-task 완료 게이트(stage N not done)가 vacuous 해진다
536
- 게이트가 같은 done 집합을 기준으로 검사하기 때문. final-verification 과
537
- 동일한 파서(`_parse_stage_map_into_ctx`)를 써 계획에 선언된 미완 stage 를
538
- container up 거부하게 한다(stage 1 done 3-stage task 를 '완성'으로
539
- 배포하지 않음). 빈/손상 Stage Map 거부는 `_parse_stage_map_into_ctx` 가
540
- 공통으로 수행한다."""
541
- from .run import _parse_stage_map_into_ctx
542
- return _parse_stage_map_into_ctx(approved_plan_path)
534
+ """Parse the approved plan's complete Stage Map for container gating."""
535
+ try:
536
+ return stage_map_records(parse_stage_map_file(Path(approved_plan_path)))
537
+ except StageMapError as exc:
538
+ raise PrepareError(
539
+ "approved-plan Stage Map 신뢰할 없어 거부합니다 "
540
+ f"({approved_plan_path}): {exc.reason}. plan Stage Map 점검하세요."
541
+ ) from exc
543
542
 
544
543
 
545
544
  def _verify_compose_present(worktree_root: Path) -> Path:
@@ -191,9 +191,10 @@ def classify_adversarial_round(
191
191
  return None
192
192
  disagrees = [vote for vote in usable if vote["verdict"] == "disagree"]
193
193
  if not disagrees:
194
+ caveats = sum(vote["verdict"] == "supplement" for vote in usable)
194
195
  return (
195
196
  "partial-consensus"
196
- if any(vote["verdict"] == "supplement" for vote in usable)
197
+ if caveats > len(usable) / 2
197
198
  else "full-consensus"
198
199
  )
199
200
  if len(disagrees) == len(usable):
@@ -15,6 +15,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
15
15
  from . import consumers, stage_targets, worktree_registry
16
16
  from .final_report_paths import final_report_markdown_path
17
17
  from .paths import RunRef
18
+ from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
18
19
  from .worktree import (compute_branch_name, compute_worktree_path,
19
20
  main_worktree_path, is_dirty_excluding_okstra,
20
21
  nested_worktree_excludes, is_ancestor, merge_branch,
@@ -411,8 +412,6 @@ def _parse_stages_csv(raw: str) -> List[int]:
411
412
  def main(argv: Optional[list] = None) -> int:
412
413
  import argparse
413
414
 
414
- from .run import _parse_stage_map_into_ctx, PrepareError
415
-
416
415
  p = argparse.ArgumentParser(prog="okstra handoff")
417
416
  sub = p.add_subparsers(dest="cmd", required=True)
418
417
 
@@ -458,14 +457,14 @@ def main(argv: Optional[list] = None) -> int:
458
457
  a = p.parse_args(argv)
459
458
  try:
460
459
  if a.cmd == "eligible":
461
- stage_map = _parse_stage_map_into_ctx(a.approved_plan)
460
+ stage_map = stage_map_records(parse_stage_map_file(Path(a.approved_plan)))
462
461
  rows = consumers.read_consumers(Path(a.plan_run_root))
463
462
  out = {"stages": compute_eligibility(stage_map, rows)}
464
463
  elif a.cmd == "assemble":
465
464
  out = assemble(
466
465
  project_root=Path(a.project_root).resolve(),
467
466
  plan_run_root=Path(a.plan_run_root),
468
- stage_map=_parse_stage_map_into_ctx(a.approved_plan),
467
+ stage_map=stage_map_records(parse_stage_map_file(Path(a.approved_plan))),
469
468
  stages=_parse_stages_csv(a.stages), base_branch=a.base,
470
469
  work_category=a.work_category, project_id=a.project_id,
471
470
  task_group=a.task_group, task_id=a.task_id)
@@ -490,10 +489,7 @@ def main(argv: Optional[list] = None) -> int:
490
489
  except HandoffError as exc:
491
490
  print(json.dumps({"error": str(exc)}, ensure_ascii=False))
492
491
  return 1
493
- except PrepareError as exc:
494
- # _parse_stage_map_into_ctx 가 빈/손상 Stage Map 을 거부하면 PrepareError 가
495
- # 오른다(HandoffError 의 형제 — 위 except 로 안 잡힘). JSON 계약을 깨고
496
- # traceback 으로 죽지 않도록 error envelope + exit 1 로 처리한다.
492
+ except StageMapError as exc:
497
493
  print(json.dumps({"error": str(exc)}, ensure_ascii=False))
498
494
  return 1
499
495
  print(json.dumps(out, ensure_ascii=False, indent=2))
@@ -12,6 +12,7 @@ from .consumers import (
12
12
  read_stage_consumer_state,
13
13
  )
14
14
  from .paths import RunRef, task_manifest_file
15
+ from .stage_map import StageMapError, load_task_stage_map
15
16
  from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
16
17
 
17
18
 
@@ -36,9 +37,15 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
36
37
  if not plan_run_root.is_dir():
37
38
  return ImplementationOutcome(completed=False, reason="implementation-planning run root missing")
38
39
 
39
- stage_map = load_stage_map(task_root, manifest)
40
- if not stage_map:
41
- return ImplementationOutcome(completed=False, reason="stage map unavailable")
40
+ try:
41
+ stage_snapshot = load_task_stage_map(task_root, manifest)
42
+ except StageMapError as exc:
43
+ return ImplementationOutcome(
44
+ completed=False, reason=f"{exc.code}: {exc.reason}"
45
+ )
46
+ if stage_snapshot.state == "missing":
47
+ return ImplementationOutcome(completed=False, reason="stage map missing")
48
+ stage_map = stage_snapshot.stages
42
49
 
43
50
  consumers = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
44
51
  required_stages = {stage["stage_number"] for stage in stage_map}
@@ -114,59 +121,6 @@ def _load_json(path: Path) -> dict[str, Any]:
114
121
  return data if isinstance(data, dict) else {}
115
122
 
116
123
 
117
- def load_stage_map(task_root: Path, manifest: dict[str, Any]) -> list[dict[str, Any]]:
118
- plan_path = _source_plan_path(task_root, manifest)
119
- if not plan_path.is_file():
120
- return []
121
- from .run import PrepareError, _load_parsed_stage_map, _stage_map_reject_detail
122
-
123
- try:
124
- stages, errs = _load_parsed_stage_map(plan_path.read_text(encoding="utf-8"))
125
- except PrepareError:
126
- return []
127
- if _stage_map_reject_detail(stages, errs) is not None:
128
- return []
129
- return [
130
- {
131
- "stage_number": stage.stage_number,
132
- "title": stage.title,
133
- "depends_on": list(stage.depends_on),
134
- "step_count": stage.step_count,
135
- }
136
- for stage in stages
137
- ]
138
-
139
-
140
- def _source_plan_path(task_root: Path, manifest: dict[str, Any]) -> Path:
141
- carry_dir = RunRef.from_task_root(task_root, "implementation").carry_dir
142
- for carry_path in sorted(carry_dir.glob("stage-*.json")):
143
- value = _load_json(carry_path).get("sourcePlanPath")
144
- if isinstance(value, str) and value:
145
- return _resolve_relative(task_root, value)
146
-
147
- latest = manifest.get("latestReportPath")
148
- if isinstance(latest, str) and "implementation-planning" in latest:
149
- return _resolve_relative(task_root, latest)
150
-
151
- reports = RunRef.from_task_root(task_root, "implementation-planning").reports_dir
152
- candidates = sorted(reports.glob("final-report-implementation-planning-*.md"))
153
- return candidates[-1] if candidates else Path()
154
-
155
-
156
- def _resolve_relative(task_root: Path, value: str) -> Path:
157
- path = Path(value)
158
- if path.is_absolute():
159
- return path
160
- project_root = _project_root_from_task_root(task_root)
161
- project_relative = project_root / path
162
- if project_relative.exists():
163
- return project_relative
164
- task_relative = task_root / path
165
- if task_relative.exists():
166
- return task_relative
167
- return project_relative
168
-
169
-
170
124
  def _project_root_from_task_root(task_root: Path) -> Path:
171
125
  parts = task_root.resolve().parts
172
126
  if ".okstra" not in parts:
@@ -30,6 +30,27 @@ def role_effort(role: str) -> str:
30
30
  return ROLE_EFFORT.get(role, _DEFAULT_EFFORT)
31
31
 
32
32
 
33
+ # agy lists a tier per model, but the listed slug is not always the model it
34
+ # serves: on agy 1.1.10 a `gemini-3.1-pro-high` session identifies itself as
35
+ # "Gemini 3.6 Flash", while `gemini-3.1-pro-low` correctly identifies as
36
+ # "Gemini 3.1 Pro". The substitution is not visible anywhere in the dispatch —
37
+ # `agy models` lists the slug and the run exits 0 — so it surfaces only as a
38
+ # verifier that never refutes anything: measured over one 63-item plan-body
39
+ # prompt, the high slug returned all-AGREE in 64s while the low slug spent 328s
40
+ # and raised three DISAGREEs, one of them the defect claude and codex both
41
+ # caught that round. Only the `high` tier is affected, so a role asking for any
42
+ # other effort still resolves normally (and a tier agy does not offer still
43
+ # hard-fails below).
44
+ _UNTRUSTED_HIGH_TIER_EXECUTIONS = frozenset({"gemini-3.1-pro"})
45
+
46
+
47
+ def _dispatch_effort(execution: str, role: str) -> str:
48
+ effort = role_effort(role).lower()
49
+ if effort == "high" and execution in _UNTRUSTED_HIGH_TIER_EXECUTIONS:
50
+ return "low"
51
+ return effort
52
+
53
+
33
54
  @lru_cache(maxsize=1)
34
55
  def agy_models(agy_bin: str = "agy") -> tuple[str, ...]:
35
56
  """Live `agy models` list, or () when agy is unavailable (non-blocking)."""
@@ -91,7 +112,7 @@ def normalize_execution_for_dispatch(
91
112
  # (`gemini-3.1-pro-high`) and rejects the bare slug with "requires --effort"
92
113
  # — a flag the wrapper never sends. So the suffixed form is the only
93
114
  # dispatchable spelling, including on the un-verified fallback below.
94
- tiered = f"{execution}-{role_effort(role).lower()}"
115
+ tiered = f"{execution}-{_dispatch_effort(execution, role)}"
95
116
  available = agy_models()
96
117
  if not available:
97
118
  return tiered # discovery unavailable → dispatch anyway, wrapper still guards
@@ -26,6 +26,17 @@ class PlanRun(NamedTuple):
26
26
  approved_plan_path: str
27
27
 
28
28
 
29
+ def list_implementation_planning_reports(reports_dir: Path) -> list[Path]:
30
+ """Return numbered implementation-planning reports in latest-first order."""
31
+ numbered: list[tuple[int, Path]] = []
32
+ for report in reports_dir.glob("final-report-implementation-planning-*.md"):
33
+ match = _FINAL_REPORT_RE.fullmatch(report.name)
34
+ if match:
35
+ numbered.append((int(match.group(1)), report))
36
+ numbered.sort(key=lambda item: item[0], reverse=True)
37
+ return [report for _, report in numbered]
38
+
39
+
29
40
  def plan_run_root_from_approved_plan(approved_plan_path: str | Path) -> Path:
30
41
  """approved-plan(final-report) 경로에서 plan_run_root 를 역산한다.
31
42
 
@@ -58,21 +69,17 @@ def resolve_plan_run_root_by_task_key(
58
69
  f"({reports_dir} 없음). 먼저 implementation-planning 을 완료하거나 "
59
70
  "approved-plan 경로를 직접 지정하세요."
60
71
  )
61
- candidates: list[tuple[int, Path, Path]] = []
62
- for report in reports_dir.glob("final-report-implementation-planning-*.md"):
63
- m = _FINAL_REPORT_RE.search(report.name)
64
- if not m:
65
- continue
72
+ candidates: list[tuple[Path, Path]] = []
73
+ for report in list_implementation_planning_reports(reports_dir):
66
74
  run_root = plan_run_root_from_approved_plan(report)
67
75
  done = [r for r in read_consumers(run_root) if r.get("status") == "done"]
68
76
  if done:
69
- candidates.append((int(m.group(1)), run_root, report))
77
+ candidates.append((run_root, report))
70
78
  if not candidates:
71
79
  raise PrepareError(
72
80
  "container up: done 상태의 implementation-planning run 이 없습니다. "
73
81
  "stage 를 완료(implementation done)한 뒤 다시 시도하거나 approved-plan "
74
82
  "경로를 직접 지정하세요."
75
83
  )
76
- candidates.sort(key=lambda c: c[0])
77
- _, run_root, report = candidates[-1]
84
+ run_root, report = candidates[0]
78
85
  return PlanRun(run_root=run_root, approved_plan_path=str(report))
@@ -15,7 +15,6 @@ state passing, and are read once at the start.
15
15
  """
16
16
  from __future__ import annotations
17
17
 
18
- import importlib.util
19
18
  import hashlib
20
19
  import json
21
20
  import os
@@ -475,62 +474,17 @@ def _validate_stage_structure(plan_path: str) -> None:
475
474
  RUN_STEP_BUDGET = _stage_targets.RUN_STEP_BUDGET
476
475
 
477
476
 
478
- def _stage_map_reject_detail(stages, errs):
479
- """빈/손상 Stage Map 거부 사유 문자열을 돌려준다. 정상이면 None.
480
-
481
- 빈/손상 Stage Map 을 흘려보내면 whole-task 완료 게이트
482
- (`_resolve_whole_task_target`의 `for stage in stage_map`)가 0회 순회로 vacuous
483
- 통과해 미완 task 를 '완성'으로 배포·검증한다. 빈 파싱(heading rename·표 손상)과
484
- stage 번호 비단조(중간 행 누락 → S2)의 판정을 한 곳에 모아, run.py 의 prepare
485
- 게이트와 wizard 의 stage picker 가 같은 기준을 상속하게 한다(single-reference)."""
486
- if stages and not errs:
487
- return None
488
- return (
489
- "; ".join(e.message for e in errs) if errs
490
- else "'## 5.5 Stage Map' heading 이 없거나 표가 비었습니다")
491
-
492
-
493
- def _load_parsed_stage_map(text: str):
494
- """validator 모듈을 로드해 `_parse_stage_map(text)` 의 (stages, errs) 를 돌려준다.
477
+ def _parse_stage_map_into_ctx(plan_path: str) -> list:
478
+ """Parse the approved plan into context records for execution consumers."""
479
+ from .stage_map import StageMapError, parse_stage_map_file, stage_map_records
495
480
 
496
- run.py 와 wizard 가 공유하는 단일 validator-load 경로 — 둘 다 `_STAGE_VALIDATOR_PATH`
497
- (설치 시 `~/.okstra/lib/validators` 로 배치돼 `parents[2]/validators` 로 해소)로
498
- 수렴해 경로 해소가 갈라지지 않게 한다."""
499
- spec = importlib.util.spec_from_file_location(
500
- "_ip_stage_validator", _STAGE_VALIDATOR_PATH
501
- )
502
- if spec is None or spec.loader is None:
503
- raise PrepareError(f"cannot load stage validator at {_STAGE_VALIDATOR_PATH}")
504
- mod = importlib.util.module_from_spec(spec)
505
- # Register before exec_module so dataclass field-type resolution can find
506
- # the module in sys.modules (required on Python 3.9).
507
- sys.modules["_ip_stage_validator"] = mod
508
481
  try:
509
- spec.loader.exec_module(mod)
510
- return mod._parse_stage_map(text)
511
- finally:
512
- sys.modules.pop("_ip_stage_validator", None)
513
-
514
-
515
- def _parse_stage_map_into_ctx(plan_path: str) -> list:
516
- """Reuse the validator's parser to extract StageMeta dicts for the ctx."""
517
- text = Path(plan_path).read_text(encoding="utf-8")
518
- stages, errs = _load_parsed_stage_map(text)
519
- detail = _stage_map_reject_detail(stages, errs)
520
- if detail is not None:
482
+ return stage_map_records(parse_stage_map_file(Path(plan_path)))
483
+ except StageMapError as exc:
521
484
  raise PrepareError(
522
- f"approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 ({plan_path}): "
523
- f"{detail}. plan 의 Stage Map 을 점검하세요.")
524
- return [
525
- {
526
- "stage_number": s.stage_number,
527
- "title": s.title,
528
- "depends_on": list(s.depends_on),
529
- "step_count": s.step_count,
530
- "exit_contract_summary": s.exit_contract_summary,
531
- }
532
- for s in stages
533
- ]
485
+ "approved-plan 의 Stage Map 을 신뢰할 수 없어 거부합니다 "
486
+ f"({plan_path}): {exc.reason}. plan 의 Stage Map 을 점검하세요."
487
+ ) from exc
534
488
 
535
489
 
536
490
  def _apply_cli_approval(path: str) -> str: