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.
- package/README.md +23 -9
- package/docs/architecture/storage-model.md +39 -65
- package/docs/architecture.md +68 -60
- package/docs/cli.md +40 -23
- package/docs/for-ai/skills/okstra-run.md +13 -34
- package/docs/performance-improvement-plan-v2.md +2 -2
- package/docs/pr-template-usage.md +1 -1
- package/docs/project-structure-overview.md +26 -22
- package/docs/task-process/README.md +4 -4
- package/docs/task-process/common-flow.md +12 -12
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation.md +1 -1
- package/docs/task-process/release-handoff.md +1 -1
- package/package.json +2 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +2 -2
- package/runtime/agents/workers/grok-worker.md +256 -0
- package/runtime/agents/workers/kimi-worker.md +256 -0
- package/runtime/agents/workers/report-writer-worker.md +12 -12
- package/runtime/bin/lib/okstra/cli.sh +13 -1
- package/runtime/bin/lib/okstra/globals.sh +3 -0
- package/runtime/bin/lib/okstra/usage.sh +17 -12
- package/runtime/bin/okstra-grok-exec.sh +5 -0
- package/runtime/bin/okstra-kimi-exec.sh +5 -0
- package/runtime/bin/okstra-provider-exec.py +235 -0
- package/runtime/bin/okstra-render-final-report.py +4 -4
- package/runtime/bin/okstra-render-report-views.py +100 -12
- package/runtime/bin/okstra.sh +3 -0
- package/runtime/prompts/lead/adapters/antigravity.md +48 -0
- package/runtime/prompts/lead/adapters/claude-code.md +13 -11
- package/runtime/prompts/lead/adapters/codex.md +7 -7
- package/runtime/prompts/lead/okstra-lead-contract.md +5 -5
- package/runtime/prompts/lead/report-writer.md +16 -12
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +16 -10
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +12 -12
- package/runtime/prompts/profiles/_implementation-self-check.md +4 -4
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
- package/runtime/prompts/profiles/change-impact-analysis.md +2 -0
- package/runtime/prompts/profiles/error-analysis.md +2 -0
- package/runtime/prompts/profiles/feature-analysis.md +2 -0
- package/runtime/prompts/profiles/final-verification.md +3 -1
- package/runtime/prompts/profiles/forbidden-actions.json +4 -4
- package/runtime/prompts/profiles/implementation-planning.md +3 -1
- package/runtime/prompts/profiles/implementation.md +2 -2
- package/runtime/prompts/profiles/improvement-discovery.md +6 -2
- package/runtime/prompts/profiles/project-analysis.md +2 -0
- package/runtime/prompts/profiles/release-handoff.md +7 -7
- package/runtime/prompts/profiles/requirements-discovery.md +2 -0
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +68 -87
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -22
- package/runtime/python/okstra_ctl/final_report_schema.py +37 -12
- package/runtime/python/okstra_ctl/lead_events.py +1 -1
- package/runtime/python/okstra_ctl/lead_runtime.py +13 -2
- package/runtime/python/okstra_ctl/models.py +156 -8
- package/runtime/python/okstra_ctl/path_hints.py +9 -25
- package/runtime/python/okstra_ctl/paths.py +1 -1
- package/runtime/python/okstra_ctl/render.py +172 -74
- package/runtime/python/okstra_ctl/render_final_report.py +136 -28
- package/runtime/python/okstra_ctl/report_contract.py +124 -0
- package/runtime/python/okstra_ctl/report_finalize.py +1 -1
- package/runtime/python/okstra_ctl/report_html/__init__.py +10 -0
- package/runtime/python/okstra_ctl/report_html/common.py +86 -0
- package/runtime/python/okstra_ctl/report_html/filters.py +104 -0
- package/runtime/python/okstra_ctl/report_html/models.py +59 -0
- package/runtime/python/okstra_ctl/report_html/render.py +76 -0
- package/runtime/python/okstra_ctl/report_html/router.py +40 -0
- package/runtime/python/okstra_ctl/report_html/view_models/__init__.py +1 -0
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +39 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +49 -0
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +39 -0
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +47 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +47 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +103 -0
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +43 -0
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +54 -0
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +54 -0
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +55 -0
- package/runtime/python/okstra_ctl/report_html/visualizations.py +139 -0
- package/runtime/python/okstra_ctl/report_view_artifacts.py +4 -1
- package/runtime/python/okstra_ctl/report_views.py +15 -43
- package/runtime/python/okstra_ctl/run.py +276 -51
- package/runtime/python/okstra_ctl/runner_resolution.py +103 -0
- package/runtime/python/okstra_ctl/schema_excerpt.py +7 -17
- package/runtime/python/okstra_ctl/team.py +2 -7
- package/runtime/python/okstra_ctl/wizard.py +194 -21
- package/runtime/python/okstra_ctl/worker_artifacts.py +46 -0
- package/runtime/python/okstra_ctl/workers.py +3 -1
- package/runtime/python/okstra_ctl/workflow.py +4 -2
- package/runtime/python/okstra_token_usage/__init__.py +1 -0
- package/runtime/python/okstra_token_usage/collect.py +32 -23
- package/runtime/python/okstra_token_usage/pricing.py +35 -3
- package/runtime/schemas/final-report-v2.0.schema.json +3923 -0
- package/runtime/skills/okstra-run/SKILL.md +31 -42
- package/runtime/templates/prd/pr-body.template.md +1 -1
- package/runtime/templates/reports/final-report-v2.template.md +66 -0
- package/runtime/templates/reports/html/assets/base.css +41 -0
- package/runtime/templates/reports/html/assets/base.js +5 -0
- package/runtime/templates/reports/html/base.template.html +79 -0
- package/runtime/templates/reports/html/macros/forms.html +47 -0
- package/runtime/templates/reports/html/macros/layout.html +19 -0
- package/runtime/templates/reports/html/macros/visualizations.html +27 -0
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +40 -0
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +40 -0
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +40 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +39 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +47 -0
- package/runtime/templates/reports/html/tasks/implementation.template.html +40 -0
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +29 -0
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +57 -0
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +36 -0
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +37 -0
- package/runtime/templates/reports/report.js +21 -4
- package/runtime/templates/reports/settings.template.json +4 -0
- package/runtime/templates/reports/task-brief.template.md +7 -7
- package/runtime/validators/validate-report-views.py +86 -4
- package/runtime/validators/validate-run.py +73 -15
- package/runtime/validators/validate_improvement_report.py +55 -0
- package/runtime/validators/validate_session_conformance.py +2 -1
- package/src/cli-registry.mjs +4 -4
- package/src/commands/execute/codex-dispatch.mjs +7 -10
- package/src/commands/execute/render-bundle.mjs +3 -3
- package/src/commands/execute/run.mjs +17 -52
- package/src/commands/execute/wizard.mjs +4 -1
- package/src/commands/lifecycle/doctor.mjs +6 -3
- package/src/commands/lifecycle/install.mjs +49 -21
- package/src/commands/report/finalize.mjs +2 -3
- package/src/commands/report/render-final-report.mjs +4 -2
- package/src/commands/report/render-views.mjs +8 -8
- package/src/lib/runtime-manifest.mjs +1 -1
- package/src/lib/runtime-resolver.mjs +2 -2
- 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``.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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 =
|
|
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
|
-
|
|
642
|
-
|
|
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
|
|
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
|
|
@@ -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]
|