@ikon85/agent-workflow-kit 0.19.0 → 0.20.0
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.
- package/.claude/hooks/_hook_utils.py +3 -0
- package/.claude/hooks/branch-context.py +25 -0
- package/.claude/hooks/branch-watch.py +25 -0
- package/.claude/hooks/enforce-worktree-cwd.py +26 -0
- package/.claude/hooks/enforce-worktree-discipline.py +26 -0
- package/.claude/hooks/enforce-worktree.py +26 -0
- package/README.md +12 -0
- package/agent-workflow-kit.package.json +52 -3
- package/package.json +1 -1
- package/scripts/worktree-lifecycle/README.md +37 -0
- package/scripts/worktree-lifecycle/core.py +194 -81
- package/scripts/worktree-lifecycle/profile.py +116 -0
- package/src/lib/bundle.mjs +9 -0
|
@@ -193,6 +193,9 @@ def load_worktree_lifecycle_core():
|
|
|
193
193
|
if existing is not None:
|
|
194
194
|
return existing
|
|
195
195
|
path = Path(__file__).resolve().parents[2] / "scripts" / "worktree-lifecycle" / "core.py"
|
|
196
|
+
module_dir = str(path.parent)
|
|
197
|
+
if module_dir not in sys.path:
|
|
198
|
+
sys.path.insert(0, module_dir)
|
|
196
199
|
spec = importlib.util.spec_from_file_location(_WORKTREE_CORE_MODULE, path)
|
|
197
200
|
if spec is None or spec.loader is None:
|
|
198
201
|
raise ImportError(f"cannot load Worktree Lifecycle core from {path}")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""SessionStart adapter for profile-driven branch and worktree facts."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from _hook_utils import hook_event_output, load_worktree_lifecycle_core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
try:
|
|
13
|
+
json.load(sys.stdin)
|
|
14
|
+
core = load_worktree_lifecycle_core()
|
|
15
|
+
profile = core.load_profile(Path("docs/agents/workflow-capabilities.json"))
|
|
16
|
+
decision = core.evaluate(profile, core.collect_facts(Path.cwd()), "session-start", {})
|
|
17
|
+
except Exception:
|
|
18
|
+
return 0
|
|
19
|
+
if decision.action == "emit":
|
|
20
|
+
print(json.dumps(hook_event_output(decision.event_name, decision.message)))
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PostToolUse adapter for branch-changing shell commands."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from _hook_utils import load_worktree_lifecycle_core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
try:
|
|
13
|
+
payload = json.load(sys.stdin)
|
|
14
|
+
core = load_worktree_lifecycle_core()
|
|
15
|
+
profile = core.load_profile(Path("docs/agents/workflow-capabilities.json"))
|
|
16
|
+
decision = core.evaluate(profile, core.collect_facts(Path.cwd()), "branch-watch", payload)
|
|
17
|
+
except Exception:
|
|
18
|
+
return 0
|
|
19
|
+
if decision.action == "emit":
|
|
20
|
+
print(json.dumps({"systemMessage": decision.message}))
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse adapter for commands that must run in their linked worktree."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from _hook_utils import load_worktree_lifecycle_core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
try:
|
|
13
|
+
payload = json.load(sys.stdin)
|
|
14
|
+
core = load_worktree_lifecycle_core()
|
|
15
|
+
profile = core.load_profile(Path("docs/agents/workflow-capabilities.json"))
|
|
16
|
+
decision = core.evaluate(profile, core.collect_facts(Path.cwd()), "command-cwd", payload)
|
|
17
|
+
except Exception:
|
|
18
|
+
return 0
|
|
19
|
+
if decision.action == "block":
|
|
20
|
+
print(decision.message, file=sys.stderr)
|
|
21
|
+
return 2
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse adapter for issue-branch creation with active worktrees."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from _hook_utils import load_worktree_lifecycle_core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
try:
|
|
13
|
+
payload = json.load(sys.stdin)
|
|
14
|
+
core = load_worktree_lifecycle_core()
|
|
15
|
+
profile = core.load_profile(Path("docs/agents/workflow-capabilities.json"))
|
|
16
|
+
decision = core.evaluate(profile, core.collect_facts(Path.cwd()), "branch-create", payload)
|
|
17
|
+
except Exception:
|
|
18
|
+
return 0
|
|
19
|
+
if decision.action == "block":
|
|
20
|
+
print(decision.message, file=sys.stderr)
|
|
21
|
+
return 2
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse adapter for tracked edits on protected worktrees."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from _hook_utils import load_worktree_lifecycle_core
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
try:
|
|
13
|
+
payload = json.load(sys.stdin)
|
|
14
|
+
core = load_worktree_lifecycle_core()
|
|
15
|
+
profile = core.load_profile(Path("docs/agents/workflow-capabilities.json"))
|
|
16
|
+
decision = core.evaluate(profile, core.collect_facts(Path.cwd()), "edit", payload)
|
|
17
|
+
except Exception:
|
|
18
|
+
return 0
|
|
19
|
+
if decision.action == "block":
|
|
20
|
+
print(decision.message, file=sys.stderr)
|
|
21
|
+
return 2
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
raise SystemExit(main())
|
package/README.md
CHANGED
|
@@ -332,6 +332,18 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
|
|
|
332
332
|
|
|
333
333
|
## Release notes
|
|
334
334
|
|
|
335
|
+
### 0.20.0
|
|
336
|
+
|
|
337
|
+
- added: `.claude/hooks/branch-context.py`
|
|
338
|
+
- added: `.claude/hooks/branch-watch.py`
|
|
339
|
+
- added: `.claude/hooks/enforce-worktree.py`
|
|
340
|
+
- added: `.claude/hooks/enforce-worktree-cwd.py`
|
|
341
|
+
- added: `.claude/hooks/enforce-worktree-discipline.py`
|
|
342
|
+
- added: `scripts/worktree-lifecycle/profile.py`
|
|
343
|
+
- added: `scripts/worktree-lifecycle/README.md`
|
|
344
|
+
- changed: `.claude/hooks/_hook_utils.py`
|
|
345
|
+
- changed: `scripts/worktree-lifecycle/core.py`
|
|
346
|
+
|
|
335
347
|
### 0.19.0
|
|
336
348
|
|
|
337
349
|
- added: `.agents/skills/memory-lifecycle/SKILL.md`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kitVersion": "0.
|
|
2
|
+
"kitVersion": "0.20.0",
|
|
3
3
|
"files": [
|
|
4
4
|
{
|
|
5
5
|
"path": ".agents/skills/ask-matt/SKILL.md",
|
|
@@ -859,10 +859,24 @@
|
|
|
859
859
|
{
|
|
860
860
|
"path": ".claude/hooks/_hook_utils.py",
|
|
861
861
|
"kind": "hook",
|
|
862
|
-
"sha256": "
|
|
862
|
+
"sha256": "58a7a8ad6cd4f1660e95343d7a0c637cf8aee92948a9c554befa5851723b3f48",
|
|
863
863
|
"mode": 420,
|
|
864
864
|
"origin": "kit"
|
|
865
865
|
},
|
|
866
|
+
{
|
|
867
|
+
"path": ".claude/hooks/branch-context.py",
|
|
868
|
+
"kind": "hook",
|
|
869
|
+
"sha256": "96bbe56a292a89cdb76db0670c2c214678fa60c08e3de5b8db507149ba24c018",
|
|
870
|
+
"mode": 493,
|
|
871
|
+
"origin": "kit"
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
"path": ".claude/hooks/branch-watch.py",
|
|
875
|
+
"kind": "hook",
|
|
876
|
+
"sha256": "15c7565790e0b108c44175b7527a4bc2b8b244737d5598081e938d2dea032424",
|
|
877
|
+
"mode": 493,
|
|
878
|
+
"origin": "kit"
|
|
879
|
+
},
|
|
866
880
|
{
|
|
867
881
|
"path": ".claude/hooks/drift-guard.py",
|
|
868
882
|
"kind": "hook",
|
|
@@ -870,6 +884,27 @@
|
|
|
870
884
|
"mode": 493,
|
|
871
885
|
"origin": "kit"
|
|
872
886
|
},
|
|
887
|
+
{
|
|
888
|
+
"path": ".claude/hooks/enforce-worktree-cwd.py",
|
|
889
|
+
"kind": "hook",
|
|
890
|
+
"sha256": "14086536ceb3368a03b84ff550ba5e3db83d6c5198c41c80312507bf6640bb9f",
|
|
891
|
+
"mode": 493,
|
|
892
|
+
"origin": "kit"
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
"path": ".claude/hooks/enforce-worktree-discipline.py",
|
|
896
|
+
"kind": "hook",
|
|
897
|
+
"sha256": "5bd9fc7a7eea5e8b0778e8272e02b5d8c6c121ea3a168ef11a9c39344085e666",
|
|
898
|
+
"mode": 493,
|
|
899
|
+
"origin": "kit"
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
"path": ".claude/hooks/enforce-worktree.py",
|
|
903
|
+
"kind": "hook",
|
|
904
|
+
"sha256": "4a5d0247d89049dde0475680595fd785bfbd1b108cdd9c9697fd74e944f52c8b",
|
|
905
|
+
"mode": 493,
|
|
906
|
+
"origin": "kit"
|
|
907
|
+
},
|
|
873
908
|
{
|
|
874
909
|
"path": ".claude/hooks/skill-drift-hint.py",
|
|
875
910
|
"kind": "hook",
|
|
@@ -2098,7 +2133,21 @@
|
|
|
2098
2133
|
{
|
|
2099
2134
|
"path": "scripts/worktree-lifecycle/core.py",
|
|
2100
2135
|
"kind": "script",
|
|
2101
|
-
"sha256": "
|
|
2136
|
+
"sha256": "36848a16ed7eaed1f731399efd2caf5771c9c5b48331771d6aedcbad2156e62a",
|
|
2137
|
+
"mode": 420,
|
|
2138
|
+
"origin": "kit"
|
|
2139
|
+
},
|
|
2140
|
+
{
|
|
2141
|
+
"path": "scripts/worktree-lifecycle/profile.py",
|
|
2142
|
+
"kind": "script",
|
|
2143
|
+
"sha256": "993b105efd2329b3a206b0c79399aff1c7840017362745966f9b158b1f165f09",
|
|
2144
|
+
"mode": 420,
|
|
2145
|
+
"origin": "kit"
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
"path": "scripts/worktree-lifecycle/README.md",
|
|
2149
|
+
"kind": "doc",
|
|
2150
|
+
"sha256": "59b78826308404497545b0a0ba57ff50f4618be2f6838f64e6a8c18bce83ea7f",
|
|
2102
2151
|
"mode": 420,
|
|
2103
2152
|
"origin": "kit"
|
|
2104
2153
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ikon85/agent-workflow-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Worktree Lifecycle contract
|
|
2
|
+
|
|
3
|
+
The consumer-owned `docs/agents/workflow-capabilities.json` profile activates
|
|
4
|
+
one Worktree Lifecycle. `profile.py` loads that policy. `core.py` is the only
|
|
5
|
+
place that derives repository facts and decides whether an event emits, allows,
|
|
6
|
+
or blocks. Surface adapters translate hook payloads and render the decision;
|
|
7
|
+
they do not carry a second branch regex, worktree traversal, or failure policy.
|
|
8
|
+
|
|
9
|
+
## Profile
|
|
10
|
+
|
|
11
|
+
`worktreeLifecycle` supports:
|
|
12
|
+
|
|
13
|
+
- `enabled`: explicit activation gate.
|
|
14
|
+
- `worktreeRoot`, `branchTemplate`, `pathTemplate`, and `branchRegex`: consumer
|
|
15
|
+
naming and location policy.
|
|
16
|
+
- `mainBranches` and `protectedBranches`: branches guarded in the main checkout.
|
|
17
|
+
- `setupEntry` and ordered `setupSteps`: the portable setup command and project
|
|
18
|
+
setup sequence.
|
|
19
|
+
- `riskyCommandPatterns`: commands that must target the active linked worktree.
|
|
20
|
+
|
|
21
|
+
Unknown or malformed events fail open without changing repository state.
|
|
22
|
+
Security-sensitive, profile-matched edits and commands fail closed only when
|
|
23
|
+
the core proves the target is unsafe.
|
|
24
|
+
|
|
25
|
+
## Adapters
|
|
26
|
+
|
|
27
|
+
| Adapter | Event | Outcome |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `branch-context.py` | SessionStart | emits branch, issue, status, and active-worktree facts |
|
|
30
|
+
| `branch-watch.py` | PostToolUse | emits the same facts after a branch-changing command |
|
|
31
|
+
| `enforce-worktree.py` | PreToolUse | blocks tracked main-checkout edits and cross-worktree leaks |
|
|
32
|
+
| `enforce-worktree-cwd.py` | PreToolUse | blocks verification or Git mutation in the wrong checkout |
|
|
33
|
+
| `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
|
|
34
|
+
|
|
35
|
+
Claude hook wiring and any Codex adaptation consume this same profile and core.
|
|
36
|
+
An adapter may change only the surface event envelope; it must preserve the
|
|
37
|
+
core verdict and message.
|
|
@@ -1,106 +1,219 @@
|
|
|
1
|
-
"""Consumer-neutral
|
|
1
|
+
"""Consumer-neutral Worktree Lifecycle facts and decisions."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import json
|
|
6
5
|
import re
|
|
7
|
-
import subprocess
|
|
8
6
|
from dataclasses import dataclass
|
|
9
7
|
from pathlib import Path
|
|
10
8
|
from typing import Any
|
|
11
9
|
|
|
10
|
+
from profile import (
|
|
11
|
+
LifecycleError,
|
|
12
|
+
WorktreeProfile,
|
|
13
|
+
load_profile,
|
|
14
|
+
local_branch_exists,
|
|
15
|
+
main_worktree,
|
|
16
|
+
registered_worktrees,
|
|
17
|
+
run,
|
|
18
|
+
)
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
_BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:merge|checkout))\b")
|
|
21
|
+
_BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
|
|
16
22
|
|
|
17
23
|
@dataclass(frozen=True)
|
|
18
|
-
class
|
|
19
|
-
root:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
|
|
28
|
-
return _render(self.branch_template, issue, slug, branch_type)
|
|
24
|
+
class RepoFacts:
|
|
25
|
+
root: Path
|
|
26
|
+
main_root: Path
|
|
27
|
+
branch: str
|
|
28
|
+
main_branch: str
|
|
29
|
+
is_main_worktree: bool
|
|
30
|
+
worktrees: tuple[Path, ...]
|
|
31
|
+
changed_count: int
|
|
29
32
|
|
|
30
|
-
def relative_path(self, issue: str, slug: str, branch_type: str) -> Path:
|
|
31
|
-
name = _render(self.path_template, issue, slug, branch_type)
|
|
32
|
-
return Path(self.root) / name
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Decision:
|
|
36
|
+
action: str
|
|
37
|
+
message: str = ""
|
|
38
|
+
event_name: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def collect_facts(cwd: Path) -> RepoFacts:
|
|
42
|
+
root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
|
|
43
|
+
main = main_worktree(root)
|
|
44
|
+
branch = run(["git", "branch", "--show-current"], cwd=root).stdout.strip()
|
|
45
|
+
main_branch = run(
|
|
46
|
+
["git", "-C", str(main), "branch", "--show-current"],
|
|
47
|
+
cwd=root,
|
|
48
|
+
).stdout.strip()
|
|
49
|
+
worktrees = tuple(sorted(registered_worktrees(root)))
|
|
50
|
+
status = run(["git", "status", "--porcelain"], cwd=root).stdout
|
|
51
|
+
return RepoFacts(
|
|
52
|
+
root=root,
|
|
53
|
+
main_root=main,
|
|
54
|
+
branch=branch,
|
|
55
|
+
main_branch=main_branch,
|
|
56
|
+
is_main_worktree=root == main,
|
|
57
|
+
worktrees=worktrees,
|
|
58
|
+
changed_count=len([line for line in status.splitlines() if line]),
|
|
59
|
+
)
|
|
37
60
|
|
|
38
61
|
|
|
39
|
-
def
|
|
40
|
-
|
|
62
|
+
def branch_context(profile: WorktreeProfile, facts: RepoFacts) -> Decision:
|
|
63
|
+
lines = [f"Branch: {facts.branch}", f"Status: {facts.changed_count} uncommitted change(s)"]
|
|
64
|
+
issue = profile.issue_from_branch(facts.branch)
|
|
65
|
+
if issue:
|
|
66
|
+
lines.insert(1, f"Issue: #{issue}")
|
|
67
|
+
elif facts.branch in profile.protected_branches:
|
|
68
|
+
lines.insert(1, "Warning: direct work on a protected branch")
|
|
69
|
+
else:
|
|
70
|
+
lines.insert(1, "Warning: branch has no issue according to the consumer profile")
|
|
71
|
+
if len(facts.worktrees) > 1:
|
|
72
|
+
lines.append(f"Worktrees: {len(facts.worktrees)} active")
|
|
73
|
+
lines.append(f"Setup entry: {profile.setup_entry}")
|
|
74
|
+
return Decision("emit", "\n".join(lines), "SessionStart")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def repo_relative(target: str, root: Path) -> str | None:
|
|
78
|
+
if not target:
|
|
79
|
+
return None
|
|
80
|
+
path = Path(target)
|
|
81
|
+
if not path.is_absolute():
|
|
82
|
+
return target
|
|
41
83
|
try:
|
|
42
|
-
return
|
|
43
|
-
except
|
|
44
|
-
|
|
84
|
+
return str(path.resolve().relative_to(root))
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
45
87
|
|
|
46
88
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
raise LifecycleError(f"cannot load worktree lifecycle profile: {error}") from error
|
|
53
|
-
if raw.get("enabled") is not True:
|
|
54
|
-
raise LifecycleError("worktree lifecycle is not enabled")
|
|
55
|
-
main = tuple(raw.get("mainBranches") or ("main", "master"))
|
|
56
|
-
return WorktreeProfile(
|
|
57
|
-
root=raw.get("worktreeRoot", ".worktrees"),
|
|
58
|
-
branch_template=raw.get("branchTemplate", "{type}/{issue}-{slug}"),
|
|
59
|
-
path_template=raw.get("pathTemplate", "{type}-{issue}-{slug}"),
|
|
60
|
-
main_branches=main,
|
|
61
|
-
protected_branches=tuple(raw.get("protectedBranches") or main),
|
|
62
|
-
setup_steps=tuple(raw.get("setupSteps") or ()),
|
|
63
|
-
branch_regex=raw.get(
|
|
64
|
-
"branchRegex",
|
|
65
|
-
r"^(?:feat|fix|chore|docs)/(?P<issue>\d+)-",
|
|
66
|
-
),
|
|
89
|
+
def is_ignored(root: Path, relative: str) -> bool:
|
|
90
|
+
result = run(
|
|
91
|
+
["git", "check-ignore", "-q", "--", relative],
|
|
92
|
+
cwd=root,
|
|
93
|
+
check=False,
|
|
67
94
|
)
|
|
95
|
+
return result.returncode == 0
|
|
68
96
|
|
|
69
97
|
|
|
70
|
-
def
|
|
71
|
-
command: list[str],
|
|
72
|
-
*,
|
|
73
|
-
cwd: Path,
|
|
74
|
-
check: bool = True,
|
|
75
|
-
) -> subprocess.CompletedProcess[str]:
|
|
76
|
-
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
|
|
77
|
-
if check and result.returncode != 0:
|
|
78
|
-
detail = (result.stderr or result.stdout).strip()
|
|
79
|
-
raise LifecycleError(f"{' '.join(command)} failed: {detail}")
|
|
80
|
-
return result
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def main_worktree(cwd: Path) -> Path:
|
|
84
|
-
output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
|
|
85
|
-
first = next((line for line in output.splitlines() if line.startswith("worktree ")), "")
|
|
86
|
-
if not first:
|
|
87
|
-
raise LifecycleError("not inside a git worktree")
|
|
88
|
-
return Path(first.split(" ", 1)[1]).resolve()
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def registered_worktrees(cwd: Path) -> set[Path]:
|
|
92
|
-
output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
|
|
93
|
-
return {
|
|
94
|
-
Path(line.split(" ", 1)[1]).resolve()
|
|
95
|
-
for line in output.splitlines()
|
|
96
|
-
if line.startswith("worktree ")
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def local_branch_exists(cwd: Path, branch: str) -> bool:
|
|
98
|
+
def is_tracked(root: Path, relative: str) -> bool:
|
|
101
99
|
result = run(
|
|
102
|
-
["git", "
|
|
103
|
-
cwd=
|
|
100
|
+
["git", "ls-files", "--error-unmatch", "--", relative],
|
|
101
|
+
cwd=root,
|
|
104
102
|
check=False,
|
|
105
103
|
)
|
|
106
104
|
return result.returncode == 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def edit_decision(
|
|
108
|
+
profile: WorktreeProfile,
|
|
109
|
+
facts: RepoFacts,
|
|
110
|
+
payload: dict[str, Any],
|
|
111
|
+
) -> Decision:
|
|
112
|
+
if payload.get("tool_name") not in {"Edit", "Write", "MultiEdit"}:
|
|
113
|
+
return Decision("skip")
|
|
114
|
+
target = str((payload.get("tool_input") or {}).get("file_path") or "")
|
|
115
|
+
if facts.is_main_worktree and facts.branch in profile.protected_branches:
|
|
116
|
+
relative = repo_relative(target, facts.root)
|
|
117
|
+
if relative is not None and not is_ignored(facts.root, relative):
|
|
118
|
+
return Decision(
|
|
119
|
+
"block",
|
|
120
|
+
f"Worktree Lifecycle blocked an edit to {relative} on protected branch "
|
|
121
|
+
f"{facts.branch}. Use `{profile.setup_entry}` first.",
|
|
122
|
+
)
|
|
123
|
+
if not facts.is_main_worktree and Path(target).is_absolute():
|
|
124
|
+
relative = repo_relative(target, facts.main_root)
|
|
125
|
+
if (
|
|
126
|
+
relative is not None
|
|
127
|
+
and facts.main_branch in profile.protected_branches
|
|
128
|
+
and is_tracked(facts.main_root, relative)
|
|
129
|
+
and not is_ignored(facts.main_root, relative)
|
|
130
|
+
):
|
|
131
|
+
return Decision(
|
|
132
|
+
"block",
|
|
133
|
+
f"Worktree Lifecycle blocked a cross-worktree edit to {relative} in "
|
|
134
|
+
f"the protected main checkout. Edit the linked worktree copy instead.",
|
|
135
|
+
)
|
|
136
|
+
return Decision("allow")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def targets_linked_worktree(command: str, facts: RepoFacts) -> bool:
|
|
140
|
+
for worktree in facts.worktrees:
|
|
141
|
+
if worktree == facts.main_root:
|
|
142
|
+
continue
|
|
143
|
+
if str(worktree) in command or str(worktree.relative_to(facts.main_root)) in command:
|
|
144
|
+
return True
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def command_decision(
|
|
149
|
+
profile: WorktreeProfile,
|
|
150
|
+
facts: RepoFacts,
|
|
151
|
+
payload: dict[str, Any],
|
|
152
|
+
) -> Decision:
|
|
153
|
+
if payload.get("tool_name") != "Bash":
|
|
154
|
+
return Decision("skip")
|
|
155
|
+
command = str((payload.get("tool_input") or {}).get("command") or "")
|
|
156
|
+
if not command:
|
|
157
|
+
return Decision("skip")
|
|
158
|
+
risky = any(re.search(pattern, command) for pattern in profile.risky_command_patterns)
|
|
159
|
+
if not risky:
|
|
160
|
+
return Decision("allow")
|
|
161
|
+
if re.search(r"\bgit\s+push\s+\S+\s+--delete\s+\S+", command):
|
|
162
|
+
return Decision("allow")
|
|
163
|
+
if targets_linked_worktree(command, facts):
|
|
164
|
+
return Decision("allow")
|
|
165
|
+
if (
|
|
166
|
+
facts.is_main_worktree
|
|
167
|
+
and facts.branch in profile.protected_branches
|
|
168
|
+
and len(facts.worktrees) > 1
|
|
169
|
+
):
|
|
170
|
+
active = ", ".join(path.name for path in facts.worktrees if path != facts.main_root)
|
|
171
|
+
return Decision(
|
|
172
|
+
"block",
|
|
173
|
+
f"Worktree Lifecycle blocked `{command}` in the protected main checkout "
|
|
174
|
+
f"while linked worktrees are active: {active}. Run it in the target worktree.",
|
|
175
|
+
)
|
|
176
|
+
return Decision("allow")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def branch_create_decision(
|
|
180
|
+
profile: WorktreeProfile,
|
|
181
|
+
facts: RepoFacts,
|
|
182
|
+
payload: dict[str, Any],
|
|
183
|
+
) -> Decision:
|
|
184
|
+
if payload.get("tool_name") != "Bash":
|
|
185
|
+
return Decision("skip")
|
|
186
|
+
command = str((payload.get("tool_input") or {}).get("command") or "")
|
|
187
|
+
match = _BRANCH_CREATE_RE.search(command)
|
|
188
|
+
if match is None or profile.issue_from_branch(match.group(1)) is None:
|
|
189
|
+
return Decision("allow")
|
|
190
|
+
if facts.is_main_worktree and len(facts.worktrees) > 1:
|
|
191
|
+
return Decision(
|
|
192
|
+
"block",
|
|
193
|
+
f"Worktree Lifecycle blocked branch creation `{match.group(1)}` in the main "
|
|
194
|
+
f"checkout while linked worktrees are active. Use `{profile.setup_entry}`.",
|
|
195
|
+
)
|
|
196
|
+
return Decision("allow")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def evaluate(
|
|
200
|
+
profile: WorktreeProfile,
|
|
201
|
+
facts: RepoFacts,
|
|
202
|
+
event: str,
|
|
203
|
+
payload: dict[str, Any],
|
|
204
|
+
) -> Decision:
|
|
205
|
+
if event == "session-start":
|
|
206
|
+
return branch_context(profile, facts)
|
|
207
|
+
if event == "branch-watch":
|
|
208
|
+
command = str((payload.get("tool_input") or {}).get("command") or "")
|
|
209
|
+
if payload.get("tool_name") != "Bash" or not _BRANCH_CHANGE_RE.search(command):
|
|
210
|
+
return Decision("skip")
|
|
211
|
+
context = branch_context(profile, facts)
|
|
212
|
+
return Decision("emit", context.message, "PostToolUse")
|
|
213
|
+
if event == "edit":
|
|
214
|
+
return edit_decision(profile, facts, payload)
|
|
215
|
+
if event == "command-cwd":
|
|
216
|
+
return command_decision(profile, facts, payload)
|
|
217
|
+
if event == "branch-create":
|
|
218
|
+
return branch_create_decision(profile, facts, payload)
|
|
219
|
+
return Decision("skip")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Worktree Lifecycle profile loading and low-level git operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LifecycleError(RuntimeError):
|
|
14
|
+
"""A safe, user-visible lifecycle refusal."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class WorktreeProfile:
|
|
19
|
+
root: str
|
|
20
|
+
branch_template: str
|
|
21
|
+
path_template: str
|
|
22
|
+
main_branches: tuple[str, ...]
|
|
23
|
+
protected_branches: tuple[str, ...]
|
|
24
|
+
setup_steps: tuple[dict[str, Any], ...]
|
|
25
|
+
branch_regex: str
|
|
26
|
+
setup_entry: str
|
|
27
|
+
risky_command_patterns: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
|
|
30
|
+
return _render(self.branch_template, issue, slug, branch_type)
|
|
31
|
+
|
|
32
|
+
def relative_path(self, issue: str, slug: str, branch_type: str) -> Path:
|
|
33
|
+
name = _render(self.path_template, issue, slug, branch_type)
|
|
34
|
+
return Path(self.root) / name
|
|
35
|
+
|
|
36
|
+
def issue_from_branch(self, branch: str) -> str | None:
|
|
37
|
+
match = re.match(self.branch_regex, branch)
|
|
38
|
+
return match.groupdict().get("issue") if match else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _render(template: str, issue: str, slug: str, branch_type: str) -> str:
|
|
42
|
+
values = {"issue": issue, "slug": slug, "type": branch_type}
|
|
43
|
+
try:
|
|
44
|
+
return template.format(**values)
|
|
45
|
+
except (KeyError, ValueError) as error:
|
|
46
|
+
raise LifecycleError(f"invalid worktree template: {error}") from error
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_profile(path: Path) -> WorktreeProfile:
|
|
50
|
+
try:
|
|
51
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
52
|
+
raw = document["worktreeLifecycle"]
|
|
53
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
|
|
54
|
+
raise LifecycleError(f"cannot load worktree lifecycle profile: {error}") from error
|
|
55
|
+
if raw.get("enabled") is not True:
|
|
56
|
+
raise LifecycleError("worktree lifecycle is not enabled")
|
|
57
|
+
main = tuple(raw.get("mainBranches") or ("main", "master"))
|
|
58
|
+
return WorktreeProfile(
|
|
59
|
+
root=raw.get("worktreeRoot", ".worktrees"),
|
|
60
|
+
branch_template=raw.get("branchTemplate", "{type}/{issue}-{slug}"),
|
|
61
|
+
path_template=raw.get("pathTemplate", "{type}-{issue}-{slug}"),
|
|
62
|
+
main_branches=main,
|
|
63
|
+
protected_branches=tuple(raw.get("protectedBranches") or main),
|
|
64
|
+
setup_steps=tuple(raw.get("setupSteps") or ()),
|
|
65
|
+
branch_regex=raw.get(
|
|
66
|
+
"branchRegex",
|
|
67
|
+
r"^(?:feat|fix|chore|docs)/(?P<issue>\d+)-",
|
|
68
|
+
),
|
|
69
|
+
setup_entry=raw.get(
|
|
70
|
+
"setupEntry",
|
|
71
|
+
"python3 scripts/worktree-lifecycle/setup.py",
|
|
72
|
+
),
|
|
73
|
+
risky_command_patterns=tuple(raw.get("riskyCommandPatterns") or (
|
|
74
|
+
r"\b(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:test|typecheck|build)\b",
|
|
75
|
+
r"\bgit\s+(?:commit|push)\b",
|
|
76
|
+
)),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run(
|
|
81
|
+
command: list[str],
|
|
82
|
+
*,
|
|
83
|
+
cwd: Path,
|
|
84
|
+
check: bool = True,
|
|
85
|
+
) -> subprocess.CompletedProcess[str]:
|
|
86
|
+
result = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
|
|
87
|
+
if check and result.returncode != 0:
|
|
88
|
+
detail = (result.stderr or result.stdout).strip()
|
|
89
|
+
raise LifecycleError(f"{' '.join(command)} failed: {detail}")
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main_worktree(cwd: Path) -> Path:
|
|
94
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
|
|
95
|
+
first = next((line for line in output.splitlines() if line.startswith("worktree ")), "")
|
|
96
|
+
if not first:
|
|
97
|
+
raise LifecycleError("not inside a git worktree")
|
|
98
|
+
return Path(first.split(" ", 1)[1]).resolve()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def registered_worktrees(cwd: Path) -> set[Path]:
|
|
102
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
|
|
103
|
+
return {
|
|
104
|
+
Path(line.split(" ", 1)[1]).resolve()
|
|
105
|
+
for line in output.splitlines()
|
|
106
|
+
if line.startswith("worktree ")
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def local_branch_exists(cwd: Path, branch: str) -> bool:
|
|
111
|
+
result = run(
|
|
112
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
|
|
113
|
+
cwd=cwd,
|
|
114
|
+
check=False,
|
|
115
|
+
)
|
|
116
|
+
return result.returncode == 0
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -69,12 +69,21 @@ export const HELPER_FILES = [
|
|
|
69
69
|
// core.py, while capabilities.json keeps the historical 8/8 denominator
|
|
70
70
|
// explicit until the remaining hook and cleanup adapters are activated.
|
|
71
71
|
{ path: 'scripts/worktree-lifecycle/core.py', kind: 'script', mode: 0o644 },
|
|
72
|
+
{ path: 'scripts/worktree-lifecycle/profile.py', kind: 'script', mode: 0o644 },
|
|
72
73
|
{ path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
|
|
73
74
|
{ path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
|
|
75
|
+
{ path: 'scripts/worktree-lifecycle/README.md', kind: 'doc', mode: 0o644 },
|
|
74
76
|
// Shared hook utility imported by the shipped hooks (drift-guard,
|
|
75
77
|
// sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
|
|
76
78
|
// hooks ImportError on arrival.
|
|
77
79
|
{ path: '.claude/hooks/_hook_utils.py', kind: 'hook', mode: 0o644 },
|
|
80
|
+
// Thin Worktree Lifecycle adapters; all branch parsing, traversal and
|
|
81
|
+
// fail-open/fail-closed policy stays in scripts/worktree-lifecycle/core.py.
|
|
82
|
+
{ path: '.claude/hooks/branch-context.py', kind: 'hook', mode: 0o755 },
|
|
83
|
+
{ path: '.claude/hooks/branch-watch.py', kind: 'hook', mode: 0o755 },
|
|
84
|
+
{ path: '.claude/hooks/enforce-worktree.py', kind: 'hook', mode: 0o755 },
|
|
85
|
+
{ path: '.claude/hooks/enforce-worktree-cwd.py', kind: 'hook', mode: 0o755 },
|
|
86
|
+
{ path: '.claude/hooks/enforce-worktree-discipline.py', kind: 'hook', mode: 0o755 },
|
|
78
87
|
{ path: '.claude/hooks/drift-guard.py', kind: 'hook', mode: 0o755 },
|
|
79
88
|
// SessionStart skill-freshness drift-hint (audit-skills names it). For each
|
|
80
89
|
// <skill>/SOURCES.txt it flags sources newer in git than the SKILL.md. Imports
|