@ikon85/agent-workflow-kit 0.19.0 → 0.21.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.
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: project-release
3
+ description: "Preview and prepare one coherent version change across a consumer's profiled packages without duplicating release logic or creating commits, tags, pushes, publishes, or merges."
4
+ ---
5
+
6
+ # Project Release
7
+
8
+ Prepare a consumer-owned multi-package release through the kit's shared
9
+ SemVer, preview, and transactional apply engine. This skill changes only the
10
+ profiled version files. It does not commit, tag, push, publish, or merge.
11
+
12
+ ## Profile contract
13
+
14
+ Read `docs/agents/workflow-capabilities.json`. The consumer owns this file and
15
+ its unknown keys. Project Release requires this versioned section:
16
+
17
+ ```json
18
+ {
19
+ "schemaVersion": 1,
20
+ "projectRelease": {
21
+ "versionFiles": [
22
+ "package.json",
23
+ "packages/example/package.json"
24
+ ],
25
+ "tagPrefix": "v"
26
+ }
27
+ }
28
+ ```
29
+
30
+ Every `versionFiles` path is repository-relative and must name a JSON file with
31
+ the same current SemVer. Never infer or silently add package files.
32
+
33
+ ## Workflow
34
+
35
+ 1. Run a byte-neutral preview for exactly one Patch, Minor, Major, or explicit
36
+ SemVer target:
37
+
38
+ ```sh
39
+ node scripts/project-release.mjs preview <patch|minor|major|version>
40
+ ```
41
+
42
+ 2. Report the structured result: exact current and target version, package
43
+ count, synchronized files, planned commit/tag actions, confirmation token,
44
+ and every blocker. Invalid or divergent versions, dirty profiled targets,
45
+ and an existing target tag block before the first write.
46
+
47
+ 3. Ask the user to confirm the exact target and preview confirmation token.
48
+ Do not treat a bump recommendation or a prior preview as confirmation.
49
+
50
+ 4. Apply only that still-current preview:
51
+
52
+ ```sh
53
+ node scripts/project-release.mjs apply <patch|minor|major|version> \
54
+ --confirm <confirmation-token>
55
+ ```
56
+
57
+ Apply re-reads repository facts and every target byte. A stale token,
58
+ changed target, blocked preview, partial write, or repeated apply fails
59
+ without a double bump. Partial writes roll back to the original bytes.
60
+
61
+ 5. Inspect the resulting version-file diff, then hand commit, tag, push,
62
+ publish, and merge to the repository's normal release/landing workflow.
63
+ Project Release does not commit, tag, push, publish, or merge.
64
+
65
+ ## Safety contract
66
+
67
+ - Preview is repeatable and byte-neutral.
68
+ - Apply writes only `projectRelease.versionFiles`.
69
+ - Commit and tag entries in preview output are plans, never side effects.
70
+ - Existing tags are never overwritten.
71
+ - No runtime dependency is required beyond Node.js and Git.
@@ -21,6 +21,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
21
21
  - **Preview or restore consumer-owned memories:** use `memory-lifecycle` to classify every configured path without writing, then apply an explicitly approved collision-safe restore with a content-free receipt.
22
22
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
23
23
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
24
+ - **Consumer project release:** use `project-release` to preview and transactionally prepare one coherent version across the package files named by the consumer-owned capability profile.
24
25
  - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
25
26
  - **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
26
27
 
@@ -193,6 +193,9 @@ def load_worktree_lifecycle_core():
193
193
  if existing is not None:
194
194
  return existing
195
195
  path = Path(__file__).resolve().parents[2] / "scripts" / "worktree-lifecycle" / "core.py"
196
+ module_dir = str(path.parent)
197
+ if module_dir not in sys.path:
198
+ sys.path.insert(0, module_dir)
196
199
  spec = importlib.util.spec_from_file_location(_WORKTREE_CORE_MODULE, path)
197
200
  if spec is None or spec.loader is None:
198
201
  raise ImportError(f"cannot load Worktree Lifecycle core from {path}")
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart adapter for profile-driven branch and worktree facts."""
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
+ 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()), "session-start", {})
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())
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse adapter for branch-changing shell commands."""
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from _hook_utils import 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()), "branch-watch", payload)
17
+ except Exception:
18
+ return 0
19
+ if decision.action == "emit":
20
+ print(json.dumps({"systemMessage": decision.message}))
21
+ return 0
22
+
23
+
24
+ if __name__ == "__main__":
25
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse adapter for commands that must run in their linked worktree."""
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from _hook_utils import 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()), "command-cwd", payload)
17
+ except Exception:
18
+ return 0
19
+ if decision.action == "block":
20
+ print(decision.message, file=sys.stderr)
21
+ return 2
22
+ return 0
23
+
24
+
25
+ if __name__ == "__main__":
26
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse adapter for issue-branch creation with active worktrees."""
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from _hook_utils import 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()), "branch-create", payload)
17
+ except Exception:
18
+ return 0
19
+ if decision.action == "block":
20
+ print(decision.message, file=sys.stderr)
21
+ return 2
22
+ return 0
23
+
24
+
25
+ if __name__ == "__main__":
26
+ raise SystemExit(main())
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse adapter for tracked edits on protected worktrees."""
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from _hook_utils import 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()), "edit", payload)
17
+ except Exception:
18
+ return 0
19
+ if decision.action == "block":
20
+ print(decision.message, file=sys.stderr)
21
+ return 2
22
+ return 0
23
+
24
+
25
+ if __name__ == "__main__":
26
+ raise SystemExit(main())
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: project-release
3
+ description: "Preview and prepare one coherent version change across a consumer's profiled packages without duplicating release logic or creating commits, tags, pushes, publishes, or merges."
4
+ ---
5
+
6
+ # Project Release
7
+
8
+ Prepare a consumer-owned multi-package release through the kit's shared
9
+ SemVer, preview, and transactional apply engine. This skill changes only the
10
+ profiled version files. It does not commit, tag, push, publish, or merge.
11
+
12
+ ## Profile contract
13
+
14
+ Read `docs/agents/workflow-capabilities.json`. The consumer owns this file and
15
+ its unknown keys. Project Release requires this versioned section:
16
+
17
+ ```json
18
+ {
19
+ "schemaVersion": 1,
20
+ "projectRelease": {
21
+ "versionFiles": [
22
+ "package.json",
23
+ "packages/example/package.json"
24
+ ],
25
+ "tagPrefix": "v"
26
+ }
27
+ }
28
+ ```
29
+
30
+ Every `versionFiles` path is repository-relative and must name a JSON file with
31
+ the same current SemVer. Never infer or silently add package files.
32
+
33
+ ## Workflow
34
+
35
+ 1. Run a byte-neutral preview for exactly one Patch, Minor, Major, or explicit
36
+ SemVer target:
37
+
38
+ ```sh
39
+ node scripts/project-release.mjs preview <patch|minor|major|version>
40
+ ```
41
+
42
+ 2. Report the structured result: exact current and target version, package
43
+ count, synchronized files, planned commit/tag actions, confirmation token,
44
+ and every blocker. Invalid or divergent versions, dirty profiled targets,
45
+ and an existing target tag block before the first write.
46
+
47
+ 3. Ask the user to confirm the exact target and preview confirmation token.
48
+ Do not treat a bump recommendation or a prior preview as confirmation.
49
+
50
+ 4. Apply only that still-current preview:
51
+
52
+ ```sh
53
+ node scripts/project-release.mjs apply <patch|minor|major|version> \
54
+ --confirm <confirmation-token>
55
+ ```
56
+
57
+ Apply re-reads repository facts and every target byte. A stale token,
58
+ changed target, blocked preview, partial write, or repeated apply fails
59
+ without a double bump. Partial writes roll back to the original bytes.
60
+
61
+ 5. Inspect the resulting version-file diff, then hand commit, tag, push,
62
+ publish, and merge to the repository's normal release/landing workflow.
63
+ Project Release does not commit, tag, push, publish, or merge.
64
+
65
+ ## Safety contract
66
+
67
+ - Preview is repeatable and byte-neutral.
68
+ - Apply writes only `projectRelease.versionFiles`.
69
+ - Commit and tag entries in preview output are plans, never side effects.
70
+ - Existing tags are never overwritten.
71
+ - No runtime dependency is required beyond Node.js and Git.
@@ -21,6 +21,7 @@ Use this section as the entry-point map for agent-assisted work. The individual
21
21
  - **Preview or restore consumer-owned memories:** use `memory-lifecycle` to classify every configured path without writing, then apply an explicitly approved collision-safe restore with a content-free receipt.
22
22
  - **Finished slice:** use `wrapup` to prepare the branch, PR, and cleanup steps your repo expects.
23
23
  - **Kit release:** use `kit-release` to derive the shipped delta, confirm Semver, regenerate the manifest, run all gates, and then hand landing to `wrapup`.
24
+ - **Consumer project release:** use `project-release` to preview and transactionally prepare one coherent version across the package files named by the consumer-owned capability profile.
24
25
  - **Kit update:** use `kit-update` to preview and transactionally apply a parity-verified scoped release without overwriting local modifications.
25
26
  - **A huge, foggy effort, too big for one session:** use `wayfinder` — it charts it as a shared map of investigation tickets, resolving one per session.
26
27
 
@@ -395,6 +395,16 @@
395
395
  ],
396
396
  "provenance": "own"
397
397
  },
398
+ "project-release": {
399
+ "class": "generic",
400
+ "publish": true,
401
+ "entryPoint": true,
402
+ "surfaces": [
403
+ "claude",
404
+ "codex"
405
+ ],
406
+ "provenance": "own"
407
+ },
398
408
  "kit-update": {
399
409
  "class": "generic",
400
410
  "publish": true,
package/PROVENANCE.md CHANGED
@@ -33,7 +33,7 @@ carries Chase's `THIRD-PARTY-NOTICES.md`.
33
33
 
34
34
  retro, wrapup, spec-self-critique, board-to-waves, verify-spike, decision-gate,
35
35
  codex-adapter-sync, setup-pre-commit, code-review, orchestrate-wave,
36
- census-update, memory-lifecycle — covered by the root LICENSE.
36
+ census-update, memory-lifecycle, project-release — covered by the root LICENSE.
37
37
  `setup-pre-commit` was rewritten from Matt Pocock's husky-based skill to a
38
38
  zero-dep native git `core.hooksPath` scaffold; nothing of the original
39
39
  mechanic remains (courtesy attribution).
package/README.md CHANGED
@@ -332,6 +332,28 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
332
332
 
333
333
  ## Release notes
334
334
 
335
+ ### 0.21.0
336
+
337
+ - added: `.agents/skills/project-release/SKILL.md`
338
+ - added: `.claude/skills/project-release/SKILL.md`
339
+ - added: `scripts/project-release.mjs`
340
+ - added: `src/lib/release-apply.mjs`
341
+ - changed: `.agents/skills/setup-workflow/workflow-overview.md`
342
+ - changed: `.claude/skills/setup-workflow/workflow-overview.md`
343
+ - changed: `scripts/kit-release.mjs`
344
+
345
+ ### 0.20.0
346
+
347
+ - added: `.claude/hooks/branch-context.py`
348
+ - added: `.claude/hooks/branch-watch.py`
349
+ - added: `.claude/hooks/enforce-worktree.py`
350
+ - added: `.claude/hooks/enforce-worktree-cwd.py`
351
+ - added: `.claude/hooks/enforce-worktree-discipline.py`
352
+ - added: `scripts/worktree-lifecycle/profile.py`
353
+ - added: `scripts/worktree-lifecycle/README.md`
354
+ - changed: `.claude/hooks/_hook_utils.py`
355
+ - changed: `scripts/worktree-lifecycle/core.py`
356
+
335
357
  ### 0.19.0
336
358
 
337
359
  - added: `.agents/skills/memory-lifecycle/SKILL.md`
@@ -640,12 +662,13 @@ still reference. Flags: `--force` (overwrite pre-existing files on `init`),
640
662
 
641
663
  ## What's in the box
642
664
 
643
- **41 skills** (Router: ask-matt — "which skill/flow fits?" · Plan: grill-me,
665
+ **42 skills** (Router: ask-matt — "which skill/flow fits?" · Plan: grill-me,
644
666
  grill-with-docs, to-prd, to-issues, board-to-waves, triage, spec-self-critique,
645
667
  verify-spike, decision-gate, scale-check, to-waves, wayfinder, research · Execute: tdd, prototype, implement, orchestrate-wave ·
646
668
  Design/diagnose/refactor streams: diagnose, zoom-out,
647
669
  improve-codebase-architecture, codebase-design, domain-modeling, security-audit · Land: wrapup,
648
- resolving-merge-conflicts, code-review, local-ci, git-worktree-recover, kit-release · Learn: retro, audit-skills, write-a-skill · Setup:
670
+ resolving-merge-conflicts, code-review, local-ci, git-worktree-recover, kit-release,
671
+ project-release · Learn: retro, audit-skills, write-a-skill · Setup:
649
672
  setup-workflow, git-guardrails, setup-pre-commit · Codex cross-model: grill-me-codex,
650
673
  grill-with-docs-codex, codex-review, codex-build),
651
674
  installed for both surfaces — `.claude/skills`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.19.0",
2
+ "kitVersion": "0.21.0",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -379,6 +379,15 @@
379
379
  "mode": 420,
380
380
  "origin": "kit"
381
381
  },
382
+ {
383
+ "path": ".agents/skills/project-release/SKILL.md",
384
+ "kind": "skill",
385
+ "ownerSkill": "project-release",
386
+ "surface": "codex",
387
+ "sha256": "5aa0b0827e8691334c30608df8506c756455ecebcc460efa140889297cf02ed5",
388
+ "mode": 420,
389
+ "origin": "kit"
390
+ },
382
391
  {
383
392
  "path": ".agents/skills/prototype/LOGIC.md",
384
393
  "kind": "skill",
@@ -609,7 +618,7 @@
609
618
  "kind": "skill",
610
619
  "ownerSkill": "setup-workflow",
611
620
  "surface": "codex",
612
- "sha256": "c522a7de1b8eaa827a72880651a94aca6d066687e8c21b789d9008e3321f7362",
621
+ "sha256": "bc4db6413135ad9056cc63691cb823a9b198ad47538f292b37fcbb915477a716",
613
622
  "mode": 420,
614
623
  "origin": "kit"
615
624
  },
@@ -859,10 +868,24 @@
859
868
  {
860
869
  "path": ".claude/hooks/_hook_utils.py",
861
870
  "kind": "hook",
862
- "sha256": "4a4b57633578ee8eabdf8ae70437e590e1747d1df5d76c730e2164e23cdfc2ed",
871
+ "sha256": "58a7a8ad6cd4f1660e95343d7a0c637cf8aee92948a9c554befa5851723b3f48",
863
872
  "mode": 420,
864
873
  "origin": "kit"
865
874
  },
875
+ {
876
+ "path": ".claude/hooks/branch-context.py",
877
+ "kind": "hook",
878
+ "sha256": "96bbe56a292a89cdb76db0670c2c214678fa60c08e3de5b8db507149ba24c018",
879
+ "mode": 493,
880
+ "origin": "kit"
881
+ },
882
+ {
883
+ "path": ".claude/hooks/branch-watch.py",
884
+ "kind": "hook",
885
+ "sha256": "15c7565790e0b108c44175b7527a4bc2b8b244737d5598081e938d2dea032424",
886
+ "mode": 493,
887
+ "origin": "kit"
888
+ },
866
889
  {
867
890
  "path": ".claude/hooks/drift-guard.py",
868
891
  "kind": "hook",
@@ -870,6 +893,27 @@
870
893
  "mode": 493,
871
894
  "origin": "kit"
872
895
  },
896
+ {
897
+ "path": ".claude/hooks/enforce-worktree-cwd.py",
898
+ "kind": "hook",
899
+ "sha256": "14086536ceb3368a03b84ff550ba5e3db83d6c5198c41c80312507bf6640bb9f",
900
+ "mode": 493,
901
+ "origin": "kit"
902
+ },
903
+ {
904
+ "path": ".claude/hooks/enforce-worktree-discipline.py",
905
+ "kind": "hook",
906
+ "sha256": "5bd9fc7a7eea5e8b0778e8272e02b5d8c6c121ea3a168ef11a9c39344085e666",
907
+ "mode": 493,
908
+ "origin": "kit"
909
+ },
910
+ {
911
+ "path": ".claude/hooks/enforce-worktree.py",
912
+ "kind": "hook",
913
+ "sha256": "4a5d0247d89049dde0475680595fd785bfbd1b108cdd9c9697fd74e944f52c8b",
914
+ "mode": 493,
915
+ "origin": "kit"
916
+ },
873
917
  {
874
918
  "path": ".claude/hooks/skill-drift-hint.py",
875
919
  "kind": "hook",
@@ -1370,6 +1414,15 @@
1370
1414
  "mode": 420,
1371
1415
  "origin": "kit"
1372
1416
  },
1417
+ {
1418
+ "path": ".claude/skills/project-release/SKILL.md",
1419
+ "kind": "skill",
1420
+ "ownerSkill": "project-release",
1421
+ "surface": "claude",
1422
+ "sha256": "5aa0b0827e8691334c30608df8506c756455ecebcc460efa140889297cf02ed5",
1423
+ "mode": 420,
1424
+ "origin": "kit"
1425
+ },
1373
1426
  {
1374
1427
  "path": ".claude/skills/prototype/LOGIC.md",
1375
1428
  "kind": "skill",
@@ -1618,7 +1671,7 @@
1618
1671
  "kind": "skill",
1619
1672
  "ownerSkill": "setup-workflow",
1620
1673
  "surface": "claude",
1621
- "sha256": "c522a7de1b8eaa827a72880651a94aca6d066687e8c21b789d9008e3321f7362",
1674
+ "sha256": "bc4db6413135ad9056cc63691cb823a9b198ad47538f292b37fcbb915477a716",
1622
1675
  "mode": 420,
1623
1676
  "origin": "kit"
1624
1677
  },
@@ -1993,7 +2046,7 @@
1993
2046
  {
1994
2047
  "path": "scripts/kit-release.mjs",
1995
2048
  "kind": "script",
1996
- "sha256": "d3148aab63fa670c0a3407d2816d287b7cdeaa130588fb55b6075217da95d3d3",
2049
+ "sha256": "3c86eb5fc4b2071b423d87f023ba4c3f778ffe716a2603dcecdbe4e83ec1b8df",
1997
2050
  "mode": 420,
1998
2051
  "origin": "kit"
1999
2052
  },
@@ -2067,6 +2120,13 @@
2067
2120
  "mode": 420,
2068
2121
  "origin": "kit"
2069
2122
  },
2123
+ {
2124
+ "path": "scripts/project-release.mjs",
2125
+ "kind": "script",
2126
+ "sha256": "6c08cf16f97fdbc0172041c64109e859b1f073862e9f8edc2accbafd97b33843",
2127
+ "mode": 493,
2128
+ "origin": "kit"
2129
+ },
2070
2130
  {
2071
2131
  "path": "scripts/release-delta-guard.mjs",
2072
2132
  "kind": "script",
@@ -2098,7 +2158,21 @@
2098
2158
  {
2099
2159
  "path": "scripts/worktree-lifecycle/core.py",
2100
2160
  "kind": "script",
2101
- "sha256": "eae5c6076611a36a8fa90e3aef3d1c49345110a392a7d7e9d95be8de00f8d9ff",
2161
+ "sha256": "36848a16ed7eaed1f731399efd2caf5771c9c5b48331771d6aedcbad2156e62a",
2162
+ "mode": 420,
2163
+ "origin": "kit"
2164
+ },
2165
+ {
2166
+ "path": "scripts/worktree-lifecycle/profile.py",
2167
+ "kind": "script",
2168
+ "sha256": "993b105efd2329b3a206b0c79399aff1c7840017362745966f9b158b1f165f09",
2169
+ "mode": 420,
2170
+ "origin": "kit"
2171
+ },
2172
+ {
2173
+ "path": "scripts/worktree-lifecycle/README.md",
2174
+ "kind": "doc",
2175
+ "sha256": "59b78826308404497545b0a0ba57ff50f4618be2f6838f64e6a8c18bce83ea7f",
2102
2176
  "mode": 420,
2103
2177
  "origin": "kit"
2104
2178
  },
@@ -2116,6 +2190,13 @@
2116
2190
  "mode": 493,
2117
2191
  "origin": "kit"
2118
2192
  },
2193
+ {
2194
+ "path": "src/lib/release-apply.mjs",
2195
+ "kind": "script",
2196
+ "sha256": "d9b3bd07c88629c570d8a58f1d91d1cdac1457470cee973f99e186d9630efb03",
2197
+ "mode": 420,
2198
+ "origin": "kit"
2199
+ },
2119
2200
  {
2120
2201
  "path": "src/lib/release-preview.mjs",
2121
2202
  "kind": "script",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.19.0",
3
+ "version": "0.21.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": {
@@ -5,19 +5,15 @@ import { tmpdir } from 'node:os';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { applyProjectRelease } from '../src/lib/release-apply.mjs';
9
+ import { previewProjectRelease } from '../src/lib/release-preview.mjs';
10
+ import { nextVersion } from '../src/lib/semver.mjs';
8
11
  import { buildKit } from './build-kit.mjs';
9
12
  import { checkReleaseDelta } from './release-delta-guard.mjs';
10
13
 
11
14
  const exec = promisify(execFile);
12
15
 
13
- export function nextVersion(version, bump) {
14
- const parts = version.split('.').map(Number);
15
- if (parts.length !== 3 || parts.some(Number.isNaN)) throw new Error(`invalid semver: ${version}`);
16
- if (bump === 'major') return `${parts[0] + 1}.0.0`;
17
- if (bump === 'minor') return `${parts[0]}.${parts[1] + 1}.0`;
18
- if (bump === 'patch') return `${parts[0]}.${parts[1]}.${parts[2] + 1}`;
19
- throw new Error(`invalid bump: ${bump}`);
20
- }
16
+ export { nextVersion };
21
17
 
22
18
  function note(version, delta) {
23
19
  const lines = ['added', 'removed', 'changed'].flatMap((kind) =>
@@ -30,8 +26,17 @@ async function updateMetadata(repoRoot, targetVersion, delta) {
30
26
  const pkg = JSON.parse(await readFile(packagePath, 'utf8'));
31
27
  const resumed = pkg.version === targetVersion;
32
28
  if (!resumed) {
33
- pkg.version = targetVersion;
34
- await writeFile(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
29
+ const preview = await previewProjectRelease({
30
+ consumerRoot: repoRoot,
31
+ profile: { versionFiles: ['package.json'], tagPrefix: 'v' },
32
+ requestedVersion: targetVersion,
33
+ repositoryFacts: { dirtyPaths: [], existingTags: [] },
34
+ });
35
+ await applyProjectRelease({
36
+ consumerRoot: repoRoot,
37
+ preview,
38
+ confirmation: preview.confirmation,
39
+ });
35
40
  }
36
41
  const readmePath = join(repoRoot, 'README.md');
37
42
  const readme = await readFile(readmePath, 'utf8');
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from 'node:child_process';
3
+ import { resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { applyProjectRelease, assertSafeReleaseTargets } from '../src/lib/release-apply.mjs';
6
+ import { loadProjectReleaseProfile, previewProjectRelease } from '../src/lib/release-preview.mjs';
7
+
8
+ function gitOutput(consumerRoot, args) {
9
+ return execFileSync('git', args, { cwd: consumerRoot, encoding: 'utf8' });
10
+ }
11
+
12
+ export function readRepositoryFacts(consumerRoot, run = gitOutput) {
13
+ const records = run(
14
+ consumerRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all'],
15
+ ).split('\0').filter(Boolean);
16
+ const dirtyPaths = [];
17
+ for (let index = 0; index < records.length; index += 1) {
18
+ const record = records[index];
19
+ dirtyPaths.push(record.slice(3));
20
+ if (record[0] === 'R' || record[0] === 'C'
21
+ || record[1] === 'R' || record[1] === 'C') index += 1;
22
+ }
23
+ const existingTags = run(consumerRoot, ['tag', '--list'])
24
+ .split('\n').filter(Boolean);
25
+ return { dirtyPaths, existingTags };
26
+ }
27
+
28
+ function argument(args, name) {
29
+ const index = args.indexOf(name);
30
+ return index < 0 ? null : args[index + 1];
31
+ }
32
+
33
+ export async function runProjectRelease(options) {
34
+ const {
35
+ consumerRoot, args,
36
+ repositoryFacts = readRepositoryFacts(consumerRoot),
37
+ output = console.log,
38
+ } = options;
39
+ const [command, requestedVersion] = args;
40
+ if (!['preview', 'apply'].includes(command) || !requestedVersion) {
41
+ throw new Error('usage: project-release <preview|apply> <patch|minor|major|version> [--confirm <token>]');
42
+ }
43
+ const profile = await loadProjectReleaseProfile(consumerRoot);
44
+ await assertSafeReleaseTargets(consumerRoot, profile.versionFiles);
45
+ const preview = await previewProjectRelease({
46
+ consumerRoot, profile, requestedVersion, repositoryFacts,
47
+ });
48
+ if (command === 'preview') {
49
+ output(JSON.stringify(preview));
50
+ return preview;
51
+ }
52
+ const result = await applyProjectRelease({
53
+ consumerRoot,
54
+ preview,
55
+ confirmation: argument(args, '--confirm'),
56
+ });
57
+ output(JSON.stringify(result));
58
+ return result;
59
+ }
60
+
61
+ async function main() {
62
+ await runProjectRelease({ consumerRoot: process.cwd(), args: process.argv.slice(2) });
63
+ }
64
+
65
+ if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) {
66
+ main().catch((error) => {
67
+ console.error(`project-release: ${error.message}`);
68
+ process.exitCode = 1;
69
+ });
70
+ }
@@ -15,8 +15,9 @@ Deny classes (each with a negative fixture below):
15
15
  - residual issue refs (`#NNN`) / hard-rule refs (`HRn`)
16
16
  - unresolvable provenance cross-refs (`ADR-####` / `Welle N` / `Slice N`),
17
17
  with a documented fixture/example allowlist (PROVENANCE_FIXTURE_SUFFIXES)
18
- - `../` cross-skill reaches (skills/scripts/docs — NOT the CLI src, which
19
- legitimately imports `../lib/…`)
18
+ - `../` cross-skill reaches (skills/scripts/docs — except a static top-level
19
+ script import into the shipped `src/lib` deep-module seam, and CLI `src`
20
+ imports which legitimately use `../lib/…`)
20
21
  - bare owner/maintainer names OUTSIDE the generated credit files
21
22
  - high-entropy secrets (after exempting the manifest's own sha256 file-hashes)
22
23
 
@@ -92,18 +93,27 @@ BARE_PRIVATE = [
92
93
  ("maintainer name", re.compile(r"\bNiko\b")),
93
94
  ]
94
95
  PARENT_REF = re.compile(r"\.\./")
96
+ SHIPPED_SRC_IMPORT = re.compile(
97
+ r"^\s*import\b.*\bfrom\s+['\"]\.\./src/lib/[a-z0-9-]+\.mjs['\"];\s*$"
98
+ )
95
99
  LONG_HEX = re.compile(r"\b[0-9a-f]{40,}\b")
96
100
  GH_TOKEN = re.compile(r"\b(?:ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{20,}")
97
101
 
98
102
 
99
103
  def _parent_ref_scan_text(rel: str, text: str) -> str:
100
- if rel != "scripts/loc_offender_core.py":
101
- return text
102
- # This is the portable path-traversal guard itself, not a cross-skill reach.
103
- return "\n".join(
104
- line for line in text.splitlines()
105
- if 'p.startswith("../")' not in line and '"/../" in p' not in line
106
- )
104
+ lines = text.splitlines()
105
+ if rel == "scripts/loc_offender_core.py":
106
+ # This is the portable path-traversal guard itself, not a cross-skill reach.
107
+ lines = [
108
+ line for line in lines
109
+ if 'p.startswith("../")' not in line and '"/../" in p' not in line
110
+ ]
111
+ if rel.startswith("scripts/") and rel.endswith(".mjs"):
112
+ # Top-level shipped scripts may consume the kit's shipped deep modules.
113
+ # The exact static-import shape stays inside the bundle root; every other
114
+ # parent reach remains visible to the deny rule below.
115
+ lines = [line for line in lines if not SHIPPED_SRC_IMPORT.match(line)]
116
+ return "\n".join(lines)
107
117
 
108
118
 
109
119
  def _known_hashes(root: Path) -> set:
@@ -230,6 +240,15 @@ class AuditCatchesEachClass(unittest.TestCase):
230
240
  def test_parent_ref(self):
231
241
  self.assertTrue(any("parent" in v for v in self._body("[x](../../other/SKILL.md)")))
232
242
 
243
+ def test_static_script_import_into_shipped_src_lib_is_allowed(self):
244
+ script = self.dir / "scripts/release.mjs"
245
+ script.parent.mkdir()
246
+ script.write_text(
247
+ "import { run } from '../src/lib/release-core.mjs';\nrun();\n",
248
+ encoding="utf-8",
249
+ )
250
+ self.assertEqual(audit_dir(self.dir), [])
251
+
233
252
  def test_bare_owner_in_body(self):
234
253
  self.assertTrue(any("iKon85" in v for v in self._body("authored by iKon85")))
235
254
 
@@ -0,0 +1,37 @@
1
+ # Worktree Lifecycle contract
2
+
3
+ The consumer-owned `docs/agents/workflow-capabilities.json` profile activates
4
+ one Worktree Lifecycle. `profile.py` loads that policy. `core.py` is the only
5
+ place that derives repository facts and decides whether an event emits, allows,
6
+ or blocks. Surface adapters translate hook payloads and render the decision;
7
+ they do not carry a second branch regex, worktree traversal, or failure policy.
8
+
9
+ ## Profile
10
+
11
+ `worktreeLifecycle` supports:
12
+
13
+ - `enabled`: explicit activation gate.
14
+ - `worktreeRoot`, `branchTemplate`, `pathTemplate`, and `branchRegex`: consumer
15
+ naming and location policy.
16
+ - `mainBranches` and `protectedBranches`: branches guarded in the main checkout.
17
+ - `setupEntry` and ordered `setupSteps`: the portable setup command and project
18
+ setup sequence.
19
+ - `riskyCommandPatterns`: commands that must target the active linked worktree.
20
+
21
+ Unknown or malformed events fail open without changing repository state.
22
+ Security-sensitive, profile-matched edits and commands fail closed only when
23
+ the core proves the target is unsafe.
24
+
25
+ ## Adapters
26
+
27
+ | Adapter | Event | Outcome |
28
+ |---|---|---|
29
+ | `branch-context.py` | SessionStart | emits branch, issue, status, and active-worktree facts |
30
+ | `branch-watch.py` | PostToolUse | emits the same facts after a branch-changing command |
31
+ | `enforce-worktree.py` | PreToolUse | blocks tracked main-checkout edits and cross-worktree leaks |
32
+ | `enforce-worktree-cwd.py` | PreToolUse | blocks verification or Git mutation in the wrong checkout |
33
+ | `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
34
+
35
+ Claude hook wiring and any Codex adaptation consume this same profile and core.
36
+ An adapter may change only the surface event envelope; it must preserve the
37
+ core verdict and message.
@@ -1,106 +1,219 @@
1
- """Consumer-neutral worktree lifecycle profile and git facts."""
1
+ """Consumer-neutral Worktree Lifecycle facts and decisions."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import re
7
- import subprocess
8
6
  from dataclasses import dataclass
9
7
  from pathlib import Path
10
8
  from typing import Any
11
9
 
10
+ from profile import (
11
+ LifecycleError,
12
+ WorktreeProfile,
13
+ load_profile,
14
+ local_branch_exists,
15
+ main_worktree,
16
+ registered_worktrees,
17
+ run,
18
+ )
12
19
 
13
- class LifecycleError(RuntimeError):
14
- """A safe, user-visible lifecycle refusal."""
15
-
20
+ _BRANCH_CHANGE_RE = re.compile(r"\b(?:git\s+(?:checkout|switch)|gh\s+pr\s+(?:merge|checkout))\b")
21
+ _BRANCH_CREATE_RE = re.compile(r"\bgit\s+(?:checkout|switch)\s+-[bc]\s+(\S+)")
16
22
 
17
23
  @dataclass(frozen=True)
18
- class WorktreeProfile:
19
- root: str
20
- branch_template: str
21
- path_template: str
22
- main_branches: tuple[str, ...]
23
- protected_branches: tuple[str, ...]
24
- setup_steps: tuple[dict[str, Any], ...]
25
- branch_regex: str
26
-
27
- def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
28
- return _render(self.branch_template, issue, slug, branch_type)
24
+ class RepoFacts:
25
+ root: Path
26
+ main_root: Path
27
+ branch: str
28
+ main_branch: str
29
+ is_main_worktree: bool
30
+ worktrees: tuple[Path, ...]
31
+ changed_count: int
29
32
 
30
- def relative_path(self, issue: str, slug: str, branch_type: str) -> Path:
31
- name = _render(self.path_template, issue, slug, branch_type)
32
- return Path(self.root) / name
33
33
 
34
- def issue_from_branch(self, branch: str) -> str | None:
35
- match = re.match(self.branch_regex, branch)
36
- return match.groupdict().get("issue") if match else None
34
+ @dataclass(frozen=True)
35
+ class Decision:
36
+ action: str
37
+ message: str = ""
38
+ event_name: str = ""
39
+
40
+
41
+ def collect_facts(cwd: Path) -> RepoFacts:
42
+ root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
43
+ main = main_worktree(root)
44
+ branch = run(["git", "branch", "--show-current"], cwd=root).stdout.strip()
45
+ main_branch = run(
46
+ ["git", "-C", str(main), "branch", "--show-current"],
47
+ cwd=root,
48
+ ).stdout.strip()
49
+ worktrees = tuple(sorted(registered_worktrees(root)))
50
+ status = run(["git", "status", "--porcelain"], cwd=root).stdout
51
+ return RepoFacts(
52
+ root=root,
53
+ main_root=main,
54
+ branch=branch,
55
+ main_branch=main_branch,
56
+ is_main_worktree=root == main,
57
+ worktrees=worktrees,
58
+ changed_count=len([line for line in status.splitlines() if line]),
59
+ )
37
60
 
38
61
 
39
- def _render(template: str, issue: str, slug: str, branch_type: str) -> str:
40
- values = {"issue": issue, "slug": slug, "type": branch_type}
62
+ def branch_context(profile: WorktreeProfile, facts: RepoFacts) -> Decision:
63
+ lines = [f"Branch: {facts.branch}", f"Status: {facts.changed_count} uncommitted change(s)"]
64
+ issue = profile.issue_from_branch(facts.branch)
65
+ if issue:
66
+ lines.insert(1, f"Issue: #{issue}")
67
+ elif facts.branch in profile.protected_branches:
68
+ lines.insert(1, "Warning: direct work on a protected branch")
69
+ else:
70
+ lines.insert(1, "Warning: branch has no issue according to the consumer profile")
71
+ if len(facts.worktrees) > 1:
72
+ lines.append(f"Worktrees: {len(facts.worktrees)} active")
73
+ lines.append(f"Setup entry: {profile.setup_entry}")
74
+ return Decision("emit", "\n".join(lines), "SessionStart")
75
+
76
+
77
+ def repo_relative(target: str, root: Path) -> str | None:
78
+ if not target:
79
+ return None
80
+ path = Path(target)
81
+ if not path.is_absolute():
82
+ return target
41
83
  try:
42
- return template.format(**values)
43
- except (KeyError, ValueError) as error:
44
- raise LifecycleError(f"invalid worktree template: {error}") from error
84
+ return str(path.resolve().relative_to(root))
85
+ except ValueError:
86
+ return None
45
87
 
46
88
 
47
- def load_profile(path: Path) -> WorktreeProfile:
48
- try:
49
- document = json.loads(path.read_text(encoding="utf-8"))
50
- raw = document["worktreeLifecycle"]
51
- except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
52
- raise LifecycleError(f"cannot load worktree lifecycle profile: {error}") from error
53
- if raw.get("enabled") is not True:
54
- raise LifecycleError("worktree lifecycle is not enabled")
55
- main = tuple(raw.get("mainBranches") or ("main", "master"))
56
- return WorktreeProfile(
57
- root=raw.get("worktreeRoot", ".worktrees"),
58
- branch_template=raw.get("branchTemplate", "{type}/{issue}-{slug}"),
59
- path_template=raw.get("pathTemplate", "{type}-{issue}-{slug}"),
60
- main_branches=main,
61
- protected_branches=tuple(raw.get("protectedBranches") or main),
62
- setup_steps=tuple(raw.get("setupSteps") or ()),
63
- branch_regex=raw.get(
64
- "branchRegex",
65
- r"^(?:feat|fix|chore|docs)/(?P<issue>\d+)-",
66
- ),
89
+ def is_ignored(root: Path, relative: str) -> bool:
90
+ result = run(
91
+ ["git", "check-ignore", "-q", "--", relative],
92
+ cwd=root,
93
+ check=False,
67
94
  )
95
+ return result.returncode == 0
68
96
 
69
97
 
70
- def run(
71
- command: list[str],
72
- *,
73
- cwd: Path,
74
- check: bool = True,
75
- ) -> subprocess.CompletedProcess[str]:
76
- result = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
77
- if check and result.returncode != 0:
78
- detail = (result.stderr or result.stdout).strip()
79
- raise LifecycleError(f"{' '.join(command)} failed: {detail}")
80
- return result
81
-
82
-
83
- def main_worktree(cwd: Path) -> Path:
84
- output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
85
- first = next((line for line in output.splitlines() if line.startswith("worktree ")), "")
86
- if not first:
87
- raise LifecycleError("not inside a git worktree")
88
- return Path(first.split(" ", 1)[1]).resolve()
89
-
90
-
91
- def registered_worktrees(cwd: Path) -> set[Path]:
92
- output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
93
- return {
94
- Path(line.split(" ", 1)[1]).resolve()
95
- for line in output.splitlines()
96
- if line.startswith("worktree ")
97
- }
98
-
99
-
100
- def local_branch_exists(cwd: Path, branch: str) -> bool:
98
+ def is_tracked(root: Path, relative: str) -> bool:
101
99
  result = run(
102
- ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
103
- cwd=cwd,
100
+ ["git", "ls-files", "--error-unmatch", "--", relative],
101
+ cwd=root,
104
102
  check=False,
105
103
  )
106
104
  return result.returncode == 0
105
+
106
+
107
+ def edit_decision(
108
+ profile: WorktreeProfile,
109
+ facts: RepoFacts,
110
+ payload: dict[str, Any],
111
+ ) -> Decision:
112
+ if payload.get("tool_name") not in {"Edit", "Write", "MultiEdit"}:
113
+ return Decision("skip")
114
+ target = str((payload.get("tool_input") or {}).get("file_path") or "")
115
+ if facts.is_main_worktree and facts.branch in profile.protected_branches:
116
+ relative = repo_relative(target, facts.root)
117
+ if relative is not None and not is_ignored(facts.root, relative):
118
+ return Decision(
119
+ "block",
120
+ f"Worktree Lifecycle blocked an edit to {relative} on protected branch "
121
+ f"{facts.branch}. Use `{profile.setup_entry}` first.",
122
+ )
123
+ if not facts.is_main_worktree and Path(target).is_absolute():
124
+ relative = repo_relative(target, facts.main_root)
125
+ if (
126
+ relative is not None
127
+ and facts.main_branch in profile.protected_branches
128
+ and is_tracked(facts.main_root, relative)
129
+ and not is_ignored(facts.main_root, relative)
130
+ ):
131
+ return Decision(
132
+ "block",
133
+ f"Worktree Lifecycle blocked a cross-worktree edit to {relative} in "
134
+ f"the protected main checkout. Edit the linked worktree copy instead.",
135
+ )
136
+ return Decision("allow")
137
+
138
+
139
+ def targets_linked_worktree(command: str, facts: RepoFacts) -> bool:
140
+ for worktree in facts.worktrees:
141
+ if worktree == facts.main_root:
142
+ continue
143
+ if str(worktree) in command or str(worktree.relative_to(facts.main_root)) in command:
144
+ return True
145
+ return False
146
+
147
+
148
+ def command_decision(
149
+ profile: WorktreeProfile,
150
+ facts: RepoFacts,
151
+ payload: dict[str, Any],
152
+ ) -> Decision:
153
+ if payload.get("tool_name") != "Bash":
154
+ return Decision("skip")
155
+ command = str((payload.get("tool_input") or {}).get("command") or "")
156
+ if not command:
157
+ return Decision("skip")
158
+ risky = any(re.search(pattern, command) for pattern in profile.risky_command_patterns)
159
+ if not risky:
160
+ return Decision("allow")
161
+ if re.search(r"\bgit\s+push\s+\S+\s+--delete\s+\S+", command):
162
+ return Decision("allow")
163
+ if targets_linked_worktree(command, facts):
164
+ return Decision("allow")
165
+ if (
166
+ facts.is_main_worktree
167
+ and facts.branch in profile.protected_branches
168
+ and len(facts.worktrees) > 1
169
+ ):
170
+ active = ", ".join(path.name for path in facts.worktrees if path != facts.main_root)
171
+ return Decision(
172
+ "block",
173
+ f"Worktree Lifecycle blocked `{command}` in the protected main checkout "
174
+ f"while linked worktrees are active: {active}. Run it in the target worktree.",
175
+ )
176
+ return Decision("allow")
177
+
178
+
179
+ def branch_create_decision(
180
+ profile: WorktreeProfile,
181
+ facts: RepoFacts,
182
+ payload: dict[str, Any],
183
+ ) -> Decision:
184
+ if payload.get("tool_name") != "Bash":
185
+ return Decision("skip")
186
+ command = str((payload.get("tool_input") or {}).get("command") or "")
187
+ match = _BRANCH_CREATE_RE.search(command)
188
+ if match is None or profile.issue_from_branch(match.group(1)) is None:
189
+ return Decision("allow")
190
+ if facts.is_main_worktree and len(facts.worktrees) > 1:
191
+ return Decision(
192
+ "block",
193
+ f"Worktree Lifecycle blocked branch creation `{match.group(1)}` in the main "
194
+ f"checkout while linked worktrees are active. Use `{profile.setup_entry}`.",
195
+ )
196
+ return Decision("allow")
197
+
198
+
199
+ def evaluate(
200
+ profile: WorktreeProfile,
201
+ facts: RepoFacts,
202
+ event: str,
203
+ payload: dict[str, Any],
204
+ ) -> Decision:
205
+ if event == "session-start":
206
+ return branch_context(profile, facts)
207
+ if event == "branch-watch":
208
+ command = str((payload.get("tool_input") or {}).get("command") or "")
209
+ if payload.get("tool_name") != "Bash" or not _BRANCH_CHANGE_RE.search(command):
210
+ return Decision("skip")
211
+ context = branch_context(profile, facts)
212
+ return Decision("emit", context.message, "PostToolUse")
213
+ if event == "edit":
214
+ return edit_decision(profile, facts, payload)
215
+ if event == "command-cwd":
216
+ return command_decision(profile, facts, payload)
217
+ if event == "branch-create":
218
+ return branch_create_decision(profile, facts, payload)
219
+ return Decision("skip")
@@ -0,0 +1,116 @@
1
+ """Worktree Lifecycle profile loading and low-level git operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ class LifecycleError(RuntimeError):
14
+ """A safe, user-visible lifecycle refusal."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class WorktreeProfile:
19
+ root: str
20
+ branch_template: str
21
+ path_template: str
22
+ main_branches: tuple[str, ...]
23
+ protected_branches: tuple[str, ...]
24
+ setup_steps: tuple[dict[str, Any], ...]
25
+ branch_regex: str
26
+ setup_entry: str
27
+ risky_command_patterns: tuple[str, ...]
28
+
29
+ def branch_name(self, issue: str, slug: str, branch_type: str) -> str:
30
+ return _render(self.branch_template, issue, slug, branch_type)
31
+
32
+ def relative_path(self, issue: str, slug: str, branch_type: str) -> Path:
33
+ name = _render(self.path_template, issue, slug, branch_type)
34
+ return Path(self.root) / name
35
+
36
+ def issue_from_branch(self, branch: str) -> str | None:
37
+ match = re.match(self.branch_regex, branch)
38
+ return match.groupdict().get("issue") if match else None
39
+
40
+
41
+ def _render(template: str, issue: str, slug: str, branch_type: str) -> str:
42
+ values = {"issue": issue, "slug": slug, "type": branch_type}
43
+ try:
44
+ return template.format(**values)
45
+ except (KeyError, ValueError) as error:
46
+ raise LifecycleError(f"invalid worktree template: {error}") from error
47
+
48
+
49
+ def load_profile(path: Path) -> WorktreeProfile:
50
+ try:
51
+ document = json.loads(path.read_text(encoding="utf-8"))
52
+ raw = document["worktreeLifecycle"]
53
+ except (OSError, json.JSONDecodeError, KeyError, TypeError) as error:
54
+ raise LifecycleError(f"cannot load worktree lifecycle profile: {error}") from error
55
+ if raw.get("enabled") is not True:
56
+ raise LifecycleError("worktree lifecycle is not enabled")
57
+ main = tuple(raw.get("mainBranches") or ("main", "master"))
58
+ return WorktreeProfile(
59
+ root=raw.get("worktreeRoot", ".worktrees"),
60
+ branch_template=raw.get("branchTemplate", "{type}/{issue}-{slug}"),
61
+ path_template=raw.get("pathTemplate", "{type}-{issue}-{slug}"),
62
+ main_branches=main,
63
+ protected_branches=tuple(raw.get("protectedBranches") or main),
64
+ setup_steps=tuple(raw.get("setupSteps") or ()),
65
+ branch_regex=raw.get(
66
+ "branchRegex",
67
+ r"^(?:feat|fix|chore|docs)/(?P<issue>\d+)-",
68
+ ),
69
+ setup_entry=raw.get(
70
+ "setupEntry",
71
+ "python3 scripts/worktree-lifecycle/setup.py",
72
+ ),
73
+ risky_command_patterns=tuple(raw.get("riskyCommandPatterns") or (
74
+ r"\b(?:npm|pnpm|yarn)\s+(?:run\s+)?(?:test|typecheck|build)\b",
75
+ r"\bgit\s+(?:commit|push)\b",
76
+ )),
77
+ )
78
+
79
+
80
+ def run(
81
+ command: list[str],
82
+ *,
83
+ cwd: Path,
84
+ check: bool = True,
85
+ ) -> subprocess.CompletedProcess[str]:
86
+ result = subprocess.run(command, cwd=cwd, capture_output=True, text=True)
87
+ if check and result.returncode != 0:
88
+ detail = (result.stderr or result.stdout).strip()
89
+ raise LifecycleError(f"{' '.join(command)} failed: {detail}")
90
+ return result
91
+
92
+
93
+ def main_worktree(cwd: Path) -> Path:
94
+ output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
95
+ first = next((line for line in output.splitlines() if line.startswith("worktree ")), "")
96
+ if not first:
97
+ raise LifecycleError("not inside a git worktree")
98
+ return Path(first.split(" ", 1)[1]).resolve()
99
+
100
+
101
+ def registered_worktrees(cwd: Path) -> set[Path]:
102
+ output = run(["git", "worktree", "list", "--porcelain"], cwd=cwd).stdout
103
+ return {
104
+ Path(line.split(" ", 1)[1]).resolve()
105
+ for line in output.splitlines()
106
+ if line.startswith("worktree ")
107
+ }
108
+
109
+
110
+ def local_branch_exists(cwd: Path, branch: str) -> bool:
111
+ result = run(
112
+ ["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
113
+ cwd=cwd,
114
+ check=False,
115
+ )
116
+ return result.returncode == 0
@@ -48,9 +48,12 @@ export const HELPER_FILES = [
48
48
  { path: 'scripts/release-parity.mjs', kind: 'script', mode: 0o644 },
49
49
  { path: 'scripts/release-state.mjs', kind: 'script', mode: 0o644 },
50
50
  // Consumer-owned project release profiles use these read-only shared
51
- // primitives before any apply/commit/tag action is allowed.
51
+ // primitives before any apply/commit/tag action is allowed. The thin CLI
52
+ // owns only repository-fact collection and delegates to the same engine.
52
53
  { path: 'src/lib/semver.mjs', kind: 'script', mode: 0o644 },
53
54
  { path: 'src/lib/release-preview.mjs', kind: 'script', mode: 0o644 },
55
+ { path: 'src/lib/release-apply.mjs', kind: 'script', mode: 0o644 },
56
+ { path: 'scripts/project-release.mjs', kind: 'script', mode: 0o755 },
54
57
  // GitHub-consumer automation: invokes the existing update command, then owns
55
58
  // only the stable tested branch/pull-request upsert.
56
59
  { path: 'scripts/kit-update-pr.mjs', kind: 'script', mode: 0o755 },
@@ -69,12 +72,21 @@ export const HELPER_FILES = [
69
72
  // core.py, while capabilities.json keeps the historical 8/8 denominator
70
73
  // explicit until the remaining hook and cleanup adapters are activated.
71
74
  { path: 'scripts/worktree-lifecycle/core.py', kind: 'script', mode: 0o644 },
75
+ { path: 'scripts/worktree-lifecycle/profile.py', kind: 'script', mode: 0o644 },
72
76
  { path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
73
77
  { path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
78
+ { path: 'scripts/worktree-lifecycle/README.md', kind: 'doc', mode: 0o644 },
74
79
  // Shared hook utility imported by the shipped hooks (drift-guard,
75
80
  // sync-board-status). Library (imported, not run) → 0o644. MUST ship or those
76
81
  // hooks ImportError on arrival.
77
82
  { path: '.claude/hooks/_hook_utils.py', kind: 'hook', mode: 0o644 },
83
+ // Thin Worktree Lifecycle adapters; all branch parsing, traversal and
84
+ // fail-open/fail-closed policy stays in scripts/worktree-lifecycle/core.py.
85
+ { path: '.claude/hooks/branch-context.py', kind: 'hook', mode: 0o755 },
86
+ { path: '.claude/hooks/branch-watch.py', kind: 'hook', mode: 0o755 },
87
+ { path: '.claude/hooks/enforce-worktree.py', kind: 'hook', mode: 0o755 },
88
+ { path: '.claude/hooks/enforce-worktree-cwd.py', kind: 'hook', mode: 0o755 },
89
+ { path: '.claude/hooks/enforce-worktree-discipline.py', kind: 'hook', mode: 0o755 },
78
90
  { path: '.claude/hooks/drift-guard.py', kind: 'hook', mode: 0o755 },
79
91
  // SessionStart skill-freshness drift-hint (audit-skills names it). For each
80
92
  // <skill>/SOURCES.txt it flags sources newer in git than the SKILL.md. Imports
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { writeAtomic } from './atomicWrite.mjs';
5
+
6
+ const sha256 = (body) => createHash('sha256').update(body).digest('hex');
7
+
8
+ async function readSnapshot(consumerRoot, snapshot) {
9
+ return Promise.all(snapshot.map(async ({ path }) => {
10
+ const body = await readFile(join(consumerRoot, path), 'utf8');
11
+ return { path, body, version: JSON.parse(body).version, sha256: sha256(body) };
12
+ }));
13
+ }
14
+
15
+ const escapes = (root, target) => {
16
+ const path = relative(root, target);
17
+ return path === '..' || path.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
18
+ || isAbsolute(path);
19
+ };
20
+
21
+ export async function assertSafeReleaseTargets(consumerRoot, paths) {
22
+ const canonicalRoot = await realpath(consumerRoot);
23
+ for (const path of paths) {
24
+ const target = resolve(consumerRoot, path);
25
+ if (isAbsolute(path) || escapes(resolve(consumerRoot), target)) {
26
+ throw new Error(`release target is outside consumer root: ${path}`);
27
+ }
28
+ if ((await lstat(target)).isSymbolicLink()) {
29
+ throw new Error(`symlinked release target is not allowed: ${path}`);
30
+ }
31
+ if (escapes(canonicalRoot, await realpath(target))) {
32
+ throw new Error(`release target resolves outside consumer root: ${path}`);
33
+ }
34
+ }
35
+ }
36
+
37
+ export async function applyProjectRelease(options) {
38
+ const { consumerRoot, preview, confirmation } = options;
39
+ const write = options.write ?? writeAtomic;
40
+ if (preview.status !== 'ready') throw new Error('release preview is blocked');
41
+ if (confirmation !== preview.confirmation) {
42
+ throw new Error('release confirmation does not match preview');
43
+ }
44
+ await assertSafeReleaseTargets(
45
+ consumerRoot, preview.snapshot.map(({ path }) => path),
46
+ );
47
+ const current = await readSnapshot(consumerRoot, preview.snapshot);
48
+ if (current.every(({ version }) => version === preview.summary.targetVersion)) {
49
+ throw new Error(`release already prepared at ${preview.summary.targetVersion}`);
50
+ }
51
+ for (const expected of preview.snapshot) {
52
+ const actual = current.find(({ path }) => path === expected.path);
53
+ if (actual.sha256 !== expected.sha256) {
54
+ throw new Error(`release target changed after preview: ${expected.path}`);
55
+ }
56
+ }
57
+ const version = preview.summary.targetVersion;
58
+ const candidates = current.map((file) => {
59
+ const body = JSON.parse(file.body);
60
+ body.version = version;
61
+ return { ...file, next: `${JSON.stringify(body, null, 2)}\n` };
62
+ });
63
+ const written = [];
64
+ try {
65
+ for (const file of candidates) {
66
+ await write(join(consumerRoot, file.path), file.next);
67
+ written.push(file);
68
+ }
69
+ } catch (error) {
70
+ for (const file of written.reverse()) {
71
+ await writeAtomic(join(consumerRoot, file.path), file.body);
72
+ }
73
+ throw error;
74
+ }
75
+ return {
76
+ status: 'prepared',
77
+ version,
78
+ updated: candidates.map(({ path }) => path),
79
+ plannedTag: preview.actions.find(({ type }) => type === 'tag').name,
80
+ };
81
+ }