arkaos 2.13.0 → 2.15.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 (51) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/forge/SKILL.md +649 -0
  3. package/config/constitution.yaml +8 -0
  4. package/config/hooks/post-tool-use.sh +43 -0
  5. package/config/hooks/session-start.sh +24 -0
  6. package/config/hooks/user-prompt-submit.sh +25 -1
  7. package/core/forge/__init__.py +104 -0
  8. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  9. package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
  10. package/core/forge/__pycache__/handoff.cpython-313.pyc +0 -0
  11. package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
  12. package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
  13. package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
  14. package/core/forge/complexity.py +125 -0
  15. package/core/forge/handoff.py +100 -0
  16. package/core/forge/persistence.py +308 -0
  17. package/core/forge/renderer.py +261 -0
  18. package/core/forge/schema.py +213 -0
  19. package/core/synapse/__init__.py +2 -2
  20. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  21. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  22. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  23. package/core/synapse/engine.py +4 -2
  24. package/core/synapse/layers.py +49 -0
  25. package/core/sync/__init__.py +25 -0
  26. package/core/sync/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
  28. package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
  29. package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
  30. package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
  31. package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
  32. package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
  33. package/core/sync/__pycache__/schema.cpython-313.pyc +0 -0
  34. package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
  35. package/core/sync/descriptor_syncer.py +166 -0
  36. package/core/sync/discovery.py +256 -0
  37. package/core/sync/engine.py +177 -0
  38. package/core/sync/features/forge.yaml +16 -0
  39. package/core/sync/features/quality-gate.yaml +15 -0
  40. package/core/sync/features/spec-gate.yaml +15 -0
  41. package/core/sync/features/workflow-tiers.yaml +19 -0
  42. package/core/sync/manifest.py +87 -0
  43. package/core/sync/mcp_syncer.py +255 -0
  44. package/core/sync/reporter.py +178 -0
  45. package/core/sync/schema.py +94 -0
  46. package/core/sync/settings_syncer.py +121 -0
  47. package/core/workflow/state_reader.sh +25 -1
  48. package/departments/ops/skills/update/SKILL.md +69 -0
  49. package/installer/update.js +14 -0
  50. package/package.json +1 -1
  51. package/pyproject.toml +1 -1
@@ -0,0 +1,308 @@
1
+ """Forge persistence — YAML plans, Obsidian export, pattern extraction."""
2
+
3
+ import os
4
+ import re as _re
5
+ from pathlib import Path
6
+ from tempfile import NamedTemporaryFile
7
+ from typing import Optional
8
+ import yaml
9
+ from core.forge.schema import ForgePlan, ForgeStatus
10
+
11
+
12
+ def _plans_dir() -> Path:
13
+ return Path.home() / ".arkaos" / "plans"
14
+
15
+
16
+ def _active_link() -> Path:
17
+ return _plans_dir() / "active.yaml"
18
+
19
+
20
+ def save_plan(plan: ForgePlan) -> Path:
21
+ """Save a forge plan as YAML. Atomic write."""
22
+ plans = _plans_dir()
23
+ plans.mkdir(parents=True, exist_ok=True)
24
+ target = plans / f"{plan.id}.yaml"
25
+ data = plan.model_dump(mode="json")
26
+ fd = NamedTemporaryFile(mode="w", dir=str(plans), suffix=".tmp", delete=False, encoding="utf-8")
27
+ try:
28
+ yaml.dump(data, fd, default_flow_style=False, allow_unicode=True)
29
+ fd.close()
30
+ os.replace(fd.name, str(target))
31
+ except BaseException:
32
+ fd.close()
33
+ os.unlink(fd.name)
34
+ raise
35
+ return target
36
+
37
+
38
+ def load_plan(plan_id: str) -> Optional[ForgePlan]:
39
+ """Load a forge plan by ID. Returns None if not found."""
40
+ path = _plans_dir() / f"{plan_id}.yaml"
41
+ if not path.exists():
42
+ return None
43
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
44
+ return ForgePlan(**data)
45
+
46
+
47
+ def list_plans() -> list[dict]:
48
+ """List all saved plans as summary dicts."""
49
+ plans = _plans_dir()
50
+ if not plans.exists():
51
+ return []
52
+ results = []
53
+ for path in sorted(plans.glob("*.yaml")):
54
+ if path.name == "active.yaml":
55
+ continue
56
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
57
+ results.append({
58
+ "id": data.get("id", path.stem),
59
+ "name": data.get("name", ""),
60
+ "status": data.get("status", "draft"),
61
+ "tier": data.get("complexity", {}).get("tier", "shallow"),
62
+ "confidence": data.get("critic", {}).get("confidence", 0.0),
63
+ "created_at": data.get("created_at", ""),
64
+ })
65
+ return results
66
+
67
+
68
+ def set_active_plan(plan_id: str) -> None:
69
+ """Set a plan as the active forge plan."""
70
+ link = _active_link()
71
+ link.parent.mkdir(parents=True, exist_ok=True)
72
+ link.write_text(plan_id, encoding="utf-8")
73
+
74
+
75
+ def get_active_plan() -> Optional[ForgePlan]:
76
+ """Get the currently active forge plan."""
77
+ link = _active_link()
78
+ if not link.exists():
79
+ return None
80
+ plan_id = link.read_text(encoding="utf-8").strip()
81
+ return load_plan(plan_id)
82
+
83
+
84
+ def clear_active_plan() -> None:
85
+ """Clear the active forge plan."""
86
+ link = _active_link()
87
+ if link.exists():
88
+ link.unlink()
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Obsidian Export (Task 6)
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def _obsidian_forge_dir() -> Path:
96
+ """Obsidian vault path for Forge documents."""
97
+ return Path.home() / "Documents" / "Personal" / "Projects" / "WizardingCode Internal" / "ArkaOS" / "Forge"
98
+
99
+
100
+ def export_to_obsidian(plan: ForgePlan) -> Path:
101
+ """Export a forge plan as structured Obsidian markdown."""
102
+ forge_dir = _obsidian_forge_dir()
103
+ plans_dir = forge_dir / "Plans"
104
+ plans_dir.mkdir(parents=True, exist_ok=True)
105
+ filename = f"{plan.id.replace('forge-', '')}.md"
106
+ target = plans_dir / filename
107
+ content = _render_obsidian_plan(plan)
108
+ target.write_text(content, encoding="utf-8")
109
+ return target
110
+
111
+
112
+ def _render_obsidian_frontmatter(plan: ForgePlan) -> list[str]:
113
+ """Render YAML frontmatter block for an Obsidian plan note."""
114
+ tags = ["forge", "plan", plan.complexity.tier.value]
115
+ for phase in plan.plan_phases:
116
+ if phase.department not in tags:
117
+ tags.append(phase.department)
118
+ lines = [
119
+ "---",
120
+ f"tags: [{', '.join(tags)}]",
121
+ f"status: {plan.status.value}",
122
+ f"confidence: {plan.critic.confidence}",
123
+ f"complexity: {plan.complexity.score}",
124
+ f"created: {plan.created_at or ''}",
125
+ ]
126
+ if plan.executed_at:
127
+ lines.append(f"executed: {plan.executed_at}")
128
+ lines += ["---", "", f"# {plan.name}", ""]
129
+ return lines
130
+
131
+
132
+ def _render_obsidian_context(plan: ForgePlan) -> list[str]:
133
+ """Render the Context and Prompt sections."""
134
+ ctx = plan.context
135
+ return [
136
+ "## Context",
137
+ f"Repo: {ctx.repo} | Branch: {ctx.branch} | Commit: {ctx.commit_at_forge} | ArkaOS: {ctx.arkaos_version}",
138
+ "",
139
+ "## Prompt",
140
+ f"> {ctx.prompt}",
141
+ "",
142
+ ]
143
+
144
+
145
+ def _render_obsidian_approaches(plan: ForgePlan) -> list[str]:
146
+ """Render the Approaches Explored section."""
147
+ if not plan.approaches:
148
+ return []
149
+ lines = ["## Approaches Explored"]
150
+ for approach in plan.approaches:
151
+ label = approach.explorer.value.title()
152
+ lines.append(f"### {label} Explorer")
153
+ lines.append(approach.summary)
154
+ if approach.key_decisions:
155
+ lines.append("")
156
+ for kd in approach.key_decisions:
157
+ lines.append(f"- **{kd.decision}**: {kd.rationale}")
158
+ lines.append("")
159
+ return lines
160
+
161
+
162
+ def _render_obsidian_critic(plan: ForgePlan) -> list[str]:
163
+ """Render the Critic Synthesis section."""
164
+ critic = plan.critic
165
+ if critic.confidence <= 0:
166
+ return []
167
+ lines = ["## Critic Synthesis", f"**Confidence:** {critic.confidence}", ""]
168
+ if critic.synthesis:
169
+ lines.append("### Adopted Elements")
170
+ for source, elements in critic.synthesis.items():
171
+ for elem in elements:
172
+ lines.append(f"- [{source}] {elem}")
173
+ lines.append("")
174
+ if critic.rejected_elements:
175
+ lines.append("### Rejected Elements")
176
+ for rej in critic.rejected_elements:
177
+ lines.append(f"- **{rej.element}**: {rej.reason}")
178
+ lines.append("")
179
+ if critic.risks:
180
+ lines.append("### Risks")
181
+ for risk in critic.risks:
182
+ lines.append(f"- **{risk.risk}** ({risk.severity.value}) — Mitigation: {risk.mitigation}")
183
+ lines.append("")
184
+ return lines
185
+
186
+
187
+ def _render_obsidian_phases(plan: ForgePlan) -> list[str]:
188
+ """Render the Plan Phases section."""
189
+ if not plan.plan_phases:
190
+ return []
191
+ lines = ["## Plan"]
192
+ for i, phase in enumerate(plan.plan_phases):
193
+ lines.append(f"### Phase {i + 1}: {phase.name}")
194
+ lines.append(f"- **Department:** {phase.department}")
195
+ if phase.agents:
196
+ lines.append(f"- **Agents:** {', '.join(phase.agents)}")
197
+ if phase.deliverables:
198
+ lines.append(f"- **Deliverables:** {', '.join(phase.deliverables)}")
199
+ if phase.acceptance_criteria:
200
+ lines.append("- **Acceptance Criteria:**")
201
+ for ac in phase.acceptance_criteria:
202
+ lines.append(f" - {ac}")
203
+ lines.append("")
204
+ return lines
205
+
206
+
207
+ def _render_obsidian_execution(plan: ForgePlan) -> list[str]:
208
+ """Render the Execution section."""
209
+ if not plan.execution_path.target:
210
+ return []
211
+ lines = [
212
+ "## Execution",
213
+ f"- **Path:** {plan.execution_path.type.value}",
214
+ f"- **Target:** {plan.execution_path.target}",
215
+ ]
216
+ if plan.governance.branch_strategy:
217
+ lines.append(f"- **Branch:** {plan.governance.branch_strategy}")
218
+ lines.append("")
219
+ return lines
220
+
221
+
222
+ def _render_obsidian_plan(plan: ForgePlan) -> str:
223
+ """Render a ForgePlan as Obsidian markdown with frontmatter."""
224
+ sections = (
225
+ _render_obsidian_frontmatter(plan)
226
+ + _render_obsidian_context(plan)
227
+ + _render_obsidian_approaches(plan)
228
+ + _render_obsidian_critic(plan)
229
+ + _render_obsidian_phases(plan)
230
+ + _render_obsidian_execution(plan)
231
+ )
232
+ return "\n".join(sections)
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Pattern Extraction (Task 7)
237
+ # ---------------------------------------------------------------------------
238
+
239
+ def extract_patterns(plan: ForgePlan) -> list[dict]:
240
+ """Extract reusable patterns from a completed plan."""
241
+ if plan.status not in (ForgeStatus.COMPLETED, ForgeStatus.ARCHIVED):
242
+ return []
243
+ patterns: list[dict] = []
244
+ if len(plan.plan_phases) >= 2:
245
+ depts = list({p.department for p in plan.plan_phases})
246
+ pattern = {
247
+ "name": _slugify(f"{plan.context.repo}-{'-'.join(depts)}-pattern"),
248
+ "source_plan": plan.id,
249
+ "departments": depts,
250
+ "phase_count": len(plan.plan_phases),
251
+ "phase_names": [p.name for p in plan.plan_phases],
252
+ "tier": plan.complexity.tier.value,
253
+ "reuse_count": 0,
254
+ }
255
+ patterns.append(pattern)
256
+ if patterns:
257
+ _save_patterns_to_obsidian(patterns)
258
+ return patterns
259
+
260
+
261
+ def load_patterns() -> list[dict]:
262
+ """Load all saved patterns from Obsidian."""
263
+ patterns_dir = _obsidian_forge_dir() / "Patterns"
264
+ if not patterns_dir.exists():
265
+ return []
266
+ results = []
267
+ for path in patterns_dir.glob("*.md"):
268
+ content = path.read_text(encoding="utf-8")
269
+ if content.startswith("---"):
270
+ parts = content.split("---", 2)
271
+ if len(parts) >= 3:
272
+ data = yaml.safe_load(parts[1])
273
+ if data:
274
+ results.append(data)
275
+ return results
276
+
277
+
278
+ def _save_patterns_to_obsidian(patterns: list[dict]) -> None:
279
+ patterns_dir = _obsidian_forge_dir() / "Patterns"
280
+ patterns_dir.mkdir(parents=True, exist_ok=True)
281
+ for pattern in patterns:
282
+ name = pattern["name"]
283
+ target = patterns_dir / f"{name}.md"
284
+ lines = [
285
+ "---",
286
+ f"name: {name}",
287
+ f"source_plan: {pattern['source_plan']}",
288
+ f"departments: [{', '.join(pattern['departments'])}]",
289
+ f"phase_count: {pattern['phase_count']}",
290
+ f"tier: {pattern['tier']}",
291
+ f"reuse_count: {pattern['reuse_count']}",
292
+ "---",
293
+ "",
294
+ f"# Pattern: {name}",
295
+ "",
296
+ f"Extracted from plan `{pattern['source_plan']}`.",
297
+ "",
298
+ "## Phases",
299
+ ]
300
+ for phase_name in pattern["phase_names"]:
301
+ lines.append(f"- {phase_name}")
302
+ lines.append("")
303
+ target.write_text("\n".join(lines), encoding="utf-8")
304
+
305
+
306
+ def _slugify(text: str) -> str:
307
+ slug = _re.sub(r"[^\w\s-]", "", text.lower())
308
+ return _re.sub(r"[\s_]+", "-", slug).strip("-")
@@ -0,0 +1,261 @@
1
+ """Forge renderer — terminal ANSI output and HTML companion generator."""
2
+
3
+ import math
4
+
5
+ from core.forge.schema import ComplexityScore, CriticVerdict, ExecutionPath, ForgePlan, ForgeTier, PlanPhase
6
+
7
+
8
+ def render_complexity(complexity: ComplexityScore) -> str:
9
+ """Render complexity analysis as terminal output."""
10
+ dims = complexity.dimensions
11
+ tier_label = complexity.tier.value.title()
12
+ explorers = {ForgeTier.SHALLOW: 1, ForgeTier.STANDARD: 2, ForgeTier.DEEP: 3}
13
+ n_exp = explorers[complexity.tier]
14
+ critic = "inline" if complexity.tier == ForgeTier.SHALLOW else "1 Plan Critic"
15
+ lines = [
16
+ f" Score: {complexity.score}/100 ({tier_label})",
17
+ _render_dimension_table(dims),
18
+ f" Tier: {tier_label} → {n_exp} explorer(s) + {critic}",
19
+ ]
20
+ if complexity.similar_plans:
21
+ lines.append(f" Similar plans: {', '.join(complexity.similar_plans)}")
22
+ else:
23
+ lines.append(" Similar plans in Obsidian: none found")
24
+ if complexity.reused_patterns:
25
+ lines.append(f" Reusing patterns: {', '.join(complexity.reused_patterns)}")
26
+ return "\n".join(lines)
27
+
28
+
29
+ def render_critic_summary(verdict: CriticVerdict) -> str:
30
+ """Render critic verdict summary for terminal."""
31
+ adopted = sum(len(v) for v in verdict.synthesis.values())
32
+ rejected = len(verdict.rejected_elements)
33
+ risks = len(verdict.risks)
34
+ risk_detail = ", ".join(
35
+ f"{sum(1 for r in verdict.risks if r.severity.value == s)} {s}"
36
+ for s in ("high", "medium", "low")
37
+ if any(r.severity.value == s for r in verdict.risks)
38
+ )
39
+ return "\n".join([
40
+ f" Confidence: {verdict.confidence}",
41
+ f" ✓ {adopted} elements adopted",
42
+ f" ✗ {rejected} elements rejected",
43
+ f" ⚠ {risks} risks identified ({risk_detail})",
44
+ ])
45
+
46
+
47
+ def render_plan_overview(phases: list[PlanPhase], execution_path: ExecutionPath) -> str:
48
+ """Render plan phase summary for terminal."""
49
+ depts = list({p.department for p in phases})
50
+ lines = []
51
+ for i, phase in enumerate(phases):
52
+ bar = "░" * 8
53
+ dep = f"[{phase.department}]"
54
+ deps_str = f" ← {', '.join(phase.depends_on)}" if phase.depends_on else ""
55
+ lines.append(f" Phase {i + 1}: {phase.name:<35} {dep:<10} {bar}{deps_str}")
56
+ lines.append("")
57
+ lines.append(f" Execution: {execution_path.type.value} → {execution_path.target}")
58
+ lines.append(f" Departments: {', '.join(depts)}")
59
+ lines.append(f" QG required: yes")
60
+ return "\n".join(lines)
61
+
62
+
63
+ def render_terminal(plan: ForgePlan) -> str:
64
+ """Render a complete forge plan for terminal display."""
65
+ lines = [
66
+ f"⚒ FORGE — {plan.name}", "",
67
+ "▸ Context Snapshot",
68
+ f" Repo: {plan.context.repo} @ {plan.context.commit_at_forge}",
69
+ f" ArkaOS: {plan.context.arkaos_version} | Branch: {plan.context.branch}", "",
70
+ "▸ Complexity Analysis",
71
+ render_complexity(plan.complexity), "",
72
+ ]
73
+ if plan.critic.confidence > 0:
74
+ lines.extend(["▸ Critic Verdict", render_critic_summary(plan.critic), ""])
75
+ if plan.plan_phases:
76
+ n_depts = len({p.department for p in plan.plan_phases})
77
+ lines.append(f"▸ Plan: {len(plan.plan_phases)} phases across {n_depts} department(s)")
78
+ lines.extend([render_plan_overview(plan.plan_phases, plan.execution_path), ""])
79
+ lines.append(" [A]pprove [R]evise [C]ompanion [D]etail phase [Q]uit")
80
+ return "\n".join(lines)
81
+
82
+
83
+ _COMPANION_CSS = """\
84
+ :root { --bg: #0d1117; --fg: #c9d1d9; --accent: #58a6ff; --border: #30363d; --green: #3fb950; --red: #f85149; --yellow: #d29922; }
85
+ * { margin: 0; padding: 0; box-sizing: border-box; }
86
+ body { background: var(--bg); color: var(--fg); font-family: -apple-system, sans-serif; padding: 2rem; max-width: 1200px; margin: 0 auto; }
87
+ h1 { color: var(--accent); margin-bottom: 0.5rem; }
88
+ h2 { color: var(--fg); margin: 1.5rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
89
+ h3 { color: var(--accent); margin: 1rem 0 0.5rem; }
90
+ .meta { color: #8b949e; font-size: 0.9rem; margin-bottom: 1rem; }
91
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
92
+ .card { background: #161b22; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
93
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8rem; margin: 2px; }
94
+ .badge-green { background: rgba(63,185,80,0.2); color: var(--green); }
95
+ .badge-red { background: rgba(248,81,73,0.2); color: var(--red); }
96
+ .badge-yellow { background: rgba(210,153,34,0.2); color: var(--yellow); }
97
+ .phase { padding: 0.5rem; border-left: 3px solid var(--accent); margin-bottom: 0.5rem; padding-left: 0.75rem; }
98
+ .phase-dept { color: #8b949e; font-size: 0.85rem; }
99
+ svg { display: block; margin: 0 auto; }
100
+ .approaches { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; }"""
101
+
102
+
103
+ def should_suggest_companion(tier: ForgeTier) -> str:
104
+ """Determine companion suggestion level. Returns 'none', 'available', or 'suggested'."""
105
+ if tier == ForgeTier.SHALLOW:
106
+ return "none"
107
+ if tier == ForgeTier.STANDARD:
108
+ return "available"
109
+ return "suggested"
110
+
111
+
112
+ def render_html(plan: ForgePlan) -> str:
113
+ """Render a standalone HTML companion for a forge plan."""
114
+ radar_svg = _render_radar_svg(plan.complexity.dimensions)
115
+ phases_html = _render_phases_html(plan.plan_phases)
116
+ critic_html = _render_critic_html(plan.critic)
117
+ approaches_html = _render_approaches_html(plan.approaches)
118
+ ctx = plan.context
119
+ meta = (
120
+ f"{_esc(ctx.repo)} @ {_esc(ctx.commit_at_forge)} | Branch: {_esc(ctx.branch)} "
121
+ f"| ArkaOS {_esc(ctx.arkaos_version)} | Score: {plan.complexity.score}/100 "
122
+ f"({plan.complexity.tier.value.title()})"
123
+ )
124
+ return (
125
+ f'<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
126
+ f'<meta name="viewport" content="width=device-width, initial-scale=1">'
127
+ f"<title>Forge: {_esc(plan.name)}</title>"
128
+ f"<style>\n{_COMPANION_CSS}\n</style></head><body>"
129
+ f"<h1>\u2692 {_esc(plan.name)}</h1>"
130
+ f'<div class="meta">{meta}</div>'
131
+ f'<div class="grid">'
132
+ f'<div class="card"><h2>Complexity Radar</h2>{radar_svg}</div>'
133
+ f'<div class="card"><h2>Critic Verdict</h2>{critic_html}</div>'
134
+ f"</div>"
135
+ f"{approaches_html}"
136
+ f"<h2>Plan Phases</h2>{phases_html}"
137
+ f'<div class="meta" style="margin-top:2rem;text-align:center;">'
138
+ f"Generated by The Forge \u2014 ArkaOS | Read-only companion</div>"
139
+ f"</body></html>"
140
+ )
141
+
142
+
143
+ def _radar_grid_and_axes(cx: int, cy: int, r: int, angles: list[float]) -> str:
144
+ """Render radar background rings and spoke axes."""
145
+ grid = "".join(
146
+ f'<circle cx="{cx}" cy="{cy}" r="{r * pct}" fill="none" stroke="#30363d" stroke-width="0.5"/>'
147
+ for pct in (0.25, 0.5, 0.75, 1.0)
148
+ )
149
+ axes = "".join(
150
+ f'<line x1="{cx}" y1="{cy}" x2="{cx + r * math.cos(a):.1f}" y2="{cy + r * math.sin(a):.1f}" stroke="#30363d" stroke-width="0.5"/>'
151
+ for a in angles
152
+ )
153
+ return grid + axes
154
+
155
+
156
+ def _radar_polygon_and_labels(cx: int, cy: int, r: int, angles: list[float], labels: list[tuple]) -> str:
157
+ """Render radar data polygon and dimension labels."""
158
+ points = " ".join(
159
+ f"{cx + (v / 100) * r * math.cos(angles[i]):.1f},{cy + (v / 100) * r * math.sin(angles[i]):.1f}"
160
+ for i, (_, v) in enumerate(labels)
161
+ )
162
+ polygon = f'<polygon points="{points}" fill="rgba(88,166,255,0.2)" stroke="#58a6ff" stroke-width="2"/>'
163
+ label_elems = ""
164
+ for i, (name, val) in enumerate(labels):
165
+ x = cx + (r + 20) * math.cos(angles[i])
166
+ y = cy + (r + 20) * math.sin(angles[i])
167
+ anchor = "end" if x < cx - 10 else "start" if x > cx + 10 else "middle"
168
+ label_elems += (
169
+ f'<text x="{x:.1f}" y="{y:.1f}" fill="#c9d1d9" font-size="12" '
170
+ f'text-anchor="{anchor}" dominant-baseline="middle">{name} ({val})</text>'
171
+ )
172
+ return polygon + label_elems
173
+
174
+
175
+ def _render_radar_svg(dims) -> str:
176
+ """Render a radar/spider chart SVG for complexity dimensions."""
177
+ cx, cy, r = 150, 150, 120
178
+ labels = [
179
+ ("Scope", dims.scope),
180
+ ("Deps", dims.dependencies),
181
+ ("Ambig.", dims.ambiguity),
182
+ ("Risk", dims.risk),
183
+ ("Novelty", dims.novelty),
184
+ ]
185
+ angles = [i * 2 * math.pi / len(labels) - math.pi / 2 for i in range(len(labels))]
186
+ inner = _radar_grid_and_axes(cx, cy, r, angles) + _radar_polygon_and_labels(cx, cy, r, angles, labels)
187
+ return f'<svg viewBox="0 0 300 300" width="280" height="280">{inner}</svg>'
188
+
189
+
190
+ def _render_phases_html(phases) -> str:
191
+ if not phases:
192
+ return "<p>No phases defined.</p>"
193
+ html = ""
194
+ for i, phase in enumerate(phases):
195
+ deps = f' ← {", ".join(phase.depends_on)}' if phase.depends_on else ""
196
+ html += (
197
+ f'<div class="phase"><strong>Phase {i+1}: {_esc(phase.name)}</strong>{deps}'
198
+ f'<br><span class="phase-dept">{_esc(phase.department)}</span></div>'
199
+ )
200
+ return html
201
+
202
+
203
+ def _render_critic_html(verdict) -> str:
204
+ if verdict.confidence == 0:
205
+ return "<p>No critic analysis.</p>"
206
+ adopted = sum(len(v) for v in verdict.synthesis.values())
207
+ lines = [
208
+ f"<p><strong>Confidence:</strong> {verdict.confidence}</p>",
209
+ f'<p><span class="badge badge-green">✓ {adopted} adopted</span> ',
210
+ f'<span class="badge badge-red">✗ {len(verdict.rejected_elements)} rejected</span> ',
211
+ f'<span class="badge badge-yellow">⚠ {len(verdict.risks)} risks</span></p>',
212
+ ]
213
+ if verdict.rejected_elements:
214
+ lines.append("<h3>Rejected</h3><ul>")
215
+ for rej in verdict.rejected_elements:
216
+ lines.append(f"<li><strong>{_esc(rej.element)}</strong>: {_esc(rej.reason)}</li>")
217
+ lines.append("</ul>")
218
+ if verdict.risks:
219
+ lines.append("<h3>Risks</h3><ul>")
220
+ for risk in verdict.risks:
221
+ lines.append(
222
+ f"<li><strong>{_esc(risk.risk)}</strong> ({risk.severity.value}) — {_esc(risk.mitigation)}</li>"
223
+ )
224
+ lines.append("</ul>")
225
+ return "\n".join(lines)
226
+
227
+
228
+ def _render_approaches_html(approaches) -> str:
229
+ if not approaches:
230
+ return ""
231
+ html = '<h2>Approaches Explored</h2><div class="approaches">'
232
+ for a in approaches:
233
+ html += f'<div class="card"><h3>{a.explorer.value.title()} Explorer</h3><p>{_esc(a.summary)}</p></div>'
234
+ html += "</div>"
235
+ return html
236
+
237
+
238
+ def _esc(text: str) -> str:
239
+ return (
240
+ text.replace("&", "&amp;")
241
+ .replace("<", "&lt;")
242
+ .replace(">", "&gt;")
243
+ .replace('"', "&quot;")
244
+ )
245
+
246
+
247
+ def _render_dimension_table(dims) -> str:
248
+ entries = [
249
+ ("Scope", dims.scope),
250
+ ("Deps", dims.dependencies),
251
+ ("Ambig.", dims.ambiguity),
252
+ ("Risk", dims.risk),
253
+ ("Novelty", dims.novelty),
254
+ ]
255
+ lines = []
256
+ for name, value in entries:
257
+ filled = value // 10
258
+ empty = 10 - filled
259
+ bar = "█" * filled + "░" * empty
260
+ lines.append(f" │ {name:<8}│ {value:>3} │ {bar}")
261
+ return "\n".join(lines)