diff --git a/.github/workflows/commit-messages.yml b/.github/workflows/commit-messages.yml new file mode 100644 index 00000000..134f860f --- /dev/null +++ b/.github/workflows/commit-messages.yml @@ -0,0 +1,26 @@ +# Every commit on a pull request follows the commit-craft format +# (tools/commit/check-commit-msg). PRs here are squash-merged and the squash +# body is the branch commits' messages, so they are checked, not only the +# title. Pull requests only: a direct push can carry older commits. +name: commit messages + +on: + pull_request: + +permissions: + contents: read + +jobs: + check: + name: Check commit messages + runs-on: ubuntu-latest + steps: + # Pinned to a commit; the comment is the tag. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - name: Check commit messages + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: tools/commit/check-commits "$BASE..$HEAD" diff --git a/CLAUDE.md b/CLAUDE.md index 188f0928..4e51b1fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,22 +87,30 @@ jj log -r '::' --limit 10 ### Commit Message Format -``` -Short imperative summary (50 chars or less) - -Longer explanation of the change and why it was made. Focus on: -- What problem does this solve? -- What alternatives were considered? -- What did you learn? -- What tradeoffs were made? - -Specific changes: -- Bullet points for key modifications -- File paths and what changed -- Configuration decisions - -[Optional: Link to resources or documentation] -``` +Every commit message follows the **commit-craft** skill +(`tools/agents/skills/commit-craft/SKILL.md`, installed at +`~/.agents/skills/commit-craft`). Read it before writing or changing any +message, in any harness, even when skills are not loaded for you +automatically. It defines the format, `(): ` plus a +body in a fixed order, and gives the body's wording to the ste100 skill. The +shape is enforced outside the skill, by the scripts in `tools/commit/`: run +`check-commit-msg` on a message before you set it, and push with `jj push`, +which checks the stack first (`tools/jujutsu/config`). A `git commit` in this +checkout runs the same check as a hook, and CI checks every commit on a pull +request. The jujutsu skill covers only the commands that set the message. +The same applies when compound-engineering's `ce-commit` makes the commit: +commit-craft is the project convention its step 3 defers to, so the body is +required even where ce-commit would leave it out. + +Two additions for this repository: + +- When a change came out of a mistake or a dead end, the body's "what and + how" paragraph also says what was learned. That is the story the history + is meant to tell. +- PRs here are squash-merged, so the PR title becomes the commit subject on + `main`. Write the title in the same subject format; GitHub appends ` (#N)`. + The squash body is every branch commit's message in turn (each subject as a + `* ` line), so branch commits follow the format too. ## Repository Structure @@ -867,6 +875,7 @@ This document should evolve as patterns emerge. When you: **Add it here** and commit with a message explaining what prompted the addition. --- +*Last updated: 2026-10-01 - Replaced the inline commit message format with a pointer to the commit-craft skill, and moved the enforcement out of the skill into configuration (a jj draft template and `jj push` alias, a git hook for this checkout, a CI job), because a skill only works when an agent loads it and the format must hold for every commit; the skill keeps what no check can judge and gives the body's wording to ste100* *Last updated: 2026-09-29 - The scheduled flake update broke on `nix/build-skill` not existing: nix-skills was renamed to agents.nix, history rewritten and restructured. `lib/skills-sh.nix` now goes through upstream's public overlay, which the restructure made cheap (measured), instead of the internals it used to avoid a then-18s eval; supabase-postgres-best-practices is pinned by hand because the index dropped its repo* *Last updated: 2026-09-21 - Ghost (#56): ghost.build is shutting down, so venari now runs a server for its OpenAPI contract from the alycda/ghost fork, with the CLI packaged from that fork and driven by an env-setting wrapper; recorded the two client-side assumptions (`tsdb` dbname, viper's empty-env handling) that the "no CLI patch needed" plan missed* *Last updated: 2026-09-21 - Added "prebuilt binaries in a persisted `$HOME` are image-scoped state" to Tools Nix Can't Fully Manage, after a rustup toolchain in the devhome volume survived an image rebuild and left `cargo` erroring ENOENT for a loader that no longer existed — with the corollary that an activation step guarded on "is it installed" can never repair state that went bad in place (#80)* diff --git a/home-manager/modules/common.nix b/home-manager/modules/common.nix index f9318c15..3edf4f74 100644 --- a/home-manager/modules/common.nix +++ b/home-manager/modules/common.nix @@ -4,6 +4,14 @@ let # Core packages shared with devShells (defined in lib/core-packages.nix) # docker on OSX is installed by homebrew (Docker Desktop/Orbstack) corePackages = import ../../lib/core-packages.nix pkgs; + + # The commit message checks (tools/commit), on PATH for jj's `jj push` + # alias. One package, because check-commits looks for check-commit-msg + # next to itself. + commitChecks = pkgs.runCommandLocal "commit-checks" { } '' + mkdir -p $out/bin + cp ${../../tools/commit}/* $out/bin/ + ''; in { imports = [ @@ -39,7 +47,7 @@ in # Core packages across all profiles # Note: helix is configured via ./tools/helix.nix (programs.helix) # Note: claude-code CLI installed here (binary only; global rules managed by ./tools/claude-code.nix) - packages = corePackages ++ [ pkgs.claude-code pkgs.presenterm ]; + packages = corePackages ++ [ pkgs.claude-code pkgs.presenterm commitChecks ]; # Set helix as default editor sessionVariables = { @@ -75,14 +83,21 @@ in }; }; - # Invocable cheatsheets as just's GLOBAL justfile: `just -g ` from - # anywhere (verified on just 1.58: -g recipes run with the invocation - # directory as cwd). The shim imports the store copy by absolute path, and - # `import?` leaves an optional hook so a profile or machine can layer its - # own recipes (e.g. the work profile's ditto-worktree recipes) by writing - # ~/.config/just/local.just instead of fighting over this file. - xdg.configFile."just/justfile".text = '' - import '${../../tools/just}/global.justfile' - import? '~/.config/just/local.just' - ''; + xdg.configFile = { + # Invocable cheatsheets as just's GLOBAL justfile: `just -g ` from + # anywhere (verified on just 1.58: -g recipes run with the invocation + # directory as cwd). The shim imports the store copy by absolute path, and + # `import?` leaves an optional hook so a profile or machine can layer its + # own recipes (e.g. the work profile's ditto-worktree recipes) by writing + # ~/.config/just/local.just instead of fighting over this file. + "just/justfile".text = '' + import '${../../tools/just}/global.justfile' + import? '~/.config/just/local.just' + ''; + + # jj settings shared with the account without Nix: the commit message + # draft template and the `jj push` alias. conf.d, not config.toml, which + # `jj config set --user` writes. + "jj/conf.d/dotfiles.toml".source = ../../tools/jujutsu/config; + }; } \ No newline at end of file diff --git a/home-manager/modules/git.nix b/home-manager/modules/git.nix index 374c4e1c..41df4190 100644 --- a/home-manager/modules/git.nix +++ b/home-manager/modules/git.nix @@ -44,7 +44,17 @@ # Settings that work without Nix (defaultBranch, pull/push behaviour, # aliases) live in tools/git/config, which the mise account includes # from ~/.gitconfig. Only what needs Nix or a secret stays below. - includes = [ { path = ../../tools/git/config; } ]; + includes = [ + { path = ../../tools/git/config; } + ] + # The commit-msg hook, in the dotfiles checkouts only (core.hooksPath + # replaces .git/hooks for the whole repo). tools/git/config has the same + # includeIf for the account without Nix, but its relative path finds + # nothing in the store copy, so the include is repeated here. + ++ map (dir: { + path = ../../tools/git/dotfiles-repo.gitconfig; + condition = "gitdir:${dir}"; + }) [ "~/dotfiles/" "/workspaces/dotfiles/" ]; settings = { include.path = "${config.home.homeDirectory}/.local/share/agenix/git-config"; diff --git a/home-manager/modules/tools/agent-skills.nix b/home-manager/modules/tools/agent-skills.nix index 9855cef0..9c714b6a 100644 --- a/home-manager/modules/tools/agent-skills.nix +++ b/home-manager/modules/tools/agent-skills.nix @@ -35,7 +35,8 @@ # Repo skills: # brag-doc - promo-packet impact entries from raw work notes # cf-now - private file sharing from Cloudflare R2 (pre-signed URLs) -# commit-craft - commit-message craft + jj describe/push workflow +# commit-craft - what a commit message says; the shape is checked +# by tools/commit (jj push, git hook, CI) # entity-level-git - Ataraxy Labs stack: sem (entity diff/blame/impact), # weave (semantic merge driver), inspect (review triage) # failure-doc - failures as deliberate-learning records diff --git a/tools/agents/skills/commit-craft/SKILL.md b/tools/agents/skills/commit-craft/SKILL.md index 475c611a..d67fb800 100644 --- a/tools/agents/skills/commit-craft/SKILL.md +++ b/tools/agents/skills/commit-craft/SKILL.md @@ -1,336 +1,251 @@ --- name: commit-craft -description: Write high-quality git commit messages following established conventions. Use when the user asks to write, review, improve, or critique commit messages, wants to prepare commits for pushing to git from jujutsu, or needs guidance on commit message structure and content. Also use when asked about commit message best practices or the seven rules. +description: > + The commit message format for Alyssa's repositories. Use every time you + write or change a commit message: before `jj describe`, `jj new -m`, + `jj commit`, `jj squash -m`, `git commit` or `git commit --amend`, when + asked to "commit this", and when reviewing or rewording existing messages. + Applies in any harness (Claude Code, Crush, Codex). The shape is checked by + check-commit-msg, the jj push alias, a git hook and CI; this skill covers + what those checks cannot judge. The facts come from sem (entity-level-git) + and the wording from the ste100 skill. --- # Commit Craft -Write clear, professional commit messages that follow established conventions from Chris Beams' "How to Write a Git Commit Message." +The commit path enforces the shape of a message. This skill covers the part +no check can judge: what the message says. It adds no language rules of its +own. Two other skills do the work it would otherwise repeat: sem, from the +entity-level-git skill, gives the facts (which entities changed), and ste100 +gives the words. This skill gives the order. + +**Done when** each logical change is a commit whose message passes +`check-commit-msg`, and `jj st` (or `git status`) shows none of the task's +changes left. **Stop when** there is nothing to commit: say so, and do not +make an empty commit. + +## What is enforced, and where + +| Where | What runs | +| --- | --- | +| `check-commit-msg FILE` (or stdin) | The shape rules below. Run it yourself before you set a message. | +| `jj describe` with an editor | The editor opens with the format as `JJ:` lines. | +| `jj push` (alias) | `check-commits --jj` on the stack that is not on trunk yet, then `jj git push` with the same arguments. | +| `git commit` in the dotfiles checkouts | The `commit-msg` hook in `tools/git/hooks`. | +| A pull request on the dotfiles repo | CI runs `check-commits` on the PR's commits. | + +The scripts are in `tools/commit/` in the dotfiles repo, and on PATH through +mise or home-manager. jj runs no git hooks, so in a jj repo push with +`jj push`, not `jj git push`. + +## Which convention wins + +1. A convention the repository documents (CONTRIBUTING, CLAUDE.md, AGENTS.md, + a commitlint config) wins. Push with `jj git push` there, because + `jj push` checks this skill's format. +2. Otherwise, use this skill. Do not copy the style of the repository's + history. + +## Procedure + +Do these steps in order for every commit. + +1. Read the full change and the task that caused it. You must know why the + change exists, not only what it is. If `sem` is installed, start with + `sem diff --no-cosmetics` (see Facts). Then read `jj diff --git` (or + `git diff --staged`) for the detail. +2. If the change does two unrelated things, split it first (see the jujutsu + skill). Signs of two commits: a subject that needs "and", two groups of + entities in `sem diff` with no relation, or formatting-only edits (sem + marks them `[cosmetic]`) next to real ones. +3. Write the message to a file outside the working copy (your scratchpad, or + `$TMPDIR`). jj snapshots every file in the working copy. +4. Apply the ste100 skill to the body (see Language). +5. Run `check-commit-msg /path/to/msg.txt`. Fix every problem it prints, and + run it again until it prints nothing. ste100 can change line lengths, so + run it after step 4. +6. Set the message from the file: `jj describe --stdin < /path/to/msg.txt`. + With git only, stage the commit's files by name and name them again on + the commit: -## The Seven Rules - -### 1. Separate subject from body with a blank line - -If your commit needs explanation beyond the subject line, add a blank line after the subject, then write the body. - -**Why:** Many git tools expect this format. `git log --oneline`, `git shortlog`, and email-based workflows all rely on this separation. - -``` -Add caching layer to user authentication - -The previous implementation made a database call on every -request to verify user credentials. This change introduces -a Redis-backed cache with a 5-minute TTL to reduce database -load during high-traffic periods. -``` - -**When to skip the body:** Simple, self-explanatory changes don't need additional context. - -``` -Fix typo in README -``` - ---- - -### 2. Limit the subject line to 50 characters - -Keep subjects concise. Aim for 50 characters; treat 72 as a hard limit. - -**Why:** GitHub truncates subjects longer than 72 characters. The 50-character guideline ensures readability in all contexts. - -**How to achieve this:** -- Focus on the essential action and target -- Remove unnecessary words ("this commit," "I have") -- Use abbreviations where standard (HTTP, API, DB) - -**Too long:** -``` -Update the user authentication module to use bcrypt instead of the old MD5 hashing algorithm -``` - -**Better:** -``` -Replace MD5 with bcrypt for password hashing -``` - ---- - -### 3. Capitalize the subject line - -Start the subject with a capital letter. - -**Why:** Basic grammar convention that maintains consistency across the project. - -**Wrong:** -``` -fix authentication bug in login handler -``` - -**Right:** -``` -Fix authentication bug in login handler -``` - ---- - -### 4. Do not end the subject line with a period - -Omit trailing punctuation from the subject. - -**Why:** Subject lines are titles, not sentences. The period wastes precious character space. - -**Wrong:** -``` -Add rate limiting to API endpoints. -``` - -**Right:** -``` -Add rate limiting to API endpoints -``` - ---- - -### 5. Use the imperative mood in the subject line - -Write subjects as commands: "Fix bug" not "Fixed bug" or "Fixes bug." - -**Why:** Git itself uses imperative mood (e.g., "Merge branch" not "Merged branch"). This convention reads as "If applied, this commit will [your subject line]." - -**Test:** Your subject should complete the sentence: "If applied, this commit will..." - -**Wrong:** -``` -Fixed the authentication timeout issue -Added support for OAuth2 -Updating dependencies to latest versions -``` - -**Right:** -``` -Fix authentication timeout issue -Add support for OAuth2 -Update dependencies to latest versions -``` - -**Common imperative verbs:** -- Add, Remove, Fix, Update, Refactor -- Implement, Extract, Merge, Revert -- Optimize, Document, Deprecate - ---- - -### 6. Wrap the body at 72 characters - -Hard-wrap body text at 72 characters. - -**Why:** Git never wraps text automatically. The 72-character limit ensures proper display in terminals and UI tools with padding. - -**How:** Configure your editor to wrap at 72 characters, or manually add line breaks. - ---- - -### 7. Use the body to explain what and why, not how - -The body should explain: -- **What** changed (if not obvious from the diff) -- **Why** the change was necessary -- **What** problem it solves or requirement it fulfills - -The body should NOT explain: -- **How** you implemented the change (the diff shows this) - -**Example:** - -``` -Refactor user session management to use Redis - -Previous implementation stored sessions in PostgreSQL, causing -performance degradation when session count exceeded 10k. Under -load testing, login response times increased from 200ms to 3s. - -Redis provides O(1) lookups and automatic expiration, resolving -the performance issue. Load tests now show consistent 150ms -response times regardless of session count. - -This change maintains backward compatibility by preserving the -session schema. The migration script handles existing sessions. -``` - -**What makes this good:** -- Explains the problem (performance degradation) -- Provides context (specific metrics) -- Justifies the solution (Redis benefits) -- Notes important details (backward compatibility) - ---- - -## Workflow Integration - -### For jujutsu users preparing to push to git - -When you've completed work in jujutsu and are ready to create permanent git commits: - -1. **Review your jujutsu changes:** ```bash - jj log - jj diff - ``` - -2. **Craft your commit message:** - - Write subject line (imperative, <50 chars) - - Add body if needed (what and why) - - Use `jj describe` to set the message: - ```bash - jj describe -m "Add user profile caching - - Reduces database queries by 60% during profile page - loads. Cache TTL set to 10 minutes based on analytics - showing most users refresh profiles within this window." + git add path/one path/two + git commit -F /path/to/msg.txt -- path/one path/two ``` -3. **Verify before pushing:** - ```bash - jj log # Check your commit message - jj bookmark set main # Point bookmark at commit - jj git push - ``` - ---- - -## Quick Reference - -**Subject line checklist:** -- [ ] Imperative mood ("Add feature" not "Added feature") -- [ ] Capitalized first letter -- [ ] Under 50 characters (72 hard limit) -- [ ] No period at the end -- [ ] Completes: "If applied, this commit will..." - -**Body checklist (when needed):** -- [ ] Blank line after subject -- [ ] Wrapped at 72 characters -- [ ] Explains what and why, not how -- [ ] Provides context for the change -- [ ] Notes any important side effects or considerations - ---- - -## Common Patterns - -### Bug fixes -``` -Fix race condition in payment processing - -Under high load, concurrent requests could process the same -payment twice. Added transaction-level locking to ensure -payment atomicity. -``` - -### New features -``` -Add export functionality to reports dashboard - -Users can now export reports as CSV or PDF. Export happens -asynchronously with email notification upon completion to -handle large datasets without blocking the UI. -``` - -### Refactoring -``` -Extract authentication logic into middleware - -Authentication was duplicated across 15 route handlers. -Consolidating into middleware reduces code by 200 lines -and ensures consistent auth checks across all routes. -``` - -### Performance improvements -``` -Optimize database queries in user listing - -Replaced N+1 query pattern with eager loading. Page load -time reduced from 2.5s to 400ms for listings with 100+ users. -``` - -### Breaking changes -``` -Remove deprecated OAuth1 support - -OAuth1 has been deprecated since v2.0 (2023-06). All clients -have migrated to OAuth2. Removing legacy code reduces -maintenance burden and attack surface. - -BREAKING CHANGE: OAuth1 endpoints no longer available. -``` - ---- - -## Additional Guidelines - -### Atomic commits - -Each commit should represent one logical change. If you're tempted to use "and" in your subject, you probably need multiple commits. - -**Too broad:** -``` -Add user profiles and fix login bug and update dependencies -``` - -**Better as separate commits:** -``` -Add user profile pages -Fix login redirect after password reset -Update authentication dependencies -``` - -### References - -Link to issue trackers, pull requests, or documentation when relevant: - -``` -Fix memory leak in background job processor - -Jobs weren't properly releasing database connections after -completion, causing connection pool exhaustion after ~6 hours -of operation. - -Fixes #1234 -See: docs/architecture/background-jobs.md -``` - -### Conventional Commits (optional extension) - -Some projects use conventional commits format: - -``` -(): - - -``` - -Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` - -``` -feat(auth): add two-factor authentication support - -Users can now enable 2FA using TOTP apps like Google -Authenticator. Recovery codes are generated and encrypted -at rest. -``` - -Only use this format if the project requires it. - ---- - -## When to Use This Skill - -Use this skill when: -- Writing new commit messages -- Reviewing/improving existing commit messages -- Preparing jujutsu changes for git push -- Squashing commits and need consolidated message -- Creating pull request with clean commit history -- Getting feedback on commit message quality - -The goal is professional, maintainable commit history that helps future developers (including yourself) understand the evolution of the codebase. + Never `git add -A` or `git add .`. A `git commit` without paths takes + everything already staged, including changes that are not part of this + commit. jj has no staging area, so this step is git only. +7. Push only when Alyssa asks, and then with `jj push`. + +## Subject line + +Format: `(): `. The `()` part is optional. + +```text +ci(nix): secure the runner dirs compaudit flags +devcontainer(nix): apply home-manager on create +``` + +`check-commit-msg` enforces these rules: + +- `area` is lowercase: letters, digits and `-`. +- One space after the colon. The summary starts with a lowercase letter, + unless its first word is a name that is always written in capitals + (`HUID`, `README`). +- 72 characters at most. Aim for 50 to 60. +- No period at the end. +- The first word of the summary is not past tense (`fixed`, `added`), third + person (`fixes`, `adds`) or "this". + +These rules are yours to apply: + +- The summary names the outcome: what is now possible or fixed. It does not + list files. `ci(nix): update nix.yml` says nothing a diff does not; + `ci(nix): stop compinit aborting on the runner` says what changed for the + reader. A noun phrase is correct when the commit adds one thing + (`global justfile with the HUID task recipes`). +- When the commit implements one unit of a compound-engineering plan and the + unit ID is already known, end the subject with it in parentheses: + `hm(zsh): add the history options (U3)`. Do not look for a plan to find + one. Leave it out when the commit spans units. +- The area is the part of the repository or the tool the change belongs to: + `hm` (home-manager), `ci` (`.github/`), `mise`, `nix`, `devcontainer`, + `just`, `agent(skills)`, `docs`. Reuse an existing area. Make a new one + only for something new at the top level of the repository. +- The scope narrows the area: `ci(nix)` is the Nix workflow. Leave it out + when the area is narrow enough. + +## Body + +A body is required. Wrap it at 72 characters. Indented lines and lines with a +URL can be longer. Write these paragraphs in this order. Leave one out only +when it has nothing to say. + +1. **Why.** The problem or the need, with the evidence: the error text, the + CI run, the task or issue. Say what was wrong before this commit. +2. **What and how.** What the change does to behavior. When there was + another option, name it and say why it was not used. Do not walk through + the diff line by line. +3. **Verification.** Name the environment. Say what you ran and what you + saw, then which checks pass. Say what you did not run, and why. Never + claim a check you did not run. +4. **What is left.** Follow-up work, known limits, and the task or issue that + tracks them. + +## Facts + +Where `sem` is installed (the entity-level-git skill says where), it lists +the entities a change touches by name: functions, classes, Nix bindings such +as `home.packages`. Use it for the facts in the body. + +- `sem diff --no-cosmetics` in a colocated jj repo shows `@` against `@-`. + For another commit, run `sem diff ^ ` with its git commit + ID (`jj log -r --no-graph -T commit_id`). +- The "what and how" paragraph covers the entities that list shows. If the + commit also has formatting-only edits, say so in one sentence. +- Name each entity as sem names it, in backticks. ste100 treats a technical + name as one word with one spelling, so do not paraphrase it. +- For the verification paragraph, `sem impact --tests` lists the + tests that depend on a changed entity. Run them and name them. A + dependent that no test covers goes in "what is left". +- sem's language coverage is uneven. When it returns `chunk` entities or an + empty dependency graph (Dart, for one), use `jj diff` and do not cite + `sem impact`. The entity-level-git skill lists the known gaps. +- Without sem (most sandboxes and devcontainers), use `jj diff`, and say in + your reply that sem was not available. Do not install it to write a + commit message. + +## Language + +The body is a "commit body" surface in the ste100 skill: use its +STE-flavored mode. Do not apply ste100 to the subject line. Read +`~/.agents/skills/ste100/SKILL.md` before you write the body. If it is not +installed, say so in your reply. Do not reconstruct its rules from memory. + +These rules are about the commit, not the language, so they apply either +way: + +- Write as the author. The commit is Alyssa's, also when an AI trailer is on + it. Do not address her or the reviewer ("as you choose", "you can squash + this"). Put notes for the reviewer in the PR body. +- Refer to another commit by its subject line, in quotes. A subject stays + correct after a rebase, a squash and a push to GitHub, and + `git log --grep` finds it. Do not use a jj change ID: GitHub cannot + resolve it. Use a git hash only for a commit on the default branch, which + is not rewritten. For merged work, use the PR number (`#N`), which GitHub + links. +- Use backticks for commands, file names and identifiers. Use a hyphen list + for parallel items. Do not use headings, bold or emoji. + +## Trailers + +Trailers go in the last paragraph, one `Key: value` per line, after a blank +line. + +- Keep the attribution trailers your harness tells you to add (for example + `Co-Authored-By:`). Do not invent others. +- Put attribution only in trailers. Never put a "Generated with ..." line in + the body. + +## Example + +This example shows the shape. ste100 decides the wording. + +```text +ci(nix): secure the runner dirs compaudit flags + +The home job in nix.yml failed in run 36364995815. In the interactive +zsh, compinit found completion dirs that it thinks are not secure: dirs +that a user other than root or the owner owns or can write to. No +terminal was available to answer its "continue?" prompt, so compinit +stopped and the compinit check failed. The devcontainer image has no +such dirs, so the local run passed. + +A new step runs before the check. It lists the dirs that compaudit +flags, and makes root their owner (chown root:root, chmod go-w). The +step prints the list, so the log shows which runner dirs they were. + +Reproduced in the devcontainer base image: another user got +/usr/local/share/zsh, compinit stopped, and the check failed as in CI. +After the step, all ten checks pass. + +Co-Authored-By: +``` + +These subjects fail `check-commit-msg`: + +```text +Updated agent skills. +``` + +```text +docs(agents): Instructions for Claude +``` + +## With compound-engineering's ce-commit + +The compound-engineering plugin is enabled here, and its `ce-commit` skill +also triggers on "commit this". Its step 3 follows "project commit +conventions already in context". In a repository that uses this skill, that +convention is this skill. When ce-commit or another compound-engineering +skill makes the commit: + +- The subject format, the required body and the ste100 wording apply. + ce-commit says to add a body only when the reason is not obvious. Here the + body is always required, and the hook and `jj push` refuse a commit + without one. +- Run `check-commit-msg` on the message file that ce-commit writes, before + `git commit -F`. +- In a jj repository, use the jujutsu skill's commands for ce-commit's git + steps. Do not run `git checkout -b` or stage files: jj has no staging + area, and a bookmark is made only when you push. +- ce-commit's done and stop rules, and its rule to name paths on + `git commit`, are the same as this skill's. + +## Rewording existing commits + +To change the message of another commit, run +`jj describe -r --stdin < msg.txt`. Run `check-commit-msg` on the +file first. Do not reword commits on a remote branch that other people use, +unless you are asked to. diff --git a/tools/agents/skills/jujutsu/SKILL.md b/tools/agents/skills/jujutsu/SKILL.md index cc1188e0..d051154d 100644 --- a/tools/agents/skills/jujutsu/SKILL.md +++ b/tools/agents/skills/jujutsu/SKILL.md @@ -105,7 +105,7 @@ The implication is in the next section. ## Agent Environment Rules -### Always use `-m` for messages +### Always pass the message: `-m` or `--stdin` Editor prompts hang in non-interactive environments. Every command that accepts `-m` must get one inline: @@ -117,6 +117,14 @@ jj commit -m "message" # NOT: jj commit jj squash -m "message" # NOT: jj squash (opens editor for combined description) ``` +A finished commit's message has a body, which is awkward in `-m`. Write it to a file +outside the working copy, check it, and pass it on stdin, as the **commit-craft** skill +describes: + +``` +jj describe --stdin < /path/to/msg.txt +``` + ### Avoid interactive commands These open TUIs and hang. Use alternatives: @@ -126,7 +134,7 @@ These open TUIs and hang. Use alternatives: | `jj split` (no args) | `jj split ...` with explicit fileset, or `jj squash`/`jj restore` patterns | | `jj squash -i` | `jj squash ...` with explicit fileset | | `jj resolve` | Edit conflict markers directly in the affected files, then `jj st` to verify | -| `jj describe` (no `-m`) | `jj describe -m "..."` | +| `jj describe` (no `-m`) | `jj describe -m "..."` or `jj describe --stdin < file` | ### Verify after mutation @@ -171,7 +179,9 @@ jj new -m "What I'm about to do" ``` This pattern surfaces intent before code exists. If Alyssa hasn't told Claude what to -commit message-wise, ask once and then proceed. +commit message-wise, ask once and then proceed. The intent message is only a subject +line, in commit-craft's subject format. Replace it with the full message (commit-craft) +when the change is done, before it is pushed. ### Atomic commits @@ -179,10 +189,9 @@ One logical change per commit. If a commit description starts to need "and" to c everything, that's the signal to split. Use the squash/restore patterns to peel out unrelated changes rather than mashing them in. -Commit message format (Alyssa's convention, matches the team's): -- Imperative verb, sentence case, no trailing period -- `feat:` / `fix:` / `refactor:` / `docs:` / `chore:` prefixes when conventional commits - are in use in the repo +What the message says (subject format, body, trailers) is the **commit-craft** skill's, +not this one's. Load it before writing any message. This skill covers only the commands +that set it. ### Viewing history @@ -561,12 +570,18 @@ jj git push --change @- # same, for parent (common when @ `jj git push` uses force-with-lease semantics by default. Safe in normal use. +In a repo that uses the commit-craft format, push with `jj push` instead. It is an +alias from the dotfiles' jj config: it runs `check-commits --jj` on the stack that is +not on trunk yet, and runs `jj git push` with the same arguments only when every +message passes. jj runs no git hooks, so this is the check before a push. + ### Before pushing 1. `jj log -r 'trunk()..@'` — confirm what's in the stack 2. `jj bookmark list` — confirm the bookmark points where intended 3. `jj diff --git -r ` — review final content -4. `jj git push -b ` — push only after Alyssa explicitly says so +4. `jj push -b ` (or `jj git push -b ` in a repo with its own + commit format) — push only after Alyssa explicitly says so **Never push unprompted.** Pushing is an Alyssa-only decision. diff --git a/tools/agents/skills/jujutsu/references/version-notes.md b/tools/agents/skills/jujutsu/references/version-notes.md index 37cd9fef..76cd582f 100644 --- a/tools/agents/skills/jujutsu/references/version-notes.md +++ b/tools/agents/skills/jujutsu/references/version-notes.md @@ -192,7 +192,8 @@ never the right move. ### Message via stdin `jj describe --stdin` reads the description from stdin. Available on the message-taking -commands; not usually relevant for agent use, which passes `-m`. +commands. Agents use it for finished messages, which have a body: commit-craft writes +the message to a file, checks it, then runs `jj describe --stdin < file`. --- diff --git a/tools/agents/skills/ste100/SKILL.md b/tools/agents/skills/ste100/SKILL.md index 3b809f5d..8d6df2ed 100644 --- a/tools/agents/skills/ste100/SKILL.md +++ b/tools/agents/skills/ste100/SKILL.md @@ -92,10 +92,11 @@ This skill runs after the skills that own structure, never instead of them. - **communication-bridge** owns audience and format. Run it first, then run ste100 on its output only when the target surface is in the strict or STE-flavored rows above. Never on `--format=pushback` output. -- **commit-craft** owns the seven rules (subject line, wrap, what-and-why). +- **commit-craft** owns the message format (subject line, body order, wrap). ste100 is a sentence-level pass on the body afterward, in STE-flavored - mode. It does not touch the subject line. The 50-character limit already - forces one reading. + mode. It does not touch the subject line. Its length limit already forces + one reading. Run `check-commit-msg` again after the pass, since rewording + can change the wrap. - **brag-doc** and **failure-doc** produce records in Alyssa's voice. Do not run ste100 on their output. If she asks, say why and offer a strict pass on any quoted command or symptom inside the entry instead. diff --git a/tools/commit/check-commit-msg b/tools/commit/check-commit-msg new file mode 100755 index 00000000..d74b2597 --- /dev/null +++ b/tools/commit/check-commit-msg @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# check-commit-msg: check one commit message against the format in the +# commit-craft skill. Reads FILE, or stdin when there is no argument. Prints +# one line per problem and exits 1; prints nothing and exits 0 when the +# message passes. It checks the shape only. Whether the body says the right +# things is the skill's job. +# +# check-commit-msg msg.txt +# jj log -r @ --no-graph -T description | check-commit-msg +# git log -1 --format=%B | check-commit-msg +# +# check-commits runs it over a range of commits; tools/git/hooks/commit-msg +# runs it on every `git commit` in this checkout. +set -euo pipefail + +max=72 +# A first word that reports the past instead of saying what the commit does. +past='^(added|adds|fixed|fixes|updated|updates|removed|removes|changed|changes|moved|moves|renamed|refactored|implemented|created|deleted|this)$' +subject_re='^([a-z0-9][a-z0-9-]*)(\([^()]+\))?: (.+)$' +trailer_re='^[A-Za-z][A-Za-z0-9-]*: ' + +mapfile -t lines < <(cat -- "${1:-/dev/stdin}") +problems=() +problem() { problems+=("line $1: $2"); } + +# Drop trailing blank lines, which jj and git both add. +n=${#lines[@]} +while [ "$n" -gt 0 ] && [ -z "${lines[n - 1]//[[:space:]]/}" ]; do n=$((n - 1)); done + +if [ "$n" -eq 0 ]; then + echo "line 1: the message is empty" + exit 1 +fi + +subject="${lines[0]}" +if [[ $subject =~ $subject_re ]]; then + summary="${BASH_REMATCH[3]}" + first="${summary%% *}" + # "Add ..." is a capitalised word; "HUID ..." or "README ..." is a name. + [[ $first =~ ^[A-Z][a-z] ]] && problem 1 "the summary starts lowercase (\"${first,}\", not \"$first\")" + [[ ${first,,} =~ $past ]] && problem 1 "\"$first\": say what the commit does, not what happened" +else + problem 1 "the subject is (): , with a lowercase area and the scope optional" +fi +[ "${#subject}" -gt "$max" ] && problem 1 "the subject is ${#subject} characters, more than $max" +[[ $subject == *. ]] && problem 1 "the subject ends with a period" + +# The trailers are the last paragraph, when every line in it is Key: value. +end=$n +start=$n +while [ "$start" -gt 1 ] && [ -n "${lines[start - 1]}" ]; do start=$((start - 1)); done +if [ "$start" -gt 1 ]; then + all=1 + for ((i = start; i < n; i++)); do [[ ${lines[i]} =~ $trailer_re ]] || all=0; done + [ "$all" -eq 1 ] && end=$((start - 1)) +fi + +if [ "$n" -gt 1 ] && [ -n "${lines[1]}" ]; then + problem 2 "a blank line separates the subject from the body" +fi + +body=0 +for ((i = 1; i < end; i++)); do + line="${lines[i]}" + [ -n "$line" ] && body=1 + # Indented lines (pasted output, code) and URLs keep their length. + [[ $line == [[:space:]]* || $line == *://* ]] && continue + [ "${#line}" -gt "$max" ] && problem $((i + 1)) "${#line} characters, more than $max: wrap the body at $max" +done +[ "$body" -eq 0 ] && problem 2 "there is no body: say why, and how it was verified" + +[ "${#problems[@]}" -eq 0 ] && exit 0 +printf '%s\n' "${problems[@]}" +exit 1 diff --git a/tools/commit/check-commits b/tools/commit/check-commits new file mode 100755 index 00000000..fa4b77a2 --- /dev/null +++ b/tools/commit/check-commits @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# check-commits: run check-commit-msg on every commit in a range, and print +# the problems under each commit that fails. Exits 1 if any commit fails. +# Merge commits are skipped: their subject is the tool's, not the author's. +# +# check-commits --jj [REVSET] jj commits. The default is the stack under +# @ that is not on trunk yet, without an empty +# undescribed @. The `jj push` alias uses it. +# check-commits RANGE git commits, e.g. origin/main..HEAD. CI +# uses this form. +set -euo pipefail + +# check-commit-msg sits next to this script in the checkout. home-manager +# copies each file into the store on its own, so fall back to PATH. +check="$(dirname "$(readlink -f "$0")")/check-commit-msg" +[ -x "$check" ] || check="check-commit-msg" + +stack='(trunk()..@) ~ merges() ~ (empty() & description(exact:""))' +failed=0 + +report() { # id subject message + local out + if ! out="$("$check" <<<"$3")"; then + printf '%s %s\n' "$1" "$2" + printf '%s\n' "$out" | sed 's/^/ /' + failed=1 + fi +} + +if [ "${1:-}" = --jj ]; then + # Assigned first, so a bad revset or a missing repo stops the script. + ids="$(jj log --no-graph --reversed -r "${2:-$stack}" -T 'commit_id.short() ++ "\n"')" + for id in $ids; do + report "$id" "$(jj log --no-graph -r "$id" -T 'description.first_line()')" \ + "$(jj log --no-graph -r "$id" -T description)" + done +elif [ $# -eq 1 ]; then + ids="$(git rev-list --no-merges --reverse "$1")" + for id in $ids; do + report "$(git rev-parse --short "$id")" "$(git log -1 --format=%s "$id")" \ + "$(git log -1 --format=%B "$id")" + done +else + echo "usage: check-commits --jj [REVSET] | check-commits " >&2 + exit 2 +fi + +exit "$failed" diff --git a/tools/git/config b/tools/git/config index d03b9d1a..5d49bc51 100644 --- a/tools/git/config +++ b/tools/git/config @@ -37,3 +37,13 @@ l = log --color --graph --format='%C(magenta)%h%Creset %s %C(cyan)<%an>%Creset' -n 20 --abbrev-commit # Short SHA, refs, subject, relative commit time, author lg = log --color --graph --format='%C(magenta)%h%Creset -%C(green)%d%Creset %s %C(yellow)(%cr) %C(cyan)<%an>%Creset' --abbrev-commit -- + +# The commit-msg hook (tools/git/hooks), in the dotfiles checkouts only. For +# the account without Nix, ~/.gitconfig includes this file from the checkout, +# so the relative path finds dotfiles-repo.gitconfig next to it. home-manager +# copies this file into the store alone, where git skips the missing file; +# git.nix adds the same conditional include for those accounts. +[includeIf "gitdir:~/dotfiles/"] + path = dotfiles-repo.gitconfig +[includeIf "gitdir:/workspaces/dotfiles/"] + path = dotfiles-repo.gitconfig diff --git a/tools/git/dotfiles-repo.gitconfig b/tools/git/dotfiles-repo.gitconfig new file mode 100644 index 00000000..b1bbd663 --- /dev/null +++ b/tools/git/dotfiles-repo.gitconfig @@ -0,0 +1,8 @@ +# Git settings for the dotfiles checkouts only. tools/git/config includes this +# file for them on the account without Nix; home-manager/modules/git.nix does +# for the others. + +# A relative hooksPath is relative to the top of the working tree. It replaces +# .git/hooks for the whole repo, which is why it is not set for every repo. +[core] + hooksPath = tools/git/hooks diff --git a/tools/git/hooks/commit-msg b/tools/git/hooks/commit-msg new file mode 100755 index 00000000..95f9633d --- /dev/null +++ b/tools/git/hooks/commit-msg @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# commit-msg: refuse a commit whose message fails check-commit-msg (the format +# in the commit-craft skill). Git runs it in this checkout only: +# dotfiles-repo.gitconfig sets core.hooksPath to this directory, and +# tools/git/config includes that file for the dotfiles checkouts. jj runs no +# git hooks; for jj, `jj push` does the same check. +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +# Git has not removed the comment lines yet when an editor wrote the file. +if ! out="$(git stripspace --strip-comments <"$1" | "$root/tools/commit/check-commit-msg")"; then + printf '%s\n' "$out" >&2 + echo "commit-msg: the message does not follow the commit-craft format" >&2 + exit 1 +fi diff --git a/tools/jujutsu/config b/tools/jujutsu/config new file mode 100644 index 00000000..e8e912c4 --- /dev/null +++ b/tools/jujutsu/config @@ -0,0 +1,35 @@ +# jj settings for every repo, shared by both kinds of account. home-manager +# links this file to ~/.config/jj/conf.d/dotfiles.toml; tools/mise/bootstrap.sh +# links the same path for the account without Nix. conf.d is merged over +# config.toml, so never put a [user] block here: it would beat +# `jj config set --user` (and the mise identity task). + +# The commit message format (the commit-craft skill) for every repo. A new +# description opens with the format as JJ: lines, which jj drops on save. +[template-aliases] +default_commit_description = ''' +"\n\n" ++ +"JJ: Subject: (): . Lowercase summary, no period,\n" ++ +"JJ: 72 characters at most. Then a blank line, then the body, wrapped at 72.\n" ++ +"JJ: Body, in this order:\n" ++ +"JJ: 1. Why: the problem or need, with the evidence.\n" ++ +"JJ: 2. What and how: what changes, and why this way.\n" ++ +"JJ: 3. Verification: what you ran and saw, and what you did not run.\n" ++ +"JJ: 4. What is left: follow-up work and known limits.\n" +''' + +# `jj push`: check the messages of the stack that is not on trunk yet, then +# `jj git push` with the same arguments. jj runs no git hooks, so this is +# where the format is enforced. check-commits is on PATH through home-manager, +# or mise's [env] on the account without Nix. `jj git push` still works when +# a repo has its own format. +[aliases] +push = [ + "util", + "exec", + "--", + "sh", + "-c", + "check-commits --jj && exec jj git push \"$@\"", + "jj-push", +] diff --git a/tools/mise/bootstrap.sh b/tools/mise/bootstrap.sh index a5bb7286..2b263b42 100755 --- a/tools/mise/bootstrap.sh +++ b/tools/mise/bootstrap.sh @@ -83,6 +83,9 @@ fi # 3. Config shared with the Nix accounts, used in place from the checkout. link "$HOME/.config/mise" "$DOTFILES/tools/mise" link "$HOME/.config/helix" "$DOTFILES/tools/helix" +# The commit message draft template and the `jj push` alias. conf.d, not +# config.toml, which `jj config set --user` (the identity task) writes. +link "$HOME/.config/jj/conf.d/dotfiles.toml" "$DOTFILES/tools/jujutsu/config" gitconfig="$DOTFILES/tools/git/config" if git config --global --get-all include.path 2>/dev/null | grep -qxF "$gitconfig"; then diff --git a/tools/mise/config.toml b/tools/mise/config.toml index 293f46ac..ecb1f3c6 100644 --- a/tools/mise/config.toml +++ b/tools/mise/config.toml @@ -22,6 +22,12 @@ # Keep this list short. A tool belongs here only if this account really uses # it. Anything the containers also need goes in lib/core-packages.nix. +# The commit message checks, on PATH for jj's `jj push` alias (jj config +# from tools/jujutsu, which bootstrap.sh links). The checkout is where +# bootstrap.sh puts it by default. +[env] +_.path = ["{{env.HOME}}/dotfiles/tools/commit"] + [tools] claude = "latest" # aqua:anthropics/claude-code helix = "latest" # aqua:helix-editor/helix