SLOPSHOPPER

tune-skill-mod

After Claude edits a SKILL.md, holds git push and releases until tune-skill has validated the change.

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

tune-skill-mod

After Claude edits a SKILL.md, holds git push and releases until tune-skill has validated the change.

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

What it does

  • Arms on SKILL.md edits only. When Claude edits or writes a SKILL.md, it adds one line telling Claude to validate it with tune-skill. Edits to any other file are ignored, so ordinary code pushes are never held.
  • Gate. While an edited SKILL.md has not been through tune-skill, a git push, gh pr create, gh release create or npm publish is handled by the mode (default nudge: it runs, then Claude is reminded). 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.
  • /tune-skill-mod shows the state; /tune-skill-mod off snoozes it for the session and /tune-skill-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 tune-skill-mod --marketplace arm-pz/caffeinity-skills

Try it from a clone

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

Run /plugin and look for "1 mod active". To share it, list it in a marketplace and install with /plugin install tune-skill-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}, tool.call{tool=Bash}, prompt.submit, command.run{command=tune-skill-mod}
calls: ./register.js $.command.register, $.ui.ask, $.ui.status, $.ui.toast

<!-- trust:end -->

Tests

claude plugin test ./tune-skill-mod

Licence

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

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