SLOPSHOPPER

Context Economy

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

newtoaststatuspromptmodelprocess
A shopper browsing a rack in a slop shop
README

Content economy

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.

/handoff

The /handoff skill writes a handoff file to a location outside the work tree, ... by default.

Handoff structure

The handoff file is structured as follows: 1 Preamble -> Metadata and session outline

  • Branch | Branch name
  • Checkout | Path the branch is checked out in
  • Status | Verification status
  • Progress | Lifecycle stage of the handoff: complete / consumed

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

Automatic trigger

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:

  1. forks the live session, which is served from its prompt cache, and asks the fork for a handoff in the format above, written only from what is already in context;
  2. runs the format gate (lib/verify-handoff.sh) on it, with one corrected retry if it fails;
  3. saves it to the store, and returns it as the compacted conversation in place of Claude Code's own summary.

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.

Manual trigger

/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.

Orchestrated trigger

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.

Source 3 files
hooks/register.ts 300 lines
1import 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}
300
hooks/handoff-text.ts 195 lines
1// 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}
195
types/index.d.ts 34 lines
1// 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