okstra 0.209.0 → 0.209.2

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 (44) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +9 -1
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/prompts/duties/business-flow-investigator.json +1 -1
  6. package/runtime/prompts/launch.template.md +1 -1
  7. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  8. package/runtime/prompts/lead/convergence.md +2 -2
  9. package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
  10. package/runtime/prompts/wizard/prompts.ko.json +15 -14
  11. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +15 -4
  12. package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +7 -3
  13. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +8 -4
  14. package/runtime/python/okstra_ctl/agent/activity.py +2 -2
  15. package/runtime/python/okstra_ctl/analysis_packet.py +7 -0
  16. package/runtime/python/okstra_ctl/business_flow/engine.py +2 -2
  17. package/runtime/python/okstra_ctl/business_flow/source.py +35 -2
  18. package/runtime/python/okstra_ctl/cmux.py +93 -25
  19. package/runtime/python/okstra_ctl/convergence.py +29 -10
  20. package/runtime/python/okstra_ctl/dispatch_checkpoints.py +93 -3
  21. package/runtime/python/okstra_ctl/dispatch_core.py +18 -7
  22. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +16 -0
  23. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +70 -2
  24. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +21 -16
  25. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +11 -9
  26. package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +79 -17
  27. package/runtime/python/okstra_ctl/plan_items.py +4 -2
  28. package/runtime/python/okstra_ctl/render.py +4 -0
  29. package/runtime/python/okstra_ctl/report_finalize.py +19 -7
  30. package/runtime/python/okstra_ctl/run.py +15 -1
  31. package/runtime/python/okstra_ctl/team.py +6 -4
  32. package/runtime/python/okstra_ctl/user_response.py +22 -0
  33. package/runtime/python/okstra_ctl/wizard/cli.py +0 -2
  34. package/runtime/python/okstra_ctl/wizard/engine.py +33 -46
  35. package/runtime/python/okstra_ctl/wizard/registry.py +2 -2
  36. package/runtime/python/okstra_ctl/wizard/roles.py +2 -8
  37. package/runtime/python/okstra_ctl/wizard/steps_identity.py +7 -5
  38. package/runtime/python/okstra_ctl/wizard/steps_plan.py +1 -1
  39. package/runtime/python/okstra_ctl/worker_audit_ledger.py +35 -24
  40. package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -1
  41. package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
  42. package/runtime/python/okstra_ctl/write_policy.py +16 -0
  43. package/runtime/skills/okstra-run/SKILL.md +3 -3
  44. package/runtime/validators/validate-run.py +9 -2
@@ -718,6 +718,21 @@ _PREP_OWNERSHIP_NOTE = (
718
718
  "writer did not produce those fields.\n"
719
719
  )
720
720
 
721
+ # 이 네 필드는 report assembly 가 선택 방향 스냅샷으로 덮어쓰고 검증기가 다른 값을
722
+ # 거절한다(validation._direction_realization_errors). 계획자가 바꿀 수 없는 값에
723
+ # planner-fixable DISAGREE 를 내면 self-fix 로 풀리지 않는 차단이 된다.
724
+ _DIR_SNAPSHOT_OWNED_NOTE = (
725
+ "\nSnapshot-owned fields: `coreMechanism`, `architectureBoundaries`, "
726
+ "`planningInvariants`, and `userConstraints` above are verbatim copies of "
727
+ "the selected-direction snapshot. Report assembly overwrites them from "
728
+ "that snapshot and validation rejects any other value, so the planner "
729
+ "cannot change them. Judge whether the writer-owned realization fields "
730
+ "honour them. When a later user decision (an answered clarification) "
731
+ "departs from them, judge whether the writer-owned fields and the plan "
732
+ "narrative's `supersessionLedger` carry that decision; do not return a "
733
+ "planner-fixable DISAGREE that asks to edit these four fields.\n"
734
+ )
735
+
721
736
 
722
737
  def _step_coordinate(payload: object, field: str) -> int | None:
723
738
  if not isinstance(payload, Mapping):
@@ -784,7 +799,9 @@ def _render_analyser_split(verdicts: object) -> str:
784
799
  continue
785
800
  kind = str(verdict.get("breakageKind") or "").strip()
786
801
  label = f"{token}({kind})" if kind else token
787
- rows.append(f"- `{worker}`: `{label}`\n")
802
+ basis = _prior_vote(verdict).get("basis")
803
+ suffix = f" — `{scalar(basis)}`" if basis else ""
804
+ rows.append(f"- `{worker}`: `{label}`{suffix}\n")
788
805
  if not rows:
789
806
  return ""
790
807
  return "Analyser split:\n" + "".join(rows)
@@ -944,6 +961,25 @@ def _render_prior_round(entry: object) -> str:
944
961
  return "".join(rows)
945
962
 
946
963
 
964
+ # 검증자의 읽기 범위(`**Read scope:**`)는 `## Inputs` 에 열거된 경로만 허용한다.
965
+ # 이 절이 없으면 P-Req 의 originalRequirementId 를 패킷의 요구 문장으로 되짚을
966
+ # 수 없어 범위를 지킨 검증자는 verification-error 를 낸다.
967
+ _INPUT_FIELDS = (
968
+ ("Primary analysis packet", "analysisPacketPath"),
969
+ ("Plan narrative", "reportNarrativePath"),
970
+ )
971
+
972
+
973
+ def _render_inputs(run_manifest: Path) -> str:
974
+ payload = validated_run_authority(run_manifest).payload
975
+ rows = [
976
+ f"- {label}: `{payload[field].strip()}`\n"
977
+ for label, field in _INPUT_FIELDS
978
+ if isinstance(payload.get(field), str) and payload[field].strip()
979
+ ]
980
+ return "\n## Inputs\n\n" + "".join(rows) if rows else ""
981
+
982
+
947
983
  def _prompt(args: argparse.Namespace) -> str:
948
984
  envelope = _load_json_object(
949
985
  _prepared_items_path(args.run_manifest, require_regular=True)
@@ -963,7 +999,11 @@ def _prompt(args: argparse.Namespace) -> str:
963
999
  if isinstance(envelope.get("priorRounds"), Mapping)
964
1000
  else {}
965
1001
  )
966
- rows = ["# Plan verification queue\n", line("Item count", len(items))]
1002
+ rows = [
1003
+ "# Plan verification queue\n",
1004
+ line("Item count", len(items)),
1005
+ _render_inputs(args.run_manifest),
1006
+ ]
967
1007
  for index, item in enumerate(items, 1):
968
1008
  if not isinstance(item, Mapping):
969
1009
  continue
@@ -982,6 +1022,8 @@ def _prompt(args: argparse.Namespace) -> str:
982
1022
  rows.append(_render_prior_steps(item, all_items))
983
1023
  if item_id.startswith("P-Prep-"):
984
1024
  rows.append(_PREP_OWNERSHIP_NOTE)
1025
+ if item_id == "P-Dir-1":
1026
+ rows.append(_DIR_SNAPSHOT_OWNED_NOTE)
985
1027
  split = _render_analyser_split(
986
1028
  (envelope.get("tieSplits") or {}).get(item_id)
987
1029
  if isinstance(envelope.get("tieSplits"), Mapping)
@@ -2270,6 +2312,10 @@ def _narrow_dispatch_queue(
2270
2312
  f"queue does not contain — pass the artifact this round dispatched, "
2271
2313
  f"not another round's"
2272
2314
  )
2315
+ if getattr(args, "append", False) and _queue_holds_open_round_votes(
2316
+ verification, assigned - narrowed, args.round_number
2317
+ ):
2318
+ return narrowed
2273
2319
  verification["dispatchQueue"] = [
2274
2320
  item_id
2275
2321
  for item_id in (queue if isinstance(queue, list) else sorted(assigned))
@@ -2278,6 +2324,28 @@ def _narrow_dispatch_queue(
2278
2324
  return narrowed
2279
2325
 
2280
2326
 
2327
+ def _queue_holds_open_round_votes(
2328
+ verification: Mapping[str, Any],
2329
+ outside: set[object],
2330
+ round_number: int,
2331
+ ) -> bool:
2332
+ """critic 의 `--append` 가 아직 열린 분석자 라운드에 얹히는지.
2333
+
2334
+ 그 라운드의 큐를 tie 부분집합으로 덮으면 이어지는 `complete-round` 가
2335
+ 분석자 표가 있는 나머지 항목을 배정 밖으로 보고 거절한다.
2336
+ """
2337
+ items = verification.get("planItems")
2338
+ return any(
2339
+ isinstance(item, Mapping)
2340
+ and item.get("id") in outside
2341
+ and any(
2342
+ isinstance(vote, Mapping) and vote.get("round") == round_number
2343
+ for vote in item.get("verdicts") or []
2344
+ )
2345
+ for item in (items if isinstance(items, list) else [])
2346
+ )
2347
+
2348
+
2281
2349
  def _incoming_verdict_rows(
2282
2350
  args: argparse.Namespace,
2283
2351
  known: set[object],
@@ -275,6 +275,8 @@ The hexagonal rule that an extracted point must declare `interfaceKind: "port"`
275
275
 
276
276
  `P-Dir-1` carries the same YAGNI judgement as the legacy option item, but its comparison source is the selected-direction snapshot rather than a trade-off matrix. A new abstraction, configuration knob, widened interface, file, or stage with no original-requirement link is a hidden direction change and receives `DISAGREE(e)` on `P-Dir-1`.
277
277
 
278
+ Four `P-Dir-1` payload fields — `coreMechanism`, `architectureBoundaries`, `planningInvariants`, `userConstraints` — are verbatim copies of the selected-direction snapshot: report assembly overwrites them from the snapshot (`scripts/okstra_ctl/report_assembly.py`), and `scripts/okstra_ctl/phases/implementation_planning/validation.py` `_direction_realization_errors` rejects any other value. The planner cannot change them, so a mismatch between one of them and a later user decision (an answered clarification) is not a planner-fixable defect in those fields. Judge it against the writer-owned realization fields and the narrative's `supersessionLedger`: DISAGREE when they fail to carry the decision, and never ask for an edit to the four snapshot fields. The rendered `P-Dir-1` block restates this rule (`_DIR_SNAPSHOT_OWNED_NOTE` in `scripts/okstra_ctl/phases/implementation_planning/authoring.py`).
279
+
278
280
  `P-Opt-<N>` carries the **YAGNI judgement** and is majority-gated for the same reason as `P-Var-*`: whether an abstraction serves the stated requirement or only a forecast is a judgement about the design, not a contradiction between two spelled-out references. Raise it as `DISAGREE(e)` — an option that carries an abstraction, parameter, or configuration knob no Requirement Coverage row demands contradicts the trade-off matrix that scored it, because the complexity the matrix priced is not the complexity the option actually buys. DISAGREE on a `P-Opt-*` item under this rule means one of:
279
281
 
280
282
  - **an abstraction nobody asked for** — a helper module, strategy / factory, indirection layer, or interface whose only justification in the plan is a caller no requirement names. A second implementation already on the table is `P-Var-*` territory and is the opposite defect: do not raise both on the same behavior;
@@ -320,7 +322,7 @@ Plan-body verification only supports **lightweight mode** (defined in `prompts/l
320
322
 
321
323
  **Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_parse_config` rejects a `verificationMode` outside `_VERIFICATION_MODES`, and the plan-body round is seeded with `lightweight` rather than the manifest value.
322
324
 
323
- Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement text embedded in the `Requirement Coverage` row with the cited Option / Stage / Step in the draft plan. A row is not sound merely because it says `covered`; the cited plan item must actually satisfy the row's stated requirement. A `documented-deviation` never earns automatic `AGREE`: verify all four parts independently — the original requirement, the concrete alternative in `coveredBy`, every `decisionRefs` target, and the `approvalDisposition`. `accepted` requires a referenced user-confirmed clarification; `blocked C-NNN` requires that same-report clarification to be open and block approval.
325
+ Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement with the cited Option / Stage / Step in the draft plan. A legacy `Requirement Coverage` row embeds that requirement in its own `requirement` field. A selected-direction row carries only `originalRequirementId` and its `*Refs`, so the requirement is the line for that id in the primary analysis packet the prompt lists under `## Inputs`. A row is not sound merely because it says `covered`; the cited plan item must actually satisfy the requirement. A `documented-deviation` never earns automatic `AGREE`: verify all four parts independently — the original requirement, the concrete alternative in `coveredBy`, every `decisionRefs` target, and the `approvalDisposition`. `accepted` requires a referenced user-confirmed clarification; `blocked C-NNN` requires that same-report clarification to be open and block approval.
324
326
 
325
327
  For selected-direction `P-Req-*` rows, `status: externally-tracked` explicitly assigns external responsibility through `crossProjectDependencyRefs`. Verify that each reference resolves to a unique, complete `crossProjectDependencies` row and that its required work and verification signal satisfy the requirement. External-only rows need no local stage/step/file references; mixed rows retain their real local references. Do not classify absence of local references as kind `f` when the external mapping is valid. This is planned responsibility, not proof of implementation or deployment. Dangling or incomplete references are rejected by `implementation_direction._coverage_reference_errors`; semantic adequacy remains the verifier judgement.
326
328
 
@@ -670,25 +672,25 @@ Required prompt anchor headers are identical to finding convergence (see `prompt
670
672
  The `prompts/lead/convergence.md` §"Required reverify output contract"
671
673
  applies unchanged: append it verbatim after the response format below.
672
674
 
673
- The prompt-body contract check runs on every `analysis`-audience prompt, not
674
- just the critic's, so this round needs the same two lines the critic's
675
- instructions need (`prompts/lead/convergence.md` §"What the critic
676
- task-instructions file MUST contain"): a `**Prompt Delivery Mode:**` header and,
677
- under `## Inputs`, exactly one `- Primary analysis packet:` line whose path ends
678
- in `analysis-packet.md`. The literal label and the backticks are what the check
679
- matches — a bare path or a reworded label counts as zero. They are in the
680
- template below; keep them when you fill it in.
675
+ `okstra plan-items prompt` renders the `## Inputs` section itself: a
676
+ `- Primary analysis packet:` line from the run manifest's `analysisPacketPath`
677
+ and a `- Plan narrative:` line from its `reportNarrativePath`. The worker's
678
+ `**Read scope:**` header admits only paths the prompt enumerates, so these lines
679
+ are what let a verifier read the requirement a selected-direction `P-Req-*` row
680
+ names by `originalRequirementId`. The lead does not add them by hand.
681
681
 
682
682
  Carrying the packet does not license re-analysis. It is there so the verifier
683
683
  can resolve a plan item back to the requirement it claims to satisfy; the
684
684
  posture in §"Adversarial plan-body posture" still applies, and this round does
685
685
  not revisit the requirements themselves.
686
686
 
687
- Omitting either line fails `okstra team dispatch --dispatch-kind
688
- plan-verify-r<N> ...` before any process starts, reported as `<task-type>
689
- prompt contract: <worker>: exactly one Primary analysis packet path is required
690
- (found 0)`. Fix the instructions file and re-materialize with
691
- `--replace-undispatched` rather than editing the published prompt.
687
+ **Enforced:** only by the renderer — `_render_inputs` in
688
+ `scripts/okstra_ctl/phases/implementation_planning/authoring.py`, asserted by
689
+ `_assert_input_paths` in `tests/run/test_plan_items.py`. No dispatch check counts
690
+ the packet line for this round: the `Primary analysis packet` count in
691
+ `scripts/okstra_ctl/worker_prompt_contract.py` `_validate_prompt_for_plan` runs
692
+ only for the `analysis` audience, and a plan-verify dispatch is a
693
+ reverification.
692
694
 
693
695
  ````
694
696
  Perform plan-body verification for <task-key> (round 1).
@@ -698,6 +700,7 @@ Perform plan-body verification for <task-key> (round 1).
698
700
  ## Inputs
699
701
 
700
702
  - Primary analysis packet: `<path ending in analysis-packet.md>`
703
+ - Plan narrative: `<run manifest reportNarrativePath>`
701
704
 
702
705
  ## Instructions
703
706
 
@@ -757,8 +760,10 @@ Every verdict requires a non-empty explanation.
757
760
 
758
761
  **Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_parse_vote` rejects a vote whose `explanation` is empty and whose verdict is outside `_VERDICTS`; `classify_collaborative_round` drops `verification-error` votes from both numerator and denominator.
759
762
 
760
- For `P-Req-*` items, compare only the requirement text embedded in the row
761
- against the cited plan item(s). Do not open the original brief, but do reject
763
+ For `P-Req-*` items, compare the requirement against the cited plan item(s):
764
+ the row's own `requirement` text when it has one, otherwise the primary analysis
765
+ packet's line for the row's `originalRequirementId`. Do not open the original
766
+ brief, but do reject
762
767
  coverage rows that cite no concrete option/stage/step or cite a plan item that
763
768
  does not satisfy the row's own requirement. For `documented-deviation`, do not
764
769
  auto-AGREE based on the status token: separately verify the requirement, the
@@ -67,6 +67,7 @@ from okstra_ctl.design_surfaces import DesignSurfaceError # noqa: E402
67
67
 
68
68
 
69
69
  from okstra_ctl.plan_items import ( # noqa: E402
70
+ content_hash,
70
71
  expected_plan_item_ids,
71
72
  extract_plan_items,
72
73
  )
@@ -501,14 +502,14 @@ def _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
501
502
  non_result = any(
502
503
  row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes
503
504
  )
505
+ disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
504
506
  if (
505
507
  classification not in {"majority-disagree", "needs-reverify", "all-non-result"}
506
- and not non_result
508
+ and not (non_result and disagrees)
507
509
  ):
508
510
  return None
509
511
  if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
510
512
  return None
511
- disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
512
513
  verified = item.get("contentHash")
513
514
  if (
514
515
  not self_fix_rounds(pbv)
@@ -1572,10 +1573,7 @@ def _validate_verdicts_match_current_subjects(
1572
1573
  if not isinstance(round_count, int) or round_count < 1:
1573
1574
  return
1574
1575
  try:
1575
- current = {
1576
- str(item["id"]): str(item.get("subject") or "")
1577
- for item in extract_plan_items(ip)
1578
- }
1576
+ current = {str(item["id"]): item for item in extract_plan_items(ip)}
1579
1577
  except Exception: # noqa: BLE001
1580
1578
  # Extraction failure is already reported by the completeness check;
1581
1579
  # do not double-report it here as a spurious subject mismatch.
@@ -1587,10 +1585,14 @@ def _validate_verdicts_match_current_subjects(
1587
1585
  continue
1588
1586
  item_id = str(item.get("id") or "").strip()
1589
1587
  recorded = str(item.get("subject") or "").strip()
1590
- expected = current.get(item_id)
1591
- if expected is None or not recorded:
1588
+ extracted = current.get(item_id)
1589
+ if extracted is None or not recorded:
1590
+ continue
1591
+ if item.get("verifiedContentHash") == content_hash(extracted):
1592
+ # seed 는 subject 를 갱신하지 않는다. 표가 지금 이 위치의 본문에
1593
+ # 찍혔으면 subject 문구 차이는 위치 이동이 아니다.
1592
1594
  continue
1593
- if recorded != expected.strip():
1595
+ if recorded != str(extracted.get("subject") or "").strip():
1594
1596
  drifted.append(item_id)
1595
1597
  if drifted:
1596
1598
  failures.append(
@@ -15,8 +15,13 @@ from okstra_ctl.implementation_direction import (
15
15
  )
16
16
  from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
17
17
  from okstra_ctl.paths import task_dir, task_runs_dir
18
- from okstra_ctl.wizard.ids import S_SELECTED_DIRECTION_PICK
19
- from okstra_ctl.wizard.state import Prompt, WizardError, WizardState
18
+ from okstra_ctl.user_response import (
19
+ UserResponseError,
20
+ pending_direction_candidates,
21
+ record_direction_selection,
22
+ )
23
+ from okstra_ctl.wizard.ids import _ABORT_OPTION, S_SELECTED_DIRECTION_PICK
24
+ from okstra_ctl.wizard.state import Option, Prompt, WizardError, WizardState
20
25
  from okstra_ctl.wizard.prompts import _opt, _p
21
26
  from okstra_ctl.wizard.sources import _project_relative_path
22
27
 
@@ -27,6 +32,7 @@ from okstra_ctl.wizard.sources import _project_relative_path
27
32
  _SELECTION_REPORT_RE = re.compile(
28
33
  r"^final-report-implementation-option-selection-(?P<seq>\d{3,})\.data\.json$"
29
34
  )
35
+ _DIRECTION_SEPARATOR = "#"
30
36
 
31
37
 
32
38
  def _selected_direction_candidates(state: WizardState) -> list[str]:
@@ -97,18 +103,67 @@ def _planning_rerun_selected(state: WizardState) -> bool:
97
103
  )
98
104
 
99
105
 
106
+ def _task_key(state: WizardState) -> str:
107
+ return f"{state.project_id}:{state.task_group}:{state.task_id}"
108
+
109
+
110
+ def _pending_directions(
111
+ state: WizardState, rel_path: str
112
+ ) -> tuple[list[dict[str, str]], str]:
113
+ try:
114
+ return pending_direction_candidates(
115
+ Path(state.project_root) / rel_path, _task_key(state)
116
+ )
117
+ except UserResponseError:
118
+ return [], ""
119
+
120
+
121
+ def _selected_direction_options(state: WizardState) -> list[Option]:
122
+ """방향을 아직 고르지 않은 리포트는 후보마다 한 줄로 펼친다.
123
+
124
+ 리포트 경로만 고르게 하면 실제 결정(어느 방향인가)은 답변 사이드카에 있어야
125
+ 해서, 사이드카가 없는 리포트는 고르는 순간 거절됐다(실측 2026-10-04,
126
+ dev-11118: 후보 IO-001 하나를 고르자 `sidecar file not found`).
127
+ """
128
+ t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
129
+ options: list[Option] = []
130
+ recommended_taken = False
131
+ for path in _selected_direction_candidates(state):
132
+ candidates, recommended_id = _pending_directions(state, path)
133
+ if not candidates:
134
+ options.append(_opt(path, _selection_option_label(state, path, t)))
135
+ continue
136
+ match = _SELECTION_REPORT_RE.fullmatch(Path(path).name)
137
+ seq = match.group("seq") if match else ""
138
+ for row in candidates:
139
+ if row["ineligibility"]:
140
+ continue
141
+ recommended = not recommended_taken and row["id"] == recommended_id
142
+ recommended_taken = recommended_taken or recommended
143
+ options.append(_opt(
144
+ f"{path}{_DIRECTION_SEPARATOR}{row['id']}",
145
+ t["labels"]["pending_option"].format(
146
+ id=row["id"], name=row["name"], seq=seq
147
+ ),
148
+ row["goal"],
149
+ recommended=recommended,
150
+ ))
151
+ return sorted(options, key=lambda option: not option.recommended)
152
+
153
+
100
154
  def _build_selected_direction_pick(state: WizardState) -> Prompt:
101
- candidates = _selected_direction_candidates(state)
155
+ options = _selected_direction_options(state)
102
156
  t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
103
- if not candidates:
157
+ if not options:
104
158
  raise WizardError(t["errors"]["none"])
159
+ # 중단 줄이 있어야 후보가 하나뿐일 때도 사용자의 결정으로 남는다 — 옵션
160
+ # 1개짜리 화면은 자동 제출되고, 이 단계의 제출은 사용자 답변 파일을 쓴다.
161
+ options.append(_opt(_ABORT_OPTION, t["labels"]["abort"]))
105
162
  return Prompt(
106
163
  step=S_SELECTED_DIRECTION_PICK,
107
164
  kind="pick",
108
165
  label=t["label"],
109
- options=[
110
- _opt(path, _selection_option_label(state, path, t)) for path in candidates
111
- ],
166
+ options=options,
112
167
  echo_template=t["echo_template"],
113
168
  )
114
169
 
@@ -144,17 +199,24 @@ def _selection_option_label(state: WizardState, rel_path: str, t: dict) -> str:
144
199
 
145
200
  def _submit_selected_direction_pick(state: WizardState, value: str) -> Optional[str]:
146
201
  t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
147
- candidates = _selected_direction_candidates(state)
148
- if value not in candidates:
202
+ if value == _ABORT_OPTION:
203
+ state.aborted = True
204
+ return t["echo_variants"]["abort"]
205
+ if value not in {option.value for option in _selected_direction_options(state)}:
149
206
  raise WizardError(t["errors"]["unknown"].format(value=value))
207
+ path, _, option_id = value.partition(_DIRECTION_SEPARATOR)
208
+ report = Path(state.project_root) / path
209
+ recorded = ""
150
210
  try:
151
- resolve_selected_direction(
152
- Path(state.project_root) / value,
153
- expected_task_key=f"{state.project_id}:{state.task_group}:{state.task_id}",
154
- )
155
- except DirectionSelectionError as exc:
211
+ if option_id:
212
+ recorded = t["echo_variants"]["recorded_direction"].format(
213
+ id=option_id,
214
+ sidecar=record_direction_selection(report, _task_key(state), option_id),
215
+ )
216
+ resolve_selected_direction(report, expected_task_key=_task_key(state))
217
+ except (DirectionSelectionError, UserResponseError) as exc:
156
218
  raise WizardError(str(exc)) from exc
157
- state.selected_direction_path = value
219
+ state.selected_direction_path = path
158
220
  # 두 입력은 상호 배타다 — 방향이 정해진 순간 이 런은 새 계획이고,
159
221
  # clarification 자리에 남은 값은 render-bundle 이 거절할 이유일 뿐이다.
160
222
  # 거절을 여기서 앞당기는 대신 값을 비우고, 무엇이 비워졌는지 echo 로
@@ -162,5 +224,5 @@ def _submit_selected_direction_pick(state: WizardState, value: str) -> Optional[
162
224
  # `--selected-direction` 경로로 계획 런에 첨부된다(`run.py` 참조).
163
225
  if state.clarification_response_path:
164
226
  state.clarification_response_path = ""
165
- return t["echo_variants"]["cleared_clarification"].format(value=value)
166
- return f"selected-direction: {value}"
227
+ return t["echo_variants"]["cleared_clarification"].format(value=path) + recorded
228
+ return f"selected-direction: {path}{recorded}"
@@ -751,8 +751,10 @@ ENVIRONMENT_CORRECTION_PREAMBLE = (
751
751
 
752
752
  CRITIC_TIE_PREAMBLE = (
753
753
  "You are the critic tie-break. Only the items below are in dispute. "
754
- "Each item already has one AGREE and one DISAGREE from the two plan-body "
755
- "verifiers. Decide the item: AGREE or DISAGREE(<kind>). Your verdict "
754
+ "Each item's plan-body verifiers split on it; its `Analyser split` block lists "
755
+ "every analyser vote with the basis that analyser gave, and a "
756
+ "verification-error vote there counted for neither side. Decide the item: "
757
+ "AGREE or DISAGREE(<kind>). Your verdict "
756
758
  "settles the split, including a prior DISAGREE(a) or DISAGREE(f). "
757
759
  "Explain whether the earlier dissent is supported or mistaken; a corrected "
758
760
  "dissent remains in the audit history without overriding your decision. "
@@ -32,6 +32,7 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
32
32
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
33
33
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
34
34
  from . import fix_cycles
35
+ from .cmux import LEAD_LOCATION_KEY
35
36
  from . import next_phase
36
37
  from .analysis_inputs import ANALYSIS_TASK_TYPES
37
38
  from .application.resolve_assignment import (
@@ -1930,6 +1931,9 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1930
1931
  payload["analysisScopeConfirmation"] = scope_confirmation
1931
1932
  if ctx.get("FIX_CYCLE_ID"):
1932
1933
  payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
1934
+ cmux_lead = json.loads(ctx.get("CMUX_LEAD_JSON") or "{}")
1935
+ if cmux_lead:
1936
+ payload[LEAD_LOCATION_KEY] = cmux_lead
1933
1937
  if (
1934
1938
  CURRENT_REPORT_SCHEMA_VERSION == "3.0"
1935
1939
  or ctx.get("TASK_TYPE") == "implementation-planning"
@@ -727,13 +727,7 @@ def run_finalize(
727
727
  if step["name"] == "business-flow":
728
728
  payload["businessProcess"] = step["result"]
729
729
  # 선택 단계라 exitCode 는 0 이다. 실패를 중첩 JSON 에만 두면 리드가 못 본다.
730
- failed = [
731
- {"id": row.get("id", ""), "mode": row.get("mode", ""), "error": row.get("error", "")}
732
- for row in step["result"].get("executions") or []
733
- if isinstance(row, dict) and row.get("status") == "failed"
734
- ]
735
- if step["result"].get("error"):
736
- failed.append({"id": "", "mode": "", "error": str(step["result"]["error"])})
730
+ failed = business_flow_warnings(step["result"])
737
731
  if failed:
738
732
  payload["businessFlowWarnings"] = failed
739
733
  census = _census_summary(ctx)
@@ -742,6 +736,24 @@ def run_finalize(
742
736
  return payload
743
737
 
744
738
 
739
+ def business_flow_warnings(result: dict[str, Any]) -> list[dict[str, str]]:
740
+ # sidecar 는 task link 이력 전체를 담는다. 같은 mode 가 뒤에서 성공했으면 그 실패는 끝난 일이다.
741
+ rows = [row for row in result.get("executions") or [] if isinstance(row, dict)]
742
+ failed = [
743
+ {"id": row.get("id", ""), "mode": row.get("mode", ""), "error": row.get("error", "")}
744
+ for index, row in enumerate(rows)
745
+ if row.get("status") == "failed"
746
+ and not any(
747
+ later.get("mode") == row.get("mode")
748
+ and later.get("status") in {"complete", "partial"}
749
+ for later in rows[index + 1 :]
750
+ )
751
+ ]
752
+ if result.get("error"):
753
+ failed.append({"id": "", "mode": "", "error": str(result["error"])})
754
+ return failed
755
+
756
+
745
757
  def _census_summary(ctx: FinalizeContext) -> dict[str, Any] | None:
746
758
  """Unverdicted census cells per worker, for the lead's completion line."""
747
759
  try:
@@ -3059,6 +3059,7 @@ def _write_instruction_set_sources(
3059
3059
  json.loads(ctx.get("RELATED_TASKS_JSON", "[]")),
3060
3060
  ),
3061
3061
  coverage_census_text=coverage_census_text,
3062
+ has_selected_direction=(instruction_set / "selected-direction.json").is_file(),
3062
3063
  )
3063
3064
  if inp.task_type == "technical-verification":
3064
3065
  packet += materialize_technical_input_packet(
@@ -3967,7 +3968,7 @@ def _resolve_terminal_backend(project_root: Path, inp: PrepareInputs) -> str:
3967
3968
  return detected
3968
3969
  if _prior_run_backend(project_root, inp) != BACKEND_CMUX_PANE:
3969
3970
  return detected
3970
- observed, remedy = cmux.unreachable_advice(cmux.unreachable_reason())
3971
+ observed, remedy = cmux.unreachable_advice(cmux.unreachable_reason(None))
3971
3972
  raise PrepareError(" ".join(part for part in (
3972
3973
  f"this task's previous {inp.task_type} run put its workers on cmux "
3973
3974
  f"panes, and now {observed}.",
@@ -3985,6 +3986,18 @@ def lead_pane_title(task_group: str, task_id: str) -> str:
3985
3986
  return f"{task_group}/{task_id}"
3986
3987
 
3987
3988
 
3989
+ def _cmux_lead_json(terminal_backend: str) -> str:
3990
+ """prepare 를 실행한 터미널의 cmux 위치. cmux 백엔드가 아니면 빈 객체.
3991
+
3992
+ 리드 프로세스의 환경은 이 터미널의 것과 다를 수 있다(`cmux.LEAD_LOCATION_KEY`
3993
+ 참조) — 그래서 리드가 뜨기 전인 지금 값을 남긴다.
3994
+ """
3995
+ location = cmux.lead_location_from_env()
3996
+ if terminal_backend != BACKEND_CMUX_PANE or location is None:
3997
+ return "{}"
3998
+ return json.dumps(cmux.lead_location_payload(location))
3999
+
4000
+
3988
4001
  def _title_lead_pane(inp: PrepareInputs) -> None:
3989
4002
  """리드 pane 제목을 `<task-group>/<task-id>` 로 바꾼다 (cmux 백엔드 전용).
3990
4003
 
@@ -4204,6 +4217,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
4204
4217
 
4205
4218
  ctx.update({
4206
4219
  "TERMINAL_BACKEND": terminal_backend,
4220
+ "CMUX_LEAD_JSON": _cmux_lead_json(terminal_backend),
4207
4221
  "EXECUTOR_WORKTREE_PATH": worktree.path,
4208
4222
  "EXECUTOR_WORKTREE_BRANCH": worktree.branch,
4209
4223
  "EXECUTOR_WORKTREE_BASE_REF": worktree.base_ref,
@@ -366,7 +366,7 @@ def _close_panes(manifest: Mapping[str, Any], panes: list[dict[str, str]]) -> No
366
366
  from .adapters.runtime.assembly import port_for, runtime_chain
367
367
  from .domain.worker_runtime import RuntimeHandle, SURFACE_CMUX_PANE
368
368
 
369
- chain = runtime_chain(_manifest_backend(manifest))
369
+ chain = runtime_chain(_manifest_backend(manifest), manifest=manifest)
370
370
  # cli-wrapper chains have no cmux port; leftover paneIds must not KeyError.
371
371
  if panes and any(port.surface == SURFACE_CMUX_PANE for port in chain):
372
372
  closer = port_for(chain, SURFACE_CMUX_PANE)
@@ -466,11 +466,13 @@ def _reclaimable_panes(
466
466
  continue
467
467
  _append_pane(panes, seen, str(record.get("paneId", "")), "worker")
468
468
  if _is_cmux_run(manifest):
469
- return _still_open_surfaces(panes)
469
+ return _still_open_surfaces(panes, cmux.recorded_lead(manifest))
470
470
  return panes
471
471
 
472
472
 
473
- def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
473
+ def _still_open_surfaces(
474
+ panes: list[dict[str, str]], recorded: cmux.LeadLocation | None
475
+ ) -> list[dict[str, str]]:
474
476
  """The recorded surfaces cmux still shows.
475
477
 
476
478
  Nothing prunes `workerDispatches` — a repeat of one `dispatchId` replaces
@@ -484,7 +486,7 @@ def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
484
486
  already went away is a silent no-op, and skipping a live one strands it on
485
487
  the user's screen for the rest of the session.
486
488
  """
487
- workspace = cmux.resolve_lead_workspace()
489
+ workspace = cmux.resolve_lead_workspace(recorded)
488
490
  if not workspace:
489
491
  return panes
490
492
  try:
@@ -1602,6 +1602,28 @@ def set_direction_selection(
1602
1602
  _write_transaction(path, payload)
1603
1603
 
1604
1604
 
1605
+ def pending_direction_candidates(
1606
+ report_path: Path, task_key: str
1607
+ ) -> tuple[list[dict[str, str]], str]:
1608
+ """아직 방향을 고르지 않은 후보비교 리포트의 (후보, 추천 id). 고른 뒤면 빈 목록."""
1609
+ context = _validate_owned_report_context(report_path, expected_task_key=task_key)
1610
+ if not _direction_selection_required(context):
1611
+ return [], ""
1612
+ return _direction_candidates(context)
1613
+
1614
+
1615
+ def record_direction_selection(report_path: Path, task_key: str, option_id: str) -> Path:
1616
+ """사용자가 고른 방향을 `okstra user-response direction` 과 같은 트랜잭션으로 기록한다."""
1617
+ context = _validate_owned_report_context(report_path, expected_task_key=task_key)
1618
+ candidates, _ = _direction_candidates(context)
1619
+ ids = [row["id"] for row in candidates]
1620
+ if option_id not in ids:
1621
+ raise UserResponseError(f"direction option is not a ranked candidate: {option_id}")
1622
+ transaction_id = begin_response(report_path, task_key)
1623
+ set_direction_selection(transaction_id, ids.index(option_id) + 1, None, None)
1624
+ return finalize_response(transaction_id)
1625
+
1626
+
1605
1627
  def set_legacy_report_authoring(
1606
1628
  transaction_id: str, status: str, reason_file: Path
1607
1629
  ) -> None:
@@ -11,7 +11,6 @@ from .state import WizardError
11
11
  from .statefile import load_state_file, save_state_file
12
12
  from .confirmation import confirmation_block
13
13
  from .engine import (
14
- _auto_start_new_task_when_it_is_the_only_choice,
15
14
  init_state,
16
15
  next_prompt,
17
16
  prompt_payload,
@@ -87,7 +86,6 @@ def main(argv: list[str]) -> int:
87
86
  host_entry_mode=args.entry_mode,
88
87
  available_functions=args.available_function,
89
88
  )
90
- _auto_start_new_task_when_it_is_the_only_choice(state)
91
89
  save_state_file(state_path, state)
92
90
  first = next_prompt(state)
93
91
  print(json.dumps({"ok": True, "next": prompt_payload(state, first)},