okstra 0.148.0 → 0.149.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/bin/okstra-render-report-views.py +20 -0
  4. package/runtime/prompts/profiles/project-analysis.md +18 -0
  5. package/runtime/prompts/profiles/release-handoff.md +3 -0
  6. package/runtime/prompts/profiles/requirements-discovery.md +7 -0
  7. package/runtime/python/okstra_ctl/report_html/common.py +77 -47
  8. package/runtime/python/okstra_ctl/report_html/filters.py +125 -30
  9. package/runtime/python/okstra_ctl/report_html/models.py +15 -0
  10. package/runtime/python/okstra_ctl/report_html/render.py +49 -5
  11. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +14 -4
  12. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +3 -3
  13. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +5 -3
  14. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -7
  15. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +5 -9
  16. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -5
  17. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +1 -2
  18. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +90 -4
  19. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +1 -8
  20. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +6 -4
  21. package/runtime/python/okstra_ctl/report_html/visualizations.py +146 -11
  22. package/runtime/python/okstra_ctl/report_view_artifacts.py +5 -0
  23. package/runtime/python/okstra_ctl/time_report.py +2 -2
  24. package/runtime/python/okstra_ctl/usage_report.py +2 -2
  25. package/runtime/schemas/final-report-v2.0.schema.json +229 -1
  26. package/runtime/templates/reports/final-report.template.md +55 -0
  27. package/runtime/templates/reports/html/assets/base.css +64 -8
  28. package/runtime/templates/reports/html/base.template.html +21 -41
  29. package/runtime/templates/reports/html/macros/forms.html +24 -22
  30. package/runtime/templates/reports/html/macros/layout.html +18 -5
  31. package/runtime/templates/reports/html/macros/visualizations.html +7 -5
  32. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +30 -15
  33. package/runtime/templates/reports/html/tasks/error-analysis.template.html +22 -15
  34. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +35 -15
  35. package/runtime/templates/reports/html/tasks/final-verification.template.html +21 -14
  36. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +78 -19
  37. package/runtime/templates/reports/html/tasks/implementation.template.html +34 -16
  38. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +11 -11
  39. package/runtime/templates/reports/html/tasks/project-analysis.template.html +65 -25
  40. package/runtime/templates/reports/html/tasks/release-handoff.template.html +28 -14
  41. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +35 -15
  42. package/runtime/templates/reports/report.js +7 -1
  43. package/runtime/validators/validate_analysis_report.py +36 -0
@@ -1,16 +1,19 @@
1
1
  """Human-first feature-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import analysis_review_ids, audit_context, evidence_index
4
+ from ..common import analysis_review_ids, evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import flow_figure
7
7
 
8
8
 
9
+ _FLOW_KINDS = {"normal": "Normal path", "alternative": "Alternative path", "failure": "Failure path"}
10
+
11
+
9
12
  def _flow_nodes(feature: dict) -> tuple[VisualNode, ...]:
10
13
  return tuple(
11
14
  VisualNode(
12
15
  id=row["id"],
13
- label=row["kind"].title(),
16
+ label=_FLOW_KINDS.get(row["kind"], row["kind"]),
14
17
  group=row["kind"],
15
18
  status="risk" if row["kind"] == "failure" else "stable",
16
19
  detail=" → ".join(step["action"] for step in row.get("steps", [])),
@@ -29,7 +32,6 @@ def build_feature_analysis_view(data: dict) -> HumanReportView:
29
32
  "flowFigure": figure,
30
33
  "analysisReviewIds": analysis_review_ids(data),
31
34
  "evidenceIndex": evidence_index(data),
32
- "audit": audit_context(data),
33
35
  }
34
36
  return HumanReportView(
35
37
  task_type="feature-analysis",
@@ -1,7 +1,7 @@
1
1
  """Human-first final-verification view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import coverage_figure
7
7
 
@@ -14,6 +14,7 @@ def _coverage_nodes(final: dict) -> tuple[VisualNode, ...]:
14
14
  "requirement",
15
15
  row["status"],
16
16
  row["artifact"],
17
+ note=row["status"],
17
18
  )
18
19
  for row in final["validationEvidence"]
19
20
  )
@@ -24,11 +25,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
24
25
  figure = coverage_figure(
25
26
  rows=_coverage_nodes(final), title="Requirement verification coverage"
26
27
  )
27
- audit = audit_context(data)
28
- audit["deliveryEvidence"] = {
29
- "sourceImplementation": final["sourceImplementationReport"],
30
- "readonlyCommandLog": final["readonlyCommandLog"],
31
- }
32
28
  context = {
33
29
  "humanSummary": data["humanSummary"],
34
30
  "verdict": data["verdictCard"],
@@ -37,7 +33,6 @@ def build_final_verification_view(data: dict) -> HumanReportView:
37
33
  "coverageFigure": figure,
38
34
  "releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
39
35
  "evidenceIndex": evidence_index(data),
40
- "audit": audit,
41
36
  }
42
37
  return HumanReportView(
43
38
  "final-verification",
@@ -1,11 +1,14 @@
1
1
  """Human-first implementation delivery view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import change_map_figure
7
7
 
8
8
 
9
+ _FILE_ACTIONS = {"created": "Created", "modified": "Modified", "deleted": "Deleted"}
10
+
11
+
9
12
  def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
10
13
  return tuple(
11
14
  VisualNode(
@@ -14,6 +17,7 @@ def _change_nodes(implementation: dict) -> tuple[VisualNode, ...]:
14
17
  row["planStep"],
15
18
  row["action"],
16
19
  row["lines"],
20
+ note=f'{_FILE_ACTIONS.get(row["action"], row["action"])} · plan step {row["planStep"]}',
17
21
  )
18
22
  for row in implementation["diffSummary"]["files"]
19
23
  )
@@ -24,20 +28,12 @@ def build_implementation_view(data: dict) -> HumanReportView:
24
28
  figure = change_map_figure(
25
29
  nodes=_change_nodes(implementation), title="Delivered change areas"
26
30
  )
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
31
  context = {
35
32
  "humanSummary": data["humanSummary"],
36
33
  "implementation": implementation,
37
34
  "narrative": implementation["userNarrative"],
38
35
  "changeFigure": figure,
39
36
  "evidenceIndex": evidence_index(data),
40
- "audit": audit,
41
37
  }
42
38
  return HumanReportView(
43
39
  "implementation",
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
  import re
5
5
  from dataclasses import dataclass
6
6
 
7
- from ..common import audit_context, evidence_index
7
+ from ..common import evidence_index
8
8
  from ..models import HumanReportView, VisualEdge, VisualNode
9
9
  from ..visualizations import stage_map_figure
10
10
 
@@ -46,7 +46,7 @@ def plan_approval_state(data: dict) -> PlanApprovalState | None:
46
46
  if row.get("blocks") == "approval"
47
47
  and row.get("status") in {"open", "answered"}
48
48
  )
49
- reason = f"승인 차단 항목 {len(blockers)} 미해소" if blockers else ""
49
+ reason = f"{len(blockers)} approval blocker(s) unresolved" if blockers else ""
50
50
  return PlanApprovalState(
51
51
  names,
52
52
  resolve_recommended_option(recommended, names),
@@ -78,8 +78,6 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
78
78
  planning = data["implementationPlanning"]
79
79
  figure = _stage_figure(planning)
80
80
  approval = plan_approval_state(data)
81
- audit = audit_context(data)
82
- audit["planBodyVerification"] = planning["planBodyVerification"]
83
81
  context = {
84
82
  "humanSummary": data["humanSummary"],
85
83
  "planning": planning,
@@ -93,7 +91,6 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
93
91
  and row.get("status") in {"open", "answered"}
94
92
  ],
95
93
  "evidenceIndex": evidence_index(data),
96
- "audit": audit,
97
94
  }
98
95
  return HumanReportView(
99
96
  "implementation-planning",
@@ -1,7 +1,7 @@
1
1
  """Human-first improvement-discovery view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import matrix_figure
7
7
 
@@ -33,7 +33,6 @@ def build_improvement_discovery_view(data: dict) -> HumanReportView:
33
33
  "narrative": improvement["userNarrative"],
34
34
  "matrixFigure": figure,
35
35
  "evidenceIndex": evidence_index(data),
36
- "audit": audit_context(data),
37
36
  }
38
37
  return HumanReportView(
39
38
  "improvement-discovery",
@@ -1,9 +1,9 @@
1
1
  """Human-first project-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
- from ..visualizations import dependency_figure
6
+ from ..visualizations import dependency_figure, infrastructure_figure, workflow_figure
7
7
 
8
8
 
9
9
  def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
@@ -14,6 +14,7 @@ def _component_nodes(project: dict) -> tuple[VisualNode, ...]:
14
14
  group=(row.get("paths") or ["project"])[0].split("/", 1)[0],
15
15
  status="stable",
16
16
  detail=row["responsibility"],
17
+ paths=tuple(row.get("paths") or ()),
17
18
  )
18
19
  for row in project.get("components", [])
19
20
  )
@@ -31,6 +32,88 @@ def _dependency_edges(project: dict) -> tuple[VisualEdge, ...]:
31
32
  )
32
33
 
33
34
 
35
+ def _infrastructure_figure(project: dict):
36
+ """The components that hold a boundary, and what sits on the far side.
37
+
38
+ The dependency graph answers "what calls what inside the code". Where the
39
+ data lives and which outside service the code depends on is a different
40
+ question with different nodes, and reading it off a component graph means
41
+ reconstructing it from adapter paths.
42
+ """
43
+ components = {row["id"]: row for row in project.get("components", [])}
44
+ nodes: list[VisualNode] = []
45
+ edges: list[VisualEdge] = []
46
+ seen: set[str] = set()
47
+
48
+ def owner_node(component_id: str) -> str | None:
49
+ row = components.get(component_id)
50
+ if row is None:
51
+ return None
52
+ if row["id"] not in seen:
53
+ seen.add(row["id"])
54
+ nodes.append(
55
+ VisualNode(row["id"], row["name"], "component", "stable", row["responsibility"], note="Component")
56
+ )
57
+ return row["id"]
58
+
59
+ for index, row in enumerate(project.get("dataStores", []), start=1):
60
+ store_id = f"store-{index}"
61
+ nodes.append(
62
+ VisualNode(store_id, row["name"], "store", "store", row.get("readBoundary", ""), note="Data store")
63
+ )
64
+ source = owner_node(row.get("ownerComponentId", ""))
65
+ if source:
66
+ edges.append(VisualEdge(source, store_id, "reads · writes", "storage"))
67
+ for index, row in enumerate(project.get("externalSystems", []), start=1):
68
+ system_id = f"external-{index}"
69
+ nodes.append(
70
+ VisualNode(system_id, row["name"], "external", "external", row.get("direction", ""), note="External system")
71
+ )
72
+ edges.append(VisualEdge(row.get("adapter", "adapter"), system_id, row.get("direction", ""), "external"))
73
+ if row.get("adapter") and row["adapter"] not in seen:
74
+ seen.add(row["adapter"])
75
+ nodes.append(
76
+ VisualNode(row["adapter"], row["adapter"], "adapter", "stable", "Adapter", note="Adapter")
77
+ )
78
+ return infrastructure_figure(
79
+ nodes=tuple(nodes), edges=tuple(edges), title="Infrastructure and boundaries"
80
+ )
81
+
82
+
83
+ def _workflow_figure(project: dict):
84
+ """Each workflow's steps, as the path a request takes between components."""
85
+ components = {row["id"]: row for row in project.get("components", [])}
86
+ nodes: list[VisualNode] = []
87
+ edges: list[VisualEdge] = []
88
+ seen: set[str] = set()
89
+ for flow in project.get("workflows", []):
90
+ steps = sorted(flow.get("steps", []), key=lambda step: step.get("order", 0))
91
+ for step in steps:
92
+ component_id = step.get("componentId", "")
93
+ if component_id and component_id not in seen:
94
+ seen.add(component_id)
95
+ row = components.get(component_id, {})
96
+ nodes.append(
97
+ VisualNode(
98
+ component_id,
99
+ row.get("name", component_id),
100
+ "component",
101
+ "stable",
102
+ step.get("action", ""),
103
+ )
104
+ )
105
+ for first, second in zip(steps, steps[1:]):
106
+ edges.append(
107
+ VisualEdge(
108
+ first.get("componentId", ""),
109
+ second.get("componentId", ""),
110
+ second.get("action", "next"),
111
+ "workflow",
112
+ )
113
+ )
114
+ return workflow_figure(nodes=tuple(nodes), edges=tuple(edges), title="Workflows")
115
+
116
+
34
117
  def build_project_analysis_view(data: dict) -> HumanReportView:
35
118
  project = data["projectAnalysis"]
36
119
  architecture = dependency_figure(
@@ -38,17 +121,20 @@ def build_project_analysis_view(data: dict) -> HumanReportView:
38
121
  edges=_dependency_edges(project),
39
122
  title="Project architecture and dependencies",
40
123
  )
124
+ infrastructure = _infrastructure_figure(project)
125
+ workflows = _workflow_figure(project) if project.get("workflows") else None
41
126
  context = {
42
127
  "humanSummary": data["humanSummary"],
43
128
  "project": project,
129
+ "infrastructureFigure": infrastructure,
130
+ "workflowFigure": workflows,
44
131
  "narrative": project["userNarrative"],
45
132
  "architectureFigure": architecture,
46
133
  "evidenceIndex": evidence_index(data),
47
- "audit": audit_context(data),
48
134
  }
49
135
  return HumanReportView(
50
136
  task_type="project-analysis",
51
137
  template_name="html/tasks/project-analysis.template.html",
52
138
  context=context,
53
- figures=(architecture,),
139
+ figures=tuple(f for f in (architecture, infrastructure, workflows) if f),
54
140
  )
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  from urllib.parse import urlparse
5
5
 
6
- from ..common import audit_context, evidence_index
6
+ from ..common import evidence_index
7
7
  from ..models import HumanReportView, VisualNode
8
8
  from ..visualizations import timeline_figure
9
9
 
@@ -31,12 +31,6 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
31
31
  figure = timeline_figure(
32
32
  events=_timeline_nodes(handoff), title="Release handoff timeline"
33
33
  )
34
- audit = audit_context(data)
35
- audit["deliveryEvidence"] = {
36
- "executedCommands": handoff["executedCommands"],
37
- "commits": handoff["commitList"],
38
- "sourceVerification": handoff["sourceVerificationReport"],
39
- }
40
34
  context = {
41
35
  "humanSummary": data["humanSummary"],
42
36
  "handoff": handoff,
@@ -44,7 +38,6 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
44
38
  "handoffFigure": figure,
45
39
  "prUrl": _safe_http_url(handoff["pullRequestOutcome"].get("url", "")),
46
40
  "evidenceIndex": evidence_index(data),
47
- "audit": audit,
48
41
  }
49
42
  return HumanReportView(
50
43
  "release-handoff",
@@ -1,7 +1,7 @@
1
1
  """Human-first requirements-discovery view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context, evidence_index
4
+ from ..common import evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import decision_flow_figure
7
7
 
@@ -15,6 +15,7 @@ def _routing_figure(data: dict, requirements: dict):
15
15
  "current",
16
16
  "active",
17
17
  requirements["systemInterpretation"],
18
+ note="This task",
18
19
  )
19
20
  }
20
21
  edges = []
@@ -22,10 +23,12 @@ def _routing_figure(data: dict, requirements: dict):
22
23
  source = row["fromTaskKey"]
23
24
  target = row["toTaskKey"]
24
25
  nodes.setdefault(
25
- source, VisualNode(source, source, "current", "active", row["relation"])
26
+ source,
27
+ VisualNode(source, source, "current", "active", row["relation"], note="This task"),
26
28
  )
27
29
  nodes.setdefault(
28
- target, VisualNode(target, target, "next", "pending", row["relation"])
30
+ target,
31
+ VisualNode(target, target, "next", "pending", row["relation"], note="Next task"),
29
32
  )
30
33
  edges.append(VisualEdge(source, target, row["relation"], "dependency"))
31
34
  return decision_flow_figure(
@@ -45,7 +48,6 @@ def build_requirements_discovery_view(data: dict) -> HumanReportView:
45
48
  "routingFigure": figure,
46
49
  "clarificationItems": data.get("clarificationItems", []),
47
50
  "evidenceIndex": evidence_index(data),
48
- "audit": audit_context(data),
49
51
  }
50
52
  return HumanReportView(
51
53
  "requirements-discovery",
@@ -13,23 +13,124 @@ _COLUMN_WIDTH = 260
13
13
  _ROW_HEIGHT = 100
14
14
 
15
15
 
16
- def _node_positions(nodes: Sequence[VisualNode]) -> dict[str, tuple[int, int]]:
17
- """Lay out one column per group, wrapping tall groups into extra columns."""
18
- groups = sorted({n.group for n in nodes})
16
+ def _group_columns(nodes: Sequence[VisualNode]) -> list[list[str]]:
17
+ """One column per group, wrapping a tall group into extra columns.
18
+
19
+ This is the layout for a figure with no edges — a coverage or matrix view,
20
+ where the group is the only structure there is to show.
21
+ """
19
22
  counts: dict[str, int] = {}
20
23
  column_offset: dict[str, int] = {}
21
24
  offset = 0
22
- for group in groups:
25
+ for group in sorted({n.group for n in nodes}):
23
26
  column_offset[group] = offset
24
27
  size = sum(1 for n in nodes if n.group == group)
25
28
  offset += max(1, -(-size // _ROWS_PER_COLUMN))
26
- positions: dict[str, tuple[int, int]] = {}
29
+ columns: list[list[str]] = [[] for _ in range(max(offset, 1))]
27
30
  for node in nodes:
28
31
  index = counts.get(node.group, 0)
29
32
  counts[node.group] = index + 1
30
- column = column_offset[node.group] + index // _ROWS_PER_COLUMN
31
- row = index % _ROWS_PER_COLUMN
32
- positions[node.id] = (50 + column * _COLUMN_WIDTH, 55 + row * _ROW_HEIGHT)
33
+ columns[column_offset[node.group] + index // _ROWS_PER_COLUMN].append(node.id)
34
+ return columns
35
+
36
+
37
+ def _depths(node_ids: list[str], edges: Sequence[VisualEdge]) -> dict[str, int]:
38
+ """Longest path from a root, so a node sits past every one it depends on.
39
+
40
+ Relaxing until nothing moves settles the longest path; a cycle would relax
41
+ forever, so the pass count bounds it — a dependency graph that loops has no
42
+ correct layering anyway, and stopping leaves it readable rather than hung.
43
+ """
44
+ depth = {node_id: 0 for node_id in node_ids}
45
+ links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]
46
+ for _ in range(len(depth)):
47
+ settled = True
48
+ for source, target in links:
49
+ if depth[target] < depth[source] + 1:
50
+ depth[target] = depth[source] + 1
51
+ settled = False
52
+ if settled:
53
+ break
54
+ return depth
55
+
56
+
57
+ _ORDERING_SWEEPS = 4
58
+
59
+
60
+ def _ordered_by_neighbours(
61
+ column: list[str], neighbours: list[str], links: list[tuple[str, str]], *, backward: bool
62
+ ) -> list[str]:
63
+ """Sort a column to sit opposite the nodes it connects to.
64
+
65
+ One direction is not enough: ordering a column by its parents can pull it
66
+ out of line with its children, so the sweeps alternate.
67
+ """
68
+ rank = {node_id: index for index, node_id in enumerate(neighbours)}
69
+ original = {node_id: index for index, node_id in enumerate(column)}
70
+
71
+ def key(node_id: str) -> tuple[float, int]:
72
+ if backward:
73
+ related = [rank[t] for s, t in links if s == node_id and t in rank]
74
+ else:
75
+ related = [rank[s] for s, t in links if t == node_id and s in rank]
76
+ centre = sum(related) / len(related) if related else float(original[node_id])
77
+ return (centre, original[node_id])
78
+
79
+ return sorted(column, key=key)
80
+
81
+
82
+ def _uncross(columns: list[list[str]], links: list[tuple[str, str]]) -> list[list[str]]:
83
+ """Alternate forward and backward barycentre sweeps until they settle."""
84
+ for sweep in range(_ORDERING_SWEEPS):
85
+ before = [list(column) for column in columns]
86
+ if sweep % 2 == 0:
87
+ for index in range(1, len(columns)):
88
+ columns[index] = _ordered_by_neighbours(
89
+ columns[index], columns[index - 1], links, backward=False
90
+ )
91
+ else:
92
+ for index in range(len(columns) - 2, -1, -1):
93
+ columns[index] = _ordered_by_neighbours(
94
+ columns[index], columns[index + 1], links, backward=True
95
+ )
96
+ if columns == before:
97
+ break
98
+ return columns
99
+
100
+
101
+ def _layer_columns(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> list[list[str]]:
102
+ """Columns by dependency depth, with the unconnected nodes held back.
103
+
104
+ A node with no edge has no place in the dependency story, and letting it
105
+ take a slot pushed the nodes that do relate to each other apart. They get
106
+ their own columns after the graph.
107
+ """
108
+ linked = {e.source for e in edges} | {e.target for e in edges}
109
+ connected = [n.id for n in nodes if n.id in linked]
110
+ loose = [n.id for n in nodes if n.id not in linked]
111
+ depth = _depths(connected, edges)
112
+ links = [(e.source, e.target) for e in edges if e.source in depth and e.target in depth]
113
+
114
+ columns: list[list[str]] = []
115
+ for level in range(max(depth.values(), default=-1) + 1):
116
+ columns.append([node_id for node_id in connected if depth[node_id] == level])
117
+ columns = _uncross(columns, links)
118
+ for index in range(0, len(loose), _ROWS_PER_COLUMN):
119
+ columns.append(loose[index:index + _ROWS_PER_COLUMN])
120
+ return columns or [[]]
121
+
122
+
123
+ def _node_positions(
124
+ nodes: Sequence[VisualNode], edges: Sequence[VisualEdge] = ()
125
+ ) -> dict[str, tuple[int, int]]:
126
+ columns = _layer_columns(nodes, edges) if edges else _group_columns(nodes)
127
+ positions: dict[str, tuple[int, int]] = {}
128
+ for column_index, column in enumerate(columns):
129
+ for row_index, node_id in enumerate(column):
130
+ positions[node_id] = (
131
+ 50 + column_index * _COLUMN_WIDTH,
132
+ 55 + row_index * _ROW_HEIGHT,
133
+ )
33
134
  return positions
34
135
 
35
136
 
@@ -41,7 +142,7 @@ _ARROW_MARKER = (
41
142
 
42
143
 
43
144
  def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
44
- positions = _node_positions(nodes)
145
+ positions = _node_positions(nodes, edges)
45
146
  width = max((x for x, _ in positions.values()), default=50) + 230
46
147
  height = max((y for _, y in positions.values()), default=55) + 90
47
148
  parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
@@ -51,10 +152,28 @@ def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> s
51
152
  continue
52
153
  x1, y1 = positions[edge.source]
53
154
  x2, y2 = positions[edge.target]
155
+ # Leave the source box on its right edge and arrive on the target's
156
+ # left, so a left-to-right layering reads as one direction of travel.
157
+ start_x, end_x = (x1 + 180, x2) if x2 > x1 else ((x1, x2 + 180) if x2 < x1 else (x1 + 90, x2 + 90))
158
+ start_y, end_y = y1 + 25, y2 + 25
159
+ span = abs(x2 - x1) // _COLUMN_WIDTH
160
+ if span > 1:
161
+ # An edge that skips a column would otherwise be drawn straight
162
+ # through the boxes standing in it. Arcing it clear of the band is
163
+ # what removes the crossings the layering itself cannot.
164
+ # Clamped so a long skip from the top row still arcs inside
165
+ # the canvas instead of being drawn off it.
166
+ lift = max(10, min(start_y, end_y) - 30 - 12 * span)
167
+ geometry = (
168
+ f'<path d="M {start_x} {start_y} Q {(start_x + end_x) // 2} {lift} {end_x} {end_y}" '
169
+ f'fill="none"'
170
+ )
171
+ else:
172
+ geometry = f'<line x1="{start_x}" y1="{start_y}" x2="{end_x}" y2="{end_y}"'
54
173
  parts.append(
55
174
  f'<g class="edge-group"><title>{html.escape(edge.label)}</title>'
56
- f'<line x1="{x1 + 90}" y1="{y1 + 25}" x2="{x2 + 90}" y2="{y2 + 25}" '
57
- f'class="edge edge-{html.escape(edge.kind)}" marker-end="url(#edge-arrow)" /></g>'
175
+ f'{geometry} class="edge edge-{html.escape(edge.kind)}" '
176
+ f'marker-end="url(#edge-arrow)" /></g>'
58
177
  )
59
178
  for node in nodes:
60
179
  x, y = positions[node.id]
@@ -86,6 +205,22 @@ def dependency_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge
86
205
  )
87
206
 
88
207
 
208
+ def infrastructure_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
209
+ return replace(
210
+ dependency_figure(nodes=nodes, edges=edges, title=title),
211
+ kind="infrastructure",
212
+ figure_id="infrastructure-graph",
213
+ )
214
+
215
+
216
+ def workflow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
217
+ return replace(
218
+ dependency_figure(nodes=nodes, edges=edges, title=title),
219
+ kind="workflow",
220
+ figure_id="workflow-graph",
221
+ )
222
+
223
+
89
224
  def flow_figure(*, nodes: Sequence[VisualNode], edges: Sequence[VisualEdge], title: str) -> FigureModel:
90
225
  return replace(dependency_figure(nodes=nodes, edges=edges, title=title), kind="flow", figure_id="flow-graph")
91
226
 
@@ -15,3 +15,8 @@ def html_view_path(report_path: Path) -> Path:
15
15
  def user_responses_dir_for_report(report_path: Path) -> Path:
16
16
  """Return the sidecar directory used by the report's exported responses."""
17
17
  return report_path.parent.parent / "user-responses"
18
+
19
+
20
+ def team_state_path_for_report(report_path: Path, task_type: str, seq: str) -> Path:
21
+ """Return the team-state sibling that records the run's usage windows."""
22
+ return report_path.parent.parent / "state" / f"team-state-{task_type}-{seq}.json"
@@ -47,7 +47,7 @@ def _parse_iso(value: str) -> dt.datetime:
47
47
  return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
48
48
 
49
49
 
50
- def _wall_clock_ms(state: dict) -> int:
50
+ def wall_clock_ms(state: dict) -> int:
51
51
  """run 의 실제 경과(벽시계) = max(endedAt) − min(startedAt). lead·worker 윈도가
52
52
  겹치므로 cpuSum 과 다르다. timestamp 부족하면 0."""
53
53
  times: list[dt.datetime] = []
@@ -105,7 +105,7 @@ def _collect_run(run: dict, project_root: Path) -> dict:
105
105
  if lead == 0 and all(w["durationMs"] == 0 for w in workers):
106
106
  return {**base, "reason": "no durationMs (Phase 7 not reached)"}
107
107
  return {**base, "leadMs": lead, "workers": workers,
108
- "wallClockMs": _wall_clock_ms(state), "phases": _phase_rows(state)}
108
+ "wallClockMs": wall_clock_ms(state), "phases": _phase_rows(state)}
109
109
 
110
110
 
111
111
  def _by_task_type(collected: list[dict]) -> list[dict]:
@@ -11,7 +11,7 @@ from typing import Any
11
11
  from okstra_ctl.time_report import (
12
12
  _duration_ms,
13
13
  _read_state,
14
- _wall_clock_ms,
14
+ wall_clock_ms,
15
15
  load_runs,
16
16
  )
17
17
  from okstra_project import ResolverError, list_project_tasks, resolve_project_root
@@ -94,7 +94,7 @@ def _wall_clock_from_blocks(lead: dict, workers: list[dict]) -> int:
94
94
  "workers": [{"usage": usage} for usage in workers],
95
95
  }
96
96
  try:
97
- return _wall_clock_ms(state)
97
+ return wall_clock_ms(state)
98
98
  except TypeError:
99
99
  return 0
100
100