Ship issue
Linear · Drives a Linear issue (or list, or parent with sub-issues) from Backlog to Done through the implement → PR → address-review → merge loop. TRIGGER when the user says "/ship-issue TICKET-ID", asks to ship/land/drive/autoland a ticket, or wants Claude to take a Linear issue through review to merge. Also trigger when resuming work on a ticket with an open PR and pending reviewer comments. Supports GitHub + GitLab and multi-repo parent issues via `repo:` Linear labels. Self-arms its own `/loop` — the user invokes once and walks away.From its SKILL.md
npx -y skills add semanticpixel/abc --skill ship-issueAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
40.4 KB, ~9.9k tokens by cl100k_base, as published. Nobody here has run it
Drive a Linear issue (or ordered list of issues, or parent with sub-issues) from Backlog to Done through the implement → open-PR → address-review → merge loop. Platform-agnostic: GitHub via gh, GitLab via glab. Stateless across sessions — Linear, GitHub, and GitLab are the sources of truth.
Usage: /ship-issue <arg> — the skill self-arms its own /loop. Invoke once and walk away.
Where <arg> is one of:
- A single ticket:
PROJ-88 - A Linear URL:
https://linear.app/<workspace>/issue/PROJ-88 - A comma-separated ordered list:
PROJ-65,PROJ-66,PROJ-67 - A parent issue ID — sub-issues are resolved and walked in Linear's default order
- A project milestone:
milestone:<uuid>— expands to non-terminal issues in that milestone, ordered bycreatedAtascending
Any shape may carry a trailing --no-compact flag to suppress the compact-on-merge prompt — see ../_shared/compact-on-merge.md.
The architecture (state machine, blocked-user triggers, escape hatches, locked decisions) lives in
DESIGN.mdalongside this file. Read that first if you're changing behavior. This file is the operational procedure.
Phase 0: Parse input
Normalize $ARGUMENTS into an ordered list of Linear ticket IDs. The branch taken depends on the shape of the arg:
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 § merged) is skipped at every trigger boundary. Contract and rationale live in ../_shared/compact-on-merge.md. Shape detection below runs on the flag-stripped string.
Shape detection (in order, first match wins)
- Starts with
milestone:→ milestone expansion (below). - Is a Linear issue URL (
https://linear.app/<org>/issue/<id>) → strip prefix, extractTEAM-Nidentifier, continue as single ID. - Is a Linear project URL (
https://linear.app/<org>/project/<slug>/...) — these are project overview / issues / triage URLs that don't point at a single ticket:- If the URL contains a
#milestone-<uuid>fragment (e.g..../project/foo/overview#milestone-abc-123), extract the UUID and route through the milestone expansion path below, as if the user had typedmilestone:<uuid>. The fragment regex is#milestone-([0-9a-fA-F-]+)(UUID may contain hex digits and dashes). - Otherwise (no fragment, or a fragment that isn't
milestone-<uuid>) →blocked-userwith reasonproject-url-without-milestone-fragment. The block message lists the four supported shapes: a single ticket (PROJ-88), a Linear issue URL, a comma-separated list, and a milestone (milestone:<uuid>or a project URL with a#milestone-<uuid>fragment). Do not proceed to Phase 0.5 — no cron arming.
- If the URL contains a
- Contains a comma → split on commas → comma-list flow.
- Otherwise → single-ID flow; check for sub-issues via
list_issueswithparentId=<id>; if present, replace with the sub-issue IDs in the API's return order; if none, keep the single ID.
Milestone expansion
For milestone:<uuid>:
- Resolve the milestone to its project. Call
mcp__claude_ai_Linear__list_milestones(no project filter needed — we're matching by UUID) and find the entry whoseid === <uuid>. Extract itsprojectreference. If no match:- →
blocked-userwith reasonmilestone-not-found:<uuid>. Do not proceed to Phase 0.5 — no cron arming.
- →
- Fetch issues scoped to that project. Call
mcp__claude_ai_Linear__list_issueswithproject=<resolved-project>,orderBy: createdAtascending,limit: 250(MCP max). If the response reportshasNextPage: true, paginate viacursoruntil exhausted. Scoping by project keeps this bounded — per-project issue counts are typically ≤ a few hundred. - Client-side filter:
- Keep only issues where
projectMilestone.id === <uuid>. - Drop terminal issues — anything with
statusTypeofcompleted(Done) orcanceled. We don't re-ship already-shipped tickets.
- Keep only issues where
- Empty-list guard. If the resulting list is empty (all issues in the milestone are already terminal, or the milestone has no issues):
- →
blocked-userwith reasonmilestone-no-open-tickets:<uuid>. Do not proceed to Phase 0.5 — no cron arming. An empty-list loop would poll nothing forever.
- →
- The resulting ordered IDs become the ticket list. Phase 1 onward is unchanged.
Ordering caveat (document inline for the user on the first wake's output): ordering is createdAt ascending. This matches the usual "tickets created in work-order" convention. If the milestone has been manually reordered in Linear, the skill won't follow the manual reorder — the MCP doesn't expose sortOrder on issues. Pass a comma-separated list in the desired order as the workaround for reordered milestones.
Raw-arg retention
Retain the raw arg string (as the user typed it — e.g. PROJ-89,PROJ-90, milestone:<uuid>, PROJ-100, NOT the expanded list) for the self-arming check in Phase 0.5. It's the match key for CronList and CronDelete. For milestone args specifically, this means one loop polls the same milestone across wakes — new issues added to the milestone mid-flight get picked up on the next wake's re-derivation without spawning a second loop.
A --no-compact flag is part of the raw arg string — it stays in the cron entry's command so the opt-out survives every subsequent wake (the skill persists nothing locally; the cron arg is the only carrier — see ../_shared/compact-on-merge.md § --no-compact).
The user's order is respected. The skill does not re-prioritise.
Phase 0.5: Self-arm the loop (load-bearing)
The skill arms its own /loop so the user invokes once and walks away. This phase runs on every wake, including loop-triggered ones — the idempotent match below makes subsequent wakes a no-op.
Cron-entry match rule
Used by both this phase's arm check and Phase 7's self-cancel — these two checks must stay in lockstep. Defined in ../_shared/cron-match.md; this skill is the ship-issue consumer (<boundary-class> = alphanumeric, -, ,; /loop interval 6m).
Arm check
- Call
CronListto enumerate active scheduled tasks in the current session. - Apply the cron-entry match rule above to each entry.
- If a match is found → no-op, proceed to Phase 1. This is the common path on loop-triggered wakes.
- If no match → the user invoked
<command-name> <raw-arg>directly without a/loopwrapper (the expected first-invocation case). InvokeSkill(skill: "loop", args: "6m <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.
Match key is the full raw arg string. Two separate invocations with different args → two independent loops. This is intentional: a second list (/ship-issue PROJ-91) shouldn't interfere with an in-flight one (/ship-issue PROJ-89,PROJ-90).
Proceed to Phase 1 in both cases — the first wake also does the work of the first iteration; arming the cron doesn't displace it.
Phase 1: Resolve each item (platform + repo)
For each item in the list — including each sub-issue when the input was a parent — apply the per-item resolution rule:
-
Fetch the ticket via
mcp__claude_ai_Linear__get_issuewithincludeRelations: true. Readlabels. -
Collect any label names starting with
repo:. -
Apply this decision table:
Count of repo:labelsAction 0 Use the current git repo of the invocation cwd. If cwd is not inside a git repo → blocked-user.1 Extract <name>fromrepo:<name>. Look for a subdirectory of cwd matching<name>. If missing →blocked-userwith a note listing available subdirs.2+ blocked-user— one item resolves to at most one repo. -
Once the working directory is picked, detect the platform:
git -C <workdir> remote get-url origin contains "github.com" → platform=github, cli=gh contains "gitlab.<host>" → platform=gitlab, cli=glab anything else → blocked-user (unknown platform) -
Confirm the CLI is authed:
gh auth status(GitHub) orglab auth status --hostname <host>(GitLab). Derive<host>from theABC_GITLAB_HOSTenv var if set, else parse it fromgit remote get-url origin; fall back to bareglab auth statusif no GitLab remote resolves. Never hardcode a GitLab hostname. If not authed,blocked-userwith the auth command to run.
Cache the resolved {workdir, platform, cli} tuple per ticket for this invocation. Re-resolution on the next /loop wake is cheap and handles the case where the user added/removed a label mid-flight.
Resolve the team's workflow-state names by statusType. Linear teams can rename their workflow states ("In Progress" → "Building", "Done" → "Shipped"), and the default workflow has two started-type states — In Progress and In Review — that statusType alone cannot tell apart. get_issue returns only the ticket's current state, not the team's full state list, so build the map from the team's states: call mcp__claude_ai_Linear__list_issues scoped to the ticket's team and collect the distinct state objects seen (each carries name, statusType/type, and position). Group by statusType and resolve + cache:
started, lowerposition→ the working/in-progress state name (defaultIn Progress) →resolved_started_statestarted, higherposition→ the review state name (defaultIn Review) →resolved_in_review_state— thisposition-based split is what disambiguates the twostartedstates; if the team has only onestartedstate, both roles map to itcompleted→ the done state name (defaultDone) →resolved_completed_statecanceled→ the canceled state name (defaultCanceled) →resolved_canceled_state
Fall back to the default name for any role not resolvable (team uses the defaults, position not exposed by the MCP, or no distinct state exists). Phase 3's row 0/row 5 and Phase 4's handlers reference these resolved-state variables (resolved_started_state / resolved_in_review_state / resolved_completed_state / resolved_canceled_state), not the literal strings.
Phase 2: Pick the next item
Walk the list in order. For each item, derive its current state (Phase 3). The skill works on one ticket at a time — serialisation is deliberate (see DESIGN.md → open questions on stacked PRs; v1 is serial).
Skip items already in merged. Work the first non-terminal item. If any item is in failed or blocked-user, halt the whole loop — see Phase 7.
Phase 3: Derive current state
Data to gather
Gather, for the current ticket:
- Ticket state (via
mcp__claude_ai_Linear__get_issue): status, description, labels, links/attachments. - Skill-authored Linear comments (via
mcp__claude_ai_Linear__list_comments). These use HTML-comment markers<!-- ship-issue:<category>:<key>=<value> -->. Retain the full non-marker comment bodies too — the cancel scan below reads them. - All PRs/MRs linked to the ticket (via the Linear attachments/links, cross-referenced with
gh pr list --search <ticket-id>orglab mr list --search <ticket-id>). - For any open PR/MR: gather its reviewer feedback across all of the platform's comment surfaces (a reviewer — including
/abc:review-epicandabc:reviewer— may use any of them; reading only inline drops feedback and mis-derivespr-open), plus its check-run / pipeline statuses and the behind-base signal.- GitHub PR — three surfaces, unioned: (1) inline review comments
gh api /repos/<o>/<r>/pulls/<n>/comments --paginate; (2) review bodiesgh api /repos/<o>/<r>/pulls/<n>/reviews --paginate(aCHANGES_REQUESTED/COMMENTEDreview with a non-emptybodyis actionable;APPROVED/empty is not); (3) top-level PR commentsgh api /repos/<o>/<r>/issues/<n>/comments --paginate. - GitLab MR — diff discussions + MR-level notes, unioned:
glab mr view <id> --comments(covered by the existingBash(glab mr:*)grant) returns both the diff-anchored discussion threads and the MR-level notes. Treat a non-system note from a reviewer as actionable; skip GitLab's own system notes (label/assignee/pipeline events) and the skill's own marker-tagged notes (excluded by their<!-- ship-issue:* -->marker, mirroring the GitHubin_reply_to_idexclusion below). - Filter to actionable items by timestamp strictly after the last skill commit and by excluding the skill's own bookkeeping. Identify the skill's own items by marker/structure, not login: do not exclude by PR/MR author (or any login filter) — the reviewer (
/abc:review-epic) and the PR/MR author commonly share one CLI login (ghon the GitHub path,glabon GitLab), so that would drop the reviewer's own review body/note. Exclude: the skill's own<!-- ship-issue:* -->comments (including the marker-taggedFixed in <sha>replies — see thefixinghandler step 5), the reviewer's<!-- review-epic:* -->marker-only comments, and skill-authored inline replies (in_reply_to_idon GitHub). This filtered union is what rows 3 / 3a / 4 mean by "review comments." - Statuses:
gh pr checks --json name,state,bucket/glab ci view --output json. Behind-base signal — GitHub:gh pr view <pr> --json mergeStateStatus(BEHIND→ branch behind base); GitLab: the diverged/behind commit counts fromglab mr view. Phase 3.5's rebase trigger reads this signal.
- GitHub PR — three surfaces, unioned: (1) inline review comments
Cancel scan. Scan the latest non-marker comments for a standalone cancel — case-insensitive, where the whole comment body (or its first line) trimmed equals cancel. On a match → terminal halt: derive failed with reason cancel-requested and run Phase 7's stop flow (CronDelete). A cancel buried mid-paragraph does not trigger — only a standalone-comment / first-line cancel. Scan all reviewer-reachable surfaces: the Linear ticket's own comments and (when an open PR/MR exists) every PR/MR comment surface gathered above. The Phase 4 fixing handler's @-mention scan runs over the same unioned set — a reviewer may cancel or @-mention on any surface, not only inline.
If any of these reads fail (non-zero exit, timeout, partial/paginated result the skill can't reconcile, or an MCP error) → transition to blocked-user with reason cannot-read-ticket-state:<tool>:<detail>. Do not fall through to the table — an unknown state is not "no match found." This matters most for row 1a: silently treating a failed list_comments as "no verify:passed marker" would bypass the validation gate.
For CI checks specifically, classify by a status vocabulary unified with the GitHub sibling's bucket schema. GitHub (gh pr checks --json name,state,bucket): a check is failing when bucket=fail, pending when bucket=pending; pass/skipping/cancel are not failing. GitLab pipelines (glab ci view): a job is failing when its status is failed, pending when running or pending; success is not failing. Only the failing-bucket checks (GitHub bucket=fail, GitLab failed) drive rows 3a/3b.
State table
Apply these rules in order — first match wins. Phase 3 is the single point of state decision; Phase 4 handlers act on the derived state, they do not re-derive it.
| # | Condition | Derived state |
|---|---|---|
| 0 | The ticket is already in a terminal tracker state (statusType of completed or canceled) AND no merged PR/MR is linked | In list/parent context: skip this item (move to the next). In single-ticket context: blocked-user (reason: ticket-already-terminal) |
| 1a | A PR/MR linked to this ticket is merged AND the ticket description has a ## Validation section (see matching rule below) AND no <!-- ship-issue:verify:passed --> comment exists | blocked-verify |
| 1 | A PR/MR linked to this ticket is merged (and 1a does not apply) | merged |
| 2 | A PR/MR linked to this ticket is closed but not merged | failed (surface the closed-PR URL, halt) |
| 3 | An open PR/MR exists AND has review comments (human or code-review-bot) created after the last skill commit | fixing |
| 3a | An open PR/MR exists, no new review comments since the last skill commit, AND one or more CI checks are in the failing bucket (GitHub bucket=fail, GitLab failed) of the assertion type (unit test, lint, type check, code-review-bot check-run with findings) | fixing |
| 3b | An open PR/MR exists, no new review comments since the last skill commit, AND one or more CI checks are in the failing bucket of the infra/env type (secrets missing, dependency resolution failure, runner error, no test output at all) | blocked-user (reason: ci-infra-failure:<check-name>) |
| 4 | An open PR/MR exists, no new review comments, no failing-bucket CI checks | pr-open |
| 5 | No PR has ever existed, ticket is in a started-type state (default In Progress / In Review) | implementing (resume — do not reset) |
| 6 | No PR, no prior activity | pending |
Row 0 is first-match-wins and precedes row 1: a ticket whose statusType is completed (Done) or canceled with no merged PR/MR is already terminal and re-shipping it would be wrong — skip it in a list/parent walk, or block in single-ticket context so the human knows the ID points at a closed ticket. (statusType is resolved per Phase 1 — see the resolved-state caching rule.)
Supporting rules
Heading-match for row 1a's ## Validation: case-insensitive match on any heading at ## level or deeper (so ##, ###, ####) whose leading word is Validation — i.e., ## Validation, ## Validation:, ## Validation steps, ### Validation checklist, ## VALIDATION all qualify. ## Validate does not (different word). When the heading is ambiguous, err on the side of row 1a (i.e., transition to blocked-verify) rather than advancing to merged — the failure mode of a false positive is a human-run manual check; the failure mode of a false negative is a silently bypassed validation gate.
Edge cases for rows 3a / 3b — apply in this order:
- Classify each failing-bucket check per-check first, before evaluating the table rows. For each failing-bucket check, decide: assertion-type (unit/lint/type/code-review-bot-with-findings) or infra-type (secrets, dep resolution, runner error, no test output). If a check's failure is ambiguous (timeout, opaque build failure, no discernible output), classify it as infra-type. Rationale: a false escalation is a minor interruption, but burning a three-strikes attempt on an infra failure wastes the counter and leaves the real issue invisible.
- After per-check classification, evaluate rows 3a / 3b against the post-classification set. If any assertion-type check remains failing, row 3a fires (first-match-wins) and an accompanying infra failure is not independently surfaced that wake — it will re-surface on a later wake (once the assertion check clears, only infra remains, and row 3b fires). If no assertion-type check remains after step 1's reclassification (e.g. the only failing check was ambiguous and got reclassified to infra), row 3a does not fire; row 3b may.
Stale-CI freshness guard (rows 3a/3b + three-strikes). A failing-bucket check counts as failing only if its run targets the current head SHA of the PR/MR (GitHub: the check-run's head_sha matching the PR's headRefOid; GitLab: the pipeline's sha matching the MR's source-branch tip). A failure recorded against an older commit is stale — treat it as pending, not failing. This stops a fix-push from being scored against a not-yet-rerun check: the old failing run lingers until CI re-triggers on the new SHA, and counting it would mis-fire row 3a and burn a three-strikes attempt on a result that no longer reflects the branch.
The "last skill commit" definition + the Skill-commit marker conventions (used by rows 3/3a/3b/4 and referenced by name from Phase 4's commit steps) are defined in ../_shared/skill-commit-marker.md. This skill is the ship-issue consumer (<pr-branch> = the Linear gitBranchName).
Never reset a ticket that Linear shows as In Progress or In Review. Resume.
Phase 4: Handle state
Run Phase 3.5 escape-hatch checks first, before executing any handler below. Phase 3.5's hard stops dominate state derivation: if any condition there is met, transition to
failedimmediately and do not execute a Phase 4 handler on this wake. The three-strikes CI counter in particular must be evaluated against the latest check statuses beforepr-openorfixingrun.
Handlers below reference Linear states by resolved-state variable (the per-team name cached in Phase 1 by
statusType), shown with the default name in parentheses. A team that renamed its states still gets the right transition.
pending → implementing
- Transition Linear status to the resolved
startedstate (defaultIn Progress) viamcp__claude_ai_Linear__save_issue. - Write a Linear comment noting start:
<!-- ship-issue:event:started --> 🚢 ship-issue started. cdto the resolved workdir. Pull latestmain/master. Create a feature branch from the ticket'sgitBranchName(Linear provides this on the issue object).- Read the full ticket description. Implement against the acceptance criteria. Run the repo's local checks (
pnpm test,pnpm lint, etc. — readpackage.jsonscripts or an existing CLAUDE.md for the correct commands). After local checks pass, run the UI-reachability check (defined below) — ensures the change is reachable from the existing UI, or that the ticket carries an explicit note for human validators. - Commit with a descriptive body explaining the why. Follow the Skill-commit marker rule (Phase 3 supporting rules): always include a
<!-- ship-issue:commit -->HTML comment in the commit body (the primary signal for the Phase 3 "last skill commit" lookup); include aCo-Authored-By: Claude <[email protected]>trailer unless a reachableCLAUDE.mdforbids it. - Push the branch.
- Open the PR/MR using the platform CLI. Include a Linear reference so the issue auto-links — but the choice of reference depends on the validation gate (same
## Validationheading-match rule as Phase 3 row 1a):- No
## Validationheading in the ticket description → use a Linear Magic URL close word (e.g.Closes PROJ-88) so the merge auto-completes the issue. ## Validationheading present → omit the magic close word; use a non-closing reference instead (a plainLinear: <issue-url>line, which links the issue without auto-completing it on merge). This lets the worker — not the merge — control when the issue moves to the done state: themergedhandler transitions it only after theblocked-verifygate passes. A magic close word would auto-complete the issue on merge before the next wake's row 1a could deriveblocked-verify, turning the gate into a post-mortem (theCloses-trailer-races-the-validation-gate race documented inDESIGN.md).
- No
- Transition Linear to the resolved in-review state (default
In Review). Record the PR URL viasave_issue'slinksfield. - State becomes
pr-open. Return — the/loopharness will wake again in 6 minutes.
UI-reachability check (referenced by pending → implementing step 4 and implementing (resume))
Defined in ../_shared/ui-reachability.md. This skill is the ship-issue consumer: option (b)'s <tracker-comment> is a <!-- ship-issue:note:reachability --> Linear comment on the ticket; the artifact opened after the check is the PR/MR.
implementing (resume)
Same as pending → implementing but:
- Check for an existing local branch matching the Linear
gitBranchName. If present, continue there; otherwise create it. - Do not re-run Linear status updates that are already set correctly.
pr-open
pr-open means: an open PR exists, no new reviewer comments, no failing checks. There's nothing to do on this wake.
This skill NEVER runs gh pr merge / glab mr merge — a human merges. The skill drives the PR/MR to green-and-reviewed and then waits; the final merge is always a human action.
- Print a one-line "still waiting" summary: the PR URL, the last skill-commit SHA, and the timestamp.
- Merge-nudge (idempotent, one-time). If the PR/MR is green-but-unmerged with review addressed (all checks in the passing/neutral buckets, no unresolved review comments) and the last skill commit is older than ~30 minutes — use the
%aitimestamp from the Phase 3 "last skill commit" lookup; the skill is stateless and keeps no per-wake counter, so the age of that commit (not a count ofpr-openwakes) is the durable, re-run-safe trigger — and no<!-- ship-issue:note:merge-nudge -->marker comment already exists on the ticket, post one marker-only Linear comment:<!-- ship-issue:note:merge-nudge -->(body is the marker plus the one-line "PR green & review addressed — ready to merge"). The marker's own presence makes this a no-op on every later wake, so it never re-posts. - Return — the
/loopharness will wake again in 6 minutes.
Do not push new commits, re-classify, re-evaluate CI state, or merge here. All classification lives in Phase 3; if a CI check flips to failure between wakes, the next wake's Phase 3 will derive fixing (row 3a) or blocked-user (row 3b).
fixing
Entered from Phase 3 row 3 (new review comments) or row 3a (failing assertion CI check). This handler is invoked by Phase 3.5 sub-phase B step 4, not alongside it — Phase 3.5 dispatches into this handler and observes whether execution reaches step 6 below. The handler itself never reads or writes failcount:<key>=N comments; Phase 3.5 writes the increment only after observing a clean step-6 return. If this handler exits via blocked-user or failed before step 6, no increment is written for this wake.
- Use the check statuses and review comments gathered in Phase 3 — do not call
gh pr checks/glab ci viewor the comments API again. For each failing assertion CI check, fetch its log output now (e.g.gh run view <run-id> --log-failed, orglab ci trace <job-id>); logs were not retrieved in Phase 3, and this log fetch is the only additional network call this handler performs. Classify each item:- code-review-bot finding — the comment author's login (or a recognizable bot comment prefix) matches the review-bot allowlist: resolve it from
~/.claude/review-bots.mdif that file exists (one bot login per line; blank lines and#-comments ignored), otherwise use the built-in default set (dependabot[bot],renovate[bot],github-actions[bot], and common code-review bots). An author matching neither the allowlist nor the default set is treated as a human reviewer comment, not a bot finding — never hardcode a specific employer's bot name here; it belongs in the user's local~/.claude/review-bots.md. See the README's Review-bot allowlist section for the file format. - Human reviewer comment.
- Failing assertion-style CI check — drive the fix from the check's output.
- Scope-creep comment (request for functionality outside the ticket's acceptance criteria) →
blocked-userwith reasonscope-creep. - @-mention of Claude (on any of the PR/MR comment surfaces gathered in Phase 3, not only inline) → this handler is canonical for @-mentions. Read it: an actionable mention (a concrete instruction the skill can carry out within the ticket's scope) → act on it here in
fixing. An ambiguous mention, or a redirect/cancel mention →blocked-userwith reasonuser-mention-ambiguous. (Phase 6 and Phase 7 defer to this rule — they do not flatly halt on every @-mention.)
- code-review-bot finding — the comment author's login (or a recognizable bot comment prefix) matches the review-bot allowlist: resolve it from
- Make the fix in the workdir.
- Before committing, re-run the repo's local checks (the same commands from the
implementingstep). Interpret results:- All checks pass → proceed to step 4.
- A check command fails to run (non-zero exit with no test output, command-not-found, runner crash) →
blocked-userwith reasonlocal-check-command-failed:<command>. Do not commit, do not push. - A check runs and reports a failure that is not the one the fix was intended to resolve (a new regression in an unrelated area) →
blocked-userwith reasonlocal-check-regression:<failing-check>. Do not commit, do not push. - A check still reports the failure the fix was intended to resolve → go back to step 2; the fix isn't done.
- Commit with a descriptive message referencing the comment thread ID or the reviewer's ask. Follow the Skill-commit marker rule (Phase 3 supporting rules) — same marker conventions as
pending → implementingstep 5. Push. - Reply per item, referencing the fix commit SHA (
Fixed in abc1234.), on the matching surface, leading any non-inline reply with a<!-- ship-issue:reply:fixed -->marker so the next wake's Phase 3 excludes it by marker (the skill shares its CLI login with the reviewer, so author can't discriminate): a GitHub inline thread → reply in-thread (gh api .../pulls/<n>/comments/<id>/replies); a GitHub review-body or top-level PR comment → one marker-led top-levelgh pr comment <n>naming what it addresses; a GitLab discussion or MR-level note → marker-ledglab mr note <id> -m <text>naming the thread/ask it addresses (covered byBash(glab mr:*)). Don't reply to the skill's own marker comments. - Return — the next wake's Phase 3 will re-derive state from the new PR/CI reality. Reaching this step is the success signal to Phase 3.5 (it's how Phase 3.5 step 4 knows the fix commit pushed cleanly and the counter can be incremented). If any earlier step transitioned to
blocked-userorfailed, execution never reaches here and Phase 3.5 does not write a failcount comment for this wake.
Do not write or modify <!-- ship-issue:failcount:... --> comments in this handler. Phase 3.5 owns the counter lifecycle.
merged
Reaching this handler means Phase 3's row 1a did not apply (no ## Validation gate, or the gate has already passed). No re-check here — Phase 3 is the single point of decision.
- Transition Linear to the resolved
completedstate (defaultDone) viasave_issue. Write a terminal comment:<!-- ship-issue:event:merged --> ✅ Merged: <PR URL>. - Compact-on-merge (skip in no-compact mode): if at least one non-terminal item remains in the queue, print the compaction prompt as the last output of this wake, after the Phase 8 block —
🗜 Sub-issue <ref> merged. Run /compact now to free context before picking up <next-ref>.Trigger boundary, safety rationale, and exact rules live in../_shared/compact-on-merge.md. Never fires when the merged item was the last (the loop is about to self-cancel). - If the step-2 prompt fired, end the wake here — return rather than working
<next-ref>in this same wake; the next/loopwake re-derives state (the merged item is now skipped by Phase 2) and picks up<next-ref>fresh (safe per "Notes on persistence"). This is what gives the user a/compactopportunity at the boundary — same-wake continuation would surface the prompt only after<next-ref>'s work has already accumulated. Otherwise (no-compact mode, or no non-terminal items remain), advance to the next item in the list.
blocked-verify
Pre-Phase C behavior — auto-verify is Planned (Phase C — /verify-ticket):
- Inline the
## Validationtext into ablocked-usercomment so a human can run it manually. The comment MUST include the unlock instruction verbatim: "post a comment containing exactly<!-- ship-issue:verify:passed -->, then re-run/abc:ship-issue <arg>". Without that line the human has no documented way to clear the gate, and the next wake will re-deriveblocked-verifyforever. - Transition to
blocked-userwith reasonawaiting-manual-verification.
Post-Phase C (not implemented here): the skill would invoke /verify-ticket <id> and branch on the result.
blocked-user
- Write a Linear comment explaining what's blocking, which item, and what input is needed:
<!-- ship-issue:event:blocked --> 🛑 Blocked: <reason>. Needs: <ask>. - Slack ping is Planned for Phase B. Until then, the Linear comment plus the terminal output is the surface.
- Leave the Linear status at its current value — do not transition it. Crucially: if
blocked-userfires duringimplementing(e.g. repo resolution fails, CLI auth missing, scope-creep detected while reading the ticket description), the status is the resolvedstartedstate (defaultIn Progress), and that's where it stays. If it fires duringpr-open/fixing, the status is the resolved in-review state (defaultIn Review) and stays there. The work isn't regressing; it's waiting on a human. - Halt the
/loop— print the reason clearly so the user sees it.
failed
- Write a Linear comment with the failure reason and any relevant URLs:
<!-- ship-issue:event:failed --> ❌ Failed: <reason>. - Transition Linear to the resolved
canceledstate (defaultCanceled) (DESIGN.md §State machine). - Halt.
Phase 3.5: Escape hatches (run before handlers)
Run immediately after Phase 3, before any Phase 4 handler — execution order matches reading order (3 → 3.5 → 4). Evaluated against the data already gathered in Phase 3 — do not re-fetch.
Three-strikes CI counter
Defined in ../_shared/three-strikes-counter.md. This skill is the ship-issue consumer: <failing-bucket> = GitHub bucket=fail / GitLab failed; <passing-bucket> = GitHub bucket=pass / GitLab success; <failcount-key> = <platform>:<check-name> (e.g. github:ci/test, gitlab:build/compile).
Rebase against base — attempt-and-gate
Defined in ../_shared/rebase-attempt-and-gate.md. This skill is the ship-issue consumer: <behind-base-signal> = GitHub mergeStateStatus=BEHIND / GitLab a non-zero behind/diverged commit count from glab mr view; <command> = /abc:ship-issue. The self-cheating hard stop below applies verbatim inside that flow.
Self-cheating hard stop (the most important rule in this skill)
If the skill catches itself about to bypass a failing check rather than fix it — deleting a failing assertion so tests pass, adding --no-verify to a commit, widening a type to suppress an error, wrapping a failing line in // @ts-expect-error, .skip()-ing a test that was failing, commenting out a lint rule that was firing, eslint-disable-ing a violation to silence it — hard stop → failed. No self-healing, no "I'll come back and fix it properly," no silent retry. Write the attempted-cheat to the Linear comment so the human can see exactly what the skill was about to do.
Phase 6: Blocked-user triggers
Soft stops — the loop pauses, the user resumes:
- Review comment requests scope outside the ticket's acceptance criteria.
- Same code-review-bot rule ID (finding-name, not severity or category) fires twice in a row after a fix commit. The precise comparison is the named rule — broader comparisons over/under-trigger.
- CI fails in a non-assertion way (env missing, secrets unavailable, dependency resolution error).
- Merge conflict with
mainafter the attempted auto-rebase (see Phase 3.5 § Rebase against base — attempt-and-gate). Conflict markers or red gates after a clean rebase both escalate asblocked-user, notfailed. - An ambiguous, or a redirect/cancel @-mention of Claude on the PR →
blocked-user. Actionable @-mentions are handled in thefixinghandler (Phase 4), which is canonical for mentions — do not halt on every @-mention. - Any repo-discovery condition from Phase 1 (missing / multiple / unresolvable
repo:labels, unknown platform).
Phase 7: Stop conditions
The loop halts when any of:
- All items reach
merged. Print summary. - Any item enters
blocked-user. Print reason + what's needed. - Any item enters
failed. Print the URL/reason. - An ambiguous, or a redirect/cancel @-mention of Claude on a PR (actionable mentions are handled in the
fixinghandler, not here). - A standalone
cancelcomment on the ticket is detected by the Phase 3 cancel scan (case-insensitive, whole-comment-body or first line) → terminal halt.
In every terminal case — blocked-user, failed, and all-merged alike — before returning, the skill calls CronDelete on its own /loop entry so the cron stops immediately. CronDelete fires on all halts; the cron never stays armed past a halt. Identify the entry via the cron-entry match rule defined in Phase 0.5 (§ Cron-entry match rule): find the CronList entry, extract its job ID, and CronDelete it. The user should not have to run /loop cancel manually — that's part of the self-contained contract.
If CronDelete fails (entry already gone, job-ID not found, etc.), do not halt the stop flow — print a note ("couldn't auto-cancel the loop; run /loop cancel if it's still firing") and continue. The terminal print + Linear comment are still the authoritative surface.
Phase 8: Output contract (every wake)
Print a concise block the user can scan at a glance:
<command-name> wake <timestamp>
Items: <N>
[merged] PROJ-65 <PR URL>
[pr-open] PROJ-66 <PR URL> (no new comments since <sha>)
[pending] PROJ-67
[blocked] PROJ-68 awaiting-manual-verification
Working: PROJ-66
Next wake: /loop 6m <command-name> <original-arg>
<command-name> — in both the title line and the Next wake: line — is the captured slash-command name from Phase 0.5 (e.g. /abc:ship-issue), not a hardcoded literal; same convention as the self-arm string.
Keep the output short on no-op wakes — the idempotency contract makes per-wake verbosity expensive.
Notes on persistence
Nothing is persisted locally. All skill-authored state lives in Linear comments using the marker format above. This is intentional — see DESIGN.md § Session-boundary behavior. Closing the terminal mid-loop is safe; re-running /ship-issue <same-arg> derives state fresh.
If a counter comment or event comment needs updating, append a new comment rather than editing the existing one — Linear comment history is the audit trail.
Planned follow-ups (not implemented here)
- Slack ping on
blocked-user→ Phase B. Placeholder only. - Auto-verify via
/verify-ticket→ Phase C. Currently all## Validationtickets escape toblocked-user. - Auto-stacked PRs for sub-tasks touching the same files → see DESIGN.md § open questions. v1 serialises.
- Cancellation mechanism beyond
/loop cancel(e.g. acancelLinear comment) → see DESIGN.md § open questions.
What ships with it: 2 files
38.3 KB alongside SKILL.md