@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.
- package/.agents/skills/grill-me/SKILL.md +3 -1
- package/.agents/skills/grill-with-docs/SKILL.md +3 -1
- package/.agents/skills/kit-update/SKILL.md +9 -11
- package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
- package/.agents/skills/setup-workflow/SKILL.md +20 -18
- package/.agents/skills/setup-workflow/board-sync.md +4 -1
- package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.agents/skills/to-issues/SKILL.md +43 -0
- package/.agents/skills/wrapup/SKILL.md +98 -54
- package/.claude/skills/grill-me/SKILL.md +3 -1
- package/.claude/skills/grill-me-codex/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
- package/.claude/skills/kit-update/SKILL.md +9 -11
- package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
- package/.claude/skills/setup-workflow/SKILL.md +20 -18
- package/.claude/skills/setup-workflow/board-sync.md +4 -1
- package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.claude/skills/to-issues/SKILL.md +43 -0
- package/.claude/skills/wrapup/SKILL.md +98 -54
- package/README.md +249 -7
- package/agent-workflow-kit.package.json +42 -42
- package/package.json +16 -12
- package/scripts/marker_lib.py +3 -0
- package/scripts/profile_globs.py +26 -45
- package/scripts/release-delta-guard.mjs +26 -9
- package/scripts/worktree-lifecycle/README.md +57 -160
- package/scripts/worktree-lifecycle/capabilities.json +2 -3
- package/scripts/worktree-lifecycle/classify.py +480 -0
- package/scripts/worktree-lifecycle/cleanup.py +9 -29
- package/scripts/worktree-lifecycle/core.py +68 -1429
- package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
- package/scripts/worktree-lifecycle/profile.py +46 -29
- package/scripts/worktree-lifecycle/setup.py +7 -19
- package/scripts/wrapup-land.py +1292 -545
- package/src/consumer-migrations.json +1 -16
- package/src/lib/bundle.mjs +31 -1
- package/docs/adr/0001-consumer-divergence-policy.md +0 -53
- package/docs/adr/0002-capability-gated-orchestration.md +0 -70
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
- package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
- package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
- package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
- package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
- package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
- package/docs/agents/board-sync.md +0 -74
- package/docs/agents/code-review.md +0 -14
- package/docs/agents/workflow-capabilities.json +0 -28
- package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
- package/docs/research/benchlm-routing-source.md +0 -198
- package/docs/research/consumer-owned-protocol-files.md +0 -238
- package/docs/research/frontend-agent-benchmarks.md +0 -282
- package/docs/research/model-effort-routing-benchmarks.md +0 -261
- package/docs/research/provider-neutral-agent-routing.md +0 -207
- package/docs/research/wave-152-consumer-acceptance.md +0 -98
- package/docs/research/wave-43-script-hook-census.md +0 -128
- package/scripts/build-kit.mjs +0 -117
- package/scripts/build-kit.test.mjs +0 -207
- package/scripts/census/census.test.mjs +0 -252
- package/scripts/census/state.test.mjs +0 -15
- package/scripts/census/transaction.test.mjs +0 -103
- package/scripts/census-contract.test.mjs +0 -227
- package/scripts/check-kit-staleness.mjs +0 -45
- package/scripts/check-kit-staleness.test.mjs +0 -19
- package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
- package/scripts/codex-exec.test.mjs +0 -871
- package/scripts/grill-census-wiring-guard.mjs +0 -87
- package/scripts/grill-census-wiring-guard.test.mjs +0 -107
- package/scripts/kit-release.test.mjs +0 -273
- package/scripts/kit-update-pr.test.mjs +0 -151
- package/scripts/lib/audit-refs.mjs +0 -146
- package/scripts/lib/scrub.mjs +0 -113
- package/scripts/lib/scrub.test.mjs +0 -187
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
- package/scripts/portability_profile_scan.py +0 -53
- package/scripts/release-delta-guard.test.mjs +0 -174
- package/scripts/release-parity.test.mjs +0 -90
- package/scripts/release-state.test.mjs +0 -360
- package/scripts/test_anchor_table.py +0 -69
- package/scripts/test_board_bootstrap.py +0 -348
- package/scripts/test_board_sync.py +0 -208
- package/scripts/test_board_sync_create_idempotency.py +0 -117
- package/scripts/test_board_sync_wave_title.py +0 -120
- package/scripts/test_census_backstop.py +0 -712
- package/scripts/test_census_forward_contract.py +0 -91
- package/scripts/test_census_update_contract.test.mjs +0 -454
- package/scripts/test_codex_adapter_sync_contract.py +0 -169
- package/scripts/test_dist_kit_smoke.py +0 -220
- package/scripts/test_drift_guard_diagnostics.py +0 -295
- package/scripts/test_issue_claim_contract.py +0 -169
- package/scripts/test_kit_docs_language_census.py +0 -178
- package/scripts/test_marker_lib.py +0 -162
- package/scripts/test_orchestrate_wave_contract.py +0 -381
- package/scripts/test_pr_body_check.py +0 -264
- package/scripts/test_profile_globs.py +0 -280
- package/scripts/test_program_planning_contract.py +0 -133
- package/scripts/test_release_authorization_contract.py +0 -101
- package/scripts/test_render_anchor.py +0 -267
- package/scripts/test_retro_wrapup_contract.py +0 -153
- package/scripts/test_skill_code_review_seed.py +0 -89
- package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
- package/scripts/test_skill_frontmatter_lint.py +0 -234
- package/scripts/test_skill_gh_lint.py +0 -124
- package/scripts/test_skill_language_census.py +0 -254
- package/scripts/test_skill_optional_readiness.py +0 -171
- package/scripts/test_skill_portability_lint.py +0 -866
- package/scripts/test_skill_precommit_template.py +0 -30
- package/scripts/test_skill_publish_audit.py +0 -358
- package/scripts/test_skill_readiness_contract.py +0 -264
- package/scripts/test_skill_readiness_preflight.py +0 -180
- package/scripts/test_skill_required_readiness.py +0 -233
- package/scripts/test_skill_selfcontainment_lint.py +0 -255
- package/scripts/test_skill_setup_workflow_seeds.py +0 -691
- package/scripts/test_skill_stale_name_lint.py +0 -126
- package/scripts/test_skill_surface_refs.py +0 -300
- package/scripts/test_skill_trailing_artifact_lint.py +0 -71
- package/scripts/test_tdd_contract.py +0 -78
- package/scripts/test_worktree_ignore_seed.py +0 -320
- package/scripts/test_worktree_setup_base_guard.py +0 -140
- package/scripts/test_worktree_wrapup_contract.py +0 -1737
- package/scripts/test_wrapup_land.py +0 -428
- 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)
|