diff --git a/skills.sh.json b/skills.sh.json index f9dc1a4..62cebb5 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -17,6 +17,13 @@ "setup-formsg-ai-skills" ] }, + { + "title": "Product", + "description": "Product management workflows.", + "skills": [ + "reportcard" + ] + }, { "title": "General", "description": "Thinking and communication skills that work across any domain.", diff --git a/skills/.claude-plugin/plugin.json b/skills/.claude-plugin/plugin.json index a384652..09a2ae6 100644 --- a/skills/.claude-plugin/plugin.json +++ b/skills/.claude-plugin/plugin.json @@ -14,6 +14,7 @@ "./skills/general/caveman", "./skills/general/grill-me", "./skills/general/handoff", - "./skills/general/write-a-skill" + "./skills/general/write-a-skill", + "./skills/product/reportcard" ] } diff --git a/skills/CLAUDE.md b/skills/CLAUDE.md index 797022e..e566f9d 100644 --- a/skills/CLAUDE.md +++ b/skills/CLAUDE.md @@ -1,9 +1,9 @@ Skills are organized into bucket folders under `skills/`: - `engineering/` — daily code work -- `productivity/` — daily non-code workflow tools +- `product/` — product management workflows -Every skill in `engineering/`, `productivity/`, or `misc/` must have a reference in the top-level `README.md` and an entry in `.claude-plugin/plugin.json`. Skills in `personal/`, `in-progress/`, and `deprecated/` must not appear in either. +Every skill in `engineering/`, `product/`, or `misc/` must have a reference in the top-level `README.md` and an entry in `skills/.claude-plugin/plugin.json`. Skills in `personal/`, `in-progress/`, and `deprecated/` must not appear in either. Each skill entry in the top-level `README.md` must link the skill name to its `SKILL.md`. diff --git a/skills/README.md b/skills/README.md index 3a7c413..772778e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -16,7 +16,14 @@ No per-repo setup required — available immediately after installing the plugin | [`/write-a-skill`](general/write-a-skill/SKILL.md) | Create new skills with proper structure and bundled resources | | [`/humanizer`](general/humanizer/SKILL.md) | Remove signs of AI writing from text, making it more natural and human | +## Product skills + +Product management workflows. No per-repo setup required. See [product/README.md](product/README.md). + +| Skill | Description | +|-------|-------------| +| [`/reportcard`](product/reportcard/SKILL.md) | Add a quarter's team, cost and metric values to an OGP product report card by auditing the last recorded quarter, then asking the PM for each missing number | + ## Engineering skills Requires `/setup-formsg-ai-skills` per repo. See [engineering/README.md](engineering/README.md) for setup, the full skill list, and the recommended workflow. -f \ No newline at end of file diff --git a/skills/product/README.md b/skills/product/README.md new file mode 100644 index 0000000..e64eeba --- /dev/null +++ b/skills/product/README.md @@ -0,0 +1,11 @@ +# Product skills + +Product management workflows. No per-repo setup required — available immediately after installing the plugin. + +These skills operate on a product's own repository (for example the products.open.gov.sg content repo), so run them from that checkout rather than from here. + +## Skills + +| Skill | Description | +|-------|-------------| +| [`/reportcard`](reportcard/SKILL.md) | Add a quarter's team, cost and metric values to an OGP product report card by auditing the last recorded quarter, then asking the PM for each missing number | diff --git a/skills/product/reportcard/ASK-FORMAT.md b/skills/product/reportcard/ASK-FORMAT.md new file mode 100644 index 0000000..3a6d0b1 --- /dev/null +++ b/skills/product/reportcard/ASK-FORMAT.md @@ -0,0 +1,137 @@ +# Ask format + +How to present the audit to a PM and collect their numbers. + +## Output order + +**Tables first, prose last.** Four tables back to back, one-line captions +only, then a single Notes block, then the reply block. Never interleave +commentary between tables — it breaks the scan, and the tables are what the +PM is actually reading. + +1. One-line header — product and target quarter +2. **Team** table +3. **Cost** table +4. **Metrics** table +5. **Dormant** table (omit if empty) +6. **Notes** (omit if nothing to flag) +7. **Reply block** + +Order rows the way `reportcard.yml` orders them — `team`, then `cost` keys, +then metrics in `metricGroups` order, then `changelog`. The audit already +prints them in file order, so the PM can read the file and the ask in step. + +## Tables + +Every table shows the previous quarter filled and the target quarter blank. +Use **quarter labels as headers, never ISO dates**. The audit prints the exact +pair as `table columns:` — copy it rather than deriving it. + +Team: + +| Person | Role | Q1 2026 | Q2 2026 | +| --- | --- | --- | --- | +| alex | eng | full | | +| bo | ops | 2/3 | | + +Quarter columns hold involvement (`full` when the YAML omits `involvement`). +Blank means carry forward; `-` means the person left. Caption with the quarter +the roster came from, and ask only for the **delta** — "unchanged" is a valid +and common answer. Never render a roster as a prose sentence; it is the +hardest bucket to check by eye. + +Cost and Metrics: + +| id | Q1 2026 | Q2 2026 | +| --- | --- | --- | +| infra | 12,345.67 | | +| paper-forms-left | 12,000 | | +| cost-per-form-submission-manual | $0.25 | | + +Dormant uses `id` + `Last value` + `Quarters missing` — **no target-quarter +column**, since nothing is being collected. Caption it as awareness only, e.g. +"not collected — say the word to revive". It is a record that these once +existed, not a request. + +### Formatting values + +- Thousands separators and prefixes are for readability only. Write the raw + number into the YAML. +- Take a prefix **verbatim from the metric's own `prefix` field**. A metric + with `prefix: '$'` renders as `$0.25`, never `FormSG$0.25`. The product name + belongs in the header line and nowhere else. +- Use each id exactly as it appears in the file, including odd casing like + `Uptime`. + +## Notes + +One bullet per flag, only things that change what the PM should do: + +- Roster empty, or carried forward from more than one quarter back +- Cost fields at `0` — real, or a placeholder? `0` renders as a genuine + datapoint on the public chart +- **Cost gates the whole quarter.** The quarter dropdown is built from + `cost.values` alone, so until a cost entry exists the new quarter will not + appear on the page at all. Say so when asking, so the PM knows these six + numbers unblock everything else +- `CHANGELOG.md` missing intervening quarters +- Dormant metrics — one bullet total, not one per metric + +Nothing to flag means no Notes block. Do not pad it. + +## Reply block + +A PM gathers these numbers from finance, Datadog, and a survey, so they will +answer in pieces. Always end with a copy-paste block keyed by id, in file +order: + +``` +team: unchanged +infra: 0 +security: 0 +manpower: 0 +corporate: 0 +tools: 0 +others: 0 +paper-forms-left: +cost-per-form-submission-manual: +respondent-satisfaction-rating: +admin-satisfaction-rating: +Uptime: +paper-forms-left-moe-schools: +percentage-forms-converted-schools: +changelog: +``` + +The `changelog:` line is optional and exists only so a PM who already has the +text can hand it over. Leaving it blank is normal — writing a proper entry +needs a scan of Slack, GitHub and the planning doc, which is out of scope +for this skill. Never write one yourself from the metric values. + +**Cost keys are pre-filled with `0`** — not because zero is likely right, but +because a quarter with no cost entry does not render at all. The default gets +the PM to a previewable page without waiting on finance. + +Say this when you send the block: *"cost is pre-filled with 0 so the quarter +renders — overwrite with real figures when finance sends them."* + +Treat cost as unanswered whenever **all six come back as `0`**. That is the +default echoed back, not a decision, so it means placeholder: draft PR, flagged +in the description. Individual zeros are ordinary — most products genuinely +have `security: 0` — so only the all-zero case is the signal. + +**Only `NEED` items appear here.** Never put a dormant metric in the reply +block, not even commented out — a blank line reads as an obligation, and the +whole point of dormant is that the PM owes nothing. Reviving one is opt-in: +they name it explicitly ("bring back officer-satisfaction"), and only then +does it join the block on the next round. + +- **Partial is fine.** A blank line is unanswered. Re-ask only the blanks; + never re-ask what is already filled. +- **Order-independent and unambiguous** — keys are ids, so nothing misaligns + the way a bare list of numbers does. +- Loose replies are fine too (`total-users 12500`, or one value at a time). + The block is a convenience, not a required format. + +Keep asking in rounds until every line is filled or the PM stops, then write +`TBC` for the rest and list them at the end. diff --git a/skills/product/reportcard/SKILL.md b/skills/product/reportcard/SKILL.md new file mode 100644 index 0000000..e83a5f3 --- /dev/null +++ b/skills/product/reportcard/SKILL.md @@ -0,0 +1,69 @@ +--- +name: reportcard +description: Add a new quarter's team, cost and metric values to an OGP product report card (_products//reportcard.yml) by auditing the last recorded quarter, then asking the PM for each missing number. Use when someone wants to update, add, or draft a quarterly report card, add a new quarter's metrics/cost/team, or mentions reportcard.yml, "Q1/Q2/Q3/Q4 report card", or the products.open.gov.sg draft editor. Writing the CHANGELOG entry is out of scope for this skill. +--- + +# Report card: add a quarter + +Collect a quarter's numbers from the PM and append them to +`_products//reportcard.yml`. + +Does not source data, does not open PRs, and does not write the changelog — +that needs a scan of Slack, GitHub and the team's planning doc, which is +out of scope here. **Never invent, interpolate, or +carry forward a metric value** — every number comes from the PM, anything +unanswered stays `TBC`, and past quarters are never edited. + +## 1. Audit — never read the YAML by eye + +```bash +python3 scripts/audit.py _products//reportcard.yml +``` + +Stdlib only, so it runs before `npm i`. `--quarter 2026-04-01` targets a +specific quarter; otherwise it infers the next one. `--active-only` hides +retired metrics. + +It prints the target quarter, the table headers to use, the branch name and +its status, the last roster and cost entry, and sorts every metric into: + +| Bucket | Meaning | What to do | +| --- | --- | --- | +| `NEED` | Carried a value last quarter | Ask the PM | +| `auto` | Derived from other fields | Never ask | +| `DORMANT` | No value last quarter | Offer once, then drop | + +PMs retire metrics all the time, so a quiet metric is usually a deliberate +choice rather than an oversight. Offer the dormant list once so nothing is +lost silently, accept "skip them", and never raise it again. + +Heed the cost warning if it appears — a quarter without a `cost.values` entry +does not render at all. + +## 2. Ask + +Follow [ASK-FORMAT.md](ASK-FORMAT.md): four tables, then Notes, then a +copy-paste reply block keyed by metric id in file order. Show every last-known +value so the PM can answer with a delta rather than hunting for context. + +## 3. Write + +Follow [WRITING.md](WRITING.md): branch at the first write, append-only edits, +and what to do when the cost figures have not arrived. + +## 4. Verify + +```bash +git diff _products//reportcard.yml +``` + +**Every hunk must be pure additions.** One deleted line means history was +rewritten instead of appended — stop and fix it. Re-run the audit; every +`NEED` should now read `OK`. + +Then have the PM preview at `products.open.gov.sg/draft/` before the +PR. The diff catches structural damage; only the preview catches a plausible +number in the wrong row. + +Handing off for review is `prepare-for-review`'s job, not this skill's. Any +screenshot goes in `.scratch/` and is never committed. diff --git a/skills/product/reportcard/WRITING.md b/skills/product/reportcard/WRITING.md new file mode 100644 index 0000000..2594e29 --- /dev/null +++ b/skills/product/reportcard/WRITING.md @@ -0,0 +1,66 @@ +# Writing the draft + +## Branch — created at the first write, not at launch + +The audit prints the conventional name as `branch:` +(`-q--report-card`). Running this skill is not a commitment +to change anything, so a read-only question must not leave a branch behind. + +Create it immediately before the first write. If the PM replies and nothing +gets written ("hold off", or every answer is "skip"), no branch. A partial +draft containing `TBC` is a write, so it does get one. + +```bash +git switch -c # only if it does not already exist +``` + +Already on that branch, or on a non-`main` branch the PM is clearly using? +Stay there. Otherwise act on what the audit reported: + +| Audit says | Do | +| --- | --- | +| `exists locally` | Switch to it, keep going | +| `exists on REMOTE only` | **Stop and ask.** Someone else likely started this quarter | +| `not found` | Create it here | +| `git unavailable` | Carry on, and say the branch step was skipped | + +## Appending + +New entry keyed by `startDate: 'YYYY-MM-01'`, month `01`/`04`/`07`/`10`. + +- Match surrounding style exactly — indentation, date quoting, decimal places. +- `team.members`: omit the block entirely to carry the roster forward. Add one + only if it actually changed. +- `cost.values`: all six keys — `infra`, `security`, `manpower`, `corporate`, + `tools`, `others`. +- `CHANGELOG.md`: **not this skill's job** — a good entry needs a scan of + Slack, GitHub and the team's planning doc, which is out of scope here. + - If the PM supplies changelog text directly, add it at the top as + `## Q `, matching the previous entry's structure. + - If they do not, leave the file alone and say the quarter is missing an + entry. Do not improvise one from the metric values — numbers are not a + story. + +## When the cost figures are not in yet + +**A quarter exists only if it has a `cost.values` entry.** The quarter +dropdown is built from `cost.values` keys alone +(`src/app/services/calculate-costs.ts`); metrics do not contribute. Add +metrics without a cost entry and the page silently keeps showing the previous +quarter — no error, nothing to notice. + +So cost blocks *preview*, never *progress*: + +1. Write the metrics as normal. +2. Add the cost entry with zeros so the quarter renders, and say plainly that + you have done so. +3. Open the PR as a **draft**, with `cost figures are placeholder zeros` in + the description as the one outstanding item. + +A draft carrying zeros is normal — it is what makes the quarter previewable. +**Approval is the gate:** reviewers do not approve until the real figures land +in a follow-up commit. + +The danger is silence, not the zeros. On the public chart a `0` reads as +"spending collapsed to nothing" rather than "not known yet", so an unflagged +draft can be approved by someone assuming the numbers are real. diff --git a/skills/product/reportcard/scripts/audit.py b/skills/product/reportcard/scripts/audit.py new file mode 100644 index 0000000..f4b528a --- /dev/null +++ b/skills/product/reportcard/scripts/audit.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Audit a product's reportcard.yml and report what a new quarter needs. + +Stdlib only -- no PyYAML, no node_modules. Parses the narrow YAML subset +this repo uses (mappings, sequences, scalars, wrapped plain scalars). + +Usage: + python3 scripts/audit.py _products/formsg/reportcard.yml [--quarter 2026-04-01] +""" + +import argparse +import re +import sys +from pathlib import Path + +KEY_RE = re.compile(r"^(\s*)(-\s+)?([A-Za-z_][\w\-]*):\s*(.*)$") +ITEM_RE = re.compile(r"^(\s*)-\s+(.*)$") + + +def unquote(v): + v = v.strip() + if v == "[]": + return [] + if v == "{}": + return {} + if len(v) >= 2 and v[0] == v[-1] and v[0] in "'\"": + return v[1:-1] + return v + + +def _significant(lines): + """Drop blanks, comments, and wrapped plain-scalar continuation lines.""" + out = [] + for raw in lines: + s = raw.rstrip() + if not s.strip() or s.lstrip().startswith("#"): + continue + if KEY_RE.match(s) or ITEM_RE.match(s): + out.append((len(s) - len(s.lstrip()), s.strip())) + return out + + +def parse(lines): + toks = _significant(lines) + node, _ = _block(toks, 0, toks[0][0] if toks else 0) + return node + + +def _block(toks, i, indent): + """Parse every token at exactly `indent` into a dict or list.""" + if i >= len(toks): + return {}, i + + if toks[i][1].startswith("- "): + seq = [] + while i < len(toks) and toks[i][0] == indent and toks[i][1].startswith("- "): + inner = toks[i][1][2:].strip() + child_indent = indent + 2 + m = KEY_RE.match(inner) + if m and not m.group(2): + # "- key: value" -- a mapping starts on this line. + entry = {} + key, val = m.group(3), m.group(4).strip() + i += 1 + if val: + entry[key] = unquote(val) + else: + sub, i = _descend(toks, i, child_indent) + entry[key] = sub + # remaining keys of the same mapping + while i < len(toks) and toks[i][0] == child_indent: + m2 = KEY_RE.match(toks[i][1]) + if not m2 or m2.group(2): + break + k2, v2 = m2.group(3), m2.group(4).strip() + i += 1 + if v2: + entry[k2] = unquote(v2) + else: + sub, i = _descend(toks, i, child_indent + 2) + entry[k2] = sub + seq.append(entry) + else: + seq.append(unquote(inner)) + i += 1 + return seq, i + + mapping = {} + while i < len(toks) and toks[i][0] == indent: + m = KEY_RE.match(toks[i][1]) + if not m or m.group(2): + break + key, val = m.group(3), m.group(4).strip() + i += 1 + if val and val not in ("|", ">", ">-", "|-"): + mapping[key] = unquote(val) + else: + sub, i = _descend(toks, i, indent + 2) + mapping[key] = sub + return mapping, i + + +def _descend(toks, i, expected): + """Parse a nested block. Sequences may sit at the parent's indent.""" + if i >= len(toks): + return {}, i + actual = toks[i][0] + if actual < expected and not toks[i][1].startswith("- "): + return {}, i + if actual < expected - 2: + return {}, i + return _block(toks, i, actual) + + +def resolve(node): + return node + + +def dates(entries): + out = [] + for e in entries or []: + if isinstance(e, dict) and e.get("startDate"): + out.append(str(e["startDate"])) + return out + + +def qlabel(d): + """'2026-01-01' -> 'Q1 2026'. Falls back to the raw date if off-cycle.""" + y, m, _ = (int(x) for x in d.split("-")) + if m not in (1, 4, 7, 10): + return d + return f"Q{(m - 1) // 3 + 1} {y}" + + +def branch_name(product, target): + """formsg + 2026-04-01 -> formsg-q2-2026-report-card""" + y, m, _ = (int(x) for x in target.split("-")) + return f"{product}-q{(m - 1) // 3 + 1}-{y}-report-card" + + +def branch_status(name): + """Where does this branch already exist? Read-only; never mutates git.""" + import subprocess + + def git(*a, timeout=10): + return subprocess.run( + ["git", *a], capture_output=True, text=True, timeout=timeout + ) + + try: + cur = git("rev-parse", "--abbrev-ref", "HEAD") + if cur.returncode != 0: + return "git unavailable (not a repo?)" + current = cur.stdout.strip() + + local = git("rev-parse", "--verify", f"refs/heads/{name}").returncode == 0 + + remote = None + try: + r = git("ls-remote", "--exit-code", "--heads", "origin", name, timeout=15) + remote = r.returncode == 0 + except Exception: + remote = None # offline -- unknown, not absent + + if current == name: + return "already on it" + if local: + return "exists locally -- switch to it" + if remote: + return "exists on REMOTE only -- someone may have started this quarter, ASK FIRST" + if remote is None: + return f"not found locally (remote unreachable); currently on '{current}'" + return f"not found -- create it before the first write; currently on '{current}'" + except Exception as e: + return f"git check skipped ({type(e).__name__})" + + +def previous_quarter(d): + y, m, _ = (int(x) for x in d.split("-")) + m -= 3 + if m < 1: + m += 12 + y -= 1 + return f"{y}-{m:02d}-01" + + +def next_quarter(d): + y, m, _ = (int(x) for x in d.split("-")) + m += 3 + if m > 12: + m -= 12 + y += 1 + return f"{y}-{m:02d}-01" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("path") + ap.add_argument("--quarter", help="target quarter, e.g. 2026-04-01") + ap.add_argument( + "--active-only", + action="store_true", + help="only list metrics that carried a value last quarter; hide retired ones", + ) + args = ap.parse_args() + + p = Path(args.path) + if not p.exists(): + sys.exit(f"error: {p} not found") + + data = resolve(parse(p.read_text().splitlines())) + + # ---- work out the target quarter ------------------------------------- + all_dates = set() + for e in (data.get("cost") or {}).get("values") or []: + if isinstance(e, dict) and e.get("startDate"): + all_dates.add(str(e["startDate"])) + for g in data.get("metricGroups") or []: + for mt in g.get("metrics") or []: + all_dates.update(dates(mt.get("values"))) + + if not all_dates: + sys.exit("error: no dated entries found -- is this a reportcard.yml?") + + latest = max(all_dates) + target = args.quarter or next_quarter(latest) + + print(f"product : {data.get('projectId', '?')}") + print(f"latest quarter : {latest} ({qlabel(latest)})") + print(f"target quarter : {target} ({qlabel(target)})") + print(f"table columns : id | {qlabel(previous_quarter(target))} | {qlabel(target)}") + bname = branch_name(data.get("projectId", "product"), target) + print(f"branch : {bname}") + print(f" {branch_status(bname)}") + if target in all_dates: + print(f"\n!! {target} ALREADY EXISTS in this file -- check before appending.") + print() + + # ---- team ------------------------------------------------------------ + members = (data.get("team") or {}).get("members") or [] + if members: + last = members[-1] + print(f"TEAM last roster ({last.get('startDate')})") + if target in dates(members): + print(f" already has {target}") + for v in last.get("values") or []: + inv = f", {v['involvement']}" if v.get("involvement") else "" + print(f" - {v.get('id')} ({v.get('role')}{inv})") + print() + + # ---- cost ------------------------------------------------------------ + costs = (data.get("cost") or {}).get("values") or [] + if costs: + last = costs[-1] + print(f"COST last entry ({last.get('startDate')})") + for k in ("infra", "security", "manpower", "corporate", "tools", "others"): + if k in last: + print(f" {k:<10} {last[k]}") + if target in dates(costs): + print(f" -> {target} already present") + else: + print( + f" -> NO {target} cost entry. The quarter dropdown is built from\n" + f" cost.values alone, so {qlabel(target)} will NOT render until one\n" + f" exists -- metrics alone are not enough. Cost is blocking." + ) + print() + + # ---- metrics --------------------------------------------------------- + # A metric is "active" if it carried a value in the quarter immediately + # before the target. Anything older was most likely retired on purpose. + prev = previous_quarter(target) + dormant = [] + + print(f"METRICS (active = has a {prev} value)") + for g in data.get("metricGroups") or []: + header_shown = False + + def header(): + nonlocal header_shown + if not header_shown: + print(f"\n [{g.get('label')}]") + header_shown = True + + for mt in g.get("metrics") or []: + mid = mt.get("id") + src = mt.get("source", "manual") + if src != "manual": + if not args.active_only: + header() + print(f" auto {mid}: computed ({src}) -- nothing to enter") + continue + + vals = mt.get("values") or [] + ds = dates(vals) + if not ds: + dormant.append((mid, "never", [])) + continue + + last_d, last_v = ds[-1], vals[-1].get("value") + + if target in ds: + header() + print(f" OK {mid}: {target} already present") + elif last_d >= prev: + header() + print(f" NEED {mid}: last {last_v} ({last_d})") + else: + gap = [] + d = next_quarter(last_d) + while d < target: + gap.append(d) + d = next_quarter(d) + dormant.append((mid, f"{last_v} ({last_d})", gap)) + + if dormant and not args.active_only: + print(f"\nDORMANT -- no {prev} value, likely retired. Skipped unless you say otherwise:") + for mid, last, gap in dormant: + missing = f", missing {len(gap)} quarter(s)" if gap else "" + print(f" {mid}: last {last}{missing}") + print(" Ask once whether to revive or backfill any of these. Do not chase them.") + elif dormant: + print(f"\n({len(dormant)} dormant metric(s) hidden -- re-run without --active-only to see them)") + + # ---- changelog ------------------------------------------------------- + cl = p.parent / "CHANGELOG.md" + if cl.exists(): + head = [l for l in cl.read_text().splitlines() if l.startswith("#")][:3] + print(f"\nCHANGELOG.md top headings: {head}") + + +if __name__ == "__main__": + main()