In an open /ship cycle, /ship-feedback <text> saves a problem report with the cycle and ledger context to ~/.agents/ship/feedback. With no text it toggles a…

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, Session, View } from '../types'
5import { NONE, age, count, entry, failed, logTail, parseScan, sessionsOf } from './logic'
6
7const PANE = 'ship-feedback'
8const view = atom({ plugin: 'ship-feedback', key: 'view' } as const, { scan: NONE, now: 0, file: '', saved: 0 } as View)
9
10// ~/.agents/ship/ledger.py is the one reader of ledgers. ship-guard and context-handoff use it too.
11async function scan($: any): Promise<Scan> {
12 try {
13 if (!(await $.env.get('MAESTRI_TERMINAL_ID'))) return NONE
14 const reader = `${await $.env.get('HOME')}/.agents/ship/ledger.py`
15 return parseScan(await $.process.run(['python3', reader, await $.session.cwd()]))
16 } catch (err) {
17 return failed((err as Error).message)
18 }
19}
20
21const readOr = ($: any, path: string, or: string): Promise<string> => $.fs.read(path).catch(() => or)
22
23// A file that does not parse is a session that just ended: skip it.
24async function liveSessions($: any, home: string): Promise<Session[]> {
25 const dir = `${home}/.claude/sessions`
26 const names: { name: string }[] = await $.fs.list(dir).catch(() => [])
27 const read = names.filter(n => n.name.endsWith('.json')).map(n => readOr($, `${dir}/${n.name}`, '').then(t => JSON.parse(t)).catch(() => null))
28 return (await Promise.all(read)).filter(Boolean)
29}
30
31async function refresh($: any) {
32 const found = await scan($)
33 const file = `${await $.env.get('HOME')}/.agents/ship/feedback/${found.project ?? 'unknown'}.md`
34 const next: View = { scan: found, now: await $.clock.now(), file, saved: count(await readOr($, file, '')) }
35 await update($, view, () => next)
36 return next
37}
38
39const isOpen = async ($: any) => (await $.ui.panes()).some((p: { id: string }) => p.id === PANE)
40
41export const register: Register = on => {
42 on('session.start', async ($, e, next) => {
43 await $.command.register({
44 name: 'ship-feedback',
45 description: 'Save a problem with the open /ship cycle: /ship-feedback <what went wrong>. No text: toggle the pane',
46 })
47 // The pane shows how long the ledger is idle: it must move while no turn runs.
48 $.clock.every(30000, () => {
49 isOpen($).then(open => open && refresh($))
50 })
51 return next(e)
52 })
53
54 on('command.run', { command: 'ship-feedback' }, async ($, e) => {
55 const v = await refresh($)
56 const s = v.scan
57 if (!s.ledgers.length) return { text: `${s.problems[0] ?? 'No open /ship cycle here'}. Nothing saved.` }
58
59 const text = e.args.trim()
60 if (!text) {
61 if (await isOpen($)) {
62 await $.ui.close({ id: PANE })
63 return { text: 'Pane closed.' }
64 }
65 await $.ui.open({ id: PANE, title: 'Ship feedback' })
66 return { text: 'Pane opened. Save a report with /ship-feedback <what went wrong>.' }
67 }
68
69 const tails: Record<string, string> = {}
70 for (const l of s.ledgers) tails[l.key] = logTail(await readOr($, l.path, ''))
71 const home = await $.env.get('HOME')
72 const cwd = await $.session.cwd()
73 const sessions = sessionsOf(await liveSessions($, home), cwd, s.floor)
74 const old = await readOr($, v.file, `# Ship feedback: ${s.project}\n\n`)
75 await $.fs.write(v.file, old + entry({ at: v.now, text, scan: s, cwd, session: await $.session.id(), home, sessions, tails }))
76 await update($, view, () => ({ ...v, saved: v.saved + 1 }))
77 return { text: `Saved to ${v.file} (${s.ledgers.map(l => l.key).join(', ')}, ${sessions.length} live sessions).` }
78 })
79
80 on('turn.complete', async ($, e, next) => {
81 const out = await next(e)
82 if (await isOpen($)) await refresh($)
83 return out
84 })
85
86 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
87 const { Box, Text } = $.ui.resolve(e)
88 const v = await read($, view)
89 const s = v.scan
90 return (
91 <Box flexDirection="column">
92 {s.problems.map(p => (
93 <Text color="red">{p}</Text>
94 ))}
95 {!s.ledgers.length && <Text dimColor>No open cycle.</Text>}
96 {s.ledgers.map(l => (
97 <Box flexDirection="column" marginBottom={1}>
98 <Text bold>{l.key}</Text>
99 <Text>Phase: {l.phase}</Text>
100 <Text>Next: {l.next}</Text>
101 <Text color={v.now - l.mtimeMs > 600000 ? 'yellow' : undefined}>Ledger idle: {age(v.now - l.mtimeMs)}</Text>
102 <Text dimColor>{l.path}</Text>
103 </Box>
104 ))}
105 <Text dimColor>{s.floor ? `floor ${s.floor}` : 'ground'} · {s.branch || 'no branch'}</Text>
106 <Text dimColor>{v.saved} report(s) in {v.file}</Text>
107 </Box>
108 )
109 })
110}
111hooks/logic.ts 61 lines1import type { Scan, Session } 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
13export const stamp = (ms: number) => `${new Date(ms).toISOString().slice(0, 19)}Z`
14
15export function age(ms: number) {
16 const m = Math.max(0, Math.floor(ms / 60000))
17 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
18}
19
20export const logTail = (ledger: string, lines = 8) => (ledger.split('\n## Log')[1] ?? '').trim().split('\n').slice(-lines).join('\n')
21
22export const count = (file: string) => file.split('\n## ').length - 1
23
24// The live Claude Code sessions of the cycle, from ~/.claude/sessions/<pid>.json.
25// A floor sees its own folder. The ground sees itself, its seats and every floor of the repo:
26// Maestri keeps floors beside the repo, in .maestri/floors/<repo folder>--<branch>.
27export function sessionsOf(all: Session[], cwd: string, floor: string | null) {
28 const mark = `/.maestri/floors/${floor ?? `${cwd.split('/').pop()}--`}`
29 return all.filter(s => s.cwd.includes(mark) || (!floor && (s.cwd === cwd || s.cwd.startsWith(`${cwd}/`))))
30}
31
32const transcript = (home: string, s: Session) => `${home}/.claude/projects/${s.cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${s.sessionId}.jsonl`
33
34// One entry of feedback/<project>.md. `tails` holds the last log lines of each ledger, by key.
35export function entry(o: { at: number; text: string; scan: Scan; cwd: string; session: string; home: string; sessions: Session[]; tails: Record<string, string> }) {
36 const s = o.scan
37 const where = s.floor ? `floor ${s.floor}` : 'ground'
38 return [
39 `## ${stamp(o.at)} · ${s.ledgers.map(l => l.key).join(', ')}`,
40 '',
41 o.text,
42 '',
43 `- Where: ${where}, branch ${s.branch || 'none'}, ${o.cwd}`,
44 `- Session: ${o.session}`,
45 ...s.ledgers.flatMap(l => [
46 `- Ledger ${l.key}: ${l.path}`,
47 ` - Phase: ${l.phase}`,
48 ` - Next: ${l.next}`,
49 ` - Last write: ${stamp(l.mtimeMs)} (${age(o.at - l.mtimeMs)} before this report)`,
50 ...(o.tails[l.key] ? [' - Last log lines:', ...o.tails[l.key].split('\n').map(line => ` ${line}`)] : []),
51 ]),
52 `- Live sessions: ${o.sessions.length}`,
53 ...o.sessions.flatMap(x => [
54 ` - ${x.sessionId}${x.sessionId === o.session ? ' (this one)' : ''} · ${x.status ?? 'unknown'} for ${age(o.at - (x.statusUpdatedAt ?? o.at))} · ${x.cwd}`,
55 ` ${transcript(o.home, x)}`,
56 ]),
57 '',
58 '',
59 ].join('\n')
60}
61types/index.d.ts 16 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
5// The fields this mod reads from ~/.claude/sessions/<pid>.json, the registry of live Claude Code sessions.
6export type Session = { sessionId: string; cwd: string; status?: string; statusUpdatedAt?: number }
7
8// What the pane draws: the last scan, when it ran, and the feedback file of the project.
9export type View = { scan: Scan; now: number; file: string; saved: number }
10
11declare module 'claude-code' {
12 interface PluginState {
13 'ship-feedback': { view: View }
14 }
15}
16