@ikon85/agent-workflow-kit 0.39.0 → 0.41.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/orchestrate-wave/SKILL.md +4 -4
- package/.agents/skills/setup-workflow/SKILL.md +50 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +23 -1
- package/.claude/skills/grill-me/SKILL.md +1 -1
- package/.claude/skills/grill-with-docs/SKILL.md +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +4 -4
- package/.claude/skills/setup-workflow/SKILL.md +50 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +23 -1
- package/README.md +33 -0
- package/agent-workflow-kit.package.json +29 -13
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +1 -1
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +84 -0
- package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +101 -0
- package/package.json +1 -1
- package/scripts/pr-body-check.py +15 -2
- package/scripts/test_pr_body_check.py +10 -0
- package/scripts/test_worktree_ignore_seed.py +320 -0
- package/scripts/worktree-lifecycle/README.md +18 -0
- package/scripts/worktree-lifecycle/ignore_seed.py +226 -0
- package/scripts/worktree-lifecycle/plan-artifacts.json +37 -0
- package/src/lib/bundle.mjs +6 -0
|
@@ -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.
|
|
3
|
+
"version": "0.41.0",
|
|
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": {
|
package/scripts/pr-body-check.py
CHANGED
|
@@ -208,13 +208,26 @@ def fetch_pr_body(branch: str):
|
|
|
208
208
|
return out if rc == 0 else None
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
def _anchor_labels() -> set:
|
|
212
|
+
"""Labels that mark an issue as a never-auto-close anchor: the wave
|
|
213
|
+
cluster label plus the board profile's Program-PRD label (a Program-PRD
|
|
214
|
+
is closed last, manually — `closes` must never target it)."""
|
|
215
|
+
labels = {"type:cluster"}
|
|
216
|
+
try:
|
|
217
|
+
import board_config
|
|
218
|
+
labels.add(board_config.program_type_label(board_config.load_board_config()))
|
|
219
|
+
except Exception:
|
|
220
|
+
labels.add("type:program") # profile unavailable → literal default
|
|
221
|
+
return labels
|
|
222
|
+
|
|
223
|
+
|
|
211
224
|
def fetch_is_anchor(number: int) -> bool:
|
|
212
|
-
"""True wenn das Issue selbst ein
|
|
225
|
+
"""True wenn das Issue selbst ein Anker ist (type:cluster oder Program-PRD)."""
|
|
213
226
|
rc, out = _run(["gh", "issue", "view", str(number),
|
|
214
227
|
"--json", "labels", "-q", ".labels[].name"])
|
|
215
228
|
if rc != 0:
|
|
216
229
|
return False # fail-open in den Leaf-Pfad (bisheriges Verhalten)
|
|
217
|
-
return
|
|
230
|
+
return bool(_anchor_labels() & set(out.splitlines()))
|
|
218
231
|
|
|
219
232
|
|
|
220
233
|
def resolve_has_e2e_na(args, branch: str) -> bool:
|
|
@@ -79,6 +79,16 @@ 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_program_prd_label_counts_as_anchor(self):
|
|
83
|
+
with mock.patch.object(pbc, "_run",
|
|
84
|
+
return_value=(0, "type:program\npriority:medium")):
|
|
85
|
+
self.assertTrue(pbc.fetch_is_anchor(320))
|
|
86
|
+
|
|
87
|
+
def test_cluster_label_still_counts_as_anchor(self):
|
|
88
|
+
with mock.patch.object(pbc, "_run",
|
|
89
|
+
return_value=(0, "type:cluster")):
|
|
90
|
+
self.assertTrue(pbc.fetch_is_anchor(41))
|
|
91
|
+
|
|
82
92
|
def test_wave_pr_still_requires_part_of_without_closing_anchor(self):
|
|
83
93
|
body = f"Part of #130\ncloses #149\n{RETRO}"
|
|
84
94
|
self.assertEqual(pbc.check_pr_body(body, 130, None, is_anchor=True), [])
|
|
@@ -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()
|
|
@@ -58,6 +58,24 @@ the core proves the target is unsafe.
|
|
|
58
58
|
| `enforce-worktree-discipline.py` | PreToolUse | routes issue-branch creation through the configured setup entry |
|
|
59
59
|
| `slice-handoff-hint.py` | UserPromptSubmit | names the configured setup entry for a defined slice |
|
|
60
60
|
|
|
61
|
+
## Planning-artifact ignore rules
|
|
62
|
+
|
|
63
|
+
`plan-artifacts.json` is the kit-owned declaration of the planning artefacts the
|
|
64
|
+
shipped skills write (`PLAN.md`, `PLAN-REVIEW-LOG.md`, `ANNAHMEN.md`).
|
|
65
|
+
`ignore_seed.py` turns that declaration into an offer, never an installation:
|
|
66
|
+
`preview` reports what an approval would append and writes nothing, and `apply`
|
|
67
|
+
appends exactly one marker block. `.gitignore` is a consumer file the kit does
|
|
68
|
+
not own, so only an explicit approved `/setup-workflow` step may run `apply`;
|
|
69
|
+
`init` and `update` reconciliation never reach it (ADR 0008).
|
|
70
|
+
|
|
71
|
+
The append never rewrites, reorders, or removes an existing line. A repository
|
|
72
|
+
that already ignores every artefact reports `nothing-to-do`, a re-run after an
|
|
73
|
+
approval is a byte-identical no-op, and a marker block the consumer has since
|
|
74
|
+
edited reports `blocked` rather than being repaired. An artefact already tracked
|
|
75
|
+
in git is named separately — an ignore rule cannot untrack it, and the helper
|
|
76
|
+
never runs `git rm`. Only after such an approval does the `scratchPatterns`
|
|
77
|
+
derivation have real ignored planning artefacts to read.
|
|
78
|
+
|
|
61
79
|
## Cleanup
|
|
62
80
|
|
|
63
81
|
`cleanup.py` previews by default. Removal refuses protected, tracked-dirty,
|