arkaos 2.2.2 → 2.3.1

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 (66) hide show
  1. package/VERSION +1 -1
  2. package/arka/skills/conclave/SKILL.md +194 -0
  3. package/arka/skills/human-writing/SKILL.md +143 -0
  4. package/config/agent-memory-template.md +28 -0
  5. package/config/disc-profiles.json +108 -0
  6. package/config/disc-team-validator.sh +94 -0
  7. package/config/gotchas-fixes.json +148 -0
  8. package/config/profile-template.json +12 -0
  9. package/config/providers-registry.json +56 -0
  10. package/config/settings-template.json +42 -0
  11. package/config/standards/communication.md +64 -0
  12. package/config/standards/orchestration.md +91 -0
  13. package/config/statusline-v2.sh +101 -0
  14. package/config/statusline.sh +139 -0
  15. package/config/system-prompt.sh +190 -0
  16. package/dashboard/LICENSE +21 -0
  17. package/dashboard/README.md +64 -0
  18. package/dashboard/app/app.config.ts +8 -0
  19. package/dashboard/app/app.vue +42 -0
  20. package/dashboard/app/assets/css/main.css +18 -0
  21. package/dashboard/app/composables/useApi.ts +8 -0
  22. package/dashboard/app/composables/useDashboard.ts +19 -0
  23. package/dashboard/app/error.vue +24 -0
  24. package/dashboard/app/layouts/default.vue +114 -0
  25. package/dashboard/app/pages/agents/[id].vue +506 -0
  26. package/dashboard/app/pages/agents/index.vue +225 -0
  27. package/dashboard/app/pages/budget.vue +132 -0
  28. package/dashboard/app/pages/commands.vue +180 -0
  29. package/dashboard/app/pages/health.vue +98 -0
  30. package/dashboard/app/pages/index.vue +126 -0
  31. package/dashboard/app/pages/knowledge.vue +729 -0
  32. package/dashboard/app/pages/personas.vue +597 -0
  33. package/dashboard/app/pages/settings.vue +146 -0
  34. package/dashboard/app/pages/tasks.vue +203 -0
  35. package/dashboard/app/types/index.d.ts +181 -0
  36. package/dashboard/app/utils/index.ts +7 -0
  37. package/dashboard/nuxt.config.ts +39 -0
  38. package/dashboard/package.json +37 -0
  39. package/dashboard/pnpm-workspace.yaml +7 -0
  40. package/dashboard/tsconfig.json +10 -0
  41. package/installer/cli.js +0 -0
  42. package/installer/index.js +262 -62
  43. package/knowledge/INDEX.md +34 -0
  44. package/knowledge/agents-registry.json +254 -0
  45. package/knowledge/channels-config.json +6 -0
  46. package/knowledge/commands-keywords.json +466 -0
  47. package/knowledge/commands-registry.json +2791 -0
  48. package/knowledge/commands-registry.json.bak +2791 -0
  49. package/knowledge/ecosystems.json +7 -0
  50. package/knowledge/obsidian-config.json +112 -0
  51. package/package.json +10 -6
  52. package/pyproject.toml +1 -1
  53. package/scripts/check-version.js +13 -0
  54. package/scripts/dashboard-api.py +636 -0
  55. package/scripts/knowledge-index.py +113 -0
  56. package/scripts/skill_validator.py +217 -0
  57. package/scripts/start-dashboard.sh +54 -0
  58. package/scripts/synapse-bridge.py +199 -0
  59. package/scripts/tools/brand_voice_analyzer.py +192 -0
  60. package/scripts/tools/dcf_calculator.py +168 -0
  61. package/scripts/tools/headline_scorer.py +215 -0
  62. package/scripts/tools/okr_cascade.py +207 -0
  63. package/scripts/tools/rice_prioritizer.py +230 -0
  64. package/scripts/tools/saas_metrics.py +234 -0
  65. package/scripts/tools/seo_checker.py +197 -0
  66. package/scripts/tools/tech_debt_analyzer.py +206 -0
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env python3
2
+ """OKR Cascade Generator -- ArkaOS v2.
3
+
4
+ Generates cascading OKRs (Company -> Product -> Team) from a strategy type.
5
+ Supports strategies: growth, retention, revenue, innovation.
6
+ Outputs alignment scores and a visual cascade dashboard.
7
+
8
+ Usage:
9
+ python okr_cascade.py growth
10
+ python okr_cascade.py retention --teams "Engineering,Design,Data"
11
+ python okr_cascade.py revenue --contribution 0.4 --json
12
+ """
13
+ from __future__ import annotations
14
+ import argparse, json, sys
15
+ from dataclasses import dataclass, field, asdict
16
+ from datetime import datetime
17
+ from typing import Dict, List
18
+
19
+ @dataclass
20
+ class KeyResult:
21
+ """A single measurable key result."""
22
+ id: str; title: str; target: float = 0.0; parent_id: str = ""
23
+
24
+ @dataclass
25
+ class Objective:
26
+ """An objective containing key results."""
27
+ id: str; title: str; owner: str = ""; parent_id: str = ""
28
+ key_results: List[KeyResult] = field(default_factory=list)
29
+
30
+ @dataclass
31
+ class OKRLevel:
32
+ """A set of objectives at one organisational level."""
33
+ level: str; objectives: List[Objective] = field(default_factory=list)
34
+
35
+ STRATEGIES: Dict[str, Dict[str, List[str]]] = {
36
+ "growth": {
37
+ "objectives": ["Accelerate user acquisition and market expansion",
38
+ "Achieve product-market fit in new segments",
39
+ "Build a sustainable growth engine"],
40
+ "key_results": ["Increase MAU by {pct}%", "Achieve {pct}% MoM growth rate",
41
+ "Reduce CAC by {pct}%"],
42
+ },
43
+ "retention": {
44
+ "objectives": ["Create lasting customer value and loyalty",
45
+ "Deliver a superior user experience",
46
+ "Maximise customer lifetime value"],
47
+ "key_results": ["Improve retention to {pct}%", "Increase NPS by {pct} points",
48
+ "Reduce churn to below {pct}%"],
49
+ },
50
+ "revenue": {
51
+ "objectives": ["Drive sustainable revenue growth",
52
+ "Optimise monetisation strategy", "Expand revenue per customer"],
53
+ "key_results": ["Grow ARR by {pct}%", "Increase ARPU by {pct}%",
54
+ "Achieve {pct}% gross margin"],
55
+ },
56
+ "innovation": {
57
+ "objectives": ["Lead the market through product innovation",
58
+ "Establish leadership in key capability areas",
59
+ "Build sustainable competitive differentiation"],
60
+ "key_results": ["Launch {pct} breakthrough features",
61
+ "Achieve {pct}% revenue from new products",
62
+ "Reduce time-to-market by {pct}%"],
63
+ },
64
+ }
65
+ PRODUCT_PREFIX = {"growth": "Build viral product features for",
66
+ "retention": "Design sticky experiences for",
67
+ "revenue": "Optimise product monetisation for",
68
+ "innovation": "Ship innovative features for"}
69
+
70
+ def _quarter() -> str:
71
+ now = datetime.now()
72
+ return f"Q{(now.month - 1) // 3 + 1} {now.year}"
73
+
74
+ def generate_cascade(strategy: str, teams: List[str], contribution: float,
75
+ target: float) -> Dict:
76
+ """Generate the full Company -> Product -> Team cascade."""
77
+ tpl, quarter = STRATEGIES[strategy], _quarter()
78
+ company = OKRLevel(level="Company")
79
+ for i, obj_title in enumerate(tpl["objectives"]):
80
+ obj = Objective(id=f"CO-{i+1}", title=obj_title, owner="CEO")
81
+ for j, kr_tpl in enumerate(tpl["key_results"]):
82
+ obj.key_results.append(KeyResult(
83
+ id=f"CO-{i+1}-KR{j+1}",
84
+ title=kr_tpl.replace("{pct}", str(int(target))), target=target))
85
+ company.objectives.append(obj)
86
+ prefix = PRODUCT_PREFIX.get(strategy, "Product:")
87
+ product = OKRLevel(level="Product")
88
+ for co in company.objectives:
89
+ po = Objective(id=co.id.replace("CO", "PO"),
90
+ title=f"{prefix} {co.title.lower()}",
91
+ owner="Head of Product", parent_id=co.id)
92
+ for kr in co.key_results:
93
+ po.key_results.append(KeyResult(
94
+ id=kr.id.replace("CO", "PO"), title=f"[Product] {kr.title}",
95
+ target=round(kr.target * contribution, 1), parent_id=kr.id))
96
+ product.objectives.append(po)
97
+ team_levels: List[OKRLevel] = []
98
+ share = round(1.0 / max(len(teams), 1), 2)
99
+ for team in teams:
100
+ tl = OKRLevel(level=f"Team:{team}")
101
+ for po in product.objectives:
102
+ to = Objective(id=po.id.replace("PO", team[:3].upper()),
103
+ title=f"[{team}] {po.title}",
104
+ owner=f"{team} Lead", parent_id=po.id)
105
+ for kr in po.key_results[:2]:
106
+ to.key_results.append(KeyResult(
107
+ id=kr.id.replace("PO", team[:3].upper()),
108
+ title=f"[{team}] {kr.title}",
109
+ target=round(kr.target * share, 1), parent_id=kr.id))
110
+ tl.objectives.append(to)
111
+ team_levels.append(tl)
112
+ return {"quarter": quarter, "strategy": strategy, "company": company,
113
+ "product": product, "teams": team_levels, "contribution": contribution}
114
+
115
+ def alignment_scores(cascade: Dict) -> Dict[str, float]:
116
+ """Calculate vertical alignment, coverage, balance, and overall scores."""
117
+ linked = total = 0
118
+ for lvl in [cascade["product"]] + cascade["teams"]:
119
+ for obj in lvl.objectives:
120
+ total += 1
121
+ linked += 1 if obj.parent_id else 0
122
+ vertical = round((linked / max(total, 1)) * 100, 1)
123
+ co_krs = sum(len(o.key_results) for o in cascade["company"].objectives)
124
+ po_krs = sum(len(o.key_results) for o in cascade["product"].objectives)
125
+ coverage = round((po_krs / max(co_krs, 1)) * 100, 1)
126
+ counts = [len(t.objectives) for t in cascade["teams"]]
127
+ avg = sum(counts) / max(len(counts), 1)
128
+ variance = sum((c - avg) ** 2 for c in counts) / max(len(counts), 1)
129
+ balance = round(max(0, 100 - variance * 10), 1)
130
+ overall = round(vertical * 0.4 + coverage * 0.3 + balance * 0.3, 1)
131
+ return {"vertical": vertical, "coverage": coverage, "balance": balance, "overall": overall}
132
+
133
+ def format_dashboard(cascade: Dict, scores: Dict[str, float]) -> str:
134
+ """Render a plain-text dashboard."""
135
+ lines = ["=" * 60, "OKR CASCADE DASHBOARD",
136
+ f"Quarter: {cascade['quarter']} | Strategy: {cascade['strategy'].upper()}",
137
+ f"Product contribution: {cascade['contribution'] * 100:.0f}%",
138
+ "=" * 60, "", "COMPANY OKRS"]
139
+ for o in cascade["company"].objectives:
140
+ lines.append(f" {o.id}: {o.title}")
141
+ for kr in o.key_results:
142
+ lines.append(f" {kr.id}: {kr.title}")
143
+ lines += ["", "PRODUCT OKRS"]
144
+ for o in cascade["product"].objectives:
145
+ lines.append(f" {o.id}: {o.title} (supports {o.parent_id})")
146
+ for kr in o.key_results:
147
+ lines.append(f" {kr.id}: {kr.title} [target: {kr.target}]")
148
+ lines += ["", "TEAM OKRS"]
149
+ for tl in cascade["teams"]:
150
+ lines.append(f" --- {tl.level} ---")
151
+ for o in tl.objectives:
152
+ lines.append(f" {o.id}: {o.title}")
153
+ for kr in o.key_results:
154
+ lines.append(f" {kr.id}: {kr.title} [target: {kr.target}]")
155
+ lines += ["", "ALIGNMENT SCORES", "-" * 40]
156
+ for k, v in scores.items():
157
+ tag = "[OK]" if v >= 80 else "[!!]" if v >= 60 else "[XX]"
158
+ lines.append(f" {tag} {k.title()}: {v}%")
159
+ if scores["overall"] >= 80:
160
+ lines.append("\n Overall alignment is GOOD (>= 80%)")
161
+ elif scores["overall"] >= 60:
162
+ lines.append("\n Overall alignment NEEDS ATTENTION (60-80%)")
163
+ else:
164
+ lines.append("\n Overall alignment is POOR (< 60%)")
165
+ return "\n".join(lines)
166
+
167
+ def to_json(cascade: Dict, scores: Dict[str, float]) -> str:
168
+ """Serialise the full cascade to JSON."""
169
+ def _level(lvl: OKRLevel) -> Dict:
170
+ return {"level": lvl.level, "objectives": [asdict(o) for o in lvl.objectives]}
171
+ return json.dumps({"quarter": cascade["quarter"], "strategy": cascade["strategy"],
172
+ "contribution": cascade["contribution"], "company": _level(cascade["company"]),
173
+ "product": _level(cascade["product"]),
174
+ "teams": [_level(t) for t in cascade["teams"]],
175
+ "alignment_scores": scores}, indent=2)
176
+
177
+ def main() -> int:
178
+ """Entry point."""
179
+ parser = argparse.ArgumentParser(
180
+ description="Generate cascading OKRs (Company -> Product -> Team)")
181
+ parser.add_argument("strategy", choices=list(STRATEGIES.keys()),
182
+ help="Strategy type for OKR generation")
183
+ parser.add_argument("--teams", "-t", default="Growth,Platform,Mobile,Data",
184
+ help="Comma-separated team names (default: Growth,Platform,Mobile,Data)")
185
+ parser.add_argument("--contribution", "-c", type=float, default=0.3,
186
+ help="Product contribution fraction 0-1 (default: 0.3)")
187
+ parser.add_argument("--target", type=float, default=30,
188
+ help="Numeric target for KR templates (default: 30)")
189
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
190
+ args = parser.parse_args()
191
+ if not 0 < args.contribution <= 1:
192
+ print("Error: --contribution must be between 0 and 1", file=sys.stderr)
193
+ return 2
194
+ teams = [t.strip() for t in args.teams.split(",") if t.strip()]
195
+ if not teams:
196
+ print("Error: at least one team is required", file=sys.stderr)
197
+ return 2
198
+ cascade = generate_cascade(args.strategy, teams, args.contribution, args.target)
199
+ scores = alignment_scores(cascade)
200
+ if args.json:
201
+ print(to_json(cascade, scores))
202
+ else:
203
+ print(format_dashboard(cascade, scores))
204
+ return 0 if scores["overall"] >= 60 else 1
205
+
206
+ if __name__ == "__main__":
207
+ sys.exit(main())
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """RICE Prioritizer -- rank features by Reach, Impact, Confidence, Effort.
3
+ RICE = (Reach x Impact x Confidence) / Effort.
4
+ Part of ArkaOS v2 -- stdlib-only, no pip dependencies.
5
+ """
6
+ from __future__ import annotations
7
+ import argparse, json, sys
8
+ from dataclasses import asdict, dataclass, field
9
+ from typing import Dict, List
10
+
11
+ IMPACT_MAP: Dict[str, float] = {"massive": 3.0, "high": 2.0, "medium": 1.0, "low": 0.5, "minimal": 0.25}
12
+ CONFIDENCE_MAP: Dict[str, int] = {"high": 100, "medium": 80, "low": 50}
13
+ EFFORT_MAP: Dict[str, int] = {"xl": 13, "l": 8, "m": 5, "s": 3, "xs": 1}
14
+
15
+ @dataclass
16
+ class Feature:
17
+ name: str
18
+ reach: int = 0
19
+ impact: str = "medium"
20
+ confidence: str = "medium"
21
+ effort: str = "m"
22
+ description: str = ""
23
+ rice_score: float = 0.0
24
+ category: str = ""
25
+
26
+ @dataclass
27
+ class PortfolioAnalysis:
28
+ total_features: int = 0
29
+ total_effort_months: int = 0
30
+ total_reach: int = 0
31
+ average_rice: float = 0.0
32
+ quick_wins: List[str] = field(default_factory=list)
33
+ big_bets: List[str] = field(default_factory=list)
34
+
35
+ @dataclass
36
+ class RICEResult:
37
+ features: List[Feature] = field(default_factory=list)
38
+ analysis: PortfolioAnalysis = field(default_factory=PortfolioAnalysis)
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Core logic
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def calculate_rice(reach: int, impact: str, confidence: str, effort: str) -> float:
46
+ """Compute the RICE score for a single feature."""
47
+ i = IMPACT_MAP.get(impact.lower(), 1.0)
48
+ c = CONFIDENCE_MAP.get(confidence.lower(), 50) / 100.0
49
+ e = EFFORT_MAP.get(effort.lower(), 5)
50
+ if e == 0:
51
+ return 0.0
52
+ return round((reach * i * c) / e, 2)
53
+
54
+
55
+ def _categorize(feature: Feature) -> str:
56
+ """Classify a feature as quick-win, big-bet, fill-in, or time-sink."""
57
+ imp = feature.impact.lower()
58
+ eff = feature.effort.lower()
59
+ high_impact = imp in ("massive", "high")
60
+ low_effort = eff in ("xs", "s")
61
+ high_effort = eff in ("l", "xl")
62
+ if high_impact and low_effort:
63
+ return "quick-win"
64
+ if high_impact and high_effort:
65
+ return "big-bet"
66
+ if not high_impact and low_effort:
67
+ return "fill-in"
68
+ return "time-sink"
69
+
70
+
71
+ def prioritize(raw_features: List[Dict]) -> RICEResult:
72
+ """Score, rank, and analyze a list of feature dicts."""
73
+ features: List[Feature] = []
74
+ for raw in raw_features:
75
+ f = Feature(
76
+ name=raw.get("name", "Unnamed"),
77
+ reach=int(raw.get("reach", 0)),
78
+ impact=str(raw.get("impact", "medium")),
79
+ confidence=str(raw.get("confidence", "medium")),
80
+ effort=str(raw.get("effort", "m")),
81
+ description=str(raw.get("description", "")),
82
+ )
83
+ f.rice_score = calculate_rice(f.reach, f.impact, f.confidence, f.effort)
84
+ f.category = _categorize(f)
85
+ features.append(f)
86
+
87
+ features.sort(key=lambda f: f.rice_score, reverse=True)
88
+
89
+ quick_wins = [f.name for f in features if f.category == "quick-win"]
90
+ big_bets = [f.name for f in features if f.category == "big-bet"]
91
+ total_effort = sum(EFFORT_MAP.get(f.effort.lower(), 5) for f in features)
92
+ total_reach = sum(f.reach for f in features)
93
+ avg_rice = round(sum(f.rice_score for f in features) / len(features), 2) if features else 0.0
94
+
95
+ analysis = PortfolioAnalysis(
96
+ total_features=len(features),
97
+ total_effort_months=total_effort,
98
+ total_reach=total_reach,
99
+ average_rice=avg_rice,
100
+ quick_wins=quick_wins[:5],
101
+ big_bets=big_bets[:5],
102
+ )
103
+
104
+ return RICEResult(features=features, analysis=analysis)
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Output formatting
109
+ # ---------------------------------------------------------------------------
110
+
111
+ def _format_text(result: RICEResult) -> str:
112
+ """Human-readable RICE report."""
113
+ lines = [
114
+ "=" * 60,
115
+ " RICE PRIORITIZATION RESULTS",
116
+ "=" * 60,
117
+ "",
118
+ " Rank RICE Category Feature",
119
+ " ---- ------- ---------- -------",
120
+ ]
121
+ for i, f in enumerate(result.features, 1):
122
+ lines.append(f" {i:<4} {f.rice_score:>7.1f} {f.category:<12} {f.name}")
123
+
124
+ a = result.analysis
125
+ lines += [
126
+ "",
127
+ "-" * 60,
128
+ " PORTFOLIO ANALYSIS",
129
+ "-" * 60,
130
+ f" Total features: {a.total_features}",
131
+ f" Total effort: {a.total_effort_months} person-months",
132
+ f" Total reach: {a.total_reach:,} users",
133
+ f" Average RICE score: {a.average_rice}",
134
+ "",
135
+ ]
136
+
137
+ if a.quick_wins:
138
+ lines.append(" Quick Wins (high impact, low effort):")
139
+ for name in a.quick_wins:
140
+ lines.append(f" - {name}")
141
+ else:
142
+ lines.append(" Quick Wins: none identified")
143
+
144
+ lines.append("")
145
+ if a.big_bets:
146
+ lines.append(" Big Bets (high impact, high effort):")
147
+ for name in a.big_bets:
148
+ lines.append(f" - {name}")
149
+ else:
150
+ lines.append(" Big Bets: none identified")
151
+
152
+ lines.append("=" * 60)
153
+ return "\n".join(lines)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Input loading
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def _load_features(source: str) -> List[Dict]:
161
+ """Parse JSON from a string. Expects a list of feature objects."""
162
+ data = json.loads(source)
163
+ if isinstance(data, dict) and "features" in data:
164
+ data = data["features"]
165
+ if not isinstance(data, list):
166
+ raise ValueError("Expected a JSON array of feature objects.")
167
+ return data
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # CLI
172
+ # ---------------------------------------------------------------------------
173
+
174
+ def main() -> int:
175
+ """Entry point. Returns 0=success, 1=warnings, 2=errors."""
176
+ parser = argparse.ArgumentParser(
177
+ description="RICE Prioritizer -- rank features by Reach, Impact, Confidence, Effort.",
178
+ )
179
+ parser.add_argument(
180
+ "input", nargs="?", default=None,
181
+ help="JSON file with features, or inline JSON string",
182
+ )
183
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
184
+ args = parser.parse_args()
185
+
186
+ # Load input
187
+ raw_json: str = ""
188
+ try:
189
+ if args.input:
190
+ # Try as file first, then as inline JSON
191
+ try:
192
+ with open(args.input, "r", encoding="utf-8") as fh:
193
+ raw_json = fh.read()
194
+ except (FileNotFoundError, IsADirectoryError):
195
+ raw_json = args.input
196
+ elif not sys.stdin.isatty():
197
+ raw_json = sys.stdin.read()
198
+ else:
199
+ parser.print_help()
200
+ return 2
201
+ except OSError as exc:
202
+ print(f"Error: {exc}", file=sys.stderr)
203
+ return 2
204
+
205
+ if not raw_json.strip():
206
+ print("Error: no input provided.", file=sys.stderr)
207
+ return 2
208
+
209
+ try:
210
+ features = _load_features(raw_json)
211
+ except (json.JSONDecodeError, ValueError) as exc:
212
+ print(f"Error: invalid JSON -- {exc}", file=sys.stderr)
213
+ return 2
214
+
215
+ if not features:
216
+ print("Error: feature list is empty.", file=sys.stderr)
217
+ return 2
218
+
219
+ result = prioritize(features)
220
+
221
+ if args.json:
222
+ print(json.dumps(asdict(result), indent=2))
223
+ else:
224
+ print(_format_text(result))
225
+
226
+ return 0
227
+
228
+
229
+ if __name__ == "__main__":
230
+ sys.exit(main())
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env python3
2
+ """SaaS Metrics Calculator -- ArkaOS v2.
3
+
4
+ Calculates Quick Ratio, net MRR, growth rate, and optional LTV/CAC
5
+ from MRR components. Assigns health status: Critical / Watch / Healthy / Excellent.
6
+
7
+ Adapted from claude-skills/finance/saas-metrics-coach/scripts/quick_ratio_calculator.py
8
+
9
+ Usage:
10
+ python saas_metrics.py --new-mrr 10000 --churned 3000
11
+ python saas_metrics.py --new-mrr 10000 --expansion 2000 --churned 3000 --contraction 500
12
+ python saas_metrics.py --new-mrr 10000 --churned 2000 --prev-mrr 50000 --ltv 3600 --cac 900 --json
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from dataclasses import dataclass, asdict
21
+ from typing import Optional
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Data structures
25
+ # ---------------------------------------------------------------------------
26
+
27
+ @dataclass
28
+ class MRRComponents:
29
+ """Raw MRR inputs."""
30
+ new_mrr: float
31
+ expansion_mrr: float
32
+ churned_mrr: float
33
+ contraction_mrr: float
34
+ previous_mrr: Optional[float] = None
35
+
36
+ @dataclass
37
+ class SaaSMetrics:
38
+ """Calculated SaaS metrics."""
39
+ quick_ratio: Optional[float]
40
+ quick_ratio_display: str
41
+ net_new_mrr: float
42
+ growth_mrr: float
43
+ lost_mrr: float
44
+ current_mrr: Optional[float]
45
+ mrr_growth_rate: Optional[float]
46
+ ltv_cac_ratio: Optional[float]
47
+ status: str
48
+ interpretation: str
49
+ new_mrr_pct: float
50
+ expansion_mrr_pct: float
51
+ churned_mrr_pct: float
52
+ contraction_mrr_pct: float
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Calculation
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def calculate_metrics(
59
+ components: MRRComponents,
60
+ ltv: Optional[float] = None,
61
+ cac: Optional[float] = None,
62
+ ) -> SaaSMetrics:
63
+ """Calculate all SaaS metrics from MRR components."""
64
+ growth = components.new_mrr + components.expansion_mrr
65
+ lost = components.churned_mrr + components.contraction_mrr
66
+ net_new = growth - lost
67
+
68
+ # Quick Ratio
69
+ if lost == 0:
70
+ qr = None if growth == 0 else float("inf")
71
+ qr_display = "0.00" if growth == 0 else "inf"
72
+ else:
73
+ qr = round(growth / lost, 2)
74
+ qr_display = f"{qr:.2f}"
75
+
76
+ # Status
77
+ if qr is None or (isinstance(qr, float) and qr < 1):
78
+ status, interp = "Critical", "Losing revenue faster than gaining -- unsustainable"
79
+ elif qr == float("inf") or qr >= 4:
80
+ status, interp = "Excellent", "Strong efficient growth -- gaining 4x+ faster than losing"
81
+ elif qr >= 2:
82
+ status, interp = "Healthy", "Good growth efficiency -- gaining 2x+ faster than losing"
83
+ else:
84
+ status, interp = "Watch", "Marginal growth -- barely gaining more than losing"
85
+
86
+ # Breakdown percentages
87
+ new_pct = round((components.new_mrr / growth) * 100, 1) if growth > 0 else 0.0
88
+ exp_pct = round((components.expansion_mrr / growth) * 100, 1) if growth > 0 else 0.0
89
+ churn_pct = round((components.churned_mrr / lost) * 100, 1) if lost > 0 else 0.0
90
+ contr_pct = round((components.contraction_mrr / lost) * 100, 1) if lost > 0 else 0.0
91
+
92
+ # Current MRR and growth rate
93
+ current_mrr = (components.previous_mrr + net_new) if components.previous_mrr is not None else None
94
+ mrr_growth = None
95
+ if components.previous_mrr and components.previous_mrr > 0:
96
+ mrr_growth = round((net_new / components.previous_mrr) * 100, 2)
97
+
98
+ # LTV/CAC
99
+ ltv_cac = round(ltv / cac, 2) if ltv and cac and cac > 0 else None
100
+
101
+ return SaaSMetrics(
102
+ quick_ratio=qr if qr != float("inf") else None,
103
+ quick_ratio_display=qr_display,
104
+ net_new_mrr=round(net_new, 2),
105
+ growth_mrr=round(growth, 2),
106
+ lost_mrr=round(lost, 2),
107
+ current_mrr=round(current_mrr, 2) if current_mrr is not None else None,
108
+ mrr_growth_rate=mrr_growth,
109
+ ltv_cac_ratio=ltv_cac,
110
+ status=status,
111
+ interpretation=interp,
112
+ new_mrr_pct=new_pct,
113
+ expansion_mrr_pct=exp_pct,
114
+ churned_mrr_pct=churn_pct,
115
+ contraction_mrr_pct=contr_pct,
116
+ )
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Formatters
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def format_report(m: SaaSMetrics, components: MRRComponents) -> str:
123
+ """Render a plain-text report."""
124
+ lines = [
125
+ "=" * 60,
126
+ "SAAS METRICS ANALYSIS",
127
+ "=" * 60,
128
+ "",
129
+ f"QUICK RATIO: {m.quick_ratio_display}",
130
+ f"Status: {m.status}",
131
+ f"{m.interpretation}",
132
+ "",
133
+ "MRR COMPONENTS",
134
+ f" Growth MRR (New + Expansion): ${m.growth_mrr:,.2f}",
135
+ f" New MRR: ${components.new_mrr:,.2f} ({m.new_mrr_pct:.1f}%)",
136
+ f" Expansion MRR: ${components.expansion_mrr:,.2f} ({m.expansion_mrr_pct:.1f}%)",
137
+ f" Lost MRR (Churned + Contraction): ${m.lost_mrr:,.2f}",
138
+ f" Churned MRR: ${components.churned_mrr:,.2f} ({m.churned_mrr_pct:.1f}%)",
139
+ f" Contraction MRR: ${components.contraction_mrr:,.2f} ({m.contraction_mrr_pct:.1f}%)",
140
+ "",
141
+ f" Net new MRR: ${m.net_new_mrr:,.2f}",
142
+ ]
143
+
144
+ if m.current_mrr is not None:
145
+ lines.append(f" Current MRR: ${m.current_mrr:,.2f}")
146
+ if m.mrr_growth_rate is not None:
147
+ lines.append(f" MRR growth rate: {m.mrr_growth_rate:.2f}%")
148
+ if m.ltv_cac_ratio is not None:
149
+ lines += [
150
+ "",
151
+ "UNIT ECONOMICS",
152
+ f" LTV/CAC ratio: {m.ltv_cac_ratio:.2f}x",
153
+ ]
154
+ if m.ltv_cac_ratio >= 3:
155
+ lines.append(" Healthy -- LTV/CAC >= 3x")
156
+ elif m.ltv_cac_ratio >= 1:
157
+ lines.append(" Watch -- LTV/CAC between 1x and 3x")
158
+ else:
159
+ lines.append(" Critical -- LTV/CAC below 1x")
160
+
161
+ lines += [
162
+ "",
163
+ "BENCHMARKS",
164
+ " Quick Ratio < 1.0 = Critical (net revenue loss)",
165
+ " Quick Ratio 1-2 = Watch (marginal growth)",
166
+ " Quick Ratio 2-4 = Healthy (good efficiency)",
167
+ " Quick Ratio > 4 = Excellent (strong growth)",
168
+ "",
169
+ "=" * 60,
170
+ ]
171
+ return "\n".join(lines)
172
+
173
+
174
+ def to_json(m: SaaSMetrics) -> str:
175
+ """Serialise metrics to JSON."""
176
+ return json.dumps(asdict(m), indent=2)
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # CLI
181
+ # ---------------------------------------------------------------------------
182
+
183
+ def main() -> int:
184
+ """Entry point."""
185
+ parser = argparse.ArgumentParser(
186
+ description="SaaS Metrics Calculator -- Quick Ratio, net MRR, LTV/CAC",
187
+ )
188
+ parser.add_argument("--new-mrr", type=float, required=True,
189
+ help="New MRR from new customers")
190
+ parser.add_argument("--expansion", type=float, default=0.0,
191
+ help="Expansion MRR from upsells (default: 0)")
192
+ parser.add_argument("--churned", type=float, required=True,
193
+ help="Churned MRR from lost customers")
194
+ parser.add_argument("--contraction", type=float, default=0.0,
195
+ help="Contraction MRR from downgrades (default: 0)")
196
+ parser.add_argument("--prev-mrr", type=float, default=None,
197
+ help="Previous month total MRR (for growth rate)")
198
+ parser.add_argument("--ltv", type=float, default=None,
199
+ help="Customer lifetime value (for LTV/CAC)")
200
+ parser.add_argument("--cac", type=float, default=None,
201
+ help="Customer acquisition cost (for LTV/CAC)")
202
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
203
+
204
+ args = parser.parse_args()
205
+
206
+ if args.new_mrr < 0 or args.churned < 0:
207
+ print("Error: MRR values must be non-negative", file=sys.stderr)
208
+ return 2
209
+
210
+ components = MRRComponents(
211
+ new_mrr=args.new_mrr,
212
+ expansion_mrr=args.expansion,
213
+ churned_mrr=args.churned,
214
+ contraction_mrr=args.contraction,
215
+ previous_mrr=args.prev_mrr,
216
+ )
217
+
218
+ metrics = calculate_metrics(components, ltv=args.ltv, cac=args.cac)
219
+
220
+ if args.json:
221
+ print(to_json(metrics))
222
+ else:
223
+ print(format_report(metrics, components))
224
+
225
+ exit_code = 0
226
+ if metrics.status == "Critical":
227
+ exit_code = 2
228
+ elif metrics.status == "Watch":
229
+ exit_code = 1
230
+ return exit_code
231
+
232
+
233
+ if __name__ == "__main__":
234
+ sys.exit(main())