Holds production deploys and PR merges until the release-gate chair (web-preflight, security red team, performance audit) has run since the last edit.

Holds production deploys and PR merges until the release-gate chair (web-preflight, security red team, performance audit) has run since the last edit.
This plugin is a Claude Code mod (needs Claude Code 2.1.287 or later). It bundles the release-gate-chair skill, unchanged, in skills/release-gate-chair/, and adds code that runs inside Claude Code to trigger it at the right moment.
release-gate-chair) 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./release-gate-mod off snoozes it for the session and /release-gate-mod on resumes.release-gate-mod, web-preflight-mod, pre-ship-security-red-team-mod, pre-ship-performance-audit-mod) makes each hold the same deploy. Pick release-gate-mod, which runs all three checks through one gate./plugin install release-gate-mod --marketplace arm-pz/caffeinity-skills
claude --plugin-dir ./release-gate-mod
Run /plugin and look for "1 mod active". To share it, list it in a marketplace and install with /plugin install release-gate-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=release-gate-mod}
calls: ./register.js $.command.register, $.ui.ask, $.ui.status, $.ui.toast
<!-- trust:end -->
claude plugin test ./release-gate-mod
Choose one before publishing and add a LICENSE file or a license field in plugin.json.
hooks/register.js 177 lines1// Generated by skill-to-mod from the skill "release-gate-chair". 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; /release-gate-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/release-gate-chair/, still does the real work.
7
8const NAME = "release-gate-mod"
9const SKILL = "release-gate-chair"
10const DEFAULT_MODE = "ask"
11const DEFAULT_REQUIRED = "release-gate-chair"
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;&|(])wrangler\\s+deploy(?![\\w-])"),
19 new RegExp("(?:^|[\\s;&|(])wrangler\\s+pages\\s+deploy(?![\\w-])"),
20 new RegExp("\\bvercel\\b[^;&|]*--prod\\b"),
21 new RegExp("\\bnetlify\\s+deploy\\b[^;&|]*--prod\\b"),
22 new RegExp("(?:^|[\\s;&|(])firebase\\s+deploy(?![\\w-])"),
23 new RegExp("(?:^|[\\s;&|(])fly\\s+deploy(?![\\w-])"),
24 new RegExp("(?:^|[\\s;&|(])railway\\s+up(?![\\w-])"),
25 new RegExp("(?:^|[\\s;&|(])gh\\s+pr\\s+merge(?![\\w-])"),
26 new RegExp("(?:^|[\\s;&|(])git\\s+push(?![\\w-])"),
27 new RegExp("(?:^|[\\s;&|(])gh\\s+release\\s+create(?![\\w-])"),
28 new RegExp("(?:^|[\\s;&|(])npm\\s+publish(?![\\w-])"),
29]
30const MAX_HOLDS = 2
31
32// What has happened since the last edit.
33const done = new Set()
34let acknowledged = false
35let holds = 0
36
37function isShip(command) {
38 return SHIP.some((re) => re.test(command))
39}
40
41function listRequired(options) {
42 const raw = typeof options.required === 'string' ? options.required : DEFAULT_REQUIRED
43 return raw.split(',').map((s) => s.trim()).filter((s) => s !== '')
44}
45
46// A skill reaches skill.prompt as `name`, or `plugin:name` for a plugin's skill.
47function sameSkill(seen, wanted) {
48 return seen === wanted || seen.endsWith(':' + wanted)
49}
50
51// Prompts that say the moment has come.
52const PRIME = [
53 new RegExp("(?<![\\w-])ship\\s+it(?![\\w-])", "i"),
54 new RegExp("(?<![\\w-])go\\s+live(?![\\w-])", "i"),
55 new RegExp("(?<![\\w-])deploy\\s+to\\s+prod(?![\\w-])", "i"),
56 new RegExp("(?<![\\w-])ready\\s+to\\s+launch(?![\\w-])", "i"),
57]
58
59export function register(on, options) {
60 const mode = typeof options.mode === 'string' ? options.mode : DEFAULT_MODE
61 const required = listRequired(options)
62
63 function outstanding() {
64 return required.filter((name) => !done.has(name))
65 }
66
67 on('session.start', async ($, e, next) => {
68 await $.command.register({
69 name: "release-gate-mod",
70 description: 'Show release-gate-mod state, or snooze it with "off" and resume it with "on"',
71 argumentHint: '[off|on]',
72 immediate: true,
73 })
74 return next(e)
75 })
76
77 on('skill.prompt', async ($, e, next) => {
78 for (const name of required) {
79 if (sameSkill(e.skill, name)) {
80 done.add(name)
81 holds = 0
82 }
83 }
84 $.ui.status(outstanding().length === 0 ? undefined : NAME + ': ' + outstanding().join(', ') + ' still to run')
85 return next(e)
86 })
87
88 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
89 const ran = await next(e)
90 const changed = ran.deny === undefined && ran.isError !== true
91 if (changed && (done.size > 0 || acknowledged)) {
92 done.clear()
93 acknowledged = false
94 holds = 0
95 $.ui.status(NAME + ': checks are stale after edits (' + required.join(', ') + ')')
96 }
97 return ran
98 }).catch(($, e, next) => next(e)) // fail open: a reminder must never block an edit
99
100 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
101 const command = String(e.command ?? '')
102 if (mode === 'off' || snoozed || required.length === 0 || !isShip(command)) {
103 return next(e)
104 }
105 const missing = outstanding()
106 if (missing.length === 0 || acknowledged) {
107 return next(e)
108 }
109 // Never trap the session: after a couple of holds, let the command through.
110 if (holds >= MAX_HOLDS) {
111 $.ui.toast(NAME + ': let it through after ' + MAX_HOLDS + ' holds')
112 return next(e)
113 }
114 const instruction =
115 NAME + ' held this command: ' + missing.join(', ') + ' has not run since the last edit. ' +
116 'Run ' + (missing.length === 1 ? 'that skill' : 'those skills') + ' on the current changes, fix what it finds, then retry. ' +
117 'If a skill is not available, tell the user instead of retrying.'
118
119 if (mode === 'nudge') {
120 const ran = await next(e)
121 if (ran.deny !== undefined) {
122 return ran
123 }
124 return { ...ran, context: [...(ran.context ?? []), NAME + ': that ran before ' + missing.join(', ') + ' had run since the last edit. Run ' + (missing.length === 1 ? 'it' : 'them') + ' now.'] }
125 }
126
127 if (mode === 'ask') {
128 // A question nobody answers counts as "run the checks first".
129 let answer = 'Run checks first'
130 try {
131 answer = await $.ui.ask(
132 missing.join(', ') + ' has not run since the last edit. Run this anyway? ' + command.slice(0, 120),
133 ['Run checks first', 'Ship anyway'],
134 )
135 } catch {
136 // dismissed, or nobody is there to ask
137 }
138 if (answer === 'Ship anyway') {
139 acknowledged = true
140 return next(e)
141 }
142 }
143
144 holds += 1
145 return { deny: instruction }
146 }).catch(($, e, next) => next(e)) // fail open: if the guard breaks, the command runs as it would without it
147
148 on('prompt.submit', async ($, e, next) => {
149 if (mode === 'off' || snoozed || required.length === 0 || outstanding().length === 0 || !PRIME.some((re) => re.test(e.text))) {
150 return next(e)
151 }
152 const line = NAME + ': before shipping, run ' + outstanding().join(', ') + ' on the current changes.'
153 return next({ ...e, context: [...(e.context ?? []), line] })
154 }).catch(($, e, next) => next(e)) // fail open
155
156 on('command.run', { command: "release-gate-mod" }, async ($, e) => {
157 const arg = String(e.args ?? '').trim()
158 if (arg === 'off') {
159 snoozed = true
160 $.ui.status(undefined)
161 return { text: NAME + ': snoozed. Run /' + "release-gate-mod" + ' on to resume.' }
162 }
163 if (arg === 'on') {
164 snoozed = false
165 return { text: NAME + ': back on.' }
166 }
167 const left = outstanding()
168 return {
169 text:
170 NAME + ' (' + mode + (snoozed ? ', snoozed' : '') + '): ' +
171 (required.length - left.length) + ' of ' + required.length + ' checks run since the last edit' +
172 (left.length > 0 ? ', still to run: ' + left.join(', ') : '') +
173 (acknowledged ? '. Shipping was acknowledged.' : '.'),
174 }
175 })
176}
177