okstra 0.197.0 → 0.198.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/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.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/conformance.py +26 -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/group_context.py +96 -3
- 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/report_assembly.py +10 -0
- package/runtime/python/okstra_ctl/run.py +23 -0
- package/runtime/python/okstra_ctl/set_work_status.py +30 -1
- package/runtime/python/okstra_ctl/verdict_blocks.py +27 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +4 -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/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-brief-gen/SKILL.md +24 -7
- package/runtime/skills/okstra-inspect/facets/recap.md +17 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -0
- package/runtime/templates/reports/group-context.template.md +1 -1
- package/runtime/validators/validate-run.py +77 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""전원 투표만 모자란 구현 후보와, 그 표를 받아야 할 분석자를 낸다.
|
|
2
|
+
|
|
3
|
+
`implementation-option-selection` 의 1라운드는 설계자들이 병렬로 돌아 서로의
|
|
4
|
+
후보를 보지 못한다. 그래서 자기가 낸 후보에만 실현 가능성 표를 남기고, 병합된
|
|
5
|
+
집합에는 분석자마다 다른 구멍이 생긴다. 유효성 규칙은 전원 투표를 요구하므로
|
|
6
|
+
(`implementation_options._validate_option_feasibility`) 그런 후보는 순위표에
|
|
7
|
+
오르지 못하고, 남는 후보가 하나도 없으면 run 이 `routing: blocked` 로 끝난다 —
|
|
8
|
+
실측(2026-09-10, dev-10629-4): 설계자 3명 로스터에서 IO-001·IO-002·IO-003 이
|
|
9
|
+
각각 `feasible` 2표를 받고도 빠진 분석자가 하나씩 달라 전부 탈락했다.
|
|
10
|
+
|
|
11
|
+
거절은 그 상태를 알려 줄 뿐 메우지 못한다. 이 명령이 앞으로 가는 길이다:
|
|
12
|
+
표만 모자란 후보를 세고, 어느 분석자에게 어떤 후보를 부쳐야 하는지 말한다.
|
|
13
|
+
재검증(reverify) 라운드는 주장을 반박하는 라운드이지 후보에 표를 남기는
|
|
14
|
+
라운드가 아니므로, 그 구멍을 메우는 디스패치는 리드가 이 목록을 보고 연다.
|
|
15
|
+
|
|
16
|
+
읽는 자리는 두 가지다. 조립 전이면 작성자 서사(`--narrative`), 이미 발행된
|
|
17
|
+
run 이면 리포트 레코드(`--report`). 로스터는 task-manifest 의
|
|
18
|
+
`recommendedWorkers` 에서 `report-writer` 를 뺀 것이고, 그것이 검증기가
|
|
19
|
+
`participating analysers` 로 쓰는 값과 같은 정의다(`validators/validate-run.py`).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Mapping
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from okstra_ctl.final_report_schema import load_schema_version
|
|
31
|
+
from okstra_ctl.implementation_options import VoteGap, vote_gaps
|
|
32
|
+
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
|
|
33
|
+
from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION
|
|
34
|
+
from okstra_ctl.report_narrative import parse_narrative_structure
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OptionVotesError(ValueError):
|
|
38
|
+
"""투표 구멍을 셀 입력이 없거나 읽히지 않는다."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _record_from_report(path: Path) -> dict[str, Any]:
|
|
42
|
+
try:
|
|
43
|
+
return load_owned_object(path, artifact="final-report data.json")
|
|
44
|
+
except (JsonBoundaryError, OSError) as exc:
|
|
45
|
+
raise OptionVotesError(f"report record is unreadable: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _record_from_narrative(path: Path) -> dict[str, Any]:
|
|
49
|
+
"""작성자 서사를 레코드 모양으로 읽는다.
|
|
50
|
+
|
|
51
|
+
값 결함은 무시한다 — 교정 원장이 고칠 자리이고, 표 구멍을 세는 데에는
|
|
52
|
+
후보 id 와 `feasibilityVotes` 만 있으면 된다. 여기서 서사 전체를 거절하면
|
|
53
|
+
아직 교정 중인 run 은 이 명령을 쓸 수 없다.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
markdown = path.read_text(encoding="utf-8")
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise OptionVotesError(f"narrative is unreadable: {exc}") from exc
|
|
59
|
+
schema = load_schema_version(CURRENT_REPORT_SCHEMA_VERSION)
|
|
60
|
+
record, _defects = parse_narrative_structure(markdown, schema)
|
|
61
|
+
return record
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def participating_analysers(manifest_path: Path) -> tuple[str, ...]:
|
|
65
|
+
try:
|
|
66
|
+
manifest = load_owned_object(manifest_path, artifact="task-manifest")
|
|
67
|
+
except (JsonBoundaryError, OSError) as exc:
|
|
68
|
+
raise OptionVotesError(f"task manifest is unreadable: {exc}") from exc
|
|
69
|
+
roster = manifest.get("recommendedWorkers")
|
|
70
|
+
if not isinstance(roster, list):
|
|
71
|
+
raise OptionVotesError("task manifest has no recommendedWorkers roster")
|
|
72
|
+
return tuple(
|
|
73
|
+
str(worker) for worker in roster if str(worker) != "report-writer"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _selection(record: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
78
|
+
selection = record.get("implementationOptionSelection")
|
|
79
|
+
if not isinstance(selection, Mapping):
|
|
80
|
+
raise OptionVotesError(
|
|
81
|
+
"the source has no implementationOptionSelection block — "
|
|
82
|
+
"this command reads an implementation-option-selection run"
|
|
83
|
+
)
|
|
84
|
+
return selection
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dispatch_lines(gaps: list[VoteGap]) -> list[str]:
|
|
88
|
+
"""분석자별로 부칠 후보 목록. 디스패치 단위가 분석자이기 때문이다."""
|
|
89
|
+
by_analyser: dict[str, list[str]] = {}
|
|
90
|
+
for gap in gaps:
|
|
91
|
+
for analyser in gap.missing:
|
|
92
|
+
by_analyser.setdefault(analyser, []).append(gap.option_id)
|
|
93
|
+
return [
|
|
94
|
+
f" {analyser}: {', '.join(options)}"
|
|
95
|
+
for analyser, options in sorted(by_analyser.items())
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _render(gaps: list[VoteGap]) -> str:
|
|
100
|
+
if not gaps:
|
|
101
|
+
return (
|
|
102
|
+
"No candidate is short of votes alone. A blocked run here is "
|
|
103
|
+
"blocked by something a vote cannot settle — safety blockers, "
|
|
104
|
+
"unresolved feasibility facts, or too few feasible verdicts."
|
|
105
|
+
)
|
|
106
|
+
lines = [
|
|
107
|
+
f"{len(gaps)} candidate(s) need only the missing feasibility votes:",
|
|
108
|
+
"",
|
|
109
|
+
]
|
|
110
|
+
lines += [
|
|
111
|
+
f" {gap.option_id}: {gap.feasible_votes} feasible so far, "
|
|
112
|
+
f"missing {', '.join(gap.missing)}"
|
|
113
|
+
for gap in gaps
|
|
114
|
+
]
|
|
115
|
+
lines += ["", "Dispatch one vote-completion assignment per analyser:"]
|
|
116
|
+
lines += _dispatch_lines(gaps)
|
|
117
|
+
lines += [
|
|
118
|
+
"",
|
|
119
|
+
"Each assignment asks that analyser for its own feasibility verdict, "
|
|
120
|
+
"rationale, and counterevidence on the named candidate — nothing else. "
|
|
121
|
+
"It generates no candidate, so the run stays in `candidate-comparison` "
|
|
122
|
+
"mode; `preselected-validation` is a whole-run mode that would collapse "
|
|
123
|
+
"the comparison to one direction.",
|
|
124
|
+
]
|
|
125
|
+
return "\n".join(lines)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _gaps(args: argparse.Namespace) -> int:
|
|
129
|
+
if bool(args.report) == bool(args.narrative):
|
|
130
|
+
raise OptionVotesError("pass exactly one of --report or --narrative")
|
|
131
|
+
record = (
|
|
132
|
+
_record_from_report(args.report)
|
|
133
|
+
if args.report
|
|
134
|
+
else _record_from_narrative(args.narrative)
|
|
135
|
+
)
|
|
136
|
+
gaps = vote_gaps(
|
|
137
|
+
_selection(record), participating_analysers(args.task_manifest)
|
|
138
|
+
)
|
|
139
|
+
if args.json:
|
|
140
|
+
print(json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"gaps": [
|
|
143
|
+
{
|
|
144
|
+
"optionId": gap.option_id,
|
|
145
|
+
"missing": list(gap.missing),
|
|
146
|
+
"feasibleVotes": gap.feasible_votes,
|
|
147
|
+
}
|
|
148
|
+
for gap in gaps
|
|
149
|
+
]
|
|
150
|
+
},
|
|
151
|
+
ensure_ascii=False,
|
|
152
|
+
indent=2,
|
|
153
|
+
))
|
|
154
|
+
else:
|
|
155
|
+
print(_render(gaps))
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
_CLI_DESCRIPTION = (
|
|
160
|
+
"Report the implementation candidates that only lack feasibility votes, "
|
|
161
|
+
"and which analyser owes each one."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parser() -> argparse.ArgumentParser:
|
|
166
|
+
parser = argparse.ArgumentParser(
|
|
167
|
+
description=_CLI_DESCRIPTION, prog="okstra option-votes"
|
|
168
|
+
)
|
|
169
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
170
|
+
gaps_parser = subparsers.add_parser("gaps")
|
|
171
|
+
gaps_parser.add_argument(
|
|
172
|
+
"--task-manifest", type=Path, required=True,
|
|
173
|
+
help="the task's task-manifest.json — its roster names the analysers")
|
|
174
|
+
gaps_parser.add_argument(
|
|
175
|
+
"--report", type=Path,
|
|
176
|
+
help="a published final-report `.data.json`")
|
|
177
|
+
gaps_parser.add_argument(
|
|
178
|
+
"--narrative", type=Path,
|
|
179
|
+
help="the report writer's narrative markdown, before assembly")
|
|
180
|
+
gaps_parser.add_argument("--json", action="store_true")
|
|
181
|
+
return parser
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def main(argv: list[str] | None = None) -> int:
|
|
185
|
+
args = _parser().parse_args(argv)
|
|
186
|
+
try:
|
|
187
|
+
return _gaps(args)
|
|
188
|
+
except OptionVotesError as exc:
|
|
189
|
+
print(f"okstra option-votes: {exc}", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -24,6 +24,7 @@ from .report_synthesis_packet import (
|
|
|
24
24
|
)
|
|
25
25
|
from .conformance import strip_stage_declaration_label
|
|
26
26
|
from .design_prep import DesignPrepError, materialize_design_prep_requests
|
|
27
|
+
from .implementation_options import validate_blocked_answer_channel
|
|
27
28
|
from .implementation_direction import (
|
|
28
29
|
load_selected_direction_snapshot,
|
|
29
30
|
validate_selected_direction_plan,
|
|
@@ -453,6 +454,15 @@ def _compose(
|
|
|
453
454
|
data["tokenUsage"] = usage
|
|
454
455
|
data["crossVerification"] = convergence_data["crossVerification"]
|
|
455
456
|
data["clarificationItems"] = _clarifications(ledger, activities, inputs["approval-decisions"].path)
|
|
457
|
+
# 차단된 option-selection 은 발행 전에 답변 채널을 갖춰야 한다. 발행 뒤에는
|
|
458
|
+
# 이 원장을 다시 읽는 경로가 없어 사용자에게 질문이 0건으로 보인다.
|
|
459
|
+
for reason in validate_blocked_answer_channel(data):
|
|
460
|
+
_fail(
|
|
461
|
+
"lead",
|
|
462
|
+
inputs["approval-decisions"].path,
|
|
463
|
+
"activeClarifications",
|
|
464
|
+
reason,
|
|
465
|
+
)
|
|
456
466
|
carry_in = _clarification_carry_in(project_root, manifest, manifest_path)
|
|
457
467
|
if carry_in is not None:
|
|
458
468
|
data["clarificationCarryIn"] = carry_in
|
|
@@ -3631,6 +3631,23 @@ def _stage_or_clear(path: Path, body: str) -> None:
|
|
|
3631
3631
|
path.unlink(missing_ok=True)
|
|
3632
3632
|
|
|
3633
3633
|
|
|
3634
|
+
def _refresh_group_context_queue(project_root: Path, task_group: str) -> None:
|
|
3635
|
+
"""그룹 문서의 시작 순서를 지금 값으로 다시 그린다. 실패해도 run 은 계속한다.
|
|
3636
|
+
|
|
3637
|
+
파생 표면이라 이것 때문에 run 준비를 멈출 이유가 없다. 대신 삼키지 않고
|
|
3638
|
+
stderr 로 알린다 — 워커가 낡은 형제 상태를 읽었다는 사실을 나중에 설명할
|
|
3639
|
+
근거가 남아야 한다.
|
|
3640
|
+
"""
|
|
3641
|
+
try:
|
|
3642
|
+
group_context.refresh_group_queue(project_root, task_group)
|
|
3643
|
+
except (OSError, ValueError) as exc:
|
|
3644
|
+
print(
|
|
3645
|
+
f"prepare: group-context start order for {task_group!r} could not be "
|
|
3646
|
+
f"redrawn before it was copied into the instruction set: {exc}",
|
|
3647
|
+
file=sys.stderr,
|
|
3648
|
+
)
|
|
3649
|
+
|
|
3650
|
+
|
|
3634
3651
|
def _write_instruction_set_sources(
|
|
3635
3652
|
inp: PrepareInputs,
|
|
3636
3653
|
ctx: dict,
|
|
@@ -3694,6 +3711,12 @@ def _write_instruction_set_sources(
|
|
|
3694
3711
|
(instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
|
|
3695
3712
|
shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
|
|
3696
3713
|
# 그룹 맥락은 preflight 를 통과한 파일만 여기 온다(`_validate_group_context_preflight`).
|
|
3714
|
+
#
|
|
3715
|
+
# 복사 직전에 시작 순서를 다시 그린다. 이 파일은 그대로 워커의 analysis
|
|
3716
|
+
# packet 에 실리므로, 형제 task 의 상태가 낡은 채로 복사되면 워커는 이미 끝난
|
|
3717
|
+
# task 를 진행 중으로 읽는다. 투영을 쓰는 곳이 report-finalize 뿐이던 동안
|
|
3718
|
+
# 리포트 없이 끝난 run 의 상태는 다음 finalize 까지 문서에 닿지 않았다.
|
|
3719
|
+
_refresh_group_context_queue(Path(inp.project_root), inp.task_group)
|
|
3697
3720
|
group_context_source = group_context.group_context_file(
|
|
3698
3721
|
Path(inp.project_root), inp.task_group
|
|
3699
3722
|
)
|
|
@@ -14,6 +14,7 @@ import sys
|
|
|
14
14
|
from datetime import datetime, timezone
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
from okstra_ctl import group_context
|
|
17
18
|
from okstra_ctl.ids import slugify_task_segment
|
|
18
19
|
from okstra_ctl.fixed_text import line, scalar
|
|
19
20
|
from okstra_ctl.paths import task_dir, task_manifest_file
|
|
@@ -38,6 +39,7 @@ def _emit(payload: dict, *, text: bool = False) -> None:
|
|
|
38
39
|
("taskKey", "Task key"), ("previousWorkStatus", "Previous work status"),
|
|
39
40
|
("workStatus", "Work status"), ("workStatusUpdatedAt", "Updated at"),
|
|
40
41
|
("workStatusNote", "Note"), ("taskManifestPath", "Task manifest"),
|
|
42
|
+
("groupContextPath", "Group context"),
|
|
41
43
|
):
|
|
42
44
|
if key in payload:
|
|
43
45
|
lines.append(line(label, payload.get(key)).rstrip("\n"))
|
|
@@ -152,6 +154,29 @@ def _updated_payload(manifest_path: Path, manifest: dict, entry: dict, args) ->
|
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
|
|
157
|
+
def _refresh_group_queue(project_root: Path, entry: dict) -> str:
|
|
158
|
+
"""상태를 바꾼 task 가 속한 그룹 문서의 시작 순서를 다시 그린다.
|
|
159
|
+
|
|
160
|
+
그 투영을 쓰는 곳이 report-finalize 하나뿐이라, 여기서 갱신하지 않으면
|
|
161
|
+
사람이 적은 상태가 다음 finalize 까지 그룹 문서에 닿지 않는다. 그룹 문서가
|
|
162
|
+
없거나 갱신에 실패해도 상태 기록 자체는 성공이다 — 매니페스트는 이미
|
|
163
|
+
쓰였고, 이것은 파생 표면이다. 실패는 삼키지 않고 stderr 로 알린다.
|
|
164
|
+
"""
|
|
165
|
+
task_group = str(entry.get("taskGroup") or "")
|
|
166
|
+
if not task_group:
|
|
167
|
+
return ""
|
|
168
|
+
try:
|
|
169
|
+
target = group_context.refresh_group_queue(project_root, task_group)
|
|
170
|
+
except (OSError, ValueError) as exc:
|
|
171
|
+
print(
|
|
172
|
+
f"set-work-status: work status recorded, but the group-context start "
|
|
173
|
+
f"order for {task_group!r} could not be redrawn: {exc}",
|
|
174
|
+
file=sys.stderr,
|
|
175
|
+
)
|
|
176
|
+
return ""
|
|
177
|
+
return str(target) if target is not None else ""
|
|
178
|
+
|
|
179
|
+
|
|
155
180
|
def main(argv: list[str] | None = None) -> int:
|
|
156
181
|
args = _parse_args(argv)
|
|
157
182
|
project_root, entry, resolution_exit = _resolve_entry(args)
|
|
@@ -184,7 +209,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
184
209
|
)
|
|
185
210
|
return 1
|
|
186
211
|
|
|
187
|
-
|
|
212
|
+
payload = _updated_payload(manifest_path, manifest, entry, args)
|
|
213
|
+
refreshed = _refresh_group_queue(project_root, entry)
|
|
214
|
+
if refreshed:
|
|
215
|
+
payload["groupContextPath"] = refreshed
|
|
216
|
+
_emit(payload, text=args.text)
|
|
188
217
|
return 0
|
|
189
218
|
|
|
190
219
|
|
|
@@ -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
|
|
|
@@ -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
|
)
|
|
@@ -222,6 +223,9 @@ def _sim_answer(prompt: Prompt) -> str:
|
|
|
222
223
|
def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
223
224
|
"""기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
|
|
224
225
|
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
226
|
+
if prompt.step == S_REPORT_LANGUAGE:
|
|
227
|
+
state.answered.append(prompt.step)
|
|
228
|
+
return
|
|
225
229
|
if is_split_picker(prompt):
|
|
226
230
|
# 조각 질문의 step 은 등록된 step 이 아니다 — 잘리지 않은 원본으로 낸다.
|
|
227
231
|
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,
|
|
@@ -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)"
|