SLOPSHOPPER

skill-release-mod

Holds gh release create and npm publish until prompts-to-skill-release has run its test cases since the last edit.

newguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skill-release-mod
› 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 › /skill-release-mod ⎿ skill-release-mod: skill-release-mod (ask): 0 of 1 checks run since the last edit, still to run: prompts-to-skill-release. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

skill-release-mod

Holds gh release create and npm publish until prompts-to-skill-release has run its test cases since the last edit.

This plugin is a Claude Code mod (needs Claude Code 2.1.287 or later). It bundles the prompts-to-skill-release skill, unchanged, in skills/prompts-to-skill-release/, and adds code that runs inside Claude Code to trigger it at the right moment.

What it does

  • Gate. A Bash command that matches one of the ship patterns is held until the required skills (prompts-to-skill-release) have run since the last edit. Modes (set with /plugin configure <plugin>@<marketplace>): ask, block, nudge, off. After 2 holds it lets the command through, so a missing skill can never trap a session.
  • Prime. When a prompt matches, one line of context tells Claude which skill applies.
  • /skill-release-mod shows the state; /skill-release-mod off snoozes it for the session and /skill-release-mod on resumes.
  • It reads the text of commands and prompts, so it is a reminder, not a security control. It fails open.

Install

/plugin install skill-release-mod --marketplace arm-pz/caffeinity-skills

Try it from a clone

claude --plugin-dir ./skill-release-mod

Run /plugin and look for "1 mod active". To share it, list it in a marketplace and install with /plugin install skill-release-mod@<marketplace>.

Trust surface

Mods are not sandboxed: they run with your permissions inside Claude Code. This block is the report from claude plugin validate, refreshed by verify_mod.py --update-readme. Read the calls: line before you install.

<!-- trust:start -->

hooks: ./register.js session.start, skill.prompt, tool.call{tool=Edit|Write|MultiEdit|NotebookEdit}, tool.call{tool=Bash}, prompt.submit, command.run{command=skill-release-mod}
calls: ./register.js $.command.register, $.ui.ask, $.ui.status, $.ui.toast

<!-- trust:end -->

Tests

claude plugin test ./skill-release-mod

Licence

Choose one before publishing and add a LICENSE file or a license field in plugin.json.

Source 1 files
hooks/register.js 167 lines
1// Generated by skill-to-mod from the skill "prompts-to-skill-release". It is plain code: edit it freely.
2//
3// Hooks: gate: holds a ship command until the required skills have run since the last edit; prime: adds one line of context when a prompt matches; /skill-release-mod: shows state, snoozes with off
4//
5// A reminder, not a security boundary: it reads text and fails open. The skill
6// itself, bundled in skills/prompts-to-skill-release/, still does the real work.
7
8const NAME = "skill-release-mod"
9const SKILL = "prompts-to-skill-release"
10const DEFAULT_MODE = "ask"
11const DEFAULT_REQUIRED = "prompts-to-skill-release"
12
13// State is per session. Module variables start over when the mod reloads.
14let snoozed = false
15
16// What counts as shipping: a command line that puts work in front of others or into production.
17const SHIP = [
18  new RegExp("(?:^|[\\s;&|(])gh\\s+release\\s+create(?![\\w-])"),
19  new RegExp("(?:^|[\\s;&|(])npm\\s+publish(?![\\w-])"),
20  new RegExp("\\bgit\\s+tag\\s+(-a\\s+)?v\\d"),
21]
22const MAX_HOLDS = 2
23
24// What has happened since the last edit.
25const done = new Set()
26let acknowledged = false
27let holds = 0
28
29function isShip(command) {
30  return SHIP.some((re) => re.test(command))
31}
32
33function listRequired(options) {
34  const raw = typeof options.required === 'string' ? options.required : DEFAULT_REQUIRED
35  return raw.split(',').map((s) => s.trim()).filter((s) => s !== '')
36}
37
38// A skill reaches skill.prompt as `name`, or `plugin:name` for a plugin's skill.
39function sameSkill(seen, wanted) {
40  return seen === wanted || seen.endsWith(':' + wanted)
41}
42
43// Prompts that say the moment has come.
44const PRIME = [
45  new RegExp("(?<![\\w-])package\\s+this\\s+skill(?![\\w-])", "i"),
46  new RegExp("(?<![\\w-])make\\s+it\\s+installable(?![\\w-])", "i"),
47]
48
49export function register(on, options) {
50  const mode = typeof options.mode === 'string' ? options.mode : DEFAULT_MODE
51  const required = listRequired(options)
52
53  function outstanding() {
54    return required.filter((name) => !done.has(name))
55  }
56
57  on('session.start', async ($, e, next) => {
58    await $.command.register({
59      name: "skill-release-mod",
60      description: 'Show skill-release-mod state, or snooze it with "off" and resume it with "on"',
61      argumentHint: '[off|on]',
62      immediate: true,
63    })
64    return next(e)
65  })
66
67  on('skill.prompt', async ($, e, next) => {
68    for (const name of required) {
69      if (sameSkill(e.skill, name)) {
70        done.add(name)
71        holds = 0
72      }
73    }
74    $.ui.status(outstanding().length === 0 ? undefined : NAME + ': ' + outstanding().join(', ') + ' still to run')
75    return next(e)
76  })
77
78  on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
79    const ran = await next(e)
80    const changed = ran.deny === undefined && ran.isError !== true
81    if (changed && (done.size > 0 || acknowledged)) {
82      done.clear()
83      acknowledged = false
84      holds = 0
85      $.ui.status(NAME + ': checks are stale after edits (' + required.join(', ') + ')')
86    }
87    return ran
88  }).catch(($, e, next) => next(e)) // fail open: a reminder must never block an edit
89
90  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
91    const command = String(e.command ?? '')
92    if (mode === 'off' || snoozed || required.length === 0 || !isShip(command)) {
93      return next(e)
94    }
95    const missing = outstanding()
96    if (missing.length === 0 || acknowledged) {
97      return next(e)
98    }
99    // Never trap the session: after a couple of holds, let the command through.
100    if (holds >= MAX_HOLDS) {
101      $.ui.toast(NAME + ': let it through after ' + MAX_HOLDS + ' holds')
102      return next(e)
103    }
104    const instruction =
105      NAME + ' held this command: ' + missing.join(', ') + ' has not run since the last edit. ' +
106      'Run ' + (missing.length === 1 ? 'that skill' : 'those skills') + ' on the current changes, fix what it finds, then retry. ' +
107      'If a skill is not available, tell the user instead of retrying.'
108
109    if (mode === 'nudge') {
110      const ran = await next(e)
111      if (ran.deny !== undefined) {
112        return ran
113      }
114      return { ...ran, context: [...(ran.context ?? []), NAME + ': that ran before ' + missing.join(', ') + ' had run since the last edit. Run ' + (missing.length === 1 ? 'it' : 'them') + ' now.'] }
115    }
116
117    if (mode === 'ask') {
118      // A question nobody answers counts as "run the checks first".
119      let answer = 'Run checks first'
120      try {
121        answer = await $.ui.ask(
122          missing.join(', ') + ' has not run since the last edit. Run this anyway? ' + command.slice(0, 120),
123          ['Run checks first', 'Ship anyway'],
124        )
125      } catch {
126        // dismissed, or nobody is there to ask
127      }
128      if (answer === 'Ship anyway') {
129        acknowledged = true
130        return next(e)
131      }
132    }
133
134    holds += 1
135    return { deny: instruction }
136  }).catch(($, e, next) => next(e)) // fail open: if the guard breaks, the command runs as it would without it
137
138  on('prompt.submit', async ($, e, next) => {
139    if (mode === 'off' || snoozed || required.length === 0 || outstanding().length === 0 || !PRIME.some((re) => re.test(e.text))) {
140      return next(e)
141    }
142    const line = NAME + ': before shipping, run ' + outstanding().join(', ') + ' on the current changes.'
143    return next({ ...e, context: [...(e.context ?? []), line] })
144  }).catch(($, e, next) => next(e)) // fail open
145
146  on('command.run', { command: "skill-release-mod" }, async ($, e) => {
147    const arg = String(e.args ?? '').trim()
148    if (arg === 'off') {
149      snoozed = true
150      $.ui.status(undefined)
151      return { text: NAME + ': snoozed. Run /' + "skill-release-mod" + ' on to resume.' }
152    }
153    if (arg === 'on') {
154      snoozed = false
155      return { text: NAME + ': back on.' }
156    }
157    const left = outstanding()
158    return {
159      text:
160        NAME + ' (' + mode + (snoozed ? ', snoozed' : '') + '): ' +
161        (required.length - left.length) + ' of ' + required.length + ' checks run since the last edit' +
162        (left.length > 0 ? ', still to run: ' + left.join(', ') : '') +
163        (acknowledged ? '. Shipping was acknowledged.' : '.'),
164    }
165  })
166}
167