lockmaster 0.0.1__py3-none-any.whl

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 (43) hide show
  1. lockmaster/__init__.py +3 -0
  2. lockmaster/__main__.py +3 -0
  3. lockmaster/cli.py +225 -0
  4. lockmaster/config.py +195 -0
  5. lockmaster/doctor.py +146 -0
  6. lockmaster/drivers.py +93 -0
  7. lockmaster/generated.py +65 -0
  8. lockmaster/git.py +102 -0
  9. lockmaster/install.py +229 -0
  10. lockmaster/kit/claude/hooks/auto-land.py +286 -0
  11. lockmaster/kit/claude/hooks/block-main.py +174 -0
  12. lockmaster/kit/claude/hooks/claim-guard.py +133 -0
  13. lockmaster/kit/claude/hooks/disk-budget.py +199 -0
  14. lockmaster/kit/claude/hooks/guard-open-work-inbox.sh +17 -0
  15. lockmaster/kit/claude/hooks/inject-inbox.sh +54 -0
  16. lockmaster/kit/claude/hooks/now-ledger.py +371 -0
  17. lockmaster/kit/claude/hooks/release-locks.sh +34 -0
  18. lockmaster/kit/claude/hooks/require-progress.sh +15 -0
  19. lockmaster/kit/claude/hooks/session-lock.sh +58 -0
  20. lockmaster/kit/claude/hooks/stop-if-blocked.sh +20 -0
  21. lockmaster/kit/claude/hooks/work-files.py +128 -0
  22. lockmaster/kit/claude/hooks/worktree-cleanup.py +159 -0
  23. lockmaster/kit/claude/settings.json +197 -0
  24. lockmaster/kit/claude/skills/docket/SKILL.md +89 -0
  25. lockmaster/kit/claude/skills/landing/SKILL.md +68 -0
  26. lockmaster/kit/work/BACKLOG.md +12 -0
  27. lockmaster/kit/work/INTERVIEW.md +22 -0
  28. lockmaster/kit/work/LESSONS.md +12 -0
  29. lockmaster/kit/work/MACHINE.md +20 -0
  30. lockmaster/kit/work/OPEN-WORK.md +24 -0
  31. lockmaster/kit/work/WORKLOG.md +24 -0
  32. lockmaster/land.py +1328 -0
  33. lockmaster/merge.py +99 -0
  34. lockmaster/now.py +92 -0
  35. lockmaster/repo.py +268 -0
  36. lockmaster/status.py +119 -0
  37. lockmaster/surface.py +102 -0
  38. lockmaster-0.0.1.dist-info/METADATA +130 -0
  39. lockmaster-0.0.1.dist-info/RECORD +43 -0
  40. lockmaster-0.0.1.dist-info/WHEEL +5 -0
  41. lockmaster-0.0.1.dist-info/entry_points.txt +2 -0
  42. lockmaster-0.0.1.dist-info/licenses/LICENSE +23 -0
  43. lockmaster-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,65 @@
1
+ """Generated files are never merged, only rebuilt — SR-ARCHITECTURE decision 9.
2
+
3
+ `lockmaster.toml` `[generated]` maps a path or glob to the command that
4
+ rebuilds it. `regen` restores each such file to `main`'s version, then runs
5
+ its command; a command that fails fails the run.
6
+
7
+ A generated file with uncommitted edits is not overwritten: that would be
8
+ `checkout --` over changes (SR-LAW-3 decision 3). It is refused, by name, and
9
+ nothing is touched until it is committed or put back.
10
+ """
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from lockmaster import git
15
+ from lockmaster.repo import LockmasterError, matches
16
+
17
+
18
+ class RegenError(LockmasterError):
19
+ pass
20
+
21
+
22
+ def _tracked(worktree, ref, pattern):
23
+ names = git.run(["ls-tree", "-r", "--name-only", "-z", ref], worktree).split("\0")
24
+ return [n for n in names if n and matches(n, pattern)]
25
+
26
+
27
+ def regen(worktree, config, ref=None):
28
+ """Rebuild every [generated] path in `worktree`. Returns the patterns rebuilt."""
29
+ worktree = Path(worktree)
30
+ ref = ref or config.main
31
+ if not config.generated:
32
+ return []
33
+ restore, refused = [], []
34
+ for pattern in config.generated:
35
+ for name in _tracked(worktree, ref, pattern):
36
+ if git.run(["status", "--porcelain", "--", name], worktree).strip():
37
+ refused.append(name)
38
+ else:
39
+ restore.append(name)
40
+ if refused:
41
+ raise RegenError("generated files with uncommitted edits — commit them or put them back, "
42
+ "then run again: " + ", ".join(refused))
43
+ for name in restore:
44
+ blob = subprocess.run(["git", "show", ref + ":" + name], cwd=str(worktree),
45
+ capture_output=True, check=True).stdout
46
+ target = worktree / name
47
+ target.parent.mkdir(parents=True, exist_ok=True)
48
+ if not target.exists() or target.read_bytes() != blob:
49
+ target.write_bytes(blob)
50
+ done = []
51
+ for pattern, command in config.generated.items():
52
+ r = subprocess.run(command, shell=True, cwd=str(worktree), capture_output=True, text=True)
53
+ if r.returncode != 0:
54
+ tail = (r.stdout + r.stderr).strip().splitlines()[-15:]
55
+ raise RegenError("rebuilding `{}` with `{}` exited {}:\n {}".format(
56
+ pattern, command, r.returncode, "\n ".join(tail)))
57
+ done.append(pattern)
58
+ return done
59
+
60
+
61
+ def stage(worktree, config):
62
+ """Stage whatever the rebuild changed under the [generated] patterns."""
63
+ for pattern in config.generated:
64
+ spec = ":(glob)" + pattern if any(c in pattern for c in "*?[") else pattern
65
+ git.run(["add", "-A", "--", spec], worktree, check=False)
lockmaster/git.py ADDED
@@ -0,0 +1,102 @@
1
+ """Thin wrappers over the `git` command — SR-ARCHITECTURE decision 1.
2
+
3
+ Every git operation lockmaster runs goes through `run`, so the forbidden forms
4
+ of SR-LAW-3 decision 3 have one place to be kept out of. Nothing here forces.
5
+ """
6
+ import os
7
+ import subprocess
8
+ import time
9
+
10
+ #: `-c core.hooksPath=…` while a Repo is open: lockmaster's own git steps run the
11
+ #: repository's gate hooks and not its post-* hooks (SR-ARCHITECTURE decision 18).
12
+ HOOKS = []
13
+
14
+
15
+ class GitError(Exception):
16
+ """A git command exited non-zero."""
17
+
18
+ def __init__(self, args, code, out, err):
19
+ self.args_ = args
20
+ self.code = code
21
+ self.out = out
22
+ self.err = err
23
+ super().__init__("git {} exited {}: {}".format(" ".join(args), code, (err or out).strip()))
24
+
25
+
26
+ def run(args, cwd, check=True, env=None, input=None):
27
+ """Run `git <args>` in `cwd`; return stdout. With check=False, return
28
+ (code, stdout, stderr) instead of raising."""
29
+ # No optional locks: a `git status` that refreshes the index takes
30
+ # index.lock and removes it, which a surface that cannot unlink (the
31
+ # Cowork bridge) would leave behind (SR-ARCHITECTURE decision 19).
32
+ full_env = dict(os.environ, GIT_OPTIONAL_LOCKS="0")
33
+ if env:
34
+ full_env.update(env)
35
+ r = subprocess.run(["git"] + HOOKS + list(args), cwd=str(cwd), capture_output=True, text=True,
36
+ env=full_env, input=input)
37
+ if not check:
38
+ return r.returncode, r.stdout, r.stderr
39
+ if r.returncode != 0:
40
+ raise GitError(list(args), r.returncode, r.stdout, r.stderr)
41
+ return r.stdout
42
+
43
+
44
+ def out(args, cwd, env=None):
45
+ """stdout of a git command, stripped."""
46
+ return run(args, cwd, env=env).strip()
47
+
48
+
49
+ def ok(args, cwd):
50
+ """True when the git command exits 0."""
51
+ return run(args, cwd, check=False)[0] == 0
52
+
53
+
54
+ def worktree_add(args, cwd, tries=8):
55
+ """`git worktree add -q <args>`. Retried when git trips on another worktree
56
+ being added at that moment: it reads the other's half-written files under
57
+ `.git/worktrees/` and dies (SR-LAW-8 — no caller is refused for that). A
58
+ `-b <branch>` attempt that died after creating the branch is retried
59
+ without `-b`, on the branch it made."""
60
+ args = list(args)
61
+ for attempt in range(tries):
62
+ code, o, e = run(["worktree", "add", "-q"] + args, cwd, check=False)
63
+ if code == 0:
64
+ return
65
+ if ".git/worktrees/" not in e or attempt == tries - 1:
66
+ raise GitError(["worktree", "add", "-q"] + args, code, o, e)
67
+ if "-b" in args:
68
+ i = args.index("-b")
69
+ branch, path, start = args[i + 1], args[i + 2], args[i + 3]
70
+ head = run(["rev-parse", "--verify", "--quiet", "refs/heads/" + branch], cwd, check=False)
71
+ held = any(t["branch"] == branch for t in worktrees(cwd))
72
+ if head[0] == 0 and not held and head[1].strip() == run(["rev-parse", start], cwd).strip():
73
+ args = [path, branch]
74
+ time.sleep(0.05 * (attempt + 1))
75
+
76
+
77
+ def worktrees(cwd):
78
+ """Every worktree of the repository: dicts of path, head, branch, locked."""
79
+ trees, cur = [], None
80
+ for line in run(["worktree", "list", "--porcelain"], cwd).splitlines() + [""]:
81
+ if not line:
82
+ if cur:
83
+ trees.append(cur)
84
+ cur = None
85
+ continue
86
+ key, _, val = line.partition(" ")
87
+ if key == "worktree":
88
+ cur = {"path": val, "head": "", "branch": "", "locked": None}
89
+ elif cur is None:
90
+ continue
91
+ elif key == "HEAD":
92
+ cur["head"] = val
93
+ elif key == "branch":
94
+ cur["branch"] = val[len("refs/heads/"):] if val.startswith("refs/heads/") else val
95
+ elif key == "locked":
96
+ cur["locked"] = val
97
+ return trees
98
+
99
+
100
+ def dirty(cwd):
101
+ """`git status --porcelain` lines: modified, staged, deleted or untracked."""
102
+ return [l for l in run(["status", "--porcelain", "--untracked-files=all"], cwd).splitlines() if l]
lockmaster/install.py ADDED
@@ -0,0 +1,229 @@
1
+ """`lockmaster install <repo>` — SR-ARCHITECTURE decisions 14 and 15.
2
+
3
+ kit/claude/** -> <repo>/.claude/** hooks made executable
4
+ kit/github/** -> <repo>/.github/** (none today: a repository's CI is its own)
5
+ kit/claude/settings.json is MERGED into <repo>/.claude/settings.json: the
6
+ kit's hook entries replace the ones a previous install wrote; the
7
+ repo's own entries and keys are kept
8
+ <repo>/.claude/lockmaster.lock records the version, the kit's digest and
9
+ each installed file's hash, so `lockmaster doctor` sees a mismatch
10
+ the merge drivers, when the repo has a lockmaster.toml
11
+ kit/work/<file> -> <repo>/work/<file> for each of the six work files the
12
+ repo lacks (SR-LAW-10 decision 6); one that exists is never touched,
13
+ and they are the project's own papers, not kit files (decision 16)
14
+
15
+ An existing file the kit would replace, and which a previous install did not
16
+ write, is never overwritten silently: the install stops and names it, or with
17
+ --backup moves it aside to `<file>.pre-lockmaster` first (SR-LAW-3).
18
+ """
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import stat
23
+ from pathlib import Path
24
+
25
+ from lockmaster import __version__
26
+ from lockmaster import git
27
+ from lockmaster.repo import LockmasterError
28
+
29
+ LOCK = ".claude/lockmaster.lock"
30
+ SETTINGS = ".claude/settings.json"
31
+ TREES = (("claude", ".claude"), ("github", ".github"))
32
+ # SR-LAW-10: the six work files every repository lockmaster is installed in keeps.
33
+ WORK_FILES = ("OPEN-WORK.md", "BACKLOG.md", "INTERVIEW.md", "MACHINE.md", "LESSONS.md", "WORKLOG.md")
34
+
35
+
36
+ def find_kit():
37
+ """The kit this lockmaster carries: $LOCKMASTER_KIT, the package's own
38
+ copy, or the source checkout's kit/."""
39
+ env = os.environ.get("LOCKMASTER_KIT")
40
+ here = Path(__file__).resolve().parent
41
+ for cand in ([Path(env)] if env else []) + [here / "kit", here.parents[1] / "kit"]:
42
+ if (cand / "claude").is_dir():
43
+ return cand
44
+ raise LockmasterError("cannot find the kit (set LOCKMASTER_KIT to a kit/ directory)")
45
+
46
+
47
+ def sha(path):
48
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
49
+
50
+
51
+ def kit_files(kit):
52
+ """(source path, installed relative path) for every kit file but settings.json."""
53
+ out = []
54
+ for sub, dest in TREES:
55
+ base = kit / sub
56
+ if not base.is_dir():
57
+ continue
58
+ for f in sorted(base.rglob("*")):
59
+ if f.is_dir() or "__pycache__" in f.parts or f.name == ".DS_Store":
60
+ continue
61
+ rel = (Path(dest) / f.relative_to(base)).as_posix()
62
+ if rel != SETTINGS:
63
+ out.append((f, rel))
64
+ return out
65
+
66
+
67
+ def kit_digest(kit):
68
+ h = hashlib.sha256()
69
+ for f, rel in kit_files(kit) + [(kit / "claude" / "settings.json", SETTINGS)]:
70
+ if f.is_file():
71
+ h.update(rel.encode() + b"\0" + f.read_bytes() + b"\0")
72
+ return h.hexdigest()
73
+
74
+
75
+ def read_lock(target):
76
+ try:
77
+ return json.loads((Path(target) / LOCK).read_text(encoding="utf-8"))
78
+ except (OSError, ValueError):
79
+ return {}
80
+
81
+
82
+ def _commands(settings):
83
+ return {h.get("command") for groups in settings.get("hooks", {}).values()
84
+ for g in groups for h in g.get("hooks", []) if h.get("command")}
85
+
86
+
87
+ def merge_settings(current, kit_settings, managed_before):
88
+ """The kit's hook entries, then the repo's own; every other key of the
89
+ repo's settings kept. `managed_before` are the commands a previous install
90
+ wrote, so a hook the kit dropped leaves too."""
91
+ managed = set(managed_before) | _commands(kit_settings)
92
+ merged = dict(current)
93
+ for key, value in kit_settings.items():
94
+ if key != "hooks" and key not in merged:
95
+ merged[key] = value
96
+ hooks = {}
97
+ events = list(kit_settings.get("hooks", {})) + [e for e in current.get("hooks", {})
98
+ if e not in kit_settings.get("hooks", {})]
99
+ for event in events:
100
+ own = []
101
+ for g in current.get("hooks", {}).get(event, []):
102
+ kept = [h for h in g.get("hooks", []) if h.get("command") not in managed]
103
+ if kept:
104
+ own.append(dict(g, hooks=kept))
105
+ groups = list(kit_settings.get("hooks", {}).get(event, [])) + own
106
+ if groups:
107
+ hooks[event] = groups
108
+ merged["hooks"] = hooks
109
+ return merged
110
+
111
+
112
+ def install(target, backup=False, out=print):
113
+ target = Path(target).resolve()
114
+ if git.run(["rev-parse", "--show-toplevel"], target, check=False)[0] != 0:
115
+ raise LockmasterError("not a git repository: {}".format(target))
116
+ kit = find_kit()
117
+ old = read_lock(target)
118
+ old_files = old.get("files", {})
119
+ plan = kit_files(kit)
120
+
121
+ conflicts = []
122
+ for src, rel in plan:
123
+ dst = target / rel
124
+ if dst.exists():
125
+ cur = sha(dst)
126
+ if cur != sha(src) and old_files.get(rel) != cur:
127
+ conflicts.append(rel)
128
+ if conflicts and not backup:
129
+ raise LockmasterError(
130
+ "these files exist and were not written by a lockmaster install; nothing was changed:\n "
131
+ + "\n ".join(conflicts)
132
+ + "\nrun again with --backup to move each aside to <file>.pre-lockmaster first")
133
+ for rel in conflicts:
134
+ dst = target / rel
135
+ aside, n = dst.with_name(dst.name + ".pre-lockmaster"), 1
136
+ while aside.exists():
137
+ aside, n = dst.with_name("{}.pre-lockmaster.{}".format(dst.name, n)), n + 1
138
+ os.rename(str(dst), str(aside))
139
+ out(" moved aside: {} -> {}".format(rel, aside.relative_to(target).as_posix()))
140
+
141
+ files = {}
142
+ for src, rel in plan:
143
+ dst = target / rel
144
+ dst.parent.mkdir(parents=True, exist_ok=True)
145
+ data = src.read_bytes()
146
+ if not dst.exists() or dst.read_bytes() != data:
147
+ dst.write_bytes(data)
148
+ if "hooks" in Path(rel).parts and dst.suffix in (".sh", ".py"):
149
+ dst.chmod(dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
150
+ files[rel] = sha(dst)
151
+
152
+ # A file an earlier kit installed and this one does not: removed only if untouched.
153
+ for rel, digest in old_files.items():
154
+ dst = target / rel
155
+ if rel not in files and dst.is_file():
156
+ if sha(dst) == digest:
157
+ dst.unlink()
158
+ out(" removed (no longer in the kit): " + rel)
159
+ else:
160
+ out(" kept, edited by hand and no longer in the kit: " + rel)
161
+
162
+ kit_settings = json.loads((kit / "claude" / "settings.json").read_text(encoding="utf-8"))
163
+ settings_path = target / SETTINGS
164
+ current = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.is_file() else {}
165
+ merged = merge_settings(current, kit_settings, old.get("settings", []))
166
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
167
+ text = json.dumps(merged, indent=2, ensure_ascii=False) + "\n"
168
+ if not settings_path.is_file() or settings_path.read_text(encoding="utf-8") != text:
169
+ settings_path.write_text(text, encoding="utf-8")
170
+
171
+ lock = {"version": __version__, "kit": kit_digest(kit), "files": files,
172
+ "settings": sorted(_commands(kit_settings))}
173
+ (target / LOCK).write_text(json.dumps(lock, indent=2, sort_keys=True) + "\n", encoding="utf-8")
174
+ out("installed kit {} into {}: {} files, settings merged".format(lock["kit"][:12], target, len(files)))
175
+
176
+ from lockmaster.repo import Repo
177
+ from lockmaster import drivers
178
+ repo = Repo(target)
179
+ if (target / "lockmaster.toml").is_file():
180
+ from lockmaster import surface
181
+ if surface.foreign(repo) or not surface.can_unlink(repo):
182
+ # Decision 10: a driver registered here would name this surface's
183
+ # paths; the machine that commits registers it on its first landing.
184
+ if drivers.write_attributes(repo.root, repo.config.merge):
185
+ out(" .gitattributes: lockmaster's [merge] block written — commit it")
186
+ out(" merge drivers not registered from here: the next landing on the machine that commits does it")
187
+ else:
188
+ if drivers.install(repo):
189
+ out(" .gitattributes: lockmaster's [merge] block written — commit it")
190
+ out(" merge drivers registered in .git/config")
191
+ made = work_files(target, kit)
192
+ if made:
193
+ out(" work files created (SR-LAW-10) — commit them: " + ", ".join("work/" + f for f in made))
194
+ _guard(repo, out)
195
+ return lock
196
+
197
+
198
+ def work_files(target, kit):
199
+ """Write each of the six work files `target` lacks from the kit's template.
200
+ Never overwrites; returns the names written."""
201
+ made = []
202
+ for name in WORK_FILES:
203
+ dst = Path(target) / "work" / name
204
+ src = kit / "work" / name
205
+ if dst.exists() or dst.is_symlink() or not src.is_file():
206
+ continue
207
+ dst.parent.mkdir(parents=True, exist_ok=True)
208
+ try:
209
+ with open(str(dst), "xb") as fh: # exclusive: a file written meanwhile is kept
210
+ fh.write(src.read_bytes())
211
+ except FileExistsError:
212
+ continue
213
+ made.append(name)
214
+ return made
215
+
216
+
217
+ def _guard(repo, out):
218
+ """The commit guard, when the repository carries scripts/sr-guard.sh (SR-WORK-OWNERSHIP decision 6)."""
219
+ guard = repo.root / "scripts" / "sr-guard.sh"
220
+ if not guard.is_file():
221
+ return
222
+ guard.chmod(guard.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
223
+ hooks = repo.common / "hooks"
224
+ hooks.mkdir(exist_ok=True)
225
+ link = hooks / "commit-msg"
226
+ if link.is_symlink() or link.exists():
227
+ return
228
+ os.symlink(os.path.relpath(str(guard), str(hooks)), str(link))
229
+ out(" commit guard: .git/hooks/commit-msg -> scripts/sr-guard.sh")
@@ -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:]))