@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.
- package/.claude/hooks/_hook_utils.py +28 -0
- package/README.md +7 -0
- package/agent-workflow-kit.package.json +23 -2
- package/package.json +1 -1
- package/scripts/worktree-lifecycle/capabilities.json +37 -0
- package/scripts/worktree-lifecycle/core.py +106 -0
- package/scripts/worktree-lifecycle/setup.py +137 -0
- package/src/lib/bundle.mjs +6 -0
|
@@ -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.
|
|
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": "
|
|
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.
|
|
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())
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -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.
|