template-jj/docs/jj-commitd.md
sid aff5a30b27
Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / build-test (1.22, macos-latest) (pull_request) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (pull_request) Has been cancelled
planctl / build-test (1.22, windows-latest) (pull_request) Has been cancelled
planctl / build-test (stable, macos-latest) (pull_request) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (pull_request) Has been cancelled
planctl / build-test (stable, windows-latest) (pull_request) Has been cancelled
planctl / bench (push) Has been cancelled
planctl / bench (pull_request) Has been cancelled
feat(planctl): v2 task-state commands (next, list, complete, status)
- Add tasks.go: TaskRecord, buildTaskRecords, findTaskByID, atomicRewriteTaskLine, evalPlanStatus
- Add runNext, runList, runComplete, runStatus subcommands in main.go
- Add emitNext, emitList, emitComplete, emitStatus formatters in emit.go
- Rewrite CLI dispatch; update printUsage to list all five subcommands
- Add 28 golden-file fixture dirs under testdata/v2/
- Add mutation tests for complete (success, already-done, triggers-lint, dry-run)
- Add per-subcommand --help tests and unit tests for all emit functions
- Fix: warnings-only status shows PASS not FAIL per R4.2
- Fix: case-insensitive T prefix for complete <task-ref> per R3.1
- Fix: dry-run JSON computes hypothetical lint diagnostics per R3.8
- Fix: dry-run JSON always exits 0 per R3.8
- Add multi-plan status aggregate: Summary line (text) + summary object (JSON) per R4.6

Tasks 2.0-6.0 from dev/plans/26174-planctl-task-cmds/prd.md
2026-04-26 09:12:36 -06:00

11 KiB

jj-commitd — command reference

Debounced commit daemon for Claude Code sessions. Per-session, file-scoped commits with ~3 s debouncing so rapid successive edits batch into one jj commit instead of racing.

Optional. If the binary isn't on PATH, the hook script falls back to file-tracking-only mode that commits at session end.

Install

go install forgejo.zerova.net/sid/template-jj/cmd/jj-commitd@latest

See docs/building.md for building from source.

Architecture

Claude Code hooks (bash)    Unix socket           Go daemon            jj CLI
┌─────────────────────┐  ┌────────────────────┐  ┌──────────────┐  ┌────────┐
│ session-start       │→ │                    │→ │              │→ │ jj     │
│ post-edit (edit N)  │→ │ /tmp/jj-commitd-   │  │ jj-commitd   │→ │ commit │
│ post-edit (edit N+1)│→ │   <repo_id>.sock   │  │ (this binary)│  │        │
│ session-end         │→ │                    │→ │              │→ │ jj ... │
└─────────────────────┘  └────────────────────┘  └──────────────┘  └────────┘
  • The bash hook client (scripts/jj-hook.sh) is installed into Claude Code's .claude/settings.json as an event hook.
  • On the first session-start the hook auto-spawns the daemon in the background.
  • Events travel over the Unix socket as one JSON object per line.
  • The daemon serialises all jj invocations through a single mutex so concurrent sessions never race.
  • When the last session ends and an idle timeout passes (30 min), the daemon exits and cleans up its socket.

Usage

The binary is normally not invoked directly — scripts/jj-hook.sh manages its lifecycle. For debugging / manual testing:

# Start manually from a repo root (daemon blocks in foreground):
REPO_ROOT=$(pwd) jj-commitd

# Ask an existing daemon to log its status (writes to the daemon's log file):
scripts/jj-hook.sh status

# Gracefully shut down a running daemon:
scripts/jj-hook.sh session-end   # (or send a shutdown event directly — see below)

There is no --help, no --version, and no subcommand dispatch. The daemon reads all configuration from environment variables.

Events

The daemon consumes one JSON object per line over its Unix socket. All events are one-shot request/response; the daemon closes the connection after replying.

Event Fields Semantics
session-start session_id, repo_root, pid Register a session. Response includes the inventory of other active sessions + their tracked files.
post-edit session_id, file (absolute path), repo_root Track a file edit. Resets the session's debounce timer; the next idle window triggers jj commit.
session-end session_id Flush any pending commit, record the session as ended. Daemon exits once all sessions end + idle out.
shutdown Immediate graceful shutdown. Used by cleanup scripts.
status Snapshot current session state to the log file. Returns {"ok": true}.

Unknown events are rejected with {"ok": false, "error": "unknown event"}.

Example — manual protocol poke

# Send a status request to an existing daemon:
echo '{"event":"status"}' \
  | nc -U /tmp/jj-commitd-$(echo -n $(pwd) | shasum -a 256 | head -c 16).sock

(The repo_id is a SHA-256 prefix of the absolute repo root — see repoID() in cmd/jj-commitd/main.go.)

Configuration (environment variables)

Variable Default Max Effect
REPO_ROOT $(pwd) Repo root the daemon manages. Auto-detected from cwd if unset.
JJ_HOOK_DEBOUNCE_SEC 3 30 Debounce window in seconds. Edits within this window batch into one commit.
JJ_HOOK_STALE_MIN 30 Minutes before a session with a dead PID is reaped.
JJ_HOOK_AUTO_SQUASH 0 When 1, session-end triggers the squash-wip flow (squashes the session's WIP commits into one).
JJ_AGENT_FEATURE (Planned, not yet implemented.) Human-readable feature name for commits and bookmarks. When set, commits become wip(<feature>:<session_id>) and the session-end bookmark becomes feat/<feature> instead of wip/claude-<session_id>.

Invalid or zero values for the numeric vars fall back to defaults.

Paths

All daemon state is per-repo, keyed by a SHA-256 prefix of $REPO_ROOT:

  • Socket: /tmp/jj-commitd-<repo_id>.sock
  • Log: /tmp/jj-commitd-<repo_id>.log (rotated when it exceeds 512 KB)

Both clean up automatically when the daemon exits. Stale sockets from a crashed daemon are detected via a DialTimeout probe on startup and unlinked.

Timing constants

All baked in at cmd/jj-commitd/main.go:46-53; not user-configurable beyond the env vars above.

Constant Value Purpose
defaultDebounce 3 s Default batching window for post-edit events.
quiescenceWindow 500 ms Must see no new edits for this long before a commit fires.
maxDebounce 30 s Cap on JJ_HOOK_DEBOUNCE_SEC.
idleShutdown 30 m Time after the last session ends before the daemon self-exits.
defaultStaleSessionAge 30 m Default stale-session reap age (overridable via JJ_HOOK_STALE_MIN).
maxLogSize 512 KB Log rotation threshold.

Session lifecycle

  1. session-start registers a new session with an empty Files map and StartedAt = now. The response carries an inventory of other active sessions — a Claude Code agent can inspect this to avoid stepping on another session's tracked files.
  2. post-edit appends the edited file to the session's dedup set and resets the debounce timer. When the timer fires and the session has been idle for quiescenceWindow, the daemon:
    • Runs jj diff --summary -- <tracked files> to confirm there are real changes (avoids empty commits from Claude re-reading an unchanged file).
    • Runs jj commit -m "wip(claude:<session>): <file-list>" -- <tracked files> under a global jjLock so concurrent sessions can't race.
  3. session-end flushes any pending commit and marks the session ended. If JJ_HOOK_AUTO_SQUASH=1, the hook then invokes the squash-wip flow.
  4. When all sessions have ended AND no new traffic arrives for idleShutdown (30 minutes), the daemon exits cleanly.

Session inventory consumption

On session-start, the daemon responds with a JSON sessions array listing every other currently-active session and its tracked files:

{
  "ok": true,
  "sessions": [
    {"id": "abc12345", "pid": 1234, "files": ["src/auth.go", "cmd/main.go"], "age": "2m34s"}
  ]
}

What agents should do with this:

  1. Log it at startup. Even if no overlap exists, the inventory tells you who else is active. This is useful context for the user.
  2. Check for file overlap. Before editing a file, verify it doesn't appear in another session's files list. If it does, flag it to the user: "Session abc12345 (pid 1234, active 2m34s) is also tracking src/auth.go — proceed anyway?"
  3. Empty inventory is the common case. Solo sessions (the most common scenario) return an empty array. Log nothing and proceed.
  4. Stale entries self-clean. The daemon reaps sessions with dead PIDs and sessions older than JJ_HOOK_STALE_MIN (default 30 min). Don't rely on the inventory being perfectly current across a long session — re-read by sending session-start again if you need a fresh snapshot. A fresh session restart is always cleaner.

The hook script (scripts/jj-hook.sh) logs the inventory to the daemon log on start but does not yet surface it to the agent automatically. In a future version this will be piped into the agent's startup context.

Conflict detection

When two sessions touch the same file, the daemon logs a warning (grep /tmp/jj-commitd-<repo_id>.log for conflict) and attributes the edit to whichever session's timer fires first. Per-session commit messages keep the history readable even when multiple agents interleave.

Legacy cleanup

On startup the daemon sweeps and removes any /tmp/jj-claude-*-files tracking files left over from pre-daemon hook versions. Safe to run alongside old installations — it won't disrupt an in-flight older hook since the files it cleans are ephemeral per-session markers.

Fallback mode

If jj-commitd is not on PATH when scripts/jj-hook.sh session-start runs, the hook falls back to file-tracking-only mode:

  • Every post-edit appends to /tmp/jj-claude-<session>-files.
  • On session-end, one big jj commit flushes all tracked files at once.
  • No debouncing, no per-session isolation beyond the file list itself.

Functional, but loses the debounce and live-status benefits of the daemon.

Troubleshooting

  • "connection refused" from the hook — stale socket. The daemon detects these on startup, but if you see it mid-session, kill any leftover jj-commitd processes (pkill jj-commitd) and retry; the next session-start will relaunch.
  • Edits not committing — check /tmp/jj-commitd-<repo_id>.log. Look for commit error or jj exec lines; the jj CLI's stderr is captured there.
  • Disappearing edits across sessions — grep the log for the file path; you're likely hitting a cross-session conflict.
  • Daemon won't shut downscripts/jj-hook.sh status to inspect active sessions; send the shutdown event directly (see "Manual protocol poke" above) to force exit.

See also