okstra 0.175.1 → 0.176.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/docs/architecture/storage-model.md +2 -2
  2. package/docs/architecture.md +22 -19
  3. package/docs/cli.md +18 -14
  4. package/docs/for-ai/skills/okstra-inspect.md +2 -3
  5. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  6. package/docs/project-structure-overview.md +57 -56
  7. package/docs/task-process/README.md +11 -9
  8. package/docs/task-process/common-flow.md +13 -16
  9. package/docs/task-process/error-analysis.md +9 -10
  10. package/docs/task-process/final-verification.md +7 -7
  11. package/docs/task-process/implementation-planning.md +9 -9
  12. package/docs/task-process/implementation.md +6 -6
  13. package/docs/task-process/release-handoff.md +8 -7
  14. package/docs/task-process/requirements-discovery.md +8 -8
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/bin/lib/okstra/interactive.sh +12 -6
  18. package/runtime/bin/lib/okstra/usage.sh +3 -2
  19. package/runtime/bin/okstra-spawn-followups.py +4 -2
  20. package/runtime/prompts/launch.template.md +2 -2
  21. package/runtime/prompts/lead/context-loader.md +1 -2
  22. package/runtime/prompts/lead/okstra-lead-contract.md +3 -4
  23. package/runtime/prompts/lead/report-writer.md +15 -1
  24. package/runtime/prompts/lead/team-contract.md +16 -12
  25. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  26. package/runtime/prompts/profiles/_implementation-executor.md +0 -3
  27. package/runtime/prompts/profiles/_implementation-verifier.md +3 -7
  28. package/runtime/prompts/profiles/change-impact-analysis.md +9 -5
  29. package/runtime/prompts/profiles/error-analysis.md +14 -8
  30. package/runtime/prompts/profiles/feature-analysis.md +9 -5
  31. package/runtime/prompts/profiles/final-verification.md +10 -7
  32. package/runtime/prompts/profiles/implementation-option-selection.md +9 -5
  33. package/runtime/prompts/profiles/implementation-planning.md +13 -7
  34. package/runtime/prompts/profiles/implementation.md +9 -5
  35. package/runtime/prompts/profiles/improvement-discovery.md +10 -6
  36. package/runtime/prompts/profiles/project-analysis.md +9 -5
  37. package/runtime/prompts/profiles/requirements-discovery.md +15 -9
  38. package/runtime/prompts/wizard/prompts.ko.json +18 -1
  39. package/runtime/python/okstra_ctl/adapters/runtime/__init__.py +1 -0
  40. package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +27 -0
  41. package/runtime/python/okstra_ctl/adapters/runtime/cli_wrapper.py +49 -0
  42. package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +74 -0
  43. package/runtime/python/okstra_ctl/application/open_worker.py +29 -0
  44. package/runtime/python/okstra_ctl/assignment_resolver.py +27 -27
  45. package/runtime/python/okstra_ctl/dispatch_core.py +120 -100
  46. package/runtime/python/okstra_ctl/domain/host.py +3 -1
  47. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +5 -0
  48. package/runtime/python/okstra_ctl/domain/worker_runtime.py +44 -0
  49. package/runtime/python/okstra_ctl/implementation_outcome.py +21 -5
  50. package/runtime/python/okstra_ctl/legacy_model_selection.py +115 -27
  51. package/runtime/python/okstra_ctl/manager_sync.py +4 -1
  52. package/runtime/python/okstra_ctl/next_phase.py +236 -0
  53. package/runtime/python/okstra_ctl/ports/worker_runtime.py +26 -0
  54. package/runtime/python/okstra_ctl/recap.py +4 -1
  55. package/runtime/python/okstra_ctl/render.py +46 -47
  56. package/runtime/python/okstra_ctl/role_requirements.py +28 -35
  57. package/runtime/python/okstra_ctl/rollup.py +4 -1
  58. package/runtime/python/okstra_ctl/run.py +8 -5
  59. package/runtime/python/okstra_ctl/stage_fix_carry.py +17 -1
  60. package/runtime/python/okstra_ctl/team.py +14 -18
  61. package/runtime/python/okstra_ctl/wizard.py +248 -56
  62. package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -2
  63. package/runtime/python/okstra_ctl/worker_prompt_contract.py +40 -9
  64. package/runtime/python/okstra_ctl/worker_prompt_headers.py +16 -1
  65. package/runtime/python/okstra_ctl/workflow.py +18 -32
  66. package/runtime/python/okstra_ctl/worktree.py +3 -3
  67. package/runtime/python/okstra_ctl/worktree_registry.py +5 -4
  68. package/runtime/python/okstra_project/state.py +54 -6
  69. package/runtime/schemas/final-report-v2.0.schema.json +43 -5
  70. package/runtime/skills/okstra-inspect/facets/recap.md +2 -0
  71. package/runtime/skills/okstra-inspect/facets/report.md +1 -1
  72. package/runtime/skills/okstra-inspect/facets/status.md +15 -13
  73. package/runtime/skills/okstra-rollup/SKILL.md +1 -0
  74. package/runtime/skills/okstra-run/SKILL.md +5 -5
  75. package/runtime/templates/implementation-worker-preamble.md +3 -18
  76. package/runtime/templates/project-docs/task-index.template.md +0 -1
  77. package/runtime/templates/reports/html/macros/forms.html +5 -4
  78. package/runtime/templates/reports/html/tasks/final-verification.template.html +1 -1
  79. package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
  80. package/runtime/templates/worker-prompt-preamble.md +3 -36
  81. package/runtime/validators/validate-run.py +57 -99
@@ -30,6 +30,7 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
30
30
  # render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
31
31
  # 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
32
32
  from . import fix_cycles
33
+ from . import next_phase
33
34
  from .analysis_inputs import ANALYSIS_TASK_TYPES
34
35
  from .application.resolve_assignment import (
35
36
  resolve_assignment_runner,
@@ -46,7 +47,7 @@ from .registry.provider_registry import default_provider_registry
46
47
  from .path_hints import compact_active_run_context, hydrate_run_context
47
48
  from .paths import task_timeline_file
48
49
  from .worker_artifacts import artifacts_from_context
49
- from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
50
+ from .workflow import PHASE_SEQUENCE
50
51
 
51
52
 
52
53
  class TokenRenderError(Exception):
@@ -563,9 +564,10 @@ def _active_workflow(ctx: dict) -> dict:
563
564
  "currentPhase": ctx.get("WORKFLOW_CURRENT_PHASE", ""),
564
565
  "currentPhaseState": ctx.get("WORKFLOW_CURRENT_PHASE_STATE", ""),
565
566
  "lastCompletedPhase": ctx.get("WORKFLOW_LAST_COMPLETED_PHASE", ""),
566
- "nextRecommendedPhase": ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", ""),
567
+ "nextRecommendedPhase": next_phase.promote(
568
+ ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE")
569
+ ),
567
570
  "awaitingApproval": ctx.get("WORKFLOW_AWAITING_APPROVAL", "false") == "true",
568
- "routingStatus": ctx.get("WORKFLOW_ROUTING_STATUS", ""),
569
571
  "allowedOutputs": ctx.get("PHASE_ALLOWED_OUTPUTS", ""),
570
572
  "forbiddenActions": ctx.get("PHASE_FORBIDDEN_ACTIONS", ""),
571
573
  }
@@ -917,15 +919,14 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
917
919
  "lastCompletedPhase": (workflow or {}).get("lastCompletedPhase", "")
918
920
  if isinstance(workflow, dict)
919
921
  else "",
920
- "nextRecommendedPhase": (workflow or {}).get("nextRecommendedPhase", "")
921
- if isinstance(workflow, dict)
922
- else "",
922
+ "nextRecommendedPhase": next_phase.promote(
923
+ (workflow or {}).get("nextRecommendedPhase")
924
+ if isinstance(workflow, dict)
925
+ else None
926
+ ),
923
927
  "awaitingApproval": (workflow or {}).get("awaitingApproval", False)
924
928
  if isinstance(workflow, dict)
925
929
  else False,
926
- "routingStatus": (workflow or {}).get("routingStatus", "")
927
- if isinstance(workflow, dict)
928
- else "",
929
930
  "taskRootPath": s(manifest, "taskRootPath") or rel(task_root),
930
931
  "taskManifestPath": s(manifest, "taskManifestPath")
931
932
  or rel(manifest_path),
@@ -1191,27 +1192,31 @@ def _derive_phase_states(existing_workflow: dict, ctx: dict) -> tuple[dict, str,
1191
1192
 
1192
1193
 
1193
1194
  def _derive_next_recommended_phase(
1194
- current_phase: str, current_phase_state: str, existing_workflow: dict, ctx: dict
1195
- ) -> str:
1196
- """canonical next phase 를 deterministic 하게 결정한다.
1197
-
1198
- `current_phase` 가 terminal-success(`completed`) 가 아니면 그 자리에 머무르도록
1199
- recommend 한다. `prepared` / `in-progress` / `blocked` / `error` /
1200
- `contract-violated` 상태에서 다음 phase 로 미리 advance 하면 wizard(와 사람)
1201
- 가 현재 phase 를 끝난 것으로 오인한다. (Historical bug: implementation 이
1202
- `prepared` 상태로 provision 됐을 때 wizard 가 `final-verification` 을 기본
1203
- task_type 으로 추천했다.)"""
1204
- canonical_next = DEFAULT_NEXT_PHASE.get(current_phase) or ctx.get(
1205
- "WORKFLOW_NEXT_RECOMMENDED_PHASE", "unknown"
1195
+ existing_workflow: dict, current_phase_state: str
1196
+ ) -> dict:
1197
+ """prepare 시점의 포인터를 만든다.
1198
+
1199
+ 대상 phase 는 리드가 정한다. prepare 는 그 값을 보존하고 진행 가능 여부만
1200
+ 내린다 — 실행이 아직 안 끝났는데 다음을 제안하면 위저드가 현재 phase 를
1201
+ 끝난 것으로 오인한다. (기록된 사례: implementation 이 `prepared` 로
1202
+ 준비됐을 때 위저드가 `final-verification` 을 기본 task_type 으로 추천했다.)
1203
+
1204
+ 현재 phase 가 `completed` 면 그 오인이 성립하지 않으므로 포인터를 승격만 해서
1205
+ 그대로 돌려준다. 내리는 대상도 `ready` 하나뿐이다 — `pending` · `blocked` ·
1206
+ `terminal` 은 애초에 착수를 부르지 않는 상태라 내려봐야 얻는 것이 없고,
1207
+ 리드가 판단해 적은 `blocked`(사람이 봐야 하는 상태)와 `terminal`(생애주기
1208
+ 종료)을 `pending`("아직 아무도 정하지 않음")으로 지워버린다.
1209
+ """
1210
+ pointer = next_phase.promote(existing_workflow.get("nextRecommendedPhase"))
1211
+ if current_phase_state == "completed":
1212
+ return pointer
1213
+ if pointer["status"] != next_phase.STATUS_READY:
1214
+ return pointer
1215
+ return next_phase.make(
1216
+ phase=pointer["phase"],
1217
+ status=next_phase.STATUS_PENDING,
1218
+ rationale=pointer["rationale"],
1206
1219
  )
1207
- existing_next = existing_workflow.get("nextRecommendedPhase") or ""
1208
- if current_phase_state not in {"completed"}:
1209
- # 아직 끝나지 않음 — 같은 phase 를 추천한다. sequence 상 더 앞을 가리키는
1210
- # stale existing_next 는 무시하고, 같은 current_phase 를 가리키는 값만 존중.
1211
- return current_phase
1212
- if existing_next and existing_next != current_phase:
1213
- return existing_next
1214
- return canonical_next
1215
1220
 
1216
1221
 
1217
1222
  def _derive_latest_pointers(existing: dict, ctx: dict, current_report_relative: str) -> dict:
@@ -1320,14 +1325,11 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1320
1325
  "WORKFLOW_WORK_CATEGORY", "unknown"
1321
1326
  )
1322
1327
  next_recommended_phase = _derive_next_recommended_phase(
1323
- current_phase, current_phase_state, existing_workflow, ctx
1328
+ existing_workflow, current_phase_state
1324
1329
  )
1325
1330
  last_completed_phase = existing_workflow.get("lastCompletedPhase") or ctx.get(
1326
1331
  "WORKFLOW_LAST_COMPLETED_PHASE", ""
1327
1332
  )
1328
- routing_status = existing_workflow.get("routingStatus") or ctx.get(
1329
- "WORKFLOW_ROUTING_STATUS", "not-applicable"
1330
- )
1331
1333
  awaiting_approval = existing_workflow.get("awaitingApproval")
1332
1334
  if not isinstance(awaiting_approval, bool):
1333
1335
  awaiting_approval = ctx.get("WORKFLOW_AWAITING_APPROVAL", "false") == "true"
@@ -1399,7 +1401,6 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
1399
1401
  "lastCompletedPhase": last_completed_phase,
1400
1402
  "nextRecommendedPhase": next_recommended_phase,
1401
1403
  "awaitingApproval": awaiting_approval,
1402
- "routingStatus": routing_status,
1403
1404
  "lastSafeCheckpoint": last_safe_checkpoint,
1404
1405
  },
1405
1406
  "artifacts": {
@@ -1746,16 +1747,13 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
1746
1747
  "lastCompletedPhase": workflow.get(
1747
1748
  "lastCompletedPhase", ctx.get("WORKFLOW_LAST_COMPLETED_PHASE", "")
1748
1749
  ),
1749
- "nextRecommendedPhase": workflow.get(
1750
- "nextRecommendedPhase", ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", "")
1750
+ "nextRecommendedPhase": next_phase.promote(
1751
+ workflow.get("nextRecommendedPhase")
1751
1752
  ),
1752
1753
  "awaitingApproval": workflow.get(
1753
1754
  "awaitingApproval",
1754
1755
  ctx.get("WORKFLOW_AWAITING_APPROVAL", "false") == "true",
1755
1756
  ),
1756
- "routingStatus": workflow.get(
1757
- "routingStatus", ctx.get("WORKFLOW_ROUTING_STATUS", "")
1758
- ),
1759
1757
  "lastSafeCheckpoint": workflow.get("lastSafeCheckpoint", {}),
1760
1758
  },
1761
1759
  "teamContract": {
@@ -2051,9 +2049,10 @@ def render_timeline(timeline_path: str, ctx: dict) -> None:
2051
2049
  "currentPhaseState": workflow.get("currentPhaseState", ""),
2052
2050
  "phaseStates": workflow.get("phaseStates", {}),
2053
2051
  "lastCompletedPhase": workflow.get("lastCompletedPhase", ""),
2054
- "nextRecommendedPhase": workflow.get("nextRecommendedPhase", ""),
2052
+ "nextRecommendedPhase": next_phase.promote(
2053
+ workflow.get("nextRecommendedPhase")
2054
+ ),
2055
2055
  "awaitingApproval": workflow.get("awaitingApproval", False),
2056
- "routingStatus": workflow.get("routingStatus", ""),
2057
2056
  "lastSafeCheckpoint": workflow.get("lastSafeCheckpoint", {}),
2058
2057
  },
2059
2058
  }
@@ -2239,15 +2238,15 @@ def render_task_index(template_path: str, output_path: str, ctx: dict) -> None:
2239
2238
  "lastCompletedPhase", ctx.get("WORKFLOW_LAST_COMPLETED_PHASE", "")
2240
2239
  )
2241
2240
  or "--",
2242
- "{{WORKFLOW_NEXT_RECOMMENDED_PHASE}}": workflow.get(
2243
- "nextRecommendedPhase", ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", "")
2244
- ),
2241
+ # 토큰은 사람이 읽는 한 줄이라 문자열이어야 한다. 포인터의 `status` 는
2242
+ # 같은 페이지의 다른 줄이 아니라 소비자 로직이 읽는다.
2243
+ "{{WORKFLOW_NEXT_RECOMMENDED_PHASE}}": next_phase.promote(
2244
+ workflow.get("nextRecommendedPhase")
2245
+ )["phase"]
2246
+ or "--",
2245
2247
  "{{WORKFLOW_AWAITING_APPROVAL}}": "yes"
2246
2248
  if workflow.get("awaitingApproval", False)
2247
2249
  else "no",
2248
- "{{WORKFLOW_ROUTING_STATUS}}": workflow.get(
2249
- "routingStatus", ctx.get("WORKFLOW_ROUTING_STATUS", "")
2250
- ),
2251
2250
  "{{WORKFLOW_PHASE_STATE_LINES}}": "\n".join(phase_state_lines),
2252
2251
  "{{WORKFLOW_LAST_SAFE_CHECKPOINT_LINES}}": "\n".join(checkpoint_lines),
2253
2252
  "{{FIX_CYCLES_SUMMARY}}": _fix_cycles_index_line(task_manifest),
@@ -14,19 +14,19 @@ class RoleProfileError(ValueError):
14
14
  @dataclass(frozen=True)
15
15
  class RoleRequirement:
16
16
  role: str
17
- count: int
17
+ min_count: int
18
+ recommended_count: int
19
+ max_count: int
18
20
  duty: str
19
- optional_count: int = 0
20
- min_distinct_providers: int | None = None
21
21
  dynamic: bool = False
22
22
 
23
- def signature(self) -> tuple[str, int, int, str, int | None, bool]:
23
+ def signature(self) -> tuple[str, int, int, int, str, bool]:
24
24
  return (
25
25
  self.role,
26
- self.count,
27
- self.optional_count,
26
+ self.min_count,
27
+ self.recommended_count,
28
+ self.max_count,
28
29
  self.duty,
29
- self.min_distinct_providers,
30
30
  self.dynamic,
31
31
  )
32
32
 
@@ -35,16 +35,12 @@ class RoleRequirement:
35
35
  class RoleProfile:
36
36
  roles: tuple[RoleRequirement, ...]
37
37
 
38
- def role_signature(self) -> tuple[tuple[str, int, int, str, int | None, bool], ...]:
38
+ def role_signature(self) -> tuple[tuple[str, int, int, int, str, bool], ...]:
39
39
  return tuple(requirement.signature() for requirement in self.roles)
40
40
 
41
41
 
42
- _REQUIRED_FIELDS = frozenset({"role", "count", "duty"})
43
- _ALLOWED_FIELDS = _REQUIRED_FIELDS | {
44
- "optionalCount",
45
- "minDistinctProviders",
46
- "dynamic",
47
- }
42
+ _REQUIRED_FIELDS = frozenset({"role", "min", "recommended", "max", "duty"})
43
+ _ALLOWED_FIELDS = _REQUIRED_FIELDS | {"dynamic"}
48
44
 
49
45
 
50
46
  def load_role_profile(path: Path) -> RoleProfile:
@@ -140,20 +136,26 @@ def _validate_requirement(
140
136
  errors.append(f"{prefix} has unknown field {field}")
141
137
 
142
138
  role = _validate_role(raw.get("role", ""), prefix, errors)
143
- count = _validate_non_negative_integer(raw.get("count", ""), "count", prefix, errors)
144
- duty = _validate_duty(raw.get("duty", ""), role, prefix, errors)
145
- optional_count = _validate_non_negative_integer(
146
- raw.get("optionalCount", "0"), "optionalCount", prefix, errors,
139
+ min_count = _validate_non_negative_integer(
140
+ raw.get("min", ""), "min", prefix, errors,
141
+ )
142
+ recommended_count = _validate_non_negative_integer(
143
+ raw.get("recommended", ""), "recommended", prefix, errors,
144
+ )
145
+ max_count = _validate_non_negative_integer(
146
+ raw.get("max", ""), "max", prefix, errors,
147
147
  )
148
- diversity = _validate_diversity(raw.get("minDistinctProviders"), prefix, errors)
148
+ duty = _validate_duty(raw.get("duty", ""), role, prefix, errors)
149
149
  dynamic = _validate_dynamic(raw.get("dynamic", "false"), prefix, errors)
150
- if diversity is not None and diversity > count + optional_count:
151
- errors.append(f"{prefix} minDistinctProviders must not exceed count + optionalCount")
152
- if dynamic and (count != 0 or optional_count != 0):
153
- errors.append(f"{prefix} dynamic roles must have count and optionalCount of zero")
154
- if dynamic and diversity is not None:
155
- errors.append(f"{prefix} dynamic roles must not declare minDistinctProviders")
156
- return RoleRequirement(role, count, duty, optional_count, diversity, dynamic)
150
+ if not (min_count <= recommended_count <= max_count):
151
+ errors.append(f"{prefix} min must be <= recommended <= max")
152
+ if dynamic and (min_count != 0 or recommended_count != 0 or max_count != 0):
153
+ errors.append(
154
+ f"{prefix} dynamic roles must have min, recommended, and max of zero"
155
+ )
156
+ return RoleRequirement(
157
+ role, min_count, recommended_count, max_count, duty, dynamic,
158
+ )
157
159
 
158
160
 
159
161
  def _validate_role(raw: str, prefix: str, errors: list[str]) -> str:
@@ -187,15 +189,6 @@ def _validate_non_negative_integer(raw: str, field: str, prefix: str, errors: li
187
189
  return value
188
190
 
189
191
 
190
- def _validate_diversity(raw: str | None, prefix: str, errors: list[str]) -> int | None:
191
- if raw is None:
192
- return None
193
- value = _validate_non_negative_integer(raw, "minDistinctProviders", prefix, errors)
194
- if value == 0:
195
- errors.append(f"{prefix} minDistinctProviders must be at least one")
196
- return value
197
-
198
-
199
192
  def _validate_dynamic(raw: str, prefix: str, errors: list[str]) -> bool:
200
193
  if raw == "true":
201
194
  return True
@@ -14,6 +14,7 @@ import json
14
14
  import sys
15
15
  from pathlib import Path
16
16
 
17
+ from okstra_ctl import next_phase
17
18
  from okstra_ctl.error_log_core import glob_error_logs, parse_records
18
19
  from okstra_ctl.time_report import aggregate_time, load_runs
19
20
  from okstra_project import (
@@ -80,7 +81,9 @@ def _collect_task(entry: dict, project_root: Path) -> dict:
80
81
  "workStatus": entry.get("workStatus", "") or "unknown",
81
82
  "currentPhase": entry.get("currentPhase", ""),
82
83
  "currentPhaseState": entry.get("currentPhaseState", ""),
83
- "nextRecommendedPhase": entry.get("nextRecommendedPhase", ""),
84
+ "nextRecommendedPhase": next_phase.promote(
85
+ entry.get("nextRecommendedPhase")
86
+ ),
84
87
  "latestRunStatus": entry.get("latestRunStatus", ""),
85
88
  "updatedAt": entry.get("updatedAt", ""),
86
89
  "reportPath": entry.get("latestReportPath", ""),
@@ -1782,7 +1782,7 @@ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str],
1782
1782
  except RoleProfileError as exc:
1783
1783
  raise PrepareError(str(exc)) from exc
1784
1784
  requires_report_writer = any(
1785
- row.role == "report-writer" and row.count == 1
1785
+ row.role == "report-writer" and row.min_count == 1
1786
1786
  for row in role_profile.roles
1787
1787
  )
1788
1788
  if requires_report_writer and "report-writer" not in workers:
@@ -1799,7 +1799,7 @@ def _profile_requires_executor(profile_file: Path) -> bool:
1799
1799
  except RoleProfileError as exc:
1800
1800
  raise PrepareError(str(exc)) from exc
1801
1801
  return any(
1802
- requirement.role == "implementer" and requirement.count > 0
1802
+ requirement.role == "implementer" and requirement.min_count > 0
1803
1803
  for requirement in profile.roles
1804
1804
  )
1805
1805
 
@@ -2088,7 +2088,7 @@ def _roles_requiring_defaults(
2088
2088
  continue
2089
2089
  desired = selection.role_counts.get(
2090
2090
  requirement.role,
2091
- requirement.count,
2091
+ requirement.recommended_count,
2092
2092
  )
2093
2093
  explicit = _explicit_selection_count(selection, requirement.role)
2094
2094
  if explicit < desired:
@@ -2150,7 +2150,7 @@ def _needs_profile_worker_candidates(
2150
2150
  continue
2151
2151
  desired = selection.role_counts.get(
2152
2152
  requirement.role,
2153
- requirement.count,
2153
+ requirement.recommended_count,
2154
2154
  )
2155
2155
  explicit = _explicit_selection_count(selection, requirement.role)
2156
2156
  has_scoped_default = (
@@ -2219,7 +2219,10 @@ def _resolver_role_counts(
2219
2219
  adjustable = {
2220
2220
  requirement.role
2221
2221
  for requirement in profile.roles
2222
- if not requirement.dynamic and requirement.optional_count > 0
2222
+ if (
2223
+ not requirement.dynamic
2224
+ and requirement.min_count < requirement.max_count
2225
+ )
2223
2226
  }
2224
2227
  return {role: count for role, count in counts.items() if role in adjustable}
2225
2228
 
@@ -84,10 +84,26 @@ def derive_stage_fix_carry(
84
84
  str(row.get("discrepancy") or row.get("verifier") or "")
85
85
  for row in failed
86
86
  ],
87
- routing_recommendation=str(impl.get("routingRecommendation") or ""),
87
+ routing_recommendation=_routing_line(impl.get("routingRecommendation")),
88
88
  )
89
89
 
90
90
 
91
+ def _routing_line(value: object) -> str:
92
+ """구조체 라우팅을 캐리 프롬프트에 실을 한 줄로 편다.
93
+
94
+ 리포트의 `implementation.routingRecommendation` 은 `{target, rationale}`
95
+ 구조체다. dict 를 str() 로 강제하면 executor 프롬프트에 파이썬 dict repr 이
96
+ 그대로 실려 사람도 모델도 읽을 수 없는 줄이 된다.
97
+ """
98
+ if not isinstance(value, dict):
99
+ return ""
100
+ target = str(value.get("target") or "").strip()
101
+ rationale = str(value.get("rationale") or "").strip()
102
+ if target and rationale:
103
+ return f"{target} — {rationale}"
104
+ return target or rationale
105
+
106
+
91
107
  def _latest_report(stage_run_dir: Path) -> Path | None:
92
108
  reports_dir = stage_run_dir / "reports"
93
109
  if not reports_dir.is_dir():
@@ -163,10 +163,21 @@ def _teardown(args) -> int:
163
163
  if args.dry_run:
164
164
  _emit_teardown(args.json, panes)
165
165
  return 0
166
- if _is_cmux_run(manifest):
166
+ from .adapters.runtime.assembly import port_for, runtime_chain
167
+ from .domain.worker_runtime import RuntimeHandle, SURFACE_CMUX_PANE
168
+
169
+ chain = runtime_chain(_manifest_backend(manifest))
170
+ # cli-wrapper chains have no cmux port; leftover paneIds must not KeyError.
171
+ if panes and any(port.surface == SURFACE_CMUX_PANE for port in chain):
172
+ closer = port_for(chain, SURFACE_CMUX_PANE)
167
173
  for pane in panes:
168
- cmux.close_surface(pane["paneId"])
169
- _restore_lead_width()
174
+ closer.close(
175
+ RuntimeHandle(SURFACE_CMUX_PANE, pane["paneId"], False)
176
+ )
177
+ try:
178
+ chain[0].restore_lead()
179
+ except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
180
+ print(f"okstra team: could not restore the lead's width: {exc}", file=sys.stderr)
170
181
  _mark_teardown_errors(team_state_path)
171
182
  _emit_teardown(args.json, panes)
172
183
  return 0
@@ -280,21 +291,6 @@ def _append_pane(panes: list[dict[str, str]], seen: set[str], pane_id: str, kind
280
291
  seen.add(pane_id)
281
292
 
282
293
 
283
- def _restore_lead_width() -> None:
284
- """Give the lead back the width the reclaimed panes left behind.
285
-
286
- Reported rather than raised: teardown's contract is to reclaim the run's
287
- panes and print what it reclaimed, and a failed resize must not cost the
288
- caller that record — the panes are already closed by the time this runs.
289
- Reported rather than swallowed, because a width failure nobody is told
290
- about is what left the lead at 18 columns unnoticed.
291
- """
292
- try:
293
- cmux.restore_lead_width()
294
- except (OSError, subprocess.SubprocessError, RuntimeError) as exc:
295
- print(f"okstra team: could not restore the lead's width: {exc}", file=sys.stderr)
296
-
297
-
298
294
  def _mark_teardown_errors(team_state_path: Path) -> None:
299
295
  def mark(payload: dict[str, Any]) -> bool:
300
296
  changed = False