@ikon85/agent-workflow-kit 0.16.4 → 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,18 @@ 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
+
342
+ ### 0.17.0
343
+
344
+ - added: `src/lib/release-preview.mjs`
345
+ - added: `src/lib/semver.mjs`
346
+
335
347
  ### 0.16.4
336
348
 
337
349
  - Metadata-only release.
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.16.4",
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,12 +2063,47 @@
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",
2069
2090
  "sha256": "24ce9373c464cc8363c53b0c690c3231cbf11362e6e08336e0e97663579d1f19",
2070
2091
  "mode": 493,
2071
2092
  "origin": "kit"
2093
+ },
2094
+ {
2095
+ "path": "src/lib/release-preview.mjs",
2096
+ "kind": "script",
2097
+ "sha256": "a4e6627fac2d62812e44cfcb80c46539563734e7fa11f94b3dda37ff6fe6ae8f",
2098
+ "mode": 420,
2099
+ "origin": "kit"
2100
+ },
2101
+ {
2102
+ "path": "src/lib/semver.mjs",
2103
+ "kind": "script",
2104
+ "sha256": "7d0a8da3740ed1eb79edb80f0eedb3cd1de47692d4886c6b119b39073a6468d1",
2105
+ "mode": 420,
2106
+ "origin": "kit"
2072
2107
  }
2073
2108
  ]
2074
2109
  }
@@ -0,0 +1,128 @@
1
+ # Wave 43 script and hook census
2
+
3
+ Verified on 2026-07-15 against `agent-workflow-kit` commit
4
+ `2d47dce23d9204b44951334c3c370bdb997c0943` and Testreporter commit
5
+ [`458747ebe8d67f66840cb3c1e75284cf2be7fb20`](https://github.com/iKon85/Testreporter/tree/458747ebe8d67f66840cb3c1e75284cf2be7fb20).
6
+ This is the research input for issue #59. It records facts only; it does not make
7
+ ship, opt-in, profile-seam, or maintenance decisions.
8
+
9
+ ## Method and reconciliation
10
+
11
+ The public-repository denominator is every tracked file below `scripts/**` and
12
+ `.claude/hooks/**`, excluding `test_*.py`, `*.test.mjs`, and the metadata file
13
+ `scripts/.npmignore`. The recount is:
14
+
15
+ ```text
16
+ 68 tracked entries
17
+ - 31 tests
18
+ - 1 metadata file
19
+ = 36 current script/hook entries
20
+ = 32 scripts + 4 hooks
21
+ = 30 shipped helpers + 6 maintainer-only helpers
22
+ ```
23
+
24
+ The shipped/helper split is derived from
25
+ [`src/lib/bundle.mjs`](../../src/lib/bundle.mjs), whose `HELPER_FILES` list is
26
+ the public distribution authority. The historical input is the 26-candidate
27
+ list in [Testreporter #1976](https://github.com/iKon85/Testreporter/issues/1976),
28
+ originally reconciled as 11 portable plus 15 generalize-first candidates in
29
+ [Testreporter #1964](https://github.com/iKon85/Testreporter/issues/1964).
30
+
31
+ Fresh exact-path comparison:
32
+
33
+ ```text
34
+ Historical candidates: 26/26 accounted for
35
+ Still present at the exact Testreporter path: 26/26
36
+ Present at the exact public-repo path: 0/26
37
+ Proven exact rename in the public repo: 0/26
38
+ Explicitly retired by a locked decision: 0/26
39
+ ```
40
+
41
+ Therefore every historical row is classified as **present in the current
42
+ consumer and absent from the current public denominator**. Related public
43
+ primitives are called out only as inputs to the later grill; similarity is not
44
+ treated as proof of a rename or as a ship decision.
45
+
46
+ ## Fresh public denominator: 36 of 36
47
+
48
+ | Current path | Distribution status |
49
+ |---|---|
50
+ | `.claude/hooks/_hook_utils.py` | shipped helper |
51
+ | `.claude/hooks/drift-guard.py` | shipped helper |
52
+ | `.claude/hooks/skill-drift-hint.py` | shipped helper |
53
+ | `.claude/hooks/sync-board-status.py` | shipped helper |
54
+ | `scripts/anchor_table.py` | shipped helper |
55
+ | `scripts/board-sync.py` | shipped helper |
56
+ | `scripts/board_config.py` | shipped helper |
57
+ | `scripts/board_fields.py` | shipped helper |
58
+ | `scripts/build-kit.mjs` | maintainer-only |
59
+ | `scripts/census/delta.mjs` | shipped helper |
60
+ | `scripts/census/fingerprint.mjs` | shipped helper |
61
+ | `scripts/census/index.mjs` | shipped helper |
62
+ | `scripts/census/scan.mjs` | shipped helper |
63
+ | `scripts/census/state.mjs` | shipped helper |
64
+ | `scripts/census/transaction.mjs` | shipped helper |
65
+ | `scripts/check-kit-staleness.mjs` | maintainer-only |
66
+ | `scripts/execute-ready-check.py` | shipped helper |
67
+ | `scripts/grill-census-wiring-guard.mjs` | maintainer-only |
68
+ | `scripts/issue_deps.py` | shipped helper |
69
+ | `scripts/kit-release.mjs` | shipped helper |
70
+ | `scripts/kit-update-pr.mjs` | shipped helper |
71
+ | `scripts/lib/audit-refs.mjs` | maintainer-only |
72
+ | `scripts/lib/scrub.mjs` | maintainer-only |
73
+ | `scripts/loc_offender_core.py` | shipped helper |
74
+ | `scripts/loc_offender_gate.py` | shipped helper |
75
+ | `scripts/node_kind.py` | shipped helper |
76
+ | `scripts/portability_profile_scan.py` | maintainer-only |
77
+ | `scripts/pr-body-check.py` | shipped helper |
78
+ | `scripts/program_graph.py` | shipped helper |
79
+ | `scripts/program_graph_parse.py` | shipped helper |
80
+ | `scripts/program_graph_validate.py` | shipped helper |
81
+ | `scripts/program_sync.py` | shipped helper |
82
+ | `scripts/release-delta-guard.mjs` | shipped helper |
83
+ | `scripts/release-parity.mjs` | shipped helper |
84
+ | `scripts/release-state.mjs` | shipped helper |
85
+ | `scripts/wrapup-land.py` | shipped helper |
86
+
87
+ ## Historical candidate crosswalk: 26 of 26
88
+
89
+ All Testreporter links below are pinned to the verified consumer commit. “No
90
+ dedicated tracked test found” means the fresh tracked-tree search found no test
91
+ named for that candidate; it is not a statement about runtime correctness.
92
+
93
+ | Historical candidate | Current public status and adjacent evidence | Current consumer dependency | Existing consumer test evidence |
94
+ |---|---|---|---|
95
+ | `scripts/audit_gate.py` | Exact path absent. | [`package.json#L44`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/package.json#L44) and `.githooks/pre-push` invoke it. | [`scripts/test_audit_gate.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/scripts/test_audit_gate.py), [`scripts/test_pre_push_audit_gate.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/scripts/test_pre_push_audit_gate.py) |
96
+ | `scripts/cleanup-worktrees.sh` | Exact path absent. Public `scripts/wrapup-land.py` has adjacent worktree-cleanup behavior, not a proven rename. | No separate tracked consumer found beyond direct/manual use. | No dedicated tracked test found. |
97
+ | `scripts/install-git-hooks.sh` | Exact path absent. | [`package.json#L45`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/package.json#L45) invokes it during postinstall. | [`scripts/test_install_git_hooks.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/scripts/test_install_git_hooks.py) |
98
+ | `scripts/ensure-gitleaks.sh` | Exact path absent. | [`package.json#L45`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/package.json#L45) invokes it during postinstall; `docs/security/secret-scan.md` documents it. | No dedicated tracked test found. |
99
+ | `.claude/hooks/block-bg-double-background.py` | Exact path absent. | [`.claude/settings.json#L85`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L85) wires it. | [`.claude/hooks/test_block_bg_double_background.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_block_bg_double_background.py) |
100
+ | `.claude/hooks/block-npm-install-in-pnpm.py` | Exact path absent. | [`.claude/settings.json#L75`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L75) wires it. | [`.claude/hooks/test_block_npm_install_in_pnpm.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_block_npm_install_in_pnpm.py) |
101
+ | `.claude/hooks/block-secrets.py` | Exact path absent. | [`.claude/settings.json#L56-L65`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L56-L65) wires it at two hook events. | [`.claude/hooks/test_block_secrets_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_block_secrets_smoke.py) |
102
+ | `.claude/hooks/branch-watch.py` | Exact path absent. | [`.claude/settings.json#L161`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L161) wires it. | [`.claude/hooks/test_branch_watch_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_branch_watch_smoke.py) |
103
+ | `.claude/hooks/grep-shim-guard.py` | Exact path absent. | [`.claude/settings.json#L80`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L80) wires it. | [`.claude/hooks/test_grep_shim_guard.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_grep_shim_guard.py) |
104
+ | `.claude/hooks/recon-size-hint.py` | Exact path absent. | [`.claude/settings.json#L134`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L134) wires it. | [`.claude/hooks/test_recon_size_hint.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_recon_size_hint.py) |
105
+ | `.claude/hooks/loc-offender-forewarn.py` | Exact path absent. Public `scripts/loc_offender_core.py` and `scripts/loc_offender_gate.py` are related shipped primitives, not a proven rename. | [`.claude/settings.json#L18`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L18) wires it. | [`scripts/test_loc_offender_forewarn.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/scripts/test_loc_offender_forewarn.py) |
106
+ | `scripts/bump-version.sh` | Exact path absent. Public `scripts/kit-release.mjs` contains tested SemVer and metadata-update behavior, not a proven rename. | No separate consumer wiring found; the script is directly invoked. | No dedicated tracked test found for the historical script. |
107
+ | `scripts/consolidate-memories.sh` | Exact path absent. | The two historical memory templates name it as their consolidator. | No dedicated tracked test found. |
108
+ | `scripts/memory-templates/meta_decision_layer_choice.md` | Exact path absent. | Input consumed by `scripts/consolidate-memories.sh`. | No dedicated tracked test found. |
109
+ | `scripts/memory-templates/meta_memory_lifecycle.md` | Exact path absent. | Input consumed by `scripts/consolidate-memories.sh`. | No dedicated tracked test found. |
110
+ | `scripts/setup-worktree.sh` | Exact path absent. Public workflow prose allows a project helper or plain `git worktree add`; that is not a rename. | Central Testreporter worktree, port, and local-env setup primitive referenced by workflow documentation. | [`scripts/setup-worktree.test.mjs`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/scripts/setup-worktree.test.mjs), [`scripts/test_pre_push_loc_gate.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/scripts/test_pre_push_loc_gate.py) |
111
+ | `.claude/hooks/baseline-capture-hint.py` | Exact path absent. | [`.claude/settings.json#L114`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L114) wires it. | [`.claude/hooks/test_baseline_capture_hint_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_baseline_capture_hint_smoke.py) |
112
+ | `.claude/hooks/branch-context.py` | Exact path absent. | [`.claude/settings.json#L13`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L13) wires it. | [`.claude/hooks/test_branch_context_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_branch_context_smoke.py) |
113
+ | `.claude/hooks/convention-drift-hint.py` | Exact path absent. Public `.claude/hooks/skill-drift-hint.py` and `drift-guard.py` are narrower adjacent mechanisms, not proven renames. | [`.claude/settings.json#L33`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L33) and rule `SOURCES.txt` files refer to it. | No dedicated tracked test found. |
114
+ | `.claude/hooks/enforce-worktree.py` | Exact path absent. | [`.claude/settings.json#L109`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L109) wires it. | [`.claude/hooks/test_enforce_worktree.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_enforce_worktree.py) |
115
+ | `.claude/hooks/enforce-worktree-cwd.py` | Exact path absent. | [`.claude/settings.json#L95`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L95) wires it. | [`.claude/hooks/test_enforce_worktree_cwd_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_enforce_worktree_cwd_smoke.py) |
116
+ | `.claude/hooks/enforce-worktree-discipline.py` | Exact path absent. | [`.claude/settings.json#L70`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L70) wires it. | [`.claude/hooks/test_enforce_worktree_discipline_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_enforce_worktree_discipline_smoke.py) |
117
+ | `.claude/hooks/migration-snapshot-reminder.py` | Exact path absent. | [`.claude/settings.json#L166`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L166) wires it. | [`.claude/hooks/test_migration_snapshot_reminder_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_migration_snapshot_reminder_smoke.py) |
118
+ | `.claude/hooks/pre-refactor-sweep.py` | Exact path absent. | [`.claude/settings.json#L23`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L23) wires it. | [`.claude/hooks/test_pre_refactor_sweep.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_pre_refactor_sweep.py) |
119
+ | `.claude/hooks/typecheck-on-stop.sh` | Exact path absent. | [`.claude/settings.json#L145`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L145) wires it. | No dedicated tracked test found. |
120
+ | `.claude/hooks/slice-handoff-hint.py` | Exact path absent. | [`.claude/settings.json#L44`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/settings.json#L44) wires it. | [`.claude/hooks/test_slice_handoff_hint_smoke.py`](https://github.com/iKon85/Testreporter/blob/458747ebe8d67f66840cb3c1e75284cf2be7fb20/.claude/hooks/test_slice_handoff_hint_smoke.py) |
121
+
122
+ ## Inputs for the decision grill
123
+
124
+ The crosswalk establishes the decision set but intentionally leaves its verdict
125
+ columns blank. Issue #59 must decide, with the user, each row’s outcome, opt-in,
126
+ profile seam, security and maintenance cost, and ship verdict. In particular,
127
+ the adjacent public primitives identified above must be compared behaviorally
128
+ before any row is called replaced, redundant, or already shipped.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.16.4",
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())
@@ -47,6 +47,10 @@ export const HELPER_FILES = [
47
47
  // the parity primitive rather than growing a second registry/GitHub comparison.
48
48
  { path: 'scripts/release-parity.mjs', kind: 'script', mode: 0o644 },
49
49
  { path: 'scripts/release-state.mjs', kind: 'script', mode: 0o644 },
50
+ // Consumer-owned project release profiles use these read-only shared
51
+ // primitives before any apply/commit/tag action is allowed.
52
+ { path: 'src/lib/semver.mjs', kind: 'script', mode: 0o644 },
53
+ { path: 'src/lib/release-preview.mjs', kind: 'script', mode: 0o644 },
50
54
  // GitHub-consumer automation: invokes the existing update command, then owns
51
55
  // only the stable tested branch/pull-request upsert.
52
56
  { path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
@@ -58,6 +62,12 @@ export const HELPER_FILES = [
58
62
  { path: 'scripts/census/delta.mjs', kind: 'script', mode: 0o644 },
59
63
  { path: 'scripts/census/state.mjs', kind: 'script', mode: 0o644 },
60
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 },
61
71
  // Shared hook utility imported by the shipped hooks (drift-guard,
62
72
  // sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
63
73
  // hooks ImportError on arrival.
@@ -0,0 +1,114 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { nextVersion, parseSemver } from './semver.mjs';
5
+
6
+ export const PROJECT_RELEASE_PROFILE = 'docs/agents/workflow-capabilities.json';
7
+
8
+ export async function loadProjectReleaseProfile(consumerRoot) {
9
+ const profile = JSON.parse(
10
+ await readFile(join(consumerRoot, PROJECT_RELEASE_PROFILE), 'utf8'),
11
+ );
12
+ if (profile.schemaVersion !== 1 || !profile.projectRelease) {
13
+ throw new Error('project release profile must contain schemaVersion 1 and projectRelease');
14
+ }
15
+ if (!Array.isArray(profile.projectRelease.versionFiles)
16
+ || profile.projectRelease.versionFiles.length === 0) {
17
+ throw new Error('project release profile requires at least one versionFiles entry');
18
+ }
19
+ return profile.projectRelease;
20
+ }
21
+
22
+ async function readVersionFile(consumerRoot, path) {
23
+ const raw = await readFile(join(consumerRoot, path), 'utf8');
24
+ const body = JSON.parse(raw);
25
+ return {
26
+ path,
27
+ version: body.version,
28
+ sha256: createHash('sha256').update(raw).digest('hex'),
29
+ };
30
+ }
31
+
32
+ export async function previewProjectRelease(options) {
33
+ const {
34
+ consumerRoot, profile, requestedVersion,
35
+ repositoryFacts = { dirtyPaths: [], existingTags: [] },
36
+ } = options;
37
+ const files = await Promise.all(
38
+ profile.versionFiles.map((path) => readVersionFile(consumerRoot, path)),
39
+ );
40
+ const currentVersion = files[0].version;
41
+ const paths = files.map(({ path }) => path);
42
+ const invalidFiles = files.filter(({ version }) => {
43
+ try { parseSemver(version); return false; } catch { return true; }
44
+ });
45
+ if (invalidFiles.length) {
46
+ return {
47
+ status: 'blocked',
48
+ summary: {
49
+ currentVersion,
50
+ targetVersion: null,
51
+ packageCount: paths.length,
52
+ synchronizedFiles: paths,
53
+ },
54
+ blockers: [{
55
+ code: 'invalid-current-version',
56
+ files: invalidFiles.map(({ path, version }) => ({ path, version })),
57
+ }],
58
+ actions: [],
59
+ repositoryFacts,
60
+ };
61
+ }
62
+ let targetVersion;
63
+ try {
64
+ targetVersion = nextVersion(currentVersion, requestedVersion);
65
+ } catch {
66
+ return {
67
+ status: 'blocked',
68
+ summary: {
69
+ currentVersion,
70
+ targetVersion: null,
71
+ packageCount: paths.length,
72
+ synchronizedFiles: paths,
73
+ },
74
+ blockers: [{ code: 'invalid-target-version', requestedVersion }],
75
+ actions: [],
76
+ repositoryFacts,
77
+ };
78
+ }
79
+ const tag = `${profile.tagPrefix ?? 'v'}${targetVersion}`;
80
+ const blockers = [];
81
+ if (files.some(({ version }) => version !== currentVersion)) {
82
+ blockers.push({
83
+ code: 'divergent-versions',
84
+ files: files.map(({ path, version }) => ({ path, version })),
85
+ });
86
+ }
87
+ const dirtyTargets = paths.filter((path) => repositoryFacts.dirtyPaths.includes(path));
88
+ if (dirtyTargets.length) blockers.push({ code: 'dirty-targets', paths: dirtyTargets });
89
+ if (repositoryFacts.existingTags.includes(tag)) {
90
+ blockers.push({ code: 'existing-tag', tag });
91
+ }
92
+ const snapshot = files.map(({ path, sha256 }) => ({ path, sha256 }));
93
+ const confirmation = createHash('sha256').update(JSON.stringify({
94
+ currentVersion, targetVersion, snapshot, tag,
95
+ })).digest('hex');
96
+ return {
97
+ status: blockers.length ? 'blocked' : 'ready',
98
+ summary: {
99
+ currentVersion,
100
+ targetVersion,
101
+ packageCount: paths.length,
102
+ synchronizedFiles: paths,
103
+ },
104
+ blockers,
105
+ actions: [
106
+ { type: 'write-version', version: targetVersion, paths },
107
+ { type: 'commit', message: `chore: prepare project release ${targetVersion}` },
108
+ { type: 'tag', name: tag },
109
+ ],
110
+ repositoryFacts,
111
+ snapshot,
112
+ confirmation,
113
+ };
114
+ }
@@ -0,0 +1,25 @@
1
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
2
+
3
+ export function parseSemver(version) {
4
+ const match = SEMVER.exec(version);
5
+ if (!match) throw new Error(`invalid semver: ${version}`);
6
+ return match.slice(1).map(Number);
7
+ }
8
+
9
+ export function compareSemver(left, right) {
10
+ const a = parseSemver(left);
11
+ const b = parseSemver(right);
12
+ for (let index = 0; index < a.length; index += 1) {
13
+ if (a[index] !== b[index]) return Math.sign(a[index] - b[index]);
14
+ }
15
+ return 0;
16
+ }
17
+
18
+ export function nextVersion(current, requested) {
19
+ const [major, minor, patch] = parseSemver(current);
20
+ if (requested === 'major') return `${major + 1}.0.0`;
21
+ if (requested === 'minor') return `${major}.${minor + 1}.0`;
22
+ if (requested === 'patch') return `${major}.${minor}.${patch + 1}`;
23
+ parseSemver(requested);
24
+ return requested;
25
+ }