SLOPSHOPPER

handoff

Answers compaction with the no-model handoff digest plus a reasoning section written by a fork of the session

newspinnertoastmodelprocess
v0.1.0no licenseupdated 2026-10-11Anyesh/harness/configs/claude-code/mods/handoff
A shopper browsing a rack in a slop shop
README

harness

Single-command bootstrap for AI coding tools. One repo, one install, full environment reproducible on any linux machine in under 30 seconds.

harness install demo

Numbers

MetricCount
Config templates80
Managed files deployed52
Hooks (Claude Code)35
Hooks (Cursor events)9
Skills (shared)19
Plugins (Claude Code)10
MCP servers5
Modules9
Template variables5
No-op redeploy<2s

Install

curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash

First run clones the repo to ~/.harness, detects installed tools, and deploys everything. Subsequent runs are idempotent (checksum-based skip).

Lite install (no memory)

For a machine that should get the hooks, skills, commands and rules but neither second-brain nor the wiki (an interview laptop, say), pass --no-memory. Through the curl bootstrap the flag goes after bash -s and the double-hyphen separator:

curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash -s -- --no-memory

HARNESS_NO_MEMORY=1 does the same and also survives the bootstrap re-exec. A lite install skips the second-brain and wiki modules (no cargo build, daemon, sync timer or MCP registration), the memory hooks, the two memory rules, the wiki skill, the devlog, plan and decision commands and the WIKI_VAULT env entry. --only second-brain and --only wiki cannot be combined with it.

Lite mode is for machines that never had the full install. Switching from full to lite does not undo the earlier install: the second-brain systemd unit and sync timer, the claude mcp add entry in ~/.claude.json, and the MCP entries in the Codex, Cursor and opencode configs stay until removed by hand, because uninstall only restores files the manifest tracks. Switching from lite to full just works.

What gets deployed

Claude Code

~/.claude/settings.json        ← permissions, hooks config, enabledMcpjsonServers
~/.claude/.mcp.json            ← MCP server definitions (cognitive-cache, web-strip, markitdown, second-brain)
~/.claude/CLAUDE.md            ← global instructions (exploration-first, TDD, scope gates, no AI comments)
~/.claude/hooks/               ← 35 hook scripts (see below)
~/.claude/skills/              ← 19 skill definitions
~/.claude/plugins/             ← 10 plugins from 2 marketplaces (claude-plugins-official, harness-mods)
~/.claude/harness-mods/        ← local folder marketplace holding the harness's own mods
~/.local/bin/claude-profile    ← one Claude Code profile per account (below)
Account profiles

claude-profile asks for a name and creates ~/.claude-<name> plus a claude-<name> launcher that runs Claude with CLAUDE_CONFIG_DIR pointed at it, then offers claude auth login. Each profile signs in to its own account and keeps its own .claude.json, while settings, hooks, CLAUDE.md, skills, commands, plugins, mods and projects/ (transcripts and auto-memory) are symlinks into ~/.claude, so every profile runs the same harness and can resume the others' sessions. User-scope MCP servers are merged in from ~/.claude.json.

claude-profile list shows each profile's account, claude-profile remove <name> deletes one, and claude-profile sync relinks all of them; every install runs sync, so profiles pick up new entries and MCP servers. An install started from inside a profile session still targets ~/.claude. If Claude replaces a shared symlink with its own file (a settings write, say), sync reports it instead of overwriting it.

Cursor

~/.cursor/rules/*.mdc          ← 15 shared rules (alwaysApply)
~/.cursor/mcp.json             ← MCP servers (merged with existing; harness adds second-brain, cognitive-cache, web-strip, markitdown)
~/.cursor/hooks.json           ← 9 hook events (session, prompt, tool, shell, edit, stop, compact)
~/.cursor/hooks/               ← shared hook scripts (same family as Claude Code)
~/.cursor/skills/              ← shared skills (converge, humanize, ownit, wiki, terminal-gif)
~/.cursor/commands/            ← shared slash commands (plan, scope, commit, decision, devlog)

Codex

~/.codex/config.toml           ← model, approval mode
~/.codex/instructions.md       ← shared instructions
~/.agents/skills/              ← shared skills (Codex reads user skills here)

opencode

~/.config/opencode/AGENTS.md   ← shared global rules
~/.config/opencode/commands/   ← shared slash commands (plan, scope, commit, decision, devlog)
~/.agents/skills/              ← shared skills (opencode auto-scans this dir and ~/.claude/skills)

opencode.json(c) stays unmanaged (model/provider config is machine-specific); a reference template lives at configs/opencode/opencode.jsonc.tmpl.

Mods (Claude Code)

Mods are Claude Code plugins with an in-process hooks module (CLI 2.1.287+). The harness keeps them in configs/claude-code/mods/, a folder marketplace named harness-mods. install.sh copies it to ~/.claude/harness-mods/ (tests excluded), registers and installs each mod, and settings.json.tmpl carries the matching extraKnownMarketplaces and enabledPlugins entries so a redeploy never drops them. Claude Code reads an installed mod straight from that folder, so after a redeploy /reload-plugins picks up the change.

ModWhat it does
nudge-bandBand above the prompt showing scope (.scope.md missing or stale), wiki (devlog and index not written this session) and untracked junk, with buttons that pre-fill /scope, /devlog or a cleanup prompt. Claude gets one reminder when a condition first holds; the wiki reminder repeats after 3 more edits or 10 more turns without a wiki write. Active humanize and ownit modes show in the footer beside Claude Code's own modes.
cache-guardTracks when the main thread last got a response and whether each turn's first request found the prompt cache warm or cold, learning the cache lifetime from those hits and misses (60 minutes until it has seen one). The status line ends with cache 42m, or cache cold ≈ $3.04 in yellow once the next send would re-write the context. When an idle composer prompt would re-write at least gateUsd (default $1) of context, it asks whether to send anyway, compact first or keep the prompt as a draft, and a dismissed or unanswerable question sends. A cold turn that re-wrote 50k tokens or more raises a toast with its estimated cost at API prices. Subagent steps are tracked the same way against their own learnt TTL (agentTtlMinutes, default 5): the status line adds agents 3 (or agents 3 · 1 cold in yellow) while $.agent.list() shows live agents, a cold resumed agent raises its own toast, and the band above the prompt lists up to three agents that sit idle past the TTL with 50k tokens or more, or have run over an hour, each with a Dismiss button. Ended subagents leave the list within a minute, so their records age out after a day instead.
guard-watchWatches tool.check verdicts and counts every call a settings PreToolUse guard denied, naming the guard from its message ([hook:cost-guard], BLOCKED by pre-bash-guard, or the hook script's path). The status line shows 2 blocks, in yellow once a subagent was blocked, and a subagent's block raises a toast (cost-guard blocked Explore's Read: ...) because it never reaches the main transcript. It only observes: the verdict passes through unchanged.
context-audit/context-audit prints what /context does not: MCP tokens per server (loaded and deferred), skills grouped by plugin or source, memory files, agent definitions and slash commands, each sorted by tokens, then up to five trim candidates (an MCP server over 5k loaded tokens, a skill over 1k, a memory file over 3k) with their share of the window. It reads $.session.usage({ breakdown: 'summary' }) on demand and runs nothing otherwise.
handoffAnswers main-thread compaction (auto, /compact, and a plugin's such as cache-guard's "Compact first") with one handoff message instead of the built-in summary: handoff-digest.sh supplies the facts (files touched, repo state, last plan, newest turns) and a tool-less $.model.fork writes goal, next step, decisions, ruled-out paths, open questions and the verify command. Each handoff is saved as ~/.claude/handoffs/<slug>/<session>.handoff-<n>.md. A failed fork keeps the built-in summary with the digest appended; a failed digest asks the built-in summarizer to cover the reasoning sections. /compact classic [text] gets the built-in summary, and subagent compactions are left alone.

Develop a mod with claude --plugin-dir configs/claude-code/mods/<mod> (hot reload), and check it with claude plugin validate --strict <dir> and claude plugin test <dir>; tests/test_harness_mods.sh runs both for every mod. A mod must not register a Bash tool.call hook while anthropics/claude-code#92533 is open, because that breaks worktree-isolated subagents.

A mod cannot draw on the status line itself, so a mod with something to show there writes { "text", "tone", "at" } to ${XDG_STATE_HOME:-~/.local/state}/harness/status/<session id>/<mod>.json and removes it when the session ends. statusline.sh appends each segment in a fixed order, draws warn in yellow, and skips a file older than 10 minutes.

Hooks

HookTriggerPurpose
pre-edit-comment-guard.pyPreToolUse (Write/Edit)Blocks AI-generated narration comments
pre-edit-code-quality.shPreToolUse (Write/Edit)Rejects shortcut-seeking language
stop-sloppiness-guard.shPostResponseDetects lazy patterns, injects correction
cost-guard.shPreToolUse (Bash)Blocks unbounded output in main context
context-cold-start-guard.shbeforeSubmitPrompt / preCompact (Cursor)Blocks cold re-bill on large context after model change, compaction, or cache idle
pre-bash-guard.shPreToolUse (Bash)Validates commands before execution
format-on-save.shPostToolUse (Write/Edit)Auto-formats written files
post-compact-context.shSessionStart compact (Claude Code, Codex) / preCompact + postToolUse (Cursor)Re-injects the active .scope.md after compaction
compact-note.shCalled by post-compact-context.sh and the opencode harness-compact-scope.js pluginRenders .scope.md within the injection cap
session-start.shSessionStartActivates modes, checks environment
session-end-ingest.shSessionEndIngests session into second-brain (machine memory)
session-start-wiki.shSessionStartInjects project wiki context + devlog
worktree-sweep.shSessionStart (Claude Code, async, logs to ~/.local/state/harness/worktree-sweep.log)Removes idle worktrees whose commits landed on main, salvaging uncommitted files to .git/worktree-salvage/; Codex and Cursor have no async session start, so run it by hand
humanize-*.jsVariousHumanize mode activation, config
ownit-*.jsVariousOwnership mindset activation, config
attention-notify.shNotification, PreToolUse `AskUserQuestion\ExitPlanMode`, prompt, tool and session-end events (Claude Code); PermissionRequest, Stop and the same cancel events (Codex); stop, prompt, tool and session-end (Cursor)Pushes to ntfy when a session waits on you or finishes (see below)
attention-core.shCalled by attention-notify.sh and the opencode harness-attention.js pluginArms, cancels and sends the ntfy push

On Claude Code the scope, wiki and hygiene nudges (scope-drift-detector.sh, wiki-nudge.sh, cleanup-nudge.sh) are replaced by the nudge-band mod below; Cursor and Codex keep the shell scripts.

Guards match on the code a command runs, not on quoted text or heredoc bodies (shell-code.sh), and every block is appended to ~/.local/state/harness/hook-blocks.jsonl (log-block.sh; override with HARNESS_BLOCK_LOG). Two scripts keep false positives visible:

  • scripts/hook-blocks.sh [--days N] summarises recent blocks per hook.
  • scripts/guard-replay.sh [--transcripts N] [--hook NAME] replays real tool calls from Claude Code transcripts through the repo's current guards and lists what each would block. Run it before shipping a guard change and read every sample.

Phone push when a session needs you

Set NTFY_URL (your ntfy server) and NTFY_TOPIC in the environment or ~/.harness.env, plus NTFY_TOKEN if the topic needs a bearer token. Nothing is sent until both the URL and topic are set, so there is no fallback to the public ntfy.sh. A session that blocks on you (a question, plan approval or permission prompt) pushes at priority 4 after NTFY_BLOCK_DELAY seconds (default 15), and a finished turn pushes at priority 3 after NTFY_DONE_DELAY (default 60; Claude Code's own idle notice already waits a minute, so it adds nothing there). Answering, pressing Esc or typing a prompt inside that window cancels the push, and a session that has exited stays quiet.

Headless claude -p never pushes; set HARNESS_NOTIFY=0 for codex exec, opencode run or other scripted runs. Cursor only gets finished turns because it has no hook for approvals or agent questions. Codex asks you to trust new hook entries through /hooks after a redeploy. Every arm, cancel, send and skip is logged with its reason to ~/.local/state/harness/attention/log (last 200 lines).

MCP Servers

ServerTransportPurpose
second-brainstdioPersonal knowledge graph (KùzuDB, BGE embeddings, 384-dim)
cognitive-cachestdioOptimal file selection for LLM context
web-stripstdioClean markdown extraction from URLs (Readability)
markitdownstdioPDF/DOCX/XLSX → markdown
verdantstdioCross-session tool call caching (per-project)

Skills

CategorySkills
SEOseo, seo-audit, seo-page, seo-plan, seo-technical, seo-schema, seo-sitemap, seo-content, seo-images, seo-hreflang, seo-geo, seo-competitor-pages, seo-programmatic
Knowledgewiki (Karpathy-pattern LLM wiki)
Workflowterminal-gif, humanize, ownit

Modules

claude        Claude Code: configs, plugins, hooks, skills, MCP
cursor        Cursor: rules, mcp.json, hooks.json, skills, commands
codex         Codex: config.toml, instructions
opencode      opencode: AGENTS.md, commands, skills via ~/.agents/skills
second-brain  second-brain daemon + MCP server registration
wiki          Obsidian wiki vault init (Karpathy pattern, per-project)
watch         File watcher for auto-ingest
leakguard     gitleaks + global pre-commit scan for personal info/secrets;
              pairs with the publish-leak-guard Claude hook that scans built
              artifacts (sdists/wheels) before uv publish/twine upload.
              Escape hatches: '#gitleaks:allow' inline, LEAKGUARD_SKIP=1.
              Caveat: repos that set their own core.hooksPath (like this
              one) bypass the global scan; they must scan themselves.
lib           Shared functions (logging, template rendering, manifest)

Wiki (Knowledge Base)

The harness deploys an LLM-maintained wiki based on Andrej Karpathy's LLM Wiki pattern. The LLM builds and maintains structured Obsidian pages as you work; you never write the wiki yourself.

Setup: Set WIKI_VAULT in ~/.harness.env to your Obsidian vault path. The wiki module creates the structure on install.

How it works:

  • Every session, the LLM sees wiki context (project devlog, index) via session-start hook
  • CLAUDE.md instructs the LLM to proactively write wiki pages when plans, decisions, spikes, or concepts emerge
  • Pages are organized per-project: wiki/projects/<slug>/ with devlog, decisions/, plans/, spikes/
  • Pre-compact hook reminds the LLM to capture anything valuable before context compression
  • /wiki skill provides full operations: ingest external sources, query, lint

What gets captured: plans, PRDs, architecture decisions (ADRs), exploration spikes, concepts, devlogs. Not captured: routine fixes, debugging transcripts, AI bloat.

Vault structure:

$WIKI_VAULT/
├── wiki/
│   ├── projects/<slug>/    ← per-project knowledge
│   ├── concepts/           ← cross-project concepts
│   ├── synthesis/          ← filed query answers
│   ├── sources/            ← external source summaries
│   ├── index.md            ← master catalog
│   └── log.md             ← activity timeline
├── raw/                    ← immutable source copies
└── WIKI_SCHEMA.md          ← conventions

Template Variables

Set in ~/.harness.env:

USER_EMAIL=you@example.com
GITHUB_USER=yourhandle
DATA_ROOT=/path/to/data
MCP_COGNITIVE_CACHE_PATH=cognitive-cache-mcp
HOME_DIR=/home/youruser

Commands

./install.sh                    # Full deploy (default)
./install.sh --only claude      # Single module
./install.sh --force            # Redeploy even if unchanged
./install.sh --dry-run          # Preview without changes
./install.sh --no-memory        # Skip second-brain and the wiki
./install.sh --override-settings  # Replace ~/.claude/settings.json instead of merging into it
./install.sh status             # Show managed file state
./install.sh uninstall          # Restore all backups

Architecture

~/.harness.env                  ← Personal variables (not committed)
~/.harness-manifest.json        ← Deployed file checksums (auto-generated)
harness/
├── configs/
│   ├── claude-code/            ← Templates: settings, CLAUDE.md, .mcp.json, hooks, skills
│   ├── cursor/                 ← Templates: rules, mcp.json
│   ├── codex/                  ← Templates: config.toml, instructions
│   └── opencode/               ← Templates: AGENTS.md, opencode.jsonc (reference)
├── modules/                    ← Deploy logic per tool (claude.sh, cursor.sh, etc.)
├── lib/                        ← Shared: template rendering, manifest, backup, detect
├── tools/                      ← Bundled tools (web-strip)
└── install.sh                  ← Entrypoint

Deploy Flow

  1. install.sh sources all libs and modules
  2. Detects installed tools via PATH and config dirs
  3. Reads ~/.harness.env for template variables
  4. Per module: renders templates → checksums → skips if unchanged → backs up → deploys
  5. Removes orphans, deployed files whose configs/ source was renamed or deleted: an unchanged copy the harness created is deleted along with any folder it leaves empty, one that replaced a file you had is swapped back to your original, and an edited copy or one with no pre-existence record stays in place for status to flag
  6. Writes ~/.harness-manifest.json with file hashes
  7. Idempotent: re-run anytime, only changed files get touched

New Machine Setup

# 1. Install
curl -fsSL https://raw.githubusercontent.com/anyesh/harness/main/install.sh | bash

# 2. Configure
cat > ~/.harness.env << 'VARS'
USER_EMAIL=you@example.com
GITHUB_USER=yourhandle
DATA_ROOT=/path/to/data
HOME_DIR=$HOME
VARS

# 3. Deploy
~/.harness/install.sh

# 4. Verify
claude mcp list    # Should show 5 servers connected

Requirements

  • bash 4+
  • git
  • jq
  • perl (template rendering)
  • claude CLI (for plugin/MCP registration; configs deploy without it)
  • Node.js (for web-strip)
  • Python 3.9+ with uv/uvx (for cognitive-cache, markitdown)
Source 3 files
hooks/register.ts 94 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  classicRequest,
6  digestBlock,
7  forkPrompt,
8  handoffMessage,
9  nextHandoffPath,
10  reasoningOutline,
11  withDigest,
12} from './rules'
13
14type Digest = { path: string; text: string }
15
16// Workaround for 2.1.296: a hook-answered auto compaction ends core's compact
17// progress before it runs the SessionStart hooks, so their spinner label is never
18// cleared and stays up for the rest of the turn. The next main-thread step proves
19// the hooks are done, so from then on the label is stale and gets dropped.
20const SESSION_START_LABEL = 'Running SessionStart hooks…'
21const spinnerFix = atom({ plugin: 'handoff', key: 'spinnerFix' } as const, 'idle')
22
23async function buildDigest($: EngineInterface): Promise<Digest> {
24  const [home, id, cwd] = await Promise.all([$.env.get('HOME'), $.session.id(), $.session.cwd()])
25  if (!home) throw new Error('HOME is not set')
26  const run = await $.process.run(['bash', `${home}/.claude/scripts/handoff-digest.sh`, id], { cwd, timeoutMs: 30_000 })
27  const path = run.stdout.trim()
28  if (run.exitCode !== 0 || path === '') throw new Error(`handoff-digest.sh exited ${run.exitCode}: ${run.stderr.trim()}`)
29  return { path, text: await $.fs.read(path) }
30}
31
32async function writeReasoning($: EngineInterface, instructions: string | undefined): Promise<string> {
33  const reply = await $.model.fork({ prompt: forkPrompt(instructions) })
34  if (!reply.isAnswered) throw new Error(`fork gave no reply: ${reply.reason}`)
35  if (reply.text.trim() === '') throw new Error('fork reply was blank')
36  return reply.text
37}
38
39async function save($: EngineInterface, digestPath: string, text: string): Promise<string> {
40  const dir = digestPath.slice(0, digestPath.lastIndexOf('/'))
41  const names = (await $.fs.list(dir)).map(entry => entry.name)
42  const path = nextHandoffPath(digestPath, names)
43  await $.fs.write(path, text)
44  return path
45}
46
47export const register: Register = on => {
48  on('session.compact', async ($, e, next) => {
49    if (e.agentId) return next(e)
50    if (e.trigger === 'precompute') return { skip: 'handoff answers at compaction time' }
51    const classic = classicRequest(e.trigger, e.instructions)
52    if (classic) return next({ ...e, instructions: classic.instructions })
53    if (e.trigger === 'auto') await update($, spinnerFix, () => 'compacted')
54
55    const [digest, reasoning] = await Promise.allSettled([buildDigest($), writeReasoning($, e.instructions)])
56
57    if (digest.status === 'rejected') {
58      $.ui.log(`handoff: digest failed (${String(digest.reason)}), core summary covers the reasoning outline`, { to: 'debug' })
59      return next({ ...e, instructions: reasoningOutline(e.instructions) })
60    }
61    const block = digestBlock(digest.value.text)
62
63    if (reasoning.status === 'rejected') {
64      $.ui.log(`handoff: ${String(reasoning.reason)}, keeping core summary plus the digest`, { to: 'debug' })
65      const core = await next(e)
66      if (core.skip !== undefined) return core
67      return { ...core, messages: withDigest(core.messages, block, digest.value.path) }
68    }
69
70    const text = handoffMessage({ reasoning: reasoning.value, block, digestPath: digest.value.path, trigger: e.trigger })
71    try {
72      $.ui.toast(`handoff: ${await save($, digest.value.path, text)}`)
73    } catch (err) {
74      $.ui.log(`handoff: could not save the handoff file (${String(err)})`, { to: 'debug' })
75    }
76    return { messages: [{ role: 'user', text, toolUses: [] }] }
77  }).catch(($, e, next) => next(e))
78
79  on('turn.step', async function* ($, e, next) {
80    if (e.agentId === undefined && (await read($, spinnerFix)) === 'compacted') await update($, spinnerFix, () => 'stale')
81    return yield* next(e)
82  })
83
84  on('turn.complete', async ($, e, next) => {
85    if ((await read($, spinnerFix)) !== 'idle') await update($, spinnerFix, () => 'idle')
86    return next(e)
87  })
88
89  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
90    if (e.props.message !== SESSION_START_LABEL || (await read($, spinnerFix)) !== 'stale') return next(e)
91    return next({ ...e, props: { ...e.props, message: null } })
92  })
93}
94
hooks/rules.ts 86 lines
1import type { SessionCompactTrigger, SessionMessage } from 'claude-code'
2
3export type DigestBlock = { text: string; lines: number }
4
5export const SECTIONS = [
6  { title: 'GOAL', ask: 'What the user wants done overall, in one or two sentences.' },
7  {
8    title: 'NEXT STEP',
9    ask: 'The single next action, concrete enough to start without asking. The latest user message wins over an older plan: if the user changed direction after the plan was written, the next step follows the change.',
10  },
11  { title: 'DECISIONS AND REASONS', ask: 'Each choice made in this session with the reason for it, one bullet each.' },
12  { title: 'RULED OUT', ask: 'Approaches tried or considered and dropped, with why, so they are not retried.' },
13  { title: 'OPEN QUESTIONS', ask: 'What is still undecided or waiting on the user; "none" if nothing is.' },
14  { title: 'VERIFY', ask: 'The command or check that proves the work is done.' },
15] as const
16
17const CLASSIC = /^\s*classic\b\s*/
18
19export function classicRequest(
20  trigger: SessionCompactTrigger,
21  instructions: string | undefined,
22): { instructions: string | undefined } | undefined {
23  if (trigger !== 'manual' || instructions === undefined || !CLASSIC.test(instructions)) return undefined
24  const rest = instructions.replace(CLASSIC, '').trim()
25  return { instructions: rest === '' ? undefined : rest }
26}
27
28export function forkPrompt(instructions?: string): string {
29  const sections = SECTIONS.map(s => `## ${s.title}\n${s.ask}`).join('\n')
30  const lines = [
31    'This conversation is about to be compacted. Everything above will be replaced by a handoff: your reply, followed by a digest built from the transcript and git without a model.',
32    'That digest already lists the files touched, the commits and git status, the last approved plan, the newest turns verbatim and an index of every user prompt, so do not repeat any of it. Write only what it cannot recover: intent, reasons and dead ends.',
33    'The latest user message wins over an older plan.',
34    '',
35    'Use no tools. Reply with the handoff only, no preamble, in exactly these sections:',
36    '',
37    sections,
38    '',
39    'Keep it under 600 words.',
40  ]
41  if (instructions?.trim()) lines.push('', `The user asked this compaction to: ${instructions.trim()}`, 'Follow that in what you keep and stress.')
42  return lines.join('\n')
43}
44
45export function reasoningOutline(instructions?: string): string {
46  const outline = `Make the summary also cover, under these headings: ${SECTIONS.map(s => s.title).join(', ')}. The latest user message wins over an older plan.`
47  return instructions?.trim() ? `${instructions.trim()}\n\n${outline}` : outline
48}
49
50export function digestBlock(digestText: string): DigestBlock {
51  const cut = digestText.search(/^## Prompt index$/m)
52  const text = (cut === -1 ? digestText : digestText.slice(0, cut)).trimEnd()
53  return { text, lines: text.split('\n').length }
54}
55
56// The "[handoff] " prefix must stay as is because handoff-digest.sh drops user messages that start with it,
57// so a later digest does not count an earlier handoff as a prompt.
58function header(digestPath: string, what: string): string {
59  return `[handoff] ${what} The full digest, with an index of every user prompt, is at ${digestPath}; read or grep it when an older prompt matters.`
60}
61
62export function handoffMessage(args: {
63  reasoning: string
64  block: DigestBlock
65  digestPath: string
66  trigger: SessionCompactTrigger
67}): string {
68  const what = `The earlier conversation was replaced by this handoff at compaction (trigger: ${args.trigger}). The reasoning was written by a fork of the session; the facts below it come from the transcript and git.`
69  return [header(args.digestPath, what), '', args.reasoning.trim(), '', args.block.text].join('\n')
70}
71
72export function withDigest(core: readonly SessionMessage[], block: DigestBlock, digestPath: string): SessionMessage[] {
73  const what = 'The summary above is the built-in one; these facts come from the transcript and git.'
74  return [...core, { role: 'user', text: [header(digestPath, what), '', block.text].join('\n'), toolUses: [] }]
75}
76
77export function nextHandoffPath(digestPath: string, names: readonly string[]): string {
78  const slash = digestPath.lastIndexOf('/')
79  const dir = digestPath.slice(0, slash)
80  const session = digestPath.slice(slash + 1).replace(/\.md$/, '')
81  const taken = names
82    .filter(n => n.startsWith(`${session}.handoff-`))
83    .map(n => Number(/\.handoff-(\d+)\.md$/.exec(n)?.[1] ?? 0))
84  return `${dir}/${session}.handoff-${Math.max(0, ...taken) + 1}.md`
85}
86
types/index.d.ts 10 lines
1export type SpinnerFix = 'idle' | 'compacted' | 'stale'
2
3declare module 'claude-code' {
4  interface PluginState {
5    handoff: {
6      spinnerFix: SpinnerFix
7    }
8  }
9}
10