@ikon85/agent-workflow-kit 0.40.0 → 0.41.1

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/README.md CHANGED
@@ -469,6 +469,24 @@ the old way. Decision record:
469
469
 
470
470
  ## Release notes
471
471
 
472
+ ### 0.41.1
473
+
474
+ - changed: `README.md`
475
+ - changed: `agent-workflow-kit.package.json`
476
+ - changed: `package.json`
477
+ - changed: `scripts/pr-body-check.py`
478
+ - changed: `scripts/test_pr_body_check.py`
479
+ - changed: `scripts/test_worktree_wrapup_contract.py`
480
+ - changed: `scripts/wrapup-land.py`
481
+
482
+ ### 0.41.0
483
+
484
+ - added: `docs/adr/0009-teardown-authority-is-stateless-repository-classification.md`
485
+ - changed: `agent-workflow-kit.package.json`
486
+ - changed: `docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md`
487
+ - changed: `scripts/pr-body-check.py`
488
+ - changed: `scripts/test_pr_body_check.py`
489
+
472
490
  ### 0.40.0 — planning ignore rules are offered, never installed
473
491
 
474
492
  Nothing in this release requires a consumer action. `kit-update` reconciles it.
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.40.0",
2
+ "kitVersion": "0.41.1",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -2627,7 +2627,7 @@
2627
2627
  "path": "scripts/pr-body-check.py",
2628
2628
  "kind": "script",
2629
2629
  "installRole": "consumer",
2630
- "sha256": "864dad50be7904cf0c1d70956352c70d2195a350a5a6875de6b866c494f16612",
2630
+ "sha256": "d18a9f1bf9285506bd7d40a1800dd9b66f631bd4666f0ae801e8db1882d3ad2d",
2631
2631
  "mode": 493,
2632
2632
  "origin": "kit"
2633
2633
  },
@@ -2867,7 +2867,7 @@
2867
2867
  "path": "scripts/wrapup-land.py",
2868
2868
  "kind": "script",
2869
2869
  "installRole": "consumer",
2870
- "sha256": "476012558bb786070ce47458c6f0734ff110d5d957af72e13b20d49a37a03592",
2870
+ "sha256": "de41f6c7bee1f67f3f25db4c7750a3caca422be5749012c6d5402cfeed91b2f7",
2871
2871
  "mode": 493,
2872
2872
  "origin": "kit"
2873
2873
  },
@@ -1,6 +1,6 @@
1
1
  # Session teardown requires provenance-bound ownership
2
2
 
3
- Status: accepted (2026-07-26, Wave #271)
3
+ Status: superseded by [ADR-0009](./0009-teardown-authority-is-stateless-repository-classification.md) (2026-07-27, #320)
4
4
 
5
5
  Long agent sessions create branches, linked worktrees, setup outputs, and
6
6
  landing-time build artifacts. The desired end state is that every
@@ -0,0 +1,101 @@
1
+ # Teardown authority is stateless repository classification
2
+
3
+ Status: accepted (2026-07-27, #320) — supersedes
4
+ [ADR-0007](./0007-session-teardown-requires-provenance-bound-ownership.md)
5
+
6
+ ADR-0007 derived teardown authority from persisted lifecycle evidence:
7
+ ownership-proof refs acquired in atomic git transactions, frozen
8
+ landing-attempt journals, baseline digests, no-follow identity binding, and a
9
+ post-merge reload of the canonical cleanup policy. Every piece was locally
10
+ justified, and the aggregate failed in practice: fifteen teardown/cleanup
11
+ issues in two weeks (#245–#319), ~5,600 lines of lifecycle code, two recovery
12
+ flags for the machinery's own bookkeeping, a legacy receipt contract, and
13
+ refusals that did not name their cause (#319: 68,856 paths, 7.4 MB, zero
14
+ actionable lines). The protection was aimed at narrow races between assessment
15
+ and deletion; the cost was that ordinary sessions could no longer finish.
16
+
17
+ We decided that teardown authority comes from the repository's **current
18
+ state**, read at the moment of action, and from nothing else:
19
+
20
+ 1. **Git's own classification is the file taxonomy.** A tracked modification
21
+ blocks teardown. An untracked, non-ignored file blocks with a bounded
22
+ report (count plus top directories — never an unbounded path dump). An
23
+ ignored file is Scratch: the repository declared it not-work, and it is
24
+ deletable. "Ignored" means git's standard exclude sources
25
+ (`--exclude-standard`: repository `.gitignore` files, `.git/info/exclude`,
26
+ the global excludes file) — whatever the repository's own tooling treats as
27
+ ignored is deletion authority here too.
28
+ 2. **One hardcoded exception: `.env*` (basename glob).** Ignored yet
29
+ potentially irreplaceable. Before removal each `.env*` regular file is
30
+ compared byte-wise with its counterpart at the same relative path in the
31
+ main checkout, both opened no-follow: identical → derived copy, deletable;
32
+ divergent, absent in the main checkout, or either side not a regular file →
33
+ block with the exact files named. The check is a present-state comparison,
34
+ not a receipt.
35
+ 3. **The platform PR record authorizes branch deletion.** Force-deletion
36
+ requires exactly one PR matching the full tuple — this repository as base
37
+ repo, head repository equal to the base repo (no fork heads), head ref
38
+ equal to the branch name, base ref equal to the configured protected
39
+ branch, state merged — whose head SHA equals the branch tip OID; the OID
40
+ is re-read immediately before deletion, and zero matches, multiple
41
+ matches, an open PR on the same head, or any drift retains the branch.
42
+ Ancestry-merged branches (against a freshly fetched configured protected
43
+ branch — stale ancestry stops rather than guesses) delete normally. A
44
+ branch with neither stays and is reported. Without platform access this
45
+ degrades to ancestry-only, reported honestly.
46
+ 4. **Idempotency by re-check, not journal.** An interrupted landing is resumed
47
+ by re-running it; every step verifies present state (PR exists? merged?
48
+ worktree present?) and skips or proceeds. There is no persisted attempt
49
+ state to validate, archive, or recover.
50
+ 5. **A direct `/wrapup` invocation is the teardown authorization.** It lands
51
+ and tears down the worktree it runs in — including worktrees created by
52
+ external tools under foreign names and paths. The kit never conditions
53
+ teardown on its own naming or location conventions; the four classification
54
+ rules above are the only protection.
55
+ 6. **Deletion policy has exactly one configuration surface: the ignore
56
+ mechanism.** The consumer profile keeps structural facts only (worktree
57
+ root, templates, protected branches, sweep opt-in). `scratchPatterns` and
58
+ `landingGeneratedArtifactPatterns` are removed without migration —
59
+ including the shipped consumer migration that seeded them; making a file
60
+ deletable means ignoring it (ADR-0008's offered seeding remains the
61
+ supported way; its consequence about deriving `scratchPatterns` from the
62
+ seeded rules is obsolete with this decision).
63
+
64
+ ## Considered options
65
+
66
+ - **Keep ADR-0007's provenance machinery:** rejected on empirical grounds. The
67
+ races it closes (a foreign file occupying an assessed path between
68
+ assessment and deletion, a same-name branch created concurrently) are rare
69
+ and low-harm next to the observed failure mode: routine teardown becoming
70
+ impossible and each repair adding machinery. The branch race is additionally
71
+ closed cheaper by the tip-SHA/PR comparison.
72
+ - **Shipping default scratch patterns:** rejected. A default allowlist of the
73
+ deletable restarts the pattern catalog and its seeding, sync, and dialect
74
+ problems; `.gitignore` already encodes the same fact with universal
75
+ tooling.
76
+ - **Migrating existing profiles and receipts:** rejected. The green-slate cut
77
+ is deliberate (#320): old keys are ignored, journaled attempts are inert
78
+ files, and no code path reads either. Maintaining both models would preserve
79
+ the complexity this decision removes.
80
+ - **Location heuristic for externally created worktrees (land, don't tear
81
+ down):** rejected by the user. `/wrapup` means tear down; splitting the
82
+ outcome by worktree origin reintroduces an ownership notion.
83
+
84
+ ## Consequences
85
+
86
+ - A worktree whose only untracked content is ignored dependency or build
87
+ output is removable with zero configuration. #319's failure class cannot
88
+ occur.
89
+ - Refusals are bounded and name their cause; the fix is always visible
90
+ (commit the tracked change, handle the named `.env*`, ignore or remove the
91
+ named untracked files).
92
+ - The accepted residual risk is explicit: between assessment and deletion a
93
+ file could in principle be replaced; a valuable file a consumer keeps
94
+ gitignored outside `.env*` is deletable at teardown. We trade these narrow
95
+ windows for a lifecycle that ordinary sessions can actually complete.
96
+ - Planning sessions stop being a special case: durable content lands through
97
+ wrapup's Content route as ordinary work, scratch dies with the worktree,
98
+ and worktree creation binds to implementation, not planning.
99
+ - The lifecycle implementation shrinks from a transaction protocol to a
100
+ classification function plus plumbing; its tests shift from race
101
+ choreography to classification truth tables.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.40.0",
3
+ "version": "0.41.1",
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": {
@@ -125,6 +125,21 @@ def _close_on(body: str, n: int) -> bool:
125
125
  return bool(pat.search(strip_code(body)))
126
126
 
127
127
 
128
+ def active_close_targets(body: str) -> list:
129
+ """All issue numbers an ACTIVE close-keyword targets (outside code spans),
130
+ as sorted, deduplicated strings. Same grammar `_close_on` enforces —
131
+ plain `closes #n`, the colon form, and the full-URL form. This is the one
132
+ close grammar; close-verification (wrapup-land Step 5b) must derive its
133
+ targets from here, never from a branch number."""
134
+ text = strip_code(body)
135
+ pat = re.compile(
136
+ rf"\b(?:{CLOSE_KEYWORDS})(?::\s*|\s+)"
137
+ rf"(?:#0*(\d+)(?!\d)|https?://\S+/issues/0*(\d+)(?!\d))",
138
+ re.IGNORECASE)
139
+ found = {a or b for a, b in pat.findall(text)}
140
+ return sorted(found, key=int)
141
+
142
+
128
143
  def _part_of(body: str, n: int) -> bool:
129
144
  return bool(re.search(
130
145
  rf"\b{PART_OF_RE_SRC}(?::\s*|\s+)#0*{n}(?!\d)", body or "", re.IGNORECASE))
@@ -208,13 +223,26 @@ def fetch_pr_body(branch: str):
208
223
  return out if rc == 0 else None
209
224
 
210
225
 
226
+ def _anchor_labels() -> set:
227
+ """Labels that mark an issue as a never-auto-close anchor: the wave
228
+ cluster label plus the board profile's Program-PRD label (a Program-PRD
229
+ is closed last, manually — `closes` must never target it)."""
230
+ labels = {"type:cluster"}
231
+ try:
232
+ import board_config
233
+ labels.add(board_config.program_type_label(board_config.load_board_config()))
234
+ except Exception:
235
+ labels.add("type:program") # profile unavailable → literal default
236
+ return labels
237
+
238
+
211
239
  def fetch_is_anchor(number: int) -> bool:
212
- """True wenn das Issue selbst ein Wellen-Anker ist (Label type:cluster)."""
240
+ """True wenn das Issue selbst ein Anker ist (type:cluster oder Program-PRD)."""
213
241
  rc, out = _run(["gh", "issue", "view", str(number),
214
242
  "--json", "labels", "-q", ".labels[].name"])
215
243
  if rc != 0:
216
244
  return False # fail-open in den Leaf-Pfad (bisheriges Verhalten)
217
- return "type:cluster" in out.splitlines()
245
+ return bool(_anchor_labels() & set(out.splitlines()))
218
246
 
219
247
 
220
248
  def resolve_has_e2e_na(args, branch: str) -> bool:
@@ -79,6 +79,25 @@ class ExistingBodyRules(unittest.TestCase):
79
79
  def test_retro_line_still_required(self):
80
80
  self.assertTrue(any("Retro" in item for item in pbc.check_pr_body("closes #149", 149, None)))
81
81
 
82
+ def test_active_close_targets_extracts_all_forms(self):
83
+ body = ("closes #12\nFixes: #13\n"
84
+ "resolves https://github.com/iKon85/agent-workflow-kit/issues/14\n"
85
+ "`closes #99` and Part of #320 and closes #12")
86
+ self.assertEqual(pbc.active_close_targets(body), ["12", "13", "14"])
87
+
88
+ def test_active_close_targets_empty_for_part_of_only(self):
89
+ self.assertEqual(pbc.active_close_targets("Part of #320\n**Retro:** skipped — x"), [])
90
+
91
+ def test_program_prd_label_counts_as_anchor(self):
92
+ with mock.patch.object(pbc, "_run",
93
+ return_value=(0, "type:program\npriority:medium")):
94
+ self.assertTrue(pbc.fetch_is_anchor(320))
95
+
96
+ def test_cluster_label_still_counts_as_anchor(self):
97
+ with mock.patch.object(pbc, "_run",
98
+ return_value=(0, "type:cluster")):
99
+ self.assertTrue(pbc.fetch_is_anchor(41))
100
+
82
101
  def test_wave_pr_still_requires_part_of_without_closing_anchor(self):
83
102
  body = f"Part of #130\ncloses #149\n{RETRO}"
84
103
  self.assertEqual(pbc.check_pr_body(body, 130, None, is_anchor=True), [])
@@ -251,6 +251,13 @@ def downgrade_landing_attempt_to_v1(core, worktree: Path) -> Path:
251
251
 
252
252
 
253
253
  class WorktreeCleanupContract(unittest.TestCase):
254
+ def test_declared_close_targets_is_pr_body_authority(self):
255
+ mod = load_wrapup()
256
+ self.assertEqual(mod.declared_close_targets("Part of #320"), [])
257
+ self.assertEqual(mod.declared_close_targets("closes #341\ncloses #12"),
258
+ ["12", "341"])
259
+ self.assertEqual(mod.declared_close_targets("`closes #341`"), [])
260
+
254
261
  def test_profile_scratch_and_generated_evidence_share_safe_removal_contract(self):
255
262
  wrapup = load_wrapup()
256
263
  with tempfile.TemporaryDirectory() as tmp:
@@ -423,6 +423,18 @@ def issue_from_branch(branch: str) -> str | None:
423
423
  return m.group(2) if m else None
424
424
 
425
425
 
426
+ def declared_close_targets(body: str) -> list:
427
+ """Close authority for Step 5b: the PR body's ACTIVE close keywords, parsed
428
+ by pr-body-check's one close grammar. A branch number is context, never
429
+ closure authority — branch-derived closing once shut a Program-PRD that a
430
+ PR deliberately referenced only via `Part of`."""
431
+ spec = importlib.util.spec_from_file_location(
432
+ "pr_body_check_grammar", Path(__file__).parent / "pr-body-check.py")
433
+ mod = importlib.util.module_from_spec(spec)
434
+ spec.loader.exec_module(mod)
435
+ return mod.active_close_targets(body)
436
+
437
+
426
438
  def load_profile() -> dict:
427
439
  try:
428
440
  from board_config import load_board_config
@@ -1245,16 +1257,26 @@ def cmd_land(args) -> dict:
1245
1257
  elif p.returncode != 0:
1246
1258
  report["warnings"].append(f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}")
1247
1259
 
1248
- # Step 5b — verify issue auto-close (backtick-swallowed `closes` misses)
1260
+ # Step 5b — verify declared auto-closes (backtick-swallowed `closes`
1261
+ # misses). Targets come from the merged PR body's close keywords, never
1262
+ # from the branch number.
1249
1263
  issue = issue_from_branch(branch)
1250
- if issue:
1251
- p = run(["gh", "issue", "view", issue, "--json", "state"])
1252
- if p.returncode == 0 and json.loads(p.stdout)["state"] == "OPEN":
1253
- run(["gh", "issue", "close", issue, "-c",
1254
- f"Merged via PR #{pr} auto-close didn't fire; closed manually."])
1255
- report["issue_close"] = f"#{issue} closed manually"
1256
- elif p.returncode == 0:
1257
- report["issue_close"] = f"#{issue} already closed"
1264
+ p = run(["gh", "pr", "view", pr, "--json", "body"])
1265
+ merged_body = (json.loads(p.stdout).get("body") or "") if p.returncode == 0 else ""
1266
+ targets = declared_close_targets(merged_body)
1267
+ if not targets:
1268
+ report["issue_close"] = "no close targets declared nothing to verify"
1269
+ else:
1270
+ states = []
1271
+ for t in targets:
1272
+ q = run(["gh", "issue", "view", t, "--json", "state"])
1273
+ if q.returncode == 0 and json.loads(q.stdout)["state"] == "OPEN":
1274
+ run(["gh", "issue", "close", t, "-c",
1275
+ f"Merged via PR #{pr} — auto-close didn't fire; closed manually."])
1276
+ states.append(f"#{t} closed manually")
1277
+ elif q.returncode == 0:
1278
+ states.append(f"#{t} already closed")
1279
+ report["issue_close"] = " · ".join(states)
1258
1280
 
1259
1281
  # Step 5c — local merged-branch sweep (-d only: unreachable-from-main is refused)
1260
1282
  swept = []