Fixes the common problems of vibe coding and stops Claude from making dangerous mistakes. Curated Claude Code discipline kit: hook-enforced approval gates…

<img src="assets/mogger-banner.png" alt="claude-mogger" width="100%"/>
Vibe coding is full of unseen problems until it's too late. Mogger fixes that.
Every claim in this kit is backed by an exit code, a benchmark you can re-run, or a diff you can read — not a testimonial. Everything that didn't clear that bar is in CONSIDERED.md, with the reason, instead of quietly not existing. That's the whole pitch: the internet is full of "make Claude 1000x better" threads that are 80% noise. This is the 20%, with receipts, and it doesn't ask your model nicely — it puts a bash script between it and anything that can't be undone.
No config file can make an AI model smarter. What a kit can do, and what this one does:
git push, merges, deploys, and anything touching money are blocked at the tool-call level by hooks — no prompt, no agent, no clever reasoning gets around a bash script that returns exit code 2.library-scout agent that checks whether a good library already exists before anyone hand-rolls date math — and is explicitly allowed to answer "write it yourself," because a scout that always recommends a dependency is how you end up with forty packages for forty one-liners. A STACK.md so those choices are made once and stay consistent.model: assignment: Haiku reads files, greps the codebase, and runs tests; Sonnet builds, reviews, and scouts libraries; the orchestrating Lead plans and decides. The seven Haiku agents (and the Sonnet docs-writer) also set effort: low — a second, independent dial on reasoning depth, documented as a real subagent frontmatter field. Model aliases (model: sonnet) resolve to whatever's current automatically, so a new Sonnet release upgrades every Sonnet-tier agent with zero file changes. The Lead is told, in writing, not to Read or Grep itself. Then: diff-only re-reads (never re-read a file you just edited — read git diff), stable-first prompt ordering for prompt caching, and short output (no preambles, no re-printing unchanged code). Optional compression proxy (headroom) for heavy tool output. Mogger is not a cost-saving tool. In our paid tests it costs about 13-23% more per run than plain Claude, and passes more hard tasks. Each ask becomes numbered requirements (R1, R2...) in the plan. The reviewer returns a pass / fail / unresolved table, one row per requirement. A failed unit comes back in one fixed form (UNIT, VERDICT, REASON, EVIDENCE, SCOPE) and only the failed units go back to the builder.retro agent proposes new entries from run history; a human approves them.planner marks which tasks have non-overlapping file sets; the Lead dispatches those builders concurrently instead of one-at-a-time. Tests follow the same logic — affected tests during the loop, full suite once before review..claude-plugin/ plugin.json + marketplace.json — install with two slash commands
.mcp.json bundles Context7 as a hosted remote MCP server — auto-registers on install
agents/ 14 agents: planner, builder, reviewer, retro, library-scout, security-reviewer , docs-writer (Sonnet) · tester, explorer, bulk-reader, code-writer, fact-checker, verifier, explainer (Haiku)
hooks/hooks.json 44 hook entries: SessionStart, PreToolUse, PostToolUse, PostToolUseFailure, Stop, PreCompact
hooks/scripts/ the actual bash — every one tested in tests/hooks.test.sh
skills/loop the orchestration loop + model routing (loads when you start a feature)
skills/standards coding principles, library rules, token discipline, SkillSpector rule, design-mogger UI rules
skills/init first-run: scaffolds CONSTRAINTS/RUNS/STACK in your project
skills/config list guards and set each to on, ask or off (`.claude/mogger.json`, list in `hooks/guards.tsv`)
skills/superpowers-preset tested config for running these gates under Superpowers
skills/ (20 in all) the four above, plus config, idea, decisions, grounding, security, code-health, edge-cases, verify-run, ship-check, rewind, handoff, docs, privacy, explain, evals, app-evals
templates/ the three project files above, plus pricing.json for the routing estimate
tests/*.test.sh 2,961 assertions across 26 suites (`for t in tests/*.test.sh; do bash $t; done`). If these fail, the gates don't work.
scripts/savings-report.py optional: estimate of what routed agent calls would cost at the Lead's rate (self-reported, see below)
incoming/ manual-install mirror (generated by scripts/sync-incoming.sh)
CURATION.md the rubric — what earns a place
CONSIDERED.md every tool evaluated, with verdicts
INTEGRATION.md manual merge guide for projects with an existing .claude/
As a plugin (recommended):
/plugin marketplace add yonitesser/claude-mogger
/plugin install mogger@claude-mogger
Then in any project, once: ask Claude to "run mogger init". It scaffolds CONSTRAINTS.md / RUNS.md / STACK.md, fills what it can from your dependency files, checks jq or python3 is present, auto-installs SkillSpector if uv is available, and tells you exactly what's now gated. Context7 is already live at this point — it registered as part of plugin install, nothing to do for it. Nothing else to configure.
Manually (if you'd rather have the files in your repo, or already have a .claude/ you want to merge into): the incoming/ folder mirrors the plugin in project-relative shape. Drop the repo in your project root and tell Claude Code: "Read INTEGRATION.md and install this kit." It handles both fresh projects and existing setups.
Try before you install: claude --plugin-dir /path/to/claude-mogger
Requirements: jq (recommended — install with winget install jqlang.jq on Windows, brew install jq on Mac, or your package manager on Linux) or a real python3. bash native on macOS/Linux; on Windows use Git Bash (comes with Git for Windows) — the tests and hooks both run fine there once jq is installed. Watch out on Windows: python3 often exists on PATH as a Microsoft Store stub that prints an install nag instead of running — lib.sh detects and ignores that stub, but installing jq sidesteps the question entirely and is the more reliable path.
Same rule as everything else here: each piece is a script with an exit code or a file you can open. Nothing is "the model promises."
| Need | What it is | Enforced by |
|---|---|---|
| A voice, not a manual | mogger is smug, dry and clever, and never mean. The status bar and toasts pick a fresh one-liner for each moment (blocked, warned, handled, tests passed, caught saying "done" with no proof, idle) from pools of 8-10 lines each. The pick is a plain rotation, so a line never shows twice in a row, and it costs no model calls and no tokens. Claude's own replies get one 169-character line at session start (about 42 tokens) that describes the character: one fresh quip when a task ends or a guard blocks, none in warnings, errors or block reasons. The quip sits next to the facts and never replaces them. Off: {"voice": "off"} in .claude/mogger.json or MOGGER_VOICE=off | session text + mod |
| Turn guards on, ask or off | .claude/mogger.json holds the switches ({"danger-guard": "ask"}). Three states: on blocks, ask gives you a permission prompt each time (Claude cannot approve it), off allows. ask works for danger-guard, secret-read-guard, config-guard, push-guard, merge-guard, deploy-guard, money-guard; the other guards are on or off. push-guard, merge-guard, deploy-guard and money-guard are on or ask in the file (no off; only you can allow them for good, with the env var). Force pushes and pushes to protected branches stay blocked in every state. Where no prompt can be shown (headless or SDK runs, bypassPermissions, dontAsk or auto mode, or MOGGER_HEADLESS=on), ask blocks instead. Skill mogger:config lists every guard with what ON, ASK and OFF do and flips them; going to off on a safety guard warns first, going to ask does not. config-guard also asks before Claude edits .claude/mogger.json itself (the switch file; the smoke settings in it prompt too). The mogger:config script is not affected. Env vars MOGGER_* (now also =ask) still work and win; a bad value keeps the default. List: hooks/guards.tsv. Test: tests/ask.test.sh | skill + script |
| Undo anything | checkpoint.sh snapshots your work before edits (non-destructive git refs). scripts/mogger-rewind.sh list/show/restore — restore snapshots first, so undo is undoable. Skill: mogger:rewind | hook |
| No leaked secrets | secret-guard.sh blocks keys/tokens/.env writes; secret-guard-bash.sh blocks git add .env and commits with secrets staged | hook (exit 2) |
| No invented packages | verify-packages.sh checks every npm/pip/cargo/go install name exists on the real registry. Fails open offline | hook (exit 2 on 404) |
| No made-up code references | check-references.sh checks that new imports resolve to real files and declared dependencies | hook (exit 2) |
| No guessing | mogger:grounding skill: every claim cites file:line or command output, unchecked things are labeled UNVERIFIED:. fact-checker agent (Haiku) returns VERIFIED / REFUTED / UNVERIFIABLE with evidence. Reviewer and builder require it | skill + agents + hook |
| Does it actually run? | scripts/smoke-check.sh starts the app, polls it, scans for errors, writes .claude/state/smoke.json. Optional gate: MOGGER_REQUIRE_SMOKE=on. Agent: verifier | script + optional hook |
| Ship checklist | scripts/ship-check.sh — report only, never pushes or deploys | script |
| Plain English | explainer agent (Haiku): what changed, why, how to check, what to watch out for | agent |
| Live status | STATUS.md regenerated on every edit and stop (when TASKS.md exists) | hook |
| Budget cap | MOGGER_BUDGET_USD=5 — cost computed from real transcript token counts x templates/pricing.json; warns at 80%, blocks at 100%. Off by default | hook |
| Spec first | mogger:idea skill: at most 5 short questions with defaults, never asks what the repo already answers, writes SPEC.md with checkable "done when" | skill |
| Remember why | DECISIONS.md append-only log, active decisions injected at session start, edits to old entries blocked | hook + skill |
Built from a list of what real developers mock vibe-coded apps for. Report-only scanners live in scripts/checks/ and all run from one command, bash scripts/ship-check.sh (--only <name>, --list, --strict). It never pushes or deploys.
| Devs mock… | What mogger does | How |
|---|---|---|
| Keys in the front end, no input checks, fake auth | security.sh scan (file:line evidence), check-risky-code.sh blocks the high-confidence cases, security-reviewer agent | scan + hook |
| Old or vulnerable packages | dep-audit.sh runs your own npm audit / pip-audit / cargo audit / osv-scanner, fails open offline | scan |
| Hand-made payment code | check-diy-payments.sh blocks card handling and unsigned Stripe webhooks | hook |
| Tests that prove nothing | check-test-quality.sh blocks empty, assertion-free and tautology tests | hook |
| Plain, generic-looking UI | design-mogger rules in mogger:standards (7 short UI-only rules). Points to the optional Taste Skill and ui-ux-pro-max; neither is required | skill |
| Fix loops | fix-loop-guard.sh stops after 3 identical failures and points to mogger:rewind | hook |
| Only the happy path works | skill mogger:edge-cases; planner and tester now require unhappy-path checks | skill |
| One giant file, copy-paste | check-structure.sh blocks oversize files and new copy-pasted blocks | hook |
| No error handling / logs | check-resilience.sh blocks swallowed errors; scan for missing timeouts, N+1, no health check | hook + scan |
| Weak database design | database.sh: no primary key, unindexed foreign keys, no RLS, no backup plan | scan |
| Runaway API bills in the app | cost-risk.sh: paid calls in loops, no max_tokens, no retry cap | scan |
| Privacy and legal gaps | privacy.sh + DATA.md data map (lists what the code does; not legal advice) | scan + skill |
| No docs | docs.sh checks the README commands really exist and env vars match .env.example; docs-writer agent writes only verified facts | scan + agent |
| Mobile / accessibility | a11y.sh static checks; a11y-browser.mjs runs axe if you already have it | scan |
| Lock-in | lockin.sh reports vendor coupling and exit paths | scan |
| Deploy habits | deploy.sh: remote, CI, staging, rollback, backup notes | scan |
| Context loss | precompact-save.sh writes .claude/state/handoff.md from facts before compaction; injected at session start | hook |
Mogger routes reading work to Haiku and building work to Sonnet. scripts/mogger-eval.sh tests that with real tasks, hidden held-out cases, repeats and confidence intervals (design follows Anthropic's Automating eval design and hillclimbing with Claude). It also improves skill descriptions one patch at a time and reverts anything that only helps the visible cases.
scripts/checks/evals-static.sh (skill overlap, missing trigger words, risky agent settings).estimate makes no model calls. apply only writes with --yes, and only under your project's .claude/.mogger:app-evals points you to /claude-api build-eval and hillclimb.The cost figure is an estimate (token counts x published rates), and check-references.sh is conservative on purpose — it would rather miss than cry wolf. Limits of each are in the script headers.
| Hook | Event | Blocks | ||
|---|---|---|---|---|
require-approval | Bash | git push, merge while on main/master/prod, gh pr merge, prod deploys, money CLIs | ||
scope-guard | Edit/Write | editing any file the current task's files: list didn't declare | ||
protect-pipeline-files | Edit/Write | CI configs, Dockerfiles, Terraform, payment code | ||
require-tests-pass | Task→reviewer | review without a recorded exit-0 full-suite run, or with edits since | ||
stop-done-means-done | Stop | ending the turn with open tasks and no BLOCKED: reason | ||
stop-tests-added | Stop | ending the turn over a red suite: runs your own test command (npm test, pytest, go test, cargo test) when code or tests changed; sends the model back with the last 25 lines. 0 tokens when green. Off: MOGGER_STOP_TESTS=off | ||
stop-claim-check | Stop | saying "done" after code edits when nothing ran after the last edit (needs the session transcript): sends the model back once with "NOT PROVEN". 0 tokens otherwise. MOGGER_CLAIM_CHECK=always also asks for a per-requirement check after every claim (measured +62% cost, no gain, so off). =off disables | ||
check-test-tamper | Edit/Write (post) | — warns when an edit to a test file removes assertions or cases, or adds skips. Warn only. Off: MOGGER_CHECK_TAMPER=off | ||
check-file-size + check-bash-read | Read, Bash | blocks reading a file over 350 lines and sends Claude to the bulk-reader agent; the Bash one catches cat/head/tail on the same files. Off: MOGGER_READ_GUARD=off. Size: MOGGER_READ_LINES=500 | ||
danger-guard | Bash | commands that destroy data or the machine: rm -r on /, ~, $HOME, ., .. or system dirs; dd/mkfs on devices; `curl \ | sh; chmod 777; git reset --hard; git clean -f; DROP/TRUNCATE/DELETE with no WHERE; rm -r .git, git checkout ., --no-verify, other AI agents started with permission checks off. Plain text match, a seat belt not a sandbox. Off: MOGGER_DANGER_GUARD=off` | |
secret-read-guard | Read / Grep / Glob / Bash | reading .env, ssh keys, ~/.aws, ~/.kube, .npmrc, .netrc, *.pem/*.key; dumping all env vars; echoing secret-named vars. .env.example and *.pub stay readable. Off: MOGGER_SECRET_READ=off | ||
config-guard | Edit / Write / Bash | — asks you first when Claude edits .claude/settings*.json, .claude/hooks/, or .mcp.json (a normal permission prompt, not a block). Off: MOGGER_CONFIG_GUARD=off | ||
record-test-run | Bash (post) | — records the real result of a test command in .claude/state/test-evidence.json. The reviewer gate needs it to say pass, whatever the tester agent wrote. Extra runner: MOGGER_TEST_CMD_REGEX. Off: MOGGER_EVIDENCE=off | ||
require-approval-mcp | GitHub MCP tools | merging a PR, auto-merge, deleting (MOGGER_MERGE_GUARD=ask prompts you instead); push_files / create_or_update_file / delete_file need MOGGER_PUSH_GUARD=ask (or the old MOGGER_ALLOW_PUSH=on) and a non-protected branch | ||
taint-mark + taint-guard | WebFetch / WebSearch / MCP (post), Bash | after Claude reads outside content, asks before curl, wget, nc, scp, ssh, git remote add. A speed bump against prompt injection, not a defence. Off: MOGGER_TAINT=off | ||
config-notice | SessionStart | says once when a repo ships .claude/settings.json, .claude/hooks/ or .mcp.json, and again when they change. Off: MOGGER_CONFIG_NOTICE=off | ||
prefs-notice | SessionStart | first load in a project only: tells Claude to say that every guard can be on, ask or off, and to ask once ("Keep the defaults" or "Review my guard choices"); on Review it loads mogger:config. Marker .claude/state/prefs-shown, so it never repeats. Silent in headless/SDK runs, with bypassPermissions, dontAsk or auto, and when .claude/mogger.json already exists. Off: MOGGER_PREFS_PROMPT=off. Test: tests/prefs.test.sh | ||
hint-noisy-bash | Bash | — hints `\ | tail -n 80 or \ | grep for test, build and log commands with no pipe. Never blocks. Off: MOGGER_HINT=off` |
session-start | SessionStart | — injects CONSTRAINTS.md, STACK.md, task status, and a one-line "least code" ladder (skip, reuse, stdlib, one-liner, new code last) into context | ||
auto-format | Edit/Write (post) | — runs your project's own formatter/linter on the touched file, and tells Claude about lint errors it could not fix | ||
repeat-call-guard | every tool (post) | — nudges at the 5th identical call in a row; blocks the 8th identical failing call. Off: MOGGER_REPEAT_GUARD=off |
Protected branches default to main|master|prod|production|release/.*; override with MOGGER_PROTECTED_BRANCHES. Reviewer agent name defaults to reviewer; override with MOGGER_REVIEWER_NAME if you use Superpowers or your own. Scope enforcement can be turned off with MOGGER_SCOPE_GUARD=off. For parallel tasks set MOGGER_SCOPE_PARALLEL=on and files from every open task are allowed. It fails open anyway when a task declares no files: list, so it never wedges a session on a half-written task board.
Mogger shows a status line (mogger: N guards fired, M blocked), a bar above the prompt with the last thing it did, and a toast when it blocks something. On by default (needs a Claude Code version with mods). The mogger chad (a pixel-art robot) sits in the bar and reacts when a guard fires. Buttons in the bar (Guards, Display, Hide) open dropdowns that set each guard to on, ask or off (safety guards: on or ask only) and choose what the bar shows. On the desktop app the robot is drawn as a picture. It runs no command: it reads .claude/state/mogger-events.log and hooks/guards.tsv, and writes the guard switch file only when you pick a state. Source: mods/mogger-status/.
These two used to be a manual step. They're not anymore:
| Tool | How it's included | Status |
|---|---|---|
| Context7 | .mcp.json at the plugin root, pointed at Context7's hosted remote server | Live the moment the plugin installs. No npx, no local server, nothing to run. |
| SkillSpector | mogger:init detects it's missing and runs uv tool install itself | Installed the first time you run "mogger init" (needs uv on the machine — if uv itself is missing, init tells you the one command for that instead). |
These two are genuinely optional and overlap with something already in the kit — install them only if you want to swap in the more mature version:
| Tool | Why | Overlaps with |
|---|---|---|
| headroom | Local compression proxy; reversible; headroom wrap claude | check-file-size.sh/check-bash-read.sh on big-file reads only — bulk-reader/explorer/code-writer/tester stay regardless, they route by model not by compression |
| Superpowers | Mature brainstorming/TDD/git-worktree workflow — better than this kit at planning and TDD | this kit's planner/builder/reviewer; keep every hook. There's a tested preset for the combination — see below |
Details and the one-adjustment-each notes are in CLAUDE.md.snippet.
Superpowers is better than this kit at planning, requirements-gathering, and TDD enforcement. This kit is better at gates that can't be reasoned around and at not paying frontier prices for file reads. Those are different axes, so the answer is usually both.
The mogger:superpowers-preset skill is the tested configuration: which mogger agents to stop dispatching, which to keep (all the hooks, all the Haiku-routed agents, library-scout, retro), and the two config changes it needs — MOGGER_GATE_ALL_TASKS=on so the test gate still fires against subagent names you don't control, and a decision on scope-guard.
Why a preset and not a hard dependency: these hooks key off the tool call, not off which skill triggered it. That's what ma
mods/mogger-status/hooks/register.tsx 268 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MoggerSummary } from '../types'
5import { ORANGE, TICK_MS, chadRows, chadSvg, headline, moodOf, toastText } from './chad'
6import { DEFAULT_BAR, isPickable, parseCatalog, parseConfig, readBar, rowOf, withPick } from './panel'
7import type { BarOptions, GuardRow } from './panel'
8import { LOG_PATH, ageText, newBlocks, newestTs, parseLog, statusText, summarize, voiceOn } from './log'
9
10const summaryAtom = atom({ plugin: 'mogger', key: 'summary' } as const, null as MoggerSummary | null)
11const isHidden = atom({ plugin: 'mogger', key: 'isHidden' } as const, false)
12const sinceAtom = atom({ plugin: 'mogger', key: 'since' } as const, 0)
13
14const voiceAtom = atom({ plugin: 'mogger', key: 'voice' } as const, true)
15const panelAtom = atom({ plugin: 'mogger', key: 'panel' } as const, 'none' as 'none' | 'guards' | 'bar')
16const guardsAtom = atom({ plugin: 'mogger', key: 'guards' } as const, [] as GuardRow[])
17const noteAtom = atom({ plugin: 'mogger', key: 'note' } as const, '')
18const barAtom = atom({ plugin: 'mogger', key: 'bar' } as const, DEFAULT_BAR as BarOptions)
19const frameAtom = atom({ plugin: 'mogger', key: 'frame' } as const, 0)
20const minuteAtom = atom({ plugin: 'mogger', key: 'minute' } as const, 0)
21
22const QUIET_MS = 60_000
23const POLL_MS = 2000
24const CONFIG_PATH = '.claude/mogger.json'
25
26// newest event time already seen (seconds); -1 until the first read, so
27// old blocks from earlier sessions never toast.
28let seenTs = -1
29// Only the terminal draws the robot as text (half blocks); the desktop app, the editor and the phone
30// get a picture. A picture does not need to blink, so those surfaces redraw less, and a button press
31// is not lost to a redraw in the middle of the click.
32let isTerminal = true
33
34async function refresh($: EngineInterface): Promise<void> {
35 let text = ''
36 try {
37 text = await $.fs.read(LOG_PATH)
38 } catch {
39 // no log yet: show nothing
40 $.ui.status(undefined)
41 if ((await read($, summaryAtom)) !== null) await update($, summaryAtom, () => null)
42 return
43 }
44 const events = parseLog(text)
45 let since = await read($, sinceAtom)
46 if (since === 0) {
47 since = await $.clock.now()
48 await update($, sinceAtom, () => since)
49 }
50 const s = summarize(events, since)
51 const voice = voiceOn(events)
52 if (voice !== (await read($, voiceAtom))) await update($, voiceAtom, () => voice)
53 if (seenTs >= 0) {
54 for (const b of newBlocks(events, seenTs)) {
55 $.ui.toast(toastText(b.msg, voice, b.ts))
56 }
57 }
58 seenTs = newestTs(events)
59 // write only a changed summary: a redraw every poll could land between a button's press and release
60 if (JSON.stringify(s) !== JSON.stringify(await read($, summaryAtom))) await update($, summaryAtom, () => s)
61 const minute = Math.floor((await $.clock.now()) / 60_000)
62 if (minute !== (await read($, minuteAtom))) await update($, minuteAtom, () => minute) // keeps "(5m ago)" fresh
63 $.ui.status(statusText(s))
64}
65
66// The guard panel reads the catalog and the switch file itself, and writes the same file the mogger:config
67// script writes. No command is run, so it needs no jq and no shell.
68async function readConfig($: EngineInterface): Promise<string | null> {
69 try {
70 return await $.fs.read(CONFIG_PATH)
71 } catch {
72 return null
73 }
74}
75
76async function loadGuards($: EngineInterface): Promise<void> {
77 try {
78 const defs = parseCatalog(await $.fs.read(`${$.plugin.root}/hooks/guards.tsv`))
79 const cfg = parseConfig(await readConfig($))
80 if (cfg === null) {
81 await update($, noteAtom, () => 'The switch file .claude/mogger.json is not valid JSON. Fix it by hand, then open Guards again.')
82 await update($, guardsAtom, () => [])
83 return
84 }
85 await update($, guardsAtom, () => defs.map(d => rowOf(d, cfg)).filter(isPickable))
86 await update($, noteAtom, () => (defs.length === 0 ? 'The guard list is empty.' : ''))
87 } catch (err) {
88 await update($, guardsAtom, () => [])
89 await update($, noteAtom, () => `Could not read the guard list: ${err instanceof Error ? err.message : String(err)}`)
90 }
91}
92
93async function setGuard($: EngineInterface, name: string, to: string): Promise<void> {
94 try {
95 const d = parseCatalog(await $.fs.read(`${$.plugin.root}/hooks/guards.tsv`)).find(x => x.name === name)
96 if (d === undefined) throw new Error(`unknown guard ${name}`)
97 const r = withPick(await readConfig($), d, to)
98 if (!r.ok) {
99 $.ui.toast(`mogger: ${r.reason}`)
100 } else {
101 await $.fs.write(CONFIG_PATH, r.text)
102 $.ui.toast(`mogger: ${name} is now ${to}`)
103 }
104 } catch (err) {
105 $.ui.toast(`mogger: could not set ${name}: ${err instanceof Error ? err.message : String(err)}`)
106 }
107 await loadGuards($)
108}
109
110async function setBar($: EngineInterface, next: BarOptions): Promise<void> {
111 await update($, barAtom, () => next)
112 try {
113 await $.store.set('mogger.bar', next)
114 } catch {
115 // not stored: the choice holds for this session only
116 }
117}
118
119export const register: Register = on => {
120 on('session.start', async ($, e, next) => {
121 isTerminal = e.surface === null || e.surface === 'terminal'
122 try {
123 const saved = await $.store.get('mogger.bar')
124 await update($, barAtom, () => readBar(saved))
125 } catch {
126 // no stored choice: defaults
127 }
128 await refresh($)
129 $.clock.every(POLL_MS, () => refresh($))
130 // motion: ticks only change the picture while the chad moves or blinks
131 $.clock.every(TICK_MS, async () => {
132 const now = await $.clock.now()
133 const s = await read($, summaryAtom)
134 const mood = moodOf(s?.last?.kind, s?.last ? now - s.last.ts * 1000 : Infinity)
135 const frame = Math.floor(now / TICK_MS)
136 const want = mood === 'idle' ? (isTerminal && frame % 24 === 0 ? 24 : 1) : frame
137 if (want !== (await read($, frameAtom))) await update($, frameAtom, () => want)
138 })
139 return next(e)
140 })
141
142 on('turn.complete', async ($, e, next) => {
143 await refresh($)
144 return next(e)
145 })
146
147 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
148 const s = await read($, summaryAtom)
149 if (e.props.hasSurvey || s === null || (await read($, isHidden))) {
150 return next(e)
151 }
152 const { Box, Button, Text } = $.ui.resolve(e)
153 // Svg is drawn on the remote surfaces (desktop, editor, phone) and Select on all but the phone;
154 // the terminal's table has no Svg and the phone's no Select
155 const table = $.ui.resolve(e) as unknown as Record<string, any>
156 const Svg = e.surface === 'terminal' ? undefined : table.Svg
157 const Select = e.surface === 'mobile' ? undefined : table.Select
158 const now = await $.clock.now()
159 const last = s.last
160 const isQuiet = last === null || now - last.ts * 1000 > QUIET_MS
161 const color = last === null ? undefined : last.kind === 'block' ? 'red' : last.kind === 'warn' ? 'yellow' : 'green'
162 const line =
163 last === null
164 ? `${statusText(s)}. Nothing to report yet.`
165 : `${statusText(s)}. Last: ${last.msg} (${ageText(now, last.ts)})`
166
167 const frame = await read($, frameAtom)
168 const voice = await read($, voiceAtom)
169 const mood = moodOf(last?.kind, last ? now - last.ts * 1000 : Infinity)
170 const rows = chadRows(mood, frame)
171 const seed = mood === 'idle' ? Math.floor((await read($, sinceAtom)) / 1000) : s.fired
172
173 const bar = await read($, barAtom)
174 const panel = await read($, panelAtom)
175 const guards = await read($, guardsAtom)
176 const note = await read($, noteAtom)
177 const toggle = async (to: 'guards' | 'bar') => {
178 const open = await read($, panelAtom)
179 if (open === to) return update($, panelAtom, () => 'none' as const)
180 if (to === 'guards') await loadGuards($)
181 return update($, panelAtom, () => to)
182 }
183 const onOff = [{ value: 'on' }, { value: 'off' }]
184 // a dropdown where the surface has one; the phone has none, so a button that flips the value
185 const pick = (key: string, label: string, options: { value: string }[], value: string, onPick: (v: string) => unknown) =>
186 Select ? (
187 <Box key={`pick-${key}`} marginRight={2}>
188 <Select key={`sel-${key}`} label={`${label}:`} options={options} value={value} onSelect={(v: string) => onPick(v)} />
189 </Box>
190 ) : (
191 <Box key={`pick-${key}`} marginRight={2}>
192 <Button
193 key={`btn-${key}`}
194 label={`${label}: ${value}`}
195 onPress={() => onPick(options[(options.findIndex(o => o.value === value) + 1) % options.length]?.value ?? value)}
196 />
197 </Box>
198 )
199
200 return (
201 <Box flexDirection="column">
202 <Box alignItems="center">
203 {bar.robot && (
204 <Box flexDirection="column" marginRight={2}>
205 {Svg ? (
206 <Svg source={chadSvg(mood, frame)} alt="mogger, a robot with orange eyes" />
207 ) : (
208 rows.map((r, i) => (
209 <Box key={`c${i}`}>
210 {r.map((seg, j) => (
211 <Text key={`c${i}-${j}`} color={seg.color} backgroundColor={seg.bg}>
212 {seg.text}
213 </Text>
214 ))}
215 </Box>
216 ))
217 )}
218 </Box>
219 )}
220 <Box flexDirection="column">
221 <Text bold color={ORANGE}>
222 {headline(mood, s.fired, s.blocked, voice, last ?? undefined, seed)}
223 </Text>
224 {bar.detail && (
225 <Text dimColor={isQuiet} color={isQuiet ? undefined : color}>
226 {line}{' '}
227 </Text>
228 )}
229 <Box>
230 <Button key="guards" label={`⚙ Guards ${panel === 'guards' ? '▴' : '▾'}`} variant="primary" onPress={() => toggle('guards')} />
231 <Text> </Text>
232 <Button key="bar" label={`◐ Display ${panel === 'bar' ? '▴' : '▾'}`} variant="primary" onPress={() => toggle('bar')} />
233 <Text> </Text>
234 <Button key="hide" label="✕ Hide" onPress={() => update($, isHidden, () => true)} />
235 </Box>
236 </Box>
237 </Box>
238 {panel === 'bar' && (
239 <Box flexDirection="column" marginTop={1}>
240 <Text dimColor>What this bar shows (kept for next time):</Text>
241 <Box flexWrap="wrap">
242 {pick('robot', 'Robot', onOff, bar.robot ? 'on' : 'off', v => setBar($, { ...bar, robot: v === 'on' }))}
243 {pick('detail', 'Detail line', onOff, bar.detail ? 'on' : 'off', v => setBar($, { ...bar, detail: v === 'on' }))}
244 </Box>
245 </Box>
246 )}
247 {panel === 'guards' && (
248 <Box flexDirection="column" marginTop={1}>
249 <Text dimColor>Pick on (block), ask (you decide each time) or off (allow) for each guard. Safety guards can only be on or ask here; to turn one off use /mogger:config. A MOGGER_* variable that is set still wins.</Text>
250 {note !== '' && <Text color="red">{note}</Text>}
251 <Box flexWrap="wrap">
252 {guards.map(g =>
253 pick(
254 g.name,
255 g.name,
256 g.options.map(value => ({ value })),
257 g.state,
258 v => setGuard($, g.name, v),
259 ),
260 )}
261 </Box>
262 </Box>
263 )}
264 </Box>
265 )
266 })
267}
268mods/mogger-status/hooks/chad.ts 216 lines1// The mogger chad: a robot (pale head, glowing orange eyes, black armour, orange
2// asterisk on the chest) drawn as pixel art in 6 rows of text. Each text row holds two
3// pixel rows, stacked with half blocks (fg = top pixel, bg = bottom pixel), so the
4// picture is 15 x 12 pixels. That is for the terminal; the desktop app, the editor and the phone
5// draw each text row with a line gap, so half blocks do not tile there and `chadSvg` draws the
6// same pixels as one picture. Pure helpers: no timers, no engine calls.
7// The band above the prompt may be taller than this (see `maxRows` in the engine
8// types: up to half the terminal in fullscreen); 6 rows is a design choice.
9export type ChadMood = 'idle' | 'block' | 'warn' | 'ok'
10export type ChadSeg = { text: string; color?: string; bg?: string }
11export type ChadRow = ChadSeg[]
12
13export const ORANGE = '#ff7a1a'
14const HEAD = '#e6e6e6'
15const SHADE = '#9a9a9a'
16const ARMOR = '#2a2a2a'
17const PLATE = '#b4b4b4'
18const BLINK = '#555555'
19// motion lasts this long after a guard fires, then the chad stands still
20export const ACTIVE_MS = 4000
21export const TICK_MS = 250
22
23export function moodOf(kind: string | undefined, ageMs: number): ChadMood {
24 if (kind === undefined || ageMs > ACTIVE_MS) return 'idle'
25 return kind === 'block' ? 'block' : kind === 'warn' ? 'warn' : 'ok'
26}
27
28// 15 wide, 12 tall. H head, S head shade, E eye (set in a dark visor), K armour, G neck,
29// P chest plate, O orange trim (headphones, armour), B asterisk pixel (drawn from PLUS or CROSS below), . empty
30const PIXELS = [
31 '....SHHKHHS....',
32 '...KHHHKHHHK...',
33 '..OKKEEKEEKKO..',
34 '...KSHHHHHSK...',
35 '....SHHHHHS....',
36 '.....SHHHS.....',
37 '......KGK......',
38 'KKKKPPPPPPPKKKK',
39 'KOKPPPBBBPPPKOK',
40 'KOKPPPBBBPPPKOK',
41 'KKKPPPBBBPPPKKK',
42 'KKKKPPPPPPPKKKK',
43]
44// the asterisk on the 3 x 3 chest cells: a plus, or an x; the active chad alternates them
45const PLUS = ['.B.', 'BBB', '.B.']
46const CROSS = ['B.B', '.B.', 'B.B']
47
48// frame: 0.. (counts ticks). Idle: eyes blink for one tick every 24 ticks
49// (6s); the asterisk stays still. Active: the asterisk spins and eyes glow.
50export function chadPixels(mood: ChadMood, frame: number): (string | null)[][] {
51 const isActive = mood !== 'idle'
52 const isBlink = !isActive && frame % 24 === 0
53 const eye = isBlink ? BLINK : mood === 'block' ? 'red' : mood === 'warn' ? 'yellow' : ORANGE
54 const star = isActive && frame % 2 === 1 ? CROSS : PLUS
55 const palette: Record<string, string> = { H: HEAD, S: SHADE, E: eye, K: ARMOR, P: PLATE, G: SHADE, O: ORANGE }
56 return PIXELS.map((row, y) =>
57 [...row].map((c, x) => {
58 if (c === 'B') return star[y - 8]?.[x - 6] === 'B' ? ORANGE : PLATE
59 return palette[c] ?? null
60 }),
61 )
62}
63
64// Two pixel rows -> one text row; equal neighbours merge into one segment.
65export function chadRows(mood: ChadMood, frame: number): ChadRow[] {
66 const px = chadPixels(mood, frame)
67 const rows: ChadRow[] = []
68 for (let y = 0; y < px.length; y += 2) {
69 const row: ChadRow = []
70 for (let x = 0; x < (px[y] as (string | null)[]).length; x++) {
71 const top = (px[y] as (string | null)[])[x] ?? null
72 const bottom = (px[y + 1] as (string | null)[])[x] ?? null
73 const seg: ChadSeg =
74 top === null && bottom === null
75 ? { text: ' ' }
76 : top === bottom
77 ? { text: '█', color: top as string }
78 : bottom === null
79 ? { text: '▀', color: top as string }
80 : top === null
81 ? { text: '▄', color: bottom }
82 : { text: '▀', color: top, bg: bottom }
83 const last = row[row.length - 1]
84 if (last && last.color === seg.color && last.bg === seg.bg) last.text += seg.text
85 else row.push(seg)
86 }
87 rows.push(row)
88 }
89 return rows
90}
91
92// The same picture as one SVG, for the surfaces named above. `px` is the size of one pixel in CSS pixels.
93export function chadSvg(mood: ChadMood, frame: number, px = 6): string {
94 const grid = chadPixels(mood, frame)
95 const w = (grid[0] as (string | null)[]).length
96 const rects: string[] = []
97 grid.forEach((row, y) =>
98 row.forEach((fill, x) => {
99 if (fill !== null) rects.push(`<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`)
100 }),
101 )
102 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${grid.length}" width="${w * px}" height="${grid.length * px}" shape-rendering="crispEdges">${rects.join('')}</svg>`
103}
104
105// Voice: pools of smug one-liners per moment. The pick is a plain rotation
106// (index = seed mod pool size), so the same line never shows twice in a row and
107// there are no model calls. The quip sits beside the facts, never in place of them.
108export type Moment = 'block' | 'warn' | 'ok' | 'pass' | 'caught' | 'idle' | 'quiet'
109
110export const QUIPS: Record<Moment, string[]> = {
111 block: [
112 'Not on my watch.',
113 'Nice try.',
114 'Denied. Warmly.',
115 'That was adorable. No.',
116 'Bold move. Blocked.',
117 'Not today, chaos.',
118 'Saved you from yourself.',
119 'Hard pass.',
120 'Tempting. Still no.',
121 'I read the room. The room says no.',
122 ],
123 warn: [
124 'Eyes on this.',
125 'Noted. Judging quietly.',
126 'Not a block. Yet.',
127 'I raised an eyebrow.',
128 'That smells off.',
129 'Careful. I am watching.',
130 'Filing that under suspicious.',
131 'Worth a second look. I took it.',
132 ],
133 ok: [
134 'Handled.',
135 'Tidied. You are welcome.',
136 'Quietly fixed.',
137 'Done before you asked.',
138 'Sorted.',
139 'Routine.',
140 'Easy.',
141 'Already on it.',
142 ],
143 pass: [
144 'Green. Obviously.',
145 'Tests pass. Not surprised.',
146 'Clean run.',
147 'Zero failures. Naturally.',
148 'Green across the board.',
149 'That is how it is done.',
150 'Passing. Smugly.',
151 'Flawless. Again.',
152 ],
153 caught: [
154 'Proof or it did not happen.',
155 '"Done" is a claim. Show me.',
156 'Nothing ran. Nice story though.',
157 'Confidence is not evidence.',
158 'Run it. Then we talk.',
159 'Bold of you to say done.',
160 'I need receipts.',
161 'Trust, but verify. Mostly verify.',
162 ],
163 idle: [
164 'mogger: watching. always.',
165 'Quiet shift. I like it.',
166 'Standing guard. Bring your worst.',
167 'All clear. Suspiciously.',
168 'Guarding. Smugly.',
169 'Ready when you make a mistake.',
170 'On duty, effortlessly.',
171 'Nothing to stop yet. Give it time.',
172 ],
173 quiet: [
174 'Back to watching.',
175 'Calm again.',
176 'Back to being bored.',
177 'Still here. Still watching.',
178 'Scoreboard so far:',
179 'Standing guard again.',
180 'Resuming smugness.',
181 'Nothing else to stop. For now.',
182 ],
183}
184
185// Which moment an event is. The log only has block|warn|ok, so a few messages
186// that mogger writes itself pick the finer ones.
187export function momentOf(kind: string, msg: string): Moment {
188 if (kind === 'ok' && msg.startsWith('tests passed')) return 'pass'
189 if (kind === 'block' && /^(done claim|stopped early|stopped with)/.test(msg)) return 'caught'
190 return kind === 'block' ? 'block' : kind === 'warn' ? 'warn' : 'ok'
191}
192
193export function quip(moment: Moment, seed: number): string {
194 const pool = QUIPS[moment]
195 return pool[Math.abs(Math.trunc(seed)) % pool.length] as string
196}
197
198// voice=true: the chad's own lines. voice=false: the plain wording. Same facts either way.
199// seed picks the line: pass the count of events so far (changes with every event);
200// for idle, pass the session start so the line is steady within a session.
201export function headline(mood: ChadMood, fired: number, blocked: number, voice = false, last?: { kind: string; msg: string }, seed = fired): string {
202 if (voice) {
203 if (mood !== 'idle' && last) return quip(momentOf(last.kind, last.msg), seed)
204 return fired === 0 ? quip('idle', seed) : `${quip('quiet', seed)} ${blocked} stopped, ${fired - blocked} noted.`
205 }
206 if (mood === 'block') return 'mogger just stopped something'
207 if (mood === 'warn') return 'mogger flagged something'
208 if (mood === 'ok') return 'mogger is on it'
209 return fired === 0 ? 'mogger is watching' : `mogger is watching: ${fired} checks, ${blocked} blocked`
210}
211
212// toast text for a new block: the facts first, the quip after
213export function toastText(msg: string, voice = false, seed = 0): string {
214 return voice ? `mogger ${msg}. ${quip(momentOf('block', msg), seed)}` : `mogger ${msg}`
215}
216mods/mogger-status/hooks/panel.ts 67 lines1// Pure helpers for the bar's panels: read the guard catalog (hooks/guards.tsv),
2// work out each guard's state from the switch file, build the new file text for a
3// pick, and the bar options the user can switch. No engine calls, no commands:
4// the mod reads and writes the same switch file the mogger:config script does.
5export type GuardDef = { name: string; envVar: string; kind: string; onValue: string; dflt: string; states: string[] }
6export type GuardRow = { name: string; kind: string; state: string; source: 'file' | 'default'; options: string[] }
7
8// guards.tsv: name, variable, kind, on-value, default, ifON, ifOFF, states, ifASK; tab separated; # lines are comments.
9export function parseCatalog(tsv: string): GuardDef[] {
10 const defs: GuardDef[] = []
11 for (const line of tsv.split('\n')) {
12 if (line.startsWith('#')) continue
13 const c = line.split('\t')
14 if (c.length < 5 || !(c[0] ?? '').trim()) continue
15 const states = (c[7] ?? '').trim() === '' ? ['on', 'off'] : (c[7] as string).trim().split(',')
16 defs.push({ name: (c[0] as string).trim(), envVar: (c[1] ?? '').trim(), kind: (c[2] ?? '').trim(), onValue: (c[3] ?? '').trim(), dflt: (c[4] ?? '').trim(), states })
17 }
18 return defs
19}
20
21// What a pick may be. A safety guard never goes `off` from the bar (that needs the warning and
22// the explicit yes in /mogger:config), so it only moves between on and ask.
23export function optionsOf(d: GuardDef): string[] {
24 return ['on', 'ask', 'off'].filter(s => d.states.includes(s) && !(d.kind === 'safety' && s === 'off'))
25}
26
27export function parseConfig(text: string | null): Record<string, unknown> | null {
28 if (text === null || text.trim() === '') return {}
29 try {
30 const v: unknown = JSON.parse(text)
31 return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
32 } catch {
33 return null
34 }
35}
36
37// What `mogger-config.sh list` shows with no MOGGER_* variable set: the file value when the guard allows
38// it, else the default. A variable that is set still wins at run time; a mod cannot read variables by a
39// computed name, so the panel says so in one line instead of marking each guard.
40export function rowOf(d: GuardDef, cfg: Record<string, unknown>): GuardRow {
41 const v = cfg[d.name]
42 const fromFile = typeof v === 'string' && d.states.includes(v)
43 return { name: d.name, kind: d.kind, state: fromFile ? (v as string) : d.dflt, source: fromFile ? 'file' : 'default', options: optionsOf(d) }
44}
45
46// A guard the bar may offer a choice for: there must be at least two states to pick from.
47export function isPickable(g: GuardRow): boolean {
48 return g.options.length >= 2
49}
50
51// The new switch-file text after a pick, or why not. A file that is not a JSON object is never overwritten.
52export function withPick(cfgText: string | null, d: GuardDef, value: string): { ok: true; text: string } | { ok: false; reason: string } {
53 if (!optionsOf(d).includes(value)) return { ok: false, reason: `${d.name} cannot be ${value} from the bar` }
54 const cfg = parseConfig(cfgText)
55 if (cfg === null) return { ok: false, reason: 'the switch file is not valid JSON; fix it by hand first' }
56 return { ok: true, text: `${JSON.stringify({ ...cfg, [d.name]: value }, null, 2)}\n` }
57}
58
59export type BarOptions = { robot: boolean; detail: boolean }
60export const DEFAULT_BAR: BarOptions = { robot: true, detail: true }
61
62// Anything stored that is not a plain object of booleans falls back to the default.
63export function readBar(v: unknown): BarOptions {
64 const o = typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
65 return { robot: o.robot !== false, detail: o.detail !== false }
66}
67mods/mogger-status/hooks/log.ts 65 lines1// Pure helpers for the mogger event log. One line per event:
2// <epoch seconds> TAB <kind> TAB <short message>
3// Written by mogger_event in hooks/scripts/lib.sh. No secrets in the log.
4import type { MoggerEvent, MoggerSummary } from '../types'
5
6export const LOG_PATH = '.claude/state/mogger-events.log'
7
8// kinds that count as "a guard fired". "info" lines (session start) do not.
9const COUNTED = ['block', 'warn', 'ok']
10
11export function parseLog(text: string): MoggerEvent[] {
12 const events: MoggerEvent[] = []
13 for (const line of text.split('\n')) {
14 const parts = line.split('\t')
15 if (parts.length < 3) continue
16 const ts = Number(parts[0])
17 if (!Number.isFinite(ts) || ts <= 0) continue
18 const kind = parts[1] ?? ''
19 if (kind === '') continue
20 events.push({ ts, kind, msg: parts.slice(2).join(' ').slice(0, 120) })
21 }
22 return events
23}
24
25// sinceMs: only count events at or after this time (0 = all).
26export function summarize(events: MoggerEvent[], sinceMs: number): MoggerSummary {
27 const mine = events.filter(e => e.ts * 1000 >= sinceMs)
28 const counted = mine.filter(e => COUNTED.includes(e.kind))
29 return {
30 fired: counted.length,
31 blocked: counted.filter(e => e.kind === 'block').length,
32 last: counted.length > 0 ? (counted[counted.length - 1] as MoggerEvent) : null,
33 }
34}
35
36export function statusText(s: MoggerSummary): string {
37 return `mogger: ${s.fired} guards fired, ${s.blocked} blocked`
38}
39
40export function newestTs(events: MoggerEvent[]): number {
41 return events.reduce((m, e) => Math.max(m, e.ts), 0)
42}
43
44// Events to toast: blocks newer than lastTs.
45export function newBlocks(events: MoggerEvent[], lastTs: number): MoggerEvent[] {
46 return events.filter(e => e.kind === 'block' && e.ts > lastTs)
47}
48
49export function ageText(nowMs: number, ts: number): string {
50 const s = Math.max(0, Math.round(nowMs / 1000 - ts))
51 if (s < 60) return `${s}s ago`
52 if (s < 3600) return `${Math.floor(s / 60)}m ago`
53 return `${Math.floor(s / 3600)}h ago`
54}
55
56// Session start logs "voice on" or "voice off" (hooks/scripts/session-start.sh).
57// The newest one wins. No line yet: voice is on, the default.
58export function voiceOn(events: MoggerEvent[]): boolean {
59 for (let i = events.length - 1; i >= 0; i--) {
60 const e = events[i] as MoggerEvent
61 if (e.kind === 'voice') return e.msg !== 'off'
62 }
63 return true
64}
65mods/mogger-status/types/index.d.ts 24 lines1export type MoggerEvent = { ts: number; kind: string; msg: string }
2export type MoggerSummary = {
3 fired: number
4 blocked: number
5 last: MoggerEvent | null
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'mogger': {
11 summary: MoggerSummary | null
12 isHidden: boolean
13 since: number
14 frame: number
15 voice: boolean
16 panel: 'none' | 'guards' | 'bar'
17 guards: { name: string; kind: string; state: string; source: 'file' | 'default'; options: string[] }[]
18 note: string
19 minute: number
20 bar: { robot: boolean; detail: boolean }
21 }
22 }
23}
24