@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.
- package/.agents/skills/project-release/SKILL.md +71 -0
- package/.agents/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/hooks/_hook_utils.py +3 -0
- package/.claude/hooks/branch-context.py +25 -0
- package/.claude/hooks/branch-watch.py +25 -0
- package/.claude/hooks/enforce-worktree-cwd.py +26 -0
- package/.claude/hooks/enforce-worktree-discipline.py +26 -0
- package/.claude/hooks/enforce-worktree.py +26 -0
- package/.claude/skills/project-release/SKILL.md +71 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/skills/skill-manifest.json +10 -0
- package/PROVENANCE.md +1 -1
- package/README.md +25 -2
- package/agent-workflow-kit.package.json +87 -6
- package/package.json +1 -1
- package/scripts/kit-release.mjs +15 -10
- package/scripts/project-release.mjs +70 -0
- package/scripts/test_skill_publish_audit.py +28 -9
- package/scripts/worktree-lifecycle/README.md +37 -0
- package/scripts/worktree-lifecycle/core.py +194 -81
- package/scripts/worktree-lifecycle/profile.py +116 -0
- package/src/lib/bundle.mjs +13 -1
- package/src/lib/release-apply.mjs +81 -0
|
@@ -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
|
-
**
|
|
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
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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": {
|
package/scripts/kit-release.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
34
|
-
|
|
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 —
|
|
19
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
19
|
-
root:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
43
|
-
except
|
|
44
|
-
|
|
84
|
+
return str(path.resolve().relative_to(root))
|
|
85
|
+
except ValueError:
|
|
86
|
+
return None
|
|
45
87
|
|
|
46
88
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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", "
|
|
103
|
-
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
|
package/src/lib/bundle.mjs
CHANGED
|
@@ -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
|
+
}
|