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