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
@@ -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(
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
5
+ import os
5
6
  from datetime import datetime, timezone
6
7
  from pathlib import Path
7
8
  from typing import Any
@@ -13,9 +14,18 @@ from .claude import (
13
14
  find_claude_agent_sessions,
14
15
  find_claude_team_sessions,
15
16
  )
16
- from .codex import codex_session_total, find_codex_sessions
17
+ from .codex import (
18
+ codex_session_ids,
19
+ codex_session_is_worker,
20
+ codex_session_total,
21
+ find_codex_sessions,
22
+ )
17
23
  from .antigravity import antigravity_session_total, find_antigravity_sessions
18
- from .grok import find_grok_sessions, grok_session_total
24
+ from .grok import (
25
+ find_grok_sessions,
26
+ grok_session_is_non_interactive,
27
+ grok_session_total,
28
+ )
19
29
  from .paths import claude_project_dir, utc_now
20
30
  from .pricing import antigravity_cost_usd, provider_cost_usd
21
31
  from okstra_ctl.dispatch_state import worker_session_ids
@@ -94,13 +104,9 @@ def _aggregate_totals(items: list[dict]) -> dict:
94
104
  aggregate["startedAt"] = s
95
105
  if e and (aggregate["endedAt"] is None or e > aggregate["endedAt"]):
96
106
  aggregate["endedAt"] = e
97
- if aggregate["startedAt"] and aggregate["endedAt"]:
98
- try:
99
- a = datetime.fromisoformat(aggregate["startedAt"].replace("Z", "+00:00"))
100
- b = datetime.fromisoformat(aggregate["endedAt"].replace("Z", "+00:00"))
101
- aggregate["durationMs"] = max(0, int((b - a).total_seconds() * 1000))
102
- except ValueError:
103
- pass
107
+ wall = _wall_ms(aggregate["startedAt"], aggregate["endedAt"])
108
+ if wall is not None:
109
+ aggregate["durationMs"] = wall
104
110
  return aggregate
105
111
 
106
112
 
@@ -557,9 +563,18 @@ def wrapper_execution(status_path: Path | None) -> dict:
557
563
  }
558
564
  if status.exit_code is not None:
559
565
  execution["exitCode"] = status.exit_code
566
+ duration = _wrapper_duration_ms(status.raw.get("duration_ms"), execution)
567
+ if duration is not None:
568
+ execution["durationMs"] = duration
560
569
  return execution
561
570
 
562
571
 
572
+ def _wrapper_duration_ms(raw_duration: object, execution: dict) -> int | None:
573
+ if isinstance(raw_duration, (int, float)) and not isinstance(raw_duration, bool):
574
+ return max(0, int(raw_duration))
575
+ return _wall_ms(execution.get("startedAt"), execution.get("endedAt"))
576
+
577
+
563
578
  def _execution_note(execution: dict) -> str:
564
579
  status = execution["status"]
565
580
  if status == "exited":
@@ -603,6 +618,8 @@ def collect_cli_usage(
603
618
  block = _cli_usage_block(provider, _aggregate_totals(totals), sessions)
604
619
 
605
620
  block["cliExecutionStatus"] = execution["status"]
621
+ if "durationMs" in execution:
622
+ block["durationMs"] = execution["durationMs"]
606
623
  if fallback_window_used:
607
624
  fallback_note = "wrapper status sidecar unavailable; used aggregate wrapper window fallback"
608
625
  prior_note = block.get("cliNote")
@@ -706,11 +723,10 @@ def _attach_cli_usage(
706
723
  block[key] = cli[key]
707
724
 
708
725
 
709
- def _collect_cli_runtime_usage(state: dict, project_root: Path) -> dict:
726
+ def _collect_cli_runtime_usage(
727
+ state: dict, project_root: Path, team_state_path: Path | None = None,
728
+ ) -> dict:
710
729
  windows_by_worker = _codex_worker_windows(project_root, state)
711
- state["leadUsage"] = na_block(
712
- "Host lead token accounting is unavailable; CLI worker usage is collected only from attributable provider logs."
713
- )
714
730
  for worker in state.get("workers", []):
715
731
  if not isinstance(worker, dict):
716
732
  continue
@@ -728,11 +744,128 @@ def _collect_cli_runtime_usage(state: dict, project_root: Path) -> dict:
728
744
  worker=worker,
729
745
  fallback_windows=windows_by_worker.get(worker_id, []),
730
746
  )
747
+ state["leadUsage"] = _cli_lead_usage(state, project_root, team_state_path)
731
748
  _populate_usage_summary(state, team_name=resolve_team_name(state),
732
749
  sessions_found=0, needle_source="none")
733
750
  return state
734
751
 
735
752
 
753
+ def _cli_lead_provider(state: dict) -> str:
754
+ lead = state.get("lead") if isinstance(state.get("lead"), dict) else {}
755
+ return str(
756
+ lead.get("provider") or lead.get("agent") or state.get("leadRuntime") or ""
757
+ ).strip()
758
+
759
+
760
+ _CLI_LEAD_PROVIDERS = frozenset({"grok", "codex"})
761
+
762
+
763
+ def _cli_lead_usage(
764
+ state: dict, project_root: Path, team_state_path: Path | None,
765
+ ) -> dict:
766
+ """호스트 리드 세션은 워커가 가져간 경로를 뺀 뒤 같은 트랜스크립트에서 읽는다."""
767
+ missing = (
768
+ "Host lead token accounting is unavailable; CLI worker usage is "
769
+ "collected only from attributable provider logs."
770
+ )
771
+ provider = _cli_lead_provider(state)
772
+ if team_state_path is None:
773
+ return na_block(missing)
774
+ if provider == "antigravity":
775
+ return na_block(
776
+ "Antigravity host lead has no registered session transcript; "
777
+ "worker stream-json logs are collected only."
778
+ )
779
+ if provider not in _CLI_LEAD_PROVIDERS:
780
+ return na_block(missing)
781
+ run_since, run_until = resolve_run_window(team_state_path, state)
782
+ if not run_since or not run_until:
783
+ return na_block(
784
+ f"{provider} lead usage accounting is unavailable because the run window is missing."
785
+ )
786
+ worker_paths = {
787
+ Path(path)
788
+ for worker in state.get("workers") or []
789
+ if isinstance(worker, dict)
790
+ for path in ((worker.get("usage") or {}).get("cliSessionPaths") or [])
791
+ }
792
+ sessions = _select_cli_lead_sessions(
793
+ provider,
794
+ [
795
+ path
796
+ for path in _cli_sessions_for_windows(
797
+ provider, project_root, [(run_since, run_until)],
798
+ )
799
+ if path not in worker_paths
800
+ ],
801
+ state,
802
+ )
803
+ totals = _cli_session_totals(provider, sessions)
804
+ if not totals:
805
+ return na_block(
806
+ f"{provider} lead usage accounting is unavailable because no host session "
807
+ "started in the run window."
808
+ )
809
+ return _cli_usage_block(provider, _aggregate_totals(totals), sessions)
810
+
811
+
812
+ def _cli_worker_session(provider: str, path: Path) -> bool:
813
+ if provider == "grok":
814
+ return grok_session_is_non_interactive(path)
815
+ if provider == "codex":
816
+ return codex_session_is_worker(path)
817
+ return False
818
+
819
+
820
+ def _select_cli_lead_sessions(
821
+ provider: str, sessions: list[Path], state: dict,
822
+ ) -> list[Path]:
823
+ """워커 래퍼 세션을 빼고, 아이디가 있으면 그 세션, 없으면 벽시계가 가장 긴 대화."""
824
+ candidates = [
825
+ path for path in sessions if not _cli_worker_session(provider, path)
826
+ ]
827
+ if not candidates:
828
+ return []
829
+ pinned = _pinned_cli_lead_session(provider, candidates, state)
830
+ if pinned is not None:
831
+ return [pinned]
832
+ if len(candidates) == 1:
833
+ return candidates
834
+ best = candidates[0]
835
+ best_ms = -1
836
+ for path in candidates:
837
+ totals = _cli_session_totals(provider, [path])
838
+ total = totals[0] if totals else {}
839
+ wall = _wall_ms(total.get("startedAt"), total.get("endedAt")) if total else None
840
+ if wall is None:
841
+ wall = -1
842
+ if wall > best_ms:
843
+ best = path
844
+ best_ms = wall
845
+ return [best]
846
+
847
+
848
+ def _pinned_cli_lead_session(
849
+ provider: str, candidates: list[Path], state: dict,
850
+ ) -> Path | None:
851
+ lead = state.get("lead") if isinstance(state.get("lead"), dict) else {}
852
+ wanted = {str(lead.get("sessionId") or "").strip()}
853
+ if provider == "grok":
854
+ wanted.add(str(os.environ.get("GROK_SESSION_ID") or "").strip())
855
+ wanted.discard("")
856
+ for path in candidates:
857
+ names = {path.parent.name, path.name, path.stem}
858
+ if provider == "codex":
859
+ names.update(codex_session_ids(path))
860
+ if wanted & names:
861
+ return path
862
+ if provider == "codex" and any(
863
+ session_id and session_id in path.name for session_id in wanted
864
+ ):
865
+ return path
866
+ return None
867
+
868
+
736
869
  def _populate_usage_summary(
737
870
  state: dict,
738
871
  *,
@@ -747,7 +880,7 @@ def _populate_usage_summary(
747
880
  lead_total = lead.get("totalTokens", 0) or 0
748
881
  lead_cache_read = lead.get("cacheReadTokens", 0) or 0
749
882
  lead_billable = lead.get("billableEquivalentTokens", 0) or 0
750
- lead_cost = lead.get("estimatedCostUsd", 0) or 0
883
+ lead_cost = lead.get("estimatedCostUsd") or lead.get("cliEstimatedCostUsd") or 0
751
884
  worker_total = sum((w.get("usage") or {}).get("totalTokens", 0) or 0 for w in workers)
752
885
  worker_cache_read = sum((w.get("usage") or {}).get("cacheReadTokens", 0) or 0 for w in workers)
753
886
  worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
@@ -1063,7 +1196,7 @@ def collect_cli_runtime_usage(
1063
1196
  ) -> dict:
1064
1197
  state = json.loads(team_state_path.read_text())
1065
1198
  cwd = project_root or _infer_project_root(team_state_path, state)
1066
- return _collect_cli_runtime_usage(state, cwd)
1199
+ return _collect_cli_runtime_usage(state, cwd, team_state_path)
1067
1200
 
1068
1201
 
1069
1202
  def collect(
@@ -6,6 +6,7 @@
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ import json
9
10
  import os
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
@@ -87,11 +88,17 @@ def grok_session_total(updates_path: Path) -> dict:
87
88
  }
88
89
 
89
90
 
90
- def _session_in_window(updates_path: Path, started_at: str, ended_at: str) -> bool:
91
+ def _session_started_in_window(
92
+ updates_path: Path, started_at: str, ended_at: str,
93
+ ) -> bool:
94
+ """첫 usage 시각이 창 안에 있을 때만 이 창의 세션이다.
95
+
96
+ 창 중간의 갱신만 보면 같은 cwd 의 리드 대화가 워커 창에 섞인다.
97
+ """
91
98
  for record in iter_jsonl(updates_path):
92
99
  iso = _iso_from_unix(record.get("timestamp"))
93
- if iso and ts_in_window(iso, started_at, ended_at):
94
- return True
100
+ if iso:
101
+ return ts_in_window(iso, started_at, ended_at)
95
102
  return False
96
103
 
97
104
 
@@ -102,7 +109,7 @@ def find_grok_sessions(
102
109
  *,
103
110
  session_root: Path | None = None,
104
111
  ) -> list[Path]:
105
- """cwd 로 인코딩된 세션 중 창 안에 갱신이 있는 updates.jsonl."""
112
+ """cwd 로 인코딩된 세션 중 창 안에서 시작된 updates.jsonl."""
106
113
  if not started_at or not ended_at:
107
114
  return []
108
115
  root = session_root or grok_sessions_root()
@@ -118,10 +125,22 @@ def find_grok_sessions(
118
125
  continue
119
126
  for session in child.iterdir():
120
127
  updates = session / "updates.jsonl"
121
- if updates.is_file() and _session_in_window(updates, started_at, ended_at):
128
+ if updates.is_file() and _session_started_in_window(
129
+ updates, started_at, ended_at
130
+ ):
122
131
  matches.append(updates)
123
132
  return sorted(matches)
124
133
 
125
134
 
126
135
  def grok_session_dir_name(cwd: Path) -> str:
127
136
  return quote(str(cwd), safe="")
137
+
138
+
139
+ def grok_session_is_non_interactive(updates_path: Path) -> bool:
140
+ """워커 래퍼 세션은 prompt_context.is_non_interactive 가 true 이다."""
141
+ context_path = updates_path.parent / "prompt_context.json"
142
+ try:
143
+ payload = json.loads(context_path.read_text(encoding="utf-8"))
144
+ except (OSError, json.JSONDecodeError):
145
+ return False
146
+ return payload.get("is_non_interactive") is True
@@ -18,6 +18,7 @@ from okstra_ctl.design_prep import ( # noqa: E402
18
18
  DesignPrepError,
19
19
  materialize_design_prep_requests,
20
20
  )
21
+ from okstra_ctl.usage_cells import duration_ms_from_bounds # noqa: E402
21
22
 
22
23
  from .task_totals import task_cumulative_usage # noqa: E402
23
24
 
@@ -81,8 +82,11 @@ def _populate_execution_row(row: dict, source: dict) -> None:
81
82
  """
82
83
  usage = source.get("usage") or {}
83
84
  if usage.get("source") == "unavailable":
84
- # Leave cells null; renderer emits `--`. A note in the row's
85
- # summary is the worker's responsibility, not ours.
85
+ # Token cells stay null (`--`). Dispatch wall-clock still belongs
86
+ # on the row: a wrapper that failed still ran for some time.
87
+ duration = duration_ms_from_bounds(source.get("startedAt"), source.get("endedAt"))
88
+ if duration is not None:
89
+ row["durationMs"] = duration
86
90
  return
87
91
  if "totalTokens" in usage:
88
92
  row["totalTokens"] = usage["totalTokens"]
@@ -98,6 +102,12 @@ def _populate_execution_row(row: dict, source: dict) -> None:
98
102
  row["cliTotalTokens"] = usage["cliTotalTokens"]
99
103
  if "cliEstimatedCostUsd" in usage:
100
104
  row["cliCostUsd"] = usage["cliEstimatedCostUsd"]
105
+ if not row.get("durationMs"):
106
+ duration = duration_ms_from_bounds(source.get("startedAt"), source.get("endedAt"))
107
+ if duration is None:
108
+ duration = duration_ms_from_bounds(usage.get("startedAt"), usage.get("endedAt"))
109
+ if duration is not None:
110
+ row["durationMs"] = duration
101
111
 
102
112
 
103
113
  def _worker_detail_label(worker: dict) -> str:
@@ -9330,6 +9330,10 @@
9330
9330
  "note": {
9331
9331
  "type": "string"
9332
9332
  },
9333
+ "explanation": {
9334
+ "type": "string",
9335
+ "description": "The worker's two-to-three-sentence verdict rationale. Distinct from `note`, which records the falsification candidate considered even on AGREE."
9336
+ },
9333
9337
  "round": {
9334
9338
  "description": "The plan-body verification round this verdict was cast in. A self-fix round rewrites the plan after a verification round, so a verdict whose round is at or before `selfFixRoundsApplied` judged text that has since changed. Without it there is no way to tell how many rewrites a surviving verdict predates, and a gate can pass on judgements two generations stale.",
9335
9339
  "type": "integer",
@@ -9322,6 +9322,10 @@
9322
9322
  "note": {
9323
9323
  "type": "string"
9324
9324
  },
9325
+ "explanation": {
9326
+ "type": "string",
9327
+ "description": "The worker's two-to-three-sentence verdict rationale. Distinct from `note`, which records the falsification candidate considered even on AGREE."
9328
+ },
9325
9329
  "round": {
9326
9330
  "description": "The plan-body verification round this verdict was cast in. A self-fix round rewrites the plan after a verification round, so a verdict whose round is at or before `selfFixRoundsApplied` judged text that has since changed. Without it there is no way to tell how many rewrites a surviving verdict predates, and a gate can pass on judgements two generations stale.",
9327
9331
  "type": "integer",
@@ -59,7 +59,9 @@ Read the absolute path in the fixed `Relay contract` line. In that file, take th
59
59
  - When `native-single` is available and the option count fits `nativeLimits` (unique labels, within min/max): call `interactions.native-single.function` once with one question and every option as `{label, description}` in original order. Do not print a numbered list in chat while the native tool is available. Claude Code's function is `AskUserQuestion`, Grok's is `ask_user_question`, Codex's is `request_user_input` — copy the relay field; do not substitute one name for another.
60
60
  - Otherwise render a 1-based numbered Markdown list and wait for the next message. Do not drop options to force the native tool.
61
61
 
62
- Pass only the choices this step already owns — the `list-view` rows, the report `options[]`, or the two confirmation labels. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row already collect a custom answer; that row is `Enters an answer`. When native-single is unavailable, a next message that is not a listed label or its 1-based number is the same `Enters an answer`. Do not ask a second question for the custom value.
62
+ Copy the view's `Picker:` `- Label:` / `Description:` pairs into that function in that order. Do not rebuild labels from the `Options:` dump. `--option-number` is the 1-based `Option N:` index, which is the same order as `Picker:`. The HTML report's `<select>` uses the same `option.answer` values.
63
+
64
+ Pass only the choices this step already owns — the `list-view` `Picker:` rows, the report `Picker:` rows, or the two confirmation labels. Do not append `Enter directly`. Claude Other, Grok `z`, and Codex's free-form row already collect a custom answer; that row is `Enters an answer`. When native-single is unavailable, a next message that is not a listed label or its 1-based number is the same `Enters an answer`. Do not ask a second question for the custom value.
63
65
 
64
66
  Never invent a picker function. Never ask the user to type a number when the native tool is available.
65
67
 
@@ -79,7 +81,7 @@ Present up to three task choices through the host picker. A host free-text row o
79
81
  okstra user-response show-view --report <reportPath> --project-root <projectRoot>
80
82
  ```
81
83
 
82
- The view contains the report identity, contract version, every open clarification question, its expected form, its current response and disposition, its options, approval context, plan option candidates, current plan decision, resolved context, why the row is asked, linked plan items, and cited artifacts. Question text and `options[]` come only from this view. Do not open a report record to select fields.
84
+ The view contains the report identity, contract version, every open clarification question, its expected form, its current response and disposition, its options, a `Picker:` block, approval context, plan option candidates, current plan decision, resolved context, why the row is asked, linked plan items, and cited artifacts. Question text and `options[]` come only from this view. Do not open a report record to select fields.
83
85
 
84
86
  Each entry in `options[]` corresponds to `{role, answer, rationale, scopeImpact, addedWork, directionChange, disposition}`. Put the `recommended` option first and suffix its label with `(Recommended)`. Then put the alternatives in view order.
85
87
 
@@ -135,9 +135,7 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
135
135
  align-items: flex-end;
136
136
  gap: .4rem;
137
137
  }
138
- .back-to-top-actions { display: flex; gap: .4rem; }
139
- .back-to-top,
140
- .back-to-top-toggle {
138
+ .back-to-top {
141
139
  padding: .55rem .9rem;
142
140
  border-radius: 8px;
143
141
  border: 1px solid color-mix(in srgb, CanvasText 40%, transparent);
@@ -148,12 +146,9 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
148
146
  font-size: .9rem;
149
147
  font-weight: 600;
150
148
  cursor: pointer;
151
- box-shadow: 0 2px 10px color-mix(in srgb, CanvasText 28%, transparent);
152
149
  }
153
- .back-to-top:hover,
154
- .back-to-top-toggle:hover { background: color-mix(in srgb, CanvasText 12%, Canvas); }
155
- .back-to-top:focus-visible,
156
- .back-to-top-toggle:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
150
+ .back-to-top:hover { background: color-mix(in srgb, CanvasText 12%, Canvas); }
151
+ .back-to-top:focus-visible { outline: 2px solid Highlight; outline-offset: 2px; }
157
152
  .back-to-top-index {
158
153
  display: none;
159
154
  box-sizing: border-box;
@@ -170,7 +165,6 @@ button[data-action="export-user-response"]:hover { background: color-mix(in srgb
170
165
  .back-to-top-index ol { margin: 0; padding-left: 1.2rem; }
171
166
  .back-to-top-index li { margin: .25em 0; }
172
167
  .back-to-top-index a { color: inherit; }
173
- .back-to-top-wrap.is-open .back-to-top-index { display: block; }
174
168
  @media (hover: hover) {
175
169
  .back-to-top-wrap:hover .back-to-top-index { display: block; }
176
170
  }
@@ -2,25 +2,4 @@
2
2
  "use strict";
3
3
 
4
4
  document.documentElement.classList.add("js-enabled");
5
-
6
- // 목차는 hover 만으로는 안 열린다. 클릭으로 연다.
7
- var wrap = document.querySelector(".back-to-top-wrap");
8
- var toggle = wrap && wrap.querySelector(".back-to-top-toggle");
9
- if (!wrap || !toggle) return;
10
-
11
- function setOpen(open) {
12
- wrap.classList.toggle("is-open", open);
13
- toggle.setAttribute("aria-expanded", open ? "true" : "false");
14
- }
15
-
16
- toggle.addEventListener("click", function (event) {
17
- event.stopPropagation();
18
- setOpen(!wrap.classList.contains("is-open"));
19
- });
20
- document.addEventListener("click", function (event) {
21
- if (!wrap.contains(event.target)) setOpen(false);
22
- });
23
- document.addEventListener("keydown", function (event) {
24
- if (event.key === "Escape") setOpen(false);
25
- });
26
5
  })();
@@ -44,6 +44,19 @@
44
44
  </header>
45
45
  <main id="main-content" data-report-role="human-main">
46
46
  {% block human_content %}{% endblock %}
47
+ {% if crossVerification.get("consensus") or crossVerification.get("differences") %}
48
+ <section data-report-section="cross-check">
49
+ <h2>{{ t('base.cross-check') }}</h2>
50
+ {% if crossVerification.get("consensus") %}
51
+ <h3>{{ t('base.agreed-across-workers') }}</h3>
52
+ <div class="summary-grid">{% for row in crossVerification.consensus %}<article class="summary-card" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }}</p><h3>{{ row.statement | inline_code }}</h3><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>
53
+ {% endif %}
54
+ {% if crossVerification.get("differences") %}
55
+ <h3>{{ t('base.workers-disagreed') }}</h3>
56
+ <div class="summary-grid">{% for row in crossVerification.differences %}<article class="summary-card tone-important" id="id-xv-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }}</p><h3>{{ row.disagreement | inline_code }}</h3><p>{% for pos in row.get("workersPosition", []) %}{{ pos.worker | inline_code }} · {{ pos.position | inline_code }}{% if not loop.last %} · {% endif %}{% endfor %}</p><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p></article>{% endfor %}</div>
57
+ {% endif %}
58
+ </section>
59
+ {% endif %}
47
60
  {{ clarification_responses(clarificationItems) }}
48
61
  {% if evidenceIndex %}
49
62
  <section data-report-section="evidence-ledger" data-reader-kind="audit">
@@ -116,10 +129,7 @@
116
129
  </footer>{% endif %}
117
130
  <div class="back-to-top-wrap">
118
131
  <nav class="back-to-top-index" id="back-to-top-index" aria-label="{{ t('base.contents') }}"><!--report-index-items--></nav>
119
- <div class="back-to-top-actions">
120
- <button type="button" class="back-to-top-toggle" aria-expanded="false" aria-controls="back-to-top-index">{{ t('base.contents') }}</button>
121
- <a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
122
- </div>
132
+ <a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
123
133
  </div>
124
134
  <script id="run-meta" type="application/json">{{ {
125
135
  "task-key": runMeta.task_key,
@@ -72,6 +72,12 @@
72
72
  "full": "Replanned in full",
73
73
  "incremental": "Partly replanned"
74
74
  },
75
+ "planGate": {
76
+ "passed": "Passed",
77
+ "passed-with-dissent": "Passed with dissent",
78
+ "blocked-by-disagreement": "Blocked by disagreement",
79
+ "aborted-non-result": "Verification produced no result"
80
+ },
75
81
  "ledgerKind": {
76
82
  "code-evidence": "Code evidence",
77
83
  "hypothesis": "Hypothesis",
@@ -106,6 +112,9 @@
106
112
  "written": "Written",
107
113
  "elapsed": "Elapsed",
108
114
  "evidence-ledger": "Evidence ledger",
115
+ "cross-check": "Worker agreement and dissent",
116
+ "agreed-across-workers": "Agreed across workers",
117
+ "workers-disagreed": "Workers disagreed",
109
118
  "source": "Source",
110
119
  "confidence": "Confidence",
111
120
  "export-my-answers": "Export my answers",
@@ -293,6 +302,7 @@
293
302
  "the-change-added-no-new-surface": "The change added no new identifier, module, or configuration entry.",
294
303
  "what-blocks-acceptance": "What blocks acceptance",
295
304
  "nothing-is-holding-acceptance": "Nothing is holding acceptance.",
305
+ "follow-up": "Follow-up:",
296
306
  "residual-risk": "Residual risk",
297
307
  "owner": "Owner:",
298
308
  "escalation-condition": "Escalation condition:",
@@ -382,6 +392,12 @@
382
392
  "mitigation": "Mitigation",
383
393
  "rollback-strategy": "Rollback strategy",
384
394
  "plan-body-verification": "Plan body verification",
395
+ "source-section": "source §",
396
+ "breakage": "Breakage",
397
+ "fixability": "Fixability",
398
+ "claim": "Claim",
399
+ "reproduction": "Reproduction",
400
+ "note": "Note:",
385
401
  "verdict": "Verdict",
386
402
  "what-it-blocked": "What it blocked",
387
403
  "plan-item": "Plan item",
@@ -439,6 +455,9 @@
439
455
  "implemented-by": "Implemented by",
440
456
  "verification-result": "Verification result",
441
457
  "independent-check": "Independent check:",
458
+ "independent-rerun": "Independent re-run:",
459
+ "command-log": "Command log:",
460
+ "discrepancy": "Discrepancy:",
442
461
  "exit-code": "exit code",
443
462
  "what-is-left": "What is left",
444
463
  "nothing-was-changed-outside-the-plan": "Nothing was changed outside the plan.",
@@ -72,6 +72,12 @@
72
72
  "full": "전부 다시 계획함",
73
73
  "incremental": "일부만 다시 계획함"
74
74
  },
75
+ "planGate": {
76
+ "passed": "통과",
77
+ "passed-with-dissent": "이견이 남았지만 통과",
78
+ "blocked-by-disagreement": "이견으로 차단",
79
+ "aborted-non-result": "검증이 결과를 내지 못함"
80
+ },
75
81
  "ledgerKind": {
76
82
  "code-evidence": "코드 근거",
77
83
  "hypothesis": "가설",
@@ -106,6 +112,9 @@
106
112
  "written": "작성",
107
113
  "elapsed": "소요 시간",
108
114
  "evidence-ledger": "근거 대장",
115
+ "cross-check": "작업자 합의와 이견",
116
+ "agreed-across-workers": "작업자 간 합의",
117
+ "workers-disagreed": "작업자 간 이견",
109
118
  "source": "출처",
110
119
  "confidence": "확신도",
111
120
  "export-my-answers": "내 답변 내보내기",
@@ -293,6 +302,7 @@
293
302
  "the-change-added-no-new-surface": "이 변경이 새로 추가한 식별자·모듈·설정 항목은 없습니다.",
294
303
  "what-blocks-acceptance": "수용을 막는 것",
295
304
  "nothing-is-holding-acceptance": "수용을 막는 것이 없습니다.",
305
+ "follow-up": "후속:",
296
306
  "residual-risk": "남은 위험",
297
307
  "owner": "담당:",
298
308
  "escalation-condition": "에스컬레이션 조건:",
@@ -382,6 +392,12 @@
382
392
  "mitigation": "완화 방안",
383
393
  "rollback-strategy": "롤백 전략",
384
394
  "plan-body-verification": "계획 본문 검증",
395
+ "source-section": "출처 §",
396
+ "breakage": "결함 종류",
397
+ "fixability": "수정 가능성",
398
+ "claim": "주장",
399
+ "reproduction": "재현",
400
+ "note": "메모:",
385
401
  "verdict": "판정",
386
402
  "what-it-blocked": "무엇을 막았는지",
387
403
  "plan-item": "계획 항목",
@@ -439,6 +455,9 @@
439
455
  "implemented-by": "구현한 곳",
440
456
  "verification-result": "검증 결과",
441
457
  "independent-check": "독립 검증:",
458
+ "independent-rerun": "독립 재실행:",
459
+ "command-log": "명령 기록:",
460
+ "discrepancy": "불일치:",
442
461
  "exit-code": "종료 코드",
443
462
  "what-is-left": "남은 것",
444
463
  "nothing-was-changed-outside-the-plan": "계획 밖에서 바뀐 것이 없습니다.",
@@ -1,5 +1,5 @@
1
1
  {% extends "html/base.template.html" %}
2
- {% from "html/macros/layout.html" import narrative as render_narrative, summary_card %}
2
+ {% from "html/macros/layout.html" import narrative as render_narrative %}
3
3
  {% from "html/macros/visualizations.html" import figure %}
4
4
 
5
5
  {% block human_content %}
@@ -24,7 +24,7 @@
24
24
  <section data-report-section="blockers" data-report-field="finalVerification.acceptanceBlockers">
25
25
  <h2>{{ t('tasks.final-verification.what-blocks-acceptance') }}</h2>
26
26
  {{ render_narrative(narrative.blockerExplanation, "finalVerification.userNarrative.blockerExplanation") }}
27
- <div class="summary-grid">{% for row in final.acceptanceBlockers %}{{ summary_card(row.id ~ " · " ~ row.severity, row.statement, "important", anchor=row.id) }}{% else %}<p>{{ t('tasks.final-verification.nothing-is-holding-acceptance') }}</p>{% endfor %}</div>
27
+ <div class="summary-grid">{% for row in final.acceptanceBlockers %}<article class="summary-card tone-important" id="id-{{ row.id }}"><p class="eyebrow">{{ row.id | inline_code }} · {{ row.severity }}</p><h3>{{ row.statement | inline_code }}</h3><p class="evidence-refs">{{ t('macros.layout.evidence') }} {{ row.evidence | inline_code }}</p><p>{{ t('tasks.final-verification.follow-up') }} {{ row.followUpPhase | inline_code }}</p></article>{% else %}<p>{{ t('tasks.final-verification.nothing-is-holding-acceptance') }}</p>{% endfor %}</div>
28
28
  </section>
29
29
 
30
30
  <section data-report-section="residual-risk" data-report-field="finalVerification.residualRisk">