ship-epic-gh — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited ship-epic-gh (Agent Skill) and scored it 87/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 3 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 3 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Drive a GitHub parent issue whose body holds a managed ## Sub-issues task-list to all-merged by firing one /loop 6m /abc:ship-issue-gh <owner>/<repo>#<n> per ready child, gating children with unmet blocked-by:* labels, and aggregating status on the parent. Each worker (/abc:ship-issue-gh) is independent — they run in parallel via their own cron entries, survive session close, and use GitHub Issues as the single source of truth.
This skill is the coordinator. It does NOT implement code, open PRs, or run tests directly — those are the workers' jobs.
This is the GitHub-Issues sibling of /abc:ship-epic. The two are deliberately parallel skills — pick by tracker, not auto-detect. The label scheme, task-list fence, and marker comments it depends on are documented in ../scaffold-sub-issues-gh/github-conventions.md.
See DESIGN.md for the architectural rationale + locked decisions specific to the GitHub case. This file is the operational procedure.
blocked-by:* label. Wait for the upstream to reach merged first.blocked-user. Other workers can continue; the blocked one waits for the human.<!-- ship-issue:* --> comments on children). Workers own their own state machines.<!-- ship-epic:sub-issues:start/end --> fence in the parent body. User-authored prose stays untouched./abc:ship-issue-gh self-cancels its own cron per its Phase 7; this skill mirrors that contract at the epic level.## Sub-issues task-list → reject and point the user at /abc:scaffold-sub-issues-gh to create one (or /abc:ship-issue-gh for a single issue).<!-- ship-epic:event:cycle --> once (dedup against an identical prior marker), CronDelete the epic's own loop, and halt. Refuse to fire any workers.Flag extraction (before shape detection): detect and strip a trailing --no-compact flag from $ARGUMENTS. When present, set no-compact mode for this invocation — the compact-on-merge prompt (Phase 4) is skipped, and the flag propagates to every worker fired in Phase 3. The flag stays in the raw arg string used for cron arming/matching, so the opt-out survives every subsequent wake. Contract and rationale live in ../_shared/compact-on-merge.md.
$ARGUMENTS is one of:
https://github.com/<owner>/<repo>/issues/<n>, optionally with Enterprise host) → extract <owner>/<repo>#<n>.Anything else (Linear IDs, bare #<n>, milestone refs, comma-lists) → reject. This skill requires an explicit GitHub parent issue. For milestone-style "ship every issue in this collection," use /abc:ship-issue-gh milestone:<owner>/<repo>/<num> instead — that's the serial walker; the coordinator pattern needs a real parent for status aggregation.
Auth pre-flight. gh auth status --hostname <host> where <host> is derived from the parent's URL (defaults to github.com). If not authed → blocked-user with the auth command.
gh issue view <n> --repo <owner>/<repo> --json number,title,state,stateReason,labels,body.state=closed and stateReason=completed → epic is already done. Emit a one-line "already merged" summary and exit (do not arm a loop).<!-- ship-epic:sub-issues:start --> and <!-- ship-epic:sub-issues:end --> fence markers in the body. If either is missing → reject with: "Parent <owner>/<repo>#<n> has no managed ## Sub-issues task-list. Run /abc:scaffold-sub-issues-gh against this parent first, or use /abc:ship-issue-gh for single-issue shipping."- [ ] <ref> and - [x] <ref> line between the fences. Each <ref> is either #<n> (same-repo as parent) or <owner>/<repo>#<n> (cross-repo). Normalize to fully-qualified form. The [x]-completed entries are kept in the set — they'll be classified merged in Phase 2, not skipped at parse time (the worker may still need to be checked for terminal cleanup).If the parsed child set is empty → reject: "Parent has an empty ## Sub-issues task-list. Add children with /abc:scaffold-sub-issues-gh first."
#### Cron-entry match rule
Defined in ../_shared/cron-match.md; this skill is the `ship-epic-gh` consumer (<boundary-class> = alphanumeric, -, ,, /, # — the / and # exclusions are GitHub-ID specific; /loop interval 10m).
Skill(skill: "loop", args: "10m <command-name> <raw-arg>") to arm the cron — substituting the captured `<command-name>`, not a hardcoded skill name. This is what makes the next wake's match check succeed against this cron entry. Then proceed to Phase 1 — the first wake also does the work of the first iteration.#### Single-session constraint (first wake only)
The coordinator is single-session: exactly one coordinator loop per parent. On the first wake (no matching cron yet — about to arm), before arming:
<!-- ship-epic:status --> comment exists on the parent that THIS session did not write, suspect a live sibling coordinator already running in another session → blocked-user with reason possible-duplicate-coordinator. Do not arm a second loop.CronList shows a serial-walker entry for this same parent — a worker cron of the form ship-issue-gh <PARENT-ID> (the namespace-aware worker cron-match rule applied to the parent's own ID, i.e. someone ran /abc:ship-issue-gh <parent> to walk the task-list serially) → refuse to start with reason parent-already-serial-walked. Coordinating in parallel while the parent is being walked serially would double-fire workers.(On non-first wakes a matching coordinator cron exists, so these checks are skipped — they guard the initial arm only.)
10-minute cadence is intentional (longer than the 6-minute worker cadence): the coordinator only needs to react when a worker reaches merged (unblocks downstream) or terminal (failed/blocked-user). Both events surface in GitHub within seconds; 10-minute lag is acceptable.
Do not hardcode `/abc:`. Derive the worker command from the captured coordinator `<command-name>` so a coordinator invoked top-level fires a top-level worker, and a plugin-namespaced coordinator fires a same-namespace worker:
<worker-command>= the captured<command-name>with its trailing skill nameship-epic-ghswapped toship-issue-gh, preserving any namespace prefix verbatim. So/abc:ship-epic-gh→/abc:ship-issue-gh, and a top-level/ship-epic-gh→/ship-issue-gh.
#### Worker cron-match rule (the single match key)
The Phase 3 fire string and the Phase 2 in-flight match key MUST be the same string — they are defined here once and referenced from both. Firing /loop 6m <worker-command> <id> while classifying in-flight against a different substring (e.g. the hardcoded /ship-issue-gh <id>) is exactly the bug this fixes: the namespaced fire string /abc:ship-issue-gh <id> never matches a bare /ship-issue-gh <id> grep, so the coordinator never recognizes its own running workers and re-fires duplicates.
A CronList entry is the worker for child `<id>` when its command string matches the namespace-aware regex (same shape as the epic's own cron-match rule, with the GitHub-ID boundary class):>
(?:^|[^A-Za-z0-9])(?:[A-Za-z][A-Za-z0-9_-]*:)?ship-issue-gh <id>(?![A-Za-z0-9_,/#-])>
where<id>is the child's fully-qualified<owner>/<repo>#<n>. The optional<plugin>:prefix capture matches whatever namespace the worker was fired under (it is the same namespace as<worker-command>); the trailing negative-lookahead is the GitHub-ID boundary class (/and#excluded). This is the same string as the Phase 3 fire string's command portion — define it once here, reference it by name from both Phase 2 (in-flight) and Phase 5 (kill targeting).
For each child in the parsed set:
gh issue view <n> --repo <owner>/<repo> --json number,state,stateReason,labels.blocks:#<N> or blocked-by:#<N> (same-repo) or blocks:<owner>/<repo>#<N> / blocked-by:<owner>/<repo>#<N> (cross-repo). Normalize all refs to fully-qualified form for graph nodes.blocked-by: label points to an issue NOT in our parsed child set, surface it as external-blocker for that child (Phase 2) — don't try to follow it.blocksMap[id]: Set<id>, blockedByMap[id]: Set<id>. Union both directions so a blocks:#A label on B and a blocked-by:#B label on A produce the same edge once.Detect cycles via DFS. On cycle → go to Phase 5 § Dependency cycle (terminal) — refuse to fire any workers. Phase 5 owns the marker-dedup + CronDelete + halt; do not write the marker or fire workers here.
Pull CronList once at the top of this phase — don't re-poll per child. First match wins:
| State | Condition |
|---|---|
merged | state=closed AND stateReason=completed AND a <!-- ship-issue:event:merged --> comment from the worker exists; and when the child body has a `## Validation` heading (heading-match per the worker's row-1a rule), a <!-- ship-issue:verify:passed --> marker also exists. A closed-completed child lacking the event:merged marker is not yet merged — the worker may still be finishing its validation gate; classify it via a lower row this wake (typically in-flight if its cron is still running, else fall through) |
failed | A <!-- ship-issue:event:failed --> comment from the worker exists (worker hit a hard stop) |
blocked-user | The child's latest <!-- ship-issue:event:blocked --> marker is not postdated by a human (non-skill) comment or a <!-- ship-issue:verify:passed --> marker — child is still state=open. (See Re-fire on human reply below: if a human reply / verify marker does postdate the blocked marker, the child is re-fireable, not still-blocked — it classifies ready when blockers are satisfied.) |
external-blocker | A blocked-by: label points to an issue outside our child set AND that referenced issue is not yet merged. Recorded only in the epic's <!-- ship-epic:status --> comment — never written as a comment on the child |
in-flight | A CronList entry matches the worker cron-match rule for this child (Phase 0.5 § Worker cron-match rule — the same (?:[A-Za-z][A-Za-z0-9_-]*:)?ship-issue-gh <id> namespace-aware key the Phase 3 fire string uses) |
dropped (human-canceled) | state=closed AND stateReason=not_planned BUT no <!-- ship-issue:event:failed --> marker (a human canceled the issue directly — not a worker failure). Surface in the status comment as dropped (human-canceled); do not count toward the all-stop halt (Phase 5 § Any child failed). The epic continues |
ready | All in-set blocked-by:* upstreams are merged, AND no in-flight cron, AND state=open (not in any terminal state). Includes a re-fireable previously-blocked child (see Re-fire on human reply) |
waiting | One or more in-set blocked-by:* upstreams not yet merged |
blocked-user: unclassifiable-child | Catch-all — no row above matched. A child can never fall through the table silently; surface it as blocked-user with reason unclassifiable-child so a human looks at it |
Re-fire on human reply (how a blocked child resumes). No worker ever writes an event:resumed marker — there is no such marker. A blocked-user child resumes by the coordinator re-firing its worker. The rule: take the child's latest <!-- ship-issue:event:blocked --> marker. If a human (non-skill) comment or a <!-- ship-issue:verify:passed --> marker postdates it (created_at strictly later), the human has answered the block → treat the child as re-fireable: it classifies ready (if its blockers are satisfied and no worker cron is in-flight) and Phase 3 fires a fresh worker, which is what resumes it. If nothing postdates the blocked marker, the child is still blocked-user and waits.
Read-failure rule. Mirror the worker's rule (/abc:ship-issue-gh Phase 3): if any read this phase fails (non-zero exit, timeout, pagination the coordinator can't reconcile), skip classifying that child this wake — do not fall through to a wrong state (e.g. treating an unread comments list as "no blocked marker"). The child keeps its prior surfaced state until a clean read. If the parent itself is unreadable on consecutive wakes → CronDelete the epic's loop and halt (Phase 5 § Parent unreadable).
Re-derive fresh on every wake — nothing is persisted locally.
For each child in ready state, fire the derived `<worker-command>` (Phase 0.5 § Derived worker command — never the hardcoded /abc: literal):
Skill(skill: "loop", args: "6m <worker-command> <owner>/<repo>#<n>")The <worker-command> <owner>/<repo>#<n> portion is the same string the Phase 2 in-flight worker cron-match rule keys on — that is what lets the next wake recognize this worker as in-flight instead of re-firing it. (e.g. coordinator /abc:ship-epic-gh → <worker-command> is /abc:ship-issue-gh.)
In no-compact mode, append the flag so workers inherit the opt-out: Skill(skill: "loop", args: "6m <worker-command> <owner>/<repo>#<n> --no-compact") (see ../_shared/compact-on-merge.md § --no-compact).
This kicks off the worker's first wake and arms its own cron. The coordinator does NOT wait — it returns and the worker runs independently.
Do not fire workers for children already in-flight (the worker's own Phase 0.5 cron-match would no-op anyway, but skip explicitly for clarity).
If multiple children are ready in the same wake, fire all of them — the workers run in parallel on independent cron entries.
Resolve each child's PR URL for the Latest column via gh pr list --repo <owner>/<repo> --search "<child-ref>" --state all --json url,state,mergedAt (take the most recent linked PR) — this is the only PR-discovery call the coordinator makes; it never fetches PR bodies or diffs.
Append (not edit) a single <!-- ship-epic:status --> comment on the parent with the current snapshot. The task-list checkboxes in the parent body auto-toggle when each child closes — GitHub manages that natively; the skill does not touch the body for this purpose:
<!-- ship-epic:status -->
Wake: 2026-05-17T18:30:00Z (4 of 6 merged)
| State | Child | Latest |
|---|---|---|
| merged | <owner>/repo-a#101 | <PR URL> |
| merged | <owner>/repo-a#102 | <PR URL> |
| in-flight | <owner>/repo-b#103 | pr-open, <PR URL> |
| ready | <owner>/repo-b#104 | → firing worker this wake |
| waiting | <owner>/repo-c#105 | blocked by <owner>/repo-b#104 |
| blocked-user | <owner>/repo-c#106 | awaiting-manual-verification |Post it by piping the body on stdin (no Write tool needed, stays inside the existing gh issue comment grant):
gh issue comment <parent-n> --repo <owner>/<repo> --body-file - <<'EOF'
<!-- ship-epic:status -->
... snapshot table ...
EOF/abc:ship-epic-gh wake <ts>
Parent: <owner>/<repo>#100 "Add WidgetRow to dashboard" (4 of 6 merged)
[merged] <owner>/repo-a#101 <PR URL>
[merged] <owner>/repo-a#102 <PR URL>
[in-flight] <owner>/repo-b#103 pr-open <PR URL>
[ready→firing] <owner>/repo-b#104
[waiting] <owner>/repo-c#105 blocked by <owner>/repo-b#104
[blocked-user] <owner>/repo-c#106 awaiting-manual-verification
Next wake: /loop 10m /abc:ship-epic-gh <owner>/<repo>#100Keep terminal output short on no-op wakes (no state changes since last wake): just one line — no-op wake — 4 of 6 merged, 1 in-flight, 1 blocked-user.
When this wake observed one or more children newly reach `merged` — derived statelessly by comparing against the most recent prior <!-- ship-epic:status --> comment (a child is newly merged when a prior status comment exists and didn't list it as merged) — print, after the terminal block, as the last output of the wake:
🗜 <n> child(ren) merged this wake. Run /compact now to free context before the next coordinator wake.First-wake baseline guard: when no prior <!-- ship-epic:status --> comment exists — the first coordinator wake, including resuming an epic whose children already merged before the coordinator ever ran — treat the current merged set as the baseline, not as newly merged: this wake's status comment establishes the snapshot and no prompt is printed.
Skip in no-compact mode, on wakes with no newly-merged children, and on terminal wakes (all merged → the epic is closing and the loop is ending anyway). At most once per wake regardless of how many children merged. Full rules: ../_shared/compact-on-merge.md.
mergedgh issue close <n> --repo <owner>/<repo> --reason completed.<!-- ship-epic:event:complete --> ✅ Epic complete: <N> children merged.CronDelete the epic's /loop via the cron-entry match rule./abc:ship-issue-gh Phase 7 — if any remain in CronList, leave them; they'll wake, derive merged, and self-cancel.failedA child counts as failed for the all-stop only when it carries the worker's <!-- ship-issue:event:failed --> marker. A child that is closed not_planned without that marker is a human cancellation, not a worker failure — classify it dropped (human-canceled) (Phase 2), surface it, and do not halt.
<!-- ship-epic:event:failed --> on the parent: ❌ Epic halted: <owner>/<repo>#<n> failed (<reason>). Other children left in their current state.CronDelete the epic's /loop.<!-- ship-issue:* --> marker (so it isn't mistaken for a worker-authored event): Epic halted upstream; this child's worker loop was cancelled. Re-run <worker-command> <owner>/<repo>#<n> to resume. (use the derived <worker-command>).failed.Reached from Phase 1 when DFS detects a cycle:
<!-- ship-epic:event:cycle --> on the parent listing the cycle members in order — once: if an identical <!-- ship-epic:event:cycle --> marker comment already exists (same cycle membership), do not repost (dedup against the prior marker).CronDelete the epic's /loop.Reached from Phase 2's read-failure rule when the parent is unreadable on consecutive wakes:
CronDelete the epic's /loop.blocked-user or external-blockerDo not halt. Leave the epic loop running — other workers may still be making progress. Surface in the terminal block and status comment, but the loop continues.
Treat as a direct interrupt — read the comment via gh api /repos/<owner>/<repo>/issues/<n>/comments, decide whether to halt or adjust. If unclear, write a blocked-user comment with reason user-mention-ambiguous:<comment-id>, `CronDelete` the epic's `/loop` (this halt self-cancels like every other terminal path), and halt.
The epic's /loop self-cancels on every terminal-state phase via CronDelete. The user should not have to run /loop cancel manually.
If CronDelete fails (entry already gone, job-ID not found), print a note and continue. The GitHub comment is authoritative.
repo: labels become ready (if no blocked-by:*) or waiting (if blocked). Fired automatically.in-flight, leave its worker running — the worker is on its own contract. Just drop it from the epic's status aggregation. label added mid-flight to an in-flight` child**: the worker keeps running. The label takes effect for state classification only — future ready/waiting decisions honor it; the current worker is not interrupted.possible-duplicate-coordinator if a foreign <!-- ship-epic:status --> comment exists; parent-already-serial-walked if a ship-issue-gh <PARENT-ID> walker cron is live) — the coordinator is single-session per parent./loop cancel): next wake sees the worker as no longer in-flight but the child issue isn't terminal — re-classifies as ready (if blocks satisfied) and fires a new worker. Re-arming is idempotent at the worker level.blocked-user and reason parent-task-list-fence-missing — don't try to re-inject silently.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.