okstra 0.148.1 → 0.149.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 (42) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/bin/okstra-render-report-views.py +20 -0
  4. package/runtime/prompts/profiles/project-analysis.md +18 -0
  5. package/runtime/prompts/profiles/release-handoff.md +3 -0
  6. package/runtime/prompts/profiles/requirements-discovery.md +7 -0
  7. package/runtime/python/okstra_ctl/report_html/common.py +77 -47
  8. package/runtime/python/okstra_ctl/report_html/filters.py +125 -30
  9. package/runtime/python/okstra_ctl/report_html/models.py +15 -0
  10. package/runtime/python/okstra_ctl/report_html/render.py +44 -5
  11. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
  12. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
  13. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
  14. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
  15. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
  16. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
  17. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
  18. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
  19. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
  20. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
  21. package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
  22. package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
  23. package/runtime/python/okstra_ctl/time_report.py +2 -2
  24. package/runtime/python/okstra_ctl/usage_report.py +2 -2
  25. package/runtime/schemas/final-report-v2.0.schema.json +229 -1
  26. package/runtime/templates/reports/final-report.template.md +55 -0
  27. package/runtime/templates/reports/html/assets/base.css +59 -9
  28. package/runtime/templates/reports/html/base.template.html +21 -42
  29. package/runtime/templates/reports/html/macros/forms.html +20 -20
  30. package/runtime/templates/reports/html/macros/layout.html +18 -5
  31. package/runtime/templates/reports/html/macros/visualizations.html +7 -5
  32. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
  33. package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
  34. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
  35. package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
  36. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
  37. package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
  38. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
  39. package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
  40. package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
  41. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
  42. package/runtime/validators/validate_analysis_report.py +36 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.148.1",
3
+ "version": "0.149.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.148.1",
3
- "builtAt": "2026-08-04T05:55:30.824Z",
2
+ "package": "0.149.0",
3
+ "builtAt": "2026-08-04T18:04:11.228Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -138,9 +138,29 @@ def _v2_run_meta(data: dict, markdown_path: Path, args: argparse.Namespace):
138
138
  task_type,
139
139
  seq,
140
140
  args.source_report or markdown_path.name,
141
+ _elapsed_ms(markdown_path, task_type, seq),
141
142
  )
142
143
 
143
144
 
145
+ def _elapsed_ms(markdown_path, task_type: str, seq: str) -> int | None:
146
+ """Wall-clock milliseconds for this run, or None when it cannot be measured.
147
+
148
+ The measurement lives in the run's team-state, not in the report data, so
149
+ the CLI resolves it here rather than teaching the renderer about run
150
+ layout. A missing or timestamp-less state yields None — the header then
151
+ omits the field instead of printing a zero.
152
+ """
153
+ from okstra_ctl.report_view_artifacts import team_state_path_for_report
154
+ from okstra_ctl.time_report import wall_clock_ms
155
+
156
+ state_path = team_state_path_for_report(markdown_path, task_type, seq)
157
+ try:
158
+ state = json.loads(state_path.read_text(encoding="utf-8"))
159
+ except (OSError, ValueError):
160
+ return None
161
+ return wall_clock_ms(state) or None if isinstance(state, dict) else None
162
+
163
+
144
164
  def main(argv: list[str] | None = None) -> int:
145
165
  parser = argparse.ArgumentParser(
146
166
  description="Render the self-contained HTML view of an okstra final-report."
@@ -18,6 +18,24 @@
18
18
  - scan scope and excluded areas
19
19
  - components, dependency directions, entry points, repositories, and external integrations
20
20
  - shallow feature index and unresolved navigation questions
21
+ - Fill these when the repository states them; leave a block out rather than guessing at it:
22
+ - `projectAnalysis.techStack` — the languages the project is written in with their
23
+ versions, the frameworks it runs on, and the package manager, build, test, lint,
24
+ typecheck, format, CI and container tools. Record where each version was read via
25
+ `versionSource`: a manifest range (`^1.17.0`) and a lockfile pin are different facts,
26
+ and a mismatch between a pinned server image and a ranged client is a real
27
+ compatibility risk that collapses if both are written the same way. A tool the
28
+ project does not have is not a row — its absence belongs in `qualityCoverage`.
29
+ - `projectAnalysis.internalInterfaces` — the seams components call across, with the
30
+ signature a caller writes against. `kind` is what the seam is, not where its file
31
+ sits: `port` for an abstraction a domain declares, `adapter` for an implementation
32
+ that satisfies one. `ownerComponentId` and every `consumers` entry must name a
33
+ component this report declares.
34
+ - `projectAnalysis.workflows` — one run of real work through the system, start to
35
+ finish: what triggers it and which component does what, in order. This is the only
36
+ block that says how the parts are used rather than how they are arranged, so a
37
+ reader can follow a request without reconstructing it from the dependency graph.
38
+ Each step's `componentId` must name a declared component.
21
39
  - Cross-verification mode:
22
40
  - Phase 5.5 convergence runs in adversarial mode (`convergence.adversarial=true`).
23
41
  - Non-goals:
@@ -1,5 +1,8 @@
1
1
  # Release Handoff Profile
2
2
 
3
+ - Record the handoff shape in `releaseHandoff.handoffScope`: `mode` always, plus `stages`
4
+ and `collectorBranch` in stage-group mode. The report is the only place a reader learns
5
+ which stages shipped — the collector branch name does not say.
3
6
  - Purpose: take an `accepted` final-verification verdict for an already-committed implementation branch and turn it into a delivered push and/or pull request, with explicit user selection at every mutating step. Two modes: **whole-task** (default — the verified task branch becomes one PR) and **stage-group** (a user-selected subset of verified stages is merged into a collector branch and becomes one PR).
4
7
  - **Execution model: single-lead, no worker dispatch.** This phase is a thin orchestrator over `git` / `gh`; it does NOT dispatch teammates, does NOT dispatch analysis or drafter sub-agents, and does NOT run convergence. The host-native Okstra lead performs every step inline (drafting PR text, asking the user, running git / gh, writing the final report) — see "Lead-only contract" below.
5
8
  - Worker roster: none — this profile intentionally has no `- Required workers:` block; the run is executed entirely by the Okstra lead.
@@ -25,6 +25,13 @@
25
25
  - classify the work as bugfix, feature, improvement, refactor, or ops
26
26
  - determine whether `error-analysis` or `implementation-planning` is the next safe step. Direct `implementation` handoff is never a valid routing target — implementation requires an approved `implementation-planning` report
27
27
  - capture the reporter's **rejection criteria** — the delivered outcome that would make this work wrong or unacceptable — as a routing input. Consume it from the brief's `Desired Outcome` / `Out of Scope` / `Source Material` when present; when it is absent AND it would change the classification (e.g. bugfix vs feature) or the next-phase choice, raise it as one `decision` clarification row with `Evidence checked: none — reporter intent`. Never infer it — this is a reporter-intent signal, the mirror of improvement-discovery's `Anti-goals`
28
+ - record the rejection criteria in `requirementsDiscovery.rejectionCriteria` with the
29
+ `source` that produced it. When it was absent and would not have changed the
30
+ classification or the routing, say so with `source: absent-not-material` — an empty
31
+ field cannot be told apart from a phase that never looked
32
+ - record the terminology this phase settled in `requirementsDiscovery.domainAlignment`:
33
+ whether the glossary and decision records were read, and for every fuzzy or overloaded
34
+ term, the single canonical form and what decided it
28
35
  - identify missing materials that block reliable routing
29
36
  - define task continuity expectations for long-running work under the same task key
30
37
  - capture approval or confirmation points before the next phase starts
@@ -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,119 @@ 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
+ # A value with no entry renders as itself — a new enum member shows up raw
101
+ # rather than silently reading as something it is not.
102
+ ENUM_LABELS: dict[str, dict[str, str]] = {
103
+ "coverage": {
104
+ "covered": "A break fails a test",
105
+ "partial": "Partly covered",
106
+ "gap": "No test",
107
+ "risk": "Passes without proving",
108
+ },
109
+ "effort": {"S": "Small", "M": "Medium", "L": "Large", "XL": "Very large"},
110
+ "versionSource": {
111
+ "manifest-range": "Manifest range",
112
+ "lockfile-pinned": "Lockfile pin",
113
+ "container-image": "Container image",
114
+ "runtime-config": "Runtime config",
115
+ "declared-doc": "Declared in docs",
116
+ },
117
+ "interfaceKind": {
118
+ "port": "Port",
119
+ "adapter": "Adapter",
120
+ "service-api": "Service API",
121
+ "module-export": "Module export",
122
+ "event": "Event",
123
+ },
124
+ "toolKind": {
125
+ "packageManager": "Package manager",
126
+ "build": "Build",
127
+ "test": "Test",
128
+ "lint": "Lint",
129
+ "typecheck": "Type check",
130
+ "format": "Format",
131
+ "ci": "CI",
132
+ "container": "Container",
133
+ },
134
+ "lens": {"candidate-found": "Candidate found", "no-candidate": "Nothing found"},
135
+ "rejectionSource": {
136
+ "brief-desired-outcome": "Brief · desired outcome",
137
+ "brief-out-of-scope": "Brief · out of scope",
138
+ "brief-source-material": "Brief · source material",
139
+ "clarification-raised": "Raised as a question",
140
+ "absent-not-material": "Absent · does not change the call",
141
+ },
142
+ "termSource": {
143
+ "glossary": "Glossary",
144
+ "decision-record": "Decision record",
145
+ "brief": "Brief",
146
+ "clarification": "Your answer",
147
+ },
148
+ "handoffMode": {"whole-task": "Whole task", "stage-group": "Selected stages"},
149
+ }
150
+
151
+
152
+ # What each member of a vocabulary actually claims. A two-word label fits a
153
+ # table cell but cannot carry a definition, and a reader meeting "Passes
154
+ # without proving" for the first time has nowhere to look it up. Only
155
+ # vocabularies whose members are judgements need one; a kind names itself.
156
+ ENUM_HINTS: dict[str, dict[str, str]] = {
157
+ "coverage": {
158
+ "covered": "Break this area and a test fails.",
159
+ "partial": "Tests exist, but part of the area runs through none of them.",
160
+ "gap": "No test runs through this area at all.",
161
+ "risk": (
162
+ "Tests run and pass, but they skip or assert nothing, so passing "
163
+ "is not evidence that the area works. More dangerous than "
164
+ "'No test', because the suite reports success."
165
+ ),
166
+ },
167
+ }
168
+
169
+
170
+ def enum_label(value: object, vocabulary: str) -> str:
171
+ """Render a schema enum as the words it stands for."""
172
+ raw = "" if value is None else str(value)
173
+ return ENUM_LABELS.get(vocabulary, {}).get(raw, raw)
174
+
175
+
176
+ def enum_legend(vocabulary: str) -> list[tuple[str, str]]:
177
+ """Pair each label with what it claims, for a legend beside the table.
178
+
179
+ Built from the same two tables the cells render from, so a legend cannot
180
+ drift from the labels it explains.
181
+ """
182
+ labels = ENUM_LABELS.get(vocabulary, {})
183
+ return [(labels.get(value, value), hint) for value, hint in ENUM_HINTS.get(vocabulary, {}).items()]
184
+
185
+
91
186
  def code_evidence(rows: object) -> Markup:
92
187
  """Render ``currentCodeEvidence`` entries as ``path:line`` code spans."""
93
188
  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)
@@ -10,7 +10,15 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoes
10
10
 
11
11
  from ..final_report_schema import load_schema_for_data, validate
12
12
  from ..report_view_artifacts import user_responses_dir_for_report
13
- from .filters import audit_json, code_evidence, evidence_refs, inline_code, paragraphs
13
+ from .common import anchor_index
14
+ from .filters import (
15
+ code_evidence,
16
+ enum_label,
17
+ enum_legend,
18
+ evidence_refs,
19
+ inline_code,
20
+ paragraphs,
21
+ )
14
22
  from .models import HtmlRunMeta
15
23
  from .router import HtmlRenderError, resolve_html_route
16
24
 
@@ -30,6 +38,32 @@ def _templates_root(start: Path | None = None) -> Path:
30
38
  raise HtmlRenderError("could not locate templates/reports")
31
39
 
32
40
 
41
+ def _elapsed_text(elapsed_ms: int | None) -> str | None:
42
+ """Render a run duration, or nothing when there is none to render.
43
+
44
+ A run whose team-state never recorded timestamps has no measured duration;
45
+ printing "0m" would claim it finished instantly.
46
+ """
47
+ if not elapsed_ms or elapsed_ms < 0:
48
+ return None
49
+ minutes, seconds = divmod(round(elapsed_ms / 1000), 60)
50
+ hours, minutes = divmod(minutes, 60)
51
+ if hours:
52
+ return f"{hours}h {minutes}m"
53
+ if minutes:
54
+ return f"{minutes}m {seconds}s"
55
+ return f"{seconds}s"
56
+
57
+
58
+ def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
59
+ return {
60
+ "createdAt": data.get("header", {}).get("createdAt", ""),
61
+ "taskTitle": data.get("frontmatter", {}).get("title", ""),
62
+ "taskKey": run_meta.task_key,
63
+ "elapsed": _elapsed_text(run_meta.elapsed_ms),
64
+ }
65
+
66
+
33
67
  def _html_path(data_path: Path) -> Path:
34
68
  suffix = ".data.json"
35
69
  if not data_path.name.endswith(suffix):
@@ -55,16 +89,21 @@ def render_v2_html_view(
55
89
  root = _templates_root(templates_root)
56
90
  env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
57
91
  env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
58
- env.filters["audit_json"] = audit_json
92
+ # Binding the index here is what lets a template cite an id without
93
+ # threading the index through every macro and call site.
94
+ anchors = anchor_index(data)
59
95
  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
96
+ env.filters["enum_label"] = enum_label
97
+ env.filters["enum_legend"] = enum_legend
98
+ env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, anchors)
99
+ env.filters["inline_code"] = lambda value: inline_code(value, anchors)
100
+ env.filters["paragraphs"] = lambda value: paragraphs(value, anchors)
63
101
  response_js = (root / "report.js").read_text(encoding="utf-8")
64
102
  base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
65
103
  context = {
66
104
  **view.context,
67
105
  "runMeta": run_meta,
106
+ "reportMeta": _report_meta(data, run_meta),
68
107
  "taskType": view.task_type,
69
108
  "dataSha256": _sha256(data_path),
70
109
  "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",