@ericrisco/rsc 1.0.25 → 1.0.27
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 +5 -0
- package/manifest.json +4 -3
- package/package.json +2 -1
- package/scripts/build-manifest.js +20 -4
- package/scripts/doctor.js +3 -0
- package/scripts/install-apply.js +1 -0
- package/scripts/lib/skill-lint.js +29 -0
- package/scripts/lib/spec-gate.js +122 -0
- package/scripts/spec-gate.js +58 -0
- package/skills/clarify/SKILL.md +26 -2
- package/skills/constitution/references/constitution-template.md +1 -1
- package/skills/git-workflow/SKILL.md +36 -12
- package/skills/git-workflow/evals/cases.yaml +5 -0
- package/skills/git-workflow/references/gitmoji.md +157 -0
- package/skills/ship/SKILL.md +2 -1
- package/skills/specify/SKILL.md +77 -26
- package/skills/specify/evals/cases.yaml +4 -4
- package/skills/specify/references/eliciting-requirements.md +50 -10
- package/skills/specify/references/spec-template.md +19 -5
- package/targets/claude.js +16 -1
- package/targets/gitmoji-guard.mjs +250 -0
|
@@ -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
|
+
}
|