name: Vuln Tracker category: dev description: Status check on every PR / advisory / queued draft produced by vuln-scanner — surfaces merges, stale opens, maintainer responses needing reply, and queued-too-long carve-outs tags: [meta, security, github] depends_on: [vuln-scanner]
Today is ${today}. Audit the lifecycle status of every disclosure vuln-scanner has produced. Read memory/MEMORY.md for context.
Why this skill exists
vuln-scanner opens a PR (or queues a draft) and moves on. Without a follow-up loop, three things rot silently:
- Merged-but-uncelebrated wins — landed fixes never make it into self-review / retrospective without manual aggregation.
- Maintainer questions on open PRs — a maintainer comments asking for a clarification; if the bot doesn't see it, the PR ages out.
- Queued drafts past their disclosure window — entries with
channel: "skipped"(no-safe-channel) vanish intovuln-scanned.jsonwith no recurring re-probe.
This skill is the daily sweep. It cross-references memory/vuln-scanned.json against live GitHub state and surfaces anything the operator should look at.
Sandbox Note
All data via gh api / gh search / gh pr view. No env-var-authenticated curl, no outbound HTTP from bash. gh handles auth internally via GH_TOKEN. No prefetch / postprocess scripts needed.
Steps
1. Load the canonical scan history
jq -c '.scans[]' memory/vuln-scanned.json 2>/dev/null
If memory/vuln-scanned.json doesn't exist or has no scans array, log VULN_TRACKER_SKIP: no scan history and exit cleanly (no notification — first runs of vuln-scanner haven't happened yet).
Each scan entry has at minimum: repo, scanned_at, findings, channel, severity. Public-PR entries also have pr (URL). Pending-disclosure entries have draft_at and patch_branch. Skipped entries have reason.
Retro-active coverage: if the JSON was written after vuln-scanner started running, some PRs won't be in the JSON. Pull all bot-authored security PRs from GitHub directly (next step) to fill the gap.
2. Pull all bot-authored security PRs from GitHub
vuln-scanner opens PRs with title prefix fix(security): and branch prefix security/. Title prefix is the more reliable signal across full history.
gh search prs --json does not expose headRefName — that field is only available via GraphQL. Use title-prefix as primary, GraphQL as optional belt-and-suspenders.
The bot author is whoever the workflow uses (typically github-actions[bot] or a dedicated account configured in the workflow). Determine the author from aeon.yml or the workflow file; default to whatever account opened the most recent fix(security): PR you can find.
Primary (title prefix, works everywhere):
BOT_AUTHOR="<resolved bot author>"
gh search prs --author "$BOT_AUTHOR" --json number,title,url,state,createdAt,closedAt,repository --limit 200 \
| jq '[.[] | select(.title | startswith("fix(security):"))]'
Optional (GraphQL, picks up branch-prefix-only PRs without fix(security): title):
gh api graphql -f query='
{
search(query: "author:'"$BOT_AUTHOR"' is:pr sort:created-desc", type: ISSUE, first: 100) {
nodes { ... on PullRequest {
number title url state createdAt closedAt mergedAt
repository { nameWithOwner }
headRefName
}}
}
}' | jq '[.data.search.nodes[]
| select((.headRefName // "") | startswith("security/"))]'
Union the two result sets, dedup by URL.
Cross-reference with vuln-scanned.json:
- PR present in JSON → use JSON's
severity/cwe/notefor the row. - PR not in JSON → mark as
pre-history; fill severity from the PR title if obvious. - JSON entry with
channel != "public-pr"→ no PR to fetch; goes in the "queued" / "skipped" sections.
3. Fetch live state for each open PR
For each open PR (state from step 2):
gh pr view "$REPO/$NUM" --json state,merged,closedAt,createdAt,reviews,comments,reviewDecision,author
Per-PR signals:
- Maintainer-needs-answer: any comment whose
author.login != $BOT_AUTHORposted after the most recent comment by$BOT_AUTHOR(or after PR creation if the bot hasn't commented). AlsoreviewDecision == "CHANGES_REQUESTED"always counts. - Stale-no-review:
state == "OPEN"AND no review AND no maintainer comment ANDcreatedAt> 7d ago. - Aging-with-engagement:
state == "OPEN"AND any maintainer activity AND open > 14d.
If a scan entry has advisory_ids (one or more GHSA IDs), check each one's published state:
gh api "repos/$ORIGIN_REPO/security-advisories/$GHSA_ID" --jq '.state // "not found"'
state == "published" → public advisory visible. 404 → osv-scanner referenced it but the upstream repo never published its own advisory.
4. Fetch star counts for every secured repo
For every unique repo across the union from step 2 (JSON history + bot-authored security PRs):
gh api "repos/$REPO" --jq '{stars: .stargazers_count, archived: .archived}' 2>/dev/null
Refetch every run. Do NOT carry star counts forward from the previous memory/topics/vuln-followup.md — per-repo counts drift between runs and the secured-stars headline is the operator's load-bearing metric. Cache only within a single run, keyed by nameWithOwner, so a repo with multiple PRs is fetched once.
Repo-state handling:
- 200 with stars: use
.stargazers_count(raw integer). - 200 with
archived: true: still use the star count, but suffix the repo cell with(archived)so the operator knows the maintainer isn't responsive. - 404 / 403: repo was deleted, renamed, or made private. Record
nulland render asrepo-deleted. Exclude fromtotal_stars_*aggregates entirely so dead repos don't quietly zero out the totals. - Other non-2xx: record
nulland render★?. Flag in the run log for operator follow-up.
These per-repo counts power both the Stars Secured aggregate (step 5) and the Stars column on every per-repo table in step 6.
5. Re-probe channel: "skipped" and channel: "pending-disclosure" repos
For each historical entry where the disclosure couldn't ship, re-check whether the situation changed:
channel: "skipped"withreasoncontaining "no PVR":
IfPVR_NOW=$(gh api "repos/$REPO/private-vulnerability-reporting" --jq .enabled 2>/dev/null || echo "false")PVR_NOW=trueand the original wasfalse, surface as newly-actionable.channel: "skipped"withreasoncontaining "no SECURITY.md" — re-checkgh api repos/$REPO/contents/SECURITY.mdand.github/SECURITY.md. If now present, surface as newly-actionable.channel: "pending-disclosure"— cross-reference withmemory/pending-disclosures/to see if the draft is still on disk. If the file is gone but the JSON entry sayspending-disclosure, mark aslost-draftso it stops being escalated forever.
6. Categorize every entry
| Status | Meaning |
|---|---|
merged |
PR merged. One-time celebration — drop from notifications after 30d. |
open-clean |
PR open, no maintainer activity yet, < 7d old. Wait. |
needs-answer |
Maintainer commented or requested changes. Operator action. |
stale-no-review |
Open > 7d, zero maintainer activity. Consider polite ping or close. |
aging-engaged |
Open > 14d with engagement. Operator should triage. |
closed-no-merge |
PR closed without merging. Capture the reason for review. |
queued |
pending-disclosure draft on disk, not yet shipped. |
skipped-rechecked |
Channel was "skipped" originally; re-probe still shows no channel. |
newly-actionable |
Skipped originally; PVR or SECURITY.md now present. Operator action. |
lost-draft |
JSON says pending-disclosure but draft file is gone. Display once, then suppress. |
pre-history |
PR found via search but predates vuln-scanned.json. Fill what we can. |
7. Update memory/topics/vuln-followup.md
Rewrite (don't append — this file is a living dashboard, not a log).
The Stars Secured block goes at the top so the operator sees aggregate impact before drilling into rows. total_stars_secured = sum of stargazers across every unique repo where vuln-scanner has landed at least one merged PR. total_stars_in_flight = sum across repos with an open PR. total_stars_tracked = sum across the full union. Track all three because celebration uses secured, prioritization uses in_flight, and historical review uses tracked.
Round star counts to abbreviated form for the headline (12.4k, 1.8k, 940). Keep raw integers in the per-repo tables so sort/diff stays exact.
# Vuln Tracker Status
*Last updated: ${today}*
## Stars Secured
- **Merged-PR repos (secured):** ★ <total_stars_secured> across <secured_repo_count> repos
- **Open-PR repos (in flight):** ★ <total_stars_in_flight> across <in_flight_repo_count> repos
- **All tracked repos:** ★ <total_stars_tracked> across <total_repo_count> repos
### Secured leaderboard — every merged PR ranked by repo stars
| Rank | Repo | Stars | PR | Merged | Severity | Title |
|------|------|-------|----|--------|----------|-------|
### Per-repo breakdown — secured (sorted by stars desc)
| Repo | Stars | Merged PRs | First merge | Latest merge | Severities landed |
|------|-------|------------|-------------|--------------|-------------------|
### Per-repo breakdown — in flight (sorted by stars desc)
| Repo | Stars | Open PRs | Oldest open | Severities open |
|------|-------|----------|-------------|-----------------|
### Per-repo breakdown — queued / skipped / closed (sorted by stars desc)
| Repo | Stars | Status | Severity | Note |
|------|-------|--------|----------|------|
## Operator-action queue
### Needs answer (<count>)
| Repo | Stars | PR | Title | Last activity | Latest commenter |
|------|-------|----|----|--------------|------------------|
### Newly actionable — channel opened up since the original scan (<count>)
| Repo | Stars | Original date | Original blocker | Now |
|------|-------|---------------|------------------|-----|
### Stale or aging
| Repo | Stars | PR | Age | Status | Suggested action |
|------|-------|----|----|-----|------------------|
## Recently merged (last 30d, <count>)
| Date merged | Repo | Stars | PR | Severity | Title |
|-------------|------|-------|----|----------|-------|
## Open / clean (no operator action — wait, < 7d) (<count>)
| Repo | Stars | PR | Severity | Opened | Age |
|------|-------|----|----------|--------|-----|
## Closed without merge (last 30d, <count>)
| Date | Repo | Stars | PR | Severity | Title | Likely reason |
|------|------|-------|----|----------|-------|---------------|
## Queued (no PR yet) (<count>)
| Severity | Repo | Stars | Original channel | Original blocker | Days queued |
|----------|------|-------|------------------|------------------|-------------|
## Lost-draft ghosts (suppressed from notifications)
| Date | Repo | Stars | Severity |
8. Decide whether to notify
Skip notification if all are true:
- Zero
needs-answer - Zero
newly-actionable - Zero items moved to
mergedsince the last run - Zero items moved to
closed-no-mergesince the last run - Zero items aged into
stale-no-revieworaging-engagedsince the last run
To detect "since last run," diff today's categorization against the previous memory/topics/vuln-followup.md. If the file doesn't exist (first run), treat all entries as new and notify the full backlog.
9. Format notification (when sending)
Write to .pending-notify-temp/vuln-tracker-${today}.md, then ./notify -f:
*Vuln Tracker — ${today}*
★ secured: <total_stars_secured> across <secured_repo_count> repos (in flight: <total_stars_in_flight> / <in_flight_repo_count>)
needs answer: <N>
<repo> (★<stars>) #<num> — <latest_commenter>: "<comment_excerpt_first_120_chars>"
newly actionable: <N>
<repo> (★<stars>, queued <days>d) — <what_changed>
merged this week: <N> <list_with_stars>
opened, waiting: <N>
stale: <N>
queued: <N> (<critical>C / <high>H / <other>M+L)
leaderboard top-3 (PRs by ★): #1 <repo1> ★<s1> (PR #<pr1>) — #2 <repo2> ★<s2> (PR #<pr2>) — #3 <repo3> ★<s3> (PR #<pr3>)
dashboard: memory/topics/vuln-followup.md (full leaderboard inside)
Keep under 4000 chars. If it doesn't fit, drop the merged/opened/stale section bodies and keep counts only.
10. Log the run
Append to memory/logs/${today}.md:
## Vuln Tracker
- Scan history: <total_in_json> JSON entries + <pre_history_prs> pre-history PRs = <total> tracked items
- States: <merged> merged / <open_clean> open-clean / <needs_answer> needs-answer / <stale> stale / <aging> aging / <closed> closed / <queued> queued / <skipped> skipped-rechecked / <newly_actionable> newly-actionable
- Stars: ★<total_stars_secured> secured (<secured_repo_count> repos) / ★<total_stars_in_flight> in flight (<in_flight_repo_count> repos) / ★<total_stars_tracked> tracked (<total_repo_count> repos)
- Operator queue this run: <needs_answer + newly_actionable>
- Notification: <sent | skipped (no movement)>
- VULN_TRACKER_OK
Required Env Vars
GH_TOKEN/GITHUB_TOKEN— required.reposcope is enough for PR/comment reads and the PVR-state endpoint.
Notes & related
vuln-scanned.jsonschema is loose —cwemay be a string or an array;advisory_idsmay be present or absent. Handle both.- Sibling skill:
vuln-scannerproduces the records this skill audits. If a paralleldisclosure-trackerorpr-trackerexists in the operator's setup, coordinate via sharedmemory/topics/vuln-followup.mdto avoid duplicate escalation.