okstra 0.146.1 → 0.148.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 (137) hide show
  1. package/README.md +23 -9
  2. package/docs/architecture/storage-model.md +39 -65
  3. package/docs/architecture.md +68 -60
  4. package/docs/cli.md +40 -23
  5. package/docs/for-ai/skills/okstra-run.md +13 -34
  6. package/docs/performance-improvement-plan-v2.md +2 -2
  7. package/docs/pr-template-usage.md +1 -1
  8. package/docs/project-structure-overview.md +26 -22
  9. package/docs/task-process/README.md +4 -4
  10. package/docs/task-process/common-flow.md +12 -12
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation.md +1 -1
  13. package/docs/task-process/release-handoff.md +1 -1
  14. package/package.json +2 -2
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/agents/workers/antigravity-worker.md +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/codex-worker.md +2 -2
  19. package/runtime/agents/workers/grok-worker.md +256 -0
  20. package/runtime/agents/workers/kimi-worker.md +256 -0
  21. package/runtime/agents/workers/report-writer-worker.md +12 -12
  22. package/runtime/bin/lib/okstra/cli.sh +13 -1
  23. package/runtime/bin/lib/okstra/globals.sh +3 -0
  24. package/runtime/bin/lib/okstra/usage.sh +17 -12
  25. package/runtime/bin/okstra-grok-exec.sh +5 -0
  26. package/runtime/bin/okstra-kimi-exec.sh +5 -0
  27. package/runtime/bin/okstra-provider-exec.py +235 -0
  28. package/runtime/bin/okstra-render-final-report.py +4 -4
  29. package/runtime/bin/okstra-render-report-views.py +100 -12
  30. package/runtime/bin/okstra.sh +3 -0
  31. package/runtime/prompts/lead/adapters/antigravity.md +48 -0
  32. package/runtime/prompts/lead/adapters/claude-code.md +13 -11
  33. package/runtime/prompts/lead/adapters/codex.md +7 -7
  34. package/runtime/prompts/lead/okstra-lead-contract.md +5 -5
  35. package/runtime/prompts/lead/report-writer.md +16 -12
  36. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  37. package/runtime/prompts/profiles/_common-contract.md +16 -10
  38. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  39. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  40. package/runtime/prompts/profiles/_implementation-executor.md +12 -12
  41. package/runtime/prompts/profiles/_implementation-self-check.md +4 -4
  42. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  43. package/runtime/prompts/profiles/change-impact-analysis.md +2 -0
  44. package/runtime/prompts/profiles/error-analysis.md +2 -0
  45. package/runtime/prompts/profiles/feature-analysis.md +2 -0
  46. package/runtime/prompts/profiles/final-verification.md +3 -1
  47. package/runtime/prompts/profiles/forbidden-actions.json +4 -4
  48. package/runtime/prompts/profiles/implementation-planning.md +3 -1
  49. package/runtime/prompts/profiles/implementation.md +2 -2
  50. package/runtime/prompts/profiles/improvement-discovery.md +6 -2
  51. package/runtime/prompts/profiles/project-analysis.md +2 -0
  52. package/runtime/prompts/profiles/release-handoff.md +7 -7
  53. package/runtime/prompts/profiles/requirements-discovery.md +2 -0
  54. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  55. package/runtime/python/okstra_ctl/codex_dispatch.py +68 -87
  56. package/runtime/python/okstra_ctl/dispatch_core.py +4 -22
  57. package/runtime/python/okstra_ctl/final_report_schema.py +37 -12
  58. package/runtime/python/okstra_ctl/lead_events.py +1 -1
  59. package/runtime/python/okstra_ctl/lead_runtime.py +13 -2
  60. package/runtime/python/okstra_ctl/models.py +156 -8
  61. package/runtime/python/okstra_ctl/path_hints.py +9 -25
  62. package/runtime/python/okstra_ctl/paths.py +1 -1
  63. package/runtime/python/okstra_ctl/render.py +172 -74
  64. package/runtime/python/okstra_ctl/render_final_report.py +136 -28
  65. package/runtime/python/okstra_ctl/report_contract.py +124 -0
  66. package/runtime/python/okstra_ctl/report_finalize.py +1 -1
  67. package/runtime/python/okstra_ctl/report_html/__init__.py +10 -0
  68. package/runtime/python/okstra_ctl/report_html/common.py +86 -0
  69. package/runtime/python/okstra_ctl/report_html/filters.py +104 -0
  70. package/runtime/python/okstra_ctl/report_html/models.py +59 -0
  71. package/runtime/python/okstra_ctl/report_html/render.py +76 -0
  72. package/runtime/python/okstra_ctl/report_html/router.py +40 -0
  73. package/runtime/python/okstra_ctl/report_html/view_models/__init__.py +1 -0
  74. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +39 -0
  75. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +49 -0
  76. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +39 -0
  77. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +47 -0
  78. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +47 -0
  79. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +103 -0
  80. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +43 -0
  81. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +54 -0
  82. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +54 -0
  83. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +55 -0
  84. package/runtime/python/okstra_ctl/report_html/visualizations.py +139 -0
  85. package/runtime/python/okstra_ctl/report_view_artifacts.py +4 -1
  86. package/runtime/python/okstra_ctl/report_views.py +15 -43
  87. package/runtime/python/okstra_ctl/run.py +276 -51
  88. package/runtime/python/okstra_ctl/runner_resolution.py +103 -0
  89. package/runtime/python/okstra_ctl/schema_excerpt.py +7 -17
  90. package/runtime/python/okstra_ctl/team.py +2 -7
  91. package/runtime/python/okstra_ctl/wizard.py +194 -21
  92. package/runtime/python/okstra_ctl/worker_artifacts.py +46 -0
  93. package/runtime/python/okstra_ctl/workers.py +3 -1
  94. package/runtime/python/okstra_ctl/workflow.py +4 -2
  95. package/runtime/python/okstra_token_usage/__init__.py +1 -0
  96. package/runtime/python/okstra_token_usage/collect.py +32 -23
  97. package/runtime/python/okstra_token_usage/pricing.py +35 -3
  98. package/runtime/schemas/final-report-v2.0.schema.json +3923 -0
  99. package/runtime/skills/okstra-run/SKILL.md +31 -42
  100. package/runtime/templates/prd/pr-body.template.md +1 -1
  101. package/runtime/templates/reports/final-report-v2.template.md +66 -0
  102. package/runtime/templates/reports/html/assets/base.css +41 -0
  103. package/runtime/templates/reports/html/assets/base.js +5 -0
  104. package/runtime/templates/reports/html/base.template.html +79 -0
  105. package/runtime/templates/reports/html/macros/forms.html +47 -0
  106. package/runtime/templates/reports/html/macros/layout.html +19 -0
  107. package/runtime/templates/reports/html/macros/visualizations.html +27 -0
  108. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +40 -0
  109. package/runtime/templates/reports/html/tasks/error-analysis.template.html +40 -0
  110. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +40 -0
  111. package/runtime/templates/reports/html/tasks/final-verification.template.html +39 -0
  112. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +47 -0
  113. package/runtime/templates/reports/html/tasks/implementation.template.html +40 -0
  114. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +29 -0
  115. package/runtime/templates/reports/html/tasks/project-analysis.template.html +57 -0
  116. package/runtime/templates/reports/html/tasks/release-handoff.template.html +36 -0
  117. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +37 -0
  118. package/runtime/templates/reports/report.js +21 -4
  119. package/runtime/templates/reports/settings.template.json +4 -0
  120. package/runtime/templates/reports/task-brief.template.md +7 -7
  121. package/runtime/validators/validate-report-views.py +86 -4
  122. package/runtime/validators/validate-run.py +73 -15
  123. package/runtime/validators/validate_improvement_report.py +55 -0
  124. package/runtime/validators/validate_session_conformance.py +2 -1
  125. package/src/cli-registry.mjs +4 -4
  126. package/src/commands/execute/codex-dispatch.mjs +7 -10
  127. package/src/commands/execute/render-bundle.mjs +3 -3
  128. package/src/commands/execute/run.mjs +17 -52
  129. package/src/commands/execute/wizard.mjs +4 -1
  130. package/src/commands/lifecycle/doctor.mjs +6 -3
  131. package/src/commands/lifecycle/install.mjs +49 -21
  132. package/src/commands/report/finalize.mjs +2 -3
  133. package/src/commands/report/render-final-report.mjs +4 -2
  134. package/src/commands/report/render-views.mjs +8 -8
  135. package/src/lib/runtime-manifest.mjs +1 -1
  136. package/src/lib/runtime-resolver.mjs +2 -2
  137. package/src/lib/worker-agent-render.mjs +50 -0
@@ -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,86 @@
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
+ _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.
27
+
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.
40
+ """
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
+ }
47
+
48
+
49
+ def evidence_index(data: dict) -> dict[str, object]:
50
+ 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, []):
59
+ row_id = row.get("id")
60
+ if row_id:
61
+ rows[row_id] = _ledger_row(row)
62
+ return rows
63
+
64
+
65
+ def _collect_ids(value: object, found: set[str]) -> None:
66
+ if isinstance(value, dict):
67
+ row_id = value.get("id")
68
+ if isinstance(row_id, str) and row_id:
69
+ found.add(row_id)
70
+ for nested in value.values():
71
+ _collect_ids(nested, found)
72
+ elif isinstance(value, list):
73
+ for nested in value:
74
+ _collect_ids(nested, found)
75
+
76
+
77
+ def analysis_review_ids(data: dict) -> tuple[str, ...]:
78
+ found: set[str] = set()
79
+ _collect_ids(data.get("analysisCommon", {}), found)
80
+ task_type = data.get("header", {}).get("taskType", "")
81
+ from ..report_contract import TASK_TYPE_DATA_PROPERTY
82
+
83
+ property_name = TASK_TYPE_DATA_PROPERTY.get(task_type)
84
+ if property_name:
85
+ _collect_ids(data.get(property_name, {}), found)
86
+ return tuple(sorted(found))
@@ -0,0 +1,104 @@
1
+ """Jinja filters for the task-specific HTML report."""
2
+ from __future__ import annotations
3
+
4
+ import html
5
+ import json
6
+ import re
7
+
8
+ import okstra_vendor # noqa: F401 # registers vendored dependency aliases
9
+ from markupsafe import Markup, escape
10
+
11
+ _INLINE_CODE = re.compile(r"`([^`]+)`")
12
+ _SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
13
+ _SENTENCES_PER_PARAGRAPH = 2
14
+
15
+
16
+ def audit_json(value: object) -> Markup:
17
+ """Serialize an audit payload for reading inside a ``<pre>`` text node.
18
+
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.
23
+ """
24
+ rendered = json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True)
25
+ return Markup(html.escape(rendered, quote=False))
26
+
27
+
28
+ def inline_code(value: object) -> Markup:
29
+ """Convert paired backticks to ``<code>``, escaping everything else.
30
+
31
+ 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.
34
+ """
35
+ text = "" if value is None else str(value)
36
+ out: list[str] = []
37
+ cursor = 0
38
+ 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>")
41
+ cursor = match.end()
42
+ out.append(str(escape(text[cursor:])))
43
+ return Markup("".join(out))
44
+
45
+
46
+ def _grouped_paragraphs(text: str) -> list[str]:
47
+ explicit = [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()]
48
+ if len(explicit) > 1:
49
+ return explicit
50
+ sentences = [s.strip() for s in _SENTENCE_END.split(text.strip()) if s.strip()]
51
+ if len(sentences) <= _SENTENCES_PER_PARAGRAPH:
52
+ return [text.strip()] if text.strip() else []
53
+ return [
54
+ " ".join(sentences[i:i + _SENTENCES_PER_PARAGRAPH])
55
+ for i in range(0, len(sentences), _SENTENCES_PER_PARAGRAPH)
56
+ ]
57
+
58
+
59
+ def paragraphs(value: object) -> Markup:
60
+ """Split prose into ``<p>`` blocks, converting inline code inside each.
61
+
62
+ ``userNarrative`` prose arrives as one unbroken run of sentences, so the
63
+ reader gets no paragraph boundary anywhere in the document. An author who
64
+ did supply blank lines keeps their own boundaries.
65
+ """
66
+ text = "" if value is None else str(value)
67
+ return Markup("".join(f"<p>{inline_code(block)}</p>" for block in _grouped_paragraphs(text)))
68
+
69
+
70
+ def evidence_refs(refs: object, index: object) -> Markup:
71
+ """Render evidence ids as anchors when the ledger defines them.
72
+
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.
78
+ """
79
+ rows = index if isinstance(index, dict) else {}
80
+ items = refs if isinstance(refs, (list, tuple)) else []
81
+ out: list[str] = []
82
+ for ref in items:
83
+ if str(ref) not in rows:
84
+ out.append(str(inline_code(ref)))
85
+ continue
86
+ text = escape(str(ref))
87
+ out.append(f'<a href="#ev-{text}">{text}</a>')
88
+ return Markup(", ".join(out))
89
+
90
+
91
+ def code_evidence(rows: object) -> Markup:
92
+ """Render ``currentCodeEvidence`` entries as ``path:line`` code spans."""
93
+ items = rows if isinstance(rows, (list, tuple)) else []
94
+ out: list[str] = []
95
+ for row in items:
96
+ if not isinstance(row, dict):
97
+ continue
98
+ path = str(row.get("path", "")).strip()
99
+ if not path:
100
+ continue
101
+ line = row.get("line")
102
+ ref = f"{path}:{line}" if line else path
103
+ out.append(f"<code>{escape(ref)}</code>")
104
+ return Markup(", ".join(out))
@@ -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]