Holds ship commands (git push, gh pr create, npm publish, deploys) until your pre-ship check skills have run since the last edit.

A Claude Code mod that holds git push, PR, publish and deploy commands until your pre-ship check skills have run since your last edit. Use it when your checks are written as skills (a security pass, a performance audit, a preflight) and Claude sometimes skips them before shipping. The checks stay skills; the mod is only the trigger. It asks, blocks or nudges, fails open, and touches no files, network or processes.
Install: /plugin install ship-sentinel --marketplace arm-pz/ship-sentinel
Then tell it which skills count as your checks: /plugin configure ship-sentinel@ship-sentinel, for example pre-ship-security-red-team,pre-ship-performance-audit. The default is release-gate, which only does something if you have a skill with that name.
Needs Claude Code 2.1.287 or later (mods). Tested on 2.1.306.
It reacts to things like:
git push origin main, gh pr create, npm publish, wrangler deploy, terraform applyShip Sentinel contains no checks of its own. Skills such as pre-ship-security-red-team, pre-ship-performance-audit and web-preflight from skills.caffeinity.com are the kind of check it is built to trigger; any skill name works. To turn a skill of your own into a mod like this one, see skill-to-mod.
| Moment | What happens |
|---|---|
Claude runs a ship command (git push, gh pr create/merge, gh release create, npm/pnpm/yarn/bun publish, wrangler deploy, vercel --prod, netlify deploy --prod, docker push, dotnet nuget push, cdk/sam deploy, terraform apply) and a required skill has not run since the last edit | Depends on the mode below |
A required skill is expanded (typed as /name, run by the Skill tool, or preloaded by a subagent) | It counts as run |
| Claude edits a file after checks have run | Checks go stale and must run again |
| You write "ship it", "go live", "deploy to prod" and so on | One line tells Claude which checks are outstanding |
Modes, set with /plugin configure ship-sentinel@ship-sentinel:
ask (default): the command is held and you pick Run checks first or Ship anyway. Not answering counts as "Run checks first".block: the command is refused with an instruction to run the checks, then retry.nudge: the command runs; Claude is reminded afterwards.off: does nothing./ship-sentinel shows the state. /ship-sentinel off snoozes it for the session and /ship-sentinel on brings it back. All the required skills must have run since the last edit.
/plugin install ship-sentinel --marketplace arm-pz/ship-sentinel
The same from a shell (Claude Code 2.1.292 or later):
claude plugin install ship-sentinel --marketplace arm-pz/ship-sentinel
Or in two steps: claude plugin marketplace add arm-pz/ship-sentinel, then claude plugin install ship-sentinel@ship-sentinel.
To try it for one session from a clone: claude --plugin-dir ./ship-sentinel, then /plugin should show the mod as active.
echo "git push" also matches, and a rewritten or indirect command can slip past. For enforcement, use permission rules or managed settings.DESIGN.md for the receipts idea.claude -p runs the hooks but nothing is shown, so use block there. An organisation can switch user mods off.Mods run inside Claude Code with your permissions and are not sandboxed, so this is spelled out. This block is written by verify_mod.py from the validator's output.
<!-- 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=ship-sentinel}
calls: ./register.js $.command.register, $.ui.ask, $.ui.status, $.ui.toast
<!-- trust:end -->
claude plugin test ./ship-sentinel
21 tests use Claude Code's test kit, which stands in for Claude Code. They show the hooks do what they should; they do not replace trying the mod in a live session.
See LICENSE.
hooks/register.js 174 lines1// Ship Sentinel: makes the right pre-ship checks happen at the moment Claude is
2// about to ship. The checks themselves are skills; this mod is only the trigger.
3//
4// skill.prompt notes which check skills have been expanded (typed as
5// /name, run by the Skill tool, or preloaded by a subagent)
6// tool.call (Edit...) a successful edit makes earlier checks stale
7// tool.call (Bash) a ship command with checks outstanding is held, refused
8// or flagged, depending on `mode`
9// prompt.submit a prompt like "ship it" gets one line of context for Claude
10// command.run /ship-sentinel shows state; /ship-sentinel off|on snoozes it
11//
12// Reminder, not a security boundary: it reads the text of a command, so it fails
13// open. It touches no files, processes, network or environment variables.
14
15const NAME = 'ship-sentinel'
16const MAX_HOLDS = 2
17
18// Commands that put work in front of other people or into production.
19const SHIP = [
20 /\bgit\s+push\b/,
21 /\bgh\s+pr\s+(create|merge)\b/,
22 /\bgh\s+release\s+create\b/,
23 /\b(npm|pnpm|yarn|bun)\s+publish\b/,
24 /\bwrangler\s+(pages\s+)?(deploy|publish)\b/,
25 /\bvercel\b.*\s--prod\b/,
26 /\bnetlify\s+deploy\b.*--prod\b/,
27 /\bdocker\s+push\b/,
28 /\bdotnet\s+nuget\s+push\b/,
29 /\b(cdk|sam)\s+deploy\b/,
30 /\bterraform\s+apply\b/,
31]
32
33// Prompts that say the person is about to ship.
34const PRIME = /\b(ship it|ship this|deploy (it|this|to prod\w*)|go live|push to prod\w*|cut a release|publish (it|this))\b/i
35
36// What has happened since the last edit. Module variables start over on a reload.
37const done = new Set()
38let acknowledged = false
39let holds = 0
40let snoozed = false
41
42function isShip(command) {
43 return SHIP.some((re) => re.test(command))
44}
45
46function listRequired(options) {
47 const raw = typeof options.required === 'string' ? options.required : 'release-gate'
48 return raw.split(',').map((s) => s.trim()).filter((s) => s !== '')
49}
50
51// A skill reaches skill.prompt as `name`, or `plugin:name` for a plugin's skill.
52function sameSkill(seen, wanted) {
53 return seen === wanted || seen.endsWith(':' + wanted)
54}
55
56export function register(on, options) {
57 const mode = typeof options.mode === 'string' ? options.mode : 'ask'
58 const required = listRequired(options)
59
60 function outstanding() {
61 return required.filter((name) => !done.has(name))
62 }
63
64 on('session.start', async ($, e, next) => {
65 await $.command.register({
66 name: 'ship-sentinel',
67 description: 'Show Ship Sentinel state, or snooze it with "off" and resume it with "on"',
68 argumentHint: '[off|on]',
69 immediate: true,
70 })
71 return next(e)
72 })
73
74 on('skill.prompt', async ($, e, next) => {
75 for (const name of required) {
76 if (sameSkill(e.skill, name)) {
77 done.add(name)
78 holds = 0
79 }
80 }
81 $.ui.status(outstanding().length === 0 ? undefined : NAME + ': ' + outstanding().join(', ') + ' still to run')
82 return next(e)
83 })
84
85 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
86 const ran = await next(e)
87 const changed = ran.deny === undefined && ran.isError !== true
88 if (changed && (done.size > 0 || acknowledged)) {
89 done.clear()
90 acknowledged = false
91 holds = 0
92 $.ui.status(NAME + ': checks are stale after edits (' + required.join(', ') + ')')
93 }
94 return ran
95 }).catch(($, e, next) => next(e)) // fail open: a reminder must never block an edit
96
97 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
98 const command = String(e.command ?? '')
99 if (mode === 'off' || snoozed || required.length === 0 || !isShip(command)) {
100 return next(e)
101 }
102 const missing = outstanding()
103 if (missing.length === 0 || acknowledged) {
104 return next(e)
105 }
106 // Never trap the session: after a couple of holds, let the command through.
107 if (holds >= MAX_HOLDS) {
108 $.ui.toast(NAME + ': let it through after ' + MAX_HOLDS + ' holds')
109 return next(e)
110 }
111 const instruction =
112 NAME + ' held this command: ' + missing.join(', ') + ' has not run since the last edit. ' +
113 'Run ' + (missing.length === 1 ? 'that skill' : 'those skills') + ' on the current changes, fix what they find, then retry. ' +
114 'If a skill is not available, tell the user instead of retrying.'
115
116 if (mode === 'nudge') {
117 const ran = await next(e)
118 if (ran.deny !== undefined) {
119 return ran
120 }
121 return { ...ran, context: [...(ran.context ?? []), NAME + ': that ran before ' + missing.join(', ') + ' had run since the last edit. Run ' + (missing.length === 1 ? 'it' : 'them') + ' now.'] }
122 }
123
124 if (mode === 'ask') {
125 // A question nobody answers counts as "run the checks first".
126 let answer = 'Run checks first'
127 try {
128 answer = await $.ui.ask(
129 missing.join(', ') + ' has not run since the last edit. Run this anyway? ' + command.slice(0, 120),
130 ['Run checks first', 'Ship anyway'],
131 )
132 } catch {
133 // dismissed, or nobody is there to ask
134 }
135 if (answer === 'Ship anyway') {
136 acknowledged = true
137 return next(e)
138 }
139 }
140
141 holds += 1
142 return { deny: instruction }
143 }).catch(($, e, next) => next(e)) // fail open: if the guard breaks, the command runs as it would without it
144
145 on('prompt.submit', async ($, e, next) => {
146 if (mode === 'off' || snoozed || required.length === 0 || outstanding().length === 0 || !PRIME.test(e.text)) {
147 return next(e)
148 }
149 const line = NAME + ': before shipping, run ' + outstanding().join(', ') + ' on the current changes.'
150 return next({ ...e, context: [...(e.context ?? []), line] })
151 }).catch(($, e, next) => next(e)) // fail open
152
153 on('command.run', { command: 'ship-sentinel' }, async ($, e) => {
154 const arg = String(e.args ?? '').trim()
155 if (arg === 'off') {
156 snoozed = true
157 $.ui.status(undefined)
158 return { text: NAME + ': snoozed. Run /ship-sentinel on to resume.' }
159 }
160 if (arg === 'on') {
161 snoozed = false
162 return { text: NAME + ': back on.' }
163 }
164 const left = outstanding()
165 return {
166 text:
167 NAME + ' (' + mode + (snoozed ? ', snoozed' : '') + '): ' +
168 (required.length - left.length) + ' of ' + required.length + ' checks run since the last edit' +
169 (left.length > 0 ? ', still to run: ' + left.join(', ') : '') +
170 (acknowledged ? '. Shipping was acknowledged.' : '.'),
171 }
172 })
173}
174