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

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.
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./skill-release-mod off snoozes it for the session and /skill-release-mod on resumes./plugin install skill-release-mod --marketplace arm-pz/caffeinity-skills
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>.
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 -->
claude plugin test ./skill-release-mod
Choose one before publishing and add a LICENSE file or a license field in plugin.json.
hooks/register.js 167 lines1// 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