Просмотр исходного кода

feat(github-ops): repo-scorecard — scored, fleet-aware repo-health capstone

One read-only command for a scored health report across every repo you own.
ORCHESTRATES the existing auditors (check-security-posture, check-issues)
rather than duplicating them, and rolls five weighted dimensions — security
(35), metadata (25), release-consistency (15), open-issues (15), latest
Actions run (10) — into a 0-100 score + A-F grade with the top-3 fixes per
repo.

- --org sweeps all non-archived repos into a matrix + roll-up (avg/median,
  worst repos, fleet open-alert total). --min-score N is a CI gate.
- Unreadable dimension scores zero ('n/a'), never a false-healthy.
- Read-only (CI-asserted self-grep); the only mutating verbs are inside
  printed remediation pointers. CRLF/MSYS-robust; stdin-piped jq survives a
  70-repo fleet (argv arg-list cap fix). +15 test assertions (34 total).
- Wired as the headline of github-ops audit mode.

Verified live: flarecrawl 68/C (caught a real release-consistency gap —
tag v0.31.1 has no release); --org 0xDarkMatter scored 70 repos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 неделя назад
Родитель
Сommit
79a5200de4
4 измененных файлов с 561 добавлено и 2 удалено
  1. 10 0
      CHANGELOG.md
  2. 14 2
      skills/github-ops/SKILL.md
  3. 486 0
      skills/github-ops/scripts/repo-scorecard.sh
  4. 51 0
      skills/github-ops/tests/run.sh

+ 10 - 0
CHANGELOG.md

@@ -35,6 +35,16 @@ feature releases live in the README "Recent Updates" section.
   conversion done). docs/ top level is now 8 load-bearing docs + `archive/` + `references/`.
 
 ### Added
+- **github-ops `repo-scorecard.sh`** (the audit capstone) - one read-only command
+  for a scored repo-health report, fleet-aware. **Orchestrates** the existing
+  auditors (`check-security-posture.sh`, `check-issues.sh`) rather than
+  re-implementing them, and rolls five dimensions — security (w35), metadata
+  (w25), release-consistency (w15), open-issues (w15), latest-Actions-run (w10) —
+  into a 0–100 score + A–F grade with the top-3 fixes per repo. `--org` sweeps
+  every non-archived repo into a matrix + roll-up (avg/median, worst repos, fleet
+  open-alert total); `--min-score N` is a CI gate (exit 10 below N). An unreadable
+  dimension scores zero ("n/a"), never a false-healthy. Read-only (CI-asserted);
+  +15 test assertions (34 total). Now the headline of github-ops `audit` mode.
 - **github-ops security-posture auditor** - `scripts/check-security-posture.sh`:
   read-only audit of a repo's GitHub security settings (Dependabot alerts +
   security updates, secret scanning + push protection, code scanning, private

+ 14 - 2
skills/github-ops/SKILL.md

@@ -167,9 +167,20 @@ Triggered by: "ship a release", "cut a release", "release v0.X.Y", "publish upda
 
 ### Mode `audit` — read-only checklist
 
-Triggered by: "audit github repo", "is this repo ready to publish", "check repo metadata", "score this repo".
+Triggered by: "audit github repo", "is this repo ready to publish", "check repo metadata", "score this repo", "how healthy is this repo", "score the fleet".
 
-Read-only — produces a report without making changes. See `references/metadata-checklist.md` for the complete checklist; the SKILL enforces these:
+**Headline: `scripts/repo-scorecard.sh` — one command for a scored repo/fleet health report.** It orchestrates the two read-only auditors (`check-security-posture.sh` + `check-issues.sh`) and adds metadata / release / actions signals, rolling everything into a single **0–100 score + letter grade** per repo, and a **matrix + roll-up** across an org. Reach for it first; drop to the manual checklist below only when you need a specific row the scorecard doesn't surface.
+
+```bash
+bash scripts/repo-scorecard.sh --repo 0xDarkMatter/flarecrawl     # single repo: score + dimensions + top 3 fixes
+bash scripts/repo-scorecard.sh --org 0xDarkMatter                 # fleet matrix + roll-up (avg/median/worst, fleet open-alert total)
+bash scripts/repo-scorecard.sh --org 0xDarkMatter --min-score 75  # CI gate: exit 10 if ANY repo scores < 75
+bash scripts/repo-scorecard.sh --repo <o>/<r> --json | jq '.data[0].top_fixes'
+```
+
+Five weighted dimensions — **security (35)** highest, then **metadata (25)**, **release (15)**, **issues (15)**, **actions (10)**. Each scores its weight in full (ok) / half (warn) / zero (gap **or** unreadable n/a — an unreadable dimension never counts as healthy). Grade: A≥90 B≥75 C≥60 D≥40 F<40. The full rubric is documented in the script header (`--help`). It surfaces the **top 3 fixes per repo**, highest-severity first, each with the exact remediation pointer (e.g. `→ check-security-posture.sh --repo … --commands`, "add CHANGELOG.md", "cut a GitHub release"). Exit `0` healthy · `10` gaps / below `--min-score` · `7` unavailable (graceful) · `5` gh missing · `2` usage. **Strictly read-only** — only GET `gh api` calls + the read-only siblings; the remediation pointers are text, never executed.
+
+Below is the underlying checklist the scorecard's dimensions roll up (and what mode `new`/`update` act on). See `references/metadata-checklist.md` for the complete version; the SKILL enforces these:
 
 ```
 LOCAL FILE CHECKS
@@ -341,6 +352,7 @@ When adding any of the above, keep the boundary discipline: anything talking to
 | `references/metadata-checklist.md` | Audit checklist source of truth |
 | `references/issue-ops.md` | Issue operation playbooks (view/triage/comment/create/close) + preview templates |
 | `references/pr-ops.md` | PR operation playbooks (create/review/merge) + pre-merge gate + merge-strategy decision tree |
+| `scripts/repo-scorecard.sh` | **Capstone audit tool.** Scored, read-only repo-health matrix — orchestrates `check-security-posture.sh` + `check-issues.sh` and adds metadata/release/actions signals into a 0–100 score + grade per repo; `--org` for a fleet matrix + roll-up; `--min-score N` to gate CI; `--json` envelope. Surfaces top-3 fixes per repo. Never mutates |
 | `scripts/check-issues.sh` | Surface open issues you may not have seen (externally-authored + stale) for a repo or remote. Read-only `gh issue list`; flags author≠owner and untouched-for-N-days |
 | `scripts/check-security-posture.sh` | Read-only repo security-posture auditor. Per-feature checklist (Dependabot alerts/updates, secret scanning + push protection, code scanning, private vuln reporting, SECURITY.md, branch protection), visibility-aware severity, open-alert exposure where a scanner is on, `--org` fleet sweep. Emits enable commands as text — never applies a change |
 | `assets/SECURITY.md.template` | Copy-ready vulnerability-disclosure policy (supported versions, private reporting via GitHub PVR, response SLAs, scope, safe harbor) — what `check-security-posture.sh` points at when SECURITY.md is absent |

+ 486 - 0
skills/github-ops/scripts/repo-scorecard.sh

@@ -0,0 +1,486 @@
+#!/usr/bin/env bash
+# Scored, read-only repo-health scorecard — orchestrates the github-ops auditors.
+#
+# READ-ONLY. Only GET `gh api` calls plus calls to the read-only sibling scripts
+# (check-security-posture.sh, check-issues.sh). NEVER a -X PUT/PATCH/POST/DELETE.
+# It rolls five dimensions into one 0–100 score + letter grade per repo, and
+# (with --org) a fleet matrix + roll-up. The remediation pointers it prints are
+# TEXT for you to act on — this script applies nothing.
+#
+# Usage:   repo-scorecard.sh [--repo OWNER/REPO | --remote NAME | --org OWNER]
+#                            [--min-score N] [--json] [-h|--help]
+# Input:   argv only. Default repo = derived from the 'origin' remote of the cwd.
+# Output:  stdout = the data product (human matrix, or --json envelope).
+#          --json schema: claude-mods.github-ops.repo-scorecard/v1
+# Stderr:  headers, progress, the review banner, skip notices, errors.
+# Exit:    0  all audited repos healthy (no gaps; or all >= --min-score)
+#          2  usage (bad/unknown flag, malformed OWNER/REPO, mutex selectors)
+#          5  gh not installed
+#          7  unavailable — non-github remote, gh unauthed/offline, timeout
+#             (graceful, like the siblings; never a false "healthy")
+#          10 findings — gaps present, or a repo scored below --min-score
+#
+# SCORING MODEL (transparent rubric, documented so it is auditable):
+#   Each dimension yields a status (ok / warn / gap / n/a) and earns a fraction
+#   of its weight. n/a (couldn't read) earns ZERO and is never treated as ok.
+#
+#     Dimension   Weight   ok(full)        warn(half)              gap(zero)
+#     ─────────   ──────   ────────        ──────────              ─────────
+#     security      35     no gaps,        low/medium gaps only    high/critical gap
+#                          0 open alerts                           OR any open alert
+#     metadata      25     all 6 present   1–2 missing             3+ missing
+#     release       15     >=1 release &   releases exist but      no releases at all
+#                          latest tag      latest tag has no rel
+#                          has a release
+#     issues        15     none external   1–3 external/stale      4+ external/stale
+#                          or stale
+#     actions       10     latest run      no runs found (warn)    latest run = failure
+#                          succeeded
+#                                                    ─────
+#                                          total weight = 100
+#
+#   score = round( sum(weight_i * fraction_i) ), fraction in {1, 0.5, 0}.
+#   n/a dimensions earn 0 of their weight (honest: an unreadable security
+#   dimension can NEVER score full). Grade: A>=90 B>=75 C>=60 D>=40 F<40.
+#   Security is weighted highest by design; a single open critical alert or a
+#   high-severity gap zeroes 35 points and caps the grade hard.
+#
+#   --min-score N: exit 10 if ANY audited repo scores below N (CI-gating knob),
+#   independent of whether other gaps exist.
+#
+# Examples:
+#   repo-scorecard.sh --repo 0xDarkMatter/flarecrawl
+#   repo-scorecard.sh --org 0xDarkMatter
+#   repo-scorecard.sh --repo OWNER/REPO --json | jq '.data[0].top_fixes'
+#   repo-scorecard.sh --org OWNER --min-score 75   # CI gate: fail if any repo < 75
+set -uo pipefail
+
+EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
+GH_TIMEOUT="${GH_TIMEOUT:-20}"   # seconds; bounds every network call
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SEC="$HERE/check-security-posture.sh"
+ISS="$HERE/check-issues.sh"
+
+REPO=""; REMOTE="origin"; ORG=""; JSON=0; MIN_SCORE=""
+while [ $# -gt 0 ]; do
+  case "$1" in
+    --repo)      REPO="${2:?--repo needs OWNER/REPO}"; shift 2 ;;
+    --remote)    REMOTE="${2:?--remote needs a name}"; shift 2 ;;
+    --org)       ORG="${2:?--org needs an OWNER}"; shift 2 ;;
+    --min-score) MIN_SCORE="${2:?--min-score needs N}"; shift 2 ;;
+    --json)      JSON=1; shift ;;
+    -h|--help)   sed -n '2,57p' "$0" | sed 's/^# \{0,1\}//'; exit "$EX_OK" ;;
+    *) echo "repo-scorecard: unknown argument: $1" >&2; exit "$EX_USAGE" ;;
+  esac
+done
+
+skip() { echo "repo-scorecard: $1" >&2; exit "$EX_UNAVAILABLE"; }
+
+command -v gh >/dev/null 2>&1 || {
+  echo "repo-scorecard: gh not installed (https://cli.github.com)" >&2
+  exit "$EX_MISSING_DEP"
+}
+command -v jq >/dev/null 2>&1 || skip "jq not installed"
+
+# --min-score must be an integer if given.
+if [ -n "$MIN_SCORE" ] && ! printf '%s' "$MIN_SCORE" | grep -Eq '^[0-9]+$'; then
+  echo "repo-scorecard: --min-score needs an integer, got '$MIN_SCORE'" >&2; exit "$EX_USAGE"
+fi
+
+runner() { if command -v timeout >/dev/null 2>&1; then timeout "$GH_TIMEOUT" "$@"; else "$@"; fi; }
+
+# Agent safety — never interpolate a fabricated path into a gh call.
+valid_repo()  { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; }
+valid_owner() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+$'; }
+
+# Weights (sum = 100).
+W_SECURITY=35; W_METADATA=25; W_RELEASE=15; W_ISSUES=15; W_ACTIONS=10
+
+# --------------------------------------------------------------------------
+# gh api wrapper that distinguishes "exists" from "404" from "couldn't read".
+# Echoes body on success; sets a global GHRC: 0 ok, 4 not-found, 7 unavailable.
+# --------------------------------------------------------------------------
+gh_get() { # path  -> echoes body, sets GHRC
+  local out
+  out="$(runner gh api "$1" 2>/dev/null)"; local rc=$?
+  if [ $rc -ne 0 ]; then
+    # gh exits nonzero on 404 too; disambiguate via the error JSON if present.
+    if printf '%s' "$out" | grep -q '"status": *"404"' 2>/dev/null; then GHRC=4; else GHRC=7; fi
+    printf '%s' "$out"; return
+  fi
+  GHRC=0; printf '%s' "$out"
+}
+
+# Does a path exist in the repo? 0 yes, 1 no, 2 couldn't-read.
+content_exists() { # OWNER/REPO  PATH
+  if runner gh api "repos/$1/contents/$2" --silent >/dev/null 2>&1; then return 0; fi
+  # --silent suppresses the body; re-probe to classify 404 vs auth/offline.
+  local body; body="$(runner gh api "repos/$1/contents/$2" 2>&1)"
+  printf '%s' "$body" | grep -q '404' && return 1
+  return 2
+}
+
+# --------------------------------------------------------------------------
+# Score ONE repo. Echoes a compact JSON object; returns 0 healthy / 10 findings
+# / 7 unavailable (couldn't read the core repo object at all).
+# --------------------------------------------------------------------------
+score_repo() { # OWNER/REPO -> echoes JSON object; returns 0|10|7
+  local R="$1" owner core vis
+  owner="${R%%/*}"
+
+  core="$(runner gh api "repos/$R" 2>/dev/null)" || return 7
+  [ -n "$core" ] || return 7
+  vis="$(printf '%s' "$core" | jq -r '.visibility // (if .private then "private" else "public" end)' | tr -d '\r')"
+  local default_branch
+  default_branch="$(printf '%s' "$core" | jq -r '.default_branch // "main"' | tr -d '\r')"
+
+  # ---- DIMENSION: metadata (6 facets) -----------------------------------
+  local md_desc md_home md_topics md_lic md_readme md_changelog md_missing=0 md_detail=""
+  md_desc="$(printf '%s' "$core" | jq -r '.description // "" | length' | tr -d '\r')"
+  md_home="$(printf '%s' "$core" | jq -r '.homepage // "" | length' | tr -d '\r')"
+  # topics live on the core object as .topics (array).
+  md_topics="$(printf '%s' "$core" | jq -r '(.topics // []) | length' | tr -d '\r')"
+
+  [ "${md_desc:-0}" -gt 0 ] || { md_missing=$((md_missing+1)); md_detail="$md_detail description;"; }
+  # homepage is optional — count it only as a soft facet (missing homepage does
+  # NOT increment md_missing; it is informational). We track it for detail only.
+  if [ "${md_home:-0}" -gt 0 ]; then md_home="set"; else md_home="unset"; fi
+  if [ "${md_topics:-0}" -ge 3 ]; then :; else md_missing=$((md_missing+1)); md_detail="$md_detail topics<3;"; fi
+
+  if content_exists "$R" "LICENSE"; then md_lic=1
+  elif content_exists "$R" "LICENSE.md"; then md_lic=1
+  else md_lic=0; md_missing=$((md_missing+1)); md_detail="$md_detail LICENSE;"; fi
+  if content_exists "$R" "README.md"; then md_readme=1
+  else md_readme=0; md_missing=$((md_missing+1)); md_detail="$md_detail README;"; fi
+  if content_exists "$R" "CHANGELOG.md"; then md_changelog=1
+  else md_changelog=0; md_missing=$((md_missing+1)); md_detail="$md_detail CHANGELOG;"; fi
+
+  # 5 hard facets (description, topics>=3, LICENSE, README, CHANGELOG).
+  local md_status md_frac
+  if [ "$md_missing" -eq 0 ]; then md_status="ok"; md_frac="1"
+  elif [ "$md_missing" -le 2 ]; then md_status="warn"; md_frac="0.5"
+  else md_status="gap"; md_frac="0"; fi
+  [ -n "$md_detail" ] || md_detail="all present"
+  md_detail="${md_detail# }"
+
+  # ---- DIMENSION: release ----------------------------------------------
+  local rel_status rel_frac rel_detail rel_count latest_tag rel_for_tag
+  rel_count="$(runner gh api "repos/$R/releases?per_page=1" --jq 'length' 2>/dev/null | tr -d '\r')"
+  latest_tag="$(runner gh api "repos/$R/tags?per_page=1" --jq '.[0].name // ""' 2>/dev/null | tr -d '\r')"
+  if [ -z "${rel_count:-}" ]; then
+    rel_status="n/a"; rel_frac="0"; rel_detail="couldn't read releases"
+  elif [ "$rel_count" -eq 0 ]; then
+    rel_status="gap"; rel_frac="0"; rel_detail="no GitHub releases"
+  else
+    # >=1 release exists. Is the latest TAG backed by a release?
+    if [ -n "$latest_tag" ]; then
+      if runner gh api "repos/$R/releases/tags/$latest_tag" --silent >/dev/null 2>&1; then
+        rel_status="ok"; rel_frac="1"; rel_detail="latest tag $latest_tag has a release"
+      else
+        rel_status="warn"; rel_frac="0.5"; rel_detail="latest tag $latest_tag has no release"
+      fi
+    else
+      rel_status="ok"; rel_frac="1"; rel_detail="releases present (no tags listed)"
+    fi
+  fi
+
+  # ---- DIMENSION: security (orchestrate the sibling) --------------------
+  local sec_status sec_frac sec_detail sec_json sec_rc
+  sec_json="$("$SEC" --repo "$R" --json 2>/dev/null)"; sec_rc=$?
+  if [ "$sec_rc" -eq 7 ] || [ -z "$sec_json" ] || ! printf '%s' "$sec_json" | jq -e . >/dev/null 2>&1; then
+    sec_status="n/a"; sec_frac="0"; sec_detail="security audit unavailable"
+    local sec_gaps=-1 sec_alerts=-1 sec_maxsev="unknown"
+  else
+    # The single-repo envelope: {data:[features], meta:{gaps, open_alerts,...}}.
+    local sec_gaps sec_alerts sec_maxsev
+    sec_gaps="$(printf '%s' "$sec_json" | jq -r '.meta.gaps // 0')"
+    sec_alerts="$(printf '%s' "$sec_json" | jq -r '.meta.open_alerts // 0')"
+    # Max severity across (a) gap rows and (b) any open alert.
+    sec_maxsev="$(printf '%s' "$sec_json" | jq -r '
+      ([ .data[]
+         | select(.applicable==true)
+         | (if ((.open_alerts // 0) > 0) then .max_severity else empty end),
+           (if (.state=="off" or .state=="unknown") then .severity else empty end) ]
+       | map(select(. != null and . != "" and . != "none" and . != "note")
+             | ascii_downcase)) as $s
+      | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // "none"')"
+    if [ "$sec_gaps" -eq 0 ] && [ "$sec_alerts" -eq 0 ]; then
+      sec_status="ok"; sec_frac="1"; sec_detail="no gaps, 0 open alerts"
+    elif [ "$sec_maxsev" = "critical" ] || [ "$sec_maxsev" = "high" ] || [ "$sec_alerts" -gt 0 ]; then
+      sec_status="gap"; sec_frac="0"
+      sec_detail="$sec_gaps gap(s), $sec_alerts open alert(s), max $sec_maxsev"
+    else
+      sec_status="warn"; sec_frac="0.5"
+      sec_detail="$sec_gaps gap(s) (max $sec_maxsev), 0 open alerts"
+    fi
+  fi
+
+  # ---- DIMENSION: issues (orchestrate the sibling) ----------------------
+  local iss_status iss_frac iss_detail iss_json iss_rc iss_flagged=-1 iss_total=-1
+  iss_json="$("$ISS" --repo "$R" --json 2>/dev/null)"; iss_rc=$?
+  if [ "$iss_rc" -eq 7 ] || [ -z "$iss_json" ] || ! printf '%s' "$iss_json" | jq -e . >/dev/null 2>&1; then
+    iss_status="n/a"; iss_frac="0"; iss_detail="issue audit unavailable"
+  else
+    iss_flagged="$(printf '%s' "$iss_json" | jq -r '.meta.flagged // 0')"
+    iss_total="$(printf '%s' "$iss_json" | jq -r '.meta.total_open // 0')"
+    if [ "$iss_flagged" -eq 0 ]; then
+      iss_status="ok"; iss_frac="1"; iss_detail="$iss_total open, none external/stale"
+    elif [ "$iss_flagged" -le 3 ]; then
+      iss_status="warn"; iss_frac="0.5"; iss_detail="$iss_flagged external/stale of $iss_total open"
+    else
+      iss_status="gap"; iss_frac="0"; iss_detail="$iss_flagged external/stale of $iss_total open"
+    fi
+  fi
+
+  # ---- DIMENSION: actions (single signal) -------------------------------
+  local act_status act_frac act_detail act_json act_concl
+  act_json="$(runner gh api "repos/$R/actions/runs?branch=$default_branch&per_page=1" 2>/dev/null)"
+  if [ -z "$act_json" ] || ! printf '%s' "$act_json" | jq -e '.workflow_runs' >/dev/null 2>&1; then
+    act_status="n/a"; act_frac="0"; act_detail="couldn't read workflow runs"
+  else
+    act_concl="$(printf '%s' "$act_json" | jq -r '.workflow_runs[0].conclusion // "none"' | tr -d '\r')"
+    case "$act_concl" in
+      success)            act_status="ok";   act_frac="1";   act_detail="latest run on $default_branch: success" ;;
+      none|null|"")       act_status="warn"; act_frac="0.5"; act_detail="no workflow runs on $default_branch" ;;
+      failure|timed_out|startup_failure)
+                          act_status="gap";  act_frac="0";   act_detail="latest run on $default_branch: $act_concl" ;;
+      *)                  act_status="warn"; act_frac="0.5"; act_detail="latest run on $default_branch: $act_concl" ;;
+    esac
+  fi
+
+  # ---- Roll up the score -------------------------------------------------
+  local score
+  score="$(awk -v ws=$W_SECURITY -v wm=$W_METADATA -v wr=$W_RELEASE -v wi=$W_ISSUES -v wa=$W_ACTIONS \
+    -v fs="$sec_frac" -v fm="$md_frac" -v fr="$rel_frac" -v fi="$iss_frac" -v fa="$act_frac" \
+    'BEGIN{ printf "%d", int(ws*fs + wm*fm + wr*fr + wi*fi + wa*fa + 0.5) }')"
+  local grade
+  if   [ "$score" -ge 90 ]; then grade="A"
+  elif [ "$score" -ge 75 ]; then grade="B"
+  elif [ "$score" -ge 60 ]; then grade="C"
+  elif [ "$score" -ge 40 ]; then grade="D"
+  else grade="F"; fi
+
+  # ---- Top 3 fixes (highest-severity gaps first) -------------------------
+  # Build a ranked list. Each entry: severity-rank \t status \t text.
+  # rank 0 highest. Only surface dimensions that are gap/warn/n/a.
+  local fixes="[]"
+  addfix() { # rank status text
+    fixes="$(jq -c --argjson r "$1" --arg st "$2" --arg t "$3" \
+      '. + [{rank:$r, status:$st, fix:$t}]' <<<"$fixes")"
+  }
+  # security first (highest weight). Map maxsev to a rank.
+  if [ "$sec_status" = "gap" ]; then
+    addfix 0 gap "security: $sec_detail → check-security-posture.sh --repo $R --commands"
+  elif [ "$sec_status" = "warn" ]; then
+    addfix 3 warn "security: $sec_detail → check-security-posture.sh --repo $R --commands"
+  elif [ "$sec_status" = "n/a" ]; then
+    addfix 5 "n/a" "security: couldn't read → re-run check-security-posture.sh --repo $R"
+  fi
+  if [ "$md_status" = "gap" ]; then
+    addfix 1 gap "metadata: missing ${md_detail} → set description / >=3 topics / add the missing file(s)"
+  elif [ "$md_status" = "warn" ]; then
+    addfix 4 warn "metadata: missing ${md_detail} → set description / >=3 topics / add the missing file(s)"
+  fi
+  if [ "$rel_status" = "gap" ]; then
+    addfix 2 gap "release: $rel_detail → cut a GitHub release (github-ops mode update)"
+  elif [ "$rel_status" = "warn" ]; then
+    addfix 4 warn "release: $rel_detail → gh release create $latest_tag"
+  fi
+  if [ "$iss_status" = "gap" ]; then
+    addfix 2 gap "issues: $iss_detail → check-issues.sh --repo $R"
+  elif [ "$iss_status" = "warn" ]; then
+    addfix 5 warn "issues: $iss_detail → check-issues.sh --repo $R"
+  fi
+  if [ "$act_status" = "gap" ]; then
+    addfix 1 gap "actions: $act_detail → inspect the failing run (gh run list --repo $R)"
+  elif [ "$act_status" = "warn" ]; then
+    addfix 5 warn "actions: $act_detail"
+  fi
+  local top_fixes
+  top_fixes="$(printf '%s' "$fixes" | jq -c 'sort_by(.rank) | [ .[] | .fix ] | .[0:3]')"
+
+  # ---- Assemble the per-repo object -------------------------------------
+  jq -c -n \
+    --arg repo "$R" --arg vis "$vis" --argjson score "$score" --arg grade "$grade" \
+    --arg md_st "$md_status" --arg md_d "$md_detail" \
+    --arg rel_st "$rel_status" --arg rel_d "$rel_detail" \
+    --arg sec_st "$sec_status" --arg sec_d "$sec_detail" \
+    --argjson sec_gaps "${sec_gaps:-0}" --argjson sec_alerts "${sec_alerts:-0}" --arg sec_mx "${sec_maxsev:-none}" \
+    --arg iss_st "$iss_status" --arg iss_d "$iss_detail" --argjson iss_fl "${iss_flagged:-0}" \
+    --arg act_st "$act_status" --arg act_d "$act_detail" \
+    --argjson topf "$top_fixes" \
+    '{repo:$repo, visibility:$vis, score:$score, grade:$grade,
+      dimensions:{
+        metadata:{status:$md_st, detail:$md_d},
+        release:{status:$rel_st, detail:$rel_d},
+        security:{status:$sec_st, detail:$sec_d, gaps:(if $sec_gaps<0 then null else $sec_gaps end), open_alerts:(if $sec_alerts<0 then null else $sec_alerts end), max_severity:$sec_mx},
+        issues:{status:$iss_st, detail:$iss_d, flagged:(if $iss_fl<0 then null else $iss_fl end)},
+        actions:{status:$act_st, detail:$act_d}
+      },
+      top_fixes:$topf}'
+
+  # Per-repo exit: findings if any dimension is gap, n/a, or warn? We count
+  # gap/n/a as findings (real problems). warn does not by itself trip exit 10
+  # unless --min-score applies. (n/a is a finding — never a clean pass.)
+  if [ "$md_status" = "gap" ] || [ "$rel_status" = "gap" ] || [ "$sec_status" = "gap" ] || \
+     [ "$iss_status" = "gap" ] || [ "$act_status" = "gap" ] || \
+     [ "$md_status" = "n/a" ] || [ "$rel_status" = "n/a" ] || [ "$sec_status" = "n/a" ] || \
+     [ "$iss_status" = "n/a" ] || [ "$act_status" = "n/a" ]; then
+    return 10
+  fi
+  return 0
+}
+
+# Glyph for a dimension status (human matrix).
+mark() { case "$1" in
+  ok) printf 'ok ';; warn) printf 'warn';; gap) printf 'GAP ';; "n/a") printf 'n/a ';; *) printf '?   ';; esac; }
+
+# Human single-repo card (data to stdout; framing to stderr).
+print_card() { # repo_json
+  local o="$1" repo vis score grade
+  repo="$(jq -r '.repo' <<<"$o")"; vis="$(jq -r '.visibility' <<<"$o")"
+  score="$(jq -r '.score' <<<"$o")"; grade="$(jq -r '.grade' <<<"$o")"
+  {
+    echo "REPO SCORECARD — $repo ($vis)"
+    echo "  SCORE: $score/100   GRADE: $grade"
+    echo "  ── dimensions (weight) ──────────────────────────────────"
+    printf '  %-9s [%s]  %s\n' "security"  "$(mark "$(jq -r '.dimensions.security.status' <<<"$o")")"  "$(jq -r '.dimensions.security.detail' <<<"$o")  (w35)"
+    printf '  %-9s [%s]  %s\n' "metadata"  "$(mark "$(jq -r '.dimensions.metadata.status' <<<"$o")")"  "$(jq -r '.dimensions.metadata.detail' <<<"$o")  (w25)"
+    printf '  %-9s [%s]  %s\n' "release"   "$(mark "$(jq -r '.dimensions.release.status' <<<"$o")")"   "$(jq -r '.dimensions.release.detail' <<<"$o")  (w15)"
+    printf '  %-9s [%s]  %s\n' "issues"    "$(mark "$(jq -r '.dimensions.issues.status' <<<"$o")")"    "$(jq -r '.dimensions.issues.detail' <<<"$o")  (w15)"
+    printf '  %-9s [%s]  %s\n' "actions"   "$(mark "$(jq -r '.dimensions.actions.status' <<<"$o")")"   "$(jq -r '.dimensions.actions.detail' <<<"$o")  (w10)"
+    local nf; nf="$(jq -r '.top_fixes | length' <<<"$o")"
+    if [ "$nf" -gt 0 ]; then
+      echo "  ── top fixes (highest-severity first) ───────────────────"
+      jq -r '.top_fixes[] | "     • " + .' <<<"$o"
+    fi
+  } >&2
+}
+
+# ==========================================================================
+# Mode dispatch
+# ==========================================================================
+
+# Mutually exclusive selectors.
+sel=0
+[ -n "$REPO" ] && sel=$((sel+1))
+[ -n "$ORG" ]  && sel=$((sel+1))
+if [ "$sel" -gt 1 ]; then
+  echo "repo-scorecard: --repo and --org are mutually exclusive" >&2; exit "$EX_USAGE"
+fi
+
+# ---- Fleet sweep ----------------------------------------------------------
+if [ -n "$ORG" ]; then
+  valid_owner "$ORG" || { echo "repo-scorecard: invalid owner '$ORG'" >&2; exit "$EX_USAGE"; }
+  echo "repo-scorecard: sweeping $ORG …" >&2
+  list="$(runner gh repo list "$ORG" --no-archived --limit 200 --json nameWithOwner,visibility 2>/dev/null)" \
+    || skip "gh repo list failed for $ORG (not authed / offline / rate-limited?)"
+  [ -n "$list" ] || skip "no repos returned for $ORG"
+  mapfile -t repos < <(printf '%s' "$list" | jq -r '.[].nameWithOwner' | tr -d '\r')
+  [ "${#repos[@]}" -gt 0 ] || skip "no non-archived repos for $ORG"
+
+  all="[]"; any_findings=0; swept=0; unread=0; below_min=0
+  for r in "${repos[@]}"; do
+    valid_repo "$r" || continue
+    obj="$(score_repo "$r")"; rc=$?
+    if [ "$rc" -eq 7 ] || [ -z "$obj" ] || ! printf '%s' "$obj" | jq -e . >/dev/null 2>&1; then
+      unread=$((unread+1))
+      [ "$JSON" -eq 1 ] || echo "  ?    $r — couldn't read (skipped)" >&2
+      continue
+    fi
+    swept=$((swept+1))
+    [ "$rc" -eq 10 ] && any_findings=1
+    all="$(jq -c --argjson o "$obj" '. + [$o]' <<<"$all")"
+    sc="$(jq -r '.score' <<<"$obj")"
+    if [ -n "$MIN_SCORE" ] && [ "$sc" -lt "$MIN_SCORE" ]; then below_min=$((below_min+1)); fi
+    if [ "$JSON" -eq 0 ]; then
+      # matrix row: per-dimension single-char marks + score + grade.
+      m() { case "$(jq -r ".dimensions.$1.status" <<<"$obj")" in
+        ok) printf '+';; warn) printf '~';; gap) printf 'X';; "n/a") printf '?';; *) printf ' ';; esac; }
+      printf '  %-34s S:%s M:%s R:%s I:%s A:%s  %3s %s\n' \
+        "$r" "$(m security)" "$(m metadata)" "$(m release)" "$(m issues)" "$(m actions)" \
+        "$sc" "$(jq -r '.grade' <<<"$obj")" >&2
+    fi
+  done
+
+  # Roll-up stats. The repo array can be large (a big org), so pipe it via STDIN
+  # rather than --argjson on argv — argv has a length cap and a fleet sweep blows
+  # past it (observed: jq "Argument list too long" at ~70 repos on MSYS).
+  rollup="$(printf '%s' "$all" | jq -c --arg org "$ORG" \
+    --argjson swept "$swept" --argjson unread "$unread" \
+    '. as $data
+     | ($data | map(.score)) as $scores
+     | ($scores | length) as $n
+     | { org:$org, repos_scored:$swept, repos_unreadable:$unread,
+         avg_score: (if $n>0 then (($scores|add)/$n|floor) else null end),
+         median_score: (if $n>0 then ($scores|sort|.[($n/2|floor)]) else null end),
+         total_open_alerts: ([ $data[].dimensions.security.open_alerts // 0 ] | add),
+         failing_by_dimension: {
+           security: ([ $data[]|select(.dimensions.security.status=="gap") ]|length),
+           metadata: ([ $data[]|select(.dimensions.metadata.status=="gap") ]|length),
+           release:  ([ $data[]|select(.dimensions.release.status=="gap") ]|length),
+           issues:   ([ $data[]|select(.dimensions.issues.status=="gap") ]|length),
+           actions:  ([ $data[]|select(.dimensions.actions.status=="gap") ]|length)
+         },
+         worst: ([ $data[] | {repo, score, grade} ] | sort_by(.score) | .[0:3])
+       }')"
+
+  if [ "$JSON" -eq 1 ]; then
+    # Pipe the (large) data array via stdin; $rollup is small enough for --argjson.
+    printf '%s' "$all" | jq -c --argjson roll "$rollup" \
+      --argjson find "$any_findings" --argjson below "$below_min" \
+      --arg minscore "${MIN_SCORE:-}" \
+      '{data:., meta:($roll + {findings:($find==1 or $below>0), below_min:$below,
+        min_score:(if $minscore=="" then null else ($minscore|tonumber) end),
+        schema:"claude-mods.github-ops.repo-scorecard/v1"})}'
+  else
+    {
+      echo "── roll-up: $ORG ───────────────────────────────────────────"
+      printf '%s' "$rollup" | jq -r '
+        "  scored: \(.repos_scored)   unreadable: \(.repos_unreadable)",
+        "  avg score: \(.avg_score)   median: \(.median_score)",
+        "  total open security alerts (fleet): \(.total_open_alerts)",
+        "  repos with a GAP — security:\(.failing_by_dimension.security) metadata:\(.failing_by_dimension.metadata) release:\(.failing_by_dimension.release) issues:\(.failing_by_dimension.issues) actions:\(.failing_by_dimension.actions)",
+        "  worst: " + ([ .worst[] | "\(.repo) (\(.score)/\(.grade))" ] | join(", "))'
+      [ -n "$MIN_SCORE" ] && echo "  below --min-score $MIN_SCORE: $below_min repo(s)"
+      echo "  legend: +=ok ~=warn X=gap ?=n/a  ·  S=security M=metadata R=release I=issues A=actions"
+    } >&2
+  fi
+  { [ "$any_findings" -eq 1 ] || [ "$below_min" -gt 0 ]; } && exit "$EX_FINDINGS"
+  exit "$EX_OK"
+fi
+
+# ---- Single repo ----------------------------------------------------------
+if [ -z "$REPO" ]; then
+  url="$(git remote get-url "$REMOTE" 2>/dev/null)" || skip "no '$REMOTE' remote here"
+  case "$url" in
+    *github.com[:/]*)
+      REPO="$(printf '%s' "$url" | tr -d '\r' | sed -E 's#^.*github\.com[:/]+##; s#\.git$##; s#/$##')" ;;
+    *) skip "remote '$REMOTE' is not a github.com repo" ;;
+  esac
+fi
+valid_repo "$REPO" || { echo "repo-scorecard: invalid OWNER/REPO '$REPO'" >&2; exit "$EX_USAGE"; }
+
+obj="$(score_repo "$REPO")"; rc=$?
+if [ "$rc" -eq 7 ] || [ -z "$obj" ] || ! printf '%s' "$obj" | jq -e . >/dev/null 2>&1; then
+  skip "couldn't read $REPO (not authed / offline / not found / timeout)"
+fi
+
+score="$(jq -r '.score' <<<"$obj")"
+below=0
+if [ -n "$MIN_SCORE" ] && [ "$score" -lt "$MIN_SCORE" ]; then below=1; fi
+
+if [ "$JSON" -eq 1 ]; then
+  jq -c --argjson find "$( [ "$rc" -eq 10 ] && echo 1 || echo 0 )" \
+    --argjson below "$below" --arg minscore "${MIN_SCORE:-}" \
+    '{data:[.], meta:{repo:.repo, visibility:.visibility, score:.score, grade:.grade,
+       findings:($find==1 or $below>0), below_min:$below,
+       min_score:(if $minscore=="" then null else ($minscore|tonumber) end),
+       schema:"claude-mods.github-ops.repo-scorecard/v1"}}' <<<"$obj"
+else
+  print_card "$obj"
+fi
+
+{ [ "$rc" -eq 10 ] || [ "$below" -eq 1 ]; } && exit "$EX_FINDINGS"
+exit "$EX_OK"

+ 51 - 0
skills/github-ops/tests/run.sh

@@ -85,6 +85,57 @@ if grep -nE '\-X (PUT|PATCH|POST|DELETE)' "$SP" | grep -vqE '_cmd='; then
   no "sp: a mutating verb appears outside an emitted *_cmd string"
 else ok "sp: all mutating verbs are emitted text only"; fi
 
+echo
+echo "-- repo-scorecard.sh (offline contract + orchestration + read-only proof) --"
+
+RS="$SCRIPTS/repo-scorecard.sh"
+
+bash -n "$RS" && ok "rs: bash -n clean" || no "rs: bash -n"
+
+bash "$RS" --help >/dev/null 2>&1; expect "rs: --help" 0 $?
+if bash "$RS" --help 2>&1 | grep -q "Examples:"; then ok "rs: --help has EXAMPLES"
+else no "rs: --help missing EXAMPLES"; fi
+# The scoring rubric must be documented in the header (transparent, auditable).
+if bash "$RS" --help 2>&1 | grep -q "SCORING MODEL"; then ok "rs: --help documents SCORING MODEL"
+else no "rs: --help missing SCORING MODEL"; fi
+
+bash "$RS" --frobnicate >/dev/null 2>&1; expect "rs: unknown flag -> usage" 2 $?
+# Malformed OWNER/REPO is a usage error, never a network call.
+bash "$RS" --repo "not-a-valid-spec" >/dev/null 2>&1; expect "rs: bad --repo shape -> usage" 2 $?
+# --repo and --org are mutually exclusive.
+bash "$RS" --repo a/b --org c >/dev/null 2>&1; expect "rs: --repo + --org -> usage" 2 $?
+# --min-score must be an integer.
+bash "$RS" --min-score xx >/dev/null 2>&1; expect "rs: bad --min-score -> usage" 2 $?
+
+# Non-github remote must skip with exit 7 and NEVER hit the network.
+( cd "$T" && bash "$RS" --remote origin >/dev/null 2>&1 ); expect "rs: non-github remote -> unavailable" 7 $?
+# Missing remote -> skip 7.
+( cd "$T" && bash "$RS" --remote nope-xyz >/dev/null 2>&1 ); expect "rs: missing remote -> unavailable" 7 $?
+
+# Orchestration: it MUST call the sibling auditors by name (the reuse is the point).
+if grep -q "check-security-posture.sh" "$RS"; then ok "rs: references check-security-posture.sh"
+else no "rs: does not reference check-security-posture.sh"; fi
+if grep -q "check-issues.sh" "$RS"; then ok "rs: references check-issues.sh"
+else no "rs: does not reference check-issues.sh"; fi
+
+# Read-only guarantee: no executed mutating gh verb anywhere. Every gh call must
+# be a GET (the remediation pointers it prints are text, not executed). Assert no
+# `gh api -X PUT/PATCH/POST/DELETE` and no `gh repo edit`/`gh release create` etc.
+if grep -E '\bgh (api )?-X (PUT|PATCH|POST|DELETE)' "$RS" | grep -vqE '^\s*#'; then
+  no "rs: found an executed mutating gh -X call (must be read-only)"
+else ok "rs: no executed mutating gh -X call (read-only)"; fi
+# Belt-and-braces: every `runner gh …` (the only network executor) is a read-only
+# subcommand — `gh api <GET path>` or `gh repo list`. No mutating subcommand runs.
+if grep -nE 'runner gh ' "$RS" | grep -Evq 'runner gh (api|repo list)'; then
+  no "rs: a 'runner gh' call uses a non-read-only subcommand"
+else ok "rs: every executed 'runner gh' is read-only (api / repo list)"; fi
+# And mutating gh subcommands, where they appear, are inside printed fix strings only
+# (the remediation pointers), never executed. Verify they sit on addfix/echo lines.
+if grep -nE 'gh (release create|repo edit|release delete|secret set|pr merge)' "$RS" \
+     | grep -vqE 'addfix|→'; then
+  no "rs: a mutating gh subcommand appears outside a printed remediation string"
+else ok "rs: mutating gh subcommands only appear as printed remediation text"; fi
+
 echo
 echo "=== $pass passed, $fail failed ==="
 [ "$fail" -eq 0 ]