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