ship-issue — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited ship-issue (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 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:
PROJ-88https://linear.app/<workspace>/issue/PROJ-88PROJ-65,PROJ-66,PROJ-67milestone:<uuid> — expands to non-terminal issues in that milestone, ordered by createdAt ascendingAny 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.md alongside this file. Read that first if you're changing behavior. This file is the operational procedure.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.
https://linear.app/<org>/issue/<id>) → strip prefix, extract TEAM-N identifier, continue as single ID.https://linear.app/<org>/project/<slug>/...) — these are project overview / issues / triage URLs that don't point at a single ticket:#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 typed milestone:<uuid>. The fragment regex is #milestone-([0-9a-fA-F-]+) (UUID may contain hex digits and dashes).milestone-<uuid>) → blocked-user with reason project-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.list_issues with parentId=<id>; if present, replace with the sub-issue IDs in the API's return order; if none, keep the single ID.For milestone:<uuid>:
mcp__claude_ai_Linear__list_milestones (no project filter needed — we're matching by UUID) and find the entry whose id === <uuid>. Extract its project reference. If no match:milestone-not-found:<uuid>. Do not proceed to Phase 0.5 — no cron arming.mcp__claude_ai_Linear__list_issues with project=<resolved-project>, orderBy: createdAt ascending, limit: 250 (MCP max). If the response reports hasNextPage: true, paginate via cursor until exhausted. Scoping by project keeps this bounded — per-project issue counts are typically ≤ a few hundred.projectMilestone.id === <uuid>.statusType of completed (Done) or canceled. We don't re-ship already-shipped tickets.milestone-no-open-tickets:<uuid>. Do not proceed to Phase 0.5 — no cron arming. An empty-list loop would poll nothing forever.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.
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.
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.
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).
CronList to enumerate active scheduled tasks in the current session.<command-name> <raw-arg> directly without a /loop wrapper (the expected first-invocation case). Invoke Skill(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.
For each item in the list — including each sub-issue when the input was a parent — apply the per-item resolution rule:
mcp__claude_ai_Linear__get_issue with includeRelations: true. Read labels.repo:.Count of repo: labels | Action |
|---|---|
| 0 | Use the current git repo of the invocation cwd. If cwd is not inside a git repo → blocked-user. |
| 1 | Extract <name> from repo:<name>. Look for a subdirectory of cwd matching <name>. If missing → blocked-user with a note listing available subdirs. |
| 2+ | blocked-user — one item resolves to at most one repo. |
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)gh auth status (GitHub) or glab auth status --hostname <host> (GitLab). Derive <host> from the ABC_GITLAB_HOST env var if set, else parse it from git remote get-url origin; fall back to bare glab auth status if no GitLab remote resolves. Never hardcode a GitLab hostname. If not authed, blocked-user with 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, lower `position` → the working/in-progress state name (default In Progress) → resolved_started_statestarted, higher `position` → the review state name (default In Review) → resolved_in_review_state — this position-based split is what disambiguates the two started states; if the team has only one started state, both roles map to itcompleted → the done state name (default Done) → resolved_completed_statecanceled → the canceled state name (default Canceled) → resolved_canceled_stateFall 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.
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.
Gather, for the current ticket:
mcp__claude_ai_Linear__get_issue): status, description, labels, links/attachments.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.gh pr list --search <ticket-id> or glab mr list --search <ticket-id>)./abc:review-epic and abc:reviewer — may use any of them; reading only inline drops feedback and mis-derives pr-open), plus its check-run / pipeline statuses and the behind-base signal.gh api /repos/<o>/<r>/pulls/<n>/comments --paginate; (2) review bodies gh api /repos/<o>/<r>/pulls/<n>/reviews --paginate (a CHANGES_REQUESTED/COMMENTED review with a non-empty body is actionable; APPROVED/empty is not); (3) top-level PR comments gh api /repos/<o>/<r>/issues/<n>/comments --paginate.glab mr view <id> --comments (covered by the existing Bash(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 GitHub in_reply_to_id exclusion below)./abc:review-epic) and the PR/MR author commonly share one CLI login (gh on the GitHub path, glab on GitLab), so that would drop the reviewer's own review body/note. Exclude: the skill's own <!-- ship-issue:* --> comments (including the marker-tagged Fixed in <sha> replies — see the fixing handler step 5), the reviewer's <!-- review-epic:* --> marker-only comments, and skill-authored inline replies (in_reply_to_id on GitHub). This filtered union is what rows 3 / 3a / 4 mean by "review comments."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 from glab mr view. Phase 3.5's rebase trigger reads this signal.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.
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.)
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:
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.
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 tofailedimmediately 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 → implementingstarted state (default In Progress) via mcp__claude_ai_Linear__save_issue.<!-- ship-issue:event:started --> 🚢 ship-issue started.cd to the resolved workdir. Pull latest main/master. Create a feature branch from the ticket's gitBranchName (Linear provides this on the issue object).pnpm test, pnpm lint, etc. — read package.json scripts 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.<!-- ship-issue:commit --> HTML comment in the commit body (the primary signal for the Phase 3 "last skill commit" lookup); include a Co-Authored-By: Claude <[email protected]> trailer unless a reachable CLAUDE.md forbids it.## Validation heading-match rule as Phase 3 row 1a):Closes PROJ-88) so the merge auto-completes the issue.Linear: <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: the merged handler transitions it only after the blocked-verify gate passes. A magic close word would auto-complete the issue on merge before the next wake's row 1a could derive blocked-verify, turning the gate into a post-mortem (the Closes-trailer-races-the-validation-gate race documented in DESIGN.md).In Review). Record the PR URL via save_issue's links field.pr-open. Return — the /loop harness 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:
gitBranchName. If present, continue there; otherwise create it.pr-openpr-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.
%ai timestamp 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 of pr-open wakes) 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./loop harness 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).
fixingEntered 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.
gh pr checks / glab ci view or the comments API again. For each failing assertion CI check, fetch its log output now (e.g. gh run view <run-id> --log-failed, or glab 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:~/.claude/review-bots.md if 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.blocked-user with reason scope-creep.fixing. An ambiguous mention, or a redirect/cancel mention → blocked-user with reason user-mention-ambiguous. (Phase 6 and Phase 7 defer to this rule — they do not flatly halt on every @-mention.)implementing step). Interpret results:blocked-user with reason local-check-command-failed:<command>. Do not commit, do not push.blocked-user with reason local-check-regression:<failing-check>. Do not commit, do not push.pending → implementing step 5. Push.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-level gh pr comment <n> naming what it addresses; a GitLab discussion or MR-level note → marker-led glab mr note <id> -m <text> naming the thread/ask it addresses (covered by Bash(glab mr:*)). Don't reply to the skill's own marker comments.blocked-user or failed, 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.
mergedReaching 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.
completed state (default Done) via save_issue. Write a terminal comment: <!-- ship-issue:event:merged --> ✅ Merged: <PR URL>.🗜 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).<next-ref> in this same wake; the next /loop wake 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 /compact opportunity 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-verifyPre-Phase C behavior — auto-verify is Planned (Phase C — /verify-ticket):
## Validation text into a blocked-user comment 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-derive blocked-verify forever.blocked-user with reason awaiting-manual-verification.Post-Phase C (not implemented here): the skill would invoke /verify-ticket <id> and branch on the result.
blocked-user<!-- ship-issue:event:blocked --> 🛑 Blocked: <reason>. Needs: <ask>.blocked-user fires during implementing (e.g. repo resolution fails, CLI auth missing, scope-creep detected while reading the ticket description), the status is the resolved started state (default In Progress), and that's where it stays. If it fires during pr-open / fixing, the status is the resolved in-review state (default In Review) and stays there. The work isn't regressing; it's waiting on a human./loop — print the reason clearly so the user sees it.failed<!-- ship-issue:event:failed --> ❌ Failed: <reason>.canceled state (default Canceled) (DESIGN.md §State machine).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.
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).
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.
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.
Soft stops — the loop pauses, the user resumes:
main after 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 as blocked-user, not failed.blocked-user. Actionable @-mentions are handled in the fixing handler (Phase 4), which is canonical for mentions — do not halt on every @-mention.repo: labels, unknown platform).The loop halts when any of:
merged. Print summary.blocked-user. Print reason + what's needed.failed. Print the URL/reason.fixing handler, not here).cancel comment 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.
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.
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.
## Validation tickets escape to blocked-user./loop cancel (e.g. a cancel Linear comment) → see DESIGN.md § open questions.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.