SLOPSHOPPER

usage-rings

Context, 5h and 7d usage above the prompt, each ring coloured by level: green, yellow, orange, red.

bandguardtoasttooltimeragents
★ 21v0.5.0GPL-3.0updated 2026-10-19fcsouza/agent-skills/mods/usage-rings
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-rings
› 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 ◑ 49% context ◔ 31% 5h [ Compact ] [ Code review ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◑ 49% context ◔ 31% 5h [ Compact ] [ Code review ] ⟨Claude Code's own drawing⟩
README

Agent Skills

Production-grade skills for AI coding agents (OpenClaw, Claude Code, and others).

Built by Fabricio Cavalcante — Software Engineer & AI Specialist.

Available Plugins

Plugins are complete workflow bundles — skills + agents + hooks working together as an integrated system. Install a plugin to get the full experience.

PluginDescriptionPlatform
game-dev26 skills, 7 commands, 3 agents, 5 hooks — complete game development workflow with engineering, design, narrative, and infrastructureGame Dev

Claude Code Mods

Mods are plugins built on function hooks: they run inside Claude Code, guard tool calls and draw above the prompt. See mods/ for install steps.

ModDescription
commit-guardBlocks commits with machine paths or .env files, git add -A, and pushes to main
prod-guardHolds Dokploy MCP writes until you press Proceed
bun-onlyRewrites npm, npx, yarn and pnpm to bun
done-gateShows edited files and the checks still owed
worktreesShows git worktrees, their changes, and the count
usage-ringsContext and plan usage, coloured by level
pr-statusThe current branch's PR: CI checks and review
sql-guardHolds writes to non-local databases until you press Proceed
pino-guardBlocks pino transports that break bundled logging

Available Skills

Skills are individual, cherry-pickable modules. Install only what you need.

Core

SkillDescriptionPlatform
claude-agent-sdkBuild production AI agents with the Claude Agent SDK — query(), hooks, subagents, custom tools, MCP servers, sessions, permissions, hostingClaude Code / API
database-performance-tuningDiagnose and fix slow databases like a DBA — pg_stat_statements, EXPLAIN plans, index design, query rewrites, vacuum and bloat, safe online rollout. Ships a read-only diagnostic scriptPostgreSQL / MySQL
foundry-vtt-module-devBuild, extend, and maintain Foundry VTT v14 modules — TypeDataModel, ApplicationV2 (detached windows), ActorSheetV2, hooks, sockets, dice, canvas/PIXI, Scene Levels, Regions as templates, ActiveEffects V2, v13→v14 migration, boilerplate filesFoundry VTT
foundry-vtt-system-devBuild Foundry VTT v14 game systems — system.json documentTypes, Actor/Item TypeDataModel, dice mechanics, combat/initiative, ActiveEffect V2 subtypes, data migration, character sheets, status effects, macros, enrichersFoundry VTT
openclaw-genieComprehensive OpenClaw gateway skill — installation, configuration, 38+ channels, memory, tools, hooks, deployment, multi-agentOpenClaw
nuvemshop-sdkPrepare, audit, implement, homologate, and publish Nuvemshop/Tiendanube apps — OAuth, API, NubeSDK, Nexo, Nimbus, webhooks, and App Store approvalNuvemshop
react-animationsReact animation patterns and utilitiesReact

Game Dev — Design

SkillDescription
game-design-fundamentalsCore loops, MDA framework, progression curves
game-economy-designVirtual economies, currencies, loot tables
level-designLevels, environments, difficulty curves
procedural-genDungeon gen, noise terrain, wave function collapse
quest-mission-designQuests, objectives, quest trees
skill-progression-treesSkill trees, talent trees, unlock graphs
ui-ux-gameHUD, menus, onboarding, accessibility

Game Dev — Engineering

SkillDescription
betterauth-integrationAuth, OAuth, JWT, RBAC
bullmq-game-queuesJob queues, async events, scheduled tasks
elevenlabs-sound-musicSFX generation, adaptive music, voice acting
game-backend-architectureGame servers, WebSocket, rooms, tick loops
game-state-syncClient-server sync, rollback netcode, delta compression
gameplay-analyticsRetention analytics, funnels, D1/D7/D30
matchmaking-systemMatchmaking, lobbies, ELO, skill-based queues
postgres-game-schemaGame DB schemas, Drizzle ORM, inventory
redis-game-patternsCaching, leaderboards, pub/sub, rate limiting
stripe-game-paymentsIn-app purchases, subscriptions, webhooks

Game Dev — Infrastructure

SkillDescription
ci-cd-gameCI/CD pipelines, GitHub Actions, deployment
claude-code-game-workflowEntry point for AI agents on game projects
cursor-codex-integrationCursor IDE, .cursorrules, Codex setup
monitoring-game-opsMonitoring, logging, telemetry, alerts

Game Dev — Narrative

SkillDescription
character-design-narrativeCharacters, NPCs, dialogue, archetypes
quest-narrative-coherenceNarrative coherence check, lore validation
story-structure-gameBranching narratives, player agency, story arcs
worldbuildingLore, factions, geography, world bibles

Structure

All skills follow a flat layout:

skills/<skill-name>/
├── README.md           # Overview and installation instructions
├── SKILL.md            # Main skill file (YAML frontmatter + body)
├── boilerplate/        # Starter code to copy and customize (optional)
├── templates/          # Document and config templates (optional)
├── references/         # Deep-dive reference files (optional)
└── ARCHITECTURE.md     # System design decisions (optional)
  • SKILL.md is always the entry point — read this first
  • boilerplate/ files are starter code, copy and customize
  • templates/ are document/config templates to fill in
  • ARCHITECTURE.md explains system design decisions

Installation

game-dev plugin (Claude Code)

/plugin marketplace add fcsouza/agent-skills
/plugin install game-dev@fcsouza-agent-skills

See plugins/game-dev/README.md for the full skill index, commands, and agents.

Individual skills (cherry-pick)

# Install a specific skill
npx skills add fcsouza/agent-skills --skill game-design-fundamentals

# Install all game-dev engineering skills at once
npx skills add fcsouza/agent-skills --skill betterauth-integration --skill postgres-game-schema --skill game-backend-architecture

# List all available skills
npx skills add fcsouza/agent-skills --list

Manual

# Plugin (full bundle)
cp -r plugins/game-dev/ .claude/plugins/game-dev/

# Individual skill
cp -r skills/<skill-name>/ .claude/skills/<skill-name>/

# For OpenClaw
cp -r skills/<skill-name>/ ~/.openclaw/workspace/skills/<skill-name>/

License

GPL-3.0

Source 3 files
hooks/register.tsx 224 lines
1import type { EngineInterface, Register, Timer } from 'claude-code';
2
3import {
4  type AgentRun,
5  applyProgress,
6  elapsed,
7  isRunning,
8  LINGER_MS,
9  MAX_ROWS,
10  modelLabel,
11  PROGRESS_BRIEF,
12  type ProgressInput,
13  progressBar,
14  sweepBar,
15  visible,
16} from './agents';
17import { colorOf, isNarrow, type Meter, metersOf, ringOf } from './meters';
18
19const REFRESH_MS = 30_000;
20
21let meters: Meter[] = [];
22let timer: Timer | undefined;
23// The action a button started and that has not finished yet; its buttons hide meanwhile.
24let running: 'compact' | 'review' | null = null;
25const PROGRESS_TOOL = 'mcp__usage-rings__progress';
26// Subagents this session started, newest last.
27let agents: AgentRun[] = [];
28// Redraws once a second while a subagent runs, so its clock moves.
29let ticker: Timer | undefined;
30
31function keepTicking($: EngineInterface): void {
32  if (ticker === undefined && isRunning(agents)) {
33    ticker = $.clock.every(1_000, () => {
34      if (!isRunning(agents)) {
35        ticker?.cancel();
36        ticker = undefined;
37      }
38      $.ui.invalidate('ui.render');
39    });
40  }
41}
42
43// Done fills the bar; a reported plan fills it by steps; no plan sweeps while running.
44function barOf(run: AgentRun, now: number): string {
45  if (run.status === 'done') return progressBar(1, 1);
46  if (run.stepTotal !== undefined) return progressBar(run.stepDone ?? 0, run.stepTotal);
47  return run.status === 'running' ? sweepBar(now - run.startedAt) : progressBar(0, 1);
48}
49
50const STATUS_COLOR = { running: '#38bdf8', done: '#22c55e', failed: '#ef4444' } as const;
51
52// `redraw: false` leaves the redraw to the ticker, which runs while any subagent does.
53function updateAgent($: EngineInterface, agentId: string, change: (run: AgentRun) => AgentRun, redraw = true): void {
54  agents = agents.map(run => (run.agentId === agentId ? change(run) : run));
55  if (redraw) $.ui.invalidate('ui.render');
56}
57
58// Runs a slash command as if typed. With `submitOnFailure`, a command the engine will not
59// run directly (a skill) is submitted as the user's prompt instead; /compact never is, so a
60// compaction that fails is not started twice.
61// Work a press starts is cancelled when the press ends, so a timer starts the command in
62// a dispatch of its own that lasts until the command ends.
63function runCommand($: EngineInterface, action: 'compact' | 'review', command: string, args = '', submitOnFailure = false): void {
64  running = action;
65  $.ui.invalidate('ui.render');
66  $.clock.after(1, () =>
67    $.command
68      .run({ command, args })
69      .catch((error: unknown) => {
70        if (!submitOnFailure) throw error;
71        return $.prompt.submit({ text: `/${command}${args === '' ? '' : ` ${args}`}`, asUser: true });
72      })
73      .catch((error: unknown) => $.ui.toast(`/${command} failed: ${error instanceof Error ? error.message : String(error)}`))
74      .finally(() => {
75        running = null;
76        $.ui.invalidate('ui.render');
77      }),
78  );
79}
80
81async function refresh($: EngineInterface): Promise<void> {
82  const [usage, now] = await Promise.all([$.session.usage(), $.clock.now()]);
83  meters = metersOf(usage, now);
84  $.ui.invalidate('ui.render');
85}
86
87export const register: Register = on => {
88  on('session.start', async ($, e, next) => {
89    const started = await next(e);
90    await $.tool
91      .register({
92        name: 'progress',
93        description:
94          'For subagents: report progress on your own task to the bar above the prompt. ' +
95          'Right after reading your task, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
96          'call it again as each step finishes. Cheap and silent: it only draws a bar.',
97        inputSchema: {
98          type: 'object',
99          properties: {
100            done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
101            total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
102            note: { type: 'string', description: 'The step in progress, a few words.' },
103          },
104          required: ['done'],
105        },
106        // In every agent's tool list, not behind ToolSearch: subagents do not go looking for it.
107        isDeferred: false,
108      })
109      .catch(() => undefined);
110    await refresh($).catch(() => undefined);
111    timer?.cancel();
112    timer = $.clock.every(REFRESH_MS, () => {
113      refresh($).catch(() => undefined);
114    });
115    return started;
116  });
117
118  on('turn.complete', async ($, e, next) => {
119    const done = await next(e);
120    const agentId = e.agentId;
121    if (agentId !== undefined) {
122      const at = await $.clock.now();
123      updateAgent($, agentId, run => ({ ...run, status: e.reason === 'answer' ? 'done' : 'failed', endedAt: at }));
124      // Redraw once the row's linger time is over, so it leaves the band.
125      $.clock.after(LINGER_MS + 50, () => $.ui.invalidate('ui.render'));
126    }
127    await refresh($).catch(() => undefined);
128    return done;
129  });
130
131  on('agent.spawn', async ($, e, next) => {
132    const started = await next(e.prompt.includes(PROGRESS_BRIEF) ? e : { ...e, prompt: e.prompt + PROGRESS_BRIEF });
133    if (started.deny !== undefined || started.agentId === undefined) {
134      return started;
135    }
136    const at = await $.clock.now();
137    const run: AgentRun = {
138      agentId: started.agentId,
139      description: e.description || e.subagentType,
140      model: started.model,
141      status: 'running',
142      startedAt: at,
143      tools: 0,
144    };
145    agents = [...visible(agents, at).filter(other => other.agentId !== run.agentId), run];
146    $.ui.invalidate('ui.render');
147    keepTicking($);
148    return started;
149  }).catch(($, e, next) => next(e));
150
151  // Answers the progress tool itself: nothing runs, the report only moves the subagent's bar.
152  on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
153    const agentId = e.agentId;
154    if (agentId === undefined || !agents.some(run => run.agentId === agentId)) {
155      return { result: 'ignored: only subagents report progress' };
156    }
157    updateAgent($, agentId, run => applyProgress(run, e as ProgressInput));
158    return { result: 'ok' };
159  }).catch(() => ({ result: 'ignored: the progress bar failed' }));
160
161  // Counts each subagent's tool calls: the closest a plain subagent comes to saying how far along it is.
162  on('tool.call', async ($, e, next) => {
163    const agentId = e.agentId;
164    if (agentId !== undefined && e.tool !== PROGRESS_TOOL && agents.some(run => run.agentId === agentId)) {
165      updateAgent($, agentId, run => ({ ...run, tools: run.tools + 1 }), false);
166    }
167    return next(e);
168  }).catch(($, e, next) => next(e));
169
170  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
171    const below = await next(e);
172    if (e.props.hasSurvey) {
173      return below;
174    }
175    const { Box, Button, Text } = $.ui.resolve(e);
176    const canAct = !e.props.isWorking && running === null;
177    const now = await $.clock.now();
178    const shown = visible(agents, now);
179    // Narrow: no reset countdowns, buttons on their own line, shorter agent rows.
180    const narrow = isNarrow(e.viewport?.columns);
181    const buttons = canAct ? (
182      <Box gap={1}>
183        <Button key="compact" label="Compact" onPress={() => runCommand($, 'compact', 'compact')} />
184        <Button key="review" label={narrow ? 'Review' : 'Code review'} onPress={() => runCommand($, 'review', 'code-review', 'high --fix', true)} />
185      </Box>
186    ) : null;
187    return (
188      <Box flexDirection="column">
189        <Box gap={narrow ? 2 : 3} justifyContent="center" flexWrap="wrap">
190          {meters.map(meter => (
191            <Box gap={1}>
192              <Text color={colorOf(meter.percent)}>{ringOf(meter.percent)}</Text>
193              <Text bold color={colorOf(meter.percent)}>
194                {meter.percent}%
195              </Text>
196              <Text dimColor>
197                {meter.label}
198                {meter.resets === undefined || narrow ? '' : ` · ${meter.resets}`}
199              </Text>
200            </Box>
201          ))}
202          {narrow ? null : buttons}
203        </Box>
204        {narrow && buttons !== null ? <Box justifyContent="center">{buttons}</Box> : null}
205        {shown.slice(-MAX_ROWS).map(run => (
206          <Box gap={1}>
207            <Text color={STATUS_COLOR[run.status]}>{run.status === 'running' ? '●' : run.status === 'done' ? '✓' : '✗'}</Text>
208            <Text wrap="truncate-end">{run.description}</Text>
209            <Text color={STATUS_COLOR[run.status]}>{barOf(run, now)}</Text>
210            {run.stepTotal !== undefined ? <Text>{`${run.stepDone ?? 0}/${run.stepTotal}`}</Text> : null}
211            <Text dimColor wrap="truncate-end">
212              {narrow
213                ? `${modelLabel(run.model)} · ${elapsed((run.endedAt ?? now) - run.startedAt)}`
214                : `${run.stepNote !== undefined && run.status === 'running' ? `${run.stepNote} · ` : ''}${modelLabel(run.model)} · ${elapsed((run.endedAt ?? now) - run.startedAt)} · ${run.tools} tool${run.tools === 1 ? '' : 's'}`}
215            </Text>
216          </Box>
217        ))}
218        {shown.length > MAX_ROWS ? <Text dimColor>{`+${shown.length - MAX_ROWS} more`}</Text> : null}
219        {below}
220      </Box>
221    );
222  });
223};
224
hooks/agents.ts 83 lines
1// The subagents this session runs, as rows under the meters. Pure, so the tests call it directly.
2
3export type AgentStatus = 'running' | 'done' | 'failed';
4
5export type AgentRun = {
6  agentId: string;
7  description: string;
8  model: string;
9  status: AgentStatus;
10  startedAt: number;
11  endedAt?: number;
12  tools: number;
13  // Set once the subagent reports its plan through the progress tool.
14  stepDone?: number;
15  stepTotal?: number;
16  stepNote?: string;
17};
18
19export type ProgressInput = { done?: unknown; total?: unknown; note?: unknown };
20
21// Appended to every subagent's prompt so it reports a plan the band can draw as a real bar.
22export const PROGRESS_BRIEF = `
23
24Progress reporting: right after reading this task, call the \`progress\` tool with \`total\` (your plan in 3-8 steps) and \`done: 0\`, then call it again each time a step finishes, with a few-word \`note\` naming the next step. It only draws a bar for the user; it costs nothing and returns nothing useful. If you have no \`progress\` tool, ignore this paragraph.`;
25
26const BAR_WIDTH = 8;
27
28// How long a finished row stays before it leaves the band.
29export const LINGER_MS = 8_000;
30// Rows drawn at most; the rest are counted on one line.
31export const MAX_ROWS = 8;
32
33/** `claude-haiku-4-5-20251001` → `Haiku 4.5`; anything else as it came. */
34export function modelLabel(model: string): string {
35  const match = /claude-(opus|sonnet|haiku|fable)-(\d+)(?:-(\d{1,2}))?(?:-|\[|$)/.exec(model);
36  if (match === null) {
37    return model;
38  }
39  const [, family = '', major = '', minor] = match;
40  return `${family[0]?.toUpperCase()}${family.slice(1)} ${major}${minor === undefined ? '' : `.${minor}`}`;
41}
42
43/** 9s, 1m05s, 1h02m. */
44export function elapsed(ms: number): string {
45  const seconds = Math.max(0, Math.floor(ms / 1000));
46  if (seconds < 60) {
47    return `${seconds}s`;
48  }
49  const minutes = Math.floor(seconds / 60);
50  if (minutes < 60) {
51    return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`;
52  }
53  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
54}
55
56/** Drops finished rows older than LINGER_MS; running rows always stay. */
57export function visible(runs: readonly AgentRun[], now: number): AgentRun[] {
58  return runs.filter(run => run.status === 'running' || now - (run.endedAt ?? now) < LINGER_MS);
59}
60
61/** Applies a progress report, clamping done to the plan and dropping bad values. */
62export function applyProgress(run: AgentRun, input: ProgressInput): AgentRun {
63  const total = typeof input.total === 'number' && input.total >= 1 ? Math.round(input.total) : run.stepTotal;
64  const doneRaw = typeof input.done === 'number' && input.done >= 0 ? Math.round(input.done) : (run.stepDone ?? 0);
65  const note = typeof input.note === 'string' && input.note.trim() !== '' ? input.note.trim().slice(0, 60) : run.stepNote;
66  return { ...run, stepTotal: total, stepDone: total === undefined ? doneRaw : Math.min(total, doneRaw), stepNote: note };
67}
68
69/** A filled bar for a known plan: ▰▰▰▱▱▱▱▱ */
70export function progressBar(done: number, total: number): string {
71  const filled = total <= 0 ? 0 : Math.round((Math.min(done, total) / total) * BAR_WIDTH);
72  return '▰'.repeat(filled) + '▱'.repeat(BAR_WIDTH - filled);
73}
74
75/** A 3-cell block sweeping across the bar, one cell a second: for a subagent with no plan. */
76export function sweepBar(elapsedMs: number): string {
77  const span = BAR_WIDTH + 2;
78  const head = Math.floor(elapsedMs / 1000) % span;
79  return Array.from({ length: BAR_WIDTH }, (_, i) => (i <= head && i > head - 3 ? '▰' : '▱')).join('');
80}
81
82export const isRunning = (runs: readonly AgentRun[]): boolean => runs.some(run => run.status === 'running');
83
hooks/meters.ts 61 lines
1// Turns the session's usage into coloured meters. Pure, so the tests call it directly.
2
3export type Meter = { label: string; percent: number; resets?: string };
4export type UsageLike = {
5  context: { percent?: number };
6  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[];
7};
8
9// Each level's colour: room to spare, worth watching, close, at the limit.
10export const LEVELS = [
11  { below: 50, color: '#22c55e' },
12  { below: 75, color: '#eab308' },
13  { below: 90, color: '#f97316' },
14  { below: Number.POSITIVE_INFINITY, color: '#ef4444' },
15] as const;
16
17export const colorOf = (percent: number): string => LEVELS.find(level => percent < level.below)?.color ?? '#ef4444';
18
19// A ring that fills by quarter: ○ ◔ ◑ ◕ ●
20export const ringOf = (percent: number): string => ['○', '◔', '◑', '◕', '●'][Math.min(4, Math.max(0, Math.round(percent / 25)))] ?? '○';
21
22const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', seven_day_opus: '7d opus', seven_day_sonnet: '7d sonnet' };
23
24export function metersOf(usage: UsageLike, now: number): Meter[] {
25  const meters: Meter[] = [];
26  if (usage.context.percent !== undefined) {
27    meters.push({ label: 'context', percent: clamp(usage.context.percent) });
28  }
29  for (const limit of usage.rateLimits) {
30    const resets = limit.resetsAt === undefined ? undefined : countdown(Date.parse(limit.resetsAt) - now);
31    meters.push({
32      label: LABELS[limit.kind] ?? limit.kind.replaceAll('_', ' '),
33      percent: clamp(limit.percentUsed),
34      ...(resets === undefined ? {} : { resets }),
35    });
36  }
37  return meters;
38}
39
40const clamp = (n: number): number => Math.max(0, Math.min(100, Math.round(n)));
41
42/** 2h05, 47m, or nothing once past. */
43export function countdown(ms: number): string | undefined {
44  if (!Number.isFinite(ms) || ms <= 0) {
45    return undefined;
46  }
47  const minutes = Math.ceil(ms / 60_000);
48  const days = Math.floor(minutes / 1440);
49  if (days > 0) {
50    return `${days}d${Math.floor((minutes % 1440) / 60)}h`;
51  }
52  const hours = Math.floor(minutes / 60);
53  return hours > 0 ? `${hours}h${String(minutes % 60).padStart(2, '0')}` : `${minutes}m`;
54}
55
56// Below this many columns the band switches to its compact form.
57export const NARROW_COLUMNS = 80;
58
59/** Narrow when the surface measured a width under NARROW_COLUMNS; wide when it measured none. */
60export const isNarrow = (columns: number | undefined): boolean => columns !== undefined && columns < NARROW_COLUMNS;
61