@ikon85/agent-workflow-kit 0.17.0 → 0.18.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.
@@ -21,12 +21,15 @@ Migration notes for hook refactors:
21
21
  """
22
22
  import re
23
23
  import subprocess
24
+ import importlib.util
25
+ import sys
24
26
  from datetime import datetime
25
27
  from pathlib import Path
26
28
 
27
29
  # Resolved relative to CWD at call time. Hooks are invoked from repo root
28
30
  # (or from a linked worktree root), so this resolves to <root>/.claude/logs.
29
31
  LOG_DIR = Path(".claude/logs")
32
+ _WORKTREE_CORE_MODULE = "_agent_workflow_kit_worktree_lifecycle"
30
33
 
31
34
 
32
35
  def rotate_log_if_needed(log_path: Path, max_bytes: int = 100_000, generations: int = 3) -> None:
@@ -182,3 +185,28 @@ def normalize_to_repo_relative(file_path: str, root: str) -> str | None:
182
185
  return str(rel)
183
186
  except ValueError:
184
187
  return None
188
+
189
+
190
+ def load_worktree_lifecycle_core():
191
+ """Load the shipped Worktree Lifecycle core without requiring a Python package."""
192
+ existing = sys.modules.get(_WORKTREE_CORE_MODULE)
193
+ if existing is not None:
194
+ return existing
195
+ path = Path(__file__).resolve().parents[2] / "scripts" / "worktree-lifecycle" / "core.py"
196
+ spec = importlib.util.spec_from_file_location(_WORKTREE_CORE_MODULE, path)
197
+ if spec is None or spec.loader is None:
198
+ raise ImportError(f"cannot load Worktree Lifecycle core from {path}")
199
+ module = importlib.util.module_from_spec(spec)
200
+ sys.modules[_WORKTREE_CORE_MODULE] = module
201
+ spec.loader.exec_module(module)
202
+ return module
203
+
204
+
205
+ def hook_event_output(event_name: str, context: str) -> dict:
206
+ """Canonical non-blocking context payload for Claude hook events."""
207
+ return {
208
+ "hookSpecificOutput": {
209
+ "hookEventName": event_name,
210
+ "additionalContext": context,
211
+ }
212
+ }
package/README.md CHANGED
@@ -332,6 +332,13 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.18.0
336
+
337
+ - added: `scripts/worktree-lifecycle/capabilities.json`
338
+ - added: `scripts/worktree-lifecycle/core.py`
339
+ - added: `scripts/worktree-lifecycle/setup.py`
340
+ - changed: `.claude/hooks/_hook_utils.py`
341
+
335
342
  ### 0.17.0
336
343
 
337
344
  - added: `src/lib/release-preview.mjs`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.17.0",
2
+ "kitVersion": "0.18.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -850,7 +850,7 @@
850
850
  {
851
851
  "path": ".claude/hooks/_hook_utils.py",
852
852
  "kind": "hook",
853
- "sha256": "d16ca26f3057a500cc67242fd67125d1715e7e7fd2984398545f6374fef497bb",
853
+ "sha256": "4a4b57633578ee8eabdf8ae70437e590e1747d1df5d76c730e2164e23cdfc2ed",
854
854
  "mode": 420,
855
855
  "origin": "kit"
856
856
  },
@@ -2063,6 +2063,27 @@
2063
2063
  "mode": 420,
2064
2064
  "origin": "kit"
2065
2065
  },
2066
+ {
2067
+ "path": "scripts/worktree-lifecycle/capabilities.json",
2068
+ "kind": "doc",
2069
+ "sha256": "3e51acaf2a8ac3ace0a400e23a30732ff0c3e9b4864f763c7abf82019605f309",
2070
+ "mode": 420,
2071
+ "origin": "kit"
2072
+ },
2073
+ {
2074
+ "path": "scripts/worktree-lifecycle/core.py",
2075
+ "kind": "script",
2076
+ "sha256": "eae5c6076611a36a8fa90e3aef3d1c49345110a392a7d7e9d95be8de00f8d9ff",
2077
+ "mode": 420,
2078
+ "origin": "kit"
2079
+ },
2080
+ {
2081
+ "path": "scripts/worktree-lifecycle/setup.py",
2082
+ "kind": "script",
2083
+ "sha256": "c720cbff9123f136a53658671d880b9a73f9da4faa0611e37a2a981fa89cda92",
2084
+ "mode": 493,
2085
+ "origin": "kit"
2086
+ },
2066
2087
  {
2067
2088
  "path": "scripts/wrapup-land.py",
2068
2089
  "kind": "script",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.17.0",
3
+ "version": "0.18.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
+ {
2
+ "version": 1,
3
+ "capabilities": [
4
+ {
5
+ "historicalPath": "scripts/setup-worktree.sh",
6
+ "primitives": ["profile", "git-facts", "transactional-setup"]
7
+ },
8
+ {
9
+ "historicalPath": ".claude/hooks/branch-context.py",
10
+ "primitives": ["profile", "git-facts", "hook-event-output"]
11
+ },
12
+ {
13
+ "historicalPath": ".claude/hooks/branch-watch.py",
14
+ "primitives": ["profile", "git-facts", "hook-event-output"]
15
+ },
16
+ {
17
+ "historicalPath": ".claude/hooks/enforce-worktree.py",
18
+ "primitives": ["profile", "git-facts", "worktree-decision"]
19
+ },
20
+ {
21
+ "historicalPath": ".claude/hooks/enforce-worktree-cwd.py",
22
+ "primitives": ["profile", "git-facts", "worktree-decision"]
23
+ },
24
+ {
25
+ "historicalPath": ".claude/hooks/enforce-worktree-discipline.py",
26
+ "primitives": ["profile", "git-facts", "worktree-decision"]
27
+ },
28
+ {
29
+ "historicalPath": ".claude/hooks/slice-handoff-hint.py",
30
+ "primitives": ["profile", "git-facts", "hook-event-output"]
31
+ },
32
+ {
33
+ "historicalPath": "scripts/cleanup-worktrees.sh",
34
+ "primitives": ["profile", "git-facts", "cleanup-safety"]
35
+ }
36
+ ]
37
+ }
@@ -0,0 +1,106 @@
1
+ """Consumer-neutral worktree lifecycle profile and git facts."""
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
+
27
+ def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
28
+ return _render(self.branch_template, issue, slug, branch_type)
29
+
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
+
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
37
+
38
+
39
+ def _render(template: str, issue: str, slug: str, branch_type: str) -> str:
40
+ values = {"issue": issue, "slug": slug, "type": branch_type}
41
+ try:
42
+ return template.format(**values)
43
+ except (KeyError, ValueError) as error:
44
+ raise LifecycleError(f"invalid worktree template: {error}") from error
45
+
46
+
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
+ ),
67
+ )
68
+
69
+
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:
101
+ result = run(
102
+ ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
103
+ cwd=cwd,
104
+ check=False,
105
+ )
106
+ return result.returncode == 0
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """Create a configured, rollback-safe consumer worktree."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import shutil
8
+ import sys
9
+ import zlib
10
+ from pathlib import Path
11
+
12
+ from core import (
13
+ LifecycleError,
14
+ load_profile,
15
+ local_branch_exists,
16
+ main_worktree,
17
+ registered_worktrees,
18
+ run,
19
+ )
20
+
21
+
22
+ def parse_args() -> argparse.Namespace:
23
+ parser = argparse.ArgumentParser()
24
+ parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
25
+ parser.add_argument("--base", default="origin/main")
26
+ parser.add_argument("issue")
27
+ parser.add_argument("slug")
28
+ parser.add_argument("branch_type", nargs="?", default="feat")
29
+ return parser.parse_args()
30
+
31
+
32
+ def port_slot(step: dict, issue: str, branch: str) -> int:
33
+ modulo = int(step.get("slotModulo", 900))
34
+ offset = int(step.get("slotOffset", 1))
35
+ seed = int(issue) if issue.isdigit() else zlib.crc32(branch.encode("utf-8"))
36
+ return seed % modulo + offset
37
+
38
+
39
+ def write_ports(step: dict, *, worktree: Path, issue: str, branch: str) -> None:
40
+ slot = port_slot(step, issue, branch)
41
+ stride = int(step.get("stride", 10))
42
+ outputs = step.get("outputs") or {}
43
+ if not outputs:
44
+ raise LifecycleError("ports step requires outputs")
45
+ unsafe = {int(port) for port in step.get("unsafePorts", ())}
46
+ modulo = int(step.get("slotModulo", 900))
47
+ for _ in range(modulo):
48
+ ports = [int(base) + slot * stride for base in outputs.values()]
49
+ if not unsafe.intersection(ports):
50
+ break
51
+ slot = slot % modulo + 1
52
+ else:
53
+ raise LifecycleError("port profile has no safe allocation")
54
+ lines = [f"{name}={int(base) + slot * stride}" for name, base in outputs.items()]
55
+ (worktree / ".dev-ports").write_text("\n".join(lines) + "\n", encoding="utf-8")
56
+
57
+
58
+ def execute_step(
59
+ step: dict,
60
+ *,
61
+ main: Path,
62
+ worktree: Path,
63
+ issue: str,
64
+ branch: str,
65
+ ) -> None:
66
+ kind = step.get("kind")
67
+ if kind == "copy":
68
+ source = main / step["source"]
69
+ target = worktree / step.get("target", step["source"])
70
+ if not source.exists() and step.get("optional"):
71
+ return
72
+ target.parent.mkdir(parents=True, exist_ok=True)
73
+ shutil.copy2(source, target)
74
+ return
75
+ if kind == "command":
76
+ command = step.get("command")
77
+ if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
78
+ raise LifecycleError("setup command must be a JSON string array")
79
+ run(command, cwd=worktree)
80
+ return
81
+ if kind == "ports":
82
+ write_ports(step, worktree=worktree, issue=issue, branch=branch)
83
+ return
84
+ raise LifecycleError(f"unsupported setup step kind: {kind!r}")
85
+
86
+
87
+ def remove_failed_worktree(main: Path, target: Path, branch: str, created_branch: bool) -> None:
88
+ run(["git", "worktree", "remove", "--force", str(target)], cwd=main, check=False)
89
+ if created_branch:
90
+ run(["git", "branch", "-d", branch], cwd=main, check=False)
91
+
92
+
93
+ def create(args: argparse.Namespace) -> Path:
94
+ cwd = Path.cwd()
95
+ main = main_worktree(cwd)
96
+ profile_path = Path(args.profile)
97
+ if not profile_path.is_absolute():
98
+ profile_path = main / profile_path
99
+ profile = load_profile(profile_path)
100
+ branch = profile.branch_name(args.issue, args.slug, args.branch_type)
101
+ target = (main / profile.relative_path(args.issue, args.slug, args.branch_type)).resolve()
102
+
103
+ if target in registered_worktrees(main):
104
+ print(f"Worktree already exists: {target} ({branch})")
105
+ return target
106
+
107
+ branch_existed = local_branch_exists(main, branch)
108
+ command = ["git", "worktree", "add", str(target)]
109
+ command += [branch] if branch_existed else ["-b", branch, args.base]
110
+ run(command, cwd=main)
111
+ try:
112
+ for step in profile.setup_steps:
113
+ execute_step(
114
+ step,
115
+ main=main,
116
+ worktree=target,
117
+ issue=args.issue,
118
+ branch=branch,
119
+ )
120
+ except Exception:
121
+ remove_failed_worktree(main, target, branch, not branch_existed)
122
+ raise
123
+ print(f"Worktree ready: {target} ({branch})")
124
+ return target
125
+
126
+
127
+ def main() -> int:
128
+ try:
129
+ create(parse_args())
130
+ return 0
131
+ except LifecycleError as error:
132
+ print(f"STOP: {error}", file=sys.stderr)
133
+ return 1
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
@@ -62,6 +62,12 @@ 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
+ // Profile-driven Worktree Lifecycle foundation. The setup adapter imports
66
+ // core.py, while capabilities.json keeps the historical 8/8 denominator
67
+ // explicit until the remaining hook and cleanup adapters are activated.
68
+ { path: 'scripts/worktree-lifecycle/core.py', kind: 'script', mode: 0o644 },
69
+ { path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
70
+ { path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
65
71
  // Shared hook utility imported by the shipped hooks (drift-guard,
66
72
  // sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
67
73
  // hooks ImportError on arrival.