lockmaster 0.0.1__tar.gz

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 (63) hide show
  1. lockmaster-0.0.1/LICENSE +23 -0
  2. lockmaster-0.0.1/PKG-INFO +130 -0
  3. lockmaster-0.0.1/README.md +106 -0
  4. lockmaster-0.0.1/kit/claude/hooks/auto-land.py +286 -0
  5. lockmaster-0.0.1/kit/claude/hooks/block-main.py +174 -0
  6. lockmaster-0.0.1/kit/claude/hooks/claim-guard.py +133 -0
  7. lockmaster-0.0.1/kit/claude/hooks/disk-budget.py +199 -0
  8. lockmaster-0.0.1/kit/claude/hooks/guard-open-work-inbox.sh +17 -0
  9. lockmaster-0.0.1/kit/claude/hooks/inject-inbox.sh +54 -0
  10. lockmaster-0.0.1/kit/claude/hooks/now-ledger.py +371 -0
  11. lockmaster-0.0.1/kit/claude/hooks/release-locks.sh +34 -0
  12. lockmaster-0.0.1/kit/claude/hooks/require-progress.sh +15 -0
  13. lockmaster-0.0.1/kit/claude/hooks/session-lock.sh +58 -0
  14. lockmaster-0.0.1/kit/claude/hooks/stop-if-blocked.sh +20 -0
  15. lockmaster-0.0.1/kit/claude/hooks/work-files.py +128 -0
  16. lockmaster-0.0.1/kit/claude/hooks/worktree-cleanup.py +159 -0
  17. lockmaster-0.0.1/kit/claude/settings.json +197 -0
  18. lockmaster-0.0.1/kit/claude/skills/docket/SKILL.md +89 -0
  19. lockmaster-0.0.1/kit/claude/skills/landing/SKILL.md +68 -0
  20. lockmaster-0.0.1/kit/work/BACKLOG.md +12 -0
  21. lockmaster-0.0.1/kit/work/INTERVIEW.md +22 -0
  22. lockmaster-0.0.1/kit/work/LESSONS.md +12 -0
  23. lockmaster-0.0.1/kit/work/MACHINE.md +20 -0
  24. lockmaster-0.0.1/kit/work/OPEN-WORK.md +24 -0
  25. lockmaster-0.0.1/kit/work/WORKLOG.md +24 -0
  26. lockmaster-0.0.1/pyproject.toml +65 -0
  27. lockmaster-0.0.1/setup.cfg +4 -0
  28. lockmaster-0.0.1/src/lockmaster/__init__.py +3 -0
  29. lockmaster-0.0.1/src/lockmaster/__main__.py +3 -0
  30. lockmaster-0.0.1/src/lockmaster/cli.py +225 -0
  31. lockmaster-0.0.1/src/lockmaster/config.py +195 -0
  32. lockmaster-0.0.1/src/lockmaster/doctor.py +146 -0
  33. lockmaster-0.0.1/src/lockmaster/drivers.py +93 -0
  34. lockmaster-0.0.1/src/lockmaster/generated.py +65 -0
  35. lockmaster-0.0.1/src/lockmaster/git.py +102 -0
  36. lockmaster-0.0.1/src/lockmaster/install.py +229 -0
  37. lockmaster-0.0.1/src/lockmaster/land.py +1328 -0
  38. lockmaster-0.0.1/src/lockmaster/merge.py +99 -0
  39. lockmaster-0.0.1/src/lockmaster/now.py +92 -0
  40. lockmaster-0.0.1/src/lockmaster/repo.py +268 -0
  41. lockmaster-0.0.1/src/lockmaster/status.py +119 -0
  42. lockmaster-0.0.1/src/lockmaster/surface.py +102 -0
  43. lockmaster-0.0.1/src/lockmaster.egg-info/PKG-INFO +130 -0
  44. lockmaster-0.0.1/src/lockmaster.egg-info/SOURCES.txt +61 -0
  45. lockmaster-0.0.1/src/lockmaster.egg-info/dependency_links.txt +1 -0
  46. lockmaster-0.0.1/src/lockmaster.egg-info/entry_points.txt +2 -0
  47. lockmaster-0.0.1/src/lockmaster.egg-info/top_level.txt +1 -0
  48. lockmaster-0.0.1/tests/test_autoland.py +365 -0
  49. lockmaster-0.0.1/tests/test_cli.py +42 -0
  50. lockmaster-0.0.1/tests/test_config.py +73 -0
  51. lockmaster-0.0.1/tests/test_gate_hooks.py +45 -0
  52. lockmaster-0.0.1/tests/test_hooks.py +112 -0
  53. lockmaster-0.0.1/tests/test_install.py +96 -0
  54. lockmaster-0.0.1/tests/test_kit_hooks.py +264 -0
  55. lockmaster-0.0.1/tests/test_land.py +253 -0
  56. lockmaster-0.0.1/tests/test_merge.py +108 -0
  57. lockmaster-0.0.1/tests/test_packaging.py +39 -0
  58. lockmaster-0.0.1/tests/test_parallel.py +147 -0
  59. lockmaster-0.0.1/tests/test_regen.py +56 -0
  60. lockmaster-0.0.1/tests/test_release.py +73 -0
  61. lockmaster-0.0.1/tests/test_status.py +98 -0
  62. lockmaster-0.0.1/tests/test_waiting.py +222 -0
  63. lockmaster-0.0.1/tests/test_work_files.py +126 -0
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arpit Arya
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 all
13
+ 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 THE
21
+ SOFTWARE.
22
+
23
+ SPDX-License-Identifier: MIT
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockmaster
3
+ Version: 0.0.1
4
+ Summary: Lands AI-agent changes into main one at a time, without ever losing work.
5
+ Author: Arpit Arya
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/arpitarya/lockmaster
8
+ Project-URL: Changelog, https://github.com/arpitarya/lockmaster/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/arpitarya/lockmaster/issues
10
+ Keywords: git,merge-queue,ai-agents,worktree,claude-code
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Dynamic: license-file
24
+
25
+ # lockmaster
26
+
27
+ **Lands AI-agent changes into `main`, one at a time, without ever losing work.**
28
+
29
+ Several coding agents working in one repository collide: one commits another's
30
+ half-done files, generated files conflict, worktrees pile up, finished work
31
+ sits on branches nobody merged. lockmaster is the doorman. Each agent claims
32
+ the files it will touch, works in its own worktree and hands its change in;
33
+ lockmaster puts the changes onto `main` one at a time — only the claimed files,
34
+ generated files rebuilt, your checks run — and cleans up after each. Like a
35
+ canal lock-keeper: one boat through at a time, the water levels kept safe.
36
+
37
+ > **Status: 0.0.1, pre-alpha.** 0.0.1 holds the name on PyPI. Claims, the
38
+ > queue, landing, generated files, structural merges, the kit's hooks and
39
+ > `install` are built and tested on scratch repositories; commands and files
40
+ > may still change before 0.1.0. The design of
41
+ > record is [SR-ARCHITECTURE](records/0101_architecture.md); what is left is
42
+ > the queue in [`work/OPEN-WORK.md`](work/OPEN-WORK.md).
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install lockmaster # Python ≥ 3.9, no dependencies
48
+ lockmaster install . # the work-system kit into this repository, pinned
49
+ ```
50
+
51
+ ## How a change moves
52
+
53
+ ```text
54
+ lockmaster start W-12 --paths src/a.py src/b.py # branch + worktree + claim
55
+ cd .claude/worktrees/W-12 # … the agent edits only the claimed files …
56
+ lockmaster submit -m "W-12: the parser" # joins the queue and lands it, one at a time
57
+ lockmaster queue # what is waiting, held, handed off, landed, or red
58
+ lockmaster status # sessions, changes, worktrees, disk use
59
+ lockmaster doctor # stale locks, orphan worktrees, unmerged branches
60
+ ```
61
+
62
+ **Nobody has to remember to land.** In Claude Code the kit's `auto-land.py`
63
+ hook lands a session's change at the end of every turn — the change stays
64
+ open and its worktree follows `main`, so conflicts stay small — and lands and
65
+ cleans it up when the session ends. A `claude --worktree` session is adopted
66
+ the same way. `[land] auto = "off"` in `lockmaster.toml` turns it off.
67
+
68
+ **From Cowork, or anywhere that edited `main`'s checkout directly:**
69
+
70
+ ```text
71
+ lockmaster save -m "W-12: the parser" src/a.py # lands it — or hands it off
72
+ ```
73
+
74
+ Where the surface can commit, that lands it. Where it cannot — the Cowork
75
+ bridge cannot remove files and sees the Mac's repository at another path —
76
+ it records a handoff and exits 0; the next Claude Code session lands it
77
+ (`lockmaster pickup`, run by the kit's hook). `lockmaster can-commit` says which.
78
+
79
+ A change that cannot land cleanly — a real conflict, a failed check — goes
80
+ **red**, is set aside so the next change still lands, and waits for its agent
81
+ or a person. lockmaster never runs `--force`, `branch -D`, `reset --hard` or
82
+ `git clean`; a worktree or branch is removed only when git itself shows nothing
83
+ in it is missing from `main`.
84
+
85
+ ## What it is not
86
+
87
+ Not a new version control system: every landing is a plain git commit, and
88
+ `git log` reads the history with nothing else installed. Why not build our own,
89
+ as Anthropic did for its monorepo: [`work/compare/source-control.compare.md`](work/compare/source-control.compare.md).
90
+
91
+ ## Per-repository config
92
+
93
+ ```toml
94
+ # lockmaster.toml
95
+ [main]
96
+ branch = "main"
97
+
98
+ [checks] # name = command; every one must pass before a landing
99
+ tests = "pytest -q"
100
+
101
+ [generated] # path or glob = command; rebuilt on landing, never merged
102
+ "CHANGELOG.md" = "python3 scripts/gen-changelog.py"
103
+
104
+ [merge] # structural merges: two new rows never conflict
105
+ "work/WORKLOG.md" = "append-newest-first"
106
+ "work/OPEN-WORK.md" = "table-rows"
107
+ "work/LESSONS.md" = "append"
108
+ ```
109
+
110
+ `lockmaster drivers` registers the merge drivers in `.git/config` and writes
111
+ the `[merge]` paths into `.gitattributes`; plain `git merge` uses them too.
112
+ `lockmaster install <repo>` puts the work-system kit — hooks, settings, the
113
+ `docket` skill — into a repository, pinned in `.claude/lockmaster.lock`.
114
+
115
+ ## Working on lockmaster
116
+
117
+ Python ≥ 3.9, standard library only.
118
+
119
+ ```bash
120
+ PYTHONPATH=src python3 -m lockmaster install . # once per clone: kit/ into .claude/ and .github/, the commit guard, the merge drivers
121
+ PYTHONPATH=src python3 -m lockmaster --help # every command
122
+ python3 -m unittest discover tests # the tests — scratch git repositories, one file per work item
123
+ python3 scripts/check-work-system.py # the work-system checks
124
+ ```
125
+
126
+ Agents start at [`CLAUDE.md`](CLAUDE.md). Contributions: [`CONTRIBUTING.md`](CONTRIBUTING.md).
127
+
128
+ ## Licence
129
+
130
+ [MIT](LICENSE).
@@ -0,0 +1,106 @@
1
+ # lockmaster
2
+
3
+ **Lands AI-agent changes into `main`, one at a time, without ever losing work.**
4
+
5
+ Several coding agents working in one repository collide: one commits another's
6
+ half-done files, generated files conflict, worktrees pile up, finished work
7
+ sits on branches nobody merged. lockmaster is the doorman. Each agent claims
8
+ the files it will touch, works in its own worktree and hands its change in;
9
+ lockmaster puts the changes onto `main` one at a time — only the claimed files,
10
+ generated files rebuilt, your checks run — and cleans up after each. Like a
11
+ canal lock-keeper: one boat through at a time, the water levels kept safe.
12
+
13
+ > **Status: 0.0.1, pre-alpha.** 0.0.1 holds the name on PyPI. Claims, the
14
+ > queue, landing, generated files, structural merges, the kit's hooks and
15
+ > `install` are built and tested on scratch repositories; commands and files
16
+ > may still change before 0.1.0. The design of
17
+ > record is [SR-ARCHITECTURE](records/0101_architecture.md); what is left is
18
+ > the queue in [`work/OPEN-WORK.md`](work/OPEN-WORK.md).
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install lockmaster # Python ≥ 3.9, no dependencies
24
+ lockmaster install . # the work-system kit into this repository, pinned
25
+ ```
26
+
27
+ ## How a change moves
28
+
29
+ ```text
30
+ lockmaster start W-12 --paths src/a.py src/b.py # branch + worktree + claim
31
+ cd .claude/worktrees/W-12 # … the agent edits only the claimed files …
32
+ lockmaster submit -m "W-12: the parser" # joins the queue and lands it, one at a time
33
+ lockmaster queue # what is waiting, held, handed off, landed, or red
34
+ lockmaster status # sessions, changes, worktrees, disk use
35
+ lockmaster doctor # stale locks, orphan worktrees, unmerged branches
36
+ ```
37
+
38
+ **Nobody has to remember to land.** In Claude Code the kit's `auto-land.py`
39
+ hook lands a session's change at the end of every turn — the change stays
40
+ open and its worktree follows `main`, so conflicts stay small — and lands and
41
+ cleans it up when the session ends. A `claude --worktree` session is adopted
42
+ the same way. `[land] auto = "off"` in `lockmaster.toml` turns it off.
43
+
44
+ **From Cowork, or anywhere that edited `main`'s checkout directly:**
45
+
46
+ ```text
47
+ lockmaster save -m "W-12: the parser" src/a.py # lands it — or hands it off
48
+ ```
49
+
50
+ Where the surface can commit, that lands it. Where it cannot — the Cowork
51
+ bridge cannot remove files and sees the Mac's repository at another path —
52
+ it records a handoff and exits 0; the next Claude Code session lands it
53
+ (`lockmaster pickup`, run by the kit's hook). `lockmaster can-commit` says which.
54
+
55
+ A change that cannot land cleanly — a real conflict, a failed check — goes
56
+ **red**, is set aside so the next change still lands, and waits for its agent
57
+ or a person. lockmaster never runs `--force`, `branch -D`, `reset --hard` or
58
+ `git clean`; a worktree or branch is removed only when git itself shows nothing
59
+ in it is missing from `main`.
60
+
61
+ ## What it is not
62
+
63
+ Not a new version control system: every landing is a plain git commit, and
64
+ `git log` reads the history with nothing else installed. Why not build our own,
65
+ as Anthropic did for its monorepo: [`work/compare/source-control.compare.md`](work/compare/source-control.compare.md).
66
+
67
+ ## Per-repository config
68
+
69
+ ```toml
70
+ # lockmaster.toml
71
+ [main]
72
+ branch = "main"
73
+
74
+ [checks] # name = command; every one must pass before a landing
75
+ tests = "pytest -q"
76
+
77
+ [generated] # path or glob = command; rebuilt on landing, never merged
78
+ "CHANGELOG.md" = "python3 scripts/gen-changelog.py"
79
+
80
+ [merge] # structural merges: two new rows never conflict
81
+ "work/WORKLOG.md" = "append-newest-first"
82
+ "work/OPEN-WORK.md" = "table-rows"
83
+ "work/LESSONS.md" = "append"
84
+ ```
85
+
86
+ `lockmaster drivers` registers the merge drivers in `.git/config` and writes
87
+ the `[merge]` paths into `.gitattributes`; plain `git merge` uses them too.
88
+ `lockmaster install <repo>` puts the work-system kit — hooks, settings, the
89
+ `docket` skill — into a repository, pinned in `.claude/lockmaster.lock`.
90
+
91
+ ## Working on lockmaster
92
+
93
+ Python ≥ 3.9, standard library only.
94
+
95
+ ```bash
96
+ PYTHONPATH=src python3 -m lockmaster install . # once per clone: kit/ into .claude/ and .github/, the commit guard, the merge drivers
97
+ PYTHONPATH=src python3 -m lockmaster --help # every command
98
+ python3 -m unittest discover tests # the tests — scratch git repositories, one file per work item
99
+ python3 scripts/check-work-system.py # the work-system checks
100
+ ```
101
+
102
+ Agents start at [`CLAUDE.md`](CLAUDE.md). Contributions: [`CONTRIBUTING.md`](CONTRIBUTING.md).
103
+
104
+ ## Licence
105
+
106
+ [MIT](LICENSE).
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env python3
2
+ """Auto-landing — SR-ARCHITECTURE decisions 19 and 20: finished work reaches main
3
+ without anyone remembering to land it, and what Cowork could not commit,
4
+ Claude Code does.
5
+
6
+ start SessionStart export CLAUDE_SESSION_ID for this session's commands,
7
+ so `lockmaster start` records whose change it is;
8
+ land waiting handoffs in the background
9
+ edit PostToolUse note each file this session writes in main's checkout
10
+ stop Stop say what earlier landings did (and, once, a landing
11
+ waiting on main's checkout); tell the agent, once,
12
+ about a landing held back, a handoff that did not
13
+ land, or edits left in main's checkout; then land
14
+ this session's open changes (they stay open) and
15
+ the handoffs, in the background
16
+ end SessionEnd land and clean up this session's changes, in the
17
+ background (the session is going away)
18
+
19
+ lockmaster is found at `$LOCKMASTER_SRC`, in this repository's own `src/`
20
+ (lockmaster itself), in a `lockmaster` checkout beside this repository, or as
21
+ an installed package. Without it the hook does nothing.
22
+ It never fails a session: every error is swallowed and the turn ends as usual.
23
+ Python >= 3.9, standard library only.
24
+ """
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ import time
31
+ from pathlib import Path
32
+
33
+ ROOT = Path(__file__).resolve().parents[2]
34
+
35
+
36
+ def git(args, cwd):
37
+ try:
38
+ r = subprocess.run(["git"] + args, cwd=str(cwd), capture_output=True, text=True)
39
+ except OSError:
40
+ return None
41
+ return r.stdout.strip() if r.returncode == 0 else None
42
+
43
+
44
+ def main_root(cwd):
45
+ """The primary checkout: the first `git worktree list` entry."""
46
+ out = git(["worktree", "list", "--porcelain"], cwd)
47
+ if not out:
48
+ return None
49
+ return Path(out.splitlines()[0][len("worktree "):])
50
+
51
+
52
+ def candidates():
53
+ env = os.environ.get("LOCKMASTER_SRC")
54
+ if env:
55
+ yield Path(env)
56
+ yield ROOT / "src"
57
+ root = main_root(ROOT)
58
+ if root is not None:
59
+ yield root / "src"
60
+ yield root.parent / "lockmaster" / "src"
61
+
62
+
63
+ def find_src():
64
+ """The directory to put on sys.path, "" when lockmaster is installed, None when absent."""
65
+ for c in candidates():
66
+ if (c / "lockmaster" / "land.py").is_file():
67
+ return str(c)
68
+ try:
69
+ import lockmaster # noqa: F401
70
+ return ""
71
+ except ImportError:
72
+ return None
73
+
74
+
75
+ def load():
76
+ src = find_src()
77
+ if src is None:
78
+ return None, None
79
+ if src:
80
+ sys.path.insert(0, src)
81
+ from lockmaster import land
82
+ from lockmaster.repo import Repo
83
+ return src, (land, Repo)
84
+
85
+
86
+ def command(src):
87
+ return "PYTHONPATH={} python3 -m lockmaster".format(src) if src else "lockmaster"
88
+
89
+
90
+ def detach(src, args, cwd):
91
+ """Run `lockmaster <args>` on its own, outliving this hook; output to the state dir's log."""
92
+ common = git(["rev-parse", "--git-common-dir"], cwd)
93
+ if not common:
94
+ return
95
+ log_dir = (Path(cwd) / common).resolve() / "lockmaster"
96
+ log_dir.mkdir(parents=True, exist_ok=True)
97
+ env = dict(os.environ)
98
+ if src:
99
+ env["PYTHONPATH"] = src + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
100
+ with open(str(log_dir / "auto-land.log"), "a", encoding="utf-8") as log:
101
+ subprocess.Popen([sys.executable, "-m", "lockmaster"] + args, cwd=str(cwd), env=env,
102
+ stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
103
+
104
+
105
+ # ---------------------------------------------------------------- the session's own notes
106
+
107
+ def notes_file(repo, sid):
108
+ safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in sid)
109
+ return repo.state / "sessions" / (safe + ".json")
110
+
111
+
112
+ def read_notes(repo, sid):
113
+ try:
114
+ return json.loads(notes_file(repo, sid).read_text(encoding="utf-8"))
115
+ except (OSError, ValueError):
116
+ return {"edits": []}
117
+
118
+
119
+ def write_notes(repo, sid, notes):
120
+ f = notes_file(repo, sid)
121
+ f.parent.mkdir(parents=True, exist_ok=True)
122
+ tmp = f.with_name(f.name + ".part")
123
+ tmp.write_text(json.dumps(notes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
124
+ os.replace(str(tmp), str(f))
125
+
126
+
127
+ # ---------------------------------------------------------------- modes
128
+
129
+ def start(sid, hook):
130
+ env_file = os.environ.get("CLAUDE_ENV_FILE")
131
+ if env_file and sid:
132
+ with open(env_file, "a", encoding="utf-8") as fh:
133
+ fh.write("export CLAUDE_SESSION_ID={}\n".format(sid))
134
+ src = find_src()
135
+ if src is not None:
136
+ detach(src, ["pickup"], hook.get("cwd") or ROOT)
137
+ return None
138
+
139
+
140
+ def edit(sid, hook):
141
+ raw = (hook.get("tool_input") or {}).get("file_path") or (hook.get("tool_input") or {}).get("notebook_path")
142
+ if not raw or not sid:
143
+ return None
144
+ src, mods = load()
145
+ if mods is None:
146
+ return None
147
+ _, Repo = mods
148
+ p = Path(raw)
149
+ if not p.is_absolute():
150
+ p = Path(hook.get("cwd") or os.getcwd()) / p
151
+ repo = Repo(hook.get("cwd") or ROOT)
152
+ root = os.path.realpath(str(repo.root))
153
+ full = os.path.realpath(str(p))
154
+ if not full.startswith(root + os.sep):
155
+ return None
156
+ rel = os.path.relpath(full, root).replace(os.sep, "/")
157
+ if rel.startswith(".claude/worktrees/") or repo.change_holding(full) is not None:
158
+ return None
159
+ notes = read_notes(repo, sid)
160
+ if rel not in notes["edits"]:
161
+ notes["edits"].append(rel)
162
+ write_notes(repo, sid, notes)
163
+ return None
164
+
165
+
166
+ def stop(sid, hook):
167
+ """Report what earlier landings did, then land in the background: a
168
+ landing runs the repository's checks and rebuilds, which can take minutes
169
+ (fux), so a turn never waits on it. `LOCKMASTER_FOREGROUND=1` lands first
170
+ and reports at once (the tests)."""
171
+ src, mods = load()
172
+ if mods is None:
173
+ return None
174
+ land, Repo = mods
175
+ cwd = hook.get("cwd") or str(ROOT)
176
+ said, lines = [], []
177
+
178
+ def step(fn):
179
+ """Each step on its own: one failing never skips the others."""
180
+ try:
181
+ return fn()
182
+ except Exception as e: # noqa: BLE001
183
+ print("auto-land.py stop: {}".format(e), file=sys.stderr)
184
+ return None
185
+
186
+ foreground = os.environ.get("LOCKMASTER_FOREGROUND") == "1"
187
+ if foreground:
188
+ step(lambda: land.sync(Repo(cwd), session=sid, cwd=cwd, out=lines.append))
189
+ step(lambda: land.pickup(Repo(cwd), out=lines.append))
190
+ repo = step(lambda: Repo(cwd))
191
+ if repo is None:
192
+ return None
193
+ notes = read_notes(repo, sid) if sid else {"edits": []}
194
+ since = notes.get("last_stop", 0)
195
+ landed = []
196
+ for c in step(repo.changes) or []:
197
+ if (c.get("landed_at") or 0) > since and land._mine(c, sid, cwd):
198
+ landed.append("landed {} as {}".format(c["item"], (c.get("landed") or "")[:10]))
199
+ # W-10: a landing waiting on main's checkout lands by itself; it is said, never blocks.
200
+ for item, reason in step(lambda: getattr(land, "waiting_reports", lambda *a, **k: [])(
201
+ repo, session=sid, cwd=cwd)) or []:
202
+ landed.append("{} is waiting: {}".format(item, reason))
203
+ told = notes.setdefault("told_handoffs", {})
204
+ for h in step(lambda: land.handoffs(repo)) or []:
205
+ if h.get("reason") and told.get(h["item"]) != h.get("tried"):
206
+ told[h["item"]] = h.get("tried")
207
+ said.append("Edits handed over from Cowork ({}) did not land:\n{}\nFix them where they are, in "
208
+ "main's checkout; the end of the next turn tries again with them.".format(
209
+ h["item"], h["reason"]))
210
+ for item, reason in step(lambda: land.held_reports(repo, session=sid, cwd=cwd)) or []:
211
+ said.append("lockmaster could not land {} at the end of a turn:\n{}\nFix it in its worktree; it "
212
+ "lands at the end of the next turn. Nothing on main changed.".format(item, reason))
213
+ if sid:
214
+ notes["last_stop"] = time.time()
215
+ write_notes(repo, sid, notes)
216
+ reminder = step(lambda: checkout_reminder(land, repo, sid, src))
217
+ if reminder:
218
+ said.append(reminder)
219
+ if not foreground:
220
+ args = ["sync", "--pickup", "--cwd", cwd] + (["--session", sid] if sid else [])
221
+ step(lambda: detach(src, args, cwd))
222
+ result = {}
223
+ if landed:
224
+ result["systemMessage"] = "lockmaster: " + "; ".join(landed)
225
+ if said:
226
+ if hook.get("stop_hook_active"):
227
+ # Already continuing because of a Stop hook: say it, never block twice in a row.
228
+ result["systemMessage"] = "\n\n".join(([result["systemMessage"]] if landed else []) + said)
229
+ else:
230
+ result["decision"] = "block"
231
+ result["reason"] = "\n\n".join(said)
232
+ return result or None
233
+
234
+
235
+ def checkout_reminder(land, repo, sid, src):
236
+ """Once per set of files: the session's own edits in main's checkout that are not on main."""
237
+ notes = read_notes(repo, sid)
238
+ if not notes["edits"]:
239
+ return None
240
+ left = [p for p in land._checkout_dirty(repo, notes["edits"]) if p in notes["edits"]]
241
+ if not left:
242
+ return None
243
+ key = hashlib.sha256("\0".join(sorted(left)).encode()).hexdigest()[:16]
244
+ if notes.get("told") == key:
245
+ return None
246
+ notes["told"] = key
247
+ write_notes(repo, sid, notes)
248
+ return ("This session edited {} in main's checkout and it is not on main. Land it now: "
249
+ "`{} save -m \"<what changed and why>\" {}`".format(", ".join(left), command(src), " ".join(left)))
250
+
251
+
252
+ def end(sid, hook):
253
+ src = find_src()
254
+ if src is None:
255
+ return None
256
+ cwd = hook.get("cwd") or str(ROOT)
257
+ args = ["sync", "--final", "--cwd", cwd] + (["--session", sid] if sid else [])
258
+ detach(src, args, cwd)
259
+ return None
260
+
261
+
262
+ def main(argv):
263
+ mode = argv[0] if argv else ""
264
+ fn = {"start": start, "edit": edit, "stop": stop, "end": end}.get(mode)
265
+ if fn is None:
266
+ print("usage: auto-land.py start|edit|stop|end", file=sys.stderr)
267
+ return 0
268
+ hook = {}
269
+ if not sys.stdin.isatty():
270
+ try:
271
+ hook = json.loads(sys.stdin.read() or "{}")
272
+ except ValueError:
273
+ hook = {}
274
+ sid = hook.get("session_id") or os.environ.get("CLAUDE_SESSION_ID", "")
275
+ try:
276
+ result = fn(sid, hook)
277
+ except Exception as e: # noqa: BLE001 — never fail the session over a landing
278
+ print("auto-land.py {}: {}".format(mode, e), file=sys.stderr)
279
+ return 0
280
+ if result:
281
+ print(json.dumps(result))
282
+ return 0
283
+
284
+
285
+ if __name__ == "__main__":
286
+ sys.exit(main(sys.argv[1:]))