okstra 0.186.4 → 0.186.6

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 (44) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +3 -3
  3. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/bin/okstra-render-report-views.py +6 -5
  7. package/runtime/prompts/launch.template.md +14 -0
  8. package/runtime/prompts/lead/convergence.md +2 -2
  9. package/runtime/prompts/lead/okstra-lead-contract.md +4 -14
  10. package/runtime/prompts/lead/plan-body-verification.md +4 -3
  11. package/runtime/prompts/lead/report-writer.md +3 -1
  12. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  13. package/runtime/prompts/profiles/error-analysis.md +2 -2
  14. package/runtime/prompts/profiles/final-verification.md +2 -2
  15. package/runtime/prompts/profiles/implementation-planning.md +3 -3
  16. package/runtime/prompts/profiles/requirements-discovery.md +2 -2
  17. package/runtime/prompts/wizard/prompts.ko.json +2 -4
  18. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +2 -5
  19. package/runtime/python/okstra_ctl/agent_activity.py +6 -0
  20. package/runtime/python/okstra_ctl/clarification_items.py +67 -11
  21. package/runtime/python/okstra_ctl/next_phase.py +6 -3
  22. package/runtime/python/okstra_ctl/plan_items.py +32 -6
  23. package/runtime/python/okstra_ctl/plan_items_cli.py +57 -3
  24. package/runtime/python/okstra_ctl/render_final_report.py +3 -1
  25. package/runtime/python/okstra_ctl/report_assembly.py +65 -3
  26. package/runtime/python/okstra_ctl/report_html/run_usage.py +5 -1
  27. package/runtime/python/okstra_ctl/report_projections.py +45 -4
  28. package/runtime/python/okstra_ctl/run.py +21 -6
  29. package/runtime/python/okstra_ctl/usage_cells.py +15 -0
  30. package/runtime/python/okstra_ctl/user_response.py +72 -6
  31. package/runtime/python/okstra_ctl/wizard.py +1 -5
  32. package/runtime/python/okstra_token_usage/codex.py +32 -3
  33. package/runtime/python/okstra_token_usage/collect.py +148 -15
  34. package/runtime/python/okstra_token_usage/grok.py +24 -5
  35. package/runtime/python/okstra_token_usage/report.py +12 -2
  36. package/runtime/skills/okstra-user-response/SKILL.md +4 -2
  37. package/runtime/templates/reports/html/assets/base.css +3 -9
  38. package/runtime/templates/reports/html/assets/base.js +0 -21
  39. package/runtime/templates/reports/html/base.template.html +1 -4
  40. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +9 -28
  41. package/runtime/validators/lib/runners.sh +5 -1
  42. package/runtime/validators/validate-report-views.py +2 -1
  43. package/runtime/validators/validate-run.py +240 -102
  44. package/runtime/validators/validate_session_conformance.py +71 -18
@@ -649,15 +649,24 @@ def advisory_plan_body_gating(
649
649
  return not (isinstance(items, list) and items)
650
650
 
651
651
 
652
+ CRITIC_WORKER_ID = "critic-worker"
653
+
654
+
655
+ def is_critic_worker(worker: str) -> bool:
656
+ """본문 동수를 가르는 critic 표인지."""
657
+ name = str(worker or "").strip().lower()
658
+ return name == CRITIC_WORKER_ID or name.endswith("-critic-worker")
659
+
660
+
652
661
  def tie_vote_item_ids(
653
662
  items: Sequence[Mapping[str, Any]],
654
663
  ledger: Mapping[str, str] | None,
655
664
  tied_ids: Sequence[str],
656
665
  ) -> list[str]:
657
- """needs-reverify 동수 항목만 번째 표에 보낸다.
666
+ """needs-reverify 동수 항목만 critic 보낸다.
658
667
 
659
668
  첫 라운드 큐나 self-fix 재검증 큐와 섞지 않는다. 동수가 없으면 빈 목록이고
660
- 번째 워커는 뜨지 않는다.
669
+ critic 배치를 띄우지 않는다.
661
670
  """
662
671
  allowed = set(dispatch_item_ids(items, ledger))
663
672
  queue: list[str] = []
@@ -686,6 +695,13 @@ ENVIRONMENT_CORRECTION_PREAMBLE = (
686
695
  "a valid answer to any of them.\n"
687
696
  )
688
697
 
698
+ CRITIC_TIE_PREAMBLE = (
699
+ "You are the critic tie-break. Only the items below are in dispute. "
700
+ "Each item already has one AGREE and one DISAGREE from the two plan-body "
701
+ "verifiers. Decide the item: AGREE or DISAGREE(<kind>). Your verdict "
702
+ "settles the split. Do not re-open items that are not listed.\n"
703
+ )
704
+
689
705
 
690
706
  def item_cites_build_command(item: Mapping[str, Any]) -> bool:
691
707
  """이 항목이 계획 워크트리에서 실행 불가한 빌드/테스트 명령을 인용하는가."""
@@ -775,10 +791,15 @@ def blanket_unverifiable_workers(
775
791
 
776
792
  def _is_tie(item: Mapping[str, Any]) -> bool:
777
793
  tokens = [
778
- token for _worker, token in _item_votes(item) if not _error_verdict(token)
794
+ token
795
+ for worker, token in _item_votes(item)
796
+ if not _error_verdict(token) and not is_critic_worker(worker)
779
797
  ]
780
798
  if len(tokens) < 2:
781
799
  return False
800
+ if any(is_critic_worker(worker) and not _error_verdict(token)
801
+ for worker, token in _item_votes(item)):
802
+ return False
782
803
  disagree = sum(1 for token in tokens if token.upper().startswith("DISAGREE"))
783
804
  agree = sum(1 for token in tokens if token.upper() in {"AGREE", "SUPPLEMENT"})
784
805
  return disagree == agree and disagree > 0
@@ -828,10 +849,10 @@ def next_dispatch(
828
849
  )
829
850
  if ties:
830
851
  return NextDispatch(
831
- kind="queue-reverify",
832
- workers=tuple(sorted(_worker_vote_map(items))),
852
+ kind="critic-tie",
853
+ workers=(CRITIC_WORKER_ID,),
833
854
  item_ids=ties,
834
- reason="unsettled tie on a blocking kind",
855
+ reason="unsettled analyser tie; critic settles",
835
856
  )
836
857
  return NextDispatch(
837
858
  kind="none", workers=(), item_ids=(),
@@ -843,3 +864,8 @@ def correction_prompt_text(queue_markdown: str) -> str:
843
864
  """시정 프롬프트. 환경 예외 단락이 큐보다 앞이다."""
844
865
  return f"{ENVIRONMENT_CORRECTION_PREAMBLE.rstrip()}\n\n{queue_markdown.lstrip()}"
845
866
 
867
+
868
+ def critic_tie_prompt_text(queue_markdown: str) -> str:
869
+ """동수 critic 프롬프트. 가르는 지시가 큐보다 앞이다."""
870
+ return f"{CRITIC_TIE_PREAMBLE.rstrip()}\n\n{queue_markdown.lstrip()}"
871
+
@@ -30,6 +30,7 @@ from .plan_items import (
30
30
  advisory_plan_body_gating,
31
31
  content_hash,
32
32
  correction_prompt_text,
33
+ critic_tie_prompt_text,
33
34
  dispatch_item_ids,
34
35
  extract_plan_items,
35
36
  next_dispatch,
@@ -154,7 +155,7 @@ def _parser() -> argparse.ArgumentParser:
154
155
  )
155
156
  prepare.add_argument(
156
157
  "--tie-vote", action="store_true",
157
- help="dispatch queue is the needs-reverify ties in --state, nothing else",
158
+ help="dispatch queue is the needs-reverify ties in --state for critic-worker",
158
159
  )
159
160
  prompt = commands.add_parser("prompt")
160
161
  prompt.add_argument("--run-manifest", type=Path, required=True)
@@ -348,6 +349,11 @@ def _prepare(args: argparse.Namespace) -> dict[str, Any]:
348
349
  args.run_manifest,
349
350
  **_queue_kwargs(args),
350
351
  )
352
+ if getattr(args, "tie_vote", False):
353
+ envelope["dispatchKind"] = "critic-tie"
354
+ envelope["tieSplits"] = _tie_splits_from_state(
355
+ getattr(args, "state", None), envelope["dispatchQueue"],
356
+ )
351
357
  gating = not advisory_plan_body_gating(_planning(source), envelope["items"])
352
358
  _sync_task_manifest_gating(args.run_manifest, gating)
353
359
  write_json_atomic(output, envelope)
@@ -500,6 +506,44 @@ def _render_payload(item_id: str, payload: object) -> list[str]:
500
506
  return rows
501
507
 
502
508
 
509
+ def _render_analyser_split(verdicts: object) -> str:
510
+ """동수 항목에 이미 찍힌 분석자 표를 critic 프롬프트에 붙인다."""
511
+ if not isinstance(verdicts, list):
512
+ return ""
513
+ rows: list[str] = []
514
+ for verdict in verdicts:
515
+ if not isinstance(verdict, Mapping):
516
+ continue
517
+ worker = str(verdict.get("worker") or "").strip()
518
+ token = str(verdict.get("verdict") or "").strip()
519
+ if not worker or not token:
520
+ continue
521
+ kind = str(verdict.get("breakageKind") or "").strip()
522
+ label = f"{token}({kind})" if kind else token
523
+ rows.append(f"- `{worker}`: `{label}`\n")
524
+ if not rows:
525
+ return ""
526
+ return "Analyser split:\n" + "".join(rows)
527
+
528
+
529
+ def _tie_splits_from_state(
530
+ state_path: Path | None, queue: Sequence[str],
531
+ ) -> dict[str, list[Any]]:
532
+ if state_path is None or not state_path.is_file():
533
+ return {}
534
+ items = _state_plan_body_items(_load_json_object(state_path), state_path)
535
+ allowed = set(queue)
536
+ splits: dict[str, list[Any]] = {}
537
+ for row in items:
538
+ if not isinstance(row, Mapping):
539
+ continue
540
+ item_id = str(row.get("id") or "")
541
+ verdicts = row.get("verdicts")
542
+ if item_id in allowed and isinstance(verdicts, list):
543
+ splits[item_id] = verdicts
544
+ return splits
545
+
546
+
503
547
  def _prompt(args: argparse.Namespace) -> str:
504
548
  envelope = _load_json_object(
505
549
  _prepared_items_path(args.run_manifest, require_regular=True)
@@ -520,7 +564,17 @@ def _prompt(args: argparse.Namespace) -> str:
520
564
  rows.extend((f"\n## Plan item {index}\n", line("Item ID", item_id),
521
565
  line("Subject", item.get("subject"))))
522
566
  rows.extend(_render_payload(item_id, item.get("payload")))
523
- return "".join(rows)
567
+ split = _render_analyser_split(
568
+ (envelope.get("tieSplits") or {}).get(item_id) if isinstance(
569
+ envelope.get("tieSplits"), Mapping
570
+ ) else None
571
+ )
572
+ if split:
573
+ rows.append(split)
574
+ body = "".join(rows)
575
+ if envelope.get("dispatchKind") == "critic-tie":
576
+ return critic_tie_prompt_text(body)
577
+ return body
524
578
 
525
579
 
526
580
  def _validate_prepared(args: argparse.Namespace) -> dict[str, Any]:
@@ -988,7 +1042,7 @@ def _append_item_verdicts(
988
1042
  round_number: int,
989
1043
  project_root: Path | None,
990
1044
  ) -> None:
991
- """동수 항목의 번째 표. 이미 투표한 워커는 거부한다."""
1045
+ """동수 항목의 critic 표. 이미 투표한 워커는 거부한다."""
992
1046
  stamped = _stamped_verdicts(incoming, round_number, project_root)
993
1047
  existing = item.get("verdicts")
994
1048
  current = existing if isinstance(existing, list) else []
@@ -557,7 +557,9 @@ def _ai_markdown_context(data: dict, schema: dict | None) -> dict:
557
557
  context["aiTaskProperty"] = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
558
558
  context["aiTaskTemplate"] = _markdown_task_template(task_type)
559
559
  context["aiBlockingIds"] = progress_blocking_ids(
560
- data.get("clarificationItems", []), USER_INPUT_BLOCKS
560
+ data.get("clarificationItems", []),
561
+ USER_INPUT_BLOCKS,
562
+ report_data=data,
561
563
  )
562
564
  sections = ReportSections(data, schema or {})
563
565
  context["md"] = sections.section
@@ -9,7 +9,11 @@ from pathlib import Path
9
9
  from typing import Any, Callable, Mapping, Sequence
10
10
 
11
11
  from .agent_activity import agent_activity_rows
12
- from .clarification_items import clarification_disposition, row_blocks_progress
12
+ from .clarification_items import (
13
+ clarification_disposition,
14
+ incorporated_clarification_ids,
15
+ row_blocks_progress,
16
+ )
13
17
  from .final_report_schema import load_schema_version, validate
14
18
  from .report_inputs import ReportInputPath, report_input_paths, uses_report_contract_v3
15
19
  from .json_boundary import JsonBoundaryError, load_owned_object, serialize_owned_object
@@ -131,10 +135,65 @@ def _clarifications(
131
135
  if not isinstance(row, Mapping):
132
136
  _fail("lead", path, f"activeClarifications[{index}]", "must be an object")
133
137
  activity_by_id = {row.get("activityId"): row for row in activities}
134
- return [
138
+ rows = [
135
139
  _clarification_row(row, activity_by_id, path)
136
140
  for row in active
137
141
  ]
142
+ rows.extend(_carried_clarification_rows(ledger, {row.get("id") for row in rows}, path))
143
+ return rows
144
+
145
+
146
+ def _carried_clarification_rows(
147
+ ledger: Mapping[str, Any], seen_ids: set[object], path: Path,
148
+ ) -> list[dict[str, Any]]:
149
+ """이월 결정은 active 질문이 아니다. 이번 런 활동 원장을 요구하지 않는다."""
150
+ carried = ledger.get("carriedDecisions")
151
+ if not isinstance(carried, list):
152
+ return []
153
+ rows: list[dict[str, Any]] = []
154
+ seen = set(seen_ids)
155
+ for index, entry in enumerate(carried):
156
+ if not isinstance(entry, Mapping):
157
+ _fail("lead", path, f"carriedDecisions[{index}]", "must be an object")
158
+ decision = entry.get("decision")
159
+ if not isinstance(decision, Mapping):
160
+ _fail(
161
+ "lead", path, f"carriedDecisions[{index}].decision",
162
+ "must be an object",
163
+ )
164
+ cid = decision.get("id")
165
+ if cid in seen:
166
+ continue
167
+ rows.append(_carried_clarification_row(decision))
168
+ seen.add(cid)
169
+ return rows
170
+
171
+
172
+ def _carried_clarification_row(source: Mapping[str, Any]) -> dict[str, Any]:
173
+ row = {
174
+ key: source[key]
175
+ for key in (
176
+ "id", "ticketId", "kind", "statement", "expectedForm", "blocks",
177
+ "origin", "userConfirmation", "options",
178
+ )
179
+ if key in source
180
+ }
181
+ if "approval" in source:
182
+ row["approvalContext"] = source["approval"]
183
+ resolution = source.get("resolutionInput")
184
+ if not isinstance(resolution, Mapping):
185
+ row["status"] = "resolved"
186
+ return row
187
+ row.update(
188
+ status="resolved",
189
+ userInput=resolution.get("userText", ""),
190
+ resolution={
191
+ "disposition": resolution.get("disposition"),
192
+ "userText": resolution.get("userText"),
193
+ "checkRefs": list(resolution.get("checkRefs") or []),
194
+ },
195
+ )
196
+ return row
138
197
 
139
198
 
140
199
  def _project_relative(project_root: Path, path: Path) -> str:
@@ -330,10 +389,13 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
330
389
  created = str(manifest.get("runTimestamp") or manifest.get("createdAt") or "unknown")
331
390
  data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
332
391
  clarifications = data.get("clarificationItems") or []
392
+ incorporated = incorporated_clarification_ids(data)
333
393
  blocked = any(
334
394
  isinstance(row, Mapping)
335
395
  and row_blocks_progress(
336
- str(row.get("status") or ""), clarification_disposition(row)
396
+ str(row.get("status") or ""),
397
+ clarification_disposition(row),
398
+ incorporated=str(row.get("id") or "") in incorporated,
337
399
  )
338
400
  for row in clarifications
339
401
  )
@@ -38,6 +38,10 @@ def _agent_row(row: dict) -> dict[str, str]:
38
38
  """
39
39
  cli_tokens = _number(row.get("cliTotalTokens")) or 0
40
40
  cli_cost = _number(row.get("cliCostUsd")) or 0
41
+ cost = _number(row.get("costUsd"))
42
+ if cost is None and cli_cost:
43
+ cost = cli_cost
44
+ cli_cost = 0
41
45
  return {
42
46
  "agent": str(row.get("agent") or ""),
43
47
  "role": str(row.get("role") or ""),
@@ -46,7 +50,7 @@ def _agent_row(row: dict) -> dict[str, str]:
46
50
  "rawTokens": format_int(row.get("totalTokens")),
47
51
  "cacheReadTokens": format_int(row.get("cacheReadTokens")),
48
52
  "billableTokens": format_int(row.get("billableTokens")),
49
- "cost": format_usd(row.get("costUsd")),
53
+ "cost": format_usd(cost),
50
54
  "duration": format_duration_ms(row.get("durationMs")),
51
55
  "cliTokens": format_int(cli_tokens) if cli_tokens else "",
52
56
  "cliCost": format_usd(cli_cost) if cli_cost else "",
@@ -7,6 +7,7 @@ from typing import Any, Mapping, Sequence
7
7
 
8
8
  from .design_surfaces import DesignSurfaceTrigger, detect_design_surfaces
9
9
  from .report_contract import execution_roles_from_manifest
10
+ from .usage_cells import duration_ms_from_bounds
10
11
 
11
12
 
12
13
  class ReportProjectionError(ValueError):
@@ -48,23 +49,58 @@ def _agent_label(row: Mapping[str, Any]) -> str:
48
49
  return _AGENT_LABELS.get(value.lower(), value)
49
50
 
50
51
 
51
- def _execution_row(row: Mapping[str, Any], *, lead: bool = False) -> dict[str, Any]:
52
+ def _execution_row(
53
+ row: Mapping[str, Any],
54
+ *,
55
+ lead: bool = False,
56
+ usage: Mapping[str, Any] | None = None,
57
+ ) -> dict[str, Any]:
52
58
  role = _text(row.get("role"), "Okstra lead" if lead else "Worker")
59
+ source = usage if usage is not None else (
60
+ row.get("usage") if isinstance(row.get("usage"), Mapping) else {}
61
+ )
62
+ status = _status(row.get("status"))
63
+ if lead and status == "not-run" and _usage_ran(source):
64
+ status = "completed"
53
65
  result = {
54
66
  "agent": _agent_label(row),
55
67
  "role": role,
56
68
  "model": _text(row.get("model") or row.get("modelExecutionValue"), "unknown"),
57
- "status": _status(row.get("status")),
69
+ "status": status,
58
70
  "summary": _text(
59
71
  row.get("summary") or row.get("reason"),
60
72
  "Run coordination recorded by team state." if lead
61
73
  else "Worker execution recorded by team state.",
62
74
  ),
63
75
  }
64
- _populate_usage(result, row.get("usage") or {})
76
+ _populate_usage(result, source)
77
+ if not result.get("durationMs"):
78
+ duration = _row_duration_ms(row, source)
79
+ if duration is not None:
80
+ result["durationMs"] = duration
65
81
  return result
66
82
 
67
83
 
84
+ def _usage_ran(usage: Mapping[str, Any]) -> bool:
85
+ if usage.get("source") == "unavailable":
86
+ return False
87
+ tokens = usage.get("totalTokens") or usage.get("cliTotalTokens") or 0
88
+ duration = usage.get("durationMs") or 0
89
+ return bool(tokens or duration)
90
+
91
+
92
+ def _row_duration_ms(row: Mapping[str, Any], usage: Mapping[str, Any]) -> int | None:
93
+ value = usage.get("durationMs") if usage.get("source") != "unavailable" else None
94
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
95
+ return int(value)
96
+ duration = duration_ms_from_bounds(row.get("startedAt"), row.get("endedAt"))
97
+ if duration is not None:
98
+ return duration
99
+ if usage.get("source") == "unavailable":
100
+ return None
101
+ return duration_ms_from_bounds(usage.get("startedAt"), usage.get("endedAt"))
102
+
103
+
68
104
  def _populate_usage(row: dict[str, Any], usage: object) -> None:
69
105
  source = usage if isinstance(usage, Mapping) else {}
70
106
  mapping = {
@@ -92,11 +128,16 @@ def project_execution(
92
128
  "status": "completed",
93
129
  "role": "Okstra lead",
94
130
  }
131
+ lead_usage = team_state.get("leadUsage")
95
132
  workers = team_state.get("workers")
96
133
  worker_rows = workers if isinstance(workers, list) else []
97
134
  result: dict[str, Any] = {
98
135
  "executionStatus": [
99
- _execution_row(lead_row, lead=True),
136
+ _execution_row(
137
+ lead_row,
138
+ lead=True,
139
+ usage=lead_usage if isinstance(lead_usage, Mapping) else None,
140
+ ),
100
141
  *[
101
142
  _execution_row(row)
102
143
  for row in worker_rows
@@ -304,7 +304,7 @@ def _blocking_gate_survives_user_decision(data: dict, gate: str) -> bool:
304
304
  for row in rows
305
305
  )
306
306
  return (not has_approval_row) or bool(
307
- progress_blocking_ids(rows, APPROVAL_BLOCKS)
307
+ progress_blocking_ids(rows, APPROVAL_BLOCKS, report_data=data)
308
308
  )
309
309
 
310
310
 
@@ -1723,7 +1723,7 @@ def _normalize_prepare_model_selection(
1723
1723
  profile_file: Path,
1724
1724
  ) -> tuple[CanonicalModelSelection, RoleProfile]:
1725
1725
  try:
1726
- _validate_critic_choice(inp.critic)
1726
+ _validate_critic_choice(inp.critic, inp.task_type)
1727
1727
  profile = load_role_profile(profile_file)
1728
1728
  profile_workers = resolve_profile_workers(profile_file)
1729
1729
  has_worker_models = bool(
@@ -2502,7 +2502,7 @@ def _resolve_model_bindings(
2502
2502
  ) -> _ModelBindings:
2503
2503
  """worker 모델 + critic 선택 + executor 바인딩을 한 묶음으로 해소·검증한다."""
2504
2504
  m = _resolve_worker_models(inp, workers, context)
2505
- critic_choice = _validate_critic_choice(inp.critic)
2505
+ critic_choice = _validate_critic_choice(inp.critic, inp.task_type)
2506
2506
  critic_model_execution = ""
2507
2507
  critic_meta = None
2508
2508
  if critic_choice in provider_ids("critic"):
@@ -2581,12 +2581,27 @@ def _resolve_model_bindings(
2581
2581
  )
2582
2582
 
2583
2583
 
2584
- def _validate_critic_choice(raw_value: str) -> str:
2584
+ _CRITIC_REQUIRED_TASK_TYPES = frozenset({
2585
+ "requirements-discovery",
2586
+ "error-analysis",
2587
+ "implementation-planning",
2588
+ "final-verification",
2589
+ })
2590
+
2591
+
2592
+ def _validate_critic_choice(raw_value: str, task_type: str = "") -> str:
2585
2593
  critic_choice = (raw_value or "").strip().lower()
2586
- allowed_critics = ["", "off", *provider_ids("critic")]
2594
+ allowed_critics = ["", *provider_ids("critic")]
2595
+ if critic_choice == "off":
2596
+ if task_type in _CRITIC_REQUIRED_TASK_TYPES:
2597
+ raise PrepareError(
2598
+ "--critic off is not allowed; critic is required "
2599
+ f"for {task_type}"
2600
+ )
2601
+ return "off"
2587
2602
  if critic_choice not in allowed_critics:
2588
2603
  raise PrepareError(
2589
- f"--critic must be one of: {', '.join(allowed_critics[1:])} "
2604
+ f"--critic must be one of: {', '.join(provider_ids('critic'))} "
2590
2605
  f"(got: {critic_choice!r})"
2591
2606
  )
2592
2607
  return critic_choice
@@ -7,6 +7,7 @@ nothing would look like, which is a different claim from "not measured".
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ from datetime import datetime
10
11
  from typing import Any
11
12
 
12
13
 
@@ -28,6 +29,20 @@ def format_usd(value: Any) -> str:
28
29
  return "--"
29
30
 
30
31
 
32
+ def duration_ms_from_bounds(started_at: object, ended_at: object) -> int | None:
33
+ """두 ISO 시각 사이의 벽시계 ms. 없거나 파싱 불가면 None."""
34
+ if not isinstance(started_at, str) or not isinstance(ended_at, str):
35
+ return None
36
+ if not started_at or not ended_at:
37
+ return None
38
+ try:
39
+ start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
40
+ end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
41
+ except ValueError:
42
+ return None
43
+ return max(0, int((end - start).total_seconds() * 1000))
44
+
45
+
31
46
  def format_duration_ms(value: Any) -> str:
32
47
  if value is None or not isinstance(value, (str, int, float)):
33
48
  return "--"
@@ -1960,21 +1960,33 @@ def finalize_response(transaction_id: str) -> Path:
1960
1960
 
1961
1961
  def format_list_view(rows: list[dict[str, Any]]) -> str:
1962
1962
  lines = ["USER RESPONSE TASKS", f"Count: {len(rows)}"]
1963
+ picker: list[str] = ["", "Picker:"]
1963
1964
  for index, row in enumerate(rows, start=1):
1964
1965
  status = "unreadable" if row.get("unreadable") else "ready"
1966
+ task_key = row.get("canonicalTaskKey", row.get("taskKey", ""))
1967
+ task_type = row.get("taskType", "")
1968
+ seq = row.get("seq", "")
1969
+ report = row.get("normalizedReportPath", row.get("reportPath", ""))
1970
+ open_items = row.get("openBlockerCount", 0)
1965
1971
  lines.extend([
1966
1972
  "",
1967
1973
  f"[{index}]",
1968
- f"Task key: {row.get('canonicalTaskKey', row.get('taskKey', ''))}",
1969
- f"Task type: {row.get('taskType', '')}",
1970
- f"Run sequence: {row.get('seq', '')}",
1971
- f"Report: {row.get('normalizedReportPath', row.get('reportPath', ''))}",
1972
- f"Open items: {row.get('openBlockerCount', 0)}",
1974
+ f"Task key: {task_key}",
1975
+ f"Task type: {task_type}",
1976
+ f"Run sequence: {seq}",
1977
+ f"Report: {report}",
1978
+ f"Open items: {open_items}",
1973
1979
  f"Open approval items: {row.get('openApprovalCount', 0)}",
1974
1980
  f"Plan decision required: {'yes' if row.get('planDecisionRequired') else 'no'}",
1975
1981
  f"Status: {status}",
1976
1982
  ])
1977
- return "\n".join(lines) + "\n"
1983
+ picker.extend([
1984
+ f"- Label: {task_key} · {task_type} · seq {seq}",
1985
+ f" Description: Open items: {open_items}. Report: {report}",
1986
+ ])
1987
+ if len(rows) == 0:
1988
+ return "\n".join(lines) + "\n"
1989
+ return "\n".join(lines + picker) + "\n"
1978
1990
 
1979
1991
 
1980
1992
  def _option_view(option: Mapping[str, Any], index: int) -> list[str]:
@@ -1996,6 +2008,59 @@ def _option_view(option: Mapping[str, Any], index: int) -> list[str]:
1996
2008
  ]
1997
2009
 
1998
2010
 
2011
+ def _picker_scope(option: Mapping[str, Any]) -> str:
2012
+ """HTML `<select>` 와 같은 범위 축. reach 가 있으면 그걸, 없으면 scopeImpact."""
2013
+ reach = str(option.get("reach") or "").strip()
2014
+ effects = option.get("scopeEffects")
2015
+ extra = (
2016
+ ", ".join(str(item) for item in effects)
2017
+ if isinstance(effects, list) and effects
2018
+ else ""
2019
+ )
2020
+ if reach:
2021
+ return f"{reach}, {extra}" if extra else reach
2022
+ scope = option.get("scopeImpact")
2023
+ if isinstance(scope, list) and scope:
2024
+ return ", ".join(str(item) for item in scope)
2025
+ return "not stated in the report"
2026
+
2027
+
2028
+ def _picker_label(option: Mapping[str, Any]) -> str:
2029
+ answer = str(option.get("answer") or "").strip()
2030
+ if option.get("role") == "recommended":
2031
+ return f"{answer} (Recommended)"
2032
+ return answer
2033
+
2034
+
2035
+ def _picker_description(option: Mapping[str, Any]) -> str:
2036
+ added = option.get("addedWork") or "not stated in the report"
2037
+ reverse = option.get("directionChange") or "not stated in the report"
2038
+ rationale = option.get("rationale") or "not stated in the report"
2039
+ return (
2040
+ f"If you pick this: {added}. What it reverses: {reverse}. "
2041
+ f"Scope: {_picker_scope(option)}. Why it is on the board: {rationale}."
2042
+ )
2043
+
2044
+
2045
+ def _format_picker(options: list[Any]) -> list[str]:
2046
+ """호스트 네이티브 픽커에 그대로 넣을 칸. 순서는 `Option N:` 과 같다.
2047
+
2048
+ HTML 리포트의 `<select>` 값도 `option.answer` 다. 스킬이 이 블록을 카드로
2049
+ 옮기면 브라우저 선택과 in-session 선택이 같은 답을 고른다.
2050
+ """
2051
+ lines = ["Picker:"]
2052
+ for option in options:
2053
+ if not isinstance(option, Mapping):
2054
+ continue
2055
+ lines.extend([
2056
+ f"- Label: {_picker_label(option)}",
2057
+ f" Description: {_picker_description(option)}",
2058
+ ])
2059
+ if len(lines) == 1:
2060
+ return ["Picker: none"]
2061
+ return lines
2062
+
2063
+
1999
2064
  def _option_probe_texts(options: list[Any]) -> list[str]:
2000
2065
  texts: list[str] = []
2001
2066
  for option in options:
@@ -2114,6 +2179,7 @@ def _format_open_row_view(
2114
2179
  ])
2115
2180
  for index, option in enumerate(row["options"], start=1):
2116
2181
  lines.extend(_option_view(option, index))
2182
+ lines.extend(_format_picker(list(row["options"] or [])))
2117
2183
  linked = _linked_plan_items(record, item.row_id)
2118
2184
  lines.extend(_format_ref_list(
2119
2185
  "Linked plan items",
@@ -4312,7 +4312,7 @@ def _critic_provider_choices() -> list[str]:
4312
4312
 
4313
4313
 
4314
4314
  def _critic_choices() -> list[str]:
4315
- return ["off", *_critic_provider_choices()]
4315
+ return list(_critic_provider_choices())
4316
4316
 
4317
4317
 
4318
4318
  def _critic_provider_label(provider: str, t: dict) -> str:
@@ -4326,14 +4326,10 @@ def _critic_provider_label(provider: str, t: dict) -> str:
4326
4326
 
4327
4327
  def _build_critic_pick(state: WizardState) -> Prompt:
4328
4328
  t = _p(state.workspace_root, "critic_pick")
4329
- off_label = t["options"].get("off", "off")
4330
- # 추천(claude critic)을 가장 먼저, 'off'(critic 미사용)를 마지막에 둔다
4331
- # (run-prompt 추천 규칙: 추천이 항상 첫 옵션).
4332
4329
  options = [
4333
4330
  _opt(provider, _critic_provider_label(provider, t))
4334
4331
  for provider in _critic_provider_choices()
4335
4332
  ]
4336
- options.append(_opt("off", off_label))
4337
4333
  return Prompt(
4338
4334
  step=S_CRITIC_PICK, kind="pick",
4339
4335
  label=t["label"],
@@ -56,7 +56,7 @@ def find_codex_session(cwd: Path, started_at: str, ended_at: str) -> Path | None
56
56
  return sessions[-1] if sessions else None
57
57
 
58
58
 
59
- def _session_metadata(path: Path) -> tuple[str, str] | None:
59
+ def _session_meta_payload(path: Path) -> dict | None:
60
60
  try:
61
61
  with path.open() as fh:
62
62
  first = fh.readline()
@@ -70,9 +70,38 @@ def _session_metadata(path: Path) -> tuple[str, str] | None:
70
70
  return None
71
71
  if record.get("type") != "session_meta":
72
72
  return None
73
- payload = record.get("payload") or {}
73
+ payload = record.get("payload")
74
+ if not isinstance(payload, dict):
75
+ payload = {}
74
76
  timestamp = payload.get("timestamp") or record.get("timestamp") or ""
75
- return str(payload.get("cwd") or ""), timestamp
77
+ return {**payload, "timestamp": timestamp}
78
+
79
+
80
+ def _session_metadata(path: Path) -> tuple[str, str] | None:
81
+ payload = _session_meta_payload(path)
82
+ if payload is None:
83
+ return None
84
+ return str(payload.get("cwd") or ""), str(payload.get("timestamp") or "")
85
+
86
+
87
+ def codex_session_is_worker(path: Path) -> bool:
88
+ """exec 래퍼 세션. 대화형 리드는 originator=codex-tui / source=cli 이다."""
89
+ payload = _session_meta_payload(path)
90
+ if not payload:
91
+ return False
92
+ originator = str(payload.get("originator") or "").strip()
93
+ source = str(payload.get("source") or "").strip()
94
+ return originator == "codex_exec" or source == "exec"
95
+
96
+
97
+ def codex_session_ids(path: Path) -> set[str]:
98
+ ids = {path.name, path.stem}
99
+ payload = _session_meta_payload(path) or {}
100
+ for key in ("session_id", "id"):
101
+ value = str(payload.get(key) or "").strip()
102
+ if value:
103
+ ids.add(value)
104
+ return {item for item in ids if item}
76
105
 
77
106
 
78
107
  def find_codex_sessions(