@ikon85/agent-workflow-kit 0.21.0 → 0.22.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.
@@ -26,6 +26,7 @@ This is a prompt-driven skill, not a deterministic script. Explore, present what
26
26
  | `## Agent skills` + `## Prod` in CLAUDE.md **and** AGENTS.md | one-line pointers + deploy target (Sections C/G + Write) |
27
27
  | `.github/workflows/agent-workflow-kit-update.yml` | optional tested Kit update pull request for GitHub consumers (Section A2) |
28
28
  | `docs/agents/census.md` | optional-census choice, paths, state, and safe disable contract seeded from [census.md](./census.md) (Section A3) |
29
+ | `docs/agents/workflow-capabilities.json` | consumer-owned capability profile, including optional Worktree Lifecycle activation (Section A4) |
29
30
 
30
31
  ## Idempotency contract — read before writing anything
31
32
 
@@ -56,6 +57,7 @@ Read the current state; don't assume. For every target file, read its first line
56
57
  - `CONTEXT.md`, `CONTEXT-MAP.md`, `docs/adr/` — domain-doc layout.
57
58
  - `docs/agents/`, `docs/agents/skills/`, `docs/conventions/` — prior output of this skill.
58
59
  - `docs/agents/census.md`, `.census/profile.json`, `.census/active.json` — an existing census choice or consumer-owned census to adopt.
60
+ - `docs/agents/workflow-capabilities.json`, `.claude/settings.json` — an existing Worktree Lifecycle choice/profile and hook wiring to adopt.
59
61
  - `gh auth status` (if GitHub) — is `gh` authenticated, and with which scopes?
60
62
 
61
63
  ### 2. Section A — Issue tracker
@@ -135,6 +137,34 @@ activate without rerunning setup. Disable enforcement first, but retain every
135
137
  consumer-owned profile, scanner, test, and active snapshot unless the user
136
138
  separately approves deletion. Repeated runs are no-ops after reconciliation.
137
139
 
140
+ ### 2c. Section A4 — Optional Worktree Lifecycle
141
+
142
+ > Worktree Lifecycle is one consumer capability for isolated setup, shared
143
+ > branch/worktree facts, enforcement, handoff, and safe cleanup. The profile is
144
+ > tracked and consumer-owned; hook wiring activates only after an explicit yes.
145
+
146
+ Read [worktree-lifecycle.md](./worktree-lifecycle.md) in full. Reconcile the
147
+ complete `yes / later / no / existing / disable` matrix from its structured
148
+ contract. If no choice exists, ask: *"Should setup activate the portable
149
+ Worktree Lifecycle for this repository?"* Offer exactly **Yes**, **Later**, and
150
+ **No**.
151
+
152
+ - **Yes** — create or deepen only the `worktreeLifecycle` section in
153
+ `docs/agents/workflow-capabilities.json`, preserve every other section and
154
+ unknown key, and reconcile only the exact kit-owned hook commands listed in
155
+ the seed.
156
+ - **Later / No** — record the choice in the tracked profile without installing
157
+ hook wiring. Ordinary reruns do not ask again.
158
+ - **Existing** — adopt the current section and wiring without normalizing
159
+ consumer-owned values.
160
+ - **Disable** — remove only the exact kit-owned hook commands first, then set
161
+ `enabled: false`; retain the profile, setup policy, and unknown keys.
162
+
163
+ The default setup entry is
164
+ `python3 scripts/worktree-lifecycle/setup.py`; a proven consumer-native helper
165
+ may remain the configured `setupEntry` for parity. The handoff advisory always
166
+ reads that configured entry and never hardcodes a project script.
167
+
138
168
  ### 3. Section B — Triage labels
139
169
 
140
170
  > When `triage` processes an incoming issue it applies labels (or your tracker's equivalent). It needs strings you've actually configured, or it creates duplicates.
@@ -220,6 +250,12 @@ pre-existing consumer-owned census file. `yes`, `later`, `no`, and an adopted
220
250
  existing census are terminal for ordinary setup reruns. Only an explicit user
221
251
  request changes a recorded choice.
222
252
 
253
+ For `docs/agents/workflow-capabilities.json`, reconcile only the selected
254
+ capability section. Never replace the whole file: Project Release, Memory
255
+ Lifecycle, Worktree Lifecycle, future sections, and unknown consumer keys share
256
+ this profile. Apply the Worktree Lifecycle transition and hook ownership rules
257
+ from [worktree-lifecycle.md](./worktree-lifecycle.md) transactionally.
258
+
223
259
  For the **`## Workflow`**, **`## Agent skills`**, and **`## Prod`** blocks, reconcile per section in **both** CLAUDE.md and AGENTS.md that exist:
224
260
 
225
261
  - If **both** files exist → write/update the block in **both** (keep them coherent — Codex is a first-class surface).
@@ -0,0 +1,81 @@
1
+ # Worktree Lifecycle setup contract
2
+
3
+ Worktree Lifecycle is one opt-in capability backed by the consumer-owned
4
+ `docs/agents/workflow-capabilities.json` profile. Enabling it activates the
5
+ portable setup entry, shared branch/worktree facts, thin hook adapters, handoff
6
+ advisory, and safe cleanup policy as one unit.
7
+
8
+ ## Choice matrix
9
+
10
+ | State | Setup action |
11
+ |---|---|
12
+ | `missing` | Ask `yes / later / no`; do not infer or write before the answer. |
13
+ | `yes` | Reconcile the enabled profile and exact kit-owned hook commands. |
14
+ | `later` | Record the retryable deferral; do not activate hooks. |
15
+ | `no` | Record the opt-out; do not activate hooks. |
16
+ | `existing` | Adopt the consumer profile byte-safely and preserve unknown keys. |
17
+ | `disable` | Remove only kit-owned hook wiring, then set `enabled: false`; retain the profile and unknown keys. |
18
+
19
+ ```json worktree-lifecycle-setup-effects
20
+ [
21
+ {
22
+ "state": "missing",
23
+ "choice": null,
24
+ "operations": []
25
+ },
26
+ {
27
+ "state": "yes",
28
+ "choice": "yes",
29
+ "operations": [
30
+ "record-choice",
31
+ "reconcile-profile-enabled",
32
+ "reconcile-hook-wiring"
33
+ ]
34
+ },
35
+ {
36
+ "state": "later",
37
+ "choice": "later",
38
+ "operations": [
39
+ "record-choice"
40
+ ]
41
+ },
42
+ {
43
+ "state": "no",
44
+ "choice": "no",
45
+ "operations": [
46
+ "record-choice"
47
+ ]
48
+ },
49
+ {
50
+ "state": "existing",
51
+ "choice": "yes",
52
+ "operations": [
53
+ "adopt-existing"
54
+ ]
55
+ },
56
+ {
57
+ "state": "disable",
58
+ "choice": "yes",
59
+ "operations": [
60
+ "remove-hook-wiring",
61
+ "update-profile-disabled"
62
+ ]
63
+ }
64
+ ]
65
+ ```
66
+
67
+ ## Hook ownership
68
+
69
+ The exact kit-owned commands are:
70
+
71
+ - SessionStart: `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/branch-context.py"`
72
+ - UserPromptSubmit: `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/slice-handoff-hint.py"`
73
+ - PostToolUse on Bash: `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/branch-watch.py"`
74
+ - PreToolUse on Edit/Write/MultiEdit:
75
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree.py"`
76
+ - PreToolUse on Bash:
77
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree-cwd.py"` and
78
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree-discipline.py"`
79
+
80
+ Preserve unrelated settings, hook groups, profile sections, and unknown keys.
81
+ Repeated reconciliation with the same choice is byte-identical.
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit adapter for configured Worktree Lifecycle handoffs."""
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
+ 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()), "handoff", payload)
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())
@@ -26,6 +26,7 @@ This is a prompt-driven skill, not a deterministic script. Explore, present what
26
26
  | `## Agent skills` + `## Prod` in CLAUDE.md **and** AGENTS.md | one-line pointers + deploy target (Sections C/G + Write) |
27
27
  | `.github/workflows/agent-workflow-kit-update.yml` | optional tested Kit update pull request for GitHub consumers (Section A2) |
28
28
  | `docs/agents/census.md` | optional-census choice, paths, state, and safe disable contract seeded from [census.md](./census.md) (Section A3) |
29
+ | `docs/agents/workflow-capabilities.json` | consumer-owned capability profile, including optional Worktree Lifecycle activation (Section A4) |
29
30
 
30
31
  ## Idempotency contract — read before writing anything
31
32
 
@@ -56,6 +57,7 @@ Read the current state; don't assume. For every target file, read its first line
56
57
  - `CONTEXT.md`, `CONTEXT-MAP.md`, `docs/adr/` — domain-doc layout.
57
58
  - `docs/agents/`, `docs/agents/skills/`, `docs/conventions/` — prior output of this skill.
58
59
  - `docs/agents/census.md`, `.census/profile.json`, `.census/active.json` — an existing census choice or consumer-owned census to adopt.
60
+ - `docs/agents/workflow-capabilities.json`, `.claude/settings.json` — an existing Worktree Lifecycle choice/profile and hook wiring to adopt.
59
61
  - `gh auth status` (if GitHub) — is `gh` authenticated, and with which scopes?
60
62
 
61
63
  ### 2. Section A — Issue tracker
@@ -135,6 +137,34 @@ activate without rerunning setup. Disable enforcement first, but retain every
135
137
  consumer-owned profile, scanner, test, and active snapshot unless the user
136
138
  separately approves deletion. Repeated runs are no-ops after reconciliation.
137
139
 
140
+ ### 2c. Section A4 — Optional Worktree Lifecycle
141
+
142
+ > Worktree Lifecycle is one consumer capability for isolated setup, shared
143
+ > branch/worktree facts, enforcement, handoff, and safe cleanup. The profile is
144
+ > tracked and consumer-owned; hook wiring activates only after an explicit yes.
145
+
146
+ Read [worktree-lifecycle.md](./worktree-lifecycle.md) in full. Reconcile the
147
+ complete `yes / later / no / existing / disable` matrix from its structured
148
+ contract. If no choice exists, ask: *"Should setup activate the portable
149
+ Worktree Lifecycle for this repository?"* Offer exactly **Yes**, **Later**, and
150
+ **No**.
151
+
152
+ - **Yes** — create or deepen only the `worktreeLifecycle` section in
153
+ `docs/agents/workflow-capabilities.json`, preserve every other section and
154
+ unknown key, and reconcile only the exact kit-owned hook commands listed in
155
+ the seed.
156
+ - **Later / No** — record the choice in the tracked profile without installing
157
+ hook wiring. Ordinary reruns do not ask again.
158
+ - **Existing** — adopt the current section and wiring without normalizing
159
+ consumer-owned values.
160
+ - **Disable** — remove only the exact kit-owned hook commands first, then set
161
+ `enabled: false`; retain the profile, setup policy, and unknown keys.
162
+
163
+ The default setup entry is
164
+ `python3 scripts/worktree-lifecycle/setup.py`; a proven consumer-native helper
165
+ may remain the configured `setupEntry` for parity. The handoff advisory always
166
+ reads that configured entry and never hardcodes a project script.
167
+
138
168
  ### 3. Section B — Triage labels
139
169
 
140
170
  > When `triage` processes an incoming issue it applies labels (or your tracker's equivalent). It needs strings you've actually configured, or it creates duplicates.
@@ -220,6 +250,12 @@ pre-existing consumer-owned census file. `yes`, `later`, `no`, and an adopted
220
250
  existing census are terminal for ordinary setup reruns. Only an explicit user
221
251
  request changes a recorded choice.
222
252
 
253
+ For `docs/agents/workflow-capabilities.json`, reconcile only the selected
254
+ capability section. Never replace the whole file: Project Release, Memory
255
+ Lifecycle, Worktree Lifecycle, future sections, and unknown consumer keys share
256
+ this profile. Apply the Worktree Lifecycle transition and hook ownership rules
257
+ from [worktree-lifecycle.md](./worktree-lifecycle.md) transactionally.
258
+
223
259
  For the **`## Workflow`**, **`## Agent skills`**, and **`## Prod`** blocks, reconcile per section in **both** CLAUDE.md and AGENTS.md that exist:
224
260
 
225
261
  - If **both** files exist → write/update the block in **both** (keep them coherent — Codex is a first-class surface).
@@ -0,0 +1,81 @@
1
+ # Worktree Lifecycle setup contract
2
+
3
+ Worktree Lifecycle is one opt-in capability backed by the consumer-owned
4
+ `docs/agents/workflow-capabilities.json` profile. Enabling it activates the
5
+ portable setup entry, shared branch/worktree facts, thin hook adapters, handoff
6
+ advisory, and safe cleanup policy as one unit.
7
+
8
+ ## Choice matrix
9
+
10
+ | State | Setup action |
11
+ |---|---|
12
+ | `missing` | Ask `yes / later / no`; do not infer or write before the answer. |
13
+ | `yes` | Reconcile the enabled profile and exact kit-owned hook commands. |
14
+ | `later` | Record the retryable deferral; do not activate hooks. |
15
+ | `no` | Record the opt-out; do not activate hooks. |
16
+ | `existing` | Adopt the consumer profile byte-safely and preserve unknown keys. |
17
+ | `disable` | Remove only kit-owned hook wiring, then set `enabled: false`; retain the profile and unknown keys. |
18
+
19
+ ```json worktree-lifecycle-setup-effects
20
+ [
21
+ {
22
+ "state": "missing",
23
+ "choice": null,
24
+ "operations": []
25
+ },
26
+ {
27
+ "state": "yes",
28
+ "choice": "yes",
29
+ "operations": [
30
+ "record-choice",
31
+ "reconcile-profile-enabled",
32
+ "reconcile-hook-wiring"
33
+ ]
34
+ },
35
+ {
36
+ "state": "later",
37
+ "choice": "later",
38
+ "operations": [
39
+ "record-choice"
40
+ ]
41
+ },
42
+ {
43
+ "state": "no",
44
+ "choice": "no",
45
+ "operations": [
46
+ "record-choice"
47
+ ]
48
+ },
49
+ {
50
+ "state": "existing",
51
+ "choice": "yes",
52
+ "operations": [
53
+ "adopt-existing"
54
+ ]
55
+ },
56
+ {
57
+ "state": "disable",
58
+ "choice": "yes",
59
+ "operations": [
60
+ "remove-hook-wiring",
61
+ "update-profile-disabled"
62
+ ]
63
+ }
64
+ ]
65
+ ```
66
+
67
+ ## Hook ownership
68
+
69
+ The exact kit-owned commands are:
70
+
71
+ - SessionStart: `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/branch-context.py"`
72
+ - UserPromptSubmit: `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/slice-handoff-hint.py"`
73
+ - PostToolUse on Bash: `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/branch-watch.py"`
74
+ - PreToolUse on Edit/Write/MultiEdit:
75
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree.py"`
76
+ - PreToolUse on Bash:
77
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree-cwd.py"` and
78
+ `python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree-discipline.py"`
79
+
80
+ Preserve unrelated settings, hook groups, profile sections, and unknown keys.
81
+ Repeated reconciliation with the same choice is byte-identical.
package/README.md CHANGED
@@ -332,6 +332,19 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.22.0
336
+
337
+ - added: `.agents/skills/setup-workflow/worktree-lifecycle.md`
338
+ - added: `.claude/hooks/slice-handoff-hint.py`
339
+ - added: `.claude/skills/setup-workflow/worktree-lifecycle.md`
340
+ - added: `scripts/worktree-lifecycle/cleanup.py`
341
+ - changed: `.agents/skills/setup-workflow/SKILL.md`
342
+ - changed: `.claude/skills/setup-workflow/SKILL.md`
343
+ - changed: `scripts/worktree-lifecycle/README.md`
344
+ - changed: `scripts/worktree-lifecycle/capabilities.json`
345
+ - changed: `scripts/worktree-lifecycle/core.py`
346
+ - changed: `scripts/wrapup-land.py`
347
+
335
348
  ### 0.21.0
336
349
 
337
350
  - added: `.agents/skills/project-release/SKILL.md`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.21.0",
2
+ "kitVersion": "0.22.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -573,7 +573,7 @@
573
573
  "kind": "skill",
574
574
  "ownerSkill": "setup-workflow",
575
575
  "surface": "codex",
576
- "sha256": "91ba30cc08206204c83d189274579471366221ccfe8f2abdf36957ad85ae136b",
576
+ "sha256": "a1145034dbc334fb388be27dfb77cf1d93f3cefc6f760d5f4fa5d3ee98050dff",
577
577
  "mode": 420,
578
578
  "origin": "kit"
579
579
  },
@@ -622,6 +622,15 @@
622
622
  "mode": 420,
623
623
  "origin": "kit"
624
624
  },
625
+ {
626
+ "path": ".agents/skills/setup-workflow/worktree-lifecycle.md",
627
+ "kind": "skill",
628
+ "ownerSkill": "setup-workflow",
629
+ "surface": "codex",
630
+ "sha256": "5e0914874f0b9f940205fbcfcfbba87d0c2df8f31db104d2c5328a0d20c80616",
631
+ "mode": 420,
632
+ "origin": "kit"
633
+ },
625
634
  {
626
635
  "path": ".agents/skills/spec-self-critique/scenarios.md",
627
636
  "kind": "skill",
@@ -921,6 +930,13 @@
921
930
  "mode": 493,
922
931
  "origin": "kit"
923
932
  },
933
+ {
934
+ "path": ".claude/hooks/slice-handoff-hint.py",
935
+ "kind": "hook",
936
+ "sha256": "664ca09a7e86c267ce7d49bf6b2529ebb9e75a4fd3443548f36f6eb9d4665abf",
937
+ "mode": 493,
938
+ "origin": "kit"
939
+ },
924
940
  {
925
941
  "path": ".claude/hooks/sync-board-status.py",
926
942
  "kind": "hook",
@@ -1626,7 +1642,7 @@
1626
1642
  "kind": "skill",
1627
1643
  "ownerSkill": "setup-workflow",
1628
1644
  "surface": "claude",
1629
- "sha256": "91ba30cc08206204c83d189274579471366221ccfe8f2abdf36957ad85ae136b",
1645
+ "sha256": "a1145034dbc334fb388be27dfb77cf1d93f3cefc6f760d5f4fa5d3ee98050dff",
1630
1646
  "mode": 420,
1631
1647
  "origin": "kit"
1632
1648
  },
@@ -1675,6 +1691,15 @@
1675
1691
  "mode": 420,
1676
1692
  "origin": "kit"
1677
1693
  },
1694
+ {
1695
+ "path": ".claude/skills/setup-workflow/worktree-lifecycle.md",
1696
+ "kind": "skill",
1697
+ "ownerSkill": "setup-workflow",
1698
+ "surface": "claude",
1699
+ "sha256": "5e0914874f0b9f940205fbcfcfbba87d0c2df8f31db104d2c5328a0d20c80616",
1700
+ "mode": 420,
1701
+ "origin": "kit"
1702
+ },
1678
1703
  {
1679
1704
  "path": ".claude/skills/spec-self-critique/scenarios.md",
1680
1705
  "kind": "skill",
@@ -2151,14 +2176,21 @@
2151
2176
  {
2152
2177
  "path": "scripts/worktree-lifecycle/capabilities.json",
2153
2178
  "kind": "doc",
2154
- "sha256": "3e51acaf2a8ac3ace0a400e23a30732ff0c3e9b4864f763c7abf82019605f309",
2179
+ "sha256": "e80501bd86bbdb0ba296fc6b172442d8ad128f43e571777698657491279e4305",
2155
2180
  "mode": 420,
2156
2181
  "origin": "kit"
2157
2182
  },
2183
+ {
2184
+ "path": "scripts/worktree-lifecycle/cleanup.py",
2185
+ "kind": "script",
2186
+ "sha256": "370af8840732db3b6ba39b4825686f3980f69f612038123bc86b2c68921f02ca",
2187
+ "mode": 493,
2188
+ "origin": "kit"
2189
+ },
2158
2190
  {
2159
2191
  "path": "scripts/worktree-lifecycle/core.py",
2160
2192
  "kind": "script",
2161
- "sha256": "36848a16ed7eaed1f731399efd2caf5771c9c5b48331771d6aedcbad2156e62a",
2193
+ "sha256": "d54c4d663c234b7d2e607b99f6090b46104c21ba4326ef03bcb65de39d1cce19",
2162
2194
  "mode": 420,
2163
2195
  "origin": "kit"
2164
2196
  },
@@ -2172,7 +2204,7 @@
2172
2204
  {
2173
2205
  "path": "scripts/worktree-lifecycle/README.md",
2174
2206
  "kind": "doc",
2175
- "sha256": "59b78826308404497545b0a0ba57ff50f4618be2f6838f64e6a8c18bce83ea7f",
2207
+ "sha256": "5dba0332253c37162ad1b2f1b7bf504a23af85bf297fdaa8920036f610ff754a",
2176
2208
  "mode": 420,
2177
2209
  "origin": "kit"
2178
2210
  },
@@ -2186,7 +2218,7 @@
2186
2218
  {
2187
2219
  "path": "scripts/wrapup-land.py",
2188
2220
  "kind": "script",
2189
- "sha256": "24ce9373c464cc8363c53b0c690c3231cbf11362e6e08336e0e97663579d1f19",
2221
+ "sha256": "ee1f567e7be4f7baae222ed3974d39e5efee93edb2a663cacc8e18b567fa837e",
2190
2222
  "mode": 493,
2191
2223
  "origin": "kit"
2192
2224
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.21.0",
3
+ "version": "0.22.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,52 @@
1
+ #!/usr/bin/env python3
2
+ """Wrapup must reuse the shipped Worktree Lifecycle cleanup assessment."""
3
+
4
+ import importlib.util
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+ from unittest.mock import patch
10
+
11
+ REPO = Path(__file__).resolve().parent.parent
12
+ WRAPUP = REPO / "scripts/wrapup-land.py"
13
+
14
+
15
+ def load_wrapup():
16
+ spec = importlib.util.spec_from_file_location("wrapup_land_worktree_contract", WRAPUP)
17
+ module = importlib.util.module_from_spec(spec)
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ class WorktreeCleanupContract(unittest.TestCase):
23
+ def test_active_profile_delegates_removal_safety_to_shared_assessment(self):
24
+ wrapup = load_wrapup()
25
+ calls = []
26
+
27
+ class FakeCore:
28
+ @staticmethod
29
+ def load_profile(path):
30
+ calls.append(("profile", path))
31
+ return {"enabled": True}
32
+
33
+ @staticmethod
34
+ def cleanup_assessment(profile, main, target, merge_target=None):
35
+ calls.append(("assessment", main, target, merge_target))
36
+ return SimpleNamespace(reasons=("dirty worktree",), assumptions="reviewed")
37
+
38
+ with tempfile.TemporaryDirectory() as tmp:
39
+ main = Path(tmp)
40
+ profile = main / "docs/agents/workflow-capabilities.json"
41
+ profile.parent.mkdir(parents=True)
42
+ profile.write_text('{"worktreeLifecycle":{"enabled":true}}\n')
43
+ with patch.object(wrapup, "load_worktree_cleanup_core", return_value=FakeCore):
44
+ with self.assertRaises(wrapup.Stop) as stopped:
45
+ wrapup.ensure_worktree_removable(str(main / "wt"), str(main))
46
+
47
+ self.assertIn("shared cleanup guard", stopped.exception.reason)
48
+ self.assertEqual(calls[1][-1], "origin/main")
49
+
50
+
51
+ if __name__ == "__main__":
52
+ unittest.main()
@@ -31,6 +31,14 @@ the core proves the target is unsafe.
31
31
  | `enforce-worktree.py` | PreToolUse | blocks tracked main-checkout edits and cross-worktree leaks |
32
32
  | `enforce-worktree-cwd.py` | PreToolUse | blocks verification or Git mutation in the wrong checkout |
33
33
  | `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
34
+ | `slice-handoff-hint.py` | UserPromptSubmit | names the configured setup entry for a defined slice |
35
+
36
+ ## Cleanup
37
+
38
+ `cleanup.py` previews by default. Removal refuses protected, dirty, or unmerged
39
+ worktrees. The assessment reads `ANNAHMEN.md` before removal and returns its
40
+ contents for propagation. `wrapup-land.py` invokes this same assessment after a
41
+ merge and before killing processes or removing the worktree.
34
42
 
35
43
  Claude hook wiring and any Codex adaptation consume this same profile and core.
36
44
  An adapter may change only the surface event envelope; it must preserve the
@@ -3,34 +3,42 @@
3
3
  "capabilities": [
4
4
  {
5
5
  "historicalPath": "scripts/setup-worktree.sh",
6
+ "artifact": "scripts/worktree-lifecycle/setup.py",
6
7
  "primitives": ["profile", "git-facts", "transactional-setup"]
7
8
  },
8
9
  {
9
10
  "historicalPath": ".claude/hooks/branch-context.py",
11
+ "artifact": ".claude/hooks/branch-context.py",
10
12
  "primitives": ["profile", "git-facts", "hook-event-output"]
11
13
  },
12
14
  {
13
15
  "historicalPath": ".claude/hooks/branch-watch.py",
16
+ "artifact": ".claude/hooks/branch-watch.py",
14
17
  "primitives": ["profile", "git-facts", "hook-event-output"]
15
18
  },
16
19
  {
17
20
  "historicalPath": ".claude/hooks/enforce-worktree.py",
21
+ "artifact": ".claude/hooks/enforce-worktree.py",
18
22
  "primitives": ["profile", "git-facts", "worktree-decision"]
19
23
  },
20
24
  {
21
25
  "historicalPath": ".claude/hooks/enforce-worktree-cwd.py",
26
+ "artifact": ".claude/hooks/enforce-worktree-cwd.py",
22
27
  "primitives": ["profile", "git-facts", "worktree-decision"]
23
28
  },
24
29
  {
25
30
  "historicalPath": ".claude/hooks/enforce-worktree-discipline.py",
31
+ "artifact": ".claude/hooks/enforce-worktree-discipline.py",
26
32
  "primitives": ["profile", "git-facts", "worktree-decision"]
27
33
  },
28
34
  {
29
35
  "historicalPath": ".claude/hooks/slice-handoff-hint.py",
36
+ "artifact": ".claude/hooks/slice-handoff-hint.py",
30
37
  "primitives": ["profile", "git-facts", "hook-event-output"]
31
38
  },
32
39
  {
33
40
  "historicalPath": "scripts/cleanup-worktrees.sh",
41
+ "artifact": "scripts/worktree-lifecycle/cleanup.py",
34
42
  "primitives": ["profile", "git-facts", "cleanup-safety"]
35
43
  }
36
44
  ]
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Preview or safely remove a profile-governed linked worktree."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from core import LifecycleError, cleanup_assessment, load_profile, main_worktree, run
12
+
13
+
14
+ def parse_args() -> argparse.Namespace:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
17
+ parser.add_argument("--remove", action="store_true")
18
+ parser.add_argument("worktree")
19
+ return parser.parse_args()
20
+
21
+
22
+ def execute(args: argparse.Namespace) -> dict:
23
+ main = main_worktree(Path.cwd())
24
+ profile_path = Path(args.profile)
25
+ if not profile_path.is_absolute():
26
+ profile_path = main / profile_path
27
+ profile = load_profile(profile_path)
28
+ assessment = cleanup_assessment(profile, main, Path(args.worktree))
29
+ report = {
30
+ "worktree": str(assessment.worktree),
31
+ "branch": assessment.branch,
32
+ "removable": assessment.removable,
33
+ "reasons": list(assessment.reasons),
34
+ "assumptions": assessment.assumptions,
35
+ "removed": False,
36
+ }
37
+ if not args.remove:
38
+ return report
39
+ if not assessment.removable:
40
+ raise LifecycleError("; ".join(assessment.reasons))
41
+ run(["git", "worktree", "remove", str(assessment.worktree)], cwd=main)
42
+ run(["git", "branch", "-d", assessment.branch], cwd=main)
43
+ report["removed"] = True
44
+ return report
45
+
46
+
47
+ def main() -> int:
48
+ try:
49
+ print(json.dumps(execute(parse_args()), ensure_ascii=False, indent=2))
50
+ return 0
51
+ except LifecycleError as error:
52
+ print(f"STOP: {error}", file=sys.stderr)
53
+ return 1
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -38,6 +38,18 @@ class Decision:
38
38
  event_name: str = ""
39
39
 
40
40
 
41
+ @dataclass(frozen=True)
42
+ class CleanupAssessment:
43
+ worktree: Path
44
+ branch: str
45
+ assumptions: str
46
+ reasons: tuple[str, ...]
47
+
48
+ @property
49
+ def removable(self) -> bool:
50
+ return not self.reasons
51
+
52
+
41
53
  def collect_facts(cwd: Path) -> RepoFacts:
42
54
  root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
43
55
  main = main_worktree(root)
@@ -104,6 +116,50 @@ def is_tracked(root: Path, relative: str) -> bool:
104
116
  return result.returncode == 0
105
117
 
106
118
 
119
+ def cleanup_assessment(
120
+ profile: WorktreeProfile,
121
+ main: Path,
122
+ target: Path,
123
+ merge_target: str | None = None,
124
+ ) -> CleanupAssessment:
125
+ worktree = target.resolve()
126
+ reasons = []
127
+ branch = run(
128
+ ["git", "-C", str(worktree), "branch", "--show-current"],
129
+ cwd=main,
130
+ check=False,
131
+ ).stdout.strip()
132
+ if worktree not in registered_worktrees(main):
133
+ reasons.append("not a registered worktree")
134
+ if not branch:
135
+ reasons.append("detached or unreadable branch")
136
+ if branch in profile.protected_branches or worktree == main.resolve():
137
+ reasons.append(f"protected worktree branch: {branch or '<unknown>'}")
138
+ status = run(
139
+ ["git", "-C", str(worktree), "status", "--porcelain"],
140
+ cwd=main,
141
+ check=False,
142
+ ).stdout
143
+ if status.strip():
144
+ reasons.append("dirty worktree")
145
+ if branch and branch not in profile.protected_branches:
146
+ main_branch = merge_target or run(
147
+ ["git", "-C", str(main), "branch", "--show-current"],
148
+ cwd=main,
149
+ check=False,
150
+ ).stdout.strip()
151
+ merged = run(
152
+ ["git", "merge-base", "--is-ancestor", branch, main_branch],
153
+ cwd=main,
154
+ check=False,
155
+ )
156
+ if merged.returncode != 0:
157
+ reasons.append(f"unmerged branch: {branch}")
158
+ assumptions_path = worktree / "ANNAHMEN.md"
159
+ assumptions = assumptions_path.read_text(encoding="utf-8") if assumptions_path.is_file() else ""
160
+ return CleanupAssessment(worktree, branch, assumptions, tuple(reasons))
161
+
162
+
107
163
  def edit_decision(
108
164
  profile: WorktreeProfile,
109
165
  facts: RepoFacts,
@@ -196,6 +252,28 @@ def branch_create_decision(
196
252
  return Decision("allow")
197
253
 
198
254
 
255
+ def handoff_decision(
256
+ profile: WorktreeProfile,
257
+ facts: RepoFacts,
258
+ payload: dict[str, Any],
259
+ ) -> Decision:
260
+ prompt = str(payload.get("prompt") or "")
261
+ pattern = re.compile(
262
+ rf"{re.escape(profile.setup_entry)}\s+(\d+)\s+(\S+)"
263
+ )
264
+ match = pattern.search(prompt)
265
+ if match is None or not facts.is_main_worktree:
266
+ return Decision("skip")
267
+ issue, slug = match.groups()
268
+ command = f"{profile.setup_entry} {issue} {slug}"
269
+ return Decision(
270
+ "emit",
271
+ f"Defined slice detected: create its isolated worktree first with `{command}`, "
272
+ "then perform repository reads from that worktree.",
273
+ "UserPromptSubmit",
274
+ )
275
+
276
+
199
277
  def evaluate(
200
278
  profile: WorktreeProfile,
201
279
  facts: RepoFacts,
@@ -216,4 +294,6 @@ def evaluate(
216
294
  return command_decision(profile, facts, payload)
217
295
  if event == "branch-create":
218
296
  return branch_create_decision(profile, facts, payload)
297
+ if event == "handoff":
298
+ return handoff_decision(profile, facts, payload)
219
299
  return Decision("skip")
@@ -20,6 +20,7 @@ no --no-verify; the caller diagnoses.
20
20
  from __future__ import annotations
21
21
 
22
22
  import argparse
23
+ import importlib.util
23
24
  import json
24
25
  import os
25
26
  import re
@@ -90,6 +91,44 @@ def load_profile() -> dict:
90
91
  return {}
91
92
 
92
93
 
94
+ def load_worktree_cleanup_core():
95
+ path = Path(__file__).resolve().parent / "worktree-lifecycle" / "core.py"
96
+ module_dir = str(path.parent)
97
+ if module_dir not in sys.path:
98
+ sys.path.insert(0, module_dir)
99
+ spec = importlib.util.spec_from_file_location("_wrapup_worktree_lifecycle", path)
100
+ if spec is None or spec.loader is None:
101
+ raise Stop("cleanup", f"cannot load shared cleanup core from {path}")
102
+ module = importlib.util.module_from_spec(spec)
103
+ sys.modules[spec.name] = module
104
+ spec.loader.exec_module(module)
105
+ return module
106
+
107
+
108
+ def ensure_worktree_removable(wt: str, main_tree: str):
109
+ profile_path = Path(main_tree) / "docs/agents/workflow-capabilities.json"
110
+ if not profile_path.is_file():
111
+ return None
112
+ core = load_worktree_cleanup_core()
113
+ try:
114
+ profile = core.load_profile(profile_path)
115
+ assessment = core.cleanup_assessment(
116
+ profile,
117
+ Path(main_tree),
118
+ Path(wt),
119
+ merge_target="origin/main",
120
+ )
121
+ except core.LifecycleError as error:
122
+ raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
123
+ if assessment.reasons:
124
+ raise Stop(
125
+ "cleanup",
126
+ "shared cleanup guard refused worktree removal",
127
+ "; ".join(assessment.reasons),
128
+ )
129
+ return assessment
130
+
131
+
93
132
  # ---------- drift log (ANNAHMEN.md) ----------
94
133
 
95
134
  def parse_annahmen(text: str, default_section: str) -> tuple[list[dict], list[str]]:
@@ -428,6 +467,12 @@ def cmd_land(args) -> dict:
428
467
 
429
468
  # Step 2 — kill the worktree's dev server, then Step 4 — teardown
430
469
  if wt_exists:
470
+ git(["fetch", "origin", "main"], cwd=main_tree, check=True)
471
+ cleanup = ensure_worktree_removable(wt, main_tree)
472
+ report["cleanup_guard"] = {
473
+ "active": cleanup is not None,
474
+ "assumptions_read": bool(cleanup and cleanup.assumptions),
475
+ }
431
476
  report["killed_processes"] = kill_worktree_processes(wt)
432
477
  p = git(["worktree", "remove", wt], cwd=main_tree)
433
478
  if p.returncode != 0:
@@ -74,6 +74,7 @@ export const HELPER_FILES = [
74
74
  { path: 'scripts/worktree-lifecycle/core.py', kind: 'script', mode: 0o644 },
75
75
  { path: 'scripts/worktree-lifecycle/profile.py', kind: 'script', mode: 0o644 },
76
76
  { path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
77
+ { path: 'scripts/worktree-lifecycle/cleanup.py', kind: 'script', mode: 0o755 },
77
78
  { path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
78
79
  { path: 'scripts/worktree-lifecycle/README.md', kind: 'doc', mode: 0o644 },
79
80
  // Shared hook utility imported by the shipped hooks (drift-guard,
@@ -87,6 +88,7 @@ export const HELPER_FILES = [
87
88
  { path: '.claude/hooks/enforce-worktree.py', kind: 'hook', mode: 0o755 },
88
89
  { path: '.claude/hooks/enforce-worktree-cwd.py', kind: 'hook', mode: 0o755 },
89
90
  { path: '.claude/hooks/enforce-worktree-discipline.py', kind: 'hook', mode: 0o755 },
91
+ { path: '.claude/hooks/slice-handoff-hint.py', kind: 'hook', mode: 0o755 },
90
92
  { path: '.claude/hooks/drift-guard.py', kind: 'hook', mode: 0o755 },
91
93
  // SessionStart skill-freshness drift-hint (audit-skills names it). For each
92
94
  // <skill>/SOURCES.txt it flags sources newer in git than the SKILL.md. Imports