SLOPSHOPPER

loop-brake

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

newtoaststatuspromptprocess
v0.2.1no licenseupdated 2026-10-10cpeaustriajc/cj-stack/mods/loop-brake
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 1 files
hooks/register.ts 113 lines
1import 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