SLOPSHOPPER

compaction-guard

Saves the task, plan and recent asks to a handoff file before each compaction, puts it back after, and warns when compaction starts thrashing.

newcommandtoaststatus
v0.2.0MITupdated 2026-10-19effinrich/claude-mods/compaction-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · compaction-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /handoff ⎿ compaction-guard: Handoff written to /Users/dev/.claude/handoffs/app-af002c.md. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Guardrails, compaction handoffs, drift checks and default skills for Claude Code, Codex CLI, Gemini CLI, Cursor and Copilot CLI. None of them call the model on their own.

One-command install (every tool)

npx --allow-git=all github:effinrich/claude-mods

--allow-git=all is needed from npm 12 on, which refuses to fetch packages from git by default (EALLOWGIT); older npm ignores it.

It detects which of the five tools you have and wires each one up. Claude Code gets its plugins from this repo's marketplace. The others get command hooks that call a small Node runtime copied to ~/.claude-mods/runtime/<version>/. Before editing any config file you own, it writes a .claude-mods-backup-<time> copy next to it.

CommandDoes
npx --allow-git=all github:effinrich/claude-mods installinstall (the default command)
… updatereinstall with your last choices, after a new version lands
… uninstallremove only what it added (hook entries, /ship and /checks, copied skills, the marked instruction block)
… doctorwhat is installed where, a stale Node path in the hooks, tokens pinned into every turn, blocks per rule over the last 7 days, payload capture status, recent hook errors
… projectadd shared claude-mods files to the repo you are in; --remove takes them out (see below)

Options: --tools claude,codex,gemini,cursor,copilot (default: detected), --mods guardrails,compaction-guard,context-audit,default-skills (default: all), --skills "ponytail:ponytail full", --dry-run.

Needs Node 20 or newer. MIT licensed.

What each tool gets

CodexGemini CLICursorCopilot CLI
Block out-of-scope, lockfile and check-weakening edits✓✓◐ Write tool input is undocumented: blocked when a path is found◐ same for edit/create
Block made-up packages✓✓◐◐
Block destructive git and --no-verify✓✓✓✓
Commit gate on checks✓✓✓✓
Plain wording in commit, PR and issue text✓✓✓✓
End-of-turn check gate (2 retries)✓ Stop✓ AfterAgent✓ stop follow-up✓ agentStop
Long prompt saved as .claude/spec.md draft✓✓✓✓
Handoff saved before compaction✓✓✓✓
Handoff returned after compaction✓ SessionStart compact✓ next prompt◐ next session start◐ next session start
Drift check on global instruction files✓✓✓✓
/ship and /checksskills ($ship)/ship commandskillsskills
Default skills (ponytail, plain-writing) always on✓ AGENTS.md✓ GEMINI.md◐ skill copied; add the rule in Cursor Settings → Rules, or per repo with project✓ copilot-instructions.md

In a git repo the gates see every change to the working tree, including edits made through the shell, and skip the check run when nothing changed since the checks last passed. With no checks: in the spec they run the typecheck, lint and test scripts, with CI=true so test runners don't start in watch mode. At the end of a turn, a test script that is just vitest, vitest run or jest runs only the tests related to changed files (vitest run --changed, jest --onlyChanged); commits run the whole suite. With backgroundChecks on (auto does this for those changed-only runs; on for any checks, off never), the checks start in the background a moment after each edit; when the turn ends the gate uses that result if it was run on exactly the current files, waits for a run that is still going, or runs the checks itself if the files changed since. A repo with none of those gets a one-time notice instead of a silent pass. A reply that claims success ("tests pass", "all green", "fixed", "verified") is checked once per turn: if the checks fail, the agent is sent back to correct the reply. Codex and Gemini CLI pass the reply to their hooks; Cursor and Copilot don't, so they skip that part.

Not ported: context-meter (these tools have no custom status line command) and cli-passthrough (it is specific to Claude Code's own CLI). The context-percentage hold is also missing, because none of these tools tell hooks how full the context is, and so is the research-first hold, because their hooks don't see reads and searches. They get the matching instruction line instead.

First run in each tool

  • Codex: hooks don't run until you approve them in /hooks.
  • Gemini CLI: it lists the new hooks once for you to confirm.
  • Cursor: restart Cursor.
  • Copilot CLI: start a new session.

Then check it works: ask the agent to run git reset --hard. It should be refused with a guardrails: reason. If it isn't, run doctor and see ~/.claude-mods/hook.log.

The hooks call Node by the absolute path it had at install time, so GUI apps without your shell's PATH still find it. After a Node upgrade (nvm, fnm, Homebrew) that path can disappear; doctor warns about it, and update fixes it.

One profile for every tool

Skills and gate settings live in one file that every part reads: the Claude Code plugins, the other tools' hooks, install, project and crew agents.

  • ~/.claude-mods/profile.json is yours. A repo's .claude/claude-mods.json is the team's, gets committed, and overrides yours key by key.
{
  "skills": ["ponytail:ponytail full", "default-skills:plain-writing"],
  "roles": { "designer": "frontend-design", "backend": "api-style" },
  "guardrails": { "writingGate": true, "stopGate": true, "researchHoldLines": 150, "checkTimeoutMinutes": 10, "backgroundChecks": "auto" },
  "crew": { "cap": 8 },
  "tokenBudget": 2000
}
  • skills replaces the default-skills list (and --skills for the other tools). roles adds skills per crew specialist: crew claim lists them and the specialist loads them before editing, so the skills reach agents that never see the main session's prompt.
  • guardrails overrides the plugin settings; crew.cap is the default width when a plan sets none; tokenBudget is the limit doctor and /default-skills warn at.
  • Keys with the wrong type are ignored, so a typo can't switch a gate off.

Shared repo setup

Run this inside a repo (any folder in it; the files go at the git root):

npx --allow-git=all github:effinrich/claude-mods project [--mods ...] [--skills ...] [--dry-run] [--remove]
FileWhat claude-mods writes
.cursor/rules/claude-mods.mdcan always-applied Cursor rule (owned by claude-mods)
AGENTS.mda marked block at the end; the rest of the file is left as is
.claude/settings.jsonmerged in: the claude-mods marketplace under extraKnownMarketplaces and <mod>@claude-mods: true under enabledPlugins
.claude/claude-mods.jsonthe team profile with the chosen skills, if the repo has none yet (an existing one is read, not changed)

The rule and the block carry the guardrails lines and one line per default skill. Each skill line points at ~/.agents/skills/<skill>/SKILL.md and tells the agent to skip it when that file is missing, so teammates without the skill are not affected. --mods also accepts context-meter and cli-passthrough for the Claude Code settings. A plugin someone set to false in the settings stays off.

--remove takes out exactly what project added and deletes files it created. Before changing a file you already had, it saves a copy under ~/.claude-mods/backups/<repo>-<hash>/<time>/, outside your working tree. Re-running is safe.

Help pin the Cursor and Copilot edit fields

Cursor's preToolUse input for its Write tool and Copilot CLI's toolArgs for edit and create are not documented. The adapters try the likely field names and let an edit through when they find no path, so scope and lockfile blocks can miss there. (Cursor's afterFileEdit still marks the turn for the end-of-turn gate; Copilot has no such hook.) One real payload is enough to fix that:

  1. Turn on capture with CLAUDE_MODS_DEBUG=1 in the tool's environment, or add "debug": true to ~/.claude-mods/config.json (a reinstall keeps it).
  2. Ask the agent to create or edit a small file.
  3. Open ~/.claude-mods/payloads.log. Each line has the tool, event, top-level keys, argument keys and the payload with long strings cut at 200 characters.
  4. Send one preToolUse line for a Write (Cursor) or edit/create (Copilot) in a GitHub issue. Check it first: it holds part of the file content and your paths.

Turn capture off again afterwards. The log rolls over at 2 MB.

New users and teams

  1. Each person, once per machine: install Node 20 or newer, then run npx --allow-git=all github:effinrich/claude-mods. Hooks and skills live in each user's own config, so nobody can install them for someone else.
  2. Each shared repo, once: run npx --allow-git=all github:effinrich/claude-mods project and commit .cursor/rules/claude-mods.mdc, AGENTS.md and .claude/settings.json. Claude Code then offers the claude-mods plugins to everyone who opens the repo.
  3. Default skills: plain-writing ships with claude-mods, so everyone gets it. ponytail and feature-based-architecture are not part of this repo; each person's install copies them from their own Claude Code installs into ~/.agents/skills/. Anyone who lacks a skill just skips it; nothing breaks.
  4. First-run approvals, per person and tool:
  5. Claude Code: when you open the repo, accept the claude-mods marketplace and the plugins it lists.
  6. Codex: approve the hooks in /hooks; they do not run until you do.
  7. Gemini CLI: confirm the new hooks when it lists them.
  8. Cursor: restart Cursor.
  9. Copilot CLI: start a new session.

Then run doctor to check, and the git reset --hard test above.

Claude Code mods

These run inside Claude Code's plugin engine. Guardrails adds one short system prompt line (scope, Suggestions, no invented APIs, prefer established libraries and their existing APIs), which you can turn off.

guardrails

Enforcement by blocking, not by instructions in a markdown file.

Spec. Put the task in .claude/spec.md:

---
scope: [src/features/auth/**, tests/auth/**]   # where Claude may edit; empty means anywhere
forbid: [package.json, "**/*.snap"]            # never edit these
checks: [pnpm typecheck, pnpm test auth]       # must pass; empty means typecheck + lint + test scripts
---
Add OAuth login. Acceptance: ...

A prompt over 1500 characters with no spec yet is saved there as a draft for you to fill in.

What it blocks

  • Edits outside scope or inside forbid. The deny tells Claude to list the change under "Suggestions:" instead, so ideas still reach you.
  • Imports of packages that are neither installed nor declared in any package.json above the file (type-only imports, relatives, tsconfig path aliases and Node builtins are fine).
  • Edits that weaken checks: .skip / .only, @ts-ignore, eslint-disable, new any types, vi.mock/jest.mock of your own modules (relative paths). Only what the edit adds counts.
  • Edits that remove assertions from a test file (expect(, assert(, .should) or lower a coverage threshold in the test config.
  • Snapshot rewrites: vitest -u, jest --updateSnapshot, pnpm test -- -u, playwright --update-snapshots.
  • Hand edits to lockfiles.
  • git commit, git tag and gh pr/gh issue text with an em dash or a stock phrase (delve, seamless, leverage, robust, comprehensive, "it's worth noting", "I hope this helps"). Claude rewords it and runs the command again. The list is short so it rarely fires on real text.
  • --no-verify, git commit -n, git push --force (lease is fine), git reset --hard, git checkout -- ., git clean -f, rm -rf ~ or /. You can still run these yourself with !.
  • Finishing a turn that changed files while checks fail. In a git repo any change counts, shell edits included. Claude is sent back up to 2 times, then stops and reports.
  • Finishing with a reply that says tests pass, all green, fixed or verified while the checks fail. Claude is sent back once to correct it. Checks that never ran are run first.
  • git commit while checks fail.
  • At the end of a turn, everything the turn changed is checked against where it started, whatever made the change: a deleted test file, or a .skip, @ts-ignore, removed assertion or lowered threshold added through the shell, sends Claude back once to restore it or explain. A moved test isn't counted as deleted, and changes that were already there before the turn aren't blamed on it.
  • With no checks: in the spec, the checks are the typecheck, lint and test scripts (npm init's placeholder test script is ignored). A repo with none of them gets a one-time notice. The end-of-turn gate runs only changed tests for a bare vitest or jest script; commits and /ship run everything.

Every block, hold and send-back is logged to ~/.claude-mods/blocks.log (shared with the other tools). npx --allow-git=all github:effinrich/claude-mods doctor counts them per rule for the last 7 days, so you can see which rules earn their keep.

What it holds back once

  • A new source file of 150+ lines that imports a library the session hasn't searched, read under node_modules/, or fetched docs for. Claude is told to check the library's docs, repo examples and source for an existing API first; writing the file again goes through. React, Vue, Svelte, Next and similar frameworks don't count.
  • A long new prompt when context is over 75%: held once with a nudge to /clear (and /handoff-load); send it again to go ahead.

Commands

  • /ship runs checks and, only if they pass, has Claude commit, push and open a draft PR.
  • /checks runs the checks now.
  • /guard shows status; /guard off and /guard on switch it for the session.

Settings in /config: end-of-turn gate, system prompt line, spec drafts, writing check, research hold size (0 turns it off), context hold threshold, extra allowed imports, check timeout.

default-skills

Pins skills into the system prompt of every session, as if you had run them at the start, and keeps them on through compaction (no more re-running /ponytail:ponytail full after a compact). It reads each skill from your own installs (plugin cache, ~/.claude/skills, skills synced from your claude.ai account, or the project's .claude/skills), so no third-party skill text lives in this repo. A configured skill you don't have is skipped. The one exception is plain-writing, which this repo wrote and ships.

  • Default: ponytail:ponytail full, feature-based-architecture, default-skills:plain-writing. Change the list in /config (comma-separated, plugin:skill args).
  • plain-writing (about 400 tokens per turn) covers anything a person reads: lead with the answer, plain words, no em dashes or formula (not-X-but-Y, groups of three), length that fits, and no "done" or "tests pass" without having run them. Drop it from the list to turn it off.
  • /default-skills shows what is pinned, from where, and roughly how many tokens it adds per turn. Over ~2,000 tokens in total it warns at session start; doctor reports the same total for every tool.
  • Each pinned skill is sent with every turn (cached with the system prompt), and is cut at 16,000 characters.
  • Skills that run shell commands while they expand (! lines) are pinned as written, not run.

compaction-guard

Before each compaction it saves the original task, the latest plan, todos, recent prompts and touched files to ~/.claude/handoffs/<project>-<hash>.md. After the compaction it adds that handoff back into context. Three compactions within ten minutes raise a warning to fork.

  • /handoff saves the handoff now.
  • /handoff-load loads this project's last handoff into a fresh session.

context-audit

Five seconds after a session starts it measures the fixed context every turn carries and shows it in the status line (ctx base 46k). It toasts only when that number is over your limit and is new or has grown by more than 10%. It also compares global CLAUDE.md files, enabled plugins and user hooks with the last session, and marks added lines that fetch remote content.

  • /context-audit prints the breakdown.

context-meter

Status line: ctx 62% ⚠ · $3.41 · 2× compacted · 5h 72% · opus-5-5. Warns at 60%, suggests forking at 80% (both in /config). The rate limit appears once a window passes 50%.

cli-passthrough

Typing claude plugin install x@y, claude mcp add … or several of them joined by && at the prompt runs them directly and shows the output, with no model turn. Anything with other shell syntax goes to the model as usual.

crew

A lead agent plans a goal as a graph of tickets, then runs specialist agents in parallel, each in its own git worktree, and merges their work into one branch.

  • /crew:crew-plan <feature> <goal>: the lead reads the code, asks you the open questions (numbered, each with a recommendation), then writes .claude/crew/<feature>/plan.md and one file per ticket. Each ticket names a role (designer, frontend, backend, tester, docs), the files it owns, what blocks it and its checks. You approve the graph before anything runs.
  • /crew:crew-run <feature>: the lead creates the branch crew/<feature>, starts every unblocked ticket at once (8 by default; a run can ask for more after a cost check), and merges each finished ticket. A merge goes through only when the specialist touched no files outside its ticket, its checks pass, and the plan's full checks pass after the merge; otherwise the merge is undone and the specialist is sent back once. At the end the reviewer agent checks the whole branch against the tickets, and /ship opens the draft PR.
  • /crew [feature] shows every ticket's state.

How the agents stay out of each other's way:

  • Two tickets run at the same time only if their files can't overlap. Shared files (package.json, tsconfig*.json, .github/**, plus the plan's shared list) belong to the lead; specialists ask for changes under "Suggestions:".
  • Guardrails reads each worktree's ticket and blocks edits outside it, and crew merge checks the diff again, so a shell edit can't slip past.
  • Specialists run in Claude Code's own worktree isolation. crew sets worktree.baseRef to "head" in .claude/settings.local.json so their worktrees start from the run branch.

Things to know:

  • Each worktree is a full checkout and runs an install (setup: in the plan; none turns it off). pnpm keeps that fast.
  • Worktrees live under .claude/worktrees/. If your test or lint config scans every folder, exclude .claude/.
  • Parallel width is limited by how independent the tickets are, not only by the cap. Every agent costs a full context.
  • The same CLI works outside Claude Code: npx --allow-git=all github:effinrich/claude-mods crew status <feature>.

Install (Claude Code only)

At the prompt of a terminal session, one line per mod you want:

/plugin install guardrails --marketplace effinrich/claude-mods
/plugin install compaction-guard --marketplace effinrich/claude-mods
/plugin install context-audit --marketplace effinrich/claude-mods
/plugin install context-meter --marketplace effinrich/claude-mods
/plugin install cli-passthrough --marketplace effinrich/claude-mods
/plugin install default-skills --marketplace effinrich/claude-mods
/plugin install crew --marketplace effinrich/claude-mods

Answer y to add the marketplace, then pick the user scope. From a shell, use claude plugin install <mod> --marketplace effinrich/claude-mods. In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with effinrich/claude-mods, then Add plugin.

Skills that pair well, and ones to skip

These were reviewed for this repo on 2026-10-19. None are bundled.

  • Matt Pocock's skills (github.com/mattpocock/skills, MIT): use on demand. His flow is grill-me, then to-spec, to-tickets, then implement with tdd. It fits guardrails: the end-of-turn gate runs after his red-green cycle finishes inside a turn, and his ticket files live in .scratch/, not .claude/spec.md. Two things to know. His specs leave out file paths, so put the paths a ticket may touch in .claude/spec.md scope yourself. And give prefactor tickets their own scope, or the scope block will stop them.
  • Claude Code /goal: use on demand with a check as the goal ("pnpm test passes and …"). Guardrails still blocks bad edits and commits inside the loop. Avoid third-party goal loops with high continuation caps.
  • humanizer (blader/humanizer): good for a one-off rewrite of a long document. At about 7,000 tokens it is too big to pin; plain-writing covers the same ground in about 400.
  • superdesign: skipped. It sends your code to a hosted service and needs an account.
  • ultrawork (oh-my-claudecode, MIT): skipped. The main repo retired it in v5.0.0 in favour of /oh-my-claudecode:execute. Its persistent mode used a Stop hook that kept blocking (capped at 50 to 500 passes) even with no todos left. Next to the guardrails gate that would override the 2-retry limit and bury the "checks fail" message under "do not stop". The OpenCode version (oh-my-opencode) has a source-available license and reported runaway loops. Ideas worth borrowing by hand: plan parallel work in waves with a dependency list, route simple lookups to a cheaper model, and keep subagent reports short.

Notes

  • Where an organization manages Claude Code, hooks on compaction, the system prompt and Stop only run for plugins the organization lists in managed settings.
  • context-audit records your current setup on its first run and only reports changes after that, so review your global CLAUDE.md once yourself.
  • Run the tests with claude plugin test <plugin folder>.

Developing

  • Shared logic lives in core/. Each Claude plugin carries a generated copy, so after editing core/ run npm run build; it syncs the copies and compiles dist/.
  • dist/ is committed, because npx github: installs run no build step.
  • Tests: npm test covers the runner, adapters and installer; npm run check checks the core copies and types; claude plugin test <plugin> covers each Claude mod. CI runs the first two on Node 20 and 22 and fails when dist/ does not match a fresh build.
Source 2 files
hooks/register.ts 170 lines
1import type { EngineInterface, PluginOptions, Register, SessionMessage } from 'claude-code'
2import { buildHandoff, renderHandoff } from './handoff.ts'
3
4type Config = {
5  handoffPath: string | undefined
6  thrashCount: number
7  thrashWindowMs: number
8}
9
10const FRESH_HANDOFF_MS = 6 * 60 * 60_000
11const TRACKED_SESSIONS = 50
12
13const SUMMARY_INSTRUCTIONS =
14  'Keep the current plan, the open todos and the decisions made. A handoff with the original task, ' +
15  'recent asks and touched files is attached after this summary, so do not repeat those.'
16
17const positive = (value: unknown, fallback: number): number =>
18  typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
19
20const readConfig = (options: PluginOptions): Config => ({
21  handoffPath:
22    typeof options.handoffPath === 'string' && options.handoffPath.trim() !== '' ? options.handoffPath.trim() : undefined,
23  thrashCount: Math.max(2, Math.round(positive(options.thrashCount, 3))),
24  thrashWindowMs: positive(options.thrashWindowMinutes, 10) * 60_000,
25})
26
27const isAbsolute = (path: string): boolean => /^([\\/]|[A-Za-z]:[\\/])/.test(path)
28
29const joinPath = (root: string, path: string): string =>
30  isAbsolute(path) ? path : `${root.replace(/[\\/]+$/, '')}/${path}`
31
32const baseName = (path: string): string => path.replace(/[\\/]+$/, '').split(/[\\/]/).at(-1) || 'project'
33
34const shortHash = async (text: string): Promise<string> => {
35  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
36  return [...new Uint8Array(digest).slice(0, 3)].map(byte => byte.toString(16).padStart(2, '0')).join('')
37}
38
39const homeDir = async ($: EngineInterface): Promise<string | undefined> =>
40  (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
41
42// Default: one file per project under ~/.claude/handoffs, so nothing lands in the repo.
43const resolveHandoffPath = async ($: EngineInterface, config: Config, root: string): Promise<string> => {
44  if (config.handoffPath !== undefined) return joinPath(root, config.handoffPath)
45  const home = await homeDir($)
46  const fileName = `${baseName(root)}-${await shortHash(root)}.md`
47  return home === undefined ? joinPath(root, `.claude/${fileName}`) : joinPath(home, `.claude/handoffs/${fileName}`)
48}
49
50const clockTime = (ms: number): string =>
51  new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
52
53const numbers = (value: unknown): number[] =>
54  Array.isArray(value) ? value.filter((n): n is number => typeof n === 'number') : []
55
56const strings = (value: unknown): string[] =>
57  Array.isArray(value) ? value.filter((s): s is string => typeof s === 'string') : []
58
59// Keeps the store bounded: per-session keys of sessions older than the last TRACKED_SESSIONS go.
60const trackSession = async ($: EngineInterface, sessionId: string): Promise<void> => {
61  const seen = strings(await $.store.get('sessions'))
62  if (seen.includes(sessionId)) return
63  const kept = [...seen, sessionId]
64  const dropped = kept.splice(0, Math.max(0, kept.length - TRACKED_SESSIONS))
65  await Promise.all(dropped.flatMap(id => [$.store.delete(`task:${id}`), $.store.delete(`compactions:${id}`)]))
66  await $.store.set('sessions', kept)
67}
68
69const writeHandoff = async (
70  $: EngineInterface,
71  config: Config,
72  messages: readonly SessionMessage[],
73): Promise<{ text: string; path: string; sessionId: string; now: number }> => {
74  const [root, sessionId, now] = await Promise.all([$.session.root(), $.session.id(), $.clock.now()])
75  await trackSession($, sessionId)
76
77  const taskKey = `task:${sessionId}`
78  const stored = await $.store.get(taskKey)
79  const knownTask = typeof stored === 'string' ? stored : undefined
80  const handoff = buildHandoff(messages, knownTask)
81  if (knownTask === undefined && handoff.originalTask !== undefined) {
82    await $.store.set(taskKey, handoff.originalTask)
83  }
84
85  const text = renderHandoff(handoff, new Date(now), baseName(root))
86  const path = await resolveHandoffPath($, config, root)
87  await $.fs.write(path, text)
88  return { text, path, sessionId, now }
89}
90
91const recordCompaction = async (
92  $: EngineInterface,
93  config: Config,
94  sessionId: string,
95  now: number,
96): Promise<number[]> => {
97  const key = `compactions:${sessionId}`
98  const recent = [...numbers(await $.store.get(key)).filter(t => now - t < config.thrashWindowMs), now]
99  await $.store.set(key, recent)
100  return recent
101}
102
103const warnThrash = ($: EngineInterface, recent: readonly number[], now: number): void => {
104  const minutes = Math.max(1, Math.round((now - (recent[0] ?? now)) / 60_000))
105  const message = `${recent.length} compactions in ${minutes} min. Fork now: /clear, then /handoff-load`
106  $.ui.status('⚠ compaction thrash · /clear then /handoff-load')
107  $.ui.toast(message, { timeoutMs: 15_000 })
108  $.ui.log(`compaction-guard: ${message}. The fixed context is likely too large (see /context-audit).`)
109  $.ui.notify(message, { title: 'Compaction thrash' }).catch(() => undefined)
110}
111
112export const register: Register = (on, options) => {
113  const config = readConfig(options)
114
115  on('session.compact', async ($, e, next) => {
116    if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
117
118    const saved = await writeHandoff($, config, e.messages)
119    const instructions = e.instructions ? `${e.instructions}\n\n${SUMMARY_INSTRUCTIONS}` : SUMMARY_INSTRUCTIONS
120    const result = await next({ ...e, instructions })
121    if (result.skip !== undefined) return result
122
123    const recent = await recordCompaction($, config, saved.sessionId, saved.now)
124    if (recent.length >= config.thrashCount) warnThrash($, recent, saved.now)
125    else $.ui.status(`handoff saved ${clockTime(saved.now)}`)
126
127    const reattached: SessionMessage = {
128      role: 'user',
129      text: `<handoff source="compaction-guard" path="${saved.path}">\n${saved.text}</handoff>`,
130      toolUses: [],
131    }
132    return { ...result, messages: [...result.messages, reattached] }
133  }).catch(($, e, next) => next(e))
134
135  on('session.start', async ($, e, next) => {
136    const result = await next(e)
137    await $.command.register({
138      name: 'handoff',
139      description: 'Save the task, plan, recent asks and touched files to this project’s handoff now.',
140    })
141    await $.command.register({
142      name: 'handoff-load',
143      description: 'Load this project’s last handoff into the conversation.',
144    })
145    if (!e.isInteractive) return result
146
147    const path = await resolveHandoffPath($, config, await $.session.root())
148    const stat = await $.fs.stat(path).catch(() => undefined)
149    const now = await $.clock.now()
150    if (stat !== undefined && now - stat.mtimeMs < FRESH_HANDOFF_MS) {
151      $.ui.toast(`Handoff from ${clockTime(stat.mtimeMs)} is saved. Load it with /handoff-load`, { timeoutMs: 10_000 })
152    }
153    return result
154  })
155
156  on('command.run', { command: 'handoff' }, async $ => {
157    const messages = await $.session.messages()
158    if (!Array.isArray(messages)) return { text: 'compaction-guard: could not read this session.' }
159    const saved = await writeHandoff($, config, messages)
160    return { text: `Handoff written to ${saved.path}.` }
161  })
162
163  on('command.run', { command: 'handoff-load' }, async $ => {
164    const path = await resolveHandoffPath($, config, await $.session.root())
165    const text = await $.fs.read(path).catch(() => undefined)
166    if (typeof text !== 'string') return { text: `No handoff saved for this project yet (${path}).` }
167    return { text: `Loaded the handoff from ${path}.`, context: [text] }
168  })
169}
170
hooks/handoff.ts 105 lines
1// Generated from core/handoff.ts by scripts/sync-core.mjs. Edit core/handoff.ts, then run the script.
2// The part of Claude Code's SessionMessage the handoff reads, kept local so core has no engine dependency.
3export type SessionMessage = {
4  role: 'user' | 'assistant'
5  text: string
6  toolUses: readonly { tool: string; input: Readonly<Record<string, unknown>>; isError?: true }[]
7  toolResults?: readonly unknown[]
8}
9
10export type Handoff = {
11  originalTask: string | undefined
12  recentAsks: readonly string[]
13  plan: string | undefined
14  todos: readonly string[]
15  filesTouched: readonly string[]
16}
17
18const LIMITS = {
19  originalTask: 4000,
20  ask: 500,
21  asks: 8,
22  plan: 6000,
23  todos: 30,
24  files: 25,
25} as const
26
27const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
28
29// The summary a compaction leaves opens the next transcript as a user message.
30const SUMMARY_OPENING = /^This session is being continued from a previous conversation/
31
32const clip = (text: string, max: number): string =>
33  text.length <= max ? text : `${text.slice(0, max)}\n[... ${text.length - max} more characters]`
34
35const isTypedPrompt = (message: SessionMessage): boolean =>
36  message.role === 'user' &&
37  message.text.trim() !== '' &&
38  (message.toolResults ?? []).length === 0 &&
39  !SUMMARY_OPENING.test(message.text)
40
41const asString = (value: unknown): string | undefined =>
42  typeof value === 'string' && value.trim() !== '' ? value : undefined
43
44const todoLine = (todo: unknown): string | undefined => {
45  if (typeof todo !== 'object' || todo === null) return undefined
46  const { content, subject, status } = todo as Record<string, unknown>
47  const label = asString(content) ?? asString(subject)
48  if (label === undefined) return undefined
49  return `[${asString(status) ?? 'pending'}] ${label}`
50}
51
52export const firstTypedPrompt = (messages: readonly SessionMessage[]): string | undefined =>
53  messages.find(isTypedPrompt)?.text
54
55export const buildHandoff = (
56  messages: readonly SessionMessage[],
57  knownOriginalTask: string | undefined,
58): Handoff => {
59  const prompts = messages.filter(isTypedPrompt).map(m => m.text)
60  const toolUses = messages.flatMap(m => m.toolUses)
61
62  const plan = toolUses
63    .filter(use => use.tool === 'ExitPlanMode')
64    .map(use => asString(use.input.plan))
65    .filter((text): text is string => text !== undefined)
66    .at(-1)
67
68  const lastTodoWrite = toolUses.filter(use => use.tool === 'TodoWrite').at(-1)
69  const todos = Array.isArray(lastTodoWrite?.input.todos)
70    ? lastTodoWrite.input.todos.map(todoLine).filter((line): line is string => line !== undefined)
71    : []
72
73  const touched = toolUses
74    .filter(use => EDIT_TOOLS.has(use.tool) && use.isError !== true)
75    .map(use => asString(use.input.file_path) ?? asString(use.input.notebook_path))
76    .filter((path): path is string => path !== undefined)
77
78  return {
79    originalTask: knownOriginalTask ?? prompts[0],
80    recentAsks: prompts.slice(-LIMITS.asks).map(text => clip(text, LIMITS.ask)),
81    plan,
82    todos: todos.slice(0, LIMITS.todos),
83    filesTouched: [...new Set(touched.reverse())].slice(0, LIMITS.files),
84  }
85}
86
87export const renderHandoff = (handoff: Handoff, savedAt: Date, project: string): string => {
88  const sections: string[] = [
89    `# Session handoff: ${project}`,
90    `Saved ${savedAt.toISOString()} by compaction-guard. Load it in a fresh session with /handoff-load.`,
91  ]
92  const add = (title: string, body: string | undefined): void => {
93    if (body !== undefined && body.trim() !== '') sections.push(`## ${title}\n\n${body.trim()}`)
94  }
95  const list = (items: readonly string[]): string | undefined =>
96    items.length === 0 ? undefined : items.map(item => `- ${item.replace(/\n+/g, ' ')}`).join('\n')
97
98  add('Original task', handoff.originalTask && clip(handoff.originalTask, LIMITS.originalTask))
99  add('Plan', handoff.plan && clip(handoff.plan, LIMITS.plan))
100  add('Todos', list(handoff.todos))
101  add('Recent asks (oldest first)', list(handoff.recentAsks))
102  add('Files touched (newest first)', list(handoff.filesTouched))
103  return `${sections.join('\n\n')}\n`
104}
105