ship フローの状態機械をペインで可視化する。/ship-hud でトグル、進行中フローがあれば自動で開く。モデル向けに status ツールも提供

個人用のdotfiles設定リポジトリです。 パッケージ管理と Homebrew 管理は別の nix-config リポジトリで行い、このリポジトリは主にシェルやアプリ設定を管理します。
git clone https://github.com/yourusername/dotfiles.git ~/dotfiles
cd ~/dotfiles
cd ~/nix-config
darwin-rebuild switch --flake .#<nix-configで定義したホスト名>
./install.sh
既存の設定ファイルは~/backupディレクトリにバックアップされます。
install.sh: dotfilesのシンボリックリンク作成スクリプトMANUAL_APPS.md: 手動インストールが必要なアプリケーション一覧.config/: 各種アプリケーションの設定ファイル.zshrc: Zshシェルの設定.gitconfig: Git設定.tmux.conf: tmux設定.claude/: Claude Code の設定.codex/: Codex の設定hooks/index.tsx 280 lines1import type {
2 BoxProps,
3 ButtonProps,
4 ElementConstructor,
5 EngineInterface,
6 Register,
7 TextProps,
8} from "claude-code";
9
10type Phase = { name: string; state: string };
11type ShipStatus =
12 | { active: false }
13 | {
14 active: true;
15 branch: string;
16 goal: string;
17 phases: Phase[];
18 extras: Record<string, string>;
19 };
20
21function parseStatus(out: string): ShipStatus {
22 const lines = out.split("\n");
23 const head = (lines[0] ?? "").match(/^branch=(\S+) goal=(.*)$/);
24 if (!head) return { active: false };
25 const phases: Phase[] = [];
26 const extras: Record<string, string> = {};
27 for (const line of lines.slice(1)) {
28 const p = line.match(/^ {2}(\w+): (\S+)/);
29 if (p) {
30 phases.push({ name: p[1] ?? "", state: p[2] ?? "" });
31 continue;
32 }
33 const kv = line.match(/^(\w+)=(.*)$/);
34 if (kv) extras[kv[1] ?? ""] = kv[2] ?? "";
35 }
36 return {
37 active: true,
38 branch: head[1] ?? "",
39 goal: head[2] ?? "",
40 phases,
41 extras,
42 };
43}
44
45// モジュール環境はセッション中維持される。ポーリングとレンダの共有状態
46let status: ShipStatus = { active: false };
47let raw = "";
48let hudHidden = false;
49
50async function refresh($: EngineInterface): Promise<boolean> {
51 const home = await $.env.get("HOME");
52 if (!home) return false;
53 const r = await $.process.run([
54 "bash",
55 `${home}/.claude/skills/ship/scripts/ship.sh`,
56 "status",
57 ]);
58 const out = (r.stdout + r.stderr).trim();
59 const changed = out !== raw;
60 raw = out;
61 status = parseStatus(out);
62 return changed;
63}
64
65const isPending = (p: Phase) => p.state !== "done" && p.state !== "skip";
66
67let pollerStarted = false;
68
69async function poll($: EngineInterface): Promise<void> {
70 const changed = await refresh($);
71 if (changed) $.ui.invalidate("ui.render");
72}
73
74// リロード時はタイマー破棄 + session.start 非再発火のため、任意のフックから遅延起動できるようにする
75function ensurePoller($: EngineInterface): void {
76 if (pollerStarted) return;
77 pollerStarted = true;
78 $.clock.every(5000, () => poll($));
79}
80
81type Resolved = {
82 Box: ElementConstructor<BoxProps>;
83 Text: ElementConstructor<TextProps>;
84 Button: ElementConstructor<ButtonProps>;
85};
86
87// Pane は plugin 発だと fullscreen で「110 桁以上 = dock / 144 桁未満 = 不可視」になるため使わない (承認済み仕様: 常に AbovePrompt)
88function renderHud($: EngineInterface, ui: Resolved) {
89 const { Box, Text, Button } = ui;
90 const s = status;
91 if (!s.active) {
92 return <Text dimColor>ship フローなし (ship.sh init で開始)</Text>;
93 }
94 const shown = s.phases.filter((p) => p.state !== "skip");
95 const cur = shown.findIndex(isPending);
96 // report / quiz は承認コマンドをボタン化 (approve は HEAD SHA に紐づく)
97 const approvable = s.phases.find(isPending)?.name;
98 const approve =
99 approvable === "report" || approvable === "quiz"
100 ? async () => {
101 const home = await $.env.get("HOME");
102 if (!home) return;
103 await $.process.run([
104 "bash",
105 `${home}/.claude/skills/ship/scripts/ship.sh`,
106 approvable,
107 "approve",
108 ]);
109 await refresh($);
110 $.ui.invalidate("ui.render");
111 }
112 : undefined;
113 const abort = async () => {
114 const home = await $.env.get("HOME");
115 if (!home) return;
116 await $.process.run([
117 "bash",
118 `${home}/.claude/skills/ship/scripts/ship.sh`,
119 "abort",
120 ]);
121 await refresh($);
122 $.ui.invalidate("ui.render");
123 };
124 return (
125 <Box flexDirection="column">
126 <Text bold wrap="truncate">
127 {s.goal}
128 </Text>
129 {/* 1 行目の右端はエンジンの折りたたみ [-] が重なるため 2 行目に置く */}
130 <Box width="100%">
131 <Box flexGrow={1} flexShrink={1}>
132 <Text dimColor wrap="truncate">
133 {s.branch}
134 </Text>
135 </Box>
136 <Box flexShrink={0} marginLeft={1}>
137 <Button
138 key="ship-abort"
139 label="[解除]"
140 plain
141 dimColor
142 onPress={abort}
143 />
144 </Box>
145 </Box>
146 <Box flexWrap="wrap">
147 {shown.map((p, i) => {
148 const [icon, color] =
149 p.state === "done"
150 ? ["✓", "green"]
151 : i === cur
152 ? ["→", "cyan"]
153 : ["○", undefined];
154 return (
155 <Text key={p.name} color={color} bold={i === cur}>
156 {`${icon}${p.name} `}
157 </Text>
158 );
159 })}
160 </Box>
161 {s.extras.worktree_dirty === "yes" && (
162 <Text dimColor>未コミット変更あり</Text>
163 )}
164 {approve && (
165 <Button
166 key="ship-approve"
167 label={`${approvable} approve`}
168 onPress={approve}
169 />
170 )}
171 </Box>
172 );
173}
174
175export const register: Register = (on) => {
176 on("session.start", async ($, e, next) => {
177 await $.tool.register({
178 name: "status",
179 description:
180 "ship フロー (開発パイプライン状態機械) の現在状態を返す。ship.sh status と同等の出力。Bash で ship.sh status を叩く代わりに使える。",
181 });
182 await $.tool.register({
183 name: "init",
184 description:
185 "ship フローを開始する (ship.sh init)。goal に開発目標を渡す。",
186 inputSchema: {
187 type: "object",
188 properties: { goal: { type: "string" } },
189 required: ["goal"],
190 },
191 });
192 await $.tool.register({
193 name: "done",
194 description:
195 "ship フローのフェーズ完了を検証して次へ進める (ship.sh done <phase>)。postcondition NG なら理由が返る。",
196 inputSchema: {
197 type: "object",
198 properties: { phase: { type: "string" } },
199 required: ["phase"],
200 },
201 });
202 await $.tool.register({
203 name: "skip",
204 description:
205 "ship フローのフェーズを理由付きで skip する (ship.sh skip <phase> <理由>)。ユーザー指示がある場合のみ使う。",
206 inputSchema: {
207 type: "object",
208 properties: { phase: { type: "string" }, reason: { type: "string" } },
209 required: ["phase", "reason"],
210 },
211 });
212 await $.command.register({
213 name: "ship-hud",
214 description: "ship フローの HUD 表示を切り替える",
215 });
216 ensurePoller($);
217 await poll($);
218 return next(e);
219 });
220
221 on("prompt.submit", ($, e, next) => {
222 ensurePoller($);
223 return next(e);
224 });
225
226 on("command.run", { command: "ship-hud" }, async ($, _e, _next) => {
227 hudHidden = !hudHidden;
228 if (!hudHidden) await refresh($);
229 $.ui.invalidate("ui.render");
230 return {
231 text: hudHidden ? "ship HUD を閉じました" : "ship HUD を開きました",
232 };
233 });
234
235 // 自前登録ツールは生成済み型の union に無いため RegExp matcher で束ねる
236 on("tool.call", { tool: /^mcp__ship-hud__status$/ }, async ($, _e, _next) => {
237 await refresh($);
238 return { result: raw };
239 });
240
241 on(
242 "tool.call",
243 { tool: /^mcp__ship-hud__(init|done|skip)$/ },
244 async ($, e, _next) => {
245 const a = e as unknown as {
246 tool: string;
247 goal?: string;
248 phase?: string;
249 reason?: string;
250 };
251 const home = await $.env.get("HOME");
252 if (!home) return { deny: "HOME が取得できません" };
253 const ship = `${home}/.claude/skills/ship/scripts/ship.sh`;
254 const argv =
255 a.tool === "mcp__ship-hud__init"
256 ? [ship, "init", a.goal ?? ""]
257 : a.tool === "mcp__ship-hud__done"
258 ? [ship, "done", a.phase ?? ""]
259 : [ship, "skip", a.phase ?? "", a.reason ?? ""];
260 const r = await $.process.run(["bash", ...argv]);
261 await refresh($);
262 $.ui.invalidate("ui.render");
263 return { result: (r.stdout + r.stderr).trim() || `exit=${r.exitCode}` };
264 },
265 );
266
267 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
268 const active = status.active && status.phases.some(isPending);
269 if (hudHidden || !active) return next(e);
270 const ui = $.ui.resolve(e);
271 const rest = await next(e);
272 return (
273 <ui.Box flexDirection="column">
274 {rest}
275 {renderHud($, ui)}
276 </ui.Box>
277 );
278 });
279};
280