@ikon85/agent-workflow-kit 0.37.0 → 0.39.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.
Files changed (44) hide show
  1. package/.agents/skills/kit-update/SKILL.md +33 -1
  2. package/.agents/skills/orchestrate-wave/SKILL.md +5 -5
  3. package/.agents/skills/setup-workflow/SKILL.md +42 -4
  4. package/.agents/skills/setup-workflow/board-sync.md +6 -2
  5. package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
  6. package/.agents/skills/setup-workflow/worktree-lifecycle.md +47 -1
  7. package/.agents/skills/wrapup/SKILL.md +46 -0
  8. package/.claude/hooks/drift-guard.py +212 -21
  9. package/.claude/skills/kit-update/SKILL.md +33 -1
  10. package/.claude/skills/orchestrate-wave/SKILL.md +5 -5
  11. package/.claude/skills/setup-workflow/SKILL.md +42 -4
  12. package/.claude/skills/setup-workflow/board-sync.md +6 -2
  13. package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
  14. package/.claude/skills/setup-workflow/worktree-lifecycle.md +47 -1
  15. package/.claude/skills/wrapup/SKILL.md +46 -0
  16. package/README.md +73 -0
  17. package/agent-workflow-kit.package.json +49 -25
  18. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
  19. package/docs/agents/workflow-capabilities.json +11 -1
  20. package/package.json +1 -1
  21. package/scripts/board_bootstrap.py +367 -0
  22. package/scripts/kit-update-pr.mjs +20 -7
  23. package/scripts/kit-update-pr.test.mjs +29 -0
  24. package/scripts/profile_globs.py +347 -0
  25. package/scripts/test_board_bootstrap.py +348 -0
  26. package/scripts/test_drift_guard_diagnostics.py +295 -0
  27. package/scripts/test_orchestrate_wave_contract.py +9 -0
  28. package/scripts/test_profile_globs.py +280 -0
  29. package/scripts/test_skill_setup_workflow_seeds.py +87 -0
  30. package/scripts/test_worktree_wrapup_contract.py +1592 -3
  31. package/scripts/workflow-advisories/core.py +29 -4
  32. package/scripts/worktree-lifecycle/README.md +139 -0
  33. package/scripts/worktree-lifecycle/capabilities.json +9 -1
  34. package/scripts/worktree-lifecycle/cleanup.py +22 -51
  35. package/scripts/worktree-lifecycle/core.py +1206 -5
  36. package/scripts/worktree-lifecycle/profile.py +38 -3
  37. package/scripts/worktree-lifecycle/session.py +1857 -0
  38. package/scripts/worktree-lifecycle/setup.py +15 -0
  39. package/scripts/wrapup-land.py +461 -14
  40. package/src/cli.mjs +32 -1
  41. package/src/commands/update.mjs +30 -21
  42. package/src/consumer-migrations.json +19 -0
  43. package/src/lib/bundle.mjs +11 -0
  44. package/src/lib/consumerMigrations.mjs +161 -0
@@ -1,13 +1,37 @@
1
1
  """Profile-driven decision core for non-blocking Workflow Advisories."""
2
2
  from __future__ import annotations
3
3
 
4
- import fnmatch
4
+ import importlib.util
5
5
  import json
6
6
  import re
7
7
  import subprocess
8
+ import sys
8
9
  from dataclasses import dataclass
9
10
  from pathlib import Path
10
11
 
12
+ PROFILE_GLOBS_MODULE = "_agent_workflow_kit_profile_globs"
13
+
14
+
15
+ def load_profile_globs():
16
+ """Load the one shared repository-relative glob dialect exactly once."""
17
+ module = sys.modules.get(PROFILE_GLOBS_MODULE)
18
+ if module is not None:
19
+ return module
20
+ path = Path(__file__).resolve().parents[1] / "profile_globs.py"
21
+ spec = importlib.util.spec_from_file_location(PROFILE_GLOBS_MODULE, path)
22
+ if spec is None or spec.loader is None:
23
+ raise ImportError(f"cannot load the shared profile glob dialect from {path}")
24
+ module = importlib.util.module_from_spec(spec)
25
+ sys.modules[PROFILE_GLOBS_MODULE] = module
26
+ spec.loader.exec_module(module)
27
+ return module
28
+
29
+
30
+ # Consumer profile globs are matched here exactly as Worktree Lifecycle matches
31
+ # its own: one dialect, so an advisory and a deletion decision can never
32
+ # disagree about which repository-relative paths a pattern selects.
33
+ path_glob_matches = load_profile_globs().path_glob_matches
34
+
11
35
 
12
36
  @dataclass(frozen=True)
13
37
  class Decision:
@@ -87,7 +111,8 @@ def baseline_decision(profile: dict, payload: dict, root: Path, branch: str) ->
87
111
  raw_path = payload.get("tool_input", {}).get("file_path")
88
112
  relative = _repo_relative(root, raw_path) if raw_path else None
89
113
  if not relative or not any(
90
- fnmatch.fnmatch(relative, pattern) for pattern in config.get("sourceGlobs", [])
114
+ path_glob_matches(relative, pattern)
115
+ for pattern in config.get("sourceGlobs", [])
91
116
  ):
92
117
  return Decision(None, "PreToolUse")
93
118
  manifest = root / config.get("manifestPath", ".agent/baseline.json")
@@ -119,7 +144,7 @@ def pre_refactor_decision(profile: dict, payload: dict, root: Path) -> Decision:
119
144
  seen: set[tuple[str, ...]] = set()
120
145
  for surface in config.get("surfaces", []):
121
146
  if not any(
122
- fnmatch.fnmatch(path, pattern)
147
+ path_glob_matches(path, pattern)
123
148
  for path in changed
124
149
  for pattern in surface.get("globs", [])
125
150
  ):
@@ -170,7 +195,7 @@ def stop_check_decision(profile: dict, payload: dict, root: Path) -> Decision:
170
195
  seen: set[tuple[str, ...]] = set()
171
196
  for surface in config.get("surfaces", []):
172
197
  if not any(
173
- fnmatch.fnmatch(path, pattern)
198
+ path_glob_matches(path, pattern)
174
199
  for path in changed
175
200
  for pattern in surface.get("globs", [])
176
201
  ):
@@ -19,6 +19,29 @@ they do not carry a second branch regex, worktree traversal, or failure policy.
19
19
  - `riskyCommandPatterns`: commands that must target the active linked worktree.
20
20
  - `scratchPatterns`: consumer-owned glob patterns for untracked disposable
21
21
  planning artefacts. No filename is assumed by Core.
22
+ - `wrapup.landingGeneratedArtifactPatterns`: an explicitly reviewed
23
+ consumer-owned allowlist for outputs created by the landing commands. It is
24
+ deletion authority, so setup never derives it from `.gitignore` alone and
25
+ update never installs a universal default.
26
+
27
+ Profile globs use one repository-relative POSIX dialect, implemented once in
28
+ `scripts/profile_globs.py` and loaded by this core and by
29
+ `scripts/workflow-advisories/core.py`. `*` and `?` stay inside one path
30
+ segment; `[seq]`/`[!seq]` are per-segment character classes; `**` as a whole
31
+ segment matches zero or more segments, so a leading `**/` also matches the
32
+ repository root and `dir/**` also matches `dir` itself; matching is always
33
+ case-sensitive on every host filesystem; and a pattern must match the whole
34
+ path. For example, `**/__pycache__/**` matches both root and nested caches,
35
+ while `dist-kit/*` does not match `dist-kit/a/b`.
36
+
37
+ Because both capabilities load the same matcher, a pattern can never select one
38
+ set of paths for an advisory and a different set for a deletion decision. Run
39
+ `python3 scripts/profile_globs.py <profile.json>` to review an installed
40
+ profile: it names every pattern whose match set narrows or widens against that
41
+ key's legacy matcher, prints the witness path proving it, marks the keys that
42
+ carry deletion authority, and exits 1 when anything needs review. It reads the
43
+ profile and never rewrites a pattern, so a migration cannot silently expand
44
+ cleanup authority.
22
45
 
23
46
  Unknown or malformed events fail open without changing repository state.
24
47
  Security-sensitive, profile-matched edits and commands fail closed only when
@@ -49,11 +72,127 @@ same removable inventory, deletes only the exact contained regular scratch
49
72
  files from that inventory, and uses ordinary `git worktree remove`. It never
50
73
  bypasses Git's final concurrent-change check with force removal.
51
74
 
75
+ The generic setup route atomically records its ignored and complete
76
+ untracked-file inventories in the linked worktree's Git metadata after its
77
+ configured setup steps. The record is bound to the worktree path, branch, root
78
+ device/inode, and setup HEAD, and carries a canonical digest. Existing
79
+ worktrees can receive a conservative baseline only when they are the exact
80
+ registered no-follow directory on an attached branch with a clean tracked
81
+ worktree and index. Setup reuse defers that backfill, without blocking the
82
+ existing worktree, while tracked work is dirty or landing-generated blockers
83
+ are present. Landing classifies those blockers before writing any baseline;
84
+ after they are moved, every remaining current ignored and untracked path is
85
+ recorded as pre-existing and therefore protected. A corrupt baseline or an
86
+ active landing attempt is never overwritten. The claim-bound session route
87
+ below captures its stricter baseline before project setup so a failed setup has
88
+ an exact recovery boundary.
89
+
90
+ Before merge, the committed worktree profile may only nominate exact,
91
+ identity-bound landing candidates; it authorizes no deletion. After merge and
92
+ `fetch origin/main`, cleanup reloads the profile directly from canonical
93
+ `origin/main`, requires its scratch and generator policies to equal the
94
+ worktree candidate and the policy digest frozen at attempt start, and only then
95
+ authorizes mutation. Every supplied generator-evidence path is independently
96
+ checked against that canonical generator policy. A missing policy is distinct
97
+ from an explicit empty policy. An unmerged or transient branch policy therefore
98
+ cannot grant itself broader cleanup authority.
99
+
100
+ Canonical policy that drifts between attempt start and post-merge cleanup keeps
101
+ that refusal, and the refusal names its supported recovery. Recovery re-derives
102
+ authority from the merged canonical policy alone: it never consults the stale
103
+ worktree candidate, never re-scans for new candidates, requires the branch to
104
+ already be an ancestor of canonical main, and requires each frozen identity to
105
+ be both named by canonical policy and unchanged on disk. Evidence outside
106
+ canonical policy, a changed identity, and pre-existing or foreign state stop the
107
+ recovery instead of being deleted, and a repeated run after a completed teardown
108
+ is a no-op.
109
+
110
+ The landing adapter may carry exact scratch evidence only for current ignored
111
+ files that match the consumer-owned
112
+ `wrapup.landingGeneratedArtifactPatterns` profile and were absent from that
113
+ creation baseline. Missing, changed, or incoherent provenance stops landing
114
+ cleanup. Initial/profile-matched files, unmatched files, symlinks, and writes
115
+ after the landing evidence snapshot remain cleanup stops; deletion still uses
116
+ the same descriptor-bound regular-file primitive and a second inventory check.
117
+ Mutable session logs belong in explicit `scratchPatterns`, not in the landing
118
+ generator allowlist: their identity is frozen at final cleanup assessment, so
119
+ normal logging before teardown remains live while a later append or replacement
120
+ still stops deletion.
121
+ Landing-start blockers are classified before a journal is written, so moving a
122
+ protected blocker permits a clean next attempt. An explicit relinquish archives
123
+ either a started or frozen attempt, including drifted evidence, without
124
+ deleting or claiming any file; the next preflight treats every current file as
125
+ pre-existing. Exact unchanged frozen evidence remains directly resumable. The
126
+ attempt journal name is classified without following a symlink, so a symlinked
127
+ or dangling journal entry stops instead of being read or replaced, and each
128
+ archived receipt is filed under a contract-version-neutral stem plus its own
129
+ recorded contract version. A journal written under a superseded contract is
130
+ classified as legacy rather than corrupt when it still satisfies its own
131
+ recorded contract; that refusal names the archive route explicitly, while a
132
+ journal that fails its own contract is still reported as incoherent evidence.
133
+
52
134
  `cleanup.py sweep` is the read-only inventory entrypoint. It accounts once for
53
135
  every linked worktree and local branch, reports issue/PR/merge/age/removal
54
136
  facts, and counts merged remote branches separately. It never removes a
55
137
  worktree or branch.
56
138
 
139
+ ## Session-owned teardown
140
+
141
+ `session.py` is the narrower orchestration route. It binds one receipt in the
142
+ Git common directory to the active `wave-active/<anchor>` annotated claim:
143
+
144
+ ```sh
145
+ python3 scripts/worktree-lifecycle/session.py begin --anchor <n> --owner <run> --base <wave-head>
146
+ python3 scripts/worktree-lifecycle/session.py create --anchor <n> --owner <run> --base <current-wave-head> <issue> <slug> <type>
147
+ python3 scripts/worktree-lifecycle/session.py recover --anchor <n> --owner <run> --branch <exact-branch>
148
+ python3 scripts/worktree-lifecycle/session.py seal --anchor <n> --owner <run>
149
+ python3 scripts/worktree-lifecycle/session.py inspect --anchor <n> --owner <run> --main origin/main
150
+ python3 scripts/worktree-lifecycle/session.py teardown --anchor <n> --owner <run> --main origin/main
151
+ ```
152
+
153
+ Only `create` can add an inventory row, and it refuses any branch or path that
154
+ already existed. It journals a provisional exact row before `git worktree add`,
155
+ then captures the shared artifact baseline before project setup and promotes
156
+ the row only after setup succeeds. Session inspection
157
+ therefore accepts generated scratch only when the exact path matches the
158
+ profile and is absent from the creation baseline; missing, changed, or
159
+ incoherent provenance stops. `seal` records the final exact branch OIDs.
160
+ Inspection reports ancestry, one-to-one stable patch equivalence, unique
161
+ content, and ambiguity separately. Empty commits, non-ancestry merge commits,
162
+ duplicate patch IDs, open or unreadable PR evidence, dirt, protected names,
163
+ stale registrations, recreated removed targets, and identity drift stop the
164
+ whole teardown before mutation.
165
+
166
+ If setup fails, the provisional row becomes `recovery-pending`. Automatic and
167
+ explicit `recover` use the same bounded route: active claim and receipt,
168
+ protected name, PR state, exact ref OID, registration/root identity, and the
169
+ creation baseline are revalidated. A branch-only remainder is compare-deleted
170
+ only at its provisional OID. A registered worktree whose add succeeded before
171
+ root journaling is adopted only when its Git registration/backlink, branch,
172
+ OID, and exact-clean inventory all match.
173
+
174
+ For failed project setup, exact index/worktree diff hashes and paths are frozen
175
+ without storing diff content. Exact regular-file or no-follow symlink identity
176
+ is frozen for every setup-created untracked path. Recovery revalidates that
177
+ evidence, restores only the frozen tracked paths to the creation OID, and
178
+ unlinks only matching untracked identities; later foreign files,
179
+ modifications, or replacements stop without losing receipt ownership. If
180
+ identity capture cannot finish, the receipt retains only a bounded failure
181
+ class and can retry from the unchanged inventory or an exact-clean worktree.
182
+ Raw command, exception, stdout, and stderr text is never retained or archived.
183
+ Recovery uses ordinary worktree removal and compare-deletes the exact ref.
184
+
185
+ Teardown re-runs that complete assessment, archives every recovery OID in the
186
+ receipt before its first mutation, and revalidates canonical main, the active
187
+ claim and receipt, profile protection, PR state, branch OID, worktree
188
+ registration/root identity, dirt, and the exact scratch inventory immediately
189
+ before each target mutation. After ordinary worktree removal it rechecks the
190
+ ref-side gates, then compare-deletes only the recorded local refs with
191
+ `git update-ref -d <ref> <expected-oid>`. A concurrent ref move therefore
192
+ survives, while a partial run keeps its recovery OIDs and can resume. Generic
193
+ cleanup and sweep never consume this ownership route and never infer authority
194
+ over a foreign branch.
195
+
57
196
  Claude hook wiring and any Codex adaptation consume this same profile and core.
58
197
  An adapter may change only the surface event envelope; it must preserve the
59
198
  core verdict and message.
@@ -39,7 +39,15 @@
39
39
  {
40
40
  "historicalPath": "scripts/cleanup-worktrees.sh",
41
41
  "artifact": "scripts/worktree-lifecycle/cleanup.py",
42
- "primitives": ["profile", "git-facts", "cleanup-safety"]
42
+ "supportingArtifacts": ["scripts/worktree-lifecycle/session.py"],
43
+ "primitives": [
44
+ "profile",
45
+ "git-facts",
46
+ "cleanup-safety",
47
+ "claim-bound-session-receipt",
48
+ "patch-equivalence",
49
+ "compare-and-delete"
50
+ ]
43
51
  }
44
52
  ]
45
53
  }
@@ -4,22 +4,22 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
- from contextlib import contextmanager
8
7
  from dataclasses import asdict, replace
9
8
  import json
10
- import os
11
- import stat
12
9
  import sys
13
- from pathlib import Path, PurePosixPath
10
+ from pathlib import Path
14
11
 
15
12
  from core import (
16
13
  LifecycleError,
14
+ bind_cleanup_scratch_evidence,
17
15
  classify_cleanup,
18
16
  collect_cleanup_facts,
19
17
  collect_sweep,
20
18
  load_profile,
21
19
  main_worktree,
20
+ remove_authorized_scratch,
22
21
  run,
22
+ verified_worktree_root,
23
23
  )
24
24
 
25
25
 
@@ -75,52 +75,18 @@ def collect_assessment(profile, main: Path, worktree: Path, gh_command: str):
75
75
  worktree,
76
76
  )
77
77
  state = pr_state(gh_command, main, facts.branch)
78
- return classify_cleanup(profile, replace(facts, pr_state=state))
79
-
80
-
81
- @contextmanager
82
- def verified_worktree_root(root: Path, expected_device: int, expected_inode: int):
83
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
84
- no_follow = getattr(os, "O_NOFOLLOW", 0)
85
- descriptor = None
78
+ assessment = classify_cleanup(profile, replace(facts, pr_state=state))
86
79
  try:
87
- descriptor = os.open(root, directory_flags | no_follow)
88
- metadata = os.fstat(descriptor)
89
- if (metadata.st_dev, metadata.st_ino) != (expected_device, expected_inode):
90
- raise LifecycleError("worktree root changed before removal")
91
- yield descriptor
92
- except OSError as error:
93
- raise LifecycleError("worktree root changed before removal") from error
94
- finally:
95
- if descriptor is not None:
96
- os.close(descriptor)
97
-
98
-
99
- def remove_contained_regular(root_descriptor: int, relative: str) -> None:
100
- path = PurePosixPath(relative)
101
- if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
102
- raise LifecycleError(f"unsafe scratch path: {relative}")
103
- directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
104
- no_follow = getattr(os, "O_NOFOLLOW", 0)
105
- descriptors = []
106
- try:
107
- current = root_descriptor
108
- for component in path.parts[:-1]:
109
- current = os.open(
110
- component,
111
- directory_flags | no_follow,
112
- dir_fd=current,
113
- )
114
- descriptors.append(current)
115
- metadata = os.stat(path.name, dir_fd=current, follow_symlinks=False)
116
- if not stat.S_ISREG(metadata.st_mode):
117
- raise LifecycleError(f"scratch path is not a regular file: {relative}")
118
- os.unlink(path.name, dir_fd=current)
119
- except OSError as error:
120
- raise LifecycleError(f"scratch path changed before removal: {relative}") from error
121
- finally:
122
- for descriptor in reversed(descriptors):
123
- os.close(descriptor)
80
+ return bind_cleanup_scratch_evidence(
81
+ profile,
82
+ assessment,
83
+ require_generator_evidence=True,
84
+ )
85
+ except LifecycleError as error:
86
+ return replace(
87
+ assessment,
88
+ reasons=assessment.reasons + (f"scratch evidence stop: {error}",),
89
+ )
124
90
 
125
91
 
126
92
  def execute(args: argparse.Namespace) -> dict:
@@ -155,6 +121,7 @@ def execute(args: argparse.Namespace) -> dict:
155
121
  or latest.assumptions != assessment.assumptions
156
122
  or latest.root_device != assessment.root_device
157
123
  or latest.root_inode != assessment.root_inode
124
+ or latest.scratch_evidence != assessment.scratch_evidence
158
125
  ):
159
126
  raise LifecycleError("cleanup changed before removal: inventory no longer matches preview")
160
127
  with verified_worktree_root(
@@ -162,8 +129,12 @@ def execute(args: argparse.Namespace) -> dict:
162
129
  latest.root_device,
163
130
  latest.root_inode,
164
131
  ) as root_descriptor:
165
- for scratch in latest.scratch_files:
166
- remove_contained_regular(root_descriptor, scratch)
132
+ remove_authorized_scratch(
133
+ profile,
134
+ root_descriptor,
135
+ latest.scratch_files,
136
+ assessment.scratch_evidence,
137
+ )
167
138
  run(["git", "worktree", "remove", str(latest.worktree)], cwd=main)
168
139
  run(["git", "branch", "-d", assessment.branch], cwd=main)
169
140
  report["removed"] = True