Andon cord: an always-on rule and eager tool for agents to report defects in their own instructions, skills and tools, with a board to triage them.

Public dotfiles for fish, git, jed, and VS Code — plus a large collection of agent skills and command-line tools for AI-assisted development with agents such as Claude Code, Codex, and Gemini CLI.
.dotfiles/
├── bin/ # Shell scripts added directly to $PATH
├── fish/
│ ├── config.fish # Main fish config; loads .private overlay if present
│ ├── conf.d/ # Fish startup snippets
│ ├── completions/ # Fish completions for bin/ scripts and functions
│ └── functions/ # Fish functions (autoloaded)
├── home/ # Dotfiles symlinked into $HOME by install.sh
├── etc/ # Tool-specific config (agents, VS Code, etc.)
├── skills/ # Agent skills (see Agent skills below)
├── config/ # Plugins for the skill and permission tools
├── docker/ # Dockerfile + notes for building an image from this repo
├── tests/ # TAP tests for bin/ scripts
└── install.sh # Idempotent install/update script
install.sh symlinks home/.* into $HOME, installs packages, and wires up tool-specific config. It is safe to run multiple times, and doubles as the updater: an existing checkout is fast-forwarded before applying. It can be run locally (./install.sh) or piped straight from the network (see Installation).
How coding agents are set up (global instructions, hooks, launch wrappers, and what each agent receives) is described in etc/agents/README.md.
The skills/ directory contains Agent Skills: folders with a SKILL.md (metadata and instructions) plus supporting scripts and references that teach AI coding agents how to perform specific tasks — driving Android devices over adb, inspecting APKs, looking up current AndroidX library facts, enforcing coding standards, planning award flights, and more.
| Skill | Description |
|---|---|
adb | ADB device automation with a Wear OS focus: screenshots, screen recording, tiles, dumpsys, logcat |
agent-tools | CLI tools delegating analysis to AI models: image description, screenshot diffing, deep research |
apk | Offline APK binary analysis: manifests, resources, launcher icons, split APKs |
coding-standards | This repo's coding standards: shell/Python/Markdown formatting, CLI design, commit message style |
jetpack | Current AndroidX/Jetpack facts that are wrong from memory: versions, Maven coordinates, dependencies |
More specialised skills: android-testing (system state and connectivity testing on phone and Wear OS), wear-widget (reverse-engineering widgets), agent-config (skill selection, agent tool permissions, MCP servers and Andon alerts), and workspace-tools (runtimes, .envrc blocks and git hooks). Personal-workflow skills live here too: agent-review, taskgo, technical-writing, and travel.
Skills are intentionally not installed globally — every project seeing every skill would be noise. Instead, the bundled skill command manages them per workspace as untracked symlinks in .claude/skills/ or .agents/skills/, auto-detecting which agent is installed:
skill add --defaults # link the default skills
skill add jetpack # add one skill by name
skill list # what's linked here, 'default' or 'added'
Skill names resolve against skills/ in this repo and its .private/.corp overlays (later layers shadow by name); plugins in config/skill/plugins/ register additional remote skills from third-party catalogs. The default skills are set by etc/skill/defaults.sh in each layer, which install.sh runs. A fresh clone links them into the repository, and the agent wrappers link them into each session.
SECURITY NOTE: The .dotfiles repository is public. To prevent the leakage of sensitive information (such as API keys, corporate tool configurations, or internal pathnames), two private companion repositories can optionally be cloned: ~/.private and ~/.corp.
This architecture is specifically designed to help both humans and AI agents understand what goes where and ensures private information remains strictly out of the public repository. This repository works fine without them.
| Repo | Intended for |
|---|---|
~/.dotfiles | Public config, tools, and scripts |
~/.private | Personal secrets, API keys, private tooling |
~/.corp | Work machine config, internal tooling |
install.sh processes overlays in this order: .dotfiles → .private → .corp. Later layers win on conflict. A file in ~/.corp always beats the same path in ~/.private or ~/.dotfiles.
Two functions handle overlay merging, with different semantics:
link_overlay_path rel dst — symlinks dst to the single highest-priority source that provides rel. Used for whole-file configs where only one version makes sense (e.g. etc/starship/starship.toml).link_overlay_files rel dst — iterates every overlay in order and symlinks each file it finds under rel/ into dst/. All overlays contribute; later layers win on filename collision. Used when multiple overlays may each add files to a directory (e.g. etc/code/).home/ dotfiles are linked by iterating all overlays in order with ln -sf, so the last overlay to provide a given filename wins.
| Path | Mechanism | Notes |
|---|---|---|
home/.* | link_home_dotfiles (all overlays) | Last overlay wins per filename |
etc/agents/AGENTS.md | overlay_path (highest wins) | Symlinked to ~/.codex/, ~/.gemini/, ~/.claude/, ~/.config/opencode/ (gated on binaries) |
etc/agents/agy/hooks.json | link_overlay_path | Symlinked to ~/.gemini/config/hooks.json (gated on agy) |
etc/agents/agy/hooks | link_overlay_path | Symlinked to ~/.gemini/config/hooks (gated on agy) |
etc/agents/hooks/ensure-commit-hooks | overlay_path | Registered as a PreToolUse hook in ~/.claude/settings.json (gated on claude) |
etc/agents/opencode/plugins/ensure-commit-hooks.ts | link_overlay_path | Symlinked to ~/.config/opencode/plugins/ (gated on opencode) |
skills/agent-config/plugins/andon/opencode | link_overlay_path | Symlinked to ~/.config/opencode/plugins/andon (gated on opencode) |
etc/shpool/config.toml | link_overlay_path | |
etc/starship/starship.toml | link_overlay_path | |
etc/ghostty/config | link_overlay_path | |
etc/bat/config | link_overlay_path | |
etc/uv/uv.toml | link_overlay_path | Restricts uv to its own Python builds (only-managed) |
etc/code/ | link_overlay_files | VS Code settings.json, keybindings.json, etc. |
etc/macos/KeyBindings | overlay_path | rsync'd (not symlinked) due to macOS bug |
etc/macos/Solarized.clr | overlay_path | Copied to ~/Library/Colors/ |
etc/skill/defaults.sh | run by install.sh | Adds or removes default skills with skill --scope defaults |
skills/*/ | skill catalog source | Not linked globally; added per workspace via skill add (see Agent skills) |
update hooksAfter the main install, install.sh runs ~/.private/update and ~/.corp/update if they exist and are executable. These hooks handle overlay-specific setup that cannot be expressed as file overlays (package installs, auth setup, etc.).
.private/ # same structure for .corp
├── fish/
│ ├── conf.d/ # Startup snippets sourced by config.fish
│ ├── completions/ # Completions prepended to fish_complete_path
│ ├── functions/ # Functions prepended to fish_function_path
│ └── secrets.fish # API keys and tokens (chmod 600)
├── home/ # Dotfiles symlinked into $HOME (e.g. .gitconfig.local)
├── etc/ # Tool-specific config (see overlay-aware paths above)
├── skills/ # Agent skills that shadow ~/.dotfiles/skills/ by name
└── update # Optional hook run at end of ./install.sh
fish/config.fish prepends ~/.private/fish/functions (and .corp equivalents), ~/.private/fish/completions and each ~/.private/skills/*/completions to the fish search paths, and sources any ~/.private/fish/conf.d/*.fish snippets at shell startup.
To install: clone your private repos to ~/.private and/or ~/.corp, then run ./install.sh again.
Secrets (API keys, tokens) live in ~/.private/fish/secrets.fish and are not sourced in full at shell startup. Three fish functions provide on-demand access:
<!-- markdownlint-disable MD013 -->
| Function | Usage | Description |
|---|---|---|
setsecret | setsecret NAME [NAME...] | Load secret(s) into the current shell |
getsecret | getsecret NAME | Print secret value(s) to stdout (array-safe) |
envsecret | envsecret NAME [...] [--] CMD | Run a command with secret(s) injected |
<!-- markdownlint-restore MD013 -->
envsecret is the safest option for scripts: secrets are injected into the child process and never leak into the calling shell.
A ~/.private/fish/conf.d/ snippet uses setsecret --if-unset to auto-load a small set of everyday secrets at shell startup.
Run setsecret --help, getsecret --help, or envsecret --help for details.
direnv handles per-project environment switching, configured via ~/.direnvrc (symlinked from home/.direnvrc). To activate it in a project, create an .envrc file in the project root.
Node.js, Ruby, and Python versions are selected per directory with envrc blocks and installed with node-install, ruby-install, and python-install:
node-install 22 # latest 22.x into ~/.local/share/node/versions
envrc create node 22 # use node 22 + layout node (node_modules/.bin on PATH)
ruby-install 3.4 # compiles latest 3.4.x into ~/.local/share/ruby/versions
envrc create ruby 3.4 # use ruby 3.4 + layout ruby (gems in .direnv/ruby)
python-install # checks that uv is installed
envrc create uv # layout uv (creates and activates .venv)
Ruby builds need the ruby-build toolchain and its build dependencies, which install with install.sh --with ruby. The installers live in the workspace-tools skill; its Language Environments section covers the full workflow, including diagnosing a project that runs on the wrong version.
The same install.sh script both installs and updates, and runs in two ways. git must already be installed either way (see Prerequisites).
Clones the repo to ~/.dotfiles (if absent), points the push remote at SSH, then installs — and on later runs, updates (it fast-forwards ~/.dotfiles before applying):
curl -fsSL https://raw.githubusercontent.com/ithinkihaveacat/dotfiles/main/install.sh | bash
It must be piped to bash, not sh — the script is bash and refuses to run under other shells. Pass flags after -s --, e.g. … | bash -s -- --force. To download the whole script before running anything (rather than streaming it into the interpreter), use the equivalent:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/ithinkihaveacat/dotfiles/main/install.sh)"
cd $HOME
git clone https://github.com/ithinkihaveacat/dotfiles.git .dotfiles
cd .dotfiles
git remote set-url origin --push git@github.com:ithinkihaveacat/dotfiles.git
./install.sh
After cloning ~/.private and/or ~/.corp (if available), run ./install.sh again so the overlay is applied.
Note:
install.shmay overwrite unmanaged files in locations such as~/Library/KeyBindings. It is otherwise safe to run multiple times.
install.sh is split into features (linking dotfiles, packages, uv, agent setup, and so on), and a profile picks which of them run. The default, workstation, sets up a long-lived machine for interactive use: dotfiles, fish and its completions, core packages and interactive extras, and a daily upgrade of everything installed. Heavier package sets (android, ruby, media, services) are in no profile and are added with --with:
./install.sh --with android,ruby # also install Android and Ruby tooling
./install.sh --without upgrades # skip the daily upgrades
./install.sh --prune # also remove brew packages no feature installs
--only names the features outright, and -n/--dry-run prints the selection without running anything; ./install.sh --help lists the features and profiles. Once installed, a package is kept current by later runs whether or not its feature is selected again.
A failing feature is reported and the rest still run; the exit status is non-zero if any failed. Set AGENT_OFFLINE=1 to skip every feature that needs the network.
For the setup script of a disposable cloud or agent container, use the cloud profile:
curl -fsSL https://raw.githubusercontent.com/ithinkihaveacat/dotfiles/main/install.sh | bash -s -- --profile cloud
It links the dotfiles and fish config, prefetches what the uv-based scripts need, and sets up skill plugins and default skills. It does not use sudo, install or upgrade packages, update uv or agent CLIs, pull the repositories, or change npm's prefix, so it needs no access beyond GitHub and uv's package index. The platform's own agent setup is left alone.
sudo apt-get install gitxcode-select --installetc/macos/Solarized Dark.terminal and set it as the default profile.etc/macos/Text Replacements.plist — see Back up and share text replacements on Mac.sudo tmutil disablelocalComplete setup sequence for a fresh Raspberry Pi OS install.
ssh mjs@lil.local (substitute your username and hostname).infocmp -x xterm-ghostty | ssh mjs@lil.local -- tic -x - sudo apt-get update && sudo apt-get upgrade -y
Reboot if /var/run/reboot-required exists: sudo reboot
sudo apt-get install gitcurl -fsSL https://tailscale.com/install.sh | sh then sudo tailscale upsudo apt-get install nodejs npm (for newer versions see Node.js).[all] in /boot/firmware/config.txt: dtoverlay=disable-wifi
dtoverlay=disable-bt
/etc/hostname and /etc/hosts.sudo rebootOnce complete, proceed to Installation.
Tip: Use VS Code's Remote - SSH extension to edit files on the Pi.
Put manually installed binaries in ~/.local/bin.
Scripts are tested with TAP-format tests run via prove. Tests are co-located with the code they cover: tests/test-* for bin/ scripts and skills/*/tests/test-* for skill scripts. Run them with:
prove -j 9 tests/test-* skills/*/tests/test-*
See tests/README.md for details, including how to run tests offline and in isolated environments.
A GitHub Actions workflow (.github/workflows/lint.yml) runs on every push: shellcheck and shfmt via skills/coding-standards/scripts/shell-format over all Bash scripts (bin/, skills/*/scripts/, install.sh), ruff via skills/coding-standards/scripts/python-format over all Python scripts, plus the full test suite.
Tools managed by uv, meaning scripts with a uv run --script shebang (including the ruff, shfmt/shellcheck, and mdformat formatters), download their dependencies on first use. To prepare a CI job or cloud agent environment that will later lose network access, run this once while online, e.g. from the environment's setup script:
./install.sh --only uv # install uv; fetch everything into uv's cache
UV_OFFLINE=1 ./install.sh --only uv # check, without the network, that nothing is missing
The check downloads nothing and puts nothing on PATH, but it is not read-only: it builds each script's environment inside uv's cache from the packages already there, which is how it proves they work offline.
--only uv runs just the uv feature of install.sh: no git pull, no symlinks into $HOME, no sudo, and no other packages.
Afterwards, run with UV_OFFLINE=1 and AGENT_OFFLINE=1 (see tests/README.md). CI does exactly this before running the tests. Dependencies installed outside uv (e.g. fish, xmllint, java) are not covered.
<mjs@beebo.org>
hooks/register.tsx 641 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { AndonAlert, AndonCounts, AndonFilter } from "../types";
5import {
6 DEFAULT_LEVEL,
7 INPUT_SCHEMA,
8 MODEL_FIELDS,
9 TOOL,
10 TOOL_NAME,
11 buildDescription,
12 buildRule,
13 isLevel,
14 levelLabel
15} from "./rule";
16import { createSensor } from "./sensor";
17
18type Engine = EngineInterface;
19
20const PANE = "andon-board";
21const REFRESH_MS = 60_000;
22const CLI_TIMEOUT_MS = 15_000;
23
24const defaultLevel = atom(
25 { plugin: "andon", key: "defaultLevel" } as const,
26 DEFAULT_LEVEL
27);
28const transcriptPath = atom(
29 { plugin: "andon", key: "transcriptPath" } as const,
30 null
31);
32const counts = atom({ plugin: "andon", key: "counts" } as const, {
33 open: 0,
34 red: 0
35});
36const board = atom({ plugin: "andon", key: "board" } as const, []);
37const filter = atom({ plugin: "andon", key: "filter" } as const, "open");
38const isSessionOnly = atom(
39 { plugin: "andon", key: "isSessionOnly" } as const,
40 false
41);
42const notice = atom({ plugin: "andon", key: "notice" } as const, null);
43
44const FILTERS: { value: AndonFilter; label: string }[] = [
45 { value: "open", label: "Open" },
46 { value: "red", label: "Red" },
47 { value: "yellow", label: "Yellow" },
48 { value: "closed", label: "Closed" },
49 { value: "all", label: "All" }
50];
51
52/** Background work that outlives its dispatch: a failure there is not the caller's. */
53function quietly(work: Promise<unknown>): void {
54 work.catch(() => undefined);
55}
56
57// --- The shared CLI: the one reader and writer of the alert store ----------
58
59type CliResult = { ok: boolean; stdout: string; error: string };
60
61/**
62 * The skill's own CLI (this plugin is skills/agent-config/plugins/andon/claude),
63 * else `andon` on PATH for a copy installed away from the repository.
64 */
65async function cliPath($: Engine): Promise<string> {
66 const skillScript = `${$.plugin.root}/../../../scripts/andon`;
67
68 return (await $.fs.exists(skillScript)) ? skillScript : "andon";
69}
70
71async function cli(
72 $: Engine,
73 args: string[],
74 stdin?: string
75): Promise<CliResult> {
76 const path = await cliPath($);
77 // The CLI is a uv script using the standard library alone: where uv is
78 // missing from the host's PATH (exit 127 from env), python3 runs it as well.
79 const attempts = [
80 [path, ...args],
81 ["python3", path, ...args]
82 ];
83 let error = "";
84 for (const argv of attempts) {
85 try {
86 const ran = await $.process.run(argv, {
87 stdin,
88 timeoutMs: CLI_TIMEOUT_MS
89 });
90 if (ran.exitCode === 0)
91 return { ok: true, stdout: ran.stdout, error: "" };
92 error = ran.stderr.trim() || `${argv[0]} exited ${ran.exitCode}`;
93 if (ran.exitCode !== 127) break;
94 } catch (err) {
95 error = `cannot run ${argv[0]}: ${String(err)}`;
96 }
97 }
98
99 return { ok: false, stdout: "", error };
100}
101
102async function cliJson<T>($: Engine, args: string[]): Promise<T | null> {
103 const ran = await cli($, args);
104 if (!ran.ok) return null;
105 try {
106 return JSON.parse(ran.stdout) as T;
107 } catch {
108 return null;
109 }
110}
111
112// --- Level, counts and the board ---------------------------------------------
113
114let levelLocked = false;
115let initialLoad: Promise<void> | null = null;
116
117async function showStatus($: Engine): Promise<void> {
118 const level = await read($, defaultLevel);
119 const { open, red } = await read($, counts);
120 if (open === 0 && level === DEFAULT_LEVEL) {
121 $.ui.status(undefined);
122
123 return;
124 }
125 // The engine heads the line with the plugin name ("andon: ").
126 const parts = [`L${level}`];
127 if (open > 0) parts.push(`${open} open`);
128 if (red > 0) parts.push(`${red} red`);
129 $.ui.status(parts.join(" · "));
130}
131
132// Where the CLI keeps the store, from `stats --json`, and the store's files'
133// mtimes when last read: the timer runs the CLI only when they change.
134let stateDir: string | null = null;
135let seenStamps = "";
136
137async function storeChanged($: Engine): Promise<boolean> {
138 if (stateDir === null) return true;
139 const dir = stateDir;
140 const files = levelLocked
141 ? ["events.jsonl"]
142 : ["events.jsonl", "config.json"];
143 const stamps = await Promise.all(
144 files.map((name) =>
145 $.fs.stat(`${dir}/${name}`).then(
146 (stat) => String(stat.mtimeMs),
147 () => "-"
148 )
149 )
150 );
151 const key = stamps.join(",");
152 if (key === seenStamps) return false;
153 seenStamps = key;
154
155 return true;
156}
157
158async function isBoardOpen($: Engine): Promise<boolean> {
159 return (await $.ui.panes()).some((pane) => pane.id === PANE);
160}
161
162async function refreshCounts($: Engine): Promise<void> {
163 const stats = await cliJson<{
164 open: number;
165 open_red: number;
166 level: number;
167 state_dir?: string;
168 }>($, ["stats", "--json"]);
169 if (stats === null) return;
170 if (typeof stats.state_dir === "string") stateDir = stats.state_dir;
171 const next: AndonCounts = { open: stats.open, red: stats.open_red };
172 const was = await read($, counts);
173 if (was.open !== next.open || was.red !== next.red) {
174 await update($, counts, () => next);
175 // Another session's pull or resolve: keep an open board's rows in step
176 // with its header.
177 if (await isBoardOpen($)) await refreshBoard($);
178 }
179 if (!levelLocked && isLevel(stats.level)) {
180 levelLocked = true;
181 if (stats.level !== (await read($, defaultLevel))) {
182 await update($, defaultLevel, () => stats.level);
183 }
184 }
185 await showStatus($);
186}
187
188async function ensureLockedLevel($: Engine): Promise<number> {
189 if (!levelLocked && initialLoad !== null) await initialLoad;
190 levelLocked = true;
191
192 return read($, defaultLevel);
193}
194
195async function refreshBoard($: Engine): Promise<void> {
196 const chosen = await read($, filter);
197 const args = [
198 "list",
199 "--json",
200 "--status",
201 chosen === "open" || chosen === "red" || chosen === "yellow"
202 ? "open"
203 : "all"
204 ];
205 if (chosen === "red" || chosen === "yellow") args.push("--color", chosen);
206 if (await read($, isSessionOnly))
207 args.push("--session", await $.session.id());
208 const alerts = await cliJson<AndonAlert[]>($, args);
209 if (alerts === null) {
210 await update(
211 $,
212 notice,
213 () => "Could not read the alert store; see `andon list`."
214 );
215
216 return;
217 }
218 const rows =
219 chosen === "closed" ? alerts.filter((a) => a.status !== "open") : alerts;
220 await update($, board, () => rows);
221}
222
223// --- The pull tool -----------------------------------------------------------
224
225type PullResponse = {
226 ok: boolean;
227 id?: string;
228 color?: string;
229 fingerprint?: string;
230 recurrences?: number;
231 guidance: string;
232 error?: string;
233};
234
235const FALLBACK_GUIDANCE: Record<string, string> = {
236 yellow: "Continue with your chosen workaround; do not interrupt the user.",
237 red: "Stop work on the blocked path and explain the blocker and the decision you need to the user."
238};
239
240async function pull(
241 $: Engine,
242 e: Record<string, unknown>
243): Promise<PullResponse> {
244 const fields: Record<string, unknown> = {};
245 for (const key of MODEL_FIELDS)
246 if (e[key] !== undefined) fields[key] = e[key];
247 const level = await ensureLockedLevel($);
248 const cwd = await $.session.cwd();
249 const root = await $.session.root();
250 const payload = {
251 ...fields,
252 level,
253 harness: "claude-code",
254 conversation_id: await $.session.id(),
255 agent_id: typeof e.agentId === "string" ? e.agentId : undefined,
256 tool_use_id: typeof e.tool_use_id === "string" ? e.tool_use_id : undefined,
257 step_idx: await $.session.turns(),
258 transcript_path: (await read($, transcriptPath)) ?? undefined,
259 workspace_paths: root === cwd ? [cwd] : [cwd, root],
260 model_name: await $.session.model()
261 };
262 const ran = await cli($, ["pull", "--json", "-"], JSON.stringify(payload));
263 const color = String(fields.color);
264 if (!ran.ok) {
265 $.ui.toast(`Andon: the alert was not saved (${ran.error.slice(0, 120)})`);
266
267 return {
268 ok: false,
269 error: ran.error,
270 guidance: `The alert was not saved; mention the defect to the user at the end of your turn. ${FALLBACK_GUIDANCE[color] ?? ""}`
271 };
272 }
273 let response: PullResponse;
274 try {
275 response = JSON.parse(ran.stdout) as PullResponse;
276 } catch {
277 // The CLI exited 0, so the alert is saved: say so, or the model pulls again.
278 return {
279 ok: true,
280 guidance: `The alert was saved, but its response was unreadable; do not pull again. ${FALLBACK_GUIDANCE[color] ?? ""}`
281 };
282 }
283 const times =
284 (response.recurrences ?? 1) > 1 ? ` (×${response.recurrences} open)` : "";
285 $.ui.toast(
286 `Andon ${color}: ${String(fields.summary ?? "").slice(0, 80)}${times}`
287 );
288 quietly(refreshCounts($));
289
290 return response;
291}
292
293// --- The board -------------------------------------------------------------
294
295function age(createdAt: string, nowMs: number): string {
296 const minutes = Math.max(
297 0,
298 Math.round((nowMs - Date.parse(createdAt)) / 60_000)
299 );
300 if (minutes < 60) return `${minutes}m`;
301 if (minutes < 48 * 60) return `${Math.round(minutes / 60)}h`;
302
303 return `${Math.round(minutes / 1440)}d`;
304}
305
306/** The newest alert of each fingerprint, carrying how many share it. */
307function groupByFingerprint(alerts: readonly AndonAlert[]): AndonAlert[] {
308 const seen = new Set<string>();
309
310 return alerts.filter((a) => {
311 if (seen.has(a.fingerprint)) return false;
312 seen.add(a.fingerprint);
313
314 return true;
315 });
316}
317
318async function act(
319 $: Engine,
320 verb: "resolve" | "dismiss" | "reopen",
321 alert: AndonAlert
322): Promise<void> {
323 const args = [verb, alert.id, "--all-matching"];
324 if (verb !== "reopen")
325 args.push(
326 "--note",
327 `${verb === "resolve" ? "Resolved" : "Dismissed"} from the Andon board`
328 );
329 const ran = await cli($, args);
330 const past = {
331 resolve: "Resolved",
332 dismiss: "Dismissed",
333 reopen: "Reopened"
334 }[verb];
335 await update($, notice, () =>
336 ran.ok ? `${past} ${alert.id}.` : `${verb} failed: ${ran.error}`
337 );
338 await refreshBoard($);
339 await refreshCounts($);
340}
341
342async function copyPrompt(
343 $: Engine,
344 alert: AndonAlert,
345 surface: Parameters<Engine["ui"]["copy"]>[0]["surface"]
346): Promise<void> {
347 const ran = await cli($, ["prompt", alert.id]);
348 if (!ran.ok) {
349 await update($, notice, () => `prompt failed: ${ran.error}`);
350
351 return;
352 }
353 const copied = await $.ui.copy({ text: ran.stdout, surface });
354 await update($, notice, () =>
355 copied.isCopied
356 ? `Copied the fix prompt for ${alert.id}. Paste it into a fresh session.`
357 : `Could not copy (${copied.reason}); run: andon prompt ${alert.id}`
358 );
359}
360
361// --- The /andon command ----------------------------------------------------
362
363const USAGE = [
364 "Usage: /andon [board|list|stats|level [1-5|default|reset]]",
365 "",
366 " /andon open the Andon board",
367 " /andon list list open alerts from every session",
368 " /andon stats counts, top sources and top fingerprints",
369 " /andon level show the trigger level in force",
370 " /andon level 5 set the default level for new sessions",
371 " /andon level reset reset the default level to 3"
372].join("\n");
373
374async function runCommand($: Engine, args: string): Promise<string> {
375 const words = args.trim().split(/\s+/).filter(Boolean);
376 const verb = words[0] ?? "board";
377 if (verb === "board") {
378 await refreshBoard($);
379 const opened = await $.ui.open({ id: PANE, title: "Andon" });
380 if (opened.isPlaced) return "Andon board opened.";
381 // No surface draws panes here (VS Code, -p): answer with the list instead.
382 const ran = await cli($, ["list"]);
383
384 return ran.ok
385 ? ran.stdout.trimEnd() || "No open Andon alerts."
386 : `andon list failed: ${ran.error}`;
387 }
388 if (verb === "list" || verb === "stats") {
389 const ran = await cli($, verb === "list" ? ["list"] : ["stats"]);
390
391 return ran.ok
392 ? ran.stdout.trimEnd() || "No open Andon alerts."
393 : `andon ${verb} failed: ${ran.error}`;
394 }
395 if (verb !== "level" || words.length > 2) return USAGE;
396 const value = words[1];
397 if (value === undefined) {
398 const level = await ensureLockedLevel($);
399
400 return `Andon level ${levelLabel(level)}.`;
401 }
402 if (value === "default" || value === "reset") {
403 const ran = await cli($, ["level", "--reset"]);
404 if (!ran.ok) return `andon level failed: ${ran.error}`;
405
406 return `Andon default level reset to ${levelLabel(DEFAULT_LEVEL)} (applies to new sessions).`;
407 }
408 const level = Number(value);
409 if (!isLevel(level)) return USAGE;
410 const ran = await cli($, ["level", String(level)]);
411 if (!ran.ok) return `andon level failed: ${ran.error}`;
412
413 return `Andon default level set to ${levelLabel(level)} (applies to new sessions).`;
414}
415
416// --- Hooks -------------------------------------------------------------------
417
418export const register: Register = (on) => {
419 const sensor = createSensor();
420
421 on("session.start", async ($, e, next) => {
422 const started = await next(e);
423 levelLocked = false;
424 initialLoad = null;
425 // Awaited before the first prompt, so nothing here waits on the CLI: the
426 // counts and the default level arrive in the background.
427 await $.tool.register({
428 name: TOOL_NAME,
429 description: buildDescription(),
430 inputSchema: INPUT_SCHEMA,
431 isDeferred: false
432 });
433 await $.command.register({
434 name: "andon",
435 description: "Andon board, alerts and trigger level",
436 argumentHint: "[board|list|stats|level [1-5|default|reset]]"
437 });
438 initialLoad = refreshCounts($);
439 quietly(initialLoad);
440 $.clock.every(REFRESH_MS, () =>
441 quietly(
442 storeChanged($).then((changed) =>
443 changed ? refreshCounts($) : undefined
444 )
445 )
446 );
447
448 return started;
449 });
450
451 // The always-on rule. Only where the tool is offered: a rule naming a tool
452 // the request lacks would send the model after something that is not there.
453 on("prompt.compose", async ($, e, next) => {
454 const composed = await next(e);
455 if (!e.tools.includes(TOOL)) return composed;
456 const level = await ensureLockedLevel($);
457
458 return {
459 sections: [
460 ...composed.sections,
461 {
462 id: "andon:rule",
463 text: buildRule(level),
464 scope: "session" as const
465 }
466 ]
467 };
468 });
469
470 // A pull the user has to approve is a pull that will not happen, so an
471 // 'ask' becomes 'allow'. A deny from the user's rules or mode stands.
472 on("tool.check", { tool: "mcp__andon__pull" }, async ($, e, next) => {
473 const verdict = await next(e);
474
475 return verdict.decision === "ask"
476 ? { ...verdict, decision: "allow" as const }
477 : verdict;
478 }).catch(($, e, next) => next(e));
479
480 on("tool.call", { tool: "mcp__andon__pull" }, async ($, e) => {
481 const response = await pull($, e as unknown as Record<string, unknown>);
482
483 return { result: JSON.stringify(response) };
484 }).catch(($, e) => ({
485 result: JSON.stringify({
486 ok: false,
487 error: `${$.plugin.name}: the pull failed (${e.tool_use_id})`,
488 guidance:
489 "The alert was not saved; mention the defect to the user at the end of your turn, then continue as the rule says."
490 })
491 }));
492
493 // The sensor reads every tool call's outcome; it only ever adds a reminder.
494 on("tool.call", async ($, e, next) => {
495 const ran = await next(e);
496 // Main loop only: a subagent's tool list may lack the pull tool, and its
497 // failures are not the main loop's pattern.
498 if (ran.deny !== undefined || e.agentId !== undefined) return ran;
499 const level = await read($, defaultLevel);
500 const reminder = sensor.observe({
501 tool: e.tool,
502 input: e as unknown as Record<string, unknown>,
503 isError: ran.isError === true,
504 level
505 });
506
507 return reminder === null
508 ? ran
509 : { ...ran, context: [...(ran.context ?? []), reminder] };
510 }).catch(($, e, next) => next(e));
511
512 on("classic.SessionStart", async ($, e, next) => {
513 if (e.transcript_path)
514 await update($, transcriptPath, () => e.transcript_path);
515
516 return next(e);
517 }).catch(($, e, next) => next(e));
518
519 on("classic.UserPromptSubmit", async ($, e, next) => {
520 if (
521 e.transcript_path &&
522 e.transcript_path !== (await read($, transcriptPath))
523 ) {
524 await update($, transcriptPath, () => e.transcript_path);
525 }
526
527 return next(e);
528 }).catch(($, e, next) => next(e));
529
530 on("command.run", { command: "andon" }, async ($, e) => ({
531 text: await runCommand($, e.args)
532 }));
533
534 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
535 const { Box, Text, Button } = $.ui.resolve(e);
536 const alerts = groupByFingerprint(await read($, board));
537 const chosen = await read($, filter);
538 const sessionOnly = await read($, isSessionOnly);
539 const { open, red } = await read($, counts);
540 const level = await read($, defaultLevel);
541 const line = await read($, notice);
542 const nowMs = await $.clock.now();
543 const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 30) - 6) / 4));
544
545 return (
546 <Box flexDirection="column">
547 <Text bold>
548 Andon · level {level} · {open} open, {red} red
549 </Text>
550 <Box flexDirection="row" gap={1}>
551 {FILTERS.map((f) => (
552 <Button
553 key={`filter-${f.value}`}
554 label={f.label}
555 variant={f.value === chosen ? "primary" : "secondary"}
556 onPress={() =>
557 quietly(
558 update($, filter, () => f.value).then(() => refreshBoard($))
559 )
560 }
561 />
562 ))}
563 <Button
564 key="session-only"
565 label={sessionOnly ? "This session" : "All sessions"}
566 onPress={() =>
567 quietly(
568 update($, isSessionOnly, (was) => !was).then(() =>
569 refreshBoard($)
570 )
571 )
572 }
573 />
574 <Button
575 key="refresh"
576 label="Refresh"
577 onPress={() => quietly(refreshBoard($))}
578 />
579 </Box>
580 {line !== null && <Text dimColor>{line}</Text>}
581 {alerts.length === 0 && <Text dimColor>No alerts here.</Text>}
582 {alerts.slice(0, room).map((alert) => (
583 <Box key={`alert-${alert.id}`} flexDirection="column" marginTop={1}>
584 <Text>
585 <Text color={alert.color === "red" ? "error" : "warning"} bold>
586 {alert.color === "red" ? "■ red " : "■ yellow"}
587 </Text>{" "}
588 {alert.summary}
589 {alert.recurrences > 1 ? (
590 <Text bold> ×{alert.recurrences}</Text>
591 ) : (
592 ""
593 )}
594 </Text>
595 <Text dimColor>
596 {alert.kind} · L{alert.level} · {age(alert.created_at, nowMs)} ago
597 · {alert.id.slice(-4)}
598 {alert.status === "open" ? "" : ` · ${alert.status}`}
599 {alert.sources.length > 0 ? ` · ${alert.sources.join(", ")}` : ""}
600 </Text>
601 <Box flexDirection="row" gap={1}>
602 <Button
603 key={`prompt-${alert.id}`}
604 label="Copy fix prompt"
605 onPress={(press) =>
606 quietly(copyPrompt($, alert, press.surface))
607 }
608 />
609 {alert.status === "open" ? (
610 <>
611 <Button
612 key={`resolve-${alert.id}`}
613 label="Resolve"
614 onPress={() => quietly(act($, "resolve", alert))}
615 />
616 <Button
617 key={`dismiss-${alert.id}`}
618 label="Dismiss"
619 onPress={() => quietly(act($, "dismiss", alert))}
620 />
621 </>
622 ) : (
623 <Button
624 key={`reopen-${alert.id}`}
625 label="Reopen"
626 onPress={() => quietly(act($, "reopen", alert))}
627 />
628 )}
629 </Box>
630 </Box>
631 ))}
632 {alerts.length > room && (
633 <Text dimColor>
634 …and {alerts.length - room} more; see `andon list`.
635 </Text>
636 )}
637 </Box>
638 );
639 });
640};
641hooks/rule.ts 213 lines1// The always-on rule, the tool's description and schema, and the level
2// vocabulary. Kept apart from register.tsx so tests can read them directly.
3
4export const TOOL_NAME = "pull";
5export const TOOL = "mcp__andon__pull";
6export const DEFAULT_LEVEL = 3;
7
8export const KINDS = [
9 "skill_contradiction",
10 "rule_conflict",
11 "stale_instruction",
12 "stuck_loop",
13 "tool_anomaly",
14 "environment_block",
15 "other"
16] as const;
17
18export const LEVELS: Record<number, { name: string; when: string }> = {
19 1: {
20 name: "red only",
21 when:
22 "Pull `red` when blocked or when an explicitly requested capability is " +
23 "unavailable. Never pull `yellow`; work around recoverable issues silently."
24 },
25 2: {
26 name: "conservative",
27 when:
28 "Pull `red` when blocked or when an explicitly requested capability is " +
29 "unavailable. Pull `yellow` only for verified, reproducible defects: a " +
30 "direct contradiction between instructions, or a documented flag, script, " +
31 "or path confirmed missing via `--help` or the filesystem."
32 },
33 3: {
34 name: "standard",
35 when:
36 "Pull `red` when blocked or when an explicitly requested capability is " +
37 "unavailable. Pull `yellow` when a skill or rule contradicts another " +
38 "instruction, names a stale flag, path, or reference, requires an " +
39 "undocumented workaround, causes a skill/harness tool, binary, MCP server, " +
40 "or subagent to fail at runtime (even on the first attempt if you abandon " +
41 "it or switch to an alternative), or causes the same documented step to " +
42 "fail twice."
43 },
44 4: {
45 name: "proactive audit",
46 when:
47 "Everything at Level 3, plus pull `yellow` on the first encounter with " +
48 "harness friction: an instruction so vague or underspecified it forced a " +
49 "`--help` probe, an unstated prerequisite, or a misleading tool " +
50 "description or error."
51 },
52 5: {
53 name: "maximum coverage",
54 when:
55 "Do not self-filter for severity or confidence. Treat every skill, rule, " +
56 "and tool interaction as under active QA audit: pull `yellow` in parallel " +
57 "whenever you deviate from, supplement, or double-check any documented " +
58 "instruction, flag, or tool behavior — even if resolved on the first try, " +
59 "and including transient failures whose retry or timeout behavior was " +
60 "undocumented or misleading."
61 }
62};
63
64export function isLevel(value: unknown): value is number {
65 return (
66 Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 5
67 );
68}
69
70export function levelLabel(level: number): string {
71 return `${level} (${LEVELS[level]?.name ?? "unknown"})`;
72}
73
74/** The system prompt section, rendered for the level in force. */
75export function buildRule(level: number): string {
76 const lines = [
77 `# Andon Cord (\`${TOOL}\`)`,
78 "",
79 "Your instructions (`SKILL.md`, `AGENTS.md`, `CLAUDE.md`), rules, tool schemas, and " +
80 "helper scripts are shared across agent sessions and can contain defects. Even when " +
81 "you find a clean workaround in this conversation, working around a flaw silently " +
82 "means future agent sessions will hit the same broken instruction or tool and waste " +
83 `tokens and time rediscovering the fix. Pulling the Andon cord with \`${TOOL}\` ` +
84 "records the defect out-of-band so its source can be fixed once, while you continue " +
85 "fulfilling the user's request.",
86 "",
87 "Between tool calls — whenever a command, tool, binary, MCP server, or subagent " +
88 "fails, times out, or is unavailable; you run `--help` or inspect a script because " +
89 "a documented step did not work as written; or you deviate from or abandon a " +
90 "`SKILL.md`, `AGENTS.md`, or `CLAUDE.md` instruction — evaluate the issue against " +
91 `the session's trigger level and call \`${TOOL}\`.`,
92 "",
93 `- **Subagent Accountability:** If a delegated subagent lacks \`${TOOL}\` and reports ` +
94 "a tool failure, timeout, or connection/permission block back to you (including " +
95 "asynchronously via `<SYSTEM_MESSAGE>`), pull the cord on that turn.",
96 "",
97 "## How to Pull: Parallel Yellow, Blocking Red",
98 "",
99 "- **Yellow (`color: \"yellow\"`) — Workaround and Continue:** A safe workaround or " +
100 `alternative exists that fulfills the user's request. Call \`${TOOL}\` **in the same ` +
101 "parallel tool-call batch** as your workaround or next task step, and **continue " +
102 "the user's task immediately** without mentioning the defect to the user. Do not " +
103 "spend a separate turn on a yellow pull.",
104 "- **Red (`color: \"red\"`) — Blocked or Requested Capability Unavailable:** Call " +
105 `\`${TOOL}\` and **stop work on that path immediately** when either:`,
106 " 1. **An explicitly requested capability is unavailable:** A tool, MCP server, or " +
107 "subagent that the user explicitly asked you to use (e.g., via a slash command, " +
108 "`@` mention, or direct instruction) fails, times out, or cannot connect — **even " +
109 "if fallback inputs (such as local files or screenshots) are available**. Do not " +
110 "silently substitute a fallback for an explicitly requested capability.",
111 " 2. **You cannot proceed safely:** Instructions conflict on an irreversible or " +
112 "externally visible action, requirements are circular, or three attempts at the " +
113 "same step produced no new diagnostic.",
114 " After pulling `red`, end your turn and tell the user what failed or conflicted, " +
115 "what decision you need, and what you completed so far. (Color is severity " +
116 "metadata; stopping is your responsibility, not enforced by the harness.)",
117 "",
118 "## Workaround Precedence and Report Fields",
119 "",
120 "- **Precedence:** Explicit user instructions and global safety rules outrank skill " +
121 "guidelines; the stricter of two verification steps wins; live `--help` output " +
122 "beats stale CLI prose (without overriding safety policy).",
123 "- **Fields:** Quote the conflicting directive, flag, path, or error in `details`; " +
124 "list the affected files, rules, tools, or subagents in `sources` (paths as read); " +
125 "put the workaround taken (`yellow`) or decision needed (`red`) in `resolution`; " +
126 "and give the concrete edit that would fix the source in `proposed_fix`.",
127 "",
128 "## Trigger Sensitivity Levels (`1`–`5`)",
129 "",
130 `Active trigger level: ${levelLabel(level)}.`,
131 "",
132 "The trigger level is fixed for the session (`$ANDON_LEVEL` > " +
133 "`~/.local/state/andon/config.json` via `andon level` or `/andon level` > default " +
134 "**Level 3 (standard)**):",
135 "",
136 ...([1, 2, 3, 4, 5] as const).map(
137 (n) => `- **Level ${levelLabel(n)}:** ${LEVELS[n]!.when}`
138 ),
139 "",
140 "## Not a Harness Defect (Do Not Pull)",
141 "",
142 "- Ordinary compile, lint, type-check, or test failures in repository code you are " +
143 "editing or reviewing.",
144 "- Normal non-error exit codes (such as `rg`, `grep`, or `diff` returning `1` on no " +
145 "matches) or routine sandbox escalations when network or write access is required " +
146 "(unless a skill falsely claimed sandbox compatibility).",
147 "- Ambiguity in the user's request — ask the user directly.",
148 "- At Levels 1–4, a transient failure that succeeds on immediate retry with " +
149 "identical arguments."
150 ];
151
152 return lines.join("\n");
153}
154
155/**
156 * Kept short: the rule carries the guidance (a long description is skimmed),
157 * and the level in force, so the description never goes stale.
158 */
159export function buildDescription(): string {
160 return (
161 "Pull the Andon cord to record a defect or friction in your operating harness " +
162 "(skills, rules, tool schemas, helper scripts, MCP servers, or subagents) so it " +
163 "can be fixed for future sessions instead of worked around silently. Call " +
164 "color='yellow' in the same parallel tool-call batch as your workaround and " +
165 "continue the user's task without interrupting them. Call color='red' and stop " +
166 "immediately when blocked or when a capability explicitly requested by the user " +
167 "is unavailable. Evaluate against the session's trigger level under 'Andon Cord' " +
168 "in your instructions."
169 );
170}
171
172export const INPUT_SCHEMA = {
173 type: "object",
174 additionalProperties: false,
175 required: ["color", "kind", "summary", "details"],
176 properties: {
177 color: {
178 type: "string",
179 enum: ["yellow", "red"],
180 description:
181 "yellow: worked around it, continuing. red: blocked, asking the user."
182 },
183 kind: { type: "string", enum: [...KINDS] },
184 summary: {
185 type: "string",
186 maxLength: 120,
187 description: "One-line headline."
188 },
189 details: {
190 type: "string",
191 description:
192 "What conflicted or failed, quoting the directives, flags, paths or errors."
193 },
194 sources: {
195 type: "array",
196 items: { type: "string" },
197 description:
198 "Files, rules or tools involved, e.g. skills/deploy/SKILL.md."
199 },
200 resolution: {
201 type: "string",
202 description:
203 "Yellow: the workaround taken. Red: the decision or fix needed."
204 },
205 proposed_fix: {
206 type: "string",
207 description: "The concrete edit that would remove the defect."
208 }
209 }
210} as const;
211
212export const MODEL_FIELDS = Object.keys(INPUT_SCHEMA.properties);
213hooks/sensor.ts 103 lines1// A passive sensor for harness friction. The engine sees patterns the model
2// tends to explain away: a command fails right after the agent read a skill,
3// a --help probe follows a failure, the same command fails again. On those it
4// adds a one-line reminder to the tool result. It never pulls the cord itself
5// and never blocks anything: the model decides.
6
7import { TOOL } from "./rule";
8
9export type Observation = {
10 tool: string;
11 input: Record<string, unknown>;
12 isError: boolean;
13 level: number;
14};
15
16const SKILL_FILE = /(?:^|\/)SKILL\.md$/;
17const HELP_PROBE = /(?:^|\s)(?:--help|-h)(?:\s|$)/;
18const MAX_NUDGES = 5;
19const MAX_DIRS = 20;
20
21function dirname(path: string): string {
22 const cut = path.lastIndexOf("/");
23
24 return cut <= 0 ? path : path.slice(0, cut);
25}
26
27function basename(path: string): string {
28 return path.slice(path.lastIndexOf("/") + 1);
29}
30
31export function createSensor() {
32 const readDirs: string[] = [];
33 const failures = new Map<string, number>();
34 const used = new Set<string>();
35 let lastFailed = false;
36
37 function nudge(key: string, text: string): string | null {
38 if (used.has(key) || used.size >= MAX_NUDGES) return null;
39 used.add(key);
40
41 return `Andon: ${text} If documented instructions were wrong or stale, pull the Andon cord (${TOOL}, yellow) in the same batch as your workaround; otherwise ignore this.`;
42 }
43
44 function onBash(command: string, isError: boolean): string | null {
45 const probesHelp = HELP_PROBE.test(command);
46 if (!isError) {
47 const followsFailure = lastFailed;
48 lastFailed = false;
49
50 return followsFailure && probesHelp
51 ? nudge(
52 `help:${command.trim().split(/\s+/)[0]}`,
53 "you probed --help right after a command failed."
54 )
55 : null;
56 }
57 lastFailed = true;
58 const key = command.trim();
59 const count = (failures.get(key) ?? 0) + 1;
60 failures.set(key, count);
61 const skill = readDirs.find(
62 (dir) => command.includes(dir) || command.includes(`${basename(dir)}/`)
63 );
64 if (skill !== undefined) {
65 return nudge(
66 `read:${skill}`,
67 `this command failed after you read the skill in ${skill}.`
68 );
69 }
70 if (count >= 2) {
71 return nudge(`repeat:${key}`, "the same command has now failed twice.");
72 }
73
74 return null;
75 }
76
77 return {
78 /** Returns a reminder for the model, or null. */
79 observe({ tool, input, isError, level }: Observation): string | null {
80 if (tool === TOOL) {
81 lastFailed = false;
82
83 return null;
84 }
85 if (
86 tool === "Read" &&
87 typeof input.file_path === "string" &&
88 SKILL_FILE.test(input.file_path)
89 ) {
90 const dir = dirname(input.file_path);
91 if (!readDirs.includes(dir)) readDirs.unshift(dir);
92 readDirs.length = Math.min(readDirs.length, MAX_DIRS);
93
94 return null;
95 }
96 if (tool !== "Bash" || typeof input.command !== "string") return null;
97 const reminder = onBash(input.command, isError);
98
99 return level >= 2 ? reminder : null;
100 }
101 };
102}
103types/index.d.ts 42 lines1export type AndonColor = "yellow" | "red";
2
3export type AndonStatus = "open" | "resolved" | "dismissed";
4
5/** Which alerts the board shows. */
6export type AndonFilter = "open" | "red" | "yellow" | "closed" | "all";
7
8/** An alert as `andon list --json` prints it (the fields the board reads). */
9export type AndonAlert = {
10 id: string;
11 created_at: string;
12 status: AndonStatus;
13 color: AndonColor;
14 kind: string;
15 level: number;
16 summary: string;
17 sources: string[];
18 fingerprint: string;
19 recurrences: number;
20 conversation_id?: string | null;
21};
22
23/** Open alerts across every session, as `andon stats --json` counts them. */
24export type AndonCounts = { open: number; red: number };
25
26declare module "claude-code" {
27 interface PluginState {
28 andon: {
29 /** The trigger level locked for this session: ANDON_LEVEL, else `andon level`, else 3. */
30 defaultLevel: number;
31 /** From the classic hook envelope; tool calls do not carry it. */
32 transcriptPath: string | null;
33 counts: AndonCounts;
34 board: AndonAlert[];
35 filter: AndonFilter;
36 isSessionOnly: boolean;
37 /** One line under the board's header: the last action or failure. */
38 notice: string | null;
39 };
40 }
41}
42