SLOPSHOPPER

usage-pace

Weekly usage in the status line, and whether it lasts until the reset

newstatustimer
v0.2.0no licenseupdated 2026-10-10cpeaustriajc/cj-stack/mods/usage-pace
A shopper browsing a rack in a slop shop
README

| session-modes | a mod: /mode audit, no-pr or chat, enforced for the session | optional | | denial-explainer | a mod: names the rule behind an auto-mode denial and the allow rule that would cover it | optional | | loop-brake | a mod: ends a turn after N forced Stop-hook continuations | optional |

cj-stack

CJ's skills and mods for Claude Code.

Install

/plugin marketplace add cpeaustriajc/cj-stack
/plugin install cj-stack@cj-stack
/plugin install linear-gate@cj-stack
/plugin install usage-pace@cj-stack
/plugin install session-modes@cj-stack
/plugin install denial-explainer@cj-stack
/plugin install loop-brake@cj-stack

While editing a mod, add the local clone instead (/plugin marketplace add ~/Projects/cj-stack): a directory marketplace hot-reloads.

Other agents (Codex and anything that reads ~/.agents/skills): scripts/link-skills.sh.

Plugins

PluginContentsInstall
cj-stackthe shipped skills belowby default
linear-gatea mod: holds Linear writes until you allow themby default
work-panea mod: test runs in a paneoptional
usage-pacea mod: weekly usage in the status line, and whether it lasts until your resetoptional
cj-paintpainting-craftwhere you paint in code
cj-jiraarchived Jira-era skillsonly for a Jira client

Skills

All skills are model-invoked: Claude picks them from their description.

SkillBucketWhat it is for
work-planningplanningshaping work in any tracker (Linear, Jira, GitHub, others): specs, work items, phases, projects, updates
project-knowledgeknowledgea decision log and research pages in the team's wiki, Notion, Linear, Obsidian or Confluence
painting-craftcraftdrawing and painting in code to a gallery standard, with a screenshot review loop
jira-ticketarchivedJira tickets and acceptance criteria
draft-ticketarchivedend-to-end Jira ticket drafting
github-issuearchivedGitHub issues holding a Jira ticket's engineering detail
linear-planningarchivedLinear-only predecessor of work-planning (not shipped)
linear-projectarchivedLinear-only predecessor of work-planning (not shipped)
project-wikiarchivedGitHub-wiki-only predecessor of project-knowledge

The archived skills were written for one project and still name its files. Shipped skills stay tool-neutral: tool specifics go in a skill's adapters, and a project's own facts stay in that project.

painting-craft: before and after

The same two prompts, each run once in a fresh session: the original skill on the left, the current painting-craft on the right. Nothing was touched up by hand.

"Paint a 1600x1000 SVG of a knight asleep against an oak tree at dusk, in a Romantic oil-painting look."

Knight at dusk, full picture: before and after

At 2× zoom, where the viewer looks. The knight is a 3D figure lit by the low sun, with separate plates, mail in rows and a visor, instead of a mannequin of tubes:

Knight at dusk, 2x crop: before and after

"Draw a night city street as a 1600x1000 SVG: a car passes behind a row of trees on the near verge. Flat vector style, but it must not look cheap."

Night street, full picture: before and after

At 2× zoom, a crown is clumps hung on a branch skeleton, in three values with broken edges, instead of a pile of round blobs:

Night street, 2x crop of a tree: before and after

What changed: build recipes for the parts that fail up close (references/recipes.md), studies of each hero element against fetched public-domain references before composing (scripts/refs.py, scripts/compare.py), the bundled 3D figure renderer, a paint pass that unifies painted styles (scripts/paintpass.py), and delivery at twice the display size.

Source 2 files
hooks/register.ts 59 lines
1import type { Hook, Register } from 'claude-code'
2import { weeklyStatus } from './pace'
3
4type Api = Parameters<Hook<'session.start'>>[0]
5
6async function show($: Api): Promise<void> {
7  const surfaces = await $.session.surfaces()
8  if (!surfaces.some(s => s !== 'terminal')) {
9    $.ui.status(undefined)
10    return
11  }
12  const { rateLimits } = await $.session.usage()
13  const week = rateLimits.find(r => r.kind === 'seven_day')
14  const text = week && weeklyStatus(week.percentUsed, week.resetsAt, await $.clock.now())
15  $.ui.status(text)
16}
17
18async function showOrClear($: Api): Promise<void> {
19  try {
20    await show($)
21  } catch (cause) {
22    $.ui.status(undefined)
23    throw new Error('usage-pace: could not read weekly usage', { cause })
24  }
25}
26
27export const register: Register = on => {
28  let timer: { cancel: () => void } | undefined
29
30  on('session.start', async ($, e, next) => {
31    const result = await next(e)
32    if (e.isInteractive === false) return result
33    timer?.cancel()
34    timer = $.clock.every(60_000, () => {
35      show($).catch(() => $.ui.status(undefined))
36    })
37    await showOrClear($)
38    return result
39  })
40
41  on('session.measure', async ($, e, next) => {
42    const result = await next(e)
43    if (e.changed.includes('rateLimits')) await showOrClear($)
44    return result
45  })
46
47  on('session.attach', async ($, e, next) => {
48    const result = await next(e)
49    await showOrClear($)
50    return result
51  })
52
53  on('session.detach', async ($, e, next) => {
54    const result = await next(e)
55    await showOrClear($)
56    return result
57  })
58}
59
hooks/pace.ts 28 lines
1const DAY = 86_400_000
2const MIN_ELAPSED = 12 * 3_600_000
3const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
4
5export function formatTime(ms: number): string {
6  const d = new Date(ms)
7  const h = d.getHours()
8  const mm = String(d.getMinutes()).padStart(2, '0')
9  return `${WEEKDAYS[d.getDay()]} ${h % 12 || 12}:${mm} ${h < 12 ? 'AM' : 'PM'}`
10}
11
12export function weeklyStatus(used: number, resetsAt: string | undefined, now: number): string | undefined {
13  const resetMs = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt)
14  if (Number.isNaN(resetMs) || resetMs <= now) return undefined
15
16  const head = `Week ${Math.round(used)}%`
17  const resets = `${head} · resets ${formatTime(resetMs)}`
18  const start = resetMs - 7 * DAY
19  const elapsed = now - start
20  if (used >= 100 || used === 0 || elapsed < MIN_ELAPSED) return resets
21
22  const perDay = Math.round((100 - used) / ((resetMs - now) / DAY))
23  const outAt = start + (elapsed * 100) / used
24  return outAt >= resetMs
25    ? `${head} · lasts to ${formatTime(resetMs)} · ${perDay}%/day left`
26    : `${head} · out ~${formatTime(outAt)} · ${perDay}%/day left`
27}
28