okstra 0.186.5 → 0.186.7

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 (54) 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 +58 -3
  24. package/runtime/python/okstra_ctl/render_final_report.py +3 -1
  25. package/runtime/python/okstra_ctl/report_assembly.py +9 -2
  26. package/runtime/python/okstra_ctl/report_contract.py +2 -0
  27. package/runtime/python/okstra_ctl/report_html/common.py +2 -7
  28. package/runtime/python/okstra_ctl/report_html/render.py +3 -0
  29. package/runtime/python/okstra_ctl/report_html/run_usage.py +5 -1
  30. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
  31. package/runtime/python/okstra_ctl/report_projections.py +45 -4
  32. package/runtime/python/okstra_ctl/run.py +21 -6
  33. package/runtime/python/okstra_ctl/usage_cells.py +15 -0
  34. package/runtime/python/okstra_ctl/user_response.py +72 -6
  35. package/runtime/python/okstra_ctl/wizard.py +1 -5
  36. package/runtime/python/okstra_token_usage/codex.py +32 -3
  37. package/runtime/python/okstra_token_usage/collect.py +148 -15
  38. package/runtime/python/okstra_token_usage/grok.py +24 -5
  39. package/runtime/python/okstra_token_usage/report.py +12 -2
  40. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  41. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  42. package/runtime/skills/okstra-user-response/SKILL.md +4 -2
  43. package/runtime/templates/reports/html/assets/base.css +3 -9
  44. package/runtime/templates/reports/html/assets/base.js +0 -21
  45. package/runtime/templates/reports/html/base.template.html +14 -4
  46. package/runtime/templates/reports/html/i18n/en.json +19 -0
  47. package/runtime/templates/reports/html/i18n/ko.json +19 -0
  48. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  49. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +20 -29
  50. package/runtime/templates/reports/html/tasks/implementation.template.html +1 -1
  51. package/runtime/validators/lib/runners.sh +5 -1
  52. package/runtime/validators/validate-report-views.py +2 -1
  53. package/runtime/validators/validate-run.py +97 -82
  54. 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]:
@@ -587,6 +641,7 @@ def _verdict_row(worker: str, block: VerdictBlock) -> dict[str, Any]:
587
641
  ("breakageKind", block.breakage_kind),
588
642
  ("fixability", block.fixability),
589
643
  ("note", block.note),
644
+ ("explanation", block.explanation),
590
645
  ):
591
646
  if value:
592
647
  row[key] = value
@@ -988,7 +1043,7 @@ def _append_item_verdicts(
988
1043
  round_number: int,
989
1044
  project_root: Path | None,
990
1045
  ) -> None:
991
- """동수 항목의 세 번째 표. 이미 투표한 워커는 거부한다."""
1046
+ """동수 항목의 critic 표. 이미 투표한 워커는 거부한다."""
992
1047
  stamped = _stamped_verdicts(incoming, round_number, project_root)
993
1048
  existing = item.get("verdicts")
994
1049
  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
@@ -385,10 +389,13 @@ def _attach_metadata(data: dict[str, Any], manifest: Mapping[str, Any]) -> None:
385
389
  created = str(manifest.get("runTimestamp") or manifest.get("createdAt") or "unknown")
386
390
  data["meta"] = {"reportLanguage": str(manifest.get("reportLanguage") or "en")}
387
391
  clarifications = data.get("clarificationItems") or []
392
+ incorporated = incorporated_clarification_ids(data)
388
393
  blocked = any(
389
394
  isinstance(row, Mapping)
390
395
  and row_blocks_progress(
391
- 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,
392
399
  )
393
400
  for row in clarifications
394
401
  )
@@ -130,6 +130,7 @@ IMPLEMENTATION_PLANNING_LEGACY_REQUIRED_HUMAN_FIELDS = (
130
130
  "implementationPlanning.tradeoffMatrix",
131
131
  "implementationPlanning.recommendedOption",
132
132
  "implementationPlanning.stageMap",
133
+ "implementationPlanning.planBodyVerification",
133
134
  )
134
135
 
135
136
  IMPLEMENTATION_PLANNING_PLAN_READY_REQUIRED_HUMAN_FIELDS = (
@@ -138,6 +139,7 @@ IMPLEMENTATION_PLANNING_PLAN_READY_REQUIRED_HUMAN_FIELDS = (
138
139
  "implementationPlanning.coverageSummary",
139
140
  "implementationPlanning.stageMap",
140
141
  "implementationPlanning.requirementCoverage",
142
+ "implementationPlanning.planBodyVerification",
141
143
  )
142
144
 
143
145
  IMPLEMENTATION_PLANNING_INVALIDATED_REQUIRED_HUMAN_FIELDS = (
@@ -2,11 +2,8 @@
2
2
  from __future__ import annotations
3
3
 
4
4
 
5
- # Every block whose rows prose cites but no section of its own renders, with
6
- # the keys each one names its content, its provenance, and its confidence by. A
7
- # key hunt across all of them would misread `crossVerification.consensus`, whose
8
- # `evidence` key holds provenance while `evidence.primary` uses the same word
9
- # for content — so the block a row came from is named here rather than guessed.
5
+ # 본문이 인용하지만 전용 섹션이 없는 행만 대장에 넣는다. 키 이름만으로
6
+ # 블록을 고르면 `evidence.primary` 의 내용과 다른 블록의 출처 칸이 섞인다.
10
7
  #
11
8
  # Source and confidence are separate columns because most blocks carry only one
12
9
  # of the two: `evidence.primary` cites a file and never rates itself, while
@@ -29,8 +26,6 @@ _LEDGER_BLOCKS = (
29
26
  (("analysisCommon", "confirmedFacts"), "statement", "", "", "confirmed-fact"),
30
27
  (("analysisCommon", "inferences"), "statement", "", "confidence", "inference"),
31
28
  (("analysisCommon", "unknowns"), "question", "reason", "", "unknown"),
32
- (("crossVerification", "consensus"), "statement", "evidence", "", "cross-check-consensus"),
33
- (("crossVerification", "differences"), "disagreement", "workersPosition", "", "cross-check-dissent"),
34
29
  (("missingInformation",), "item", "risk", "", "missing-information"),
35
30
  (("followUpTasks",), "title", "reason", "", "follow-up"),
36
31
  )
@@ -157,6 +157,9 @@ def render_v2_html_view(
157
157
  "sourceData": source_data,
158
158
  "dataSha256": _sha256(data_path),
159
159
  "clarificationItems": data.get("clarificationItems", []),
160
+ # 합의·이견 근거는 태스크 본문과 같이 기본 화면에 올린다. 근거 대장
161
+ # 감사 모드에만 두면 판정이 사용자에게 안 보인다.
162
+ "crossVerification": data.get("crossVerification") or {},
160
163
  # Every task type ends with the same run-cost section, so it is bound
161
164
  # here rather than in ten view models that would each rebuild it.
162
165
  "runUsage": run_usage(data),
@@ -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 "",
@@ -89,16 +89,13 @@ def _stage_figure(planning: dict):
89
89
  return stage_map_figure(nodes=nodes, edges=edges, title="Implementation stage dependencies")
90
90
 
91
91
 
92
- # Blocks the approver's view leaves to the markdown report: the checklist the
93
- # implementer works from, the dependency and rollback tables an incident reader
94
- # needs, and the verification rounds the audit trail keeps. Declared here so the
95
- # ids they carry stop being anchor targets in this document.
92
+ # 승인자가 보지 않는 구현자·사고 대응 표. 계획 본문 검증은 판정과 근거가
93
+ # 승인 판단이라 여기 두지 않는다.
96
94
  _OMITTED_FIELDS = (
97
95
  "validationChecklist",
98
96
  "crossProjectDependencies",
99
97
  "dependencyMigrationRisk",
100
98
  "rollbackStrategy",
101
- "planBodyVerification",
102
99
  )
103
100
 
104
101
 
@@ -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"],