SLOPSHOPPER

global-hooks

User-global Claude Code hooks, loaded in place as global-hooks@skills-dir through the ~/.claude/skills symlink

newguardstatusprocess
v0.1.0no licenseupdated 2026-10-09irisTa56/dotfiles/.claude/skills/global-hooks
A shopper browsing a rack in a slop shop
README

dotfiles

This repository sets up a Mac: its shell, its tools, and the agent configuration every project on it loads. Using from Other Repositories covers what another repository takes from it.

Setup

Homebrew is left to the user: install Homebrew, and have it install what the Brewfile lists, mise and gh among them, from a clone of this repository. Sign gh in before mise runs: mise installs most tools from GitHub, whose API limits requests without a token, and MISE_GITHUB_CREDENTIAL_COMMAND hands mise gh's token.

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew bundle
gh auth login

mise sets up the rest. mise refuses to parse a mise.toml from a directory it has not been told to trust, so trust this one first:

mise trust
MISE_GITHUB_CREDENTIAL_COMMAND="$(brew --prefix)/bin/gh auth token" mise bootstrap

The first run sets that variable by hand, since .zshenv exports it only once this run has had it source the shell fragments. Both name gh by absolute path, for the reason zsh_fragments/zshenv.sh gives beside its export.

On a Mac already set up, rerun brew bundle before mise bootstrap, which installs nothing the Brewfile lists, gh included.

mise bootstrap applies what the root mise.toml declares, and a rerun changes only what has drifted:

  • [dotfiles] symlinks the global mise config and file tasks, and the agent instructions below, into place, and adds the line that imports them to ~/.claude/CLAUDE.md.
  • It refuses to replace a file or directory already at a link's path, and changes nothing until that one is moved aside.
  • Each link points into the checkout it runs from, so a hook stops a run from a worktree before anything is written.
  • It installs the tools that the global config and the root mise.toml pin.
  • The bootstrap task runs last, on every run, as the sections below describe: setup:dotfiles, rtk init, apm install, and the basic-memory MCP server.

The global config, .config/mise/config.toml, holds the tools, settings and tasks that reach every repository, and .config/mise/tasks/ its file tasks; the root mise.toml pins the tools this repository's own tasks use.

A few more steps stay by hand, since each needs a secret, a sign-in or a path only this machine knows: rclone's remotes below, fnox keys under Secrets, and K-Boat's project registration.

Shell and User Config

mise bootstrap runs mise run setup:dotfiles, which does the following:

  • Drops ~/.dircolors and ~/.config/git/ignore, each overwritten with canonical content.
  • Makes ~/.zshenv, ~/.zprofile and ~/.zshrc source this repository's shell fragments.
  • Sets npm's min-release-age in ~/.npmrc.
  • Sets pitchfork's general.shell and boot.executable in ~/.config/pitchfork/config.toml.
  • Encrypts rclone's config with a password it keeps in the login keychain.

Shell startup: .zshenv, .zprofile and .zshrc

Each of the three files sources the fragment of the same name in zsh_fragments/ (zshenv.sh, zprofile.sh, zshrc.sh); the .zshenv and .zprofile fragments say, line by line, why each line is in that file. scripts/setup_dotfiles.sh appends the sourcing line, pointing at the main checkout, to a file that does not already name its fragment, so an edit to a fragment reaches the next shell without rerunning setup, and lines an installer appends to the files stay.

.zprofile

.zprofile is read by login shells, and only after macOS's /etc/zprofile has run /usr/libexec/path_helper — so PATH set anywhere earlier is already demoted by then. See Homebrew discussion #1127.

.zshenv

.zshenv is read by every shell, which is what HOMEBREW_PREFIX and the nomatch guard need.

  • It also exports RCLONE_PASSWORD_COMMAND, which reads rclone's config password from the login keychain, so a config encrypted with it opens without a prompt in any shell, an agent's included.
  • rclone runs the command only for an encrypted config, so an unencrypted one is unaffected.
  • setup:dotfiles stores a random password where the command reads it, and encrypts the config with it, before any remote exists if need be; a remote added later with rclone config is saved encrypted.
  • A config already encrypted with another password stops opening under the export, since a failing password command does not fall back to the prompt, and setup:dotfiles stops on it.
  • Decrypt it first with env -u RCLONE_PASSWORD_COMMAND rclone config encryption remove, which asks for that password; for a config copied from another Mac, the password command prints it there.
  • Without the password, as when the keychain item is gone, move the config aside and add the remotes again.
  • It also makes uv (exclude-newer) pick no package version published less than a day ago, as the script makes npm do through its user config (min-release-age); mise and pnpm 11 already wait a day by default.
  • The window applies when a version is picked, for a one-off install or a lockfile update, not to a version a lockfile already pins.
  • npm's sits in the user config, below a project's own .npmrc, so a project can set a longer one.
  • For uv it covers only uvx and uv tool, through shell functions: uv writes a user-wide window into each project's uv.lock, which then fails uv lock --check for anyone locking without it (astral-sh/uv#18775).
  • A project gets the window by setting [tool.uv] exclude-newer = "1 day" in its own pyproject.toml, which every checkout then shares.
  • Other uv commands get none, including a one-off uv run --with <pkg> and a script's inline dependencies, so run a one-off through uvx --with <pkg> instead.
  • To take a fix released within the day, override it for that command: npm install --min-release-age=0, or UV_EXCLUDE_NEWER=false uvx ….
  • pitchfork daemons get the export too: launchd starts the supervisor with no shell environment, so the script sets pitchfork's general.shell to /bin/zsh -c, whose non-interactive zsh still reads .zshenv. Under the default sh -c, a daemon that runs rclone stalls on the password prompt and fails.

Agent Instructions

  • CLAUDE.md — this repository's own project instructions, loaded only for sessions working inside it.
  • .claude/INSTRUCTIONS.md — user-scoped principles (shareable), symlinked to ~/.claude/INSTRUCTIONS.md.
  • .claude/rules/ — path-scoped rules, loaded when Claude works with files matching each rule's paths.

~/.claude/CLAUDE.md is a thin, machine-local entry point that imports the user-scoped parts. mise bootstrap links them, adds the @INSTRUCTIONS.md line to that file, leaving any other line in it, and runs rtk init -g --hook-only --auto-patch for the rtk hook that .claude/INSTRUCTIONS.md assumes. Hook-only, because the ~/.claude/RTK.md a plain rtk init -g writes and imports is regenerated on every run, and it tells the agent to treat condensed output as complete, against what .claude/INSTRUCTIONS.md says of rtk proxy.

Agent Skills

Most skills live under .claude/skills/, managed by APM, co-located with the instructions and rules above. GitHub Copilot also reads .claude/skills/, so this single directory serves both the primary Claude setup and Copilot as a secondary client. apm.yml declares the packages and apm.lock.yaml pins the resolved commits and content hashes.

The target: claude field in apm.yml is deliberately the singular target: key, not the plural targets:. apm uninstall reads only the singular field, so with target: set it honors the pin and touches .claude/skills/ alone. A plural targets: reads as unset, which makes uninstall auto-detect on-disk targets (.github/, .cursor/, …) and mirror skills into a stray .agents/skills/.

mise bootstrap symlinks ~/.claude/skills to it. That symlink carries user-global hooks. .claude/skills/global-hooks/ holds a .claude-plugin/plugin.json, so Claude Code loads it in place as the skills-directory plugin global-hooks@skills-dir in every project, and its hooks/hooks.json stays out of the machine-local ~/.claude/settings.json. An edit to it takes effect after /reload-plugins or a restart. The same hooks/hooks.json names a function-hooks module under modules, hooks/review-loop.ts, which supports the review-loop skill by showing the round of the branch's unclosed record in the status line. The function-hooks API is early access and may change with a Claude Code update, so mise run pre-commit runs the module's tests with claude plugin test and type-checks it against the installed CLI's declarations (mise run ts:types); Biome lints and formats it (mise run qa:ts, mise run format-ts), in CI as well. The rtk hook stays in ~/.claude/settings.json, since rtk init writes it there.

Restore pinned skills from apm.lock.yaml, which mise bootstrap also does:

apm install

Add a new skill package (owner/repo for a single-skill repo, owner/repo/path/to/skill for a monorepo entry):

apm install owner/repo

Remove installed packages (also strips them from apm.yml and apm.lock.yaml):

mise run skills:remove <package> [more...]

Audit deployed files against the lockfile, plus integrity and hidden-character checks:

apm audit

Show packages whose upstream advanced past the pinned ref, then update:

apm outdated
apm install --update

Gist-sourced skills

Some skills are published as a single-file GitHub gist holding a SKILL.md. APM installs one from the gist's git URL like any other package, and pins it in apm.lock.yaml. It deploys under a directory named after the gist hash unless the dependency carries an alias, so each is declared in apm.yml in the object form:

- git: https://gist.github.com/<owner>/<gist_id>.git
  alias: <name>

Then run apm install.

The gist is the only durable copy of a skill's content, and apm install restores the pinned copy over a local edit, so push the edit back to the gist first:

mise run skills:push japanese-tech-writing

This commits the edit on top of the pinned revision in a temporary clone of the gist and pushes it with git, using gh's credential for that push alone. A gist that has moved past the pin rejects the push as non-fast-forward, which keeps the edit from dropping that revision. It then runs apm update on that skill, which moves the pin to the pushed commit, and fails if the pin lands anywhere else; until that apm.lock.yaml change reaches main, an apm install from main restores the old copy, and a push from it is refused.

Repo-tracked skills

A few skills are written here rather than pulled from an upstream, and this repository is their only copy. .gitignore excludes all of .claude/skills/*, which is what keeps APM output out of version control. A hand-written skill therefore needs one line to unignore it:

!/.claude/skills/<name>/

Re-including the directory is enough — the exclusion above uses a single *, which does not cross /, so it never matched the contents in the first place.

MCP Servers

The only stdio MCP server in use is basic-memory, already configured in Claude Desktop and Claude Code. Claude Desktop's DXT extensions and remote connectors are managed in-app, not from this directory.

mise bootstrap registers basic-memory in Claude Code's user scope when it is not there yet:

claude mcp add-json -s user basic-memory '{"command":"uvx","args":["basic-memory","mcp"]}'

Another client is wired by hand.

Setting up K-Boat

K-Boat is a skill package that reads sources through NotebookLM and matures them into a concept graph. It owns the writing side; this repository only reads that graph, through the repo-tracked ask-kboat skill, which answers a question from the concept notes and keeps what they say distinct from general knowledge.

K-Boat stores that graph as a Basic Memory *project* — a name bound to a directory of Markdown notes — and the ask-kboat skill requires that project to be registered. Setting up the MCP server above is not enough on its own because project registration is per-machine local state that no clone carries.

basic-memory project add k-boat-knowledge <KBOAT_KNOWLEDGE_PATH>
basic-memory project list

Using from Other Repositories

The sections above set this Mac up; the two below are for work inside another repository. Secrets works only on a Mac set up from here, since its task comes with the global mise config, while the shared mise tasks come in through an include and run wherever mise does.

Secrets

API keys stay out of every file a repository keeps, .env and mise's [env] included, so those hold only settings anyone may read, an agent included. fnox, which the global mise config installs, keeps each key in the macOS login keychain and hands it to one command at a time.

  • Store a key from anywhere in the repository, typing it at the prompt so it lands in neither shell history nor a process's arguments:
  mise run secrets:add <NAME>
  • The task declares a keychain provider named after the repository in a fnox.local.toml at the main checkout's root, which the global git ignore that mise run setup:dotfiles writes keeps out of every repository, and has fnox add the key's entry there.
  • It also creates the root's CLAUDE.local.md, likewise ignored, with a line telling an agent there to run what needs a key through fnox; if the file already exists without that line, the task prints it for you to place instead.
  • The login keychain does not sync through iCloud, so another Mac needs the key stored again.
  • Run what needs the key as fnox exec -- <command>, which puts it in that command's environment alone; fnox activate would export it to everything run in the directory.
  • A worktree has no fnox.local.toml of its own. Claude Code puts worktrees under the main checkout's .claude/worktrees/, where fnox finds the main checkout's by searching upward; a worktree placed elsewhere does not.

.claude/rules/secrets.md tells an agent the same when it opens .env, mise config or fnox config in any repository.

Shared mise Tasks

tasks/ holds the repository-agnostic tasks this repository lends to others:

  • a gitleaks scan of a commit's staged changes;
  • a networked check of the links in the files a commit stages;
  • a trufflehog scan of the commits a push would send;
  • a check that a consumer's pinned copy of these tasks is the current one.

This repository runs the first three itself, the same way a consumer would, from the pre-commit and pre-push hooks that mise install sets up. tasks/README.md is where a repository taking them starts, and where the constraints on writing another are stated.

Source 2 files
hooks/review-loop.ts 72 lines
1import type { EngineInterface, Register } from "claude-code";
2import { readRecord } from "./review-loop-record";
3
4// Supports the review-loop skill (review-loop/SKILL.md): shows the state of the
5// loop's record on the current branch in the status line. It never writes the
6// record.
7
8const RECORD_DIR = "/.claude/review-loop/";
9
10// Where the skill keeps the current branch's record.
11const recordPath = async ($: EngineInterface): Promise<string | undefined> => {
12  const home = await $.env.get("HOME");
13  const git = await $.process.run(
14    [
15      "git",
16      "rev-parse",
17      "--path-format=absolute",
18      "--git-common-dir",
19      "--abbrev-ref",
20      "HEAD",
21    ],
22    { timeoutMs: 5000 },
23  );
24  // A failed git prints nothing, or on an unborn branch names a record that is not there.
25  const [commonDir, branch] = git.stdout.trim().split("\n");
26  if (home === undefined || !commonDir || !branch) return undefined;
27
28  return `${home}${RECORD_DIR}${commonDir.replaceAll("/", "-")}/${branch}.md`;
29};
30
31const statusText = async ($: EngineInterface): Promise<string | undefined> => {
32  const path = await recordPath($);
33  const text = path === undefined ? undefined : await $.fs.read(path);
34  const open = typeof text === "string" ? readRecord(text) : undefined;
35  if (open === undefined) return undefined;
36
37  return `review-loop: ${open.round > 0 ? `round ${open.round}` : "no round yet"}`;
38};
39
40// Any failure on the way, a missing record included, shows no status.
41const showStatus = async ($: EngineInterface): Promise<void> =>
42  $.ui.status(await statusText($).catch(() => undefined));
43
44export const register: Register = (on) => {
45  on("session.start", async ($, e, next) => {
46    const started = await next(e);
47    await showStatus($);
48
49    return started;
50  });
51
52  on("turn.start", async ($, e, next) => {
53    await showStatus($);
54
55    return next(e);
56  });
57
58  on("turn.complete", async ($, e, next) => {
59    await showStatus($);
60
61    return next(e);
62  });
63
64  // The loop writes its record mid-turn, so reread it as the write lands.
65  on("tool.call", { tool: ["Edit", "Write"] }, async ($, e, next) => {
66    const ran = await next(e);
67    if (e.file_path.includes(RECORD_DIR)) await showStatus($);
68
69    return ran;
70  });
71};
72
hooks/review-loop-record.ts 49 lines
1// Reads a review-loop record (review-loop/SKILL.md, "The record"). A model keeps
2// the file as prose, so this parses loosely and answers undefined, which shows
3// no status, wherever it cannot tell.
4
5export type OpenRecord = {
6  // The highest round a verdict group names; 0 before the first one.
7  round: number;
8};
9
10const CLOSED_HEADING = /^##\s+Closed\b/;
11// Records also end on a bare sentence in place of the heading.
12const CLOSED_SENTENCE = /^Closed\b/;
13const VERDICTS = /^##\s+Verdicts\b/;
14// "### Round 3 — held", "## Round 4 (endgame)", "**Round 2.**", and a group of
15// several: "### Rounds 1-6 — ...", "### Rounds 9 and 10", "### Rounds 1 to 3, ...".
16// A group counts as its last round; after the singular a second number is a
17// count, as in "### Round 2 - 3 findings".
18const ROUND =
19  /^(?:#{2,4}\s+|\*\*)Round(s?)\s+(\d+(?:\s*(?:[-–,]|and|to)\s*\d+)*)/;
20
21const isClosed = (lines: readonly string[]): boolean => {
22  const last = lines.at(-1) ?? "";
23  if (CLOSED_HEADING.test(last) || CLOSED_SENTENCE.test(last)) return true;
24  // The closing commit, or a note made after the close, often follows the
25  // heading; only a round group after it is a reopened loop.
26  const closedAt = lines.findLastIndex((line) => CLOSED_HEADING.test(line));
27
28  return (
29    closedAt >= 0 && !lines.slice(closedAt + 1).some((line) => ROUND.test(line))
30  );
31};
32
33export const readRecord = (text: string): OpenRecord | undefined => {
34  const lines = text.split("\n").filter((line) => line.trim() !== "");
35  const verdictsAt = lines.findIndex((line) => VERDICTS.test(line));
36  if (verdictsAt < 0 || isClosed(lines)) return undefined;
37
38  const rounds = lines.slice(verdictsAt + 1).flatMap((line) => {
39    const [, plural, list] = ROUND.exec(line) ?? [];
40    const numbers = list?.match(/\d+/g) ?? [];
41
42    return numbers.length > 0
43      ? [Number(plural ? numbers.at(-1) : numbers[0])]
44      : [];
45  });
46
47  return { round: Math.max(0, ...rounds) };
48};
49