okstra 0.196.0 → 0.197.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 +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/cli.md +14 -1
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/host-orchestration/implementation-planning.md +56 -0
- package/runtime/prompts/lead/plan-body-verification.md +21 -3
- package/runtime/prompts/profiles/implementation-planning.md +3 -2
- package/runtime/prompts/wizard/prompts.ko.json +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +15 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +15 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +15 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +12 -10
- package/runtime/python/okstra_ctl/blocking_checks.py +19 -0
- package/runtime/python/okstra_ctl/conformance.py +36 -5
- package/runtime/python/okstra_ctl/dispatch_core.py +11 -6
- package/runtime/python/okstra_ctl/dispatch_state.py +11 -7
- package/runtime/python/okstra_ctl/domain/worker_stream.py +4 -5
- package/runtime/python/okstra_ctl/final_report_schema.py +62 -1
- package/runtime/python/okstra_ctl/group_context.py +96 -3
- package/runtime/python/okstra_ctl/plan_items.py +14 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +45 -3
- package/runtime/python/okstra_ctl/run.py +77 -0
- package/runtime/python/okstra_ctl/session_transcript.py +4 -4
- package/runtime/python/okstra_ctl/set_work_status.py +30 -1
- package/runtime/python/okstra_ctl/stage_close.py +244 -0
- package/runtime/python/okstra_ctl/tdd_bypass.py +131 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +27 -24
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +37 -14
- package/runtime/python/okstra_ctl/wizard/roles.py +16 -11
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +15 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +45 -2
- 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 +63 -4
- package/runtime/templates/reports/group-context.template.md +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +109 -23
- package/runtime/validators/validate-run.py +63 -8
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""`okstra stage-close` — 이미 랜딩한 stage 를 기록만 남겨 done 으로 닫는다.
|
|
2
|
+
|
|
3
|
+
stage 완료의 정본은 `runs/implementation-planning/consumers.jsonl` 의 `done`
|
|
4
|
+
행이고, 그 행은 두 경로로만 생겼다: implementation run 이 정상 종료하거나,
|
|
5
|
+
`backfill_done_from_carry` 가 carry 사이드카에서 복원하거나. 두 경로 모두
|
|
6
|
+
없는 상태가 실재한다 — 제품 변경은 커밋되고 conformance 결과도 PASS 인데
|
|
7
|
+
run 이 carry 를 쓰기 전에 끝난 경우다. 그러면 `stage-map` 은 `doneStages: []`
|
|
8
|
+
을 보고하고, 다음 계획은 아무 일도 없었던 것처럼 그 stage 를 다시 쓰며, 거기서
|
|
9
|
+
나오는 RED 기대는 전부 도달 불가다(실측 2026-09-10, fontsninja-v3-site
|
|
10
|
+
dev-10628-3: 마지막 행이 `started`, carry 디렉터리는 빈 폴더, stage 결과는
|
|
11
|
+
`overall: PASS`).
|
|
12
|
+
|
|
13
|
+
이 명령은 그 복구 경로다. 커밋과 conformance 결과라는 두 증거를 확인한 뒤
|
|
14
|
+
done 행을 append 한다 — 증거 없이 닫는 수단은 아니다.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from okstra_project import (
|
|
24
|
+
ResolverError,
|
|
25
|
+
StateError,
|
|
26
|
+
resolve_project_root,
|
|
27
|
+
resolve_task_identity,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from .conformance import (
|
|
31
|
+
conformance_result_file,
|
|
32
|
+
decide_conformance_gate,
|
|
33
|
+
qa_result_from_dict,
|
|
34
|
+
)
|
|
35
|
+
from .consumers import append_consumer, last_lifecycle_status_by_stage, read_consumers
|
|
36
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
37
|
+
from .paths import RunRef, task_conformance_manifest_file, task_qa_dir
|
|
38
|
+
from .stage_map import StageMapError, load_latest_plan_stage_map
|
|
39
|
+
|
|
40
|
+
# 이 status 를 이미 가진 stage 는 리드가 판정을 내린 것이다. 되쓰기는 그 판정을
|
|
41
|
+
# 덮는 일이므로 여기서 하지 않는다.
|
|
42
|
+
_SETTLED = ("done", "failed")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _git_commit_exists(repo_root: Path, commit: str) -> bool:
|
|
46
|
+
probe = subprocess.run(
|
|
47
|
+
["git", "-C", str(repo_root), "cat-file", "-e", f"{commit}^{{commit}}"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
)
|
|
50
|
+
return probe.returncode == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _manifest_entry(task_root: Path, stage: int) -> dict | None:
|
|
54
|
+
"""이 stage 의 conformance entry, 선언이 없으면 None.
|
|
55
|
+
|
|
56
|
+
stageKey 의 `<task-id>` 는 planning 이 쓴 원문이라 디렉터리 segment 와 표기가
|
|
57
|
+
다를 수 있으므로 `-stage-<N>` 접미사로 맞춘다 — `_clear_stale_stage_waiver`
|
|
58
|
+
와 같은 규칙이다.
|
|
59
|
+
"""
|
|
60
|
+
path = task_conformance_manifest_file(task_root)
|
|
61
|
+
if not path.is_file():
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
manifest = load_owned_object(path, artifact="conformance manifest")
|
|
65
|
+
except JsonBoundaryError as exc:
|
|
66
|
+
raise StateError(str(exc), stage="conformance") from exc
|
|
67
|
+
entries = manifest.get("entries")
|
|
68
|
+
if not isinstance(entries, list):
|
|
69
|
+
return None
|
|
70
|
+
suffix = f"-stage-{stage}"
|
|
71
|
+
return next(
|
|
72
|
+
(
|
|
73
|
+
entry for entry in entries
|
|
74
|
+
if isinstance(entry, dict)
|
|
75
|
+
and isinstance(entry.get("stageKey"), str)
|
|
76
|
+
and entry["stageKey"].endswith(suffix)
|
|
77
|
+
),
|
|
78
|
+
None,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _conformance_state(task_root: Path, stage: int) -> tuple[bool, str]:
|
|
83
|
+
"""(닫아도 되는가, 사람이 읽는 근거).
|
|
84
|
+
|
|
85
|
+
선언 자체가 없으면 게이트할 것이 없다. 있으면 그 stage 의 결과 사이드카로
|
|
86
|
+
`decide_conformance_gate` 를 그대로 돌린다 — 검증기가 쓰는 것과 같은 판정
|
|
87
|
+
함수라, 여기서 통과한 stage 는 검증기에서도 통과한다.
|
|
88
|
+
"""
|
|
89
|
+
entry = _manifest_entry(task_root, stage)
|
|
90
|
+
if entry is None:
|
|
91
|
+
return True, "no conformance entry declared for this stage"
|
|
92
|
+
key = str(entry.get("stageKey"))
|
|
93
|
+
sidecar = conformance_result_file(task_qa_dir(task_root), key)
|
|
94
|
+
result = None
|
|
95
|
+
if sidecar.is_file():
|
|
96
|
+
try:
|
|
97
|
+
result = qa_result_from_dict(
|
|
98
|
+
load_owned_object(sidecar, artifact="conformance result")
|
|
99
|
+
)
|
|
100
|
+
except JsonBoundaryError:
|
|
101
|
+
# 읽을 수 없는 결과는 결과가 아니다 — MISSING 으로 게이트에 넘겨
|
|
102
|
+
# 판정을 `decide_conformance_gate` 가 내리게 한다. 검증기도 같다.
|
|
103
|
+
result = qa_result_from_dict(None)
|
|
104
|
+
verdict = decide_conformance_gate(entry, result)
|
|
105
|
+
return verdict.ok, f"{verdict.status}: {verdict.message}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def close_stage(
|
|
109
|
+
project_root: Path, task_key: str, stage: int, head_commit: str,
|
|
110
|
+
) -> dict:
|
|
111
|
+
"""stage 를 done 으로 닫고 그 근거를 함께 돌려준다."""
|
|
112
|
+
identity = resolve_task_identity(project_root, task_key)
|
|
113
|
+
task_root = Path(identity["taskRoot"])
|
|
114
|
+
plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
|
|
115
|
+
if not plan_run_root.is_dir():
|
|
116
|
+
raise StateError(
|
|
117
|
+
f"this task has no implementation-planning run at {plan_run_root} — "
|
|
118
|
+
"there is no Stage Map to close a stage of",
|
|
119
|
+
stage="plan-run",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
stage_map = load_latest_plan_stage_map(task_root)
|
|
124
|
+
except StageMapError as exc:
|
|
125
|
+
raise StateError(str(exc), stage=exc.code) from exc
|
|
126
|
+
known = sorted(
|
|
127
|
+
row["stage_number"] for row in stage_map.stages
|
|
128
|
+
if isinstance(row, dict) and isinstance(row.get("stage_number"), int)
|
|
129
|
+
)
|
|
130
|
+
if stage not in known:
|
|
131
|
+
raise StateError(
|
|
132
|
+
f"stage {stage} is not in this task's Stage Map (has {known or 'none'})",
|
|
133
|
+
stage="stage-map",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
recorded = last_lifecycle_status_by_stage(read_consumers(plan_run_root))
|
|
137
|
+
if recorded.get(stage) in _SETTLED:
|
|
138
|
+
raise StateError(
|
|
139
|
+
f"stage {stage} is already recorded {recorded[stage]!r} — a lead's "
|
|
140
|
+
"ruling is not rewritten here",
|
|
141
|
+
stage="consumers",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not _git_commit_exists(project_root, head_commit):
|
|
145
|
+
raise StateError(
|
|
146
|
+
f"{head_commit} is not a commit in {project_root} — pass the commit "
|
|
147
|
+
"the stage's work actually landed as",
|
|
148
|
+
stage="git",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
ok, conformance = _conformance_state(task_root, stage)
|
|
152
|
+
if not ok:
|
|
153
|
+
raise StateError(
|
|
154
|
+
f"stage {stage} conformance does not permit closing it "
|
|
155
|
+
f"({conformance}) — run the stage's conformance script, or record a "
|
|
156
|
+
'user waiver with prepare `--qa-waiver "<stageKey>:<reason>"`, '
|
|
157
|
+
"before closing the stage",
|
|
158
|
+
stage="conformance",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
append_consumer(
|
|
162
|
+
plan_run_root,
|
|
163
|
+
impl_task_key=identity["taskKey"],
|
|
164
|
+
stage=stage,
|
|
165
|
+
status="done",
|
|
166
|
+
head_commit=head_commit,
|
|
167
|
+
closed_by="stage-close",
|
|
168
|
+
)
|
|
169
|
+
return {
|
|
170
|
+
"taskKey": identity["taskKey"],
|
|
171
|
+
"taskRoot": str(task_root),
|
|
172
|
+
"stage": stage,
|
|
173
|
+
"headCommit": head_commit,
|
|
174
|
+
"conformance": conformance,
|
|
175
|
+
"consumersPath": str(plan_run_root / "consumers.jsonl"),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_CLI_EPILOG = r"""Usage:
|
|
180
|
+
okstra stage-close <task-key> --stage <N> --from-commit <sha>
|
|
181
|
+
okstra stage-close <task-key> --stage <N> --from-commit <sha> --project <dir>
|
|
182
|
+
|
|
183
|
+
Records the `done` row an implementation run would have written, for a stage
|
|
184
|
+
whose work is already committed but which never registered as done (the run
|
|
185
|
+
ended before writing its carry sidecar). Refuses unless the Stage Map has that
|
|
186
|
+
stage, no `done`/`failed` row exists for it, `--from-commit` resolves to a
|
|
187
|
+
commit in the project repo, and the stage's conformance gate permits progress.
|
|
188
|
+
|
|
189
|
+
Output: JSON { ok, taskKey, taskRoot, stage, headCommit, conformance,
|
|
190
|
+
consumersPath }. Exit 1 on a refusal (the reason names what to do), 2 when
|
|
191
|
+
PROJECT_ROOT cannot be resolved.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def main(argv: list[str] | None = None) -> int:
|
|
196
|
+
parser = argparse.ArgumentParser(
|
|
197
|
+
description="Close an already-landed implementation stage as done.",
|
|
198
|
+
epilog=_CLI_EPILOG,
|
|
199
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
200
|
+
prog="okstra stage-close")
|
|
201
|
+
parser.add_argument("task_key", metavar="task-key",
|
|
202
|
+
help="project-id:task-group:task-id")
|
|
203
|
+
parser.add_argument("--stage", type=int, required=True,
|
|
204
|
+
help="the Stage Map number to close")
|
|
205
|
+
parser.add_argument("--from-commit", required=True, dest="from_commit",
|
|
206
|
+
help="the commit the stage's work landed as")
|
|
207
|
+
parser.add_argument("--project-root", "--project", default="",
|
|
208
|
+
help="use this directory as PROJECT_ROOT")
|
|
209
|
+
parser.add_argument("--cwd", default=".",
|
|
210
|
+
help="resolve PROJECT_ROOT starting from here")
|
|
211
|
+
args = parser.parse_args(argv)
|
|
212
|
+
|
|
213
|
+
def emit(payload: dict) -> None:
|
|
214
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
resolved = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
|
|
218
|
+
except ResolverError as exc:
|
|
219
|
+
emit({"ok": False, "stage": "resolve", "reason": str(exc)})
|
|
220
|
+
return 2
|
|
221
|
+
|
|
222
|
+
if args.stage < 1:
|
|
223
|
+
emit({"ok": False, "stage": "stage-map",
|
|
224
|
+
"reason": f"--stage must be a positive integer, got {args.stage}"})
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
closed = close_stage(
|
|
229
|
+
Path(resolved), args.task_key, args.stage, args.from_commit.strip(),
|
|
230
|
+
)
|
|
231
|
+
except StateError as exc:
|
|
232
|
+
emit({
|
|
233
|
+
"ok": False,
|
|
234
|
+
"stage": getattr(exc, "stage", None) or "stage-close",
|
|
235
|
+
"reason": str(exc),
|
|
236
|
+
})
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
emit({"ok": True, **closed})
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""사용자 확인형 TDD 우회 원장 — `<task-root>/qa/tdd-bypass.json`.
|
|
2
|
+
|
|
3
|
+
S10c 는 모든 stage 의 첫 step 에 `RED:` 를, 뒤 step 중 하나에 `GREEN:` 을
|
|
4
|
+
요구하고, S10e 는 그 요구를 `doc-only` / `config-only` / `pure-rename` 세
|
|
5
|
+
사유로만 면제한다. 세 사유 중 어느 것도 맞지 않는 stage 는 통과할 값이
|
|
6
|
+
없으므로, 계획서가 가장 가까운 토큰을 골라 자기를 잘못 기술하게 된다
|
|
7
|
+
(실측 2026-09-10, fontsninja-v3-site dev-10628-3 implementation-planning 002:
|
|
8
|
+
제품 변경이 이전 run 에서 이미 커밋·적합성 PASS 까지 끝난 stage 를
|
|
9
|
+
`config-only` 로 신고).
|
|
10
|
+
|
|
11
|
+
그래서 네 번째 사유 `user-bypass` 는 계획서의 선언만으로는 성립하지 않는다.
|
|
12
|
+
사용자가 `okstra prepare --tdd-bypass "<stage>:<reason>"` 로 이 파일에 사유를
|
|
13
|
+
원문 그대로 남겨야 검증기가 인정한다 — `qa/self-mock-waivers.json` 이 gate A/B
|
|
14
|
+
의 우회를 담는 방식과 같은 idiom 이고, 같은 `{reason, acknowledgedBy}` 계약을
|
|
15
|
+
쓴다. 계획서가 스스로에게 면제를 발급하는 경로는 없다.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Mapping
|
|
21
|
+
|
|
22
|
+
from .json_boundary import (
|
|
23
|
+
JsonBoundaryError,
|
|
24
|
+
load_owned_object,
|
|
25
|
+
write_owned_object_atomic,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ARTIFACT = "tdd bypass ledger"
|
|
29
|
+
FILENAME = "tdd-bypass.json"
|
|
30
|
+
SCHEMA_VERSION = "1.0"
|
|
31
|
+
|
|
32
|
+
# `tddExemption` 에 적는 값. S10e 가 이 토큰을 볼 때만 원장을 조회한다.
|
|
33
|
+
REASON_TOKEN = "user-bypass"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TddBypassError(ValueError):
|
|
37
|
+
"""TDD 우회 원장 입력이 계약을 위반했다."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bypass_file(task_root: Path) -> Path:
|
|
41
|
+
"""이 task 의 우회 원장 경로. conformance 산출물과 같은 `qa/` 아래다."""
|
|
42
|
+
return task_root / "qa" / FILENAME
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_bypass_arg(value: object) -> tuple[int, str] | None:
|
|
46
|
+
"""`--tdd-bypass` 값 `<stage>:<reason>` 를 (stage, reason) 로 분해.
|
|
47
|
+
|
|
48
|
+
형식이 아니거나 stage 가 1 이상의 정수가 아니면 None — 호출 측이 무엇을
|
|
49
|
+
받았는지 그대로 보여 주며 거절한다.
|
|
50
|
+
"""
|
|
51
|
+
if not isinstance(value, str) or ":" not in value:
|
|
52
|
+
return None
|
|
53
|
+
raw_stage, reason = value.split(":", 1)
|
|
54
|
+
raw_stage, reason = raw_stage.strip(), reason.strip()
|
|
55
|
+
if not raw_stage.isdigit() or not reason:
|
|
56
|
+
return None
|
|
57
|
+
stage = int(raw_stage)
|
|
58
|
+
if stage < 1:
|
|
59
|
+
return None
|
|
60
|
+
return stage, reason
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _entries(ledger: object) -> list[dict[str, Any]]:
|
|
64
|
+
rows = ledger.get("entries") if isinstance(ledger, Mapping) else None
|
|
65
|
+
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def record_bypass(
|
|
69
|
+
path: Path, stage: int, reason: str, *, at: str, acknowledged_by: str = "user",
|
|
70
|
+
) -> None:
|
|
71
|
+
"""stage 의 우회 사유를 원문 그대로 기록한다(같은 stage 는 마지막 값이 이긴다).
|
|
72
|
+
|
|
73
|
+
사용자가 사유를 고쳐 다시 부여하는 것이 정상 경로이므로 중복은 오류가
|
|
74
|
+
아니라 교체다. 파일이 없으면 만든다 — 전제 파일 부재로 거절하면 사용자가
|
|
75
|
+
빈 원장을 손으로 만들어야 한다.
|
|
76
|
+
"""
|
|
77
|
+
if not isinstance(stage, int) or stage < 1:
|
|
78
|
+
raise TddBypassError(f"stage must be a positive integer, got {stage!r}")
|
|
79
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
80
|
+
raise TddBypassError("reason must be a non-empty string")
|
|
81
|
+
if not isinstance(acknowledged_by, str) or not acknowledged_by.strip():
|
|
82
|
+
raise TddBypassError("acknowledgedBy must be a non-empty string")
|
|
83
|
+
ledger: dict[str, Any]
|
|
84
|
+
if path.is_file():
|
|
85
|
+
try:
|
|
86
|
+
ledger = load_owned_object(path, artifact=ARTIFACT)
|
|
87
|
+
except JsonBoundaryError as exc:
|
|
88
|
+
raise TddBypassError(str(exc)) from exc
|
|
89
|
+
else:
|
|
90
|
+
ledger = {"schemaVersion": SCHEMA_VERSION, "entries": []}
|
|
91
|
+
rows = [row for row in _entries(ledger) if row.get("stage") != stage]
|
|
92
|
+
rows.append({
|
|
93
|
+
"stage": stage,
|
|
94
|
+
"reason": reason.strip(),
|
|
95
|
+
"acknowledgedBy": acknowledged_by.strip(),
|
|
96
|
+
"at": at,
|
|
97
|
+
})
|
|
98
|
+
ledger["schemaVersion"] = ledger.get("schemaVersion") or SCHEMA_VERSION
|
|
99
|
+
ledger["entries"] = sorted(rows, key=lambda row: row["stage"])
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
try:
|
|
102
|
+
write_owned_object_atomic(path, ledger, artifact=ARTIFACT)
|
|
103
|
+
except JsonBoundaryError as exc:
|
|
104
|
+
raise TddBypassError(str(exc)) from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def granted_stages(path: Path) -> dict[int, str]:
|
|
108
|
+
"""`{stage: reason}` — 사용자가 우회를 부여한 stage 들.
|
|
109
|
+
|
|
110
|
+
파일이 없으면 빈 map 이다(우회 없음). 사유나 승인자가 빈 행은 우회로
|
|
111
|
+
세지 않는다 — 그 행은 사용자가 무엇을 승인했는지 말하지 못한다.
|
|
112
|
+
"""
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
return {}
|
|
115
|
+
try:
|
|
116
|
+
ledger = load_owned_object(path, artifact=ARTIFACT)
|
|
117
|
+
except JsonBoundaryError as exc:
|
|
118
|
+
raise TddBypassError(str(exc)) from exc
|
|
119
|
+
granted: dict[int, str] = {}
|
|
120
|
+
for row in _entries(ledger):
|
|
121
|
+
stage = row.get("stage")
|
|
122
|
+
reason = row.get("reason")
|
|
123
|
+
acknowledged_by = row.get("acknowledgedBy")
|
|
124
|
+
if not isinstance(stage, int) or isinstance(stage, bool) or stage < 1:
|
|
125
|
+
continue
|
|
126
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
127
|
+
continue
|
|
128
|
+
if not isinstance(acknowledged_by, str) or not acknowledged_by.strip():
|
|
129
|
+
continue
|
|
130
|
+
granted[stage] = reason.strip()
|
|
131
|
+
return granted
|
|
@@ -35,10 +35,10 @@ from .state import Prompt, WizardError, WizardState, _is_role_selection_step
|
|
|
35
35
|
from .prompts import _domain_prompt
|
|
36
36
|
from .picker_navigation import (
|
|
37
37
|
accept_picker_answer,
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
is_split_picker,
|
|
39
|
+
merge_split_picker_answer,
|
|
40
40
|
present_picker,
|
|
41
|
-
|
|
41
|
+
split_picker,
|
|
42
42
|
)
|
|
43
43
|
from .roles import _submit_role_prompt, next_role_prompt
|
|
44
44
|
from .steps_identity import _submit_task_pick
|
|
@@ -140,15 +140,18 @@ def next_prompt(state: WizardState) -> Prompt:
|
|
|
140
140
|
def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
|
|
141
141
|
"""호스트 네이티브 선택기 한도에 맞춘 화면.
|
|
142
142
|
|
|
143
|
-
단일
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
한도를 넘는 픽은 체크박스든 단일 선택이든 네이티브 질문 묶음에 실리는
|
|
144
|
+
크기(claude-code 4×4=16)까지 같은 화면의 체크박스 질문 여러 개로 자른다
|
|
145
|
+
(`split_picker`) — 사용자는 탭을 옮겨 다니며 한 번에 답한다. 단일 선택을
|
|
146
|
+
쪽으로 나누던 종전 화면은 후보 하나를 고르는 데 "다음 선택지" 를 누를
|
|
147
|
+
때마다 턴이 하나씩 들었다(실측 2026-09-09, 후보 13개인 critic 화면).
|
|
148
|
+
|
|
149
|
+
묶음에 못 실리면(옵션 16개 초과, 라벨 중복, 질문 묶음이 없는 세션)
|
|
150
|
+
단일 선택은 종전대로 쪽으로 나누고(`present_picker`), 체크박스는
|
|
151
|
+
`CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 전체 목록을
|
|
152
|
+
한 번에 보인다. 체크박스는 쪽으로 나누지 않는다 — 쪽 사본이 추천 표시를
|
|
153
|
+
단 채 단일 선택이 돼 `Prompt` 의 추천 불변식(단일 선택은 추천 정확히
|
|
154
|
+
하나)에 걸려 화면이 열리지 않았다(실측 2026-09-09, verifier 전체 후보 화면).
|
|
152
155
|
"""
|
|
153
156
|
if "native_single_select" not in state.available_functions:
|
|
154
157
|
return prompt
|
|
@@ -157,14 +160,14 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
|
|
|
157
160
|
return prompt
|
|
158
161
|
prompt = prompt.questions[0]
|
|
159
162
|
port = default_host_registry().resolve(state.host_runtime).interaction()
|
|
163
|
+
split = split_picker(
|
|
164
|
+
prompt,
|
|
165
|
+
max_options=port.native_option_limit,
|
|
166
|
+
max_questions=port.native_question_limit,
|
|
167
|
+
)
|
|
168
|
+
if split is not prompt and _interaction_plan(state, split).kind == "native-group":
|
|
169
|
+
return split
|
|
160
170
|
if prompt.multi:
|
|
161
|
-
split = split_checkbox(
|
|
162
|
-
prompt,
|
|
163
|
-
max_options=port.native_option_limit,
|
|
164
|
-
max_questions=port.native_question_limit,
|
|
165
|
-
)
|
|
166
|
-
if split is not prompt and _interaction_plan(state, split).kind == "native-group":
|
|
167
|
-
return split
|
|
168
171
|
return prompt
|
|
169
172
|
return present_picker(state, prompt, limit=port.native_option_limit)
|
|
170
173
|
|
|
@@ -219,7 +222,7 @@ def _sim_answer(prompt: Prompt) -> str:
|
|
|
219
222
|
def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
220
223
|
"""기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
|
|
221
224
|
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
222
|
-
if
|
|
225
|
+
if is_split_picker(prompt):
|
|
223
226
|
# 조각 질문의 step 은 등록된 step 이 아니다 — 잘리지 않은 원본으로 낸다.
|
|
224
227
|
prompt = _next_prompt_screen(state)
|
|
225
228
|
try:
|
|
@@ -402,10 +405,10 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
402
405
|
value = accept_picker_answer(state, original, value)
|
|
403
406
|
if value is None:
|
|
404
407
|
return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
|
|
405
|
-
if
|
|
406
|
-
# 질문 묶음으로 잘린
|
|
407
|
-
#
|
|
408
|
-
value =
|
|
408
|
+
if is_split_picker(prompt):
|
|
409
|
+
# 질문 묶음으로 잘린 픽 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의 제출
|
|
410
|
+
# 경로로 보낸다. 원본의 선택지로 검증한다.
|
|
411
|
+
value = merge_split_picker_answer(prompt, value)
|
|
409
412
|
prompt = _next_prompt_screen(state)
|
|
410
413
|
elif prompt.kind == "pick_group":
|
|
411
414
|
return _submit_group(state, prompt, value)
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
"""호스트 선택기 한도 안에서 원래 선택지를 보존하는 두 가지 강등.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
한 번에 보인다
|
|
3
|
+
네이티브 질문 묶음(claude-code `AskUserQuestion` 의 질문 4개 × 옵션 4개)에
|
|
4
|
+
실리는 크기면 같은 화면 안의 체크박스 질문 여러 개로 자른다(`split_picker`):
|
|
5
|
+
사용자는 탭마다 체크하고, 답은 하나의 CSV 로 합쳐져(`merge_split_picker_answer`)
|
|
6
|
+
원래 step 의 제출 경로로 간다. 단일 선택(예: critic 처럼 최대 1개인 역할)도
|
|
7
|
+
같은 방식으로 자르되 통틀어 하나만 고른 답이어야 한다.
|
|
8
|
+
|
|
9
|
+
그 크기도 넘거나 질문 묶음이 없는 세션이면, 단일 선택은 쪽으로 나누고
|
|
10
|
+
(`present_picker`) 체크박스는 `numbered-multi` 로 전체 목록을 한 번에 보인다
|
|
11
|
+
(`engine._native_picker_screen`). 체크박스를 쪽으로 나누던 경로는 쪽 사본이
|
|
12
|
+
추천 표시를 단 채 단일 선택이 돼 `Prompt` 의 추천 불변식에 걸리므로
|
|
13
|
+
2026-09-09 에 뺐다.
|
|
11
14
|
"""
|
|
12
15
|
import json
|
|
13
16
|
import math
|
|
@@ -20,10 +23,18 @@ _PAGE_PREFIX = "__okstra_picker_page__:"
|
|
|
20
23
|
_SPLIT_SEPARATOR = "#"
|
|
21
24
|
|
|
22
25
|
|
|
23
|
-
def
|
|
26
|
+
def split_picker(
|
|
24
27
|
prompt: Prompt, *, max_options: int, max_questions: int,
|
|
25
28
|
) -> Prompt:
|
|
26
|
-
"""옵션이 `max_options` 를 넘는
|
|
29
|
+
"""옵션이 `max_options` 를 넘는 픽을 같은 화면의 체크박스 질문 묶음으로 자른다.
|
|
30
|
+
|
|
31
|
+
조각은 원래 픽이 단일 선택이어도 체크박스다. 단일 선택 조각으로 두면 고를
|
|
32
|
+
것이 없는 조각에도 답이 있어야 해서 조각마다 "여기 없음" 한 줄이 들어가고,
|
|
33
|
+
조각당 실선택지가 하나 줄어 후보 13개짜리 critic 화면은 조각 5개가 돼
|
|
34
|
+
묶음에 아예 못 실린다(claude-code 한도 4×4). 체크박스 조각은 아무것도
|
|
35
|
+
고르지 않은 조각이 곧 답 없는 조각이라 그 줄이 필요 없다. 원래 픽이 단일
|
|
36
|
+
선택이었다는 사실은 그룹의 `multi=False` 로 남고, `merge_split_picker_answer`
|
|
37
|
+
가 통틀어 하나만 골랐는지 본다.
|
|
27
38
|
|
|
28
39
|
질문 수는 옵션이 들어가는 최소 개수이고 옵션은 질문에 고르게 나눈다 —
|
|
29
40
|
마지막 질문이 한 줄짜리가 되면 호스트 최소 옵션 수(2)에 걸려 묶음 전체가
|
|
@@ -31,7 +42,7 @@ def split_checkbox(
|
|
|
31
42
|
앞머리 run 이 된다(`Prompt._check_recommendations`). `max_questions` 도
|
|
32
43
|
넘으면 자르지 않고 그대로 돌려준다.
|
|
33
44
|
"""
|
|
34
|
-
if prompt.kind != "pick" or
|
|
45
|
+
if prompt.kind != "pick" or len(prompt.options) <= max_options:
|
|
35
46
|
return prompt
|
|
36
47
|
count = len(prompt.options)
|
|
37
48
|
questions = math.ceil(count / max_options)
|
|
@@ -48,6 +59,7 @@ def split_checkbox(
|
|
|
48
59
|
step=f"{prompt.step}{_SPLIT_SEPARATOR}{index + 1}",
|
|
49
60
|
label=f"{prompt.label} ({offset + 1}–{offset + size}/{count})",
|
|
50
61
|
options=shown,
|
|
62
|
+
multi=True,
|
|
51
63
|
))
|
|
52
64
|
offset += size
|
|
53
65
|
return Prompt(
|
|
@@ -56,11 +68,12 @@ def split_checkbox(
|
|
|
56
68
|
label=prompt.label,
|
|
57
69
|
help=prompt.help,
|
|
58
70
|
echo_template=prompt.echo_template,
|
|
71
|
+
multi=prompt.multi,
|
|
59
72
|
questions=chunks,
|
|
60
73
|
)
|
|
61
74
|
|
|
62
75
|
|
|
63
|
-
def
|
|
76
|
+
def is_split_picker(prompt: Prompt) -> bool:
|
|
64
77
|
return prompt.kind == "pick_group" and bool(prompt.questions) and all(
|
|
65
78
|
question.multi
|
|
66
79
|
and question.step.startswith(f"{prompt.step}{_SPLIT_SEPARATOR}")
|
|
@@ -68,8 +81,13 @@ def is_split_checkbox(prompt: Prompt) -> bool:
|
|
|
68
81
|
)
|
|
69
82
|
|
|
70
83
|
|
|
71
|
-
def
|
|
72
|
-
"""질문 묶음 답(JSON, 조각 step → CSV)을 원래
|
|
84
|
+
def merge_split_picker_answer(prompt: Prompt, value: str) -> str:
|
|
85
|
+
"""질문 묶음 답(JSON, 조각 step → CSV)을 원래 픽의 CSV 한 줄로 합친다.
|
|
86
|
+
|
|
87
|
+
원래 픽이 단일 선택이면(그룹의 `multi=False`) 통틀어 한 줄만 고른 답이어야
|
|
88
|
+
한다 — 두 조각에서 고른 답을 그대로 합치면 원래 step 은 `a,b` 를 값 하나로
|
|
89
|
+
받아 "선택지가 아니다" 라고만 말한다.
|
|
90
|
+
"""
|
|
73
91
|
try:
|
|
74
92
|
answers = json.loads(value or "{}")
|
|
75
93
|
except json.JSONDecodeError as exc:
|
|
@@ -87,6 +105,11 @@ def merge_split_checkbox_answer(prompt: Prompt, value: str) -> str:
|
|
|
87
105
|
for question in prompt.questions:
|
|
88
106
|
raw = str(answers.get(question.step, "") or "")
|
|
89
107
|
chosen.extend(item.strip() for item in raw.split(",") if item.strip())
|
|
108
|
+
if not prompt.multi and len(chosen) > 1:
|
|
109
|
+
raise WizardError(
|
|
110
|
+
f"wizard step {prompt.step!r}: this screen takes one choice, "
|
|
111
|
+
f"but {len(chosen)} were picked across its tabs: {', '.join(chosen)}"
|
|
112
|
+
)
|
|
90
113
|
return ",".join(chosen)
|
|
91
114
|
|
|
92
115
|
|
|
@@ -7,15 +7,16 @@ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인
|
|
|
7
7
|
(`min = 0`, 예: critic)은 같은 화면에 "추가 안 함" 줄이 있다(종전 `role-add:`).
|
|
8
8
|
고정 단일 역할(`min = max = 1`, 예: report-writer·implementer)은 단일 선택 한 장이다.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가
|
|
12
|
-
수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도
|
|
13
|
-
codex 3, grok 15)를 넘으면
|
|
14
|
-
까지는 같은 화면의 체크박스 질문 여러
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
번째
|
|
10
|
+
모델 화면(`role-models:<role>`, `role-model:<role>:1`)은 실행 가능한 전체 후보를
|
|
11
|
+
한 번에 싣는다. 기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가
|
|
12
|
+
앞이고 권장 수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도
|
|
13
|
+
(claude-code 4, codex 3, grok 15)를 넘으면 체크박스든 단일 선택이든 네이티브
|
|
14
|
+
질문 묶음에 실리는 크기(claude-code 4×4)까지는 같은 화면의 체크박스 질문 여러
|
|
15
|
+
개로 자르고(`picker_navigation.split_picker`), 그것도 넘으면 체크박스는
|
|
16
|
+
`CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 번호 목록이,
|
|
17
|
+
단일 선택은 쪽 나누기가 된다 — 기본 후보만 실은 짧은 화면과 "직접 선택" 이 여는
|
|
18
|
+
두 번째 화면으로 나누던 설계는 2026-09-09 사용자 요청으로 뺐다(후보 12개 중
|
|
19
|
+
3개만 보이고, 두 번째 화면은 쪽 나누기가 추천 불변식을 깨 열리지도 않았다).
|
|
19
20
|
"""
|
|
20
21
|
from __future__ import annotations
|
|
21
22
|
|
|
@@ -199,12 +200,16 @@ def _available_role_models(
|
|
|
199
200
|
|
|
200
201
|
def _skip_option(requirement: RoleRequirement, t: dict, *, recommended: bool) -> Option:
|
|
201
202
|
# 역할을 빼면 그 역할이 맡던 판정이 사라진다. 그 결과를 아는 역할만 경고를
|
|
202
|
-
# 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다.
|
|
203
|
+
# 단다 — 예: critic 이 없으면 분석자 동수를 가를 주체가 없다. 경고는 라벨이
|
|
204
|
+
# 아니라 설명에 실린다: 체크박스 탭의 답은 고른 라벨을 `, ` 로 이어 붙인
|
|
205
|
+
# 한 줄이라(claude-code relay), 쉼표가 든 라벨 하나가 그 화면 전체를
|
|
206
|
+
# 네이티브 묶음에서 떨어뜨려 쪽 나누기로 되돌린다.
|
|
203
207
|
warnings = t["options"].get("skip_warnings", {})
|
|
204
208
|
warning = warnings.get(requirement.role, "") if isinstance(warnings, dict) else ""
|
|
205
209
|
return _opt(
|
|
206
210
|
ROLE_SKIP_TOKEN,
|
|
207
|
-
t["options"]["skip"]
|
|
211
|
+
t["options"]["skip"],
|
|
212
|
+
warning,
|
|
208
213
|
recommended=recommended,
|
|
209
214
|
)
|
|
210
215
|
|
|
@@ -13,6 +13,7 @@ from .convergence_reverify_prompt import RENDERED_BY_LINE
|
|
|
13
13
|
from .convergence_critic_verify_prompt import (
|
|
14
14
|
RENDERED_BY_LINE as CRITIC_VERIFY_RENDERED_BY_LINE,
|
|
15
15
|
)
|
|
16
|
+
from .plan_items import RENDERED_BY_LINE as PLAN_VERIFY_RENDERED_BY_LINE
|
|
16
17
|
from .worker_prompt_body import analysis_worker_label
|
|
17
18
|
from .json_boundary import load_owned_object
|
|
18
19
|
from .worker_prompt_policy import (
|
|
@@ -20,6 +21,7 @@ from .worker_prompt_policy import (
|
|
|
20
21
|
IMPLEMENTATION_HEADERS,
|
|
21
22
|
CRITIC_VERIFY_DISPATCH_KIND,
|
|
22
23
|
PromptPlan,
|
|
24
|
+
is_plan_verify_dispatch_kind,
|
|
23
25
|
resolve_prompt_plan_for_manifest,
|
|
24
26
|
)
|
|
25
27
|
from .worker_prompt_headers import EVIDENCE_LEDGER_HEADER
|
|
@@ -325,7 +327,19 @@ def validate_reverify_prompt(
|
|
|
325
327
|
):
|
|
326
328
|
errors.append("phase boundary block must precede reverify instructions")
|
|
327
329
|
instructions = normalized[_task_instructions_offset(normalized):]
|
|
328
|
-
if dispatch_kind
|
|
330
|
+
if is_plan_verify_dispatch_kind(dispatch_kind):
|
|
331
|
+
# 계획 본문 라운드의 정본 렌더러는 convergence 가 아니라 `okstra
|
|
332
|
+
# plan-items prompt` 다. 같은 서명을 요구하면 통과할 값이 하나도 없다
|
|
333
|
+
# (2026-09-09 dev-10642 implementation-planning 001: 라운드 0회).
|
|
334
|
+
if PLAN_VERIFY_RENDERED_BY_LINE not in instructions:
|
|
335
|
+
errors.append(
|
|
336
|
+
"plan-verify instruction is not the output of `okstra plan-items "
|
|
337
|
+
"prompt` (missing the `**Rendered by:**` line) — render it with "
|
|
338
|
+
"`okstra plan-items prompt --run-manifest <run-manifest>` and pass "
|
|
339
|
+
"that output verbatim as --instruction; hand-written plan item "
|
|
340
|
+
"queues are refused"
|
|
341
|
+
)
|
|
342
|
+
elif dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND:
|
|
329
343
|
if CRITIC_VERIFY_RENDERED_BY_LINE not in instructions:
|
|
330
344
|
errors.append(
|
|
331
345
|
"critic-verify instruction is not the output of `okstra convergence "
|