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
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
310
|
-
if
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
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
|
-
|
|
361
|
-
if explicit and
|
|
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
|
|
448
|
+
if fixed and count != requirement.min_count:
|
|
366
449
|
raise ModelSelectionInputError(
|
|
367
|
-
f"role {role!r} has a fixed quantity of {requirement.
|
|
450
|
+
f"role {role!r} has a fixed quantity of {requirement.min_count}"
|
|
368
451
|
)
|
|
369
|
-
if count < requirement.
|
|
452
|
+
if count < requirement.min_count or count > requirement.max_count:
|
|
370
453
|
raise ModelSelectionInputError(
|
|
371
|
-
f"role {role!r} count must be in
|
|
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":
|
|
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":
|
|
112
|
+
"nextRecommendedPhase": next_phase.promote(
|
|
113
|
+
snap.get("nextRecommendedPhase")
|
|
114
|
+
),
|
|
112
115
|
"reportPath": run.get("reportPath", ""),
|
|
113
116
|
})
|
|
114
117
|
prev_phase = cur
|