okstra 0.146.0 → 0.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/docs/architecture/storage-model.md +8 -7
  3. package/docs/architecture.md +18 -12
  4. package/docs/cli.md +3 -3
  5. package/docs/project-structure-overview.md +16 -14
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/workers/report-writer-worker.md +10 -10
  9. package/runtime/bin/okstra-render-final-report.py +4 -4
  10. package/runtime/bin/okstra-render-report-views.py +100 -12
  11. package/runtime/bin/okstra-trace-cleanup.sh +13 -9
  12. package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
  13. package/runtime/prompts/lead/report-writer.md +15 -11
  14. package/runtime/prompts/profiles/_common-contract.md +13 -7
  15. package/runtime/prompts/profiles/improvement-discovery.md +3 -1
  16. package/runtime/python/okstra_ctl/final_report_schema.py +37 -12
  17. package/runtime/python/okstra_ctl/render_final_report.py +136 -28
  18. package/runtime/python/okstra_ctl/report_contract.py +124 -0
  19. package/runtime/python/okstra_ctl/report_finalize.py +1 -1
  20. package/runtime/python/okstra_ctl/report_html/__init__.py +10 -0
  21. package/runtime/python/okstra_ctl/report_html/common.py +50 -0
  22. package/runtime/python/okstra_ctl/report_html/models.py +59 -0
  23. package/runtime/python/okstra_ctl/report_html/render.py +69 -0
  24. package/runtime/python/okstra_ctl/report_html/router.py +40 -0
  25. package/runtime/python/okstra_ctl/report_html/view_models/__init__.py +1 -0
  26. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +38 -0
  27. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +48 -0
  28. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +38 -0
  29. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +46 -0
  30. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +46 -0
  31. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +102 -0
  32. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +42 -0
  33. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +54 -0
  34. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +53 -0
  35. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +54 -0
  36. package/runtime/python/okstra_ctl/report_html/visualizations.py +113 -0
  37. package/runtime/python/okstra_ctl/report_view_artifacts.py +4 -1
  38. package/runtime/python/okstra_ctl/report_views.py +15 -43
  39. package/runtime/python/okstra_ctl/run.py +12 -6
  40. package/runtime/python/okstra_ctl/schema_excerpt.py +7 -17
  41. package/runtime/schemas/final-report-v2.0.schema.json +3923 -0
  42. package/runtime/templates/reports/final-report-v2.template.md +66 -0
  43. package/runtime/templates/reports/html/assets/base.css +38 -0
  44. package/runtime/templates/reports/html/assets/base.js +5 -0
  45. package/runtime/templates/reports/html/base.template.html +65 -0
  46. package/runtime/templates/reports/html/macros/forms.html +47 -0
  47. package/runtime/templates/reports/html/macros/layout.html +19 -0
  48. package/runtime/templates/reports/html/macros/visualizations.html +17 -0
  49. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +40 -0
  50. package/runtime/templates/reports/html/tasks/error-analysis.template.html +40 -0
  51. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +40 -0
  52. package/runtime/templates/reports/html/tasks/final-verification.template.html +39 -0
  53. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +47 -0
  54. package/runtime/templates/reports/html/tasks/implementation.template.html +40 -0
  55. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +29 -0
  56. package/runtime/templates/reports/html/tasks/project-analysis.template.html +57 -0
  57. package/runtime/templates/reports/html/tasks/release-handoff.template.html +36 -0
  58. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +37 -0
  59. package/runtime/validators/validate-report-views.py +86 -4
  60. package/runtime/validators/validate-run.py +62 -9
  61. package/runtime/validators/validate_improvement_report.py +55 -0
  62. package/src/commands/lifecycle/install.mjs +18 -13
  63. package/src/commands/report/finalize.mjs +2 -3
  64. package/src/commands/report/render-final-report.mjs +4 -2
  65. package/src/commands/report/render-views.mjs +8 -8
@@ -0,0 +1,36 @@
1
+ {% extends "html/base.template.html" %}
2
+ {% from "html/macros/layout.html" import narrative as render_narrative, summary_card %}
3
+ {% from "html/macros/visualizations.html" import figure %}
4
+
5
+ {% block human_content %}
6
+ <section data-report-section="handoff-outcome">
7
+ <h2>인계 결과</h2>
8
+ {{ render_narrative(narrative.handoffOutcome, "releaseHandoff.userNarrative.handoffOutcome") }}
9
+ {{ figure(handoffFigure) }}
10
+ </section>
11
+
12
+ <section data-report-section="user-selection" data-report-field="releaseHandoff.userSelections">
13
+ <h2>사용자가 선택한 동작</h2>
14
+ <dl><dt>인계 방식</dt><dd>{{ handoff.userSelections.h1 }}</dd><dt>충돌 확인</dt><dd>{{ handoff.userSelections.h2b }}</dd><dt>PR 본문</dt><dd>{{ handoff.userSelections.h3 }}</dd></dl>
15
+ </section>
16
+
17
+ <section data-report-section="branch-and-pr" data-report-field="releaseHandoff.featureBranchState">
18
+ <h2>브랜치와 PR</h2>
19
+ {{ render_narrative(narrative.branchAndPrExplanation, "releaseHandoff.userNarrative.branchAndPrExplanation") }}
20
+ <p><strong>브랜치:</strong> {{ handoff.featureBranchState.branchName }}</p>
21
+ <p data-report-field="releaseHandoff.pullRequestOutcome"><strong>PR 결과:</strong> {{ handoff.pullRequestOutcome.kind }}{% if prUrl %} · <a href="{{ prUrl }}" rel="noopener noreferrer">Pull request 열기</a>{% endif %}</p>
22
+ </section>
23
+
24
+ <section data-report-section="conflicts" data-report-field="releaseHandoff.mergeConflictProbe">
25
+ <h2>충돌 판정</h2>
26
+ {{ render_narrative(narrative.conflictExplanation, "releaseHandoff.userNarrative.conflictExplanation") }}
27
+ {{ summary_card(handoff.mergeConflictProbe.kind, "기준 브랜치: " ~ (handoff.mergeConflictProbe.baseBranch | default('not-run'))) }}
28
+ {% if handoff.mergeConflictProbe.conflictingPaths %}<ul>{% for path in handoff.mergeConflictProbe.conflictingPaths %}<li>{{ path }}</li>{% endfor %}</ul>{% endif %}
29
+ </section>
30
+
31
+ <section data-report-section="next-action">
32
+ <h2>다음 행동</h2>
33
+ {{ render_narrative(narrative.nextAction, "releaseHandoff.userNarrative.nextAction") }}
34
+ <p>{{ handoff.routingRecommendation }}</p>
35
+ </section>
36
+ {% endblock %}
@@ -0,0 +1,37 @@
1
+ {% extends "html/base.template.html" %}
2
+ {% from "html/macros/layout.html" import narrative as render_narrative, action_list, summary_card %}
3
+ {% from "html/macros/visualizations.html" import figure %}
4
+
5
+ {% block human_content %}
6
+ <section data-report-section="request">
7
+ <h2>요청 이해</h2>
8
+ {{ render_narrative(narrative.requestUnderstanding, "requirementsDiscovery.userNarrative.requestUnderstanding") }}
9
+ <h3>사용자 요청 원문</h3>
10
+ <p data-report-field="requirementsDiscovery.requestVerbatim">{{ requirements.requestVerbatim }}</p>
11
+ <h3>시스템 해석</h3>
12
+ <p data-report-field="requirementsDiscovery.systemInterpretation">{{ requirements.systemInterpretation }}</p>
13
+ <dl data-report-field="requirementsDiscovery.classification">{% for key, value in requirements.classification.items() %}<dt>{{ key }}</dt><dd>{{ value }}</dd>{% endfor %}</dl>
14
+ </section>
15
+
16
+ <section data-report-section="requirements">
17
+ <h2>확정 요구사항과 미확정 요구사항</h2>
18
+ {{ render_narrative(narrative.confirmedRequirements, "requirementsDiscovery.userNarrative.confirmedRequirements") }}
19
+ <div class="summary-grid" data-report-field="requirementsDiscovery.confirmedRequirements">{% for row in requirements.confirmedRequirements %}{{ summary_card(row.id ~ " · 확정", row.statement) }}{% endfor %}</div>
20
+ {{ render_narrative(narrative.unresolvedRequirements, "requirementsDiscovery.userNarrative.unresolvedRequirements") }}
21
+ <div class="summary-grid" data-report-field="requirementsDiscovery.unresolvedRequirements">{% for row in requirements.unresolvedRequirements %}{{ summary_card(row.id ~ " · 결정 필요", row.statement, "important") }}{% else %}<p>미확정 요구사항이 없습니다.</p>{% endfor %}</div>
22
+ </section>
23
+
24
+ <section data-report-section="routing">
25
+ <h2>다음 태스크와 의존성</h2>
26
+ {{ render_narrative(narrative.routingExplanation, "requirementsDiscovery.userNarrative.routingExplanation") }}
27
+ <p><strong>다음 태스크:</strong> {{ requirements.routing.nextTaskType }} · {{ requirements.routing.rationale }}</p>
28
+ {{ figure(routingFigure) }}
29
+ {% if not requirements.taskDependencies %}<p class="empty-state">이 요구사항 태스크는 다른 태스크에 의존하지 않는 단일 작업입니다.</p>{% endif %}
30
+ </section>
31
+
32
+ <section data-report-section="decisions">
33
+ <h2>사용자가 결정할 항목</h2>
34
+ <div class="summary-grid">{% for row in clarificationItems %}{{ summary_card(row.id ~ " · " ~ row.kind, row.statement, "important") }}{% else %}<p>추가 결정이 필요하지 않습니다.</p>{% endfor %}</div>
35
+ {{ action_list(humanSummary.actions) }}
36
+ </section>
37
+ {% endblock %}
@@ -23,6 +23,8 @@ per line to stderr.
23
23
  from __future__ import annotations
24
24
 
25
25
  import argparse
26
+ import hashlib
27
+ import json
26
28
  import re
27
29
  import sys
28
30
  from pathlib import Path
@@ -43,6 +45,8 @@ from okstra_ctl.report_views import ( # noqa: E402
43
45
  source_digest,
44
46
  )
45
47
  from okstra_ctl.report_view_artifacts import html_view_path # noqa: E402
48
+ from okstra_ctl.final_report_paths import final_report_data_path # noqa: E402
49
+ from okstra_ctl.report_contract import TASK_TYPE_REQUIRED_HUMAN_FIELDS # noqa: E402
46
50
 
47
51
 
48
52
  _EXTERNAL_URL_RE = re.compile(
@@ -66,11 +70,86 @@ _ANALYSIS_REVIEW_STATUSES = ["accepted", "rejected", "revision-requested"]
66
70
 
67
71
 
68
72
  def _main_body(html_text: str) -> str:
69
- start = html_text.find("<main>")
70
- end = html_text.find("</main>", start) if start >= 0 else -1
71
- if start < 0 or end < 0:
73
+ match = re.search(r"<main[^>]*>(?P<body>.*?)</main>", html_text, re.DOTALL)
74
+ if match is None:
72
75
  return html_text
73
- return html_text[start + len("<main>"): end]
76
+ return match.group("body")
77
+
78
+
79
+ def _sha256(path: Path) -> str:
80
+ return hashlib.sha256(path.read_bytes()).hexdigest()
81
+
82
+
83
+ def _digest_from_run_meta(html_text: str, key: str) -> str:
84
+ match = re.search(rf'"{re.escape(key)}"\s*:\s*"([0-9a-f]{{64}})"', html_text)
85
+ return match.group(1) if match else ""
86
+
87
+
88
+ def _load_v2_data(report_path: Path) -> tuple[Path, dict] | None:
89
+ data_path = final_report_data_path(report_path)
90
+ if not data_path.is_file():
91
+ return None
92
+ try:
93
+ data = json.loads(data_path.read_text(encoding="utf-8"))
94
+ except (OSError, json.JSONDecodeError):
95
+ return None
96
+ if isinstance(data, dict) and data.get("schemaVersion") == "2.0":
97
+ return data_path, data
98
+ return None
99
+
100
+
101
+ def _validate_v2(report_path: Path, data_path: Path, data: dict) -> list[str]:
102
+ html_path = html_view_path(report_path)
103
+ if not html_path.is_file():
104
+ return [f"missing html artifact: {html_path}"]
105
+ html_text = html_path.read_text(encoding="utf-8")
106
+ main_body = _main_body(html_text)
107
+ failures: list[str] = []
108
+
109
+ for key, path in (
110
+ ("source-data-sha256", data_path),
111
+ ("source-md-sha256", report_path),
112
+ ):
113
+ recorded = _digest_from_run_meta(html_text, key)
114
+ expected = _sha256(path)
115
+ if not recorded:
116
+ failures.append(f"html missing {key} in run-meta")
117
+ elif recorded != expected:
118
+ failures.append(f"stale html: {key} does not match current source")
119
+
120
+ task_type = data.get("header", {}).get("taskType", "")
121
+ if f'data-task-template="{task_type}"' not in html_text:
122
+ failures.append(f"v2 html task template mismatch for {task_type}")
123
+ actual_fields = set(re.findall(r'data-report-field="([^"]+)"', main_body))
124
+ for field in TASK_TYPE_REQUIRED_HUMAN_FIELDS.get(task_type, ()):
125
+ if field not in actual_fields:
126
+ failures.append(f"missing human field: {field}")
127
+
128
+ if "data-audit-field=" in main_body:
129
+ failures.append("audit field inside human main")
130
+ node_ids = set(re.findall(r'data-node-id="([^"]+)"', html_text))
131
+ fallback_ids = set(re.findall(r'data-fallback-id="([^"]+)"', html_text))
132
+ if node_ids != fallback_ids:
133
+ failures.append(
134
+ f"visualization ID mismatch: svg={sorted(node_ids)}, fallback={sorted(fallback_ids)}"
135
+ )
136
+
137
+ data_ids = []
138
+ for row in data.get("clarificationItems", []):
139
+ if not isinstance(row, dict):
140
+ continue
141
+ response_id = row.get("id")
142
+ if isinstance(response_id, str) and re.fullmatch(r"C-\d+", response_id):
143
+ data_ids.append(response_id)
144
+ data_ids.sort()
145
+ html_ids = sorted(set(_RESPONSE_ID_ATTR_RE.findall(html_text)))
146
+ if data_ids != html_ids:
147
+ failures.append(
148
+ f"Response ID mismatch: data.json has {data_ids}, HTML has {html_ids}"
149
+ )
150
+ if _EXTERNAL_URL_RE.search(html_text):
151
+ failures.append("html contains external URL in script/link/img — must be self-contained")
152
+ return failures
74
153
 
75
154
 
76
155
  def _no_form_sections(html_body: str) -> list[str]:
@@ -128,6 +207,9 @@ def validate(report_path: Path) -> list[str]:
128
207
  return [f"final-report not found: {report_path}"]
129
208
 
130
209
  md = report_path.read_text(encoding="utf-8")
210
+ v2 = _load_v2_data(report_path)
211
+ if v2 is not None:
212
+ return _validate_v2(report_path, v2[0], v2[1])
131
213
  html_path = html_view_path(report_path)
132
214
  # §1 헤딩이 있는데 파싱이 실패하면 md_ids 가 빈 []이 되어 "clarification 없음
133
215
  # → skip" 으로 흘러 HTML form parity 게이트가 조용히 열린다. fail-closed.
@@ -30,12 +30,12 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
30
30
  try:
31
31
  from okstra_ctl.final_report_schema import (
32
32
  SchemaError,
33
- load_schema,
33
+ load_schema_for_data,
34
34
  validate as schema_validate,
35
35
  )
36
36
  except ImportError: # pragma: no cover — runtime guarantees this import
37
37
  SchemaError = None # type: ignore[assignment]
38
- load_schema = None # type: ignore[assignment]
38
+ load_schema_for_data = None # type: ignore[assignment]
39
39
  schema_validate = None # type: ignore[assignment]
40
40
 
41
41
  from okstra_project import project_json_path # noqa: E402
@@ -1021,6 +1021,48 @@ def _scan_token_usage_summary(
1021
1021
  # a section heading line (not as inline text inside a paragraph or table).
1022
1022
  _VERDICT_CARD_HEADING_RE = re.compile(r"^##[ \t]+Verdict Card\b", re.MULTILINE)
1023
1023
 
1024
+ _V2_AI_HANDOFF_HEADINGS = (
1025
+ "## AI Handoff Summary",
1026
+ "## Clarification and User Decisions",
1027
+ "## Evidence Ledger",
1028
+ "## Task Deliverable:",
1029
+ "## Cross Verification Audit",
1030
+ "## Execution Audit",
1031
+ "## Token and Cost Audit",
1032
+ )
1033
+
1034
+
1035
+ def _validate_v2_ai_handoff(content: str, failures: list[str]) -> None:
1036
+ positions: list[int] = []
1037
+ for heading in _V2_AI_HANDOFF_HEADINGS:
1038
+ count = content.count(heading)
1039
+ if count != 1:
1040
+ failures.append(
1041
+ "schema-v2 AI handoff markdown requires exactly one "
1042
+ f"`{heading}` heading; found {count}."
1043
+ )
1044
+ continue
1045
+ positions.append(content.index(heading))
1046
+ if len(positions) == len(_V2_AI_HANDOFF_HEADINGS) and positions != sorted(
1047
+ positions
1048
+ ):
1049
+ failures.append(
1050
+ "schema-v2 AI handoff markdown heading order does not match "
1051
+ "templates/reports/final-report-v2.template.md."
1052
+ )
1053
+ for human_only_field in ('"humanSummary"', '"userNarrative"'):
1054
+ if human_only_field in content:
1055
+ failures.append(
1056
+ "schema-v2 AI handoff markdown contains human-only field "
1057
+ f"{human_only_field}; render it only in the task-specific HTML."
1058
+ )
1059
+ if _READING_CONFIRMATION_HEADING_RE.search(content) is not None:
1060
+ failures.append(
1061
+ "final report contains a `## 0. Reading Confirmation` heading — "
1062
+ "Reading Confirmation lives in the worker audit sidecar, never "
1063
+ "in the AI handoff markdown."
1064
+ )
1065
+
1024
1066
  # Top-of-report Index block. The renderer
1025
1067
  # (scripts/okstra_ctl/render_final_report.py) injects `<a id="report-index">`
1026
1068
  # into the index heading; a missing anchor means the markdown was produced
@@ -2229,6 +2271,10 @@ def validate_report(
2229
2271
  "run `okstra-token-usage.py ... --substitute-data <report-path>` during Phase 7"
2230
2272
  )
2231
2273
 
2274
+ if (report_data or {}).get("schemaVersion") == "2.0":
2275
+ _validate_v2_ai_handoff(content, failures)
2276
+ return
2277
+
2232
2278
  # Catch the "workers typed `0` / `pending` instead of the placeholder"
2233
2279
  # failure mode that bypasses the placeholder check above.
2234
2280
  _scan_token_usage_summary(
@@ -2995,7 +3041,7 @@ def validate_final_report_data(
2995
3041
  project_root: Path | None = None,
2996
3042
  clarification_text: str = "",
2997
3043
  ) -> Mapping[str, Any] | None:
2998
- """Validate the final-report data.json against the v1.0 schema.
3044
+ """Validate final-report data.json against its declared versioned schema.
2999
3045
 
3000
3046
  The data.json is the source-of-truth that the renderer reads to
3001
3047
  produce the markdown. If schema validation passes here, the rendered
@@ -3010,7 +3056,7 @@ def validate_final_report_data(
3010
3056
  The returned mapping is the exact loaded snapshot consumed by later
3011
3057
  finalization checks and workflow persistence.
3012
3058
  """
3013
- if schema_validate is None or load_schema is None:
3059
+ if schema_validate is None or load_schema_for_data is None:
3014
3060
  # Module-load fallback path; should never fire in a real install.
3015
3061
  failures.append(
3016
3062
  "validate-run: okstra_ctl.final_report_schema is not importable — "
@@ -3028,13 +3074,11 @@ def validate_final_report_data(
3028
3074
  return
3029
3075
 
3030
3076
  try:
3031
- schema = load_schema()
3077
+ data = json.loads(data_path.read_text(encoding="utf-8"))
3078
+ schema = load_schema_for_data(data)
3032
3079
  except SchemaError as exc:
3033
3080
  failures.append(f"final-report schema could not be loaded: {exc}")
3034
3081
  return
3035
-
3036
- try:
3037
- data = json.loads(data_path.read_text(encoding="utf-8"))
3038
3082
  except json.JSONDecodeError as exc:
3039
3083
  failures.append(f"final-report data.json is not valid JSON: {exc}")
3040
3084
  return
@@ -6035,6 +6079,8 @@ def validate_phase_boundary(
6035
6079
  task_type: str,
6036
6080
  report_path: Path,
6037
6081
  failures: list[str],
6082
+ *,
6083
+ report_data: Mapping[str, Any] | None = None,
6038
6084
  ) -> None:
6039
6085
  """Phase-specific contract checks.
6040
6086
 
@@ -6050,6 +6096,8 @@ def validate_phase_boundary(
6050
6096
  MUST be absent (lead converted findings into Clarification rows
6051
6097
  instead of opening the gate).
6052
6098
  """
6099
+ if (report_data or {}).get("schemaVersion") == "2.0":
6100
+ return
6053
6101
  if not report_path.exists():
6054
6102
  return
6055
6103
  content = report_path.read_text()
@@ -7167,7 +7215,12 @@ def main() -> int:
7167
7215
  )
7168
7216
  validate_team_state_usage(team_state, failures)
7169
7217
 
7170
- validate_phase_boundary(task_type, report_path, failures)
7218
+ validate_phase_boundary(
7219
+ task_type,
7220
+ report_path,
7221
+ failures,
7222
+ report_data=validation_data,
7223
+ )
7171
7224
  _validate_phase_boundary_error_log(
7172
7225
  report_path.parent.parent,
7173
7226
  task_type,
@@ -7,6 +7,7 @@ Called by validators/validate-run.py when task_type == "improvement-discovery".
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
+ import json
10
11
  import re
11
12
  import sys
12
13
  from dataclasses import dataclass, field
@@ -25,6 +26,10 @@ from okstra_ctl.improvement_lenses import (
25
26
  ABSOLUTE_CANDIDATE_CAP,
26
27
  SOURCE_WORKERS,
27
28
  )
29
+ from okstra_ctl.final_report_schema import (
30
+ load_schema_version,
31
+ validate as validate_final_report_data,
32
+ )
28
33
  from okstra_ctl.md_table import split_pipe_row
29
34
 
30
35
 
@@ -290,6 +295,47 @@ def _check_verdict_card(
290
295
  errors.append("Verdict Card row must byte-match `## 7. Final Verdict` row")
291
296
 
292
297
 
298
+ def _v2_candidate_rows(data: dict) -> list[list[str]]:
299
+ candidates = data["improvementDiscovery"]["candidates"]
300
+ return [
301
+ [
302
+ row["id"],
303
+ ",".join(row["lens"]),
304
+ row["title"],
305
+ ",".join(row["scope"]),
306
+ row["severity"],
307
+ row["effort"],
308
+ row["consensus"],
309
+ ",".join(row["sourceWorkers"]),
310
+ row["recommendedNextPhase"],
311
+ row["expectedBehaviorAfter"],
312
+ ",".join(row["evidence"]),
313
+ ]
314
+ for row in candidates
315
+ ]
316
+
317
+
318
+ def _validate_v2_report(
319
+ data: dict,
320
+ run_dir: Path,
321
+ brief_frontmatter: dict,
322
+ ) -> ValidationResult:
323
+ errors = validate_final_report_data(data, load_schema_version("2.0"))
324
+ _check_grilling_log(run_dir, errors)
325
+ if errors:
326
+ return ValidationResult(ok=False, errors=errors)
327
+
328
+ rows = _v2_candidate_rows(data)
329
+ _check_candidates_cap(len(rows), brief_frontmatter, errors)
330
+ _check_candidate_rows(
331
+ rows,
332
+ brief_frontmatter.get("scan-scope") or [],
333
+ brief_frontmatter.get("out-of-scope") or [],
334
+ errors,
335
+ )
336
+ return ValidationResult(ok=not errors, errors=errors)
337
+
338
+
293
339
  def validate_improvement_report(
294
340
  report_path: Path, run_dir: Path, brief_frontmatter: dict
295
341
  ) -> ValidationResult:
@@ -299,6 +345,15 @@ def validate_improvement_report(
299
345
  errors.append(f"report file not found: {report_path}")
300
346
  return ValidationResult(ok=False, errors=errors)
301
347
 
348
+ data_path = report_path.with_suffix(".data.json")
349
+ if data_path.exists():
350
+ try:
351
+ data = json.loads(data_path.read_text(encoding="utf-8"))
352
+ except (OSError, json.JSONDecodeError) as exc:
353
+ return ValidationResult(ok=False, errors=[f"invalid report data: {exc}"])
354
+ if data.get("schemaVersion") == "2.0":
355
+ return _validate_v2_report(data, run_dir, brief_frontmatter)
356
+
302
357
  body = report_path.read_text(encoding="utf-8")
303
358
 
304
359
  _check_grilling_log(run_dir, errors)
@@ -80,7 +80,8 @@ Effect (copy mode):
80
80
  ${"$HOME"}/.okstra/lib/python <- runtime/python
81
81
  ${"$HOME"}/.okstra/lib/validators <- runtime/validators (stage/report structure validators)
82
82
  ${"$HOME"}/.okstra/bin <- runtime/bin
83
- ${"$HOME"}/.okstra/templates <- runtime/templates (report.css / report.js / *.template.md)
83
+ ${"$HOME"}/.okstra/templates <- runtime/templates (v1 assets + v2 AI/HTML template trees)
84
+ ${"$HOME"}/.okstra/schemas <- runtime/schemas (final-report schema v1 + v2)
84
85
  ${"$HOME"}/.okstra/prompts <- runtime/prompts (lead contracts + coding-preflight pack)
85
86
  ${"$HOME"}/.okstra/templates/settings.local.json <- runtime/templates/reports/settings.template.json
86
87
  ${"$HOME"}/.claude/skills/<name> <- skills when ${"$HOME"}/.claude exists
@@ -775,8 +776,9 @@ export async function runInstall(args) {
775
776
  paths.bin,
776
777
  { refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o755 },
777
778
  );
778
- // templates/ tree — report.css / report.js / *.template.md are consumed at
779
- // runtime by okstra-render-report-views.py and final-report assembly. They
779
+ // templates/ tree — the v1 report.css/report.js compatibility assets, the
780
+ // v2 AI handoff template, and the task-specific html/** tree are consumed by
781
+ // okstra-render-report-views.py and final-report assembly. They
780
782
  // are NOT covered by installNamedTemplate (which only handles the renamed
781
783
  // settings.local.json sidecar), so without this step copy-mode installs
782
784
  // miss every asset other than that single file. See
@@ -786,13 +788,10 @@ export async function runInstall(args) {
786
788
  join(paths.home, "templates"),
787
789
  { refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o644 },
788
790
  );
789
- // schemas/ tree — final-report-v1.0.schema.json is loaded at runtime by
790
- // okstra_ctl.final_report_schema.load_schema() (parent-walk from the
791
- // installed okstra_ctl package finds ~/.okstra/schemas/). It is needed by
792
- // (a) prepare_task_bundle to write the per-task-type schema excerpt into
793
- // each run's instruction-set, and (b) validate-run's data.json schema
794
- // check. Without this step copy-mode installs leave load_schema() unable
795
- // to locate the schema. Link mode resolves it through the repo symlink.
791
+ // schemas/ tree — version-selected final-report v1/v2 schemas are loaded by
792
+ // okstra_ctl.final_report_schema. They support bundle excerpts, legacy
793
+ // re-renders, new task HTML, and validate-run. Link mode resolves the same
794
+ // files through the repo symlink.
796
795
  const schemasResult = await copyTreeIfChanged(
797
796
  join(runtimeRoot, "schemas"),
798
797
  join(paths.home, "schemas"),
@@ -840,7 +839,7 @@ export async function runInstall(args) {
840
839
  }
841
840
  if (templatesResult.missingSource) {
842
841
  process.stderr.write(
843
- "warning: runtime/templates is empty. report.css / report.js will be missing — re-run the build step.\n",
842
+ "warning: runtime/templates is empty. final-report Markdown and HTML templates will be missing — re-run the build step.\n",
844
843
  );
845
844
  }
846
845
  if (schemasResult.missingSource) {
@@ -994,8 +993,14 @@ export async function runEnsureInstalled(args) {
994
993
  }
995
994
  if (!(await dirExists(paths.pythonpath))) reasons.push(`missing ${paths.pythonpath}`);
996
995
  if (!(await dirExists(paths.agents))) reasons.push(`missing agents dir ${paths.agents}`);
997
- if (!(await fileExists(join(paths.home, "schemas", "final-report-v1.0.schema.json")))) {
998
- reasons.push(`missing ${join(paths.home, "schemas", "final-report-v1.0.schema.json")}`);
996
+ for (const relative of [
997
+ ["schemas", "final-report-v1.0.schema.json"],
998
+ ["schemas", "final-report-v2.0.schema.json"],
999
+ ["templates", "reports", "final-report-v2.template.md"],
1000
+ ["templates", "reports", "html", "base.template.html"],
1001
+ ]) {
1002
+ const installedPath = join(paths.home, ...relative);
1003
+ if (!(await fileExists(installedPath))) reasons.push(`missing ${installedPath}`);
999
1004
  }
1000
1005
  for (const reason of await promptResourceDriftReasons(paths)) {
1001
1006
  reasons.push(reason);
@@ -10,9 +10,8 @@ Usage:
10
10
  Runs the four Phase 7 steps in their contractual order against one final-report:
11
11
 
12
12
  1. token-usage substitute real token/cost numbers into the data.json
13
- 2. render-views write the self-contained *.html sibling (skipped when the
14
- report has no C-* clarification rows and is not a plan
15
- approval target)
13
+ 2. render-views write the schema-v2 task-specific *.html sibling; v1
14
+ keeps the legacy conditional interactive view
16
15
  3. spawn-followups turn section 4 rows into task stubs
17
16
  4. validate-run validate the finished run artifacts
18
17
 
@@ -1,6 +1,6 @@
1
1
  import { runInstalledScript } from "../../lib/python-helper.mjs";
2
2
 
3
- const USAGE = `okstra render-final-report — render the markdown sibling of a final-report data.json
3
+ const USAGE = `okstra render-final-report — render the AI handoff Markdown sibling of final-report data.json
4
4
 
5
5
  Wraps the python helper (\`okstra-render-final-report.py\`) installed under
6
6
  \`~/.okstra/bin/\` so skills call \`okstra render-final-report\` instead of
@@ -10,7 +10,9 @@ permission matching and prompts on every call).
10
10
  Usage:
11
11
  okstra render-final-report <path-to-final-report.data.json>
12
12
 
13
- The argument is forwarded verbatim to the python helper.
13
+ The argument is forwarded verbatim to the Python helper. schemaVersion 1.0
14
+ keeps the legacy Markdown template; schemaVersion 2.0 selects the AI handoff
15
+ template. Human HTML is rendered separately from the same data.json.
14
16
  `;
15
17
 
16
18
  export async function run(args) {
@@ -3,20 +3,20 @@ import { runInstalledScript } from "../../lib/python-helper.mjs";
3
3
  const USAGE = `okstra render-views — render the self-contained HTML view of a final-report
4
4
 
5
5
  A thin spawn shim over \`scripts/okstra-render-report-views.py\` (installed
6
- at \`$HOME/.okstra/bin/okstra-render-report-views.py\`). Reads the final-
7
- report MD and writes a single sibling:
6
+ at \`$HOME/.okstra/bin/okstra-render-report-views.py\`). Resolves the final-
7
+ report data and Markdown pair, then writes a single sibling:
8
8
 
9
- <stem>.html — single-file self-contained human view with form
10
- controls on §5 clarification rows (skipped when the
11
- report has no §5 C-* clarification rows)
9
+ <stem>.html — single-file self-contained human view; schema v2 is
10
+ always generated, while v1/quick remains conditional
12
11
 
13
12
  Usage:
14
- okstra render-views <path-to-final-report.md>
13
+ okstra render-views <path-to-final-report.data.json|path-to-final-report.md>
15
14
  [--task-key <key>] [--task-type <type>] [--seq <NNN>]
16
15
  [--source-report <project-relative-path>]
17
16
 
18
- When the optional flags are omitted the script infers from the report
19
- path and its '- Task Type:' / '- Task Key:' lines.
17
+ Schema v2 data is rendered directly into its task-specific HTML view and is
18
+ always generated. Schema v1 and quick reports keep the legacy conditional
19
+ Markdown view. Optional flags override inferred report metadata.
20
20
  `;
21
21
 
22
22
  export async function run(args) {