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.
- package/README.md +2 -2
- package/docs/architecture/storage-model.md +8 -7
- package/docs/architecture.md +18 -12
- package/docs/cli.md +3 -3
- package/docs/project-structure-overview.md +16 -14
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +10 -10
- package/runtime/bin/okstra-render-final-report.py +4 -4
- package/runtime/bin/okstra-render-report-views.py +100 -12
- package/runtime/bin/okstra-trace-cleanup.sh +13 -9
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
- package/runtime/prompts/lead/report-writer.md +15 -11
- package/runtime/prompts/profiles/_common-contract.md +13 -7
- package/runtime/prompts/profiles/improvement-discovery.md +3 -1
- package/runtime/python/okstra_ctl/final_report_schema.py +37 -12
- 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 +50 -0
- package/runtime/python/okstra_ctl/report_html/models.py +59 -0
- package/runtime/python/okstra_ctl/report_html/render.py +69 -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 +38 -0
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +48 -0
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +38 -0
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +46 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +46 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +102 -0
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +42 -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 +53 -0
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +54 -0
- package/runtime/python/okstra_ctl/report_html/visualizations.py +113 -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 +12 -6
- package/runtime/python/okstra_ctl/schema_excerpt.py +7 -17
- package/runtime/schemas/final-report-v2.0.schema.json +3923 -0
- package/runtime/templates/reports/final-report-v2.template.md +66 -0
- package/runtime/templates/reports/html/assets/base.css +38 -0
- package/runtime/templates/reports/html/assets/base.js +5 -0
- package/runtime/templates/reports/html/base.template.html +65 -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 +17 -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/validators/validate-report-views.py +86 -4
- package/runtime/validators/validate-run.py +62 -9
- package/runtime/validators/validate_improvement_report.py +55 -0
- package/src/commands/lifecycle/install.mjs +18 -13
- 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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Human-first error-analysis view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..common import audit_context
|
|
5
|
+
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
|
+
from ..visualizations import cause_graph_figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _cause_figure(error: dict):
|
|
10
|
+
symptom = VisualNode(
|
|
11
|
+
"symptom", "Observed failure", "effect", "risk", error["observableFailure"]
|
|
12
|
+
)
|
|
13
|
+
causes = tuple(
|
|
14
|
+
VisualNode(
|
|
15
|
+
row["id"],
|
|
16
|
+
row["statement"],
|
|
17
|
+
"candidate",
|
|
18
|
+
row["confidence"],
|
|
19
|
+
row["disproveWith"],
|
|
20
|
+
)
|
|
21
|
+
for row in error.get("causeCandidates", [])
|
|
22
|
+
)
|
|
23
|
+
edges = tuple(
|
|
24
|
+
VisualEdge(row.id, symptom.id, "may cause", "hypothesis") for row in causes
|
|
25
|
+
)
|
|
26
|
+
return cause_graph_figure(
|
|
27
|
+
nodes=(symptom, *causes),
|
|
28
|
+
edges=edges,
|
|
29
|
+
title="Cause hypotheses and observed symptom",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_error_analysis_view(data: dict) -> HumanReportView:
|
|
34
|
+
error = data["errorAnalysis"]
|
|
35
|
+
figure = _cause_figure(error)
|
|
36
|
+
context = {
|
|
37
|
+
"humanSummary": data["humanSummary"],
|
|
38
|
+
"error": error,
|
|
39
|
+
"narrative": error["userNarrative"],
|
|
40
|
+
"causeFigure": figure,
|
|
41
|
+
"audit": audit_context(data),
|
|
42
|
+
}
|
|
43
|
+
return HumanReportView(
|
|
44
|
+
"error-analysis",
|
|
45
|
+
"html/tasks/error-analysis.template.html",
|
|
46
|
+
context,
|
|
47
|
+
(figure,),
|
|
48
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Human-first feature-analysis view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..common import analysis_review_ids, audit_context
|
|
5
|
+
from ..models import HumanReportView, VisualNode
|
|
6
|
+
from ..visualizations import flow_figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _flow_nodes(feature: dict) -> tuple[VisualNode, ...]:
|
|
10
|
+
return tuple(
|
|
11
|
+
VisualNode(
|
|
12
|
+
id=row["id"],
|
|
13
|
+
label=row["kind"].title(),
|
|
14
|
+
group=row["kind"],
|
|
15
|
+
status="risk" if row["kind"] == "failure" else "stable",
|
|
16
|
+
detail=" → ".join(step["action"] for step in row.get("steps", [])),
|
|
17
|
+
)
|
|
18
|
+
for row in feature.get("flows", [])
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_feature_analysis_view(data: dict) -> HumanReportView:
|
|
23
|
+
feature = data["featureAnalysis"]
|
|
24
|
+
figure = flow_figure(nodes=_flow_nodes(feature), edges=(), title="Feature behavior paths")
|
|
25
|
+
context = {
|
|
26
|
+
"humanSummary": data["humanSummary"],
|
|
27
|
+
"feature": feature,
|
|
28
|
+
"narrative": feature["userNarrative"],
|
|
29
|
+
"flowFigure": figure,
|
|
30
|
+
"analysisReviewIds": analysis_review_ids(data),
|
|
31
|
+
"audit": audit_context(data),
|
|
32
|
+
}
|
|
33
|
+
return HumanReportView(
|
|
34
|
+
task_type="feature-analysis",
|
|
35
|
+
template_name="html/tasks/feature-analysis.template.html",
|
|
36
|
+
context=context,
|
|
37
|
+
figures=(figure,),
|
|
38
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Human-first final-verification view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..common import audit_context
|
|
5
|
+
from ..models import HumanReportView, VisualNode
|
|
6
|
+
from ..visualizations import coverage_figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _coverage_nodes(final: dict) -> tuple[VisualNode, ...]:
|
|
10
|
+
return tuple(
|
|
11
|
+
VisualNode(
|
|
12
|
+
row["id"],
|
|
13
|
+
row["requirement"],
|
|
14
|
+
"requirement",
|
|
15
|
+
row["status"],
|
|
16
|
+
row["artifact"],
|
|
17
|
+
)
|
|
18
|
+
for row in final["validationEvidence"]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_final_verification_view(data: dict) -> HumanReportView:
|
|
23
|
+
final = data["finalVerification"]
|
|
24
|
+
figure = coverage_figure(
|
|
25
|
+
rows=_coverage_nodes(final), title="Requirement verification coverage"
|
|
26
|
+
)
|
|
27
|
+
audit = audit_context(data)
|
|
28
|
+
audit["deliveryEvidence"] = {
|
|
29
|
+
"sourceImplementation": final["sourceImplementationReport"],
|
|
30
|
+
"readonlyCommandLog": final["readonlyCommandLog"],
|
|
31
|
+
}
|
|
32
|
+
context = {
|
|
33
|
+
"humanSummary": data["humanSummary"],
|
|
34
|
+
"verdict": data["verdictCard"],
|
|
35
|
+
"final": final,
|
|
36
|
+
"narrative": final["userNarrative"],
|
|
37
|
+
"coverageFigure": figure,
|
|
38
|
+
"releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
|
|
39
|
+
"audit": audit,
|
|
40
|
+
}
|
|
41
|
+
return HumanReportView(
|
|
42
|
+
"final-verification",
|
|
43
|
+
"html/tasks/final-verification.template.html",
|
|
44
|
+
context,
|
|
45
|
+
(figure,),
|
|
46
|
+
)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Human-first implementation delivery view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..common import audit_context
|
|
5
|
+
from ..models import HumanReportView, VisualNode
|
|
6
|
+
from ..visualizations import change_map_figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
|
|
10
|
+
return tuple(
|
|
11
|
+
VisualNode(
|
|
12
|
+
row["file"],
|
|
13
|
+
row["file"],
|
|
14
|
+
row["planStep"],
|
|
15
|
+
row["action"],
|
|
16
|
+
row["lines"],
|
|
17
|
+
)
|
|
18
|
+
for row in implementation["diffSummary"]["files"]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_implementation_view(data: dict) -> HumanReportView:
|
|
23
|
+
implementation = data["implementation"]
|
|
24
|
+
figure = change_map_figure(
|
|
25
|
+
nodes=_change_nodes(implementation), title="Delivered change areas"
|
|
26
|
+
)
|
|
27
|
+
audit = audit_context(data)
|
|
28
|
+
audit["deliveryEvidence"] = {
|
|
29
|
+
"commits": implementation["commitList"],
|
|
30
|
+
"rawDiffStat": implementation["diffSummary"]["rawStat"],
|
|
31
|
+
"verifiers": implementation["verifierResults"],
|
|
32
|
+
"validationCommands": implementation["validationEvidence"],
|
|
33
|
+
}
|
|
34
|
+
context = {
|
|
35
|
+
"humanSummary": data["humanSummary"],
|
|
36
|
+
"implementation": implementation,
|
|
37
|
+
"narrative": implementation["userNarrative"],
|
|
38
|
+
"changeFigure": figure,
|
|
39
|
+
"audit": audit,
|
|
40
|
+
}
|
|
41
|
+
return HumanReportView(
|
|
42
|
+
"implementation",
|
|
43
|
+
"html/tasks/implementation.template.html",
|
|
44
|
+
context,
|
|
45
|
+
(figure,),
|
|
46
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Human-first implementation-planning view and approval state."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from ..common import audit_context
|
|
8
|
+
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
9
|
+
from ..visualizations import stage_map_figure
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class PlanApprovalState:
|
|
14
|
+
option_names: tuple[str, ...]
|
|
15
|
+
recommended_option: str
|
|
16
|
+
disabled_reason: str
|
|
17
|
+
blocker_ids: tuple[str, ...]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_recommended_option(rec_name: str, names: tuple[str, ...]) -> str:
|
|
21
|
+
if rec_name in names:
|
|
22
|
+
return rec_name
|
|
23
|
+
matches = [
|
|
24
|
+
name
|
|
25
|
+
for name in names
|
|
26
|
+
if rec_name and (name.startswith(rec_name) or rec_name.startswith(name))
|
|
27
|
+
]
|
|
28
|
+
return matches[0] if len(matches) == 1 else names[0]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def plan_approval_state(data: dict) -> PlanApprovalState | None:
|
|
32
|
+
planning = data.get("implementationPlanning")
|
|
33
|
+
if not isinstance(planning, dict):
|
|
34
|
+
return None
|
|
35
|
+
names = tuple(
|
|
36
|
+
row["name"]
|
|
37
|
+
for row in planning.get("optionCandidates", [])
|
|
38
|
+
if isinstance(row, dict) and row.get("name")
|
|
39
|
+
)
|
|
40
|
+
if not names:
|
|
41
|
+
return None
|
|
42
|
+
recommended = planning.get("recommendedOption", {}).get("name", "")
|
|
43
|
+
blockers = tuple(
|
|
44
|
+
row["id"]
|
|
45
|
+
for row in data.get("clarificationItems", [])
|
|
46
|
+
if row.get("blocks") == "approval"
|
|
47
|
+
and row.get("status") in {"open", "answered"}
|
|
48
|
+
)
|
|
49
|
+
reason = f"승인 차단 항목 {len(blockers)}건 미해소" if blockers else ""
|
|
50
|
+
return PlanApprovalState(
|
|
51
|
+
names,
|
|
52
|
+
resolve_recommended_option(recommended, names),
|
|
53
|
+
reason,
|
|
54
|
+
blockers,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _stage_figure(planning: dict):
|
|
59
|
+
nodes = tuple(
|
|
60
|
+
VisualNode(
|
|
61
|
+
f"stage-{row['stage']}",
|
|
62
|
+
f"Stage {row['stage']} · {row['title']}",
|
|
63
|
+
"stage",
|
|
64
|
+
"planned",
|
|
65
|
+
row["exitContractSummary"],
|
|
66
|
+
)
|
|
67
|
+
for row in planning["stageMap"]
|
|
68
|
+
)
|
|
69
|
+
edges = tuple(
|
|
70
|
+
VisualEdge(f"stage-{dependency}", f"stage-{row['stage']}", "precedes", "dependency")
|
|
71
|
+
for row in planning["stageMap"]
|
|
72
|
+
for dependency in re.findall(r"\d+", row["dependsOn"])
|
|
73
|
+
)
|
|
74
|
+
return stage_map_figure(nodes=nodes, edges=edges, title="Implementation stage dependencies")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_implementation_planning_view(data: dict) -> HumanReportView:
|
|
78
|
+
planning = data["implementationPlanning"]
|
|
79
|
+
figure = _stage_figure(planning)
|
|
80
|
+
approval = plan_approval_state(data)
|
|
81
|
+
audit = audit_context(data)
|
|
82
|
+
audit["planBodyVerification"] = planning["planBodyVerification"]
|
|
83
|
+
context = {
|
|
84
|
+
"humanSummary": data["humanSummary"],
|
|
85
|
+
"planning": planning,
|
|
86
|
+
"narrative": planning["userNarrative"],
|
|
87
|
+
"stageFigure": figure,
|
|
88
|
+
"approval": approval,
|
|
89
|
+
"openDecisions": [
|
|
90
|
+
row
|
|
91
|
+
for row in data.get("clarificationItems", [])
|
|
92
|
+
if row.get("blocks") == "approval"
|
|
93
|
+
and row.get("status") in {"open", "answered"}
|
|
94
|
+
],
|
|
95
|
+
"audit": audit,
|
|
96
|
+
}
|
|
97
|
+
return HumanReportView(
|
|
98
|
+
"implementation-planning",
|
|
99
|
+
"html/tasks/implementation-planning.template.html",
|
|
100
|
+
context,
|
|
101
|
+
(figure,),
|
|
102
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Human-first improvement-discovery view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..common import audit_context
|
|
5
|
+
from ..models import HumanReportView, VisualNode
|
|
6
|
+
from ..visualizations import matrix_figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _candidate_nodes(improvement: dict) -> tuple[VisualNode, ...]:
|
|
10
|
+
return tuple(
|
|
11
|
+
VisualNode(
|
|
12
|
+
row["id"],
|
|
13
|
+
row["title"],
|
|
14
|
+
f"effort-{row['effort']}",
|
|
15
|
+
row["severity"],
|
|
16
|
+
row["expectedBehaviorAfter"],
|
|
17
|
+
)
|
|
18
|
+
for row in improvement.get("candidates", [])
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_improvement_discovery_view(data: dict) -> HumanReportView:
|
|
23
|
+
improvement = data["improvementDiscovery"]
|
|
24
|
+
figure = matrix_figure(
|
|
25
|
+
items=_candidate_nodes(improvement),
|
|
26
|
+
title="Candidate impact and effort",
|
|
27
|
+
x_label="effort",
|
|
28
|
+
y_label="severity",
|
|
29
|
+
)
|
|
30
|
+
context = {
|
|
31
|
+
"humanSummary": data["humanSummary"],
|
|
32
|
+
"improvement": improvement,
|
|
33
|
+
"narrative": improvement["userNarrative"],
|
|
34
|
+
"matrixFigure": figure,
|
|
35
|
+
"audit": audit_context(data),
|
|
36
|
+
}
|
|
37
|
+
return HumanReportView(
|
|
38
|
+
"improvement-discovery",
|
|
39
|
+
"html/tasks/improvement-discovery.template.html",
|
|
40
|
+
context,
|
|
41
|
+
(figure,),
|
|
42
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Human-first project-analysis view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..common import audit_context, evidence_index
|
|
5
|
+
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
|
+
from ..visualizations import dependency_figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
|
|
10
|
+
return tuple(
|
|
11
|
+
VisualNode(
|
|
12
|
+
id=row["id"],
|
|
13
|
+
label=row["name"],
|
|
14
|
+
group=(row.get("paths") or ["project"])[0].split("/", 1)[0],
|
|
15
|
+
status="stable",
|
|
16
|
+
detail=row["responsibility"],
|
|
17
|
+
)
|
|
18
|
+
for row in project.get("components", [])
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _dependency_edges(project: dict) -> tuple[VisualEdge, ...]:
|
|
23
|
+
return tuple(
|
|
24
|
+
VisualEdge(
|
|
25
|
+
source=row["fromComponentId"],
|
|
26
|
+
target=row["toComponentId"],
|
|
27
|
+
label=row.get("direction", "dependency"),
|
|
28
|
+
kind="dependency",
|
|
29
|
+
)
|
|
30
|
+
for row in project.get("dependencies", [])
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_project_analysis_view(data: dict) -> HumanReportView:
|
|
35
|
+
project = data["projectAnalysis"]
|
|
36
|
+
architecture = dependency_figure(
|
|
37
|
+
nodes=_component_nodes(project),
|
|
38
|
+
edges=_dependency_edges(project),
|
|
39
|
+
title="Project architecture and dependencies",
|
|
40
|
+
)
|
|
41
|
+
context = {
|
|
42
|
+
"humanSummary": data["humanSummary"],
|
|
43
|
+
"project": project,
|
|
44
|
+
"narrative": project["userNarrative"],
|
|
45
|
+
"architectureFigure": architecture,
|
|
46
|
+
"evidenceIndex": evidence_index(data),
|
|
47
|
+
"audit": audit_context(data),
|
|
48
|
+
}
|
|
49
|
+
return HumanReportView(
|
|
50
|
+
task_type="project-analysis",
|
|
51
|
+
template_name="html/tasks/project-analysis.template.html",
|
|
52
|
+
context=context,
|
|
53
|
+
figures=(architecture,),
|
|
54
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Human-first release-handoff view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from ..common import audit_context
|
|
7
|
+
from ..models import HumanReportView, VisualNode
|
|
8
|
+
from ..visualizations import timeline_figure
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _safe_http_url(value: str) -> str:
|
|
12
|
+
parsed = urlparse(value)
|
|
13
|
+
return value if parsed.scheme in {"http", "https"} and parsed.netloc else ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _timeline_nodes(handoff: dict) -> tuple[VisualNode, ...]:
|
|
17
|
+
selection = handoff["userSelections"]["h1"]
|
|
18
|
+
branch = handoff["featureBranchState"]["branchName"]
|
|
19
|
+
conflict = handoff["mergeConflictProbe"]["kind"]
|
|
20
|
+
pr = handoff["pullRequestOutcome"]["kind"]
|
|
21
|
+
return (
|
|
22
|
+
VisualNode("selection", selection, "handoff", "completed", "User-selected handoff"),
|
|
23
|
+
VisualNode("branch", branch, "handoff", "completed", "Feature branch state"),
|
|
24
|
+
VisualNode("conflict", conflict, "handoff", conflict, "Merge conflict probe"),
|
|
25
|
+
VisualNode("pull-request", pr, "handoff", pr, "Pull request outcome"),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_release_handoff_view(data: dict) -> HumanReportView:
|
|
30
|
+
handoff = data["releaseHandoff"]
|
|
31
|
+
figure = timeline_figure(
|
|
32
|
+
events=_timeline_nodes(handoff), title="Release handoff timeline"
|
|
33
|
+
)
|
|
34
|
+
audit = audit_context(data)
|
|
35
|
+
audit["deliveryEvidence"] = {
|
|
36
|
+
"executedCommands": handoff["executedCommands"],
|
|
37
|
+
"commits": handoff["commitList"],
|
|
38
|
+
"sourceVerification": handoff["sourceVerificationReport"],
|
|
39
|
+
}
|
|
40
|
+
context = {
|
|
41
|
+
"humanSummary": data["humanSummary"],
|
|
42
|
+
"handoff": handoff,
|
|
43
|
+
"narrative": handoff["userNarrative"],
|
|
44
|
+
"handoffFigure": figure,
|
|
45
|
+
"prUrl": _safe_http_url(handoff["pullRequestOutcome"].get("url", "")),
|
|
46
|
+
"audit": audit,
|
|
47
|
+
}
|
|
48
|
+
return HumanReportView(
|
|
49
|
+
"release-handoff",
|
|
50
|
+
"html/tasks/release-handoff.template.html",
|
|
51
|
+
context,
|
|
52
|
+
(figure,),
|
|
53
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Human-first requirements-discovery view model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ..common import audit_context
|
|
5
|
+
from ..models import HumanReportView, VisualEdge, VisualNode
|
|
6
|
+
from ..visualizations import decision_flow_figure
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _routing_figure(data: dict, requirements: dict):
|
|
10
|
+
current = data["header"]["taskKey"]
|
|
11
|
+
nodes = {
|
|
12
|
+
current: VisualNode(
|
|
13
|
+
current,
|
|
14
|
+
"Current requirements task",
|
|
15
|
+
"current",
|
|
16
|
+
"active",
|
|
17
|
+
requirements["systemInterpretation"],
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
edges = []
|
|
21
|
+
for row in requirements.get("taskDependencies", []):
|
|
22
|
+
source = row["fromTaskKey"]
|
|
23
|
+
target = row["toTaskKey"]
|
|
24
|
+
nodes.setdefault(
|
|
25
|
+
source, VisualNode(source, source, "current", "active", row["relation"])
|
|
26
|
+
)
|
|
27
|
+
nodes.setdefault(
|
|
28
|
+
target, VisualNode(target, target, "next", "pending", row["relation"])
|
|
29
|
+
)
|
|
30
|
+
edges.append(VisualEdge(source, target, row["relation"], "dependency"))
|
|
31
|
+
return decision_flow_figure(
|
|
32
|
+
nodes=tuple(nodes.values()),
|
|
33
|
+
edges=tuple(edges),
|
|
34
|
+
title="Requirement routing and task dependencies",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_requirements_discovery_view(data: dict) -> HumanReportView:
|
|
39
|
+
requirements = data["requirementsDiscovery"]
|
|
40
|
+
figure = _routing_figure(data, requirements)
|
|
41
|
+
context = {
|
|
42
|
+
"humanSummary": data["humanSummary"],
|
|
43
|
+
"requirements": requirements,
|
|
44
|
+
"narrative": requirements["userNarrative"],
|
|
45
|
+
"routingFigure": figure,
|
|
46
|
+
"clarificationItems": data.get("clarificationItems", []),
|
|
47
|
+
"audit": audit_context(data),
|
|
48
|
+
}
|
|
49
|
+
return HumanReportView(
|
|
50
|
+
"requirements-discovery",
|
|
51
|
+
"html/tasks/requirements-discovery.template.html",
|
|
52
|
+
context,
|
|
53
|
+
(figure,),
|
|
54
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Deterministic inline-SVG builders with table-compatible node IDs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import html
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from typing import Iterable, Sequence
|
|
7
|
+
|
|
8
|
+
from .models import FigureModel, VisualEdge, VisualNode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _node_positions(nodes: Sequence[VisualNode]) -> dict[str, tuple[int, int]]:
|
|
12
|
+
groups = {group: index for index, group in enumerate(sorted({n.group for n in nodes}))}
|
|
13
|
+
rows: dict[str, int] = {}
|
|
14
|
+
positions: dict[str, tuple[int, int]] = {}
|
|
15
|
+
for node in nodes:
|
|
16
|
+
row = rows.get(node.group, 0)
|
|
17
|
+
rows[node.group] = row + 1
|
|
18
|
+
positions[node.id] = (50 + groups[node.group] * 260, 55 + row * 100)
|
|
19
|
+
return positions
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
|
|
23
|
+
positions = _node_positions(nodes)
|
|
24
|
+
width = max((x for x, _ in positions.values()), default=50) + 230
|
|
25
|
+
height = max((y for _, y in positions.values()), default=55) + 90
|
|
26
|
+
parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
|
|
27
|
+
for edge in edges:
|
|
28
|
+
if edge.source not in positions or edge.target not in positions:
|
|
29
|
+
continue
|
|
30
|
+
x1, y1 = positions[edge.source]
|
|
31
|
+
x2, y2 = positions[edge.target]
|
|
32
|
+
parts.append(f'<line x1="{x1 + 90}" y1="{y1 + 25}" x2="{x2 + 90}" y2="{y2 + 25}" class="edge edge-{html.escape(edge.kind)}" />')
|
|
33
|
+
for node in nodes:
|
|
34
|
+
x, y = positions[node.id]
|
|
35
|
+
node_id = html.escape(node.id)
|
|
36
|
+
label = html.escape(node.label)
|
|
37
|
+
status = html.escape(node.status)
|
|
38
|
+
detail = html.escape(node.detail)
|
|
39
|
+
parts.append(
|
|
40
|
+
f'<g data-node-id="{node_id}" class="node node-{status}">'
|
|
41
|
+
f"<title>{label}: {status}. {detail}</title>"
|
|
42
|
+
f'<rect x="{x}" y="{y}" width="180" height="50" rx="8"/>'
|
|
43
|
+
f'<text x="{x + 12}" y="{y + 30}">{label}</text></g>'
|
|
44
|
+
)
|
|
45
|
+
parts.append("</svg>")
|
|
46
|
+
return "".join(parts)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def dependency_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
50
|
+
ordered_nodes = tuple(sorted(nodes, key=lambda node: node.id))
|
|
51
|
+
ordered_edges = tuple(sorted(edges, key=lambda edge: (edge.source, edge.target, edge.label)))
|
|
52
|
+
return FigureModel(
|
|
53
|
+
figure_id="dependency-graph",
|
|
54
|
+
kind="dependency",
|
|
55
|
+
title=title,
|
|
56
|
+
summary=f"{len(ordered_nodes)} nodes and {len(ordered_edges)} relationships",
|
|
57
|
+
nodes=ordered_nodes,
|
|
58
|
+
edges=ordered_edges,
|
|
59
|
+
svg=_svg_document(ordered_nodes, ordered_edges),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def flow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
64
|
+
return replace(dependency_figure(nodes=nodes, edges=edges, title=title), kind="flow", figure_id="flow-graph")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decision_flow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
68
|
+
return replace(
|
|
69
|
+
dependency_figure(nodes=nodes, edges=edges, title=title),
|
|
70
|
+
kind="decision-flow",
|
|
71
|
+
figure_id="decision-flow",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cause_graph_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
76
|
+
return replace(
|
|
77
|
+
dependency_figure(nodes=nodes, edges=edges, title=title),
|
|
78
|
+
kind="cause-graph",
|
|
79
|
+
figure_id="cause-graph",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stage_map_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
|
|
84
|
+
return replace(
|
|
85
|
+
dependency_figure(nodes=nodes, edges=edges, title=title),
|
|
86
|
+
kind="stage-map",
|
|
87
|
+
figure_id="stage-map",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def change_map_figure(*, nodes: Sequence[VisualNode], title: str) -> FigureModel:
|
|
92
|
+
return replace(
|
|
93
|
+
dependency_figure(nodes=nodes, edges=(), title=title),
|
|
94
|
+
kind="change-map",
|
|
95
|
+
figure_id="change-map",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def matrix_figure(*, items: Iterable[VisualNode], title: str, x_label: str, y_label: str) -> FigureModel:
|
|
100
|
+
nodes = tuple(items)
|
|
101
|
+
summary = f"{len(nodes)} items compared by {x_label} and {y_label}"
|
|
102
|
+
return replace(dependency_figure(nodes=nodes, edges=(), title=title), kind="matrix", figure_id="matrix", summary=summary)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def coverage_figure(*, rows: Iterable[VisualNode], title: str) -> FigureModel:
|
|
106
|
+
nodes = tuple(rows)
|
|
107
|
+
return replace(dependency_figure(nodes=nodes, edges=(), title=title), kind="coverage", figure_id="coverage")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def timeline_figure(*, events: Iterable[VisualNode], title: str) -> FigureModel:
|
|
111
|
+
nodes = tuple(events)
|
|
112
|
+
edges = tuple(VisualEdge(nodes[i].id, nodes[i + 1].id, "next", "sequence") for i in range(len(nodes) - 1))
|
|
113
|
+
return replace(dependency_figure(nodes=nodes, edges=edges, title=title), kind="timeline", figure_id="timeline")
|
|
@@ -5,7 +5,10 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def html_view_path(report_path: Path) -> Path:
|
|
8
|
-
"""Return the
|
|
8
|
+
"""Return the HTML sibling for a final-report Markdown or data path."""
|
|
9
|
+
if report_path.name.endswith(".data.json"):
|
|
10
|
+
stem = report_path.name.removesuffix(".data.json")
|
|
11
|
+
return report_path.with_name(stem + ".html")
|
|
9
12
|
return report_path.with_suffix(".html")
|
|
10
13
|
|
|
11
14
|
|