From ab5adaa86b0db6418bf8efbc40ecc71a7b7c33af Mon Sep 17 00:00:00 2001 From: kenneth Date: Thu, 30 Jul 2026 06:32:48 +0000 Subject: [PATCH 1/7] chore: rename productivity bucket to product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bucket was documented in skills/CLAUDE.md but never created on disk. It was defined as "daily non-code workflow tools", which excludes anything on a slower cadence. Naming buckets by domain, as engineering/ already does, keeps cadence out of the taxonomy. No skill moves — nothing was in productivity/. Co-Authored-By: Claude Opus 5 --- skills/CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/CLAUDE.md b/skills/CLAUDE.md index 797022e..8c48230 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 `.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`. From 4154b571b480d16d37efa09533a066862c5ef9ec Mon Sep 17 00:00:00 2001 From: kenneth Date: Thu, 30 Jul 2026 06:32:57 +0000 Subject: [PATCH 2/7] docs: remove stray trailing character in skills README A lone "f" sat at the end of the file with no trailing newline. Unrelated to any skill, separated out so it does not muddy the reportcard diff. Co-Authored-By: Claude Opus 5 --- skills/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/README.md b/skills/README.md index 3a7c413..243f02b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -19,4 +19,3 @@ No per-repo setup required — available immediately after installing the plugin ## 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 From ea2baa0e0240e7620ffc94af2c8426d50ce84d1a Mon Sep 17 00:00:00 2001 From: kenneth Date: Thu, 30 Jul 2026 06:39:35 +0000 Subject: [PATCH 3/7] feat: add reportcard skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends a quarter's team, cost and metric values to an OGP product report card at _products//reportcard.yml. scripts/audit.py parses the narrow YAML subset the report-card repo uses, stdlib only so it runs before npm i. It infers the target quarter and sorts every metric into NEED, auto or DORMANT, so nothing is carried forward or interpolated by eye. ASK-FORMAT.md fixes the ask as four tables plus a reply block keyed by metric id, since a PM gathers these numbers from finance, Datadog and a survey and will answer in pieces. Its examples use invented names and figures — the report-card repo is private and this one is public. WRITING.md keeps edits append-only and creates the branch at the first write rather than at launch. Cost entries gate whether a quarter renders at all, so a missing one is written as placeholder zeros, stated plainly, and flagged on a draft PR rather than left silent. Co-Authored-By: Claude Opus 5 --- skills/product/reportcard/ASK-FORMAT.md | 137 +++++++++ skills/product/reportcard/SKILL.md | 68 +++++ skills/product/reportcard/WRITING.md | 66 ++++ skills/product/reportcard/scripts/audit.py | 333 +++++++++++++++++++++ 4 files changed, 604 insertions(+) create mode 100644 skills/product/reportcard/ASK-FORMAT.md create mode 100644 skills/product/reportcard/SKILL.md create mode 100644 skills/product/reportcard/WRITING.md create mode 100644 skills/product/reportcard/scripts/audit.py diff --git a/skills/product/reportcard/ASK-FORMAT.md b/skills/product/reportcard/ASK-FORMAT.md new file mode 100644 index 0000000..952094f --- /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 — `/highlights` writes a +proper entry from a scan of Slack, GitHub and the planning doc. 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..956576d --- /dev/null +++ b/skills/product/reportcard/SKILL.md @@ -0,0 +1,68 @@ +--- +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. For writing the CHANGELOG entry, use the highlights skill instead. +--- + +# 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 — +`/highlights` owns that, and will be suggested if the quarter has no entry. **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..be2bdd9 --- /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** — `/highlights` owns it, because a + good entry needs a scan of Slack, GitHub and the team's planning doc. + - 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, then point them at `/highlights`. 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..8c7335f --- /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 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() From 69c072b53db81297e4f2792a1cfc760c9fbfc575 Mon Sep 17 00:00:00 2001 From: kenneth Date: Thu, 30 Jul 2026 06:39:35 +0000 Subject: [PATCH 4/7] docs: register reportcard in the skill listings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the product/ bucket README and the entries skills/CLAUDE.md requires: the plugin.json path, a Product grouping in skills.sh.json, and a row in the skills README table. The plugin.json path is added for consistency with the convention, but every path in that file is currently unresolvable — see #18. It does not affect this skill, since the documented install discovers skills by scanning for SKILL.md. Co-Authored-By: Claude Opus 5 --- skills.sh.json | 7 +++++++ skills/.claude-plugin/plugin.json | 3 ++- skills/README.md | 8 ++++++++ skills/product/README.md | 11 +++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 skills/product/README.md 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/README.md b/skills/README.md index 243f02b..772778e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -16,6 +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. 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 | From 25f75be06c966f98d3e490b45e4d9b8f4f71a0cd Mon Sep 17 00:00:00 2001 From: kenneth Date: Thu, 30 Jul 2026 07:13:35 +0000 Subject: [PATCH 5/7] fix: address copilot review comments on reportcard skill Remove references to the not-yet-existing /highlights skill, fix the plugin.json path in skills/CLAUDE.md, and fix audit.py's usage string to match how it's actually invoked from SKILL.md. Co-Authored-By: Claude Sonnet 5 --- skills/CLAUDE.md | 2 +- skills/product/reportcard/ASK-FORMAT.md | 6 +++--- skills/product/reportcard/SKILL.md | 5 +++-- skills/product/reportcard/WRITING.md | 8 ++++---- skills/product/reportcard/scripts/audit.py | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/skills/CLAUDE.md b/skills/CLAUDE.md index 8c48230..e566f9d 100644 --- a/skills/CLAUDE.md +++ b/skills/CLAUDE.md @@ -3,7 +3,7 @@ Skills are organized into bucket folders under `skills/`: - `engineering/` — daily code work - `product/` — product management workflows -Every skill in `engineering/`, `product/`, 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/product/reportcard/ASK-FORMAT.md b/skills/product/reportcard/ASK-FORMAT.md index 952094f..3a6d0b1 100644 --- a/skills/product/reportcard/ASK-FORMAT.md +++ b/skills/product/reportcard/ASK-FORMAT.md @@ -104,9 +104,9 @@ 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 — `/highlights` writes a -proper entry from a scan of Slack, GitHub and the planning doc. Never write -one yourself from the metric values. +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 diff --git a/skills/product/reportcard/SKILL.md b/skills/product/reportcard/SKILL.md index 956576d..e83a5f3 100644 --- a/skills/product/reportcard/SKILL.md +++ b/skills/product/reportcard/SKILL.md @@ -1,6 +1,6 @@ --- 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. For writing the CHANGELOG entry, use the highlights skill instead. +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 @@ -9,7 +9,8 @@ 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 — -`/highlights` owns that, and will be suggested if the quarter has no entry. **Never invent, interpolate, or +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. diff --git a/skills/product/reportcard/WRITING.md b/skills/product/reportcard/WRITING.md index be2bdd9..2594e29 100644 --- a/skills/product/reportcard/WRITING.md +++ b/skills/product/reportcard/WRITING.md @@ -33,13 +33,13 @@ New entry keyed by `startDate: 'YYYY-MM-01'`, month `01`/`04`/`07`/`10`. only if it actually changed. - `cost.values`: all six keys — `infra`, `security`, `manpower`, `corporate`, `tools`, `others`. -- `CHANGELOG.md`: **not this skill's job** — `/highlights` owns it, because a - good entry needs a scan of Slack, GitHub and the team's planning doc. +- `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, then point them at `/highlights`. Do not improvise one from the - metric values — numbers are not a story. + entry. Do not improvise one from the metric values — numbers are not a + story. ## When the cost figures are not in yet diff --git a/skills/product/reportcard/scripts/audit.py b/skills/product/reportcard/scripts/audit.py index 8c7335f..f4b528a 100644 --- a/skills/product/reportcard/scripts/audit.py +++ b/skills/product/reportcard/scripts/audit.py @@ -5,7 +5,7 @@ this repo uses (mappings, sequences, scalars, wrapped plain scalars). Usage: - python3 audit.py _products/formsg/reportcard.yml [--quarter 2026-04-01] + python3 scripts/audit.py _products/formsg/reportcard.yml [--quarter 2026-04-01] """ import argparse From 89ac8c8a27179a65e9e4e9a66832bc6fe9bccd0e Mon Sep 17 00:00:00 2001 From: kenneth Date: Thu, 30 Jul 2026 07:50:58 +0000 Subject: [PATCH 6/7] feat: add highlights skill Co-Authored-By: Claude Sonnet 5 --- skills/product/highlights/CURATING.md | 73 ++++++++++++++++ skills/product/highlights/SKILL.md | 82 +++++++++++++++++ skills/product/highlights/scripts/prep.py | 102 ++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 skills/product/highlights/CURATING.md create mode 100644 skills/product/highlights/SKILL.md create mode 100644 skills/product/highlights/scripts/prep.py diff --git a/skills/product/highlights/CURATING.md b/skills/product/highlights/CURATING.md new file mode 100644 index 0000000..eb679cf --- /dev/null +++ b/skills/product/highlights/CURATING.md @@ -0,0 +1,73 @@ +# Curating + +Turning a few hundred raw findings into the handful that belong on a public +page. Do this with every source visible at once — the ranking signal only +exists across sources. + +## Rank by corroboration + +**An item appearing in more than one source is almost always a highlight.** + +A real example: a v4 encryption rollout was filed as a routine refactor by the +GitHub pass, because that is what the PR titles looked like. Slack showed it +tracked as a milestone with a success metric; the Notion log tracked it weekly +all quarter. Three appearances meant it was the biggest item of the quarter. +The ~190 dependency bumps appeared once each, in one source. + +Ranking, strongest first: + +1. In all sources, with a number attached +2. In Slack or the planning doc with an outcome — someone reported it worked +3. In the planning doc as an intent **and** in GitHub as merged code +4. GitHub only, user-visible +5. GitHub only, internal — almost never publish + +## What each source is actually good for + +| Source | Gives you | Blind to | +| --- | --- | --- | +| GitHub | What was built, exact dates | Whether anyone used it, anything without code | +| Slack | Outcomes, adoption, real numbers | Anything not worth announcing | +| Planning doc | Intent, and what was dropped | What actually shipped | + +**GitHub alone produces a feature list with no evidence anything worked.** +Adoption numbers, training sessions, and school or agency onboarding leave no +code trace at all, yet they are often what moved the metrics on the report +card. + +The planning doc is the only place that shows **descoped** work. Worth a look: +something dropped for a good reason is occasionally a better story than +something shipped. + +## What to cut + +- Dependency bumps, CI, refactors, test changes — usually 70% of merged PRs +- Bug fixes, unless the bug was widely felt or the fix is itself a feature +- Internal team matters: hiring, leave, tooling experiments, token costs +- Security vulnerability details. Note that hardening happened; do not + enumerate what was exploitable +- Anything you cannot trace to a source + +## Writing it + +- **Plain language.** Public officers read this, not the team. Internal names + get a plain-English gloss: an internal schema name is fine in parentheses if + the line explains what changed for users. +- **Do not claim what has not landed.** Something merged behind a feature flag + and announced next quarter belongs in next quarter's entry. Say "rolled out + to X" only if it was. +- **Keep numbers exactly as reported.** `27.7k submissions` not `~28k`. + Rounding a sourced figure makes it untraceable. +- Group under the same top-level headings the last entry used, even if the + fit is imperfect. Consistency across quarters beats a tidier taxonomy. + +## Before handing over + +State plainly: + +- which sources were unreachable, and what may be missing as a result +- which items you cut that were arguable, so the PM can overrule you +- every placeholder, and where the number would come from + +Never present a draft as complete when a source failed. Silence about a gap +reads as "there was nothing there". diff --git a/skills/product/highlights/SKILL.md b/skills/product/highlights/SKILL.md new file mode 100644 index 0000000..c30d5b4 --- /dev/null +++ b/skills/product/highlights/SKILL.md @@ -0,0 +1,82 @@ +--- +name: highlights +description: Scan a product's Slack channel, GitHub repo and Notion pages for a quarter's shipped work, then draft a CHANGELOG.md entry in that product's existing format. Use when someone wants to write or draft quarterly highlights, gather what shipped last quarter, update a product CHANGELOG, or asks what to put in the changelog for Q1/Q2/Q3/Q4. +--- + +# Quarterly highlights + +Gather what a product shipped in a quarter, decide the few things worth +publishing, and draft the `CHANGELOG.md` entry. + +Does not touch `reportcard.yml` — that is `/reportcard`'s job. **Never invent a +metric, a date, or a feature.** Everything published must trace to something a +person actually wrote. A fabricated usage number on a public government page is +the worst outcome available here, and plausible numbers are the easiest thing +to generate by accident. + +## 1. Prep + +```bash +python3 scripts/prep.py [--quarter q2-2026] +``` + +Prints the date range, the heading to add, and **the last entry verbatim** — +that is the format to mirror. Do not impose a house style; every product +formats differently and the file is the source of truth. It also lists the +metric labels that product uses (`Usage`, `Ease of use`, …). + +## 2. Confirm the sources + +Sources differ per product and are not recorded anywhere, so ask — but arrive +with guesses rather than a blank question: + +- **GitHub** — try `gh repo view opengovsg/` and propose it if it exists +- **Slack** — search for a channel matching the product or its division +- **Notion** — ask; there is rarely a way to guess + +Ask once, in one message, then confirm before scanning. Also ask if anything +lives elsewhere — a Jira board, a team wiki, a mailing list. + +Worth knowing: teams often post through a **weekly digest bot** rather than +individually. Ask how updates usually reach the channel; it changes what to +search for. + +## 3. Fan out — one subagent per source + +Retrieval is mechanical, so run it in parallel on a cheaper tier and keep the +raw volume out of the main context. Each subagent gets the date range and +returns **raw findings only** — no judgment about importance: + +- date, one-line description in the author's own words, link, any hard numbers +- an explicit "could not access X, because Y" rather than silence + +Tell each one plainly: do not infer, do not fill gaps, accuracy over +completeness. + +## 4. Curate + +This is the step that cannot be parallelised — you cannot tell which items +matter until every source is visible at once. See +[CURATING.md](CURATING.md) for how to rank and what to cut. + +## 5. Draft + +Mirror the previous entry's structure exactly, down to the group names and +metric labels. Where a feature carried a metric last quarter and this +quarter's number is unknown, write: + +``` + - Usage: [Placeholder — Q1 reported 6.6k] +``` + +Quoting last quarter's value gives the PM a baseline — a number that has gone +flat after a feature left beta is worth noticing before it is published. + +Write the draft to `.scratch/--changelog-draft.md` for review. +Only paste it into `CHANGELOG.md` once the PM has filled or accepted the +placeholders. + +## Model + +Opus, high effort, for curation — choosing 10 items from 300 is judgment, not +retrieval. The fan-out subagents can run on Sonnet at medium. diff --git a/skills/product/highlights/scripts/prep.py b/skills/product/highlights/scripts/prep.py new file mode 100644 index 0000000..306b3a8 --- /dev/null +++ b/skills/product/highlights/scripts/prep.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Prepare a highlights scan: quarter bounds + last changelog entry as the format template. + +Stdlib only. + +Usage: + python3 prep.py formsg # infers the quarter after the latest logged + python3 prep.py formsg --quarter q2-2026 +""" + +import argparse +import re +import sys +from pathlib import Path + +HEADING = re.compile(r"^##\s+Q([1-4])\s+(\d{4})\b(.*)$") +MONTHS = {1: ("Jan-Mar", "01", "03-31"), 2: ("Apr-Jun", "04", "06-30"), + 3: ("Jul-Sep", "07", "09-30"), 4: ("Oct-Dec", "10", "12-31")} + + +def parse_quarter(s): + m = re.fullmatch(r"[qQ]?([1-4])[-_ /]?(\d{4})", s.strip()) or \ + re.fullmatch(r"(\d{4})[-_ /]?[qQ]?([1-4])", s.strip()) + if not m: + sys.exit(f"error: could not read quarter '{s}'. Try q2-2026.") + a, b = m.group(1), m.group(2) + return (int(a), int(b)) if len(b) == 4 else (int(b), int(a)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("product") + ap.add_argument("--quarter", help="e.g. q2-2026") + ap.add_argument("--root", default=".", help="repo root") + args = ap.parse_args() + + cl = Path(args.root) / "_products" / args.product / "CHANGELOG.md" + if not cl.exists(): + sys.exit(f"error: {cl} not found -- check the product id") + + lines = cl.read_text().splitlines() + entries = [(i, m) for i, l in enumerate(lines) if (m := HEADING.match(l))] + if not entries: + sys.exit(f"error: no '## Q ' headings in {cl}") + + last_i, last_m = entries[0] + last_q, last_y = int(last_m.group(1)), int(last_m.group(2)) + + if args.quarter: + q, y = parse_quarter(args.quarter) + else: + q, y = (last_q + 1, last_y) if last_q < 4 else (1, last_y + 1) + + label, start_mm, end_md = MONTHS[q] + print(f"product : {args.product}") + print(f"target quarter : Q{q} {y} ({label})") + print(f"date range : {y}-{start_mm}-01 to {y}-{end_md}") + print(f"heading to add : ## Q{q} {y} {label}") + print(f"latest logged : Q{last_q} {last_y}") + + if (y, q) == (last_y, last_q): + print(f"\n!! Q{q} {y} IS ALREADY IN THE CHANGELOG -- check before adding another.") + gap = (y - last_y) * 4 + (q - last_q) + if gap > 1: + print(f"\n!! GAP: {gap - 1} quarter(s) missing between Q{last_q} {last_y} and target.") + + # ---- last entry, verbatim: this IS the format to mirror ----------------- + end = entries[1][0] if len(entries) > 1 else len(lines) + block = lines[last_i:end] + + # Only count labels that sit inside a "- Metrics" block, i.e. indented + # deeper than the Metrics bullet itself. + labels, metrics_indent = set(), None + for l in block: + if not l.strip(): + continue + indent = len(l) - len(l.lstrip()) + if re.match(r"^\s*-\s+Metrics\s*:?\s*$", l): + metrics_indent = indent + continue + if metrics_indent is None: + continue + if indent <= metrics_indent: + metrics_indent = None + continue + if m := re.match(r"^\s*-\s+([A-Za-z][A-Za-z ()]*?)\s*:", l): + labels.add(m.group(1).strip()) + labels = sorted(labels) + groups = [l.strip()[2:] for l in block if re.match(r"^-\s+\S", l)] + + print(f"\ntop-level groups used last quarter: {groups}") + print(f"metric labels used last quarter : {labels}") + print("\nIf a feature carried a metric last quarter, it needs one this quarter --") + print("a real value or an explicit [Placeholder]. Never a guess.") + print("\n" + "=" * 70) + print("LAST ENTRY VERBATIM -- mirror this structure exactly") + print("=" * 70) + print("\n".join(block).rstrip()) + + +if __name__ == "__main__": + main() From ba7fda3c37141be502bb71359831025bbe2e8b41 Mon Sep 17 00:00:00 2001 From: kenneth Date: Thu, 30 Jul 2026 07:51:01 +0000 Subject: [PATCH 7/7] docs: register highlights in the skill listings Co-Authored-By: Claude Sonnet 5 --- skills.sh.json | 1 + skills/.claude-plugin/plugin.json | 1 + skills/README.md | 1 + skills/product/README.md | 15 +++++++++++++++ 4 files changed, 18 insertions(+) diff --git a/skills.sh.json b/skills.sh.json index 62cebb5..a55869a 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -21,6 +21,7 @@ "title": "Product", "description": "Product management workflows.", "skills": [ + "highlights", "reportcard" ] }, diff --git a/skills/.claude-plugin/plugin.json b/skills/.claude-plugin/plugin.json index 09a2ae6..383185a 100644 --- a/skills/.claude-plugin/plugin.json +++ b/skills/.claude-plugin/plugin.json @@ -15,6 +15,7 @@ "./skills/general/grill-me", "./skills/general/handoff", "./skills/general/write-a-skill", + "./skills/product/highlights", "./skills/product/reportcard" ] } diff --git a/skills/README.md b/skills/README.md index 772778e..cd209ec 100644 --- a/skills/README.md +++ b/skills/README.md @@ -22,6 +22,7 @@ Product management workflows. No per-repo setup required. See [product/README.md | Skill | Description | |-------|-------------| +| [`/highlights`](product/highlights/SKILL.md) | Scan a product's Slack, GitHub and Notion sources for a quarter's shipped work, then draft the `CHANGELOG.md` entry in that product's existing format | | [`/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 diff --git a/skills/product/README.md b/skills/product/README.md index e64eeba..871668a 100644 --- a/skills/product/README.md +++ b/skills/product/README.md @@ -8,4 +8,19 @@ These skills operate on a product's own repository (for example the products.ope | Skill | Description | |-------|-------------| +| [`/highlights`](highlights/SKILL.md) | Scan a product's Slack channel, GitHub repo and Notion pages for a quarter's shipped work, then draft the `CHANGELOG.md` entry in that product's existing format | | [`/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 | + +## Order + +Run them in this order — `/highlights` writes the story, `/reportcard` fills in +the numbers: + +``` +/highlights → CHANGELOG.md entry +/reportcard → reportcard.yml values +``` + +They are separate because the jobs differ: `/highlights` is broad retrieval and +judgment across several sources and wants a strong model, while `/reportcard` is +a mechanical append that runs in seconds and works offline.