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…

English | 日本語
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."
claude plugin marketplace add insession-space/claude-ship
claude plugin install ship-session@claude-ship
Restart Claude Code and it is ready to use.
/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.
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.
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 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.)
hooks/ship-gate.py). Both ways of starting it are covered/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 countPreToolUse hook sets up the gateAskUserQuestion, 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)AskUserQuestion header is Goal / Approach (English) or 到達点 / 進め方 (Japanese), the hook records the answer automaticallyhooks/ship-goal.sh record "<goal>". If it forgets, the message on the next blocked tool call tells it toState 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.
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 │
╰──────────────────────────────────────────────────────────────╯
gh issue create / gh pr create print. Nothing is shown when the command fails or prints no URLcode-review ran during the implementation loopThe 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.
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.
language setting (~/.claude/settings.local.json → ~/.claude/settings.json)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.
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.
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.
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.
UserPromptSubmit hook runs, and if the session name is still auto-generated, it tells the agent to rename it in your display languagehooks/rename-session.sh exactly oncename in ~/.claude/jobs/<jobId>/state.json becomes a name in your display language, and it shows up in lists such as Session DeskWhen it does nothing (none of these are errors):
nameSource: "user", or a marker that it has already been renamed)state.json to write to)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"
| Skill | Role | Usable on its own |
|---|---|---|
ship-session | Agree on the goal → create the Issue → implementation loop → offer next actions | — |
create-issue | Dig into requirements and spec and create an Issue (creation only) | ✔ |
issue-loop | Iterate on implementing one ticket until the stop conditions are met | ✔ |
code-review | Review a diff | ✔ |
session-naming | Rename 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."
gh CLI is authenticated (used for Issue and PR operations)The following are used if available; everything works without them.
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)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 finds | Candidate verification commands |
|---|---|
package.json | Read scripts. The package manager is determined from the lockfile |
Package.swift | swift build / swift test |
go.mod | go build ./... / go test ./... |
Cargo.toml | cargo build / cargo test |
Gemfile | bundle exec rspec etc. |
Makefile | Actually look at the targets |
If it still cannot decide, it asks before entering the loop.
This plugin was built by working backward from patterns that actually failed.
error reports failures as successesMIT
hooks/ship-band.tsx 190 lines1// 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}
190hooks/ship-progress.ts 114 lines1// 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))
114types/index.d.ts 23 lines1/** 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