@ikon85/agent-workflow-kit 0.42.1 → 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 (133) 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 +268 -7
  24. package/agent-workflow-kit.package.json +42 -42
  25. package/package.json +16 -2
  26. package/scripts/marker_lib.py +3 -0
  27. package/scripts/profile_globs.py +26 -45
  28. package/scripts/release-delta-guard.mjs +29 -4
  29. package/scripts/worktree-lifecycle/README.md +57 -154
  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 -1289
  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/domain.md +0 -7
  53. package/docs/agents/issue-tracker.md +0 -23
  54. package/docs/agents/skills/local-ci.md +0 -90
  55. package/docs/agents/skills/orchestrate-wave.md +0 -166
  56. package/docs/agents/skills/spec-self-critique.md +0 -18
  57. package/docs/agents/triage-labels.md +0 -10
  58. package/docs/agents/workflow-capabilities.json +0 -28
  59. package/docs/conventions/spec-completeness.md +0 -23
  60. package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
  61. package/docs/research/benchlm-routing-source.md +0 -198
  62. package/docs/research/consumer-owned-protocol-files.md +0 -238
  63. package/docs/research/frontend-agent-benchmarks.md +0 -282
  64. package/docs/research/model-effort-routing-benchmarks.md +0 -261
  65. package/docs/research/provider-neutral-agent-routing.md +0 -207
  66. package/docs/research/wave-152-consumer-acceptance.md +0 -98
  67. package/docs/research/wave-43-script-hook-census.md +0 -128
  68. package/scripts/build-kit.mjs +0 -117
  69. package/scripts/build-kit.test.mjs +0 -204
  70. package/scripts/census/census.test.mjs +0 -252
  71. package/scripts/census/state.test.mjs +0 -15
  72. package/scripts/census/transaction.test.mjs +0 -103
  73. package/scripts/census-contract.test.mjs +0 -227
  74. package/scripts/check-kit-staleness.mjs +0 -45
  75. package/scripts/check-kit-staleness.test.mjs +0 -19
  76. package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -152
  77. package/scripts/codex-exec.test.mjs +0 -871
  78. package/scripts/grill-census-wiring-guard.mjs +0 -87
  79. package/scripts/grill-census-wiring-guard.test.mjs +0 -107
  80. package/scripts/kit-release.test.mjs +0 -273
  81. package/scripts/kit-update-pr.test.mjs +0 -151
  82. package/scripts/lib/audit-refs.mjs +0 -146
  83. package/scripts/lib/scrub.mjs +0 -113
  84. package/scripts/lib/scrub.test.mjs +0 -187
  85. package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
  86. package/scripts/portability_profile_scan.py +0 -53
  87. package/scripts/release-delta-guard.test.mjs +0 -151
  88. package/scripts/release-parity.test.mjs +0 -90
  89. package/scripts/release-state.test.mjs +0 -360
  90. package/scripts/test_anchor_table.py +0 -69
  91. package/scripts/test_board_bootstrap.py +0 -348
  92. package/scripts/test_board_sync.py +0 -208
  93. package/scripts/test_board_sync_create_idempotency.py +0 -117
  94. package/scripts/test_board_sync_wave_title.py +0 -120
  95. package/scripts/test_census_backstop.py +0 -712
  96. package/scripts/test_census_forward_contract.py +0 -91
  97. package/scripts/test_census_update_contract.test.mjs +0 -454
  98. package/scripts/test_codex_adapter_sync_contract.py +0 -169
  99. package/scripts/test_dist_kit_smoke.py +0 -220
  100. package/scripts/test_drift_guard_diagnostics.py +0 -295
  101. package/scripts/test_issue_claim_contract.py +0 -169
  102. package/scripts/test_kit_docs_language_census.py +0 -178
  103. package/scripts/test_marker_lib.py +0 -162
  104. package/scripts/test_orchestrate_wave_contract.py +0 -381
  105. package/scripts/test_pr_body_check.py +0 -264
  106. package/scripts/test_profile_globs.py +0 -280
  107. package/scripts/test_program_planning_contract.py +0 -133
  108. package/scripts/test_release_authorization_contract.py +0 -101
  109. package/scripts/test_render_anchor.py +0 -267
  110. package/scripts/test_retro_wrapup_contract.py +0 -153
  111. package/scripts/test_skill_code_review_seed.py +0 -89
  112. package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
  113. package/scripts/test_skill_frontmatter_lint.py +0 -234
  114. package/scripts/test_skill_gh_lint.py +0 -124
  115. package/scripts/test_skill_language_census.py +0 -254
  116. package/scripts/test_skill_optional_readiness.py +0 -171
  117. package/scripts/test_skill_portability_lint.py +0 -866
  118. package/scripts/test_skill_precommit_template.py +0 -30
  119. package/scripts/test_skill_publish_audit.py +0 -358
  120. package/scripts/test_skill_readiness_contract.py +0 -264
  121. package/scripts/test_skill_readiness_preflight.py +0 -180
  122. package/scripts/test_skill_required_readiness.py +0 -233
  123. package/scripts/test_skill_selfcontainment_lint.py +0 -255
  124. package/scripts/test_skill_setup_workflow_seeds.py +0 -691
  125. package/scripts/test_skill_stale_name_lint.py +0 -126
  126. package/scripts/test_skill_surface_refs.py +0 -300
  127. package/scripts/test_skill_trailing_artifact_lint.py +0 -71
  128. package/scripts/test_tdd_contract.py +0 -78
  129. package/scripts/test_worktree_ignore_seed.py +0 -320
  130. package/scripts/test_worktree_setup_base_guard.py +0 -140
  131. package/scripts/test_worktree_wrapup_contract.py +0 -1648
  132. package/scripts/test_wrapup_land.py +0 -428
  133. 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()