@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
package/scripts/wrapup-land.py
CHANGED
|
@@ -20,6 +20,7 @@ no --no-verify; the caller diagnoses.
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
22
|
import argparse
|
|
23
|
+
import importlib.util
|
|
23
24
|
import json
|
|
24
25
|
import os
|
|
25
26
|
import re
|
|
@@ -90,6 +91,44 @@ def load_profile() -> dict:
|
|
|
90
91
|
return {}
|
|
91
92
|
|
|
92
93
|
|
|
94
|
+
def load_worktree_cleanup_core():
|
|
95
|
+
path = Path(__file__).resolve().parent / "worktree-lifecycle" / "core.py"
|
|
96
|
+
module_dir = str(path.parent)
|
|
97
|
+
if module_dir not in sys.path:
|
|
98
|
+
sys.path.insert(0, module_dir)
|
|
99
|
+
spec = importlib.util.spec_from_file_location("_wrapup_worktree_lifecycle", path)
|
|
100
|
+
if spec is None or spec.loader is None:
|
|
101
|
+
raise Stop("cleanup", f"cannot load shared cleanup core from {path}")
|
|
102
|
+
module = importlib.util.module_from_spec(spec)
|
|
103
|
+
sys.modules[spec.name] = module
|
|
104
|
+
spec.loader.exec_module(module)
|
|
105
|
+
return module
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def ensure_worktree_removable(wt: str, main_tree: str):
|
|
109
|
+
profile_path = Path(main_tree) / "docs/agents/workflow-capabilities.json"
|
|
110
|
+
if not profile_path.is_file():
|
|
111
|
+
return None
|
|
112
|
+
core = load_worktree_cleanup_core()
|
|
113
|
+
try:
|
|
114
|
+
profile = core.load_profile(profile_path)
|
|
115
|
+
assessment = core.cleanup_assessment(
|
|
116
|
+
profile,
|
|
117
|
+
Path(main_tree),
|
|
118
|
+
Path(wt),
|
|
119
|
+
merge_target="origin/main",
|
|
120
|
+
)
|
|
121
|
+
except core.LifecycleError as error:
|
|
122
|
+
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
123
|
+
if assessment.reasons:
|
|
124
|
+
raise Stop(
|
|
125
|
+
"cleanup",
|
|
126
|
+
"shared cleanup guard refused worktree removal",
|
|
127
|
+
"; ".join(assessment.reasons),
|
|
128
|
+
)
|
|
129
|
+
return assessment
|
|
130
|
+
|
|
131
|
+
|
|
93
132
|
# ---------- drift log (ANNAHMEN.md) ----------
|
|
94
133
|
|
|
95
134
|
def parse_annahmen(text: str, default_section: str) -> tuple[list[dict], list[str]]:
|
|
@@ -428,6 +467,12 @@ def cmd_land(args) -> dict:
|
|
|
428
467
|
|
|
429
468
|
# Step 2 — kill the worktree's dev server, then Step 4 — teardown
|
|
430
469
|
if wt_exists:
|
|
470
|
+
git(["fetch", "origin", "main"], cwd=main_tree, check=True)
|
|
471
|
+
cleanup = ensure_worktree_removable(wt, main_tree)
|
|
472
|
+
report["cleanup_guard"] = {
|
|
473
|
+
"active": cleanup is not None,
|
|
474
|
+
"assumptions_read": bool(cleanup and cleanup.assumptions),
|
|
475
|
+
}
|
|
431
476
|
report["killed_processes"] = kill_worktree_processes(wt)
|
|
432
477
|
p = git(["worktree", "remove", wt], cwd=main_tree)
|
|
433
478
|
if p.returncode != 0:
|
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 },
|
|
@@ -71,6 +74,7 @@ export const HELPER_FILES = [
|
|
|
71
74
|
{ path: 'scripts/worktree-lifecycle/core.py', kind: 'script', mode: 0o644 },
|
|
72
75
|
{ path: 'scripts/worktree-lifecycle/profile.py', kind: 'script', mode: 0o644 },
|
|
73
76
|
{ path: 'scripts/worktree-lifecycle/setup.py', kind: 'script', mode: 0o755 },
|
|
77
|
+
{ path: 'scripts/worktree-lifecycle/cleanup.py', kind: 'script', mode: 0o755 },
|
|
74
78
|
{ path: 'scripts/worktree-lifecycle/capabilities.json', kind: 'doc', mode: 0o644 },
|
|
75
79
|
{ path: 'scripts/worktree-lifecycle/README.md', kind: 'doc', mode: 0o644 },
|
|
76
80
|
// Shared hook utility imported by the shipped hooks (drift-guard,
|
|
@@ -84,6 +88,7 @@ export const HELPER_FILES = [
|
|
|
84
88
|
{ path: '.claude/hooks/enforce-worktree.py', kind: 'hook', mode: 0o755 },
|
|
85
89
|
{ path: '.claude/hooks/enforce-worktree-cwd.py', kind: 'hook', mode: 0o755 },
|
|
86
90
|
{ path: '.claude/hooks/enforce-worktree-discipline.py', kind: 'hook', mode: 0o755 },
|
|
91
|
+
{ path: '.claude/hooks/slice-handoff-hint.py', kind: 'hook', mode: 0o755 },
|
|
87
92
|
{ path: '.claude/hooks/drift-guard.py', kind: 'hook', mode: 0o755 },
|
|
88
93
|
// SessionStart skill-freshness drift-hint (audit-skills names it). For each
|
|
89
94
|
// <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
|
+
}
|