@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,866 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Portability lint v2 (Welle 26 / Slice 2 follow-up / #1019): generic & vendored
3
- skills must carry NO hardcoded, project-coupled board CONSTANTS.
4
-
5
- Why: a published skill (npx/git-clone into a foreign repo, or a plugin cache)
6
- must read every board-specific value from the project profile
7
- (`docs/agents/board-sync.md`, parsed by `scripts/board_config.py`,
8
- `/setup-workflow`-seeded per consumer), NEVER an inline constant. An opaque
9
- board/project/field node id or a status/wave/cluster option hash means NOTHING
10
- in another repo — and these specific tokens slip past BOTH existing nets: the
11
- publish scrub (`scripts/lib/scrub.mjs`) does not touch them, and the publish
12
- audit only denies 40+-char hex, so an 8-hex option id would ship silently. This
13
- lint closes that gap AT THE SOURCE, in both the Claude tree (`.claude/skills`)
14
- and the Codex mirror (`.agents/skills`).
15
-
16
- Scope: skills whose manifest class is `generic` or `vendored`. Owner/repo/domain
17
- tokens are deliberately NOT re-detected here — they are already double-covered
18
- (scrub + publish audit) and survive at source only inside runnable example
19
- commands. Generic label vocab (`type:cluster`, `ready-for-agent`, `wave-stub`)
20
- is a shipped convention, not a leak, and is out of scope.
21
-
22
- A line carrying `portability-lint: ok` is exempt — line-scoped, for deliberate
23
- doc counterexamples.
24
-
25
- This file also asserts mirror parity:
26
- - every dual-surface skill has the same set of distributed files in both trees
27
- (catches a forgotten mirror support file of any type);
28
- - generic/vendored dual-surface skill bodies match after stripping paired
29
- `mirror-xform` regions. The paired marker convention lets codex-adapter-sync
30
- keep legitimate Codex body translations local instead of maintaining a central
31
- model-mapping table.
32
-
33
- Run: python3 scripts/test_skill_portability_lint.py
34
- """
35
- import importlib.util
36
- import json
37
- import re
38
- import unittest
39
- from pathlib import Path
40
-
41
- REPO_ROOT = Path(__file__).resolve().parent.parent
42
- MANIFEST = REPO_ROOT / ".claude/skills/skill-manifest.json"
43
-
44
- # Profile-VALUE literal scan (#1878, Welle 49 Slice 7) — extracted to its own
45
- # module (see docstring there) rather than growing this already-large file;
46
- # loaded via spec_from_file_location (test_board_config.py convention) so it
47
- # resolves regardless of how this test file itself is invoked.
48
- _PVL_SPEC = importlib.util.spec_from_file_location(
49
- "portability_profile_scan", Path(__file__).parent / "portability_profile_scan.py")
50
- pvl = importlib.util.module_from_spec(_PVL_SPEC)
51
- _PVL_SPEC.loader.exec_module(pvl)
52
-
53
- SKILL_DIRS = [".claude/skills", ".agents/skills"]
54
- ENFORCED_CLASSES = {"generic", "vendored"}
55
- EXEMPT = "portability-lint: ok"
56
- MIRROR_XFORM_START = re.compile(r"^\s*<!--\s*mirror-xform:start(?:\s+([^>]*?))?\s*-->\s*$")
57
- MIRROR_XFORM_END = re.compile(r"^\s*<!--\s*mirror-xform:end\s*-->\s*$")
58
- DIRECT_SPAWN_PATTERN = re.compile(
59
- r"Run \*\*one read-only research subagent|"
60
- r"Run both axes as \*\*parallel sub-agents|"
61
- r"Spawn 3\+ sub-agents|"
62
- r"spawn 3\+ sub-agents|"
63
- r"make 3\+ parallel\s+`spawn_agent` calls|"
64
- r"Then use the Agent tool|"
65
- r"use `spawn_agent` with `agent_type: explorer`|"
66
- r"use `spawn_agent` with only|"
67
- r"Spin up a \*\*background agent",
68
- )
69
-
70
- # Opaque, project-coupled board constants — meaningless in a foreign repo.
71
- CONSTANT_PATTERNS = [
72
- # GitHub Projects v2 node/field ids: PVT_… (project), PVTSSF_… (single-select
73
- # field), PVTF_… (field). e.g. PVT_kwHOAuH31M4BVtcf, PVTSSF_lAHO…, PVTF_lAHO…
74
- ("board/project/field node id", re.compile(r"PVT(?:SSF|F)?_[A-Za-z0-9]+")),
75
- # status/wave/cluster single-select OPTION ids: bare 8-hex (e.g. 1db44002).
76
- # Hex colors are 6, full SHAs 40 (no \b…\b match inside a longer run), so this
77
- # is tight. A legitimate 8-hex (rare) gets the line-scoped exempt marker.
78
- ("status/option id hash", re.compile(r"\b[0-9a-f]{8}\b")),
79
- ]
80
-
81
-
82
- def find_constants(text: str) -> list[tuple[int, str, str]]:
83
- """Return (line_no, label, line) for every project-coupled constant found,
84
- minus lines carrying the exempt marker."""
85
- out = []
86
- for n, line in enumerate(text.splitlines(), 1):
87
- if EXEMPT in line:
88
- continue
89
- for label, pat in CONSTANT_PATTERNS:
90
- if pat.search(line):
91
- out.append((n, label, line.strip()))
92
- return out
93
-
94
-
95
- # board-sync.py invocations in shipped skill prose must use `--status-role
96
- # <role>` (resolved via the profile's fields.status.roles), never a literal
97
- # option NAME — a name is board-private vocabulary and breaks on any board
98
- # that names its stages differently (#1947, ADR-0057). `--status <name>`
99
- # stays legal in scripts/docs for free-name use; in SHIPPED skill bodies it
100
- # is a leak. Line-scoped exempt marker for deliberate free-name examples.
101
- STATUS_LITERAL_CMD = re.compile(r"board-sync\.py.*--status(?!-role)\s+\S")
102
-
103
-
104
- def find_status_literal_commands(text: str) -> list[tuple[int, str]]:
105
- """(line_no, line) for every shipped `board-sync.py ... --status <literal>`
106
- command, minus exempt-marked lines. Shell continuations (trailing `\\`)
107
- are joined first — skills wrap long board-sync.py calls over several
108
- lines, and a `--status Spec` on a continuation line is the same leak."""
109
- joined: list[tuple[int, str]] = []
110
- for n, line in enumerate(text.splitlines(), 1):
111
- if joined and joined[-1][1].rstrip().endswith("\\"):
112
- first_n, prev = joined[-1]
113
- joined[-1] = (first_n, prev.rstrip().rstrip("\\") + " " + line.strip())
114
- else:
115
- joined.append((n, line))
116
- out = []
117
- for n, line in joined:
118
- if EXEMPT in line:
119
- continue
120
- if STATUS_LITERAL_CMD.search(line):
121
- out.append((n, line.strip()))
122
- return out
123
-
124
-
125
- def load_manifest() -> dict:
126
- return json.loads(MANIFEST.read_text(encoding="utf-8"))
127
-
128
-
129
- def markdown_body(text: str) -> str:
130
- lines = text.splitlines()
131
- if lines and lines[0] == "---":
132
- for i, line in enumerate(lines[1:], 1):
133
- if line == "---":
134
- return "\n".join(lines[i + 1:]).strip()
135
- return text.strip()
136
-
137
-
138
- def strip_mirror_xform_regions(text: str) -> str:
139
- out = []
140
- in_region = False
141
- for line in text.splitlines():
142
- if MIRROR_XFORM_START.match(line):
143
- if in_region:
144
- raise ValueError("nested mirror-xform region")
145
- in_region = True
146
- continue
147
- if MIRROR_XFORM_END.match(line):
148
- if not in_region:
149
- raise ValueError("mirror-xform end without start")
150
- in_region = False
151
- continue
152
- if not in_region:
153
- out.append(line.rstrip())
154
- if in_region:
155
- raise ValueError("mirror-xform start without end")
156
- return "\n".join(out).strip()
157
-
158
-
159
- def normalized_mirror_body(text: str) -> str:
160
- return strip_mirror_xform_regions(markdown_body(text))
161
-
162
-
163
- def mirror_xform_sequence(text: str) -> list[str]:
164
- seq = []
165
- in_region = False
166
- for line in markdown_body(text).splitlines():
167
- start = MIRROR_XFORM_START.match(line)
168
- if start:
169
- if in_region:
170
- raise ValueError("nested mirror-xform region")
171
- seq.append((start.group(1) or "").strip())
172
- in_region = True
173
- continue
174
- if MIRROR_XFORM_END.match(line):
175
- if not in_region:
176
- raise ValueError("mirror-xform end without start")
177
- in_region = False
178
- if in_region:
179
- raise ValueError("mirror-xform start without end")
180
- return seq
181
-
182
-
183
- def mirror_content_drift(name: str, rel: str, claude_text: str, codex_text: str) -> list[str]:
184
- try:
185
- claude_seq = mirror_xform_sequence(claude_text)
186
- codex_seq = mirror_xform_sequence(codex_text)
187
- if claude_seq != codex_seq:
188
- return [f"{name}/{rel}: mirror-xform marker mismatch"]
189
- claude = normalized_mirror_body(claude_text)
190
- codex = normalized_mirror_body(codex_text)
191
- except ValueError as exc:
192
- return [f"{name}/{rel}: invalid mirror-xform marker: {exc}"]
193
- if claude == codex:
194
- return []
195
- return [f"{name}/{rel}: unmarked mirror content drift"]
196
-
197
-
198
- HEADING_RE = re.compile(r"^#{1,6} .*$", re.MULTILINE)
199
-
200
-
201
- def heading_sequence(text: str) -> list[str]:
202
- """Ordered list of markdown section-heading lines (any level), after
203
- stripping frontmatter and paired mirror-xform regions."""
204
- return HEADING_RE.findall(normalized_mirror_body(text))
205
-
206
-
207
- def structure_drift(name: str, rel: str, claude_text: str, codex_text: str) -> list[str]:
208
- """Section-heading-only parity check (Welle 49 / #1874): lighter than
209
- mirror_content_drift — a project-private dual-surface skill is allowed to
210
- have Codex-specific body wording, but its section STRUCTURE (the ordered
211
- heading list) must still match, or an unmirrored section silently exists
212
- on only one surface (#1593: api-contracts carried a 21-line
213
- DB-column-rename section on the Claude side only)."""
214
- try:
215
- claude_seq = mirror_xform_sequence(claude_text)
216
- codex_seq = mirror_xform_sequence(codex_text)
217
- if claude_seq != codex_seq:
218
- return [f"{name}/{rel}: mirror-xform marker mismatch"]
219
- claude_headings = heading_sequence(claude_text)
220
- codex_headings = heading_sequence(codex_text)
221
- except ValueError as exc:
222
- return [f"{name}/{rel}: invalid mirror-xform marker: {exc}"]
223
- if claude_headings == codex_headings:
224
- return []
225
- return [f"{name}/{rel}: structure drift — section headings differ "
226
- f"(claude={claude_headings!r} codex={codex_headings!r})"]
227
-
228
-
229
- def enforced_skills() -> set[str]:
230
- return {n for n, e in load_manifest()["skills"].items()
231
- if e["class"] in ENFORCED_CLASSES}
232
-
233
-
234
- def distributed_file_set(skill_dir: Path) -> set[str]:
235
- """Relative paths of every distributed file under one skill directory."""
236
- if not skill_dir.is_dir():
237
- return set()
238
- return {
239
- p.relative_to(skill_dir).as_posix()
240
- for p in skill_dir.rglob("*")
241
- if p.is_file()
242
- }
243
-
244
-
245
- def skill_file_set(tree: str, name: str) -> set[str]:
246
- return distributed_file_set(REPO_ROOT / tree / name)
247
-
248
-
249
- def skill_md_set(tree: str, name: str) -> set[str]:
250
- """Markdown subset used by the body and structure parity checks."""
251
- return {p for p in skill_file_set(tree, name) if p.endswith(".md")}
252
-
253
-
254
- def direct_spawn_contracts(tree: str) -> dict[str, str]:
255
- root = REPO_ROOT / tree
256
- return {
257
- path.relative_to(root).as_posix(): path.read_text(encoding="utf-8")
258
- for path in root.rglob("*.md")
259
- if DIRECT_SPAWN_PATTERN.search(path.read_text(encoding="utf-8"))
260
- }
261
-
262
-
263
- def mirror_presence_drift(name: str, claude: set[str], codex: set[str]) -> list[str]:
264
- problems = [
265
- f"{name}: .claude/skills/{name}/{rel} has no codex mirror"
266
- for rel in sorted(claude - codex)
267
- ]
268
- problems.extend(
269
- f"{name}: .agents/skills/{name}/{rel} has no claude source"
270
- for rel in sorted(codex - claude)
271
- )
272
- return problems
273
-
274
-
275
- def dual_surface_skills() -> list[str]:
276
- """Skills the manifest hosts on BOTH the Claude and Codex surfaces."""
277
- return [n for n, e in load_manifest()["skills"].items()
278
- if {"claude", "codex"} <= set(e.get("surfaces", []))]
279
-
280
-
281
- def enforced_dual_surface_skills() -> list[str]:
282
- """Portable dual-surface skills whose mirrors must stay content-synced."""
283
- enforced = enforced_skills()
284
- return [n for n in dual_surface_skills() if n in enforced]
285
-
286
-
287
- def project_private_dual_surface_skills() -> list[str]:
288
- """project-private dual-surface skills — outside ENFORCED_CLASSES, so
289
- MirrorContentParity never checks them and a section can drift onto only
290
- one surface unnoticed (#1874). Structure-parity (below) covers them."""
291
- manifest = load_manifest()["skills"]
292
- return [n for n in dual_surface_skills() if manifest[n]["class"] == "project-private"]
293
-
294
-
295
- # project-private dual-surface skills whose Codex mirror is a documented FULL
296
- # adaptation — not a section-local mirror-xform transform — and are therefore
297
- # exempt from structure-parity entirely (MirrorPresenceParity's file-set check
298
- # still applies). Each entry needs a one-line reason; keep in lockstep with
299
- # the skill-manifest note that documents the divergence.
300
- STRUCTURE_PARITY_FULL_ADAPTATION_EXEMPT = {
301
- # NOTE: orchestrate-wave was here while project-private with a full Codex
302
- # adaptation. It is now generic/published (#1958) → it sits inside
303
- # ENFORCED_CLASSES, so MirrorContentParity enforces full body parity and the
304
- # Codex mirror is a codex-adapter-sync content-identical copy (differences
305
- # only inside paired mirror-xform regions). No structure-parity exemption.
306
- # manifest note: "local third-party frontend design skill installed on
307
- # Claude, Codex, and GitHub skill surfaces" — the Codex mirror carries its
308
- # own extensive Codex-only sections (sub-agent gate, Run Notes, AGENTS.md
309
- # vs CLAUDE.md, `$command` vs `/command`) installed independently of
310
- # codex-adapter-sync, not a local transform (#1874 census).
311
- "impeccable",
312
- }
313
-
314
-
315
- class Detector(unittest.TestCase):
316
- """The constant detector itself behaves."""
317
-
318
- def test_flags_project_node_id(self):
319
- hits = find_constants("nodeId PVT_kwHOAuH31M4BVtcf")
320
- self.assertEqual(len(hits), 1)
321
- self.assertEqual(hits[0][1], "board/project/field node id")
322
-
323
- def test_flags_single_select_field_id(self):
324
- self.assertEqual(len(find_constants("status PVTSSF_lAHOAuH31M4BVtcfzhRHT")), 1)
325
-
326
- def test_flags_option_hash(self):
327
- hits = find_constants("Triaged: 1db44002")
328
- self.assertEqual(len(hits), 1)
329
- self.assertEqual(hits[0][1], "status/option id hash")
330
-
331
- def test_ignores_hex_color(self):
332
- self.assertEqual(find_constants("color #0f172a and #dc2626"), [])
333
-
334
- def test_ignores_clean_convention_path(self):
335
- self.assertEqual(find_constants("read `docs/agents/board-sync.md`"), [])
336
-
337
- def test_exempt_marker_line_scoped(self):
338
- self.assertEqual(
339
- find_constants("bad PVT_kwHOAuH31M4BVtcf <!-- portability-lint: ok -->"), [])
340
-
341
- def test_exempt_is_line_scoped_not_file_wide(self):
342
- text = "ok PVT_a1b2c3 <!-- portability-lint: ok -->\nleak 1db44002"
343
- self.assertEqual(len(find_constants(text)), 1)
344
-
345
-
346
- class ProfileValueLiteralDetector(unittest.TestCase):
347
- """The profile-VALUE literal detector (portability_profile_scan.py, #1878)
348
- behaves — a published skill must reference `prMarkers.retroValues` /
349
- `headings.vorBau` by KEY, never mandate the configured VALUE."""
350
-
351
- def test_flags_mandated_retro_value(self):
352
- hits = pvl.find_profile_value_literals(
353
- "add the line `**Retro:** `gefahren` — Findings unter ## Retro")
354
- self.assertEqual(len(hits), 1)
355
- self.assertEqual(hits[0][1], "prMarkers.retroValues literal")
356
-
357
- def test_flags_mandated_vorbau_heading(self):
358
- hits = pvl.find_profile_value_literals(
359
- "set the leaf to HITL, add `## Vor Bau zu klären`")
360
- self.assertEqual(len(hits), 1)
361
- self.assertEqual(hits[0][1], "headings.vorBau literal")
362
-
363
- def test_ignores_key_referenced_retro_value(self):
364
- text = ("one of the `prMarkers.retroValues` words (testreporter aktuell "
365
- "`gefahren`/`übersprungen`)")
366
- self.assertEqual(pvl.find_profile_value_literals(text), [])
367
-
368
- def test_ignores_key_referenced_vorbau_heading(self):
369
- text = "the `headings.vorBau` heading (testreporter aktuell `Vor Bau zu klären`)"
370
- self.assertEqual(pvl.find_profile_value_literals(text), [])
371
-
372
- def test_ignores_plain_german_verb_gefahren(self):
373
- """'gefahren' unquoted is the ordinary German past participle of
374
- 'fahren' ('Retro schon gefahren?') — not the retroValues literal."""
375
- self.assertEqual(
376
- pvl.find_profile_value_literals("Retro schon gefahren? (a) ja / weiter"), [])
377
-
378
- def test_exempt_marker_line_scoped(self):
379
- text = "add `## Vor Bau zu klären` <!-- portability-lint: ok -->"
380
- self.assertEqual(pvl.find_profile_value_literals(text), [])
381
-
382
-
383
- class StatusLiteralCommandDetector(unittest.TestCase):
384
- """The `--status <literal>` command detector (#1947) behaves."""
385
-
386
- def test_flags_status_literal_command(self):
387
- hits = find_status_literal_commands(
388
- "python3 scripts/board-sync.py add --issue 5 --status Spec")
389
- self.assertEqual(len(hits), 1)
390
-
391
- def test_flags_quoted_literal(self):
392
- self.assertEqual(len(find_status_literal_commands(
393
- "board-sync.py add --issue <n> --status 'In Arbeit'")), 1)
394
-
395
- def test_ignores_status_role_form(self):
396
- self.assertEqual(find_status_literal_commands(
397
- "python3 scripts/board-sync.py add --issue 5 --status-role spec"), [])
398
-
399
- def test_ignores_status_mention_without_command(self):
400
- # prose about the Status field, no board-sync.py invocation
401
- self.assertEqual(find_status_literal_commands(
402
- "the board Status field moves to the in-progress role"), [])
403
-
404
- def test_exempt_marker_line_scoped(self):
405
- self.assertEqual(find_status_literal_commands(
406
- "board-sync.py add --status MyName <!-- portability-lint: ok -->"), [])
407
-
408
- def test_flags_literal_on_shell_continuation_line(self):
409
- text = ('python3 scripts/board-sync.py create --title "t" \\\n'
410
- " --status Triaged\n")
411
- hits = find_status_literal_commands(text)
412
- self.assertEqual(len(hits), 1)
413
- self.assertEqual(hits[0][0], 1)
414
-
415
- def test_status_role_on_continuation_is_clean(self):
416
- text = ('python3 scripts/board-sync.py create --title "t" \\\n'
417
- " --status-role triaged\n")
418
- self.assertEqual(find_status_literal_commands(text), [])
419
-
420
-
421
- class EnforcedSkillsPortable(unittest.TestCase):
422
- """generic & vendored skills carry no opaque board constant, all .md, both trees."""
423
-
424
- def test_no_status_literal_commands_in_shipped_skills(self):
425
- enforced = enforced_skills()
426
- problems = []
427
- for tree in SKILL_DIRS:
428
- for name in sorted(enforced):
429
- d = REPO_ROOT / tree / name
430
- if not d.is_dir():
431
- continue
432
- for md in sorted(d.rglob("*.md")):
433
- for ln, src in find_status_literal_commands(
434
- md.read_text(encoding="utf-8")):
435
- problems.append(f"{md.relative_to(REPO_ROOT)}:{ln}: {src}")
436
- self.assertEqual(
437
- problems, [],
438
- "shipped board-sync.py command with a status-NAME literal — use "
439
- "`--status-role <idea|triaged|spec|inProgress|review|done>` (resolved via "
440
- "the profile's fields.status.roles) instead, or exempt a deliberate "
441
- "free-name example with `<!-- portability-lint: ok -->`:\n"
442
- + "\n".join(problems))
443
-
444
- def test_no_hardcoded_constants(self):
445
- enforced = enforced_skills()
446
- problems = []
447
- for tree in SKILL_DIRS:
448
- for name in sorted(enforced):
449
- d = REPO_ROOT / tree / name
450
- if not d.is_dir():
451
- continue
452
- for md in sorted(d.rglob("*.md")):
453
- for ln, label, src in find_constants(md.read_text(encoding="utf-8")):
454
- problems.append(f"{md.relative_to(REPO_ROOT)}:{ln}: {label}: {src}")
455
- self.assertEqual(
456
- problems, [],
457
- "hardcoded board constant in a generic/vendored skill — read it from the "
458
- "project profile (docs/agents/board-sync.md via scripts/board_config.py) "
459
- "instead, or exempt a deliberate doc example with "
460
- "`<!-- portability-lint: ok -->`:\n" + "\n".join(problems))
461
-
462
- def test_no_hardcoded_profile_value_literals(self):
463
- enforced = enforced_skills()
464
- problems = []
465
- for tree in SKILL_DIRS:
466
- for name in sorted(enforced):
467
- d = REPO_ROOT / tree / name
468
- if not d.is_dir():
469
- continue
470
- for md in sorted(d.rglob("*.md")):
471
- for ln, label, src in pvl.find_profile_value_literals(
472
- md.read_text(encoding="utf-8")):
473
- problems.append(f"{md.relative_to(REPO_ROOT)}:{ln}: {label}: {src}")
474
- self.assertEqual(
475
- problems, [],
476
- "hardcoded profile-VALUE literal (prMarkers.retroValues / headings.vorBau) "
477
- "in a generic/vendored skill — reference the profile KEY instead (see "
478
- "docs/agents/board-sync.md), or exempt a deliberate doc example with "
479
- "`<!-- portability-lint: ok -->`:\n" + "\n".join(problems))
480
-
481
-
482
- class NegativeFixture(unittest.TestCase):
483
- """A real constant in a scanned .md is caught end-to-end (regression guard)."""
484
-
485
- def test_planted_node_id_is_flagged(self):
486
- import tempfile
487
- with tempfile.TemporaryDirectory() as d:
488
- f = Path(d) / "SKILL.md"
489
- f.write_text("project PVT_kwHOAuH31M4BVtcf\n", encoding="utf-8")
490
- self.assertEqual(len(find_constants(f.read_text(encoding="utf-8"))), 1)
491
-
492
- def test_exempt_line_in_md_is_not_flagged(self):
493
- import tempfile
494
- with tempfile.TemporaryDirectory() as d:
495
- f = Path(d) / "SKILL.md"
496
- f.write_text("ex PVT_x1y2 <!-- portability-lint: ok -->\n", encoding="utf-8")
497
- self.assertEqual(find_constants(f.read_text(encoding="utf-8")), [])
498
-
499
-
500
- class ProfileValueLiteralNegativeFixture(unittest.TestCase):
501
- """A mandated profile-value literal in a scanned .md is caught end-to-end
502
- (regression guard, #1878) — and a profile-key reference stays green."""
503
-
504
- def test_planted_mandatory_heading_literal_is_flagged(self):
505
- import tempfile
506
- with tempfile.TemporaryDirectory() as d:
507
- f = Path(d) / "SKILL.md"
508
- f.write_text("HITL: add `## Vor Bau zu klären`\n", encoding="utf-8")
509
- self.assertEqual(
510
- len(pvl.find_profile_value_literals(f.read_text(encoding="utf-8"))), 1)
511
-
512
- def test_key_referenced_heading_in_md_is_not_flagged(self):
513
- import tempfile
514
- with tempfile.TemporaryDirectory() as d:
515
- f = Path(d) / "SKILL.md"
516
- f.write_text(
517
- "HITL: add the `headings.vorBau` heading (testreporter aktuell "
518
- "`## Vor Bau zu klären`)\n", encoding="utf-8")
519
- self.assertEqual(pvl.find_profile_value_literals(f.read_text(encoding="utf-8")), [])
520
-
521
- def test_exempt_marker_in_md_is_not_flagged(self):
522
- import tempfile
523
- with tempfile.TemporaryDirectory() as d:
524
- f = Path(d) / "SKILL.md"
525
- f.write_text(
526
- "HITL: add `## Vor Bau zu klären` <!-- portability-lint: ok -->\n",
527
- encoding="utf-8")
528
- self.assertEqual(pvl.find_profile_value_literals(f.read_text(encoding="utf-8")), [])
529
-
530
-
531
- class MirrorPresenceParity(unittest.TestCase):
532
- """LEAN mirror parity: a dual-surface skill has the SAME set of distributed
533
- files in both trees. Presence/file-set only; Markdown content parity is handled
534
- separately because codex-adapter-sync legitimately translates body model-dispatch.
535
- Catches a forgotten / orphaned mirror file (Codex R1#11 first half)."""
536
-
537
- def test_dual_surface_distributed_filesets_match(self):
538
- problems = []
539
- for name in sorted(dual_surface_skills()):
540
- claude = skill_file_set(".claude/skills", name)
541
- codex = skill_file_set(".agents/skills", name)
542
- problems.extend(mirror_presence_drift(name, claude, codex))
543
- self.assertEqual(
544
- problems, [],
545
- "mirror file-set drift — run codex-adapter-sync to add/remove the mirror "
546
- "file:\n" + "\n".join(problems))
547
-
548
-
549
- class MirrorContentParity(unittest.TestCase):
550
- """Dual-surface generic/vendored skills keep body content mirrored.
551
-
552
- Codex-specific rewrites are allowed only inside paired `mirror-xform` regions;
553
- frontmatter stays covered by the Codex skill-frontmatter validation.
554
- """
555
-
556
- def test_dual_surface_markdown_body_content_matches(self):
557
- problems = []
558
- for name in sorted(enforced_dual_surface_skills()):
559
- for rel in sorted(skill_md_set(".claude/skills", name)):
560
- claude = REPO_ROOT / ".claude/skills" / name / rel
561
- codex = REPO_ROOT / ".agents/skills" / name / rel
562
- if not codex.exists():
563
- continue
564
- problems.extend(mirror_content_drift(
565
- name,
566
- rel,
567
- claude.read_text(encoding="utf-8"),
568
- codex.read_text(encoding="utf-8"),
569
- ))
570
- self.assertEqual(
571
- problems,
572
- [],
573
- "unmarked mirror content drift — run codex-adapter-sync to refresh the "
574
- "mirror, or bracket an intentional source/mirror rewrite with paired "
575
- "`<!-- mirror-xform:start <reason> -->` / `<!-- mirror-xform:end -->` "
576
- "regions:\n" + "\n".join(problems))
577
-
578
- def test_every_codex_direct_spawn_contract_uses_the_shared_guard(self):
579
- claude = direct_spawn_contracts(".claude/skills")
580
- codex = direct_spawn_contracts(".agents/skills")
581
- self.assertTrue(claude, "direct-spawn census unexpectedly empty")
582
- self.assertEqual(set(codex), set(claude))
583
-
584
- problems = []
585
- required = (
586
- "provider-neutral Routing intent",
587
- "routeDispatcher.mjs",
588
- "shared spawn guard",
589
- "Dispatch receipt",
590
- )
591
- for relative, body in codex.items():
592
- prose = " ".join(body.split())
593
- missing = [fragment for fragment in required if fragment not in prose]
594
- if missing:
595
- problems.append(f"{relative}: missing {', '.join(missing)}")
596
- unsupported = [
597
- token for token in ("Agent tool", "subagent_type", "agent_type")
598
- if token in prose
599
- ]
600
- if unsupported:
601
- problems.append(
602
- f"{relative}: unsupported Codex spawn syntax "
603
- f"{', '.join(unsupported)}"
604
- )
605
- self.assertEqual(
606
- problems,
607
- [],
608
- f"Codex direct-spawn guard coverage drift ({len(codex)} of "
609
- f"{len(claude)}):\n" + "\n".join(problems),
610
- )
611
-
612
-
613
- class MirrorParityFixture(unittest.TestCase):
614
- """The file-set comparison catches an orphaned mirror file (regression guard)."""
615
-
616
- def test_non_markdown_support_file_is_detected(self):
617
- import tempfile
618
- with tempfile.TemporaryDirectory() as d:
619
- claude = Path(d) / "claude"
620
- codex = Path(d) / "codex"
621
- (claude / "assets").mkdir(parents=True)
622
- codex.mkdir()
623
- (claude / "SKILL.md").write_text("# Example\n", encoding="utf-8")
624
- (codex / "SKILL.md").write_text("# Example\n", encoding="utf-8")
625
- (claude / "assets/example.yml").write_text("enabled: true\n", encoding="utf-8")
626
-
627
- self.assertEqual(
628
- mirror_presence_drift(
629
- "example",
630
- distributed_file_set(claude),
631
- distributed_file_set(codex),
632
- ),
633
- ["example: .claude/skills/example/assets/example.yml has no codex mirror"],
634
- )
635
-
636
- def test_extra_mirror_file_is_detected(self):
637
- claude = {"SKILL.md", "tests.md"}
638
- codex = {"SKILL.md"}
639
- self.assertEqual(claude - codex, {"tests.md"})
640
- self.assertEqual(codex - claude, set())
641
-
642
-
643
- class MirrorContentParityFixture(unittest.TestCase):
644
- """Content parity catches functional source/mirror drift."""
645
-
646
- def test_unmarked_deleted_paragraph_is_detected(self):
647
- claude = """---
648
- name: example
649
- description: A Claude source description.
650
- ---
651
-
652
- # Example
653
-
654
- Keep this paragraph.
655
-
656
- This behavior must stay mirrored.
657
- """
658
- codex = """---
659
- name: example
660
- description: "A Codex-safe quoted description."
661
- ---
662
-
663
- # Example
664
-
665
- Keep this paragraph.
666
- """
667
- self.assertTrue(mirror_content_drift("example", "SKILL.md", claude, codex))
668
-
669
- def test_marked_transform_region_is_ignored(self):
670
- claude = """---
671
- name: example
672
- description: A Claude source description.
673
- ---
674
-
675
- # Example
676
-
677
- Keep this paragraph.
678
-
679
- <!-- mirror-xform:start codex-dispatch -->
680
- Use the Agent tool with model: sonnet.
681
- <!-- mirror-xform:end -->
682
-
683
- Continue here.
684
- """
685
- codex = """---
686
- name: example
687
- description: "A Codex-safe quoted description."
688
- ---
689
-
690
- # Example
691
-
692
- Keep this paragraph.
693
-
694
- <!-- mirror-xform:start codex-dispatch -->
695
- Use spawn_agent with model: gpt-5.4-mini.
696
- <!-- mirror-xform:end -->
697
-
698
- Continue here.
699
- """
700
- self.assertEqual(mirror_content_drift("example", "SKILL.md", claude, codex), [])
701
-
702
- def test_unpaired_transform_marker_is_detected(self):
703
- claude = """# Example
704
-
705
- Keep this paragraph.
706
-
707
- <!-- mirror-xform:start missing-codex-pair -->
708
- This behavior must stay mirrored.
709
- <!-- mirror-xform:end -->
710
- """
711
- codex = """# Example
712
-
713
- Keep this paragraph.
714
- """
715
- self.assertTrue(mirror_content_drift("example", "SKILL.md", claude, codex))
716
-
717
-
718
- class StructureParityFixture(unittest.TestCase):
719
- """Structure-parity (Welle 49 / #1874) catches a section artificially
720
- removed on one side — project-private skills sit outside
721
- MirrorContentParity's ENFORCED_CLASSES, so only the section HEADING list
722
- is compared, not full body wording."""
723
-
724
- def test_removed_heading_is_detected(self):
725
- claude = """---
726
- name: example
727
- description: A Claude source description.
728
- ---
729
-
730
- # Example
731
-
732
- ## Kept Section
733
-
734
- Body text.
735
-
736
- ## Only On Claude
737
-
738
- This section was never mirrored to Codex (#1593-shaped drift).
739
- """
740
- codex = """---
741
- name: example
742
- description: "A Codex-safe quoted description."
743
- ---
744
-
745
- # Example
746
-
747
- ## Kept Section
748
-
749
- Body text.
750
- """
751
- self.assertTrue(structure_drift("example", "SKILL.md", claude, codex))
752
-
753
- def test_reworded_body_with_matching_headings_is_not_flagged(self):
754
- claude = """# Example
755
-
756
- ## Kept Section
757
-
758
- Body text differs but headings match.
759
- """
760
- codex = """# Example
761
-
762
- ## Kept Section
763
-
764
- Different body wording is fine here — structure-parity only compares headings.
765
- """
766
- self.assertEqual(structure_drift("example", "SKILL.md", claude, codex), [])
767
-
768
- def test_mirror_xform_region_headings_are_ignored(self):
769
- claude = """# Example
770
-
771
- ## Kept Section
772
-
773
- <!-- mirror-xform:start codex-dispatch -->
774
- ## Claude-Only Subsection Inside Xform
775
-
776
- Uses the Agent tool.
777
- <!-- mirror-xform:end -->
778
-
779
- ## After
780
- """
781
- codex = """# Example
782
-
783
- ## Kept Section
784
-
785
- <!-- mirror-xform:start codex-dispatch -->
786
- ## Codex-Only Subsection Inside Xform
787
-
788
- Uses spawn_agent.
789
- <!-- mirror-xform:end -->
790
-
791
- ## After
792
- """
793
- self.assertEqual(structure_drift("example", "SKILL.md", claude, codex), [])
794
-
795
-
796
- class StructureParityProjectPrivate(unittest.TestCase):
797
- """project-private dual-surface skills (drizzle, migrations, api-contracts,
798
- ...) sit outside MirrorContentParity's ENFORCED_CLASSES ({generic,
799
- vendored}), so a documented section can silently exist on only one
800
- surface — api-contracts' #1593 DB-column-rename section was Claude-only
801
- until #1874. This compares SECTION HEADINGS ONLY (not full body content)
802
- after stripping mirror-xform regions, so a deliberately-adapted Codex
803
- mirror (different wording, same structure) stays legal. Skills that are
804
- documented FULL rewrites (STRUCTURE_PARITY_FULL_ADAPTATION_EXEMPT, e.g.
805
- orchestrate-wave) are exempt outright."""
806
-
807
- def test_dual_surface_section_headings_match(self):
808
- problems = []
809
- for name in sorted(project_private_dual_surface_skills()):
810
- if name in STRUCTURE_PARITY_FULL_ADAPTATION_EXEMPT:
811
- continue
812
- claude_files = skill_md_set(".claude/skills", name)
813
- codex_files = skill_md_set(".agents/skills", name)
814
- for rel in sorted(claude_files & codex_files):
815
- claude = REPO_ROOT / ".claude/skills" / name / rel
816
- codex = REPO_ROOT / ".agents/skills" / name / rel
817
- problems.extend(structure_drift(
818
- name,
819
- rel,
820
- claude.read_text(encoding="utf-8"),
821
- codex.read_text(encoding="utf-8"),
822
- ))
823
- self.assertEqual(
824
- problems,
825
- [],
826
- "dual-surface section-heading drift on a project-private skill — sync "
827
- "the missing/changed section onto the other surface, bracket a "
828
- "deliberate rewrite in a paired `<!-- mirror-xform:start <reason> -->` "
829
- "/ `<!-- mirror-xform:end -->` region, or add a documented entry to "
830
- "STRUCTURE_PARITY_FULL_ADAPTATION_EXEMPT for a full-rewrite skill:\n"
831
- + "\n".join(problems))
832
-
833
-
834
- class InlineSizeThresholdBan(unittest.TestCase):
835
- """#1419: the max-lines size threshold is a single SSOT (`maxLines` in
836
- max-lines-allowlist.json). New kit code/skills/templates must READ it, never
837
- re-hardcode it as an assignment — else a consumer with a different profile
838
- silently runs the wrong gate. Narrowly scoped to the `max[-_]lines [:=] 300`
839
- DEFINITION form so it cannot false-positive on unrelated 300s (ports, timeouts)."""
840
-
841
- THRESHOLD_RE = re.compile(r"max[-_]?[lL]ines\s*[:=]\s*300")
842
- SCAN_GLOBS = [
843
- ("scripts", "*.py"),
844
- (".claude/skills", "**/*.md"),
845
- (".agents/skills", "**/*.md"),
846
- ("docs/agents", "*.md"),
847
- ]
848
- SSOT = "max-lines-allowlist.json"
849
-
850
- def test_no_hardcoded_size_threshold_outside_ssot(self):
851
- hits = []
852
- for sub, glob in self.SCAN_GLOBS:
853
- for path in (REPO_ROOT / sub).glob(glob):
854
- if path.name == self.SSOT:
855
- continue
856
- for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
857
- if EXEMPT in line:
858
- continue
859
- if self.THRESHOLD_RE.search(line):
860
- hits.append(f"{path.relative_to(REPO_ROOT)}:{i}: {line.strip()}")
861
- self.assertEqual(hits, [], "hardcoded size threshold — read maxLines from "
862
- f"{self.SSOT} instead (or add `{EXEMPT}`):\n" + "\n".join(hits))
863
-
864
-
865
- if __name__ == "__main__":
866
- unittest.main(verbosity=2)