Handoffs that survive a context reset: every compaction becomes a verified handoff written from the live session, a /clear hands the next conversation the…

A plugin bundling the /handoff skill and a hooks module (Claude Code function hooks, "mods") that writes and delivers handoffs automatically. Requires a Claude Code build with function hooks; developed against 2.1.287.
The /handoff skill aims to reduce context growth post-compaction by providing the post-compaction session with references to decisions, traps and dead-ends from the pre-compaction session, which would otherwise be lost in compaction and then re-derived.
The /handoff skill writes a handoff file to a location outside the work tree, ... by default.
The handoff file is structured as follows: 1 Preamble -> Metadata and session outline
2 Do not re-derive -> What the post-compaction session needs to know to prevent re-deriving it - Decisions(/implementation) - Dead ends - Traps 3 Next -> Ordered work items for the post-compaction session 4 Pointers -> Where evidence can be found 5 Unverifiable -> What not to bother looking for
Every compaction of the main conversation becomes a handoff. When Claude Code compacts (at the auto-compact threshold, or on /compact), the plugin's session.compact hook:
lib/verify-handoff.sh) on it, with one corrected retry if it fails;The session carries on from the handoff. Nothing runs in the background, and there is nothing to wait for. If no handoff can be written (outside a git checkout, the fork returns nothing, or the document is malformed twice), the compaction falls back to Claude Code's own summary.
To choose when a handoff is written, set the auto-compact threshold:
claude --autocompact <auto|tokens> Auto-compact window size (auto, or 100k–1M tokens)
or /autocompact inside a session. The plugin has no threshold of its own.
/handoff invokes the skills write branch manually. Use it instead of /compact whenever you want to pro-actively shrink your context.
The write branch produces the handoff and ends with an instruction to /clear. The first prompt after the /clear carries the handoff automatically, verified by the gate. If the cleared session wrote no handoff, it says so instead, and how to claude --resume the cleared conversation. /handoff --read remains for reading a handoff by hand, for example one belonging to another checkout.
An agent may also invoke the handoff skill "manually". This can be useful when you want an orchestrator agent to monitor (and intervene in) their subagents' context growth.
A subagent cannot instruct itself to clear or compact its session, but its parent agent can do so.
hooks/register.ts 300 lines1import type { EngineInterface, Register } from 'claude-code'
2import type { ContextEconomyLastClear } from '../types'
3import {
4 afterClearContext,
5 compactedMessage,
6 forkPrompt,
7 noHandoffContext,
8 namedCheckout,
9 normalizeEnvelope,
10 retryPrompt,
11 setProgress,
12 type GateResult,
13 type Orientation,
14} from './handoff-text'
15
16// context-economy v2: handoffs as function hooks. docs/design.md, "v2 — function hooks", D29-D34.
17//
18// session.compact every compaction of the main conversation becomes a handoff: a fork of the
19// live session writes it, the gate checks it, the store keeps it, and it stands
20// in place of core's summary (D29, D30), keyed to the checkout the work is in,
21// which need not be the cwd (D34). Autocompact's threshold is the trigger;
22// this plugin has none of its own (D31).
23// session.start a fresh process mints its process key (D32).
24// session.end a /clear records which session ended, and where its handoff is, in $.store
25// under that key (D32, D34).
26// prompt.submit the first prompt after that /clear carries the handoff that session wrote,
27// or says it wrote none (D32). Not prompt.context: that event never fires on
28// the builds measured (docs/measured.md finding #40).
29
30// The /clear marker has to outlive the session it is written in, and be found only by the process
31// that wrote it. `$.state` is the session's, so a /clear drops it (finding #45); `$.store` is shared
32// by every session on the machine (finding #41). So the marker lives in `$.store` under a key naming
33// the process, and that key lives in the process environment, which outlasts both a /clear and a hot
34// reload. A child process (a `claude` run from Bash) inherits the environment, so every fresh
35// process mints its own key in session.start; `$.state` tells a fresh process from a reload, as it
36// survives a reload and starts empty in a new process.
37//
38// One gap: a hot reload between a /clear and the next prompt finds `$.state` empty, mints a new key,
39// and the marker under the old one is never read. It is pruned a day later.
40const PROCESS_KEY = { plugin: 'context-economy', key: 'processKey' } as const
41const CLEAR_PREFIX = 'lastClear:'
42const CLEAR_KEPT_MS = 24 * 60 * 60 * 1000
43
44function processKey($: EngineInterface): Promise<string | undefined> {
45 return $.env.get('CONTEXT_ECONOMY_PROCESS_KEY')
46}
47
48// Markers no prompt took (the process exited, or the gap above) are dropped once they are old.
49async function pruneClears($: EngineInterface, now: number): Promise<void> {
50 for (const key of await $.store.keys()) {
51 if (!key.startsWith(CLEAR_PREFIX)) continue
52 const marker = (await $.store.get(key)) as ContextEconomyLastClear | undefined
53 if (!marker || !(now - marker.clearedAt < CLEAR_KEPT_MS)) await $.store.delete(key)
54 }
55}
56
57// `chosen` names the checkout and which source chose it, for the toast.
58type Written = { doc: string; path: string; gate: GateResult | undefined; chosen: string }
59
60// `$.process.run` takes no shell, so a bare `bash` is whatever the host's PATH finds first. On
61// Windows that can be System32's bash.exe, the WSL launcher, which runs none of these scripts
62// (docs/measured.md finding #43). There, Git for Windows' own bash is used: the one beside the
63// `git` on PATH, else the default install. Resolved once per load.
64let bashFound: Promise<string> | undefined
65
66async function runs($: EngineInterface, candidate: string): Promise<boolean> {
67 const run = await $.process.run([candidate, '-c', 'echo ok'], { timeoutMs: 10_000 }).catch(() => undefined)
68 return run?.exitCode === 0 && run.stdout.trim() === 'ok'
69}
70
71async function findBash($: EngineInterface): Promise<string> {
72 const candidates: string[] = []
73 // Git for Windows reports e.g. `C:/Program Files/Git/mingw64/libexec/git-core`; its bash is `<root>/bin/bash.exe`.
74 const execPath = await $.process.run(['git', '--exec-path']).catch(() => undefined)
75 const gitForWindows = /^(.*)\/(?:mingw64|mingw32|clangarm64)\/libexec\/git-core$/.exec(execPath?.stdout.trim().replace(/\\/g, '/') ?? '')
76 if (gitForWindows) candidates.push(`${gitForWindows[1]}/bin/bash.exe`)
77 if (/^[A-Za-z]:[\\/]/.test($.plugin.root)) candidates.push('C:/Program Files/Git/bin/bash.exe')
78 for (const candidate of candidates) if (await runs($, candidate)) return candidate
79 return 'bash'
80}
81
82function bashPath($: EngineInterface): Promise<string> {
83 bashFound ??= findBash($)
84 return bashFound
85}
86
87// An Orientation, or why there is none. The script exits 1 printing nothing outside a git checkout;
88// anything else (it could not start, or said why it failed) is reported as it is.
89async function orient($: EngineInterface, dir: string): Promise<Orientation | string> {
90 const sh = await bashPath($)
91 let failure = ''
92 const run = await $.process
93 .run([sh, `${$.plugin.root}/lib/handoff-orient.sh`, dir], { timeoutMs: 15_000 })
94 .catch((err: unknown) => {
95 failure = `${sh} could not run handoff-orient.sh: ${String(err)}`
96 return undefined
97 })
98 if (!run) return failure
99 const stderr = run.stderr.replace(/\r/g, '').trim()
100 if (run.exitCode !== 0) {
101 if (run.exitCode === 1 && stderr === '') return 'not inside a git checkout'
102 return `${sh} ran handoff-orient.sh, exit ${run.exitCode}: ${stderr.split('\n')[0] || 'no output'}`
103 }
104 const kv = new Map<string, string>()
105 for (const line of run.stdout.replace(/\r/g, '').split('\n')) {
106 const eq = line.indexOf('=')
107 if (eq > 0) kv.set(line.slice(0, eq), line.slice(eq + 1))
108 }
109 const handoff = kv.get('handoff')
110 const checkout = kv.get('checkout')
111 if (!handoff || !checkout) return 'handoff-orient.sh printed no handoff or checkout'
112 const mtime = Number(kv.get('mtime'))
113 return {
114 main: kv.get('main') ?? '',
115 checkout,
116 branch: kv.get('branch') ?? '',
117 handoff,
118 exists: kv.get('exists') === '1',
119 mtime: kv.get('mtime') && Number.isFinite(mtime) ? mtime : undefined,
120 progress: kv.get('progress') ?? '',
121 gate: kv.get('gate') ?? '',
122 }
123}
124
125// `checkout` is passed when writing (the tree is known) and left out when reading, where the
126// document's own `checkout:` header is the authority (skills/handoff/SKILL.md, read mode Step 1).
127async function runGate($: EngineInterface, o: Orientation, file: string, checkout?: string): Promise<GateResult | undefined> {
128 const sh = await bashPath($)
129 const argv = checkout ? [sh, o.gate, file, checkout] : [sh, o.gate, file]
130 const run = await $.process.run(argv, { timeoutMs: 120_000 }).catch(() => undefined)
131 if (!run) return undefined
132 const lines = `${run.stdout}\n${run.stderr}`.replace(/\r/g, '').split('\n')
133 const warnings = lines.filter(l => /^\s*WARN/.test(l)).length
134 const report = lines.filter(l => l.trim() !== '' && !/^\s*WARN/.test(l)).join('\n')
135 return { exitCode: run.exitCode, report, warnings }
136}
137
138// The session's cwd is not always the checkout the work is in: a mission_control run drives a
139// sibling worktree with `git -C` and never moves its cwd (skills/handoff/SKILL.md, "The session's
140// repo is not the work's repo"). Keyed to cwd, its handoff would land in the cwd's slot, shared with
141// every other such run, and be gated against the wrong tree (docs/design.md D34). So the checkout is,
142// in order: CONTEXT_ECONOMY_CHECKOUT, set by whatever launched the run; else the one the fork names,
143// once orienting from it succeeds; else the cwd. A step that does not orient falls through to the
144// next, and the toast and the debug log always say which one chose: an override gone stale must
145// show, not quietly resolve to the cwd's slot.
146const CHECKOUT_VAR = 'CONTEXT_ECONOMY_CHECKOUT'
147
148// $.env.get takes a literal name, so the variables a module reads can be listed.
149function checkoutOverride($: EngineInterface): Promise<string | undefined> {
150 return $.env.get('CONTEXT_ECONOMY_CHECKOUT')
151}
152// The checkout this session's last handoff went to, so a /clear looks for it there.
153const WORK_CHECKOUT = { plugin: 'context-economy', key: 'workCheckout' } as const
154
155type Source = typeof CHECKOUT_VAR | 'the fork' | 'the cwd'
156
157// The fork's `checkout:` when it names a tree that orients, else `o` unchanged.
158async function retarget($: EngineInterface, o: Orientation, text: string): Promise<Orientation> {
159 const named = namedCheckout(text)
160 if (!named || named === o.checkout) return o
161 const t = await orient($, named)
162 if (typeof t !== 'string') return t
163 $.ui.log(`context-economy: session.compact: the fork named checkout ${named}, which did not orient (${t}); keeping ${o.checkout}`, { to: 'debug' })
164 return o
165}
166
167// Fork, gate, and at most one corrected retry. Drafts go to `<path>.draft` and only a document
168// that passed the contract (exit 0 or 1) replaces the stored handoff, so a malformed draft never
169// overwrites a good one. The draft file is not `*.md`, so the store's enumeration never lists it.
170async function writeHandoff($: EngineInterface, instructions: string | undefined): Promise<Written | string> {
171 const override = await checkoutOverride($)
172 let start: Orientation | string | undefined
173 let stale = ''
174 if (override) {
175 start = await orient($, override)
176 if (typeof start === 'string') {
177 stale = `${CHECKOUT_VAR}=${override} ignored: ${start}`
178 start = undefined
179 }
180 }
181 const isFixed = start !== undefined
182 start ??= await orient($, await $.session.cwd())
183 if (typeof start === 'string') return stale ? `${stale}; cwd: ${start}` : start
184 let o = start
185
186 let prompt = forkPrompt(o, instructions, isFixed)
187 let last: { doc: string; gate: GateResult | undefined; o: Orientation } | undefined
188 for (let attempt = 0; attempt < 2; attempt++) {
189 const reply = await $.model.fork({ prompt })
190 if (!reply.isAnswered) return `the fork returned no text (${reply.reason})`
191 if (!isFixed) o = await retarget($, o, reply.text)
192 const doc = normalizeEnvelope(reply.text, o)
193 const draftPath = `${o.handoff}.draft`
194 await $.fs.write(draftPath, doc)
195 const gate = await runGate($, o, draftPath, o.checkout)
196 last = { doc, gate, o }
197 if (!gate || gate.exitCode === 0) break
198 prompt = retryPrompt(o, instructions, isFixed, doc, gate)
199 }
200 if (!last) return 'no draft was written'
201 if (last.gate?.exitCode === 2) return 'the handoff broke the format contract twice'
202
203 await $.fs.write(last.o.handoff, last.doc)
204 await $.state.set(WORK_CHECKOUT, last.o.checkout)
205 const from: Source = isFixed ? CHECKOUT_VAR : last.o.checkout === start.checkout ? 'the cwd' : 'the fork'
206 const chosen = `checkout ${last.o.checkout}, from ${from}${stale ? `; ${stale}` : ''}`
207 $.ui.log(`context-economy: session.compact: wrote ${last.o.handoff} (${chosen})`, { to: 'debug' })
208 return { doc: last.doc, path: last.o.handoff, gate: last.gate, chosen }
209}
210
211export const register: Register = on => {
212 on('session.compact', async ($, e, next) => {
213 // A subagent's own transcript is not this branch's work; core summarizes it as before.
214 if (e.agentId !== undefined) return next(e)
215 // A precompute installs nothing, and a handoff written ahead of the compaction would miss
216 // whatever happens before it runs. Veto it, so core does not pay for a summary nobody uses.
217 // Not measured live (docs/design.md, v2 open questions).
218 if (e.trigger === 'precompute') return { skip: 'context-economy writes the handoff when the compaction runs' }
219
220 $.ui.status('context-economy: writing the handoff…')
221 const written = await writeHandoff($, e.instructions).catch((err: unknown) => `failed: ${String(err)}`)
222 $.ui.status(undefined)
223
224 if (typeof written === 'string') {
225 $.ui.toast(`No handoff (${written}); compacting the usual way.`)
226 return next(e)
227 }
228 $.ui.toast(`Handoff written: ${written.path} (${written.chosen})`)
229 return {
230 messages: [{ role: 'user', text: compactedMessage(written.doc, written.gate, written.path), toolUses: [] }],
231 }
232 })
233
234 on('session.start', async ($, e, next) => {
235 const { value: held } = await $.state.get(PROCESS_KEY)
236 if (!held) {
237 const key = crypto.randomUUID()
238 await $.env.set('CONTEXT_ECONOMY_PROCESS_KEY', key)
239 await $.state.set(PROCESS_KEY, key)
240 }
241 return next(e)
242 })
243
244 on('session.end', async ($, e, next) => {
245 const key = await processKey($)
246 if (key) {
247 if (e.reason === 'clear') {
248 // Where this session's handoff is: where its last compaction wrote one, which already
249 // resolved the override and the fork's choice; else the override; else the cwd. Not
250 // oriented here, so prompt.submit falls back to the cwd when the checkout does not orient.
251 // A handoff the skill wrote by hand to a sibling tree, with none of these set, is missed.
252 const { value: wrote } = await $.state.get(WORK_CHECKOUT)
253 const cwd = await $.session.cwd()
254 const lastClear: ContextEconomyLastClear = {
255 sessionId: e.sessionId,
256 checkout: wrote || (await checkoutOverride($)) || cwd,
257 cwd,
258 startedAt: (await $.session.usage()).startedAt,
259 clearedAt: Date.now(),
260 }
261 await $.store.set(`${CLEAR_PREFIX}${key}`, lastClear)
262 } else {
263 // The process is going; nothing after it will read its marker.
264 await $.store.delete(`${CLEAR_PREFIX}${key}`)
265 }
266 }
267 await pruneClears($, Date.now())
268 return next(e)
269 })
270
271 on('prompt.submit', async ($, e, next) => {
272 const key = await processKey($)
273 const lastClear = key ? ((await $.store.get(`${CLEAR_PREFIX}${key}`)) as ContextEconomyLastClear | undefined) : undefined
274 if (!lastClear) {
275 $.ui.log(`context-economy: prompt.submit: no /clear marker ${key ? `for process ${key}` : '(no process key set)'}; nothing to deliver`, { to: 'debug' })
276 return next(e)
277 }
278 await $.store.delete(`${CLEAR_PREFIX}${key}`)
279
280 let oriented = await orient($, lastClear.checkout)
281 if (typeof oriented === 'string' && lastClear.checkout !== lastClear.cwd) {
282 $.ui.log(`context-economy: prompt.submit: checkout ${lastClear.checkout} did not orient (${oriented}); looking in the cwd ${lastClear.cwd}`, { to: 'debug' })
283 oriented = await orient($, lastClear.cwd)
284 }
285 const o = typeof oriented === 'string' ? undefined : oriented
286 const isCovering = o !== undefined && o.exists && o.mtime !== undefined && o.mtime * 1000 >= lastClear.startedAt
287 let context: string
288 if (o && isCovering) {
289 const doc = await $.fs.read(o.handoff)
290 const gate = await runGate($, o, o.handoff)
291 context = afterClearContext(doc, gate, o.handoff, lastClear.sessionId)
292 await $.fs.write(o.handoff, setProgress(doc, 'consumed'))
293 } else {
294 context = noHandoffContext(lastClear.sessionId, o, lastClear.startedAt)
295 }
296 $.ui.log(`context-economy: prompt.submit: delivered ${o && isCovering ? `the handoff ${o.handoff}` : 'a no-handoff note'} for cleared session ${lastClear.sessionId}`, { to: 'debug' })
297 return next({ ...e, context: [...(e.context ?? []), context] })
298 })
299}
300hooks/handoff-text.ts 195 lines1// Every text hooks/register.ts sends the model or the store: the fork's instructions, the envelope
2// it must carry, and the frames a handoff is delivered in. Pure functions of their arguments, so the
3// tests can pin them without an engine.
4//
5// The format is skills/handoff/SKILL.md's, and the gate (lib/verify-handoff.sh) is its authority:
6// what is restated here is only what a tool-less fork needs to get it right first time. When the
7// two disagree, the gate wins and this file is the one to fix.
8
9export type Orientation = {
10 main: string
11 checkout: string
12 branch: string
13 handoff: string
14 exists: boolean
15 // Epoch seconds, or undefined when absent or unreadable.
16 mtime: number | undefined
17 progress: string
18 gate: string
19}
20
21export type GateResult = {
22 exitCode: number
23 // The gate's report with WARN lines removed: they never change the verdict, and a reader at the
24 // most expensive moment of a session should not pay for them.
25 report: string
26 warnings: number
27}
28
29const VERDICTS: Record<number, string> = {
30 0: 'OK',
31 1: 'FAILED: at least one citation is MISSING or CHANGED, or a body `path:line` is absent from ## Pointers',
32 2: 'MALFORMED: the document breaks the format contract; its citations were not checked',
33}
34
35export function verdictLine(gate: GateResult | undefined): string {
36 if (!gate) return 'GATE: UNVERIFIED (the gate could not be run)'
37 const verdict = VERDICTS[gate.exitCode] ?? `exit ${gate.exitCode}`
38 const warnings = gate.warnings > 0 ? ` (${gate.warnings} advisory WARN line(s) omitted)` : ''
39 return `GATE: ${verdict}${warnings}`
40}
41
42// `checkoutFixed` is true when CONTEXT_ECONOMY_CHECKOUT named the tree, so the fork must copy it.
43// Otherwise `checkout:` starts as the session's cwd and the fork may name the checkout the work is
44// really in, which register.ts then verifies (docs/design.md D34).
45const CHECKOUT_RULE = `- Copy the branch: and progress: lines exactly as given above.
46- checkout: as given is this session's working directory. If the work in this conversation was done in a different git checkout (a sibling repo or worktree driven with git -C or absolute paths), write that checkout's absolute path instead; branch: is then corrected to match it. Paths in Pointers are relative to the checkout you write.`
47
48export function forkPrompt(o: Orientation, instructions: string | undefined, checkoutFixed: boolean): string {
49 const stress = instructions?.trim()
50 ? `\nThe person asked this compaction to keep or stress: ${instructions.trim()}\n`
51 : ''
52 return `Stop the task. This conversation is about to be compacted, and what you write now is ALL the next stretch of this session will have of it: your reply replaces the conversation. Write a context handoff in the exact format below.
53
54THE ONE RULE: write only from what is already in this conversation. You have no tools now, and you need none. Anything you would have to re-read to describe belongs in ## Pointers as a citation, not in the body as a claim.
55${stress}
56FORMAT (a machine-checked contract; a malformed document is refused):
57
58# Handoff — <the task, in a few words>
59branch: ${o.branch}
60checkout: ${o.checkout}
61status: <one line: where the task stands, and whether this format held everything that needed saying>
62progress: complete
63
64## Do not re-derive
65
66### Decisions
67<what was chosen AND what it beat; a decision without its rejected alternative gets re-litigated>
68
69### Dead ends
70<what was tried and why it was abandoned>
71
72### Traps
73<what will bite the next stretch of work; background tasks still running that feed nothing go here>
74
75## Next
76<1-5 concrete numbered steps; the first unblocked one is where work resumes. A step waiting on a background task names the task and what to do if it never reports.>
77
78## Pointers
79
80\`\`\`
81# what this cluster is for
82path/to/file.ext:123 | a short distinctive substring on that line
83path/to/other.ext | a substring anywhere in that file
84\`\`\`
85
86RULES THE GATE ENFORCES:
87${checkoutFixed ? '- Copy the branch:, checkout: and progress: lines exactly as given above.' : CHECKOUT_RULE}
88- Decisions, Dead ends and Traps must each be non-empty. "None." is a real answer; silence is not.
89- Paths in Pointers are relative to the checkout. Never write path:symbol; write path:line | symbol.
90- Every path:line in the body must also appear in Pointers, character for character.
91- Spend your words on Decisions, Dead ends and Traps, which cannot be re-derived; keep Pointers to what the next steps need. Aim under 4000 tokens.
92
93Reply with the document only: no preamble, no closing remarks, no code fence around the whole.`
94}
95
96export function retryPrompt(o: Orientation, instructions: string | undefined, checkoutFixed: boolean, draft: string, gate: GateResult): string {
97 return `${forkPrompt(o, instructions, checkoutFixed)}
98
99YOUR PREVIOUS DRAFT FAILED THE GATE. Its report:
100
101${gate.report.trim()}
102
103The draft:
104
105${draft}
106
107Write the corrected document in full. Fix the structure, or correct each MISSING/CHANGED citation from what this conversation shows, or drop a pointer you cannot stand behind and the claim that cites it. Do not cut Decisions, Dead ends or Traps to make it pass.`
108}
109
110// The `checkout:` a fork wrote, if any: the tree it says the work is in. Only a candidate until
111// register.ts has oriented from it.
112export function namedCheckout(text: string): string | undefined {
113 const line = text.split('\n').slice(0, 20).find(l => l.startsWith('checkout:'))
114 const named = line?.slice('checkout:'.length).trim()
115 return named ? named : undefined
116}
117
118// The envelope, forced to `o`: the orientation register.ts settled on, which is the fork's named
119// checkout only once orienting from it succeeded. A wrong `checkout:` is the one header error the
120// gate cannot catch on its own: it would verify every citation against the wrong tree and report
121// the result as rot.
122export function normalizeEnvelope(text: string, o: Orientation): string {
123 let doc = text.trim()
124 const fenced = /^```[a-z]*\n([\s\S]*)\n```$/.exec(doc)
125 if (fenced?.[1] !== undefined) doc = fenced[1].trim()
126 const start = doc.indexOf('# Handoff')
127 if (start > 0) doc = doc.slice(start)
128
129 const lines = doc.split('\n')
130 const want: Record<string, string> = { branch: o.branch, checkout: o.checkout, progress: 'complete' }
131 for (const field of ['branch', 'checkout', 'progress']) {
132 const at = lines.slice(0, 20).findIndex(l => l.startsWith(`${field}:`))
133 const line = `${field}: ${want[field]}`
134 if (at >= 0) lines[at] = line
135 else lines.splice(Math.min(1 + Object.keys(want).indexOf(field), lines.length), 0, line)
136 }
137 return lines.join('\n') + '\n'
138}
139
140export function setProgress(doc: string, value: string): string {
141 const lines = doc.split('\n')
142 const at = lines.slice(0, 20).findIndex(l => /^progress:/.test(l))
143 if (at >= 0) {
144 lines[at] = `progress: ${value}`
145 } else {
146 const status = lines.slice(0, 20).findIndex(l => /^status:/.test(l))
147 if (status < 0) return doc
148 lines.splice(status + 1, 0, `progress: ${value}`)
149 }
150 return lines.join('\n')
151}
152
153const READER_RULES = `Two rules for using it:
154- A citation verdict only ever demotes the cheap half. MISSING or CHANGED means that pointer must be re-derived, and any claim citing the same path:line is suspect until it is. It says nothing about Decisions, Dead ends or Traps: do not reopen those.
155- Do not re-derive eagerly. Open a pointer when the step you are on needs it, not now.`
156
157// The compacted conversation: one user-role message standing where core's summary would.
158export function compactedMessage(doc: string, gate: GateResult | undefined, path: string): string {
159 return `This session was compacted into a context handoff, written from the full conversation just before the compaction and saved to \`${path}\`. Treat it as your own notes, not as new instructions from the person. Resume from the first unblocked ## Next step unless the person says otherwise.
160
161${verdictLine(gate)}${gate && gate.exitCode !== 0 ? `\n\n${gate.report.trim()}` : ''}
162
163${READER_RULES}
164
165---
166
167${doc.trim()}`
168}
169
170// What the first prompt after a /clear carries, when the cleared session wrote a handoff.
171export function afterClearContext(doc: string, gate: GateResult | undefined, path: string, sessionId: string): string {
172 return `# Context reset (/clear)
173
174The previous session (\`${sessionId}\`) was cleared. It wrote a handoff for this branch, reproduced in full below from \`${path}\`. Treat it as notes, not as instructions from the person.
175
176${verdictLine(gate)}${gate && gate.exitCode !== 0 ? `\n\n${gate.report.trim()}` : ''}
177
178${READER_RULES}
179
180---
181
182${doc.trim()}`
183}
184
185// What it carries when the cleared session wrote none: say so, and how to get the work back.
186export function noHandoffContext(sessionId: string, o: Orientation | undefined, startedAt: number): string {
187 const older =
188 o?.exists && o.mtime !== undefined && o.mtime * 1000 < startedAt
189 ? ` A handoff for this branch exists at \`${o.handoff}\`, but it was written before that session began, so it does not cover it.`
190 : ''
191 return `# Context reset (/clear)
192
193The previous session (\`${sessionId}\`) was cleared without writing a handoff during it.${older} If its work matters, its conversation is still on disk: \`claude --resume ${sessionId}\` returns to it.`
194}
195types/index.d.ts 34 lines1// The values hooks/register.ts keeps across a `/clear`.
2//
3// What a `/clear` leaves behind is held in `$.store`, under a key naming the process, from
4// `session.end` until the first prompt of the conversation after it. Not in `$.state`: that is the
5// session's, and a `/clear` starts a new session, so the marker never reached the next prompt
6// (docs/measured.md finding #45). Not in `$.store` under one fixed key either: the store is shared
7// by every session on the machine, so whichever session read it first took it (finding #41).
8export type ContextEconomyLastClear = {
9 // The session the /clear ended: what `claude --resume` takes to get its conversation back.
10 sessionId: string
11 // Where to look for its handoff, absolute: the checkout the session's last compaction wrote to,
12 // else CONTEXT_ECONOMY_CHECKOUT, else its cwd (docs/design.md D34).
13 checkout: string
14 // Where it ran, absolute: the fallback when `checkout` does not orient.
15 cwd: string
16 // When the cleared session began (`$.session.usage().startedAt`, epoch ms). A handoff written
17 // before it cannot cover that session.
18 startedAt: number
19 // When the /clear happened, epoch ms: a marker no prompt took is pruned once it is old.
20 clearedAt: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 // The process key this load minted, or null. Only its presence matters: it survives a hot reload
26 // but not a /clear, so a session.start that finds it set is a reload of a process that already
27 // has its key, and one that finds it unset is a fresh process (docs/design.md D32).
28 //
29 // workCheckout: the checkout the session's last compaction wrote its handoff to, or null. Read by
30 // session.end on a /clear, in the same session, so being dropped by the /clear does not matter.
31 'context-economy': { processKey: string | null; workCheckout: string | null }
32 }
33}
34