okstra 0.200.0 → 0.201.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 (96) hide show
  1. package/README.md +4 -2
  2. package/dist/cli-registry.mjs +6 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/docs/cli.md +14 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +7 -3
  8. package/runtime/bin/okstra-spawn-followups.py +2 -2
  9. package/runtime/prompts/duties/technical-verification-worker.md +44 -0
  10. package/runtime/prompts/launch.template.md +7 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
  12. package/runtime/prompts/lead/plan-body-verification.md +3 -1
  13. package/runtime/prompts/lead/report-writer.md +11 -5
  14. package/runtime/prompts/lead/team-contract.md +6 -0
  15. package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
  16. package/runtime/prompts/profiles/final-verification.md +5 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +6 -0
  18. package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
  19. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  20. package/runtime/prompts/profiles/technical-verification.md +53 -0
  21. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  22. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
  23. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
  24. package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
  25. package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
  29. package/runtime/python/okstra_ctl/conformance.py +2 -20
  30. package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
  31. package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
  32. package/runtime/python/okstra_ctl/domain/provider.py +0 -1
  33. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  34. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
  35. package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
  36. package/runtime/python/okstra_ctl/implementation_options.py +58 -45
  37. package/runtime/python/okstra_ctl/model_pool.py +2 -5
  38. package/runtime/python/okstra_ctl/next_phase.py +3 -0
  39. package/runtime/python/okstra_ctl/plan_items.py +15 -0
  40. package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
  41. package/runtime/python/okstra_ctl/qa_commands.py +30 -0
  42. package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
  43. package/runtime/python/okstra_ctl/render.py +3 -0
  44. package/runtime/python/okstra_ctl/render_final_report.py +1 -0
  45. package/runtime/python/okstra_ctl/report_assembly.py +8 -2
  46. package/runtime/python/okstra_ctl/report_contract.py +3 -0
  47. package/runtime/python/okstra_ctl/report_corrections.py +209 -93
  48. package/runtime/python/okstra_ctl/report_finalize.py +25 -8
  49. package/runtime/python/okstra_ctl/report_html/router.py +2 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
  51. package/runtime/python/okstra_ctl/report_projections.py +4 -3
  52. package/runtime/python/okstra_ctl/report_synthesis_packet.py +181 -47
  53. package/runtime/python/okstra_ctl/run.py +82 -0
  54. package/runtime/python/okstra_ctl/team.py +4 -1
  55. package/runtime/python/okstra_ctl/technical_verification.py +195 -0
  56. package/runtime/python/okstra_ctl/usage_identity.py +54 -0
  57. package/runtime/python/okstra_ctl/usage_report.py +22 -8
  58. package/runtime/python/okstra_ctl/verification_target.py +74 -0
  59. package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
  60. package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
  61. package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
  62. package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
  63. package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
  64. package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
  65. package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
  66. package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
  67. package/runtime/python/okstra_ctl/wizard/render.py +8 -55
  68. package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
  69. package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
  70. package/runtime/python/okstra_ctl/wizard/state.py +13 -6
  71. package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
  72. package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  74. package/runtime/python/okstra_ctl/workflow.py +8 -0
  75. package/runtime/python/okstra_ctl/write_policy.py +23 -0
  76. package/runtime/python/okstra_token_usage/blocks.py +50 -1
  77. package/runtime/python/okstra_token_usage/claude.py +42 -21
  78. package/runtime/python/okstra_token_usage/codex.py +17 -0
  79. package/runtime/python/okstra_token_usage/collect.py +299 -162
  80. package/runtime/python/okstra_token_usage/cursor.py +2 -3
  81. package/runtime/python/okstra_token_usage/report.py +35 -30
  82. package/runtime/python/okstra_token_usage/task_totals.py +3 -12
  83. package/runtime/schemas/final-report-v2.0.schema.json +298 -7
  84. package/runtime/schemas/final-report-v3.0.schema.json +298 -7
  85. package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
  86. package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +1 -1
  87. package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
  88. package/runtime/skills/okstra-run/SKILL.md +10 -2
  89. package/runtime/skills/okstra-setup/SKILL.md +42 -7
  90. package/runtime/templates/report-writer-prompt-preamble.md +7 -3
  91. package/runtime/templates/reports/html/i18n/en.json +11 -0
  92. package/runtime/templates/reports/html/i18n/ko.json +11 -0
  93. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
  94. package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
  95. package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
  96. package/runtime/validators/validate-run.py +9 -4
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  from typing import Any
5
+ import json
5
6
 
6
7
  from okstra_ctl.legacy_model_selection import serialize_host_session_context
7
8
  from okstra_ctl.wizard_stage_intent import (
@@ -17,7 +18,6 @@ from .state import (
17
18
  _role_selection_enabled,
18
19
  )
19
20
  from .roles import _host_session_context, _selectable_static_requirements
20
- from .confirmation import confirmation_block
21
21
 
22
22
 
23
23
  def _stage_intent(state: WizardState) -> WizardStageIntent:
@@ -132,58 +132,11 @@ def render_args(state: WizardState) -> dict[str, Any]:
132
132
  for index, token in enumerate(role_argv)
133
133
  if token == "--role-model"
134
134
  ]
135
+ if state.user_authorization:
136
+ stages = _stage_intent(state).chain_stages.split(",") if _stage_intent(state).chain_stages else []
137
+ if state.user_authorization.get("stageScope", []) != stages:
138
+ raise WizardError("confirmed stage scope changed; obtain confirmation for the changed stages")
139
+ if state.user_authorization.get("scope") != rendered:
140
+ raise WizardError("confirmed scope changed; obtain confirmation for the changed inputs")
141
+ rendered["user-authorization-json"] = json.dumps(state.user_authorization, ensure_ascii=False)
135
142
  return rendered
136
-
137
-
138
- def _render_argv(rendered: dict[str, Any], *, host_runtime: str) -> list[str]:
139
- """Flatten render arguments into the canonical render-bundle argv."""
140
- argv = ["--lead-runtime", host_runtime]
141
- for name, raw_value in rendered.items():
142
- values = raw_value if isinstance(raw_value, list) else [raw_value]
143
- for value in values:
144
- if not isinstance(value, str):
145
- raise WizardError(
146
- f"wizard render arg --{name} must be a string"
147
- )
148
- argv.extend([f"--{name}", value])
149
- return argv
150
-
151
-
152
- def _wizard_persist_actions(state: WizardState) -> list[dict[str, str]]:
153
- if state.task_type != "release-handoff":
154
- return []
155
- if not state.pr_template_path:
156
- return []
157
- if state.pr_template_scope not in ("project", "global"):
158
- return []
159
- return [
160
- {
161
- "command": "config.set",
162
- "key": "pr-template-path",
163
- "scope": state.pr_template_scope,
164
- "value": state.pr_template_path,
165
- }
166
- ]
167
-
168
-
169
- def wizard_outcome(state: WizardState) -> dict[str, Any]:
170
- """Public outcome for callers that need launch data and follow-up writes.
171
-
172
- `renderArgs` carries only what `okstra render-bundle` accepts, so a caller
173
- can pass every entry through unfiltered — which is exactly what the
174
- okstra-run skill is told to do. Signals the skill consumes itself, like the
175
- unattended stage chain, live under `orchestration`; mixing them into
176
- `renderArgs` made the renderer reject the wizard's own output.
177
- """
178
- if state.aborted:
179
- raise WizardError("wizard was aborted by the user — outcome is unavailable")
180
- if state.confirmed is not True:
181
- raise WizardError("wizard is not complete — outcome is unavailable")
182
- rendered = render_args(state)
183
- return {
184
- "renderArgs": rendered,
185
- "renderArgv": _render_argv(rendered, host_runtime=state.host_runtime),
186
- "orchestration": {"chainStages": _stage_intent(state).chain_stages},
187
- "persistActions": _wizard_persist_actions(state),
188
- "confirmationText": confirmation_block(state),
189
- }
@@ -8,8 +8,9 @@ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인
8
8
  고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.
9
9
 
10
10
  모델 화면(`role-models:<role>`, `role-model:<role>:1`)은 실행 가능한 전체 후보를
11
- 한 번에 싣는다. 기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가
12
- 앞이고 권장 수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도
11
+ 한 번에 제공자별로 묶어 싣는다. 기본 후보(프로젝트 `modelDefaults`, 없으면
12
+ 카탈로그 기본값)에서 권장 수만큼 추천하고 제공자 안에서는 기본 후보가 앞이다.
13
+ 호스트 네이티브 선택기의 옵션 한도
13
14
  (claude-code 4, codex 3, grok 15)를 넘으면 체크박스든 단일 선택이든 네이티브
14
15
  질문 묶음에 실리는 크기(claude-code 4×4)까지는 같은 화면의 체크박스 질문 여러
15
16
  개로 자르고(`picker_navigation.split_picker`), 그것도 넘으면 체크박스는
@@ -31,6 +32,7 @@ from okstra_ctl.assignment_resolver import (
31
32
  resolve_model_assignment,
32
33
  )
33
34
  from okstra_ctl.domain.host import CurrentSessionModelAttestation, HostSessionContext
35
+ from okstra_ctl.registry.provider_registry import provider_display_order
34
36
  from okstra_ctl.dispatch_state import detect_terminal_backend
35
37
  from okstra_ctl.registry.host_registry import default_host_registry
36
38
  from okstra_ctl.model_defaults import ModelDefaultScopes, default_candidates
@@ -221,9 +223,9 @@ def _role_models_prompt(
221
223
  context: AssignmentContext,
222
224
  scopes: ModelDefaultScopes,
223
225
  ) -> Prompt:
224
- """역할 하나의 모델 화면 — 실행 가능한 전체 후보, 기본 후보가 앞이다.
226
+ """역할 하나의 모델 화면 — 실행 가능한 전체 후보를 제공자별로 묶는다.
225
227
 
226
- 추천은 권장 수만큼의 앞줄이다 — 프로젝트 `modelDefaults`(없으면 카탈로그
228
+ 추천은 정렬 전 권장 수만큼의 후보다 — 프로젝트 `modelDefaults`(없으면 카탈로그
227
229
  기본값) 순서가 그 근거다. 권장이 0인 선택 역할은 "추가 안 함" 이 추천이다.
228
230
  """
229
231
  role = requirement.role
@@ -245,8 +247,6 @@ def _role_models_prompt(
245
247
  optional = requirement.min_count == 0
246
248
  skip_first = optional and requirement.recommended_count == 0
247
249
  options: list[Option] = []
248
- if skip_first:
249
- options.append(_skip_option(requirement, t, recommended=True))
250
250
  for index, model_ref in enumerate(everything):
251
251
  model = pool.resolve(model_ref)
252
252
  options.append(_opt(
@@ -254,6 +254,9 @@ def _role_models_prompt(
254
254
  t["options"]["model"].format(model_ref=model_ref, display=model.display_name),
255
255
  recommended=index < requirement.recommended_count,
256
256
  ))
257
+ options.sort(key=lambda option: provider_display_order(option.value.split("/", 1)[0]))
258
+ if skip_first:
259
+ options.insert(0, _skip_option(requirement, t, recommended=True))
257
260
  if optional and not skip_first:
258
261
  options.append(_skip_option(requirement, t, recommended=False))
259
262
  return Prompt(
@@ -273,7 +276,7 @@ def _single_model_options(
273
276
  context: AssignmentContext,
274
277
  scopes: ModelDefaultScopes,
275
278
  ) -> list[Option]:
276
- """고정 단일 역할의 후보 전체. 기본 후보가 앞이고 첫 줄이 추천이다."""
279
+ """고정 단일 역할의 후보를 제공자별로 묶고 기존 추천을 유지한다."""
277
280
  pool = context.pool
278
281
  requirement = next(row for row in profile.roles if row.role == role)
279
282
  defaults, everything = _available_role_models(
@@ -308,6 +311,7 @@ def _single_model_options(
308
311
  if not options:
309
312
  _validate_role_selection_feasibility(state, profile, context, scopes)
310
313
  raise WizardError(f"role {role!r} has no executable model candidates")
314
+ options.sort(key=lambda option: provider_display_order(option.value.split("/", 1)[0]))
311
315
  return options
312
316
 
313
317
 
@@ -533,6 +533,30 @@ def _same_file(a: Path, b_str: str) -> bool:
533
533
  return False
534
534
 
535
535
 
536
+
537
+ def _technical_evidence_for_comparison(
538
+ state: WizardState, source: Path | None, task_root: Path,
539
+ ) -> Path | None:
540
+ """현재 비교 보고서를 검증한 결과만 다음 재비교의 입력으로 추천한다."""
541
+ if state.task_type != "implementation-option-selection" or source is None:
542
+ return source
543
+ project_root = Path(state.project_root)
544
+ report = _newest_contained_final_report(
545
+ task_root / "runs", task_root,
546
+ "technical-verification/reports/final-report-*.data.json", project_root,
547
+ )
548
+ if report is None:
549
+ return source
550
+ try:
551
+ data = load_owned_object(report, artifact="technical verification report")
552
+ except (OSError, ValueError):
553
+ return source
554
+ block = data.get("technicalVerification") or {}
555
+ if block.get("sourceReport") != str(source.relative_to(project_root)):
556
+ return source
557
+ return report
558
+
559
+
536
560
  def _suggest_latest_final_report(state: WizardState) -> str:
537
561
  """clarification carry-in 으로 추천할 직전 final-report 의 relpath.
538
562
 
@@ -561,7 +585,8 @@ def _suggest_latest_final_report(state: WizardState) -> str:
561
585
  revision = _latest_revision_requested_analysis_report(state)
562
586
  best = revision
563
587
  if best is None and state.task_type:
564
- seg = slugify_task_segment(state.task_type)
588
+ source_type = "implementation-option-selection" if state.task_type == "technical-verification" else state.task_type
589
+ seg = slugify_task_segment(source_type)
565
590
  best = _newest_contained_final_report(
566
591
  runs_base,
567
592
  task_root,
@@ -573,13 +598,14 @@ def _suggest_latest_final_report(state: WizardState) -> str:
573
598
  # 리포트가 없으면 이 런은 새 계획이고, 답은 `--selected-direction` 으로
574
599
  # 들어간다 — 전체 phase 폴백은 여기서 직전 후보비교 리포트를 추천했고,
575
600
  # 그것을 고른 사용자는 두 상호 배타 입력을 동시에 갖게 됐다.
576
- if best is None and state.task_type != "implementation-planning":
601
+ if best is None and state.task_type not in {"implementation-planning", "technical-verification"}:
577
602
  best = _newest_contained_final_report(
578
603
  runs_base,
579
604
  task_root,
580
605
  "*/reports/final-report-*.data.json",
581
606
  Path(state.project_root),
582
607
  )
608
+ best = _technical_evidence_for_comparison(state, best, task_root)
583
609
  if best is None:
584
610
  return ""
585
611
  # The approved plan is already wired via --approved-plan. On the first run
@@ -147,6 +147,10 @@ class WizardState:
147
147
  # "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
148
148
  fix_cycle: str = ""
149
149
  confirmed: Optional[bool] = None
150
+ confirmation_stages: str = ""
151
+ confirmation_prompt: str = ""
152
+ confirmation_scope: dict[str, Any] = field(default_factory=dict)
153
+ user_authorization: dict[str, Any] = field(default_factory=dict)
150
154
  edit_target: str = ""
151
155
  # terminal: user picked 중단 — no further prompt ever applies
152
156
  aborted: bool = False
@@ -225,26 +229,28 @@ class Prompt:
225
229
  self._check_recommendations()
226
230
 
227
231
  def _check_recommendations(self) -> None:
228
- """단일 선택의 추천은 정확히 하나이고 1번이다. 탈출구는 추천이 아니다.
232
+ """단일 추천은 하나다. 모델 선택은 제공자 순서, 그 외에는 추천이 앞이다.
229
233
 
230
234
  실측(2026-09-09, task 선택 화면): 남은 task 세 줄이 전부 `(추천)` 을
231
235
  달고 나왔고, 리드는 산문에서 2번을 권했다. 추천이 여럿이면 라벨은
232
236
  아무것도 고르지 않은 것이고, 추천이 1번이 아니면 사용자는 목록을
233
- 끝까지 읽어야 추천을 찾는다. 체크박스(`multi`)는 추천이 기본 선택
234
- 집합이라 여럿일 수 있되 앞머리에 모여 있다. 그리고 `직접 입력` /
237
+ 끝까지 읽어야 추천을 찾는다. 모델 선택은 제공자별 묶음을 우선한다.
238
+ 외 체크박스(`multi`)는 추천이 여럿일 수 있되 앞머리에 모인다.
239
+ 그리고 `직접 입력` /
235
240
  `중단` 은 앞의 선택지가 전부 맞지 않을 때의 탈출구이므로 추천 대상이
236
241
  될 수 없다.
237
242
  """
238
243
  flags = [option.recommended for option in self.options]
244
+ grouped_models = self.step.startswith(("role-models:", "role-model:"))
239
245
  if any(flags):
240
246
  if self.multi:
241
- if any(flags[index] for index in range(1, len(flags))
247
+ if not grouped_models and any(flags[index] for index in range(1, len(flags))
242
248
  if not flags[index - 1]):
243
249
  raise WizardError(
244
250
  f"wizard step {self.step!r}: recommended options must "
245
251
  "be the leading run of the list"
246
252
  )
247
- elif sum(flags) != 1 or not flags[0]:
253
+ elif sum(flags) != 1 or (not grouped_models and not flags[0]):
248
254
  raise WizardError(
249
255
  f"wizard step {self.step!r}: a single-select step carries "
250
256
  "exactly one recommendation and it is the first option"
@@ -684,7 +690,8 @@ _FIELD_DEFAULTS: dict[str, Any] = {
684
690
  "pr_template_path": "", "pr_template_pending_text": False,
685
691
  "pr_template_scope": "",
686
692
  "fix_cycle": "",
687
- "confirmed": None, "edit_target": "",
693
+ "confirmed": None, "edit_target": "", "confirmation_prompt": "", "confirmation_stages": "",
694
+ "confirmation_scope": {}, "user_authorization": {},
688
695
  }
689
696
 
690
697
 
@@ -12,6 +12,7 @@ from okstra_ctl.incremental_scope import CARRY_ALL_SCOPE
12
12
  from okstra_ctl.implementation_direction import (
13
13
  DirectionSelectionError,
14
14
  lexical_absolute_path,
15
+ resolve_selected_direction,
15
16
  validate_task_artifact_path,
16
17
  )
17
18
  from okstra_ctl.final_report_paths import final_report_data_path
@@ -191,6 +192,13 @@ def _submit_selected_direction_pick(
191
192
  candidates = _selected_direction_candidates(state)
192
193
  if value not in candidates:
193
194
  raise WizardError(t["errors"]["unknown"].format(value=value))
195
+ try:
196
+ resolve_selected_direction(
197
+ Path(state.project_root) / value,
198
+ expected_task_key=f"{state.project_id}:{state.task_group}:{state.task_id}",
199
+ )
200
+ except DirectionSelectionError as exc:
201
+ raise WizardError(str(exc)) from exc
194
202
  state.selected_direction_path = value
195
203
  # 두 입력은 상호 배타다 — 방향이 정해진 순간 이 런은 새 계획이고,
196
204
  # clarification 자리에 남은 값은 render-bundle 이 거절할 이유일 뿐이다.
@@ -162,7 +162,8 @@ def probe_launch(
162
162
  """Whether the wrapper behind *prompt* ever started."""
163
163
  log, status = _log_path(prompt), Path(f"{prompt}.status.json")
164
164
  probe = {"kind": "launch", "path": str(prompt)}
165
- if log.exists() or status.exists():
165
+ if any(path.is_file() and path.stat().st_mtime >= dispatched_at.timestamp()
166
+ for path in (log, status)):
166
167
  return {**probe, "state": "live", "reason": ""}
167
168
  waited = (now - dispatched_at).total_seconds()
168
169
  if waited <= grace:
@@ -273,7 +274,8 @@ def probe_all(
273
274
  def result_ready(target: ProbeTarget) -> bool:
274
275
  """Whether this worker's result file has landed with content in it."""
275
276
  path = target.result_path
276
- return bool(path and path.is_file() and path.stat().st_size > 0)
277
+ return bool(path and path.is_file() and path.stat().st_size > 0
278
+ and path.stat().st_mtime >= target.dispatched_at.timestamp())
277
279
 
278
280
 
279
281
  def wait_for_results(
@@ -370,53 +372,58 @@ def _dispatch_worker_id(record: Mapping[str, Any]) -> str:
370
372
  return ""
371
373
 
372
374
 
373
- def _dispatch_fallback(state: Mapping[str, Any], worker_id: str) -> dict:
374
- """같은 워커의 디스패치 마지막 것.
375
-
376
- 로스터 행에 없는 키를 여기서 보충한다. 재디스패치는 같은 워커의 행을 뒤에
377
- 덧붙이므로 마지막 행이 이번 시도다.
378
- """
379
- records = state.get("workerDispatches")
380
- if not isinstance(records, list):
381
- return {}
375
+ def _dispatch_fallback(state: Mapping[str, Any], worker_id: str, dispatch_id: str = "") -> dict:
376
+ """명시한 배정 또는 하나로 확정되는 구형 배정만 선택한다."""
377
+ records = state.get("workerDispatches") or []
382
378
  matches = [
383
- row for row in records
384
- if isinstance(row, Mapping) and _dispatch_worker_id(row) == worker_id
379
+ row for row in records if isinstance(row, Mapping)
380
+ and (row.get("dispatchId") == dispatch_id if dispatch_id
381
+ else _dispatch_worker_id(row) == worker_id)
385
382
  ]
386
- return dict(matches[-1]) if matches else {}
387
-
383
+ if len(matches) > 1:
384
+ raise DispatchError(f"multiple dispatches for {worker_id or dispatch_id}; pass --dispatch-id")
385
+ if dispatch_id and not matches:
386
+ raise DispatchError(f"team-state has no dispatchId={dispatch_id}")
387
+ return dict(matches[0]) if matches else {}
388
388
 
389
- def _worker_row(team_state_path: Path, worker_id: str) -> dict:
390
- """이 워커의 프로브 입력 — 로스터 행에 디스패치 행을 덧댄 것.
391
389
 
392
- `livenessMode` 디스패치 행에만 실린다(`dispatch_core._dispatch_record`);
393
- 로스터 행은 키를 갖지 않는다. 로스터만 읽으면 cmux 백엔드의 모든 run 에서
394
- 프로브가 전면 거부되고, 리드는 계약이 금지한 자체 폴링으로 밀려난다.
395
- 보충은 로스터에 없거나 빈 키에만 적용한다 — `startedAt` 처럼 로스터가 정본인
396
- 값을 디스패치 행이 덮어쓰면 grace 앵커가 이번 시도에서 어긋난다.
397
- """
390
+ def _worker_row(team_state_path: Path, worker_id: str, dispatch_id: str = "") -> dict:
391
+ """새 배정의 필드는 원자적으로 읽고 구형 단일 기록만 보완한다."""
398
392
  state = load_json_object(team_state_path, "team-state")
393
+ dispatch = _dispatch_fallback(state, worker_id, dispatch_id)
394
+ if dispatch.get("dispatchId") and dispatch.get("startedAt"):
395
+ result = dispatch.get("resultPath")
396
+ root = _project_root_for_team_state(team_state_path)
397
+ if result and any(
398
+ isinstance(row, Mapping) and row.get("dispatchId") != dispatch["dispatchId"]
399
+ and row.get("status") not in {"completed", "error", "timeout", "not-run"}
400
+ and row.get("resultPath")
401
+ and (root / row["resultPath"]).resolve() == (root / result).resolve()
402
+ for row in state.get("workerDispatches", [])
403
+ ):
404
+ raise DispatchError("resultPath is shared by dispatches; use an attempt-specific result path")
405
+ return dispatch
399
406
  workers = state.get("workers")
400
407
  if not isinstance(workers, list):
401
408
  raise DispatchError(f"team-state workers must be an array: {team_state_path}")
402
- worker = next(
403
- (
404
- row for row in workers
405
- if isinstance(row, dict) and row.get("workerId") == worker_id
406
- ),
407
- None,
408
- )
409
+ selected_worker = _dispatch_worker_id(dispatch) if dispatch_id else worker_id
410
+ worker = next((row for row in workers if isinstance(row, dict)
411
+ and row.get("workerId") == selected_worker), None)
409
412
  if worker is None:
410
- raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
413
+ raise DispatchError(f"team-state has no workerId={selected_worker}: {team_state_path}")
414
+ if dispatch and worker.get("promptPath") and dispatch.get("promptPath"):
415
+ root = _project_root_for_team_state(team_state_path)
416
+ if (root / worker["promptPath"]).resolve() != (root / dispatch["promptPath"]).resolve():
417
+ raise DispatchError("dispatch has no startedAt and roster promptPath differs; repair the recorded attempt")
411
418
  merged = dict(worker)
412
- for key, value in _dispatch_fallback(state, worker_id).items():
419
+ for key, value in dispatch.items():
413
420
  current = merged.get(key)
414
421
  if key not in merged or (isinstance(current, str) and not current.strip()):
415
422
  merged[key] = value
416
423
  return merged
417
424
 
418
425
 
419
- def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
426
+ def probe_target(team_state_value: str, worker_id: str, *, dispatch_id: str = "") -> ProbeTarget:
420
427
  """Resolve one worker's probe target from team-state.
421
428
 
422
429
  ``livenessMode`` is authoritative — never infer the transport from the
@@ -424,7 +431,7 @@ def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
424
431
  worker for a wrapper log that will never exist.
425
432
  """
426
433
  team_state_path = Path(team_state_value).resolve()
427
- worker = _worker_row(team_state_path, worker_id)
434
+ worker = _worker_row(team_state_path, worker_id, dispatch_id)
428
435
  mode = worker.get("livenessMode")
429
436
  field = _ARTIFACT_FIELD_BY_MODE.get(mode) if isinstance(mode, str) else None
430
437
  if field is None:
@@ -492,6 +499,8 @@ def main(argv: list[str] | None = None) -> int:
492
499
  help="team-state path for a pending worker (repeatable)")
493
500
  parser.add_argument("--worker", action="append", default=[],
494
501
  help="worker id paired with --team-state (repeatable)")
502
+ parser.add_argument("--dispatch-id", action="append", default=[],
503
+ help="exact dispatch id paired with --team-state; do not mix with --worker")
495
504
  parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
496
505
  help="heartbeat staleness budget in seconds")
497
506
  parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
@@ -520,15 +529,19 @@ def main(argv: list[str] | None = None) -> int:
520
529
  )
521
530
  args = parser.parse_args(argv)
522
531
 
523
- if len(args.team_state) != len(args.worker):
524
- parser.error("each --team-state must have one paired --worker")
532
+ if args.worker and args.dispatch_id:
533
+ parser.error("use --worker or --dispatch-id, not both")
534
+ selectors = args.dispatch_id or args.worker
535
+ if len(args.team_state) != len(selectors):
536
+ parser.error("each --team-state must have one paired --worker or --dispatch-id")
525
537
  if not args.team_state:
526
538
  parser.error("pass at least one --team-state/--worker pair")
527
539
 
528
540
  try:
529
541
  targets = [
530
- probe_target(team_state, worker)
531
- for team_state, worker in zip(args.team_state, args.worker, strict=True)
542
+ probe_target(team_state, "" if args.dispatch_id else worker,
543
+ dispatch_id=worker if args.dispatch_id else "")
544
+ for team_state, worker in zip(args.team_state, selectors, strict=True)
532
545
  ]
533
546
  except DispatchError as exc:
534
547
  parser.error(str(exc))
@@ -547,7 +560,7 @@ def main(argv: list[str] | None = None) -> int:
547
560
  return 0 if result["ok"] else 1
548
561
 
549
562
  unwaitable = [
550
- worker for target, worker in zip(targets, args.worker, strict=True)
563
+ worker for target, worker in zip(targets, selectors, strict=True)
551
564
  if target.result_path is None
552
565
  ]
553
566
  if unwaitable:
@@ -49,6 +49,7 @@ FINAL_VERIFICATION_HEADERS = (
49
49
  "**Verification target digest:**",
50
50
  )
51
51
  SUPPORTED_TASK_TYPES = frozenset({
52
+ "technical-verification",
52
53
  "requirements-discovery",
53
54
  "error-analysis",
54
55
  "implementation-option-selection",
@@ -65,6 +66,7 @@ SUPPORTED_TASK_TYPES = frozenset({
65
66
  # something else gets its own. A task type absent from this map takes the
66
67
  # observational default below: describe the area, do not design for it.
67
68
  ANALYSIS_DUTY_BY_TASK_TYPE: dict[str, AgentAudience] = {
69
+ "technical-verification": "technical-verification-worker",
68
70
  "requirements-discovery": "discovery-worker",
69
71
  "improvement-discovery": "discovery-worker",
70
72
  "error-analysis": "diagnosis-worker",
@@ -39,6 +39,14 @@ ERROR_ANALYSIS_ROUTING_DIRECTIONS = {
39
39
  # prompt template 에 그대로 박혀 lead 가 읽는다. forbidden actions 는 이 dict 가
40
40
  # 아니라 prompts/profiles/forbidden-actions.json (load_phase_forbidden) 이 SSOT.
41
41
  PHASE_RULES: dict[str, dict[str, str]] = {
42
+ "technical-verification": {
43
+ "allowed": (
44
+ " - falsifiable experiment plans for the frozen unresolved facts\n"
45
+ " - dependency installation, source experiments, tests and builds only in this run's experiment copies\n"
46
+ " - command logs, observed signals and per-fact supported/refuted/inconclusive/not-run results\n"
47
+ " - return to implementation-option-selection with evidence, without adoption or plan approval"
48
+ ),
49
+ },
42
50
  "requirements-discovery": {
43
51
  "allowed": (
44
52
  " - work-category classification (bugfix / feature / refactor / ops / improvement)\n"
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import hashlib
5
5
  import json
6
+ import re
6
7
  import subprocess
7
8
  from collections.abc import Mapping, Sequence
8
9
  from dataclasses import dataclass
@@ -120,6 +121,27 @@ def _role_qa_artifact_paths(role: str, task_root: Path | None) -> tuple[Path, ..
120
121
  return ()
121
122
 
122
123
 
124
+
125
+ def _technical_experiment_paths(
126
+ artifact_paths: Sequence[Path], task_root: Path | None,
127
+ ) -> tuple[Path, ...]:
128
+ """정규 작업 결과 경로에서 해당 작업자의 시험 디렉터리만 파생한다."""
129
+ if task_root is None:
130
+ return ()
131
+ run_root = task_root / "runs" / "technical-verification"
132
+ paths = set()
133
+ for artifact in artifact_paths:
134
+ if artifact.parent != run_root / "worker-results":
135
+ continue
136
+ match = re.fullmatch(
137
+ r"([a-z0-9][a-z0-9-]*)-worker-technical-verification-(\d{3,})\.md",
138
+ artifact.name,
139
+ )
140
+ if match:
141
+ paths.add(run_root / "experiments" / match[2] / match[1])
142
+ return tuple(sorted(paths))
143
+
144
+
123
145
  def build_invocation_write_contract(
124
146
  *,
125
147
  role: str,
@@ -142,6 +164,7 @@ def build_invocation_write_contract(
142
164
  _relative_to_root(path, root, "artifact")
143
165
  for path in (
144
166
  *artifact_paths,
167
+ *_technical_experiment_paths(artifact_paths, task_root),
145
168
  *_role_qa_artifact_paths(role, task_root),
146
169
  )
147
170
  )
@@ -1,12 +1,62 @@
1
1
  """Per-source usage block constructors (consumed by collect())."""
2
2
  from __future__ import annotations
3
3
 
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
4
7
  from .paths import utc_now
5
8
  from .pricing import (
6
9
  claude_billable_equivalent,
7
10
  claude_cost_usd,
8
11
  )
9
12
 
13
+ _CACHE_INCLUDED_SOURCES = frozenset({"codex-cli", "grok-cli", "kimi-cli"})
14
+
15
+
16
+ def accounting_workers(state: Mapping[str, Any]) -> list[dict]:
17
+ """초기 명부와 명부 밖 실행의 사용량 행을 같은 소비 경로로 전달한다."""
18
+ rows = []
19
+ for key in ("workers", "additionalWorkerUsage"):
20
+ collection = state.get(key)
21
+ if isinstance(collection, list):
22
+ rows.extend(row for row in collection if isinstance(row, dict))
23
+ return rows
24
+
25
+
26
+ def usage_blocks(state: dict) -> list[dict]:
27
+ blocks = [state.get("leadUsage") or {}]
28
+ blocks.extend(worker.get("usage") or {} for worker in accounting_workers(state))
29
+ unattributed = (state.get("usageSummary") or {}).get("unattributedWorkerUsage")
30
+ if isinstance(unattributed, dict):
31
+ blocks.append(unattributed)
32
+ return [block for block in blocks if isinstance(block, dict)]
33
+
34
+
35
+ def normalize_usage_block(block: dict) -> dict:
36
+ """과거 캐시 포함 합계를 정규화하고 게시 당시 합계를 별도로 보존한다."""
37
+ normalized = dict(block)
38
+ cached = block.get("cachedInputTokens") or 0
39
+ if (
40
+ block.get("source") not in _CACHE_INCLUDED_SOURCES
41
+ or "cacheReadTokens" in block
42
+ or block.get("accountingBasis") == "cache-read-excluded"
43
+ or not isinstance(cached, int)
44
+ or isinstance(cached, bool)
45
+ or cached <= 0
46
+ ):
47
+ return normalized
48
+ total = block.get("totalTokens")
49
+ if not isinstance(total, int) or total < cached:
50
+ return normalized
51
+ normalized["reportedTotalTokens"] = total
52
+ normalized["totalTokens"] = total - cached
53
+ normalized["cacheReadTokens"] = cached
54
+ normalized["accountingBasis"] = "cache-read-excluded"
55
+ if isinstance(block.get("cliTotalTokens"), int):
56
+ normalized["reportedCliTotalTokens"] = block["cliTotalTokens"]
57
+ normalized["cliTotalTokens"] = max(0, block["cliTotalTokens"] - cached)
58
+ return normalized
59
+
10
60
 
11
61
  def usage_block(totals: dict, source: str, note: str | None = None) -> dict:
12
62
  block = {
@@ -65,4 +115,3 @@ def na_block(reason: str) -> dict:
65
115
  "collectedAt": utc_now(),
66
116
  "note": reason,
67
117
  }
68
-