agent-lock 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +1 -0
  3. data/.envrc +2 -0
  4. data/.rubocop_todo.yml +90 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +54 -0
  7. data/CHANGELOG.md +48 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +374 -0
  10. data/Rakefile +12 -0
  11. data/exe/agent-lock +8 -0
  12. data/exe/alock +9 -0
  13. data/justfile +127 -0
  14. data/lib/agent/lock/cli/commands/acquire.rb +87 -0
  15. data/lib/agent/lock/cli/commands/base.rb +148 -0
  16. data/lib/agent/lock/cli/commands/break.rb +36 -0
  17. data/lib/agent/lock/cli/commands/check.rb +56 -0
  18. data/lib/agent/lock/cli/commands/completion.rb +50 -0
  19. data/lib/agent/lock/cli/commands/list.rb +70 -0
  20. data/lib/agent/lock/cli/commands/mine.rb +35 -0
  21. data/lib/agent/lock/cli/commands/note.rb +37 -0
  22. data/lib/agent/lock/cli/commands/release.rb +33 -0
  23. data/lib/agent/lock/cli/commands/release_all.rb +24 -0
  24. data/lib/agent/lock/cli/commands/resume.rb +36 -0
  25. data/lib/agent/lock/cli/commands/skill.rb +75 -0
  26. data/lib/agent/lock/cli/commands/version.rb +20 -0
  27. data/lib/agent/lock/cli/commands/whoami.rb +83 -0
  28. data/lib/agent/lock/cli.rb +80 -0
  29. data/lib/agent/lock/error.rb +10 -0
  30. data/lib/agent/lock/freeze.rb +109 -0
  31. data/lib/agent/lock/identity.rb +166 -0
  32. data/lib/agent/lock/launcher.rb +126 -0
  33. data/lib/agent/lock/manager.rb +313 -0
  34. data/lib/agent/lock/process_info.rb +60 -0
  35. data/lib/agent/lock/record.rb +216 -0
  36. data/lib/agent/lock/scope.rb +173 -0
  37. data/lib/agent/lock/skill.rb +104 -0
  38. data/lib/agent/lock/store/file_system_store.rb +161 -0
  39. data/lib/agent/lock/store/redis_store.rb +225 -0
  40. data/lib/agent/lock/store.rb +104 -0
  41. data/lib/agent/lock/tree.rb +126 -0
  42. data/lib/agent/lock/version.rb +7 -0
  43. data/lib/agent/lock.rb +30 -0
  44. data/sig/agent/lock.rbs +6 -0
  45. data/skills/agent-lock/SKILL.md +59 -0
  46. metadata +150 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 13449d302065507190249f6bcf9cea27e2e4ce283a1aa422213122951826d78e
4
+ data.tar.gz: db759d6540ea353eda999c77eb02d2b6d8d5887f86e8fad52036ef93774124b6
5
+ SHA512:
6
+ metadata.gz: 2f56d58d5250a8dda75cae4f6a39b741f2186864bf91296954b6996e36b9cf9984413ddb19c92d96570f2286bfc0b88feea58d1ade5e7fcbb2a8e595e74450b5
7
+ data.tar.gz: 9421c3c1397eedbd49fbe3ac735aec9f649ef7938bf4f0d16e2bb70368278e5ff60caf5dc291356e43adef931f23f34c3632f120ed8731a78c182be2b9e2ce7e
data/.claude/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ ../AGENTS.md
data/.envrc ADDED
@@ -0,0 +1,2 @@
1
+ PATH_add bin
2
+ PATH_add exe
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,90 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config --no-auto-gen-timestamp`
3
+ # using RuboCop version 1.91.0.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 1
10
+ Gemspec/RequiredRubyVersion:
11
+ Exclude:
12
+ - 'agent-lock.gemspec'
13
+
14
+ # Offense count: 3
15
+ # Configuration parameters: Prefixes, AllowedPatterns.
16
+ # Prefixes: when, with, without
17
+ RSpec/ContextWording:
18
+ Exclude:
19
+ - 'spec/agent/lock/tree_spec.rb'
20
+ - 'spec/support/aruba.rb'
21
+ - 'spec/support/checkout.rb'
22
+
23
+ # Offense count: 3
24
+ # Configuration parameters: IgnoredMetadata.
25
+ RSpec/DescribeClass:
26
+ Exclude:
27
+ - '**/spec/features/**/*'
28
+ - '**/spec/requests/**/*'
29
+ - '**/spec/routing/**/*'
30
+ - '**/spec/system/**/*'
31
+ - '**/spec/views/**/*'
32
+ - 'spec/agent/lock/concurrency_spec.rb'
33
+ - 'spec/agent/lock/loading_spec.rb'
34
+ - 'spec/agent/lock/manager_spec.rb'
35
+
36
+ # Offense count: 1
37
+ # This cop supports unsafe autocorrection (--autocorrect-all).
38
+ # Configuration parameters: SkipBlocks, EnforcedStyle, OnlyStaticConstants.
39
+ # SupportedStyles: described_class, explicit
40
+ RSpec/DescribedClass:
41
+ Exclude:
42
+ - 'spec/agent/lock/manager_spec.rb'
43
+
44
+ # Offense count: 46
45
+ # Configuration parameters: CountAsOne.
46
+ RSpec/ExampleLength:
47
+ Max: 20
48
+
49
+ # Offense count: 2
50
+ # This cop supports unsafe autocorrection (--autocorrect-all).
51
+ RSpec/IncludeExamples:
52
+ Exclude:
53
+ - 'spec/agent/lock/concurrency_spec.rb'
54
+
55
+ # Offense count: 5
56
+ # Configuration parameters: AssignmentOnly.
57
+ RSpec/InstanceVariable:
58
+ Exclude:
59
+ - 'spec/agent/lock/tree_spec.rb'
60
+ - 'spec/support/checkout.rb'
61
+
62
+ # Offense count: 2
63
+ RSpec/LeakyLocalVariable:
64
+ Exclude:
65
+ - 'spec/agent/lock/loading_spec.rb'
66
+
67
+ # Offense count: 2
68
+ # Configuration parameters: EnforcedStyle.
69
+ # SupportedStyles: have_received, receive
70
+ RSpec/MessageSpies:
71
+ Exclude:
72
+ - 'spec/agent/lock/freeze_spec.rb'
73
+ - 'spec/agent/lock/store_spec.rb'
74
+
75
+ # Offense count: 1
76
+ RSpec/MultipleDescribes:
77
+ Exclude:
78
+ - 'spec/agent/lock/manager_spec.rb'
79
+
80
+ # Offense count: 6
81
+ RSpec/MultipleExpectations:
82
+ Max: 2
83
+
84
+ # Offense count: 1
85
+ # Configuration parameters: AllowedClasses.
86
+ Style/OneClassPerFile:
87
+ Exclude:
88
+ - 'spec/**/*'
89
+ - 'test/**/*'
90
+ - 'lib/agent/lock/cli.rb'
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.6
data/AGENTS.md ADDED
@@ -0,0 +1,54 @@
1
+ # AGENTS.md
2
+
3
+ Instructions for AI coding agents (Claude Code, Codex, Cursor, etc.) working in this repository. See `README.md` for what the gem does and how it works; this file covers only what an agent needs to know that isn't obvious from reading the code.
4
+
5
+ ## Checks before calling anything done
6
+
7
+ ```bash
8
+ # check if redis is running
9
+ echo 'INFO' | redis-cli | grep redis_version
10
+ redis_version:8.8.0 # success
11
+
12
+ # when redis is not running, it return this:
13
+ Could not connect to Redis at 127.0.0.1:6379: Connection refused
14
+
15
+ # default backend (redis) if it's available
16
+ AGENT_LOCK_TEST_BACKEND=redis
17
+ bundle exec rspec
18
+
19
+ # same suite, against (file-system) locking
20
+ AGENT_LOCK_TEST_BACKEND=file bundle exec rspec
21
+
22
+ # lint
23
+ bundle exec rubocop
24
+ ```
25
+
26
+ Both spec runs and rubocop must be clean. CI (`.github/workflows/main.yml`) runs the suite once per backend plus rubocop as a third, independent job — a change that only passes one backend, or that is only checked against one, is not verified.
27
+
28
+ A `justfile` exists for the rest (`just test`, `just ci`, `just format`, `just publish`, `just release`). **`just lint` currently names `standardrb`, which is not a dependency of this gem** — that recipe does not work; use `bundle exec rubocop` instead until it's reconciled.
29
+
30
+ ## The dual-backend test suite
31
+
32
+ `spec/spec_helper.rb` pins every example to one backend via `AGENT_LOCK_BACKEND`, read from `AGENT_LOCK_TEST_BACKEND` (default `file`). This exists because the library's default backend picks Redis when one answers locally: without the pin, running the suite on a machine with Redis running writes real lock records into that Redis, under whatever tree digest a throwaway checkout hashes to, with nothing to ever clean them up. It has happened.
33
+
34
+ If you add a spec that is inherently about one backend's own on-disk or on-Redis shape (not just the `Store` contract both satisfy), pin it to that backend explicitly regardless of `AGENT_LOCK_TEST_BACKEND` — see `manager_spec.rb`'s "the store the locks live in" block for the pattern.
35
+
36
+ Redis-backed tests use a fixed test database (`RedisHelpers::TEST_REDIS_URL`, `spec/support/redis.rb`), never whatever `REDIS_URL` happens to be set to in the shell — a `flush_test_redis!` runs after every example. Do not make that depend on an ambient env var again; an unrelated project's `.envrc` exporting `REDIS_URL` is exactly the failure mode that guards against, and it has bitten this exact repo's own tooling (see the git log for "Never let ambient REDIS_URL decide what the suite flushes").
37
+
38
+ ## Naming: `FileSystemStore` / `RedisStore`, not `FileSystem` / `Redis`
39
+
40
+ The two backend classes are named with a `Store` suffix (`Agent::Lock::Store::FileSystemStore`, `Agent::Lock::Store::RedisStore`), deliberately, so that `RedisStore.new` inside `lib/agent/lock/store/redis_store.rb` is never confused with `Redis.new` (the `redis` gem's own client class) at a glance. Any reference to the gem's `Redis` class from inside `Agent::Lock::Store::RedisStore` still needs a leading `::` to escape the enclosing module, but keeping the class itself named `RedisStore` avoids the sharper version of that trap, where an *unqualified* `Redis` inside a class literally named `Redis` silently resolves to itself.
41
+
42
+ ## Backend selection is a security-relevant path
43
+
44
+ `Store.for` decides a virgin tree's backend once and records it in a marker file; every later process in that tree is bound to it regardless of what Redis is doing. That marker is claimed atomically (`O_CREAT|O_EXCL`) so two processes racing to open the same virgin tree can't end up on different backends and silently stop seeing each other's locks — worse than no lock at all. See `lib/agent/lock/store.rb`'s moduledoc before changing anything in `.for`, `.claim`, or `.default_backend`.
45
+
46
+ ## Branch stacking
47
+
48
+ `kig/add-autocomplete` (PR #4) is currently rebased on top of `kig/fix-concurrent-agent-locking` (PR #3), not on `main`, to keep them from diverging into a large manual conflict resolution later. Its diff against `main` will look large — it includes PR #3's commits — until PR #3 merges. If you rebase either branch again, verify both spec backends and rubocop afterward per the checks above, and force-push with `--force-with-lease`.
49
+
50
+ ## Known gaps
51
+
52
+ ## (not yet fixed, tracked via PR review comments on PR #3)
53
+
54
+ `Manager`'s store-wide mutex covers `acquire`/`resume` but not `note`, `release`, `break_lock`, `release_all`, or the bare `reap` in `list`/`mine`; those still do read-modify-write outside it. The Redis mutex lease is not renewed while its critical section runs, so a slow `reap` (which can shell out to `ps` per local holder) can outlive it. Treat any change that widens what runs inside those bare `reap` calls, or that makes a critical section slower, as touching this.
data/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.2.0]
6
+
7
+ ### Added
8
+
9
+ - An `agent-lock` skill, shipped in the gem under `skills/`, that teaches an agent to claim files: naming itself on every call, running `alock` in the checkout it writes, and claiming its own files inside an orchestrator's lock. `alock skill install [--into DIR] [--force]` copies it into a skills directory, and `alock skill path` prints where the bundled copy is. `--for claude` installs into `~/.claude/skills`; without `--for` or `--into`, `~/.agents/skills` is the default for most other agents.
10
+ - `AGENT_LOCK_MUTEX_TIMEOUT`, the seconds a claim waits for the store's mutex.
11
+ - `whoami`, which prints the name this session signs locks with, its parent, and where each came from.
12
+ - A virgin tree now defaults to the Redis backend when one answers on `REDIS_URL`, and the file store when none does, rather than always defaulting to file. `AGENT_LOCK_BACKEND` still overrides it, and a tree that already has a backend recorded stays on it regardless of what Redis is doing. Two processes racing to decide the default for the same virgin tree are made to agree: the marker recording the choice is claimed with `O_CREAT|O_EXCL`, and every process builds from whichever value actually lands on disk rather than its own guess. See `README.md#backends`.
13
+ - Colorized `--help` and error output via `pastel`, disabled automatically when stdout is not a terminal.
14
+ - `AGENT_LOCK_TEST_BACKEND`, which runs the whole spec suite against Redis instead of the file store. CI now runs the suite once per backend.
15
+
16
+ ### Changed
17
+
18
+ - The packaged gem no longer includes `.plans/`.
19
+ - `redis` and `pastel` are now runtime dependencies of the gem, rather than gems you install yourself to opt into the Redis backend: deciding the default now means probing for Redis whether or not you asked for it.
20
+ - A sub-agent that sets `AGENT_ID` but not `AGENT_PARENT_ID` takes its session's own name as its parent: `CLAUDE_SESSION_ID` if set, else the fingerprint. Claude Code runs sub-agents inside the parent's own process, so without this every sub-agent signed locks as the parent and none could block another.
21
+ - Ownership no longer runs upwards. `mine`, `release`, `release-all` and `note` cover your own locks and your sub-agents', never your parent's, so a sub-agent finishing up no longer releases the orchestrator's claim.
22
+ - A sub-agent asking for exactly its parent's scope is refused with exit 1 and told to claim something narrower.
23
+ - A scope that is empty, or that resolves outside the tree, is refused with exit 2. An empty scope used to mean the whole tree, and `/etc/passwd` used to become a lock on the tree-local file `passwd`. Absolute paths inside the tree, globs included, are made relative.
24
+ - `list` counts only live locks as held, lists interrupted ones separately, and tags a live lock past the stale window `STALE`, as do `check` and `mine`. `--json` carries `stale`.
25
+ - Hints and error messages name the program that was run, `alock` or `agent-lock`.
26
+ - `whoami` without `AGENT_ID` warns that every sub-agent of the session shares that name.
27
+
28
+ ### Fixed
29
+
30
+ - A sub-agent claiming inside its parent's lock was told `ALREADY YOURS` and nothing was recorded, so two siblings could both claim the same file. The sub-agent now gets a lock of its own, which blocks its siblings.
31
+ - Claims on overlapping scopes with different names raced: the conflict scan and the write were separate steps, and `O_EXCL` or `SET NX` only guard an identical scope. Ten parallel claims of `lib/**` and `lib/aN.rb` left up to eleven overlapping locks. Every claim now scans and writes under one store-wide mutex: `flock` on the file store, a leased `SET NX PX` key released by compare-and-delete on Redis.
32
+ - Holding one file and asking for a wider scope answered `ALREADY YOURS` and wrote nothing, so the rest of the wider scope stayed open. Only a lock that contains the requested scope counts as already yours now.
33
+ - A live holder's lock was reaped once it was older than the stale window, measured from when it was taken, so an agent working for more than two hours lost its lock while still writing. A lock on this machine is now cleared only when its holder has exited; one from another machine expires when untouched past the window, and `note` counts as a touch.
34
+ - The `resume` and `break` hints printed before the listing they belong to whenever stdout and stderr went down one pipe, which is how an agent harness reads them. Stdout is flushed before a hint is written.
35
+ - A scope starting with a dot, such as `.plans/**` or `.github/**`, produced a lock file whose name also started with one, and the file store's `Dir.glob` skipped it. The lock was written and then enumerated nowhere, so `list`, `mine` and `check` reported it absent and a second session was free to acquire an overlapping scope. Both agents were told they held it.
36
+
37
+ ## [0.1.0]
38
+
39
+ First release.
40
+
41
+ - `acquire`, `release`, `check`, `list`, `mine`, `release-all`, `break`, plus `note` and `resume` for work a crash interrupted.
42
+ - Session identity: `AGENT_ID`, else `CLAUDE_SESSION_ID`, else a fingerprint of the first ancestor process that is not a shell, so a lock survives the fresh shell an agent harness starts for every command.
43
+ - Locks live in `.git/agent-locks`, one store per repository, shared by every worktree and untrackable by git.
44
+ - Glob scopes, and a conflict rule that refuses rather than guesses.
45
+ - Sub-agents work inside a parent's claim; siblings block each other.
46
+ - Stale locks are reaped by liveness, and orphaned rather than deleted when they carry notes.
47
+ - Optional Redis backend, chosen explicitly and recorded per tree.
48
+ - Optional `--enforce` freeze via `chflags uchg` on macOS.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Konstantin Gredeskoul
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,374 @@
1
+ # Agent::Lock (v0.2.0)
2
+
3
+ [![Ruby](https://github.com/kigster/agent-lock/actions/workflows/main.yml/badge.svg)](https://github.com/kigster/agent-lock/actions/workflows/main.yml)
4
+
5
+ Advisory file locks for coding agents that share a checkout.
6
+
7
+ ## What it is
8
+
9
+ `agent-lock` is a gem with one executable, `alock` (also installed as `agent-lock`). An agent runs `alock acquire <scope>` before it writes, and learns in one reply whether anybody else is working there: who, since when, and doing what. It works for separate sessions in one checkout, and for the sub-agents of a single session, which is the harder case.
10
+
11
+ Git does not help here. Two agents on one branch and one working tree never produce a conflict: the second writer simply wins, and the first one's work is gone without an error anywhere. A lock stops that, but only if every writer can be named reliably, even though an agent harness runs each command in a brand new shell. Making that name stable, and keeping claims correct when several agents ask at the same moment, is most of what this gem does.
12
+
13
+ ## Who it is for
14
+
15
+ - **People running several coding agents at once** (Claude Code, Codex, Cursor, a terminal of your own) in the same checkout.
16
+
17
+ - **Orchestrators that fan out sub-agents** into one worktree, each taking a different part of it.
18
+
19
+ - **Harnesses** that launch agents over a repository and need them to stay out of each other's way.
20
+
21
+ Not for work that can have a checkout of its own. A git worktree per agent removes the sharing altogether, which is better than coordinating it. The locks are advisory: they protect a file only from agents that check. `--enforce` exists for the few files that must not move at all.
22
+
23
+ ## How to use it
24
+
25
+ ### Install
26
+
27
+ ```bash
28
+ gem install agent-lock
29
+ alock version # alock must be on PATH
30
+ ```
31
+
32
+ Or add `gem "agent-lock"` to a Gemfile.
33
+
34
+ ### Claim before you write
35
+
36
+ ```bash
37
+ alock acquire "lib/billing/**" "rewriting the invoices" # claim a corner of the tree
38
+ alock check "lib/billing/tax.rb" # exits 1 if somebody else holds it
39
+ alock note "lib/billing/**" "totals done, specs red" # where you are, for whoever comes next
40
+ alock list # everything held in this repository
41
+ alock release-all # when you are done
42
+ ```
43
+
44
+ A refusal tells you who and what, not just that you lost:
45
+
46
+ ```bash
47
+ $ alock acquire lib/billing/tax.rb
48
+ REFUSED, do not write here
49
+ HELD lib/billing/** by luke-backend since 2026-09-09T21:04:11Z
50
+ intent: rewriting the invoices
51
+ ```
52
+
53
+ Exit 1 means do not write there. Exit 2 means the command could not run at all.
54
+
55
+ ### Fan out to sub-agents in one worktree
56
+
57
+ The orchestrator claims the area it hands out, running `alock` bare. Each sub-agent then claims its own part of it, naming itself on every call:
58
+
59
+ ```bash
60
+ alock acquire "src/**" "fanning out the billing work" # orchestrator
61
+
62
+ AGENT_ID=billing-a alock acquire "src/billing/**" "tax rounding" # sub-agent A
63
+ AGENT_ID=billing-b alock acquire "src/billing/tax.rb" "..." # sub-agent B: REFUSED, held by billing-a
64
+ AGENT_ID=billing-a alock release-all # A's own locks, never the orchestrator's
65
+ ```
66
+
67
+ ```mermaid
68
+ sequenceDiagram
69
+ participant O as Orchestrator (alo)
70
+ participant A as Sub-agent billing-a
71
+ participant B as Sub-agent billing-b
72
+ participant S as Lock store (.git/agent-locks)
73
+ O->>S: acquire src/**
74
+ S-->>O: ACQUIRED
75
+ par fan out
76
+ A->>S: AGENT_ID=billing-a acquire src/billing/**
77
+ S-->>A: ACQUIRED, parent inferred as the orchestrator
78
+ and
79
+ B->>S: AGENT_ID=billing-b acquire src/billing/tax.rb
80
+ S-->>B: REFUSED, held by billing-a
81
+ end
82
+ B->>S: AGENT_ID=billing-b acquire src/shipping/**
83
+ S-->>B: ACQUIRED
84
+ A->>S: AGENT_ID=billing-a release-all
85
+ Note over O,S: the orchestrator's src/** survives its children's release-all
86
+ ```
87
+
88
+ Three things matter, and each one fails silently if skipped:
89
+
90
+ 1. **The name goes on every call.** Sub-agents run inside the parent's process and would sign every lock as the parent. Each call is also a fresh shell, so an `export AGENT_ID` from an earlier call is gone. `AGENT_ID=<name> alock whoami` shows the name a lock would be signed with.
91
+
92
+ 1. **Run `alock` in the checkout being written**, with `cd` or `--dir`. A lock in another repository protects nothing.
93
+
94
+ 1. **Sub-agents claim even inside the orchestrator's lock.** The orchestrator's lock keeps other sessions out; only a sub-agent's own claim keeps its siblings out.
95
+
96
+ ### Teach your agents
97
+
98
+ The gem ships a skill that teaches an agent all of the above, so the rules reach the agents rather than living only in this README. Install it into the skills directory your agent reads:
99
+
100
+ ```bash
101
+ alock skill install # default: into ~/.agents/skills, for most agents
102
+ alock skill install --for claude # into ~/.claude/skills instead
103
+ alock skill install --into some/path # anywhere else, --for is ignored if both are given
104
+ alock skill path # where the bundled copy is
105
+ ```
106
+
107
+ `install` refuses to overwrite a copy that differs (`--force` replaces it) and never touches a symlink, since a symlink is some other installer's.
108
+
109
+ If skills on your machine are installed from repositories by a manifest, name this one as a source instead, so the skill is recorded along with where it came from. For example:
110
+
111
+ ```yaml
112
+ - name: agent-lock
113
+ type: skills
114
+ repo: git@github.com:kigster/agent-lock.git
115
+ path: skills
116
+ ```
117
+
118
+ Then put the rule where every agent reads it, such as `CLAUDE.md` or `AGENTS.md`:
119
+
120
+ ```markdown
121
+ Several agents may work in this checkout at once. Before creating or editing
122
+ files, load the agent-lock skill and claim the narrowest scope that covers your
123
+ writes:
124
+
125
+ alock acquire <scope> "<what you are doing>"
126
+
127
+ If it refuses, work somewhere else. A sub-agent prefixes every call with its
128
+ own name: AGENT_ID=<sub-agent-name> alock acquire ... Release with
129
+ `alock release-all` when you are done.
130
+ ```
131
+
132
+ Advisory locks work because everybody checks. That is why the rule belongs in the instructions your agents load, and not only here.
133
+
134
+ ## How it works
135
+
136
+ ### Identity comes from the session
137
+
138
+ In order of preference:
139
+
140
+ | Source | When it applies |
141
+ | :------------------ | :------------------------------------------------------ |
142
+ | `AGENT_ID` | A human or a harness named this session on purpose |
143
+ | `CLAUDE_SESSION_ID` | A harness that exports it, since it survives `--resume` |
144
+ | Fingerprint | Neither of the above is set |
145
+
146
+ The fingerprint walks up from the current process until it finds an ancestor that is not a shell. That is the `claude` or `codex` process driving the session, or the terminal a person is typing in. Its pid, start time, uid and hostname are hashed into a name like `claude-1f4c8a02`, which stays the same for as long as the session lives and differs from anybody else's.
147
+
148
+ > [!NOTE]
149
+ > The start time is in the hash on purpose. Pids get recycled, and a new session that lands on a dead one's number should not inherit its locks.
150
+
151
+ `alock whoami` prints the name this session would sign a lock with, its parent, and where each came from. Run it before the first claim if in doubt.
152
+
153
+ ### Locks live inside `.git`
154
+
155
+ The store is `$(git rev-parse --git-common-dir)/agent-locks`.
156
+
157
+ | Property | Why it matters |
158
+ | :-------------------------------------------- | :----------------------------------------------- |
159
+ | Git cannot track anything in `.git` | No repository needs a `.gitignore` entry |
160
+ | `git clean -xdf` cannot reach it | A cleanup does not silently drop every lock |
161
+ | Every worktree resolves to the same directory | One store serves the whole repository |
162
+ | It is deleted with the checkout | Nothing outlives the repo in your home directory |
163
+
164
+ Outside a git repository the store falls back to `~/.agent-locks`, keyed by a digest of the tree.
165
+
166
+ ### A lock is a document, not a flag
167
+
168
+ ```markdown
169
+ ---
170
+ agent_id: luke-backend
171
+ scope: workflow/**
172
+ status: active
173
+ pid: 69232
174
+ created_at: 2026-09-09T21:04:11Z
175
+ ---
176
+ rewriting the installer
177
+
178
+ ## Progress
179
+ - 2026-09-09T21:14:02Z installer rewritten, specs still red
180
+ ```
181
+
182
+ An agent that runs into this learns who holds the scope and what they are doing, so it can decide whether to wait or to work somewhere else. A human can read the same file in an editor.
183
+
184
+ ### Scopes are globs
185
+
186
+ | Scope | Means |
187
+ | :------------ | :------------------------------------------------- |
188
+ | `**` | The whole tree |
189
+ | `workflow/**` | Everything under `workflow` |
190
+ | `workflow` | The same thing. A directory means everything in it |
191
+ | `lib/cli.rb` | One file |
192
+
193
+ Two scopes conflict when either one's fixed part contains the other's. So `workflow/**` conflicts with `workflow/lib/cli.rb`, and `docs/**` does not conflict with `workflow/**`.
194
+
195
+ > [!TIP]
196
+ > The rule deliberately refuses more often than it strictly must. Working out whether two globs can ever match the same path has answers nobody can predict, and the price of guessing wrong is somebody's lost work.
197
+
198
+ A scope is read from where you stand, globs included, and an absolute path inside the tree is made relative. So `$PWD/lib/**` and `lib/**` are the same claim, and `*.rb` typed in `lib/` is `lib/*.rb`. Only `.`, `*` and `**` mean the whole tree from anywhere. Two things are refused with exit 2 rather than guessed at:
199
+
200
+ | Scope | Why it is refused |
201
+ | :--------------------------- | :--------------------------------------------------------------------------------------------------------- |
202
+ | `""` | Usually an unset variable. Claiming the whole tree by accident blocks everybody; write `**` if you mean it |
203
+ | `/etc/passwd`, `../other/**` | Outside the tree, so no lock in this store can protect it |
204
+
205
+ ### Sub-agents are a family, not one holder
206
+
207
+ Ownership and blocking are separate questions, and conflating them is what lets sibling sub-agents overwrite each other.
208
+
209
+ | Relationship | May claim an overlapping scope | Appears in `mine`, can be released |
210
+ | :---------------------- | :-------------------------------------------------------- | :--------------------------------- |
211
+ | Yourself | Yes, it is already yours | Yes |
212
+ | Your parent session | Yes, inside its claim, and the claim is recorded as yours | No |
213
+ | A sub-agent you spawned | No, you handed that scope out | Yes, so cleanup takes them with it |
214
+ | A sibling sub-agent | No | No |
215
+
216
+ A sub-agent claiming inside its parent's lock gets a lock of its own, and that lock is what keeps its siblings out. Asking for exactly the parent's scope is refused, since that would leave nothing for a sibling to be kept out of; claim something narrower. A sub-agent's `release-all` gives back its own locks and its children's, never its parent's.
217
+
218
+ #### Sub-agents that share their parent's process
219
+
220
+ Claude Code runs sub-agents inside the parent's own `claude` process, and sets nothing in the environment that tells them apart. They therefore share the parent's fingerprint, and without help every one of them would sign locks as the parent and none would ever block another.
221
+
222
+ So a sub-agent names itself, on every call, since each command runs in a fresh shell and an `export` does not survive to the next one:
223
+
224
+ ```bash
225
+ AGENT_ID=luke-backend alock whoami # id luke-backend, parent claude-1f4c8a02 (inferred)
226
+ AGENT_ID=luke-backend alock acquire lib/billing/** "invoices"
227
+ AGENT_ID=luke-backend alock release-all
228
+ ```
229
+
230
+ When `AGENT_ID` is set and `AGENT_PARENT_ID` is not, the parent is taken to be the session's own name, `CLAUDE_SESSION_ID` if the harness exports it and the fingerprint otherwise, which is the orchestrator running bare `alock`. Set `AGENT_PARENT_ID` explicitly when the parent named itself too.
231
+
232
+ > [!NOTE]
233
+ > The same rule applies in a terminal. Setting `AGENT_ID` there makes the terminal's own session your parent, so a bare `alock` typed in that terminal can release what you claimed under the name.
234
+
235
+ ### A crash does not strand the tree
236
+
237
+ A lock is cleared out of the way when its holder is provably gone: the process that signed it has exited, on this machine. A holder on another machine cannot be checked, so its lock is trusted until nobody has touched it for `AGENT_LOCK_STALE_MINUTES` (120 by default). Every `note` counts as a touch. What happens next depends on whether anything was written down:
238
+
239
+ | The lock has | What happens to it |
240
+ | :----------- | :----------------------------------------------- |
241
+ | No notes | Deleted. There is nothing to come back to |
242
+ | Notes | Orphaned. The claim is void, the record survives |
243
+
244
+ ```
245
+ $ alock acquire workflow/**
246
+ INTERRUPTED WORK on workflow/**, left by luke-backend
247
+ alock resume workflow/** # take it back, notes and all
248
+ alock break workflow/** # throw it away and start over
249
+ ```
250
+
251
+ > [!WARNING]
252
+ > An orphan blocks nobody, but `acquire` will not silently overwrite one, because the notes inside it may be the only record of half-finished work. Choose `resume` or `break`.
253
+
254
+ A live holder's lock is never cleared, however old it is. An agent may legitimately work on one scope for hours, and deleting its lock underneath it would hand its files to the next agent while it is still writing them. Past the stale window `list`, `check` and `mine` tag it `STALE` instead, and taking it is a `break` that somebody announces first.
255
+
256
+ ```
257
+ $ alock list
258
+ Locks held (2):
259
+ app/** orchestrator 2026-09-11T16:04:56Z
260
+ fanning out
261
+ docs/** slow-agent 2026-09-11T13:04:55Z STALE
262
+ rewriting the guides
263
+ Interrupted (1):
264
+ db/** crashed-agent 2026-09-11T15:54:55Z INTERRUPTED
265
+ splitting the migrations
266
+ ```
267
+
268
+ Only live claims are counted as held. `--json` gives every record its `status` and a `stale` flag.
269
+
270
+ ## Commands
271
+
272
+ | Command | Does | Exit code |
273
+ | :------------------------- | :----------------------------------------------------- | :----------------------------------------------------- |
274
+ | `acquire <scope> [intent]` | Claim a scope | 1 if held, interrupted, or exactly your parent's scope |
275
+ | `release <scope>` | Give it back | 1 if it belongs to somebody else |
276
+ | `check <scope>` | Report who holds it | 1 if held by another session |
277
+ | `note <scope> <text>` | Record progress inside a lock you hold | 1 if you do not hold it |
278
+ | `resume <scope>` | Take back work a crash interrupted | 1 if there is nothing to resume |
279
+ | `list` | Every lock in the store | 0 |
280
+ | `mine` | What this session and its sub-agents hold | 0 |
281
+ | `release-all` | Everything this session and its sub-agents hold | 0 |
282
+ | `break <scope>` | Take a live lock away from its holder | 0 |
283
+ | `whoami` | The name this session signs locks with, and its parent | 0 |
284
+ | `skill install` | Copy the bundled skill into a skills directory | 1 if a different copy or a symlink is there |
285
+ | `skill path` | Where the bundled skill is | 0 |
286
+ | `completion bash\|zsh` | Print a shell completion script | 1 for any other shell |
287
+
288
+ Flags:
289
+
290
+ | Flag | Where | Does |
291
+ | :----------- | :-------------------------------- | :-------------------------------------- |
292
+ | `--json` | `check`, `list`, `mine`, `whoami` | Machine-readable output |
293
+ | `--dir PATH` | Everywhere | Work as if run from `PATH` |
294
+ | `--enforce` | `acquire` | Also make the matched files unwritable |
295
+ | `--force` | `acquire` | Permit `--enforce` on a very wide scope |
296
+
297
+ Any command exits 2 when it cannot run at all: a scope that is empty or outside the tree, a backend mismatch, or a store it cannot reach.
298
+
299
+ ## Shell completion
300
+
301
+ ```bash
302
+ alock completion bash > "$(brew --prefix)/etc/bash_completion.d/alo"
303
+ alock completion zsh > "${fpath[1]}/_alo"
304
+ ```
305
+
306
+ Names every command and flag `alock` currently knows, since the script is generated from the same registry the CLI runs, rather than hand-maintained separately from it.
307
+
308
+ ## Configuration
309
+
310
+ | Variable | Default | Does |
311
+ | :------------------------- | :-------------------------------------- | :----------------------------------------------------------------------- |
312
+ | `AGENT_ID` | fingerprint | Name this session yourself |
313
+ | `AGENT_PARENT_ID` | the fingerprint, when `AGENT_ID` is set | The session that spawned this one |
314
+ | `AGENT_LOCK_DIR` | `.git/agent-locks` | Keep locks somewhere else |
315
+ | `AGENT_LOCK_STALE_MINUTES` | `120` | When a lock is tagged `STALE`, and when one from another machine expires |
316
+ | `AGENT_LOCK_MUTEX_TIMEOUT` | `15` | Seconds a claim waits for the store's mutex before giving up with exit 2 |
317
+ | `AGENT_LOCK_BACKEND` | `redis` if one answers, else `file` | `file` or `redis` |
318
+ | `AGENT_LOCK_TTL_SECONDS` | `0` | Redis expiry. `0` means no TTL |
319
+ | `REDIS_URL` | `redis://127.0.0.1:6379/0` | Where Redis is |
320
+
321
+ ## Backends
322
+
323
+ Redis stores the same documents as the file store, and buys two things a filesystem cannot: its mutex holds across machines, and a TTL expires an abandoned lock without anybody having to reason about liveness. A tree defaults to Redis when one answers on `REDIS_URL`, and falls back to the file store, which needs nothing installed, when none does.
324
+
325
+ ```bash
326
+ AGENT_LOCK_BACKEND=redis alock acquire workflow/** # force it, rather than autodetect
327
+ AGENT_LOCK_BACKEND=file alock acquire workflow/** # or force the file store instead
328
+ ```
329
+
330
+ Either way, every claim checks for conflicts and writes its lock while holding one mutex for the whole store. Refusing an atomic write of an identical scope is not enough on its own: `lib/**` and `lib/cli.rb` are different keys, and two agents claiming them at the same moment would both find the store empty and both win. The file store takes an exclusive `flock` on `.mutex` beside the locks. Redis takes `agent-lock-mutex:<tree digest>` with `SET NX PX` and a random token, and gives it back with a compare-and-delete script, so a process whose lease ran out cannot release somebody else's.
331
+
332
+ > [!CAUTION]
333
+ > Picking a default from whether Redis happens to answer is still never a runtime auto-*switch*. If one agent found a running Redis and switched to it while the agent beside it did not, the two would take locks in different stores, see nothing of each other, and both report success — worse than no lock at all. The first store created in a tree records which backend it is, in a marker file, and every later process in that tree is bound to it regardless of what Redis is doing next; a mismatch stops the run rather than silently picking the other one.
334
+ >
335
+ > That default is decided at the one moment two processes could otherwise race: both find a virgin tree, both probe Redis, and a flaky answer could hand them different defaults before either writes the marker. The marker is claimed atomically — the first `O_CREAT|O_EXCL` wins — and every process builds from whatever ends up on disk, never from its own guess, so a race can land on either backend but never on a split.
336
+
337
+ The `redis` gem ships as a dependency of this one now, since deciding the default means probing for it. If you never want that probe, or never install a local Redis, set `AGENT_LOCK_BACKEND=file` yourself.
338
+
339
+ ## Freezing files, on macOS
340
+
341
+ `acquire --enforce` also runs `chflags uchg` on every matched file, which makes them unwritable by anything, whether it checks for locks or not.
342
+
343
+ ```bash
344
+ alock acquire "config/credentials/**" "keys must not move during the migration" --enforce
345
+ ```
346
+
347
+ > [!WARNING]
348
+ > This is opt-in for three reasons. It is macOS only, since the Linux equivalent (`chattr +i`) requires root. It denies the holder too, so it suits a freeze rather than a file you are editing. And a session that dies leaves the files frozen, at which point `git checkout` and `rm -rf` start failing with "Operation not permitted".
349
+ >
350
+ > Every frozen path is written into the lock, so `release` and `break` thaw them without needing the process that froze them to still exist.
351
+
352
+ ## Development
353
+
354
+ ```bash
355
+ bin/setup
356
+ bundle exec rspec # 228 examples, against the file backend
357
+ AGENT_LOCK_TEST_BACKEND=redis bundle exec rspec # the same suite, against Redis instead
358
+ bundle exec rubocop
359
+ ```
360
+
361
+ The suite runs against one backend at a time, picked by `AGENT_LOCK_TEST_BACKEND` rather than the machine's own default, so it stays deterministic whether or not Redis happens to be running: only the examples that test one backend's own on-disk or on-Redis shape care which one that is, and a Redis run defaults to database 15 so it never touches whatever database a developer's own Redis work lives in. CI runs both, plus rubocop as a third, independent job.
362
+
363
+ A `justfile` covers the rest: `just test`, `just test-coverage`, `just ci` (rubocop then coverage), `just format` (autocorrect, then regenerate `.rubocop_todo.yml`), and `just publish`/`just release` for cutting a version. `just lint` currently names `standardrb`, which is not one of this gem's dependencies; use `bundle exec rubocop` until that recipe is reconciled with the rest of the project's tooling.
364
+
365
+ The library decides and the CLI prints. Every verb is a method on `Manager` that returns a result and prints nothing, so the whole lifecycle can be tested without capturing output. `Launcher` takes `argv`, `stdin`, `stdout`, `stderr` and `kernel` as arguments, and nothing below it calls `puts` or a receiverless `exit`, which is what lets Aruba run the CLI end to end inside the test process instead of forking a Ruby per example.
366
+
367
+ ## License
368
+
369
+ MIT
370
+
371
+ ## Authors
372
+
373
+ - Konstantin Gredeskoul @kigster
374
+ - Claude Code, @claude
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/agent-lock ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "agent/lock"
5
+
6
+ # The name is passed in, not read inside the gem, so that every hint the CLI
7
+ # prints tells the user to type what they actually typed.
8
+ Agent::Lock::Launcher.new(ARGV, program: File.basename($PROGRAM_NAME)).execute!
data/exe/alock ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The short name, for the times you are typing it twenty times an hour.
5
+ require "agent/lock"
6
+
7
+ # The name is passed in, not read inside the gem, so that every hint the CLI
8
+ # prints tells the user to type what they actually typed.
9
+ Agent::Lock::Launcher.new(ARGV, program: File.basename($PROGRAM_NAME)).execute!