SLOPSHOPPER

andon

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.

paneguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · andon
│ ┃ Andon ✕ › fix the failing auth test and add an audit log call │ ┃ Andon · level 3 · 0 open, 0 red │ ┃ [ Open ] [ Red ] [ Yellow ] [ Closed ] [ All ⏺ Read(src/auth.ts) │ ┃ Could not read the alert store; see `andon ⎿ Read 6 lines │ ┃ list`. ⏺ Update(src/auth.ts) │ ┃ No alerts here. ⎿ 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 │ │ › /andon │ ⎿ andon: Andon board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Andon
Andon · level 3 · 0 open, 0 red [ Open ] [ Red ] [ Yellow ] [ Closed ] [ All ] [ All session Could not read the alert store; see `andon list`. No alerts here.
README

.dotfiles

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.

Repository layout

.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.

Agent skills

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.

Key skills

SkillDescription
adbADB device automation with a Wear OS focus: screenshots, screen recording, tiles, dumpsys, logcat
agent-toolsCLI tools delegating analysis to AI models: image description, screenshot diffing, deep research
apkOffline APK binary analysis: manifests, resources, launcher icons, split APKs
coding-standardsThis repo's coding standards: shell/Python/Markdown formatting, CLI design, commit message style
jetpackCurrent 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.

Installing skills into a workspace

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.

Overlays

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.

Purpose of each layer

RepoIntended for
~/.dotfilesPublic config, tools, and scripts
~/.privatePersonal secrets, API keys, private tooling
~/.corpWork machine config, internal tooling

Precedence

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.

How files are applied

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.

Overlay-aware paths

PathMechanismNotes
home/.*link_home_dotfiles (all overlays)Last overlay wins per filename
etc/agents/AGENTS.mdoverlay_path (highest wins)Symlinked to ~/.codex/, ~/.gemini/, ~/.claude/, ~/.config/opencode/ (gated on binaries)
etc/agents/agy/hooks.jsonlink_overlay_pathSymlinked to ~/.gemini/config/hooks.json (gated on agy)
etc/agents/agy/hookslink_overlay_pathSymlinked to ~/.gemini/config/hooks (gated on agy)
etc/agents/hooks/ensure-commit-hooksoverlay_pathRegistered as a PreToolUse hook in ~/.claude/settings.json (gated on claude)
etc/agents/opencode/plugins/ensure-commit-hooks.tslink_overlay_pathSymlinked to ~/.config/opencode/plugins/ (gated on opencode)
skills/agent-config/plugins/andon/opencodelink_overlay_pathSymlinked to ~/.config/opencode/plugins/andon (gated on opencode)
etc/shpool/config.tomllink_overlay_path
etc/starship/starship.tomllink_overlay_path
etc/ghostty/configlink_overlay_path
etc/bat/configlink_overlay_path
etc/uv/uv.tomllink_overlay_pathRestricts uv to its own Python builds (only-managed)
etc/code/link_overlay_filesVS Code settings.json, keybindings.json, etc.
etc/macos/KeyBindingsoverlay_pathrsync'd (not symlinked) due to macOS bug
etc/macos/Solarized.clroverlay_pathCopied to ~/Library/Colors/
etc/skill/defaults.shrun by install.shAdds or removes default skills with skill --scope defaults
skills/*/skill catalog sourceNot linked globally; added per workspace via skill add (see Agent skills)

Per-overlay update hooks

After 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.).

Expected layout

.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.

Secret management

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 -->

FunctionUsageDescription
setsecretsetsecret NAME [NAME...]Load secret(s) into the current shell
getsecretgetsecret NAMEPrint secret value(s) to stdout (array-safe)
envsecretenvsecret NAME [...] [--] CMDRun 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.

Environment management

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.

Installation

The same install.sh script both installs and updates, and runs in two ways. git must already be installed either way (see Prerequisites).

Option 1: Straight from the network

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)"

Option 2: From a local checkout

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.sh may overwrite unmanaged files in locations such as ~/Library/KeyBindings. It is otherwise safe to run multiple times.

Choosing what runs

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.

Cloud environments

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.

Prerequisites

git

  • Ubuntu/Debian: sudo apt-get install git
  • macOS: xcode-select --install

Platform-specific setup

macOS

  • Terminal: Import etc/macos/Solarized Dark.terminal and set it as the default profile.
  • Keyboard: System Preferences > Keyboard > Shortcuts > Services > File and Folders: enable "New Terminal at Folder".
  • Text Replacements: If not shared via iCloud, restore from etc/macos/Text Replacements.plist — see Back up and share text replacements on Mac.
  • Lock Screen: Add to Menu Bar via Keychain Access preferences.
  • Volume: Add to Menu Bar via Control Center > Sound > Always Show in Menu Bar.
  • Time Machine: Disable local snapshots: sudo tmutil disablelocal
  • Hot Corners: Disable via System Preferences.
  • Fonts: See <https://typography.guru/journal/awesome-catalina-fonts/>.
  • iA Writer theme: <https://ia.net/writer/templates/>
  • Network Link Conditioner (for simulating degraded network conditions): <https://developer.apple.com/download/more/?q=Additional%20Tools>

Raspberry Pi

Complete setup sequence for a fresh Raspberry Pi OS install.

  1. Use the Raspberry Pi Imager to write the OS to an SD card or SSD.
  • For a headless install: Raspberry Pi OS (other) > Raspberry Pi OS Lite.
  • In OS customisation: enable SSH (Remote access section).
  • (Optional) Configure Wi-Fi.
  1. SSH in: ssh mjs@lil.local (substitute your username and hostname).
  • (Optional) If using Ghostty, copy the terminfo from your local machine: infocmp -x xterm-ghostty | ssh mjs@lil.local -- tic -x -
  1. Update the OS:
   sudo apt-get update && sudo apt-get upgrade -y

Reboot if /var/run/reboot-required exists: sudo reboot

  1. Install git: sudo apt-get install git
  1. Install Tailscale: curl -fsSL https://tailscale.com/install.sh | sh then sudo tailscale up
  1. (Optional) Install Node.js and npm: sudo apt-get install nodejs npm (for newer versions see Node.js).
  1. (Optional) Disable WiFi and Bluetooth: add these lines under [all] in /boot/firmware/config.txt:
   dtoverlay=disable-wifi
   dtoverlay=disable-bt
  1. (Optional) Change hostname: edit /etc/hostname and /etc/hosts.
  1. Reboot after any optional steps above: sudo reboot

Once complete, proceed to Installation.

Tip: Use VS Code's Remote - SSH extension to edit files on the Pi.

Local binaries

Put manually installed binaries in ~/.local/bin.

Testing and CI

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.

Offline environments

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.

Author

<mjs@beebo.org>

Source 4 files
hooks/register.tsx 641 lines
1import { 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};
641
hooks/rule.ts 213 lines
1// 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);
213
hooks/sensor.ts 103 lines
1// 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}
103
types/index.d.ts 42 lines
1export 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