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

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.
## Rules, so it does not happen twice.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.
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.
| Claude Code | Codex | Effect |
|---|---|---|
/lean-and-mean:lean-and-mean | $lean-and-mean | Create 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 debt | List every // lean: shortcut with its upgrade path |
/lean-and-mean:endsession | $endsession | Save 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.
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.
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.
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.
hooks/register.tsx 198 lines1import { 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}
198hooks/judge.ts 63 lines1import 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
63types/index.d.ts 17 lines1export 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