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.
- package/VERSION +1 -1
- package/arka/skills/forge/SKILL.md +649 -0
- package/config/constitution.yaml +8 -0
- package/config/hooks/post-tool-use.sh +43 -0
- package/config/hooks/session-start.sh +24 -0
- package/config/hooks/user-prompt-submit.sh +25 -1
- package/core/forge/__init__.py +104 -0
- package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/complexity.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/handoff.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/persistence.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/renderer.cpython-313.pyc +0 -0
- package/core/forge/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/forge/complexity.py +125 -0
- package/core/forge/handoff.py +100 -0
- package/core/forge/persistence.py +308 -0
- package/core/forge/renderer.py +261 -0
- package/core/forge/schema.py +213 -0
- package/core/synapse/__init__.py +2 -2
- package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
- package/core/synapse/engine.py +4 -2
- package/core/synapse/layers.py +49 -0
- package/core/sync/__init__.py +25 -0
- package/core/sync/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/descriptor_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/discovery.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/mcp_syncer.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/reporter.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/schema.cpython-313.pyc +0 -0
- package/core/sync/__pycache__/settings_syncer.cpython-313.pyc +0 -0
- package/core/sync/descriptor_syncer.py +166 -0
- package/core/sync/discovery.py +256 -0
- package/core/sync/engine.py +177 -0
- package/core/sync/features/forge.yaml +16 -0
- package/core/sync/features/quality-gate.yaml +15 -0
- package/core/sync/features/spec-gate.yaml +15 -0
- package/core/sync/features/workflow-tiers.yaml +19 -0
- package/core/sync/manifest.py +87 -0
- package/core/sync/mcp_syncer.py +255 -0
- package/core/sync/reporter.py +178 -0
- package/core/sync/schema.py +94 -0
- package/core/sync/settings_syncer.py +121 -0
- package/core/workflow/state_reader.sh +25 -1
- package/departments/ops/skills/update/SKILL.md +69 -0
- package/installer/update.js +14 -0
- package/package.json +1 -1
- 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("&", "&")
|
|
241
|
+
.replace("<", "<")
|
|
242
|
+
.replace(">", ">")
|
|
243
|
+
.replace('"', """)
|
|
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)
|