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

| 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's skills and mods for Claude Code.
/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.
| Plugin | Contents | Install |
|---|---|---|
cj-stack | the shipped skills below | by default |
linear-gate | a mod: holds Linear writes until you allow them | by default |
work-pane | a mod: test runs in a pane | optional |
usage-pace | a mod: weekly usage in the status line, and whether it lasts until your reset | optional |
cj-paint | painting-craft | where you paint in code |
cj-jira | archived Jira-era skills | only for a Jira client |
All skills are model-invoked: Claude picks them from their description.
| Skill | Bucket | What it is for |
|---|---|---|
| work-planning | planning | shaping work in any tracker (Linear, Jira, GitHub, others): specs, work items, phases, projects, updates |
| project-knowledge | knowledge | a decision log and research pages in the team's wiki, Notion, Linear, Obsidian or Confluence |
| painting-craft | craft | drawing and painting in code to a gallery standard, with a screenshot review loop |
| jira-ticket | archived | Jira tickets and acceptance criteria |
| draft-ticket | archived | end-to-end Jira ticket drafting |
| github-issue | archived | GitHub issues holding a Jira ticket's engineering detail |
| linear-planning | archived | Linear-only predecessor of work-planning (not shipped) |
| linear-project | archived | Linear-only predecessor of work-planning (not shipped) |
| project-wiki | archived | GitHub-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.
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."

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:

"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."

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:

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.
hooks/register.ts 59 lines1import 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}
59hooks/pace.ts 28 lines1const 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