SLOPSHOPPER

lean-and-mean

Concise prose and YAGNI code, installed into the project AGENTS.md. /endsession writes Rules and Next, commits, pushes, then stops.

newbandtoastpromptmodeltimer
v5.8.0MITupdated 2026-10-08spinlockdevelopment/lean-and-mean
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lean-and-mean
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 exp. 60m [ end session ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
exp. 60m [ end session ]
README

lean-and-mean

Overview → spinlockdevelopment.github.io/lean-and-mean · in-depth guide

A Claude Code and Codex plugin. It writes concise prose and YAGNI code rules into your project's AGENTS.md once, so they apply every session with no plugin code in the loop. /endsession closes a session: it writes what was learned back into that file, commits, pushes, then stops.

  • Prose. Answer first. Concise, active sentences in plain technical English, broadly following ASD-STE100. No filler or repetition. A diagram or an HTML page when that explains it better.
  • Code. A YAGNI ladder: skip speculative work, then reuse the repo, stdlib, the platform, an installed dependency, one line, and only then new code. Never cut input validation, data-loss handling, security, or accessibility.
  • Memory. Each correction becomes one binding line under ## Rules, so it does not happen twice.

Why use it

Faster, fewer tokens, lower cost: short answers, less code to review, and short sessions that keep each request's context small. The measured numbers: why sessions should be short.

Install

Claude Code (or /plugin marketplace add … and /plugin install … in the app):

claude plugin marketplace add spinlockdevelopment/lean-and-mean
claude plugin install lean-and-mean@lean-and-mean

Restart Claude Code, then run /lean-and-mean:lean-and-mean once per project.

Codex:

codex plugin marketplace add spinlockdevelopment/lean-and-mean
codex plugin add lean-and-mean@lean-and-mean

Start a new session at the project root, trust the SessionStart hook when prompted, and run $lean-and-mean once.

Local checkouts, manual installs, and Codex notes: install details.

Use

Claude CodeCodexEffect
/lean-and-mean:lean-and-mean$lean-and-meanCreate or review AGENTS.md: refresh the block, reorder, verify commands and paths, prune, split past 250 lines. Usually runs on its own
/lean-and-mean:lean-and-mean debt$lean-and-mean debtList every // lean: shortcut with its upgrade path
/lean-and-mean:endsession$endsessionSave Rules and Next, commit, push, and run ## Done steps when the work is complete. Then stop. Details
Session Status Mod—Colored cache countdown on top, task checklist, End session button, auto /endsession with 5 minutes of cache left, a Rehydrate / Clear first question when the cache has gone cold. Off in /config. Details
@agent-lean-and-mean:explainer—Explainer Agent: local .pages/<slug>.html explainer page with inline SVG diagrams

A manual install into ~/.claude/skills/ gives the bare /lean-and-mean and /endsession.

Extras (/lean-and-mean:statusline, /lean-and-mean:typesafe-ai): extras.

How it works

Claude Code loads AGENTS.md through a one-line CLAUDE.md stub (@AGENTS.md); Codex loads it directly. Once the ## Operating Mode block is in it, the mode is on. One SessionStart hook stays silent unless the block is missing or a review is due. Hook details.

Off: disable the plugin and delete the Operating Mode block. Disabling alone leaves the rules active.

It governs code shape, prose, and context files, not correctness. Pair it with /code-review; /simplify reviews code bloat.

Development

python3 -m unittest discover -s tests -v
claude plugin validate .claude-plugin/plugin.json && claude plugin test .

The Codex skill validators reject Claude's argument-hint and disable-model-invocation frontmatter. That is a known exception: the fields stay for Claude, and Codex's explicit-only policy is in skills/endsession/agents/openai.yaml.

License

MIT. See LICENSE.

skills/typesafe-ai/ is copied from typesafe-ai/skills, tracked as the vendor/typesafe-ai-skills submodule. Copyright and credit belong to TypeSafe AI (MIT, Copyright (c) 2026 TypeSafe AI; see skills/typesafe-ai/LICENSE). The only change is routing Jev calls through OpenRouter.

Source 3 files
hooks/register.tsx 198 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Task } from '../types'
5import { JUDGE_SYSTEM, allDone, autoEndDue, cacheCold, cacheLabel, judgePrompt, parseTasks } from './judge'
6
7const cacheAt = atom({ plugin: 'lean-and-mean', key: 'cacheAt' } as const, null)
8const tasks = atom({ plugin: 'lean-and-mean', key: 'tasks' } as const, [] as Task[])
9const nudged = atom({ plugin: 'lean-and-mean', key: 'nudged' } as const, false)
10const lastPrompt = atom({ plugin: 'lean-and-mean', key: 'lastPrompt' } as const, '')
11const endCommand = atom({ plugin: 'lean-and-mean', key: 'endCommand' } as const, 'lean-and-mean:endsession')
12const tasksHidden = atom({ plugin: 'lean-and-mean', key: 'tasksHidden' } as const, false)
13// true from a real prompt until an endsession runs, so an idle wrapped-up session isn't ended again every hour.
14const armed = atom({ plugin: 'lean-and-mean', key: 'armed' } as const, false)
15// Timer-written clock the band reads, so the cache countdown redraws while idle.
16const now = atom({ plugin: 'lean-and-mean', key: 'now' } as const, 0)
17
18const isEnd = (name: string) => /(^|:)endsession$/.test(name)
19
20const NUDGE =
21  'lean-and-mean: every task on this session checklist is done. Before starting the new request, ' +
22  'tell the user in one line that the checklist is complete and suggest running /endsession first; then proceed only if they want to continue.'
23
24export const register: Register = (on, options) => {
25  if (options.sessionBand === false) return
26  // cacheAt only moves at turn end, so a 55m+ turn would look expired mid-turn.
27  // lean: module variable, lost on reload mid-turn; move to an atom if that bites.
28  let inTurn: string | null = null
29
30  on('session.start', async ($, e, next) => {
31    const tick = async () => {
32      const t = await $.clock.now()
33      await update($, now, () => t)
34      if (inTurn !== null || !(await read($, armed)) || !autoEndDue(await read($, cacheAt), t)) return
35      await update($, armed, () => false)
36      $.ui.toast('Prompt cache expires in 5m: running /endsession')
37      // Queued until the session is idle; not awaited so the timer isn't held for the whole wrap-up.
38      $.command.run({ command: await read($, endCommand), args: 'auto' }).catch(() => $.ui.toast('Auto /endsession failed: run it by hand'))
39    }
40    await tick()
41    $.clock.every(30_000, tick)
42    const found = (await $.command.list()).find(c => isEnd(c.name))
43    if (found) await update($, endCommand, () => found.name)
44    return next(e)
45  })
46
47  on('session.end', async ($, e, next) => {
48    if (e.reason === 'clear') {
49      await update($, tasks, () => [])
50      await update($, nudged, () => false)
51      await update($, armed, () => false)
52      await update($, cacheAt, () => null)
53    }
54    return next(e)
55  })
56
57  on('command.run', async ($, e, next) => {
58    if (isEnd(e.command)) await update($, armed, () => false)
59    return next(e)
60  })
61
62  on('prompt.submit', async ($, e, next) => {
63    if (e.origin.kind === 'plugin' || e.text.trimStart().startsWith('/')) return next(e)
64    const at = await read($, cacheAt)
65    if (e.turnId === undefined && inTurn === null && at !== null && cacheCold(at, await $.clock.now())) {
66      // armed false = an endsession (auto, button or typed) ran after the last real prompt.
67      const ended = !(await read($, armed))
68      const command = await read($, endCommand)
69      let answer: string
70      try {
71        answer = await $.ui.ask(
72          ended
73            ? 'Prompt cache is cold; /endsession already wrapped up. Resend the full context to rehydrate it?'
74            : 'Prompt cache is cold and /endsession has not run. Resend the full context to rehydrate it?',
75          { header: 'Cold cache', options: ['Rehydrate', 'Clear first'] },
76        )
77      } catch {
78        // Dismissed, or nobody to ask (-p): send; a cache miss beats a lost prompt.
79        return next(e)
80      }
81      if (answer !== 'Clear first') return next(e)
82      // lean: the typed prompt is not kept; stash it and refill after /clear if retyping bites.
83      // Guarded: a rejected fill would skip this hook and send the prompt the user held back.
84      await $.prompt.fill({ text: ended ? '/clear' : `/${command}` }).catch(() => undefined)
85      return {
86        drop: ended
87          ? 'Not sent. /endsession saved Next in AGENTS.md. Run /clear, then resend your prompt.'
88          : `Not sent. Run /${command} to save Rules and Next (one cold resend), then /clear and resend your prompt.`,
89      }
90    }
91    await update($, lastPrompt, () => e.text)
92    await update($, armed, () => true)
93    if (allDone(await read($, tasks)) && !(await read($, nudged))) {
94      await update($, nudged, () => true)
95      $.ui.toast('Checklist done: consider /endsession before new work')
96      return next({ ...e, context: [...(e.context ?? []), NUDGE] })
97    }
98    return next(e)
99  })
100
101  on('turn.start', async (_$, e, next) => {
102    inTurn = e.turnId
103    return next(e)
104  })
105
106  on('turn.complete', async ($, e, next) => {
107    if (e.agentId !== undefined) return next(e)
108    if (e.turnId === inTurn) inTurn = null
109    // usage present = a real request ran, so the 1h cache was refreshed (interrupted turns too).
110    if (e.usage) {
111      const t = await $.clock.now()
112      await update($, cacheAt, () => t)
113      await update($, now, () => t)
114    }
115    const prompt = await read($, lastPrompt)
116    if (e.reason !== 'answer' || prompt === '') return next(e)
117    await update($, lastPrompt, () => '')
118
119    const before = await read($, tasks)
120    // lean: one Haiku call per main turn, awaited (≤8s); move to fire-and-forget from a timer if it drags.
121    const r = await $.model.complete({
122      model: 'haiku',
123      effort: 'low',
124      maxTokens: 600,
125      timeoutMs: 8000,
126      system: JUDGE_SYSTEM,
127      prompt: judgePrompt(before, prompt, e.answer),
128    })
129    const after = r.isAnswered ? parseTasks(r.text) : null
130    if (after) {
131      await update($, tasks, () => after)
132      if (allDone(after) && !allDone(before)) {
133        await update($, nudged, () => false)
134        $.ui.toast('All session tasks done: /endsession?')
135      }
136    }
137    return next(e)
138  })
139
140  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141    if (e.props.hasSurvey) return next(e)
142    const at = await read($, cacheAt)
143    const list = await read($, tasks)
144    if (at === null && list.length === 0) return next(e)
145
146    const { Box, Text, Button } = $.ui.resolve(e)
147    const cache = cacheLabel(at, await read($, now))
148    const hidden = await read($, tasksHidden)
149    const done = list.filter(t => t.done).length
150    const finished = allDone(list)
151    const command = await read($, endCommand)
152    const end = async () => {
153      // $.command.run skips this plugin's own command.run hook.
154      await update($, armed, () => false)
155      try {
156        await $.command.run({ command })
157      } catch {
158        await $.prompt.fill({ text: `/${command}` })
159      }
160    }
161
162    return (
163      <Box flexDirection="column">
164        {cache && (
165          <Text key="cache" color={cache.color}>
166            {cache.text}
167          </Text>
168        )}
169        <Box>
170          <Text key="tasks" dimColor={!finished} color={finished ? 'green' : undefined}>
171            {list.length ? `tasks ${done}/${list.length}${finished ? ' done' : ''}  ` : ''}
172          </Text>
173          {list.length > 0 && (
174            <Button
175              key="toggle"
176              label={hidden ? 'show tasks' : 'hide tasks'}
177              variant="secondary"
178              onPress={() => update($, tasksHidden, h => !h)}
179            />
180          )}
181          <Button
182            key="end"
183            label={finished ? 'End session' : 'end session'}
184            variant={finished ? 'primary' : 'secondary'}
185            onPress={end}
186          />
187        </Box>
188        {!hidden && list.map((t, i) => (
189          <Text key={`t${i}`} dimColor={t.done} wrap="truncate-end">
190            {t.done ? '  ✓ ' : '  ☐ '}
191            {t.text}
192          </Text>
193        ))}
194      </Box>
195    )
196  })
197}
198
hooks/judge.ts 63 lines
1import type { Task } from '../types'
2
3export const JUDGE_SYSTEM =
4  'You keep a coding session task checklist. Reply with JSON only: {"tasks":[{"text":string,"done":boolean}]}. ' +
5  'Add each distinct task the user asked for; keep existing tasks and their order; ' +
6  'mark done only when the assistant reply shows it finished (not planned, not partially done). ' +
7  'Task text under 60 characters. Questions, chit-chat and /commands are not tasks.'
8
9export function judgePrompt(tasks: Task[], prompt: string, answer: string): string {
10  return [
11    `Checklist: ${JSON.stringify({ tasks })}`,
12    `User said:\n${prompt.slice(0, 4000)}`,
13    `Assistant replied:\n${answer.slice(-6000)}`,
14  ].join('\n\n')
15}
16
17// Untrusted model output: anything malformed returns null so the caller keeps the old list.
18export function parseTasks(text: string): Task[] | null {
19  const start = text.indexOf('{')
20  const end = text.lastIndexOf('}')
21  if (start < 0 || end <= start) return null
22  try {
23    const raw: unknown = JSON.parse(text.slice(start, end + 1))
24    const list = (raw as { tasks?: unknown }).tasks
25    if (!Array.isArray(list)) return null
26    const tasks = list
27      .filter(
28        (t): t is Task =>
29          typeof t === 'object' && t !== null &&
30          typeof (t as Task).text === 'string' && (t as Task).text.trim() !== '' &&
31          typeof (t as Task).done === 'boolean',
32      )
33      .map(t => ({ text: t.text.trim().slice(0, 80), done: t.done }))
34      .slice(0, 20)
35    return tasks.length === list.length ? tasks : null
36  } catch {
37    return null
38  }
39}
40
41export const allDone = (tasks: Task[]) => tasks.length > 0 && tasks.every(t => t.done)
42
43export const CACHE_TTL_MS = 60 * 60 * 1000
44
45export const AUTO_END_MS = 5 * 60 * 1000
46
47/** True once the 1h prompt cache has 5 minutes or less left. */
48export const autoEndDue = (cacheAt: number | null, now: number) =>
49  cacheAt !== null && now >= cacheAt + CACHE_TTL_MS - AUTO_END_MS
50
51/** Band label for the 1h cache: "exp. 42m", "exp. cold"; null before the first request.
52 *  Color by time left: >30m green, >15m yellow, >10m orange, else red (auto-end fires at 5m). */
53export function cacheLabel(cacheAt: number | null, now: number): { text: string; color: string } | null {
54  if (cacheAt === null) return null
55  const left = Math.max(0, cacheAt + CACHE_TTL_MS - now)
56  const m = left / 60_000
57  const color = m > 30 ? 'green' : m > 15 ? 'yellow' : m > 10 ? '#ff8700' : 'red'
58  return { text: left === 0 ? 'exp. cold' : `exp. ${Math.ceil(m)}m`, color }
59}
60
61/** True once the 1h prompt cache has expired: the next request resends the whole context uncached. */
62export const cacheCold = (cacheAt: number | null, now: number) => cacheAt !== null && now >= cacheAt + CACHE_TTL_MS
63
types/index.d.ts 17 lines
1export type Task = { text: string; done: boolean }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'lean-and-mean': {
6      cacheAt: number | null
7      tasks: Task[]
8      nudged: boolean
9      lastPrompt: string
10      endCommand: string
11      tasksHidden: boolean
12      armed: boolean
13      now: number
14    }
15  }
16}
17