ship-epic — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited ship-epic (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 Linear parent issue with sub-issues to all-merged by firing one /loop 6m /abc:ship-issue <SUB-ID> per ready sub-issue, gating sub-issues with unmet blocked by relations, and aggregating status on the parent. Each worker (/abc:ship-issue) is independent — they run in parallel via their own cron entries, survive session close, and use Linear as the single source of truth.
This skill is the coordinator. It does NOT implement code, open PRs/MRs, or run tests directly — those are the workers' jobs.
See DESIGN.md for the architectural design rationale. This file is the operational procedure.
blocked by relation. Wait for the upstream to reach merged first.blocked-user. Other workers can continue; the blocked one waits for the human.<!-- ship-issue:* --> comments). Workers own their own state machines./abc:ship-issue self-cancels its own cron per its Phase 7; this skill mirrors that contract at the epic level./abc:ship-issue for single tickets.<!-- 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://linear.app/<org>/issue/<id>) → strip prefix, extract TEAM-N (the parent ID).https://linear.app/<org>/project/<slug>/...) — project overview / issues / triage URLs that don't point at a parent issue → blocked-user with reason project-url-needs-parent-id. The block message lists the two supported shapes: bare parent ID (PROJ-100) and parent issue URL. Do not proceed to Phase 0.5 — no cron arming.PROJ-100) → use as-is./abc:ship-issue milestone:<uuid> (the serial walker) instead. This mirrors how /abc:ship-epic-gh rejects milestone refs. blocked-user with reason milestone-needs-serial-walker; do not proceed to Phase 0.5 — no cron arming. (Whether a milestone-mode coordinator is worth building is an open question — see DESIGN.md § Open questions.)For the rest of this doc, assume bare PARENT-ID unless noted.
mcp__claude_ai_Linear__get_issue with id: PARENT-ID, includeRelations: true. Read description, labels, statusType.statusType is completed (Done) on a fresh invocation → the epic is already complete. Print a one-line "epic already complete" summary and exit without arming a loop and without writing any comment (mirrors the /abc:ship-epic-gh closed-completed early exit). Do not proceed to Phase 0.5.mcp__claude_ai_Linear__list_issues with parentId: PARENT-ID, no status filter (we want everything, including merged ones to derive merged state).If the sub-issue list is empty → reject: "PARENT-ID has no sub-issues. Use /abc:ship-issue PARENT-ID for single-ticket shipping."
#### Cron-entry match rule
Defined in ../_shared/cron-match.md; this skill is the `ship-epic` consumer (<boundary-class> = alphanumeric, -, ,; /loop interval 10m).
#### Arm check
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 <PARENT-ID> (the namespace-aware worker cron-match rule below applied to the parent's own ID, i.e. someone ran /abc:ship-issue <parent> to walk the sub-issues 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 Linear 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-epicswapped toship-issue, preserving any namespace prefix verbatim. So/abc:ship-epic→/abc:ship-issue, and a top-level/ship-epic→/ship-issue.
#### 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> <SUB-ID> while classifying in-flight against a different substring (e.g. the hardcoded /ship-issue <SUB-ID>) is exactly the bug this fixes: the namespaced fire string /abc:ship-issue <SUB-ID> never matches a bare /ship-issue <SUB-ID> grep, so the coordinator never recognizes its own running workers and re-fires duplicates.
A CronList entry is the worker for sub-issue `<SUB-ID>` when its command string matches the namespace-aware regex (same shape as the epic's own cron-match rule, with the Linear-ID boundary class):>
(?:^|[^A-Za-z0-9])(?:[A-Za-z][A-Za-z0-9_-]*:)?ship-issue <SUB-ID>(?![A-Za-z0-9_,-])>
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 Linear-ID boundary class (no//#exclusions — the GitHub sibling needs those, Linear doesn't). 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 sub-issue:
blocks and blocked by relations from the relations field on the get_issue response.external-blocker state for that sub-issue (Phase 2), recorded only in the epic's <!-- ship-epic:status --> comment — never as a comment on the sub-issue itself.blocksMap[id]: Set<id>, blockedByMap[id]: Set<id>.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.
First match wins (no need to re-read CronList per sub-issue — pull it once at the top of the phase):
| State | Condition |
|---|---|
merged | Linear statusType is completed AND a <!-- ship-issue:event:merged --> comment exists (worker reached its merged terminal) — OR statusType is completed with no event:merged marker and no linked PR/MR and no `## Validation` heading in its description (a human marked it Done directly — treat as done; a ## Validation-gated issue is excluded from this arm so it falls through rather than silently skipping the verify gate — unlike the event:merged arm, there is no marker here implying the gate ran) |
failed | A <!-- ship-issue:event:failed --> comment from the worker exists |
blocked-user | The sub-issue's Linear statusType is neither completed nor canceled (a blocked-then-canceled child falls through to dropped (human-canceled), not here), AND its latest <!-- ship-issue:event:blocked --> marker is not postdated by a human (non-skill) comment or a <!-- ship-issue:verify:passed --> marker. (See Re-fire on human reply below: if a human reply / verify marker does postdate the blocked marker, the sub-issue is re-fireable, not still-blocked — it classifies ready when blockers are satisfied.) |
external-blocker | A blocked by relation points to an issue outside our sub-issue set AND that issue is not merged. Recorded only in the epic's <!-- ship-epic:status --> comment — never written as a comment on the sub-issue |
in-flight | A CronList entry matches the worker cron-match rule for this sub-issue (Phase 0.5 § Worker cron-match rule — the same (?:[A-Za-z][A-Za-z0-9_-]*:)?ship-issue <SUB-ID> namespace-aware key the Phase 3 fire string uses) |
dropped (human-canceled) | Linear statusType is canceled 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 sub-issue failed). The epic continues |
ready | All blocked by upstreams in merged, AND no in-flight cron, AND Linear state not terminal. Includes a re-fireable previously-blocked sub-issue (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 sub-issue 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 sub-issue resumes). No worker ever writes an event:resumed marker — there is no such marker. A blocked-user sub-issue resumes by the coordinator re-firing its worker. The rule: take the sub-issue's latest <!-- ship-issue:event:blocked --> marker. If a human (non-skill) comment or a <!-- ship-issue:verify:passed --> marker postdates it (later timestamp), the human has answered the block → treat the sub-issue 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 sub-issue is still blocked-user and waits.
Read-failure rule. Mirror the worker's rule (/abc:ship-issue Phase 3): if any read this phase fails (non-zero exit, timeout, an MCP error, pagination the coordinator can't reconcile), skip classifying that sub-issue this wake — do not fall through to a wrong state (e.g. treating an unread list_comments as "no blocked marker"). The sub-issue 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 sub-issue 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> <SUB-ID>")The <worker-command> <SUB-ID> 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 → <worker-command> is /abc:ship-issue.)
In no-compact mode, append the flag so workers inherit the opt-out: Skill(skill: "loop", args: "6m <worker-command> <SUB-ID> --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 on its own loop.
Do not fire workers for sub-issues already in-flight (the worker's own Phase 0.5 cron-match would no-op anyway, but skip explicitly for clarity and to avoid any potential race).
If multiple sub-issues are ready in the same wake, fire all of them — the workers run in parallel on independent cron entries.
Append (not edit) a single <!-- ship-epic:status --> comment on the parent with the current snapshot:
<!-- ship-epic:status -->
Wake: 2026-05-17T18:30:00Z (4 of 6 merged)
| State | Sub-issue | Latest |
|---|---|---|
| merged | PROJ-101 | <PR URL> |
| merged | PROJ-102 | <PR URL> |
| in-flight | PROJ-103 | pr-open, <PR URL> |
| ready | PROJ-104 | → firing worker this wake |
| waiting | PROJ-105 | blocked by PROJ-104 |
| blocked-user | PROJ-106 | awaiting-manual-verification |/abc:ship-epic wake <ts>
Parent: PROJ-100 "Add WidgetRow to dashboard" (4 of 6 merged)
[merged] PROJ-101 <PR URL>
[merged] PROJ-102 <PR URL>
[in-flight] PROJ-103 pr-open <PR URL>
[ready→firing] PROJ-104
[waiting] PROJ-105 blocked by PROJ-104
[blocked-user] PROJ-106 awaiting-manual-verification
Next wake: /loop 10m /abc:ship-epic PROJ-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 sub-issues 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 sub-issues 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.
mergedDone via save_issue.<!-- ship-epic:event:complete --> comment: ✅ Epic complete: <N> sub-issues merged./loop via CronDelete (use the same match rule as Phase 0)./abc:ship-issue Phase 7 — if any remain in CronList, leave them; they'll wake, derive merged, and self-cancel.failedA sub-issue counts as failed for the all-stop only when it carries the worker's <!-- ship-issue:event:failed --> marker. A sub-issue that is canceled 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: <SUB-ID> failed (<reason>). Other sub-issues 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 sub-issue's worker loop was cancelled. Re-run <worker-command> <SUB-ID> to resume. (use the derived <worker-command>).Open question: should we let other workers finish even if one failed? v1 halts because cross-repo failures usually mean the design has a problem. If this is wrong in practice, flip to "let others continue, just block fan-out."
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 Linear status comment, but the loop continues.
Treat as a direct interrupt — read the comment, decide whether to halt or adjust. If unclear, write a blocked-user comment with reason user-mention-ambiguous:<parent-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 Linear 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.possible-duplicate-coordinator if a foreign <!-- ship-epic:status --> comment exists; parent-already-serial-walked if a ship-issue <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 Linear state not terminal — re-classifies as ready (if blocks satisfied) and fires a new worker. Re-arming is idempotent at the worker level.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.