@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,234 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Skill-frontmatter parse-parity lint (#1603-Retro).
3
-
4
- Why: every other skill-lint reads `name`/`description` with a *regex* (e.g.
5
- stale_name_lint `re.compile(rf"name:\\s*{n}\\b")`), while the real consumer —
6
- Claude Code's skill loader — parses the frontmatter as **strict YAML**. A
7
- SKILL.md whose frontmatter does not parse therefore passes `ci:local` green but
8
- silently fails to load. That actually happened building the `impact-census`
9
- skill: the description carried an ASCII `"` instead of the German `"`, which
10
- terminated the YAML double-quoted scalar early — green CI, dead skill, caught
11
- only by an ad-hoc `yaml.safe_load`.
12
-
13
- This lint closes the parse-parity gap: it loads the frontmatter the same way the
14
- consumer does and asserts the keys the loader needs. Scope is the whole class
15
- (unparseable frontmatter — ASCII quotes, tabs, bad indentation, unbalanced
16
- colons), not just the one quote char; it adds NO speculative content rules.
17
-
18
- Checks, per SKILL.md in both trees (.claude/skills + .agents/skills):
19
- 1. the frontmatter block parses as YAML;
20
- 2. `name` is a non-empty string AND equals the skill directory name;
21
- 3. `description` is a non-empty string.
22
-
23
- Run: python3 scripts/test_skill_frontmatter_lint.py
24
- """
25
- import re
26
- import json
27
- import unittest
28
- from pathlib import Path
29
-
30
- REPO_ROOT = Path(__file__).resolve().parent.parent
31
- SKILL_DIRS = [".claude/skills", ".agents/skills"]
32
-
33
-
34
- def skill_md_files() -> list[Path]:
35
- """Every <tree>/<skill>/SKILL.md across both surface trees."""
36
- out = []
37
- for tree in SKILL_DIRS:
38
- base = REPO_ROOT / tree
39
- if not base.is_dir():
40
- continue
41
- for d in sorted(base.iterdir()):
42
- md = d / "SKILL.md"
43
- if d.is_dir() and md.is_file():
44
- out.append(md)
45
- return out
46
-
47
-
48
- def extract_frontmatter(text: str) -> str:
49
- """Return the YAML block between the first two `---` fences.
50
-
51
- Raises ValueError when the file has no leading frontmatter fence — the
52
- loader requires it, so its absence is itself a failure.
53
- """
54
- if not text.startswith("---"):
55
- raise ValueError("no leading `---` frontmatter fence")
56
- parts = text.split("---", 2)
57
- if len(parts) < 3:
58
- raise ValueError("unterminated frontmatter (missing closing `---`)")
59
- return parts[1]
60
-
61
-
62
- class FrontmatterError(ValueError):
63
- pass
64
-
65
-
66
- def parse_scalar(value: str) -> str:
67
- value = value.strip()
68
- if value.startswith('"'):
69
- try:
70
- return json.loads(value)
71
- except json.JSONDecodeError as exc:
72
- raise FrontmatterError(str(exc)) from exc
73
- if value.startswith("'"):
74
- if len(value) < 2 or not value.endswith("'"):
75
- raise FrontmatterError("unterminated single-quoted scalar")
76
- return value[1:-1].replace("''", "'")
77
- if ": " in value:
78
- raise FrontmatterError("mapping values are not allowed in a plain scalar")
79
- return value.split(" #", 1)[0].strip()
80
-
81
-
82
- def parse_frontmatter(block: str) -> dict:
83
- """Parse the loader-relevant YAML subset without a third-party package."""
84
- if "\t" in block:
85
- raise FrontmatterError("tabs are not valid indentation")
86
- data = {}
87
- lines = block.splitlines()
88
- i = 0
89
- while i < len(lines):
90
- line = lines[i]
91
- i += 1
92
- if not line.strip() or line.lstrip().startswith("#"):
93
- continue
94
- match = re.match(r'^((?:[A-Za-z][\w-]*)|(?:"[A-Za-z][\w-]*")):(?:[ ]*(.*))$', line)
95
- if not match:
96
- raise FrontmatterError(f"invalid top-level mapping line: {line!r}")
97
- key, value = match.groups()
98
- key = key.strip('"')
99
- if value in (">", ">-", "|", "|-"):
100
- chunks = []
101
- while i < len(lines) and (not lines[i].strip() or lines[i].startswith(" ")):
102
- chunks.append(lines[i].strip())
103
- i += 1
104
- data[key] = " ".join(filter(None, chunks))
105
- else:
106
- data[key] = parse_scalar(value)
107
- return data
108
-
109
-
110
- def validate(md: Path) -> list[str]:
111
- """Return human-readable problems for one SKILL.md (empty = clean)."""
112
- try:
113
- rel = md.relative_to(REPO_ROOT).as_posix()
114
- except ValueError:
115
- rel = md.as_posix() # tmp paths in the validator's own unit tests
116
- skill_name = md.parent.name
117
- text = md.read_text(encoding="utf-8")
118
- try:
119
- block = extract_frontmatter(text)
120
- data = parse_frontmatter(block)
121
- except (ValueError, FrontmatterError) as exc:
122
- # one-line message — YAML errors are multi-line, keep only the gist
123
- return [f"{rel}: frontmatter does not parse — {str(exc).splitlines()[0]}"]
124
- problems = []
125
- if not isinstance(data, dict):
126
- return [f"{rel}: frontmatter is not a YAML mapping"]
127
- name = data.get("name")
128
- if not isinstance(name, str) or not name.strip():
129
- problems.append(f"{rel}: `name` missing or empty")
130
- elif name != skill_name:
131
- problems.append(f"{rel}: `name` is '{name}' but directory is '{skill_name}'")
132
- desc = data.get("description")
133
- if not isinstance(desc, str) or not desc.strip():
134
- problems.append(f"{rel}: `description` missing or empty")
135
- elif plain_desc_has_comment(block):
136
- problems.append(
137
- f"{rel}: plain-scalar `description` contains ' #' — YAML silently "
138
- "truncates the rest as a comment (board-to-waves lost its anti-trigger "
139
- "this way, PR #1914); quote it or fold with '>-'"
140
- )
141
- return problems
142
-
143
-
144
- DESC_LINE = re.compile(r"^description:[ \t]*(.*)$", re.M)
145
-
146
-
147
- def plain_desc_has_comment(block: str) -> bool:
148
- """True when a PLAIN-style description scalar carries a ' #' — YAML reads
149
- everything from the '#' on as a comment and silently shortens the value
150
- (the file still parses, so the parse check above cannot catch it)."""
151
- m = DESC_LINE.search(block)
152
- if not m:
153
- return False
154
- first = m.group(1).strip()
155
- if first[:1] in ('"', "'", ">", "|"):
156
- return False # quoted or block scalar — '#' is literal there
157
- lines = [m.group(1)]
158
- for ln in block[m.end():].splitlines():
159
- # plain multi-line scalars continue on indented lines; stop at the
160
- # next top-level key or a blank line
161
- if ln.startswith((" ", "\t")) and ln.strip():
162
- lines.append(ln)
163
- else:
164
- break
165
- return any(" #" in ln for ln in lines)
166
-
167
-
168
- class FrontmatterParses(unittest.TestCase):
169
- """Every SKILL.md frontmatter loads as the consumer (strict YAML) sees it."""
170
-
171
- def test_all_skill_frontmatter_valid(self):
172
- problems = [p for md in skill_md_files() for p in validate(md)]
173
- self.assertEqual(
174
- problems,
175
- [],
176
- "SKILL.md frontmatter that the loader would reject (regex lints miss "
177
- "this — see #1603-Retro):\n" + "\n".join(problems),
178
- )
179
-
180
-
181
- class ValidatorBehaves(unittest.TestCase):
182
- """The validator itself catches the incident class (regression guard)."""
183
-
184
- def _tmp_skill(self, body: str) -> Path:
185
- import tempfile
186
-
187
- d = Path(tempfile.mkdtemp()) / "demo"
188
- d.mkdir()
189
- md = d / "SKILL.md"
190
- md.write_text(body, encoding="utf-8")
191
- return md
192
-
193
- def test_ascii_quote_in_description_is_flagged(self):
194
- # the exact #1603 incident: an ASCII " closes the scalar early
195
- md = self._tmp_skill('---\nname: demo\ndescription: "Use „x" then y, z"\n---\nbody\n')
196
- self.assertTrue(any("does not parse" in p for p in validate(md)))
197
-
198
- def test_plain_scalar_colon_followed_by_space_is_flagged(self):
199
- md = self._tmp_skill(
200
- "---\nname: demo\ndescription: This is invalid YAML: mapping-like text.\n---\nbody\n"
201
- )
202
- self.assertTrue(any("does not parse" in p for p in validate(md)))
203
-
204
- def test_name_dir_mismatch_is_flagged(self):
205
- md = self._tmp_skill("---\nname: wrong\ndescription: ok\n---\nbody\n")
206
- self.assertTrue(any("but directory is 'demo'" in p for p in validate(md)))
207
-
208
- def test_missing_description_is_flagged(self):
209
- md = self._tmp_skill("---\nname: demo\n---\nbody\n")
210
- self.assertTrue(any("`description` missing" in p for p in validate(md)))
211
-
212
- def test_clean_frontmatter_passes(self):
213
- md = self._tmp_skill('---\nname: demo\ndescription: "Use when foo: bar, baz."\n---\nbody\n')
214
- self.assertEqual(validate(md), [])
215
-
216
- def test_plain_desc_with_hash_comment_is_flagged(self):
217
- # the PR-#1914 incident class: an unquoted ' #' turns the rest of a
218
- # plain scalar into a YAML comment — parses fine, value silently short
219
- md = self._tmp_skill(
220
- "---\nname: demo\ndescription: Use for sweeps (e.g. #602); the anti-trigger after this vanishes.\n---\nbody\n"
221
- )
222
- self.assertTrue(any("silently" in p for p in validate(md)))
223
-
224
- def test_quoted_and_folded_desc_with_hash_pass(self):
225
- md = self._tmp_skill('---\nname: demo\ndescription: "Use for sweeps (e.g. #602); all kept."\n---\nbody\n')
226
- self.assertEqual(validate(md), [])
227
- md2 = self._tmp_skill(
228
- "---\nname: demo\ndescription: >-\n Use for sweeps\n (e.g. #602); all kept.\n---\nbody\n"
229
- )
230
- self.assertEqual(validate(md2), [])
231
-
232
-
233
- if __name__ == "__main__":
234
- unittest.main()
@@ -1,124 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Lint fixture (Welle 26 / #980): no bare board-writing `gh` in enforced skills.
3
-
4
- The board-sync mechanics now live behind `scripts/board-sync.py`. Skills that have
5
- been migrated to route through the helper must not inline bare board-mutating `gh`
6
- commands again — they drift from the SSOT and bypass the one-parent-check / preview
7
- header / field-ID encapsulation.
8
-
9
- Scope grows per slice: a skill joins ENFORCED_SKILLS once its slice routes it
10
- through the helper (1b: board-to-waves; to-issues lands in 1d/#982). The legacy
11
- two-line planning skill the helper was extracted from has since been removed
12
- (1f/#985).
13
-
14
- Only *runnable* lines inside ```bash/```sh fences are checked — prose mentions in
15
- backticks are fine. A line carrying the marker `board-sync-lint: ok` is exempt
16
- (for deliberate "don't do this" doc examples).
17
-
18
- Run: python3 scripts/test_skill_gh_lint.py
19
- """
20
- import re
21
- import unittest
22
- from pathlib import Path
23
-
24
- REPO_ROOT = Path(__file__).resolve().parent.parent
25
-
26
- # Skills whose prose must route board writes through scripts/board-sync.py.
27
- # Grows per slice as each skill is migrated (1b: board-to-waves; 1c/#981: to-prd
28
- # routes creation through the helper; to-issues lands in 1d/#982; retro lands
29
- # in Welle 49 / Slice 9 / #1880).
30
- ENFORCED_SKILLS = ["board-to-waves", "to-prd", "to-issues", "retro"]
31
-
32
- # Both the Claude source and the Codex mirror must stay clean.
33
- SKILL_DIRS = [".claude/skills", ".agents/skills"]
34
-
35
- # Board-mutating gh commands that the helper now owns.
36
- FORBIDDEN = [
37
- re.compile(r"\bgh\s+issue\s+create\b"),
38
- re.compile(r"\bgh\s+project\s+item-add\b"),
39
- re.compile(r"\bgh\s+project\s+item-edit\b"),
40
- re.compile(r"\baddSubIssue\s*\(input"),
41
- re.compile(r"\bremoveSubIssue\s*\(input"),
42
- # workflow-state label edits the helper owns (R2#4): ready-for-agent / needs-info /
43
- # type:cluster / wave-stub via gh issue edit --add-label/--remove-label. priority:* etc. stay fine.
44
- re.compile(r"\bgh\s+issue\s+edit\b.*--(add|remove)-label\b.*"
45
- r"\b(ready-for-agent|needs-info|type:cluster|wave-stub)\b"),
46
- ]
47
- FENCE_OPEN = re.compile(r"^\s*```(bash|sh|shell)\b")
48
- FENCE_ANY = re.compile(r"^\s*```")
49
- EXEMPT = "board-sync-lint: ok"
50
-
51
-
52
- def find_offenders(text: str) -> list[tuple[int, str]]:
53
- """Return (line_no, line) for forbidden gh inside bash/sh fences."""
54
- offenders = []
55
- in_shell = False
56
- for n, line in enumerate(text.splitlines(), 1):
57
- if not in_shell and FENCE_OPEN.match(line):
58
- in_shell = True
59
- continue
60
- if in_shell and FENCE_ANY.match(line):
61
- in_shell = False
62
- continue
63
- if in_shell and EXEMPT not in line:
64
- if any(p.search(line) for p in FORBIDDEN):
65
- offenders.append((n, line.strip()))
66
- return offenders
67
-
68
-
69
- class SelfCheck(unittest.TestCase):
70
- """The detector itself behaves."""
71
-
72
- def test_flags_bare_create_in_bash_fence(self):
73
- text = "```bash\ngh issue create --title X\n```"
74
- self.assertEqual(len(find_offenders(text)), 1)
75
-
76
- def test_flags_workflow_state_label_edit(self):
77
- for lbl in ("ready-for-agent", "needs-info", "type:cluster", "wave-stub"):
78
- text = f"```bash\ngh issue edit 982 --add-label {lbl}\n```"
79
- self.assertEqual(len(find_offenders(text)), 1, lbl)
80
-
81
- def test_flags_workflow_state_label_remove(self):
82
- text = "```bash\ngh issue edit 982 --remove-label ready-for-agent\n```"
83
- self.assertEqual(len(find_offenders(text)), 1)
84
-
85
- def test_ignores_non_workflow_label_edit(self):
86
- # priority:* is a normal label, not workflow-state — editing it is allowed
87
- text = "```bash\ngh issue edit 982 --add-label priority:high\n```"
88
- self.assertEqual(find_offenders(text), [])
89
-
90
- def test_ignores_prose_mention(self):
91
- text = "Der Helper kapselt `gh project item-edit` — nicht selbst aufrufen."
92
- self.assertEqual(find_offenders(text), [])
93
-
94
- def test_ignores_read_only_gh(self):
95
- text = "```bash\ngh issue list --state open\ngh project item-list 1\n```"
96
- self.assertEqual(find_offenders(text), [])
97
-
98
- def test_exempt_marker(self):
99
- text = "```bash\ngh issue create --title X # board-sync-lint: ok (anti-example)\n```"
100
- self.assertEqual(find_offenders(text), [])
101
-
102
- def test_helper_call_is_clean(self):
103
- text = "```bash\npython3 scripts/board-sync.py create --title X --body-file b.md\n```"
104
- self.assertEqual(find_offenders(text), [])
105
-
106
-
107
- class EnforcedSkillsClean(unittest.TestCase):
108
- def test_no_bare_board_gh_in_enforced_skills(self):
109
- problems = []
110
- for skill in ENFORCED_SKILLS:
111
- for d in SKILL_DIRS:
112
- path = REPO_ROOT / d / skill / "SKILL.md"
113
- if not path.exists():
114
- continue
115
- for ln, src in find_offenders(path.read_text(encoding="utf-8")):
116
- problems.append(f"{d}/{skill}/SKILL.md:{ln}: {src}")
117
- self.assertEqual(
118
- problems, [],
119
- "Bare board-mutating gh in enforced skill prose — route through "
120
- "scripts/board-sync.py:\n" + "\n".join(problems))
121
-
122
-
123
- if __name__ == "__main__":
124
- unittest.main(verbosity=2)
@@ -1,254 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Language census lint (Welle 50 / Slice 6 / #1897).
3
-
4
- Mechanical proof that "the published skill prose is English" is COUNTED,
5
- not remembered (HR23 "Vollständigkeit ist gezählt, nicht erinnert" — the same
6
- principle behind `impact-census`, applied here to language instead of
7
- blast-radius).
8
-
9
- Denominator (Y): every skill with `publish: true` in the manifest
10
- (`.claude/skills/skill-manifest.json`) — the same SSOT the other skill lints
11
- use (see `test_skill_selfcontainment_lint.py`). Each skill's SKILL.md is read
12
- from whichever surface actually hosts it — `.claude/skills` if `"claude"` is
13
- in its `surfaces`, else `.agents/skills` (the sole surface for a Codex-only
14
- adapter such as `codex-adapter-sync`).
15
-
16
- Heuristic: a wordlist of common German function words, scanned whole-word
17
- case-insensitive against the BODY only (frontmatter — the first `---`...`---`
18
- block — is skipped; a skill's `description:` may legitimately quote German
19
- trigger phrases the user actually types, e.g. board-to-waves).
20
-
21
- False-positive guard: two short words in the list collide with real English
22
- tokens — `mit` collides with the "MIT" license acronym (always uppercase in
23
- this repo), and `die` collides with the English verb ("logs die"). Both are
24
- treated as WEAK signals: a line is only flagged if it carries a STRONG hit
25
- (a German word with no plausible English reading) or *multiple* WEAK hits
26
- (a coincidence of two independent English collisions on one line is not
27
- realistic). A single stray `mit`/`die` hit alone is not a violation.
28
-
29
- Allowlist (each entry is why it's exempt, not prose to translate):
30
-
31
- 1. **Bilingual PRD example blocks** (`to-prd`) — the template deliberately
32
- pairs a `lang="de"` user-story example with a `lang="en"` one so a spec
33
- author can pick their spec's language; lines inside a
34
- `<user-story-example lang="de">...</user-story-example>` block are
35
- exempt.
36
- 2. **Cross-skill contract literals** kept German on purpose because another
37
- skill or the board-sync tooling consumes the exact string:
38
- - `## Vor Bau zu klären` — the `headings.vorBau` profile VALUE
39
- (`docs/agents/board-sync.md`); `to-issues`/`grill-with-docs(-codex)`/
40
- `wrapup` all reference this literal heading text.
41
- - (removed #1947: `In Arbeit`/`Triaged` were exempt as board Status option
42
- values — shipped skills now speak role language + `--status-role`, the
43
- vocabulary lives only in the profile's `fields.status.roles`, so the
44
- exemption is dead and stays removed.)
45
- - `Welle <N>` — the wave-anchor issue-title format `board-sync.py` parses.
46
- - `Trigger` / `Check` / `Korrektur` — the `## Self-Critique-Check` block
47
- format (`Trigger / Check / Korrektur`) that `setup-workflow`'s seed and
48
- `spec-self-critique` both parse verbatim; matched via a dedicated
49
- 'Trigger/Check/Korrektur' regex, not the generic wordlist (none of
50
- those three words are in the wordlist itself).
51
- A line carrying ONLY these literals (no other wordlist hit) is exempt.
52
- 3. **`skill-surface-lint`'s `CLAUDE_ONLY_MARKER`** — `test_skill_surface_refs.py`
53
- greps prose for a literal marker string to suppress a Claude-only-skill
54
- cross-surface reference. It shipped as the German phrase "nur Claude Code"
55
- (Welle 26) and is now the English phrase "Claude Code only" (translated
56
- alongside `ask-matt`, the only skill that carries it, in this slice) — so
57
- this allowlist class exists for documentation/audit completeness but has
58
- no live match after the translation.
59
-
60
- If the heuristic (after the allowlist) still flags a real hit in a
61
- publish:true body, that is a residual finding: translate it, or — if it's a
62
- genuine additional intentional literal — add it to `LINE_ALLOWLIST_LITERALS`
63
- with a one-line reason.
64
-
65
- Run: python3 -m unittest scripts.test_skill_language_census
66
- (or, from scripts/: python3 test_skill_language_census.py)
67
- """
68
- import json
69
- import re
70
- import unittest
71
- from pathlib import Path
72
-
73
- REPO_ROOT = Path(__file__).resolve().parent.parent
74
- MANIFEST = REPO_ROOT / ".claude/skills/skill-manifest.json"
75
-
76
- # STRONG: German function words with no realistic English reading.
77
- STRONG_WORDS = [
78
- "der", "das", "und", "nicht", "wird", "werden", "muss", "kein", "oder",
79
- "für", "über", "eine", "einen", "auch", "nur", "schon",
80
- ]
81
- # WEAK: collide with real English tokens (MIT license acronym; "die" the verb).
82
- WEAK_WORDS = ["mit", "die"]
83
-
84
- STRONG_PAT = re.compile(r"\b(" + "|".join(STRONG_WORDS) + r")\b", re.IGNORECASE)
85
- WEAK_PAT = re.compile(r"\b(" + "|".join(WEAK_WORDS) + r")\b", re.IGNORECASE)
86
-
87
- # Cross-skill contract literals: a line whose ONLY wordlist hit(s) come from
88
- # inside one of these exact literals is exempt (§2 in the module docstring).
89
- CONTRACT_LITERALS = [
90
- "## Vor Bau zu klären",
91
- ]
92
- WAVE_TITLE = re.compile(r"\bWelle\s+\d+\b")
93
-
94
- BILINGUAL_BLOCK_START = re.compile(r'<user-story-example\s+lang="de"\s*>')
95
- BILINGUAL_BLOCK_END = re.compile(r"</user-story-example>")
96
-
97
- LINE_EXEMPT_MARKER = "language-census: ok"
98
-
99
-
100
- def load_manifest() -> dict:
101
- return json.loads(MANIFEST.read_text(encoding="utf-8"))
102
-
103
-
104
- def publish_true_skills() -> dict:
105
- """name -> SKILL.md path, resolved to whichever surface hosts the skill."""
106
- manifest = load_manifest()["skills"]
107
- out = {}
108
- for name, entry in manifest.items():
109
- if not entry.get("publish"):
110
- continue
111
- surfaces = entry.get("surfaces", [])
112
- tree = ".claude/skills" if "claude" in surfaces else ".agents/skills"
113
- out[name] = REPO_ROOT / tree / name / "SKILL.md"
114
- return out
115
-
116
-
117
- def strip_frontmatter(text: str) -> str:
118
- """Drop the leading `---`...`---` frontmatter block; description: may
119
- legitimately quote German trigger phrases (board-to-waves)."""
120
- if not text.startswith("---"):
121
- return text
122
- parts = text.split("---", 2)
123
- return parts[2] if len(parts) >= 3 else text
124
-
125
-
126
- def _strip_contract_literals(line: str) -> str:
127
- """Remove known cross-skill contract literals from a line before wordlist
128
- matching, so a line consisting only of a literal (e.g. a heading
129
- `## Vor Bau zu klären`) doesn't trip the census."""
130
- stripped = line
131
- for literal in CONTRACT_LITERALS:
132
- stripped = stripped.replace(literal, "")
133
- stripped = WAVE_TITLE.sub("", stripped)
134
- return stripped
135
-
136
-
137
- def _line_hits(line: str) -> bool:
138
- """True if this line carries a real (non-allowlisted) German-prose hit."""
139
- if LINE_EXEMPT_MARKER in line:
140
- return False
141
- scan = _strip_contract_literals(line)
142
- strong = STRONG_PAT.search(scan) is not None
143
- weak_count = len(WEAK_PAT.findall(scan))
144
- return strong or weak_count >= 2
145
-
146
-
147
- def find_violations(name: str, path: Path) -> list:
148
- """Return [(lineno, line)] of real German-prose hits in a skill body."""
149
- if not path.is_file():
150
- return [(0, f"MISSING FILE: {path}")]
151
- text = path.read_text(encoding="utf-8")
152
- body = strip_frontmatter(text)
153
-
154
- violations = []
155
- in_bilingual_de_block = False
156
- for i, line in enumerate(body.splitlines(), start=1):
157
- if BILINGUAL_BLOCK_START.search(line):
158
- in_bilingual_de_block = True
159
- if in_bilingual_de_block:
160
- if BILINGUAL_BLOCK_END.search(line):
161
- in_bilingual_de_block = False
162
- continue # allowlist §1: deliberate bilingual example block
163
- if _line_hits(line):
164
- violations.append((i, line.strip()))
165
- return violations
166
-
167
-
168
- class LanguageCensus(unittest.TestCase):
169
- def test_all_publish_true_skills_are_english(self):
170
- skills = publish_true_skills()
171
- self.assertGreater(len(skills), 0, "no publish:true skills found — manifest read failed?")
172
-
173
- all_violations = {}
174
- for name, path in sorted(skills.items()):
175
- v = find_violations(name, path)
176
- if v:
177
- all_violations[name] = v
178
-
179
- total = len(skills)
180
- clean = total - len(all_violations)
181
- msg_lines = [f"Census: {clean} of {total} publish:true skills English."]
182
- for name, v in all_violations.items():
183
- msg_lines.append(f"\n{name}:")
184
- for lineno, line in v[:10]:
185
- msg_lines.append(f" {lineno}: {line[:160]}")
186
- self.assertEqual(
187
- all_violations, {},
188
- "\n".join(msg_lines) +
189
- "\n\nTranslate the residual German prose, or — if it's a genuine "
190
- "intentional literal — add it to the allowlist (CONTRACT_LITERALS / "
191
- "a bilingual-block marker) with a one-line reason, or mark the "
192
- "single line with `language-census: ok`.",
193
- )
194
-
195
-
196
- class HeuristicFixtures(unittest.TestCase):
197
- """The heuristic + allowlist mechanics, exercised in isolation."""
198
-
199
- def test_strong_word_is_flagged(self):
200
- self.assertTrue(_line_hits("Diese Änderung ist querschnittig und betrifft alles."))
201
-
202
- def test_single_weak_word_alone_is_not_flagged(self):
203
- # "MIT" license acronym collides with weak-word "mit"
204
- self.assertFalse(_line_hits("Adopted from Matt Pocock's skill (MIT license)."))
205
- # English verb "die" collides with weak-word "die"
206
- self.assertFalse(_line_hits("Untagged logs survive; tagged logs die."))
207
-
208
- def test_two_weak_words_together_are_flagged(self):
209
- self.assertTrue(_line_hits("mit der Kampagne verglichen"))
210
-
211
- def test_contract_literal_heading_alone_is_not_flagged(self):
212
- self.assertFalse(_line_hits("## Vor Bau zu klären"))
213
-
214
- def test_status_option_names_are_no_longer_exempt(self):
215
- # #1947: `In Arbeit` lost its contract-literal exemption — shipped
216
- # skills speak role language, so German option names in prose are a
217
- # finding again ("Arbeit" itself is not in the wordlist; the article
218
- # trips it, exactly like any other German prose).
219
- self.assertTrue(_line_hits("verschiebe das Item auf `In Arbeit`"))
220
- # An option name that carries no wordlist token stays invisible to the
221
- # heuristic — the census is a German-prose net, not a name detector.
222
- self.assertFalse(_line_hits("status `Triaged`"))
223
-
224
- def test_wave_title_alone_is_not_flagged(self):
225
- self.assertFalse(_line_hits("title **without** a `Welle 42` prefix"))
226
-
227
- def test_contract_literal_plus_real_prose_is_still_flagged(self):
228
- self.assertTrue(_line_hits("## Vor Bau zu klären und weitere Fragen für den Nutzer"))
229
-
230
- def test_inline_exempt_marker_suppresses_the_line(self):
231
- self.assertFalse(_line_hits("Dies ist deutsch und bleibt es. <!-- language-census: ok -->"))
232
-
233
- def test_frontmatter_is_stripped(self):
234
- text = (
235
- "---\n"
236
- "name: x\n"
237
- 'description: Use when "lass uns das Board durchgehen".\n'
238
- "---\n\n"
239
- "Body is English only.\n"
240
- )
241
- body = strip_frontmatter(text)
242
- self.assertNotIn("lass uns das Board durchgehen", body)
243
-
244
- def test_bilingual_example_block_is_exempt(self):
245
- path = REPO_ROOT / ".claude/skills/to-prd/SKILL.md"
246
- if not path.is_file():
247
- self.skipTest("to-prd SKILL.md not present")
248
- violations = find_violations("to-prd", path)
249
- flagged_lines = "\n".join(line for _, line in violations)
250
- self.assertNotIn("Als QA-Lead", flagged_lines)
251
-
252
-
253
- if __name__ == "__main__":
254
- unittest.main(verbosity=2)