jj: split pitfalls into a reference and expand it
The commit-over-squash, `jj edit`, and megamerge rules deleted here
already live in `ai/_AGENTS.md`, which loads on every turn; the
conflict-provenance pitfall moved to `resolve-conflicts`.
Assisted-by: Claude Opus 5 via pi
diff --git a/ai/skills/jj/SKILL.md b/ai/skills/jj/SKILL.md
index 88af9d4c..69efb830 100644
--- a/ai/skills/jj/SKILL.md
+++ b/ai/skills/jj/SKILL.md
@@ -3,18 +3,22 @@ name: jj
description: Use when running any jj (Jujutsu) version control command — committing, describing, bookmarks, rebase, squash, restore, push, or inspecting log/diff/status — and when looking up jj syntax, flags, or behavior. Invoke before free-handing jj commands from memory.
---
-# Jujutsu (jj) Version Control
+# Jujutsu (jj) version control
Never treat this skill as authoritative. Run `jj <command> --help` to verify
behavior, flags, and current options.
-## Core Workflow
+Read `pitfalls.md` before reaching for `squash`, `show`, `file show`,
+filesets, `-R`, or a revset with parentheses — it catalogues flags that fail
+silently, hang without a TTY, or make a no-op look like it worked.
+
+*This is a self-improving skill — see the `self-improving-skills` skill.*
+
+## Core workflow
### commit vs. describe vs. new
The working copy `@` is always a real commit; edits stream into it live.
-These three verbs differ only in whether they set a message and whether
-they move `@`:
| Command | Sets message? | Moves `@`? | Use for |
|---|---|---|---|
@@ -22,45 +26,30 @@ they move `@`:
| `jj new <rev>` | no | yes, to a new empty commit | start fresh work on top of `<rev>` |
| `jj commit -m` | yes, on `@` | yes, to a new empty commit | finish `@` and move on — `describe @` + `new` in one step |
-**Prefer `jj commit` over `jj squash`.** Create new commits by default —
-even for refactors, cleanups, or small follow-ups. Only squash when the
-prior commit specifically needs fixing up (e.g., correcting a bug it
-introduced, fixing a typo in code it added).
-
### Splitting a commit
`jj split -r <rev> -m 'message' <paths>` extracts the named paths
-non-interactively; `-m` is what keeps it out of `$EDITOR` when `<rev>`
-already has a description.
+non-interactively; `-m` keeps it out of `$EDITOR` when `<rev>` already has a
+description.
By default the *selected* paths keep the original change ID and the
-remainder becomes a new child commit. To keep the original change's ID and
-description where they are and make the extracted paths the new commit,
-pass the same revision to both `-r` and `-A`:
+remainder becomes a new child commit. To leave the original change ID and
+description in place and make the extracted paths the new commit, pass the
+same revision to both `-r` and `-A`:
```bash
jj split -r abcd -A abcd -m 'scope: message' path/to/file
```
-Splitting an ancestor rebases every descendant, `@` included — content is
-preserved but commit IDs change, so it isn't an option when a commit ID
-has to stay put.
-
-## Landing a Follow-up Change
+Splitting an ancestor rebases every descendant, `@` included — content
+survives but commit IDs change, so it's out when a commit ID has to stay put.
-**Never rewrite an existing commit with `jj edit`.** `jj edit` moves `@`
-onto that commit and streams later edits straight into it, which is easy
-to forget and rewrites history in place. Make the fix in a new commit,
-then route it to where it belongs:
+## Absorb
-- `jj absorb` splits the new change hunk by hunk and moves each to the
- closest mutable ancestor that last touched those lines. It abandons the
- source commit when every hunk lands and the source has no description;
- hunks it can't place unambiguously stay put, so re-check `jj st` after.
-- `jj squash --into <rev> <fileset>` folds specific paths into one target
- commit (see the squash pitfalls below for the `-u`/`-m` requirement).
-- `jj rebase` inserts the new commit as its own commit elsewhere.
-- Leaving it alone is fine when the change stands on its own.
+`jj absorb` moves each hunk of the working-copy change to the closest mutable
+ancestor that last touched those lines. Hunks it can't place unambiguously
+stay put, so re-check `jj st` after; the source commit is abandoned only when
+every hunk lands and it has no description.
```bash
jj absorb # distribute every hunk to its ancestor
@@ -68,29 +57,55 @@ jj absorb <fileset> # only these paths
jj absorb --into <rev> # limit destinations to <rev> and ancestors
```
+When the hunks all belong in one specific commit, use `jj squash --into <rev>
+<fileset>` instead — `pitfalls.md` covers its `--from` and `-u`/`-m`
+requirements.
+
## Megamerges
-A megamerge is a single working commit that merges several parallel
-branches at once (often bookmarked `mm`), giving one working copy that
-integrates all of them. Moving `@` off it — `jj new` or `jj edit` onto
-another commit — discards that combined view, and rebuilding it means
-re-running the merge by hand. Stay on the megamerge unless explicitly
-told to move; land new work with `jj absorb` or `jj squash --into` so `@`
-stays put. The `jj-workspaces` skill uses `mm` when placing workspaces in
-the DAG.
+A megamerge is bookmarked `mm` by convention, and the `jj-workspaces` skill
+uses that name when placing workspaces in the DAG. Land new work with
+`jj absorb` or `jj squash --into` so `@` stays on the merge.
+
+### Adding a new branch to the merge
+
+Work written on top of `mm` can't be pushed — its diff against trunk
+contains every parent. Move it beneath the merge instead, so it hangs off
+trunk and `mm` picks it up as one more parent:
+
+```bash
+jj rebase -r <rev> -A trunk -B mm # one rewrite, no cleanup
+```
+
+Do it in one rewrite. `-r` always re-parents the rebased commit's descendants
+onto its former parents, so rewriting `mm` separately to add a destination
+hands `@` all of `mm`'s former parents, leaving a second near-identical
+megamerge beside the real one. `jj rebase -r @ -o mm` repairs it.
+
+`-o`/`--onto` is repeatable (`-d` is the older alias), so `-o p1 -o p2 ...`
+sets several parents. The `all:` revset modifier is not valid there —
+`-o 'all:mm- | x'` fails with "`:` is not an infix operator".
+
+Don't read the result off the ASCII graph. Diff the merge as an invariant
+across the rewrite, and list its parents explicitly:
+
+```bash
+jj diff --from trunk --to mm --summary > /tmp/mm-before.txt
+# ...rewrite...
+jj diff --from trunk --to mm --summary | diff /tmp/mm-before.txt -
+
+jj log --no-graph -r mm -T 'parents.map(|p| p.change_id().short()).join("\n")'
+```
-## Key Differences from Git
+## Key differences from git
-No staging area — working copy edits map straight to `@`, and tracking is
-automatic for *every* change, removals included. Deleting a file with `rm`
-(or moving/renaming it) is already snapshotted into `@` on the next jj
-command — there is no `git rm`, no `git add -A`, and no "untrack" step.
-Just delete the file and commit.
+No staging area, and tracking is automatic for *every* change, removals
+included. `rm` (or a move/rename) is snapshotted into `@` on the next jj
+command — no `git rm`, no `git add -A`. Just delete the file and commit.
-## Bookmarks and Pushing
+## Bookmarks and pushing
Bookmarks are mutable named pointers to commits (like git branches).
-Use `jj new <bookmark>` to start working on top of one.
```bash
jj bookmark list # show all bookmarks
@@ -98,11 +113,14 @@ jj bookmark set feature -r <rev> # move bookmark to revision
jj bookmark track main --remote=origin # start tracking a remote bookmark
```
+A bookmark reported "ahead by at least N, behind by at least N" against its
+remote is usually just a rebase, not lost work — compare the tips with
+`jj diff --from <remote> --to <local> --stat` before assuming otherwise.
+
### Advancing bookmarks after commit
-`jj bookmark advance` moves the nearest ancestor bookmark(s) forward to a
-target revision — the preferred way to move `main` after committing on top
-of it.
+`jj bookmark advance` moves the nearest ancestor bookmark(s) forward — the
+preferred way to move `main` after committing on top of it.
```bash
jj bookmark advance # advance closest bookmarks to @ (default)
@@ -110,9 +128,6 @@ jj bookmark advance --to @- # advance closest bookmarks to @-
jj bookmark advance main --to @- # advance specific bookmark
```
-After `jj commit`, the working copy `@` is empty so `jj bookmark advance`
-with no args works — the bookmark is an ancestor of `@`.
-
### Pushing
```bash
@@ -122,14 +137,19 @@ jj git push --named <name>=<rev> # create named bookmark and push
```
`-b` refuses to push untracked bookmarks ("Refusing to create new remote
-bookmark"). Use `--named` to create, push, and auto-track in one step.
-`-c` generates a bookmark name from the change ID (`push-<short-change-id>`).
+bookmark"). `--named` creates, pushes, and auto-tracks in one step — it is
+the whole step, not a follow-up to `jj bookmark create`, and errors
+"Bookmark already exists" if the name is already local. `-c` derives the name
+from the change ID (`push-<short-change-id>`).
-`--named` is the whole step, not a follow-up to `jj bookmark create` — it
-errors "Bookmark already exists" if the name exists locally, so don't run
-`create` first.
+**Push a stack bottom-first, or pass `-b <bookmark>` per branch.** A bare
+`jj git push` only considers `remote_bookmarks(remote=origin)..@`, so pushing
+the *tip* first puts a remote bookmark below the others and drops every
+ancestor bookmark out of that revset. The next bare push then reports "No
+bookmarks/tags found in the default push revset: Nothing changed" and
+silently leaves the lower branch unpushed — it reads like success.
-## Restoring Files
+## Restoring files
`jj restore` uses `--from` and `--into`/`--to`, not `-r`:
@@ -139,17 +159,14 @@ jj restore SOME_FILE # restore file from parent
jj restore --from kn --into kn FILE # restore file in a specific revision
```
-**`jj restore FILE` is not "undo my last edit."** It resets the whole
-file to the parent revision, wiping *every* uncommitted change in it —
-not just the one you meant to back out. Backing out a temporary sed
-this way also destroyed unrelated in-progress work in the same file.
-Before restoring, run `jj diff FILE` to see everything that would be
-lost; to back out one surgical experiment, revert it with the same
-tool that made it (another sed/edit) instead of `jj restore`.
+**`jj restore FILE` is not "undo my last edit."** It resets the whole file
+to the parent, wiping *every* uncommitted change in it, not just the one you
+meant to back out. Run `jj diff FILE` first to see what would be lost; to
+back out one surgical experiment, revert it with the tool that made it.
-## Divergent Changes
+## Divergent changes
-A change is divergent when it has more than one visible commit — most often
+A change is divergent when it has more than one visible commit — usually
because two workspaces rewrote it concurrently. jj labels these
`(divergent)` in the log, and the edits from one side appear to have
vanished. Don't rewrite the missing files; recover them:
@@ -160,123 +177,15 @@ jj diff -r <commit> --name-only # see what each holds
jj restore --from <commit> <paths> # recover
```
-A bare change ID errors once it's divergent, so use the change offsets jj
-suggests (`abcd/0`, `abcd/1`) to inspect them. Offsets are assigned by
-recency, with the most recent commit at `/0`, so re-read `jj log` rather
-than reusing an offset from earlier in a session — a concurrent rewrite in
-another workspace shifts them all down by one. For `jj abandon`, pass the
-short commit ID, as jj's own hint recommends.
-
-## Common Pitfalls
-
-**Avoid revset functions with parentheses.** Claude Code's shell
-command parser treats `()` as subshell syntax even when quoted, which
-triggers permission prompts. Use the underlying value directly:
-
-```bash
-jj log -r trunk # correct — paren-free alias
-jj log -r 'trunk()' # WRONG — triggers permission prompt
-```
-
-`trunk` is a config alias for `trunk()`, so it stays correct per repo.
-Don't substitute a hardcoded bookmark: `trunk()` is itself an alias and
-resolves differently across repos — of the 20 that override it here, four
-point at something other than `main@origin` (`mm@origin`, `master@origin`).
-`jj config get revset-aliases."trunk()"` shows the current repo's value.
-
-**`jj show` does not accept path arguments.** It takes an optional revision
-but cannot be scoped to a path. Use `jj diff` instead:
-
-```bash
-jj diff -r @- # all changes in parent revision
-jj diff -r @- path/to/file # specific file in parent revision
-```
-
-**`--no-patch` can't combine with `--summary`/`--stat`/`--name-only`.**
-Those flags already suppress the full patch, so adding `--no-patch`
-errors out ("cannot be used with"). To see just the changed files, use
-`--summary` alone:
-
-```bash
-jj show -r @- --summary # description + file list, no patch
-```
-
-**Fileset expressions with special characters need quoting.** Fileset
-operators (`,` `~` `|` `&` `()`) in a bare path are parsed as syntax, not
-literal characters, so a path like `bin/,z` fails with "Failed to parse
-fileset: Syntax error". Wrap the path in a double-quoted string literal —
-the shell needs single quotes around it so jj sees the double quotes:
-
-```bash
-jj commit -m '...' '".config/foo"' '"bin/,z"' # literal path with a comma
-```
-
-For glob patterns, use `glob:"pattern"` the same way:
-
-```bash
-jj diff -- 'glob:"bin/*" ~ glob:"bin/,special"'
-```
-
-**There is no `--cwd` flag.** To target a repo without `cd`ing into it,
-use `-R <path>` (short for `--repository`):
-
-```bash
-jj log -R /path/to/repo -r main # correct
-jj log --cwd /path/to/repo # WRONG — no such flag; fails silently
- # if stderr is redirected (e.g. `2>/dev/null`)
-```
-
-**`jj op undo` has been removed (v0.39+).** Use `jj op revert` or
-`jj undo`/`jj redo`.
-
-**Put `-m` before `--` or fileset args.** jj parses everything after `--` as
-fileset, so `-m` placed after `--` becomes a parse error.
-
-**`jj squash` takes its source from `@` unless you pass `--from`.** Naming
-only `--into <rev>` and a fileset moves that fileset out of `@`, not out of
-the commit you were reading about. When `@` has moved since you last ran
-`jj log` — the human committing in another terminal is enough — the squash
-moves nothing, yet still prints "Rebased N descendant commits" and gives the
-destination a new commit ID, so a no-op looks like it worked. Name the source
-and re-check `jj st` immediately before any rewrite:
-
-```bash
-jj squash --from <rev> --into <rev> -u FILE # explicit source
-```
-
-**`--from` is repeatable, so collapsing several commits is one rewrite.**
-Each `--from` takes a revset and the sources need not be adjacent —
-commits in between are rebased, not folded in. Pair it with `-m`, since
-abandoning several described sources otherwise opens the editor:
-
-```bash
-jj squash --from <rev> --from <rev> --into <rev> -m 'combined message'
-```
-
-**`jj squash` opens `$EDITOR` to merge descriptions and hangs in
-non-interactive shells.** When source and destination both have
-descriptions, jj launches the editor to combine them — Claude Code's
-Bash tool has no TTY, so the command appears to hang silently and
-nothing changes. Always pass one of:
-
-```bash
-jj squash --use-destination-message # discard source description
-jj squash -u # short form
-jj squash -m 'new message' # inline replacement
-```
-
-The same applies to `jj squash --from X --into Y`. If you actually want
-to merge the two descriptions, do it from a real terminal.
+A bare change ID errors once it's divergent, so use the offsets jj suggests
+(`abcd/0`, `abcd/1`). Offsets go by recency with the newest at `/0`, so
+re-read `jj log` rather than reusing one from earlier in the session — a
+concurrent rewrite in another workspace shifts them all down by one. For
+`jj abandon`, pass the short commit ID, as jj's own hint recommends.
-**"New conflicts appeared in 1 commits" doesn't mean your commit caused
-them.** A conflict already sitting in the working copy — usually from a
-rebase someone ran in another terminal — gets carried into the new working
-copy commit and reported as new. With a fileset, `jj commit` keeps the named
-commit clean and leaves the conflict behind in `@`. Check provenance before
-resolving anything:
+Not every duplicate is litter: copies pinned by tags or another
+`immutable_heads()` clause can't be abandoned at all.
```bash
-jj op log --limit 5 # find the rebase that introduced it
-jj op show <op-id> # commits it marked (conflict)
-jj log -r @ -T 'if(conflict, "CONFLICT", "clean")' # which commits are conflicted now
+jj log -r 'divergent() & ~immutable()' # the only ones you could touch
```
diff --git a/ai/skills/jj/pitfalls.md b/ai/skills/jj/pitfalls.md
new file mode 100644
index 00000000..9f94d162
--- /dev/null
+++ b/ai/skills/jj/pitfalls.md
@@ -0,0 +1,141 @@
+# jj pitfalls
+
+Verify anything here against `jj <command> --help`.
+
+## Revsets and filesets
+
+**Avoid revset functions with parentheses.** Claude Code's shell command
+parser treats `()` as subshell syntax even when quoted, which triggers
+permission prompts. Use the underlying value directly:
+
+```bash
+jj log -r trunk # correct — paren-free alias
+jj log -r 'trunk()' # WRONG — triggers permission prompt
+```
+
+`trunk` is a config alias for `trunk()`, so it stays correct per repo. Don't
+substitute a hardcoded bookmark — `trunk()` resolves differently across
+repos, and plenty of them point at something other than `main@origin`.
+`jj config get revset-aliases."trunk()"` shows the current repo's value.
+
+**Fileset expressions with special characters need quoting.** Fileset
+operators (`,` `~` `|` `&` `()`) in a bare path are parsed as syntax, not
+literal characters, so a path like `bin/,z` fails with "Failed to parse
+fileset: Syntax error". Wrap the path in a double-quoted string literal —
+the shell needs single quotes around it so jj sees the double quotes:
+
+```bash
+jj commit -m '...' '".config/foo"' '"bin/,z"' # literal path with a comma
+```
+
+For glob patterns, use `glob:"pattern"` the same way:
+
+```bash
+jj diff -- 'glob:"bin/*" ~ glob:"bin/,special"'
+```
+
+**Put `-m` before `--` or fileset args.** jj parses everything after `--` as
+fileset, so `-m` placed after `--` becomes a parse error.
+
+## Inspecting revisions
+
+**`jj show` does not accept path arguments.** It takes an optional revision
+but cannot be scoped to a path. Use `jj diff` instead:
+
+```bash
+jj diff -r @- # all changes in parent revision
+jj diff -r @- path/to/file # specific file in parent revision
+```
+
+**`jj file show` needs `-r`, and silently ignores a revision without it.**
+Every positional argument is a path, so `jj file show main@origin FILE`
+prints the *working-copy* `FILE` and only warns `No matching entries for
+paths: main@origin` — no error, exit status 0:
+
+```bash
+jj file show -r main@origin path/to/file # correct
+jj file show main@origin path/to/file # WRONG — dumps the working-copy version
+```
+
+Extracting a "base" copy this way yields a byte-identical duplicate of the
+file under test, so any comparison against it passes vacuously. Diff the
+extracted copy against the live one before trusting a green result.
+
+**`--no-patch` can't combine with `--summary`/`--stat`/`--name-only`.**
+Those flags already suppress the full patch, so adding `--no-patch` errors
+out ("cannot be used with"). To see just the changed files, use `--summary`
+alone:
+
+```bash
+jj show -r @- --summary # description + file list, no patch
+```
+
+**There is no `--cwd` flag.** To target a repo without `cd`ing into it, use
+`-R <path>` (short for `--repository`):
+
+```bash
+jj log -R /path/to/repo -r main # correct
+jj log --cwd /path/to/repo # WRONG — no such flag; fails silently
+ # if stderr is redirected (e.g. `2>/dev/null`)
+```
+
+## squash
+
+**It takes its source from `@` unless you pass `--from`.** Naming only
+`--into <rev>` and a fileset moves that fileset out of `@`, not out of the
+commit you were reading about. When `@` has moved since you last ran `jj
+log` — the human committing in another terminal is enough — the squash moves
+nothing, yet still prints "Rebased N descendant commits" and gives the
+destination a new commit ID, so a no-op looks like it worked. Name the
+source and re-check `jj st` immediately before any rewrite:
+
+```bash
+jj squash --from <rev> --into <rev> -u FILE # explicit source
+```
+
+**`--from` is repeatable, so collapsing several commits is one rewrite.**
+Each `--from` takes a revset and the sources need not be adjacent — commits
+in between are rebased, not folded in. Pair it with `-m`, since abandoning
+several described sources otherwise opens the editor:
+
+```bash
+jj squash --from <rev> --from <rev> --into <rev> -m 'combined message'
+```
+
+**It opens `$EDITOR` to merge descriptions and hangs in non-interactive
+shells.** When source and destination both have descriptions, jj launches
+the editor to combine them — an agent's Bash tool has no TTY, so the
+command appears to hang silently and nothing changes. Always pass one of:
+
+```bash
+jj squash --use-destination-message # discard source description
+jj squash -u # short form
+jj squash -m 'new message' # inline replacement
+```
+
+This applies to `jj squash --from X --into Y` too. If you actually want to
+merge the two descriptions, do it from a real terminal.
+
+## Tracking
+
+**`jj file untrack` only sticks for paths something already ignores.** It
+reports success either way, but jj snapshots the working copy on every
+command, so an untracked-but-unignored path is back in `@` the moment any jj
+command runs — including one from another terminal or agent. Repeated
+untracking of the same path means the ignore rule is missing, not that
+untrack failed. Add the path to `.gitignore` or the global ignore first.
+
+With `snapshot.auto-track = "all()"`, a missing global ignore file is enough
+to sweep in thousands of files. jj reads it from `core.excludesFile`, falling
+back to `$XDG_CONFIG_HOME/git/ignore` — check that path exists before
+blaming the repo's `.gitignore`:
+
+```bash
+jj config get core.excludesFile # errors when unset (the common case)
+ls ~/.config/git/ignore # the XDG fallback jj actually reads
+```
+
+## Operations
+
+**`jj op undo` has been removed (v0.39+).** Use `jj op revert` or
+`jj undo`/`jj redo`.
diff --git a/ai/skills/resolve-conflicts/SKILL.md b/ai/skills/resolve-conflicts/SKILL.md
index 3be368c8..7b75d3fa 100644
--- a/ai/skills/resolve-conflicts/SKILL.md
+++ b/ai/skills/resolve-conflicts/SKILL.md
@@ -2,7 +2,7 @@
name: resolve-conflicts
description: Use when resolving jj conflicts after rebase, squash, or merge — handles conflict markers, mergiraf automation, and file-by-file manual resolution in an isolated workspace
argument-hint: [change-id]
-allowed-tools: [Bash(mkdir -p work), Bash(jj workspace add --name=resolve-* -r * work/resolve-*), Bash(cd work/resolve-*), Bash(jj status), Bash(jj resolve *), Bash(jj diff *), Bash(jj log *), Bash(jj squash *), Bash(jj workspace forget resolve-*), Bash(jj workspace update-stale), Bash(rm -rf work/resolve-*)]
+allowed-tools: [Bash(mkdir -p work), Bash(jj workspace add --name=resolve-* -r * work/resolve-*), Bash(cd work/resolve-*), Bash(jj status), Bash(jj resolve *), Bash(jj diff *), Bash(jj log *), Bash(jj op log *), Bash(jj op show *), Bash(jj squash *), Bash(jj workspace forget resolve-*), Bash(jj workspace update-stale), Bash(rm -rf work/resolve-*)]
---
# Resolve Conflicts
@@ -15,6 +15,21 @@ skill for the general mechanics referenced below (workspace naming,
`work/` layout, sync behavior); the steps here are the conflict-specific
application of it, using the exact commands `allowed-tools` permits.
+## Step 0 — Check where the conflict came from
+
+**"New conflicts appeared in 1 commits" doesn't mean the command you just
+ran caused them.** A conflict already sitting in the working copy — usually
+from a rebase someone ran in another terminal — gets carried into the new
+working copy commit and reported as new. With a fileset, `jj commit` keeps
+the named commit clean and leaves the conflict behind in `@`. Check
+provenance before resolving anything:
+
+```
+jj op log --limit 5 # find the rebase that introduced it
+jj op show <op-id> # commits it marked (conflict)
+jj log -r @ -T 'if(conflict, "CONFLICT", "clean")' # which commits are conflicted now
+```
+
## Step 1 — Set up an isolated resolution workspace
If `$ARGUMENTS` contains a change ID, use it as the conflicted revision.