mirror of
https://github.com/conorbronsdon/avoid-ai-writing.git
synced 2026-09-19 01:32:11 +08:00
fix: preserve required skill names and validate duplicate identities
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ All notable changes to this project are documented here.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Drop redundant `name` frontmatter from the generated `SKILL.full.md` and OpenAI bundled `skills/avoid-ai-writing/SKILL.md` copy; the directory name remains authoritative per Agent Skills convention (#259).
|
||||
- Require explicit skill names matching their directories and reject duplicate frontmatter keys, including mixed quoted/unquoted keys, while retaining required names in every generated distribution (#259).
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
Generated
+1
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: avoid-ai-writing
|
||||
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Use this skill when asked to "remove AI-isms," "clean up AI writing," "edit writing for AI patterns," "audit writing for AI tells," or "make this sound less like AI." Supports a detect-only mode, an edit-in-place mode for files, an optional voice profile (casual / professional / technical / warm / blunt), and an iterate-to-convergence pass.
|
||||
version: 3.35.0
|
||||
license: MIT
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: avoid-ai-writing
|
||||
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Use this skill when asked to "remove AI-isms," "clean up AI writing," "edit writing for AI patterns," "audit writing for AI tells," or "make this sound less like AI." Supports a detect-only mode, an edit-in-place mode for files, an optional voice profile (casual / professional / technical / warm / blunt), and an iterate-to-convergence pass.
|
||||
version: 3.35.0
|
||||
license: MIT
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
function stripRedundantNameFromFrontmatter(text) {
|
||||
if (!text.startsWith('---\n')) return text;
|
||||
const end = text.indexOf('\n---\n', 4);
|
||||
if (end < 0) return text;
|
||||
const inner = text.slice(4, end);
|
||||
const kept = inner.split('\n').filter((line) => !/^name:/.test(line));
|
||||
return `---\n${kept.join('\n')}\n---\n${text.slice(end + 5)}`;
|
||||
}
|
||||
|
||||
function flatten(root) {
|
||||
let front = fs.readFileSync(path.join(root, 'SKILL.md'), 'utf8').replace(/\r\n/g, '\n');
|
||||
const reference = fs.readFileSync(path.join(root, 'references/patterns.md'), 'utf8').replace(/\r\n/g, '\n');
|
||||
@@ -25,7 +16,7 @@ function flatten(root) {
|
||||
front = front.replace(marker, () => content);
|
||||
}
|
||||
if (/<!-- patterns:/.test(front)) throw new Error('Unknown pattern include marker');
|
||||
return stripRedundantNameFromFrontmatter(front);
|
||||
return front;
|
||||
}
|
||||
if (require.main === module) {
|
||||
const root = path.resolve(__dirname, '..');
|
||||
|
||||
@@ -7,6 +7,7 @@ const {flatten} = require('./flatten-skill');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const normalized = p => fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
|
||||
assert.equal(flatten(root), normalized(path.join(root, 'SKILL.full.md')), 'Flattened artifact must equal canonical content');
|
||||
assert.match(flatten(root).split('\n---\n')[0], /^name: avoid-ai-writing$/m, 'Portable skill retains its required name');
|
||||
assert.ok(normalized(path.join(root, 'SKILL.md')).split('\n').length < 500, 'Entry skill stays below 500 lines');
|
||||
// Both single-file targets must carry the same portable instructions.
|
||||
const portableBody = file => normalized(path.join(root, file))
|
||||
|
||||
@@ -56,12 +56,11 @@ for skill_root in "$(dirname "$claude_dest")" "$canonical_skill_root"; do
|
||||
done
|
||||
# The OpenAI plugin portal rejects a `metadata` key in SKILL.md frontmatter
|
||||
# ("Skill interface settings must use agents/openai.yaml"); that block carries
|
||||
# agentskills.io/OpenClaw fields, so the OpenAI copy omits it. The bundled copy
|
||||
# also omits redundant `name` (the directory name is authoritative). The
|
||||
# transform lives in validate-openai-plugin.py so the sync and the drift check
|
||||
# cannot diverge.
|
||||
# agentskills.io/OpenClaw fields, so the OpenAI copy omits it and every other
|
||||
# byte stays identical. The transform lives in validate-openai-plugin.py so the
|
||||
# sync and the drift check cannot diverge.
|
||||
python_bin="$(command -v python3 || command -v python)"
|
||||
"$python_bin" "$repo_root/scripts/validate-openai-plugin.py" --openai-canonical-skill-copy "$src" > "$openai_dest"
|
||||
"$python_bin" "$repo_root/scripts/validate-openai-plugin.py" --strip-frontmatter-metadata "$src" > "$openai_dest"
|
||||
cp "$patterns_src" "$detector_patterns_dest"
|
||||
cp "$patterns_src" "$verifier_patterns_dest"
|
||||
cp "$validate_src" "$verifier_validate_dest"
|
||||
|
||||
@@ -12,10 +12,6 @@ import xml.etree.ElementTree as ET
|
||||
|
||||
SEMVER = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$")
|
||||
FRONTMATTER = re.compile(r"\A---\s*\n(.*?)\n---\s*\n(.*)\Z", re.S)
|
||||
# The top-level `metadata` key only: `metadata:` at column 0 followed by
|
||||
# whitespace or end of line, so `metadata:extra:` (a different plain key) is kept.
|
||||
METADATA_KEY = re.compile(r"metadata:(?:\s|$)")
|
||||
TOP_LEVEL_NAME_KEY = re.compile(r"name:(?:\s|$)")
|
||||
TOP_LEVEL_INCLUDE_FILES = ("OPENAI_PLUGIN.md", "NOTICE.md", "PRIVACY.md", "TERMS.md", "SUPPORT.md", "LICENSE")
|
||||
CANONICAL_PROJECT_URL = "https://github.com/conorbronsdon/avoid-ai-writing"
|
||||
MAX_SVG_BYTES = 256 * 1024
|
||||
@@ -31,11 +27,9 @@ def parse_frontmatter(path: Path):
|
||||
if not match:
|
||||
return {}, ""
|
||||
meta = {}
|
||||
for line in match.group(1).splitlines():
|
||||
if ":" not in line or line.startswith((" ", "\t")):
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
meta[key.strip()] = value.strip().strip('"').strip("'")
|
||||
for key, value in frontmatter_entries(match.group(1)):
|
||||
parsed = parse_supported_yaml_scalar(value)
|
||||
meta[key] = parsed if parsed is not None else value.strip()
|
||||
return meta, match.group(2).strip()
|
||||
|
||||
def frontmatter_inner(text: str) -> str | None:
|
||||
@@ -43,42 +37,29 @@ def frontmatter_inner(text: str) -> str | None:
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def duplicate_top_level_frontmatter_keys(inner: str) -> list[str]:
|
||||
"""Return top-level YAML keys that appear more than once in a frontmatter block."""
|
||||
counts: dict[str, int] = {}
|
||||
def frontmatter_entries(inner: str):
|
||||
"""Read top-level scalar keys; normalize quoted and plain spellings alike."""
|
||||
for line in inner.splitlines():
|
||||
if not line or line[0] in (" ", "\t", "#"):
|
||||
continue
|
||||
if ":" not in line:
|
||||
continue
|
||||
key = line.split(":", 1)[0].strip()
|
||||
match = re.fullmatch(r'''("(?:\\.|[^"\\])*"|'(?:''|[^'])*'|[A-Za-z_][A-Za-z0-9_-]*)[ \t]*:(.*)''', line)
|
||||
if match:
|
||||
# JSON-style quoted YAML keys permit a value directly after ':'.
|
||||
if match.group(2) and not match.group(2)[0].isspace() and match.group(1)[0] not in ("'", '"'):
|
||||
continue
|
||||
key = parse_supported_yaml_scalar(match.group(1))
|
||||
if key is not None:
|
||||
yield key, match.group(2) or ""
|
||||
|
||||
|
||||
def duplicate_top_level_frontmatter_keys(inner: str) -> list[str]:
|
||||
"""Return repeated keys within one frontmatter mapping, not across copies."""
|
||||
counts: dict[str, int] = {}
|
||||
for key, _ in frontmatter_entries(inner):
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return sorted(key for key, count in counts.items() if count > 1)
|
||||
|
||||
|
||||
def strip_frontmatter_name(text: str) -> str:
|
||||
"""Drop the top-level `name` line from SKILL.md frontmatter when the directory name is authoritative."""
|
||||
match = FRONTMATTER.match(text)
|
||||
if not match:
|
||||
return text
|
||||
kept, skip = [], False
|
||||
for line in match.group(1).splitlines(keepends=True):
|
||||
if not skip and line[:1] not in (" ", "\t") and TOP_LEVEL_NAME_KEY.match(line.rstrip("\r\n")):
|
||||
continue
|
||||
skip = False
|
||||
kept.append(line)
|
||||
joined = "".join(kept)
|
||||
if joined and joined.endswith("\n"):
|
||||
joined = joined[:-1]
|
||||
start, end = match.start(1), match.end(1)
|
||||
return text[:start] + joined + text[end:]
|
||||
|
||||
|
||||
def openai_canonical_skill_copy(text: str) -> str:
|
||||
"""OpenAI bundled copy: no portal-rejected metadata, no redundant `name` (directory is avoid-ai-writing)."""
|
||||
return strip_frontmatter_name(strip_frontmatter_metadata(text))
|
||||
|
||||
|
||||
def strip_frontmatter_metadata(text: str) -> str:
|
||||
"""Drop the top-level `metadata` block from SKILL.md frontmatter, byte-exact otherwise.
|
||||
|
||||
@@ -94,7 +75,7 @@ def strip_frontmatter_metadata(text: str) -> str:
|
||||
inner = match.group(1)
|
||||
kept, skip = [], False
|
||||
for line in inner.splitlines(keepends=True):
|
||||
if METADATA_KEY.match(line):
|
||||
if any(key == "metadata" for key, _ in frontmatter_entries(line)):
|
||||
skip = True
|
||||
continue
|
||||
if skip and (line[:1] in (" ", "\t", "#") or line.strip() == ""):
|
||||
@@ -116,7 +97,7 @@ def strip_frontmatter_metadata(text: str) -> str:
|
||||
|
||||
def frontmatter_has_metadata(path: Path) -> bool:
|
||||
match = FRONTMATTER.match(path.read_text(encoding="utf-8"))
|
||||
return bool(match) and any(METADATA_KEY.match(line) for line in match.group(1).split("\n"))
|
||||
return bool(match) and any(key == "metadata" for key, _ in frontmatter_entries(match.group(1)))
|
||||
|
||||
|
||||
def safe_rel(value: str) -> bool:
|
||||
@@ -549,9 +530,9 @@ def validate(root: Path):
|
||||
if inner:
|
||||
for key in duplicate_top_level_frontmatter_keys(inner):
|
||||
errors.append(f"{skill_path}: duplicate frontmatter key: {key}")
|
||||
name = meta.get("name") or skill_dir.name
|
||||
name = meta.get("name", "")
|
||||
desc = meta.get("description", "")
|
||||
if not desc or not body:
|
||||
if not name or not desc or not body:
|
||||
errors.append(f"{skill_path}: name, description, and body are required")
|
||||
if meta.get("name") and meta.get("name") != skill_dir.name:
|
||||
errors.append(
|
||||
@@ -576,10 +557,10 @@ def validate(root: Path):
|
||||
if canonical.is_file():
|
||||
if not openai_copy.is_file():
|
||||
errors.append("skills/avoid-ai-writing/SKILL.md missing; cannot check drift from root SKILL.md")
|
||||
elif openai_canonical_skill_copy(canonical.read_text(encoding="utf-8")) != openai_copy.read_text(encoding="utf-8"):
|
||||
elif strip_frontmatter_metadata(canonical.read_bytes().decode("utf-8")).encode("utf-8") != openai_copy.read_bytes():
|
||||
errors.append(
|
||||
"skills/avoid-ai-writing/SKILL.md drifted from root SKILL.md "
|
||||
"(expected: root minus the frontmatter `metadata` block and redundant `name`)"
|
||||
"(expected: root minus the frontmatter `metadata` block)"
|
||||
)
|
||||
canonical_inner = frontmatter_inner(canonical.read_text(encoding="utf-8"))
|
||||
if canonical_inner:
|
||||
@@ -745,20 +726,11 @@ def main():
|
||||
metavar="SKILL_MD",
|
||||
help="print SKILL_MD with the frontmatter `metadata` block removed and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--openai-canonical-skill-copy",
|
||||
metavar="SKILL_MD",
|
||||
help="print the OpenAI bundled canonical skill copy (metadata and redundant name stripped) and exit",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
if args.strip_frontmatter_metadata:
|
||||
data = Path(args.strip_frontmatter_metadata).read_bytes().decode("utf-8")
|
||||
sys.stdout.buffer.write(strip_frontmatter_metadata(data).encode("utf-8"))
|
||||
return 0
|
||||
if args.openai_canonical_skill_copy:
|
||||
data = Path(args.openai_canonical_skill_copy).read_bytes().decode("utf-8")
|
||||
sys.stdout.buffer.write(openai_canonical_skill_copy(data).encode("utf-8"))
|
||||
return 0
|
||||
errors, warnings, summary = validate(Path(args.root).resolve())
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
@@ -95,6 +95,44 @@ def validation_errors_for(*, manifest=None, tests=None, listing=None, pack=None)
|
||||
return errors
|
||||
|
||||
|
||||
def skill_name_errors(replacement: str):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
make_valid_plugin_root(root)
|
||||
skill = root / "skills" / "voice-preserving-rewriter" / "SKILL.md"
|
||||
text = skill.read_text(encoding="utf-8")
|
||||
original = "name: voice-preserving-rewriter"
|
||||
assert original in text
|
||||
skill.write_text(text.replace(original, replacement, 1), encoding="utf-8")
|
||||
return MODULE.validate(root)[0]
|
||||
|
||||
|
||||
for key in ("name", '"name"', "'name'", r'"\u006eame"'):
|
||||
assert skill_name_errors(f'{key}: "voice-preserving-rewriter" # identity') == []
|
||||
errors = skill_name_errors(f"{key}: wrong-name")
|
||||
assert any("must match directory" in error for error in errors), errors
|
||||
errors = skill_name_errors(f"name: voice-preserving-rewriter\n{key}: voice-preserving-rewriter")
|
||||
assert any("duplicate frontmatter key: name" in error for error in errors), errors
|
||||
|
||||
for replacement in ("", "name:", "name: ''", '"name": ""'):
|
||||
errors = skill_name_errors(replacement)
|
||||
assert any("name, description, and body are required" in error for error in errors), errors
|
||||
|
||||
assert skill_name_errors('"name":"voice-preserving-rewriter"') == []
|
||||
errors = skill_name_errors('name: voice-preserving-rewriter\n"name" :"wrong-name"')
|
||||
assert any("duplicate frontmatter key: name" in error for error in errors), errors
|
||||
assert any("must match directory" in error for error in errors), errors
|
||||
|
||||
errors = skill_name_errors('"name": false-positive-reviewer')
|
||||
assert any("duplicate skill name 'false-positive-reviewer'" in error for error in errors), errors
|
||||
assert MODULE.duplicate_top_level_frontmatter_keys(
|
||||
'name: first\nmetadata:\n name: nested\n name: nested-again\n# name: comment\n'
|
||||
) == []
|
||||
assert MODULE.duplicate_top_level_frontmatter_keys(
|
||||
'description: first\n"description": second\n'
|
||||
) == ["description"]
|
||||
|
||||
|
||||
assert errors_for(" products: [CHAT, CODEX]\n") == []
|
||||
assert errors_for(' products: ["CHAT", "CODEX"]\n') == []
|
||||
assert errors_for(" products: ['CHAT', 'CODEX']\n") == []
|
||||
@@ -262,7 +300,7 @@ with tempfile.TemporaryDirectory() as temp_dir:
|
||||
text = skill_path.read_text(encoding="utf-8")
|
||||
skill_path.write_text(text.replace("name: avoid-ai-writing\n", "", 1), encoding="utf-8")
|
||||
errors, _, _ = MODULE.validate(root)
|
||||
assert errors == [], errors
|
||||
assert any("name, description, and body are required" in error for error in errors), errors
|
||||
|
||||
for payload in ("[]", "null"):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
@@ -373,14 +411,14 @@ with tempfile.TemporaryDirectory() as temp_dir:
|
||||
assert any("symlink not allowed in plugin surface: LICENSE" in error for error in errors)
|
||||
|
||||
# The OpenAI copy of the canonical skill must drop the frontmatter `metadata`
|
||||
# block (the portal rejects it) and the redundant `name` field.
|
||||
# block (the portal rejects it) and preserve the required `name` field.
|
||||
ROOT_SKILL = (REPO_ROOT / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert "\nmetadata:\n" in ROOT_SKILL, "fixture assumption: root SKILL.md carries a metadata block"
|
||||
STRIPPED = MODULE.openai_canonical_skill_copy(ROOT_SKILL)
|
||||
STRIPPED = MODULE.strip_frontmatter_metadata(ROOT_SKILL)
|
||||
ROOT_HEAD, ROOT_BODY = ROOT_SKILL.split("\n---\n", 1)
|
||||
STRIPPED_HEAD, STRIPPED_BODY = STRIPPED.split("\n---\n", 1)
|
||||
assert "metadata:" not in STRIPPED_HEAD
|
||||
assert "name:" not in STRIPPED_HEAD
|
||||
assert "name: avoid-ai-writing" in STRIPPED_HEAD
|
||||
assert "\nversion:" in STRIPPED_HEAD and "\nlicense:" in STRIPPED_HEAD and "\ncompatibility:" in STRIPPED_HEAD
|
||||
assert STRIPPED_BODY == ROOT_BODY, "body must be untouched"
|
||||
assert MODULE.strip_frontmatter_metadata("no frontmatter\nmetadata:\n x: y\n") == "no frontmatter\nmetadata:\n x: y\n"
|
||||
@@ -396,24 +434,20 @@ assert STRIP("---\r\nname: x\r\nmetadata:\r\n a: b\r\nlicense: MIT\r\n---\r\nBo
|
||||
# a column-zero comment inside the block belongs to it; `metadata:extra` is a different key and stays
|
||||
assert STRIP("---\nname: x\nmetadata:\n author: y\n# note\n repository: z\nlicense: MIT\n---\nBody\n") == "---\nname: x\nlicense: MIT\n---\nBody\n"
|
||||
assert STRIP("---\nname: x\nmetadata:extra: keep\n---\nBody\n") == "---\nname: x\nmetadata:extra: keep\n---\nBody\n"
|
||||
assert STRIP('---\nname: x\n"metadata":\n author: y\n---\nBody\n') == '---\nname: x\n---\nBody\n'
|
||||
# missing closing delimiter: not a frontmatter, untouched
|
||||
assert STRIP("---\nname: x\nmetadata:\n author: y\nBody\n") == "---\nname: x\nmetadata:\n author: y\nBody\n"
|
||||
# sync-plugin-skill.sh uses --openai-canonical-skill-copy; metadata-only strip keeps `name`
|
||||
# sync-plugin-skill.sh uses metadata-only stripping and keeps `name`
|
||||
import subprocess
|
||||
metadata_only = subprocess.run(
|
||||
[sys.executable, str(MODULE_PATH), "--strip-frontmatter-metadata", str(REPO_ROOT / "SKILL.md")],
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
assert metadata_only.stdout == MODULE.strip_frontmatter_metadata(ROOT_SKILL).encode("utf-8")
|
||||
openai_copy = subprocess.run(
|
||||
[sys.executable, str(MODULE_PATH), "--openai-canonical-skill-copy", str(REPO_ROOT / "SKILL.md")],
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
assert openai_copy.stdout == STRIPPED.encode("utf-8"), "CLI output differs from openai_canonical_skill_copy"
|
||||
assert STRIPPED == (REPO_ROOT / "skills" / "avoid-ai-writing" / "SKILL.md").read_text(encoding="utf-8"), (
|
||||
"run bash scripts/sync-plugin-skill.sh; the OpenAI copy is out of date"
|
||||
)
|
||||
assert "name:" not in (REPO_ROOT / "SKILL.full.md").read_text(encoding="utf-8").split("\n---\n", 1)[0]
|
||||
assert "name: avoid-ai-writing" in (REPO_ROOT / "SKILL.full.md").read_text(encoding="utf-8").split("\n---\n", 1)[0]
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
|
||||
Generated
+1
@@ -1,4 +1,5 @@
|
||||
---
|
||||
name: avoid-ai-writing
|
||||
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Use this skill when asked to "remove AI-isms," "clean up AI writing," "edit writing for AI patterns," "audit writing for AI tells," or "make this sound less like AI." Supports a detect-only mode, an edit-in-place mode for files, an optional voice profile (casual / professional / technical / warm / blunt), and an iterate-to-convergence pass.
|
||||
version: 3.35.0
|
||||
license: MIT
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"architecture": "skills-only",
|
||||
"publicSkills": ["avoid-ai-writing","avoid-ai-writing-router","ai-writing-detector","voice-preserving-rewriter","file-edit-in-place","preservation-verifier","false-positive-reviewer"],
|
||||
"canonicalSkill": {"path":"skills/avoid-ai-writing/SKILL.md","source":"SKILL.md","preservation":"generated copy, identical except the frontmatter metadata block and redundant name field are omitted (OpenAI portal rejects metadata; directory name is authoritative)"},
|
||||
"canonicalSkill": {"path":"skills/avoid-ai-writing/SKILL.md","source":"SKILL.md","preservation":"generated copy, identical except the frontmatter metadata block is omitted (OpenAI portal rejects it)"},
|
||||
"bundledDeterministicResources": [
|
||||
{"path":"skills/ai-writing-detector/scripts/patterns.js","source":"detector/patterns.js","purpose":"detect-only local analysis"},
|
||||
{"path":"skills/preservation-verifier/scripts/validate.js","source":"detector/validate.js","purpose":"before/after preservation verification"},
|
||||
|
||||
Reference in New Issue
Block a user