@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.
@@ -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:
@@ -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
+ }