@ericrisco/rsc 1.0.25 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,6 +119,11 @@ also wires a `SessionStart` hook (so your assistant proposes new skills on its
119
119
  own) and a `UserPromptSubmit` hook that re-asserts the **SDD new-feature gate**
120
120
  on every turn — so a feature request, in any language, routes through `specify`
121
121
  first, before any skill writes code. Opt out per project with `.rsc/.no-feature-gate`.
122
+ It also wires a `PreToolUse` **gitmoji guard**: a `git commit` whose message carries no
123
+ [gitmoji](https://gitmoji.dev) is denied, with the corrected message inside the refusal
124
+ (`✨ feat(api): add cursor paging`) — so `git log --oneline` stays readable at a glance instead of
125
+ depending on the agent remembering a convention. It only judges messages it can actually read
126
+ (`-m`, heredoc), never an editor commit or `--amend --no-edit`. Opt out with `.rsc/.no-gitmoji`.
122
127
 
123
128
  Everything stays **in the project**, and the real skill files are written
124
129
  **once** to `.rsc/skills/<id>/`. Each assistant you pick gets a lightweight
package/manifest.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.25",
2
+ "version": "1.0.26",
3
3
  "counts": {
4
4
  "skills": 264
5
5
  },
@@ -2114,11 +2114,12 @@
2114
2114
  },
2115
2115
  {
2116
2116
  "id": "git-workflow",
2117
- "description": "Use when naming or scoping a branch, writing or fixing a commit message, untangling history (rebase versus merge versus squash), or cutting a versioned release — the portable git-convention layer for any repo. Covers Conventional Commits, SemVer tags, branch hygiene, force-push safety and gh pr/release mechanics. NOT the land-it decision and pre-ship checklist (that is `ship`), NOT an isolated checkout before coding (that is `worktrees`), NOT CI/CD release automation (that is `deployment`).",
2117
+ "description": "Use when naming or scoping a branch, writing or fixing a commit message, picking the gitmoji for a commit, untangling history (rebase versus merge versus squash), or cutting a versioned release — the portable git-convention layer for any repo. Covers gitmoji + Conventional Commits, SemVer tags, branch hygiene, force-push safety and gh pr/release mechanics. NOT the land-it decision and pre-ship checklist (that is `ship`), NOT an isolated checkout before coding (that is `worktrees`), NOT CI/CD release automation (that is `deployment`).",
2118
2118
  "tags": [
2119
2119
  "git",
2120
2120
  "version-control",
2121
2121
  "conventional-commits",
2122
+ "gitmoji",
2122
2123
  "semver",
2123
2124
  "pull-request"
2124
2125
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ericrisco/rsc",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "Eric Risco's agent-skills catalog as a granular, self-recommending CLI installer.",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.js CHANGED
@@ -57,6 +57,9 @@ export function doctor({ target, home, cwd }) {
57
57
  },
58
58
  contextBudget: contextBudget({ target, home, cwd }),
59
59
  sello: selloStatus(root),
60
+ // An inert gate must never read as an armed one (the same rule the sello follows):
61
+ // the gitmoji guard has a per-project kill switch, so say which state it is in.
62
+ gitmojiGuard: existsSync(join(root, '.rsc', '.no-gitmoji')) ? 'opted-out' : 'armed',
60
63
  // Counted, never interpreted — by spec, the gap log's reader is the user.
61
64
  automationGaps: countGaps(root),
62
65
  };
@@ -60,6 +60,7 @@ function generatedHookFiles({ target, cwd }) {
60
60
  join(cwd, '.rsc', 'worklog-checkpoint.mjs'),
61
61
  join(cwd, '.rsc', 'ship-guard.mjs'),
62
62
  join(cwd, '.rsc', 'danger-guard.mjs'),
63
+ join(cwd, '.rsc', 'gitmoji-guard.mjs'),
63
64
  join(cwd, '.rsc', 'userprompt-gate.mjs'),
64
65
  join(cwd, '.rsc', 'hook-once.mjs'),
65
66
  join(cwd, '.rsc', 'sello.mjs'),
@@ -40,7 +40,7 @@ version: v1.0.0
40
40
 
41
41
  7. Naming & structure: <directory layout, module boundaries, file naming>.
42
42
  8. API & errors: <error shape, status codes, response envelope> where applicable.
43
- 9. Commit messages: <format, e.g. Conventional Commits>.
43
+ 9. Commit messages: <format, e.g. gitmoji + Conventional Commits — `✨ feat(scope): subject`>.
44
44
 
45
45
  ## 4. Branching & shipping
46
46
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: git-workflow
3
- description: "Use when naming or scoping a branch, writing or fixing a commit message, untangling history (rebase versus merge versus squash), or cutting a versioned release — the portable git-convention layer for any repo. Covers Conventional Commits, SemVer tags, branch hygiene, force-push safety and gh pr/release mechanics. NOT the land-it decision and pre-ship checklist (that is `ship`), NOT an isolated checkout before coding (that is `worktrees`), NOT CI/CD release automation (that is `deployment`)."
4
- tags: [git, version-control, conventional-commits, semver, pull-request]
3
+ description: "Use when naming or scoping a branch, writing or fixing a commit message, picking the gitmoji for a commit, untangling history (rebase versus merge versus squash), or cutting a versioned release — the portable git-convention layer for any repo. Covers gitmoji + Conventional Commits, SemVer tags, branch hygiene, force-push safety and gh pr/release mechanics. NOT the land-it decision and pre-ship checklist (that is `ship`), NOT an isolated checkout before coding (that is `worktrees`), NOT CI/CD release automation (that is `deployment`)."
4
+ tags: [git, version-control, conventional-commits, gitmoji, semver, pull-request]
5
5
  recommends: [ship, worktrees, deployment, github-actions]
6
6
  origin: risco
7
7
  ---
@@ -44,12 +44,12 @@ Good fix/expired-refresh-token-401
44
44
  Good chore/bump-node-22
45
45
  ```
46
46
 
47
- ## Conventional Commits grammar
47
+ ## Commit grammar — gitmoji + Conventional Commits
48
48
 
49
- Write every commit to **Conventional Commits 1.0.0**. The structure:
49
+ Write every commit to **Conventional Commits 1.0.0**, opened by a **gitmoji**. The structure:
50
50
 
51
51
  ```text
52
- type(scope)!: subject
52
+ <gitmoji> type(scope)!: subject
53
53
 
54
54
  body — what changed and why, wrapped, optional
55
55
 
@@ -57,6 +57,8 @@ BREAKING CHANGE: description of the incompatible change
57
57
  Fixes #123
58
58
  ```
59
59
 
60
+ - The **gitmoji is mandatory** and comes first — the intention of the change, readable in one glyph
61
+ when you scan `git log --oneline`. `type` is what tooling reads; the emoji is what humans read.
60
62
  - `type` is mandatory. `scope` in parentheses is optional. `!` before the colon marks a breaking change.
61
63
  - Subject: imperative mood ("add", not "added"/"adds"), ≤72 chars, no trailing period.
62
64
  - Body explains *why*, not *what the diff already shows*. Separate from subject by a blank line.
@@ -74,23 +76,44 @@ Type → SemVer effect:
74
76
  `BREAKING CHANGE` **must be uppercase** in the footer; the type/scope units are case-insensitive but
75
77
  write them lowercase by convention.
76
78
 
79
+ Type → gitmoji, the everyday set (the full 75-emoji table, and *why* the emoji never replaces the
80
+ type, are in **`references/gitmoji.md`**):
81
+
82
+ | Type | gitmoji | Type | gitmoji | Type | gitmoji |
83
+ |------------|---------|------------|---------|-----------|---------|
84
+ | `feat` | ✨ | `refactor` | ♻️ | `build` | 📦️ |
85
+ | `fix` | 🐛 | `test` | ✅ | `style` | 🎨 |
86
+ | `docs` | 📝 | `perf` | ⚡️ | `revert` | ⏪️ |
87
+ | `chore` | 🔧 | `ci` | 👷 | breaking | 💥 |
88
+
89
+ Pick by **intention**, not by which file changed, and prefer the specific one: 🚑️ for a production
90
+ hotfix, 🩹 for a trivial non-critical fix, 🔥 for a deletion, 🚚 for a rename, ⬆️ for a dep bump,
91
+ 🔖 for a release commit.
92
+
77
93
  ```text
78
94
  Bad fix stuff
79
95
  Bad updates
80
96
  Bad Fixed the login bug. (past tense, capitalized, trailing period)
81
- Good fix(auth): reject expired refresh tokens
82
- Good feat(api): add /v2/search endpoint with cursor paging
83
- Good refactor(parser): extract token scanner, no behavior change
97
+ Bad fix(auth): reject expired refresh tokens (no gitmoji)
98
+ Bad ✨ added a search endpoint (gitmoji but no type → no derivable bump)
99
+ Good 🐛 fix(auth): reject expired refresh tokens
100
+ Good ✨ feat(api): add /v2/search endpoint with cursor paging
101
+ Good ♻️ refactor(parser): extract token scanner, no behavior change
84
102
  ```
85
103
 
86
104
  A breaking change, both forms equivalent:
87
105
 
88
106
  ```text
89
- feat(api)!: drop the legacy /v1 search endpoint
107
+ 💥 feat(api)!: drop the legacy /v1 search endpoint
90
108
 
91
109
  BREAKING CHANGE: /v1/search is removed; callers must migrate to /v2/search.
92
110
  ```
93
111
 
112
+ If the repo runs a **strict** conventional parser (commitlint, semantic-release) it anchors the type
113
+ at position 0 and rejects the emoji prefix. Either widen its `headerPattern` — the config is in
114
+ `references/gitmoji.md` — or move the emoji behind the header (`feat(api): ✨ add cursor paging`),
115
+ which every parser accepts. Both forms satisfy this convention; dropping the gitmoji does not.
116
+
94
117
  **Authorship is always Eric.** Never add a `Co-Authored-By: Claude` trailer, never a
95
118
  "Generated with" footer, never any line crediting an AI tool — in a commit *or* a PR body. The work
96
119
  is Eric's; the agent is a tool, like the compiler.
@@ -103,7 +126,7 @@ Decide by who else has the commits. The lease rule below is non-negotiable.
103
126
  |------------------------------------------------------|------------------------------------------------------|------------------------------------------------------------|
104
127
  | Private branch, only you have it, want linear history | `git rebase main`, then `git push --force-with-lease`| rebase rewrites hashes; safe because nobody built on them |
105
128
  | Branch others have pulled / built on | `git merge main` — **never** rebase it | rebase changes every hash; collaborators' work diverges |
106
- | Noisy PR (many `wip` commits) | squash-merge into one conventional commit | `main` gets one meaningful entry, not 9 scratch commits |
129
+ | Noisy PR (many `wip` commits) | squash-merge into one gitmoji + conventional commit | `main` gets one meaningful entry, not 9 scratch commits |
107
130
  | Already pushed, shared, *and* you rewrote it | **STOP** — coordinate, or `git revert` instead | force-pushing shared history breaks everyone downstream |
108
131
 
109
132
  After a rebase, push with `--force-with-lease`, never bare `--force`:
@@ -168,7 +191,8 @@ enough to hand over. Setting up the isolated checkout *before* you start coding
168
191
  | Anti-pattern | Why it hurts | Instead |
169
192
  |-------------------------------------------------------|-----------------------------------------------------------|------------------------------------------------------|
170
193
  | `git push --force` on a shared branch | silently erases teammates' commits | `--force-with-lease`, or don't rewrite shared history |
171
- | `git commit -m "wip"` / `"fix"` / `"updates"` | the log carries zero signal for the next reader | conventional `type(scope): imperative subject` |
194
+ | `git commit -m "wip"` / `"fix"` / `"updates"` | the log carries zero signal for the next reader | `<gitmoji> type(scope): imperative subject` |
195
+ | Commit message with no gitmoji | `git log --oneline` reads as a wall of undifferentiated text | pick the intention's emoji (`references/gitmoji.md`) |
172
196
  | Mixing unrelated changes in one commit | can't revert or review one concern in isolation | one logical change per commit |
173
197
  | Long-lived branch (weeks) | diverges from `main`, merge becomes a battle | short-lived; rebase or merge `main` in often |
174
198
  | Hand-computing the semver bump | breaking change shipped as a MINOR → broken downstream | derive the bump from the commit log |
@@ -183,7 +207,7 @@ enough to hand over. Setting up the isolated checkout *before* you start coding
183
207
 
184
208
  - [ ] Working tree clean (`git status`), no stray or generated files staged.
185
209
  - [ ] Branch rebased on / merged with current `main`; no avoidable conflicts.
186
- - [ ] Every commit is conventional; `wip`/scratch commits squashed away.
210
+ - [ ] Every commit carries its gitmoji **and** a conventional header; `wip`/scratch commits squashed away.
187
211
  - [ ] No secrets, no AI-attribution trailers.
188
212
  - [ ] PR body explains the why and links the issue (`Fixes #`).
189
213
  - [ ] (Release) version bump **derived from the commit log**, tag is `vX.Y.Z`, annotated.
@@ -13,6 +13,10 @@ should_trigger:
13
13
  why: Spanish trigger covering history cleanup plus conventional-commit formatting.
14
14
  - prompt: "Name a branch for the OAuth PKCE work"
15
15
  why: Branch-naming convention — feat/ prefix plus kebab slug from intent.
16
+ - prompt: "Which gitmoji goes on this commit — I deleted a deprecated module"
17
+ why: Gitmoji selection by intention (🔥 remove code, not ♻️ refactor); the commit-grammar layer owns the emoji.
18
+ - prompt: "Mi commitlint rechaza el commit por el emoji delante del type"
19
+ why: Spanish; the strict-parser escape hatch (widen headerPattern or move the emoji behind the header) lives in this skill.
16
20
  - prompt: "Tag v2.1.0 and write the release notes"
17
21
  why: Release mechanics — annotated tag plus gh release create --generate-notes.
18
22
 
@@ -41,6 +45,7 @@ capability:
41
45
  must_include:
42
46
  - Collapses the two "wip" commits via squash or fixup and explains why (one meaningful entry in main).
43
47
  - Identifies a feat commit and a BREAKING CHANGE footer written in uppercase.
48
+ - Every proposed commit message opens with a gitmoji (✨ for the feature, 💥 for the breaking removal) before the conventional header.
44
49
  - Bump is MAJOR because of the breaking change, with a tag of the form vX.0.0.
45
50
  - Uses gh release create vX.0.0 --generate-notes (or an equivalent that produces notes).
46
51
  - Uses git push --force-with-lease (not bare --force) for the post-rebase push.
@@ -0,0 +1,157 @@
1
+ # gitmoji — the full set, and the tooling that has to accept it
2
+
3
+ The commit rule lives in `../SKILL.md`: **every commit opens with a gitmoji, then the
4
+ Conventional Commits header.** This file is the lookup table and the tooling escape hatches — read
5
+ it when you need the right glyph for an intention, or when a parser in the repo rejects the format.
6
+
7
+ ```text
8
+ <gitmoji> type(scope)!: imperative subject
9
+ ```
10
+
11
+ The gitmoji says *what kind of intention* this is at a glance in `git log --oneline`; the `type`
12
+ still drives the SemVer bump. They are two different jobs and neither replaces the other — a commit
13
+ with 💥 but no `!`/`BREAKING CHANGE:` footer still releases as a MINOR, because tooling reads the
14
+ type, not the picture.
15
+
16
+ ## Picking one
17
+
18
+ Match the **intention**, not the file that changed. If two fit, take the more specific one (🚑️ over
19
+ 🐛 for a production hotfix; 🩹 over 🐛 for a trivial non-critical fix). The `semver` column is
20
+ gitmoji's own hint, not a substitute for the type → bump table in `../SKILL.md`.
21
+
22
+ The everyday dozen, mapped to the conventional types:
23
+
24
+ | Type | gitmoji | Type | gitmoji |
25
+ |------------|---------|-------------|---------|
26
+ | `feat` | ✨ | `perf` | ⚡️ |
27
+ | `fix` | 🐛 | `ci` | 👷 |
28
+ | `docs` | 📝 | `build` | 📦️ |
29
+ | `refactor` | ♻️ | `style` | 🎨 |
30
+ | `test` | ✅ | `revert` | ⏪️ |
31
+ | `chore` | 🔧 | breaking | 💥 |
32
+
33
+ ## The full set
34
+
35
+ Canonical source: <https://gitmoji.dev> (machine-readable at `https://gitmoji.dev/api/gitmojis`).
36
+ The same 75 rows are enforced as data by the gitmoji guard, and a test compares this table against
37
+ it — if the two ever disagree, CI says so.
38
+
39
+ | Emoji | Code | SemVer | Intention |
40
+ |-------|------|--------|-----------|
41
+ | 🎨 | `:art:` | — | Improve structure / format of the code |
42
+ | ⚡️ | `:zap:` | patch | Improve performance |
43
+ | 🔥 | `:fire:` | — | Remove code or files |
44
+ | 🐛 | `:bug:` | patch | Fix a bug |
45
+ | 🚑️ | `:ambulance:` | patch | Critical hotfix |
46
+ | ✨ | `:sparkles:` | minor | Introduce new features |
47
+ | 📝 | `:memo:` | — | Add or update documentation |
48
+ | 🚀 | `:rocket:` | — | Deploy stuff |
49
+ | 💄 | `:lipstick:` | patch | Add or update the UI and style files |
50
+ | 🎉 | `:tada:` | — | Begin a project |
51
+ | ✅ | `:white_check_mark:` | — | Add, update, or pass tests |
52
+ | 🔒️ | `:lock:` | patch | Fix security or privacy issues |
53
+ | 🔐 | `:closed_lock_with_key:` | — | Add or update secrets |
54
+ | 🔖 | `:bookmark:` | — | Release / Version tags |
55
+ | 🚨 | `:rotating_light:` | — | Fix compiler / linter warnings |
56
+ | 🚧 | `:construction:` | — | Work in progress |
57
+ | 💚 | `:green_heart:` | — | Fix CI Build |
58
+ | ⬇️ | `:arrow_down:` | patch | Downgrade dependencies |
59
+ | ⬆️ | `:arrow_up:` | patch | Upgrade dependencies |
60
+ | 📌 | `:pushpin:` | patch | Pin dependencies to specific versions |
61
+ | 👷 | `:construction_worker:` | — | Add or update CI build system |
62
+ | 📈 | `:chart_with_upwards_trend:` | patch | Add or update analytics or track code |
63
+ | ♻️ | `:recycle:` | — | Refactor code |
64
+ | ➕ | `:heavy_plus_sign:` | patch | Add a dependency |
65
+ | ➖ | `:heavy_minus_sign:` | patch | Remove a dependency |
66
+ | 🔧 | `:wrench:` | patch | Add or update configuration files |
67
+ | 🔨 | `:hammer:` | — | Add or update development scripts |
68
+ | 🌐 | `:globe_with_meridians:` | patch | Internationalization and localization |
69
+ | ✏️ | `:pencil2:` | patch | Fix typos |
70
+ | 💩 | `:poop:` | — | Write bad code that needs to be improved |
71
+ | ⏪️ | `:rewind:` | patch | Revert changes |
72
+ | 🔀 | `:twisted_rightwards_arrows:` | — | Merge branches |
73
+ | 📦️ | `:package:` | patch | Add or update compiled files or packages |
74
+ | 👽️ | `:alien:` | patch | Update code due to external API changes |
75
+ | 🚚 | `:truck:` | — | Move or rename resources (e.g.: files, paths, routes) |
76
+ | 📄 | `:page_facing_up:` | — | Add or update license |
77
+ | 💥 | `:boom:` | major | Introduce breaking changes |
78
+ | 🍱 | `:bento:` | patch | Add or update assets |
79
+ | ♿️ | `:wheelchair:` | patch | Improve accessibility |
80
+ | 💡 | `:bulb:` | — | Add or update comments in source code |
81
+ | 🍻 | `:beers:` | — | Write code drunkenly |
82
+ | 💬 | `:speech_balloon:` | patch | Add or update text and literals |
83
+ | 🗃️ | `:card_file_box:` | patch | Perform database related changes |
84
+ | 🔊 | `:loud_sound:` | — | Add or update logs |
85
+ | 🔇 | `:mute:` | — | Remove logs |
86
+ | 👥 | `:busts_in_silhouette:` | — | Add or update contributor(s) |
87
+ | 🚸 | `:children_crossing:` | patch | Improve user experience / usability |
88
+ | 🏗️ | `:building_construction:` | — | Make architectural changes |
89
+ | 📱 | `:iphone:` | patch | Work on responsive design |
90
+ | 🤡 | `:clown_face:` | — | Mock things |
91
+ | 🥚 | `:egg:` | patch | Add or update an easter egg |
92
+ | 🙈 | `:see_no_evil:` | — | Add or update a .gitignore file |
93
+ | 📸 | `:camera_flash:` | — | Add or update snapshots |
94
+ | ⚗️ | `:alembic:` | patch | Perform experiments |
95
+ | 🔍️ | `:mag:` | patch | Improve SEO |
96
+ | 🏷️ | `:label:` | patch | Add or update types |
97
+ | 🌱 | `:seedling:` | — | Add or update seed files |
98
+ | 🚩 | `:triangular_flag_on_post:` | patch | Add, update, or remove feature flags |
99
+ | 🥅 | `:goal_net:` | patch | Catch errors |
100
+ | 💫 | `:dizzy:` | patch | Add or update animations and transitions |
101
+ | 🗑️ | `:wastebasket:` | patch | Deprecate code that needs to be cleaned up |
102
+ | 🛂 | `:passport_control:` | patch | Work on code related to authorization, roles and permissions |
103
+ | 🩹 | `:adhesive_bandage:` | patch | Simple fix for a non-critical issue |
104
+ | 🧐 | `:monocle_face:` | — | Data exploration/inspection |
105
+ | ⚰️ | `:coffin:` | — | Remove dead code |
106
+ | 🧪 | `:test_tube:` | — | Add a failing test |
107
+ | 👔 | `:necktie:` | patch | Add or update business logic |
108
+ | 🩺 | `:stethoscope:` | — | Add or update healthcheck |
109
+ | 🧱 | `:bricks:` | — | Infrastructure related changes |
110
+ | 🧑‍💻 | `:technologist:` | — | Improve developer experience |
111
+ | 💸 | `:money_with_wings:` | — | Add sponsorships or money related infrastructure |
112
+ | 🧵 | `:thread:` | — | Add or update code related to multithreading or concurrency |
113
+ | 🦺 | `:safety_vest:` | — | Add or update code related to validation |
114
+ | ✈️ | `:airplane:` | — | Improve offline support |
115
+ | 🦖 | `:t-rex:` | — | Code that adds backwards compatibility |
116
+ ## Tooling that has to accept the format
117
+
118
+ Emoji-first is what gitmoji itself prescribes and what `gitmoji -c` writes, but a **strict
119
+ Conventional Commits parser anchors the type at position 0** and will reject the header. Two honest
120
+ ways out, in order of preference:
121
+
122
+ **1. Teach the parser about the prefix.** commitlint, with the emoji allowed before the type:
123
+
124
+ ```js
125
+ // commitlint.config.js
126
+ export default {
127
+ extends: ['@commitlint/config-conventional'],
128
+ parserPreset: {
129
+ parserOpts: {
130
+ // <emoji> type(scope)!: subject
131
+ headerPattern: /^(?:\S+\s)?(\w+)(?:\(([^)]*)\))?(!)?: (.+)$/,
132
+ headerCorrespondence: ['type', 'scope', 'breaking', 'subject'],
133
+ },
134
+ },
135
+ };
136
+ ```
137
+
138
+ semantic-release / conventional-changelog take the same `parserOpts` under
139
+ `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator`. Set it in
140
+ **both** or the bump and the changelog disagree about the same commit.
141
+
142
+ **2. Put the emoji after the header** — `feat(api): ✨ add cursor paging`. Every conventional parser
143
+ accepts it unmodified, and the guard accepts it too. Prefer this in a repo whose release pipeline
144
+ you do not own.
145
+
146
+ What **not** to do: drop the type and commit `✨ add cursor paging` (gitmoji's own minimal form).
147
+ It is valid gitmoji and useless for releases — nothing can derive the bump, so the version has to be
148
+ hand-picked, which `../SKILL.md` forbids for a reason.
149
+
150
+ ## Related
151
+
152
+ - `gitmoji-cli` (`npm i -g gitmoji-cli`, then `gitmoji -c`) prompts for the emoji and writes the
153
+ commit. Optional — the convention is the rule, the CLI is one way to type it.
154
+ - On Claude Code, rsc wires a `gitmoji-guard` PreToolUse hook that denies a `git commit` whose
155
+ message has no gitmoji, with the corrected message in the refusal. It only judges messages it can
156
+ actually read (`-m`, heredoc) and never blocks an editor commit, a `-F` file, or `--amend
157
+ --no-edit`. Opt out per project with `.rsc/.no-gitmoji`.
@@ -184,7 +184,7 @@ cleanup). If a native `EnterWorktree`-style tool created it, exit through that t
184
184
 
185
185
  The commit is the durable record. Make it describe the change and tie it to the spec — and keep it Eric's.
186
186
 
187
- - **Subject:** `type: imperative summary (<spec-slug>)` — `feat:`, `fix:`, `refactor:`, etc. Under ~72 chars.
187
+ - **Subject:** `<gitmoji> type: imperative summary (<spec-slug>)` — `✨ feat:`, `🐛 fix:`, `♻️ refactor:`, etc. Under ~72 chars. The gitmoji is **not optional**: on Claude Code a PreToolUse guard denies a `git commit -m` without one, and the refusal hands back the corrected message. Emoji → intention table: `../git-workflow/references/gitmoji.md`.
188
188
  - **Body:** *why*, not a restatement of the diff. Reference the spec slug and any decision logged in `decisions.md`.
189
189
  - **Footer:** issue/PR refs only. **No `Co-Authored-By` for any AI. No "generated with" line.** This is where the violation usually sneaks in — leave the footer clean.
190
190
 
@@ -212,6 +212,7 @@ Read the level from `02-DOCS/wiki/harness/user-profile.md`. It changes what you
212
212
  | "The tree has a couple of stray edits, they're harmless" | A dirty tree means the merge is not the reviewed diff. Clean it or stash it first. |
213
213
  | "`main` is protected but I'll just force-merge, I'm sure" | Protected means PR. Don't bypass the gate the repo deliberately set. |
214
214
  | "I'll rebase and resolve conflicts during the merge" | Resolve before. A conflicted merge commit hides what actually shipped. |
215
+ | "The gitmoji is decoration, the conventional type is what matters" | Both ship or neither does. The type is for tooling, the emoji for the human scanning `git log` — and the guard denies the commit either way. |
215
216
  | "This branch is dead, I'll just delete it" | Discard is destructive — confirm with the quoted branch name and log why first. |
216
217
  | "Squash everything, history doesn't matter" | Squash noise, preserve meaning. The history is the next reader's spec. |
217
218
  | "There's a key in the diff but it's a test key" | A secret in the diff is a blocker regardless. Pull it out before landing. |
package/targets/claude.js CHANGED
@@ -113,6 +113,21 @@ export function wireHook(paths) {
113
113
  );
114
114
  settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: dgCmd }] });
115
115
 
116
+ // Gitmoji guard: a PreToolUse(Bash) hook that DENIES a `git commit` whose message
117
+ // carries no gitmoji (gitmoji.dev) in front of the Conventional Commits header. The
118
+ // convention lives in `git-workflow`; prose asks, this hook decides — at the one
119
+ // deterministic moment the message exists. Only fires when the message is inline and
120
+ // readable (-m / heredoc); editor commits, -F files and --amend --no-edit are allowed.
121
+ // Materialized + node-run (Windows-safe), idempotent, fail-open, opt-out via
122
+ // .rsc/.no-gitmoji.
123
+ const gmDest = join(paths.projectRoot, '.rsc', 'gitmoji-guard.mjs');
124
+ copyFileSync(join(HERE, 'gitmoji-guard.mjs'), gmDest);
125
+ const gmCmd = `node "${gmDest}" "${paths.projectRoot}"`;
126
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
127
+ (e) => !JSON.stringify(e).includes('.rsc/gitmoji-guard.'),
128
+ );
129
+ settings.hooks.PreToolUse.push({ matcher: 'Bash', hooks: [{ type: 'command', command: gmCmd }] });
130
+
116
131
  // New-feature gate: a UserPromptSubmit hook that re-injects the SDD new-feature gate as
117
132
  // the most-recent instruction on every user turn. SessionStart injects the full gate (the
118
133
  // `suggest` body) once; this keeps the precedence rule salient regardless of how many
@@ -132,5 +147,5 @@ export function wireHook(paths) {
132
147
 
133
148
  mkdirSync(dirname(file), { recursive: true });
134
149
  writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
135
- return [file, scriptDest, wlDest, sgDest, dgDest, fgDest];
150
+ return [file, scriptDest, wlDest, sgDest, dgDest, gmDest, fgDest];
136
151
  }
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ // rsc Gitmoji guard (claude). Wired by targets/claude.js onto PreToolUse (matcher Bash)
3
+ // as `node ...` so it runs on every platform including Windows.
4
+ // argv[2] = absolute project root stdin = PreToolUse hook JSON
5
+ //
6
+ // Enforces one convention at the only deterministic moment it can be enforced: the
7
+ // commit is being written. Every commit message this harness produces carries a
8
+ // gitmoji (https://gitmoji.dev) — the intention of the change, visible in one glyph
9
+ // in `git log --oneline`, on top of the Conventional Commits grammar that already
10
+ // drives the semver bump. Prose in a skill asks; this hook decides.
11
+ //
12
+ // Accepted shapes (both are gitmoji-valid; the first is what `git-workflow` prescribes):
13
+ // ✨ feat(api): add cursor paging
14
+ // feat(api): ✨ add cursor paging
15
+ //
16
+ // Design (constitution P1/P2/P6/P7): deterministic, local-only, precise — it fires only
17
+ // on a `git commit` that carries its message inline, where the message can actually be
18
+ // read. Anything it cannot read with certainty (editor commit, -F file, --amend
19
+ // --no-edit, an unparseable command) is ALLOWED: a guard that guesses is a guard that
20
+ // gets turned off. Every deny names its recovery. Opt out with .rsc/.no-gitmoji.
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+
24
+ // The official gitmoji set (gitmoji.dev/api/gitmojis), as data: [emoji, code, semver, meaning].
25
+ // A table, not a chain of ifs — so a test can iterate it and the reference doc can be
26
+ // checked against it instead of drifting apart in prose.
27
+ export const GITMOJIS = [
28
+ ['🎨', ':art:', null, "Improve structure / format of the code."],
29
+ ['⚡️', ':zap:', 'patch', "Improve performance."],
30
+ ['🔥', ':fire:', null, "Remove code or files."],
31
+ ['🐛', ':bug:', 'patch', "Fix a bug."],
32
+ ['🚑️', ':ambulance:', 'patch', "Critical hotfix."],
33
+ ['✨', ':sparkles:', 'minor', "Introduce new features."],
34
+ ['📝', ':memo:', null, "Add or update documentation."],
35
+ ['🚀', ':rocket:', null, "Deploy stuff."],
36
+ ['💄', ':lipstick:', 'patch', "Add or update the UI and style files."],
37
+ ['🎉', ':tada:', null, "Begin a project."],
38
+ ['✅', ':white_check_mark:', null, "Add, update, or pass tests."],
39
+ ['🔒️', ':lock:', 'patch', "Fix security or privacy issues."],
40
+ ['🔐', ':closed_lock_with_key:', null, "Add or update secrets."],
41
+ ['🔖', ':bookmark:', null, "Release / Version tags."],
42
+ ['🚨', ':rotating_light:', null, "Fix compiler / linter warnings."],
43
+ ['🚧', ':construction:', null, "Work in progress."],
44
+ ['💚', ':green_heart:', null, "Fix CI Build."],
45
+ ['⬇️', ':arrow_down:', 'patch', "Downgrade dependencies."],
46
+ ['⬆️', ':arrow_up:', 'patch', "Upgrade dependencies."],
47
+ ['📌', ':pushpin:', 'patch', "Pin dependencies to specific versions."],
48
+ ['👷', ':construction_worker:', null, "Add or update CI build system."],
49
+ ['📈', ':chart_with_upwards_trend:', 'patch', "Add or update analytics or track code."],
50
+ ['♻️', ':recycle:', null, "Refactor code."],
51
+ ['➕', ':heavy_plus_sign:', 'patch', "Add a dependency."],
52
+ ['➖', ':heavy_minus_sign:', 'patch', "Remove a dependency."],
53
+ ['🔧', ':wrench:', 'patch', "Add or update configuration files."],
54
+ ['🔨', ':hammer:', null, "Add or update development scripts."],
55
+ ['🌐', ':globe_with_meridians:', 'patch', "Internationalization and localization."],
56
+ ['✏️', ':pencil2:', 'patch', "Fix typos."],
57
+ ['💩', ':poop:', null, "Write bad code that needs to be improved."],
58
+ ['⏪️', ':rewind:', 'patch', "Revert changes."],
59
+ ['🔀', ':twisted_rightwards_arrows:', null, "Merge branches."],
60
+ ['📦️', ':package:', 'patch', "Add or update compiled files or packages."],
61
+ ['👽️', ':alien:', 'patch', "Update code due to external API changes."],
62
+ ['🚚', ':truck:', null, "Move or rename resources (e.g.: files, paths, routes)."],
63
+ ['📄', ':page_facing_up:', null, "Add or update license."],
64
+ ['💥', ':boom:', 'major', "Introduce breaking changes."],
65
+ ['🍱', ':bento:', 'patch', "Add or update assets."],
66
+ ['♿️', ':wheelchair:', 'patch', "Improve accessibility."],
67
+ ['💡', ':bulb:', null, "Add or update comments in source code."],
68
+ ['🍻', ':beers:', null, "Write code drunkenly."],
69
+ ['💬', ':speech_balloon:', 'patch', "Add or update text and literals."],
70
+ ['🗃️', ':card_file_box:', 'patch', "Perform database related changes."],
71
+ ['🔊', ':loud_sound:', null, "Add or update logs."],
72
+ ['🔇', ':mute:', null, "Remove logs."],
73
+ ['👥', ':busts_in_silhouette:', null, "Add or update contributor(s)."],
74
+ ['🚸', ':children_crossing:', 'patch', "Improve user experience / usability."],
75
+ ['🏗️', ':building_construction:', null, "Make architectural changes."],
76
+ ['📱', ':iphone:', 'patch', "Work on responsive design."],
77
+ ['🤡', ':clown_face:', null, "Mock things."],
78
+ ['🥚', ':egg:', 'patch', "Add or update an easter egg."],
79
+ ['🙈', ':see_no_evil:', null, "Add or update a .gitignore file."],
80
+ ['📸', ':camera_flash:', null, "Add or update snapshots."],
81
+ ['⚗️', ':alembic:', 'patch', "Perform experiments."],
82
+ ['🔍️', ':mag:', 'patch', "Improve SEO."],
83
+ ['🏷️', ':label:', 'patch', "Add or update types."],
84
+ ['🌱', ':seedling:', null, "Add or update seed files."],
85
+ ['🚩', ':triangular_flag_on_post:', 'patch', "Add, update, or remove feature flags."],
86
+ ['🥅', ':goal_net:', 'patch', "Catch errors."],
87
+ ['💫', ':dizzy:', 'patch', "Add or update animations and transitions."],
88
+ ['🗑️', ':wastebasket:', 'patch', "Deprecate code that needs to be cleaned up."],
89
+ ['🛂', ':passport_control:', 'patch', "Work on code related to authorization, roles and permissions."],
90
+ ['🩹', ':adhesive_bandage:', 'patch', "Simple fix for a non-critical issue."],
91
+ ['🧐', ':monocle_face:', null, "Data exploration/inspection."],
92
+ ['⚰️', ':coffin:', null, "Remove dead code."],
93
+ ['🧪', ':test_tube:', null, "Add a failing test."],
94
+ ['👔', ':necktie:', 'patch', "Add or update business logic."],
95
+ ['🩺', ':stethoscope:', null, "Add or update healthcheck."],
96
+ ['🧱', ':bricks:', null, "Infrastructure related changes."],
97
+ ['🧑‍💻', ':technologist:', null, "Improve developer experience."],
98
+ ['💸', ':money_with_wings:', null, "Add sponsorships or money related infrastructure."],
99
+ ['🧵', ':thread:', null, "Add or update code related to multithreading or concurrency."],
100
+ ['🦺', ':safety_vest:', null, "Add or update code related to validation."],
101
+ ['✈️', ':airplane:', null, "Improve offline support."],
102
+ ['🦖', ':t-rex:', null, "Code that adds backwards compatibility."],];
103
+
104
+ // Some official gitmojis carry U+FE0F (⚡️, 🔒️, 🗑️ …). A human typing the bare
105
+ // code point means the same emoji, so compare with the variation selector stripped —
106
+ // otherwise the guard would reject a correct commit over an invisible byte.
107
+ const bare = (s) => s.replace(/️/g, '');
108
+ export const EMOJI_SET = new Set(GITMOJIS.map(([e]) => bare(e)));
109
+ export const CODE_SET = new Set(GITMOJIS.map(([, c]) => c));
110
+
111
+ // git's own functional prefixes: these messages are consumed by `rebase --autosquash`,
112
+ // not read by humans, and the final message is the target commit's. Nothing to enforce.
113
+ const GIT_MAGIC = /^(fixup|squash|amend)!/;
114
+ // A Conventional Commits header, optionally present before the emoji.
115
+ const CONVENTIONAL = /^[a-zA-Z]+(\([^)\n]*\))?!?:\s*/;
116
+
117
+ // Does this subject line open with a gitmoji — either before the conventional prefix
118
+ // or immediately after it? An emoji buried mid-sentence is not an intention marker.
119
+ export function hasGitmoji(message) {
120
+ const subject = String(message ?? '').split(/\r?\n/, 1)[0].trim();
121
+ if (!subject) return false;
122
+ if (GIT_MAGIC.test(subject)) return true;
123
+ const candidates = [subject, subject.replace(CONVENTIONAL, '')];
124
+ for (const c of candidates) {
125
+ const head = bare(c);
126
+ for (const e of EMOJI_SET) if (head.startsWith(e)) return true;
127
+ const code = head.match(/^:[a-z0-9_+-]+:/);
128
+ if (code && CODE_SET.has(code[0])) return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ // ---- reading the message out of a shell command --------------------------------
134
+ // Shell is not parseable in a regex and we do not pretend otherwise: this recognizes
135
+ // the forms an agent or a human actually types, and returns null ("cannot tell") for
136
+ // everything else so the caller allows it.
137
+
138
+ const COMMIT = /\bgit\b(?:\s+-{1,2}\S+(?:\s+\S+)?)*\s+commit\b/;
139
+ const NO_MESSAGE_TO_READ = /(?:^|\s)(?:-F|--file|--template|-C\s+\S*HEAD|--reuse-message|--reedit-message)\b/;
140
+ const AMEND_NO_EDIT = /--no-edit\b/;
141
+ const HAS_INLINE_M = /(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:=|\s|$)/;
142
+ // `git` must be the command being RUN, not text inside someone else's argument —
143
+ // otherwise `echo "git commit -m x"` or `grep "git commit -m"` gets denied, and a guard
144
+ // that blocks the discussion of commits is exactly the kind that gets switched off.
145
+ const RUNS_GIT = /^\s*(?:sudo\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*git\b/;
146
+
147
+ const SEPARATORS = /&&|\|\||[;\n]/;
148
+
149
+ // Is this segment a `git commit` that carries its message inline and readably?
150
+ function readableCommit(segment) {
151
+ return RUNS_GIT.test(segment)
152
+ && COMMIT.test(segment)
153
+ && HAS_INLINE_M.test(segment)
154
+ && !NO_MESSAGE_TO_READ.test(segment)
155
+ && !AMEND_NO_EDIT.test(segment);
156
+ }
157
+
158
+ // `-m "$(cat <<'EOF' … EOF)"` — the multi-line form. The heredoc body IS the message,
159
+ // and it may contain && or ; so it must be lifted out before any splitting.
160
+ function heredocBody(command) {
161
+ const m = command.match(/<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?\r?\n([\s\S]*?)\r?\n[ \t]*\1\b/);
162
+ return m ? m[2] : null;
163
+ }
164
+
165
+ // -m/--message value, quoted or bare.
166
+ function inlineMessage(segment) {
167
+ const m = segment.match(/(?:^|\s)(?:-[a-zA-Z]*m|--message)(?:=|\s+)("([^"]*)"|'([^']*)'|([^\s'"][^\s]*))/);
168
+ if (!m) return null;
169
+ return m[2] ?? m[3] ?? m[4] ?? null;
170
+ }
171
+
172
+ // Every commit message this command would write, or null when nothing is checkable.
173
+ export function commitMessages(command) {
174
+ const cmd = String(command ?? '');
175
+ if (!COMMIT.test(cmd)) return [];
176
+
177
+ // The heredoc form must be handled before any splitting: its body is the message and
178
+ // may legitimately contain && or ;. The command that owns it is whatever runs just
179
+ // before the `<<`.
180
+ const hIdx = cmd.search(/<<-?\s*['"]?[A-Za-z_]/);
181
+ if (hIdx !== -1) {
182
+ const owner = cmd.slice(0, hIdx).split(SEPARATORS).pop() || '';
183
+ if (!readableCommit(owner)) return [];
184
+ const body = heredocBody(cmd);
185
+ if (body === null) return [];
186
+ const first = body.split(/\r?\n/).find((l) => l.trim() !== '');
187
+ return first === undefined ? [] : [first];
188
+ }
189
+
190
+ const out = [];
191
+ for (const segment of cmd.split(SEPARATORS)) {
192
+ if (!readableCommit(segment)) continue;
193
+ const msg = inlineMessage(segment);
194
+ if (msg === null) continue; // editor commit — cannot read it here
195
+ if (/^\$/.test(msg.trim())) continue; // a variable/substitution, not a literal
196
+ out.push(msg);
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // ---- the deny message (P6: the recovery lives inside the refusal) ---------------
202
+
203
+ export const SUGGESTIONS = [
204
+ ['feat', '✨'], ['fix', '🐛'], ['docs', '📝'], ['refactor', '♻️'], ['test', '✅'],
205
+ ['perf', '⚡️'], ['chore', '🔧'], ['ci', '👷'], ['style', '🎨'], ['revert', '⏪️'],
206
+ ];
207
+
208
+ export function denyMessage(message) {
209
+ const subject = String(message ?? '').split(/\r?\n/, 1)[0].trim();
210
+ const type = subject.match(/^([a-zA-Z]+)(\([^)\n]*\))?!?:/)?.[1]?.toLowerCase();
211
+ const hit = SUGGESTIONS.find(([t]) => t === type);
212
+ const rewrite = hit
213
+ ? `${hit[1]} ${subject}`
214
+ : `${SUGGESTIONS.find(([t]) => t === 'chore')[1]} ${subject || '<type>(<scope>): <subject>'}`;
215
+ return [
216
+ `This commit message has no gitmoji: "${subject}".`,
217
+ 'Every commit in this project opens with a gitmoji (gitmoji.dev) before the Conventional Commits header — the intention of the change, readable in one glyph in `git log --oneline`.',
218
+ `Recover: re-run the commit with the emoji in front — \`${rewrite}\` — picking the gitmoji that matches the intention (✨ feature · 🐛 fix · 📝 docs · ♻️ refactor · ✅ tests · ⚡️ perf · 🔧 config · 👷 CI · 💥 breaking · 🔖 release). The full table is in the \`git-workflow\` skill (references/gitmoji.md). To turn this guard off for this project, create .rsc/.no-gitmoji.`,
219
+ ].join('\n');
220
+ }
221
+
222
+ // ---- hook entrypoint -----------------------------------------------------------
223
+ // Skipped when imported by a test (P2: the mechanism is testable without a subprocess).
224
+ if (import.meta.url === `file://${process.argv[1]}`) {
225
+ const root = process.argv[2] || process.cwd();
226
+ const allow = () => process.exit(0);
227
+
228
+ if (existsSync(join(root, '.rsc', '.no-gitmoji'))) allow();
229
+
230
+ let input = {};
231
+ try { input = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { allow(); }
232
+ if ((input.tool_name || input.toolName) !== 'Bash') allow();
233
+ const command = input.tool_input?.command || input.toolInput?.command || '';
234
+ if (typeof command !== 'string' || !command) allow();
235
+
236
+ let offender = null;
237
+ try {
238
+ offender = commitMessages(command).find((m) => !hasGitmoji(m)) ?? null;
239
+ } catch { allow(); } // any parsing surprise → allow (fail open, by design)
240
+ if (offender === null) allow();
241
+
242
+ process.stdout.write(JSON.stringify({
243
+ hookSpecificOutput: {
244
+ hookEventName: 'PreToolUse',
245
+ permissionDecision: 'deny',
246
+ permissionDecisionReason: denyMessage(offender),
247
+ },
248
+ }));
249
+ process.exit(0);
250
+ }