---
name: commit-create
description: "Use when you are about to create, amend or squash a git commit, finalize or ship a branch, write a pull-request TITLE, or compose a conventional commit message in any form, including the contents of a proposed-squash-commit block. Defines the commit-message contract enforced by @savvy-web/commitlint: type enum, tdd scope grammar, subject rules, the brevity doctrine for bodies, DCO signoff, comma-separated Closes trailers, signing posture, and a pre-commit checklist. Triggered by \"commit this\", \"write a commit message\", \"amend\", \"squash\", \"wrap up this branch\", \"ship it\", \"land this\", \"write the PR title\". Does NOT apply to the prose of a PR description; that is the pr-body skill."
user-invocable: false
allowed-tools:
- execute
---
# commit-create
This skill defines the complete commit-message contract for this repository.
Read it fully before you compose a subject line, body, or trailer. The rules
below are enforced by the `@savvy-web/commitlint` Silk preset — violations
cause the `commit-msg` husky hook to reject the commit.
## Scope: this skill or `pr-body`?
The two skills split on document, not on command. Both can apply to one PR.
| You are writing | Skill | Held to the rules below? |
| --- | --- | --- |
| A commit message | `commit-create` | Yes |
| A pull-request **title** | `commit-create` | Yes — a PR title is a conventional-commit subject |
| The contents of a `proposed-squash-commit` fence | `commit-create` | Yes — it becomes a commit message |
| A pull-request **description** | `pr-body` | **No** — markdown, only `plan-leakage`/`closes-trailer` apply |
So `gh pr create --title` is this skill's business and `--body` is not, and a
single `gh pr create` call routinely needs both. When you are writing a PR
description that contains a squash-commit fence, follow `pr-body` for the
document and this skill for what goes inside the fence.
You CANNOT eyeball a 300-character body line, a 100-character header, or a
100-character trailer line and get it right. This is not a skill issue —
LLMs measurably cannot count characters by inspection. Guessing and retrying
is not a viable strategy: the `commit-msg` hook fires AFTER `lint-staged`
(biome + markdownlint + chmod over every staged file, tens of seconds), so
every wrong guess costs a full lint-staged cycle before you even find out.
The fix is not "be more careful." It is: never invoke `git commit` yourself.
Compose the message into a file, then run
```bash
bash "/scripts/commit.sh"
```
This validates the message against the REAL commitlint preset and, ONLY on
success, creates the commit. If validation fails, NOTHING is committed and
you fix the file and re-run the same command.
Do not run a length check yourself and then call `git commit` as a second,
separate command — EVEN IF the check says the message is fine. That exact
sequence is a known failure mode: a check can report a violation correctly
and the commit still happens anyway, because reading a check's output and
acting on it are two different steps, and under context pressure the second
step gets skipped. `commit.sh` exists precisely so there is no second step —
git commit is unreachable inside the script unless validation already
exited 0. Calling `git commit` directly, for any reason, bypasses this
guarantee. If `commit.sh` errors for a reason unrelated to the message
(missing config, wrong directory), fix that reason and rerun `commit.sh` —
do not fall back to a bare `git commit`.
For `--amend` or a signed commit, pass the git flags after `--`:
```bash
bash "/scripts/commit.sh" -- --amend
bash "/scripts/commit.sh" -- -S
```
`commit.sh` refuses `--no-verify`/`-n` outright — it would skip lint-staged
and the commit-msg hook for the actual commit. If a hook seems wrong, fix
the hook; do not bypass it.
### Which repository the commit lands in
`commit.sh` and `validate-message.sh` resolve the target repository the same
way, and the current working directory is the primary authority:
1. **Your cwd, if it is inside a git repository, wins.** They run
`git -C "$PWD" rev-parse --show-toplevel` first. Inside a linked git
worktree (e.g. `.claude/worktrees/agent-*/`) this correctly resolves to
the WORKTREE's own root, never the primary checkout — that is where the
commit belongs.
2. **`CLAUDE_PROJECT_DIR` and `SILK_PROJECT_DIR` are fallbacks, not
overrides.** Both are routinely inherited: `CLAUDE_PROJECT_DIR` is the
host's pin to the session's primary checkout, and a coordinating
session's `SILK_PROJECT_DIR` reaches every subagent it starts in a
worktree. When either names another worktree of the same repository, cwd
still wins (a `SILK_PROJECT_DIR` mismatch prints a one-line `NOTICE`). If
either instead names a genuinely different repository (no shared git
history with cwd — a cross-repo agent session, or a stale value left over
from earlier, unrelated work) the scripts refuse rather than guess,
printing an error that names both paths. Fix this by `cd`-ing to the repo
you mean to operate on, or by unsetting the variable.
3. Outside any git repository entirely (cwd resolution itself fails), the
fallback chain is `SILK_PROJECT_DIR` → `CLAUDE_PROJECT_DIR` → the literal
cwd. To target another repository, `cd` into it:
```bash
cd /path/to/intended/repo && bash "/scripts/commit.sh"
```
This is the fix for a family of reproduced bugs — savvy-web/systems issues
706, 474, 434 and 418 — where an inherited, stale
`SILK_PROJECT_DIR`/`CLAUDE_PROJECT_DIR` silently outranked the actual working
tree, including a near-miss where one agent's commit almost landed on another
agent's staged tree. If `commit.sh` ever reports the wrong repository, that
is a bug in the resolution above, not something to work around by exporting
an env var and moving on without understanding why it was necessary.
If the commit is being made through an MCP tool (GitKraken) or as a `gh pr
create`/`gh pr edit` body instead of a Bash `git commit` — where there is no
wrapper script to call — the same discipline still applies without the
structural guarantee: run
```bash
bash "/scripts/validate-message.sh"
```
and treat a non-zero exit as an absolute stop. Do not call the MCP tool or
`gh` command in the same turn as a failed validation "to save time" — fix
the message and re-validate first.
---
## Commit format
```text
type(scope): subject
body (optional — a few bullets, or one to two short paragraphs)
Closes #N, #N, #N (optional, all issues on ONE comma-separated line)
Signed-off-by: Full Name
```
Every part has constraints. Read each section.
Blank lines separate the subject, the body, the closing trailer, and the
signoff. Squashing them together is not an error — commitlint accepts it —
but the spaced form is the house style. Write it that way.
A commit message is a scannable index entry, not a design document. This repo
squash-merges, and commits are sometimes rebased — a long body is discarded at
merge or stranded on a commit nobody reads, after costing real time and tokens
to write. Explanation that deserves to survive belongs in the PR description,
the changeset, or a design doc, all of which outlive the commit.
Agents here produce large commits, and that is fine. A large commit does NOT
earn a large message. Report the few changes that matter and drop the rest.
See "Body rules" for the length target and what qualifies as "matters".
---
## Allowed commit types
Use exactly one of the following lowercase identifiers as the type:
| Type | When to use |
| --- | --- |
| `ai` | AI/LLM agent document updates — CLAUDE.md, context files, design docs |
| `build` | Changes to the build system or external dependencies |
| `chore` | Housekeeping that does not touch src or test files |
| `ci` | CI configuration and pipeline scripts |
| `docs` | Documentation-only changes |
| `feat` | A new user-facing feature |
| `fix` | A bug fix |
| `perf` | A change that improves performance |
| `refactor` | Code change that neither fixes a bug nor adds a feature |
| `release` | Version bumps and changelog commits |
| `revert` | Reverts a previous commit |
| `style` | Formatting, whitespace, semicolons — no logic change |
| `tdd` | TDD agent commit — **requires a mandatory scope** (see below) |
| `test` | Adding or correcting tests (non-TDD workflow) |
Do not invent types. Do not use a type not in this list.
---
## Scope rules
Scope is optional for most types. When provided, it names the logical
component, module, or concern being changed. Examples: `(deps)`, `(cli)`,
`(config)`, `(detection)`.
### TDD scope is mandatory and structured
`tdd` commits require a scope in the format `goalId:state` where:
- `goalId` is a positive integer identifying the TDD goal
- `state` is one of: `spike`, `red`, `green`, `refactor`
Pattern: `^\d+:(spike|red|green|refactor)$`
Valid: `tdd(42:red)`, `tdd(7:green)`, `tdd(1:refactor)`, `tdd(99:spike)`
Invalid: `tdd(red)`, `tdd(42)`, `tdd(green:42)`, `tdd` (no scope)
A `tdd` commit without a valid scope will be rejected by the hook.
---
## Subject line rules
- **Mood:** imperative ("add", "fix", "remove" — not "added", "adding", "fixes")
- **Case:** prefer a lowercase first letter after the colon for consistency with the rest of this repo's history and rendered changelogs. Note this is a style preference, not a mechanically enforced rule — the Silk preset explicitly disables commitlint's built-in `subject-case` check (`AI tools often capitalize, which is acceptable`) — so getting it wrong will not fail the hook, but match the convention anyway
- **Length:** max 100 characters for the full `type(scope): subject` header
- **No period:** do not end the subject with a period or any punctuation
- **No markdown:** no backticks, bold, italic, or links in the subject
- **Be specific:** describe what changed, not that "things were updated"
Bad: `feat: Updated the user authentication flow`
Good: `feat: add JWT refresh token rotation to auth flow`
Bad: `fix: fix bug`
Good: `fix(cli): handle missing config file with a clear error message`
---
## Body rules
The body is optional. Omit it entirely when the subject already says the whole
thing (`chore: bump lockfile`, `style: apply biome formatting`, most `tdd`
cycle commits). An absent body is a correct body — do not manufacture one.
### Length
**Three to five bullets, or one to two short paragraphs. Not both, and never
more than about eight body lines total.** Pick the shape that fits:
- **Bullets** for a commit that did several separable things. One line each,
imperative, roughly 10–20 words. This is the default shape.
- **Prose** for a commit whose point is a single idea that needs a sentence or
two of "why" — a subtle trap, a non-obvious constraint, a behavior change a
reader would otherwise misread.
Each bullet or paragraph is **one continuous line** — do not soft-wrap at 72
or any other column. A wrapped continuation line reads as a stray indented
line and trips the soft-wrap heuristic. The hard cap is 300 characters per
line, but a line anywhere near that is already too long for this format:
**if a line passes roughly 200 characters, the content is wrong, not the
formatting.** Split it or cut it.
### What earns a line
Write the line only if a reader scanning `git log` next quarter needs it:
- The user-visible or API-visible change
- A behavior change a consumer could trip over
- A non-obvious constraint or trap the diff does not reveal on its own
### What does not earn a line — cut these
- Restating the subject in longer words
- Narrating the change's motivation when the subject already implies it
- Test counts, coverage deltas, or "pinned by tests that…" — the tests are in
the diff
- Reasoning, investigation notes, or the evidence behind a decision — that is
PR-description and design-doc material
- File-by-file or module-by-module walkthroughs
- Refactors, renames, and mechanical churn carried along by the real change
- Routine updates to CLAUDE.md, `.claude/design/`, `okf/`, skills, or any AI
context file — unless that update IS the commit
- Config tweaks (biome.jsonc, tsconfig, lint-staged) — unless the config
change is the substantive point
- Vague qualifiers: "for clarity", "to improve readability", "as a cleanup"
- Dependency updates: name only direct deps changed in the manifest; never
enumerate transitive lockfile entries
The test for any candidate line: **would you lose something if this line were
deleted?** If not, delete it. Most first drafts lose half their lines to this
question and improve.
Do not count characters by eye and do not estimate. Write the message to a
file and run `scripts/validate-message.sh` (or `scripts/commit.sh`, which
calls it for you) — it prints the exact length and line number of every
violation. See "Validate before you commit" above.
### Formatting the preset rejects
- Markdown headers (`##`), numbered lists (`1.`), code fences (` ``` `), links (`[text](url)`), bold (`**text**`), horizontal rules (`---`), or more than two inline-code spans
- References to plan files, design docs, or task IDs ("as decided in the plan", "see .claude/plans/...", "previously documented in")
**Dash bullets (`- item`) are allowed and are the preferred body shape.** The
`silk/body-no-markdown` rule does not flag them, and the stricter
`silk/body-prose-only` rule is not enabled in this repo's config. Numbered
lists (`1.`) are still rejected — use dashes.
---
## DCO signoff
This repository requires a Developer Certificate of Origin (DCO) signoff on
every commit. The trailer must be the **last line** of the commit message,
separated from any `Closes` trailer above it by a blank line:
```text
Signed-off-by: Full Name
```
Use the committer's real name and the email associated with their git config.
Run `git config user.name` and `git config user.email` if unsure.
Do not paraphrase or abbreviate — the exact format `Signed-off-by: Name ` is required.
**The trailer line itself is capped at 100 characters** — the
`footer-max-line-length` rule from `@commitlint/config-conventional` applies
to every trailer line (`Signed-off-by:`, `Closes #N`, `Fixes #N`,
`Resolves #N`), not just the body. It is easy to miss because nothing else
in this contract calls it out, and it normally never comes up — but a very
long name/email combination or a stacked list of `Closes` trailers can trip
it. `validate-message.sh` measures this too.
---
## Closes / Fixes / Resolves trailers
When the commit resolves a tracked GitHub issue, add a closing trailer above
the `Signed-off-by` line:
```text
Closes #42
Signed-off-by: Spencer Beggs
```
Any of `Closes`, `Fixes`, or `Resolves` followed by `#N` are accepted.
**Multiple issues go on ONE line, comma-separated — never one trailer per
line.** A stacked column of `Closes` lines is noise in `git log` and eats the
footer's 100-character budget for nothing.
```text
Closes #247, #248, #251, #252, #253, #254
Signed-off-by: Spencer Beggs
```
Not:
```text
Closes #247
Closes #248
Closes #251
```
If the list would exceed 100 characters (roughly a dozen issues), start a
second `Closes` line rather than wrapping the first. If the branch name
contains a ticket number and the work closes that issue, always include it.
---
## Signing posture
If the project's git config has `commit.gpgsign=true`, commits must be
signed. The SessionStart context block includes a ``
element that reports the current signing state. Consult it when composing a
commit on a machine where signing is configured.
If signing is required and the key does not resolve or the agent is
unresponsive, do not silently create an unsigned commit. Report the signing
failure to the user and suggest they verify their gpg-agent or ssh-agent
configuration.
---
## Good examples
These are the target. Note how much they leave out.
### Bullets — a commit that did several separable things
```text
feat(actions): canonicalize GitHub Actions skills
- Consolidate Actions skills into indexed guidance with focused references
- Add action design and repository structure skills
- Preload the complete Actions skill suite in action-engineer
- Validate construct coverage across exported package APIs
Signed-off-by: Spencer Beggs
```
### Bullets with a Closes trailer
```text
feat: add deferred runtime action capabilities
- Add branch, cache digest, child environment, lockfile, and npm cache APIs
- Document schemastore usage and correct its example
- Publish changesets for package and dependency updates
Closes #218
Signed-off-by: Spencer Beggs
```
### Prose — one idea worth two sentences
Reach for this shape only when the point is a trap or constraint the diff
does not reveal on its own.
```text
ai(design): record the duplicate-service-identity trap
Two resolved copies of one @effected package are two distinct Context.Service tags, so a layer built from one does not satisfy a requirement expressed by the other.
It surfaces as a service reading unprovided in a graph that visibly provides it, not as a version error, which sends a reader looking for a signature change that never happened.
Closes #16, #17, #18
Signed-off-by: Spencer Beggs
```
### No body at all
```text
tdd(14:green): implement scope validator for tdd commits
Signed-off-by: Spencer Beggs
```
---
## Bad examples
```text
feat: Updated Things
```
Problems: past tense ("Updated"), vague ("Things"), capital U.
```text
fix: fixed the bug where the thing crashes when the other thing is missing and also updated the error message and added a test
```
Problems: past tense, run-on subject over 100 chars, multiple distinct changes crammed into one line.
```text
chore: dependency updates
## Changes
- Updated `effect` from 3.1.0 to 3.14.0
- Updated `@effect/cli` from 0.46.0 to 0.52.0
- Updated 47 transitive dependencies in the lockfile
```
Problems: markdown header in body, enumerating transitive lockfile entries.
### The failure mode this skill exists to prevent
```text
feat(github-actions): serve a test payload and emit per-step summaries
ActionEnvironment.makeTest and layerTest take the webhook payload as a second argument, serving it directly rather than through a GITHUB_EVENT_PATH read.
The layer hard-provides a noop filesystem and captures it at construction, so seeding the path had no route to a payload, and every event-driven suite rebuilt the double by hand.
ActionLogger.withStep adds the summary line withBuffer alone cannot reach: discard on success plus one info line, and a failure header emitted ahead of the transcript it announces.
Docstrings now cover two failure modes that ship green. An explicitly empty input cannot be read through Config.withDefault, confirmed against runner source and a live probe.
A bare ConfigProvider.fromEnv composed beneath the input provider uppercases the path, so an input-name key never matches and the read silently takes its default.
Also lifts the GitHubMarkdown rename out of the does-not-ship list it was filed under, and adds a legacy-to-kit symbol map for ports that change every import and no pipeline step.
Closes #247
Closes #248
Closes #251
Closes #252
Signed-off-by: Spencer Beggs
```
Every sentence here is accurate and well written, and the message is still
wrong. It passes commitlint, so nothing will stop you. Problems: six
paragraphs where three bullets would do; the investigation evidence
("confirmed against runner source and a live probe") belongs in the PR;
mechanical carry-along changes ("also lifts the rename…") promoted to a
paragraph; stacked `Closes` lines instead of one comma-separated line. On a
squash merge, all of it is discarded.
The same commit, correctly:
```text
feat(github-actions): serve a test payload and emit per-step summaries
- Take the webhook payload as an argument to ActionEnvironment.makeTest and layerTest, replacing the GITHUB_EVENT_PATH read that the hard-provided noop filesystem made unreachable
- Add ActionLogger.withStep for per-step summaries: discard on success, failure header ahead of the transcript
- Document two silent-default traps: an empty input is unreadable through Config.withDefault, and a bare ConfigProvider.fromEnv beneath the input provider uppercases the key so it never matches
Closes #247, #248, #251, #252
Signed-off-by: Spencer Beggs
```
---
## Composing the message: what to get right before you validate
The validator catches every violation listed in this skill, but composing a
message that passes on the first try (rather than the second or third) still
starts with getting these right:
1. Confirm the type is in the allowed list above.
2. For `tdd` commits, confirm the scope matches `^\d+:(spike|red|green|refactor)$`.
3. Check the subject: imperative mood, no period, under 100 characters total.
4. **Count your body lines. More than about eight, or any line past ~200 characters, means cut — not reformat.** Re-read "What does not earn a line" and delete every line that survives the "would you lose something?" test only because you wrote it.
5. If you wrote a body, verify no markdown headers, numbered lists, code fences, or plan-file references, and every line under 300 characters.
6. Confirm `Signed-off-by: Full Name ` is present, each trailer line under 100 characters.
7. If the branch implies a ticket and the work closes it, add `Closes #N` above the signoff — all issues on one comma-separated line.
8. If `commit.gpgsign=true`, confirm the signing agent is responsive before committing.
## The one command that actually commits
Write the composed message to a file, then run:
```bash
bash "/scripts/commit.sh"
```
This is not an optional pre-check — it is the entire mechanism by which a
commit gets created in this workflow. It validates against the real
commitlint preset and only execs `git commit -F ` on success.
See "Validate before you commit" at the top of this skill for why a separate
"check, then commit" sequence is the specific failure this replaces, and for
the `--amend`/`-S` passthrough form.