@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.
@@ -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.19.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": "4a4b57633578ee8eabdf8ae70437e590e1747d1df5d76c730e2164e23cdfc2ed",
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": "eae5c6076611a36a8fa90e3aef3d1c49345110a392a7d7e9d95be8de00f8d9ff",
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.19.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 worktree lifecycle profile and git facts."""
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
- class LifecycleError(RuntimeError):
14
- """A safe, user-visible lifecycle refusal."""
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 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
-
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
- def issue_from_branch(self, branch: str) -> str | None:
35
- match = re.match(self.branch_regex, branch)
36
- return match.groupdict().get("issue") if match else None
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 _render(template: str, issue: str, slug: str, branch_type: str) -> str:
40
- values = {"issue": issue, "slug": slug, "type": branch_type}
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 template.format(**values)
43
- except (KeyError, ValueError) as error:
44
- raise LifecycleError(f"invalid worktree template: {error}") from error
84
+ return str(path.resolve().relative_to(root))
85
+ except ValueError:
86
+ return None
45
87
 
46
88
 
47
- def load_profile(path: Path) -> WorktreeProfile:
48
- try:
49
- document = json.loads(path.read_text(encoding="utf-8"))
50
- raw = document["worktreeLifecycle"]
51
- except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
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 run(
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", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
103
- cwd=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
@@ -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