Shows when the ship skill is not loaded during an open /ship cycle and forces the model to load it.

Portable Claude Code setup. The git root is $HOME. Only ~/.claude and ~/.agents are tracked, through a whitelist in ~/.gitignore.
This file is not loaded into Claude sessions. Only CLAUDE.md, rules/, skills/, agents/, output-styles/ and the hooks in settings.json are.
Using an agent: paste ~/PROMPT.md into it. By hand:
cd ~
git init -b main
git remote add origin <repo-url>
git fetch origin
git reset --hard origin/main # writes only the tracked files; existing untracked files stay
sh ~/.claude/install.sh
| Path | What |
|---|---|
.claude/CLAUDE.md | Global instructions |
.claude/settings.json | Permissions, hooks, env, enabled plugins, status line |
.claude/statusline.sh | Status line (needs git, jq) |
.claude/rules/ | Always-on rules |
.claude/skills/ | Skills. Most are symlinks into ~/.agents/skills |
.claude/agents/ | Custom subagents |
.claude/output-styles/ | Output styles |
.claude/hooks/ | Hook scripts referenced from settings.json. no-bash-file-edits.sh blocks file edits made through Bash so the agent uses Read, Edit and Write |
.agents/skills/ | Skill content shared by all agents (Claude Code, Codex, Cursor) |
.agents/ship/ledger-stop.py | Stop hook for the ship workflow |
.agents/ship/state/ | Only .gitkeep. Ship state is never tracked |
Everything else under $HOME is ignored. Sessions, projects, memories, history, caches, credentials and backups must stay untracked.
Installed by install.sh:
| Tool | Used by | Source |
|---|---|---|
| codegraph | MCP server codegraph, UserPromptSubmit hook codegraph prompt-hook | https://github.com/colbymchenry/codegraph |
| ai-memory | MCP server ai-memory (http://127.0.0.1:49374), hooks on most lifecycle events | https://github.com/akitaonrails/ai-memory |
| ponytail | Plugin ponytail@ponytail | https://github.com/DietrichGebert/ponytail |
| paper-desktop | Plugin paper-desktop@paper | https://github.com/paper-design/agent-plugins |
| atlassian, typescript-lsp | Plugins from claude-plugins-official | https://github.com/anthropics/claude-plugins-official |
| maestri | The maestri-* skills. install.sh exports MAESTRI_CLI and adds the CLI to PATH in your shell rc when /Applications/Maestri.app exists (macOS only) | Maestri desktop app |
| context7, mobbin | HTTP MCP servers (stored in ~/.claude.json, not in this repo) | https://mcp.context7.com/mcp, https://api.mobbin.com/mcp |
ai-memory install-hooks rewrites the ai-memory hook commands in settings.json with absolute paths for the current machine. Commit that diff after running it on a new machine..claude/skills/elixir is a link into a local project and is gitignored.~/.agents/.skill-lock.json records where each skill in ~/.agents/skills came from, for npx skills update.hooks/register.tsx 111 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Scan } from '../types'
5import { NONE, failed, idleLine, parseScan } from './logic'
6
7const isLoaded = atom({ plugin: 'ship-guard', key: 'isLoaded' } as const, false)
8const tried = atom({ plugin: 'ship-guard', key: 'tried' } as const, false)
9const last = atom({ plugin: 'ship-guard', key: 'scan' } as const, NONE as Scan)
10
11// ~/.agents/ship/ledger.py is the one reader of ledgers. The Stop hooks and context-handoff use it too.
12async function scan($: any): Promise<Scan> {
13 try {
14 if (!(await $.env.get('MAESTRI_TERMINAL_ID'))) return NONE
15 const reader = `${await $.env.get('HOME')}/.agents/ship/ledger.py`
16 return parseScan(await $.process.run(['python3', reader, await $.session.cwd()]))
17 } catch (err) {
18 return failed((err as Error).message)
19 }
20}
21
22async function refresh($: any) {
23 const found = await scan($)
24 await update($, last, () => found)
25 return found
26}
27
28// Runs /ship once per context. If the command fails, the prompt note still forces the load.
29async function autoLoad($: any) {
30 const found = await refresh($)
31 if (!found.ledgers.length || (await read($, isLoaded)) || (await read($, tried))) return
32 await update($, tried, () => true)
33 $.command
34 .run({ command: 'ship', args: 'resume the open cycle from its ledger. If `maestri list` shows a note named "Handoff from ...", read it too.' })
35 .catch(() => $.ui.toast('ship-guard: could not run /ship. Type /ship.'))
36}
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 $.clock.after(1500, () => {
41 autoLoad($)
42 })
43 return next(e)
44 })
45
46 // /clear fires no session.start: reset here, the context is empty again.
47 on('session.end', async ($, e, next) => {
48 if (e.reason === 'clear') {
49 await update($, isLoaded, () => false)
50 await update($, tried, () => false)
51 $.clock.after(1500, () => {
52 autoLoad($)
53 })
54 }
55 return next(e)
56 })
57
58 // Typed /ship or loaded through the Skill tool: same event.
59 on('skill.prompt', { skill: 'ship' }, async ($, e, next) => {
60 await update($, isLoaded, () => true)
61 return next(e)
62 })
63
64 // Force: until the skill is loaded, every prompt carries the order to load it.
65 on('prompt.submit', async ($, e, next) => {
66 if (await read($, isLoaded)) return next(e)
67 const found = (await refresh($)).ledgers
68 if (!found.length || /^\s*\/ship\b/.test(e.text)) return next(e)
69 const list = found.map(l => `- ${l.key} (Phase: ${l.phase}) Next: ${l.next}`).join('\n')
70 return next({
71 ...e,
72 context: [
73 ...(e.context ?? []),
74 `ship-guard: open /ship cycle ledger(s) exist for this repo and the ship skill is NOT loaded in this context.\n${list}\nBefore anything else, load the ship skill with the Skill tool and follow its resume rule. Then answer the user's message.`,
75 ],
76 })
77 })
78
79 // A turn can open, close or break a ledger: the band follows.
80 on('turn.complete', async ($, e, next) => {
81 const out = await next(e)
82 await refresh($)
83 return out
84 })
85
86 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87 const s = await read($, last)
88 if (e.props.hasSurvey || s.seat || (!s.project && !s.problems.length)) return next(e)
89 const { Box, Text } = $.ui.resolve(e)
90 const keys = s.ledgers.map(l => l.key).join(', ')
91 const loaded = await read($, isLoaded)
92 // The band has one tree: carry the plugins beneath (context-handoff's ctx line) inside it.
93 const below = await next(e)
94 return (
95 <Box flexDirection="column">
96 {s.problems.map(p => (
97 <Text color="red">ship: {p}</Text>
98 ))}
99 {!keys ? (
100 <Text dimColor>{idleLine(s)}</Text>
101 ) : loaded ? (
102 <Text dimColor>ship: loaded ({keys})</Text>
103 ) : (
104 <Text color="red">ship: NOT loaded, open cycle {keys}. Loading it now.</Text>
105 )}
106 {below}
107 </Box>
108 )
109 })
110}
111hooks/logic.ts 16 lines1import type { Scan } from '../types'
2
3export const NONE: Scan = { project: null, seat: false, floor: null, branch: '', ledgers: [], problems: [] }
4
5// A failure is a problem to show, never an empty answer: an empty answer looks like "no open cycle".
6export const failed = (why: string): Scan => ({ ...NONE, problems: [`ledger.py did not run: ${why}`] })
7
8export function parseScan(run: { exitCode: number; stdout: string; stderr: string }): Scan {
9 if (run.exitCode !== 0) return failed(run.stderr.trim().split('\n').pop() || `exit ${run.exitCode}`)
10 return JSON.parse(run.stdout)
11}
12
13// The line for a Maestro with no open ledger: it shows that the mod works and found none.
14export const idleLine = (s: Scan) =>
15 s.floor ? `ship: no open ledger names this floor (folder ${s.floor}, branch ${s.branch || 'none'})` : 'ship: no open cycle'
16types/index.d.ts 10 lines1// What ~/.agents/ship/ledger.py prints: the open ledgers of the agent in a folder, and every problem.
2export type OpenLedger = { key: string; path: string; phase: string; next: string; master: string; routed: boolean; mtimeMs: number }
3export type Scan = { project: string | null; seat: boolean; floor: string | null; branch: string; ledgers: OpenLedger[]; problems: string[] }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'ship-guard': { isLoaded: boolean; tried: boolean; scan: Scan }
8 }
9}
10