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.
- package/docs/architecture/storage-model.md +2 -2
- package/docs/architecture.md +22 -19
- package/docs/cli.md +18 -14
- package/docs/for-ai/skills/okstra-inspect.md +2 -3
- package/docs/for-ai/skills/okstra-rollup.md +1 -0
- package/docs/project-structure-overview.md +57 -56
- package/docs/task-process/README.md +11 -9
- package/docs/task-process/common-flow.md +13 -16
- package/docs/task-process/error-analysis.md +9 -10
- package/docs/task-process/final-verification.md +7 -7
- package/docs/task-process/implementation-planning.md +9 -9
- package/docs/task-process/implementation.md +6 -6
- package/docs/task-process/release-handoff.md +8 -7
- package/docs/task-process/requirements-discovery.md +8 -8
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +12 -6
- package/runtime/bin/lib/okstra/usage.sh +3 -2
- package/runtime/bin/okstra-spawn-followups.py +4 -2
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/lead/context-loader.md +1 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +3 -4
- package/runtime/prompts/lead/report-writer.md +15 -1
- package/runtime/prompts/lead/team-contract.md +16 -12
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +0 -3
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -7
- package/runtime/prompts/profiles/change-impact-analysis.md +9 -5
- package/runtime/prompts/profiles/error-analysis.md +14 -8
- package/runtime/prompts/profiles/feature-analysis.md +9 -5
- package/runtime/prompts/profiles/final-verification.md +10 -7
- package/runtime/prompts/profiles/implementation-option-selection.md +9 -5
- package/runtime/prompts/profiles/implementation-planning.md +13 -7
- package/runtime/prompts/profiles/implementation.md +9 -5
- package/runtime/prompts/profiles/improvement-discovery.md +10 -6
- package/runtime/prompts/profiles/project-analysis.md +9 -5
- package/runtime/prompts/profiles/requirements-discovery.md +15 -9
- package/runtime/prompts/wizard/prompts.ko.json +18 -1
- package/runtime/python/okstra_ctl/adapters/runtime/__init__.py +1 -0
- package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +27 -0
- package/runtime/python/okstra_ctl/adapters/runtime/cli_wrapper.py +49 -0
- package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +74 -0
- package/runtime/python/okstra_ctl/application/open_worker.py +29 -0
- package/runtime/python/okstra_ctl/assignment_resolver.py +27 -27
- package/runtime/python/okstra_ctl/dispatch_core.py +120 -100
- package/runtime/python/okstra_ctl/domain/host.py +3 -1
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +5 -0
- package/runtime/python/okstra_ctl/domain/worker_runtime.py +44 -0
- package/runtime/python/okstra_ctl/implementation_outcome.py +21 -5
- package/runtime/python/okstra_ctl/legacy_model_selection.py +115 -27
- package/runtime/python/okstra_ctl/manager_sync.py +4 -1
- package/runtime/python/okstra_ctl/next_phase.py +236 -0
- package/runtime/python/okstra_ctl/ports/worker_runtime.py +26 -0
- package/runtime/python/okstra_ctl/recap.py +4 -1
- package/runtime/python/okstra_ctl/render.py +46 -47
- package/runtime/python/okstra_ctl/role_requirements.py +28 -35
- package/runtime/python/okstra_ctl/rollup.py +4 -1
- package/runtime/python/okstra_ctl/run.py +8 -5
- package/runtime/python/okstra_ctl/stage_fix_carry.py +17 -1
- package/runtime/python/okstra_ctl/team.py +14 -18
- package/runtime/python/okstra_ctl/wizard.py +248 -56
- package/runtime/python/okstra_ctl/worker_prompt_body.py +18 -2
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +40 -9
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +16 -1
- package/runtime/python/okstra_ctl/workflow.py +18 -32
- package/runtime/python/okstra_ctl/worktree.py +3 -3
- package/runtime/python/okstra_ctl/worktree_registry.py +5 -4
- package/runtime/python/okstra_project/state.py +54 -6
- package/runtime/schemas/final-report-v2.0.schema.json +43 -5
- package/runtime/skills/okstra-inspect/facets/recap.md +2 -0
- package/runtime/skills/okstra-inspect/facets/report.md +1 -1
- package/runtime/skills/okstra-inspect/facets/status.md +15 -13
- package/runtime/skills/okstra-rollup/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +5 -5
- package/runtime/templates/implementation-worker-preamble.md +3 -18
- package/runtime/templates/project-docs/task-index.template.md +0 -1
- package/runtime/templates/reports/html/macros/forms.html +5 -4
- package/runtime/templates/reports/html/tasks/final-verification.template.html +1 -1
- package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
- package/runtime/templates/worker-prompt-preamble.md +3 -36
- 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.
|
|
1135
|
-
if providers and cross_requirement.
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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.
|
|
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.
|
|
1289
|
-
return requirement.
|
|
1290
|
-
|
|
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.
|
|
1319
|
-
maximum=str(
|
|
1320
|
-
default=str(requirement.
|
|
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.
|
|
1394
|
+
if count == requirement.recommended_count
|
|
1334
1395
|
else ""
|
|
1335
1396
|
),
|
|
1336
1397
|
),
|
|
1337
1398
|
)
|
|
1338
|
-
for count in range(requirement.
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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")
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}={
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
160
|
-
"""
|
|
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
|
-
|
|
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 += [
|
|
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}",
|