Thinking discipline for AI work. Clarify intent, shape approach, consult experts, guard unsupervised work, hand work back so you own it. The slop hook needs…

Why introduce friction? Because YOU the human end up being the world model. The agent is just your hands.
moo/hope doesn't build, and doesn't author your code, rules, or taste. It works alongside build tooling like superpowers.
One idea runs through every layer: never delegate a decision.
Two ways in, two philosophies. The Claude Code plugin installs each layer as a managed bundle that updates when I ship — you subscribe rather than fork. skills.sh copies editable skill files into your project, on any agent, so you can make them your own. Pick one — installing both leaves you with every skill twice.
/plugin marketplace add saadshahd/moo.md
/plugin install hope@moo.md
hope is the decision loop, and the reason to be here. More layers ship from the same marketplace: hold@moo.md, comprehension at your own pace, sound@moo.md, code taste as installable rules, and hunch@moo.md, experiments that graduate or die.
npx skills@latest add saadshahd/moo.md
Pick the skills you want and the agents to install them on — but take hope and hold whole if you take them at all: their skills call each other by name, and a missing one stops the skill that called it. sound and hunch have no such requirement.
They land as ordinary files you own — flat, and without the plugin prefix, so hope:intent arrives as intent and sound:review as review. Watch for collisions with skills you already have.
/hope:full <your seed>
Or run the stages directly — /hope:intent <your seed>, then /hope:shape <your intent>.
The loop is pure decision work. You drive it slowly, in your own context.
Every piece of work starts as a raw <seed>, the rough thing you fully typed. It is the one artifact that is fully yours.
/intent and /shape do not make the seed more honest. They make it explicit and specified. Each surfaces a decision as an interactive question, every choice previewed, a few at a time, and you answer. Every added detail stays yours because you chose it. /intent turns the <seed> into a confirmed statement of what you want. /shape turns that <work order> into a chosen approach before any code exists.
The outcome is short enough to carry comfortably outside the context window.

freeze anchors the work to what is observed, not what you remember. When a stage depends on state that lives outside the repo — a service, a database, a queue, live logs — that state keeps moving, and memory of it goes stale the moment you look away.
freeze snaps the slice your work touches into one immutable value: every fact observed live or named as an open gap, never inferred. The stages decide against a fact, not a guess. It is the ground the other three stand on — and repo-local work skips it.

router is the line between deciding and doing.
You keep every decision. The session spawns agents and verifies their output, doing no work itself — only tactical, observable work fans out: implement, test, verify, audit, explore.
This is also what stops compaction from silently rewriting your context. The verbose doing never enters your main thread, so it can never quietly mutate what you decided.

| The trap | Layer | Guard |
|---|---|---|
| AI fills in your decisions | /hope:intent & /hope:shape | interactive questions, each choice previewed |
| compaction mutates & drifts your context | /hope:router | doing stays out, deciding stays in |
| stale or remembered external state | /hope:freeze | snapshot facts, never infer |
hooks/slop.tsx 418 lines1import type { Register } from "claude-code";
2import { update } from "claude-code";
3import type { Chosen, Finding, Mark, Slop } from "../slop.d.ts";
4
5// After a main-thread turn that edited repo-bound files, judge.sh judges them off the turn.
6// A `slop N` button waits above the prompt. It opens a pane showing one finding at a time as the
7// diff that fixes it, and the row turns into the accept, edit or reject choice for that finding: the
8// row already holds the keys, which a pane opened from it would not get. Nothing here starts a turn.
9
10const SLOP = { plugin: "hope", key: "slop" } as const;
11const PANE = "slop";
12
13const EMPTY: Slop = {
14 findings: [],
15 cursor: 0,
16 marks: [],
17 reviewing: false,
18 editing: false,
19 chosen: [],
20 seen: [],
21 offset: 0,
22};
23
24const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
25
26export const FIX_STEM = "Fix the slop findings ";
27
28// ---- pure ----
29
30type Message = {
31 role: string;
32 toolUses?: {
33 tool: string;
34 input: Record<string, unknown>;
35 isError?: true;
36 }[];
37};
38
39/** The files the messages from `offset` on edited, each once. A refused or failed edit
40 * changed nothing, so it names no file. */
41export function touchedFiles(messages: Message[], offset: number): string[] {
42 const paths = messages.slice(offset).flatMap((m) =>
43 m.role === "assistant"
44 ? (m.toolUses ?? []).flatMap((u) => {
45 const p = u.input.file_path ?? u.input.notebook_path;
46 return EDIT_TOOLS.has(u.tool) && !u.isError && typeof p === "string"
47 ? [p]
48 : [];
49 })
50 : [],
51 );
52 return [...new Set(paths)];
53}
54
55const HEADER = /^(.+?):(\d+) \| (.+?) \| (.+)$/;
56
57/** judge.sh's output as findings: none when it printed nothing or CLEAN first. A header
58 * "<file>:<line> | <rule> | <claim>" starts a finding and the "- " / "+ " lines after it are its
59 * change, a bare "-" or "+" being a blank line of it. Every other line after a header, a code fence
60 * say, belongs to that finding and is dropped; before any header, a line is a finding of its text alone. */
61export function parseFindings(stdout: string): Finding[] {
62 const lines = stdout.split("\n").filter((l) => l.trim() !== "");
63 if (lines.length === 0 || lines[0].trim() === "CLEAN") return [];
64 const found: Finding[] = [];
65 for (const l of lines) {
66 const last = found.at(-1);
67 const m = HEADER.exec(l.trim());
68 const d = /^\s*([-+])( |$)/.exec(l);
69 if (m)
70 found.push({
71 file: m[1],
72 line: Number(m[2]),
73 rule: m[3],
74 claim: m[4],
75 before: [],
76 after: [],
77 });
78 else if (!last?.file)
79 found.push({ rule: "", claim: l.trim(), before: [], after: [] });
80 else if (d)
81 (d[1] === "-" ? last.before : last.after).push(l.slice(d[0].length));
82 }
83 return found;
84}
85
86/** One key per rule on a file, so the judge rewording a rule between turns keys it the same:
87 * lowercased, parentheticals and a leading "no " dropped. A finding with no file has no key. */
88export function findingKey(f: Finding): string | undefined {
89 if (!f.file) return undefined;
90 return `${f.file}|${f.rule}`
91 .toLowerCase()
92 .replace(/\([^)]*\)/g, "")
93 .replace(/\|no /, "|")
94 .replace(/[^a-z0-9|/._-]/g, "");
95}
96
97/** The findings not shown before, and the seen keys with theirs added. A keyless one always shows. */
98export function freshFindings(
99 found: Finding[],
100 seen: string[],
101): { fresh: Finding[]; seen: string[] } {
102 const known = new Set(seen);
103 const fresh = found.filter((f) => {
104 const k = findingKey(f);
105 if (k === undefined) return true;
106 if (known.has(k)) return false;
107 known.add(k);
108 return true;
109 });
110 return { fresh, seen: [...known] };
111}
112
113/** A git status line keeps a file when it is tracked: not untracked (??), not ignored (!!). */
114export function isRepoBound(status: string): boolean {
115 return !status.startsWith("??") && !status.startsWith("!!");
116}
117
118/** A finding carries a change when it names a file and lines to take out or put in. */
119export function hasChange(f: Finding): boolean {
120 return !!f.file && f.before.length + f.after.length > 0;
121}
122
123/** `text` with the finding's change made: its "-" lines, found at its line or else in one place
124 * anywhere, swapped for its "+" lines. Throws when they are in no place, or in several but its line. */
125export function applyChange(text: string, f: Finding): string {
126 const lines = text.split("\n");
127 const at = (f.line ?? 1) - 1;
128 const matches = (i: number) => f.before.every((b, j) => lines[i + j] === b);
129 const starts = matches(at) ? [at] : lines.map((_, i) => i).filter(matches);
130 if (starts.length !== 1)
131 throw new Error(
132 `${f.file}:${f.line}: the lines to replace are ${starts.length ? `in ${starts.length} places` : "not in the file"}`,
133 );
134 lines.splice(starts[0]!, f.before.length, ...f.after);
135 return lines.join("\n");
136}
137
138/** The finding's change as one unified-diff hunk. */
139export function hunk(f: Finding): string {
140 const line = f.line ?? 1;
141 return [
142 `@@ -${line},${f.before.length} +${line},${f.after.length} @@`,
143 ...f.before.map((l) => `-${l}`),
144 ...f.after.map((l) => `+${l}`),
145 ].join("\n");
146}
147
148/** The state after deciding the finding under the cursor. Past the last one, the review ends:
149 * the findings for Claude wait for the hand-off and decided findings leave. An accepted or edited
150 * finding's key is forgotten, so the same rule broken there again shows again; a rejected one
151 * stays quiet. An accepted finding goes to Claude only when it carries no change to write. */
152export function decide(s: Slop, mark: Mark): Slop {
153 if (!s.findings[s.cursor]) return s;
154 const marks = [...s.marks, mark];
155 const cursor = s.cursor + 1;
156 if (cursor < s.findings.length)
157 return { ...s, cursor, marks, editing: false };
158 const decided = marks.map((m, i) => ({ m, finding: s.findings[i]! }));
159 const taken = decided.filter(({ m }) => m.kind !== "reject");
160 const forget = new Set(taken.map(({ finding }) => findingKey(finding)));
161 const chosen = decided.flatMap(({ m, finding }): Chosen[] => {
162 if (m.kind === "edit") return [{ finding, note: m.note }];
163 if (m.kind === "accept" && !hasChange(finding)) return [{ finding }];
164 return [];
165 });
166 return {
167 ...s,
168 findings: [],
169 cursor: 0,
170 marks: [],
171 reviewing: false,
172 editing: false,
173 chosen: [...s.chosen, ...chosen],
174 seen: s.seen.filter((k) => !forget.has(k)),
175 };
176}
177
178/** How the chosen findings read to Claude: each located, with the change the judge suggested and
179 * the person's words over it when they edited it. */
180export function handoff(chosen: Chosen[]): string {
181 return [
182 "Slop findings to fix:",
183 ...chosen.flatMap(({ finding: f, note }) => [
184 ...(f.file
185 ? [
186 `${f.file}:${f.line} — ${f.claim} (${f.rule})`,
187 ...f.before.map((l) => `- ${l}`),
188 ...f.after.map((l) => `+ ${l}`),
189 ]
190 : [f.claim]),
191 ...(note
192 ? [`The person's edit, which wins over the change above: ${note}`]
193 : []),
194 ]),
195 ].join("\n");
196}
197
198// ---- effects ----
199
200async function readSlop($: any): Promise<Slop> {
201 const { value } = await $.state.get(SLOP);
202 return { ...EMPTY, ...value };
203}
204
205function change($: any, fn: (s: Slop) => Slop): Promise<unknown> {
206 return update($, SLOP, (s: Slop | undefined) => fn({ ...EMPTY, ...s }));
207}
208
209async function repoBound($: any, files: string[]): Promise<string[]> {
210 const kept = await Promise.all(
211 files.map(async (f) => {
212 const dir = f.slice(0, f.lastIndexOf("/")) || "/";
213 const r = await $.process
214 .run([
215 "git",
216 "-C",
217 dir,
218 "status",
219 "--porcelain",
220 "--ignored=matching",
221 "--",
222 f,
223 ])
224 .catch(() => undefined);
225 // No git, or no worktree holds the file: out.
226 return r && r.exitCode === 0 && isRepoBound(r.stdout) ? f : undefined;
227 }),
228 );
229 return kept.filter((f): f is string => f !== undefined);
230}
231
232async function judge($: any, files: string[]): Promise<void> {
233 const r = await $.process.run([`${$.plugin.root}/hooks/judge.sh`], {
234 cwd: await $.session.root(),
235 stdin: files.join("\n") + "\n",
236 timeoutMs: 600_000,
237 });
238 if (r.exitCode !== 0) {
239 const why = `judge.sh exited ${r.exitCode}: ${r.stderr.trim().split("\n")[0].slice(0, 200)}`;
240 // The same failure shows once a session, not on every edit turn.
241 const { seen } = await readSlop($);
242 if (seen.includes(`error|${why}`)) return;
243 await change($, (s) => ({ ...s, seen: [...s.seen, `error|${why}`] }));
244 throw new Error(why);
245 }
246 const found = parseFindings(r.stdout);
247 if (found.length === 0) return;
248 await change($, (s) => {
249 const { fresh, seen } = freshFindings(found, s.seen);
250 return { ...s, seen, findings: [...s.findings, ...fresh] };
251 });
252}
253
254export async function afterTurn($: any): Promise<void> {
255 const messages = await $.session.messages();
256 const { offset } = await readSlop($);
257 await change($, (s) => ({ ...s, offset: messages.length }));
258 const files = await repoBound($, touchedFiles(messages, offset));
259 if (files.length) await judge($, files);
260}
261
262async function openReview($: any): Promise<void> {
263 await change($, (s) => ({ ...s, reviewing: true }));
264 const r = await $.ui.open({ id: PANE, title: "slop", closeOnEscape: true });
265 if (!r.isPlaced) {
266 await change($, (s) => ({ ...s, reviewing: false }));
267 $.ui.toast(`hope slop: ${r.reason}`);
268 }
269}
270
271// The last decision ends the review: the pane closes, and when any finding waits for Claude the
272// stem goes in the prompt box for the person to finish.
273async function choose($: any, mark: Mark): Promise<void> {
274 await change($, (s) => decide(s, mark));
275 const s = await readSlop($);
276 if (s.reviewing) return;
277 await $.ui.close({ id: PANE });
278 if (s.chosen.length) await $.prompt.fill({ text: FIX_STEM });
279}
280
281// A change the file no longer holds throws, and the finding stays under the cursor.
282async function accept($: any): Promise<void> {
283 const { findings, cursor } = await readSlop($);
284 const f = findings[cursor];
285 if (f && hasChange(f)) {
286 const path = f.file!.startsWith("/")
287 ? f.file!
288 : `${await $.session.root()}/${f.file}`;
289 await $.fs.write(path, applyChange(await $.fs.read(path), f));
290 }
291 await choose($, { kind: "accept" });
292}
293
294// A failure shows as a toast; the session goes on (hooks fail open).
295function loud($: any, work: Promise<unknown>): void {
296 work.catch((err) => $.ui.toast(`hope slop: ${err?.message ?? err}`));
297}
298
299// ---- hooks ----
300
301export const register: Register = (on) => {
302 on("turn.complete", async ($, e, next) => {
303 const r = await next(e);
304 // The judge runs past this dispatch, so the turn's end is never held.
305 if (!e.agentId) loud($, afterTurn($));
306 return r;
307 });
308
309 // The chosen findings reach Claude only with the person's own sentence, on their Enter.
310 on("prompt.submit", async ($, e, next) => {
311 const s = await readSlop($);
312 if (!s.chosen.length || !e.text.startsWith(FIX_STEM.trim())) return next(e);
313 await change($, (t) => ({ ...t, chosen: [] }));
314 return next({ ...e, context: [...(e.context ?? []), handoff(s.chosen)] });
315 });
316
317 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
318 const below = await next(e);
319 if (e.props.hasSurvey) return below;
320 const s = await readSlop($);
321 if (s.findings.length === 0) return below;
322 const els = $.ui.resolve(e);
323 const { Box, Text, Button } = els;
324 // The mobile app draws no field, so there a finding offers no edit.
325 const Input = "Input" in els ? els.Input : undefined;
326 const count = (
327 <Text dimColor>{`slop ${s.cursor + 1} of ${s.findings.length}`}</Text>
328 );
329 return (
330 <Box flexDirection="column">
331 {s.reviewing && s.editing && Input ? (
332 <Box gap={1}>
333 {count}
334 <Input
335 key="slop-note"
336 label="edit ›"
337 placeholder="what Claude should do instead; empty goes back"
338 submitLabel="keep for Claude"
339 autoFocus
340 onSubmit={(v: string) =>
341 loud(
342 $,
343 v.trim()
344 ? choose($, { kind: "edit", note: v.trim() })
345 : change($, (t) => ({ ...t, editing: false })),
346 )
347 }
348 />
349 </Box>
350 ) : s.reviewing ? (
351 <Box gap={1}>
352 {count}
353 <Button
354 key="slop-accept"
355 label="accept"
356 autoFocus
357 onPress={() => loud($, accept($))}
358 />
359 {Input ? (
360 <Button
361 key="slop-edit"
362 label="edit"
363 onPress={() =>
364 loud(
365 $,
366 change($, (t) => ({ ...t, editing: true })),
367 )
368 }
369 />
370 ) : null}
371 <Button
372 key="slop-reject"
373 label="reject"
374 onPress={() => loud($, choose($, { kind: "reject" }))}
375 />
376 </Box>
377 ) : (
378 <Button
379 key="slop-open"
380 label={`slop ${s.findings.length}`}
381 onPress={() => loud($, openReview($))}
382 />
383 )}
384 {below}
385 </Box>
386 );
387 });
388
389 // Closing the pane ends the review; the undecided findings wait for the next one.
390 on("ui.close", async ($, e, next) => {
391 const r = await next(e);
392 if (e.id === PANE)
393 await change($, (s) => ({ ...s, reviewing: false, editing: false }));
394 return r;
395 });
396
397 // The finding under the cursor, whole: where it is, what is wrong, and the diff that fixes it.
398 on("ui.render", { component: "Pane" }, async ($, e, next) => {
399 if (e.requestId !== PANE) return next(e);
400 const s = await readSlop($);
401 const f = s.findings[s.cursor];
402 const { Box, Text, Code } = $.ui.resolve(e);
403 if (!f) return <Text dimColor>no findings left</Text>;
404 return (
405 <Box flexDirection="column" paddingX={1} gap={1}>
406 <Text bold wrap="wrap">
407 {f.file ? `${f.file}:${f.line}` : f.claim}
408 </Text>
409 {f.file ? <Text wrap="wrap">{f.claim}</Text> : null}
410 {hasChange(f) ? (
411 <Code key="slop-diff" format="diff" path={f.file} source={hunk(f)} />
412 ) : null}
413 {f.rule ? <Text dimColor>{f.rule}</Text> : null}
414 </Box>
415 );
416 });
417};
418slop.d.ts 52 lines1// hope's contract: the session state the slop judge writes and its review reads.
2// Held by the host for the session only. Callers spell the ref as a literal in their own file:
3// { plugin: "hope", key: "slop" }.
4
5/** One judge finding. A line the judge wrote in another shape keeps only its text as `claim`. */
6export type Finding = {
7 file?: string;
8 line?: number;
9 /** The violated preference, a few words. */
10 rule: string;
11 /** What is wrong there, one sentence. */
12 claim: string;
13 /** The judge's suggested change: the current lines from `line` on, and their replacement. */
14 before: string[];
15 after: string[];
16};
17
18/** The decision on one finding: accept writes its change to the file (or hands it to Claude when it
19 * carries none), edit hands it to Claude with what the person wants instead, reject drops it. */
20export type Mark =
21 | { kind: "accept" }
22 | { kind: "edit"; note: string }
23 | { kind: "reject" };
24
25/** A finding for Claude to fix, with the person's words when they edited it. */
26export type Chosen = { finding: Finding; note?: string };
27
28export type Slop = {
29 /** Findings waiting for a decision, oldest first. */
30 findings: Finding[];
31 /** The finding the review asks about; the ones before it are decided. */
32 cursor: number;
33 /** The decision on each finding before the cursor, in order. */
34 marks: Mark[];
35 /** The review pane is open, so the row above the prompt asks accept, edit or reject. */
36 reviewing: boolean;
37 /** The row holds the field for the edit of the finding under the cursor. */
38 editing: boolean;
39 /** Findings for Claude; the next submit that keeps the fix stem carries them. */
40 chosen: Chosen[];
41 /** Keys of every finding or failure shown this session, so each shows once. */
42 seen: string[];
43 /** How many session messages were already scanned for touched files. */
44 offset: number;
45};
46
47declare module "claude-code" {
48 interface PluginState {
49 hope: { slop: Slop };
50 }
51}
52