@ikon85/agent-workflow-kit 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/project-release/SKILL.md +71 -0
- package/.agents/skills/setup-workflow/SKILL.md +36 -0
- package/.agents/skills/setup-workflow/workflow-overview.md +1 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +81 -0
- package/.claude/hooks/slice-handoff-hint.py +25 -0
- package/.claude/skills/project-release/SKILL.md +71 -0
- package/.claude/skills/setup-workflow/SKILL.md +36 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +1 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +81 -0
- package/.claude/skills/skill-manifest.json +10 -0
- package/PROVENANCE.md +1 -1
- package/README.md +26 -2
- package/agent-workflow-kit.package.json +74 -10
- 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/test_worktree_wrapup_contract.py +52 -0
- package/scripts/worktree-lifecycle/README.md +8 -0
- package/scripts/worktree-lifecycle/capabilities.json +8 -0
- package/scripts/worktree-lifecycle/cleanup.py +57 -0
- package/scripts/worktree-lifecycle/core.py +80 -0
- package/scripts/wrapup-land.py +45 -0
- package/src/lib/bundle.mjs +6 -1
- package/src/lib/release-apply.mjs +81 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"kitVersion": "0.
|
|
2
|
+
"kitVersion": "0.22.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",
|
|
@@ -564,7 +573,7 @@
|
|
|
564
573
|
"kind": "skill",
|
|
565
574
|
"ownerSkill": "setup-workflow",
|
|
566
575
|
"surface": "codex",
|
|
567
|
-
"sha256": "
|
|
576
|
+
"sha256": "a1145034dbc334fb388be27dfb77cf1d93f3cefc6f760d5f4fa5d3ee98050dff",
|
|
568
577
|
"mode": 420,
|
|
569
578
|
"origin": "kit"
|
|
570
579
|
},
|
|
@@ -609,7 +618,16 @@
|
|
|
609
618
|
"kind": "skill",
|
|
610
619
|
"ownerSkill": "setup-workflow",
|
|
611
620
|
"surface": "codex",
|
|
612
|
-
"sha256": "
|
|
621
|
+
"sha256": "bc4db6413135ad9056cc63691cb823a9b198ad47538f292b37fcbb915477a716",
|
|
622
|
+
"mode": 420,
|
|
623
|
+
"origin": "kit"
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
"path": ".agents/skills/setup-workflow/worktree-lifecycle.md",
|
|
627
|
+
"kind": "skill",
|
|
628
|
+
"ownerSkill": "setup-workflow",
|
|
629
|
+
"surface": "codex",
|
|
630
|
+
"sha256": "5e0914874f0b9f940205fbcfcfbba87d0c2df8f31db104d2c5328a0d20c80616",
|
|
613
631
|
"mode": 420,
|
|
614
632
|
"origin": "kit"
|
|
615
633
|
},
|
|
@@ -912,6 +930,13 @@
|
|
|
912
930
|
"mode": 493,
|
|
913
931
|
"origin": "kit"
|
|
914
932
|
},
|
|
933
|
+
{
|
|
934
|
+
"path": ".claude/hooks/slice-handoff-hint.py",
|
|
935
|
+
"kind": "hook",
|
|
936
|
+
"sha256": "664ca09a7e86c267ce7d49bf6b2529ebb9e75a4fd3443548f36f6eb9d4665abf",
|
|
937
|
+
"mode": 493,
|
|
938
|
+
"origin": "kit"
|
|
939
|
+
},
|
|
915
940
|
{
|
|
916
941
|
"path": ".claude/hooks/sync-board-status.py",
|
|
917
942
|
"kind": "hook",
|
|
@@ -1405,6 +1430,15 @@
|
|
|
1405
1430
|
"mode": 420,
|
|
1406
1431
|
"origin": "kit"
|
|
1407
1432
|
},
|
|
1433
|
+
{
|
|
1434
|
+
"path": ".claude/skills/project-release/SKILL.md",
|
|
1435
|
+
"kind": "skill",
|
|
1436
|
+
"ownerSkill": "project-release",
|
|
1437
|
+
"surface": "claude",
|
|
1438
|
+
"sha256": "5aa0b0827e8691334c30608df8506c756455ecebcc460efa140889297cf02ed5",
|
|
1439
|
+
"mode": 420,
|
|
1440
|
+
"origin": "kit"
|
|
1441
|
+
},
|
|
1408
1442
|
{
|
|
1409
1443
|
"path": ".claude/skills/prototype/LOGIC.md",
|
|
1410
1444
|
"kind": "skill",
|
|
@@ -1608,7 +1642,7 @@
|
|
|
1608
1642
|
"kind": "skill",
|
|
1609
1643
|
"ownerSkill": "setup-workflow",
|
|
1610
1644
|
"surface": "claude",
|
|
1611
|
-
"sha256": "
|
|
1645
|
+
"sha256": "a1145034dbc334fb388be27dfb77cf1d93f3cefc6f760d5f4fa5d3ee98050dff",
|
|
1612
1646
|
"mode": 420,
|
|
1613
1647
|
"origin": "kit"
|
|
1614
1648
|
},
|
|
@@ -1653,7 +1687,16 @@
|
|
|
1653
1687
|
"kind": "skill",
|
|
1654
1688
|
"ownerSkill": "setup-workflow",
|
|
1655
1689
|
"surface": "claude",
|
|
1656
|
-
"sha256": "
|
|
1690
|
+
"sha256": "bc4db6413135ad9056cc63691cb823a9b198ad47538f292b37fcbb915477a716",
|
|
1691
|
+
"mode": 420,
|
|
1692
|
+
"origin": "kit"
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
"path": ".claude/skills/setup-workflow/worktree-lifecycle.md",
|
|
1696
|
+
"kind": "skill",
|
|
1697
|
+
"ownerSkill": "setup-workflow",
|
|
1698
|
+
"surface": "claude",
|
|
1699
|
+
"sha256": "5e0914874f0b9f940205fbcfcfbba87d0c2df8f31db104d2c5328a0d20c80616",
|
|
1657
1700
|
"mode": 420,
|
|
1658
1701
|
"origin": "kit"
|
|
1659
1702
|
},
|
|
@@ -2028,7 +2071,7 @@
|
|
|
2028
2071
|
{
|
|
2029
2072
|
"path": "scripts/kit-release.mjs",
|
|
2030
2073
|
"kind": "script",
|
|
2031
|
-
"sha256": "
|
|
2074
|
+
"sha256": "3c86eb5fc4b2071b423d87f023ba4c3f778ffe716a2603dcecdbe4e83ec1b8df",
|
|
2032
2075
|
"mode": 420,
|
|
2033
2076
|
"origin": "kit"
|
|
2034
2077
|
},
|
|
@@ -2102,6 +2145,13 @@
|
|
|
2102
2145
|
"mode": 420,
|
|
2103
2146
|
"origin": "kit"
|
|
2104
2147
|
},
|
|
2148
|
+
{
|
|
2149
|
+
"path": "scripts/project-release.mjs",
|
|
2150
|
+
"kind": "script",
|
|
2151
|
+
"sha256": "6c08cf16f97fdbc0172041c64109e859b1f073862e9f8edc2accbafd97b33843",
|
|
2152
|
+
"mode": 493,
|
|
2153
|
+
"origin": "kit"
|
|
2154
|
+
},
|
|
2105
2155
|
{
|
|
2106
2156
|
"path": "scripts/release-delta-guard.mjs",
|
|
2107
2157
|
"kind": "script",
|
|
@@ -2126,14 +2176,21 @@
|
|
|
2126
2176
|
{
|
|
2127
2177
|
"path": "scripts/worktree-lifecycle/capabilities.json",
|
|
2128
2178
|
"kind": "doc",
|
|
2129
|
-
"sha256": "
|
|
2179
|
+
"sha256": "e80501bd86bbdb0ba296fc6b172442d8ad128f43e571777698657491279e4305",
|
|
2130
2180
|
"mode": 420,
|
|
2131
2181
|
"origin": "kit"
|
|
2132
2182
|
},
|
|
2183
|
+
{
|
|
2184
|
+
"path": "scripts/worktree-lifecycle/cleanup.py",
|
|
2185
|
+
"kind": "script",
|
|
2186
|
+
"sha256": "370af8840732db3b6ba39b4825686f3980f69f612038123bc86b2c68921f02ca",
|
|
2187
|
+
"mode": 493,
|
|
2188
|
+
"origin": "kit"
|
|
2189
|
+
},
|
|
2133
2190
|
{
|
|
2134
2191
|
"path": "scripts/worktree-lifecycle/core.py",
|
|
2135
2192
|
"kind": "script",
|
|
2136
|
-
"sha256": "
|
|
2193
|
+
"sha256": "d54c4d663c234b7d2e607b99f6090b46104c21ba4326ef03bcb65de39d1cce19",
|
|
2137
2194
|
"mode": 420,
|
|
2138
2195
|
"origin": "kit"
|
|
2139
2196
|
},
|
|
@@ -2147,7 +2204,7 @@
|
|
|
2147
2204
|
{
|
|
2148
2205
|
"path": "scripts/worktree-lifecycle/README.md",
|
|
2149
2206
|
"kind": "doc",
|
|
2150
|
-
"sha256": "
|
|
2207
|
+
"sha256": "5dba0332253c37162ad1b2f1b7bf504a23af85bf297fdaa8920036f610ff754a",
|
|
2151
2208
|
"mode": 420,
|
|
2152
2209
|
"origin": "kit"
|
|
2153
2210
|
},
|
|
@@ -2161,10 +2218,17 @@
|
|
|
2161
2218
|
{
|
|
2162
2219
|
"path": "scripts/wrapup-land.py",
|
|
2163
2220
|
"kind": "script",
|
|
2164
|
-
"sha256": "
|
|
2221
|
+
"sha256": "ee1f567e7be4f7baae222ed3974d39e5efee93edb2a663cacc8e18b567fa837e",
|
|
2165
2222
|
"mode": 493,
|
|
2166
2223
|
"origin": "kit"
|
|
2167
2224
|
},
|
|
2225
|
+
{
|
|
2226
|
+
"path": "src/lib/release-apply.mjs",
|
|
2227
|
+
"kind": "script",
|
|
2228
|
+
"sha256": "d9b3bd07c88629c570d8a58f1d91d1cdac1457470cee973f99e186d9630efb03",
|
|
2229
|
+
"mode": 420,
|
|
2230
|
+
"origin": "kit"
|
|
2231
|
+
},
|
|
2168
2232
|
{
|
|
2169
2233
|
"path": "src/lib/release-preview.mjs",
|
|
2170
2234
|
"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.22.0",
|
|
4
4
|
"description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
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,52 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Wrapup must reuse the shipped Worktree Lifecycle cleanup assessment."""
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
12
|
+
WRAPUP = REPO / "scripts/wrapup-land.py"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_wrapup():
|
|
16
|
+
spec = importlib.util.spec_from_file_location("wrapup_land_worktree_contract", WRAPUP)
|
|
17
|
+
module = importlib.util.module_from_spec(spec)
|
|
18
|
+
spec.loader.exec_module(module)
|
|
19
|
+
return module
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WorktreeCleanupContract(unittest.TestCase):
|
|
23
|
+
def test_active_profile_delegates_removal_safety_to_shared_assessment(self):
|
|
24
|
+
wrapup = load_wrapup()
|
|
25
|
+
calls = []
|
|
26
|
+
|
|
27
|
+
class FakeCore:
|
|
28
|
+
@staticmethod
|
|
29
|
+
def load_profile(path):
|
|
30
|
+
calls.append(("profile", path))
|
|
31
|
+
return {"enabled": True}
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def cleanup_assessment(profile, main, target, merge_target=None):
|
|
35
|
+
calls.append(("assessment", main, target, merge_target))
|
|
36
|
+
return SimpleNamespace(reasons=("dirty worktree",), assumptions="reviewed")
|
|
37
|
+
|
|
38
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
39
|
+
main = Path(tmp)
|
|
40
|
+
profile = main / "docs/agents/workflow-capabilities.json"
|
|
41
|
+
profile.parent.mkdir(parents=True)
|
|
42
|
+
profile.write_text('{"worktreeLifecycle":{"enabled":true}}\n')
|
|
43
|
+
with patch.object(wrapup, "load_worktree_cleanup_core", return_value=FakeCore):
|
|
44
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
45
|
+
wrapup.ensure_worktree_removable(str(main / "wt"), str(main))
|
|
46
|
+
|
|
47
|
+
self.assertIn("shared cleanup guard", stopped.exception.reason)
|
|
48
|
+
self.assertEqual(calls[1][-1], "origin/main")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
unittest.main()
|
|
@@ -31,6 +31,14 @@ the core proves the target is unsafe.
|
|
|
31
31
|
| `enforce-worktree.py` | PreToolUse | blocks tracked main-checkout edits and cross-worktree leaks |
|
|
32
32
|
| `enforce-worktree-cwd.py` | PreToolUse | blocks verification or Git mutation in the wrong checkout |
|
|
33
33
|
| `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
|
|
34
|
+
| `slice-handoff-hint.py` | UserPromptSubmit | names the configured setup entry for a defined slice |
|
|
35
|
+
|
|
36
|
+
## Cleanup
|
|
37
|
+
|
|
38
|
+
`cleanup.py` previews by default. Removal refuses protected, dirty, or unmerged
|
|
39
|
+
worktrees. The assessment reads `ANNAHMEN.md` before removal and returns its
|
|
40
|
+
contents for propagation. `wrapup-land.py` invokes this same assessment after a
|
|
41
|
+
merge and before killing processes or removing the worktree.
|
|
34
42
|
|
|
35
43
|
Claude hook wiring and any Codex adaptation consume this same profile and core.
|
|
36
44
|
An adapter may change only the surface event envelope; it must preserve the
|
|
@@ -3,34 +3,42 @@
|
|
|
3
3
|
"capabilities": [
|
|
4
4
|
{
|
|
5
5
|
"historicalPath": "scripts/setup-worktree.sh",
|
|
6
|
+
"artifact": "scripts/worktree-lifecycle/setup.py",
|
|
6
7
|
"primitives": ["profile", "git-facts", "transactional-setup"]
|
|
7
8
|
},
|
|
8
9
|
{
|
|
9
10
|
"historicalPath": ".claude/hooks/branch-context.py",
|
|
11
|
+
"artifact": ".claude/hooks/branch-context.py",
|
|
10
12
|
"primitives": ["profile", "git-facts", "hook-event-output"]
|
|
11
13
|
},
|
|
12
14
|
{
|
|
13
15
|
"historicalPath": ".claude/hooks/branch-watch.py",
|
|
16
|
+
"artifact": ".claude/hooks/branch-watch.py",
|
|
14
17
|
"primitives": ["profile", "git-facts", "hook-event-output"]
|
|
15
18
|
},
|
|
16
19
|
{
|
|
17
20
|
"historicalPath": ".claude/hooks/enforce-worktree.py",
|
|
21
|
+
"artifact": ".claude/hooks/enforce-worktree.py",
|
|
18
22
|
"primitives": ["profile", "git-facts", "worktree-decision"]
|
|
19
23
|
},
|
|
20
24
|
{
|
|
21
25
|
"historicalPath": ".claude/hooks/enforce-worktree-cwd.py",
|
|
26
|
+
"artifact": ".claude/hooks/enforce-worktree-cwd.py",
|
|
22
27
|
"primitives": ["profile", "git-facts", "worktree-decision"]
|
|
23
28
|
},
|
|
24
29
|
{
|
|
25
30
|
"historicalPath": ".claude/hooks/enforce-worktree-discipline.py",
|
|
31
|
+
"artifact": ".claude/hooks/enforce-worktree-discipline.py",
|
|
26
32
|
"primitives": ["profile", "git-facts", "worktree-decision"]
|
|
27
33
|
},
|
|
28
34
|
{
|
|
29
35
|
"historicalPath": ".claude/hooks/slice-handoff-hint.py",
|
|
36
|
+
"artifact": ".claude/hooks/slice-handoff-hint.py",
|
|
30
37
|
"primitives": ["profile", "git-facts", "hook-event-output"]
|
|
31
38
|
},
|
|
32
39
|
{
|
|
33
40
|
"historicalPath": "scripts/cleanup-worktrees.sh",
|
|
41
|
+
"artifact": "scripts/worktree-lifecycle/cleanup.py",
|
|
34
42
|
"primitives": ["profile", "git-facts", "cleanup-safety"]
|
|
35
43
|
}
|
|
36
44
|
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Preview or safely remove a profile-governed linked worktree."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from core import LifecycleError, cleanup_assessment, load_profile, main_worktree, run
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_args() -> argparse.Namespace:
|
|
15
|
+
parser = argparse.ArgumentParser()
|
|
16
|
+
parser.add_argument("--profile", default="docs/agents/workflow-capabilities.json")
|
|
17
|
+
parser.add_argument("--remove", action="store_true")
|
|
18
|
+
parser.add_argument("worktree")
|
|
19
|
+
return parser.parse_args()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def execute(args: argparse.Namespace) -> dict:
|
|
23
|
+
main = main_worktree(Path.cwd())
|
|
24
|
+
profile_path = Path(args.profile)
|
|
25
|
+
if not profile_path.is_absolute():
|
|
26
|
+
profile_path = main / profile_path
|
|
27
|
+
profile = load_profile(profile_path)
|
|
28
|
+
assessment = cleanup_assessment(profile, main, Path(args.worktree))
|
|
29
|
+
report = {
|
|
30
|
+
"worktree": str(assessment.worktree),
|
|
31
|
+
"branch": assessment.branch,
|
|
32
|
+
"removable": assessment.removable,
|
|
33
|
+
"reasons": list(assessment.reasons),
|
|
34
|
+
"assumptions": assessment.assumptions,
|
|
35
|
+
"removed": False,
|
|
36
|
+
}
|
|
37
|
+
if not args.remove:
|
|
38
|
+
return report
|
|
39
|
+
if not assessment.removable:
|
|
40
|
+
raise LifecycleError("; ".join(assessment.reasons))
|
|
41
|
+
run(["git", "worktree", "remove", str(assessment.worktree)], cwd=main)
|
|
42
|
+
run(["git", "branch", "-d", assessment.branch], cwd=main)
|
|
43
|
+
report["removed"] = True
|
|
44
|
+
return report
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> int:
|
|
48
|
+
try:
|
|
49
|
+
print(json.dumps(execute(parse_args()), ensure_ascii=False, indent=2))
|
|
50
|
+
return 0
|
|
51
|
+
except LifecycleError as error:
|
|
52
|
+
print(f"STOP: {error}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
raise SystemExit(main())
|
|
@@ -38,6 +38,18 @@ class Decision:
|
|
|
38
38
|
event_name: str = ""
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class CleanupAssessment:
|
|
43
|
+
worktree: Path
|
|
44
|
+
branch: str
|
|
45
|
+
assumptions: str
|
|
46
|
+
reasons: tuple[str, ...]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def removable(self) -> bool:
|
|
50
|
+
return not self.reasons
|
|
51
|
+
|
|
52
|
+
|
|
41
53
|
def collect_facts(cwd: Path) -> RepoFacts:
|
|
42
54
|
root = Path(run(["git", "rev-parse", "--show-toplevel"], cwd=cwd).stdout.strip()).resolve()
|
|
43
55
|
main = main_worktree(root)
|
|
@@ -104,6 +116,50 @@ def is_tracked(root: Path, relative: str) -> bool:
|
|
|
104
116
|
return result.returncode == 0
|
|
105
117
|
|
|
106
118
|
|
|
119
|
+
def cleanup_assessment(
|
|
120
|
+
profile: WorktreeProfile,
|
|
121
|
+
main: Path,
|
|
122
|
+
target: Path,
|
|
123
|
+
merge_target: str | None = None,
|
|
124
|
+
) -> CleanupAssessment:
|
|
125
|
+
worktree = target.resolve()
|
|
126
|
+
reasons = []
|
|
127
|
+
branch = run(
|
|
128
|
+
["git", "-C", str(worktree), "branch", "--show-current"],
|
|
129
|
+
cwd=main,
|
|
130
|
+
check=False,
|
|
131
|
+
).stdout.strip()
|
|
132
|
+
if worktree not in registered_worktrees(main):
|
|
133
|
+
reasons.append("not a registered worktree")
|
|
134
|
+
if not branch:
|
|
135
|
+
reasons.append("detached or unreadable branch")
|
|
136
|
+
if branch in profile.protected_branches or worktree == main.resolve():
|
|
137
|
+
reasons.append(f"protected worktree branch: {branch or '<unknown>'}")
|
|
138
|
+
status = run(
|
|
139
|
+
["git", "-C", str(worktree), "status", "--porcelain"],
|
|
140
|
+
cwd=main,
|
|
141
|
+
check=False,
|
|
142
|
+
).stdout
|
|
143
|
+
if status.strip():
|
|
144
|
+
reasons.append("dirty worktree")
|
|
145
|
+
if branch and branch not in profile.protected_branches:
|
|
146
|
+
main_branch = merge_target or run(
|
|
147
|
+
["git", "-C", str(main), "branch", "--show-current"],
|
|
148
|
+
cwd=main,
|
|
149
|
+
check=False,
|
|
150
|
+
).stdout.strip()
|
|
151
|
+
merged = run(
|
|
152
|
+
["git", "merge-base", "--is-ancestor", branch, main_branch],
|
|
153
|
+
cwd=main,
|
|
154
|
+
check=False,
|
|
155
|
+
)
|
|
156
|
+
if merged.returncode != 0:
|
|
157
|
+
reasons.append(f"unmerged branch: {branch}")
|
|
158
|
+
assumptions_path = worktree / "ANNAHMEN.md"
|
|
159
|
+
assumptions = assumptions_path.read_text(encoding="utf-8") if assumptions_path.is_file() else ""
|
|
160
|
+
return CleanupAssessment(worktree, branch, assumptions, tuple(reasons))
|
|
161
|
+
|
|
162
|
+
|
|
107
163
|
def edit_decision(
|
|
108
164
|
profile: WorktreeProfile,
|
|
109
165
|
facts: RepoFacts,
|
|
@@ -196,6 +252,28 @@ def branch_create_decision(
|
|
|
196
252
|
return Decision("allow")
|
|
197
253
|
|
|
198
254
|
|
|
255
|
+
def handoff_decision(
|
|
256
|
+
profile: WorktreeProfile,
|
|
257
|
+
facts: RepoFacts,
|
|
258
|
+
payload: dict[str, Any],
|
|
259
|
+
) -> Decision:
|
|
260
|
+
prompt = str(payload.get("prompt") or "")
|
|
261
|
+
pattern = re.compile(
|
|
262
|
+
rf"{re.escape(profile.setup_entry)}\s+(\d+)\s+(\S+)"
|
|
263
|
+
)
|
|
264
|
+
match = pattern.search(prompt)
|
|
265
|
+
if match is None or not facts.is_main_worktree:
|
|
266
|
+
return Decision("skip")
|
|
267
|
+
issue, slug = match.groups()
|
|
268
|
+
command = f"{profile.setup_entry} {issue} {slug}"
|
|
269
|
+
return Decision(
|
|
270
|
+
"emit",
|
|
271
|
+
f"Defined slice detected: create its isolated worktree first with `{command}`, "
|
|
272
|
+
"then perform repository reads from that worktree.",
|
|
273
|
+
"UserPromptSubmit",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
199
277
|
def evaluate(
|
|
200
278
|
profile: WorktreeProfile,
|
|
201
279
|
facts: RepoFacts,
|
|
@@ -216,4 +294,6 @@ def evaluate(
|
|
|
216
294
|
return command_decision(profile, facts, payload)
|
|
217
295
|
if event == "branch-create":
|
|
218
296
|
return branch_create_decision(profile, facts, payload)
|
|
297
|
+
if event == "handoff":
|
|
298
|
+
return handoff_decision(profile, facts, payload)
|
|
219
299
|
return Decision("skip")
|