SLOPSHOPPER

hope

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…

newpanebandtoastpromptprocess
★ 35v13.1.0MITupdated 2026-09-30saadshahd/moo.md/hope
A shopper browsing a rack in a slop shop
README

skills.sh

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.

Installation (30-second setup)

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.

1. Get the skills

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

2. Run the loop

/hope:full <your seed>

Or run the stages directly — /hope:intent <your seed>, then /hope:shape <your intent>.

A loop

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.

The loop

An anchor

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.

freeze

A mode

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.

Deciding stays, doing leaves

Overview

The trapLayerGuard
AI fills in your decisions/hope:intent & /hope:shapeinteractive questions, each choice previewed
compaction mutates & drifts your context/hope:routerdoing stays out, deciding stays in
stale or remembered external state/hope:freezesnapshot facts, never infer

Reading

Source 2 files
hooks/slop.tsx 418 lines
1import 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};
418
slop.d.ts 52 lines
1// 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