SLOPSHOPPER

run-ledger

Dotfiles – (neo)vim, tmux and zsh configuration

newbandguardcommandprompttool
v?no licenseupdated 2026-10-03rcliao/dotfiles/dot_claude/mods/run-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · run-ledger
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ledger ⎿ run-ledger: ## Run ledger ⎿ run-ledger: Context: 49% (97k tokens), peak 49%, session cost $0.42 ⎿ run-ledger: Outlook: ctx 97k/200k 49% ⎿ run-ledger: ⎿ run-ledger: ### Changed ⎿ run-ledger: - Files edited (3): /work/app/src/auth.ts, /work/app/src/audit.ts, /work/app/src/cache.ts ctx 97k/200k 49% 3 files ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx 97k/200k 49% 3 files ⟨Claude Code's own drawing⟩
README

Eric's Dotfiles

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.

Set up a new machine

brew install chezmoi
chezmoi init --apply rcliao

That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:

PromptWhy it is per-machine
Full namegit author name
Git emailso a work laptop and a personal box can share this repo without cross-signing commits
Ghost hookssee Ghost below

Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.

The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.

Day to day

chezmoi diff                 # what would change
chezmoi apply                # everything
chezmoi apply ~/.zshrc       # just one target
chezmoi add ~/.tmux.conf     # pull a local edit back into the repo
chezmoi update               # git pull + apply

This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.

What is in here

Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).

Terminals — ghostty.

Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.

Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.

Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.

Window management — aerospace.

Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.

Other — bat, yazi.

Ghost memory hooks (opt-in)

The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.

To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.

aerospace

Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:

brew trust --cask nikitabobko/tap/aerospace   # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace

What is deliberately not tracked

Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.

This repo is public, so nothing employer-specific goes in it.

Source 3 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CtxSample, LedgerEntry, TurnSample } from '../types'
5import {
6  appendEntries,
7  appendSample,
8  classifyBash,
9  classifyMcp,
10  appendTurn,
11  ctxView,
12  FILE_TOOLS,
13  ledgerMarkdown,
14  summaryLine,
15} from './ledger.ts'
16
17const entriesAtom = atom({ plugin: 'run-ledger', key: 'entries' } as const, [] as LedgerEntry[])
18const ctxAtom = atom({ plugin: 'run-ledger', key: 'ctx' } as const, [] as CtxSample[])
19const failsAtom = atom({ plugin: 'run-ledger', key: 'fails' } as const, 0)
20const turnsAtom = atom({ plugin: 'run-ledger', key: 'turns' } as const, [] as TurnSample[])
21const limitsAtom = atom({ plugin: 'run-ledger', key: 'limits' } as const, null as null | { window: number; threshold?: number; isAutoCompact: boolean })
22
23const LEDGER_TOOL = 'mcp__run-ledger__ledger'
24const REPORT_RULE =
25  `Before you write an end-of-run report or summary of what you changed (for example under Changed / Blocked on me / Found), call ${LEDGER_TOOL} and build the Changed section from it, so no commit, PR, tag, publish, outward write or edited file is left out.`
26// Context grows inside one long autonomous turn, so sample between tool calls too, at most this often.
27const SAMPLE_EVERY_MS = 15_000
28
29async function sampleContext($: EngineInterface): Promise<void> {
30  const usage = await $.session.usage()
31  const { tokens, percent } = usage.context
32  // Absent before the window's first response and right after a compaction.
33  if (tokens === undefined || percent === undefined) return
34  const sample: CtxSample = { percent, tokens, usd: usage.cost?.usd, at: await $.clock.now() }
35  await update($, ctxAtom, prev => appendSample(prev, sample))
36}
37
38// Once per main-loop turn: the turn's end size (growth per turn) and the compaction threshold.
39// `summary` is the engine's local estimate: no token-count requests.
40async function sampleTurn($: EngineInterface): Promise<void> {
41  const usage = await $.session.usage({ breakdown: 'summary' })
42  const { tokens, window, breakdown } = usage.context
43  const limits =
44    breakdown?.autoCompactThreshold === undefined
45      ? { window, isAutoCompact: breakdown?.isAutoCompactEnabled ?? false }
46      : { window, threshold: breakdown.autoCompactThreshold, isAutoCompact: breakdown.isAutoCompactEnabled }
47  await update($, limitsAtom, () => limits)
48  if (tokens === undefined) return
49  const turn: TurnSample = { tokens, at: await $.clock.now() }
50  await update($, turnsAtom, prev => appendTurn(prev, turn))
51}
52
53async function currentLedger($: EngineInterface): Promise<string> {
54  return ledgerMarkdown(
55    await read($, entriesAtom),
56    await read($, ctxAtom),
57    await read($, failsAtom),
58    await read($, turnsAtom),
59    await read($, limitsAtom),
60  )
61}
62
63export const register: Register = on => {
64  let lastSampleAt = 0
65
66  on('session.start', async ($, e, next) => {
67    await $.command.register({
68      name: 'ledger',
69      description: 'Show what this run changed and sent out, plus context use (reset: start a new baseline)',
70      argumentHint: '[reset]',
71      immediate: true,
72    })
73    await $.tool.register({
74      name: 'ledger',
75      description:
76        "This session's ledger of PRs, commits, tags, publishes, paid calls, outward writes, files edited, failures and context use. Read it before writing the end-of-run Changed / Blocked on me / Found report.",
77      inputSchema: { type: 'object', properties: {} },
78    })
79    return next(e)
80  })
81
82  on('tool.call', async ($, e, next) => {
83    if (e.tool === LEDGER_TOOL) return { result: await currentLedger($) }
84
85    const ran = await next(e)
86    if (ran.deny !== undefined) return ran
87    const now = await $.clock.now()
88
89    if (e.tool === 'Bash') {
90      if (ran.isError === true) await update($, failsAtom, n => (n ?? 0) + 1)
91      const found = classifyBash(e.command, ran.text ?? '', now, ran.isError === true)
92      if (found.length > 0) await update($, entriesAtom, prev => appendEntries(prev, found))
93    } else if (ran.isError !== true) {
94      const filePath = FILE_TOOLS.has(String(e.tool)) ? (e as { file_path?: unknown }).file_path : undefined
95      const found =
96        typeof filePath === 'string'
97          ? { kind: 'file' as const, label: filePath, at: now }
98          : classifyMcp(String(e.tool), now)
99      if (found !== undefined) await update($, entriesAtom, prev => appendEntries(prev, [found]))
100    }
101
102    if (now - lastSampleAt >= SAMPLE_EVERY_MS) {
103      lastSampleAt = now
104      await sampleContext($)
105    }
106    return ran
107  })
108
109  on('turn.complete', async ($, e, next) => {
110    const done = await next(e)
111    // Subagent turns fire this too; only the main loop's window is the one on screen.
112    if (e.agentId === undefined) {
113      lastSampleAt = await $.clock.now()
114      await sampleContext($)
115      await sampleTurn($)
116    }
117    return done
118  })
119
120  // Eval finding: with the tool description alone the model never read the ledger (0/3 runs).
121  on('prompt.compose', async ($, e, next) => {
122    const composed = await next(e)
123    if (!e.tools.includes(LEDGER_TOOL)) return composed
124    return { sections: [...composed.sections, { id: 'run-ledger:report', text: REPORT_RULE, scope: 'session' }] }
125  })
126
127  on('command.run', { command: 'ledger' }, async ($, e) => {
128    if (e.args.trim() === 'reset') {
129      // A new baseline for a new task in a long session; context figures are the session's and stay.
130      await update($, entriesAtom, () => [])
131      await update($, failsAtom, () => 0)
132      return { text: 'Ledger reset: changes, outward actions and failures start from zero.' }
133    }
134    return { text: await currentLedger($) }
135  })
136
137  // Stack on whatever is beneath (another mod's band, e.g. jobs) instead of replacing it.
138  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139    const below = await next(e)
140    if (e.props.hasSurvey) return below
141    const summary = summaryLine(await read($, entriesAtom))
142    const view = ctxView(await read($, ctxAtom), await read($, turnsAtom), await read($, limitsAtom))
143    const ctx = view?.text
144    if (ctx === undefined && summary === '') return below
145    const ctxColor = view?.level === 'hot' ? 'red' : view?.level === 'warn' ? 'yellow' : undefined
146    const { Box, Text } = $.ui.resolve(e)
147    return (
148      <Box flexDirection="column">
149        <Box flexDirection="row">
150          {ctx === undefined ? null : ctxColor === undefined ? <Text dimColor>{ctx}</Text> : <Text color={ctxColor}>{ctx}</Text>}
151          {summary === '' ? null : <Text dimColor>{`${ctx === undefined ? '' : '   '}${summary}`}</Text>}
152        </Box>
153        {below}
154      </Box>
155    )
156  })
157}
158
hooks/ledger.ts 240 lines
1// Pure helpers: classify tool calls into ledger entries and format the ledger. No `$`.
2import type { CtxLimits, CtxSample, LedgerEntry, LedgerKind, TurnSample } from '../types'
3
4export const MAX_ENTRIES = 300
5export const MAX_SAMPLES = 60
6export const CTX_WARN = 70
7export const CTX_HOT = 85
8
9const firstMatch = (re: RegExp, text: string): string | undefined => re.exec(text)?.[1]
10
11// One Bash command can do several things (`git commit … && git push`), so return a list.
12// Called on errored commands too: a chain can fail after its commit or charge already landed,
13// so anything outward is recorded only on evidence in the output (SHA, PR url, "Paid", "[new tag]").
14export const classifyBash = (command: string, output: string, at: number, isError = false): LedgerEntry[] => {
15  const out: LedgerEntry[] = []
16  const add = (kind: LedgerKind, label: string, detail?: string) =>
17    out.push(detail === undefined ? { kind, label, at } : { kind, label, at, detail })
18  // Each command of a chain, so a flag or token is read only from the command it belongs to.
19  const segments = command.split(/&&|\|\||;|\|/).map(seg => seg.trim())
20  const has = (re: RegExp) => segments.some(seg => re.test(seg))
21  const seg = (re: RegExp) => segments.find(x => re.test(x))
22
23  if (has(/^git\b.*\bcommit\b/)) {
24    for (const m of output.matchAll(/\[[^\]\s]+(?: \([^)]*\))? ([0-9a-f]{7,40})\]/g)) if (m[1]) add('commit', m[1])
25  }
26  if (has(/^gh pr create\b/)) {
27    const url = firstMatch(/(https:\/\/github\.com\/\S+\/pull\/\d+)/, output)
28    if (url !== undefined) add('pr', `PR #${url.split('/').pop()}`, url)
29  }
30
31  const push = seg(/^git push\b/)
32  if (push !== undefined) {
33    const tags = [...output.matchAll(/\* \[new tag\]\s+(\S+)/g)].map(m => m[1] ?? '')
34    for (const tag of tags) add('tag', tag)
35    const isForced = /\(forced update\)/.test(output) || /(--force\b|--force-with-lease\b|\s-f\b)/.test(push)
36    const branch = firstMatch(/(?:\* \[new branch\]|[0-9a-f]+\.\.\.?[0-9a-f]+)\s+\S+\s+->\s+(\S+)/, output)
37    if (branch !== undefined) add(isForced ? 'force-push' : 'push', branch)
38  }
39
40  // A payment CLI's own receipt line. Commands that only display text (grep, cat, echo…) can print one too.
41  const isDisplayOnly = has(/^(grep|rg|cat|bat|sed|awk|head|tail|less|echo|printf|jq)\b/)
42  if (!isDisplayOnly) for (const m of output.matchAll(/^Paid ([\d.]+ [A-Z][A-Z0-9]{1,9}) via \S+/gm)) add('paid', `${m[1]}${isError ? ' (call errored)' : ''}`)
43  // Live finding: inline scripts (heredocs, python -, node -e) carry these commands as text,
44  // so kinds with no output evidence are recorded only for real, successful commands.
45  if (isError || /<<|\bpython3? -|\bnode -e\b|\bcat >/.test(command)) return out
46  if (has(/^tempo request\b/)) add('paid', 'tempo request')
47
48  const merged = seg(/^gh pr merge\b/)
49  if (merged !== undefined && /merged pull request/i.test(output)) add('merge', `merge${firstMatch(/merge\s+#?(\d+)/, merged)?.replace(/^/, ' #') ?? ''}`)
50  const commented = /^gh (pr|issue) (comment|review)\b\s*#?(\d+)?/.exec(seg(/^gh (pr|issue) (comment|review)\b/) ?? '')
51  if (commented && /#(issuecomment|pullrequestreview)-\d+/.test(output)) add('comment', `gh ${commented[1]} ${commented[2]}${commented[3] ? ` #${commented[3]}` : ''}`)
52  const published = firstMatch(/^\+ (\S+@\d\S*)/m, output)
53  if (has(/^(npm|pnpm|yarn) publish\b/) && published !== undefined) add('publish', published)
54  const deploy = seg(/^(wrangler (deploy|publish)|gcloud functions deploy|render deploy|kubectl apply)\b/)
55  if (deploy !== undefined) add('deploy', deploy.split(/\s+/).slice(0, 3).join(' '))
56  const secret = seg(/^doppler secrets set\b.*(-c|--config)[ =]prd\b/)
57  if (secret !== undefined) add('prod-secret', `doppler prd: ${firstMatch(/\s([A-Z][A-Z0-9_]+)=/, secret) ?? 'secret'}`)
58  return out
59}
60
61const MCP_KINDS: ReadonlyArray<[RegExp, LedgerKind]> = [
62  [/^mcp__linear-server__(save_issue|save_comment|save_status_update|save_document|save_project)$/, 'linear'],
63  [/^mcp__plugin_slack_slack__slack_(send_message|schedule_message|update_canvas|create_canvas)$/, 'slack'],
64  [/^mcp__notion__notion-(create-pages|update-page|create-comment)$/, 'notion'],
65]
66
67export const classifyMcp = (tool: string, at: number): LedgerEntry | undefined => {
68  for (const [re, kind] of MCP_KINDS) {
69    if (re.test(tool)) return { kind, label: tool.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, ''), at }
70  }
71  return undefined
72}
73
74export const FILE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
75
76export const appendEntries = (prev: readonly LedgerEntry[] | undefined, add: readonly LedgerEntry[]) => {
77  const next = [...(prev ?? [])]
78  for (const entry of add) {
79    if (entry.kind === 'file' && next.some(x => x.kind === 'file' && x.label === entry.label)) continue
80    next.push(entry)
81  }
82  return next.slice(-MAX_ENTRIES)
83}
84
85export const appendSample = (prev: readonly CtxSample[] | undefined, s: CtxSample) => {
86  const list = [...(prev ?? [])]
87  const last = list[list.length - 1]
88  // Keep one sample per whole-percent change; refresh cost on the last one otherwise.
89  if (last !== undefined && last.percent === s.percent) list[list.length - 1] = { ...last, usd: s.usd, at: s.at }
90  else list.push(s)
91  return list.slice(-MAX_SAMPLES)
92}
93
94const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
95
96export const countBy = (entries: readonly LedgerEntry[]) => {
97  const counts = new Map<LedgerKind, number>()
98  for (const e of entries) counts.set(e.kind, (counts.get(e.kind) ?? 0) + 1)
99  return counts
100}
101
102export const OUTWARD: ReadonlySet<LedgerKind> = new Set([
103  'merge', 'force-push', 'tag', 'publish', 'deploy', 'paid', 'comment', 'linear', 'slack', 'notion', 'prod-secret',
104])
105
106export const MAX_TURNS = 40
107// Turns-to-compact thresholds for the context figure's color.
108export const TURNS_WARN = 15
109export const TURNS_HOT = 5
110
111export const fmtTokens = (n: number): string =>
112  n >= 1_000_000 ? `${Number((n / 1_000_000).toFixed(1))}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
113
114export const appendTurn = (prev: readonly TurnSample[] | undefined, t: TurnSample) => [...(prev ?? []), t].slice(-MAX_TURNS)
115
116// Mean growth over the last 5 turns since the last compaction (a drop starts the series over).
117export const growthPerTurn = (turns: readonly TurnSample[]): number | undefined => {
118  let start = 0
119  for (let i = 1; i < turns.length; i++) if ((turns[i]?.tokens ?? 0) < (turns[i - 1]?.tokens ?? 0)) start = i
120  const since = turns.slice(start).slice(-6)
121  if (since.length < 2) return undefined
122  const first = since[0]
123  const last = since[since.length - 1]
124  if (first === undefined || last === undefined) return undefined
125  return Math.max(0, (last.tokens - first.tokens) / (since.length - 1))
126}
127
128export type CtxView = { text: string; level: 'ok' | 'warn' | 'hot' }
129
130export const ctxView = (
131  samples: readonly CtxSample[],
132  turns: readonly TurnSample[],
133  limits: CtxLimits | null,
134): CtxView | undefined => {
135  const last = samples[samples.length - 1]
136  if (last === undefined) return undefined
137  const window = limits?.window
138  const parts = [window ? `ctx ${fmtTokens(last.tokens)}/${fmtTokens(window)}  ${last.percent}%` : `ctx ${last.percent}%`]
139  const growth = growthPerTurn(turns)
140  let level: CtxView['level'] = last.percent >= CTX_HOT ? 'hot' : last.percent >= CTX_WARN ? 'warn' : 'ok'
141  if (growth !== undefined && growth > 0) {
142    parts.push(`+${fmtTokens(Math.round(growth))}/turn`)
143    const limit = limits?.isAutoCompact && limits.threshold ? limits.threshold : window
144    if (limit !== undefined) {
145      const left = Math.max(0, Math.floor((limit - last.tokens) / growth))
146      parts.push(`~${left} turns to ${limits?.isAutoCompact && limits.threshold ? 'compact' : 'full'}`)
147      level = left <= TURNS_HOT ? 'hot' : left <= TURNS_WARN ? 'warn' : level
148    }
149  }
150  // Cost stays in /ledger: not worth a constant place in the band.
151  return { text: parts.join('  '), level }
152}
153
154const NOTABLE: Partial<Record<LedgerKind, (label: string) => string>> = {
155  pr: l => l,
156  merge: l => l,
157  tag: l => l,
158  'force-push': l => `force-push ${l}`,
159  publish: l => `published ${l}`,
160  deploy: () => 'deploy',
161  // paid: kept in /ledger only, too routine for the band
162  comment: l => l,
163  linear: () => 'linear write',
164  slack: () => 'slack message',
165  notion: () => 'notion write',
166  'prod-secret': l => l,
167}
168
169// The rest of the band: the latest outward actions by name, then files edited. Failures stay in /ledger.
170export const summaryLine = (entries: readonly LedgerEntry[]): string => {
171  const named = entries.flatMap(e => {
172    const f = NOTABLE[e.kind]
173    return f === undefined ? [] : [f(e.label)]
174  })
175  const parts: string[] = []
176  if (named.length > 0) parts.push(named.slice(-3).reverse().join(' · ') + (named.length > 3 ? ` +${named.length - 3}` : ''))
177  const files = countBy(entries).get('file') ?? 0
178  if (files > 0) parts.push(plural(files, 'file'))
179  return parts.join('   ')
180}
181
182const LABELS: Record<LedgerKind, string> = {
183  pr: 'PRs opened',
184  merge: 'PRs merged',
185  commit: 'Commits',
186  push: 'Pushes',
187  'force-push': 'Force pushes',
188  tag: 'Tags pushed',
189  publish: 'Packages published',
190  deploy: 'Deploys',
191  paid: 'Paid calls',
192  comment: 'GitHub comments/reviews',
193  linear: 'Linear writes',
194  slack: 'Slack writes',
195  notion: 'Notion writes',
196  'prod-secret': 'Prod secret writes',
197  file: 'Files edited',
198}
199
200const section = (title: string, kinds: readonly LedgerKind[], entries: readonly LedgerEntry[]) => {
201  const lines: string[] = []
202  for (const kind of kinds) {
203    const of = entries.filter(e => e.kind === kind)
204    if (of.length === 0) continue
205    const items = of.map(e => (e.detail ? `${e.label} (${e.detail})` : e.label))
206    lines.push(`- ${LABELS[kind]} (${of.length}): ${items.slice(-12).join(', ')}${of.length > 12 ? ', …' : ''}`)
207  }
208  return lines.length === 0 ? [] : [`### ${title}`, ...lines, '']
209}
210
211export const ledgerMarkdown = (
212  entries: readonly LedgerEntry[],
213  samples: readonly CtxSample[],
214  fails: number,
215  turns: readonly TurnSample[] = [],
216  limits: CtxLimits | null = null,
217) => {
218  const last = samples[samples.length - 1]
219  const peak = samples.reduce((m, s) => Math.max(m, s.percent), 0)
220  const ctx =
221    last === undefined
222      ? ['Context: no API response yet this window.']
223      : [
224          `Context: ${last.percent}% (${Math.round(last.tokens / 1000)}k tokens), peak ${peak}%` +
225            (last.usd === undefined ? '' : `, session cost $${last.usd.toFixed(2)}`),
226          `Outlook: ${ctxView(samples, turns, limits)?.text ?? 'n/a'}`,
227        ]
228  const body = [
229    ...section('Changed', ['pr', 'commit', 'push', 'file'], entries),
230    ...section('Outward-facing', ['merge', 'tag', 'force-push', 'publish', 'deploy', 'comment', 'linear', 'slack', 'notion', 'prod-secret', 'paid'], entries),
231  ]
232  return [
233    '## Run ledger',
234    ...ctx,
235    '',
236    ...(body.length === 0 ? ['Nothing changed or sent out yet.', ''] : body),
237    `Failed Bash calls: ${fails}`,
238  ].join('\n')
239}
240
types/index.d.ts 33 lines
1export type LedgerKind =
2  | 'pr'
3  | 'merge'
4  | 'commit'
5  | 'push'
6  | 'force-push'
7  | 'tag'
8  | 'publish'
9  | 'deploy'
10  | 'paid'
11  | 'comment'
12  | 'linear'
13  | 'slack'
14  | 'notion'
15  | 'prod-secret'
16  | 'file'
17
18export type LedgerEntry = { kind: LedgerKind; label: string; at: number; detail?: string }
19
20export type CtxSample = { percent: number; tokens: number; usd?: number; at: number }
21
22/** Context tokens at the end of each main-loop turn, for growth per turn. */
23export type TurnSample = { tokens: number; at: number }
24
25/** The window and where auto-compaction fires, from the engine's local estimate. */
26export type CtxLimits = { window: number; threshold?: number; isAutoCompact: boolean }
27
28declare module 'claude-code' {
29  interface PluginState {
30    'run-ledger': { entries: LedgerEntry[]; ctx: CtxSample[]; fails: number; turns: TurnSample[]; limits: CtxLimits | null }
31  }
32}
33