okstra 0.197.1 → 0.198.1
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/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/wizard.mjs +6 -0
- package/dist/commands/execute/wizard.mjs.map +1 -1
- package/docs/cli.md +4 -2
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +19 -4
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +4 -3
- package/runtime/prompts/profiles/implementation-option-selection.md +4 -0
- package/runtime/prompts/wizard/prompts.ko.json +6 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +5 -3
- package/runtime/python/okstra_ctl/cmux.py +20 -5
- package/runtime/python/okstra_ctl/convergence.py +58 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +71 -0
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +14 -3
- package/runtime/python/okstra_ctl/convergence_store.py +36 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -3
- package/runtime/python/okstra_ctl/dispatch_state.py +30 -4
- package/runtime/python/okstra_ctl/implementation_options.py +123 -0
- package/runtime/python/okstra_ctl/option_votes.py +194 -0
- package/runtime/python/okstra_ctl/render.py +4 -11
- package/runtime/python/okstra_ctl/report_assembly.py +10 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +27 -0
- package/runtime/python/okstra_ctl/wizard/cli.py +2 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +6 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +1 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +8 -0
- package/runtime/python/okstra_ctl/wizard/state.py +1 -0
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +14 -3
- package/runtime/python/okstra_ctl/wizard/steps_options.py +37 -1
- package/runtime/python/okstra_ctl/worker_audit_check.py +38 -16
- package/runtime/python/okstra_ctl/worker_liveness.py +48 -2
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/skills/okstra-run/SKILL.md +8 -1
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +1 -0
- package/runtime/templates/reports/html/i18n/ko.json +1 -0
- package/runtime/validators/validate-run.py +51 -11
|
@@ -169,6 +169,33 @@ def parse_finding_votes(text: str, *, adversarial: bool) -> dict[str, FindingVot
|
|
|
169
169
|
)
|
|
170
170
|
|
|
171
171
|
|
|
172
|
+
def finding_vote_defect(text: str) -> str | None:
|
|
173
|
+
"""재검증 결과가 표로 읽히지 않는 이유. 읽히면 ``None``.
|
|
174
|
+
|
|
175
|
+
수집(`okstra convergence collect-results`)은 이 파일이 표로 읽혀야 진행한다.
|
|
176
|
+
읽히지 않는 파일은 산출물이 있어도 표가 없는 것이므로, 디스패치가 그 자리에서
|
|
177
|
+
"없는 산출물" 로 세어 같은 배치 안에서 재시도한다
|
|
178
|
+
(`okstra_ctl.dispatch_state.unusable_result_defect`). 그러지 않으면 원장은
|
|
179
|
+
`ok`, 수집기는 거절이 되어 리드가 손으로 재띄우거나 run 을 닫는 수밖에 없다
|
|
180
|
+
(2026-09-10 실측, fontsninja-v3-site dev-10631 implementation-option-selection).
|
|
181
|
+
|
|
182
|
+
라운드가 adversarial 인지 collaborative 인지는 결과 파일에 적혀 있지 않다. 한쪽
|
|
183
|
+
어휘로만 읽으면 반대쪽 라운드의 정상 결과를 결함으로 신고하므로, 어느 한쪽으로
|
|
184
|
+
읽히면 결함이 아니다. 둘 다 실패했을 때만 adversarial 쪽 메시지를 낸다 — 실측된
|
|
185
|
+
결함(설명 누락)은 두 어휘에서 같은 문장을 낸다.
|
|
186
|
+
"""
|
|
187
|
+
adversarial_error: str | None = None
|
|
188
|
+
for adversarial in (True, False):
|
|
189
|
+
try:
|
|
190
|
+
parse_finding_votes(text, adversarial=adversarial)
|
|
191
|
+
except VerdictBlockError as exc:
|
|
192
|
+
if adversarial:
|
|
193
|
+
adversarial_error = str(exc)
|
|
194
|
+
continue
|
|
195
|
+
return None
|
|
196
|
+
return adversarial_error
|
|
197
|
+
|
|
198
|
+
|
|
172
199
|
def _collect_blocks(blocks: dict[str, dict[str, str]], parse) -> dict:
|
|
173
200
|
"""모든 블록을 읽고 결함을 한 번에 보고한다.
|
|
174
201
|
|
|
@@ -42,6 +42,7 @@ def main(argv: list[str]) -> int:
|
|
|
42
42
|
p_init.add_argument("--workspace-root", required=True)
|
|
43
43
|
p_init.add_argument("--project-root", required=True)
|
|
44
44
|
p_init.add_argument("--project-id", required=True)
|
|
45
|
+
p_init.add_argument("--preferred-task-key", default="")
|
|
45
46
|
p_init.add_argument(
|
|
46
47
|
"--host-runtime",
|
|
47
48
|
default="claude-code",
|
|
@@ -81,6 +82,7 @@ def main(argv: list[str]) -> int:
|
|
|
81
82
|
workspace_root=args.workspace_root,
|
|
82
83
|
project_root=args.project_root,
|
|
83
84
|
project_id=args.project_id,
|
|
85
|
+
preferred_task_key=args.preferred_task_key,
|
|
84
86
|
host_runtime=args.host_runtime,
|
|
85
87
|
host_entry_mode=args.entry_mode,
|
|
86
88
|
available_functions=args.available_function,
|
|
@@ -28,6 +28,7 @@ from .ids import (
|
|
|
28
28
|
S_DESIGN_PREP_OVERRIDES,
|
|
29
29
|
S_DONE,
|
|
30
30
|
S_TASK_PICK,
|
|
31
|
+
S_REPORT_LANGUAGE,
|
|
31
32
|
TASK_PICK_NEW_TOKEN,
|
|
32
33
|
_STEP_TO_GROUP,
|
|
33
34
|
)
|
|
@@ -53,6 +54,7 @@ def init_state(
|
|
|
53
54
|
host_runtime: str = "claude-code",
|
|
54
55
|
host_entry_mode: str = "current-session",
|
|
55
56
|
available_functions: Optional[list[str]] = None,
|
|
57
|
+
preferred_task_key: str = "",
|
|
56
58
|
) -> WizardState:
|
|
57
59
|
"""Bootstrap a new wizard state."""
|
|
58
60
|
return WizardState(
|
|
@@ -60,6 +62,7 @@ def init_state(
|
|
|
60
62
|
workspace_root=workspace_root,
|
|
61
63
|
project_root=project_root,
|
|
62
64
|
project_id=project_id,
|
|
65
|
+
preferred_task_key=preferred_task_key,
|
|
63
66
|
host_runtime=host_runtime,
|
|
64
67
|
host_entry_mode=host_entry_mode,
|
|
65
68
|
available_functions=list(available_functions or []),
|
|
@@ -222,6 +225,9 @@ def _sim_answer(prompt: Prompt) -> str:
|
|
|
222
225
|
def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
223
226
|
"""기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
|
|
224
227
|
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
228
|
+
if prompt.step == S_REPORT_LANGUAGE:
|
|
229
|
+
state.answered.append(prompt.step)
|
|
230
|
+
return
|
|
225
231
|
if is_split_picker(prompt):
|
|
226
232
|
# 조각 질문의 step 은 등록된 step 이 아니다 — 잘리지 않은 원본으로 낸다.
|
|
227
233
|
prompt = _next_prompt_screen(state)
|
|
@@ -50,6 +50,7 @@ from .ids import (
|
|
|
50
50
|
S_RELATED_TASKS,
|
|
51
51
|
S_RELATED_TASKS_PICK,
|
|
52
52
|
S_REPORT_WRITER_MODEL,
|
|
53
|
+
S_REPORT_LANGUAGE,
|
|
53
54
|
S_REUSE_PREVIOUS,
|
|
54
55
|
S_REVERIFY_SCOPE_PICK,
|
|
55
56
|
S_REVERIFY_SCOPE_STAGES,
|
|
@@ -167,6 +168,9 @@ from .steps_plan import (
|
|
|
167
168
|
_submit_stage_pick,
|
|
168
169
|
)
|
|
169
170
|
from .steps_options import (
|
|
171
|
+
_report_language_required,
|
|
172
|
+
_build_report_language,
|
|
173
|
+
_submit_report_language,
|
|
170
174
|
_build_clarification,
|
|
171
175
|
_build_clarification_pick,
|
|
172
176
|
_build_directive,
|
|
@@ -278,6 +282,10 @@ def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
|
|
|
278
282
|
# --- step registry ---
|
|
279
283
|
|
|
280
284
|
STEPS: list[Step] = [
|
|
285
|
+
Step(S_REPORT_LANGUAGE,
|
|
286
|
+
applies=_report_language_required,
|
|
287
|
+
build=_build_report_language, submit=_submit_report_language,
|
|
288
|
+
owns=()),
|
|
281
289
|
Step(S_TASK_PICK,
|
|
282
290
|
applies=lambda s: s.is_new_task is None,
|
|
283
291
|
build=_build_task_pick, submit=_submit_task_pick,
|
|
@@ -39,6 +39,7 @@ class WizardState:
|
|
|
39
39
|
workspace_root: str = ""
|
|
40
40
|
project_root: str = ""
|
|
41
41
|
project_id: str = ""
|
|
42
|
+
preferred_task_key: str = ""
|
|
42
43
|
host_runtime: str = "claude-code"
|
|
43
44
|
host_entry_mode: str = "current-session"
|
|
44
45
|
available_functions: list[str] = field(default_factory=list)
|
|
@@ -129,9 +129,12 @@ def _next_phase_cell(raw: Any) -> str:
|
|
|
129
129
|
return f"{cell} ({pointer['status']})"
|
|
130
130
|
|
|
131
131
|
|
|
132
|
-
def _recommended_task_entry(
|
|
132
|
+
def _recommended_task_entry(
|
|
133
|
+
remaining: list[dict], latest_key: str, preferred_key: str = "",
|
|
134
|
+
) -> dict | None:
|
|
133
135
|
"""남은 task 중 이 run 이 권하는 하나.
|
|
134
136
|
|
|
137
|
+
현재 대화에서 전달한 task 를 우선한다. 전달값이 없으면
|
|
135
138
|
포인터가 `ready` 인 task 는 지금 바로 다음 phase 를 시작할 수 있는 task 다
|
|
136
139
|
— 그중 최신(카탈로그는 updatedAt 내림차순)을 권한다. `ready` 가 없으면
|
|
137
140
|
마지막으로 만진 task(`latest-task.json`)를, 그것도 목록에 없으면 최신
|
|
@@ -139,6 +142,11 @@ def _recommended_task_entry(remaining: list[dict], latest_key: str) -> dict | No
|
|
|
139
142
|
산문에서 2번을 권했다 — 추천이 여럿이면 라벨은 아무것도 고르지 않은
|
|
140
143
|
것이다.
|
|
141
144
|
"""
|
|
145
|
+
if preferred_key:
|
|
146
|
+
for entry in remaining:
|
|
147
|
+
if entry.get("taskKey") == preferred_key:
|
|
148
|
+
return entry
|
|
149
|
+
raise WizardError(f"preferred task not found in project catalog: {preferred_key}")
|
|
142
150
|
if not remaining:
|
|
143
151
|
return None
|
|
144
152
|
for entry in remaining:
|
|
@@ -158,9 +166,10 @@ def _build_task_pick(state: WizardState) -> Prompt:
|
|
|
158
166
|
latest = read_latest_task(project_root) or {}
|
|
159
167
|
latest_key = latest.get("taskKey") or ""
|
|
160
168
|
latest_suffix = t["options"].get("_LATEST_SUFFIX", "")
|
|
161
|
-
remaining = [e for e in tasks if (e.get("workStatus") or "") != "done"
|
|
169
|
+
remaining = [e for e in tasks if (e.get("workStatus") or "") != "done"
|
|
170
|
+
or e.get("taskKey") == state.preferred_task_key]
|
|
162
171
|
# 추천은 하나이고 1번이다. 나머지는 최신순 그대로.
|
|
163
|
-
recommended = _recommended_task_entry(remaining, latest_key)
|
|
172
|
+
recommended = _recommended_task_entry(remaining, latest_key, state.preferred_task_key)
|
|
164
173
|
ordered = (
|
|
165
174
|
[recommended, *(e for e in remaining if e is not recommended)]
|
|
166
175
|
if recommended is not None else remaining
|
|
@@ -187,6 +196,7 @@ def _build_task_pick(state: WizardState) -> Prompt:
|
|
|
187
196
|
def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
|
|
188
197
|
value = value.strip()
|
|
189
198
|
if value == TASK_PICK_NEW_TOKEN:
|
|
199
|
+
state.preferred_task_key = ""
|
|
190
200
|
state.is_new_task = True
|
|
191
201
|
state.task_group = ""
|
|
192
202
|
state.task_id = ""
|
|
@@ -207,6 +217,7 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
207
217
|
state.is_new_task = False
|
|
208
218
|
state.task_group = tg
|
|
209
219
|
state.task_id = tid
|
|
220
|
+
state.preferred_task_key = value
|
|
210
221
|
state.existing_brief_path = _existing_task_brief(
|
|
211
222
|
Path(state.project_root), value
|
|
212
223
|
)
|
|
@@ -6,7 +6,8 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Callable, Optional
|
|
7
7
|
|
|
8
8
|
from okstra_ctl.clarification_items import user_response_sidecars
|
|
9
|
-
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
|
|
9
|
+
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
10
|
+
from okstra_ctl.report_language import is_language_tag
|
|
10
11
|
from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
11
12
|
from okstra_project.dirs import project_json_path
|
|
12
13
|
from okstra_project.state import StateError, list_project_tasks
|
|
@@ -23,6 +24,7 @@ from .ids import (
|
|
|
23
24
|
S_PR_TEMPLATE_SCOPE,
|
|
24
25
|
S_RELATED_TASKS,
|
|
25
26
|
S_RELATED_TASKS_PICK,
|
|
27
|
+
S_REPORT_LANGUAGE,
|
|
26
28
|
_LATEST_REPORT_TOKEN,
|
|
27
29
|
_PROJECT_DEFAULT_TOKEN,
|
|
28
30
|
_REUSE_LAST_TOKEN,
|
|
@@ -33,6 +35,40 @@ from .prompts import _opt, _p, _pick_snippet
|
|
|
33
35
|
from .sources import _latest_run_inputs, _require_file, _suggest_latest_final_report
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
def _report_language_required(state: WizardState) -> bool:
|
|
39
|
+
path = project_json_path(Path(state.project_root))
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
data = load_owned_object(path, artifact="project config")
|
|
44
|
+
except JsonBoundaryError as exc:
|
|
45
|
+
raise WizardError(str(exc)) from exc
|
|
46
|
+
return data.get("reportLanguage") in (None, "")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_report_language(state: WizardState) -> Prompt:
|
|
50
|
+
text = _p(state.workspace_root, "report_language")
|
|
51
|
+
return Prompt(
|
|
52
|
+
step=S_REPORT_LANGUAGE, kind="text",
|
|
53
|
+
label=text["label"], echo_template=text["echo_template"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _submit_report_language(state: WizardState, value: str) -> Optional[str]:
|
|
58
|
+
language = value.strip()
|
|
59
|
+
if not is_language_tag(language):
|
|
60
|
+
raise WizardError("보고서 언어 태그를 입력하세요: en, ko, fr, pt-BR")
|
|
61
|
+
path = project_json_path(Path(state.project_root))
|
|
62
|
+
try:
|
|
63
|
+
data = load_owned_object(path, artifact="project config")
|
|
64
|
+
if data.get("reportLanguage") in (None, ""):
|
|
65
|
+
data["reportLanguage"] = language
|
|
66
|
+
write_owned_object_atomic(path, data, artifact="project config")
|
|
67
|
+
except (OSError, JsonBoundaryError) as exc:
|
|
68
|
+
raise WizardError(str(exc)) from exc
|
|
69
|
+
return f"report-language: {data['reportLanguage']}"
|
|
70
|
+
|
|
71
|
+
|
|
36
72
|
def _clarification_sidecar_note(state: WizardState, suggestion: str) -> str:
|
|
37
73
|
"""추천 final-report 와 함께 carry-in 될 ``user-responses/`` 사이드카 현황 문구.
|
|
38
74
|
|
|
@@ -31,12 +31,16 @@ _CLI_EPILOG = r"""Usage:
|
|
|
31
31
|
--seq <nnn> [--worker <id>]
|
|
32
32
|
|
|
33
33
|
--run-dir the run directory; worker-results/ and prompts/ hang off it
|
|
34
|
+
--seq this run's seq; a bare number is zero-padded to three digits
|
|
34
35
|
--worker check only this worker (default: every worker in the run)
|
|
35
36
|
|
|
36
37
|
Emits one JSON object — `{ok, inspected, inspectedFiles[], failures[],
|
|
37
38
|
blocking[], advisory[], runImpact}` — and exits 2 when failures[] is
|
|
38
39
|
non-empty, 0 otherwise. `failures` is `blocking` followed by `advisory`.
|
|
39
40
|
|
|
41
|
+
A selector that matches no result file also exits 2, carrying `selectorError`
|
|
42
|
+
instead of failures: nothing was checked, which is not a pass.
|
|
43
|
+
|
|
40
44
|
Runs the Phase 7 audit-sidecar rules now, while the worker session is still
|
|
41
45
|
alive: that every result file carries no `## 0. Reading Confirmation` heading,
|
|
42
46
|
that its audit sidecar exists (blocking), and that every backticked `path:line`
|
|
@@ -49,6 +53,15 @@ row alone.
|
|
|
49
53
|
"""
|
|
50
54
|
|
|
51
55
|
|
|
56
|
+
# 결과 파일명은 seq 를 3자리로 적고, 선택은 그 문자열과의 동등 비교다
|
|
57
|
+
# (`worker_audit_ledger.worker_result_files`). `--seq 1` 은 `001` 과 같지 않아
|
|
58
|
+
# 전건이 걸러지고, 검사 0건은 실패 0건이므로 통과로 읽혔다 — 2026-09-10
|
|
59
|
+
# dev-10642-15 final-verification 001 실측.
|
|
60
|
+
def _normalized_seq(value: str) -> str:
|
|
61
|
+
text = value.strip()
|
|
62
|
+
return text.zfill(3) if text.isdigit() else text
|
|
63
|
+
|
|
64
|
+
|
|
52
65
|
def _parser() -> argparse.ArgumentParser:
|
|
53
66
|
parser = argparse.ArgumentParser(
|
|
54
67
|
epilog=_CLI_EPILOG,
|
|
@@ -59,8 +72,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
59
72
|
parser.add_argument("--run-dir", type=Path, required=True,
|
|
60
73
|
help="runs/<task-type>/ for this run")
|
|
61
74
|
parser.add_argument("--task-type", required=True)
|
|
62
|
-
parser.add_argument("--seq", required=True,
|
|
63
|
-
help="this run's
|
|
75
|
+
parser.add_argument("--seq", required=True, type=_normalized_seq,
|
|
76
|
+
help="this run's seq; a bare number is zero-padded to "
|
|
77
|
+
"three digits")
|
|
64
78
|
parser.add_argument("--worker", default=None,
|
|
65
79
|
help="check only this worker id, with or without the "
|
|
66
80
|
"`-worker` suffix (default: every worker)")
|
|
@@ -83,20 +97,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
83
97
|
args.run_dir, args.task_type, args.seq, args.worker
|
|
84
98
|
)
|
|
85
99
|
]
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
# 검사한 파일이 없다는 것과 위반이 없다는 것은 다른 사실이고, `ok` 는 둘을
|
|
101
|
+
# 구분하지 못한다. 선택자가 아무것도 고르지 못했으면 판정 자체가 없었으므로
|
|
102
|
+
# 통과로 보고하지 않는다.
|
|
103
|
+
selector_error = "" if inspected else (
|
|
104
|
+
f"no worker-results file matched task-type={args.task_type} "
|
|
105
|
+
f"seq={args.seq}"
|
|
106
|
+
+ (f" worker={args.worker}" if args.worker else "")
|
|
107
|
+
+ f" under {args.run_dir}"
|
|
108
|
+
)
|
|
109
|
+
payload = {
|
|
110
|
+
"ok": bool(not failures and inspected),
|
|
111
|
+
"inspected": len(inspected),
|
|
112
|
+
"inspectedFiles": inspected,
|
|
113
|
+
"failures": failures,
|
|
114
|
+
"blocking": blocking,
|
|
115
|
+
"advisory": advisory,
|
|
116
|
+
"runImpact": RUN_IMPACT,
|
|
117
|
+
}
|
|
118
|
+
if selector_error:
|
|
119
|
+
payload["selectorError"] = selector_error
|
|
120
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
121
|
+
return 2 if failures or selector_error else 0
|
|
100
122
|
|
|
101
123
|
|
|
102
124
|
if __name__ == "__main__":
|
|
@@ -32,10 +32,11 @@ import argparse
|
|
|
32
32
|
import json
|
|
33
33
|
import sys
|
|
34
34
|
import time
|
|
35
|
-
from collections.abc import Callable
|
|
35
|
+
from collections.abc import Callable, Mapping
|
|
36
36
|
from dataclasses import dataclass
|
|
37
37
|
from datetime import datetime, timezone
|
|
38
38
|
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
39
40
|
|
|
40
41
|
from .wrapper_status import log_path_for_prompt
|
|
41
42
|
|
|
@@ -353,7 +354,47 @@ _ARTIFACT_FIELD_BY_MODE = {
|
|
|
353
354
|
}
|
|
354
355
|
|
|
355
356
|
|
|
357
|
+
def _dispatch_worker_id(record: Mapping[str, Any]) -> str:
|
|
358
|
+
"""이 디스패치 행이 말하는 워커 id.
|
|
359
|
+
|
|
360
|
+
execution-identity v2 행에는 `workerId` 가 실리지 않는다
|
|
361
|
+
(`dispatch_core._dispatch_record`). 그 경우 `assignmentRef` 의 마지막 마디가
|
|
362
|
+
워커 id 이므로 — `initial/codex-verifier`, `critic/acceptance` — 거기서 읽는다.
|
|
363
|
+
"""
|
|
364
|
+
worker_id = record.get("workerId")
|
|
365
|
+
if isinstance(worker_id, str) and worker_id.strip():
|
|
366
|
+
return worker_id.strip()
|
|
367
|
+
assignment_ref = record.get("assignmentRef")
|
|
368
|
+
if isinstance(assignment_ref, str) and assignment_ref.strip():
|
|
369
|
+
return assignment_ref.strip().rsplit("/", 1)[-1]
|
|
370
|
+
return ""
|
|
371
|
+
|
|
372
|
+
|
|
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 {}
|
|
382
|
+
matches = [
|
|
383
|
+
row for row in records
|
|
384
|
+
if isinstance(row, Mapping) and _dispatch_worker_id(row) == worker_id
|
|
385
|
+
]
|
|
386
|
+
return dict(matches[-1]) if matches else {}
|
|
387
|
+
|
|
388
|
+
|
|
356
389
|
def _worker_row(team_state_path: Path, worker_id: str) -> dict:
|
|
390
|
+
"""이 워커의 프로브 입력 — 로스터 행에 디스패치 행을 덧댄 것.
|
|
391
|
+
|
|
392
|
+
`livenessMode` 는 디스패치 행에만 실린다(`dispatch_core._dispatch_record`);
|
|
393
|
+
로스터 행은 그 키를 갖지 않는다. 로스터만 읽으면 cmux 백엔드의 모든 run 에서
|
|
394
|
+
프로브가 전면 거부되고, 리드는 계약이 금지한 자체 폴링으로 밀려난다.
|
|
395
|
+
보충은 로스터에 없거나 빈 키에만 적용한다 — `startedAt` 처럼 로스터가 정본인
|
|
396
|
+
값을 디스패치 행이 덮어쓰면 grace 앵커가 이번 시도에서 어긋난다.
|
|
397
|
+
"""
|
|
357
398
|
state = load_json_object(team_state_path, "team-state")
|
|
358
399
|
workers = state.get("workers")
|
|
359
400
|
if not isinstance(workers, list):
|
|
@@ -367,7 +408,12 @@ def _worker_row(team_state_path: Path, worker_id: str) -> dict:
|
|
|
367
408
|
)
|
|
368
409
|
if worker is None:
|
|
369
410
|
raise DispatchError(f"team-state has no workerId={worker_id}: {team_state_path}")
|
|
370
|
-
|
|
411
|
+
merged = dict(worker)
|
|
412
|
+
for key, value in _dispatch_fallback(state, worker_id).items():
|
|
413
|
+
current = merged.get(key)
|
|
414
|
+
if key not in merged or (isinstance(current, str) and not current.strip()):
|
|
415
|
+
merged[key] = value
|
|
416
|
+
return merged
|
|
371
417
|
|
|
372
418
|
|
|
373
419
|
def probe_target(team_state_value: str, worker_id: str) -> ProbeTarget:
|
|
@@ -88,7 +88,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
88
88
|
"implementation-option-selection": {
|
|
89
89
|
"allowed": (
|
|
90
90
|
" - candidate-comparison evidence for up to three options per worker\n"
|
|
91
|
-
" - preselected-validation that re-
|
|
91
|
+
" - preselected-validation of exactly ONE candidate: every analyser returns its own feasibility verdict on that one direction, `candidateAudit` stays empty, and no analyser generates an alternative. It is not a re-evaluation of the merged set — `okstra_ctl.implementation_options._validate_mode` rejects a preselected-validation report carrying more than one option or a non-empty audit\n"
|
|
92
92
|
" - a ranked display of at most three options with requirement mappings\n"
|
|
93
93
|
" - an audit record for every rejected candidate\n"
|
|
94
94
|
" - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
|
|
@@ -149,7 +149,13 @@ okstra wizard new-state-file
|
|
|
149
149
|
|
|
150
150
|
This prints one absolute path on stdout (e.g. `/var/folders/.../okstra-wizard.AbCd.json`). Read that path from the tool output and **paste it literally** into every subsequent `--state-file` argument.
|
|
151
151
|
|
|
152
|
-
Then initialize the wizard with the literal `projectRoot` / `projectId` you parsed from Step 1 and the literal state-file path from above
|
|
152
|
+
Then initialize the wizard with the literal `projectRoot` / `projectId` you parsed from Step 1 and the literal state-file path from above.
|
|
153
|
+
|
|
154
|
+
For task continuity, pass `--preferred-task-key <project-id:task-group:task-id>` on every initialization when this conversation has a known task. Use the task explicitly requested in the current message first; otherwise use the last task the user selected in this conversation's wizard. Resolve abbreviated task IDs against the project catalog before passing the full key. If the ID is ambiguous, ask which matching task the user means. Never infer this conversation's selection from project-wide `latest-task.json`, catalog recency, or another session. With neither an explicit task nor a prior selection in this conversation, omit the flag. The runtime puts the preferred task first, including a completed task when explicitly carried in, and rejects a key absent from the catalog instead of substituting another task; ordering is covered by `tests/domain/wizard/test_task_selection.py`. The task picker still lets the user change tasks.
|
|
155
|
+
|
|
156
|
+
After a successful task selection, retain the returned full task key in this conversation as its task context, including in continuation summaries, and carry it into the next initialization. A different selection replaces it. Selecting a brand-new task clears the previous selection; retain the new full key once its project, group, and task ID are resolved. This conversation carry-forward is a lead instruction, not automatic host-session discovery by the runtime.
|
|
157
|
+
|
|
158
|
+
Initialize with the optional preferred key resolved above:
|
|
153
159
|
|
|
154
160
|
```bash
|
|
155
161
|
okstra wizard init \
|
|
@@ -191,6 +197,7 @@ Repeat until `next.kind == "done"` (or `"aborted"` — terminal cancel, see "How
|
|
|
191
197
|
|
|
192
198
|
That is the entire interactive flow. The wizard handles:
|
|
193
199
|
|
|
200
|
+
- project report language before task selection: when `.okstra/project.json` has no `reportLanguage` value, ask for a language tag and save the answer to that file. An existing value skips this question. Prompt rendering and progress estimation do not save a language,
|
|
194
201
|
- new-vs-existing task split (remaining work — `workStatus != done` — top-3 newest recommendations + Enter directly), task-group / task-id slug validation (task-group offers the top-3 newest candidates combining recent task use + recent `.okstra/briefs/<group>/` creation activity + Enter directly; task-id offers the top-3 recent candidates from the same group + Enter directly),
|
|
195
202
|
- task-type pick (3 options + Enter directly; Enter directly is validated against the full task-type whitelist in a follow-up `text` step). **For a brand-new task the brief is asked first** and the three options are entry phases only (`requirements-discovery` / `improvement-discovery` / `project-analysis` / `feature-analysis` / `change-impact-analysis` / `error-analysis`) — a new task-key has no approved plan, no implementation and no commits, so `implementation`, `final-verification` and `release-handoff` cannot be entered there; the recommended slot comes from the selected brief's `Recommended next phase:` line and falls back to `requirements-discovery` when the brief carries none. For an existing task-key what fills those three depends on `workflow.nextRecommendedPhase` — an object `{phase, status, rationale}`, not a phase-name string. `status: ready` gives the classic trio: its `phase` marked recommended, re-run the current phase, the lifecycle's next step. Any other status contributes nothing at all, since both the recommended slot and the next-step slot derive from that phase — re-run the current phase becomes the first option and the remaining slots fill unlabelled from recently used task-types and then the whitelist. Never recover a phase name from a non-`ready` pointer and propose it yourself: prepare deliberately lowers the pointer to `pending` while its run is unfinished, and the missing recommendation is that signal,
|
|
196
203
|
- brief path — **for a new task it is asked right after task-group, before the task-type** (it is the only input that says what the task is, and the task-type recommendation reads it); for an existing task it is **asked only for entry task-types (requirements-discovery / error-analysis / improvement-discovery / project-analysis / feature-analysis / change-impact-analysis)** (same-group `.okstra/briefs/<task-group>/**/*.md` candidates first, sorted by the newer of file-created/modified time and latest task-catalog use; direct input last; `Keep / Change` for existing entry tasks). `project-analysis`, `feature-analysis`, and `change-impact-analysis` are brief entry task types. On an existing task a downstream lifecycle task-type auto carries in the manifest's brief, and when no registered brief exists a `brief_carry` 3-option prompt appears (recommend switching to entry / Enter directly / Abort). `release-handoff` has no brief step of its own — a new-task run answers the brief before the type is known, and the brief is dropped at render time, so prepare always generates the input document that cites the verification report,
|
|
@@ -77,7 +77,6 @@
|
|
|
77
77
|
</table>
|
|
78
78
|
</section>
|
|
79
79
|
{% endif %}
|
|
80
|
-
{{ clarification_responses(clarificationItems) }}
|
|
81
80
|
{% if evidenceIndex %}
|
|
82
81
|
<section data-report-section="evidence-ledger" data-reader-kind="audit">
|
|
83
82
|
<h2>{{ t('base.evidence-ledger') }}</h2>
|
|
@@ -139,6 +138,7 @@
|
|
|
139
138
|
</table>
|
|
140
139
|
</section>
|
|
141
140
|
{% endif %}
|
|
141
|
+
{{ clarification_responses(clarificationItems) }}
|
|
142
142
|
</main>
|
|
143
143
|
{% if approval is not defined or not (approval and approval.reentry_command) %}<footer class="human-report-footer">
|
|
144
144
|
<button type="button" data-action="export-user-response">{{ t('base.export-my-answers') }}</button>
|
|
@@ -147,8 +147,9 @@
|
|
|
147
147
|
<p class="user-response-hint">{{ t('base.export-downloads') }} <code>user-response-{{ runMeta.task_type }}-{{ runMeta.seq }}.md</code>{{ t('base.drop-that-file-into') }} <code>{{ userResponseDir }}</code> {{ t('base.and-the-next-run-picks-your-answers-up-on-it') }}</p>
|
|
148
148
|
<pre id="user-response-output" aria-live="polite"></pre>
|
|
149
149
|
</footer>{% endif %}
|
|
150
|
+
<div id="bottom"></div>
|
|
150
151
|
<div class="back-to-top-wrap">
|
|
151
|
-
<nav class="back-to-top-index" id="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items
|
|
152
|
+
<nav class="back-to-top-index" id="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--><a href="#bottom">{{ t('base.go-to-bottom') }}</a></nav>
|
|
152
153
|
<a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
|
|
153
154
|
</div>
|
|
154
155
|
<script id="run-meta" type="application/json">{{ {
|
|
@@ -115,6 +115,7 @@ from okstra_ctl.final_report_paths import ( # noqa: E402
|
|
|
115
115
|
)
|
|
116
116
|
from okstra_token_usage.report import _match_worker_index # noqa: E402
|
|
117
117
|
from okstra_ctl.implementation_options import ( # noqa: E402
|
|
118
|
+
validate_blocked_answer_channel,
|
|
118
119
|
validate_implementation_option_selection,
|
|
119
120
|
)
|
|
120
121
|
from okstra_ctl.implementation_direction import ( # noqa: E402
|
|
@@ -149,6 +150,7 @@ from okstra_ctl.agent.invocation import ( # noqa: E402
|
|
|
149
150
|
AgentInvocationError,
|
|
150
151
|
agent_model_assignment_from_payload,
|
|
151
152
|
invocation_execution_identity_from_manifest,
|
|
153
|
+
invocation_input_digest,
|
|
152
154
|
verify_agent_invocation,
|
|
153
155
|
)
|
|
154
156
|
from okstra_ctl.execution_identity import ExecutionManifestError # noqa: E402
|
|
@@ -253,6 +255,39 @@ def _result_link_attempt_status_failure(
|
|
|
253
255
|
)
|
|
254
256
|
|
|
255
257
|
|
|
258
|
+
def _dispatch_input_digest(
|
|
259
|
+
project_root: Path, row: Mapping[str, Any]
|
|
260
|
+
) -> tuple[str | None, str]:
|
|
261
|
+
"""이 디스패치의 예약 입력 해시, 예약이 계산한 것과 같은 방법으로.
|
|
262
|
+
|
|
263
|
+
`inputDigest` 와 `promptDigest` 는 서로 다른 대상이다. 전달 계약
|
|
264
|
+
`execution-identity-v1` 부터 예약은 **논리 작업**을 해시한다 — 시도별 전달값과
|
|
265
|
+
prompt history 경로를 뺀 본문(`agent_prompt_task_bytes`) — 반면 `promptDigest`
|
|
266
|
+
는 프롬프트 파일 전체 바이트다. 둘을 동등 비교하면 그 계약을 쓰는 run 은
|
|
267
|
+
통과할 수 있는 값이 하나도 없다(2026-09-08 f56ec08 이후 전부, 2026-09-10
|
|
268
|
+
dev-10642-15 final-verification 001 에서 7/7 디스패치가 이 규칙에 걸렸다).
|
|
269
|
+
|
|
270
|
+
그래서 재구현하지 않고 예약이 쓰는 함수를 그대로 부른다. 메타데이터 경로가
|
|
271
|
+
없는 구형 행만 `promptDigest` 로 돌아간다 — 그 계약에서는 두 값이 같다.
|
|
272
|
+
"""
|
|
273
|
+
metadata_value = row.get("promptMetadataPath")
|
|
274
|
+
if not isinstance(metadata_value, str) or not metadata_value.strip():
|
|
275
|
+
return row.get("promptDigest"), ""
|
|
276
|
+
try:
|
|
277
|
+
metadata = json.loads(
|
|
278
|
+
_resolve_prompt_record_path(project_root, metadata_value)
|
|
279
|
+
.read_text(encoding="utf-8")
|
|
280
|
+
)
|
|
281
|
+
except (OSError, json.JSONDecodeError):
|
|
282
|
+
return None, "prompt metadata is missing or invalid"
|
|
283
|
+
if not isinstance(metadata, Mapping):
|
|
284
|
+
return None, "prompt metadata is not an object"
|
|
285
|
+
try:
|
|
286
|
+
return invocation_input_digest(metadata, project_root), ""
|
|
287
|
+
except (AgentInvocationError, KeyError, OSError, ValueError) as exc:
|
|
288
|
+
return None, f"input digest cannot be recomputed: {exc}"
|
|
289
|
+
|
|
290
|
+
|
|
256
291
|
def _validate_agent_dispatch_contract(
|
|
257
292
|
*,
|
|
258
293
|
project_root: Path,
|
|
@@ -399,12 +434,16 @@ def _validate_agent_dispatch_contract(
|
|
|
399
434
|
)
|
|
400
435
|
continue
|
|
401
436
|
invocation = canonical_invocations.get(row.get("invocationRef"))
|
|
437
|
+
input_digest, digest_error = _dispatch_input_digest(project_root, row)
|
|
438
|
+
if digest_error:
|
|
439
|
+
failures.append(f"agent dispatch {dispatch_id}: {digest_error}")
|
|
440
|
+
continue
|
|
402
441
|
if not isinstance(invocation, Mapping) or any((
|
|
403
442
|
invocation.get("participantRef") != row.get("participantRef"),
|
|
404
443
|
invocation.get("roleExecutionRef") != row.get("roleExecutionRef"),
|
|
405
444
|
invocation.get("dutyId") != row.get("dutyId"),
|
|
406
445
|
invocation.get("dispatchKind") != dispatch_kind,
|
|
407
|
-
invocation.get("inputDigest") !=
|
|
446
|
+
invocation.get("inputDigest") != input_digest,
|
|
408
447
|
)):
|
|
409
448
|
failures.append(
|
|
410
449
|
f"agent dispatch {dispatch_id}: does not match canonical invocation"
|
|
@@ -1531,7 +1570,12 @@ def validate_team_state(
|
|
|
1531
1570
|
by_role[role] = worker
|
|
1532
1571
|
|
|
1533
1572
|
expected_workers: dict[str, dict] = {}
|
|
1534
|
-
|
|
1573
|
+
# 선택은 배정 전의 선택이다. 배정된 비평 역할도 결과 또는 생략 사유가
|
|
1574
|
+
# 있어야 하므로 필수 역할과 같은 상태 검사를 거친다.
|
|
1575
|
+
for worker in [
|
|
1576
|
+
*contract["required_worker_roles"],
|
|
1577
|
+
*contract.get("optional_worker_roles", []),
|
|
1578
|
+
]:
|
|
1535
1579
|
if not isinstance(worker, dict):
|
|
1536
1580
|
failures.append("requiredWorkerRoles contains a non-object entry")
|
|
1537
1581
|
continue
|
|
@@ -1607,15 +1651,7 @@ def validate_team_state(
|
|
|
1607
1651
|
if status != "completed" and not reason:
|
|
1608
1652
|
failures.append(f"{role} with status `{status}` must include a reason")
|
|
1609
1653
|
|
|
1610
|
-
|
|
1611
|
-
# absent: the run states which ones it can dispatch (today, the critics),
|
|
1612
|
-
# and running one is not a contract violation.
|
|
1613
|
-
optional_roles = {
|
|
1614
|
-
str(worker.get("role", "")).strip()
|
|
1615
|
-
for worker in contract.get("optional_worker_roles", [])
|
|
1616
|
-
if isinstance(worker, dict) and str(worker.get("role", "")).strip()
|
|
1617
|
-
}
|
|
1618
|
-
unexpected_roles = set(by_role) - set(expected_workers) - optional_roles
|
|
1654
|
+
unexpected_roles = set(by_role) - set(expected_workers)
|
|
1619
1655
|
for role in sorted(unexpected_roles):
|
|
1620
1656
|
failures.append(f"unexpected worker role detected: {role}")
|
|
1621
1657
|
|
|
@@ -3273,6 +3309,10 @@ def validate_final_report_data(
|
|
|
3273
3309
|
participating_analysers,
|
|
3274
3310
|
)
|
|
3275
3311
|
)
|
|
3312
|
+
failures.extend(
|
|
3313
|
+
f"implementation-option-selection: {error}"
|
|
3314
|
+
for error in validate_blocked_answer_channel(data)
|
|
3315
|
+
)
|
|
3276
3316
|
elif task_type == "implementation":
|
|
3277
3317
|
_validate_stage_carry_sidecar_exists(data, report_path, failures)
|
|
3278
3318
|
_validate_lead_authored_report(data, report_path, failures)
|