- 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
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.jsonas an event hook. - On the first
session-startthe hook auto-spawns the daemon in the background. - Events travel over the Unix socket as one JSON object per line.
- The daemon serialises all
jjinvocations 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
session-startregisters a new session with an emptyFilesmap andStartedAt = 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.post-editappends the edited file to the session's dedup set and resets the debounce timer. When the timer fires and the session has been idle forquiescenceWindow, 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 globaljjLockso concurrent sessions can't race.
- Runs
session-endflushes any pending commit and marks the session ended. IfJJ_HOOK_AUTO_SQUASH=1, the hook then invokes the squash-wip flow.- 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:
- Log it at startup. Even if no overlap exists, the inventory tells you who else is active. This is useful context for the user.
- Check for file overlap. Before editing a file, verify it doesn't appear in another session's
fileslist. If it does, flag it to the user: "Session abc12345 (pid 1234, active 2m34s) is also trackingsrc/auth.go— proceed anyway?" - Empty inventory is the common case. Solo sessions (the most common scenario) return an empty array. Log nothing and proceed.
- 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 sendingsession-startagain 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-editappends to/tmp/jj-claude-<session>-files. - On
session-end, one bigjj commitflushes 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-commitdprocesses (pkill jj-commitd) and retry; the nextsession-startwill relaunch. - Edits not committing — check
/tmp/jj-commitd-<repo_id>.log. Look forcommit errororjj execlines; 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 down —
scripts/jj-hook.sh statusto inspect active sessions; send theshutdownevent directly (see "Manual protocol poke" above) to force exit.
See also
INTEGRATION.md— installing hooks in a new or existing project.scripts/jj-hook.sh— the bash client that talks to the daemon.docs/planctl.md— the other binary in this repo.docs/building.md— build from source.