jj: cover the flags and stale IDs that cost the most retries
Chosen by counting jj failures across 34 days of session logs rather than
by guessing; the pitfalls.md `jj config get` example was itself wrong, and
paren-quoted config keys accounted for four of those failures.

Assisted-by: Claude Opus 5 via Claude Code
change txyqsywysurvlppxlqtvrvyoosluokmy
commit b3561beff3bfe6994b24d2384894b3b4d7aac16a
author Alpha Chen <alpha@kejadlen.dev>
date
parent suultszl
diff --git a/ai/skills/jj/SKILL.md b/ai/skills/jj/SKILL.md
index 2602c5fd..3340c3c1 100644
--- a/ai/skills/jj/SKILL.md
+++ b/ai/skills/jj/SKILL.md
@@ -7,11 +7,33 @@ description: Use when running any jj (Jujutsu) version control command — commi
 
 Verify behavior and flags against `jj <command> --help`, and read
 `pitfalls.md` before reaching for `squash`, `diff`, `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.
+`config get`, `tag`, `op`, 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.*
 
+Flags below are verified against jj 0.45.1 unless a line names another
+version.
+
+## Naming revisions
+
+**Re-read `jj log` before reusing an ID from earlier in the session.** Any
+rewrite — yours, another workspace's, or the human's in another terminal —
+gives commits new IDs and can abandon a change outright. "Revision `abcd`
+doesn't exist" means the ID went stale far more often than it means you
+mistyped it, and it is the most common jj failure there is: re-derive the ID
+rather than hunting for the typo.
+
+- Prefer change IDs, a bookmark, or `@`-relative expressions, which resolve
+  fresh. A bare commit-ID prefix is both stale-prone and ambiguous.
+- `-r main` fails when the repo has no *local* `main` — a clone often has only
+  `main@origin`, and jj's hint lists what does exist. `trunk` is the
+  paren-free alias to reach for, but it resolves out of config rather than
+  being fixed: `jj config get 'revset-aliases."trunk()"'` when the repo's
+  answer matters.
+- A divergent change ID errors on its own; jj prints the offsets to use.
+
 ## Core workflow
 
 ### commit vs. describe vs. new
@@ -62,6 +84,30 @@ 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.
 
+## Rebase
+
+Which commits move is set by exactly one of `-b`/`-s`/`-r`; where they land by
+exactly one of `-o`/`-A`/`-B`:
+
+| Flag | Selects |
+|---|---|
+| `-b, --branch` | the whole branch relative to the destination's ancestors — jj's own gloss is that `-b=br -o=dst` equals `-s=roots(dst..br) -o=dst`. The default when none of the three is given is `-b @` |
+| `-s, --source` | the revset and all its descendants |
+| `-r, --revision` | just the revset, re-parenting its descendants onto its former parents |
+| `-o, --onto` | new parent; repeatable, so `-o p1 -o p2` makes a merge (`-d` is the older alias) |
+| `-A, --insert-after` / `-B, --insert-before` | splice into the DAG at a position, rewriting neighbours |
+
+Also useful: `--skip-emptied` drops commits a rebase makes empty,
+`--keep-divergent` stops jj abandoning a copy, `--simplify-parents` prunes
+redundant edges from a merge.
+
+**`--ignore-immutable` is a global flag, not a rebase one** — it works on
+`rebase`, `bookmark set`, `git push`, and every other rewriting command, so
+"Commit `<id>` is immutable" has the same override everywhere. `-b` hits it
+most, because a branch reaches further back than you meant. Check what the
+revset actually covers before overriding; immutability usually means you
+selected too much, not that it's in the way.
+
 ## Megamerges
 
 A megamerge is bookmarked `mm` by convention, and the `jj-workspaces` skill
@@ -83,9 +129,6 @@ 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 `jj stack` alias does this for a whole branch rather than one commit,
 defaulting to `mm+::@-` — everything between the merge and `@`. Read it with
 `jj config get aliases.stack`; `jj stack --help` doesn't work, because the
@@ -111,10 +154,19 @@ jj bookmark set feature -r <rev>           # move bookmark to revision
 jj bookmark track main --remote=origin     # start tracking a remote bookmark
 ```
 
+`jj bookmark set` refuses to move a bookmark backwards or sideways
+(`-B`/`--allow-backwards` overrides). That guard fires on the ordinary case of
+resetting a local bookmark onto its remote after a rewrite, so
+`jj bookmark set main -r main@origin` needs the flag; it is only a real
+warning when you didn't expect the target to be behind.
+
 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.
 
+A bookmark name that resolves to more than one commit errors `Name <x> is
+conflicted`; jj prints the commit IDs to disambiguate with.
+
 ### Advancing bookmarks after commit
 
 `jj bookmark advance` moves the nearest ancestor bookmark(s) forward — the
@@ -133,11 +185,26 @@ jj git push -c <rev>                       # create auto-named bookmark and push
 jj git push --named <name>=<rev>           # create named bookmark and push
 ```
 
-`-b` refuses to push untracked bookmarks ("Refusing to create new remote
-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>`).
+One decision, on whether the name already exists locally. Check with
+`jj bookmark list` if you don't know:
+
+| Local bookmark | Command |
+|---|---|
+| doesn't exist yet | `jj git push --named <name>=<rev>` — creates, pushes, tracks |
+| already exists | `jj git push -b <name>` — pushes, tracks if untracked |
+
+So `jj bookmark create <name>` followed by `jj git push --named <name>=<rev>`
+is always wrong: the create makes the name local, and `--named` then errors
+"Bookmark already exists". Pick one or the other, not both.
+
+There is no `--allow-new` on 0.45 — reaching for it is a git habit. 0.45
+auto-tracks on `-b` instead ("If a bookmark isn't tracking anything yet, the
+remote bookmark will be tracked automatically"); older versions refused with
+"Refusing to create new remote bookmark", which is the error `--allow-new`
+looks like it would fix.
+
+`-c` derives the name from the change ID (`push-<short-change-id>`).
+`jj git push --dry-run` exists and is cheap; `jj absorb --dry-run` does not.
 
 **Push a stack bottom-first, or pass `-b <bookmark>` per branch.** A bare
 `jj git push` only considers `remote_bookmarks(remote=origin)..@`, so pushing
diff --git a/ai/skills/jj/evals.yml b/ai/skills/jj/evals.yml
index 3ce2be06..942956c9 100644
--- a/ai/skills/jj/evals.yml
+++ b/ai/skills/jj/evals.yml
@@ -37,6 +37,20 @@ adherence:
       - warns that the next push reports nothing changed and leaves the lower branch unpushed, so the failure reads like success
       - tells me to push bottom-first or to pass `-b <bookmark>` per branch
 
+  - prompt: |
+      I ran `jj bookmark create alpha/my-fix -r abcd`. Now finish the job and push it to origin.
+    expect:
+      - pushes with `jj git push -b alpha/my-fix`
+      - does not use `--named`, or says `--named` would error because the name is already local
+      - does not reach for `--allow-new`, or says no such flag exists
+
+  - prompt: |
+      Ten commands ago `jj log` showed me change `qrstuvwx`. I've squashed twice since then and now
+      `jj log -r qrstuvwx` says the revision doesn't exist. Did I mistype it?
+    expect:
+      - says the ID most likely went stale because the rewrites reassigned it, not that it was mistyped
+      - tells me to re-run `jj log` and re-derive the ID rather than searching for a typo
+
   - prompt: |
       I wrote a fix in a commit on top of `mm` and now I can't push it. How do I get it into the
       merge properly?
@@ -112,6 +126,16 @@ adherence:
       - the commit it creates contains bin/,z
       - bin/normal is still uncommitted in the working copy afterward
 
+  - prompt: |
+      Print the revset this repo's `trunk()` alias expands to.
+    note: 'pitfall: jj parses the config key as TOML, so the shell must not eat the inner quotes'
+    tools: [read, bash]
+    setup: |
+      jj git init
+    expect:
+      - single-quotes the whole config key so jj receives the inner double quotes
+      - the command succeeds rather than failing with a TOML parse error
+
   - prompt: |
       In this repo, split lib/a.rb out of @ into its own commit described "lib: extract a", and leave
       the original change ID and description on the commit that still holds lib/b.rb. Then show me
diff --git a/ai/skills/jj/pitfalls.md b/ai/skills/jj/pitfalls.md
index d8d7cc77..88d5d353 100644
--- a/ai/skills/jj/pitfalls.md
+++ b/ai/skills/jj/pitfalls.md
@@ -16,7 +16,18 @@ 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.
+
+**Reading a paren'd config key needs the whole key single-quoted.** jj parses
+the key as TOML, so it has to *see* the double quotes around the paren'd
+segment; letting the shell strip them fails with a TOML parse error that names
+the stripped key and points at a column, which reads like the key is malformed
+rather than unquoted:
+
+```bash
+jj config get 'revset-aliases."trunk()"'         # correct
+jj config get revset-aliases."trunk()"           # WRONG — shell eats the quotes
+jj config get revset-aliases.'immutable_heads()' # WRONG — jj sees no quotes
+```
 
 **Fileset expressions with special characters need quoting.** Fileset
 operators (`,` `~` `|` `&` `()`) in a bare path are parsed as syntax, not
@@ -68,6 +79,19 @@ jj diff --stat config                          # correct — relative to cwd
 `jj st` prints cwd-relative paths, so it's the safe place to copy from. Use
 `jj workspace root` when the cwd's depth is unclear.
 
+**`-R` does not move the cwd, so a repo-relative path under it is a parse
+error.** Filesets still resolve from wherever you actually are, and a path
+that doesn't exist there fails with "Failed to parse fileset: Invalid file
+pattern" — nothing about the real problem. Prefix it `root:` instead:
+
+```bash
+jj -R ~/src/alpha/dotfiles diff 'root:"ai/skills/jj/SKILL.md"'   # correct
+jj -R ~/src/alpha/dotfiles diff ai/skills/jj/SKILL.md            # WRONG
+```
+
+jj's own hint says this ("Consider using root:...") but only when it can
+guess, so don't wait for it.
+
 ## Inspecting revisions
 
 **Always pass `--git` when reading a diff.** The default color-words format
@@ -76,7 +100,9 @@ so a piped diff is both ambiguous and unquotable. Works on `jj diff`,
 `jj show`, and `jj log -p`.
 
 **`jj show` does not accept path arguments.** It takes an optional revision
-but cannot be scoped to a path. Use `jj diff` instead:
+but cannot be scoped to a path, and it reports the path as a bad revision
+("Revision `docs/local-development.md` doesn't exist"), so the message sends
+you looking for a stale change ID instead. Use `jj diff`:
 
 ```bash
 jj diff -r @- --git                  # all changes in parent revision
@@ -171,7 +197,16 @@ jj config get core.excludesFile     # errors when unset (the common case)
 ls ~/.config/git/ignore             # the XDG fallback jj actually reads
 ```
 
+## Tags
+
+Subcommands on 0.45 are `list`, `set`, `delete`, `track`, and `untrack` —
+there is no `jj tag create` or `jj tag move`, and `jj tag` with no subcommand
+just prints usage. `untrack` takes `<TAG[@REMOTE]>` forms, not bare names, and
+both `list` and `untrack` accept fileset-style patterns (`'glob:"v*"'`), which
+need the same quoting as any other paren- or star-bearing argument.
+
 ## Operations
 
 **`jj op undo` has been removed (v0.39+).** Use `jj op revert` or
-`jj undo`/`jj redo`.
+`jj undo`/`jj redo`. The removal shows up as `Usage: jj operation` with no
+error line, which reads like a missing argument rather than a gone subcommand.