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.
- package/README.md +4 -2
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +14 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +7 -3
- package/runtime/bin/okstra-spawn-followups.py +2 -2
- package/runtime/prompts/duties/technical-verification-worker.md +44 -0
- package/runtime/prompts/launch.template.md +7 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -2
- package/runtime/prompts/lead/plan-body-verification.md +3 -1
- package/runtime/prompts/lead/report-writer.md +11 -5
- package/runtime/prompts/lead/team-contract.md +6 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +7 -1
- package/runtime/prompts/profiles/final-verification.md +5 -0
- package/runtime/prompts/profiles/forbidden-actions.json +6 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +7 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -0
- package/runtime/prompts/profiles/technical-verification.md +53 -0
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +36 -5
- package/runtime/python/okstra_ctl/agent/invocation.py +14 -6
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +4 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/corrections.py +83 -22
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +44 -2
- package/runtime/python/okstra_ctl/conformance.py +2 -20
- package/runtime/python/okstra_ctl/dispatch_core.py +25 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +2 -0
- package/runtime/python/okstra_ctl/domain/provider.py +0 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +64 -7
- package/runtime/python/okstra_ctl/implementation_options.py +58 -45
- package/runtime/python/okstra_ctl/model_pool.py +2 -5
- package/runtime/python/okstra_ctl/next_phase.py +3 -0
- package/runtime/python/okstra_ctl/plan_items.py +15 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +9 -3
- package/runtime/python/okstra_ctl/qa_commands.py +30 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +11 -8
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/render_final_report.py +1 -0
- package/runtime/python/okstra_ctl/report_assembly.py +8 -2
- package/runtime/python/okstra_ctl/report_contract.py +3 -0
- package/runtime/python/okstra_ctl/report_corrections.py +209 -93
- package/runtime/python/okstra_ctl/report_finalize.py +25 -8
- package/runtime/python/okstra_ctl/report_html/router.py +2 -0
- package/runtime/python/okstra_ctl/report_html/view_models/technical_verification.py +21 -0
- package/runtime/python/okstra_ctl/report_projections.py +4 -3
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +181 -47
- package/runtime/python/okstra_ctl/run.py +82 -0
- package/runtime/python/okstra_ctl/team.py +4 -1
- package/runtime/python/okstra_ctl/technical_verification.py +195 -0
- package/runtime/python/okstra_ctl/usage_identity.py +54 -0
- package/runtime/python/okstra_ctl/usage_report.py +22 -8
- package/runtime/python/okstra_ctl/verification_target.py +74 -0
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -1
- package/runtime/python/okstra_ctl/wizard/cli.py +2 -1
- package/runtime/python/okstra_ctl/wizard/confirmation.py +38 -2
- package/runtime/python/okstra_ctl/wizard/engine.py +3 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/outcome.py +63 -0
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +2 -2
- package/runtime/python/okstra_ctl/wizard/registry.py +1 -1
- package/runtime/python/okstra_ctl/wizard/render.py +8 -55
- package/runtime/python/okstra_ctl/wizard/roles.py +11 -7
- package/runtime/python/okstra_ctl/wizard/sources.py +28 -2
- package/runtime/python/okstra_ctl/wizard/state.py +13 -6
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +8 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +52 -39
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +8 -0
- package/runtime/python/okstra_ctl/write_policy.py +23 -0
- package/runtime/python/okstra_token_usage/blocks.py +50 -1
- package/runtime/python/okstra_token_usage/claude.py +42 -21
- package/runtime/python/okstra_token_usage/codex.py +17 -0
- package/runtime/python/okstra_token_usage/collect.py +299 -162
- package/runtime/python/okstra_token_usage/cursor.py +2 -3
- package/runtime/python/okstra_token_usage/report.py +35 -30
- package/runtime/python/okstra_token_usage/task_totals.py +3 -12
- package/runtime/schemas/final-report-v2.0.schema.json +298 -7
- package/runtime/schemas/final-report-v3.0.schema.json +298 -7
- package/runtime/schemas/report-narrative-v3.0.schema.json +1 -0
- package/runtime/schemas/report-synthesis-packet-v1.0.schema.json +1 -1
- package/runtime/schemas/report-writer-corrections-v1.0.schema.json +30 -3
- package/runtime/skills/okstra-run/SKILL.md +10 -2
- package/runtime/skills/okstra-setup/SKILL.md +42 -7
- package/runtime/templates/report-writer-prompt-preamble.md +7 -3
- package/runtime/templates/reports/html/i18n/en.json +11 -0
- package/runtime/templates/reports/html/i18n/ko.json +11 -0
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +7 -3
- package/runtime/templates/reports/html/tasks/technical-verification.template.html +35 -0
- package/runtime/templates/reports/md/tasks/technical-verification.template.md +5 -0
- 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
|
-
추천은 권장 수만큼의
|
|
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
|
-
|
|
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
|
|
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
|
-
"""단일
|
|
232
|
+
"""단일 추천은 하나다. 모델 선택은 제공자 순서, 그 외에는 추천이 앞이다.
|
|
229
233
|
|
|
230
234
|
실측(2026-09-09, task 선택 화면): 남은 task 세 줄이 전부 `(추천)` 을
|
|
231
235
|
달고 나왔고, 리드는 산문에서 2번을 권했다. 추천이 여럿이면 라벨은
|
|
232
236
|
아무것도 고르지 않은 것이고, 추천이 1번이 아니면 사용자는 목록을
|
|
233
|
-
끝까지 읽어야 추천을 찾는다.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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={
|
|
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
|
|
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
|
|
524
|
-
parser.error("
|
|
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
|
-
|
|
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,
|
|
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
|
-
|