okstra 0.187.0 → 0.188.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/commands/lifecycle/install.mjs +3 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-render-report-views.py +23 -5
- package/runtime/prompts/lead/adapters/cmux.md +3 -3
- package/runtime/prompts/lead/convergence.md +3 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
- package/runtime/python/okstra_ctl/agent/activity.py +4 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
- package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
- package/runtime/python/okstra_ctl/context_cost.py +16 -2
- package/runtime/python/okstra_ctl/convergence.py +6 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
- package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
- package/runtime/python/okstra_ctl/implementation_options.py +7 -2
- package/runtime/python/okstra_ctl/render_final_report.py +1 -1
- package/runtime/python/okstra_ctl/report_finalize.py +7 -1
- package/runtime/python/okstra_ctl/report_narrative.py +132 -33
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
- package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
- package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
- package/runtime/python/okstra_ctl/worker_runner.py +42 -8
- package/runtime/python/okstra_token_usage/antigravity.py +50 -12
- package/runtime/python/okstra_token_usage/collect.py +105 -31
- package/runtime/python/okstra_token_usage/pricing.py +6 -3
- package/runtime/templates/report-writer-prompt-preamble.md +2 -0
- package/runtime/validators/validate-run.py +6 -1
|
@@ -26,7 +26,7 @@ import uuid
|
|
|
26
26
|
from dataclasses import dataclass
|
|
27
27
|
from datetime import datetime, timezone
|
|
28
28
|
from pathlib import Path
|
|
29
|
-
from typing import Any, Callable, Mapping, Sequence
|
|
29
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
30
30
|
|
|
31
31
|
from . import cmux
|
|
32
32
|
from .agent.invocation import (
|
|
@@ -985,6 +985,25 @@ def _project_or_absolute(project_root: Path, value: str) -> Path:
|
|
|
985
985
|
return path if path.is_absolute() else project_root / path
|
|
986
986
|
|
|
987
987
|
|
|
988
|
+
def prompt_anchor_values(
|
|
989
|
+
prompt_path: Path, labels: Iterable[str],
|
|
990
|
+
) -> dict[str, str]:
|
|
991
|
+
"""`**<label>:** <value>` 앵커 줄을 라벨별로 읽는다. 첫 번째 값이 이긴다.
|
|
992
|
+
|
|
993
|
+
앵커는 materialize 가 문서 머리에 쓰고, 본문(리드 지시문)이 같은 라벨을
|
|
994
|
+
예시로 되풀이할 수 있다. `_prompt_write_paths` 는 마지막 값을 취하는데
|
|
995
|
+
그것은 write-policy 의 허용 집합을 넓힐 뿐이지만, 여기서 읽는 값은 워커가
|
|
996
|
+
실제로 쓰는 경로의 판정 기준이므로 머리의 것이어야 한다.
|
|
997
|
+
"""
|
|
998
|
+
values: dict[str, str] = {}
|
|
999
|
+
for line in prompt_path.read_text(encoding="utf-8").splitlines():
|
|
1000
|
+
for label in labels:
|
|
1001
|
+
prefix = f"**{label}:** "
|
|
1002
|
+
if label not in values and line.startswith(prefix):
|
|
1003
|
+
values[label] = line.removeprefix(prefix).strip()
|
|
1004
|
+
return values
|
|
1005
|
+
|
|
1006
|
+
|
|
988
1007
|
def _execution_dispatch_round(dispatch_kind: str) -> int:
|
|
989
1008
|
prefix = "reverify-r"
|
|
990
1009
|
if not dispatch_kind.startswith(prefix):
|
|
@@ -1477,6 +1496,14 @@ def _task_project_root(team_state_path: Path) -> Path | None:
|
|
|
1477
1496
|
return None
|
|
1478
1497
|
|
|
1479
1498
|
|
|
1499
|
+
class CompletedWithoutResultError(DispatchError):
|
|
1500
|
+
"""명부의 `resultPath` 파일 없이 `completed` 로 적으려 했다.
|
|
1501
|
+
|
|
1502
|
+
`_finish_attempt` 가 이 거절을 데이터로 돌려세우기 위해 구분한다 — 디스패치
|
|
1503
|
+
자신의 산출물은 있는데 명부가 가리키는 파일이 다른 경우다.
|
|
1504
|
+
"""
|
|
1505
|
+
|
|
1506
|
+
|
|
1480
1507
|
def _reject_completed_without_result(
|
|
1481
1508
|
team_state_path: Path, worker_id: str, worker: Mapping[str, Any]
|
|
1482
1509
|
) -> None:
|
|
@@ -1505,7 +1532,7 @@ def _reject_completed_without_result(
|
|
|
1505
1532
|
candidate = project_root / candidate
|
|
1506
1533
|
if candidate.is_file():
|
|
1507
1534
|
return
|
|
1508
|
-
raise
|
|
1535
|
+
raise CompletedWithoutResultError(
|
|
1509
1536
|
f"worker {worker_id} cannot be recorded completed: its result "
|
|
1510
1537
|
f"{relative} does not exist. Record the terminal status the dispatch "
|
|
1511
1538
|
"actually reached (`error` / `timeout` / `not-run`) with a reason."
|
|
@@ -1676,7 +1703,7 @@ def worker_session_ids(
|
|
|
1676
1703
|
for record in dispatches if isinstance(dispatches, list) else []:
|
|
1677
1704
|
if not isinstance(record, Mapping):
|
|
1678
1705
|
continue
|
|
1679
|
-
if worker_id is not None and record
|
|
1706
|
+
if worker_id is not None and _dispatch_worker_key(record) != worker_id:
|
|
1680
1707
|
continue
|
|
1681
1708
|
session_id = str(record.get("sessionId") or "").strip()
|
|
1682
1709
|
if session_id and session_id not in session_ids:
|
|
@@ -1684,6 +1711,27 @@ def worker_session_ids(
|
|
|
1684
1711
|
return session_ids
|
|
1685
1712
|
|
|
1686
1713
|
|
|
1714
|
+
def _dispatch_worker_key(record: Mapping[str, Any]) -> str:
|
|
1715
|
+
"""The `workers[]` row a dispatch record belongs to.
|
|
1716
|
+
|
|
1717
|
+
A v1 record names it as `workerId`. A v2 record carries execution identity
|
|
1718
|
+
instead and no `workerId` (`_dispatch_record`), so its row is the one the
|
|
1719
|
+
roster keys off the assignment ref's last segment — `critic/scope` is the
|
|
1720
|
+
row `scope`, `initial/claude-analyser` the row `claude-analyser` — the same
|
|
1721
|
+
projection `v2_worker_state_key` makes for the jobs file. Matching on
|
|
1722
|
+
`workerId` alone found no v2 record, so a pane worker's session id was never
|
|
1723
|
+
read and every v2 pane worker stayed `unavailable` (observed 2026-09-02,
|
|
1724
|
+
dev-10626 r04: the critic's session was on disk with 79 usage records).
|
|
1725
|
+
"""
|
|
1726
|
+
worker_id = str(record.get("workerId") or "").strip()
|
|
1727
|
+
if worker_id:
|
|
1728
|
+
return worker_id
|
|
1729
|
+
assignment_ref = record.get("assignmentRef")
|
|
1730
|
+
if not isinstance(assignment_ref, str):
|
|
1731
|
+
return ""
|
|
1732
|
+
return assignment_ref.rsplit("/", 1)[-1].strip()
|
|
1733
|
+
|
|
1734
|
+
|
|
1687
1735
|
def dispatch_result_path(
|
|
1688
1736
|
worker_id: str,
|
|
1689
1737
|
worker_result_path: Path,
|
|
@@ -2120,10 +2168,82 @@ def worker_jobs_from_file(
|
|
|
2120
2168
|
for item in workers
|
|
2121
2169
|
if isinstance(item, Mapping)
|
|
2122
2170
|
]
|
|
2171
|
+
_validate_jobs_file_prompt_anchors(jobs)
|
|
2123
2172
|
_validate_v2_jobs_file_authority(manifest, jobs)
|
|
2124
2173
|
return jobs
|
|
2125
2174
|
|
|
2126
2175
|
|
|
2176
|
+
def _normalized_path(path: Path) -> Path:
|
|
2177
|
+
return Path(os.path.normpath(path))
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
def _validate_jobs_file_prompt_anchors(jobs: Sequence[WorkerJob]) -> None:
|
|
2181
|
+
"""jobs-file 의 결과 경로가 프롬프트 앵커와 같은 파일인지 본다.
|
|
2182
|
+
|
|
2183
|
+
워커는 프롬프트의 `**Result Path:**` 에 쓰고(포인터를 가진 report-writer 는
|
|
2184
|
+
`**Worker Result Path:**` 에 포인터를), 수집기는 jobs-file 의
|
|
2185
|
+
`workerResultPath` 를 기다린다. 두 값은 리드가 따로 적으므로 어긋날 수 있고,
|
|
2186
|
+
어긋나면 워커가 결과를 다 써도 `required worker artifact was not produced`
|
|
2187
|
+
로 끝나 같은 프롬프트가 한 번 더 돈다. 실측(2026-09-02, fontsninja-v3-site
|
|
2188
|
+
dev-10626-1 error-analysis r04): 검증 디스패치 9건 중 8건이 이 형태였고
|
|
2189
|
+
재시도 6건이 같은 이유로 실패했다.
|
|
2190
|
+
|
|
2191
|
+
report-writer 는 한 가지를 더 본다 — 프롬프트의 `**Result Path:**`(서술문)가
|
|
2192
|
+
`dispatch_result_path` 가 정한 조립 입력과 같은가. 다르면 서술문은 써지지만
|
|
2193
|
+
`report-finalize` 가 읽는 자리에는 아무것도 없다.
|
|
2194
|
+
|
|
2195
|
+
프롬프트 파일이 아직 없는 잡은 건너뛴다. 실제 디스패치는 같은 파일을
|
|
2196
|
+
write-policy 검사(`_prompt_write_paths`)가 반드시 읽으므로 빠져나갈 자리가
|
|
2197
|
+
없고, 단위 테스트의 빈 경로만 여기서 면제된다.
|
|
2198
|
+
"""
|
|
2199
|
+
errors: list[str] = []
|
|
2200
|
+
for job in jobs:
|
|
2201
|
+
if not job.prompt_path.is_file():
|
|
2202
|
+
continue
|
|
2203
|
+
anchors = prompt_anchor_values(
|
|
2204
|
+
job.prompt_path, ("Result Path", "Worker Result Path"),
|
|
2205
|
+
)
|
|
2206
|
+
label = (
|
|
2207
|
+
"Worker Result Path"
|
|
2208
|
+
if anchors.get("Worker Result Path")
|
|
2209
|
+
else "Result Path"
|
|
2210
|
+
)
|
|
2211
|
+
declared = anchors.get(label)
|
|
2212
|
+
if not declared:
|
|
2213
|
+
continue
|
|
2214
|
+
declared_path = _normalized_path(
|
|
2215
|
+
_project_or_absolute(job.project_root, declared)
|
|
2216
|
+
)
|
|
2217
|
+
accepted = {
|
|
2218
|
+
_normalized_path(path)
|
|
2219
|
+
for path in (job.worker_result_path, *job.result_aliases)
|
|
2220
|
+
}
|
|
2221
|
+
if declared_path not in accepted:
|
|
2222
|
+
errors.append(
|
|
2223
|
+
f"{job.worker_id}: workerResultPath {job.worker_result_path} "
|
|
2224
|
+
f"differs from the prompt's **{label}:** {declared_path}"
|
|
2225
|
+
)
|
|
2226
|
+
narrative = anchors.get("Result Path")
|
|
2227
|
+
if (
|
|
2228
|
+
label == "Worker Result Path"
|
|
2229
|
+
and narrative
|
|
2230
|
+
and _normalized_path(job.result_path)
|
|
2231
|
+
!= _normalized_path(job.worker_result_path)
|
|
2232
|
+
and _normalized_path(_project_or_absolute(job.project_root, narrative))
|
|
2233
|
+
!= _normalized_path(job.result_path)
|
|
2234
|
+
):
|
|
2235
|
+
errors.append(
|
|
2236
|
+
f"{job.worker_id}: the prompt's **Result Path:** {narrative} is "
|
|
2237
|
+
f"not the result this run assembles from ({job.result_path})"
|
|
2238
|
+
)
|
|
2239
|
+
if errors:
|
|
2240
|
+
raise DispatchError(
|
|
2241
|
+
"jobs file result paths differ from the prompt anchors — the worker "
|
|
2242
|
+
"writes where the prompt says, so the jobs file must name that path: "
|
|
2243
|
+
+ "; ".join(errors)
|
|
2244
|
+
)
|
|
2245
|
+
|
|
2246
|
+
|
|
2127
2247
|
def _worker_job_from_file(
|
|
2128
2248
|
project_root: Path,
|
|
2129
2249
|
item: Mapping[str, Any],
|
|
@@ -2136,6 +2256,14 @@ def _worker_job_from_file(
|
|
|
2136
2256
|
resolve_wrapper: Callable[[str], Path],
|
|
2137
2257
|
default_provider: Callable[[str], str],
|
|
2138
2258
|
) -> WorkerJob:
|
|
2259
|
+
# 빠진 필드를 한꺼번에 댄다. 하나씩 거절하던 동안 세 워커짜리 파일 하나를
|
|
2260
|
+
# 통과시키는 데 dry-run 세 번이 들었다(2026-09-02 실측).
|
|
2261
|
+
missing = _missing_required_strings(item)
|
|
2262
|
+
if missing:
|
|
2263
|
+
raise DispatchError(
|
|
2264
|
+
"jobs file worker is missing required string fields: "
|
|
2265
|
+
+ ", ".join(missing)
|
|
2266
|
+
)
|
|
2139
2267
|
identity = worker_execution_identity(item)
|
|
2140
2268
|
worker_id = (
|
|
2141
2269
|
require_string(item, "workerId")
|
|
@@ -2218,6 +2346,25 @@ def _worker_job_from_file(
|
|
|
2218
2346
|
)
|
|
2219
2347
|
|
|
2220
2348
|
|
|
2349
|
+
_V1_JOB_REQUIRED_STRINGS = (
|
|
2350
|
+
"workerId", "promptPath", "workerResultPath", "modelExecutionValue", "role",
|
|
2351
|
+
)
|
|
2352
|
+
_V2_JOB_REQUIRED_STRINGS = (
|
|
2353
|
+
"provider", "assignmentRef", "participantRef", "roleExecutionRef",
|
|
2354
|
+
"executionLabel", "dutyId", "invocationRef", "promptPath",
|
|
2355
|
+
"workerResultPath", "modelExecutionValue", "role",
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
|
|
2359
|
+
def _missing_required_strings(item: Mapping[str, Any]) -> list[str]:
|
|
2360
|
+
keys = (
|
|
2361
|
+
_V2_JOB_REQUIRED_STRINGS
|
|
2362
|
+
if item.get("schemaVersion") == "2.0"
|
|
2363
|
+
else _V1_JOB_REQUIRED_STRINGS
|
|
2364
|
+
)
|
|
2365
|
+
return [key for key in keys if not string_value(item.get(key))]
|
|
2366
|
+
|
|
2367
|
+
|
|
2221
2368
|
def worker_execution_identity(
|
|
2222
2369
|
item: Mapping[str, Any],
|
|
2223
2370
|
) -> dict[str, Any] | None:
|
|
@@ -30,6 +30,8 @@ Sink = Callable[[str], str | None]
|
|
|
30
30
|
Channel = Literal["stdout", "stderr"]
|
|
31
31
|
SinkSpec = tuple[Channel, Sink]
|
|
32
32
|
ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
|
|
33
|
+
# 이벤트가 실어 온 토큰 사용량 스냅샷(공급자 어휘 그대로). 없으면 None.
|
|
34
|
+
ObserveUsage = Callable[[Mapping[str, Any]], Mapping[str, Any] | None]
|
|
33
35
|
|
|
34
36
|
WORKER = "worker"
|
|
35
37
|
|
|
@@ -113,6 +115,11 @@ class JsonEvents:
|
|
|
113
115
|
|
|
114
116
|
normalise: Normalise
|
|
115
117
|
observe: ObserveServedModel
|
|
118
|
+
# 어떤 CLI 는 토큰 사용량을 홈 디렉터리의 트랜스크립트가 아니라 이 스트림에만
|
|
119
|
+
# 싣는다(agy 의 `result.usage`). 어댑터가 그 스냅샷을 읽는 법을 넘기면
|
|
120
|
+
# 러너가 마지막 값을 status 사이드카에 적어, 수집기가 로그를 다시 파싱하지
|
|
121
|
+
# 않아도 된다. 어느 CLI 가 그런지는 이 계층이 알 필요도, 알아서도 안 된다.
|
|
122
|
+
observe_usage: ObserveUsage | None = None
|
|
116
123
|
# 어떤 CLI 의 streaming-json 은 문장이 아니라 토큰을 한 줄씩 보낸다. 붙이지
|
|
117
124
|
# 않으면 pane 이 한 글자 한 줄이 된다. 기본은 끄고, 그런 CLI 의 어댑터가 켠다 —
|
|
118
125
|
# 어느 CLI 가 그런지는 이 계층이 알 필요도, 알아서도 안 되는 사실이다.
|
|
@@ -7,6 +7,11 @@ from dataclasses import dataclass
|
|
|
7
7
|
from typing import Literal
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
# 판정 우선순위 — 여러 조건이 동시에 성립할 때 앞선 것이 이긴다. 저작 계약이
|
|
11
|
+
# 이 순서를 작성자에게 말하므로 `calculate_exact_coverage` 의 분기와 함께 간다.
|
|
12
|
+
COVERAGE_VERDICT_PRECEDENCE = ("contradicted", "under", "over", "exact")
|
|
13
|
+
|
|
14
|
+
|
|
10
15
|
class ExactCoverageError(ValueError):
|
|
11
16
|
"""Raised when coverage input cannot define a valid comparison."""
|
|
12
17
|
|
|
@@ -22,7 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
import json
|
|
23
23
|
import re
|
|
24
24
|
from pathlib import Path
|
|
25
|
-
from typing import Any
|
|
25
|
+
from typing import Any, Mapping
|
|
26
26
|
|
|
27
27
|
class SchemaError(ValueError):
|
|
28
28
|
"""Raised when a schema itself is malformed (e.g. a $ref points
|
|
@@ -334,6 +334,234 @@ class _Validator:
|
|
|
334
334
|
self.errors.append(f"{_format_path(path)}: {message}")
|
|
335
335
|
|
|
336
336
|
|
|
337
|
+
def verdict_token_rule(schema: Mapping[str, Any], task_type: str) -> tuple[str, ...]:
|
|
338
|
+
"""이 task type 의 `finalVerdict.verdictToken` 에 스키마가 허용하는 값.
|
|
339
|
+
|
|
340
|
+
스키마는 `allOf` 의 if/then 가지로 세 묶음을 나눈다(분석형 enum,
|
|
341
|
+
final-verification enum, 나머지 `not-applicable` const). 작성기는 그 가지를
|
|
342
|
+
읽지 못해 `analysis-complete` 를 error-analysis 에 썼고(2026-09-02 실측),
|
|
343
|
+
조립이 아니라 HTML 렌더에서야 거절됐다. 여기서 뽑아 저작 계약에 싣는다.
|
|
344
|
+
가지가 없으면 빈 튜플이다 — 제약이 없다는 뜻이지 실패가 아니다.
|
|
345
|
+
"""
|
|
346
|
+
def _walk(node: Any) -> tuple[str, ...]:
|
|
347
|
+
if isinstance(node, dict):
|
|
348
|
+
condition = node.get("if")
|
|
349
|
+
then = node.get("then")
|
|
350
|
+
if isinstance(condition, dict) and isinstance(then, dict):
|
|
351
|
+
header = (condition.get("properties") or {}).get("header") or {}
|
|
352
|
+
selector = (header.get("properties") or {}).get("taskType") or {}
|
|
353
|
+
allowed = selector.get("enum")
|
|
354
|
+
if allowed is None and "const" in selector:
|
|
355
|
+
allowed = [selector["const"]]
|
|
356
|
+
if isinstance(allowed, list) and task_type in allowed:
|
|
357
|
+
verdict = (then.get("properties") or {}).get("finalVerdict") or {}
|
|
358
|
+
token = (verdict.get("properties") or {}).get("verdictToken") or {}
|
|
359
|
+
if "const" in token:
|
|
360
|
+
return (str(token["const"]),)
|
|
361
|
+
if isinstance(token.get("enum"), list):
|
|
362
|
+
return tuple(str(value) for value in token["enum"])
|
|
363
|
+
for value in node.values():
|
|
364
|
+
found = _walk(value)
|
|
365
|
+
if found:
|
|
366
|
+
return found
|
|
367
|
+
elif isinstance(node, list):
|
|
368
|
+
for value in node:
|
|
369
|
+
found = _walk(value)
|
|
370
|
+
if found:
|
|
371
|
+
return found
|
|
372
|
+
return ()
|
|
373
|
+
|
|
374
|
+
return _walk(dict(schema))
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def task_block_rules(schema: Mapping[str, Any], block_key: str) -> tuple[str, ...]:
|
|
378
|
+
"""이 task type 의 데이터 블록(`properties[block_key]`)이 요구하는 모양을 한
|
|
379
|
+
객체당 한 줄의 저작 계약 문장으로 편다.
|
|
380
|
+
|
|
381
|
+
리드는 report-writer 지시문을 쓸 때 블록 안쪽의 필수 필드·식별자 패턴·
|
|
382
|
+
정수 필드·고정 길이 배열을 알 길이 프롬프트 경로에 없어 스키마 JSON 을
|
|
383
|
+
손으로 파싱했고, 그래도 놓친 만큼 같은 리포트를 다시 썼다(2026-09-03 실측,
|
|
384
|
+
dev-10626 implementation-option-selection: 네 회차). 여기서 뽑아 합성 묶음의
|
|
385
|
+
저작 계약에 싣는다. 블록이 없거나 객체가 아니면 빈 튜플이다.
|
|
386
|
+
|
|
387
|
+
경로는 스키마 키다(`rankedOptions[]` 는 `- Item N` 목록의 각 항목). 같은
|
|
388
|
+
정의(`$ref`)가 두 경로에 나오면 두 번째는 첫 경로를 가리킨다 — 랭킹 옵션과
|
|
389
|
+
감사 항목이 하위 구조 여섯 개를 공유한다.
|
|
390
|
+
"""
|
|
391
|
+
root = dict(schema)
|
|
392
|
+
block = (root.get("properties") or {}).get(block_key)
|
|
393
|
+
if not isinstance(block, dict):
|
|
394
|
+
return ()
|
|
395
|
+
lines: list[str] = []
|
|
396
|
+
described: dict[str, str] = {}
|
|
397
|
+
_describe_object(root, block, block_key, lines, described)
|
|
398
|
+
return tuple(lines)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _resolved(root: dict, node: Any) -> tuple[dict, str | None]:
|
|
402
|
+
"""`$ref` 를 풀고, allOf 가지의 properties/required 를 얕게 합친다.
|
|
403
|
+
|
|
404
|
+
반환값의 둘째는 이 노드가 가리킨 정의 이름 — 같은 정의를 두 경로에서
|
|
405
|
+
두 번 펴지 않기 위해 쓴다.
|
|
406
|
+
"""
|
|
407
|
+
if not isinstance(node, dict):
|
|
408
|
+
return {}, None
|
|
409
|
+
ref_name: str | None = None
|
|
410
|
+
if "$ref" in node:
|
|
411
|
+
ref_name = str(node["$ref"]).rsplit("/", 1)[-1]
|
|
412
|
+
node = _resolve_ref(node["$ref"], root)
|
|
413
|
+
merged = dict(node)
|
|
414
|
+
for branch in node.get("allOf") or []:
|
|
415
|
+
resolved, branch_ref = _resolved(root, branch)
|
|
416
|
+
if ref_name is None:
|
|
417
|
+
ref_name = branch_ref
|
|
418
|
+
properties = dict(merged.get("properties") or {})
|
|
419
|
+
for key, value in (resolved.get("properties") or {}).items():
|
|
420
|
+
existing = properties.get(key)
|
|
421
|
+
properties[key] = {**existing, **value} if isinstance(existing, dict) else value
|
|
422
|
+
merged["properties"] = properties
|
|
423
|
+
merged["required"] = list(
|
|
424
|
+
dict.fromkeys([*(merged.get("required") or []), *(resolved.get("required") or [])])
|
|
425
|
+
)
|
|
426
|
+
for keyword in ("type", "pattern", "const", "enum", "minItems", "maxItems", "items", "prefixItems"):
|
|
427
|
+
if keyword in resolved and keyword not in merged:
|
|
428
|
+
merged[keyword] = resolved[keyword]
|
|
429
|
+
return merged, ref_name
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _describe_object(
|
|
433
|
+
root: dict, node: Any, path: str, lines: list[str], described: dict[str, str],
|
|
434
|
+
) -> None:
|
|
435
|
+
schema, ref_name = _resolved(root, node)
|
|
436
|
+
if ref_name and ref_name in described:
|
|
437
|
+
lines.append(f"`{path}`: same shape as `{described[ref_name]}`")
|
|
438
|
+
return
|
|
439
|
+
if ref_name:
|
|
440
|
+
described[ref_name] = path
|
|
441
|
+
properties = schema.get("properties") or {}
|
|
442
|
+
required = [key for key in (schema.get("required") or []) if isinstance(key, str)]
|
|
443
|
+
optional = [key for key in properties if key not in required]
|
|
444
|
+
parts: list[str] = []
|
|
445
|
+
if required:
|
|
446
|
+
parts.append("required " + ", ".join(f"`{key}`" for key in required))
|
|
447
|
+
if optional:
|
|
448
|
+
parts.append("optional " + ", ".join(f"`{key}`" for key in optional))
|
|
449
|
+
nested: list[tuple[str, Any]] = []
|
|
450
|
+
for key, value in properties.items():
|
|
451
|
+
constraint = _describe_property(root, value, f"{path}.{key}", nested)
|
|
452
|
+
if constraint:
|
|
453
|
+
parts.append(f"`{key}` {constraint}")
|
|
454
|
+
lines.append(f"`{path}`: " + "; ".join(parts))
|
|
455
|
+
for child_path, child in nested:
|
|
456
|
+
_describe_object(root, child, child_path, lines, described)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _describe_property(
|
|
460
|
+
root: dict, node: Any, path: str, nested: list[tuple[str, Any]],
|
|
461
|
+
) -> str:
|
|
462
|
+
"""한 속성의 제약을 한 구절로. 객체 자식은 `nested` 에 넣어 자기 줄을 받는다."""
|
|
463
|
+
schema, _ref = _resolved(root, node)
|
|
464
|
+
branches = schema.get("oneOf") or schema.get("anyOf") or []
|
|
465
|
+
nullable = False
|
|
466
|
+
if branches:
|
|
467
|
+
resolved_branches = [_resolved(root, branch)[0] for branch in branches]
|
|
468
|
+
nullable = any(branch.get("type") == "null" for branch in resolved_branches)
|
|
469
|
+
others = [branch for branch in branches if _resolved(root, branch)[0].get("type") != "null"]
|
|
470
|
+
if len(others) == 1:
|
|
471
|
+
schema = _resolved(root, others[0])[0] | {"__branch": others[0]}
|
|
472
|
+
types = schema.get("type")
|
|
473
|
+
types = types if isinstance(types, list) else ([types] if types else [])
|
|
474
|
+
if "null" in types:
|
|
475
|
+
nullable = True
|
|
476
|
+
types = [item for item in types if item != "null"]
|
|
477
|
+
suffix = " or `_none_`" if nullable else ""
|
|
478
|
+
if "const" in schema:
|
|
479
|
+
return f"exactly `{schema['const']}`{suffix}"
|
|
480
|
+
if isinstance(schema.get("enum"), list):
|
|
481
|
+
return "one of " + ", ".join(f"`{value}`" for value in schema["enum"]) + suffix
|
|
482
|
+
if "properties" in schema or types == ["object"]:
|
|
483
|
+
nested.append((path, schema.get("__branch", node)))
|
|
484
|
+
return f"object (see `{path}`){suffix}"
|
|
485
|
+
if types == ["array"] or "items" in schema or "prefixItems" in schema:
|
|
486
|
+
return _describe_array(root, schema, path, nested) + suffix
|
|
487
|
+
if types == ["integer"]:
|
|
488
|
+
return "integer" + _range(schema) + suffix
|
|
489
|
+
if types == ["number"]:
|
|
490
|
+
return "number" + _range(schema) + suffix
|
|
491
|
+
if types == ["string"]:
|
|
492
|
+
pattern = schema.get("pattern")
|
|
493
|
+
return (f"matches `{pattern}`" if pattern else "string") + suffix
|
|
494
|
+
if types == ["boolean"]:
|
|
495
|
+
return "`true` or `false`" + suffix
|
|
496
|
+
return ""
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _describe_array(
|
|
500
|
+
root: dict, schema: dict, path: str, nested: list[tuple[str, Any]],
|
|
501
|
+
) -> str:
|
|
502
|
+
minimum = schema.get("minItems")
|
|
503
|
+
maximum = schema.get("maxItems")
|
|
504
|
+
if maximum == 0:
|
|
505
|
+
return "must be empty (`> _none_`)"
|
|
506
|
+
if minimum is not None and minimum == maximum:
|
|
507
|
+
size = f"exactly {maximum} items"
|
|
508
|
+
else:
|
|
509
|
+
bounds = [
|
|
510
|
+
text
|
|
511
|
+
for text in (
|
|
512
|
+
f"at least {minimum}" if minimum else "",
|
|
513
|
+
f"at most {maximum}" if maximum is not None else "",
|
|
514
|
+
)
|
|
515
|
+
if text
|
|
516
|
+
]
|
|
517
|
+
size = "list" + (f" ({', '.join(bounds)})" if bounds else "")
|
|
518
|
+
prefix_items = schema.get("prefixItems")
|
|
519
|
+
if isinstance(prefix_items, list) and prefix_items:
|
|
520
|
+
order = _prefix_order(root, prefix_items)
|
|
521
|
+
first, _ = _resolved(root, prefix_items[0])
|
|
522
|
+
if "properties" in first:
|
|
523
|
+
nested.append((f"{path}[]", prefix_items[0]))
|
|
524
|
+
return f"{size} in this order: {order}; each item (see `{path}[]`)"
|
|
525
|
+
return f"{size} in this order: {order}"
|
|
526
|
+
items = schema.get("items")
|
|
527
|
+
item_schema, _ = _resolved(root, items) if items is not None else ({}, None)
|
|
528
|
+
if "properties" in item_schema:
|
|
529
|
+
nested.append((f"{path}[]", items))
|
|
530
|
+
return f"{size} of objects (see `{path}[]`)"
|
|
531
|
+
if "pattern" in item_schema:
|
|
532
|
+
return f"{size} of strings matching `{item_schema['pattern']}`"
|
|
533
|
+
if isinstance(item_schema.get("enum"), list):
|
|
534
|
+
return f"{size}, each one of " + ", ".join(f"`{value}`" for value in item_schema["enum"])
|
|
535
|
+
if item_schema.get("type"):
|
|
536
|
+
return f"{size} of {item_schema['type']}"
|
|
537
|
+
return size
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _prefix_order(root: dict, prefix_items: list) -> str:
|
|
541
|
+
"""고정 순서 배열의 각 자리가 못 박은 값 — `criterion` 처럼 상수인 속성."""
|
|
542
|
+
labels: list[str] = []
|
|
543
|
+
for item in prefix_items:
|
|
544
|
+
merged, _ = _resolved(root, item)
|
|
545
|
+
consts = [
|
|
546
|
+
f"`{key}`=`{value['const']}`"
|
|
547
|
+
for key, value in (merged.get("properties") or {}).items()
|
|
548
|
+
if isinstance(value, dict) and "const" in value
|
|
549
|
+
]
|
|
550
|
+
labels.append(", ".join(consts) if consts else "item")
|
|
551
|
+
return "; ".join(labels)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _range(schema: dict) -> str:
|
|
555
|
+
low, high = schema.get("minimum"), schema.get("maximum")
|
|
556
|
+
if low is not None and high is not None:
|
|
557
|
+
return f" {low}..{high}"
|
|
558
|
+
if low is not None:
|
|
559
|
+
return f" >= {low}"
|
|
560
|
+
if high is not None:
|
|
561
|
+
return f" <= {high}"
|
|
562
|
+
return ""
|
|
563
|
+
|
|
564
|
+
|
|
337
565
|
def validate(data: Any, schema: dict) -> list[str]:
|
|
338
566
|
"""Validate ``data`` against ``schema``. Returns the list of human-
|
|
339
567
|
readable error messages (empty when the data is valid)."""
|
|
@@ -26,6 +26,11 @@ MIN_CRITERION_VALUE = 1
|
|
|
26
26
|
MAX_CRITERION_VALUE = 5
|
|
27
27
|
MIN_FEASIBLE_VOTES = 2
|
|
28
28
|
MAX_RANKED_OPTIONS = 3
|
|
29
|
+
# 분석자 한 명이 낼 수 있는 원시 후보(`rankedOptions` + `candidateAudit`) 수.
|
|
30
|
+
# 저작 계약(`report_synthesis_packet`)이 같은 값을 작성자에게 말한다 — 리터럴로
|
|
31
|
+
# 두던 동안 계약은 `proposedBy` 를 `string` 으로만 적었고, 리드가 세 이름을
|
|
32
|
+
# 쉼표로 이어 쓰라고 지시해 한 라운드를 버렸다(2026-09-03 실측).
|
|
33
|
+
MAX_RAW_CANDIDATES_PER_ANALYSER = 3
|
|
29
34
|
CANDIDATE_COMPARISON_ROUTING = "pending-direction-selection"
|
|
30
35
|
NO_VALID_OPTIONS_ROUTING = "blocked"
|
|
31
36
|
|
|
@@ -362,9 +367,9 @@ def _validate_candidate_ids_and_caps(
|
|
|
362
367
|
analyser_set = set(participating_analysers)
|
|
363
368
|
if any(worker not in analyser_set for worker in counts):
|
|
364
369
|
errors.append("every candidate proposedBy must name a participating analyser")
|
|
365
|
-
if any(count >
|
|
370
|
+
if any(count > MAX_RAW_CANDIDATES_PER_ANALYSER for count in counts.values()):
|
|
366
371
|
errors.append("each analyser may submit at most three raw candidates")
|
|
367
|
-
if len(candidate_ids) > len(analyser_set) *
|
|
372
|
+
if len(candidate_ids) > len(analyser_set) * MAX_RAW_CANDIDATES_PER_ANALYSER:
|
|
368
373
|
errors.append("total raw candidates exceed analyser count times three")
|
|
369
374
|
|
|
370
375
|
|
|
@@ -480,7 +480,7 @@ def _enforce_schema(data: dict) -> dict | None:
|
|
|
480
480
|
# 구체버전 계열로 돌았는지" 가독성만 준다. CLI 가 실제 고른 버전과 다를 수
|
|
481
481
|
# 있으나 표시이므로 실행에는 무해하다. 새 버전이 나오면 여기만 갱신한다.
|
|
482
482
|
_DISPLAY_CONCRETE_CLAUDE = {
|
|
483
|
-
"fable": "claude-fable-5",
|
|
483
|
+
"fable": "claude-fable-5-1",
|
|
484
484
|
"opus": "claude-opus-5",
|
|
485
485
|
"sonnet": "claude-sonnet-5",
|
|
486
486
|
"haiku": "claude-haiku-4-5",
|
|
@@ -349,6 +349,10 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
|
|
|
349
349
|
# 방향 선택 게이트를 통과할 수 없었다.
|
|
350
350
|
"--source-report",
|
|
351
351
|
sidecar_source_rel(markdown_path),
|
|
352
|
+
# 머리말 소요 시간의 출처. 보고서 seq 로 되짚으면 seq 가 갈린
|
|
353
|
+
# run 의 team-state 를 읽는다 — 여기서는 이미 알고 있다.
|
|
354
|
+
"--team-state",
|
|
355
|
+
str(ctx.team_state_path),
|
|
352
356
|
],
|
|
353
357
|
),
|
|
354
358
|
(
|
|
@@ -745,7 +749,9 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
745
749
|
required=True,
|
|
746
750
|
help="final-report record (.data.json); a leftover markdown path still locates the sibling record",
|
|
747
751
|
)
|
|
748
|
-
|
|
752
|
+
# `okstra report-finalize` 래퍼(src/lib/python-command.mts)가 넣고, 사용자가
|
|
753
|
+
# 직접 주면 거절한다. usage 에 필수로 찍히면 그 거절과 모순되므로 감춘다.
|
|
754
|
+
parser.add_argument("--workspace-root", required=True, help=argparse.SUPPRESS)
|
|
749
755
|
parser.add_argument(
|
|
750
756
|
"--team-state",
|
|
751
757
|
default="",
|