@ikon85/agent-workflow-kit 0.18.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.
@@ -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
@@ -62,16 +62,28 @@ export const HELPER_FILES = [
62
62
  { path: 'scripts/census/delta.mjs', kind: 'script', mode: 0o644 },
63
63
  { path: 'scripts/census/state.mjs', kind: 'script', mode: 0o644 },
64
64
  { path: 'scripts/census/transaction.mjs', kind: 'script', mode: 0o644 },
65
+ // Consumer-owned memory planning and recovery. The module is both the stable
66
+ // skill API and the no-write-by-default CLI.
67
+ { path: 'scripts/memory-lifecycle/index.mjs', kind: 'script', mode: 0o644 },
65
68
  // Profile-driven Worktree Lifecycle foundation. The setup adapter imports
66
69
  // core.py, while capabilities.json keeps the historical 8/8 denominator
67
70
  // explicit until the remaining hook and cleanup adapters are activated.
68
71
  { path: 'scripts/worktree-lifecycle/core.py', kind: 'script', mode: 0o644 },
72
+ { path: 'scripts/worktree-lifecycle/profile.py', kind: 'script', mode: 0o644 },
69
73
  { path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
70
74
  { path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
75
+ { path: 'scripts/worktree-lifecycle/README.md', kind: 'doc', mode: 0o644 },
71
76
  // Shared hook utility imported by the shipped hooks (drift-guard,
72
77
  // sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
73
78
  // hooks ImportError on arrival.
74
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 },
75
87
  { path: '.claude/hooks/drift-guard.py', kind: 'hook', mode: 0o755 },
76
88
  // SessionStart skill-freshness drift-hint (audit-skills names it). For each
77
89
  // <skill>/SOURCES.txt it flags sources newer in git than the SKILL.md. Imports