This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Lokt is a file-based lock manager written in Go. It provides CLI commands for acquiring, releasing, and managing locks to coordinate concurrent processes (e.g., multiple AI agents working in the same repository).
This project uses beads for task tracking. For most changes, follow this flow:
┌─────────────────────────────────────────────────────────────┐
│ 1. Pick work from bd ready or user request │
│ ↓ │
│ 2. Implement (small changes) or /plan (complex changes) │
│ ↓ │
│ 3. Test: go test ./... && golangci-lint run │
│ ↓ │
│ 4. /commit (APPROVAL checkpoint) │
│ ↓ │
│ 5. bd sync --flush-only │
└─────────────────────────────────────────────────────────────┘
Use /plan or EnterPlanMode for:
- New commands or subcommands
- Changes affecting multiple packages
- Protocol changes (lockfile format, audit log schema)
- Anything touching atomic file operations or concurrency
Skip planning for:
- Single-file bug fixes with clear scope
- Documentation updates
- Flag additions to existing commands
- Test additions for existing behavior
- Direct user requests with explicit instructions
When in doubt: Ask the user if they want planning or a quick fix.
The agent should guide the workflow, not just respond:
- Before implementing: For non-trivial changes, suggest planning first
- After completing work: Run tests and lint before offering to commit
- When skipping steps: Briefly note why (e.g., "Skipping /plan for this flag addition")
Mandatory Checkpoints (require human approval):
| Action | Correct Approach | NEVER Do |
|---|---|---|
| Commits | Use /commit skill |
Raw git commit |
| Push to remote | Ask user to approve | Raw git push |
| Breaking changes | Discuss impact first | Silent protocol changes |
Lint-on-Edit (MANDATORY before commit/push): After every Go code change, run both:
go test ./... # Run tests
golangci-lint run # Lint + format check (includes gofmt, goimports)Never commit or push without both passing. No exceptions.
Hooks are versioned in util/hooks/ and enforce quality on commit:
| Hook | Checks |
|---|---|
pre-commit |
gofmt, golangci-lint, beads flush |
commit-msg |
Conventional commit format, blocks LLM ads |
post-merge |
Beads sync after pull |
./util/install-hooks.sh # Install hooks
./util/install-hooks.sh status # Show status
./util/install-hooks.sh uninstall # Remove hooksNew clones should run ./util/install-hooks.sh after cloning.
For versioned builds with ldflags:
./scripts/build.sh # Build with git describe version
./scripts/build.sh v1.0.0 # Build with explicit versionProject uses .golangci.yml (v2 format) with golangci-lint v2.
Linters (on top of defaults): errname, errorlint, gocritic, gosec, misspell, revive, unconvert
Formatters (run as part of golangci-lint run): gofmt, goimports
.editorconfig ensures consistent formatting:
- Go: tabs
- YAML/JSON/Markdown: 2 spaces
- Shell: 4 spaces
This project uses beads (bd) for task tracking:
bd ready # Find available work (no blockers)
bd create --title="..." --type=task --priority=2 # Create task
bd show <id> # View issue details
bd update <id> --status=in_progress # Claim work
bd close <id> # Complete work
bd sync --flush-only # Export to JSONLPriority values: 0-4 (0=critical, 2=medium, 4=backlog). NOT "high"/"medium"/"low".
# Build (ALWAYS use the build script - includes version info + lock)
./scripts/build.sh
# Run tests
go test ./...
# Run single test
go test -run TestName ./path/to/package
# Lint + format (MUST pass before commit/push)
golangci-lint runImportant: Always use ./scripts/build.sh for builds. The script:
- Embeds version/commit/date via ldflags
- Auto-acquires
buildlock via lokt guard (prevents concurrent builds) - First build bootstraps without lock (lokt doesn't exist yet)
cmd/lokt/ # CLI entrypoint
internal/ # Private packages (lock protocol, root discovery, etc.)
pkg/ # Public packages (if any)
Lock files are stored in a Lokt root directory, resolved in order:
LOKT_ROOTenvironment variable- Git common dir (
.git/lokt/viagit rev-parse --git-common-dir) .lokt/in current working directory
Locks are JSON files in <root>/locks/<name>.json:
{"version":1, "name":"...", "owner":"...", "host":"...", "pid":123, "acquired_ts":"...", "ttl_sec":300, "expires_at":"..."}Fields: version (always 1), name, owner, host, pid, pid_start_ns (omitempty), acquired_ts (RFC3339), ttl_sec (omitempty, 0 = no expiry), expires_at (omitempty, computed as acquired_ts + ttl_sec at write time).
Acquisition uses O_CREATE|O_EXCL for atomic create-or-fail semantics with fsync for durability.
lokt lock <name>- Acquire lock atomically; print holder info on denylokt unlock <name>- Release lock (owner-checked);--forcefor break-glasslokt status [name]- List locks with age/TTL/expired status;--jsonfor machine outputlokt guard <name> -- <cmd...>- Acquire, exec child, release on exit (propagate exit code)
Locks have optional TTL. When TTL is set, expires_at is computed at write time and stored in the lockfile. Expiry checks use the explicit expires_at timestamp (time.Now().After(expires_at)) when present, falling back to acquired_ts + ttl_sec arithmetic for old lockfiles.
Expired locks can be broken with --break-stale. On Unix, PID liveness (kill(pid, 0)) helps detect stale locks from crashed processes.
Clock skew and monotonic time: The expires_at and acquired_ts fields use wall-clock time (RFC3339). Within a single Go process, time.Since() uses the monotonic clock component and is safe from NTP adjustments. However, cross-process stale detection compares deserialized wall-clock values and is susceptible to NTP jumps or clock skew between hosts. This is an inherent limitation of file-based coordination. Mitigations: PID liveness detection catches crashed processes on the same host; guard heartbeat renewal (TTL/2 interval) keeps live locks fresh; --wait retries with backoff until the lock becomes available.
lokt freeze <name> creates a special lock that blocks all guard commands for that name until unfreeze or TTL expiry.
Append-only JSONL at <root>/audit.log with events: acquire, deny, release, force-break, etc.
- Exit codes: Consistent codes for held-by-other, not-found, expired, etc.
- Lock names: Validated pattern
[A-Za-z0-9._-]+; reject..and absolute paths - Owner identity: From
LOKT_OWNERenv, falling back to OS username - Atomic writes: All file operations use atomic write + fsync pattern
This is a trunk-based development project. Commit directly to main. No feature branches.
- Commit directly to main - no feature branches, no PRs
- Push to main -
git push origin mainafter commits - For parallel agents - use git worktrees, not branches
# Single agent workflow
git pull --ff-only origin main
# ... do work, commit ...
git push origin main
# Multiple agents (use worktrees for isolation)
git worktree add ../lokt-agent-2 main
cd ../lokt-agent-2
# ... work in separate directory, same main branch ...- Conventional commits required - see format below
- Keep commits atomic and focused
- Use
/commitskill for proper formatting
<type>(<scope>): <description>
[optional body]
Types:
| Type | Use for |
|---|---|
feat |
New feature or capability |
fix |
Bug fix |
docs |
Documentation only |
refactor |
Code change that neither fixes a bug nor adds a feature |
test |
Adding or updating tests |
chore |
Maintenance (deps, config, scripts) |
Scope (optional): cli, lock, guard, stale, audit, root
Examples:
feat(cli): add --break-stale flag to unlock command
fix(lock): handle race condition in atomic acquire
docs: update CLAUDE.md with workflow guidance
refactor(guard): extract signal handling to helper
test(stale): add PID liveness edge cases
chore(deps): update cobra to v1.8.0