Ends a turn after N forced Stop-hook continuations so a looping goal hook cannot burn your usage

| 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 113 lines1import type { Hook, Register } from 'claude-code'
2
3type Api = Parameters<Hook<'classic.Stop'>>[0]
4
5const MOD = 'loop-brake'
6const WEEK = 7 * 86_400_000
7const DEFAULT_CAP = 3
8
9function capFrom(value: unknown): number {
10 return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 20 ? value : DEFAULT_CAP
11}
12
13async function stateDir($: Api): Promise<string | undefined> {
14 const config = await $.env.get('CLAUDE_CONFIG_DIR')
15 if (config) return `${config}/mod-state`
16 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
17 return home ? `${home}/.claude/mod-state` : undefined
18}
19
20async function remove($: Api, paths: string[]): Promise<void> {
21 if (paths.length === 0) return
22 await $.process.run(['rm', '-f', '--', ...paths])
23}
24
25async function publishState($: Api, streak: number, cap: number): Promise<void> {
26 try {
27 const dir = await stateDir($)
28 if (!dir) return
29 const path = `${dir}/${await $.session.id()}.${MOD}.json`
30 await $.fs.write(path, JSON.stringify({ v: 1, streak, cap }))
31 } catch (cause) {
32 throw new Error(`${MOD}: could not publish state for the status line`, { cause })
33 }
34}
35
36async function pruneState($: Api): Promise<void> {
37 try {
38 const dir = await stateDir($)
39 if (!dir || !(await $.fs.exists(dir))) return
40 const now = await $.clock.now()
41 const stale = (await $.fs.list(dir)).filter(
42 f => f.kind === 'file' && f.name.endsWith(`.${MOD}.json`) && now - f.mtimeMs > WEEK,
43 )
44 await remove($, stale.map(f => `${dir}/${f.name}`))
45 } catch (cause) {
46 throw new Error(`${MOD}: could not prune old state files`, { cause })
47 }
48}
49
50async function drawStatus($: Api, streak: number, cap: number): Promise<void> {
51 const surfaces = await $.session.surfaces()
52 const shown = streak > 0 && surfaces.some(s => s !== 'terminal')
53 await $.ui.status(shown ? `${streak} of ${cap} forced continuations` : undefined)
54}
55
56async function show($: Api, streak: number, cap: number): Promise<void> {
57 await drawStatus($, streak, cap)
58 await publishState($, streak, cap)
59}
60
61async function clearStreak($: Api, streak: number, cap: number): Promise<void> {
62 if (streak > 0) await show($, 0, cap)
63}
64
65export const register: Register = (on, options) => {
66 const cap = capFrom(options.maxContinuations)
67 let streak = 0
68
69 on('session.start', async ($, e, next) => {
70 const result = await next(e)
71 await pruneState($)
72 return result
73 })
74
75 on('session.attach', async ($, e, next) => {
76 const result = await next(e)
77 await drawStatus($, streak, cap)
78 return result
79 })
80
81 on('session.detach', async ($, e, next) => {
82 const result = await next(e)
83 await drawStatus($, streak, cap)
84 return result
85 })
86
87 on('prompt.submit', async ($, e, next) => {
88 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
89 await clearStreak($, streak, cap)
90 streak = 0
91 }
92 return next(e)
93 })
94
95 on('classic.Stop', async ($, e, next) => {
96 const result = await next(e)
97 if (result.block === undefined || result.preventContinuation) {
98 await clearStreak($, streak, cap)
99 streak = 0
100 return result
101 }
102 if (streak >= cap) {
103 await clearStreak($, streak, cap)
104 streak = 0
105 $.ui.toast(`loop-brake: stopped after ${cap} forced continuations — reply to keep going`)
106 return { ...result, block: undefined }
107 }
108 streak++
109 await show($, streak, cap)
110 return result
111 })
112}
113