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.
Files changed (36) hide show
  1. package/dist/commands/lifecycle/install.mjs +3 -1
  2. package/dist/commands/lifecycle/install.mjs.map +1 -1
  3. package/docs/architecture.md +2 -2
  4. package/docs/cli.md +2 -2
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/bin/okstra-render-report-views.py +23 -5
  8. package/runtime/prompts/lead/adapters/cmux.md +3 -3
  9. package/runtime/prompts/lead/convergence.md +3 -1
  10. package/runtime/prompts/lead/report-writer.md +1 -1
  11. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
  12. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
  13. package/runtime/python/okstra_ctl/agent/activity.py +4 -1
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
  15. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
  16. package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
  17. package/runtime/python/okstra_ctl/context_cost.py +16 -2
  18. package/runtime/python/okstra_ctl/convergence.py +6 -1
  19. package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
  20. package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
  21. package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
  22. package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
  23. package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
  24. package/runtime/python/okstra_ctl/implementation_options.py +7 -2
  25. package/runtime/python/okstra_ctl/render_final_report.py +1 -1
  26. package/runtime/python/okstra_ctl/report_finalize.py +7 -1
  27. package/runtime/python/okstra_ctl/report_narrative.py +132 -33
  28. package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
  29. package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
  30. package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
  31. package/runtime/python/okstra_ctl/worker_runner.py +42 -8
  32. package/runtime/python/okstra_token_usage/antigravity.py +50 -12
  33. package/runtime/python/okstra_token_usage/collect.py +105 -31
  34. package/runtime/python/okstra_token_usage/pricing.py +6 -3
  35. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  36. 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 DispatchError(
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.get("workerId") != worker_id:
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 > 3 for count in counts.values()):
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) * 3:
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
- parser.add_argument("--workspace-root", required=True)
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="",