SLOPSHOPPER

context-handoff

In a /ship cycle, writes a handoff (ledger and Maestri note) when the Maestro context reaches its ceiling, then optionally runs /clear.

newbandguardtoastprocesstimer
A shopper browsing a rack in a slop shop
README

Claude Code user config

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.

Install on a new machine

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

Tracked

PathWhat
.claude/CLAUDE.mdGlobal instructions
.claude/settings.jsonPermissions, hooks, env, enabled plugins, status line
.claude/statusline.shStatus 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.pyStop 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.

Dependencies

Installed by install.sh:

ToolUsed bySource
codegraphMCP server codegraph, UserPromptSubmit hook codegraph prompt-hookhttps://github.com/colbymchenry/codegraph
ai-memoryMCP server ai-memory (http://127.0.0.1:49374), hooks on most lifecycle eventshttps://github.com/akitaonrails/ai-memory
ponytailPlugin ponytail@ponytailhttps://github.com/DietrichGebert/ponytail
paper-desktopPlugin paper-desktop@paperhttps://github.com/paper-design/agent-plugins
atlassian, typescript-lspPlugins from claude-plugins-officialhttps://github.com/anthropics/claude-plugins-official
maestriThe 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, mobbinHTTP MCP servers (stored in ~/.claude.json, not in this repo)https://mcp.context7.com/mcp, https://api.mobbin.com/mcp

Notes

  • 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.
Source 3 files
hooks/register.tsx 114 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Scan, Stage } from '../types'
5import { NONE, handoffPrompt, newest, parseScan, tone } from './logic'
6
7const stage = atom({ plugin: 'context-handoff', key: 'stage' } as const, 'idle' as Stage)
8const mark = atom({ plugin: 'context-handoff', key: 'mark' } as const, 0)
9// The band's value. null: no open ledger, no band.
10const shown = atom({ plugin: 'context-handoff', key: 'pct' } as const, null as number | null)
11
12const usage = async ($: any) => (await $.session.usage()).context.percent ?? 0
13
14// ~/.agents/ship/ledger.py is the one reader of ledgers. The Stop hooks and ship-guard use it too.
15// It answers the open ledgers of this agent's cycle, only for a Maestro in a Maestri terminal.
16async function scan($: any): Promise<Scan> {
17  try {
18    if (!(await $.env.get('MAESTRI_TERMINAL_ID'))) return NONE
19    const reader = `${await $.env.get('HOME')}/.agents/ship/ledger.py`
20    return parseScan(await $.process.run(['python3', reader, await $.session.cwd()]))
21  } catch {
22    return NONE
23  }
24}
25
26async function tick($: any, ceiling: number, autoClearOption: boolean) {
27  const found = await scan($)
28  const floor = found.floor !== null
29  const autoClear = autoClearOption && !floor
30  const ledgers = found.ledgers
31  if (!ledgers.length) return update($, shown, () => null)
32  const pct = await usage($)
33  await update($, shown, () => pct)
34
35  const s = await read($, stage)
36  if (s === 'idle' && pct >= ceiling) {
37    const before = newest(ledgers)
38    await update($, mark, () => before)
39    await update($, stage, () => 'sent')
40    // Outside this dispatch: the prompt waits for an idle session.
41    $.clock.after(800, () => {
42      // The call resolves as the handoff turn starts: the next turn.complete is that turn's.
43      $.prompt
44        .submit({ text: handoffPrompt(autoClear, ledgers, floor) })
45        .then(() => update($, stage, () => 'running'))
46        .catch(() => update($, stage, () => 'failed'))
47    })
48  } else if (s === 'running') {
49    // Safety check: no ledger change, no clear.
50    if (newest(ledgers) > (await read($, mark))) {
51      await update($, stage, () => 'done')
52      if (autoClear) {
53        $.ui.toast('Handoff saved. Clearing.')
54        $.clock.after(1500, () => {
55          $.command.run({ command: 'clear' }).catch(() => $.ui.toast('Could not run /clear. Run it yourself.'))
56        })
57      } else {
58        $.ui.toast(floor ? 'Handoff saved. The master restarts this Maestro.' : 'Handoff saved. Run /clear.', { timeoutMs: 15000 })
59      }
60    } else {
61      await update($, stage, () => 'failed')
62      $.ui.toast('Handoff did not update the ledger. No clear. Check it.', { timeoutMs: 15000 })
63    }
64  } else if ((s === 'done' || s === 'failed') && pct < ceiling - 5) {
65    await update($, stage, () => 'idle')
66  }
67}
68
69export const register: Register = (on, options) => {
70  const ceiling = Number(options.ceilingPercent ?? 35)
71  const autoClear = options.autoClear === true
72
73  on('turn.complete', async ($, e, next) => {
74    const out = await next(e)
75    await tick($, ceiling, autoClear)
76    return out
77  })
78
79  // Live value between turns. The ledger scan stays on turn.complete.
80  on('tool.call', async ($, e, next) => {
81    const out = await next(e)
82    try {
83      if ((await read($, shown)) !== null) {
84        const pct = await usage($)
85        await update($, shown, () => pct)
86      }
87    } catch {}
88    return out
89  })
90
91  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92    const pct = await read($, shown)
93    if (pct === null || e.props.hasSurvey) return next(e)
94    const { Text } = $.ui.resolve(e)
95    return <Text color={tone(pct, ceiling)}>● ctx {pct}% · handoff at {ceiling}%</Text>
96  })
97
98  // A reload fires this too. It drops the timers but keeps the state: a handoff in flight would stay stuck.
99  on('session.start', async ($, e, next) => {
100    const s = await read($, stage)
101    if (s === 'sent' || s === 'running') await update($, stage, () => 'idle')
102    $.clock.after(1500, () => {
103      tick($, ceiling, autoClear)
104    })
105    return next(e)
106  })
107
108  // The context is empty again, the cycle can hand off again later.
109  on('session.end', async ($, e, next) => {
110    if (e.reason === 'clear') await update($, stage, () => 'idle')
111    return next(e)
112  })
113}
114
hooks/logic.ts 40 lines
1import type { OpenLedger, Scan } from '../types'
2
3export const MARK = 'Context handoff.'
4
5export const NONE: Scan = { project: null, seat: false, floor: null, branch: '', ledgers: [], problems: [] }
6
7// ship-guard shows a failure of ledger.py. Here a failure means no handoff.
8export function parseScan(run: { exitCode: number; stdout: string }): Scan {
9  return run.exitCode === 0 ? JSON.parse(run.stdout) : NONE
10}
11
12// Green far from the ceiling, yellow from 60% of it, red from 85% of it.
13export function tone(pct: number, ceiling: number): 'success' | 'warning' | 'error' {
14  if (pct >= ceiling * 0.85) return 'error'
15  return pct >= ceiling * 0.6 ? 'warning' : 'success'
16}
17
18export const newest =(ledgers: OpenLedger[]) => Math.max(0, ...ledgers.map(l => l.mtimeMs))
19
20// A floor Maestro never clears itself: nobody prompts it afterwards. The master restarts it.
21function lastStep(autoClear: boolean, ledgers: OpenLedger[], floor: boolean): string {
22  if (!floor) return autoClear ? 'The session clears itself after this turn.' : 'Tell Lucas to run /clear.'
23  const master = ledgers.find(l => l.master)?.master
24  const to = master ? `"${master}"` : 'the master named in your standing orders'
25  const keys = ledgers.map(l => l.key).join(', ')
26  return `Tell the master in the background: \`maestri ask ${to} "Context handoff written for ${keys}. Restart me and send the handoff again."\` Do not run /clear.`
27}
28
29// The head of a routed ledger belongs to its floor Maestro: two writers on one head lose changes.
30const ROUTED = ' (a floor cycle that you route: append one log line, do not change its head)'
31
32export function handoffPrompt(autoClear: boolean, ledgers: OpenLedger[], floor = false): string {
33  const files = ledgers.map(l => `- ${l.path}${l.routed ? ROUTED : ''}`).join('\n')
34  return `${MARK} Your context reached its ceiling. Do these steps now and nothing else.
351. Update the head of each open ledger (Phase, Floor, Branch, PR, Ticket, Next, Open, Seats) and append one log line. Follow the ship skill state rules.
36${files}
372. Load the maestri skill. Run \`maestri list\` to read your own agent name and role. Create one Maestri note: \`maestri note create --name "Handoff from <agent name> (<agent role>)" "<content>"\`. The content has: ledger paths, Phase, Next, Open items, each seat and whether it is clean or dirty, the sibling files to read first, and decisions not yet in the ledger. Maximum 40 lines.
383. Stop. Start no new work. ${lastStep(autoClear, ledgers, floor)}`
39}
40
types/index.d.ts 12 lines
1export type Stage = 'idle' | 'sent' | 'running' | 'done' | 'failed'
2
3// What ~/.agents/ship/ledger.py prints: the open ledgers of the agent in a folder, and every problem.
4export type OpenLedger = { key: string; path: string; phase: string; next: string; master: string; routed: boolean; mtimeMs: number }
5export type Scan = { project: string | null; seat: boolean; floor: string | null; branch: string; ledgers: OpenLedger[]; problems: string[] }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'context-handoff': { stage: Stage; mark: number; pct: number | null }
10  }
11}
12