@ikon85/agent-workflow-kit 0.43.0 → 0.44.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 (126) hide show
  1. package/.agents/skills/grill-me/SKILL.md +3 -1
  2. package/.agents/skills/grill-with-docs/SKILL.md +3 -1
  3. package/.agents/skills/kit-update/SKILL.md +9 -11
  4. package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
  5. package/.agents/skills/setup-workflow/SKILL.md +20 -18
  6. package/.agents/skills/setup-workflow/board-sync.md +4 -1
  7. package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
  8. package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
  9. package/.agents/skills/to-issues/SKILL.md +43 -0
  10. package/.agents/skills/wrapup/SKILL.md +98 -54
  11. package/.claude/skills/grill-me/SKILL.md +3 -1
  12. package/.claude/skills/grill-me-codex/SKILL.md +3 -1
  13. package/.claude/skills/grill-with-docs/SKILL.md +3 -1
  14. package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
  15. package/.claude/skills/kit-update/SKILL.md +9 -11
  16. package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
  17. package/.claude/skills/setup-workflow/SKILL.md +20 -18
  18. package/.claude/skills/setup-workflow/board-sync.md +4 -1
  19. package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
  20. package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
  21. package/.claude/skills/to-issues/SKILL.md +43 -0
  22. package/.claude/skills/wrapup/SKILL.md +98 -54
  23. package/README.md +249 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -12
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +26 -9
  29. package/scripts/worktree-lifecycle/README.md +57 -160
  30. package/scripts/worktree-lifecycle/capabilities.json +2 -3
  31. package/scripts/worktree-lifecycle/classify.py +480 -0
  32. package/scripts/worktree-lifecycle/cleanup.py +9 -29
  33. package/scripts/worktree-lifecycle/core.py +68 -1429
  34. package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
  35. package/scripts/worktree-lifecycle/profile.py +46 -29
  36. package/scripts/worktree-lifecycle/setup.py +7 -19
  37. package/scripts/wrapup-land.py +1292 -545
  38. package/src/consumer-migrations.json +1 -16
  39. package/src/lib/bundle.mjs +31 -1
  40. package/docs/adr/0001-consumer-divergence-policy.md +0 -53
  41. package/docs/adr/0002-capability-gated-orchestration.md +0 -70
  42. package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
  43. package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
  44. package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
  45. package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
  46. package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
  47. package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
  48. package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
  49. package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
  50. package/docs/agents/board-sync.md +0 -74
  51. package/docs/agents/code-review.md +0 -14
  52. package/docs/agents/workflow-capabilities.json +0 -28
  53. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  54. package/docs/research/benchlm-routing-source.md +0 -198
  55. package/docs/research/consumer-owned-protocol-files.md +0 -238
  56. package/docs/research/frontend-agent-benchmarks.md +0 -282
  57. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  58. package/docs/research/provider-neutral-agent-routing.md +0 -207
  59. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  60. package/docs/research/wave-43-script-hook-census.md +0 -128
  61. package/scripts/build-kit.mjs +0 -117
  62. package/scripts/build-kit.test.mjs +0 -207
  63. package/scripts/census/census.test.mjs +0 -252
  64. package/scripts/census/state.test.mjs +0 -15
  65. package/scripts/census/transaction.test.mjs +0 -103
  66. package/scripts/census-contract.test.mjs +0 -227
  67. package/scripts/check-kit-staleness.mjs +0 -45
  68. package/scripts/check-kit-staleness.test.mjs +0 -19
  69. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
  70. package/scripts/codex-exec.test.mjs +0 -871
  71. package/scripts/grill-census-wiring-guard.mjs +0 -87
  72. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  73. package/scripts/kit-release.test.mjs +0 -273
  74. package/scripts/kit-update-pr.test.mjs +0 -151
  75. package/scripts/lib/audit-refs.mjs +0 -146
  76. package/scripts/lib/scrub.mjs +0 -113
  77. package/scripts/lib/scrub.test.mjs +0 -187
  78. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  79. package/scripts/portability_profile_scan.py +0 -53
  80. package/scripts/release-delta-guard.test.mjs +0 -174
  81. package/scripts/release-parity.test.mjs +0 -90
  82. package/scripts/release-state.test.mjs +0 -360
  83. package/scripts/test_anchor_table.py +0 -69
  84. package/scripts/test_board_bootstrap.py +0 -348
  85. package/scripts/test_board_sync.py +0 -208
  86. package/scripts/test_board_sync_create_idempotency.py +0 -117
  87. package/scripts/test_board_sync_wave_title.py +0 -120
  88. package/scripts/test_census_backstop.py +0 -712
  89. package/scripts/test_census_forward_contract.py +0 -91
  90. package/scripts/test_census_update_contract.test.mjs +0 -454
  91. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  92. package/scripts/test_dist_kit_smoke.py +0 -220
  93. package/scripts/test_drift_guard_diagnostics.py +0 -295
  94. package/scripts/test_issue_claim_contract.py +0 -169
  95. package/scripts/test_kit_docs_language_census.py +0 -178
  96. package/scripts/test_marker_lib.py +0 -162
  97. package/scripts/test_orchestrate_wave_contract.py +0 -381
  98. package/scripts/test_pr_body_check.py +0 -264
  99. package/scripts/test_profile_globs.py +0 -280
  100. package/scripts/test_program_planning_contract.py +0 -133
  101. package/scripts/test_release_authorization_contract.py +0 -101
  102. package/scripts/test_render_anchor.py +0 -267
  103. package/scripts/test_retro_wrapup_contract.py +0 -153
  104. package/scripts/test_skill_code_review_seed.py +0 -89
  105. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  106. package/scripts/test_skill_frontmatter_lint.py +0 -234
  107. package/scripts/test_skill_gh_lint.py +0 -124
  108. package/scripts/test_skill_language_census.py +0 -254
  109. package/scripts/test_skill_optional_readiness.py +0 -171
  110. package/scripts/test_skill_portability_lint.py +0 -866
  111. package/scripts/test_skill_precommit_template.py +0 -30
  112. package/scripts/test_skill_publish_audit.py +0 -358
  113. package/scripts/test_skill_readiness_contract.py +0 -264
  114. package/scripts/test_skill_readiness_preflight.py +0 -180
  115. package/scripts/test_skill_required_readiness.py +0 -233
  116. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  117. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  118. package/scripts/test_skill_stale_name_lint.py +0 -126
  119. package/scripts/test_skill_surface_refs.py +0 -300
  120. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  121. package/scripts/test_tdd_contract.py +0 -78
  122. package/scripts/test_worktree_ignore_seed.py +0 -320
  123. package/scripts/test_worktree_setup_base_guard.py +0 -140
  124. package/scripts/test_worktree_wrapup_contract.py +0 -1737
  125. package/scripts/test_wrapup_land.py +0 -428
  126. package/scripts/worktree-lifecycle/session.py +0 -1857
@@ -1,320 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Consumer ignore-gap contract (#255).
3
-
4
- Shipped skills write `PLAN.md`, `PLAN-REVIEW-LOG.md`, and `ANNAHMEN.md` into a
5
- session worktree, but `.gitignore` is a consumer file the kit does not own, so
6
- `init`/`update` never seed the matching rules. ADR 0008 resolves the gap two
7
- ways, and both are pinned here:
8
-
9
- 1. `/setup-workflow` may **offer** the rules through
10
- `scripts/worktree-lifecycle/ignore_seed.py`. The helper is append-only
11
- inside one idempotent marker block: it never rewrites, reorders, or removes
12
- an existing line, it is a no-op on re-run, and it is unreachable from
13
- `init`/`update` reconciliation.
14
- 2. Shipped skill prose states the assumption instead of asserting the ignore
15
- state as a fact the kit cannot guarantee.
16
-
17
- Run: python3 scripts/test_worktree_ignore_seed.py
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import importlib.util
23
- import json
24
- import re
25
- import subprocess
26
- import sys
27
- import tempfile
28
- import unittest
29
- from pathlib import Path
30
-
31
- REPO = Path(__file__).resolve().parent.parent
32
- LIFECYCLE = REPO / "scripts/worktree-lifecycle"
33
- HELPER = LIFECYCLE / "ignore_seed.py"
34
- ARTIFACT_MANIFEST = LIFECYCLE / "plan-artifacts.json"
35
- SKILL_TREES = (REPO / ".claude/skills", REPO / ".agents/skills")
36
-
37
-
38
- def load_helper():
39
- spec = importlib.util.spec_from_file_location("wl_ignore_seed", HELPER)
40
- module = importlib.util.module_from_spec(spec)
41
- sys.modules[spec.name] = module
42
- spec.loader.exec_module(module)
43
- return module
44
-
45
-
46
- def git(repo: Path, *args: str) -> subprocess.CompletedProcess:
47
- return subprocess.run(
48
- ["git", *args], cwd=repo, capture_output=True, text=True, check=True,
49
- )
50
-
51
-
52
- def make_repo(stack, gitignore: str | None = None) -> Path:
53
- repo = Path(stack.enter_context(tempfile.TemporaryDirectory()))
54
- git(repo, "init", "-q")
55
- git(repo, "config", "user.email", "test@example.com")
56
- git(repo, "config", "user.name", "test")
57
- if gitignore is not None:
58
- (repo / ".gitignore").write_text(gitignore, encoding="utf-8")
59
- return repo
60
-
61
-
62
- class ArtifactManifestTest(unittest.TestCase):
63
- """The kit declares which planning artifacts its own skills write."""
64
-
65
- def test_manifest_declares_the_three_planning_artifacts(self):
66
- document = json.loads(ARTIFACT_MANIFEST.read_text(encoding="utf-8"))
67
- paths = [entry["path"] for entry in document["artifacts"]]
68
- self.assertEqual(
69
- paths, ["PLAN.md", "PLAN-REVIEW-LOG.md", "ANNAHMEN.md"],
70
- )
71
- for entry in document["artifacts"]:
72
- self.assertTrue(entry["writtenBy"], entry["path"])
73
-
74
-
75
- class PlanTest(unittest.TestCase):
76
- """`plan()` reports the exact append, never a rewrite."""
77
-
78
- def setUp(self):
79
- self.helper = load_helper()
80
- self.stack = __import__("contextlib").ExitStack()
81
- self.addCleanup(self.stack.close)
82
-
83
- def test_fresh_repo_lists_every_artifact_as_pending(self):
84
- repo = make_repo(self.stack, "node_modules/\n")
85
- plan = self.helper.plan(repo)
86
- self.assertEqual(
87
- plan.pending, ("PLAN.md", "PLAN-REVIEW-LOG.md", "ANNAHMEN.md"),
88
- )
89
- self.assertEqual(plan.already_ignored, ())
90
- self.assertEqual(plan.status, "append")
91
- for path in plan.pending:
92
- self.assertIn(f"\n{path}\n", plan.block)
93
- self.assertTrue(plan.block.startswith(self.helper.BLOCK_START))
94
- self.assertTrue(plan.block.rstrip("\n").endswith(self.helper.BLOCK_END))
95
-
96
- def test_missing_gitignore_is_reported_as_a_create(self):
97
- repo = make_repo(self.stack)
98
- plan = self.helper.plan(repo)
99
- self.assertEqual(plan.status, "append")
100
- self.assertFalse(plan.gitignore_exists)
101
-
102
- def test_consumer_rules_already_covering_everything_are_nothing_to_do(self):
103
- repo = make_repo(
104
- self.stack, "PLAN.md\nPLAN-REVIEW-LOG.md\nANNAHMEN.md\n",
105
- )
106
- plan = self.helper.plan(repo)
107
- self.assertEqual(plan.pending, ())
108
- self.assertEqual(plan.status, "nothing-to-do")
109
- self.assertIsNone(plan.block)
110
-
111
- def test_partial_coverage_pends_only_the_missing_rules(self):
112
- repo = make_repo(self.stack, "PLAN.md\n")
113
- plan = self.helper.plan(repo)
114
- self.assertEqual(plan.pending, ("PLAN-REVIEW-LOG.md", "ANNAHMEN.md"))
115
- self.assertEqual(plan.already_ignored, ("PLAN.md",))
116
- self.assertNotIn("\nPLAN.md\n", plan.block)
117
-
118
- def test_a_wildcard_consumer_rule_counts_as_covered(self):
119
- repo = make_repo(self.stack, "*.md\n")
120
- plan = self.helper.plan(repo)
121
- self.assertEqual(plan.pending, ())
122
-
123
- def test_a_tracked_artifact_is_named_because_a_rule_cannot_untrack_it(self):
124
- repo = make_repo(self.stack, "node_modules/\n")
125
- (repo / "PLAN.md").write_text("plan\n", encoding="utf-8")
126
- git(repo, "add", "PLAN.md")
127
- git(repo, "commit", "-qm", "add plan")
128
- plan = self.helper.plan(repo)
129
- self.assertEqual(plan.tracked, ("PLAN.md",))
130
- self.assertIn("PLAN.md", plan.pending)
131
-
132
-
133
- class ApplyTest(unittest.TestCase):
134
- """`apply()` is append-only, idempotent, and never rewrites."""
135
-
136
- def setUp(self):
137
- self.helper = load_helper()
138
- self.stack = __import__("contextlib").ExitStack()
139
- self.addCleanup(self.stack.close)
140
-
141
- def test_apply_appends_and_preserves_the_existing_bytes_verbatim(self):
142
- original = "node_modules/\n\n# my rules\ndist/\n"
143
- repo = make_repo(self.stack, original)
144
- result = self.helper.apply(repo)
145
- self.assertEqual(result.status, "appended")
146
- text = (repo / ".gitignore").read_text(encoding="utf-8")
147
- self.assertTrue(text.startswith(original))
148
- self.assertIn(self.helper.BLOCK_START, text)
149
- for path in ("PLAN.md", "PLAN-REVIEW-LOG.md", "ANNAHMEN.md"):
150
- self.assertIn(f"\n{path}\n", text)
151
-
152
- def test_rerun_is_a_byte_identical_no_op(self):
153
- repo = make_repo(self.stack, "node_modules/\n")
154
- self.helper.apply(repo)
155
- first = (repo / ".gitignore").read_bytes()
156
- second_result = self.helper.apply(repo)
157
- self.assertEqual(second_result.status, "nothing-to-do")
158
- self.assertEqual((repo / ".gitignore").read_bytes(), first)
159
- self.assertEqual(first.decode().count(self.helper.BLOCK_START), 1)
160
-
161
- def test_decline_path_writes_nothing(self):
162
- repo = make_repo(self.stack, "node_modules/\n")
163
- before = (repo / ".gitignore").read_bytes()
164
- self.helper.plan(repo)
165
- self.assertEqual((repo / ".gitignore").read_bytes(), before)
166
-
167
- def test_already_covered_repo_is_left_untouched(self):
168
- original = "PLAN.md\nPLAN-REVIEW-LOG.md\nANNAHMEN.md\n"
169
- repo = make_repo(self.stack, original)
170
- result = self.helper.apply(repo)
171
- self.assertEqual(result.status, "nothing-to-do")
172
- self.assertEqual(
173
- (repo / ".gitignore").read_text(encoding="utf-8"), original,
174
- )
175
-
176
- def test_missing_gitignore_is_created_with_only_the_block(self):
177
- repo = make_repo(self.stack)
178
- self.helper.apply(repo)
179
- text = (repo / ".gitignore").read_text(encoding="utf-8")
180
- self.assertTrue(text.startswith(self.helper.BLOCK_START))
181
-
182
- def test_an_edited_marker_block_blocks_instead_of_rewriting(self):
183
- repo = make_repo(self.stack)
184
- self.helper.apply(repo)
185
- text = (repo / ".gitignore").read_text(encoding="utf-8")
186
- edited = text.replace("ANNAHMEN.md\n", "")
187
- (repo / ".gitignore").write_text(edited, encoding="utf-8")
188
- result = self.helper.apply(repo)
189
- self.assertEqual(result.status, "blocked")
190
- self.assertEqual(
191
- (repo / ".gitignore").read_text(encoding="utf-8"), edited,
192
- )
193
- self.assertEqual(edited.count(self.helper.BLOCK_START), 1)
194
-
195
- def test_cli_preview_writes_nothing_and_reports_json(self):
196
- repo = make_repo(self.stack, "node_modules/\n")
197
- before = (repo / ".gitignore").read_bytes()
198
- result = subprocess.run(
199
- [sys.executable, str(HELPER), "preview", "--repo", str(repo), "--json"],
200
- capture_output=True, text=True,
201
- )
202
- self.assertEqual(result.returncode, 0, result.stderr)
203
- payload = json.loads(result.stdout)
204
- self.assertEqual(payload["status"], "append")
205
- self.assertEqual((repo / ".gitignore").read_bytes(), before)
206
-
207
- def test_cli_apply_exits_zero_and_is_idempotent(self):
208
- repo = make_repo(self.stack, "node_modules/\n")
209
- for _ in range(2):
210
- result = subprocess.run(
211
- [sys.executable, str(HELPER), "apply", "--repo", str(repo)],
212
- capture_output=True, text=True,
213
- )
214
- self.assertEqual(result.returncode, 0, result.stderr)
215
- text = (repo / ".gitignore").read_text(encoding="utf-8")
216
- self.assertEqual(text.count(self.helper.BLOCK_START), 1)
217
-
218
-
219
- class ReconciliationBoundaryTest(unittest.TestCase):
220
- """Only setup-workflow may reach the seeder; never init/update."""
221
-
222
- def test_no_installer_command_invokes_the_seeder(self):
223
- # bundle.mjs only DECLARES the file as shipped; every other installer
224
- # module must not know the seeder exists.
225
- declaration = REPO / "src/lib/bundle.mjs"
226
- offenders = []
227
- for path in sorted((REPO / "src").rglob("*.mjs")):
228
- if path == declaration:
229
- continue
230
- if "ignore_seed" in path.read_text(encoding="utf-8"):
231
- offenders.append(str(path.relative_to(REPO)))
232
- self.assertEqual(offenders, [])
233
-
234
- def test_the_seeder_ships_with_the_kit(self):
235
- bundle = (REPO / "src/lib/bundle.mjs").read_text(encoding="utf-8")
236
- self.assertIn("scripts/worktree-lifecycle/ignore_seed.py", bundle)
237
- self.assertIn("scripts/worktree-lifecycle/plan-artifacts.json", bundle)
238
-
239
-
240
- class SetupWorkflowOfferTest(unittest.TestCase):
241
- """The offer is an explicit, previewed, declinable setup step."""
242
-
243
- def _skill(self, tree: Path) -> str:
244
- return (tree / "setup-workflow/SKILL.md").read_text(encoding="utf-8")
245
-
246
- def _seed(self, tree: Path) -> str:
247
- return (tree / "setup-workflow/worktree-lifecycle.md").read_text(
248
- encoding="utf-8",
249
- )
250
-
251
- def test_both_surfaces_document_the_previewed_offer(self):
252
- for tree in SKILL_TREES:
253
- body = self._skill(tree)
254
- self.assertIn(
255
- "python3 scripts/worktree-lifecycle/ignore_seed.py preview", body,
256
- )
257
- self.assertIn(
258
- "python3 scripts/worktree-lifecycle/ignore_seed.py apply", body,
259
- )
260
- flat = re.sub(r"\s+", " ", body)
261
- self.assertIn("Add the rules", flat)
262
- self.assertIn("Not now", flat)
263
-
264
- def test_the_seed_contract_carries_the_ignore_offer_matrix(self):
265
- for tree in SKILL_TREES:
266
- flat = re.sub(r"\s+", " ", self._seed(tree))
267
- self.assertIn("ignore_seed.py", flat)
268
- for row in ("approve", "decline", "already ignored", "re-run"):
269
- self.assertIn(row, flat)
270
-
271
-
272
- class ProseClaimCensusTest(unittest.TestCase):
273
- """No shipped skill asserts an ignore state the kit cannot guarantee."""
274
-
275
- ARTIFACT = re.compile(
276
- r"PLAN\.md|PLAN-REVIEW-LOG\.md|ANNAHMEN\.md|plan doc|assumptions log",
277
- re.IGNORECASE,
278
- )
279
- # Unconditional assertions of the ignore state. Conditional or hedged
280
- # wording ("a project may gitignore these files", "when `PLAN.md` is
281
- # ignored") is deliberately allowed.
282
- FORBIDDEN = (
283
- re.compile(r"\(gitignored\b", re.IGNORECASE),
284
- re.compile(r"\b(?:is|are|stays?|remains?)\s+gitignored\b", re.IGNORECASE),
285
- re.compile(r"\bgitignored\s+(?:at|in|since)\b", re.IGNORECASE),
286
- re.compile(r"\ba\s+gitignored\s+plan\s+doc\b", re.IGNORECASE),
287
- re.compile(r"\bgitignored,\s*on-disk only\b", re.IGNORECASE),
288
- )
289
- WINDOW = 160
290
-
291
- def _claim_offenders(self):
292
- offenders = []
293
- for tree in SKILL_TREES:
294
- for path in sorted(tree.rglob("*.md")):
295
- flat = re.sub(r"\s+", " ", path.read_text(encoding="utf-8"))
296
- for pattern in self.FORBIDDEN:
297
- for match in pattern.finditer(flat):
298
- window = flat[
299
- max(0, match.start() - self.WINDOW):
300
- match.end() + self.WINDOW
301
- ]
302
- if self.ARTIFACT.search(window):
303
- offenders.append(
304
- f"{path.relative_to(REPO)}: {match.group(0)}"
305
- )
306
- return sorted(set(offenders))
307
-
308
- def test_no_skill_states_the_ignore_rule_as_an_installed_fact(self):
309
- self.assertEqual(self._claim_offenders(), [])
310
-
311
- def test_the_replacement_wording_names_who_can_make_it_true(self):
312
- for name in ("grill-me", "grill-with-docs", "orchestrate-wave"):
313
- for tree in SKILL_TREES:
314
- body = (tree / name / "SKILL.md").read_text(encoding="utf-8")
315
- flat = re.sub(r"\s+", " ", body)
316
- self.assertIn("setup-workflow", flat, f"{tree}/{name}")
317
-
318
-
319
- if __name__ == "__main__":
320
- unittest.main()
@@ -1,140 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Worktree reuse must refuse a stale base on BOTH reuse paths.
3
-
4
- `setup.py create()` can reach an existing worktree two ways: the target is
5
- already a registered worktree, or only its branch exists locally and
6
- `git worktree add <target> <branch>` silently ignores `--base`. Reuse is safe
7
- only when the reused HEAD is AT the base or cleanly behind it (fast-forwardable);
8
- anything ahead or diverged builds the slice on a stale base and must STOP.
9
- """
10
-
11
- import importlib.util
12
- import os
13
- import subprocess
14
- import sys
15
- import tempfile
16
- import unittest
17
- from argparse import Namespace
18
- from contextlib import contextmanager
19
- from pathlib import Path
20
-
21
- REPO = Path(__file__).resolve().parent.parent
22
- LIFECYCLE = REPO / "scripts/worktree-lifecycle"
23
-
24
- PROFILE = """{
25
- "worktreeLifecycle": {
26
- "enabled": true,
27
- "worktreeRoot": ".worktrees",
28
- "branchTemplate": "{type}/{issue}-{slug}",
29
- "pathTemplate": "{issue}-{slug}",
30
- "setupSteps": []
31
- }
32
- }
33
- """
34
-
35
-
36
- def load_setup():
37
- sys.path.insert(0, str(LIFECYCLE))
38
- try:
39
- spec = importlib.util.spec_from_file_location(
40
- "worktree_setup_base_guard", LIFECYCLE / "setup.py"
41
- )
42
- module = importlib.util.module_from_spec(spec)
43
- spec.loader.exec_module(module)
44
- return module
45
- finally:
46
- sys.path.remove(str(LIFECYCLE))
47
-
48
-
49
- def git(repo: Path, *args: str) -> str:
50
- result = subprocess.run(
51
- ["git", *args], cwd=repo, capture_output=True, text=True, check=True
52
- )
53
- return result.stdout.strip()
54
-
55
-
56
- @contextmanager
57
- def fixture():
58
- """A repo whose `main` carries two commits: base_first, then head."""
59
- with tempfile.TemporaryDirectory() as tmp:
60
- repo = Path(tmp).resolve() / "repo"
61
- repo.mkdir()
62
- git(repo, "init", "--initial-branch=main")
63
- git(repo, "config", "user.email", "test@example.invalid")
64
- git(repo, "config", "user.name", "Test User")
65
- profile = repo / "docs/agents/workflow-capabilities.json"
66
- profile.parent.mkdir(parents=True)
67
- profile.write_text(PROFILE, encoding="utf-8")
68
- (repo / "README.md").write_text("# fixture\n", encoding="utf-8")
69
- git(repo, "add", "-A")
70
- git(repo, "commit", "-m", "first")
71
- first = git(repo, "rev-parse", "HEAD")
72
- (repo / "README.md").write_text("# fixture 2\n", encoding="utf-8")
73
- git(repo, "commit", "-am", "second")
74
- second = git(repo, "rev-parse", "HEAD")
75
- yield repo, first, second
76
-
77
-
78
- @contextmanager
79
- def chdir(path: Path):
80
- previous = Path.cwd()
81
- os.chdir(path)
82
- try:
83
- yield
84
- finally:
85
- os.chdir(previous)
86
-
87
-
88
- def args_for(base: str) -> Namespace:
89
- return Namespace(
90
- profile="docs/agents/workflow-capabilities.json",
91
- base=base,
92
- issue="173",
93
- slug="wave-safety",
94
- branch_type="feat",
95
- )
96
-
97
-
98
- class WorktreeReuseBaseGuard(unittest.TestCase):
99
- def test_registered_worktree_reuse_stops_when_head_is_not_at_base(self):
100
- setup = load_setup()
101
- with fixture() as (repo, first, second):
102
- target = repo / ".worktrees/173-wave-safety"
103
- git(repo, "worktree", "add", "-b", "feat/173-wave-safety", str(target), second)
104
- # base is the OLDER commit -> the registered worktree is ahead of it.
105
- with chdir(repo):
106
- with self.assertRaises(setup.LifecycleError) as stopped:
107
- setup.create(args_for(first))
108
- self.assertIn("stale", str(stopped.exception).lower())
109
-
110
- def test_existing_branch_reuse_stops_when_branch_is_not_at_base(self):
111
- setup = load_setup()
112
- with fixture() as (repo, first, second):
113
- git(repo, "branch", "feat/173-wave-safety", second)
114
- target = repo / ".worktrees/173-wave-safety"
115
- with chdir(repo):
116
- with self.assertRaises(setup.LifecycleError) as stopped:
117
- setup.create(args_for(first))
118
- self.assertIn("stale", str(stopped.exception).lower())
119
- self.assertFalse(target.exists(), "no worktree may be created on a stale base")
120
-
121
- def test_registered_worktree_reuse_allows_head_at_base(self):
122
- setup = load_setup()
123
- with fixture() as (repo, _first, second):
124
- target = repo / ".worktrees/173-wave-safety"
125
- git(repo, "worktree", "add", "-b", "feat/173-wave-safety", str(target), second)
126
- with chdir(repo):
127
- self.assertEqual(setup.create(args_for(second)).resolve(), target.resolve())
128
-
129
- def test_existing_branch_reuse_allows_a_branch_cleanly_behind_base(self):
130
- setup = load_setup()
131
- with fixture() as (repo, first, second):
132
- git(repo, "branch", "feat/173-wave-safety", first)
133
- target = repo / ".worktrees/173-wave-safety"
134
- with chdir(repo):
135
- self.assertEqual(setup.create(args_for(second)).resolve(), target.resolve())
136
- self.assertTrue(target.is_dir())
137
-
138
-
139
- if __name__ == "__main__":
140
- unittest.main()