okstra 0.147.0 → 0.148.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +21 -7
  2. package/docs/architecture/storage-model.md +34 -61
  3. package/docs/architecture.md +51 -49
  4. package/docs/cli.md +38 -21
  5. package/docs/for-ai/skills/okstra-run.md +13 -34
  6. package/docs/performance-improvement-plan-v2.md +2 -2
  7. package/docs/pr-template-usage.md +1 -1
  8. package/docs/project-structure-overview.md +10 -8
  9. package/docs/task-process/README.md +4 -4
  10. package/docs/task-process/common-flow.md +12 -12
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation.md +1 -1
  13. package/docs/task-process/release-handoff.md +1 -1
  14. package/package.json +2 -2
  15. package/runtime/BUILD.json +2 -2
  16. package/runtime/agents/workers/antigravity-worker.md +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/codex-worker.md +2 -2
  19. package/runtime/agents/workers/grok-worker.md +256 -0
  20. package/runtime/agents/workers/kimi-worker.md +256 -0
  21. package/runtime/agents/workers/report-writer-worker.md +2 -2
  22. package/runtime/bin/lib/okstra/cli.sh +13 -1
  23. package/runtime/bin/lib/okstra/globals.sh +3 -0
  24. package/runtime/bin/lib/okstra/usage.sh +17 -12
  25. package/runtime/bin/okstra-grok-exec.sh +5 -0
  26. package/runtime/bin/okstra-kimi-exec.sh +5 -0
  27. package/runtime/bin/okstra-provider-exec.py +235 -0
  28. package/runtime/bin/okstra.sh +3 -0
  29. package/runtime/prompts/lead/adapters/antigravity.md +48 -0
  30. package/runtime/prompts/lead/adapters/claude-code.md +13 -11
  31. package/runtime/prompts/lead/adapters/codex.md +7 -7
  32. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  33. package/runtime/prompts/lead/report-writer.md +1 -1
  34. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  35. package/runtime/prompts/profiles/_common-contract.md +4 -4
  36. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  37. package/runtime/prompts/profiles/_implementation-diff-review.md +1 -1
  38. package/runtime/prompts/profiles/_implementation-executor.md +12 -12
  39. package/runtime/prompts/profiles/_implementation-self-check.md +4 -4
  40. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  41. package/runtime/prompts/profiles/change-impact-analysis.md +2 -0
  42. package/runtime/prompts/profiles/error-analysis.md +2 -0
  43. package/runtime/prompts/profiles/feature-analysis.md +2 -0
  44. package/runtime/prompts/profiles/final-verification.md +3 -1
  45. package/runtime/prompts/profiles/forbidden-actions.json +4 -4
  46. package/runtime/prompts/profiles/implementation-planning.md +3 -1
  47. package/runtime/prompts/profiles/implementation.md +2 -2
  48. package/runtime/prompts/profiles/improvement-discovery.md +3 -1
  49. package/runtime/prompts/profiles/project-analysis.md +2 -0
  50. package/runtime/prompts/profiles/release-handoff.md +7 -7
  51. package/runtime/prompts/profiles/requirements-discovery.md +2 -0
  52. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  53. package/runtime/python/okstra_ctl/codex_dispatch.py +68 -87
  54. package/runtime/python/okstra_ctl/dispatch_core.py +4 -22
  55. package/runtime/python/okstra_ctl/lead_events.py +1 -1
  56. package/runtime/python/okstra_ctl/lead_runtime.py +13 -2
  57. package/runtime/python/okstra_ctl/models.py +156 -8
  58. package/runtime/python/okstra_ctl/path_hints.py +9 -25
  59. package/runtime/python/okstra_ctl/paths.py +1 -1
  60. package/runtime/python/okstra_ctl/render.py +172 -74
  61. package/runtime/python/okstra_ctl/report_html/common.py +38 -2
  62. package/runtime/python/okstra_ctl/report_html/filters.py +104 -0
  63. package/runtime/python/okstra_ctl/report_html/render.py +7 -0
  64. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +2 -1
  65. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +2 -1
  66. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +2 -1
  67. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -1
  68. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +2 -1
  69. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +2 -1
  70. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +2 -1
  71. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +2 -1
  72. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +2 -1
  73. package/runtime/python/okstra_ctl/report_html/visualizations.py +32 -6
  74. package/runtime/python/okstra_ctl/run.py +264 -45
  75. package/runtime/python/okstra_ctl/runner_resolution.py +103 -0
  76. package/runtime/python/okstra_ctl/team.py +2 -7
  77. package/runtime/python/okstra_ctl/wizard.py +194 -21
  78. package/runtime/python/okstra_ctl/worker_artifacts.py +46 -0
  79. package/runtime/python/okstra_ctl/workers.py +3 -1
  80. package/runtime/python/okstra_ctl/workflow.py +4 -2
  81. package/runtime/python/okstra_token_usage/__init__.py +1 -0
  82. package/runtime/python/okstra_token_usage/collect.py +32 -23
  83. package/runtime/python/okstra_token_usage/pricing.py +35 -3
  84. package/runtime/schemas/final-report-v2.0.schema.json +2 -2
  85. package/runtime/skills/okstra-run/SKILL.md +31 -42
  86. package/runtime/templates/prd/pr-body.template.md +1 -1
  87. package/runtime/templates/reports/html/assets/base.css +6 -3
  88. package/runtime/templates/reports/html/base.template.html +22 -8
  89. package/runtime/templates/reports/html/macros/forms.html +2 -2
  90. package/runtime/templates/reports/html/macros/layout.html +5 -5
  91. package/runtime/templates/reports/html/macros/visualizations.html +11 -1
  92. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +11 -11
  93. package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
  94. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +9 -9
  95. package/runtime/templates/reports/html/tasks/final-verification.template.html +7 -7
  96. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +12 -12
  97. package/runtime/templates/reports/html/tasks/implementation.template.html +7 -7
  98. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +6 -6
  99. package/runtime/templates/reports/html/tasks/project-analysis.template.html +11 -11
  100. package/runtime/templates/reports/html/tasks/release-handoff.template.html +5 -5
  101. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +8 -8
  102. package/runtime/templates/reports/report.js +21 -4
  103. package/runtime/templates/reports/settings.template.json +4 -0
  104. package/runtime/templates/reports/task-brief.template.md +7 -7
  105. package/runtime/validators/validate-run.py +11 -6
  106. package/runtime/validators/validate_session_conformance.py +2 -1
  107. package/src/cli-registry.mjs +4 -4
  108. package/src/commands/execute/codex-dispatch.mjs +7 -10
  109. package/src/commands/execute/render-bundle.mjs +3 -3
  110. package/src/commands/execute/run.mjs +17 -52
  111. package/src/commands/execute/wizard.mjs +4 -1
  112. package/src/commands/lifecycle/doctor.mjs +6 -3
  113. package/src/commands/lifecycle/install.mjs +31 -8
  114. package/src/lib/runtime-manifest.mjs +1 -1
  115. package/src/lib/runtime-resolver.mjs +2 -2
  116. package/src/lib/worker-agent-render.mjs +50 -0
@@ -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))
@@ -9,6 +9,7 @@ import okstra_vendor # noqa: F401 # registers vendored dependency aliases
9
9
  from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
10
10
 
11
11
  from ..final_report_schema import load_schema_for_data, validate
12
+ from .filters import audit_json, code_evidence, evidence_refs, inline_code, paragraphs
12
13
  from .models import HtmlRunMeta
13
14
  from .router import HtmlRenderError, resolve_html_route
14
15
 
@@ -52,6 +53,12 @@ def render_v2_html_view(
52
53
  view = route.view_builder(data)
53
54
  root = _templates_root(templates_root)
54
55
  env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
56
+ env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
57
+ env.filters["audit_json"] = audit_json
58
+ env.filters["code_evidence"] = code_evidence
59
+ env.filters["evidence_refs"] = evidence_refs
60
+ env.filters["inline_code"] = inline_code
61
+ env.filters["paragraphs"] = paragraphs
55
62
  response_js = (root / "report.js").read_text(encoding="utf-8")
56
63
  base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
57
64
  context = {
@@ -1,7 +1,7 @@
1
1
  """Human-first change-impact-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import analysis_review_ids, audit_context
4
+ from ..common import analysis_review_ids, audit_context, evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import flow_figure
7
7
 
@@ -28,6 +28,7 @@ def build_change_impact_analysis_view(data: dict) -> HumanReportView:
28
28
  "narrative": change["userNarrative"],
29
29
  "blastRadiusFigure": figure,
30
30
  "analysisReviewIds": analysis_review_ids(data),
31
+ "evidenceIndex": evidence_index(data),
31
32
  "audit": audit_context(data),
32
33
  }
33
34
  return HumanReportView(
@@ -1,7 +1,7 @@
1
1
  """Human-first error-analysis view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context
4
+ from ..common import audit_context, evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import cause_graph_figure
7
7
 
@@ -38,6 +38,7 @@ def build_error_analysis_view(data: dict) -> HumanReportView:
38
38
  "error": error,
39
39
  "narrative": error["userNarrative"],
40
40
  "causeFigure": figure,
41
+ "evidenceIndex": evidence_index(data),
41
42
  "audit": audit_context(data),
42
43
  }
43
44
  return HumanReportView(
@@ -1,7 +1,7 @@
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
4
+ from ..common import analysis_review_ids, audit_context, evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import flow_figure
7
7
 
@@ -28,6 +28,7 @@ def build_feature_analysis_view(data: dict) -> HumanReportView:
28
28
  "narrative": feature["userNarrative"],
29
29
  "flowFigure": figure,
30
30
  "analysisReviewIds": analysis_review_ids(data),
31
+ "evidenceIndex": evidence_index(data),
31
32
  "audit": audit_context(data),
32
33
  }
33
34
  return HumanReportView(
@@ -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
4
+ from ..common import audit_context, evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import coverage_figure
7
7
 
@@ -36,6 +36,7 @@ def build_final_verification_view(data: dict) -> HumanReportView:
36
36
  "narrative": final["userNarrative"],
37
37
  "coverageFigure": figure,
38
38
  "releaseAllowed": data["verdictCard"]["verdictToken"] == "accepted",
39
+ "evidenceIndex": evidence_index(data),
39
40
  "audit": audit,
40
41
  }
41
42
  return HumanReportView(
@@ -1,7 +1,7 @@
1
1
  """Human-first implementation delivery view model."""
2
2
  from __future__ import annotations
3
3
 
4
- from ..common import audit_context
4
+ from ..common import audit_context, evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import change_map_figure
7
7
 
@@ -36,6 +36,7 @@ def build_implementation_view(data: dict) -> HumanReportView:
36
36
  "implementation": implementation,
37
37
  "narrative": implementation["userNarrative"],
38
38
  "changeFigure": figure,
39
+ "evidenceIndex": evidence_index(data),
39
40
  "audit": audit,
40
41
  }
41
42
  return HumanReportView(
@@ -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
7
+ from ..common import audit_context, evidence_index
8
8
  from ..models import HumanReportView, VisualEdge, VisualNode
9
9
  from ..visualizations import stage_map_figure
10
10
 
@@ -92,6 +92,7 @@ def build_implementation_planning_view(data: dict) -> HumanReportView:
92
92
  if row.get("blocks") == "approval"
93
93
  and row.get("status") in {"open", "answered"}
94
94
  ],
95
+ "evidenceIndex": evidence_index(data),
95
96
  "audit": audit,
96
97
  }
97
98
  return HumanReportView(
@@ -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
4
+ from ..common import audit_context, evidence_index
5
5
  from ..models import HumanReportView, VisualNode
6
6
  from ..visualizations import matrix_figure
7
7
 
@@ -32,6 +32,7 @@ def build_improvement_discovery_view(data: dict) -> HumanReportView:
32
32
  "improvement": improvement,
33
33
  "narrative": improvement["userNarrative"],
34
34
  "matrixFigure": figure,
35
+ "evidenceIndex": evidence_index(data),
35
36
  "audit": audit_context(data),
36
37
  }
37
38
  return HumanReportView(
@@ -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
6
+ from ..common import audit_context, evidence_index
7
7
  from ..models import HumanReportView, VisualNode
8
8
  from ..visualizations import timeline_figure
9
9
 
@@ -43,6 +43,7 @@ def build_release_handoff_view(data: dict) -> HumanReportView:
43
43
  "narrative": handoff["userNarrative"],
44
44
  "handoffFigure": figure,
45
45
  "prUrl": _safe_http_url(handoff["pullRequestOutcome"].get("url", "")),
46
+ "evidenceIndex": evidence_index(data),
46
47
  "audit": audit,
47
48
  }
48
49
  return HumanReportView(
@@ -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
4
+ from ..common import audit_context, evidence_index
5
5
  from ..models import HumanReportView, VisualEdge, VisualNode
6
6
  from ..visualizations import decision_flow_figure
7
7
 
@@ -44,6 +44,7 @@ def build_requirements_discovery_view(data: dict) -> HumanReportView:
44
44
  "narrative": requirements["userNarrative"],
45
45
  "routingFigure": figure,
46
46
  "clarificationItems": data.get("clarificationItems", []),
47
+ "evidenceIndex": evidence_index(data),
47
48
  "audit": audit_context(data),
48
49
  }
49
50
  return HumanReportView(
@@ -8,28 +8,54 @@ from typing import Iterable, Sequence
8
8
  from .models import FigureModel, VisualEdge, VisualNode
9
9
 
10
10
 
11
+ _ROWS_PER_COLUMN = 8
12
+ _COLUMN_WIDTH = 260
13
+ _ROW_HEIGHT = 100
14
+
15
+
11
16
  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] = {}
17
+ """Lay out one column per group, wrapping tall groups into extra columns."""
18
+ groups = sorted({n.group for n in nodes})
19
+ counts: dict[str, int] = {}
20
+ column_offset: dict[str, int] = {}
21
+ offset = 0
22
+ for group in groups:
23
+ column_offset[group] = offset
24
+ size = sum(1 for n in nodes if n.group == group)
25
+ offset += max(1, -(-size // _ROWS_PER_COLUMN))
14
26
  positions: dict[str, tuple[int, int]] = {}
15
27
  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)
28
+ index = counts.get(node.group, 0)
29
+ 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)
19
33
  return positions
20
34
 
21
35
 
36
+ _ARROW_MARKER = (
37
+ '<defs><marker id="edge-arrow" viewBox="0 0 10 10" refX="9" refY="5" '
38
+ 'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
39
+ '<path d="M 0 0 L 10 5 L 0 10 z" class="edge-arrow-head" /></marker></defs>'
40
+ )
41
+
42
+
22
43
  def _svg_document(nodes: Sequence[VisualNode], edges: Sequence[VisualEdge]) -> str:
23
44
  positions = _node_positions(nodes)
24
45
  width = max((x for x, _ in positions.values()), default=50) + 230
25
46
  height = max((y for _, y in positions.values()), default=55) + 90
26
47
  parts = [f'<svg viewBox="0 0 {width} {height}" role="img" xmlns="http://www.w3.org/2000/svg">']
48
+ parts.append(_ARROW_MARKER)
27
49
  for edge in edges:
28
50
  if edge.source not in positions or edge.target not in positions:
29
51
  continue
30
52
  x1, y1 = positions[edge.source]
31
53
  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)}" />')
54
+ parts.append(
55
+ 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>'
58
+ )
33
59
  for node in nodes:
34
60
  x, y = positions[node.id]
35
61
  node_id = html.escape(node.id)