SLOPSHOPPER

exo

Claude Code plugin with four stages and hooks that refuse risky commands.

statusprompttimer
★ 1v0.137.0MITupdated 2026-10-26blauwtje/exo
A shopper browsing a rack in a slop shop
README

<img src="assets/logo-light.svg" alt="exo" width="280">

exo

<strong>An exoskeleton for AI engineering. A plugin for Claude Code and Codex.</strong>

<a href="https://github.com/blauwtje/exo/actions/workflows/quality.yml"><img src="https://github.com/blauwtje/exo/actions/workflows/quality.yml/badge.svg" alt="quality"></a>

Install

curl -fsSL https://raw.githubusercontent.com/blauwtje/exo/main/install.sh | bash

That asks which harnesses and projects get exo, then installs it. The script keeps its copy in ~/.exo.

It needs git, Node 22 or newer, and bash on PATH for the hooks. On Windows, Git Bash runs them; support is best effort.

Inside Claude Code, run these, then restart it:

/plugin marketplace add blauwtje/exo
/plugin install exo@blauwtje

To check, run /exo:configure in a new session. If it lists exo's settings, exo is loaded.

curl -fsSL https://raw.githubusercontent.com/blauwtje/exo/main/install.sh | bash -s -- --harness codex

To check, run $configure in a new session. Codex needs a ChatGPT plan that includes Sol: Plus, Pro, Business, Enterprise or Edu. docs/codex.md lists what differs from Claude Code.

These act on every install the script made.

curl -fsSL https://raw.githubusercontent.com/blauwtje/exo/main/install.sh | bash -s -- --update
curl -fsSL https://raw.githubusercontent.com/blauwtje/exo/main/install.sh | bash -s -- --remove

Installed from inside Claude Code? Update with claude plugin update exo@blauwtje in a terminal, then restart. Uninstall with /plugin uninstall exo@blauwtje.

Already have exo? Run the install command again; it updates the copy in ~/.exo.

Use

Describe what you want and Claude starts the skill that fits. A feature goes through four stages, and each asks which runs next. A bug goes to find-cause first. If you do not know a skill's name, type /exo:start <goal>.

StageSkillWhat it does
1specDecides what to build.
2buildBuilds a plan or a decided change with subagents.
3verifyChecks the branch before a pull request.
4shipPushes, opens or merges the pull request.

Other skills, written as you call them. Those with /exo: start only when you type them:

  • find-cause: finds why something broke before any code changes.
  • refactor: restructures code without changing what it does.
  • design-ui: builds the real page or component directly, asks only when it cannot build without the answer, and finishes motion, reduced motion and every state.
  • /exo:check-docs: looks up how the installed version of a library behaves.
  • /exo:write-docs: writes READMEs, pull requests and commit messages.
  • /exo:file-issues: writes GitHub issues and files them.
  • /exo:prompt: rewrites a rough prompt and runs it after you say yes.
  • /exo:improve: changes code in a loop against one fixed score command, keeps a commit only when the score gets better, and logs every round.
  • /exo:save-session: saves unfinished work for a fresh session.
  • /exo:remember: keeps a repository correction after two sessions have booked it.
  • /exo:run-plan: builds a plan through build; /exo:run-plan setup installs exo run <plan> for your terminal.
  • /exo:configure: shows or changes exo's settings.

docs/skills/ has a page for every skill. docs/settings.md covers the settings, including shorter replies and the status line.

Before and after

Each "before" is plain Claude Code. Nothing here compares exo with other plugins.

BeforeAfterTest
A task lands once Claude says the tests pass, even if the proof never failed.A task lands only if its proof fails before the change and passes after; otherwise no red proof.tests/red-bound.test.mjs
A task lands even when the project's own check is red.A failing Land gate is refused before anything commits.tests/land-task-land-gate.test.mjs

On value-test-pollution, a suite that fails only in CI, plain Claude Code passed 0 of 5 runs and exo 2 of 5. Over all five benchmark tasks it was 18 of 25 against 20 of 25. That is 0.7 standard errors, so it does not separate them, and exo cost 1.38 times as much. See the results.

More

MIT licensed.

Source 1 files
hooks/run-watch.mjs 193 lines
1// why: a mod cannot import, so this copy stays; keep it equal to FINAL_PHASES in run-state.mjs.
2export const FINAL_PHASES = ['passed', 'landed', 'parked', 'stopped', 'refused', 'crashed'];
3const TICK_MS = 5000;
4const STALE_MS = 3600000;
5const WORKTREES_MARK = '/.git/worktrees/';
6const REPORT_PHASES = ['landed', 'passed'];
7const RUN_START = /run start:/;
8const LIVE_AGENT = ['pending', 'running', 'waiting'];
9
10function parseState(text) {
11  try {
12    return JSON.parse(text);
13  } catch {
14    return null;
15  }
16}
17
18async function readState($, file) {
19  try {
20    return parseState(await $.fs.read(file));
21  } catch {
22    return null;
23  }
24}
25
26async function readRuns($, base) {
27  let entries;
28  try {
29    entries = await $.fs.list(base);
30  } catch {
31    return [];
32  }
33  const dirs = entries.filter((entry) => entry.kind === 'dir');
34  const runs = await Promise.all(dirs.map(async (dir) => ({ id: dir.name, dir: `${base}/${dir.name}`, state: await readState($, `${base}/${dir.name}/state.json`) })));
35  return runs.filter((run) => run.state);
36}
37
38// why: a mod cannot import node:path; a gitdir may be relative to the worktree (worktree.useRelativePaths) or use backslashes.
39function resolveFrom(base, path) {
40  if (/^([A-Za-z]:)?\//.test(path)) return path;
41  const parts = base.replace(/\\/g, '/').split('/');
42  for (const part of path.split('/')) {
43    if (part === '..') parts.pop();
44    else if (part && part !== '.') parts.push(part);
45  }
46  return parts.join('/');
47}
48
49async function mainRoot($, root) {
50  let text;
51  try {
52    text = await $.fs.read(`${root}/.git`);
53  } catch {
54    return root;
55  }
56  const gitdir = typeof text === 'string' && text.startsWith('gitdir:') ? resolveFrom(root, text.slice('gitdir:'.length).trim().replace(/\\/g, '/')) : '';
57  const at = gitdir.indexOf(WORKTREES_MARK);
58  return at >= 0 ? gitdir.slice(0, at) : root;
59}
60
61async function readAllRuns($, root) {
62  const main = await mainRoot($, root);
63  const own = await readRuns($, `${root}/.exo/run`);
64  const other = main === root ? [] : await readRuns($, `${main}/.exo/run`);
65  const ids = new Set(own.map((run) => run.id));
66  return [...own, ...other.filter((run) => !ids.has(run.id))].sort((a, b) => (a.id < b.id ? -1 : 1));
67}
68
69async function newestMtime($, dir) {
70  try {
71    const entries = await $.fs.list(dir);
72    return Math.max(0, ...entries.map((entry) => entry.mtimeMs ?? 0));
73  } catch {
74    return Date.now();
75  }
76}
77
78async function isLive($, run) {
79  if (FINAL_PHASES.includes(run.state.phase)) return true;
80  return Date.now() - (await newestMtime($, run.dir)) <= STALE_MS;
81}
82
83function statusLine(run, now) {
84  const { landed = 0, total = 0, startedAt } = run.state;
85  const minutes = Math.max(0, Math.floor((now - Date.parse(startedAt)) / 60000));
86  const filled = total ? Math.min(8, Math.floor((landed / total) * 8)) : 0;
87  return `${'▰'.repeat(filled)}${'▱'.repeat(8 - filled)}  ${landed} of ${total}  ·  ${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
88}
89
90function wakeText(run) {
91  const { phase, reason } = run.state;
92  const head = `exo run ${run.id} ended ${phase}${reason ? `: ${reason}` : ''}.`;
93  if (REPORT_PHASES.includes(phase)) return `${head} Report the result to the user.`;
94  return `${head} Follow exo:spec references/watching.md to repair and resume.`;
95}
96
97async function readLog($, dir) {
98  try {
99    return await $.fs.read(`${dir}/run.log`);
100  } catch {
101    return '';
102  }
103}
104
105async function wake($, context, run) {
106  const before = context.seen.get(run.id);
107  context.seen.set(run.id, run.state.phase);
108  if (!FINAL_PHASES.includes(run.state.phase)) {
109    context.notified.delete(run.id);
110    return;
111  }
112  if (before === undefined || FINAL_PHASES.includes(before)) return;
113  if (context.notified.has(run.id)) return;
114  if (!context.sessionId || run.state.ownerSession !== context.sessionId) return;
115  const log = await readLog($, run.dir);
116  context.pending.set(run.id, { dir: run.dir, logLength: log.length });
117}
118
119async function agentLive($) {
120  try {
121    return (await $.agent.list()).some((agent) => LIVE_AGENT.includes(agent.status));
122  } catch {
123    return false;
124  }
125}
126
127async function flush($, context) {
128  if (await agentLive($)) return;
129  for (const [id, stop] of context.pending) {
130    context.pending.delete(id);
131    const state = await readState($, `${stop.dir}/state.json`);
132    if (context.notified.has(id) || !FINAL_PHASES.includes(state?.phase)) continue;
133    const log = await readLog($, stop.dir);
134    if (!RUN_START.test(log.slice(stop.logLength))) void $.prompt.submit({ text: wakeText({ id, state }) });
135  }
136}
137
138async function tick($, context) {
139  const root = await $.session.root();
140  // why: the session id changes on /clear, so it is read on every tick.
141  // why: a missing or throwing id counts as no match, so the status line still draws.
142  context.sessionId = await Promise.resolve().then(() => $.session.id()).catch(() => undefined);
143  const all =await readAllRuns($, root);
144  const flags = await Promise.all(all.map((run) => isLive($, run)));
145  const runs = all.filter((run, i) => flags[i]);
146  for (const run of runs) {
147    if (context.first && FINAL_PHASES.includes(run.state.phase)) context.seen.set(run.id, run.state.phase);
148    else await wake($, context, run);
149  }
150  context.first = false;
151  const own = runs.filter((run) => !FINAL_PHASES.includes(run.state.phase) && (run.dir.startsWith(`${root}/.exo/run/`) || run.state.runRoot === root));
152  const newest = own[own.length - 1];
153  // why: the engine draws no PromptHint row under a custom settings.statusLine; ui.status shows beside it.
154  $.ui.status(newest ? statusLine(newest, await $.clock.now()) : undefined);
155  if (!context.busy) await flush($, context);
156}
157
158async function noteNotification($, context, text) {
159  const file = /<output-file>([^<]+)<\/output-file>/.exec(text)?.[1]?.trim();
160  const output = file ? await $.fs.read(file).catch(() => '') : '';
161  for (const id of context.seen.keys()) {
162    if (!text.includes(id) && !new RegExp(`(^|\\n)exo: run ${id} `).test(output)) continue;
163    context.pending.delete(id);
164    context.notified.add(id);
165  }
166}
167
168export function register(on) {
169  const context = { first: true, busy: false, sessionId: undefined, seen: new Map(), pending: new Map(), notified: new Set() };
170  on('session.start', ($, e, next) => {
171    if (!e.isInteractive) return next(e);
172    $.clock.every(TICK_MS, () => tick($, context));
173    return next(e);
174  });
175  on('turn.start', ($, e, next) => {
176    context.busy = true;
177    return next(e);
178  });
179  on('turn.complete', async ($, e, next) => {
180    context.busy = false;
181    await flush($, context);
182    return next(e);
183  });
184  on('session.receive', async ($, e, next) => {
185    if (e.origin.kind === 'task-notification') await noteNotification($, context, e.text);
186    return next(e);
187  });
188  on('prompt.submit', async ($, e, next) => {
189    if (e.origin?.kind === 'task-notification') await noteNotification($, context, e.text);
190    return next(e);
191  });
192}
193