SLOPSHOPPER

failure-streak

Dotfiles – (neo)vim, tmux and zsh configuration

newguardstatus
A shopper browsing a rack in a slop shop
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 2 files
hooks/register.ts 81 lines
1import type { Register } from 'claude-code'
2
3import {
4  errorDigest,
5  isPolling,
6  isSandboxBlock,
7  SANDBOX_HINT,
8  shortLabel,
9  signatureOf,
10  STRIKE_LIMIT,
11  strikeOutReason,
12  thirdStrikeHint,
13} from './classify.ts'
14
15type Streak = { count: number; label: string; digest: string }
16
17const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
18
19const statusText = (streaks: ReadonlyMap<string, Streak>): string | undefined => {
20  let top: Streak | undefined
21  for (const s of streaks.values()) if (top === undefined || s.count > top.count) top = s
22  // A single failure is normal; only a repeat is worth a pinned line.
23  return top === undefined || top.count < 2 ? undefined : `${top.label} failed ×${top.count}`
24}
25
26export const register: Register = on => {
27  // Consecutive identical failures per command signature. Module-level on purpose: a reload starts clean.
28  const streaks = new Map<string, Streak>()
29
30  // A reload starts with no streaks: clear whatever the previous load pinned.
31  on('session.start', ($, e, next) => {
32    $.ui.status(undefined)
33    return next(e)
34  })
35
36  // An edit changes what a rerun means (edit → rerun typecheck is a fix loop, not a retry loop).
37  on('tool.call', async ($, e, next) => {
38    const ran = await next(e)
39    if (EDIT_TOOLS.has(String(e.tool)) && ran.deny === undefined && ran.isError !== true && streaks.size > 0) {
40      streaks.clear()
41      $.ui.status(undefined)
42    }
43    return ran
44  })
45
46  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
47    if (isPolling(e.command)) return next(e)
48    const sig = signatureOf(e.command, e.dangerouslyDisableSandbox === true, e.agentId)
49    const prior = streaks.get(sig)
50
51    // The 4th identical try: refuse once, then forget, so a later attempt can run.
52    if (prior !== undefined && prior.count >= STRIKE_LIMIT) {
53      streaks.delete(sig)
54      $.ui.status(statusText(streaks))
55      return { deny: strikeOutReason(prior.count, e.command) }
56    }
57
58    const ran = await next(e)
59    if (ran.deny !== undefined) return ran
60
61    if (ran.isError !== true) {
62      if (streaks.delete(sig)) $.ui.status(statusText(streaks))
63      return ran
64    }
65
66    // Re-read after the await: parallel identical calls must not overwrite each other's count.
67    const digest = errorDigest(ran.text ?? '')
68    const current = streaks.get(sig)
69    const count = current !== undefined && current.digest === digest ? current.count + 1 : 1
70    streaks.set(sig, { count, label: shortLabel(e.command), digest })
71    $.ui.status(statusText(streaks))
72
73    const notes: string[] = []
74    if (isSandboxBlock(e.command, ran.text ?? '')) notes.push(SANDBOX_HINT)
75    if (count >= STRIKE_LIMIT) notes.push(thirdStrikeHint(count))
76    if (notes.length === 0) return ran
77
78    return { ...ran, context: [...(ran.context ?? []), ...notes] }
79  })
80}
81
hooks/classify.ts 75 lines
1// Pure helpers: no `$`, so tests can call them directly.
2
3// Failures of the same command allowed before the next identical try is refused.
4export const STRIKE_LIMIT = 3
5
6// Only commands that reach the network can be blocked by the sandbox's network policy.
7// Without this gate, a failing test run that merely prints "ENOTFOUND" fixtures would get the hint.
8const NETWORK_COMMAND =
9  /\b(curl|wget|http|https|ssh|scp|rsync|git (fetch|pull|push|clone|ls-remote)|gh|npm|pnpm|yarn|npx|pip3?|uv|brew|doppler|render|wrangler|gcloud|bq|psql|tempo|tailscale|nc|dig|nslookup)\b/
10
11// Symptoms of the OS sandbox's network/socket policy, not of the command itself.
12const SANDBOX_PATTERNS: readonly RegExp[] = [
13  /certificate verify failed/i,
14  /unable to get local issuer certificate/i,
15  /self[- ]signed certificate in certificate chain/i,
16  /x509: certificate/i,
17  /tls: failed to verify/i,
18  /Could not resolve host/i,
19  /getaddrinfo (ENOTFOUND|EAI_AGAIN)/i,
20  /Temporary failure in name resolution/i,
21  /nodename nor servname provided/i,
22  /Received HTTP code 403 from proxy/i,
23  /CONNECT tunnel failed/i,
24  /(socket|connect|bind)\(?\)?:? .*Operation not permitted/i,
25  /EPERM: operation not permitted, (connect|bind|listen)/i,
26  /ssh: Could not resolve hostname/i,
27]
28
29export const isSandboxBlock = (command: string, text: string): boolean =>
30  NETWORK_COMMAND.test(command) && SANDBOX_PATTERNS.some(re => re.test(text))
31
32// Polling commands fail by design until the thing they wait on is ready; never strike them.
33const POLLING = /\b(gh pr checks|gh run (view|watch)|grep -q|pg_isready|kubectl rollout status)\b/
34export const isPolling = (command: string): boolean => POLLING.test(command)
35
36// FNV-1a, so the whole command counts (no truncation collisions) without keeping long keys.
37const hash = (s: string): string => {
38  let h = 0x811c9dc5
39  for (let i = 0; i < s.length; i++) {
40    h ^= s.charCodeAt(i)
41    h = Math.imul(h, 0x01000193)
42  }
43  return (h >>> 0).toString(36)
44}
45
46// Same command modulo whitespace and volatile numbers (ports, pids, timestamps), per loop
47// (each subagent has its own streaks), plus whether the sandbox was off: an unsandboxed
48// rerun is a different attempt.
49export const signatureOf = (command: string, isUnsandboxed = false, agentId?: string): string => {
50  const body = command.trim().replace(/\s+/g, ' ').replace(/\d{4,}/g, 'N')
51  return `${agentId ?? 'main'}|${isUnsandboxed ? 'nosandbox' : 'sandbox'}|${hash(body)}`
52}
53
54// A different error is progress, not a repeat: only identical failures count toward a strike.
55export const errorDigest = (text: string): string =>
56  hash(text.replace(/\d+(\.\d+)?(ms|s)?\b/g, 'N').replace(/\s+/g, ' ').slice(0, 2000))
57
58export const shortLabel = (command: string): string => {
59  const first = command.trim().split(/\s+/).slice(0, 3).join(' ')
60  return first.length > 32 ? `${first.slice(0, 31)}…` : first
61}
62
63export const SANDBOX_HINT =
64  'failure-streak: this error looks like a sandbox network/socket block, not a bug in the command. ' +
65  'Do not debug it. If the command is safe, rerun it once with dangerouslyDisableSandbox: true; ' +
66  'otherwise hand the exact command to the user to run in their pane.'
67
68export const thirdStrikeHint = (count: number): string =>
69  `failure-streak: this exact command has now failed ${count} times in a row with the same error. ` +
70  'Do not run it again unchanged. Say what you tried, then propose a different approach.'
71
72export const strikeOutReason = (count: number, command: string): string =>
73  `failure-streak: refused — \`${shortLabel(command)}\` already failed ${count} times in a row with the same error and nothing was edited since. ` +
74  'Stop retrying it: say what you tried and propose a different approach (a changed command, or one after an edit, will run).'
75