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
@@ -9,7 +9,6 @@ from datetime import datetime, timezone
9
9
  from pathlib import Path
10
10
  from typing import Any, Mapping, Sequence
11
11
 
12
- from . import cmux
13
12
  from .dispatch_state import (
14
13
  BACKEND_CLI_WRAPPER,
15
14
  BACKEND_CMUX_PANE,
@@ -51,9 +50,19 @@ from .assignment_resolver import (
51
50
  RoleInstance,
52
51
  resolve_model_assignment,
53
52
  )
53
+ from .application.open_worker import open_worker
54
54
  from .domain.host import HostSessionContext
55
55
  from .domain.provider import HostModelBinding, ServedModelAttestation
56
56
  from .domain.worker_exec import SERVED_MODEL_MISMATCH_EXIT_CODE
57
+ from .domain.worker_runtime import (
58
+ SURFACE_CMUX_PANE,
59
+ EnvironmentBlocked,
60
+ ProgressEvent,
61
+ RuntimeHandle,
62
+ SurfaceUnavailable,
63
+ WorkerSpawnRequest,
64
+ )
65
+ from .ports.worker_runtime import WorkerRuntimePort
57
66
  from .execution_identity import Attempt, Invocation, RoleExecution, model_spec_digest
58
67
  from .execution_manifest import (
59
68
  finish_attempt_mutation,
@@ -229,6 +238,7 @@ class WorkerHandle:
229
238
  mutation_retry_allowed: bool = True
230
239
  write_policy_digest: str = ""
231
240
  write_enforcement: WriteEnforcement | None = None
241
+ runtime_handle: RuntimeHandle | None = None
232
242
 
233
243
 
234
244
  @dataclass(frozen=True)
@@ -252,6 +262,7 @@ class DispatchPlan:
252
262
  manifest: Mapping[str, Any]
253
263
  jobs: tuple[WorkerJob, ...]
254
264
  default_backend: str
265
+ runtime_chain: tuple[WorkerRuntimePort, ...] = ()
255
266
 
256
267
  def to_payload(self, dry_run: bool) -> dict[str, Any]:
257
268
  payload = {
@@ -358,6 +369,7 @@ def build_dispatch_plan(
358
369
 
359
370
 
360
371
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
372
+ plan = _ensure_runtime_chain(plan)
361
373
  _validate_report_writer_isolation(plan.jobs)
362
374
  _validate_implementation_phase_order(plan.jobs)
363
375
  _revalidate_plan_role_executions(plan)
@@ -391,6 +403,7 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
391
403
  """Start one dependency-free CLI batch before collecting any worker."""
392
404
  if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
393
405
  raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
406
+ plan = _ensure_runtime_chain(plan)
394
407
  _validate_report_writer_isolation(plan.jobs)
395
408
  _validate_implementation_phase_order(plan.jobs)
396
409
  _revalidate_plan_role_executions(plan)
@@ -564,14 +577,17 @@ def _dispatch_cli_wrapper_batch(
564
577
  exc,
565
578
  )
566
579
  raise
580
+ from .adapters.runtime.assembly import port_for
581
+
567
582
  outcomes: list[tuple[WorkerHandle, int, WorkerOutcome]] = []
568
583
  for handle, attempt in active:
569
- process = handle.running_process
570
- if process is None:
584
+ runtime = handle.runtime_handle
585
+ if runtime is None or not runtime.waitable:
571
586
  raise DispatchError("concurrent CLI worker process is missing")
572
- returncode = process.wait()
587
+ port = port_for(plan.runtime_chain, runtime.surface)
588
+ returncode = port.wait(runtime)
573
589
  completed = subprocess.CompletedProcess(handle.job.command, returncode)
574
- settled = replace(handle, completed_process=completed, running_process=None)
590
+ settled = replace(handle, completed_process=completed)
575
591
  outcomes.append((settled, attempt, _outcome_from_completed(settled)))
576
592
 
577
593
  next_round: list[tuple[WorkerJob, int]] = []
@@ -611,15 +627,18 @@ def _abort_cli_batch_start(
611
627
  policies: Sequence[WritePolicy],
612
628
  error: Exception,
613
629
  ) -> None:
630
+ from .adapters.runtime.assembly import port_for
631
+
614
632
  reason = f"concurrent CLI batch launch failed: {error}"
615
633
  for handle, _attempt in active:
616
- process = handle.running_process
617
- if process is not None and process.poll() is None:
618
- process.terminate()
634
+ runtime = handle.runtime_handle
635
+ if runtime is None:
636
+ continue
637
+ port_for(plan.runtime_chain, runtime.surface).terminate(runtime)
619
638
  for handle, attempt in active:
620
- process = handle.running_process
621
- if process is not None:
622
- process.wait()
639
+ runtime = handle.runtime_handle
640
+ if runtime is not None:
641
+ port_for(plan.runtime_chain, runtime.surface).wait(runtime)
623
642
  _transition_job_status(plan, handle.job, "error", reason)
624
643
  _update_dispatch_status(
625
644
  plan.team_state_path, handle.job, attempt, "error", reason
@@ -1283,18 +1302,24 @@ def _spawn_job(plan: DispatchPlan, job: WorkerJob, attempt: int) -> WorkerHandle
1283
1302
  def _spawn_cli_job_nonblocking(
1284
1303
  plan: DispatchPlan, job: WorkerJob, attempt: int,
1285
1304
  ) -> WorkerHandle:
1286
- process: subprocess.Popen[str] | None = None
1305
+ plan = _ensure_runtime_chain(plan)
1287
1306
  try:
1288
- process = subprocess.Popen(job.command, cwd=plan.project_root, text=True)
1289
- handle = WorkerHandle(
1290
- job, "", None, status_path_for_prompt(job.prompt_path), "", process
1307
+ runtime = open_worker(
1308
+ _chain_starting_at(plan.runtime_chain, job.backend),
1309
+ lambda surface: _spawn_request(plan, job, surface),
1291
1310
  )
1292
- return handle
1293
- except (OSError, DispatchError):
1294
- if process is not None and process.poll() is None:
1295
- process.terminate()
1296
- process.wait()
1297
- raise
1311
+ except EnvironmentBlocked as exc:
1312
+ raise DispatchError(str(exc)) from exc
1313
+ except (SurfaceUnavailable, OSError) as exc:
1314
+ raise DispatchError(str(exc)) from exc
1315
+ return WorkerHandle(
1316
+ job,
1317
+ "",
1318
+ None,
1319
+ status_path_for_prompt(job.prompt_path),
1320
+ "",
1321
+ runtime_handle=runtime,
1322
+ )
1298
1323
 
1299
1324
 
1300
1325
  def _prepare_job_attempt(
@@ -1515,35 +1540,66 @@ def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
1515
1540
  return 1
1516
1541
 
1517
1542
 
1543
+ def _ensure_runtime_chain(plan: DispatchPlan) -> DispatchPlan:
1544
+ if plan.runtime_chain:
1545
+ return plan
1546
+ from .adapters.runtime.assembly import runtime_chain as build_chain
1547
+ return replace(plan, runtime_chain=build_chain(plan.default_backend))
1548
+
1549
+
1550
+ def _spawn_request(
1551
+ plan: DispatchPlan, job: WorkerJob, surface: str
1552
+ ) -> WorkerSpawnRequest:
1553
+ targeted = replace(job, backend=surface)
1554
+ title = (
1555
+ targeted.execution_label
1556
+ if targeted.has_execution_identity
1557
+ else f"{targeted.worker_id}-worker"
1558
+ )
1559
+ return WorkerSpawnRequest(
1560
+ command=tuple(targeted.command),
1561
+ cwd=plan.project_root,
1562
+ title=title,
1563
+ owned_surface_ids=_opened_cmux_surfaces(plan.team_state_path),
1564
+ )
1565
+
1566
+
1567
+ def _chain_starting_at(
1568
+ chain: tuple[WorkerRuntimePort, ...], surface: str
1569
+ ) -> tuple[WorkerRuntimePort, ...]:
1570
+ # A retry job is already cli-wrapper; do not walk back up to cmux-pane.
1571
+ for index, port in enumerate(chain):
1572
+ if port.surface == surface:
1573
+ return chain[index:]
1574
+ raise DispatchError(f"unsupported worker backend: {surface}")
1575
+
1576
+
1518
1577
  def _start_job(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
1519
- if job.backend == BACKEND_CLI_WRAPPER:
1520
- return _run_cli_wrapper(plan, job, "")
1521
- if job.backend == BACKEND_CMUX_PANE:
1522
- return _start_cmux_or_degrade(plan, job)
1523
- raise DispatchError(f"unsupported worker backend: {job.backend}")
1524
-
1525
-
1526
- def _start_cmux_or_degrade(plan: DispatchPlan, job: WorkerJob) -> WorkerHandle:
1527
- workspace = cmux.resolve_lead_workspace()
1528
- if not workspace:
1529
- _refuse_when_cmux_is_walled_off()
1530
- return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
1578
+ plan = _ensure_runtime_chain(plan)
1531
1579
  try:
1532
- surface_id = cmux.spawn_worker_surface(
1533
- workspace=workspace,
1534
- cwd=plan.project_root,
1535
- command=job.command,
1536
- title=(
1537
- job.execution_label
1538
- if job.has_execution_identity
1539
- else f"{job.worker_id}-worker"
1540
- ),
1541
- owned_surface_ids=_opened_cmux_surfaces(plan.team_state_path),
1580
+ runtime = open_worker(
1581
+ _chain_starting_at(plan.runtime_chain, job.backend),
1582
+ lambda surface: _spawn_request(plan, job, surface),
1542
1583
  )
1543
- except (RuntimeError, OSError, subprocess.SubprocessError):
1544
- return _run_cli_wrapper(plan, job, BACKEND_CMUX_PANE)
1584
+ except EnvironmentBlocked as exc:
1585
+ raise DispatchError(str(exc)) from exc
1586
+ except SurfaceUnavailable as exc:
1587
+ raise DispatchError(str(exc)) from exc
1588
+ degraded_from = job.backend if runtime.surface != job.backend else ""
1589
+ settled = replace(job, backend=runtime.surface) if degraded_from else job
1590
+ completed = None
1591
+ if runtime.waitable:
1592
+ from .adapters.runtime.assembly import port_for
1593
+ returncode = port_for(plan.runtime_chain, runtime.surface).wait(runtime)
1594
+ completed = subprocess.CompletedProcess(list(settled.command), returncode)
1595
+ pane_id = runtime.identifier if runtime.surface == SURFACE_CMUX_PANE else ""
1545
1596
  return WorkerHandle(
1546
- job, surface_id, None, status_path_for_prompt(job.prompt_path), ""
1597
+ settled,
1598
+ pane_id,
1599
+ completed,
1600
+ status_path_for_prompt(settled.prompt_path),
1601
+ degraded_from,
1602
+ runtime_handle=runtime,
1547
1603
  )
1548
1604
 
1549
1605
 
@@ -1569,42 +1625,6 @@ def _opened_cmux_surfaces(team_state_path: Path) -> tuple[str, ...]:
1569
1625
  return tuple(opened)
1570
1626
 
1571
1627
 
1572
- def _refuse_when_cmux_is_walled_off() -> None:
1573
- """Stop rather than degrade when a sandbox stands between okstra and cmux.
1574
-
1575
- prepare records `cmux-pane` only after reaching cmux, so losing it by
1576
- dispatch time means something came between. When the socket is present but
1577
- connecting is denied, that something is a sandbox around the lead — and the
1578
- same sandbox hides the worker CLIs' own config, so the fallback this would
1579
- otherwise take is already dead. Degrading here spends every worker's retry
1580
- budget on the same wall and leaves the user with three failures and no
1581
- explanation, which is exactly what it looks like when nothing happens.
1582
- """
1583
- reason = cmux.unreachable_reason()
1584
- if reason not in (cmux.LOST_ENVIRONMENT, cmux.LOST_DENIED):
1585
- return
1586
- observed = (
1587
- "this process has no cmux environment left — a sandbox sanitized it"
1588
- if reason == cmux.LOST_ENVIRONMENT
1589
- else "cmux is running but this process may not connect to its socket"
1590
- )
1591
- raise DispatchError(
1592
- f"this run was prepared for cmux panes, but {observed}. The same "
1593
- "sandbox blocks worker CLIs from their own config, so falling back to "
1594
- "blocking workers would fail too. Relaunch the lead outside a sandbox "
1595
- "(codex: `codex -s danger-full-access`)."
1596
- )
1597
-
1598
-
1599
- def _run_cli_wrapper(plan: DispatchPlan, job: WorkerJob, degraded_from: str) -> WorkerHandle:
1600
- # Whatever the job was planned as, this is a cli-wrapper dispatch: no pane
1601
- # took it, so the worker's stdout is read by whoever called the lead. The
1602
- # record already says so (`_dispatch_record`), and the command must agree.
1603
- job = replace(job, backend=BACKEND_CLI_WRAPPER)
1604
- completed = subprocess.run(job.command, cwd=plan.project_root, text=True)
1605
- return WorkerHandle(job, "", completed, status_path_for_prompt(job.prompt_path), degraded_from)
1606
-
1607
-
1608
1628
  def _outcome_from_completed(handle: WorkerHandle) -> WorkerOutcome:
1609
1629
  completed = handle.completed_process
1610
1630
  if completed is None:
@@ -2494,33 +2514,33 @@ _SIDEBAR_LEVELS = {
2494
2514
 
2495
2515
 
2496
2516
  def _relay_to_sidebar(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
2497
- """Mirror a dispatch event onto the cmux sidebar.
2517
+ """Mirror a dispatch event onto the lead surface.
2498
2518
 
2499
- A long run is mostly silence, and the sidebar is the one surface still
2519
+ A long run is mostly silence, and the lead surface is the one still
2500
2520
  visible after the user scrolls away or switches workspaces. A failed worker
2501
2521
  additionally raises a notification, because that is the event whose cost
2502
- grows the longer it goes unnoticed.
2522
+ grows the longer it goes unnoticed. The lead port is `runtime_chain[0]`,
2523
+ so a worker that degraded to the wrapper still lands on a cmux run's
2524
+ sidebar. A cli-wrapper lead is a no-op.
2503
2525
  """
2504
- # The plan's backend, not the manifest field it was resolved from: a job
2505
- # that degraded to the blocking wrapper is still part of a cmux run, and
2506
- # that degradation is exactly what the sidebar should keep showing.
2507
- if plan.default_backend != BACKEND_CMUX_PANE:
2508
- return
2509
- workspace = cmux.resolve_lead_workspace()
2526
+ plan = _ensure_runtime_chain(plan)
2527
+ lead = plan.runtime_chain[0]
2510
2528
  worker_id = str(
2511
2529
  details.get("executionLabel") or details.get("workerId") or "worker"
2512
2530
  )
2513
- cmux.sidebar_log(
2514
- workspace,
2515
- f"{worker_id}: {event_type.removeprefix('worker-')}",
2516
- level=_SIDEBAR_LEVELS.get(event_type, "info"),
2517
- )
2531
+ notify_title = None
2532
+ notify_body = None
2518
2533
  if event_type == "worker-failed":
2519
- cmux.sidebar_notify(
2520
- workspace,
2521
- title=f"okstra — {_require_string(plan.manifest, 'taskType')}",
2522
- body=f"{worker_id} failed: {details.get('reason', 'no reason recorded')}",
2534
+ notify_title = f"okstra — {_require_string(plan.manifest, 'taskType')}"
2535
+ notify_body = f"{worker_id} failed: {details.get('reason', 'no reason recorded')}"
2536
+ lead.notify(
2537
+ ProgressEvent(
2538
+ message=f"{worker_id}: {event_type.removeprefix('worker-')}",
2539
+ level=_SIDEBAR_LEVELS.get(event_type, "info"),
2540
+ notify_title=notify_title,
2541
+ notify_body=notify_body,
2523
2542
  )
2543
+ )
2524
2544
 
2525
2545
 
2526
2546
  def _append_event(plan: DispatchPlan, event_type: str, details: Mapping[str, Any]) -> None:
@@ -20,6 +20,8 @@ class CurrentSessionModelAttestation:
20
20
  "cli-handoff",
21
21
  "unavailable",
22
22
  ]
23
+ # 호스트가 알려 줄 때만 채운다. 스크레이핑하지 않는다.
24
+ effort: str | None = None
23
25
 
24
26
  @classmethod
25
27
  def unknown(
@@ -27,7 +29,7 @@ class CurrentSessionModelAttestation:
27
29
  provider_id: str,
28
30
  source: Literal["unavailable"] = "unavailable",
29
31
  ) -> CurrentSessionModelAttestation:
30
- return cls(provider_id, None, None, "unknown", source)
32
+ return cls(provider_id, None, None, "unknown", source, None)
31
33
 
32
34
 
33
35
  def _unavailable_current_model() -> CurrentSessionModelAttestation:
@@ -71,7 +71,12 @@ def _normalize_numbered_item(prompt: WizardPrompt, answer: str) -> str:
71
71
  number = int(candidate)
72
72
  if 1 <= number <= len(prompt.options):
73
73
  return prompt.options[number - 1].value
74
+ # 옵션 값 자체가 숫자인 role-count 는 목록 밖 번호를 값으로도 허용한다
75
+ # (예: 옵션 [3,4,5] 에서 "4" → 수량 4). 목록에 없으면 범위 오류.
74
76
  if prompt.step.startswith("role-count:"):
77
+ exact_count = _exact_value_match(prompt, candidate)
78
+ if exact_count is not None:
79
+ return exact_count
75
80
  role = prompt.step.split(":", 1)[1]
76
81
  minimum = prompt.options[0].value if prompt.options else "?"
77
82
  maximum = prompt.options[-1].value if prompt.options else "?"
@@ -0,0 +1,44 @@
1
+ """Execution-surface value objects.
2
+
3
+ The surface is where a worker process starts and where its output lands.
4
+ It is not a host, a provider, or a role.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ SURFACE_CMUX_PANE = "cmux-pane"
13
+ SURFACE_CLI_WRAPPER = "cli-wrapper"
14
+
15
+
16
+ class SurfaceUnavailable(Exception):
17
+ """The surface could not be opened. The application may try the next one."""
18
+
19
+
20
+ class EnvironmentBlocked(Exception):
21
+ """A sandbox hid the surface and the worker CLI config. Do not degrade."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class WorkerSpawnRequest:
26
+ command: tuple[str, ...]
27
+ cwd: Path
28
+ title: str
29
+ owned_surface_ids: tuple[str, ...] = ()
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class RuntimeHandle:
34
+ surface: str
35
+ identifier: str
36
+ waitable: bool
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ProgressEvent:
41
+ message: str
42
+ level: str = "info"
43
+ notify_title: str | None = None
44
+ notify_body: str | None = None
@@ -11,9 +11,10 @@ from .consumers import (
11
11
  carry_head_commit,
12
12
  read_stage_consumer_state,
13
13
  )
14
+ from . import next_phase
14
15
  from .paths import RunRef, task_manifest_file
15
16
  from .stage_map import StageMapError, load_task_stage_map
16
- from .workflow import DEFAULT_NEXT_PHASE, PHASE_SEQUENCE
17
+ from .workflow import PHASE_SEQUENCE
17
18
 
18
19
 
19
20
  @dataclass(frozen=True)
@@ -22,7 +23,6 @@ class ImplementationOutcome:
22
23
  stages: list[int] = field(default_factory=list)
23
24
  head_commit: str = ""
24
25
  report_path: str = ""
25
- next_recommended_phase: str = ""
26
26
  reason: str = ""
27
27
 
28
28
 
@@ -66,7 +66,6 @@ def derive_implementation_outcome(task_root: Path) -> ImplementationOutcome:
66
66
  stages=sorted(required_stages),
67
67
  head_commit=str(latest_done.get("head_commit") or "") or carry_head_commit(latest_carry),
68
68
  report_path=_latest_implementation_report(task_root),
69
- next_recommended_phase=DEFAULT_NEXT_PHASE["implementation"],
70
69
  reason="all implementation stages have pass-grade carry and done rows",
71
70
  )
72
71
 
@@ -174,7 +173,22 @@ def _promote_workflow(workflow: dict[str, Any], outcome: ImplementationOutcome)
174
173
  workflow["currentPhaseState"] = "completed"
175
174
  phase_states["implementation"] = "completed"
176
175
  workflow["lastCompletedPhase"] = "implementation"
177
- workflow["nextRecommendedPhase"] = outcome.next_recommended_phase
176
+ # 재조정기의 사실은 "모든 stage 가 끝났다"는 상태다. 대상 phase 는 마지막
177
+ # stage 검증이 이미 정했으므로 건드리지 않는다 (ADR-0004).
178
+ #
179
+ # 대상 phase 가 비어 있으면 status 도 올리지 않는다. `ready` 는 "지금 이
180
+ # phase 를 시작할 수 있다"는 뜻인데 가리킬 phase 가 없으면 시작할 대상이
181
+ # 없다. 마지막 stage 리포트에 라우팅 대상이 없으면 이 자리에 빈 phase 의
182
+ # pending 포인터가 들어오므로, 그때는 그대로 둔다.
183
+ pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
184
+ if pointer["phase"]:
185
+ workflow["nextRecommendedPhase"] = next_phase.make(
186
+ phase=pointer["phase"],
187
+ status=next_phase.STATUS_READY,
188
+ rationale=pointer["rationale"],
189
+ )
190
+ else:
191
+ workflow["nextRecommendedPhase"] = pointer
178
192
  after = json.dumps(workflow, sort_keys=True, ensure_ascii=False)
179
193
  return before != after
180
194
 
@@ -195,7 +209,9 @@ def _update_task_catalog(project_root: Path, manifest: dict[str, Any]) -> bool:
195
209
  entry["currentPhase"] = workflow.get("currentPhase", "")
196
210
  entry["currentPhaseState"] = workflow.get("currentPhaseState", "")
197
211
  entry["lastCompletedPhase"] = workflow.get("lastCompletedPhase", "")
198
- entry["nextRecommendedPhase"] = workflow.get("nextRecommendedPhase", "")
212
+ entry["nextRecommendedPhase"] = next_phase.promote(
213
+ workflow.get("nextRecommendedPhase")
214
+ )
199
215
  entry["latestReportPath"] = manifest.get("latestReportPath", "")
200
216
  entry["latestRunStatus"] = manifest.get("latestRunStatus", entry.get("latestRunStatus", ""))
201
217
  entry["currentStatus"] = manifest.get("currentStatus", entry.get("currentStatus", ""))