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
@@ -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:
@@ -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
  })();
@@ -116,10 +116,7 @@
116
116
  </footer>{% endif %}
117
117
  <div class="back-to-top-wrap">
118
118
  <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>
119
+ <a class="back-to-top" href="#top">{{ t('base.back-to-top') }}</a>
123
120
  </div>
124
121
  <script id="run-meta" type="application/json">{{ {
125
122
  "task-key": runMeta.task_key,
@@ -85,12 +85,14 @@
85
85
  {% endif %}
86
86
 
87
87
  {# The verification checklist, cross-project dependencies, migration risk,
88
- rollback strategy and plan-body verification rounds are the implementer's
89
- and the auditor's working material, not the approver's: this reader is
90
- deciding whether to greenlight the plan, and none of those tables move that
91
- decision. They stay in the markdown report, which is what the next phase and
92
- the audit trail read. Requirement coverage is the exception — "does this plan
93
- actually do what the brief asked" is the approval question itself. #}
88
+ rollback strategy, plan-body verification rounds, and the agent-activity
89
+ ledger are the implementer's and the auditor's working material, not the
90
+ approver's: this reader is deciding whether to greenlight the plan, and none
91
+ of those tables move that decision. They stay in the markdown report, which
92
+ is what the next phase and the audit trail read. Requirement coverage is the
93
+ exception — "does this plan actually do what the brief asked" is the
94
+ approval question itself. A-NNN citations still need an id in this document
95
+ or the checkRef links land nowhere. #}
94
96
  <section data-report-section="requirement-coverage" data-report-field="implementationPlanning.requirementCoverage">
95
97
  <h2>{{ t('tasks.implementation-planning.how-each-requirement-gets-met') }}</h2>
96
98
  {% if planning.get("planningContract") == "selected-direction" %}
@@ -134,28 +136,7 @@
134
136
  {% endif %}
135
137
 
136
138
  {% if agentActivities %}
137
- <section data-report-section="agent-activity">
138
- <h2>{{ t('tasks.implementation-planning.what-each-agent-did') }}</h2>
139
- <div class="activity-list">
140
- {% for row in agentActivities %}
141
- <article class="activity-card" id="id-{{ row.activityId }}">
142
- <p class="eyebrow">{{ row.activityId | inline_code }} · {{ row.agent }}</p>
143
- <h3>{{ row.summary | inline_code }}</h3>
144
- <p>{{ t('tasks.implementation-planning.result') }}: {{ row.outcome | inline_code }}</p>
145
- <details>
146
- <summary>{{ t('tasks.implementation-planning.commands-and-evidence') }}</summary>
147
- <p><code>{{ row.kind }}</code></p>
148
- {% if row.planItemIds %}<p>{{ row.planItemIds | join(', ') | inline_code }}</p>{% endif %}
149
- {% for command in row.commands %}
150
- <p><code>{{ command.command }}</code> · exit {{ command.exitCode }} · {{ command.outputSummary | inline_code }}</p>
151
- {% endfor %}
152
- {% if row.evidenceRefs %}<p>{{ row.evidenceRefs | join(', ') | inline_code }}</p>{% endif %}
153
- {% if row.resultPath %}<p><code>{{ row.resultPath }}</code></p>{% endif %}
154
- </details>
155
- </article>
156
- {% endfor %}
157
- </div>
158
- </section>
139
+ <div hidden>{% for row in agentActivities %}<span id="id-{{ row.activityId }}"></span>{% endfor %}</div>
159
140
  {% endif %}
160
141
 
161
142
  <section data-report-section="open-decisions">
@@ -102,8 +102,12 @@ if not process.stdout.strip():
102
102
  payload = json.loads(process.stdout)
103
103
  actual_status = payload.get("validationStatus")
104
104
  if actual_status != expected_status:
105
+ extra = ""
106
+ if expected_status == "passed":
107
+ listed = payload.get("failures") or []
108
+ extra = "\n" + "\n".join(str(item) for item in listed)
105
109
  raise SystemExit(
106
- f"validator status mismatch: expected {expected_status}, got {actual_status}"
110
+ f"validator status mismatch: expected {expected_status}, got {actual_status}{extra}"
107
111
  )
108
112
 
109
113
  if expected_status == "passed" and process.returncode != 0:
@@ -40,6 +40,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
40
40
  sys.path.insert(0, str(_ssot_dir))
41
41
 
42
42
  from okstra_ctl.clarification_items import ( # noqa: E402
43
+ STRUCTURED_REPORT_VERSIONS,
43
44
  parse_clarification_items,
44
45
  section_1_present_but_unparsed,
45
46
  )
@@ -87,7 +88,7 @@ def _load_v2_data(report_path: Path) -> tuple[Path, dict] | None:
87
88
  data = json.loads(data_path.read_text(encoding="utf-8"))
88
89
  except (OSError, json.JSONDecodeError):
89
90
  return None
90
- if isinstance(data, dict) and data.get("schemaVersion") == "2.0":
91
+ if isinstance(data, dict) and data.get("schemaVersion") in STRUCTURED_REPORT_VERSIONS:
91
92
  return data_path, data
92
93
  return None
93
94