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

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.
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.
| Command | Does |
|---|---|
npx --allow-git=all github:effinrich/claude-mods install | install (the default command) |
… update | reinstall with your last choices, after a new version lands |
… uninstall | remove only what it added (hook entries, /ship and /checks, copied skills, the marked instruction block) |
… doctor | what 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 |
… project | add 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.
| Codex | Gemini CLI | Cursor | Copilot 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 /checks | skills ($ship) | /ship command | skills | skills |
| 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.
/hooks.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.
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.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]
| File | What claude-mods writes |
|---|---|
.cursor/rules/claude-mods.mdc | an always-applied Cursor rule (owned by claude-mods) |
AGENTS.md | a marked block at the end; the rest of the file is left as is |
.claude/settings.json | merged in: the claude-mods marketplace under extraKnownMarketplaces and <mod>@claude-mods: true under enabledPlugins |
.claude/claude-mods.json | the 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.
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:
CLAUDE_MODS_DEBUG=1 in the tool's environment, or add "debug": true to ~/.claude-mods/config.json (a reinstall keeps it).~/.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.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.
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.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.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./hooks; they do not run until you do.Then run doctor to check, and the git reset --hard test above.
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.
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
scope or inside forbid. The deny tells Claude to list the change under "Suggestions:" instead, so ideas still reach you.package.json above the file (type-only imports, relatives, tsconfig path aliases and Node builtins are fine)..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.expect(, assert(, .should) or lower a coverage threshold in the test config.vitest -u, jest --updateSnapshot, pnpm test -- -u, playwright --update-snapshots.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 !.git commit while checks fail..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.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
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./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.
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.
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.! lines) are pinned as written, not run.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.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.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%.
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.
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:
package.json, tsconfig*.json, .github/**, plus the plan's shared list) belong to the lead; specialists ask for changes under "Suggestions:".crew merge checks the diff again, so a shell edit can't slip past.worktree.baseRef to "head" in .claude/settings.local.json so their worktrees start from the run branch.Things to know:
setup: in the plan; none turns it off). pnpm keeps that fast..claude/worktrees/. If your test or lint config scans every folder, exclude .claude/.npx --allow-git=all github:effinrich/claude-mods crew status <feature>.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.
These were reviewed for this repo on 2026-10-19. None are bundled.
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./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.plain-writing covers the same ground in about 400./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.context-audit records your current setup on its first run and only reports changes after that, so review your global CLAUDE.md once yourself.claude plugin test <plugin folder>.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.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.hooks/register.ts 170 lines1import 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}
170hooks/handoff.ts 105 lines1// 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