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.
- lockmaster/__init__.py +3 -0
- lockmaster/__main__.py +3 -0
- lockmaster/cli.py +225 -0
- lockmaster/config.py +195 -0
- lockmaster/doctor.py +146 -0
- lockmaster/drivers.py +93 -0
- lockmaster/generated.py +65 -0
- lockmaster/git.py +102 -0
- lockmaster/install.py +229 -0
- lockmaster/kit/claude/hooks/auto-land.py +286 -0
- lockmaster/kit/claude/hooks/block-main.py +174 -0
- lockmaster/kit/claude/hooks/claim-guard.py +133 -0
- lockmaster/kit/claude/hooks/disk-budget.py +199 -0
- lockmaster/kit/claude/hooks/guard-open-work-inbox.sh +17 -0
- lockmaster/kit/claude/hooks/inject-inbox.sh +54 -0
- lockmaster/kit/claude/hooks/now-ledger.py +371 -0
- lockmaster/kit/claude/hooks/release-locks.sh +34 -0
- lockmaster/kit/claude/hooks/require-progress.sh +15 -0
- lockmaster/kit/claude/hooks/session-lock.sh +58 -0
- lockmaster/kit/claude/hooks/stop-if-blocked.sh +20 -0
- lockmaster/kit/claude/hooks/work-files.py +128 -0
- lockmaster/kit/claude/hooks/worktree-cleanup.py +159 -0
- lockmaster/kit/claude/settings.json +197 -0
- lockmaster/kit/claude/skills/docket/SKILL.md +89 -0
- lockmaster/kit/claude/skills/landing/SKILL.md +68 -0
- lockmaster/kit/work/BACKLOG.md +12 -0
- lockmaster/kit/work/INTERVIEW.md +22 -0
- lockmaster/kit/work/LESSONS.md +12 -0
- lockmaster/kit/work/MACHINE.md +20 -0
- lockmaster/kit/work/OPEN-WORK.md +24 -0
- lockmaster/kit/work/WORKLOG.md +24 -0
- lockmaster/land.py +1328 -0
- lockmaster/merge.py +99 -0
- lockmaster/now.py +92 -0
- lockmaster/repo.py +268 -0
- lockmaster/status.py +119 -0
- lockmaster/surface.py +102 -0
- lockmaster-0.0.1.dist-info/METADATA +130 -0
- lockmaster-0.0.1.dist-info/RECORD +43 -0
- lockmaster-0.0.1.dist-info/WHEEL +5 -0
- lockmaster-0.0.1.dist-info/entry_points.txt +2 -0
- lockmaster-0.0.1.dist-info/licenses/LICENSE +23 -0
- lockmaster-0.0.1.dist-info/top_level.txt +1 -0
lockmaster/generated.py
ADDED
|
@@ -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:]))
|