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
@@ -83,7 +83,8 @@ def normalize_model_selection_inputs(
83
83
  report_writer_model=report_writer_model,
84
84
  provider_models=provider_models,
85
85
  )
86
- _reject_source_conflicts(counts, models, legacy)
86
+ # 같은 값이면 정본을 남기고 레거시를 접는다. 다르면 prepare 전에 거부.
87
+ legacy = _reconcile_legacy_with_canonical(counts, models, legacy)
87
88
  merged_counts = {**counts, **legacy.role_counts}
88
89
  merged_models = {**models, **legacy.role_models}
89
90
  _validate_selection(profile, merged_counts, merged_models, counts)
@@ -171,7 +172,7 @@ def _legacy_selection(
171
172
  constraints[initial.role] = providers
172
173
  report_writer = _requirement(profile, "report-writer")
173
174
  if report_writer is not None:
174
- counts["report-writer"] = report_writer.count
175
+ counts["report-writer"] = report_writer.recommended_count
175
176
  sources.append("--workers")
176
177
  worker_values = _parse_worker_models(worker_model, provider_models)
177
178
  if worker_values and initial is not None:
@@ -215,18 +216,14 @@ def _validate_legacy_workers(
215
216
  requirement: RoleRequirement,
216
217
  providers: tuple[str, ...],
217
218
  ) -> None:
218
- maximum = requirement.count + requirement.optional_count
219
- diversity = requirement.min_distinct_providers
220
- if diversity is not None and len(set(providers)) < diversity:
221
- raise ModelSelectionInputError(
222
- "legacy --workers roster violates "
223
- f"minDistinctProviders={diversity}; select at least {diversity} "
224
- "providers or migrate to --role-model"
225
- )
226
- if len(providers) < requirement.count or len(providers) > maximum:
219
+ # minDistinctProviders 제거. 모델 참조 중복 거부는 Task 2.
220
+ if (
221
+ len(providers) < requirement.min_count
222
+ or len(providers) > requirement.max_count
223
+ ):
227
224
  raise ModelSelectionInputError(
228
225
  f"legacy --workers selects {len(providers)} {requirement.role} instances; "
229
- f"profile requires {requirement.count}..{maximum}"
226
+ f"profile requires {requirement.min_count}..{requirement.max_count}"
230
227
  )
231
228
 
232
229
 
@@ -295,23 +292,109 @@ def _add_simple_legacy_roles(
295
292
  sources.append("executor")
296
293
 
297
294
 
298
- def _reject_source_conflicts(
295
+ def _reconcile_legacy_with_canonical(
299
296
  canonical_counts: Mapping[str, int],
300
297
  canonical_models: Mapping[str, tuple[str, ...]],
301
298
  legacy: CanonicalModelSelection,
302
- ) -> None:
299
+ ) -> CanonicalModelSelection:
300
+ """충돌하는 레거시 입력을 거부하고, 같은 값 겹침은 정본만 남긴다."""
303
301
  canonical_roles = set(canonical_counts) | set(canonical_models)
304
302
  legacy_roles = (
305
303
  set(legacy.role_counts)
306
304
  | set(legacy.provider_constraints)
307
305
  | set(legacy.legacy_model_values)
308
306
  )
309
- conflicts = sorted(canonical_roles & legacy_roles)
310
- if conflicts:
311
- raise ModelSelectionInputError(
312
- "canonical and legacy model selections conflict for role "
313
- f"{conflicts[0]!r}"
307
+ overlap = sorted(canonical_roles & legacy_roles)
308
+ if not overlap:
309
+ return legacy
310
+
311
+ counts = dict(legacy.role_counts)
312
+ constraints = dict(legacy.provider_constraints)
313
+ values = {
314
+ role: dict(models)
315
+ for role, models in legacy.legacy_model_values.items()
316
+ }
317
+ for role in overlap:
318
+ if not _legacy_matches_canonical(
319
+ role,
320
+ canonical_counts,
321
+ canonical_models,
322
+ legacy,
323
+ ):
324
+ raise ModelSelectionInputError(
325
+ "canonical and legacy model selections conflict for role "
326
+ f"{role!r}"
327
+ )
328
+ # 정본 모델이 있으면 레거시 공급자 제약을 접어 materialize 덮어쓰기를 막는다.
329
+ if role in canonical_models:
330
+ constraints.pop(role, None)
331
+ values.pop(role, None)
332
+ if role in canonical_counts:
333
+ counts.pop(role, None)
334
+ return CanonicalModelSelection(
335
+ counts,
336
+ {},
337
+ constraints,
338
+ values,
339
+ legacy.legacy_sources,
340
+ )
341
+
342
+
343
+ def _legacy_matches_canonical(
344
+ role: str,
345
+ canonical_counts: Mapping[str, int],
346
+ canonical_models: Mapping[str, tuple[str, ...]],
347
+ legacy: CanonicalModelSelection,
348
+ ) -> bool:
349
+ """레거시(--workers 등)가 변환 후 정본과 같은 값으로 수렴하는지 본다."""
350
+ leg_count = legacy.role_counts.get(role)
351
+ can_count = canonical_counts.get(role)
352
+ if can_count is not None and leg_count is not None and can_count != leg_count:
353
+ return False
354
+
355
+ leg_providers = legacy.provider_constraints.get(role)
356
+ can_models = canonical_models.get(role)
357
+ if can_models is not None and leg_providers is not None:
358
+ can_providers = tuple(
359
+ _provider_from_model_ref(model_ref) for model_ref in can_models
314
360
  )
361
+ if can_providers != tuple(
362
+ provider.lower() for provider in leg_providers
363
+ ):
364
+ return False
365
+ # 워커 목록은 칸 전체 순서다. 칸 수가 다르면 다른 선택이다.
366
+ if len(can_models) != len(leg_providers):
367
+ return False
368
+ leg_models = legacy.legacy_model_values.get(role, {})
369
+ for provider, model_ref in zip(leg_providers, can_models):
370
+ explicit = leg_models.get(provider)
371
+ if not explicit:
372
+ continue
373
+ model_id = (
374
+ model_ref.split("/", 1)[1] if "/" in model_ref else model_ref
375
+ )
376
+ if explicit != model_id and model_ref != f"{provider}/{explicit}":
377
+ return False
378
+ return True
379
+
380
+ if can_count is not None and leg_providers is not None:
381
+ if can_count != len(leg_providers):
382
+ return False
383
+ if (
384
+ can_models is not None
385
+ and leg_count is not None
386
+ and leg_providers is None
387
+ and len(can_models) > leg_count
388
+ ):
389
+ return False
390
+ return True
391
+
392
+
393
+ def _provider_from_model_ref(model_ref: str) -> str:
394
+ provider, separator, _model = model_ref.partition("/")
395
+ if separator:
396
+ return provider.strip().lower()
397
+ return model_ref.strip().lower()
315
398
 
316
399
 
317
400
  def _validate_selection(
@@ -321,7 +404,7 @@ def _validate_selection(
321
404
  explicit_counts: Mapping[str, int],
322
405
  ) -> None:
323
406
  requirements = {row.role: row for row in profile.roles if not row.dynamic}
324
- requirements["leader"] = RoleRequirement("leader", 1, "lead")
407
+ requirements["leader"] = RoleRequirement("leader", 1, 1, 1, "lead")
325
408
  for role in sorted(set(counts) | set(models)):
326
409
  requirement = requirements.get(role)
327
410
  if requirement is None:
@@ -334,7 +417,7 @@ def _validate_selection(
334
417
  requirement,
335
418
  explicit=role in explicit_counts,
336
419
  )
337
- selected_count = counts.get(role, requirement.count)
420
+ selected_count = counts.get(role, requirement.recommended_count)
338
421
  model_count = len(models.get(role, ()))
339
422
  if model_count > selected_count:
340
423
  if role not in explicit_counts:
@@ -357,18 +440,19 @@ def _validate_role_count(
357
440
  if role not in counts:
358
441
  return
359
442
  count = counts[role]
360
- maximum = requirement.count + requirement.optional_count
361
- if explicit and requirement.optional_count == 0:
443
+ fixed = requirement.min_count == requirement.max_count
444
+ if explicit and fixed:
362
445
  raise ModelSelectionInputError(
363
446
  f"role {role!r} has a fixed quantity and cannot receive --role-count"
364
447
  )
365
- if requirement.optional_count == 0 and count != requirement.count:
448
+ if fixed and count != requirement.min_count:
366
449
  raise ModelSelectionInputError(
367
- f"role {role!r} has a fixed quantity of {requirement.count}"
450
+ f"role {role!r} has a fixed quantity of {requirement.min_count}"
368
451
  )
369
- if count < requirement.count or count > maximum:
452
+ if count < requirement.min_count or count > requirement.max_count:
370
453
  raise ModelSelectionInputError(
371
- f"role {role!r} count must be in {requirement.count}..{maximum}: {count}"
454
+ f"role {role!r} count must be in "
455
+ f"{requirement.min_count}..{requirement.max_count}: {count}"
372
456
  )
373
457
 
374
458
 
@@ -382,6 +466,7 @@ def serialize_host_session_context(context: HostSessionContext) -> str:
382
466
  payload = {
383
467
  "availableFunctions": sorted(context.available_functions),
384
468
  "currentModel": {
469
+ "effort": current.effort,
385
470
  "level": current.level,
386
471
  "normalizedModelRef": current.normalized_model_ref,
387
472
  "observedModel": current.observed_model,
@@ -439,6 +524,8 @@ def _deserialize_attestation(raw: object) -> CurrentSessionModelAttestation:
439
524
  )
440
525
  observed = _nullable_string(raw, "observedModel")
441
526
  normalized = _nullable_string(raw, "normalizedModelRef")
527
+ # 키 없으면 null. 호스트 어댑터가 아직 안 채운 경우를 허용한다.
528
+ effort = _nullable_string(raw, "effort")
442
529
  if level == "unknown" and (observed is not None or normalized is not None):
443
530
  raise ModelSelectionInputError("unknown currentModel must use null model fields")
444
531
  if level != "unknown" and normalized is None:
@@ -451,6 +538,7 @@ def _deserialize_attestation(raw: object) -> CurrentSessionModelAttestation:
451
538
  normalized,
452
539
  level,
453
540
  source,
541
+ effort,
454
542
  )
455
543
 
456
544
 
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from okstra_project import TASKS_RELATIVE, StateError, parse_task_key, read_task_catalog, read_task_manifest, slugify
7
7
 
8
+ from . import next_phase
8
9
  from .manager_paths import children_json_path, projects_json_path, snapshots_json_path, task_manifest_path
9
10
  from .manager_store import _now_iso, _read_json, _read_json_default, _write_json
10
11
 
@@ -84,7 +85,9 @@ def _snapshot_child(project_root: Path, child: dict) -> dict:
84
85
  "currentPhase": str(workflow.get("currentPhase") or ""),
85
86
  "currentPhaseState": str(workflow.get("currentPhaseState") or ""),
86
87
  "lastCompletedPhase": str(workflow.get("lastCompletedPhase") or ""),
87
- "nextRecommendedPhase": str(workflow.get("nextRecommendedPhase") or ""),
88
+ "nextRecommendedPhase": next_phase.promote(
89
+ workflow.get("nextRecommendedPhase")
90
+ ),
88
91
  "latestRunStatus": str(manifest.get("latestRunStatus") or manifest.get("currentStatus") or ""),
89
92
  "latestReportPath": str(manifest.get("latestReportPath") or ""),
90
93
  "finalVerdict": str(outcome.get("finalVerdict") or ""),
@@ -0,0 +1,236 @@
1
+ """다음 Phase 포인터의 shape · 승격 · 투영 SSOT.
2
+
3
+ 포인터는 `{phase, status, rationale}` 구조체다. 저작은 리드(report-writer)가
4
+ 하고, Phase 7 검증기가 리포트의 Phase 라우팅과 대조해 어긋나면 정정한다.
5
+ 이 모듈은 세 가지만 소유한다 — 구조체 판별, 구형 문자열 승격, 리포트 투영.
6
+ 다음 phase 를 계산하는 다른 알고리즘을 다시 만들지 않는다.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Mapping
11
+
12
+ STATUS_READY = "ready"
13
+ STATUS_PENDING = "pending"
14
+ STATUS_BLOCKED = "blocked"
15
+ STATUS_TERMINAL = "terminal"
16
+
17
+ POINTER_STATUSES = frozenset(
18
+ {STATUS_READY, STATUS_PENDING, STATUS_BLOCKED, STATUS_TERMINAL}
19
+ )
20
+
21
+ # 구형 문자열 포인터가 쓰던 센티널 → status. 이 표는 승격에만 쓰이며
22
+ # 새 포인터를 만드는 데는 절대 쓰지 않는다.
23
+ _LEGACY_SENTINELS = {
24
+ "": STATUS_PENDING,
25
+ "unknown": STATUS_PENDING,
26
+ "pending-routing-decision": STATUS_PENDING,
27
+ "pending-release-handoff": STATUS_PENDING,
28
+ "done-or-follow-up": STATUS_TERMINAL,
29
+ }
30
+
31
+ # 라우팅 필드가 없어 항상 terminal 인 task-type.
32
+ _TERMINAL_TASK_TYPES = frozenset({"release-handoff"})
33
+
34
+ # 라우팅 필드가 없어 항상 pending 인 사이드트랙 task-type.
35
+ _SIDETRACK_TASK_TYPES = frozenset(
36
+ {
37
+ "improvement-discovery",
38
+ "project-analysis",
39
+ "feature-analysis",
40
+ "change-impact-analysis",
41
+ }
42
+ )
43
+
44
+ # project() 가 명시 분기로 다루는 task-type 전체를 손으로 적은 선언.
45
+ # project() 는 이 집합을 읽지 않는다 — 분기는 아래에 그대로 나열돼 있다.
46
+ #
47
+ # 이 집합을 대조하는 계약 테스트는 PHASE_SEQUENCE 에 있으면서 여기 없는 phase 만
48
+ # 잡아낸다. 선언 두 개를 비교하는 것이므로, 여기에 이름을 적고 project() 에 분기를
49
+ # 안 넣으면 그 phase 는 여전히 pending 폴백으로 조용히 흐른다. 분기의 실재는
50
+ # tests/contract/test_next_phase_projection.py 의
51
+ # test_project_has_a_live_branch_for_every_lifecycle_phase 가 정상 리포트를 실제로
52
+ # 투영해 확인한다.
53
+ HANDLED_TASK_TYPES = frozenset(
54
+ _TERMINAL_TASK_TYPES
55
+ | _SIDETRACK_TASK_TYPES
56
+ | {
57
+ "requirements-discovery",
58
+ "error-analysis",
59
+ "implementation-option-selection",
60
+ "implementation-planning",
61
+ "implementation",
62
+ "final-verification",
63
+ }
64
+ )
65
+
66
+ # implementation-option-selection 의 routing enum 중 phase 가 아닌 값.
67
+ _OPTION_SELECTION_NON_PHASE = {
68
+ "pending-direction-selection": STATUS_PENDING,
69
+ "blocked": STATUS_BLOCKED,
70
+ }
71
+
72
+ # final-verification 의 routing enum 중 phase 이름이 아니라 phase 에 붙은 범위
73
+ # 한정자인 값 → 실제로 실행할 phase. `release-handoff(stage-group)` 은 넘길 stage
74
+ # 묶음을 좁힌다는 뜻이지 다른 phase 가 아니다. 범위는 위저드의 handoff_stage_pick
75
+ # 이 따로 묻는다. 토큰을 그대로 phase 에 두면 `autofill_task_type` 이 그 문자열을
76
+ # 셸에 TASK_TYPE 으로 건네는데, 그런 task-type 은 존재하지 않는다.
77
+ _FINAL_VERIFICATION_PHASE_ALIASES = {
78
+ "release-handoff(stage-group)": "release-handoff",
79
+ }
80
+
81
+
82
+ def make(
83
+ phase: str = "", status: str = STATUS_PENDING, rationale: str = ""
84
+ ) -> dict[str, str]:
85
+ """포인터 구조체 하나를 만든다. status 가 허용 집합 밖이면 거부한다."""
86
+ if status not in POINTER_STATUSES:
87
+ raise ValueError(f"unknown next-phase status: {status!r}")
88
+ return {"phase": phase, "status": status, "rationale": rationale}
89
+
90
+
91
+ def is_pointer(value: Any) -> bool:
92
+ """value 가 유효한 포인터 구조체인지."""
93
+ return (
94
+ isinstance(value, Mapping)
95
+ and isinstance(value.get("phase"), str)
96
+ and value.get("status") in POINTER_STATUSES
97
+ and isinstance(value.get("rationale"), str)
98
+ )
99
+
100
+
101
+ def promote(value: Any) -> dict[str, str]:
102
+ """구형 문자열·None 을 포인터로 올린다.
103
+
104
+ 이미 포인터인 입력은 같은 값을 담은 새 dict 로 돌려준다 — 입력을 그대로
105
+ 돌려주지 않는다. 호출부가 반환값을 그 자리에서 변형해도 manifest 원본이
106
+ 오염되지 않아야 하기 때문이다.
107
+
108
+ pre-v1 무호환 원칙의 의도적 예외다. 근거는 업그레이드를 가로지르는
109
+ 진행 중 태스크가 영구히 막히는 기존 사례 — ADR-0004 Consequences.
110
+ """
111
+ if is_pointer(value):
112
+ return {
113
+ "phase": value["phase"],
114
+ "status": value["status"],
115
+ "rationale": value["rationale"],
116
+ }
117
+ if not isinstance(value, str):
118
+ return make()
119
+ text = value.strip()
120
+ if text in _LEGACY_SENTINELS:
121
+ return make(status=_LEGACY_SENTINELS[text])
122
+ return make(phase=text, status=STATUS_READY)
123
+
124
+
125
+ def autofill_task_type(manifest: Mapping[str, Any]) -> str:
126
+ """매니페스트의 포인터에서 바로 실행 가능한 task-type 을 뽑는다.
127
+
128
+ `ready` 가 아니면 빈 문자열이다. 셸 진입점의 autofill 이 이 함수를 쓴다.
129
+ """
130
+ workflow = manifest.get("workflow")
131
+ if not isinstance(workflow, Mapping):
132
+ return ""
133
+ pointer = promote(workflow.get("nextRecommendedPhase"))
134
+ return pointer["phase"] if pointer["status"] == STATUS_READY else ""
135
+
136
+
137
+ def project(report_data: Mapping[str, Any]) -> dict[str, str]:
138
+ """리포트 data.json 하나를 포인터로 투영한다.
139
+
140
+ 라우팅 판단의 옳고 그름은 다루지 않는다. 리포트가 이미 내린 판단을
141
+ 옮기기만 한다. 규칙표는 이 프로젝트의 구현 계획 문서에 있다.
142
+ """
143
+ header = report_data.get("header")
144
+ task_type = ""
145
+ if isinstance(header, Mapping):
146
+ task_type = str(header.get("taskType") or "")
147
+
148
+ if task_type in _TERMINAL_TASK_TYPES:
149
+ return make(status=STATUS_TERMINAL)
150
+ if task_type in _SIDETRACK_TASK_TYPES:
151
+ return make(status=STATUS_PENDING)
152
+
153
+ if task_type == "requirements-discovery":
154
+ return _from_nested_routing(report_data, "requirementsDiscovery")
155
+ if task_type == "error-analysis":
156
+ return _from_nested_routing(report_data, "errorAnalysis")
157
+ if task_type == "implementation-option-selection":
158
+ return _from_option_selection(report_data)
159
+ if task_type == "implementation-planning":
160
+ return _from_planning(report_data)
161
+ if task_type == "implementation":
162
+ return _from_target(report_data, "implementation")
163
+ if task_type == "final-verification":
164
+ return _from_final_verification(report_data)
165
+ return make(status=STATUS_PENDING)
166
+
167
+
168
+ def _block(report_data: Mapping[str, Any], key: str) -> Mapping[str, Any]:
169
+ block = report_data.get(key)
170
+ return block if isinstance(block, Mapping) else {}
171
+
172
+
173
+ def _from_nested(
174
+ report_data: Mapping[str, Any],
175
+ block_key: str,
176
+ routing_key: str,
177
+ target_key: str,
178
+ ) -> dict[str, str]:
179
+ """블록 → 라우팅 → 대상 phase 로 두 단 내려가 읽는 공용 판독부.
180
+
181
+ 대상이 문자열이 아니면 결측과 똑같이 취급한다. 문자열이 아닌 값을 str() 로
182
+ 강제하면 dict repr 같은 것이 phase 이름 자리에 들어앉고, status 는 ready 가
183
+ 되어 소비자에게 "지금 시작 가능한 phase" 로 읽힌다. 결측은 pending 에서
184
+ 무해하게 멈추지만 이쪽은 존재하지 않는 phase 로 소비자를 보낸다.
185
+ """
186
+ routing = _block(report_data, block_key).get(routing_key)
187
+ target = ""
188
+ if isinstance(routing, Mapping):
189
+ raw = routing.get(target_key)
190
+ target = raw.strip() if isinstance(raw, str) else ""
191
+ if not target:
192
+ return make(status=STATUS_PENDING)
193
+ return make(phase=target, status=STATUS_READY)
194
+
195
+
196
+ def _from_nested_routing(report_data: Mapping[str, Any], key: str) -> dict[str, str]:
197
+ return _from_nested(report_data, key, "routing", "nextTaskType")
198
+
199
+
200
+ def _from_option_selection(report_data: Mapping[str, Any]) -> dict[str, str]:
201
+ # 이 phase 의 routing 은 문자열 enum 이다. 이웃 두 phase 가 쓰는 중첩 dict 가
202
+ # 잘못 들어오면 결측과 똑같이 취급한다 — str() 로 강제하면 dict repr 이 phase
203
+ # 이름이 되어 ready 로 나간다.
204
+ raw = _block(report_data, "implementationOptionSelection").get("routing")
205
+ routing = raw.strip() if isinstance(raw, str) else ""
206
+ if routing in _OPTION_SELECTION_NON_PHASE:
207
+ return make(status=_OPTION_SELECTION_NON_PHASE[routing])
208
+ if not routing:
209
+ return make(status=STATUS_PENDING)
210
+ return make(phase=routing, status=STATUS_READY)
211
+
212
+
213
+ def _from_planning(report_data: Mapping[str, Any]) -> dict[str, str]:
214
+ outcome = str(_block(report_data, "implementationPlanning").get("outcome") or "")
215
+ if outcome == "plan-ready":
216
+ return make(phase="implementation", status=STATUS_READY)
217
+ if outcome == "direction-invalidated":
218
+ return make(phase="implementation-option-selection", status=STATUS_READY)
219
+ return make(status=STATUS_PENDING)
220
+
221
+
222
+ def _from_target(report_data: Mapping[str, Any], key: str) -> dict[str, str]:
223
+ return _from_nested(report_data, key, "routingRecommendation", "target")
224
+
225
+
226
+ def _from_final_verification(report_data: Mapping[str, Any]) -> dict[str, str]:
227
+ pointer = _from_target(report_data, "finalVerification")
228
+ if pointer["phase"] == "done":
229
+ return make(status=STATUS_TERMINAL)
230
+ if pointer["phase"] in _FINAL_VERIFICATION_PHASE_ALIASES:
231
+ return make(
232
+ phase=_FINAL_VERIFICATION_PHASE_ALIASES[pointer["phase"]],
233
+ status=pointer["status"],
234
+ rationale=pointer["rationale"],
235
+ )
236
+ return pointer
@@ -0,0 +1,26 @@
1
+ """Port for opening, closing, and signalling a worker execution surface."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Protocol
5
+
6
+ from ..domain.worker_runtime import (
7
+ ProgressEvent,
8
+ RuntimeHandle,
9
+ WorkerSpawnRequest,
10
+ )
11
+
12
+
13
+ class WorkerRuntimePort(Protocol):
14
+ surface: str
15
+
16
+ def spawn(self, request: WorkerSpawnRequest) -> RuntimeHandle: ...
17
+
18
+ def close(self, handle: RuntimeHandle) -> None: ...
19
+
20
+ def wait(self, handle: RuntimeHandle) -> int: ...
21
+
22
+ def terminate(self, handle: RuntimeHandle) -> None: ...
23
+
24
+ def notify(self, event: ProgressEvent) -> None: ...
25
+
26
+ def restore_lead(self) -> None: ...
@@ -11,6 +11,7 @@ import json
11
11
  import sys
12
12
  from pathlib import Path
13
13
 
14
+ from okstra_ctl import next_phase
14
15
  from okstra_ctl.clarification_items import sidecar_answers, user_response_sidecars
15
16
  from okstra_ctl.ids import slugify_task_segment
16
17
  from okstra_ctl.incremental_scope import preview_link_availability_for_report
@@ -108,7 +109,9 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
108
109
  "fromPhase": prev_phase,
109
110
  "toPhase": cur,
110
111
  "lastCompletedPhase": snap.get("lastCompletedPhase", ""),
111
- "nextRecommendedPhase": snap.get("nextRecommendedPhase", ""),
112
+ "nextRecommendedPhase": next_phase.promote(
113
+ snap.get("nextRecommendedPhase")
114
+ ),
112
115
  "reportPath": run.get("reportPath", ""),
113
116
  })
114
117
  prev_phase = cur