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
lockmaster/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """lockmaster — lands AI-agent changes into main one at a time (SR-ARCHITECTURE)."""
2
+
3
+ __version__ = "0.0.1"
lockmaster/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from lockmaster.cli import main
2
+
3
+ raise SystemExit(main())
lockmaster/cli.py ADDED
@@ -0,0 +1,225 @@
1
+ """The `lockmaster` command — SR-ARCHITECTURE decision 14.
2
+
3
+ Python >= 3.9, standard library only (decision 2). Exit codes: 0 done, 1 a
4
+ change went red or a check found a problem, 2 refused or misused.
5
+ """
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+ from lockmaster import __version__
11
+
12
+ # command -> what it does (decision 14's table)
13
+ COMMANDS = {
14
+ "start": "branch + worktree + claim for a work item",
15
+ "claim": "widen the claim: claim add <path>…",
16
+ "submit": "commit the claimed paths, join the queue, start landing",
17
+ "run": "land queued changes, one at a time",
18
+ "queue": "what is waiting, landed or red",
19
+ "status": "live sessions, changes, worktrees, disk use; --now prints work/NOW.md",
20
+ "abandon": "drop a change; remove its worktree only if nothing is lost",
21
+ "save": "land edits made in main's checkout; hand them to Claude Code where this surface cannot commit",
22
+ "pickup": "land the edits other surfaces handed over",
23
+ "sync": "land this session's open changes now (the kit's hooks, at the end of a turn)",
24
+ "can-commit": "say whether this surface can commit (exit 0) or must hand off (exit 1)",
25
+ "doctor": "stale locks, orphan worktrees, unmerged branches, kit mismatch",
26
+ "install": "install the kit into a repository, pinned",
27
+ "regen": "rebuild the generated files lockmaster.toml lists",
28
+ "drivers": "register the structural merge drivers; write .gitattributes from [merge]",
29
+ "merge-driver": "what git runs for a structural merge (installed by `drivers`)",
30
+ }
31
+
32
+
33
+ def _parser():
34
+ p = argparse.ArgumentParser(prog="lockmaster",
35
+ description="Lands AI-agent changes into main one at a time, without ever losing work.")
36
+ p.add_argument("--version", action="version", version="lockmaster " + __version__)
37
+ sub = p.add_subparsers(dest="command", metavar="<command>")
38
+
39
+ s = sub.add_parser("start", help=COMMANDS["start"])
40
+ s.add_argument("item")
41
+ s.add_argument("--paths", nargs="+", required=True, metavar="PATH")
42
+
43
+ s = sub.add_parser("claim", help=COMMANDS["claim"])
44
+ s.add_argument("action", choices=["add"])
45
+ s.add_argument("paths", nargs="+")
46
+ s.add_argument("--item")
47
+
48
+ s = sub.add_parser("submit", help=COMMANDS["submit"])
49
+ s.add_argument("-m", "--message", default="")
50
+ s.add_argument("--item")
51
+ s.add_argument("--no-run", action="store_true", help="queue only; do not start landing")
52
+
53
+ sub.add_parser("run", help=COMMANDS["run"])
54
+ sub.add_parser("queue", help=COMMANDS["queue"])
55
+
56
+ s = sub.add_parser("status", help=COMMANDS["status"])
57
+ s.add_argument("--now", action="store_true", help="print work/NOW.md as it should be")
58
+ s.add_argument("--write-now", action="store_true", help="write work/NOW.md")
59
+
60
+ s = sub.add_parser("abandon", help=COMMANDS["abandon"])
61
+ s.add_argument("--item")
62
+
63
+ s = sub.add_parser("save", help=COMMANDS["save"])
64
+ s.add_argument("paths", nargs="+", metavar="PATH")
65
+ s.add_argument("-m", "--message", required=True)
66
+ s.add_argument("--item", help="the work item, when there is one (default: save-<time>)")
67
+ s.add_argument("--by", default="", help="who hands it over, for the record (e.g. \"Cowork\")")
68
+ s.add_argument("--handoff", action="store_true", help="hand off even where this surface could commit")
69
+
70
+ sub.add_parser("pickup", help=COMMANDS["pickup"])
71
+
72
+ s = sub.add_parser("sync", help=COMMANDS["sync"])
73
+ s.add_argument("--session", default="")
74
+ s.add_argument("--cwd", default="")
75
+ s.add_argument("--final", action="store_true", help="the session is ending: land and clean up")
76
+ s.add_argument("--no-adopt", action="store_true", help="do not adopt a worktree lockmaster did not create")
77
+ s.add_argument("--pickup", action="store_true", help="then land the handoffs too")
78
+
79
+ sub.add_parser("can-commit", help=COMMANDS["can-commit"])
80
+
81
+ sub.add_parser("doctor", help=COMMANDS["doctor"])
82
+
83
+ s = sub.add_parser("install", help=COMMANDS["install"])
84
+ s.add_argument("repo", nargs="?", default=".")
85
+ s.add_argument("--backup", action="store_true",
86
+ help="move each file the kit would replace aside to <file>.pre-lockmaster")
87
+
88
+ sub.add_parser("regen", help=COMMANDS["regen"])
89
+ sub.add_parser("drivers", help=COMMANDS["drivers"])
90
+
91
+ s = sub.add_parser("merge-driver", help=COMMANDS["merge-driver"])
92
+ s.add_argument("args", nargs=argparse.REMAINDER)
93
+ return p
94
+
95
+
96
+ def main(argv=None):
97
+ from lockmaster.config import ConfigError
98
+ from lockmaster.repo import LockmasterError
99
+
100
+ args = _parser().parse_args(argv)
101
+ if not args.command:
102
+ _parser().print_help()
103
+ return 0
104
+ try:
105
+ return _dispatch(args)
106
+ except (LockmasterError, ConfigError) as e:
107
+ print("lockmaster {}: {}".format(args.command, e), file=sys.stderr)
108
+ return 2
109
+
110
+
111
+ def _out(text):
112
+ print(text)
113
+
114
+
115
+ def _save(repo, args):
116
+ from lockmaster import land, surface
117
+ paths = land._relative(repo, args.paths)
118
+ ok, why = (False, "asked to hand off") if args.handoff else surface.can_commit(repo)
119
+ if ok:
120
+ change = land.save(repo, paths, args.message, item=args.item, out=_out)
121
+ if change is None:
122
+ print("nothing uncommitted under " + ", ".join(paths))
123
+ return 0
124
+ return 1 if change["status"] == "red" else 0
125
+ h = land.handoff(repo, paths, args.message, item=args.item, by=args.by)
126
+ if h is None:
127
+ print("nothing uncommitted under " + ", ".join(paths))
128
+ return 0
129
+ print("handed off {}: {} file{} ({}).".format(h["item"], len(h["files"]), "s" * (len(h["files"]) != 1), why))
130
+ print("The next Claude Code session in this repository lands it — nothing else to do here.")
131
+ return 0
132
+
133
+
134
+ def _dispatch(args):
135
+ from lockmaster import land
136
+ from lockmaster.repo import Repo
137
+
138
+ if args.command == "merge-driver":
139
+ from lockmaster import merge
140
+ return merge.main(args.args)
141
+ if args.command == "install":
142
+ from lockmaster import install
143
+ install.install(args.repo, backup=args.backup, out=_out)
144
+ return 0
145
+
146
+ repo = Repo(os.getcwd())
147
+ if args.command == "start":
148
+ change, overlaps = land.start(repo, args.item, args.paths, os.environ.get("CLAUDE_SESSION_ID", ""))
149
+ print("started {} on {}: work in {}".format(change["item"], change["branch"], change["worktree"]))
150
+ print("claim: " + ", ".join(change["paths"]))
151
+ for other, path in overlaps:
152
+ print("note: {} also claims {} — whichever lands second may go red".format(other, path))
153
+ return 0
154
+ if args.command == "claim":
155
+ change = land.claim_add(repo, repo.current_change(args.item), args.paths)
156
+ print("claim of {}: {}".format(change["item"], ", ".join(change["paths"])))
157
+ return 0
158
+ if args.command == "submit":
159
+ change = land.submit(repo, repo.current_change(args.item), args.message, out=_out)
160
+ print("queued " + change["item"])
161
+ if args.no_run:
162
+ return 0
163
+ land.run(repo, out=_out)
164
+ return 1 if repo.load(change["item"])["status"] == "red" else 0
165
+ if args.command == "run":
166
+ land.run(repo, out=_out)
167
+ return 0
168
+ if args.command == "queue":
169
+ print("\n".join(land.queue_lines(repo)))
170
+ return 0
171
+ if args.command == "abandon":
172
+ land.abandon(repo, repo.current_change(args.item), out=_out)
173
+ return 0
174
+ if args.command == "save":
175
+ return _save(repo, args)
176
+ if args.command == "pickup":
177
+ results = land.pickup(repo, out=_out)
178
+ if not results:
179
+ print("no handoffs")
180
+ return 1 if any(r in ("red", "failed") for _, r, _ in results) else 0
181
+ if args.command == "sync":
182
+ queued = land.sync(repo, session=args.session or os.environ.get("CLAUDE_SESSION_ID", ""),
183
+ cwd=args.cwd or None, final=args.final, adopt_cwd=not args.no_adopt, out=_out)
184
+ if not queued:
185
+ print("nothing to land")
186
+ if args.pickup:
187
+ land.pickup(repo, out=_out)
188
+ return 0
189
+ if args.command == "can-commit":
190
+ from lockmaster import surface
191
+ ok, why = surface.can_commit(repo)
192
+ print("yes — this surface lands its own changes" if ok else "no — {}; `lockmaster save` hands "
193
+ "edits to the next Claude Code session".format(why))
194
+ return 0 if ok else 1
195
+ if args.command == "status":
196
+ from lockmaster import now, status
197
+ if args.write_now:
198
+ print("work/NOW.md " + ("written" if now.write_if_present(repo) else "not kept here"))
199
+ return 0
200
+ if args.now:
201
+ sys.stdout.write(status.now_text(repo))
202
+ return 0
203
+ print("\n".join(status.report(repo)))
204
+ return 0
205
+ if args.command == "doctor":
206
+ from lockmaster import doctor
207
+ problems = doctor.check(repo)
208
+ print("\n".join(problems) if problems else "all well")
209
+ return 1 if problems else 0
210
+ if args.command == "regen":
211
+ from lockmaster import generated
212
+ done = generated.regen(repo.toplevel, repo.config)
213
+ print("rebuilt: " + (", ".join(done) if done else "nothing — lockmaster.toml lists no [generated] paths"))
214
+ return 0
215
+ if args.command == "drivers":
216
+ from lockmaster import drivers
217
+ changed = drivers.install(repo)
218
+ print("merge drivers registered in .git/config"
219
+ + ("; .gitattributes written — commit it" if changed else ""))
220
+ return 0
221
+ return 2
222
+
223
+
224
+ if __name__ == "__main__":
225
+ raise SystemExit(main())
lockmaster/config.py ADDED
@@ -0,0 +1,195 @@
1
+ """`lockmaster.toml` — SR-ARCHITECTURE decision 13.
2
+
3
+ [main]
4
+ branch = "main" # the branch lockmaster lands onto
5
+
6
+ [checks] # name = command; every one must pass
7
+ tests = "python3 -m unittest discover -s tests"
8
+
9
+ [generated] # path or glob = the command that rebuilds it
10
+ "CHANGELOG.md" = "python3 scripts/changelog.py"
11
+
12
+ [merge] # path or glob = structural driver
13
+ "work/WORKLOG.md" = "append-newest-first"
14
+
15
+ [land] # optional
16
+ auto = "on" # "off": no landing at the end of a turn (decision 20)
17
+
18
+ Read with `tomllib` on Python >= 3.11 and with the subset reader below on
19
+ 3.9-3.10. Both feed the same validation, so a file means the same thing on
20
+ every Python: four tables of string values and nothing else. Anything outside
21
+ that subset is a loud error, never a guess (W-03 hazard).
22
+ """
23
+ import re
24
+
25
+ DRIVERS = ("append-newest-first", "table-rows", "append")
26
+ TABLES = ("main", "checks", "generated", "merge", "land")
27
+ AUTO = ("on", "off")
28
+ FILENAME = "lockmaster.toml"
29
+
30
+
31
+ class ConfigError(Exception):
32
+ pass
33
+
34
+
35
+ class Config:
36
+ def __init__(self, main="main", checks=None, generated=None, merge=None, auto="on"):
37
+ self.main = main
38
+ self.auto_land = auto == "on"
39
+ self.checks = dict(checks or {})
40
+ self.generated = dict(generated or {})
41
+ self.merge = dict(merge or {})
42
+
43
+ def as_dict(self):
44
+ return {"main": {"branch": self.main}, "checks": self.checks,
45
+ "generated": self.generated, "merge": self.merge,
46
+ "land": {"auto": "on" if self.auto_land else "off"}}
47
+
48
+
49
+ # ---------------------------------------------------------------- the subset reader
50
+
51
+ _BARE = re.compile(r"[A-Za-z0-9_-]+")
52
+ _ESCAPES = {"b": "\b", "t": "\t", "n": "\n", "f": "\f", "r": "\r", '"': '"', "\\": "\\"}
53
+
54
+
55
+ def _string(s, i, where):
56
+ """Parse a basic ("...") or literal ('...') string at s[i]; return (value, next index)."""
57
+ q = s[i]
58
+ if s.startswith(q * 3, i):
59
+ raise ConfigError(where + ": multi-line strings are outside lockmaster.toml's subset")
60
+ i += 1
61
+ buf = []
62
+ while i < len(s):
63
+ c = s[i]
64
+ if c == q:
65
+ return "".join(buf), i + 1
66
+ if q == '"' and c == "\\":
67
+ nxt = s[i + 1:i + 2]
68
+ if nxt in _ESCAPES:
69
+ buf.append(_ESCAPES[nxt])
70
+ i += 2
71
+ continue
72
+ if nxt in ("u", "U"):
73
+ n = 4 if nxt == "u" else 8
74
+ hexs = s[i + 2:i + 2 + n]
75
+ if not re.fullmatch(r"[0-9A-Fa-f]{%d}" % n, hexs):
76
+ raise ConfigError(where + ": bad \\" + nxt + " escape")
77
+ buf.append(chr(int(hexs, 16)))
78
+ i += 2 + n
79
+ continue
80
+ raise ConfigError(where + ": unknown escape \\" + nxt)
81
+ buf.append(c)
82
+ i += 1
83
+ raise ConfigError(where + ": unterminated string")
84
+
85
+
86
+ def _rest_is_comment(s, i, where):
87
+ rest = s[i:].strip()
88
+ if rest and not rest.startswith("#"):
89
+ raise ConfigError(where + ": unexpected `" + rest + "`")
90
+
91
+
92
+ def parse_subset(text):
93
+ """The subset of TOML lockmaster.toml uses: `[table]` headers and
94
+ `key = "string"` pairs, keys bare or quoted, comments. Returns nested dicts
95
+ shaped like tomllib's result."""
96
+ data, table = {}, None
97
+ for n, raw in enumerate(text.splitlines(), 1):
98
+ where = "lockmaster.toml line {}".format(n)
99
+ line = raw.strip()
100
+ if not line or line.startswith("#"):
101
+ continue
102
+ if line.startswith("["):
103
+ m = re.match(r"\[\s*([A-Za-z0-9_-]+)\s*\]", line)
104
+ if not m or line.startswith("[["):
105
+ raise ConfigError(where + ": only plain `[table]` headers are in the subset")
106
+ _rest_is_comment(line, m.end(), where)
107
+ name = m.group(1)
108
+ if name in data:
109
+ raise ConfigError(where + ": table [" + name + "] appears twice")
110
+ table = data[name] = {}
111
+ continue
112
+ if table is None:
113
+ raise ConfigError(where + ": a key outside any [table]")
114
+ if line[0] in "\"'":
115
+ key, i = _string(line, 0, where)
116
+ else:
117
+ m = _BARE.match(line)
118
+ if not m:
119
+ raise ConfigError(where + ": cannot read a key here")
120
+ key, i = m.group(0), m.end()
121
+ while i < len(line) and line[i] in " \t":
122
+ i += 1
123
+ if line[i:i + 1] == ".":
124
+ raise ConfigError(where + ": dotted keys are outside the subset")
125
+ if line[i:i + 1] != "=":
126
+ raise ConfigError(where + ": expected `=` after the key")
127
+ i += 1
128
+ while i < len(line) and line[i] in " \t":
129
+ i += 1
130
+ if line[i:i + 1] not in ("\"", "'"):
131
+ raise ConfigError(where + ": only string values are in the subset (got `" + line[i:] + "`)")
132
+ value, i = _string(line, i, where)
133
+ _rest_is_comment(line, i, where)
134
+ if key in table:
135
+ raise ConfigError(where + ": key `" + key + "` appears twice")
136
+ table[key] = value
137
+ return data
138
+
139
+
140
+ # ---------------------------------------------------------------- one validation
141
+
142
+ def parse(text):
143
+ try:
144
+ import tomllib # Python >= 3.11
145
+ except ImportError:
146
+ data = parse_subset(text)
147
+ else:
148
+ try:
149
+ data = tomllib.loads(text)
150
+ except tomllib.TOMLDecodeError as e:
151
+ raise ConfigError("lockmaster.toml: " + str(e))
152
+ # Valid TOML that 3.9 would refuse is refused here too: same file, same result.
153
+ if parse_subset(text) != data:
154
+ raise ConfigError("lockmaster.toml: uses TOML outside lockmaster's subset")
155
+ return validate(data)
156
+
157
+
158
+ def validate(data):
159
+ for name, table in data.items():
160
+ if name not in TABLES:
161
+ raise ConfigError("lockmaster.toml: unknown table [" + name + "]; the tables are "
162
+ + ", ".join("[" + t + "]" for t in TABLES))
163
+ if not isinstance(table, dict):
164
+ raise ConfigError("lockmaster.toml: `" + name + "` must be a [table]")
165
+ for key, value in table.items():
166
+ if not isinstance(value, str):
167
+ raise ConfigError("lockmaster.toml: [{}] {} is not a string; only strings are in "
168
+ "the subset".format(name, key))
169
+ main = data.get("main", {})
170
+ extra = set(main) - {"branch"}
171
+ if extra:
172
+ raise ConfigError("lockmaster.toml: [main] knows only `branch` (got " + ", ".join(sorted(extra)) + ")")
173
+ merge = data.get("merge", {})
174
+ for path, driver in merge.items():
175
+ if driver not in DRIVERS:
176
+ raise ConfigError("lockmaster.toml: [merge] {} = \"{}\" is no driver; the drivers are {}".format(
177
+ path, driver, ", ".join(DRIVERS)))
178
+ land = data.get("land", {})
179
+ extra = set(land) - {"auto"}
180
+ if extra:
181
+ raise ConfigError("lockmaster.toml: [land] knows only `auto` (got " + ", ".join(sorted(extra)) + ")")
182
+ auto = land.get("auto", "on")
183
+ if auto not in AUTO:
184
+ raise ConfigError("lockmaster.toml: [land] auto = \"{}\"; it is \"on\" or \"off\"".format(auto))
185
+ return Config(main.get("branch", "main"), data.get("checks"), data.get("generated"), merge, auto)
186
+
187
+
188
+ def load(path):
189
+ """The Config at `path`, or the defaults when there is no file."""
190
+ try:
191
+ with open(str(path), encoding="utf-8") as fh:
192
+ text = fh.read()
193
+ except FileNotFoundError:
194
+ return Config()
195
+ return parse(text)
lockmaster/doctor.py ADDED
@@ -0,0 +1,146 @@
1
+ """`lockmaster doctor` — SR-ARCHITECTURE decision 14: stale locks, orphan
2
+ worktrees, unmerged branches, and an installed kit that does not match this
3
+ lockmaster's. Read-only: it says what is wrong and what to type, and fixes nothing.
4
+ """
5
+ import os
6
+
7
+ from lockmaster import __version__
8
+ from lockmaster import git
9
+ from lockmaster import install as install_mod
10
+ from lockmaster.merge import DRIVERS
11
+ from lockmaster.repo import LANDING, LIVE, WORKTREES_DIR, LandLock
12
+
13
+
14
+ def check(repo):
15
+ """One line per problem found; empty when all is well."""
16
+ out = []
17
+ lock = LandLock(repo)
18
+ if lock.stale():
19
+ out.append("stale land lock {} (holder {} is not running) — the next `lockmaster run` takes it over".format(
20
+ lock.dir, lock.holder() or "unknown"))
21
+ landing = repo.state / "landing"
22
+ if landing.is_dir():
23
+ for d in sorted(landing.iterdir()):
24
+ out.append("a landing worktree was left behind: {} — `git worktree remove` it once you have "
25
+ "looked".format(d))
26
+
27
+ changes = repo.changes()
28
+ live = [c for c in changes if c["status"] in LIVE]
29
+ for c in live:
30
+ if not os.path.isdir(c["worktree"]):
31
+ out.append("change {} is {} but its worktree {} is gone — `lockmaster abandon --item {}`".format(
32
+ c["item"], c["status"], c["worktree"], c["item"]))
33
+ if c["status"] == LANDING and not lock.dir.is_dir():
34
+ out.append("change {} was landing when its lander stopped — `lockmaster run` queues it again".format(
35
+ c["item"]))
36
+
37
+ from lockmaster import land
38
+ for item, st, paths in land.checkout_overlaps(repo): # W-10 step 3
39
+ out.append("main's checkout has uncommitted edits to {} that change {} ({}) claims — its landing "
40
+ "waits until they are committed elsewhere, saved (`lockmaster save`) or undone".format(
41
+ ", ".join(paths), item, st))
42
+ for c in live:
43
+ if c["status"] == "waiting":
44
+ out.append("change {} is waiting: {}".format(c["item"], (c.get("reason") or "").splitlines()[0]))
45
+
46
+ # A landed or abandoned change whose cleanup kept something: kept on purpose, nothing lost.
47
+ kept = [c for c in changes if c["status"] not in LIVE and c.get("cleanup")
48
+ and (os.path.isdir(c["worktree"]) or git.ok(["rev-parse", "--verify", "--quiet",
49
+ "refs/heads/" + c["branch"]], repo.root))]
50
+ for c in kept:
51
+ out.append("change {} is {}, and its cleanup kept: {} — look, then `git worktree remove` and "
52
+ "`git branch -d` by hand, or the decider rules (SR-WORK-CLEANUP decision 3)".format(
53
+ c["item"], c["status"], "; ".join(c["cleanup"])))
54
+
55
+ owned = {os.path.realpath(c["worktree"]) for c in live + kept}
56
+ root_wt = os.path.realpath(str(repo.root / WORKTREES_DIR))
57
+ for t in git.worktrees(repo.root):
58
+ p = os.path.realpath(t["path"])
59
+ if p.startswith(root_wt + os.sep) and p not in owned:
60
+ out.append("orphan worktree {} [{}]: no live change owns it".format(t["path"], t["branch"] or "detached"))
61
+
62
+ live_branches = {c["branch"] for c in live + kept}
63
+ for b in git.out(["for-each-ref", "--format=%(refname:short)", "refs/heads/lm/"], repo.root).split():
64
+ if b in live_branches:
65
+ continue
66
+ if git.ok(["merge-base", "--is-ancestor", b, repo.main], repo.root):
67
+ out.append("branch {} is in {} and no change owns it — `git branch -d {}`".format(b, repo.main, b))
68
+ else:
69
+ out.append("unmerged branch {}: no live change owns it and its commits are not in {} — "
70
+ "the decider rules (SR-WORK-CLEANUP decision 3)".format(b, repo.main))
71
+
72
+ if repo.config.merge:
73
+ missing = [d for d in DRIVERS
74
+ if not git.ok(["config", "--get", "merge.lockmaster-{}.driver".format(d)], repo.root)]
75
+ if missing:
76
+ out.append("merge drivers not registered ({}) — `lockmaster drivers`".format(", ".join(missing)))
77
+ from lockmaster import drivers as drivers_mod
78
+ stale = drivers_mod.stale(repo)
79
+ if stale:
80
+ out.append("merge drivers registered with another machine's paths ({}) — the next landing re-points "
81
+ "them, or `lockmaster drivers`".format(", ".join(stale)))
82
+
83
+ out += kit_problems(repo.root)
84
+ out += work_problems(repo)
85
+ return out
86
+
87
+
88
+ # SR-LAW-10 / W-15: this many landings on main with no WORKLOG.md change reads as drift.
89
+ STALE_LANDINGS = 10
90
+
91
+
92
+ def work_problems(repo):
93
+ """A missing work file, and a worklog main has moved past (SR-LAW-10). Only
94
+ where the kit is installed; whether a lesson, a machine quirk or a backlog
95
+ row should have been written is judgment, never checked here."""
96
+ if not install_mod.read_lock(repo.root):
97
+ return []
98
+ out = []
99
+ missing = [f for f in install_mod.WORK_FILES if not (repo.root / "work" / f).is_file()]
100
+ if missing:
101
+ out.append("work file{} missing: {} — `lockmaster install {}` creates {} (SR-LAW-10)".format(
102
+ "s" * (len(missing) > 1), ", ".join("work/" + f for f in missing), repo.root,
103
+ "them" if len(missing) > 1 else "it"))
104
+ if "WORKLOG.md" not in missing:
105
+ n = landings_since_worklog(repo)
106
+ if n is not None and n > STALE_LANDINGS:
107
+ out.append("work/WORKLOG.md is stale: {} commits on {} since it last changed (more than {}) — "
108
+ "each session appends an entry (SR-LAW-10)".format(n, repo.main, STALE_LANDINGS))
109
+ return out
110
+
111
+
112
+ def landings_since_worklog(repo):
113
+ """First-parent commits on main since the last one that changed work/WORKLOG.md;
114
+ None when main has no commit that did."""
115
+ code, last, _ = git.run(["log", "-1", "--format=%H", repo.main, "--", "work/WORKLOG.md"], repo.root, check=False)
116
+ last = last.strip()
117
+ if code != 0 or not last:
118
+ return None
119
+ code, n, _ = git.run(["rev-list", "--count", "--first-parent", last + ".." + repo.main], repo.root, check=False)
120
+ return int(n.strip()) if code == 0 and n.strip().isdigit() else None
121
+
122
+
123
+ def kit_problems(root):
124
+ lock = install_mod.read_lock(root)
125
+ if not lock:
126
+ if (root / ".claude" / "hooks").is_dir():
127
+ return ["the kit in .claude/ was not installed by `lockmaster install` (no {})".format(install_mod.LOCK)]
128
+ return []
129
+ out = []
130
+ try:
131
+ kit = install_mod.find_kit()
132
+ except Exception: # noqa: BLE001 — no kit to compare against is not a problem of the repo
133
+ kit = None
134
+ if lock.get("version") != __version__:
135
+ out.append("installed kit is from lockmaster {}, this is {} — `lockmaster install {}`".format(
136
+ lock.get("version"), __version__, root))
137
+ elif kit is not None and lock.get("kit") != install_mod.kit_digest(kit):
138
+ out.append("installed kit {} differs from this lockmaster's {} — `lockmaster install {}`".format(
139
+ (lock.get("kit") or "?")[:12], install_mod.kit_digest(kit)[:12], root))
140
+ for rel, digest in sorted(lock.get("files", {}).items()):
141
+ f = root / rel
142
+ if not f.is_file():
143
+ out.append("installed file missing: " + rel)
144
+ elif install_mod.sha(f) != digest:
145
+ out.append("installed file edited by hand: {} — edit the kit, then reinstall".format(rel))
146
+ return out
lockmaster/drivers.py ADDED
@@ -0,0 +1,93 @@
1
+ """Installing the structural merge drivers — SR-ARCHITECTURE decision 10.
2
+
3
+ Each driver is registered in the repository's `.git/config` as
4
+ `merge.lockmaster-<driver>`, and `.gitattributes` gets one line per
5
+ `lockmaster.toml` `[merge]` path, inside a block lockmaster owns. Usable from
6
+ plain git: `git merge` and `git cherry-pick` call the drivers too.
7
+ """
8
+ import shlex
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from lockmaster import git
13
+ from lockmaster.merge import DRIVERS
14
+
15
+ BEGIN = "# lockmaster:begin — written by `lockmaster drivers` from lockmaster.toml [merge]; edit that, not this"
16
+ END = "# lockmaster:end"
17
+
18
+
19
+ def driver_command(name):
20
+ script = Path(__file__).with_name("merge.py").resolve()
21
+ return "{} {} {} %O %A %B %P".format(shlex.quote(sys.executable), shlex.quote(str(script)), name)
22
+
23
+
24
+ def attributes_block(merge):
25
+ lines = [BEGIN]
26
+ for path, driver in merge.items():
27
+ if any(c.isspace() for c in path):
28
+ raise ValueError("a [merge] path with whitespace cannot go into .gitattributes: " + path)
29
+ lines.append("{} merge=lockmaster-{}".format(path, driver))
30
+ return lines + [END]
31
+
32
+
33
+ def write_attributes(root, merge):
34
+ """Replace lockmaster's block in `.gitattributes` (or add it); everything
35
+ else in the file is kept as it is. Returns True when the file changed."""
36
+ path = Path(root) / ".gitattributes"
37
+ old = path.read_text(encoding="utf-8") if path.exists() else ""
38
+ lines = old.splitlines()
39
+ if BEGIN.split(" —")[0] in "\n".join(lines):
40
+ start = next(i for i, l in enumerate(lines) if l.startswith(BEGIN.split(" —")[0]))
41
+ end = next((i for i in range(start, len(lines)) if lines[i] == END), len(lines) - 1)
42
+ lines = lines[:start] + lines[end + 1:]
43
+ while lines and not lines[-1].strip():
44
+ lines.pop()
45
+ block = attributes_block(merge) if merge else []
46
+ new_lines = lines + ([""] if lines and block else []) + block
47
+ new = "\n".join(new_lines) + ("\n" if new_lines else "")
48
+ if new == old:
49
+ return False
50
+ if new:
51
+ path.write_text(new, encoding="utf-8")
52
+ elif path.exists():
53
+ path.write_text("", encoding="utf-8")
54
+ return True
55
+
56
+
57
+ def stale(repo):
58
+ """The drivers whose registered command cannot run on this machine — its
59
+ interpreter or merge.py is missing here, as when `install` ran on another
60
+ surface (the Cowork bridge sees the repository at another path). Only
61
+ drivers that are registered count; an unregistered one is `doctor`'s."""
62
+ out = []
63
+ for name in DRIVERS:
64
+ code, cmd, _ = git.run(["config", "--get", "merge.lockmaster-{}.driver".format(name)], repo.root,
65
+ check=False)
66
+ if code != 0:
67
+ continue
68
+ try:
69
+ words = shlex.split(cmd.strip())
70
+ except ValueError:
71
+ words = []
72
+ if len(words) < 2 or not Path(words[0]).exists() or not Path(words[1]).exists():
73
+ out.append(name)
74
+ return out
75
+
76
+
77
+ def heal(repo):
78
+ """Re-register the drivers when their command points at another machine's
79
+ paths (SR-ARCHITECTURE decision 10). Returns the drivers re-pointed."""
80
+ names = stale(repo)
81
+ for name in names:
82
+ git.run(["config", "merge.lockmaster-{}.driver".format(name), driver_command(name)], repo.root)
83
+ return names
84
+
85
+
86
+ def install(repo):
87
+ """Register every driver in .git/config; write .gitattributes from [merge]."""
88
+ for name in DRIVERS:
89
+ key = "merge.lockmaster-" + name
90
+ git.run(["config", key + ".name", "lockmaster " + name + " (SR-ARCHITECTURE decision 10)"], repo.root)
91
+ git.run(["config", key + ".driver", driver_command(name)], repo.root)
92
+ changed = write_attributes(repo.root, repo.config.merge)
93
+ return changed