Add cross-tool positioning, Python helpers, tiers, and hygiene docs
Five improvements to position the library as a serious engineering project: 1. Cross-tool compatibility — new README "Works With" section honestly documenting where skills run (Claude Code natively; SKILL.md bodies port to other agents and chat LLMs as system prompts). 2. Python helper scripts (stdlib-only) for the three strongest skills: - sprint-planning: capacity_calculator.py (recommended commitment) - rice-prioritisation: rice_calculator.py (ranks, flags quick wins/moonshots) - cs-health-scorecard: health_score.py (weighted total + RAG) Each is wired into its SKILL.md and synced to the plugin copies. 3. Explicit skill tiering — TIERS.md + README section marking 46 Production-Ready skills and calling out Experimental (external-dependency) ones; everything else is Stable. 4. Repository hygiene — new CHANGELOG.md (Keep a Changelog format) and SKILL-AUTHORING-STANDARD.md; refreshed SECURITY.md version table and helper-script disclosure; added .gitignore. 5. Related Projects — README section linking to alirezarezvani/claude-skills and the major awesome-claude-skills / awesome-claude-code lists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JWn5jRD5tcEFKrubjQ6Px
This commit is contained in:
@@ -35,6 +35,20 @@ Score each dimension 1–5. Weight as shown. Calculate weighted total out of 100
|
||||
- 60–79: Amber (at risk, needs attention)
|
||||
- 0–59: Red (high churn risk, escalate)
|
||||
|
||||
## Programmatic Helper
|
||||
|
||||
This skill ships with a stdlib-only Python script that applies the weights above and converts the weighted total to a RAG status — so the headline score is computed identically every time and weights always sum to 100%.
|
||||
|
||||
```bash
|
||||
# Five scores 1-5 in order: adoption engagement outcomes support commercial
|
||||
python3 scripts/health_score.py --scores 4 3 4 2 5 --account "Acme Corp"
|
||||
|
||||
# Or from JSON (lets you override the default weights per account/segment)
|
||||
python3 scripts/health_score.py --input account.json
|
||||
```
|
||||
|
||||
It returns the per-dimension weighted points, the **total out of 100**, and the **RAG band** (Green ≥80, Amber 60–79, Red <60) with a one-line next step. Run it to set the headline number, then write the dimension detail and actions below around it. Add `--json` for downstream tooling.
|
||||
|
||||
## Output Format
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Customer health score calculator for the cs-health-scorecard skill.
|
||||
|
||||
Takes per-dimension scores (1-5), applies the standard weights, and returns a
|
||||
weighted total out of 100 plus a RAG status — so the headline number in a health
|
||||
scorecard is computed the same way every time. Pure Python standard library —
|
||||
no dependencies, no network access.
|
||||
|
||||
Standard dimensions and weights (override with --weights or in the JSON):
|
||||
|
||||
Product Adoption 30%
|
||||
Engagement 20%
|
||||
Outcomes 20%
|
||||
Support Health 15%
|
||||
Commercial 15%
|
||||
|
||||
Usage
|
||||
-----
|
||||
Quick scoring from flags (order: adoption engagement outcomes support commercial):
|
||||
|
||||
python3 health_score.py --scores 4 3 4 2 5
|
||||
|
||||
From a JSON file that can also override weights:
|
||||
|
||||
python3 health_score.py --input account.json
|
||||
|
||||
account.json:
|
||||
|
||||
{
|
||||
"account": "Acme Corp",
|
||||
"scores": {"Product Adoption": 4, "Engagement": 3, "Outcomes": 4,
|
||||
"Support Health": 2, "Commercial": 5},
|
||||
"weights": {"Product Adoption": 0.30, "Engagement": 0.20, "Outcomes": 0.20,
|
||||
"Support Health": 0.15, "Commercial": 0.15}
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
DEFAULT_WEIGHTS = {
|
||||
"Product Adoption": 0.30,
|
||||
"Engagement": 0.20,
|
||||
"Outcomes": 0.20,
|
||||
"Support Health": 0.15,
|
||||
"Commercial": 0.15,
|
||||
}
|
||||
MAX_DIMENSION_SCORE = 5
|
||||
|
||||
|
||||
def rag(total: float) -> str:
|
||||
if total >= 80:
|
||||
return "Green"
|
||||
if total >= 60:
|
||||
return "Amber"
|
||||
return "Red"
|
||||
|
||||
|
||||
def compute(scores: dict[str, float], weights: dict[str, float] | None = None) -> dict:
|
||||
weights = weights or DEFAULT_WEIGHTS
|
||||
weight_sum = sum(weights.values())
|
||||
if abs(weight_sum - 1.0) > 0.001:
|
||||
raise ValueError(f"Weights must sum to 1.0 (got {weight_sum:.3f}).")
|
||||
|
||||
breakdown = []
|
||||
total = 0.0
|
||||
for dimension, weight in weights.items():
|
||||
if dimension not in scores:
|
||||
raise ValueError(f"Missing score for dimension '{dimension}'.")
|
||||
raw = float(scores[dimension])
|
||||
if not 1 <= raw <= MAX_DIMENSION_SCORE:
|
||||
raise ValueError(f"Score for '{dimension}' must be between 1 and {MAX_DIMENSION_SCORE} (got {raw}).")
|
||||
# Normalise the 1-5 score to a 0-100 contribution weighted by importance.
|
||||
weighted = (raw / MAX_DIMENSION_SCORE) * weight * 100
|
||||
total += weighted
|
||||
breakdown.append({
|
||||
"dimension": dimension,
|
||||
"score": raw,
|
||||
"weight": weight,
|
||||
"weighted_points": round(weighted, 1),
|
||||
})
|
||||
|
||||
total = round(total, 1)
|
||||
return {"total": total, "rag": rag(total), "breakdown": breakdown}
|
||||
|
||||
|
||||
def _render(result: dict, account: str | None) -> str:
|
||||
title = f"Customer Health Scorecard: {account}" if account else "Customer Health Scorecard"
|
||||
lines = [title, "=" * len(title)]
|
||||
lines.append(f"{'Dimension':<18} {'Score':>5} {'Weight':>7} {'Weighted':>9}")
|
||||
lines.append("-" * 41)
|
||||
for row in result["breakdown"]:
|
||||
lines.append(
|
||||
f"{row['dimension']:<18} {row['score']:>5g} {row['weight']*100:>6.0f}% {row['weighted_points']:>9g}"
|
||||
)
|
||||
lines.append("-" * 41)
|
||||
badge = {"Green": "🟢", "Amber": "🟡", "Red": "🔴"}[result["rag"]]
|
||||
lines.append(f"{'TOTAL':<18} {'':>5} {'100%':>7} {result['total']:>9g}/100")
|
||||
lines.append("")
|
||||
lines.append(f"Overall health: {badge} {result['rag']} — {result['total']}/100")
|
||||
guidance = {
|
||||
"Green": "Healthy — renew likely. Look for expansion signals.",
|
||||
"Amber": "At risk — needs attention. Build a save/grow plan before renewal.",
|
||||
"Red": "High churn risk — escalate now and assign an executive sponsor.",
|
||||
}[result["rag"]]
|
||||
lines.append(guidance)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _load_inputs(args: argparse.Namespace) -> tuple[dict, dict | None, str | None]:
|
||||
if args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input).read()
|
||||
data = json.loads(raw)
|
||||
return data["scores"], data.get("weights"), data.get("account")
|
||||
|
||||
if args.scores:
|
||||
dims = list(DEFAULT_WEIGHTS.keys())
|
||||
if len(args.scores) != len(dims):
|
||||
raise ValueError(f"--scores needs {len(dims)} values in order: {', '.join(dims)}")
|
||||
return dict(zip(dims, args.scores)), None, args.account
|
||||
|
||||
raise ValueError("Provide --input or --scores.")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--input", help="Path to a JSON file (or '-' for stdin).")
|
||||
parser.add_argument("--scores", nargs="+", type=float,
|
||||
help="Five scores 1-5 in order: adoption engagement outcomes support commercial.")
|
||||
parser.add_argument("--account", help="Account name for the report header.")
|
||||
parser.add_argument("--json", action="store_true", dest="as_json", help="Emit JSON instead of a report.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
scores, weights, account = _load_inputs(args)
|
||||
result = compute(scores, weights)
|
||||
except (ValueError, KeyError, json.JSONDecodeError, OSError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.as_json:
|
||||
result["account"] = account
|
||||
print(json.dumps(result, indent=2))
|
||||
else:
|
||||
print(_render(result, account))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -53,6 +53,22 @@ Availability factor: 0.7–0.85 depending on holidays/events
|
||||
Story points to commit = Historical velocity × Availability factor
|
||||
```
|
||||
|
||||
## Programmatic Helper
|
||||
|
||||
This skill ships with a stdlib-only Python script that computes capacity instead of estimating it by hand. Use it whenever the team's numbers are known — it applies the availability and 80% commit-ratio rules consistently.
|
||||
|
||||
```bash
|
||||
# Quick estimate from flags
|
||||
python3 scripts/capacity_calculator.py --team 5 --days 10 --velocity 30 --availability 0.8 --carryover 5
|
||||
|
||||
# Detailed estimate from per-member availability (JSON via stdin or --input file.json)
|
||||
echo '{"sprint_days":10,"historical_velocity":40,"carryover_points":8,
|
||||
"members":[{"name":"Ada","available_days":10},{"name":"Linus","available_days":7}]}' \
|
||||
| python3 scripts/capacity_calculator.py --input -
|
||||
```
|
||||
|
||||
The script returns available focus hours, a velocity figure adjusted for real availability, the **recommended commitment** (capped at 80% of velocity), and the remaining **capacity for new work** after carry-overs. Run it first, then build the sprint backlog to fit the recommended number. Add `--json` to pipe the result into other tooling.
|
||||
|
||||
## Output Format
|
||||
|
||||
### Sprint [N] — [Start Date] to [End Date]
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sprint capacity calculator for the sprint-planning skill.
|
||||
|
||||
Turns team and availability inputs into a recommended sprint commitment so the
|
||||
numbers in a sprint plan are computed, not guessed. Pure Python standard
|
||||
library — no dependencies, no network access.
|
||||
|
||||
Examples
|
||||
--------
|
||||
Quick estimate from flags:
|
||||
|
||||
python3 capacity_calculator.py --team 5 --days 10 --velocity 30 \
|
||||
--availability 0.8 --carryover 5
|
||||
|
||||
Detailed estimate from a JSON file describing each team member:
|
||||
|
||||
python3 capacity_calculator.py --input team.json
|
||||
|
||||
Where team.json looks like:
|
||||
|
||||
{
|
||||
"sprint_days": 10,
|
||||
"focus_hours_per_day": 6,
|
||||
"historical_velocity": 30,
|
||||
"carryover_points": 5,
|
||||
"commit_ratio": 0.8,
|
||||
"members": [
|
||||
{"name": "Ada", "available_days": 10},
|
||||
{"name": "Linus", "available_days": 7, "note": "2 days PTO, 1 day interview"}
|
||||
]
|
||||
}
|
||||
|
||||
The recommended commitment deliberately leaves slack for unplanned work — it
|
||||
never commits 100% of theoretical capacity.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Member:
|
||||
name: str
|
||||
available_days: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapacityInputs:
|
||||
sprint_days: int = 10
|
||||
focus_hours_per_day: float = 6.0
|
||||
historical_velocity: float | None = None
|
||||
carryover_points: float = 0.0
|
||||
commit_ratio: float = 0.8
|
||||
team_size: int | None = None
|
||||
availability_factor: float = 0.8
|
||||
members: list[Member] = field(default_factory=list)
|
||||
|
||||
|
||||
def _availability_from_members(inp: CapacityInputs) -> float:
|
||||
"""Return the blended availability factor (0-1) from per-member days."""
|
||||
if not inp.members:
|
||||
return inp.availability_factor
|
||||
theoretical = len(inp.members) * inp.sprint_days
|
||||
if theoretical == 0:
|
||||
return 0.0
|
||||
actual = sum(m.available_days for m in inp.members)
|
||||
return actual / theoretical
|
||||
|
||||
|
||||
def compute(inp: CapacityInputs) -> dict:
|
||||
team_size = inp.team_size if inp.team_size is not None else len(inp.members)
|
||||
if not team_size:
|
||||
raise ValueError("Provide --team or a non-empty members list.")
|
||||
|
||||
availability = _availability_from_members(inp)
|
||||
focus_hours = team_size * inp.sprint_days * inp.focus_hours_per_day * availability
|
||||
|
||||
result: dict = {
|
||||
"team_size": team_size,
|
||||
"sprint_days": inp.sprint_days,
|
||||
"focus_hours_per_day": inp.focus_hours_per_day,
|
||||
"availability_factor": round(availability, 3),
|
||||
"available_focus_hours": round(focus_hours, 1),
|
||||
}
|
||||
|
||||
if inp.historical_velocity is not None:
|
||||
velocity_adjusted = inp.historical_velocity * availability
|
||||
recommended = velocity_adjusted * inp.commit_ratio
|
||||
new_work_capacity = max(recommended - inp.carryover_points, 0.0)
|
||||
result.update(
|
||||
{
|
||||
"historical_velocity": inp.historical_velocity,
|
||||
"velocity_adjusted_for_availability": round(velocity_adjusted, 1),
|
||||
"commit_ratio": inp.commit_ratio,
|
||||
"carryover_points": inp.carryover_points,
|
||||
"recommended_commitment_points": round(recommended, 1),
|
||||
"capacity_for_new_work_points": round(new_work_capacity, 1),
|
||||
}
|
||||
)
|
||||
if inp.carryover_points > recommended:
|
||||
result["warning"] = (
|
||||
"Carry-over alone exceeds the recommended commitment — "
|
||||
"pull in little or no new work this sprint."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_inputs(args: argparse.Namespace) -> CapacityInputs:
|
||||
if args.input:
|
||||
raw = sys.stdin.read() if args.input == "-" else open(args.input).read()
|
||||
data = json.loads(raw)
|
||||
members = [
|
||||
Member(
|
||||
name=m.get("name", f"member-{i+1}"),
|
||||
available_days=float(m.get("available_days", data.get("sprint_days", 10))),
|
||||
note=m.get("note", ""),
|
||||
)
|
||||
for i, m in enumerate(data.get("members", []))
|
||||
]
|
||||
return CapacityInputs(
|
||||
sprint_days=int(data.get("sprint_days", 10)),
|
||||
focus_hours_per_day=float(data.get("focus_hours_per_day", 6.0)),
|
||||
historical_velocity=(
|
||||
float(data["historical_velocity"])
|
||||
if data.get("historical_velocity") is not None
|
||||
else None
|
||||
),
|
||||
carryover_points=float(data.get("carryover_points", 0.0)),
|
||||
commit_ratio=float(data.get("commit_ratio", 0.8)),
|
||||
team_size=data.get("team_size"),
|
||||
availability_factor=float(data.get("availability_factor", 0.8)),
|
||||
members=members,
|
||||
)
|
||||
|
||||
return CapacityInputs(
|
||||
sprint_days=args.days,
|
||||
focus_hours_per_day=args.focus_hours,
|
||||
historical_velocity=args.velocity,
|
||||
carryover_points=args.carryover,
|
||||
commit_ratio=args.commit_ratio,
|
||||
team_size=args.team,
|
||||
availability_factor=args.availability,
|
||||
)
|
||||
|
||||
|
||||
def _render(result: dict) -> str:
|
||||
lines = ["Sprint Capacity Estimate", "=" * 24]
|
||||
label = {
|
||||
"team_size": "Team size",
|
||||
"sprint_days": "Sprint days",
|
||||
"focus_hours_per_day": "Focus hours/day",
|
||||
"availability_factor": "Availability factor",
|
||||
"available_focus_hours": "Available focus hours",
|
||||
"historical_velocity": "Historical velocity (pts)",
|
||||
"velocity_adjusted_for_availability": "Velocity adj. for availability",
|
||||
"commit_ratio": "Commit ratio",
|
||||
"carryover_points": "Carry-over (pts)",
|
||||
"recommended_commitment_points": "RECOMMENDED commitment (pts)",
|
||||
"capacity_for_new_work_points": "Capacity for NEW work (pts)",
|
||||
}
|
||||
for key, text in label.items():
|
||||
if key in result:
|
||||
lines.append(f"{text:<32}: {result[key]}")
|
||||
if "warning" in result:
|
||||
lines.append("")
|
||||
lines.append(f"⚠️ {result['warning']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--input", help="Path to a JSON file describing the team (or '-' for stdin).")
|
||||
parser.add_argument("--team", type=int, help="Number of team members.")
|
||||
parser.add_argument("--days", type=int, default=10, help="Working days in the sprint (default: 10).")
|
||||
parser.add_argument("--focus-hours", type=float, default=6.0, dest="focus_hours",
|
||||
help="Focus hours per person per day (default: 6).")
|
||||
parser.add_argument("--velocity", type=float, help="Historical average velocity in story points.")
|
||||
parser.add_argument("--carryover", type=float, default=0.0, help="Carry-over story points from last sprint.")
|
||||
parser.add_argument("--availability", type=float, default=0.8,
|
||||
help="Availability factor 0-1 when not using per-member days (default: 0.8).")
|
||||
parser.add_argument("--commit-ratio", type=float, default=0.8, dest="commit_ratio",
|
||||
help="Fraction of velocity to commit, leaving slack (default: 0.8).")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON instead of a formatted report.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
result = compute(_parse_inputs(args))
|
||||
except (ValueError, json.JSONDecodeError, OSError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps(result, indent=2) if args.json else _render(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -25,6 +25,24 @@ Ask the user for these if not provided:
|
||||
## RICE Formula
|
||||
RICE Score = (Reach × Impact × Confidence) / Effort
|
||||
|
||||
## Programmatic Helper
|
||||
|
||||
This skill ships with a stdlib-only Python script that calculates and ranks RICE scores so the maths is consistent and the quick-win / moonshot flags are applied by rule, not by feel. Feed it the initiatives once R, I, C, and E are gathered.
|
||||
|
||||
```bash
|
||||
# From a JSON file (confidence accepts 0.8 or 80)
|
||||
python3 scripts/rice_calculator.py initiatives.json
|
||||
|
||||
# Or from a CSV with header: name,reach,impact,confidence,effort
|
||||
python3 scripts/rice_calculator.py initiatives.csv --format csv
|
||||
|
||||
# Or piped in
|
||||
echo '[{"name":"Onboarding","reach":5000,"impact":2,"confidence":0.8,"effort":3}]' \
|
||||
| python3 scripts/rice_calculator.py -
|
||||
```
|
||||
|
||||
It outputs a ranked table with computed RICE scores and auto-flags **quick-win** (strong score, low relative effort), **moonshot** (high impact, high effort), and **low-confidence** (≤50%) items. Use the computed ranking as the starting point, then apply the validation step below — never accept a surprising top rank without checking the estimates behind it.
|
||||
|
||||
## Process
|
||||
1. For each initiative provided, gather or estimate R, I, C, E values
|
||||
2. Flag where estimates are weak and note what data would improve them
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RICE score calculator for the rice-prioritisation skill.
|
||||
|
||||
Computes RICE = (Reach × Impact × Confidence) / Effort for a list of
|
||||
initiatives, ranks them, and flags quick wins and moonshots so the ranking in a
|
||||
prioritisation doc is calculated consistently rather than eyeballed. Pure Python
|
||||
standard library — no dependencies, no network access.
|
||||
|
||||
Input
|
||||
-----
|
||||
A JSON or CSV list of initiatives. Each needs: name, reach, impact, confidence,
|
||||
effort.
|
||||
|
||||
- impact uses the standard RICE scale (3, 2, 1, 0.5, 0.25) but any number works.
|
||||
- confidence is a fraction (0.8) or a percentage (80) — both are accepted.
|
||||
- effort is in person-months and must be > 0.
|
||||
|
||||
JSON example (rice.json):
|
||||
|
||||
[
|
||||
{"name": "Onboarding redesign", "reach": 5000, "impact": 2, "confidence": 0.8, "effort": 3},
|
||||
{"name": "Dark mode", "reach": 8000, "impact": 0.5, "confidence": 1.0, "effort": 1}
|
||||
]
|
||||
|
||||
CSV example (header row required):
|
||||
|
||||
name,reach,impact,confidence,effort
|
||||
Onboarding redesign,5000,2,0.8,3
|
||||
Dark mode,8000,0.5,1.0,1
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 rice_calculator.py rice.json
|
||||
python3 rice_calculator.py rice.csv --format csv
|
||||
cat rice.json | python3 rice_calculator.py - --json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Initiative:
|
||||
name: str
|
||||
reach: float
|
||||
impact: float
|
||||
confidence: float
|
||||
effort: float
|
||||
|
||||
@property
|
||||
def score(self) -> float:
|
||||
if self.effort <= 0:
|
||||
raise ValueError(f"Effort for '{self.name}' must be greater than 0.")
|
||||
return (self.reach * self.impact * self.confidence) / self.effort
|
||||
|
||||
|
||||
def _normalise_confidence(value: float) -> float:
|
||||
"""Accept 80 or 0.8; return a fraction between 0 and 1."""
|
||||
return value / 100.0 if value > 1 else value
|
||||
|
||||
|
||||
def _to_initiative(row: dict) -> Initiative:
|
||||
try:
|
||||
return Initiative(
|
||||
name=str(row["name"]).strip(),
|
||||
reach=float(row["reach"]),
|
||||
impact=float(row["impact"]),
|
||||
confidence=_normalise_confidence(float(row["confidence"])),
|
||||
effort=float(row["effort"]),
|
||||
)
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Missing required field {exc} in row: {row}") from None
|
||||
|
||||
|
||||
def load(text: str, fmt: str) -> list[Initiative]:
|
||||
if fmt == "csv":
|
||||
rows = list(csv.DictReader(io.StringIO(text)))
|
||||
else:
|
||||
rows = json.loads(text)
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("Input must be a list of initiatives.")
|
||||
return [_to_initiative(r) for r in rows]
|
||||
|
||||
|
||||
def rank(initiatives: list[Initiative]) -> list[dict]:
|
||||
scored = []
|
||||
for i in initiatives:
|
||||
scored.append({
|
||||
"name": i.name,
|
||||
"reach": i.reach,
|
||||
"impact": i.impact,
|
||||
"confidence": round(i.confidence, 2),
|
||||
"effort": i.effort,
|
||||
"rice_score": round(i.score, 1),
|
||||
})
|
||||
scored.sort(key=lambda d: d["rice_score"], reverse=True)
|
||||
|
||||
if scored:
|
||||
max_score = max(d["rice_score"] for d in scored) or 1
|
||||
max_effort = max(d["effort"] for d in scored) or 1
|
||||
for rank_index, d in enumerate(scored, start=1):
|
||||
d["rank"] = rank_index
|
||||
flags = []
|
||||
# Quick win: strong score relative to the field, low relative effort.
|
||||
if d["rice_score"] >= 0.5 * max_score and d["effort"] <= 0.33 * max_effort:
|
||||
flags.append("quick-win")
|
||||
# Moonshot: high raw impact, high relative effort.
|
||||
if d["impact"] >= 2 and d["effort"] >= 0.66 * max_effort:
|
||||
flags.append("moonshot")
|
||||
# Low-confidence estimates should be revisited before acting.
|
||||
if d["confidence"] <= 0.5:
|
||||
flags.append("low-confidence")
|
||||
d["flags"] = flags
|
||||
return scored
|
||||
|
||||
|
||||
def _render(scored: list[dict]) -> str:
|
||||
header = f"{'#':>2} {'Initiative':<32} {'Reach':>8} {'Imp':>4} {'Conf':>5} {'Eff':>5} {'RICE':>8} Flags"
|
||||
lines = ["RICE Prioritisation", "=" * len(header), header, "-" * len(header)]
|
||||
for d in scored:
|
||||
lines.append(
|
||||
f"{d['rank']:>2} {d['name'][:32]:<32} {d['reach']:>8g} {d['impact']:>4g} "
|
||||
f"{d['confidence']:>5.2f} {d['effort']:>5g} {d['rice_score']:>8g} {', '.join(d['flags'])}"
|
||||
)
|
||||
quick = [d["name"] for d in scored if "quick-win" in d["flags"]]
|
||||
moon = [d["name"] for d in scored if "moonshot" in d["flags"]]
|
||||
lowc = [d["name"] for d in scored if "low-confidence" in d["flags"]]
|
||||
lines.append("")
|
||||
lines.append(f"Quick wins (do alongside bigger bets): {', '.join(quick) or 'none'}")
|
||||
lines.append(f"Moonshots (high impact, high effort): {', '.join(moon) or 'none'}")
|
||||
lines.append(f"Low confidence — revisit estimates: {', '.join(lowc) or 'none'}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("input", help="Path to a JSON/CSV file of initiatives, or '-' for stdin.")
|
||||
parser.add_argument("--format", choices=["json", "csv"], help="Input format (inferred from extension if omitted).")
|
||||
parser.add_argument("--json", action="store_true", dest="as_json", help="Emit ranked JSON instead of a table.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
text = sys.stdin.read() if args.input == "-" else None
|
||||
fmt = args.format
|
||||
if text is None:
|
||||
try:
|
||||
text = open(args.input).read()
|
||||
except OSError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if fmt is None:
|
||||
fmt = "csv" if args.input.lower().endswith(".csv") else "json"
|
||||
fmt = fmt or "json"
|
||||
|
||||
try:
|
||||
scored = rank(load(text, fmt))
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps(scored, indent=2) if args.as_json else _render(scored))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user