okstra 0.148.1 → 0.150.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 (78) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +1 -1
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/translator-worker.md +67 -0
  7. package/runtime/bin/okstra-render-final-report.py +0 -11
  8. package/runtime/bin/okstra-render-report-views.py +20 -0
  9. package/runtime/bin/okstra-report-translate.py +158 -0
  10. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  12. package/runtime/prompts/lead/report-writer.md +14 -13
  13. package/runtime/prompts/lead/team-contract.md +2 -2
  14. package/runtime/prompts/profiles/project-analysis.md +18 -0
  15. package/runtime/prompts/profiles/release-handoff.md +3 -0
  16. package/runtime/prompts/profiles/requirements-discovery.md +7 -0
  17. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  18. package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
  19. package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
  20. package/runtime/python/okstra_ctl/clarification_items.py +241 -44
  21. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  22. package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
  23. package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
  24. package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
  25. package/runtime/python/okstra_ctl/i18n.py +12 -7
  26. package/runtime/python/okstra_ctl/render_final_report.py +18 -17
  27. package/runtime/python/okstra_ctl/report_html/common.py +77 -47
  28. package/runtime/python/okstra_ctl/report_html/filters.py +63 -30
  29. package/runtime/python/okstra_ctl/report_html/models.py +15 -0
  30. package/runtime/python/okstra_ctl/report_html/render.py +86 -5
  31. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
  32. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
  33. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
  34. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
  35. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
  36. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
  37. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
  38. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
  39. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
  40. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
  41. package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
  42. package/runtime/python/okstra_ctl/report_translation.py +440 -0
  43. package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
  44. package/runtime/python/okstra_ctl/report_views.py +23 -9
  45. package/runtime/python/okstra_ctl/run.py +1 -1
  46. package/runtime/python/okstra_ctl/time_report.py +2 -2
  47. package/runtime/python/okstra_ctl/usage_report.py +2 -2
  48. package/runtime/python/okstra_ctl/user_response.py +11 -6
  49. package/runtime/python/okstra_ctl/wizard.py +100 -25
  50. package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
  51. package/runtime/schemas/final-report-v2.0.schema.json +229 -1
  52. package/runtime/templates/reports/final-report.template.md +55 -0
  53. package/runtime/templates/reports/html/assets/base.css +59 -9
  54. package/runtime/templates/reports/html/base.template.html +23 -44
  55. package/runtime/templates/reports/html/i18n/en.json +395 -0
  56. package/runtime/templates/reports/html/i18n/ko.json +395 -0
  57. package/runtime/templates/reports/html/macros/forms.html +20 -20
  58. package/runtime/templates/reports/html/macros/layout.html +18 -5
  59. package/runtime/templates/reports/html/macros/visualizations.html +7 -5
  60. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
  61. package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
  62. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
  63. package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
  64. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
  65. package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
  66. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
  67. package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
  68. package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
  69. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
  70. package/runtime/templates/reports/report.js +8 -5
  71. package/runtime/validators/validate-report-views.py +1 -1
  72. package/runtime/validators/validate-run.py +28 -31
  73. package/runtime/validators/validate_analysis_report.py +36 -0
  74. package/src/cli-registry.mjs +11 -0
  75. package/src/commands/inspect/worker-liveness.mjs +9 -7
  76. package/src/commands/report/translate.mjs +31 -0
  77. package/src/lib/helper-scripts.mjs +1 -0
  78. package/runtime/templates/reports/i18n/ko.json +0 -273
@@ -1,64 +1,75 @@
1
1
  """Common data transformations that carry no task-specific section order."""
2
2
  from __future__ import annotations
3
3
 
4
-
5
- def audit_context(data: dict) -> dict[str, object]:
4
+ import re
5
+
6
+
7
+ # Every block whose rows prose cites but no section of its own renders, with
8
+ # the keys each one names its content and its provenance by. A key hunt across
9
+ # all of them would misread `crossVerification.consensus`, whose `evidence` key
10
+ # holds provenance while `evidence.primary` uses the same word for content —
11
+ # so the block a row came from is named here rather than guessed.
12
+ _LEDGER_BLOCKS = (
13
+ (("evidence", "primary"), "evidence", "source", "unclassified"),
14
+ (("evidence", "secondary"), "hypothesis", "confidence", "hypothesis"),
15
+ (("analysisCommon", "confirmedFacts"), "statement", "", "confirmed fact"),
16
+ (("analysisCommon", "inferences"), "statement", "confidence", "inference"),
17
+ (("analysisCommon", "unknowns"), "question", "reason", "unknown"),
18
+ (("crossVerification", "consensus"), "statement", "evidence", "cross-check consensus"),
19
+ (("crossVerification", "differences"), "disagreement", "workersPosition", "cross-check dissent"),
20
+ (("missingInformation",), "item", "risk", "missing information"),
21
+ (("endStateCoverage",), "coveredBy", "evidence", "exit contract"),
22
+ (("followUpTasks",), "title", "reason", "follow-up"),
23
+ )
24
+
25
+
26
+ def _dig(data: dict, path: tuple[str, ...]) -> list:
27
+ node: object = data
28
+ for key in path:
29
+ if not isinstance(node, dict):
30
+ return []
31
+ node = node.get(key)
32
+ return node if isinstance(node, list) else []
33
+
34
+
35
+ def _ledger_row(row: dict, text_key: str, source_key: str, kind: str) -> dict[str, object]:
6
36
  return {
7
- "crossVerification": data.get("crossVerification", {}),
8
- "executionStatus": data.get("executionStatus", []),
9
- "tokenUsage": data.get("tokenUsage", {}),
37
+ "id": row.get("id", ""),
38
+ "kind": kind,
39
+ "text": str(row.get(text_key) or ""),
40
+ "codeEvidence": row.get("currentCodeEvidence") or [],
41
+ "source": str(row.get(source_key) or "") if source_key else "",
10
42
  }
11
43
 
12
44
 
13
- _LEDGER_TEXT_KEYS = ("statement", "evidence", "hypothesis", "question")
14
- _LEDGER_SOURCE_KEYS = ("source", "confidence", "reason")
15
-
16
-
17
- def _first_filled(row: dict, keys: tuple[str, ...]) -> str:
18
- for key in keys:
19
- value = row.get(key)
20
- if value:
21
- return str(value)
22
- return ""
23
-
24
-
25
- def _ledger_row(row: dict) -> dict[str, object]:
26
- """Flatten one evidence row into the single shape the ledger renders.
45
+ def _own_section_ids(data: dict) -> set[str]:
46
+ """Ids a section of the report already renders and anchors.
27
47
 
28
- Every row type that reaches the ledger is ``additionalProperties: false``
29
- and names its content and source differently, so no row carries another
30
- row type's keys:
31
-
32
- - ``evidence.primary`` — ``evidence`` / ``source``
33
- - ``evidence.secondary`` — ``hypothesis`` / ``confidence``
34
- - ``analysisCommon.confirmedFacts`` — ``statement`` / ``currentCodeEvidence``
35
- - ``analysisCommon.inferences`` — ``statement`` / ``confidence``
36
- - ``analysisCommon.unknowns`` — ``question`` / ``reason``
37
-
38
- Only this function knows which block a row came from, so the key hunt
39
- belongs here rather than in the template.
48
+ The ledger is the fallback home for a cited row, so it must not claim an id
49
+ that has one — two elements with the same anchor send half the links to the
50
+ wrong place. `crossVerification.consensus` numbers its rows `C-001` in some
51
+ runs, exactly where a clarification lives.
40
52
  """
41
- return {
42
- "id": row.get("id", ""),
43
- "text": _first_filled(row, _LEDGER_TEXT_KEYS),
44
- "codeEvidence": row.get("currentCodeEvidence") or [],
45
- "source": _first_filled(row, _LEDGER_SOURCE_KEYS),
46
- }
53
+ from ..report_contract import TASK_TYPE_DATA_PROPERTY
54
+
55
+ found: set[str] = set()
56
+ _collect_ids(data.get("clarificationItems", []), found)
57
+ property_name = TASK_TYPE_DATA_PROPERTY.get(data.get("header", {}).get("taskType", ""))
58
+ if property_name:
59
+ _collect_ids(data.get(property_name, {}), found)
60
+ return found
47
61
 
48
62
 
49
63
  def evidence_index(data: dict) -> dict[str, object]:
64
+ owned = _own_section_ids(data)
50
65
  rows: dict[str, object] = {}
51
- for section in ("primary", "secondary"):
52
- for row in data.get("evidence", {}).get(section, []):
53
- row_id = row.get("id")
54
- if row_id:
55
- rows[row_id] = _ledger_row(row)
56
- analysis = data.get("analysisCommon", {})
57
- for collection in ("confirmedFacts", "inferences", "unknowns"):
58
- for row in analysis.get(collection, []):
66
+ for path, text_key, source_key, kind in _LEDGER_BLOCKS:
67
+ for row in _dig(data, path):
68
+ if not isinstance(row, dict):
69
+ continue
59
70
  row_id = row.get("id")
60
- if row_id:
61
- rows[row_id] = _ledger_row(row)
71
+ if row_id and row_id not in owned and row_id not in rows:
72
+ rows[row_id] = _ledger_row(row, text_key, source_key, kind)
62
73
  return rows
63
74
 
64
75
 
@@ -74,6 +85,25 @@ def _collect_ids(value: object, found: set[str]) -> None:
74
85
  _collect_ids(nested, found)
75
86
 
76
87
 
88
+ _ROW_ID = re.compile(r"[A-Z]{1,3}-\d+")
89
+
90
+
91
+ def anchor_index(data: dict) -> dict[str, str]:
92
+ """Map every row a reader can reach to the anchor name that lands on it.
93
+
94
+ Prose cites ids across section boundaries — a hotspot names a
95
+ cross-verification finding, a quality row names a difference — so the
96
+ target set spans the whole reader-facing report: the task's own sections,
97
+ the clarifications, and every block the ledger takes in.
98
+
99
+ It stops there. `summary` is the AI-facing digest and
100
+ `analysisCommon.scope` describes the analysis target rather than listing
101
+ rows; neither renders, so a link to one would land nowhere.
102
+ """
103
+ found = _own_section_ids(data) | set(evidence_index(data))
104
+ return {row_id: f"id-{row_id}" for row_id in sorted(found) if _ROW_ID.fullmatch(row_id)}
105
+
106
+
77
107
  def analysis_review_ids(data: dict) -> tuple[str, ...]:
78
108
  found: set[str] = set()
79
109
  _collect_ids(data.get("analysisCommon", {}), found)
@@ -1,8 +1,6 @@
1
1
  """Jinja filters for the task-specific HTML report."""
2
2
  from __future__ import annotations
3
3
 
4
- import html
5
- import json
6
4
  import re
7
5
 
8
6
  import okstra_vendor # noqa: F401 # registers vendored dependency aliases
@@ -11,35 +9,43 @@ from markupsafe import Markup, escape
11
9
  _INLINE_CODE = re.compile(r"`([^`]+)`")
12
10
  _SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
13
11
  _SENTENCES_PER_PARAGRAPH = 2
12
+ _ID_TOKEN = re.compile(r"\b[A-Z]{1,3}-\d+\b")
14
13
 
15
14
 
16
- def audit_json(value: object) -> Markup:
17
- """Serialize an audit payload for reading inside a ``<pre>`` text node.
15
+ def _link_ids(escaped: str, anchors: dict) -> str:
16
+ """Anchor the row ids inside text that is already escaped.
18
17
 
19
- Jinja's ``tojson`` guards a ``<script>`` context, so it escapes quotes and
20
- angle brackets as ``\\uXXXX`` and leaves shell commands and non-ASCII
21
- evidence unreadable. A text node only needs ``&``, ``<`` and ``>`` escaped,
22
- hence ``quote=False`` — quotes carry no meaning outside an attribute value.
18
+ Running after the escape keeps this anchor the only markup in the result.
19
+ Only ids the document defines become links — an id with no row would point
20
+ at a missing anchor, and a ticket number like ``DEV-10339`` has the same
21
+ shape as a row id without being one.
23
22
  """
24
- rendered = json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)
25
- return Markup(html.escape(rendered, quote=False))
23
+ if not anchors:
24
+ return escaped
26
25
 
26
+ def swap(match: re.Match) -> str:
27
+ name = anchors.get(match.group(0))
28
+ return f'<a href="#{name}">{match.group(0)}</a>' if name else match.group(0)
27
29
 
28
- def inline_code(value: object) -> Markup:
30
+ return _ID_TOKEN.sub(swap, escaped)
31
+
32
+
33
+ def inline_code(value: object, anchors: dict | None = None) -> Markup:
29
34
  """Convert paired backticks to ``<code>``, escaping everything else.
30
35
 
31
36
  Workers author report prose with Markdown conventions, so an identifier
32
- arrives wrapped in backticks. ``escape`` also covers quotes, unlike
33
- ``audit_json`` above, which is text-node only.
37
+ arrives wrapped in backticks. ``anchors`` links the row ids cited in the
38
+ prose, inside backticks and out.
34
39
  """
40
+ index = anchors or {}
35
41
  text = "" if value is None else str(value)
36
42
  out: list[str] = []
37
43
  cursor = 0
38
44
  for match in _INLINE_CODE.finditer(text):
39
- out.append(str(escape(text[cursor:match.start()])))
40
- out.append(f"<code>{escape(match.group(1))}</code>")
45
+ out.append(_link_ids(str(escape(text[cursor:match.start()])), index))
46
+ out.append(f"<code>{_link_ids(str(escape(match.group(1))), index)}</code>")
41
47
  cursor = match.end()
42
- out.append(str(escape(text[cursor:])))
48
+ out.append(_link_ids(str(escape(text[cursor:])), index))
43
49
  return Markup("".join(out))
44
50
 
45
51
 
@@ -56,7 +62,7 @@ def _grouped_paragraphs(text: str) -> list[str]:
56
62
  ]
57
63
 
58
64
 
59
- def paragraphs(value: object) -> Markup:
65
+ def paragraphs(value: object, anchors: dict | None = None) -> Markup:
60
66
  """Split prose into ``<p>`` blocks, converting inline code inside each.
61
67
 
62
68
  ``userNarrative`` prose arrives as one unbroken run of sentences, so the
@@ -64,30 +70,57 @@ def paragraphs(value: object) -> Markup:
64
70
  did supply blank lines keeps their own boundaries.
65
71
  """
66
72
  text = "" if value is None else str(value)
67
- return Markup("".join(f"<p>{inline_code(block)}</p>" for block in _grouped_paragraphs(text)))
73
+ return Markup(
74
+ "".join(f"<p>{inline_code(block, anchors)}</p>" for block in _grouped_paragraphs(text))
75
+ )
68
76
 
69
77
 
70
- def evidence_refs(refs: object, index: object) -> Markup:
71
- """Render evidence ids as anchors when the ledger defines them.
78
+ def evidence_refs(refs: object, anchors: object) -> Markup:
79
+ """Render a citation list, anchoring the entries the document defines.
72
80
 
73
- A citation whose id has no ledger row would link to a missing anchor, so it
74
- stays text. No schema behind this filter constrains that text to an id —
75
- most of it is prose citing a ``path:line``, so it takes ``inline_code`` like
76
- every other prose cell. An anchor's own text is the id it links to and gets
77
- no ``<code>`` wrapper.
81
+ The same index the prose links against decides here too — a citation and a
82
+ mid-sentence mention of the same id must land in the same place. No schema
83
+ behind this filter constrains an entry to an id: most of it is prose citing
84
+ a ``path:line``, which takes ``inline_code`` like every other prose cell.
78
85
  """
79
- rows = index if isinstance(index, dict) else {}
86
+ index = anchors if isinstance(anchors, dict) else {}
80
87
  items = refs if isinstance(refs, (list, tuple)) else []
81
88
  out: list[str] = []
82
89
  for ref in items:
83
- if str(ref) not in rows:
84
- out.append(str(inline_code(ref)))
90
+ name = index.get(str(ref))
91
+ if name is None:
92
+ out.append(str(inline_code(ref, index)))
85
93
  continue
86
- text = escape(str(ref))
87
- out.append(f'<a href="#ev-{text}">{text}</a>')
94
+ out.append(f'<a href="#{name}">{escape(str(ref))}</a>')
88
95
  return Markup(", ".join(out))
89
96
 
90
97
 
98
+ # Schema enums a reader has to decode. Each vocabulary is named so a value can
99
+ # mean different things in different fields without one table flattening them.
100
+ # The tables live in `templates/reports/html/i18n/<lang>.json` beside the rest
101
+ # of the page's fixed strings — a label is chrome, not data, so it follows the
102
+ # reader's language rather than the SSOT's. A value with no entry renders as
103
+ # itself: a new enum member shows up raw rather than silently reading as
104
+ # something it is not.
105
+ def enum_label(value: object, vocabulary: str, chrome: dict) -> str:
106
+ """Render a schema enum as the words it stands for."""
107
+ raw = "" if value is None else str(value)
108
+ return (chrome.get("enum") or {}).get(vocabulary, {}).get(raw, raw)
109
+
110
+
111
+ def enum_legend(vocabulary: str, chrome: dict) -> list[tuple[str, str]]:
112
+ """Pair each label with what it claims, for a legend beside the table.
113
+
114
+ A two-word label fits a table cell but cannot carry a definition, and a
115
+ reader meeting "Passes without proving" for the first time has nowhere to
116
+ look it up. Built from the same dictionary the cells render from, so a
117
+ legend cannot drift from the labels it explains.
118
+ """
119
+ labels = (chrome.get("enum") or {}).get(vocabulary, {})
120
+ hints = (chrome.get("enumHint") or {}).get(vocabulary, {})
121
+ return [(labels.get(value, value), hint) for value, hint in hints.items()]
122
+
123
+
91
124
  def code_evidence(rows: object) -> Markup:
92
125
  """Render ``currentCodeEvidence`` entries as ``path:line`` code spans."""
93
126
  items = rows if isinstance(rows, (list, tuple)) else []
@@ -11,15 +11,30 @@ class HtmlRunMeta:
11
11
  task_type: str
12
12
  seq: str
13
13
  source_report: str
14
+ # Wall-clock milliseconds for the run, or None when the team-state
15
+ # carried no timestamps to measure between.
16
+ elapsed_ms: int | None = None
14
17
 
15
18
 
16
19
  @dataclass(frozen=True)
17
20
  class VisualNode:
18
21
  id: str
19
22
  label: str
23
+ # Which column the figure puts this node in, and which fill it draws it
24
+ # with. Both are drawing instructions: `group` is often a path prefix and
25
+ # `status` is often a constant. The text table printed them raw under the
26
+ # headings "group" and "status", so in a single-root project every
27
+ # component read "src · stable" — a layout key and a literal, neither a
28
+ # fact about the project. Anything the reader should see goes in `note`.
20
29
  group: str
21
30
  status: str
22
31
  detail: str
32
+ # Files the node stands for. The figure's text alternative is the only
33
+ # place a reader can look them up, so they travel with the node.
34
+ paths: tuple[str, ...] = ()
35
+ # What this node is, in the reader's words. Empty when the figure has
36
+ # nothing to say beyond the label.
37
+ note: str = ""
23
38
 
24
39
 
25
40
  @dataclass(frozen=True)
@@ -3,14 +3,26 @@ from __future__ import annotations
3
3
 
4
4
  import hashlib
5
5
  import json
6
+ import sys
6
7
  from pathlib import Path
7
8
 
8
9
  import okstra_vendor # noqa: F401 # registers vendored dependency aliases
9
10
  from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
10
11
 
12
+ from ..final_report_paths import translation_sidecar_path
11
13
  from ..final_report_schema import load_schema_for_data, validate
14
+ from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
15
+ from ..report_translation import overlay
12
16
  from ..report_view_artifacts import user_responses_dir_for_report
13
- from .filters import audit_json, code_evidence, evidence_refs, inline_code, paragraphs
17
+ from .common import anchor_index
18
+ from .filters import (
19
+ code_evidence,
20
+ enum_label,
21
+ enum_legend,
22
+ evidence_refs,
23
+ inline_code,
24
+ paragraphs,
25
+ )
14
26
  from .models import HtmlRunMeta
15
27
  from .router import HtmlRenderError, resolve_html_route
16
28
 
@@ -30,6 +42,65 @@ def _templates_root(start: Path | None = None) -> Path:
30
42
  raise HtmlRenderError("could not locate templates/reports")
31
43
 
32
44
 
45
+ def _elapsed_text(elapsed_ms: int | None) -> str | None:
46
+ """Render a run duration, or nothing when there is none to render.
47
+
48
+ A run whose team-state never recorded timestamps has no measured duration;
49
+ printing "0m" would claim it finished instantly.
50
+ """
51
+ if not elapsed_ms or elapsed_ms < 0:
52
+ return None
53
+ minutes, seconds = divmod(round(elapsed_ms / 1000), 60)
54
+ hours, minutes = divmod(minutes, 60)
55
+ if hours:
56
+ return f"{hours}h {minutes}m"
57
+ if minutes:
58
+ return f"{minutes}m {seconds}s"
59
+ return f"{seconds}s"
60
+
61
+
62
+ def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
63
+ return {
64
+ "createdAt": data.get("header", {}).get("createdAt", ""),
65
+ "taskTitle": data.get("frontmatter", {}).get("title", ""),
66
+ "taskKey": run_meta.task_key,
67
+ "elapsed": _elapsed_text(run_meta.elapsed_ms),
68
+ }
69
+
70
+
71
+ def _localize(data: dict, data_path: Path) -> tuple[dict, str]:
72
+ """Apply the translation sidecar for this report's language, if there is one.
73
+
74
+ The data.json is the English SSOT and is never rewritten — the sidecar is
75
+ read here and overlaid onto an in-memory copy, so only this HTML document
76
+ speaks the reader's language. A pointer the sidecar left untranslated
77
+ renders in English; that keeps a half-finished translation readable, and
78
+ the counts are printed because a silently half-empty sidecar otherwise
79
+ ships looking finished.
80
+ """
81
+ lang = str((data.get("meta") or {}).get("reportLanguage") or "en")
82
+ if lang == "en":
83
+ return data, lang
84
+ sidecar_file = translation_sidecar_path(data_path, lang)
85
+ if not sidecar_file.is_file():
86
+ sys.stdout.write(
87
+ f"note: no {lang} translation sidecar at {sidecar_file.name}; "
88
+ "rendering the English source\n"
89
+ )
90
+ return data, lang
91
+ payload = json.loads(sidecar_file.read_text(encoding="utf-8"))
92
+ strings = payload.get("strings")
93
+ if not isinstance(strings, dict):
94
+ raise HtmlRenderError(f"translation sidecar has no 'strings' object: {sidecar_file}")
95
+ localized, report = overlay(data, strings)
96
+ sys.stdout.write(
97
+ f"translated {report.applied} string(s) into {lang}"
98
+ f" ({len(report.untranslated)} left in English"
99
+ f", {len(report.unresolved)} unresolved)\n"
100
+ )
101
+ return localized, lang
102
+
103
+
33
104
  def _html_path(data_path: Path) -> Path:
34
105
  suffix = ".data.json"
35
106
  if not data_path.name.endswith(suffix):
@@ -50,21 +121,31 @@ def render_v2_html_view(
50
121
  raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
51
122
  if not markdown_path.is_file():
52
123
  raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
124
+ # Validate the SSOT, then localize — the sidecar carries presentation and
125
+ # has no say in whether the report is well-formed.
126
+ data, lang = _localize(data, data_path)
53
127
  route = resolve_html_route(run_meta.task_type)
54
128
  view = route.view_builder(data)
55
129
  root = _templates_root(templates_root)
56
130
  env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
57
131
  env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
58
- env.filters["audit_json"] = audit_json
132
+ # Binding the index here is what lets a template cite an id without
133
+ # threading the index through every macro and call site.
134
+ anchors = anchor_index(data)
135
+ chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
136
+ env.globals["t"] = make_jinja_global(chrome)
59
137
  env.filters["code_evidence"] = code_evidence
60
- env.filters["evidence_refs"] = evidence_refs
61
- env.filters["inline_code"] = inline_code
62
- env.filters["paragraphs"] = paragraphs
138
+ env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
139
+ env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
140
+ env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, anchors)
141
+ env.filters["inline_code"] = lambda value: inline_code(value, anchors)
142
+ env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)
63
143
  response_js = (root / "report.js").read_text(encoding="utf-8")
64
144
  base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
65
145
  context = {
66
146
  **view.context,
67
147
  "runMeta": run_meta,
148
+ "reportMeta": _report_meta(data, run_meta),
68
149
  "taskType": view.task_type,
69
150
  "dataSha256": _sha256(data_path),
70
151
  "markdownSha256": _sha256(markdown_path),
@@ -1,20 +1,31 @@
1
1
  """Human-first change-impact-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import analysis_review_ids, audit_context, evidence_index
4
+ from ..common import analysis_review_ids, evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import flow_figure
7
7
 
8
8
 
9
9
  def _impact_figure(change: dict):
10
10
  nodes = [
11
- VisualNode(row["id"], row["target"], "affected", row["level"], row["impactKind"])
11
+ VisualNode(
12
+ row["id"],
13
+ row["target"],
14
+ "affected",
15
+ row["level"],
16
+ row["impactKind"],
17
+ note=f'Impact {row["level"]}',
18
+ )
12
19
  for row in change.get("impactItems", [])
13
20
  ]
14
21
  edges: list[VisualEdge] = []
15
22
  for index, row in enumerate(change.get("dependencyBlastRadius", []), start=1):
16
23
  target_id = f"BR-{index:03d}"
17
- nodes.append(VisualNode(target_id, row["affectedTarget"], "downstream", "risk", row["direction"]))
24
+ nodes.append(
25
+ VisualNode(
26
+ target_id, row["affectedTarget"], "downstream", "risk", row["direction"], note="Downstream"
27
+ )
28
+ )
18
29
  edges.append(VisualEdge(row["sourceImpactId"], target_id, row["direction"], "impact"))
19
30
  return flow_figure(nodes=tuple(nodes), edges=tuple(edges), title="Change blast radius")
20
31
 
@@ -29,7 +40,6 @@ def build_change_impact_analysis_view(data: dict) -> HumanReportView:
29
40
  "blastRadiusFigure": figure,
30
41
  "analysisReviewIds": analysis_review_ids(data),
31
42
  "evidenceIndex": evidence_index(data),
32
- "audit": audit_context(data),
33
43
  }
34
44
  return HumanReportView(
35
45
  task_type="change-impact-analysis",
@@ -1,14 +1,14 @@
1
1
  """Human-first error-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import cause_graph_figure
7
7
 
8
8
 
9
9
  def _cause_figure(error: dict):
10
10
  symptom = VisualNode(
11
- "symptom", "Observed failure", "effect", "risk", error["observableFailure"]
11
+ "symptom", "Observed failure", "effect", "risk", error["observableFailure"], note="Observed failure"
12
12
  )
13
13
  causes = tuple(
14
14
  VisualNode(
@@ -17,6 +17,7 @@ def _cause_figure(error: dict):
17
17
  "candidate",
18
18
  row["confidence"],
19
19
  row["disproveWith"],
20
+ note=f'Confidence {row["confidence"]}',
20
21
  )
21
22
  for row in error.get("causeCandidates", [])
22
23
  )
@@ -39,7 +40,6 @@ def build_error_analysis_view(data: dict) -> HumanReportView:
39
40
  "narrative": error["userNarrative"],
40
41
  "causeFigure": figure,
41
42
  "evidenceIndex": evidence_index(data),
42
- "audit": audit_context(data),
43
43
  }
44
44
  return HumanReportView(
45
45
  "error-analysis",
@@ -1,16 +1,19 @@
1
1
  """Human-first feature-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import analysis_review_ids, audit_context, evidence_index
4
+ from ..common import analysis_review_ids, evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import flow_figure
7
7
 
8
8
 
9
+ _FLOW_KINDS = {"normal": "Normal path", "alternative": "Alternative path", "failure": "Failure path"}
10
+
11
+
9
12
  def _flow_nodes(feature: dict) -> tuple[VisualNode, ...]:
10
13
  return tuple(
11
14
  VisualNode(
12
15
  id=row["id"],
13
- label=row["kind"].title(),
16
+ label=_FLOW_KINDS.get(row["kind"], row["kind"]),
14
17
  group=row["kind"],
15
18
  status="risk" if row["kind"] == "failure" else "stable",
16
19
  detail=" → ".join(step["action"] for step in row.get("steps", [])),
@@ -29,7 +32,6 @@ def build_feature_analysis_view(data: dict) -> HumanReportView:
29
32
  "flowFigure": figure,
30
33
  "analysisReviewIds": analysis_review_ids(data),
31
34
  "evidenceIndex": evidence_index(data),
32
- "audit": audit_context(data),
33
35
  }
34
36
  return HumanReportView(
35
37
  task_type="feature-analysis",
@@ -1,7 +1,7 @@
1
1
  """Human-first final-verification view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import coverage_figure
7
7
 
@@ -14,6 +14,7 @@ def _coverage_nodes(final: dict) -> tuple[VisualNode, ...]:
14
14
  "requirement",
15
15
  row["status"],
16
16
  row["artifact"],
17
+ note=row["status"],
17
18
  )
18
19
  for row in final["validationEvidence"]
19
20
  )
@@ -24,11 +25,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
24
25
  figure = coverage_figure(
25
26
  rows=_coverage_nodes(final), title="Requirement verification coverage"
26
27
  )
27
- audit = audit_context(data)
28
- audit["deliveryEvidence"] = {
29
- "sourceImplementation": final["sourceImplementationReport"],
30
- "readonlyCommandLog": final["readonlyCommandLog"],
31
- }
32
28
  context = {
33
29
  "humanSummary": data["humanSummary"],
34
30
  "verdict": data["verdictCard"],
@@ -37,7 +33,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
37
33
  "coverageFigure": figure,
38
34
  "releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
39
35
  "evidenceIndex": evidence_index(data),
40
- "audit": audit,
41
36
  }
42
37
  return HumanReportView(
43
38
  "final-verification",
@@ -1,11 +1,14 @@
1
1
  """Human-first implementation delivery view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import change_map_figure
7
7
 
8
8
 
9
+ _FILE_ACTIONS = {"created": "Created", "modified": "Modified", "deleted": "Deleted"}
10
+
11
+
9
12
  def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
10
13
  return tuple(
11
14
  VisualNode(
@@ -14,6 +17,7 @@ def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
14
17
  row["planStep"],
15
18
  row["action"],
16
19
  row["lines"],
20
+ note=f'{_FILE_ACTIONS.get(row["action"], row["action"])} · plan step {row["planStep"]}',
17
21
  )
18
22
  for row in implementation["diffSummary"]["files"]
19
23
  )
@@ -24,20 +28,12 @@ def build_implementation_view(data: dict) -> HumanReportView:
24
28
  figure = change_map_figure(
25
29
  nodes=_change_nodes(implementation), title="Delivered change areas"
26
30
  )
27
- audit = audit_context(data)
28
- audit["deliveryEvidence"] = {
29
- "commits": implementation["commitList"],
30
- "rawDiffStat": implementation["diffSummary"]["rawStat"],
31
- "verifiers": implementation["verifierResults"],
32
- "validationCommands": implementation["validationEvidence"],
33
- }
34
31
  context = {
35
32
  "humanSummary": data["humanSummary"],
36
33
  "implementation": implementation,
37
34
  "narrative": implementation["userNarrative"],
38
35
  "changeFigure": figure,
39
36
  "evidenceIndex": evidence_index(data),
40
- "audit": audit,
41
37
  }
42
38
  return HumanReportView(
43
39
  "implementation",