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
@@ -59,6 +59,7 @@ from okstra_ctl.registry.host_registry import default_host_registry
59
59
  from okstra_ctl.registry.provider_registry import default_provider_registry
60
60
  from okstra_ctl.model_defaults import ModelDefaultScopes, default_candidates
61
61
  from okstra_ctl.model_pool import ModelPool
62
+ from okstra_ctl import next_phase
62
63
  from okstra_ctl.legacy_model_selection import serialize_host_session_context
63
64
  from okstra_ctl.role_requirements import (
64
65
  RoleProfile,
@@ -1131,11 +1132,13 @@ def _convert_v1_provider_selections(
1131
1132
  cross_requirement = _initial_cross_verification_requirement(profile)
1132
1133
  providers = _legacy_worker_providers(payload)
1133
1134
  if cross_requirement is not None:
1134
- selected_count = cross_requirement.count
1135
- if providers and cross_requirement.optional_count > 0:
1136
- maximum = cross_requirement.count + cross_requirement.optional_count
1137
- selected_count = min(maximum, max(cross_requirement.count, len(providers)))
1138
- if cross_requirement.optional_count > 0:
1135
+ selected_count = cross_requirement.recommended_count
1136
+ if providers and cross_requirement.min_count < cross_requirement.max_count:
1137
+ selected_count = min(
1138
+ cross_requirement.max_count,
1139
+ max(cross_requirement.min_count, len(providers)),
1140
+ )
1141
+ if cross_requirement.min_count < cross_requirement.max_count:
1139
1142
  state.role_counts[cross_requirement.role] = selected_count
1140
1143
  count_id = f"role-count:{cross_requirement.role}"
1141
1144
  state.role_selection_order.append(count_id)
@@ -1204,7 +1207,7 @@ def _convert_v1_provider_selections(
1204
1207
  static_roles = {
1205
1208
  requirement.role
1206
1209
  for requirement in profile.roles
1207
- if not requirement.dynamic and requirement.count > 0
1210
+ if not requirement.dynamic and requirement.min_count > 0
1208
1211
  }
1209
1212
  if state.host_entry_mode == "spawn-process":
1210
1213
  static_roles.add("leader")
@@ -1246,6 +1249,17 @@ def _wizard_state_from_json(payload: dict[str, Any]) -> WizardState:
1246
1249
  if version != 2:
1247
1250
  state.execution_identity_version = 2
1248
1251
  _convert_v1_provider_selections(state, payload)
1252
+ # v1 은 리더 확인 칸이 없었다. 변환 재개 시 다시 묻지 않는다.
1253
+ if (
1254
+ state.host_entry_mode == "current-session"
1255
+ and _LEADER_SESSION_STEP not in state.answered
1256
+ ):
1257
+ state.answered.append(_LEADER_SESSION_STEP)
1258
+ if (
1259
+ state.host_entry_mode == "current-session"
1260
+ and _LEADER_SESSION_STEP not in state.role_selection_order
1261
+ ):
1262
+ state.role_selection_order.insert(0, _LEADER_SESSION_STEP)
1249
1263
  return state
1250
1264
 
1251
1265
 
@@ -1277,17 +1291,35 @@ def _role_count_prompt_id(role: str) -> str:
1277
1291
  return f"role-count:{role}"
1278
1292
 
1279
1293
 
1294
+ def _role_add_prompt_id(role: str) -> str:
1295
+ return f"role-add:{role}"
1296
+
1297
+
1280
1298
  def _role_model_prompt_id(role: str, ordinal: int) -> str:
1281
1299
  return f"role-model:{role}:{ordinal}"
1282
1300
 
1283
1301
 
1302
+ _LEADER_SESSION_STEP = "leader-session"
1303
+
1304
+
1305
+ def _is_role_selection_step(step_id: str) -> bool:
1306
+ return step_id == _LEADER_SESSION_STEP or step_id.startswith(
1307
+ ("role-count:", "role-model:", "role-add:")
1308
+ )
1309
+
1310
+
1284
1311
  def _selected_role_count(
1285
1312
  state: WizardState,
1286
1313
  requirement: RoleRequirement,
1287
1314
  ) -> int:
1288
- if requirement.optional_count == 0:
1289
- return requirement.count
1290
- return state.role_counts.get(requirement.role, requirement.count)
1315
+ if requirement.min_count == requirement.max_count:
1316
+ return requirement.min_count
1317
+ if requirement.role in state.role_counts:
1318
+ return state.role_counts[requirement.role]
1319
+ # min=0 선택 역할(예: critic)은 사용자가 열기 전까지 기본 0
1320
+ if requirement.min_count == 0:
1321
+ return 0
1322
+ return requirement.recommended_count
1291
1323
 
1292
1324
 
1293
1325
  def _selectable_static_requirements(
@@ -1296,7 +1328,7 @@ def _selectable_static_requirements(
1296
1328
  ) -> tuple[RoleRequirement, ...]:
1297
1329
  requirements: list[RoleRequirement] = []
1298
1330
  if state.host_entry_mode == "spawn-process":
1299
- requirements.append(RoleRequirement("leader", 1, "lead"))
1331
+ requirements.append(RoleRequirement("leader", 1, 1, 1, "lead"))
1300
1332
  requirements.extend(
1301
1333
  requirement
1302
1334
  for requirement in profile.roles
@@ -1306,18 +1338,47 @@ def _selectable_static_requirements(
1306
1338
  return tuple(requirements)
1307
1339
 
1308
1340
 
1341
+ def _leader_session_prompt(state: WizardState) -> Prompt:
1342
+ """current-session 리더 칸: 모델/effort 표시만 하고 role_models 에 쓰지 않는다."""
1343
+ attestation = _host_session_context(state).current_model
1344
+ model_ref = (
1345
+ attestation.normalized_model_ref
1346
+ or attestation.observed_model
1347
+ or "current-session"
1348
+ )
1349
+ steps = _load_wizard_root(state.workspace_root)["steps"]
1350
+ raw = steps.get("leader_session") or {}
1351
+ effort_suffix = ""
1352
+ if attestation.effort:
1353
+ effort_template = raw.get("effort_suffix", " · effort {effort}")
1354
+ effort_suffix = effort_template.format(effort=attestation.effort)
1355
+ prompt = _p(
1356
+ state.workspace_root,
1357
+ "leader_session",
1358
+ model_ref=model_ref,
1359
+ effort_suffix=effort_suffix,
1360
+ )
1361
+ continue_label = prompt["options"].get("continue", "계속")
1362
+ return Prompt(
1363
+ step=_LEADER_SESSION_STEP,
1364
+ kind="pick",
1365
+ label=prompt["label"],
1366
+ options=[_opt("continue", continue_label)],
1367
+ echo_template=prompt["echo_template"],
1368
+ )
1369
+
1370
+
1309
1371
  def _count_prompt(
1310
1372
  state: WizardState,
1311
1373
  requirement: RoleRequirement,
1312
1374
  ) -> Prompt:
1313
- maximum = requirement.count + requirement.optional_count
1314
1375
  prompt = _p(
1315
1376
  state.workspace_root,
1316
1377
  "role_count",
1317
1378
  role=requirement.role,
1318
- minimum=str(requirement.count),
1319
- maximum=str(maximum),
1320
- default=str(requirement.count),
1379
+ minimum=str(requirement.min_count),
1380
+ maximum=str(requirement.max_count),
1381
+ default=str(requirement.recommended_count),
1321
1382
  )
1322
1383
  return Prompt(
1323
1384
  step=_role_count_prompt_id(requirement.role),
@@ -1330,17 +1391,52 @@ def _count_prompt(
1330
1391
  count=count,
1331
1392
  default_suffix=(
1332
1393
  prompt["options"].get("default_suffix", "")
1333
- if count == requirement.count
1394
+ if count == requirement.recommended_count
1334
1395
  else ""
1335
1396
  ),
1336
1397
  ),
1337
1398
  )
1338
- for count in range(requirement.count, maximum + 1)
1399
+ for count in range(requirement.min_count, requirement.max_count + 1)
1339
1400
  ],
1340
1401
  echo_template=prompt["echo_template"],
1341
1402
  )
1342
1403
 
1343
1404
 
1405
+ def _role_add_prompt(
1406
+ state: WizardState,
1407
+ requirement: RoleRequirement,
1408
+ ) -> Prompt:
1409
+ """min=0 선택 역할: 기본은 추가 안 함, 추가 시 1..max 수량을 이 스텝에서 고른다."""
1410
+ prompt = _p(
1411
+ state.workspace_root,
1412
+ "role_add",
1413
+ role=requirement.role,
1414
+ maximum=str(requirement.max_count),
1415
+ )
1416
+ options = [
1417
+ _opt(
1418
+ "0",
1419
+ prompt["options"]["skip"].format(
1420
+ default_suffix=prompt["options"].get("default_suffix", ""),
1421
+ ),
1422
+ ),
1423
+ ]
1424
+ for count in range(1, requirement.max_count + 1):
1425
+ options.append(
1426
+ _opt(
1427
+ str(count),
1428
+ prompt["options"]["add"].format(count=count),
1429
+ )
1430
+ )
1431
+ return Prompt(
1432
+ step=_role_add_prompt_id(requirement.role),
1433
+ kind="pick",
1434
+ label=prompt["label"],
1435
+ options=options,
1436
+ echo_template=prompt["echo_template"],
1437
+ )
1438
+
1439
+
1344
1440
  def _role_default_candidates(
1345
1441
  state: WizardState,
1346
1442
  role: str,
@@ -1628,30 +1724,19 @@ def _completed_role_models(
1628
1724
  candidates = _executable_role_models(state, profile, requirement, context)
1629
1725
  if not candidates or any(model not in candidates for model in selected):
1630
1726
  return None
1631
- providers = {
1632
- context.pool.resolve(model).provider_id
1633
- for model in selected[:requirement.count]
1634
- }
1635
- required = requirement.min_distinct_providers or 0
1636
- available_providers = {
1637
- context.pool.resolve(model).provider_id for model in candidates
1638
- }
1639
- required_slots = max(0, requirement.count - len(selected))
1640
- possible = len(providers | available_providers)
1641
- if min(possible, len(providers) + required_slots) < required:
1727
+ # 같은 역할 패널의 model_ref 는 서로 달라야 한다.
1728
+ if len(set(selected)) < len(selected):
1729
+ return None
1730
+ used = set(selected)
1731
+ remaining = [model for model in candidates if model not in used]
1732
+ if len(remaining) < count - len(selected):
1642
1733
  return None
1643
1734
  while len(selected) < count:
1644
- ordinal = len(selected) + 1
1645
- model_ref = candidates[0]
1646
- if ordinal <= requirement.count and len(providers) < required:
1647
- model_ref = next(
1648
- model
1649
- for model in candidates
1650
- if context.pool.resolve(model).provider_id not in providers
1651
- )
1735
+ model_ref = next(
1736
+ model for model in candidates if model not in used
1737
+ )
1652
1738
  selected.append(model_ref)
1653
- if ordinal <= requirement.count:
1654
- providers.add(context.pool.resolve(model_ref).provider_id)
1739
+ used.add(model_ref)
1655
1740
  return {role: tuple(models) for role, models in completed.items()}
1656
1741
 
1657
1742
 
@@ -1701,18 +1786,45 @@ def next_role_prompt(state: WizardState) -> Prompt | None:
1701
1786
  if not _role_selection_enabled(state) or not _identity_ready(state):
1702
1787
  return None
1703
1788
  state.use_defaults = False
1789
+ # current-session: 리더는 읽기 전용 확인 칸만 먼저 보여 준다.
1790
+ if (
1791
+ state.host_entry_mode == "current-session"
1792
+ and _LEADER_SESSION_STEP not in state.answered
1793
+ and _LEADER_SESSION_STEP not in state.role_selection_order
1794
+ ):
1795
+ return _leader_session_prompt(state)
1704
1796
  profile = _load_role_profile_for_state(state)
1705
1797
  for requirement in profile.roles:
1706
- if requirement.dynamic or requirement.optional_count == 0:
1798
+ # 필수 수량: min < max 이고 min > 0 일 때만.
1799
+ if (
1800
+ requirement.dynamic
1801
+ or requirement.min_count == requirement.max_count
1802
+ or requirement.min_count == 0
1803
+ ):
1707
1804
  continue
1708
1805
  if requirement.role not in state.role_counts:
1709
1806
  return _count_prompt(state, requirement)
1710
1807
  count = state.role_counts[requirement.role]
1711
- maximum = requirement.count + requirement.optional_count
1712
- if count < requirement.count or count > maximum:
1808
+ if count < requirement.min_count or count > requirement.max_count:
1713
1809
  raise WizardError(
1714
1810
  f"role {requirement.role!r} count must be in "
1715
- f"{requirement.count}..{maximum}: {count}"
1811
+ f"{requirement.min_count}..{requirement.max_count}: {count}"
1812
+ )
1813
+ for requirement in profile.roles:
1814
+ # 선택 역할(min=0, max>0): 기본은 추가 안 함. role-add 로만 연다.
1815
+ if (
1816
+ requirement.dynamic
1817
+ or requirement.min_count != 0
1818
+ or requirement.max_count == 0
1819
+ ):
1820
+ continue
1821
+ if requirement.role not in state.role_counts:
1822
+ return _role_add_prompt(state, requirement)
1823
+ count = state.role_counts[requirement.role]
1824
+ if count < 0 or count > requirement.max_count:
1825
+ raise WizardError(
1826
+ f"role {requirement.role!r} count must be in "
1827
+ f"0..{requirement.max_count}: {count}"
1716
1828
  )
1717
1829
  requirements = _selectable_static_requirements(state, profile)
1718
1830
  try:
@@ -1781,6 +1893,11 @@ def _validate_submitted_role_model(
1781
1893
 
1782
1894
 
1783
1895
  def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
1896
+ if prompt.step == _LEADER_SESSION_STEP:
1897
+ if value != "continue":
1898
+ raise WizardError("leader session step accepts only 'continue'")
1899
+ # role_models["leader"] 에 쓰지 않는다 — 현재 세션 모델을 그대로 쓴다.
1900
+ return "leader-session: continue"
1784
1901
  if prompt.step.startswith("role-count:"):
1785
1902
  role = prompt.step.split(":", 1)[1]
1786
1903
  profile = _load_role_profile_for_state(state)
@@ -1788,21 +1905,57 @@ def _submit_role_prompt(state: WizardState, prompt: Prompt, value: str) -> str:
1788
1905
  (row for row in profile.roles if row.role == role),
1789
1906
  None,
1790
1907
  )
1791
- if requirement is None or requirement.dynamic or requirement.optional_count == 0:
1908
+ if (
1909
+ requirement is None
1910
+ or requirement.dynamic
1911
+ or requirement.min_count == requirement.max_count
1912
+ or requirement.min_count == 0
1913
+ ):
1792
1914
  raise WizardError(f"role {role!r} does not accept a count selection")
1793
1915
  try:
1794
1916
  count = int(value)
1795
1917
  except ValueError as exc:
1796
1918
  raise WizardError(f"role {role!r} count must be an integer") from exc
1797
- maximum = requirement.count + requirement.optional_count
1798
- if count < requirement.count or count > maximum:
1919
+ if count < requirement.min_count or count > requirement.max_count:
1799
1920
  raise WizardError(
1800
1921
  f"role {role!r} count must be in "
1801
- f"{requirement.count}..{maximum}: {count}"
1922
+ f"{requirement.min_count}..{requirement.max_count}: {count}"
1802
1923
  )
1803
1924
  state.role_counts[role] = count
1804
1925
  state.role_models.pop(role, None)
1805
1926
  return f"role-count: {role}={count}"
1927
+ if prompt.step.startswith("role-add:"):
1928
+ role = prompt.step.split(":", 1)[1]
1929
+ profile = _load_role_profile_for_state(state)
1930
+ requirement = next(
1931
+ (row for row in profile.roles if row.role == role),
1932
+ None,
1933
+ )
1934
+ if (
1935
+ requirement is None
1936
+ or requirement.dynamic
1937
+ or requirement.min_count != 0
1938
+ or requirement.max_count == 0
1939
+ ):
1940
+ raise WizardError(f"role {role!r} does not accept an optional add")
1941
+ allowed = {option.value for option in prompt.options}
1942
+ if value not in allowed:
1943
+ raise WizardError(
1944
+ f"role {role!r} add selection must be one of "
1945
+ f"{sorted(allowed, key=int)}: {value}"
1946
+ )
1947
+ try:
1948
+ count = int(value)
1949
+ except ValueError as exc:
1950
+ raise WizardError(f"role {role!r} count must be an integer") from exc
1951
+ if count < 0 or count > requirement.max_count:
1952
+ raise WizardError(
1953
+ f"role {role!r} count must be in "
1954
+ f"0..{requirement.max_count}: {count}"
1955
+ )
1956
+ state.role_counts[role] = count
1957
+ state.role_models.pop(role, None)
1958
+ return f"role-add: {role}={count}"
1806
1959
  _, role, ordinal_raw = prompt.step.split(":", 2)
1807
1960
  ordinal = int(ordinal_raw)
1808
1961
  allowed = {option.value for option in prompt.options}
@@ -2136,6 +2289,30 @@ def _contract_outcome_suffix(entry: dict) -> str:
2136
2289
  return ""
2137
2290
 
2138
2291
 
2292
+ def _next_phase_cell(raw: Any) -> str:
2293
+ """task picker 한 줄에 실을 다음 phase 표기.
2294
+
2295
+ `phase` 만 싣고 `status` 를 버리면 "아직 여기 있다" 가 "다음으로 갈 수 있다"
2296
+ 로 읽힌다. prepare 는 끝나지 않은 run 의 포인터를 `ready` → `pending` 으로
2297
+ 내리면서 `phase` 는 남겨두므로(`render._derive_next_recommended_phase`),
2298
+ 준비만 된 implementation 태스크의 포인터가
2299
+ `{"phase": "final-verification", "status": "pending"}` 이다. status 를 안
2300
+ 보면 그 줄이 `next: final-verification` 으로 찍혀 착수 가능한 것처럼 읽힌다.
2301
+
2302
+ 그래서 `ready` 가 아닌 status 는 괄호로 함께 싣는다. `okstra-inspect` 의
2303
+ status 표는 같은 정보를 `*` 마커로 싣지만(`facets/status.md`), 그쪽은 표
2304
+ 아래 범례 한 줄을 붙일 수 있는 자리다. picker 옵션은 라벨 한 줄이 전부라
2305
+ 범례를 둘 데가 없으므로 status 이름을 그대로 적는다. phase 를 `--` 로
2306
+ 지우지 않는 이유는 그 이름이 "어디로 가는 태스크인가" 라는 정보를 여전히
2307
+ 나르기 때문이다 — 지우는 것은 착수 가능성만이 아니라 목적지까지 지운다.
2308
+ """
2309
+ pointer = next_phase.promote(raw)
2310
+ cell = pointer["phase"] or "--"
2311
+ if pointer["status"] == next_phase.STATUS_READY:
2312
+ return cell
2313
+ return f"{cell} ({pointer['status']})"
2314
+
2315
+
2139
2316
  def _build_task_pick(state: WizardState) -> Prompt:
2140
2317
  t = _p(state.workspace_root, "task_pick")
2141
2318
  project_root = Path(state.project_root)
@@ -2151,7 +2328,7 @@ def _build_task_pick(state: WizardState) -> Prompt:
2151
2328
  # catalog entries are flat (render_task_catalog_discovery) — there is
2152
2329
  # no nested "workflow" object here, unlike task-manifest.json.
2153
2330
  phase = entry.get("currentPhase") or ttype
2154
- nxt = entry.get("nextRecommendedPhase") or ""
2331
+ nxt = _next_phase_cell(entry.get("nextRecommendedPhase"))
2155
2332
  suffix = latest_suffix if key == latest_key else ""
2156
2333
  label = f"{key} · {phase} · next: {nxt}{_contract_outcome_suffix(entry)}{suffix}"
2157
2334
  options.append(_opt(value=key, label=label))
@@ -2191,8 +2368,7 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
2191
2368
  # task_type seeded from manifest's nextRecommendedPhase as the recommended default
2192
2369
  root = find_task_root(Path(state.project_root), value)
2193
2370
  manifest = (read_task_manifest(root) or {}) if root else {}
2194
- workflow = manifest.get("workflow") or {}
2195
- state.task_type = workflow.get("nextRecommendedPhase") or ""
2371
+ state.task_type = next_phase.autofill_task_type(manifest)
2196
2372
  return f"task: {value}"
2197
2373
 
2198
2374
 
@@ -2636,8 +2812,18 @@ def _build_task_type(state: WizardState) -> Prompt:
2636
2812
 
2637
2813
  workflow = _existing_task_workflow(state)
2638
2814
  revision_requested = _latest_revision_requested_analysis_type(state)
2815
+ # 포인터에서 phase 를 꺼내는 것은 `status` 가 `ready` 일 때뿐이다. prepare 는
2816
+ # 실행이 끝나지 않은 run 의 포인터를 일부러 `pending` 으로 내려두는데
2817
+ # (`render._derive_next_recommended_phase`), 여기서 status 를 안 보고 `phase`
2818
+ # 만 꺼내면 그 방어를 그대로 우회한다 — 기록된 사례가 `implementation` 이
2819
+ # `prepared` 상태인데 `final-verification` 이 추천으로 뜬 것이다. 판정은
2820
+ # 셸 진입점이 쓰는 것과 같은 함수(`next_phase.autofill_task_type`)로 한다.
2821
+ #
2822
+ # `ready` 가 아니면 추천은 비고, 아래 `currentPhase` 재실행 옵션이 남는다.
2823
+ # 실패한 run 의 포인터가 `{"phase": "", "status": "blocked"}` 라는 점에서
2824
+ # 그것이 맞는 제안이다 — 그 옵션은 포인터가 아니라 `currentPhase` 에서 온다.
2639
2825
  recommended = (revision_requested or state.task_type
2640
- or workflow.get("nextRecommendedPhase") or "")
2826
+ or next_phase.autofill_task_type({"workflow": workflow}))
2641
2827
  if not recommended and not workflow:
2642
2828
  recommended = TASK_TYPE_VALUES[0]
2643
2829
  add(recommended, recommended_suffix)
@@ -5020,7 +5206,7 @@ def _reset_role_selection_from(state: WizardState, target_step: str) -> None:
5020
5206
  kept_counts = {
5021
5207
  step_id.split(":", 1)[1]
5022
5208
  for step_id in kept_ids
5023
- if step_id.startswith("role-count:")
5209
+ if step_id.startswith(("role-count:", "role-add:"))
5024
5210
  }
5025
5211
  kept_models: dict[str, int] = {}
5026
5212
  for step_id in kept_ids:
@@ -5052,12 +5238,12 @@ def _clear_role_selection(state: WizardState) -> None:
5052
5238
  state.answered = [
5053
5239
  step_id
5054
5240
  for step_id in state.answered
5055
- if not step_id.startswith(("role-count:", "role-model:"))
5241
+ if not _is_role_selection_step(step_id)
5056
5242
  ]
5057
5243
 
5058
5244
 
5059
5245
  def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
5060
- if value.startswith(("role-count:", "role-model:")):
5246
+ if _is_role_selection_step(value):
5061
5247
  _reset_role_selection_from(state, value)
5062
5248
  elif any(s.id == value for s in STEPS):
5063
5249
  _reset_from(state, value)
@@ -5779,7 +5965,7 @@ def _sim_advance(state: WizardState, prompt: Prompt) -> None:
5779
5965
  """기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
5780
5966
  _submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
5781
5967
  try:
5782
- if prompt.step.startswith(("role-count:", "role-model:")):
5968
+ if _is_role_selection_step(prompt.step):
5783
5969
  _submit_role_prompt(state, prompt, _sim_answer(prompt))
5784
5970
  if prompt.step not in state.answered:
5785
5971
  state.answered.append(prompt.step)
@@ -5966,7 +6152,7 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
5966
6152
  value = _normalize_interaction_answer(state, prompt, plan, value)
5967
6153
  if prompt.kind == "pick_group":
5968
6154
  return _submit_group(state, prompt, value)
5969
- if prompt.step.startswith(("role-count:", "role-model:")):
6155
+ if _is_role_selection_step(prompt.step):
5970
6156
  echo = _submit_role_prompt(state, prompt, value or "")
5971
6157
  if prompt.step not in state.answered:
5972
6158
  state.answered.append(prompt.step)
@@ -6006,13 +6192,19 @@ def render_role_args(state: WizardState) -> list[str]:
6006
6192
  profile = _load_role_profile_for_state(state)
6007
6193
  requirements = _selectable_static_requirements(state, profile)
6008
6194
  for requirement in profile.roles:
6009
- if requirement.dynamic or requirement.optional_count == 0:
6195
+ if (
6196
+ requirement.dynamic
6197
+ or requirement.min_count == requirement.max_count
6198
+ ):
6010
6199
  continue
6011
6200
  if requirement.role not in state.role_counts:
6012
6201
  continue
6202
+ count = state.role_counts[requirement.role]
6203
+ if count <= 0:
6204
+ continue
6013
6205
  role_argv.extend([
6014
6206
  "--role-count",
6015
- f"{requirement.role}={state.role_counts[requirement.role]}",
6207
+ f"{requirement.role}={count}",
6016
6208
  ])
6017
6209
  ordered_roles = [requirement.role for requirement in requirements]
6018
6210
  else:
@@ -6,11 +6,27 @@ from typing import Any, Mapping, Sequence
6
6
  from .worker_prompt_policy import PromptPlan
7
7
 
8
8
 
9
- ANALYSIS_WORKER_LABELS = {
9
+ _ANALYSIS_WORKER_LABELS = {
10
10
  "claude": "Claude worker",
11
11
  "codex": "Codex worker",
12
12
  "antigravity": "Antigravity worker",
13
13
  }
14
+
15
+
16
+ def analysis_worker_label(worker_id: str) -> str:
17
+ """The role label this worker's prompt body is titled with.
18
+
19
+ The map above only supplies display capitalization for the three providers
20
+ that predate it; every other worker id (`grok`, `kimi`, a user-installed
21
+ adapter) takes the id itself. Both branches are identity deltas the equality
22
+ group must normalize away, so `worker_prompt_contract` erases exactly what
23
+ this function returns rather than restating the map — an enumeration that
24
+ covered only the map's keys let `# grok worker Dispatch` through and failed
25
+ every roster carrying grok or kimi before publication.
26
+ """
27
+ return _ANALYSIS_WORKER_LABELS.get(worker_id, f"{worker_id} worker")
28
+
29
+
14
30
  def analysis_prompt_body(
15
31
  manifest: Mapping[str, Any],
16
32
  active_context: Mapping[str, Any],
@@ -19,7 +35,7 @@ def analysis_prompt_body(
19
35
  plan: PromptPlan,
20
36
  ) -> list[str]:
21
37
  """Render the provider-neutral body for an initial analysis audience."""
22
- label = ANALYSIS_WORKER_LABELS.get(worker_id, f"{worker_id} worker")
38
+ label = analysis_worker_label(worker_id)
23
39
  pane_role = {
24
40
  "implementation-executor": "executor",
25
41
  "implementation-verifier": "verifier",
@@ -5,8 +5,9 @@ import json
5
5
  import re
6
6
  from dataclasses import dataclass
7
7
  from pathlib import Path
8
- from typing import Any, Mapping, Sequence
8
+ from typing import Any, Iterable, Mapping, Sequence
9
9
 
10
+ from .worker_prompt_body import analysis_worker_label
10
11
  from .worker_prompt_policy import (
11
12
  ERRORS_PATH_HEADERS,
12
13
  IMPLEMENTATION_HEADERS,
@@ -88,10 +89,7 @@ _WORKER_SPECIFIC_PREFIXES = (
88
89
  "**Host runtime:**",
89
90
  "**Host model value:**",
90
91
  )
91
- _WORKER_LABEL_RE = re.compile(
92
- r"\b(?:Claude|Codex|Antigravity) worker\b",
93
- re.IGNORECASE,
94
- )
92
+ _WORKER_LABEL_SUBSTITUTE = "<analysis-worker>"
95
93
 
96
94
 
97
95
  @dataclass(frozen=True)
@@ -156,8 +154,38 @@ def validate_final_verification_initial_prompt(text: str) -> list[str]:
156
154
  return errors
157
155
 
158
156
 
159
- def normalise_analysis_prompt(text: str) -> str:
160
- """Remove only permitted worker identity, model, role, and path deltas."""
157
+ def _worker_label_pattern(worker_ids: Iterable[str]) -> re.Pattern[str] | None:
158
+ """Match the role label the body renderer titled each compared worker with.
159
+
160
+ Built from `analysis_worker_label`, the same function that writes the label,
161
+ so a provider outside its display map (`grok`, `kimi`) is covered as it
162
+ comes. Restating the map here is what forked the roster: the enumeration
163
+ named only Claude / Codex / Antigravity, `# grok worker Dispatch` survived
164
+ normalization, and every run rostering grok or kimi failed the equality group
165
+ before publication with no prompt defect to fix.
166
+ """
167
+ labels = sorted(
168
+ {
169
+ analysis_worker_label(worker_id.strip())
170
+ for worker_id in worker_ids
171
+ if worker_id.strip()
172
+ },
173
+ key=lambda label: (-len(label), label),
174
+ )
175
+ if not labels:
176
+ return None
177
+ alternation = "|".join(re.escape(label) for label in labels)
178
+ return re.compile(rf"\b(?:{alternation})\b", re.IGNORECASE)
179
+
180
+
181
+ def normalise_analysis_prompt(text: str, *, worker_ids: Iterable[str]) -> str:
182
+ """Remove only permitted worker identity, model, role, and path deltas.
183
+
184
+ ``worker_ids`` is every worker in the comparison group, not just this
185
+ prompt's own: a body that names a sibling worker must normalize to the same
186
+ bytes in all of them, or the mention itself reads as divergence.
187
+ """
188
+ label = _worker_label_pattern(worker_ids)
161
189
  normalized: list[str] = []
162
190
  for line in text.replace("\r\n", "\n").replace("\r", "\n").splitlines():
163
191
  stripped = line.strip()
@@ -167,7 +195,10 @@ def normalise_analysis_prompt(text: str) -> str:
167
195
  )
168
196
  if prefix:
169
197
  continue
170
- normalized.append(_WORKER_LABEL_RE.sub("<analysis-worker>", line.rstrip()))
198
+ line = line.rstrip()
199
+ normalized.append(
200
+ line if label is None else label.sub(_WORKER_LABEL_SUBSTITUTE, line)
201
+ )
171
202
  return "\n".join(normalized).strip() + "\n"
172
203
 
173
204
 
@@ -176,7 +207,7 @@ def validate_analysis_prompt_set(prompts: Mapping[str, str]) -> list[str]:
176
207
  if len(prompts) < 2:
177
208
  return []
178
209
  normalized = {
179
- worker_id: normalise_analysis_prompt(text)
210
+ worker_id: normalise_analysis_prompt(text, worker_ids=prompts.keys())
180
211
  for worker_id, text in sorted(prompts.items())
181
212
  }
182
213
  baseline_worker = next(iter(normalized))
@@ -54,6 +54,17 @@ EVIDENCE_CITATION_HEADER = (
54
54
  "ledger row and fails exactly like a file you never opened, however many "
55
55
  "times you cited the full path earlier."
56
56
  )
57
+ # 파일 인용과 같이 첫 결론 명령 앞에 형식을 둔다. 분석 프리앰블에만 있으면
58
+ # 구현 워커는 명령 행을 쓰지 못한다.
59
+ EVIDENCE_COMMANDS_HEADER = (
60
+ "**Evidence commands:** Append one `- Evidence command: "
61
+ "{\"command\":\"<exact command>\",\"cwd\":\"<project-root>\","
62
+ "\"exitCode\":0,\"outputSummary\":\"<one-line result>\"}` row to the audit "
63
+ "sidecar for each command that produced or verified a conclusion. Do not "
64
+ "record exploratory `rg`, `ls`, or file-opening commands. Write the command "
65
+ "as you ran it. A value that must not be written down is yours to omit or "
66
+ "pass as a `$VAR` reference."
67
+ )
57
68
 
58
69
  # `agy`'s write tool validates the target against the Gemini artifact store
59
70
  # whenever the model attaches ArtifactMetadata, and rejects every path outside
@@ -120,7 +131,11 @@ def worker_prompt_headers(
120
131
  f"**Coding preflight pack:** {_coding_preflight_pack_path(active_context)}"
121
132
  )
122
133
  if dispatch_kind == "initial" and plan.audience != "report-writer":
123
- headers += [EVIDENCE_LEDGER_HEADER, EVIDENCE_CITATION_HEADER]
134
+ headers += [
135
+ EVIDENCE_LEDGER_HEADER,
136
+ EVIDENCE_CITATION_HEADER,
137
+ EVIDENCE_COMMANDS_HEADER,
138
+ ]
124
139
  headers.extend([
125
140
  f"**Errors log path:** {errors_log_path}",
126
141
  f"**Errors sidecar path:** {errors_sidecar_path}",