SLOPSHOPPER

ship-session

A workflow that turns a request into a GitHub Issue and implements it in the same session until every acceptance criterion is met, verification is green, and…

newbandguardtoastprompt
★ 1v0.8.3MITupdated 2026-10-06insession-space/claude-ship/plugins/ship-session
A shopper browsing a rack in a slop shop
README

English | 日本語

claude-ship

A workflow plugin for Claude Code that turns a request into a GitHub Issue and implements it in the same session until every acceptance criterion is met, verification is green, and code review has zero findings.

Its defining trait is that it decides the goal (Issue only / implementation only / up to PR / up to merge) once, before starting, and stops there. No more "it got merged before I noticed," and no more "it created an Issue and left it at that."

Install

claude plugin marketplace add insession-space/claude-ship
claude plugin install ship-session@claude-ship

Restart Claude Code and it is ready to use.

Usage

/ship-session:ship-session Add a dark mode toggle to settings.

Or just ask in plain words and it starts.

ship this

Mention the goal along with the request, and it proceeds without asking about it.

/ship-session:ship-session Add a dark mode toggle to settings. Make a PR.

Each skill's description carries trigger examples in both English and Japanese, so asking in Japanese (for example, この要望を ship して) starts it the same way.

What happens

Phase 0  Ask for the goal, once (Issue / implementation / PR / merge)
   ↓
Phase 1  create-issue — dig into requirements and spec, then create the Issue
   ↓
Phase 2  issue-loop  — repeat "implement → verify → review" until the stop conditions are met
   ↓
Phase 3  Offer next options based on where it stopped

There are four goal options: Up to PR (Recommended) / Up to merge / Implementation only (no PR) / Issue only. These are the canonical English labels defined in the skill, and they are shown translated into the user's language.

Three stop conditions

  1. Every acceptance criterion in the ticket is met
  2. Verification is green (exit code 0; never judged by grepping the output)
  3. Zero review findings

It loops until all three hold. If it hits the limit (5 rounds by default), it lists the remaining gaps and stops.

For a GitHub Issue, it ticks the checkbox of each acceptance criterion in the Issue body as soon as verification shows the criterion is met. It fetches, edits and writes back the body in one command, so it is unlikely to overwrite other people's edits with an old copy (an edit made in those few seconds is not protected). Install the acceptance-progress mod to see that progress above the prompt too.

Phase 0 is enforced by a hook

Phase 0 is not just an instruction in SKILL.md; a hook enforces it mechanically. (In past measurements, every session that ran Phase 0 reached the implementation loop, and every session that skipped it stalled silently partway through.)

  1. Invoking ship-session sets up a gate (hooks/ship-gate.py). Both ways of starting it are covered
  2. Typing /ship-session:ship-session (or /ship-session) at the prompt: the UserPromptSubmit hook sets up the gate and tells the agent to fix the goal first. A mere mention of the command inside a sentence does not count
  3. The agent calling the Skill tool: the PreToolUse hook sets up the gate
  4. If the goal is already recorded and you type the command again with a different request, it is treated as a new request and the gate is set up again. The same request text, or the command alone, keeps the recorded goal. Only a fingerprint of the request text is stored, never the text itself
  5. Until the goal is recorded, every tool call other than AskUserQuestion, session renaming, and the recording script is blocked, and the agent is told what to do instead (reading files and investigating also come after the gate)
  6. The goal is recorded in one of the following ways, which opens the gate
  7. If the AskUserQuestion header is Goal / Approach (English) or 到達点 / 進め方 (Japanese), the hook records the answer automatically
  8. If the question was asked in another language, or the user's message states the goal explicitly, the agent runs hooks/ship-goal.sh record "<goal>". If it forgets, the message on the next blocked tool call tells it to

State is kept in ~/.claude/cache/ship-gate/<pid>.json, and leftovers from a different session are never used as grounds for blocking. If the check itself fails, it always lets the call through (the gate is an added safeguard, not something that should stop the user's work). Sessions that do not use ship-session are left untouched.

Progress band above the prompt

While ship-session runs, a band above the prompt input shows how far it has got. It is a Claude Code mod (a hooks module, hooks/ship-band.tsx, listed under modules in hooks/hooks.json).

╭──────────────────────────────────────────────────────────────╮
│ ship-session                                  Goal  Up to PR │
│ ✔ Goal  ✔ Issue  ▶ Implementation loop  ○ Next step          │
│ Issue #42   PR not yet   2 reviews                           │
│  Gate  Stopped Read because the goal is not decided yet      │
╰──────────────────────────────────────────────────────────────╯
  • Goal and steps: the goal agreed in Phase 0, the steps already done, and the current step highlighted
  • Issue / PR: the numbers taken from the URLs that gh issue create / gh pr create print. Nothing is shown when the command fails or prints no URL
  • Reviews: how many times code-review ran during the implementation loop
  • Gate: when the Phase 0 gate blocks a tool, the band names the tool and a toast appears. The line goes away once the goal is recorded

The band appears only after ship-session starts (/ship-session:ship-session typed at the prompt, or the Skill tool), and a new start resets it. Collapse it with [-] or ctrl+x ctrl+a. When another mod, such as agent-cast, also draws a band above the prompt, both bands are stacked vertically. Its language follows Claude Code's language setting, then the language of your latest message, then English.

It is drawn in the terminal and in the desktop app's Code tab. VS Code and mobile do not draw this band. A Claude Code version without mods does not read modules and runs the gate and session-naming hooks as before.

Speaks the user's language

The skill bodies are written in English, but everything addressed to the user is written in the user's language: AskUserQuestion questions and options, progress and completion reports, and Artifacts (title, body, and UI strings inside the page).

The language is decided by checking the following in order and using the first that decides it.

  1. Claude Code's language setting (~/.claude/settings.local.json → ~/.claude/settings.json)
  2. The language of the user's latest message
  3. English, if neither decides it

Code, commands, file paths, and the completion-signal prefixes result: / needs input: / failed: are not translated (callers look for them literally). GitHub Issues, pull requests, and commit messages follow the repository's existing convention (the language of existing Issues/PRs/commits, and any rule in CLAUDE.md / CONTRIBUTING.md); only when there is no convention are they written in the user's language.

The rule itself lives in skills/_shared/user-language.md.

Japanese is written with yomiyasu

When the language is Japanese, Artifact bodies, reports, and Japanese Issue / PR bodies are written following the yomiyasu skill: who does what in every sentence, no metaphor verbs, no emoji or bold for emphasis. Code, quotes, signal prefixes, and attribution lines are left as they are. Install it separately:

claude plugin marketplace add nanaism/yomiyasu
claude plugin install yomiyasu@yomiyasu

Without it, a few of its principles are applied directly. Do not enable another Japanese style skill (such as natural-japanese) at the same time; yomiyasu warns that they interfere. The rule lives in skills/_shared/japanese-writing.md.

How completion reports look

Completion-report Artifacts use the page types of the artifact-templates plugin (implementation for shipped work, review for review findings, investigation-report for Issue decisions). The mapping is in skills/_shared/artifact-templates.md. Without that plugin, reports are built with the artifact-design skill alone.

Renaming sessions in your display language

The session names Claude Code generates automatically are always English kebab-case (an English example like fix-login-bug is embedded in the built-in naming prompt, so specifying a language in CLAUDE.md does not change it). When the session list is full of mechanical names, you cannot tell sessions apart. This helps either way: if you work in Japanese, it fixes a list full of English; if you work in English, Fix the login redirect is still easier to read than fix-login-bug.

With this plugin installed, the following happens even in ordinary sessions that do not use /ship-session.

  1. When you send your first prompt, a UserPromptSubmit hook runs, and if the session name is still auto-generated, it tells the agent to rename it in your display language
  2. Once the agent has grasped what the request is about, it runs hooks/rename-session.sh exactly once
  3. name in ~/.claude/jobs/<jobId>/state.json becomes a name in your display language, and it shows up in lists such as Session Desk

When it does nothing (none of these are errors):

  • The session already has a user-given name (nameSource: "user", or a marker that it has already been renamed)
  • The session is not a background job (there is no state.json to write to)
  • The reminder count has reached its limit (3)

The display language is taken from language in ~/.claude/settings.local.json → ~/.claude/settings.json, falling back to AppleLocale. If none of these decides it, the reminder assumes English (it does not stay silent).

Only name / nameSource in state.json are rewritten. ~/.claude/sessions/<pid>.json is only read, to look up the jobId, and never rewritten. Even if the hook crashes with an exception, it does not stop the prompt from being sent.

To rename a session by hand, just ask.

Rename this session to "Pass signature verification in the release steps"

Included skills

SkillRoleUsable on its own
ship-sessionAgree on the goal → create the Issue → implementation loop → offer next actions—
create-issueDig into requirements and spec and create an Issue (creation only)✔
issue-loopIterate on implementing one ticket until the stop conditions are met✔
code-reviewReview a diff✔
session-namingRename the session in the user's display language✔

ship-session delegates to the three skills below it. They also work on their own, so you can call them directly when you "just want an Issue" or "just want the diff reviewed."

Requirements

  • gh CLI is authenticated (used for Issue and PR operations)
  • Used inside a git repository

The following are used if available; everything works without them.

  • External review CLI (codex etc.) — if available, review and investigation are delegated to it to save tokens. Otherwise, multiple subagents with separate lenses are used instead (it never falls back to a single read-through)
  • Notion MCP — if available, Notion pages can also be handled as tickets
  • yomiyasu — if installed, Japanese prose is written following it (see above)

Verification commands are chosen automatically

It never hard-codes "this project's build command." Reading the CI definitions (.github/workflows/) comes first; if there are none, it decides from the shape of the repository.

What it findsCandidate verification commands
package.jsonRead scripts. The package manager is determined from the lockfile
Package.swiftswift build / swift test
go.modgo build ./... / go test ./...
Cargo.tomlcargo build / cargo test
Gemfilebundle exec rspec etc.
MakefileActually look at the targets

If it still cannot decide, it asks before entering the loop.

Design principles

This plugin was built by working backward from patterns that actually failed.

  • Decide the goal first — if it runs while "how far to go" is still vague, it goes all the way to merge when you wanted it to stop after creating the Issue
  • Do not cut the spec deep-dive short — an Issue that merely reshapes a one-sentence request into "background + two lines of acceptance criteria" has not resolved its ambiguity, and that turns into back-and-forth during implementation. One round of digging prevents five rounds of implementation
  • Judge green by exit code — searching the output for error reports failures as successes
  • Do not change information hierarchy nobody asked for — the most expensive failure is not breaking during implementation but getting reverted wholesale after merge. Asking takes 30 seconds; a revert costs a full cycle
  • Do not fall back to a single-reader review — when no external CLI is available, one agent reading everything alone loses coverage yet gets reported just like a successful review
  • Clean up what you created — only the one who started a server, took a screenshot, or made a temporary worktree knows exactly what needs cleaning up

License

MIT

Source 3 files
hooks/ship-band.tsx 190 lines
1// ship-session の進捗とゲートのブロックを、プロンプト入力欄の上のバンドに出す mod。
2// command hook(ship-gate.py など)とは別に、hooks.json の `modules` から読まれる。
3// mods を知らない Claude Code ではこのファイルは読まれず、command hook だけが動く。
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Lang, ShipProgress } from '../types'
8import {
9  GATE_MARKER,
10  LABELS,
11  NEXT_HEADERS,
12  currentStep,
13  goalFromAnswers,
14  goalFromRecord,
15  hasJapanese,
16  isGoalRecord,
17  isIssueCreate,
18  isPrCreate,
19  langFromSetting,
20  numberFromUrl,
21  onSkill,
22  slashSkill,
23} from './ship-progress'
24
25const progress = atom({ plugin: 'ship-session', key: 'progress' } as const, null as ShipProgress | null)
26const lang = atom({ plugin: 'ship-session', key: 'lang' } as const, null as Lang | null)
27
28/**
29 * ship-session の進行として数える呼び出しか。ship-session を起動していない
30 * セッションと、サブエージェントの呼び出しは数えない。
31 */
32const isTracked = async ($: EngineInterface, agentId: string | undefined): Promise<boolean> =>
33  agentId === undefined && (await read($, progress)) !== null
34
35export const register: Register = on => {
36  // settings の `language` が決まっていればそれを使い、無ければプロンプトの文字で決める
37  // (skills/_shared/user-language.md と同じ優先順位)
38  let isLangFromSettings = false
39
40  on('session.start', async ($, e, next) => {
41    const fromSettings = langFromSetting((await $.settings.read()).language)
42    isLangFromSettings = fromSettings !== null
43    if (fromSettings !== null) await update($, lang, () => fromSettings)
44
45    return next(e)
46  })
47
48  on('prompt.submit', async ($, e, next) => {
49    // ユーザーが打った文だけを見る(タスク通知などエンジン由来の文は英語で来る)
50    const isUserText = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
51    if (isUserText && !isLangFromSettings) {
52      await update($, lang, () => (hasJapanese(e.text) ? 'ja' : 'en'))
53    }
54    // `/ship-session:ship-session ...` と打たれた起動。skill.prompt はユーザー層の
55    // プラグインに届かない(組み込みのセキュリティ層が素通しする)ので、入力から拾う
56    const typed = isUserText ? slashSkill(e.text) : null
57    if (typed !== null) await update($, progress, p => onSkill(p, typed))
58
59    return next(e)
60  })
61
62  // エージェントが Skill ツールで呼んだ起動と委譲
63  on('tool.call', { tool: 'Skill' }, async ($, e, next) => {
64    const ran = await next(e)
65    if (ran.deny === undefined && ran.isError !== true && e.agentId === undefined) {
66      await update($, progress, p => onSkill(p, e.skill))
67    }
68
69    return ran
70  })
71
72  // ゲート(ship-gate.py の PreToolUse)が止めた呼び出しは、エラー本文に印が入って返る
73  on('tool.call', async ($, e, next) => {
74    const ran = await next(e)
75    if (!(await isTracked($, e.agentId))) return ran
76
77    const blocked = ran.deny ?? (ran.isError === true ? ran.text : undefined)
78    if (blocked?.includes(GATE_MARKER)) {
79      await update($, progress, p => (p === null ? p : { ...p, gate: e.tool }))
80      $.ui.toast(LABELS[(await read($, lang)) ?? 'en'].toast(e.tool))
81    }
82
83    return ran
84  })
85
86  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
87    const ran = await next(e)
88    if (ran.deny !== undefined || ran.isError === true || !(await isTracked($, e.agentId))) return ran
89
90    const text = ran.text ?? ''
91    const goal = isGoalRecord(e.command) ? goalFromRecord(text) : null
92    const issue = isIssueCreate(e.command) ? numberFromUrl(text, 'issues') : null
93    const pr = isPrCreate(e.command) ? numberFromUrl(text, 'pull') : null
94    // 進捗に関係ないコマンドで状態を書くと、バンドが毎回描き直される
95    if (goal === null && issue === null && pr === null) return ran
96    await update($, progress, p =>
97      p === null
98        ? p
99        : {
100            ...p,
101            ...(goal !== null && { goal, gate: null }),
102            ...(issue !== null && { issue }),
103            ...(pr !== null && { pr }),
104          },
105    )
106
107    return ran
108  })
109
110  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
111    const ran = await next(e)
112    if (ran.deny !== undefined || ran.isError === true || !(await isTracked($, e.agentId))) return ran
113
114    const { questions, answers } = ran.result
115    const goal = goalFromAnswers(questions, answers)
116    const isNext = questions.some(q => NEXT_HEADERS.includes(q.header))
117    if (goal === null && !isNext) return ran
118    await update($, progress, p =>
119      p === null
120        ? p
121        : {
122            ...p,
123            ...(goal !== null && { goal, gate: null }),
124            ...(isNext && { phase: 3 as const }),
125          },
126    )
127
128    return ran
129  })
130
131  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
132    const p = await read($, progress)
133    if (e.props.hasSurvey || p === null) return next(e)
134
135    // 他のプラグイン(agent-cast の子エージェントのバンドなど)が描くものの下に並べる
136    const below = await next(e)
137    const { Box, Text } = $.ui.resolve(e)
138    const l = LABELS[(await read($, lang)) ?? 'en']
139    const step = currentStep(p)
140
141    return (
142      <Box flexDirection="column">
143        {below}
144        <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
145          <Box justifyContent="space-between">
146            <Text bold color="cyan">
147              {l.title}
148            </Text>
149            <Text>
150              <Text dimColor>{l.goal} </Text>
151              <Text bold>{p.goal ?? '…'}</Text>
152            </Text>
153          </Box>
154          <Box flexWrap="wrap" columnGap={2}>
155            {l.steps.map((label, i) =>
156              i < step ? (
157                <Text color="green">✔ {label}</Text>
158              ) : i === step ? (
159                <Text bold color="black" backgroundColor="yellow">
160                  {' '}▶ {label}{' '}
161                </Text>
162              ) : (
163                <Text dimColor>○ {label}</Text>
164              ),
165            )}
166          </Box>
167          <Box columnGap={3}>
168            <Text>
169              Issue <Text bold>{p.issue === null ? l.none : `#${p.issue}`}</Text>
170            </Text>
171            <Text>
172              PR <Text bold>{p.pr === null ? l.none : `#${p.pr}`}</Text>
173            </Text>
174            {p.phase >= 2 && <Text dimColor>{l.reviews(p.reviews)}</Text>}
175          </Box>
176          {p.gate !== null && (
177            <Box columnGap={1}>
178              <Text bold color="black" backgroundColor="red">
179                {' '}
180                {l.gate}{' '}
181              </Text>
182              <Text color="red">{l.gateText(p.gate)}</Text>
183            </Box>
184          )}
185        </Box>
186      </Box>
187    )
188  })
189}
190
hooks/ship-progress.ts 114 lines
1// ship-session の進捗を、観測したイベントから組み立てる純粋関数。
2// 描画(ship-band.tsx)から切り離して、遷移だけをテストできるようにしている。
3import type { Lang, ShipProgress } from '../types'
4
5/** ship-gate.py の GOAL_HEADERS と揃える(到達点を聞く質問の header) */
6export const GOAL_HEADERS = ['Goal', 'Approach', '到達点', '進め方']
7
8/** Phase 3 で次の一手を聞く質問の header。SKILL.md の `Next step` と、その日本語訳 */
9export const NEXT_HEADERS = ['Next step', '次の一手', '次のステップ', '次の手順']
10
11/** ship-gate.py の block_message() の先頭。これを含むエラーをゲートのブロックとみなす */
12export const GATE_MARKER = '[ship-gate]'
13
14export const initial = (): ShipProgress => ({
15  goal: null,
16  phase: 0,
17  issue: null,
18  pr: null,
19  reviews: 0,
20  gate: null,
21})
22
23/** `plugin:skill` の skill 側。 */
24export const skillName = (skill: string): string => skill.split(':').pop() ?? skill
25
26/**
27 * スキルの展開を受けて進める。ship-session 本体は状態を作り直し、
28 * 委譲先のスキルはフェーズを進める。ship-session の外で呼ばれた委譲先は無視する。
29 */
30export const onSkill = (p: ShipProgress | null, skill: string): ShipProgress | null => {
31  const name = skillName(skill)
32  if (name === 'ship-session') return initial()
33  if (p === null) return null
34  if (name === 'create-issue') return { ...p, phase: Math.max(p.phase, 1) as ShipProgress['phase'] }
35  if (name === 'issue-loop') return { ...p, phase: Math.max(p.phase, 2) as ShipProgress['phase'] }
36  if (name === 'code-review' && p.phase === 2) return { ...p, reviews: p.reviews + 1 }
37  return p
38}
39
40/** `/ship-session:ship-session 依頼` のようなスラッシュコマンドのスキル名。コマンドでなければ null。 */
41export const slashSkill = (text: string): string | null => /^\/([\w-]+(?::[\w-]+)?)(?:\s|$)/.exec(text.trim())?.[1] ?? null
42
43/** ship-goal.sh record の出力(`Recorded the goal: <goal>`)から到達点を取る。 */
44export const goalFromRecord = (text: string): string | null => {
45  const goal = /Recorded the goal: (.+)/.exec(text)?.[1]?.trim()
46  return goal ? goal : null
47}
48
49/**
50 * AskUserQuestion の回答から、到達点の質問への答えを取る。回答は質問文と header の
51 * どちらをキーにしても来るので、ship-gate.py と同じく両方を引く。
52 */
53export const goalFromAnswers = (
54  questions: readonly { question: string; header: string }[],
55  answers: unknown,
56): string | null => {
57  if (answers === null || typeof answers !== 'object') return null
58  const map = answers as Record<string, unknown>
59  for (const q of questions) {
60    if (!GOAL_HEADERS.includes(q.header)) continue
61    for (const key of [q.question, q.header]) {
62      const a = map[key]
63      if (typeof a === 'string' && a.trim() !== '') return a.trim()
64    }
65  }
66  return null
67}
68
69/** `gh issue create` / `gh pr create` の出力の URL から番号を取る。取れなければ null。 */
70export const numberFromUrl = (text: string, kind: 'issues' | 'pull'): number | null => {
71  const n = new RegExp(`https://github\\.com/[^\\s/]+/[^\\s/]+/${kind}/(\\d+)`).exec(text)?.[1]
72  return n === undefined ? null : Number(n)
73}
74
75export const isIssueCreate = (command: string): boolean => /\bgh\s+issue\s+create\b/.test(command)
76export const isPrCreate = (command: string): boolean => /\bgh\s+pr\s+create\b/.test(command)
77export const isGoalRecord = (command: string): boolean => /ship-goal\.sh["']?\s+record\b/.test(command)
78
79/** 文字列に日本語(かな・漢字)が入っているか。 */
80export const hasJapanese = (text: string): boolean => /[぀-ヿ一-鿿]/.test(text)
81
82/** settings の `language` から表示言語を決める。決まらなければ null。 */
83export const langFromSetting = (language: unknown): Lang | null => {
84  if (typeof language !== 'string' || language.trim() === '') return null
85  // `ja` / `ja-JP` / `日本語` / `Japanese` だけを日本語とみなす(`Javanese` は含めない)
86  return /^(ja([-_].*)?|日本語?|japanese)$/i.test(language.trim()) ? 'ja' : 'en'
87}
88
89export const LABELS = {
90  ja: {
91    title: 'ship-session',
92    goal: '到達点',
93    steps: ['到達点', 'Issue', '実装ループ', '次の一手'],
94    reviews: (n: number) => `レビュー ${n} 回`,
95    none: 'まだ',
96    gate: 'ゲート',
97    gateText: (tool: string) => `到達点が決まっていないので ${tool} を止めました`,
98    toast: (tool: string) => `ship-session: 到達点が決まっていないので ${tool} を止めました`,
99  },
100  en: {
101    title: 'ship-session',
102    goal: 'Goal',
103    steps: ['Goal', 'Issue', 'Implementation loop', 'Next step'],
104    reviews: (n: number) => (n === 1 ? '1 review' : `${n} reviews`),
105    none: 'not yet',
106    gate: 'Gate',
107    gateText: (tool: string) => `Stopped ${tool} because the goal is not decided yet`,
108    toast: (tool: string) => `ship-session: stopped ${tool} because the goal is not decided yet`,
109  },
110} as const
111
112/** 今いるステップの番号(0 到達点 / 1 Issue / 2 実装ループ / 3 次の一手)。 */
113export const currentStep = (p: ShipProgress): number => (p.goal === null ? 0 : Math.max(p.phase, 1))
114
types/index.d.ts 23 lines
1/** ship-session の進捗。ship-session を起動していないセッションでは持たない(null)。 */
2export type ShipProgress = {
3  /** 合意した到達点。ユーザーの言語のまま持つ。未確定なら null */
4  goal: string | null
5  /** 0 到達点 / 1 Issue 作成 / 2 実装ループ / 3 次の一手 */
6  phase: 0 | 1 | 2 | 3
7  issue: number | null
8  pr: number | null
9  /** 実装ループ中に code-review を呼んだ回数 */
10  reviews: number
11  /** ゲートが最後に止めたツール名。到達点が決まったら null に戻す */
12  gate: string | null
13}
14
15/** バンドの表示言語 */
16export type Lang = 'ja' | 'en'
17
18declare module 'claude-code' {
19  interface PluginState {
20    'ship-session': { progress: ShipProgress | null; lang: Lang | null }
21  }
22}
23