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

<img src="assets/logo-light.svg" alt="exo" width="280">
<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>
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.
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>.
| Stage | Skill | What it does |
|---|---|---|
| 1 | spec | Decides what to build. |
| 2 | build | Builds a plan or a decided change with subagents. |
| 3 | verify | Checks the branch before a pull request. |
| 4 | ship | Pushes, 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.
Each "before" is plain Claude Code. Nothing here compares exo with other plugins.
| Before | After | Test |
|---|---|---|
| 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.
MIT licensed.
hooks/run-watch.mjs 193 lines1// 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