@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,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)