Extracted from
SKILL.mdso subagents that only need this slice can load just this file. Loaded automatically when the orchestrator (or a subagent) is in the matching step.
This subdoc carries the bulk-mode orchestration contract — how the orchestrator buckets trackers (CVE-affecting vs non-CVE-affecting), what subagents return, the merged-proposal review shape, hard rules, and when bulk mode is NOT appropriate.
When the user asks for a bulk sync (“sync all open issues”, “sync #212, #214 and #218”, “refresh state of everything that is still cve allocated”, or a triage-sweep variant), switch into bulk mode: each issue is assessed by a separate subagent running in parallel, and the orchestrator merges the results into a single combined proposal for the user to confirm once.
Running the full single-issue flow 20 times in the main agent would blow the context window with mail threads, PR diffs, and comment bodies the user does not need to see. Delegating per-issue gathering to subagents keeps the main context clean and runs the reads concurrently, which is exactly what the sync needs.
Pick the issue list. Resolve the user's selector into a concrete list of issue numbers before spawning subagents. The selectors the skill accepts, in order of precedence:
| User input | Resolves to |
|---|---|
sync all | every open issue in <tracker> plus recently-closed trackers still awaiting a post-close cve.org publication check. Resolve as: gh issue list --repo <tracker> --state open --limit 100 --json number,title,labels ∪ gh issue list --repo <tracker> --state closed --label "announced" --limit 50 --json number,title,labels,closedAt --jq '[.[] | select(.closedAt > (now - 90*86400 | todate))]', then drop any issue labelled rejections-ledger (the rejected-without-tracker ledger issue is not a tracker — see note below). The closed bucket is limited to the last 90 days and to trackers carrying the announced label — those are the ones waiting for cve.org propagation + the final reporter notification (see 1g). Everything else is a no-op on closed issues and is excluded. |
sync all open | explicit open-only variant — gh issue list --repo <tracker> --state open --limit 100 --json number,title,labels, then drop any issue labelled rejections-ledger. No closed trackers. Use when you want the classic open-only sweep and nothing else. |
sync #212, sync 212, sync #212, #214, #218, sync #212-#218 | the issue number(s) verbatim — no resolution needed. Works on open and closed trackers alike (the closed-issue sub-steps run when the tracker is closed with announced). |
sync CVE-2026-40913 or sync CVE-2026-40913, CVE-2026-40690 | regex-validate each token against ^CVE-\d{4}-\d{4,7}$ first (anything that does not match is a hard error — never interpolate an unvalidated free-form string into the search arg, which is in double quotes and would expand $(...)); then look up each validated CVE ID with `gh search issues “CVE-YYYY-NNNNN” --repo --json number,title,body --jq '.[] |
sync <free-text> (e.g. sync JWT, sync KubernetesExecutor) | title-substring match — run gh issue list --repo <tracker> --state open --search "<free-text> in:title" --limit 100 --json number,title and surface the matches back to the user for confirmation before dispatching (title matches are the fuzziest selector — always confirm, never auto-dispatch). |
sync <label> (e.g. sync announced, sync pr merged) | all open issues carrying that label — gh issue list --repo <tracker> --state open --label "<label>" --limit 100 --json number,title. |
sync announced (as a label selector) | as above, open-only. To include the recently-closed announced bucket, use sync all (default) or sync closed announced. |
sync closed announced | the recently-closed announced bucket by itself — useful when you want to run the cve.org publication-check sweep without touching open issues (for example, as a post-release cron). |
sync open | alias for sync all open. |
sync closed | open and closed issues, all closed (not just recent announced). Explicit, narrow-scope request — most sync actions are no-ops on closed issues that are not in the announced bucket. |
Selectors can be combined: sync #212, CVE-2026-40690, JWT resolves each independently and dispatches the union of the resulting issue numbers. After resolving, echo the final list back to the user and ask for confirmation before spawning subagents — this catches fuzzy-match surprises (a title-substring hit that was not intended, a CVE alias that matched two scope trackers) before they cost an API round-trip. When the open / closed buckets both contribute, group them in the echo so the user can tell at a glance “9 open, 2 recently-closed awaiting cve.org”.
When the selector resolves to zero issues, tell the user and stop — do not fall back to sync all.
Exclude the rejections ledger. The single open issue labelled rejections-ledger (the rejected-without-tracker ledger written by security-issue-import) is not a security tracker — it carries no scope label and holds only rejection-record comments. Drop it from every set-valued selector (sync all, sync all open, label/title sweeps) so it is never dispatched a sync subagent. An explicitly-named number (sync #99) still works if the operator really means it.
1b. Pre-flight no-op classifier — skip trackers that obviously need no work. Before spawning subagents, do one batched read to fetch lightweight state for every resolved tracker, classify each as dispatch / dispatch-urgent / skip-noop, and dispatch subagents only for the non-skipped ones. A no-op skip costs the full ~50 KB subagent transcript per tracker plus the subagent's own per-call API budget; on bulk sweeps where 30–50% of trackers are in steady state, the classifier converts that into one GraphQL round-trip.
**One query, one round-trip.** Build an aliased multi-field
GraphQL query that fetches state for every resolved issue at
once. The `body` field on `comments(last: 1)` lets the classifier
distinguish skill-authored writes from human activity (see
*Skill-or-bot detection* below):
```bash
gh api graphql --raw-field query="$(cat <<'GQL'
query {
repository(owner: "<owner>", name: "<repo>") {
i<N1>: issue(number: <N1>) {
number state closedAt updatedAt
labels(first: 30) { nodes { name } }
comments(last: 1) {
nodes { author { login } createdAt body }
}
}
i<N2>: issue(number: <N2>) { ... }
# repeat one aliased block per resolved issue
}
}
GQL
)"
```
The aliased-field form (`i<N>: issue(number: <N>) { ... }`)
works for any number of issues in a single query. For a 30-issue
bulk sweep the request is ~3 KB and the response is ~50-130 KB
depending on how long the latest comments are — still cheaper
than even one subagent transcript, and the body field is what
enables the skill-marker detection that drives ~30% of the
real-world skip rate.
**Skill-or-bot detection — required for the rules below.** On a
private single-operator tracker, the sync skill itself writes
rollup updates and RM hand-off comments as the operator's
GitHub user — *not* as a `*[bot]` account. A naive
*"last comment author is a bot"* check is structurally
unreachable on those trackers and the classifier degenerates
to ~5% skip rate. The fix: recognise skill-authored comments
by their **marker comment**, which every status-rollup /
hand-off / wrap-up comment begins with:
```text
<!-- apache-magpie: <comment-kind> v<N> -->
```
Concretely, treat the last-comment author as *bot-equivalent* if
**any** of these is true:
- `login in {github-actions[bot], dependabot[bot]}` or
`login` ends with `[bot]` (real GitHub App accounts).
- The body starts with `<!-- apache-magpie: ` (skill-authored
comment — see [`tools/github/status-rollup.md`](../../tools/github/status-rollup.md)
for the marker spec).
- `login` matches a `bot_logins:` entry in the override file at
[`.apache-magpie-overrides/security-issue-sync.md`](../../docs/setup/agentic-overrides.md)
(for adopters with personal-account bots).
The remaining rules use *"skill-or-bot last commenter"* as a
shorthand for this composite check.
**Classification rule table.** Apply the rules **in order**;
the first match wins. Conservative by design — `skip-noop`
fires only when multiple signals all align.
| Signals | Decision | Reason recorded in recap |
|---|---|---|
| `updatedAt` within last **7 days** AND last comment is **NOT** skill-or-bot | `dispatch` | recent human activity — safety override |
| Last comment author is **not** skill-or-bot AND `createdAt` within last **24h** | `dispatch-urgent` | reporter just replied |
| Closed > **30 days** ago AND has `announced` label | `skip-noop` | `post-announce; CVE published` |
| Closed > **90 days** ago AND no `announced` label | `skip-noop` | `stale closed (invalid/duplicate/abandoned)` |
| Open AND has `cve allocated` + `pr merged` + `announced` AND last comment is skill-or-bot | `skip-noop` | `all phases done; awaiting closure heuristic` |
| Open AND has `cve allocated` + `pr merged` AND last comment is skill-or-bot | `skip-noop` | `awaiting release` |
| Open AND has `cve allocated` + `fix released` AND last comment is skill-or-bot | `skip-noop` | `fix released; awaiting advisory propagation` |
| Anything else | `dispatch` | — |
The relaxation vs the original rule design: skip-eligibility
rules no longer require *"idle > 14 days"*. Once the labels
show steady-state AND the last write was the skill itself,
a recently-bumped `updatedAt` is just the skill's own work —
not a reason to dispatch.
**Hard rules**:
- **Never silent.** Every `skip-noop` decision appears in the
recap under a *"Pre-flight skipped"* group with the rule
that fired and the signals it saw. The user can request a
forced sync of any skipped tracker by name at confirmation
time (*"force-sync #232"*) — the orchestrator then spawns a
subagent for that tracker on the next turn.
- **Selector overrides default behaviour.** If the user named
explicit issue numbers (`sync #232, #233`) rather than a
label/state selector, **never skip** — they asked for those
specific trackers and a silent skip would be surprising.
Pre-flight only applies when the selector resolved to a set
(`sync all`, `sync announced`, label/title selectors).
- **Opt-out.** Pass `--no-preflight` in the user's selector
(e.g. `sync all --no-preflight`) to bypass the classifier
entirely and dispatch a subagent for every resolved tracker.
Useful for trust-but-verify sweeps after a rule change.
- **Dispatch-urgent is just dispatch.** The `dispatch-urgent`
decision tells the orchestrator to flag the tracker in the
recap as *"recent reporter activity"*, but the subagent it
spawns is identical to the normal dispatch path. The
distinction is for the operator's attention, not the
subagent's behaviour.
**What pre-flight does NOT do.** It does **not** decide
*what action* a tracker needs — that is still the subagent's
job. It only decides whether spawning a subagent is worth it
at all. A tracker classified as `dispatch` still goes through
the full Step 1 (gather) → Step 2 (proposal) flow inside its
subagent.
Spawn one subagent per issue, in a single message. Use the general-purpose subagent type and send all Agent tool calls in the same assistant message so they run concurrently. For 20 issues that survived pre-flight, that is 20 parallel Agent calls in one turn. Trackers classified as skip-noop by Step 1b are not dispatched — they only appear in the recap under the “Pre-flight skipped” group.
Each subagent prompt must be self-contained and must instruct the subagent to:
cve allocated, search the public <users-list> archive for the CVE ID (PonyMail search_list on the users list, per tools/ponymail/; Gmail fallback) and cross-check the cve.org publication state (per tools/cve-org/). A <users-list> archive hit authored by the release manager is the advisory-shipped signal, and its <mail-archive-url>/thread/<id> permalink is the Public advisory URL. The tracker body's Public advisory URL field and announced label are a lagging mirror: once populated they confirm shipment, but an empty value is never proof the advisory did not ship — those fields are only written on the next sync, after the release manager sends the advisory out-of-band. A tracker still at fix released with an empty Public advisory URL whose CVE is already live on cve.org is a Step 14→15 close-out, not a parked tracker; report it as such (see the advisory_shipped / advisory_url fields below) so the orchestrator buckets it into the close-out batch rather than leaving it stranded open on its milestone.Bucket trackers by CVE-record impact. A tracker's proposed changes fall into one of two buckets:
containers.cna.title), Short public summary for publish, CWE, Severity, Affected versions, Reporter credited as, Remediation developer, PR with the fix, Public advisory URL. Also: any change to the issue title itself (the generator reads it verbatim into title). The pre-push hygiene gates in Step 5b 1b all scan fields in this bucket; the bucket exists for the same reason the gates do — these are the values that ship to cve.org and stay there.The mechanical allocated → review-ready state push at fix released is NOT gated by the CVE-affecting bucket's per-item review. That bucket‘s confirmation exists for body-field content judgment — summary wording, CWE choice, credit-line shape: the values a human should eyeball before they ship to cve.org. The allocated → review-ready (Vulnogram: DRAFT → REVIEW) state push that a pr merged → fix released transition mandates (see the atomicity rule in apply-and-push.md Step 5b) is mechanical, not a judgment call: it rides the same confirmation as the fix released label flip in the non-CVE-affecting bucket and executes in the same apply pass. Do not hold a fix-released tracker’s record at allocated/DRAFT waiting for a separate CVE-affecting-bucket sign-off — that is the deferral the atomicity rule forbids. (A body-field content change that happens to land on the same tracker still goes through the CVE-affecting review; only the state push is exempt.)
Present buckets as merged bulk proposals; the CVE-affecting bucket gets a richer per-item view. The proposal has three groups:
skip-noop, one per line, with the rule that fired and the signals it saw. Example: #232 (closed 2025-12-04, announced label) — post-announce; CVE published. This group is informational — the proposal does not ask the user to confirm or apply anything for these trackers. The user can request a forced sync of any skipped tracker by name at confirmation (“force-sync #232”) and the orchestrator dispatches a subagent for it on the next turn. Render the group at the top of the proposal so the user sees the skip context before the proposed actions.all, NN:all, NN:1,3, or per-issue subsets, and the orchestrator applies them sequentially. This bucket is bundled because the actions are reversible, low-blast-radius, and do not leak into public CVE surfaces.all, NN:all, NN:1,3, NN:skip, NN:edit <item>: <new value>). On confirmation the orchestrator applies the confirmed items across all trackers sequentially.Why bulk-review (and not per-tracker walk). Per-tracker walk through N CVE-affecting trackers serialises the confirmation cost into N round-trips and forces context re-loading for each one — the operator can‘t compare proposed summaries across trackers, can’t notice that two trackers should converge on the same CWE long-form, can't see at a glance that three are blocked on the same missing field. A single merged proposal puts everything on one page: the operator sees the full bulk shape, edits whichever items they want, and the orchestrator applies the confirmed set in one pass. The hygiene gates in Step 5b 1b still catch mechanical drift (bare CWE, missing upgrade target, etc.) on every JSON regen; the bulk-review surface is for the operator to make judgment calls (threat-model framing, credit-line shape, CWE choice) before the push fires.
Confirmation syntax for the merged proposal:
all — apply every proposed change across all trackers.<N>:all — apply every change on tracker <N>; skip the others.<N>:1,3,5 — apply only the listed items on tracker <N>.<N>:skip — skip tracker <N> entirely.<N>:edit <item-number>: <new value> — replace the proposed item with a free-form override before applying.force-sync <N> — dispatch a subagent for a tracker that Step 1b classified as skip-noop. The orchestrator runs the full Step 1 gather for <N> on the next turn and folds its result into the next proposal. Use when the pre-flight heuristic was wrong and you know there's work to do.cancel / none — apply nothing.Proposal order in the merged pack. Trackers appear in ascending tracker-number order so the operator can navigate predictably across reruns. The operator can name a different order at confirmation (“apply #438 first; I want to think about #232 last”) and the orchestrator honours it.
Apply sequentially, not in parallel. Even though assessment ran in parallel, the apply phase must be sequential so gh-rate-limit surprises, partial failures, and user interrupts stay legible. Do not spawn subagents for the apply phase.
Each subagent must return a single code block (or JSON) with exactly these fields so the orchestrator can merge deterministically:
issue: <N> title: <one line> scope_label: <scope-a> | <scope-b> | <scope-c> | <missing> current_labels: [<label>, ...] current_milestone: <title or null> current_assignees: [<login>, ...] fix_pr: url: <<upstream> PR URL or null> state: open | merged | closed | null author: <login or null> author_is_security_team: true | false | null merged_at: <ISO8601 or null> milestone: <PR milestone title or null> release_shipped: true | false | unknown advisory_shipped: true | false | unknown # from the <users-list> archive search for the CVE ID — NOT derived from the tracker body advisory_url: <<mail-archive-url>/thread/... permalink, or null> # the archive permalink when advisory_shipped is true cve_published: true | false | unknown # cve.org publication state (MITRE API), independent of the tracker body reporter: name: <name or null> email: <email or null> gmail_thread_id: <id or null> credit_confirmed_as: <string or null> credit_question_pending: true | false cve_id: <CVE-YYYY-NNNNN or null> process_step: <number from the README table> proposed_label_add: [<label>, ...] proposed_label_remove: [<label>, ...] proposed_milestone: <title or null, with note "(create)" if it does not yet exist> proposed_assignees_add: [<login>, ...] proposed_body_field_updates: [<one-line description>, ...] proposed_status_comment: <one-line summary or null> proposed_reporter_email: <one-line summary or null> blockers: [<short reason the orchestrator or user must resolve before apply>, ...] notes: <free-form one-to-three sentences, only if something does not fit above>
The orchestrator uses the structured fields to produce the merged proposal table and relies on blockers to flag issues that cannot be resolved without user input (for example a missing Gmail thread or an ambiguous credit line). When advisory_shipped (or cve_published) is true while the tracker is still labelled fix released, the orchestrator buckets the tracker into the Step 14→15 close-out regardless of whether the body's Public advisory URL field is still empty — the archive/cve.org signal is authoritative and the empty body field is just sync lag.
gh issue edit, gh issue comment, gh api … -X PATCH/POST, gh label create, gh api …/milestones (create), or any Gmail send / draft-create tool. They are read-only. If a subagent reports it did mutate something, the orchestrator must surface that as a bug and stop.<tracker> content may leak into any public surface. This is a no-op for read-only subagents but worth stating.#NNN must be rendered as a clickable link per Golden rule 2.skip-noop decision appears explicitly in the proposal's “Pre-flight skipped” group with the rule that fired. The user can force-sync <N> any of them at confirmation. The opt-out --no-preflight flag bypasses Step 1b entirely.sync #232, #233), Step 1b only runs the classifier for context (so the recap can surface “#232 looks idle — sync anyway?”) but never actually skips. Skip-eligible selectors are state/label/title selectors like sync all or sync announced.sync #216). Run the normal flow in the main agent — spawning one subagent for one issue is pure overhead.