@ikon85/agent-workflow-kit 0.38.0 → 0.40.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.
- package/.agents/skills/grill-me/SKILL.md +1 -1
- package/.agents/skills/grill-with-docs/SKILL.md +1 -1
- package/.agents/skills/kit-update/SKILL.md +33 -1
- package/.agents/skills/orchestrate-wave/SKILL.md +4 -4
- package/.agents/skills/setup-workflow/SKILL.md +84 -3
- package/.agents/skills/setup-workflow/board-sync.md +6 -2
- package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.agents/skills/wrapup/SKILL.md +24 -0
- package/.claude/hooks/drift-guard.py +212 -21
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/kit-update/SKILL.md +33 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
- package/.claude/skills/setup-workflow/SKILL.md +84 -3
- package/.claude/skills/setup-workflow/board-sync.md +6 -2
- package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +54 -3
- package/.claude/skills/wrapup/SKILL.md +24 -0
- package/README.md +62 -0
- package/agent-workflow-kit.package.json +57 -25
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
- package/package.json +1 -1
- package/scripts/board_bootstrap.py +367 -0
- package/scripts/kit-update-pr.mjs +20 -7
- package/scripts/kit-update-pr.test.mjs +29 -0
- package/scripts/profile_globs.py +347 -0
- package/scripts/test_board_bootstrap.py +348 -0
- package/scripts/test_drift_guard_diagnostics.py +295 -0
- package/scripts/test_profile_globs.py +280 -0
- package/scripts/test_skill_setup_workflow_seeds.py +87 -0
- package/scripts/test_worktree_ignore_seed.py +320 -0
- package/scripts/test_worktree_wrapup_contract.py +588 -0
- package/scripts/workflow-advisories/core.py +29 -4
- package/scripts/worktree-lifecycle/README.md +53 -4
- package/scripts/worktree-lifecycle/core.py +211 -60
- package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
- package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
- package/scripts/wrapup-land.py +179 -34
- package/src/cli.mjs +32 -1
- package/src/commands/update.mjs +30 -21
- package/src/consumer-migrations.json +19 -0
- package/src/lib/bundle.mjs +16 -0
- package/src/lib/consumerMigrations.mjs +161 -0
|
@@ -0,0 +1,320 @@
|
|
|
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()
|