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
@@ -1,11 +1,10 @@
1
1
  """Render `final-report-<task-type>-<seq>.md` from its JSON SSOT.
2
2
 
3
3
  The JSON SSOT lives next to the rendered markdown as
4
- ``final-report-<task-type>-<seq>.data.json``. The schema for that file is
5
- ``schemas/final-report-v1.0.schema.json``. Report-writer-worker writes the
6
- data.json in Phase 6; this renderer + the Jinja2 template at
7
- ``templates/reports/final-report.template.md`` deterministically produce
8
- the canonical user-facing markdown.
4
+ ``final-report-<task-type>-<seq>.data.json``. Its ``schemaVersion`` selects the
5
+ matching final-report schema. Report-writer-worker writes the data.json in
6
+ Phase 6; this renderer + the matching Jinja2 template deterministically
7
+ produce the canonical AI-facing markdown.
9
8
 
10
9
  Why this exists: prior to v0.32, report-writer-worker wrote the markdown
11
10
  directly. Free-form authoring led to silent contract violations — missing
@@ -13,13 +12,10 @@ columns in the Execution Status table, omitted §4 phase-continuation
13
12
  rows, ad-hoc ``## Index`` sections. Routing everything through one
14
13
  template + schema cuts those failure modes to zero.
15
14
 
16
- The top-of-report ``## Index`` is now a *deterministic* post-render
17
- section: after Jinja2 renders the body, ``_inject_index_and_anchors``
18
- appends a scroll anchor to every heading and every ID-defining table row,
19
- links in-body ID references to their definition, and builds the index
20
- (section list + ID index) — so every ``FU-001`` / ``E-001`` / ``S-001``
21
- token is clickable and the reader can jump to any section. This runs on
22
- every render (including the Phase 7 re-render) and is idempotent.
15
+ For schema-v1 compatibility, the top-of-report ``## Index`` remains a
16
+ deterministic post-render section built by ``_inject_index_and_anchors``.
17
+ Schema v2 keeps its compact fixed AI-handoff order and does not inject the
18
+ legacy reader index; the task-specific HTML provides the human navigation.
23
19
 
24
20
  Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
25
21
  the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
@@ -27,9 +23,9 @@ data.json, then re-invokes this renderer so the markdown stays in sync.
27
23
  The markdown is never hand-edited.
28
24
 
29
25
  As of v0.33+, the renderer itself is the schema-enforcement seam: data.json
30
- is validated against ``schemas/final-report-v1.0.schema.json`` before any
31
- Jinja2 rendering begins, so schema violations are caught at write-time
32
- rather than only by the post-hoc ``validators/validate-run.py``.
26
+ is validated against its version-selected schema before any Jinja2 rendering
27
+ begins, so schema violations are caught at write-time rather than only by the
28
+ post-hoc ``validators/validate-run.py``.
33
29
  """
34
30
  from __future__ import annotations
35
31
 
@@ -48,16 +44,37 @@ from typing import Any
48
44
  import okstra_vendor # noqa: F401 — side effect: sys.modules aliases
49
45
  from jinja2 import ChainableUndefined, Environment, FileSystemLoader
50
46
 
51
- from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as schema_validate
47
+ from okstra_ctl.final_report_schema import (
48
+ SchemaError,
49
+ load_schema_for_data,
50
+ validate as schema_validate,
51
+ )
52
52
  from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
53
53
  from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
54
54
  from okstra_ctl.models import UnknownModelError, resolve_model_metadata
55
+ from okstra_ctl.report_contract import TASK_TYPE_DATA_PROPERTY
55
56
  from okstra_ctl.schema_excerpt import excerpt_cut_from_version
56
57
  from okstra_ctl.seeding import installed_version
57
58
 
58
59
 
59
- DEFAULT_TEMPLATE_REL = ("templates", "reports", "final-report.template.md")
60
-
60
+ TEMPLATE_BY_SCHEMA_VERSION = {
61
+ "1.0": ("templates", "reports", "final-report.template.md"),
62
+ "2.0": ("templates", "reports", "final-report-v2.template.md"),
63
+ }
64
+ DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION["1.0"]
65
+
66
+ TASK_DELIVERABLE_TITLES = {
67
+ "requirements-discovery": "Requirements Discovery",
68
+ "improvement-discovery": "Improvement Discovery",
69
+ "error-analysis": "Error Analysis",
70
+ "project-analysis": "Project Analysis",
71
+ "feature-analysis": "Feature Analysis",
72
+ "change-impact-analysis": "Change Impact Analysis",
73
+ "implementation-planning": "Implementation Planning",
74
+ "implementation": "Implementation",
75
+ "final-verification": "Final Verification",
76
+ "release-handoff": "Release Handoff",
77
+ }
61
78
 
62
79
  class FinalReportRenderError(RuntimeError):
63
80
  """Raised when the data.json cannot be rendered. Wraps jinja2 errors
@@ -438,12 +455,11 @@ def _inject_index_and_anchors(markdown: str, dictionary: dict | None) -> str:
438
455
 
439
456
 
440
457
  def inject_index_into_file(md_path: Path, *, report_language: str = "en") -> int:
441
- """Apply the top-of-report index + scroll anchors to an already-written
442
- markdown report, in place. This is the seam for task-types that author
443
- the markdown free-form (``improvement-discovery``) instead of through the
444
- data.json renderer every other task-type gets the same treatment inside
445
- ``render()``. Idempotent (the index anchor guards re-runs). Returns the
446
- number of bytes written.
458
+ """Apply the legacy top-of-report index to an existing Markdown report.
459
+
460
+ This remains for schema-v1 and quick compatibility artifacts. Schema-v2
461
+ reports use the fixed AI-handoff structure and never call this seam.
462
+ Idempotent; returns the number of bytes written.
447
463
  """
448
464
  if not md_path.is_file():
449
465
  raise FinalReportRenderError(f"report markdown not found: {md_path}")
@@ -470,7 +486,7 @@ def _enforce_schema(data: dict) -> None:
470
486
  Phase 7 재렌더를 hard-fail 시키는 것은 과도하다.
471
487
  """
472
488
  try:
473
- schema = load_schema()
489
+ schema = load_schema_for_data(data)
474
490
  except SchemaError as exc:
475
491
  print(
476
492
  f"render-final-report: schema not locatable; skipping schema enforcement ({exc})",
@@ -544,6 +560,7 @@ def _build_environment(template_dir: Path) -> Environment:
544
560
  env.filters["yaml_scalar"] = _yaml_scalar
545
561
  env.filters["yaml_inline_list"] = _yaml_inline_list
546
562
  env.filters["model_detail"] = _model_detail
563
+ env.filters["json_block"] = _json_markdown_block
547
564
  # `mdcell` neutralises the two things in worker prose that can break a
548
565
  # markdown table row: a literal `|` (splits the row) and a newline
549
566
  # (truncates it, dropping every later column). Table-cell interpolations
@@ -558,6 +575,61 @@ def _build_environment(template_dir: Path) -> Environment:
558
575
  return env
559
576
 
560
577
 
578
+ def _json_markdown_block(value: Any) -> str:
579
+ serialized = json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
580
+ return "\n".join(f" {line}" for line in serialized.splitlines())
581
+
582
+
583
+ def _without_human_narrative(value: Any) -> Any:
584
+ if isinstance(value, dict):
585
+ return {
586
+ key: _without_human_narrative(item)
587
+ for key, item in value.items()
588
+ if key != "userNarrative"
589
+ }
590
+ if isinstance(value, list):
591
+ return [_without_human_narrative(item) for item in value]
592
+ return value
593
+
594
+
595
+ def _ai_markdown_context(data: dict) -> dict:
596
+ context = _with_optional_defaults(data)
597
+ header = data.get("header") if isinstance(data.get("header"), dict) else {}
598
+ task_type = header.get("taskType", "")
599
+ property_name = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
600
+ context["aiTaskDeliverableTitle"] = TASK_DELIVERABLE_TITLES.get(
601
+ task_type, task_type
602
+ )
603
+ context["aiTaskDeliverable"] = _without_human_narrative(
604
+ data.get(property_name, {})
605
+ )
606
+ context["aiEvidenceLedger"] = {
607
+ "evidence": data.get("evidence", {}),
608
+ "missingInformation": data.get("missingInformation", []),
609
+ "endStateCoverage": data.get("endStateCoverage", []),
610
+ "analysisCommon": data.get("analysisCommon"),
611
+ }
612
+ context["aiRoutingContract"] = {
613
+ "recommendedNextSteps": data.get("recommendedNextSteps", []),
614
+ "followUpTasks": data.get("followUpTasks", []),
615
+ }
616
+ context["aiDecisionContext"] = {
617
+ "rationale": data.get("rationale", {}),
618
+ "summary": data.get("summary", []),
619
+ "ticketCoverage": data.get("ticketCoverage"),
620
+ "finalVerdict": data.get("finalVerdict", {}),
621
+ }
622
+ context["aiBlockingIds"] = [
623
+ row.get("id")
624
+ for row in data.get("clarificationItems", [])
625
+ if isinstance(row, dict)
626
+ and row.get("status") in {"open", "answered"}
627
+ and row.get("blocks") in {"approval", "next-phase"}
628
+ and isinstance(row.get("id"), str)
629
+ ]
630
+ return context
631
+
632
+
561
633
  def resolve_report_language(data: dict, *, override: str | None) -> str:
562
634
  """우선순위: override > data.meta.reportLanguage > 'en'."""
563
635
  if override is not None:
@@ -638,8 +710,14 @@ def render(
638
710
 
639
711
  try:
640
712
  template = env.get_template(template_path.name)
641
- rendered = template.render(**_with_optional_defaults(data))
642
- rendered = _inject_index_and_anchors(rendered, dictionary)
713
+ context = (
714
+ _ai_markdown_context(data)
715
+ if data.get("schemaVersion") == "2.0"
716
+ else _with_optional_defaults(data)
717
+ )
718
+ rendered = template.render(**context)
719
+ if data.get("schemaVersion") == "1.0":
720
+ rendered = _inject_index_and_anchors(rendered, dictionary)
643
721
  return _ventilate_prose(rendered)
644
722
  except I18nError as exc:
645
723
  raise FinalReportRenderError(
@@ -680,6 +758,36 @@ def find_default_template(start: Path | None = None) -> Path:
680
758
  )
681
759
 
682
760
 
761
+ def find_default_template_for_data(
762
+ data: dict, start: Path | None = None
763
+ ) -> Path:
764
+ """Locate the Markdown template selected by ``data.schemaVersion``."""
765
+ version = data.get("schemaVersion")
766
+ try:
767
+ relative_path = TEMPLATE_BY_SCHEMA_VERSION[version]
768
+ except KeyError as exc:
769
+ raise FinalReportRenderError(
770
+ f"unsupported final-report schemaVersion: {version}"
771
+ ) from exc
772
+
773
+ okstra_home = os.environ.get("OKSTRA_HOME")
774
+ if okstra_home:
775
+ candidate = Path(okstra_home).joinpath(*relative_path)
776
+ if candidate.is_file():
777
+ return candidate
778
+
779
+ here = Path(start or __file__).resolve()
780
+ for parent in [here, *here.parents]:
781
+ candidate = parent.joinpath(*relative_path)
782
+ if candidate.is_file():
783
+ return candidate
784
+
785
+ raise FinalReportRenderError(
786
+ f"could not locate {relative_path[-1]}. Set OKSTRA_HOME or run from a "
787
+ "checkout that contains templates/reports/."
788
+ )
789
+
790
+
683
791
  def _bundle_excerpt_path(data_path: Path) -> Path | None:
684
792
  """The task bundle's schema excerpt, found by walking up from *data_path*."""
685
793
  for ancestor in data_path.resolve().parents:
@@ -746,7 +854,7 @@ def render_to_file(
746
854
  # 프로젝트의 .okstra 트리만 위로 뒤지다 templates/reports 를 못 찾아
747
855
  # 설치본에서 항상 'could not locate template' 으로 실패한다(OKSTRA_HOME 을
748
856
  # 수동 설정해야 했던 원인). 프로젝트별 override 는 --template 으로 한다.
749
- resolved_template = template_path or find_default_template()
857
+ resolved_template = template_path or find_default_template_for_data(data)
750
858
  try:
751
859
  rendered = render(
752
860
  data,
@@ -0,0 +1,124 @@
1
+ """Single registry for public final-report task contracts."""
2
+ from __future__ import annotations
3
+
4
+
5
+ CURRENT_REPORT_SCHEMA_VERSION = "2.0"
6
+ LEGACY_REPORT_SCHEMA_VERSION = "1.0"
7
+
8
+ PUBLIC_REPORT_TASK_TYPES = (
9
+ "requirements-discovery",
10
+ "improvement-discovery",
11
+ "error-analysis",
12
+ "project-analysis",
13
+ "feature-analysis",
14
+ "change-impact-analysis",
15
+ "implementation-planning",
16
+ "implementation",
17
+ "final-verification",
18
+ "release-handoff",
19
+ )
20
+
21
+ TASK_TYPE_DATA_PROPERTY = {
22
+ "requirements-discovery": "requirementsDiscovery",
23
+ "improvement-discovery": "improvementDiscovery",
24
+ "error-analysis": "errorAnalysis",
25
+ "project-analysis": "projectAnalysis",
26
+ "feature-analysis": "featureAnalysis",
27
+ "change-impact-analysis": "changeImpactAnalysis",
28
+ "implementation-planning": "implementationPlanning",
29
+ "implementation": "implementation",
30
+ "final-verification": "finalVerification",
31
+ "release-handoff": "releaseHandoff",
32
+ }
33
+
34
+ TASK_TYPE_HTML_TEMPLATE = {
35
+ task_type: f"html/tasks/{task_type}.template.html"
36
+ for task_type in PUBLIC_REPORT_TASK_TYPES
37
+ }
38
+
39
+ TASK_TYPE_REQUIRED_HUMAN_FIELDS = {
40
+ "requirements-discovery": (
41
+ "requirementsDiscovery.requestVerbatim",
42
+ "requirementsDiscovery.systemInterpretation",
43
+ "requirementsDiscovery.classification",
44
+ "requirementsDiscovery.confirmedRequirements",
45
+ "requirementsDiscovery.unresolvedRequirements",
46
+ ),
47
+ "improvement-discovery": (
48
+ "improvementDiscovery.candidates",
49
+ "improvementDiscovery.lensCoverage",
50
+ ),
51
+ "error-analysis": (
52
+ "errorAnalysis.symptomVerbatim",
53
+ "errorAnalysis.observableFailure",
54
+ "errorAnalysis.causeCandidates.confidence",
55
+ "errorAnalysis.causeCandidates.falsifyingEvidenceChecked",
56
+ "errorAnalysis.causeCandidates.disproveWith",
57
+ ),
58
+ "project-analysis": (
59
+ "projectAnalysis.components",
60
+ "projectAnalysis.entryPoints",
61
+ "projectAnalysis.dataStores",
62
+ "projectAnalysis.externalSystems",
63
+ "projectAnalysis.rankedHotspots",
64
+ "projectAnalysis.qualityCoverage",
65
+ "projectAnalysis.featureIndex",
66
+ ),
67
+ "feature-analysis": (
68
+ "featureAnalysis.flows",
69
+ "featureAnalysis.domainRules",
70
+ "featureAnalysis.stateChanges",
71
+ "featureAnalysis.externalInteractions",
72
+ "featureAnalysis.testCoverage",
73
+ ),
74
+ "change-impact-analysis": (
75
+ "changeImpactAnalysis.impactItems",
76
+ "changeImpactAnalysis.dependencyBlastRadius",
77
+ "changeImpactAnalysis.unaffectedBoundaries",
78
+ "changeImpactAnalysis.compatibilityImpact",
79
+ "changeImpactAnalysis.migrationImpact",
80
+ "changeImpactAnalysis.rollbackImpact",
81
+ "changeImpactAnalysis.securityAndPerformanceImpact",
82
+ "changeImpactAnalysis.planningInputs",
83
+ ),
84
+ "implementation-planning": (
85
+ "implementationPlanning.optionCandidates",
86
+ "implementationPlanning.tradeoffMatrix",
87
+ "implementationPlanning.recommendedOption",
88
+ "implementationPlanning.stageMap",
89
+ "implementationPlanning.validationChecklist",
90
+ "implementationPlanning.rollbackStrategy",
91
+ ),
92
+ "implementation": (
93
+ "implementation.diffSummary",
94
+ "implementation.requirementCoverage",
95
+ "implementation.validationEvidence",
96
+ "implementation.manualUserTest",
97
+ ),
98
+ "final-verification": (
99
+ "finalVerification.validationEvidence",
100
+ "finalVerification.acceptanceBlockers",
101
+ "finalVerification.residualRisk",
102
+ "finalVerification.manualUserTest",
103
+ ),
104
+ "release-handoff": (
105
+ "releaseHandoff.userSelections",
106
+ "releaseHandoff.featureBranchState",
107
+ "releaseHandoff.pullRequestOutcome",
108
+ "releaseHandoff.mergeConflictProbe",
109
+ ),
110
+ }
111
+
112
+
113
+ def report_property_for(task_type: str) -> str:
114
+ try:
115
+ return TASK_TYPE_DATA_PROPERTY[task_type]
116
+ except KeyError as exc:
117
+ raise ValueError(f"unsupported public report task type: {task_type}") from exc
118
+
119
+
120
+ def html_template_for(task_type: str) -> str:
121
+ try:
122
+ return TASK_TYPE_HTML_TEMPLATE[task_type]
123
+ except KeyError as exc:
124
+ raise ValueError(f"no task-specific html template for: {task_type}") from exc
@@ -227,7 +227,7 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
227
227
  ctx.workspace_root, "okstra-render-report-views.py"
228
228
  )
229
229
  ),
230
- str(markdown_path),
230
+ str(ctx.data_path),
231
231
  "--task-key",
232
232
  ctx.task_key,
233
233
  "--task-type",
@@ -0,0 +1,10 @@
1
+ """Task-specific human HTML rendering for final-report schema v2."""
2
+
3
+
4
+ def render_v2_html_view(*args, **kwargs):
5
+ """Load the vendored template runtime only when a v2 view is rendered."""
6
+ from .render import render_v2_html_view as render
7
+
8
+ return render(*args, **kwargs)
9
+
10
+ __all__ = ["render_v2_html_view"]
@@ -0,0 +1,50 @@
1
+ """Common data transformations that carry no task-specific section order."""
2
+ from __future__ import annotations
3
+
4
+
5
+ def audit_context(data: dict) -> dict[str, object]:
6
+ return {
7
+ "crossVerification": data.get("crossVerification", {}),
8
+ "executionStatus": data.get("executionStatus", []),
9
+ "tokenUsage": data.get("tokenUsage", {}),
10
+ }
11
+
12
+
13
+ def evidence_index(data: dict) -> dict[str, object]:
14
+ rows: dict[str, object] = {}
15
+ for section in ("primary", "secondary"):
16
+ for row in data.get("evidence", {}).get(section, []):
17
+ row_id = row.get("id")
18
+ if row_id:
19
+ rows[row_id] = row
20
+ analysis = data.get("analysisCommon", {})
21
+ for collection in ("confirmedFacts", "inferences", "unknowns"):
22
+ for row in analysis.get(collection, []):
23
+ row_id = row.get("id")
24
+ if row_id:
25
+ rows[row_id] = row
26
+ return rows
27
+
28
+
29
+ def _collect_ids(value: object, found: set[str]) -> None:
30
+ if isinstance(value, dict):
31
+ row_id = value.get("id")
32
+ if isinstance(row_id, str) and row_id:
33
+ found.add(row_id)
34
+ for nested in value.values():
35
+ _collect_ids(nested, found)
36
+ elif isinstance(value, list):
37
+ for nested in value:
38
+ _collect_ids(nested, found)
39
+
40
+
41
+ def analysis_review_ids(data: dict) -> tuple[str, ...]:
42
+ found: set[str] = set()
43
+ _collect_ids(data.get("analysisCommon", {}), found)
44
+ task_type = data.get("header", {}).get("taskType", "")
45
+ from ..report_contract import TASK_TYPE_DATA_PROPERTY
46
+
47
+ property_name = TASK_TYPE_DATA_PROPERTY.get(task_type)
48
+ if property_name:
49
+ _collect_ids(data.get(property_name, {}), found)
50
+ return tuple(sorted(found))
@@ -0,0 +1,59 @@
1
+ """Stable view-model types shared by task-specific HTML renderers."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Callable
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class HtmlRunMeta:
10
+ task_key: str
11
+ task_type: str
12
+ seq: str
13
+ source_report: str
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class VisualNode:
18
+ id: str
19
+ label: str
20
+ group: str
21
+ status: str
22
+ detail: str
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class VisualEdge:
27
+ source: str
28
+ target: str
29
+ label: str
30
+ kind: str
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class FigureModel:
35
+ figure_id: str
36
+ kind: str
37
+ title: str
38
+ summary: str
39
+ nodes: tuple[VisualNode, ...]
40
+ edges: tuple[VisualEdge, ...]
41
+ svg: str
42
+
43
+ @property
44
+ def table_row_ids(self) -> tuple[str, ...]:
45
+ return tuple(node.id for node in self.nodes)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class HumanReportView:
50
+ task_type: str
51
+ template_name: str
52
+ context: dict[str, object]
53
+ figures: tuple[FigureModel, ...]
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class HtmlRoute:
58
+ template_name: str
59
+ view_builder: Callable[[dict], HumanReportView]
@@ -0,0 +1,69 @@
1
+ """Render schema-v2 data directly into a task-specific HTML document."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import okstra_vendor # noqa: F401 # registers vendored dependency aliases
9
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
10
+
11
+ from ..final_report_schema import load_schema_for_data, validate
12
+ from .models import HtmlRunMeta
13
+ from .router import HtmlRenderError, resolve_html_route
14
+
15
+
16
+ def _sha256(path: Path) -> str:
17
+ return hashlib.sha256(path.read_bytes()).hexdigest()
18
+
19
+
20
+ def _templates_root(start: Path | None = None) -> Path:
21
+ if start is not None:
22
+ return start
23
+ here = Path(__file__).resolve()
24
+ for parent in [here, *here.parents]:
25
+ candidate = parent / "templates" / "reports"
26
+ if candidate.is_dir():
27
+ return candidate
28
+ raise HtmlRenderError("could not locate templates/reports")
29
+
30
+
31
+ def _html_path(data_path: Path) -> Path:
32
+ suffix = ".data.json"
33
+ if not data_path.name.endswith(suffix):
34
+ raise HtmlRenderError(f"v2 report path must end with {suffix}: {data_path}")
35
+ return data_path.with_name(data_path.name.removesuffix(suffix) + ".html")
36
+
37
+
38
+ def render_v2_html_view(
39
+ data_path: Path,
40
+ markdown_path: Path,
41
+ *,
42
+ run_meta: HtmlRunMeta,
43
+ templates_root: Path | None = None,
44
+ ) -> Path:
45
+ data = json.loads(data_path.read_text(encoding="utf-8"))
46
+ errors = validate(data, load_schema_for_data(data))
47
+ if errors:
48
+ raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
49
+ if not markdown_path.is_file():
50
+ raise HtmlRenderError(f"v2 markdown sibling not found: {markdown_path}")
51
+ route = resolve_html_route(run_meta.task_type)
52
+ view = route.view_builder(data)
53
+ root = _templates_root(templates_root)
54
+ env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
55
+ response_js = (root / "report.js").read_text(encoding="utf-8")
56
+ base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
57
+ context = {
58
+ **view.context,
59
+ "runMeta": run_meta,
60
+ "taskType": view.task_type,
61
+ "dataSha256": _sha256(data_path),
62
+ "markdownSha256": _sha256(markdown_path),
63
+ "clarificationItems": data.get("clarificationItems", []),
64
+ "css": (root / "html/assets/base.css").read_text(encoding="utf-8"),
65
+ "js": response_js + "\n" + base_js,
66
+ }
67
+ output_path = _html_path(data_path)
68
+ output_path.write_text(env.get_template(route.template_name).render(**context), encoding="utf-8")
69
+ return output_path
@@ -0,0 +1,40 @@
1
+ """Fail-closed task-type routing for schema-v2 HTML views."""
2
+ from __future__ import annotations
3
+
4
+ from ..report_contract import html_template_for
5
+ from .models import HtmlRoute
6
+ from .view_models.change_impact_analysis import build_change_impact_analysis_view
7
+ from .view_models.error_analysis import build_error_analysis_view
8
+ from .view_models.feature_analysis import build_feature_analysis_view
9
+ from .view_models.final_verification import build_final_verification_view
10
+ from .view_models.improvement_discovery import build_improvement_discovery_view
11
+ from .view_models.implementation import build_implementation_view
12
+ from .view_models.implementation_planning import build_implementation_planning_view
13
+ from .view_models.project_analysis import build_project_analysis_view
14
+ from .view_models.release_handoff import build_release_handoff_view
15
+ from .view_models.requirements_discovery import build_requirements_discovery_view
16
+
17
+
18
+ class HtmlRenderError(ValueError):
19
+ """Raised when a v2 task has no dedicated human view."""
20
+
21
+
22
+ VIEW_BUILDERS = {
23
+ "requirements-discovery": build_requirements_discovery_view,
24
+ "improvement-discovery": build_improvement_discovery_view,
25
+ "error-analysis": build_error_analysis_view,
26
+ "project-analysis": build_project_analysis_view,
27
+ "feature-analysis": build_feature_analysis_view,
28
+ "change-impact-analysis": build_change_impact_analysis_view,
29
+ "implementation-planning": build_implementation_planning_view,
30
+ "implementation": build_implementation_view,
31
+ "final-verification": build_final_verification_view,
32
+ "release-handoff": build_release_handoff_view,
33
+ }
34
+
35
+
36
+ def resolve_html_route(task_type: str) -> HtmlRoute:
37
+ builder = VIEW_BUILDERS.get(task_type)
38
+ if builder is None:
39
+ raise HtmlRenderError(f"no v2 html view builder for {task_type}")
40
+ return HtmlRoute(template_name=html_template_for(task_type), view_builder=builder)
@@ -0,0 +1 @@
1
+ """Task-specific human report view builders."""
@@ -0,0 +1,38 @@
1
+ """Human-first change-impact-analysis view model."""
2
+ from __future__ import annotations
3
+
4
+ from ..common import analysis_review_ids, audit_context
5
+ from ..models import HumanReportView, VisualEdge, VisualNode
6
+ from ..visualizations import flow_figure
7
+
8
+
9
+ def _impact_figure(change: dict):
10
+ nodes = [
11
+ VisualNode(row["id"], row["target"], "affected", row["level"], row["impactKind"])
12
+ for row in change.get("impactItems", [])
13
+ ]
14
+ edges: list[VisualEdge] = []
15
+ for index, row in enumerate(change.get("dependencyBlastRadius", []), start=1):
16
+ target_id = f"BR-{index:03d}"
17
+ nodes.append(VisualNode(target_id, row["affectedTarget"], "downstream", "risk", row["direction"]))
18
+ edges.append(VisualEdge(row["sourceImpactId"], target_id, row["direction"], "impact"))
19
+ return flow_figure(nodes=tuple(nodes), edges=tuple(edges), title="Change blast radius")
20
+
21
+
22
+ def build_change_impact_analysis_view(data: dict) -> HumanReportView:
23
+ change = data["changeImpactAnalysis"]
24
+ figure = _impact_figure(change)
25
+ context = {
26
+ "humanSummary": data["humanSummary"],
27
+ "change": change,
28
+ "narrative": change["userNarrative"],
29
+ "blastRadiusFigure": figure,
30
+ "analysisReviewIds": analysis_review_ids(data),
31
+ "audit": audit_context(data),
32
+ }
33
+ return HumanReportView(
34
+ task_type="change-impact-analysis",
35
+ template_name="html/tasks/change-impact-analysis.template.html",
36
+ context=context,
37
+ figures=(figure,),
38
+ )