mirror of
https://github.com/conorbronsdon/avoid-ai-writing.git
synced 2026-09-19 01:32:11 +08:00
feat: optional --style house-style layer (config-driven, points at Vale) (#99)
* feat: optional --style house-style layer (config-driven, points at Vale) Implements the config-driven design recorded in #88, which #76 and the fork review converged on. No bundled guides: the layer applies register/voice directives and removes AI tells on top of whatever mechanics you enforce. --style ./house.json applies a user-supplied JSON config (register + mechanics) and scripts/check-style.js verifies the CHECKABLE mechanics deterministically: quotes and latinAbbrev gate the exit code (0 clean / 1 hard / 2 tool error), headings, emDash and spellNumbersUpTo are advisory, serialComma is never checked. The docs scope the verb accordingly rather than claiming "mechanics are verified" flatly. examples/ ships two generic, guide-neutral starters plus the schema, and links #88 for the licensing rule. A bare --style "APA" is a best-effort fallback, not a feature: SKILL.md instructs the model to print a no-compliance-claim status line and not to reproduce the guide's text. Those are instructions rather than checked rules, and the docs say so instead of asserting the behaviour as fact. --style is listed in the SKILL.md invocation line and has an explicit composition rule with --voice/--context, since the file specifies composition for every other axis. README: the "House style is a different job" section said "There's no --style input", which this makes false. The first paragraph now describes the config input and reconciles with the heading (enforcing a PUBLISHED guide is the different job). The Vale paragraph and the heading anchor are unchanged. cursor-rules: the ported rule would have told a Cursor user to run scripts/check-style.js and read examples/, neither of which ships in the .mdc. Two anchored spans in sync-cursor-rules.sh rewrite those the way span 3 already handles detector/validate.js, so the port stays self-contained. Worth noting the drift guard only covers edits to anchored spans, not newly added SKILL.md sections that introduce repo references. The checker went through three adversarial review rounds, and each round found the previous round's fix had introduced a new way to hard-fail a correct document or to miss a real one. The tests pin all of them: markdown link titles, reference definitions (including escaped quotes in a title), HTML attribute values, nested and tilde fences, BOM'd frontmatter, URLs containing parentheses, parentheticals that wrap or span a code block, and a stray "(" that must not suppress later findings, and prose containing a comparison ("n<N ... >") that must not be mistaken for an HTML tag. Masking runs refdef -> links -> tags, in that order, because masking a link destination first makes an ordinary "[a](url): text" line look like a reference definition. Link masking walks to the matching paren and scans linearly; the regex form was quadratic (22.7s on a hostile line, now 4ms). resolveConfig decides path-vs-name by shape so a cwd file can't shadow a bundled example, the CLI parses by position, a config with "mechanics": null or [] exits 2 rather than a false-green 0, and advisory findings print instead of hiding behind --json. Version 3.22.3 -> 3.23.0 in SKILL.md frontmatter, package.json and plugin.json; CHANGELOG entry under a dated heading; plugin and cursor copies regenerated. No detector changes: catalog counts unchanged (61 / 112). 32 tests in scripts/check-style.test.js under npm test; detector-test.yml path filters extended to scripts/** and examples/**. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: mask indented code, tighten frontmatter open, announce the resolved mode Review fixes applied on top of the submitted branch: - Indented code blocks (4+ spaces / tab after a blank line, outside a list) are masked, so pasted terminal output can't hard-fail a correct document. Lazy continuation and list-item content stay checked; a code block nested in a list item needs a fence. Was the largest undocumented false positive. - A document opening with a thematic break is no longer read as frontmatter (which silently swallowed everything to the next ---). - The bare-name traversal regex was dead code its test never reached; a bare name is a single path segment by construction, so the guard is now a comment stating that argument, and a test pins dot-bearing names. - Config runs open by naming the resolved config, mirroring the fallback's status line, so which of the two modes ran is always explicit in output. - Known-limitations lists gain the double-backtick span and next-line reference-definition title gaps; examples/README.md joins the self-scan budgets; CHANGELOG updated; cursor-rule anchors follow the SKILL.md edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,8 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- "detector/**"
|
||||
- "scripts/self-scan.js"
|
||||
- "scripts/**"
|
||||
- "examples/**"
|
||||
- "package.json"
|
||||
- ".github/workflows/detector-test.yml"
|
||||
- "README.md"
|
||||
@@ -15,7 +16,8 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "detector/**"
|
||||
- "scripts/self-scan.js"
|
||||
- "scripts/**"
|
||||
- "examples/**"
|
||||
- "package.json"
|
||||
- ".github/workflows/detector-test.yml"
|
||||
- "README.md"
|
||||
|
||||
@@ -4,6 +4,16 @@ All notable changes to this project are documented here.
|
||||
|
||||
---
|
||||
|
||||
## [3.23.0] — 2026-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Optional `--style` house-style layer, with no bundled guides.** `--style ./house.json` applies a user-supplied config (`register` directives the model follows, plus `mechanics`) and `scripts/check-style.js` verifies the checkable ones deterministically: `quotes` and `latinAbbrev` gate the exit code (0 clean / 1 hard / 2 tool error), `headings`, `emDash` and `spellNumbersUpTo` are advisory, `serialComma` is never checked. `examples/` holds two generic starters and the schema.
|
||||
- **A bare `--style "APA"` is a best-effort fallback, not a feature.** `SKILL.md` instructs the model to open with a status line claiming no compliance and not to reproduce the guide's text. Those are instructions rather than checked rules, so that path is unverified by construction. The README says where encoded guides actually live, and the licensing rule behind it is recorded in #88.
|
||||
- No detector changes, so the catalog stays 61 / 112. 39 tests in `scripts/check-style.test.js`, most of them pinning must-not-fire cases: link titles and reference definitions, HTML attributes, nested and tilde fences, BOM'd frontmatter, parentheticals that wrap or span a code block, URLs containing parentheses, and indented code blocks (while lazy continuation, list-item content, and a document opening with a thematic break stay checked). Each was a hard violation on a correct document at some point during review.
|
||||
|
||||
---
|
||||
|
||||
## [3.22.3] — 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -359,11 +359,16 @@ CI fails when a document drifts past its budget.
|
||||
|
||||
## House style is a different job
|
||||
|
||||
This skill removes AI-writing tells. It doesn't enforce a house style guide, and
|
||||
it ships no style guides of its own. There's no `--style` input. If you want
|
||||
house style applied in the same pass, put the guide in your agent's context
|
||||
alongside a [voice profile](#triggering-the-skill) and it should follow both,
|
||||
as instructions rather than as a checked rule set.
|
||||
This skill removes AI-writing tells. Enforcing a published style guide is the
|
||||
different job: it doesn't do that, and it ships no style guides of its own. The
|
||||
optional `--style` input takes a house-style config you supply: a `register` list
|
||||
the model applies, and a `mechanics` object whose checkable rules
|
||||
`scripts/check-style.js` verifies deterministically (quote form and Latin
|
||||
abbreviations gate the exit code; heading case, em-dash rate, and number spelling
|
||||
are advisory). [`examples/`](./examples/) has the schema. You can skip the input
|
||||
entirely and put your guide in your agent's context alongside a
|
||||
[voice profile](#triggering-the-skill), as instructions rather than as a checked
|
||||
rule set.
|
||||
|
||||
If you want Google, Microsoft, Red Hat, or Salesforce style checked in CI,
|
||||
[Vale](https://github.com/vale-cli/vale) already covers that. Its
|
||||
@@ -378,7 +383,11 @@ time, while this skill rewrites whole passages as you draft.
|
||||
Paywalled guides (Chicago, APA, MLA, AP) have no machine-readable
|
||||
implementation here or in Vale, and won't get one here. Nothing in this repo
|
||||
could verify that a rewrite is Chicago-compliant, so claiming it would fail the
|
||||
same bar [`PROOF.md`](./PROOF.md) holds every other number to. The
|
||||
same bar [`PROOF.md`](./PROOF.md) holds every other number to. Passing one of
|
||||
their names to `--style` bundles nothing; it falls back to the model's own
|
||||
knowledge, and `SKILL.md` instructs it to say so and to claim no compliance.
|
||||
That is an instruction rather than a checked rule, which is the point: there is
|
||||
nothing here to check it against. The
|
||||
[license audit](https://github.com/conorbronsdon/avoid-ai-writing/issues/88)
|
||||
behind that line is public.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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.22.3
|
||||
version: 3.23.0
|
||||
license: MIT
|
||||
compatibility: Any AI coding assistant that supports agentskills.io SKILL.md format (Claude Code, Cursor, VS Code Copilot, Hermes Agent, OpenHands, etc.) or OpenClaw. No external tools or APIs required.
|
||||
metadata:
|
||||
@@ -40,7 +40,7 @@ This skill operates in one of three modes:
|
||||
|
||||
Trigger detect mode when the user says "detect," "flag only," "audit only," "just flag," "scan," "what AI patterns are in this," or similar. Trigger edit mode when the user names a file and asks you to fix or clean it in place. Default to rewrite mode if not specified.
|
||||
|
||||
**Invocation.** Natural language is enough ("rewrite this in a blunt voice for LinkedIn," "edit `post.md` in place," "scan this, don't rewrite"). Power users can also pass explicit options, which map to the sections below: `[--mode rewrite|detect|edit]`, `[--voice casual|professional|technical|warm|blunt]`, `[--context linkedin|blog|technical-blog|investor-email|docs|casual]`, `[--file PATH]`, `[--iterate N]` (max 2).
|
||||
**Invocation.** Natural language is enough ("rewrite this in a blunt voice for LinkedIn," "edit `post.md` in place," "scan this, don't rewrite"). Power users can also pass explicit options, which map to the sections below: `[--mode rewrite|detect|edit]`, `[--voice casual|professional|technical|warm|blunt]`, `[--context linkedin|blog|technical-blog|investor-email|docs|casual]`, `[--file PATH]`, `[--iterate N]` (max 2), `[--style CONFIG|GUIDE]`.
|
||||
|
||||
**Iterate to convergence (optional).** Rewrite mode already runs one corrective second pass (see Output format) — that built-in pass *is* pass 2, so `--iterate` does not stack on top of it. When the writer asks to "iterate," "keep going until it's clean," or passes `--iterate N`, repeat the audit→rewrite cycle until no patterns remain or **N passes** are reached. Cap **N at 2**: a rewrite plus one corrective pass clears the flagged patterns, and a third pass costs a full regeneration while rarely finding more. Report how many passes it took ("converged in 2 passes").
|
||||
|
||||
@@ -709,6 +709,18 @@ Each profile is a set of concrete targets, not a vibe:
|
||||
|
||||
---
|
||||
|
||||
## House style (optional): `--style <config-or-guide>`
|
||||
|
||||
`--style` copyedits to a house style on top of the de-AI pass (which always runs). No bundled guides. This layer is not a guide registry: it applies **register/voice** directives and removes AI tells, on top of whatever **mechanics** you enforce.
|
||||
|
||||
**Preferred: a config file.** `--style ./house.json` (or a bare name matching `examples/<name>.json`) applies a user-supplied JSON config and verifies the checkable subset of its mechanics with `node scripts/check-style.js <file> --config <path>` (exit 0 clean / 1 hard violation / 2 tool error). A config is JSON: **`register`** (voice directives you apply as written) plus **`mechanics`** (`quotes` and `latinAbbrev` hard-checkable; `headings`, `emDash`, `spellNumbersUpTo` advisory; `serialComma` model-applied). Schema and rationale: `examples/README.md`. Open the output by naming the resolved config (`Applying config examples/technical.json; checkable mechanics verified.`), the way the fallback below names its guide, so which mode ran is never ambiguous.
|
||||
|
||||
**How `--style` composes.** It is a third axis alongside `--voice` and `--context`, and the narrowest wins: `mechanics` beat everything (they're checkable), then `--voice`, then a config's `register`, then `--context`. So `--voice blunt` with a config asking for warmth stays blunt, while that config's `emDash: deliberate` still governs dashes.
|
||||
|
||||
**Fallback: a named guide from memory.** If someone passes `--style "APA"` or `"Chicago"` with no config, you may apply it from general knowledge as best-effort, not as a feature. Open with a status line such as `Applying APA from general knowledge (not verified; no compliance claim).`, apply the register and mechanics you know, and make no compliance claim. Do **not** reproduce the guide's copyrighted text, and note that your knowledge may reflect an older edition. Paywalled guides (Chicago, APA, MLA, AP) are never bundled in any form.
|
||||
|
||||
**Resolving `--style <arg>`.** A path, or a bare name matching `examples/<name>.json`, loads that config (apply and verify); anything else is the named-guide fallback above. When a guide's mechanics conflict with the AI-ism catalog the guide wins the mechanic (for example, CMOS keeps deliberate em dashes); still flag the AI *habit* such as em-dash stacking. A bare de-AI request (no `--style`) is unchanged; don't apply a guide to a genre it wasn't written for.
|
||||
|
||||
## Output format
|
||||
|
||||
### Rewrite mode (default)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Activate whenever editing prose-heavy files (Markdown, documentation, blog posts, READMEs, release notes, emails). Cursor port of the avoid-ai-writing skill v3.22.3. See https://github.com/conorbronsdon/avoid-ai-writing.
|
||||
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Activate whenever editing prose-heavy files (Markdown, documentation, blog posts, READMEs, release notes, emails). Cursor port of the avoid-ai-writing skill v3.23.0. See https://github.com/conorbronsdon/avoid-ai-writing.
|
||||
globs: ["**/*.md", "**/*.mdx", "**/*.txt", "**/*.rst", "**/*.adoc"]
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -35,7 +35,7 @@ This skill operates in one of three modes:
|
||||
|
||||
Trigger detect mode when the user says "detect," "flag only," "audit only," "just flag," "scan," "what AI patterns are in this," or similar. Trigger edit mode when the user names a file and asks you to fix or clean it in place. Default to rewrite mode if not specified.
|
||||
|
||||
**Invocation.** Natural language is enough ("rewrite this in a blunt voice for LinkedIn," "edit `post.md` in place," "scan this, don't rewrite"). Power users can also pass explicit options, which map to the sections below: `[--mode rewrite|detect|edit]`, `[--voice casual|professional|technical|warm|blunt]`, `[--context linkedin|blog|technical-blog|investor-email|docs|casual]`, `[--file PATH]`, `[--iterate N]` (max 2).
|
||||
**Invocation.** Natural language is enough ("rewrite this in a blunt voice for LinkedIn," "edit `post.md` in place," "scan this, don't rewrite"). Power users can also pass explicit options, which map to the sections below: `[--mode rewrite|detect|edit]`, `[--voice casual|professional|technical|warm|blunt]`, `[--context linkedin|blog|technical-blog|investor-email|docs|casual]`, `[--file PATH]`, `[--iterate N]` (max 2), `[--style CONFIG|GUIDE]`.
|
||||
|
||||
**Iterate to convergence (optional).** Rewrite mode already runs one corrective second pass (see Output format) — that built-in pass *is* pass 2, so `--iterate` does not stack on top of it. When the writer asks to "iterate," "keep going until it's clean," or passes `--iterate N`, repeat the audit→rewrite cycle until no patterns remain or **N passes** are reached. Cap **N at 2**: a rewrite plus one corrective pass clears the flagged patterns, and a third pass costs a full regeneration while rarely finding more. Report how many passes it took ("converged in 2 passes").
|
||||
|
||||
@@ -704,6 +704,18 @@ Each profile is a set of concrete targets, not a vibe:
|
||||
|
||||
---
|
||||
|
||||
## House style (optional): `--style <config-or-guide>`
|
||||
|
||||
`--style` copyedits to a house style on top of the de-AI pass (which always runs). No bundled guides. This layer is not a guide registry: it applies **register/voice** directives and removes AI tells, on top of whatever **mechanics** you enforce.
|
||||
|
||||
**Preferred: a config file.** `--style ./house.json` applies a user-supplied JSON config: **`register`** (voice directives you apply as written) plus **`mechanics`** (`quotes`, `latinAbbrev`, `headings`, `emDash`, `spellNumbersUpTo`, `serialComma`). Apply the register and enforce the mechanics as written. The upstream repo ships a deterministic checker for the checkable ones; without it, treat the mechanics as applied but unverified. Open the output by naming the config you applied, the way the fallback below names its guide, so which mode ran is never ambiguous.
|
||||
|
||||
**How `--style` composes.** It is a third axis alongside `--voice` and `--context`, and the narrowest wins: `mechanics` beat everything (they're checkable), then `--voice`, then a config's `register`, then `--context`. So `--voice blunt` with a config asking for warmth stays blunt, while that config's `emDash: deliberate` still governs dashes.
|
||||
|
||||
**Fallback: a named guide from memory.** If someone passes `--style "APA"` or `"Chicago"` with no config, you may apply it from general knowledge as best-effort, not as a feature. Open with a status line such as `Applying APA from general knowledge (not verified; no compliance claim).`, apply the register and mechanics you know, and make no compliance claim. Do **not** reproduce the guide's copyrighted text, and note that your knowledge may reflect an older edition. Paywalled guides (Chicago, APA, MLA, AP) are never bundled in any form.
|
||||
|
||||
**Resolving `--style <arg>`.** A path to a JSON config loads it, and you apply it as written; anything else is the named-guide fallback above. When a guide's mechanics conflict with the AI-ism catalog the guide wins the mechanic (for example, CMOS keeps deliberate em dashes); still flag the AI *habit* such as em-dash stacking. A bare de-AI request (no `--style`) is unchanged; don't apply a guide to a genre it wasn't written for.
|
||||
|
||||
## Output format
|
||||
|
||||
### Rewrite mode (default)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# House-style config examples
|
||||
|
||||
`--style` adds a house style on top of the de-AI pass. It is not a guide registry: it
|
||||
applies **register/voice** directives and removes AI tells, on top of whatever
|
||||
**mechanics** you enforce. The preferred way in is a **config file**
|
||||
(`--style ./house.json`, or a bare name matching `examples/<name>.json`): it is applied, and
|
||||
the checkable subset of its mechanics is verified deterministically (see the table below for
|
||||
which rules gate the exit code and which are advisory). The files here are *examples of that
|
||||
format*; copy one and edit it.
|
||||
|
||||
## Where encoded guides live
|
||||
|
||||
For a real published guide, don't reach for a bare name or expect a bundled config: see the
|
||||
README's [**House style is a different job**](../README.md#house-style-is-a-different-job)
|
||||
section, which points at [Vale](https://github.com/vale-cli/vale) (where licensed, attributed
|
||||
guide packages live) and records the licensing decision in
|
||||
[#88](https://github.com/conorbronsdon/avoid-ai-writing/issues/88). In short: Vale enforces a
|
||||
guide's mechanics; this layer adds register/voice and removes AI tells; the config format
|
||||
below is for a quick custom house style.
|
||||
|
||||
**This repo bundles no style guides.** The example files are generic and guide-neutral (no
|
||||
guide names or aliases), so nothing here claims to implement a guide or tracks its edition.
|
||||
|
||||
A bare name resolves by filename only: `--config technical` loads `technical.json`. Because
|
||||
the shipped examples carry no guide names, `--style chicago` resolves to no config and falls
|
||||
back to applying the guide from the model's own knowledge as best-effort, labeled such as
|
||||
`Applying Chicago from general knowledge (not verified; no compliance claim).`. `SKILL.md`
|
||||
instructs the model to print that status line and not to reproduce the guide's text; both are
|
||||
instructions rather than checked rules, so treat that path as unverified. For enforcement, use
|
||||
Vale or write a config. The checker covers only the config path, so pointing it at an
|
||||
unresolvable name exits 2 (a tool error).
|
||||
|
||||
## Schema
|
||||
|
||||
A config is JSON with two parts:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My house style",
|
||||
"genre": "technical documentation",
|
||||
"register": [
|
||||
"Second person, active voice, present tense.",
|
||||
"No hype."
|
||||
],
|
||||
"mechanics": {
|
||||
"quotes": "straight",
|
||||
"headings": "sentence",
|
||||
"emDash": "sparing",
|
||||
"latinAbbrev": "parentheses",
|
||||
"serialComma": true,
|
||||
"spellNumbersUpTo": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`register`** (list of strings) — voice/register directives the model applies as
|
||||
guidance. These are judgment calls, not machine-checked.
|
||||
- **`genre`** (string, optional) — what the config is written for. Don't apply a config
|
||||
to a genre it wasn't written for.
|
||||
- **`mechanics`** (object) — output rules, of which the checkable subset is verified by
|
||||
`node scripts/check-style.js <file> --config <config.json>`:
|
||||
|
||||
| key | values | how it's checked |
|
||||
|---|---|---|
|
||||
| `quotes` | `straight` \| `curly` | **hard** — flags the wrong mark form in prose |
|
||||
| `latinAbbrev` | `never` \| `parentheses` \| `any` | **hard** — `never` flags any `e.g.`/`i.e.`; `parentheses` flags them outside parentheses; `any` is unchecked |
|
||||
| `headings` | `sentence` \| `title` | advisory — proper nouns make sentence vs. title case ambiguous, so it can't be verified deterministically |
|
||||
| `emDash` | `sparing` \| `deliberate` | advisory — `sparing` flags a rate over ~1 per 1,000 words; `deliberate` is unchecked |
|
||||
| `spellNumbersUpTo` | number | advisory — flags numerals at or below the threshold in prose |
|
||||
| `serialComma` | `true` \| `false` | model-applied only; not machine-checked |
|
||||
|
||||
Unrecognized keys or values are reported as **warnings** (a config the tool couldn't fully
|
||||
apply) rather than silently ignored; omitted keys do nothing.
|
||||
|
||||
Before checking, the checker skips YAML frontmatter (only when it closes), code (fenced and
|
||||
inline), and markdown link destinations, link titles, and reference-definition tails, so
|
||||
identifiers, examples, and link syntax don't false-positive. It also masks HTML tags, whose
|
||||
attribute values are straight-quoted. Link titles matter here because they are delimited with
|
||||
straight quotes as *syntax*, which `quotes: curly` would otherwise read as a violation. Some
|
||||
limits worth knowing: an unclosed or multi-line HTML tag still registers, as do quotes inside
|
||||
an HTML comment; and the `latinAbbrev` parenthesis carve-out tracks depth across wrapped
|
||||
lines but resets at a paragraph break, so an unclosed `(` disables that rule for the rest of
|
||||
its paragraph. Indented code blocks are masked, with a list exception: 4-space content
|
||||
inside a list item is the item's own prose (use a fence there). A double-backtick code span
|
||||
whose body contains a backtick leaks to the quote checks; a reference definition with its
|
||||
title on the next line is read as prose; and a document opening with a thematic break is
|
||||
prose, not frontmatter.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Long-form prose (example)",
|
||||
"genre": "published prose",
|
||||
"register": [
|
||||
"Consistent, flowing authorial voice.",
|
||||
"Formal but not stiff; contractions are acceptable."
|
||||
],
|
||||
"mechanics": {
|
||||
"quotes": "curly",
|
||||
"headings": "title",
|
||||
"emDash": "deliberate",
|
||||
"latinAbbrev": "parentheses",
|
||||
"serialComma": true,
|
||||
"spellNumbersUpTo": 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "Technical documentation (example)",
|
||||
"genre": "technical documentation",
|
||||
"register": [
|
||||
"Second person (\"you\"), active voice, present tense.",
|
||||
"Imperative mood for steps.",
|
||||
"One idea per sentence; lead with what the reader can do.",
|
||||
"No marketing language or hype; cut dismissive words (simply, easily, just, obviously)."
|
||||
],
|
||||
"mechanics": {
|
||||
"quotes": "straight",
|
||||
"headings": "sentence",
|
||||
"emDash": "sparing",
|
||||
"latinAbbrev": "never",
|
||||
"serialComma": true,
|
||||
"spellNumbersUpTo": 9
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "avoid-ai-writing-detector",
|
||||
"version": "3.22.3",
|
||||
"version": "3.23.0",
|
||||
"description": "Deterministic detection engine for the Avoid AI Writing skill — pattern + stylometric analysis of AI-generated text.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node detector/patterns.test.js && node detector/categories.test.js && node detector/validate.test.js && node scripts/corpus.test.js",
|
||||
"test": "node detector/patterns.test.js && node detector/categories.test.js && node detector/validate.test.js && node scripts/corpus.test.js && node scripts/check-style.test.js",
|
||||
"self-scan": "node scripts/self-scan.js",
|
||||
"self-scan:check": "node scripts/self-scan.js --check",
|
||||
"corpus": "node scripts/corpus.js list",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "avoid-ai-writing",
|
||||
"description": "Audit & rewrite content to remove AI writing patterns (\"AI-isms\"). Supports detect-only and edit-in-place modes, voice profiles, and iterate-to-convergence.",
|
||||
"version": "3.22.3",
|
||||
"version": "3.23.0",
|
||||
"author": {
|
||||
"name": "Conor Bronsdon"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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.22.3
|
||||
version: 3.23.0
|
||||
license: MIT
|
||||
compatibility: Any AI coding assistant that supports agentskills.io SKILL.md format (Claude Code, Cursor, VS Code Copilot, Hermes Agent, OpenHands, etc.) or OpenClaw. No external tools or APIs required.
|
||||
metadata:
|
||||
@@ -40,7 +40,7 @@ This skill operates in one of three modes:
|
||||
|
||||
Trigger detect mode when the user says "detect," "flag only," "audit only," "just flag," "scan," "what AI patterns are in this," or similar. Trigger edit mode when the user names a file and asks you to fix or clean it in place. Default to rewrite mode if not specified.
|
||||
|
||||
**Invocation.** Natural language is enough ("rewrite this in a blunt voice for LinkedIn," "edit `post.md` in place," "scan this, don't rewrite"). Power users can also pass explicit options, which map to the sections below: `[--mode rewrite|detect|edit]`, `[--voice casual|professional|technical|warm|blunt]`, `[--context linkedin|blog|technical-blog|investor-email|docs|casual]`, `[--file PATH]`, `[--iterate N]` (max 2).
|
||||
**Invocation.** Natural language is enough ("rewrite this in a blunt voice for LinkedIn," "edit `post.md` in place," "scan this, don't rewrite"). Power users can also pass explicit options, which map to the sections below: `[--mode rewrite|detect|edit]`, `[--voice casual|professional|technical|warm|blunt]`, `[--context linkedin|blog|technical-blog|investor-email|docs|casual]`, `[--file PATH]`, `[--iterate N]` (max 2), `[--style CONFIG|GUIDE]`.
|
||||
|
||||
**Iterate to convergence (optional).** Rewrite mode already runs one corrective second pass (see Output format) — that built-in pass *is* pass 2, so `--iterate` does not stack on top of it. When the writer asks to "iterate," "keep going until it's clean," or passes `--iterate N`, repeat the audit→rewrite cycle until no patterns remain or **N passes** are reached. Cap **N at 2**: a rewrite plus one corrective pass clears the flagged patterns, and a third pass costs a full regeneration while rarely finding more. Report how many passes it took ("converged in 2 passes").
|
||||
|
||||
@@ -709,6 +709,18 @@ Each profile is a set of concrete targets, not a vibe:
|
||||
|
||||
---
|
||||
|
||||
## House style (optional): `--style <config-or-guide>`
|
||||
|
||||
`--style` copyedits to a house style on top of the de-AI pass (which always runs). No bundled guides. This layer is not a guide registry: it applies **register/voice** directives and removes AI tells, on top of whatever **mechanics** you enforce.
|
||||
|
||||
**Preferred: a config file.** `--style ./house.json` (or a bare name matching `examples/<name>.json`) applies a user-supplied JSON config and verifies the checkable subset of its mechanics with `node scripts/check-style.js <file> --config <path>` (exit 0 clean / 1 hard violation / 2 tool error). A config is JSON: **`register`** (voice directives you apply as written) plus **`mechanics`** (`quotes` and `latinAbbrev` hard-checkable; `headings`, `emDash`, `spellNumbersUpTo` advisory; `serialComma` model-applied). Schema and rationale: `examples/README.md`. Open the output by naming the resolved config (`Applying config examples/technical.json; checkable mechanics verified.`), the way the fallback below names its guide, so which mode ran is never ambiguous.
|
||||
|
||||
**How `--style` composes.** It is a third axis alongside `--voice` and `--context`, and the narrowest wins: `mechanics` beat everything (they're checkable), then `--voice`, then a config's `register`, then `--context`. So `--voice blunt` with a config asking for warmth stays blunt, while that config's `emDash: deliberate` still governs dashes.
|
||||
|
||||
**Fallback: a named guide from memory.** If someone passes `--style "APA"` or `"Chicago"` with no config, you may apply it from general knowledge as best-effort, not as a feature. Open with a status line such as `Applying APA from general knowledge (not verified; no compliance claim).`, apply the register and mechanics you know, and make no compliance claim. Do **not** reproduce the guide's copyrighted text, and note that your knowledge may reflect an older edition. Paywalled guides (Chicago, APA, MLA, AP) are never bundled in any form.
|
||||
|
||||
**Resolving `--style <arg>`.** A path, or a bare name matching `examples/<name>.json`, loads that config (apply and verify); anything else is the named-guide fallback above. When a guide's mechanics conflict with the AI-ism catalog the guide wins the mechanic (for example, CMOS keeps deliberate em dashes); still flag the AI *habit* such as em-dash stacking. A bare de-AI request (no `--style`) is unchanged; don't apply a guide to a genre it wasn't written for.
|
||||
|
||||
## Output format
|
||||
|
||||
### Rewrite mode (default)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* check-style.js — deterministic conformance check for a user-supplied house-style
|
||||
* config's MECHANICS. It verifies "did the output apply the config it was given"; it
|
||||
* does not judge register (that's the model's job). No bundled guides — the rules come
|
||||
* entirely from the config you pass.
|
||||
*
|
||||
* Usage: node scripts/check-style.js <file.md> --config <config.json|name> [--json]
|
||||
* Exit codes: 0 clean, 1 a hard violation, 2 a tool/usage error (missing or unreadable
|
||||
* config, no mechanics). Unrecognized config keys/values are surfaced as warnings.
|
||||
*
|
||||
* Hard-checkable mechanics: quotes, latinAbbrev. Heading case, em-dash rate, and number
|
||||
* spelling are ADVISORY (heading case can't be verified deterministically — proper nouns
|
||||
* make sentence and title case ambiguous). serialComma is declared but model-applied only.
|
||||
* See examples/README.md. Skipped before checking: frontmatter (only when it closes), code
|
||||
* (fenced and inline), and markdown link destinations, link titles, and reference-definition
|
||||
* tails, since a link title is delimited with straight quotes as SYNTAX.
|
||||
* HTML tags and their attribute values are masked too. Known limitations: straight
|
||||
* feet/inch primes (5'11") after a digit are carved out; an unclosed or multi-line HTML tag
|
||||
* still registers, as do quotes inside an HTML comment; and the latinAbbrev parenthesis
|
||||
* carve-out tracks depth across wrapped lines but resets at a paragraph break, so an
|
||||
* unclosed "(" disables that rule for the rest of its paragraph. A double-backtick code
|
||||
* span whose body contains a backtick leaks its body to the quote checks; a reference
|
||||
* definition with its title on the following line is read as prose; and indented code
|
||||
* inside a LIST item is treated as the item's prose (fence it to skip it).
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Resolve a --config argument to a file path. The decision is by SHAPE, not by what
|
||||
* happens to exist in the working directory: an arg with a path separator or a .json
|
||||
* suffix is a path (used as-is if it exists, else null); anything else is a bare name
|
||||
* resolved against examples/<name>.json. Deciding by shape stops a file named "technical"
|
||||
* sitting in the cwd from shadowing examples/technical.json. Bare names can't traverse
|
||||
* out of examples/. Null if nothing matches.
|
||||
*/
|
||||
function resolveConfig(arg) {
|
||||
if (!arg) return null;
|
||||
if (/[\\/]/.test(arg) || /\.json$/i.test(arg)) return fs.existsSync(arg) ? arg : null;
|
||||
const q = arg.trim().toLowerCase();
|
||||
// No traversal guard needed: a bare name has no separator (anything with one took the
|
||||
// path branch above), so it joins as a single segment inside examples/ by construction.
|
||||
const direct = path.join(__dirname, '..', 'examples', `${q}.json`);
|
||||
return fs.existsSync(direct) ? direct : null;
|
||||
}
|
||||
|
||||
// Known mechanics keys and their allowed values (for config validation).
|
||||
const KNOWN = {
|
||||
quotes: ['straight', 'curly'],
|
||||
headings: ['sentence', 'title'],
|
||||
latinAbbrev: ['parentheses', 'never', 'any'],
|
||||
emDash: ['sparing', 'deliberate'],
|
||||
spellNumbersUpTo: 'number',
|
||||
serialComma: 'boolean',
|
||||
};
|
||||
|
||||
const SMALL = new Set(['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'with', 'vs', 'nor', 'so', 'yet']);
|
||||
const majorWords = (h) => h.replace(/[*_`]/g, '').trim().split(/\s+/).slice(1)
|
||||
.map((w) => w.replace(/[^A-Za-z]/g, ''))
|
||||
.filter((b) => b && b !== b.toUpperCase() && !SMALL.has(b.toLowerCase())); // drop acronyms + minor words
|
||||
const isTitleCase = (h) => majorWords(h).filter((b) => /^[A-Z]/.test(b)).length >= 2;
|
||||
const looksSentenceCase = (h) => { const w = majorWords(h); return w.length >= 1 && w.every((b) => /^[a-z]/.test(b)); };
|
||||
|
||||
/**
|
||||
* Blank a markdown link destination and title, `](...)`, keeping the link text. Walks to
|
||||
* the MATCHING paren so a nested one in the URL (a Wikipedia disambiguation link, say)
|
||||
* doesn't terminate it early and leave a stray `)` behind to corrupt the paren balance.
|
||||
* Scans linearly: a regex here is quadratic on `](`-heavy input. An unclosed `](` is not a
|
||||
* link, so the rest of the line is left alone rather than swallowed.
|
||||
*/
|
||||
function maskLinks(s) {
|
||||
let out = '', i = 0;
|
||||
for (;;) {
|
||||
const j = s.indexOf('](', i);
|
||||
if (j < 0) return out + s.slice(i);
|
||||
let depth = 0, k = j + 1, closed = false;
|
||||
for (; k < s.length; k += 1) {
|
||||
if (s[k] === '(') depth += 1;
|
||||
else if (s[k] === ')') { depth -= 1; if (depth === 0) { k += 1; closed = true; break; } }
|
||||
}
|
||||
if (!closed) return out + s.slice(i);
|
||||
out += s.slice(i, j + 1); // keep through the ']'
|
||||
i = k;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank the tail of a reference definition (`[1]: https://x "Title"`), whose title also
|
||||
* uses straight quotes as syntax. Requires a destination and an optional title and nothing
|
||||
* else, so ordinary prose that happens to start `[sic]: he said "hi"` is left alone. Title
|
||||
* bodies allow backslash escapes, which is how a title legally contains its own delimiter.
|
||||
* MUST run before maskLinks: masking `[a](url): x` down to `[a]: x` would otherwise make an
|
||||
* ordinary link-led definition list line look like a reference definition and blank it.
|
||||
*/
|
||||
const TITLE = '"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|\\((?:\\\\.|[^)\\\\])*\\)';
|
||||
const REF_DEF = new RegExp(`^(\\s*\\[[^\\]]+\\]:\\s*)(?:<[^>]*>|\\S+)(?:\\s+(?:${TITLE}))?\\s*$`);
|
||||
const maskRefDef = (s) => s.replace(REF_DEF, '$1');
|
||||
|
||||
/**
|
||||
* Blank HTML tags, whose attribute values are straight-quoted as syntax. The shape follows
|
||||
* CommonMark's raw-HTML grammar (tag name, then attribute-shaped pairs) rather than
|
||||
* "<letter ... >": the loose form swallowed ordinary prose containing a comparison, such as
|
||||
* `For n<N, the "tail" sum > epsilon`, hiding real violations between the two brackets.
|
||||
*/
|
||||
const maskTags = (s) => s.replace(
|
||||
/<\/?[a-zA-Z][a-zA-Z0-9-]*(?:\s+[a-zA-Z_:][\w:.-]*(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*\s*\/?>/g, '');
|
||||
|
||||
/** Returns { hard, advisory, warnings } for the config's mechanics; register is not checked. */
|
||||
function check(text, mechanics) {
|
||||
const m = mechanics || {};
|
||||
const warnings = [];
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (!(k in KNOWN)) { warnings.push({ rule: 'unknown-key', detail: k }); continue; }
|
||||
const spec = KNOWN[k];
|
||||
if (Array.isArray(spec)) { if (!spec.includes(v)) warnings.push({ rule: 'unknown-value', detail: `${k}: ${JSON.stringify(v)}` }); }
|
||||
else if (typeof v !== spec) warnings.push({ rule: 'unknown-value', detail: `${k}: ${JSON.stringify(v)} (expected ${spec})` });
|
||||
}
|
||||
|
||||
const lines = text.replace(/^\uFEFF/, '').split('\n'); // a BOM would hide the frontmatter
|
||||
const bare = (s) => s.replace(/\r$/, '');
|
||||
// Frontmatter: skip a leading --- ... --- block only when it actually closes.
|
||||
let fmEnd = -1;
|
||||
// Only an opener with content on the next line is frontmatter: a document that OPENS
|
||||
// with a thematic break (`---`, blank line after) would otherwise be swallowed to the
|
||||
// next `---` anywhere in the file, silently hiding everything between.
|
||||
if (bare(lines[0]) === '---' && lines.length > 1 && !/^\s*$/.test(bare(lines[1]))) {
|
||||
for (let k = 1; k < lines.length; k += 1) { if (bare(lines[k]) === '---') { fmEnd = k; break; } }
|
||||
}
|
||||
// Fences track their character and length, so an inner ``` doesn't close an outer ````
|
||||
// and a ~~~ doesn't close a ``` block. A bare toggle desyncs on a nested fence and then
|
||||
// checks the code inside it as prose, which is a hard violation on a correct document.
|
||||
let inFence = false, fenceChar = '', fenceLen = 0;
|
||||
// Indented code (4+ spaces or a tab) opens only after a blank line and outside a list:
|
||||
// a 4-space line directly under a paragraph is lazy continuation (prose), and inside a
|
||||
// list item it is the item's own content (prose), so both stay checked. List tracking is
|
||||
// approximate (a marker line enters list context, a flush-left non-marker line leaves
|
||||
// it); a code block nested inside a list item needs a fence to be skipped.
|
||||
let inIndent = false, prevBlank = true, listCtx = false;
|
||||
// A paragraph break is a blank line in the ORIGINAL that is not inside frontmatter or a
|
||||
// fence. Masked-empty lines aren't breaks (that would cut a parenthetical at a code
|
||||
// block), and a blank line inside a fence isn't either.
|
||||
const paraBreak = [];
|
||||
const prose = lines.map((l, i) => {
|
||||
if (i <= fmEnd) { paraBreak.push(false); return ''; }
|
||||
const b = bare(l);
|
||||
const fm = b.match(/^\s*(`{3,}|~{3,})(.*)$/);
|
||||
if (fm) {
|
||||
const ch = fm[1][0], len = fm[1].length;
|
||||
if (!inFence) { inFence = true; fenceChar = ch; fenceLen = len; }
|
||||
else if (ch === fenceChar && len >= fenceLen && /^\s*$/.test(fm[2])) inFence = false;
|
||||
paraBreak.push(false);
|
||||
inIndent = false; prevBlank = false;
|
||||
return '';
|
||||
}
|
||||
if (inFence) { paraBreak.push(false); prevBlank = false; return ''; }
|
||||
const blank = /^\s*$/.test(b);
|
||||
const ind4 = /^(?: {4}|\t)/.test(b);
|
||||
if (blank) inIndent = false;
|
||||
else if (!inIndent && ind4 && prevBlank && !listCtx) inIndent = true;
|
||||
else if (!ind4) inIndent = false;
|
||||
const isCode = !blank && inIndent;
|
||||
if (!blank && !isCode) {
|
||||
if (/^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/.test(b)) listCtx = true;
|
||||
else if (/^\S/.test(b)) listCtx = false;
|
||||
}
|
||||
prevBlank = blank;
|
||||
paraBreak.push(blank);
|
||||
if (isCode) return '';
|
||||
// Strip inline code, then link destinations and reference-definition tails. Markdown
|
||||
// link titles are delimited with STRAIGHT quotes as syntax, so leaving them in makes
|
||||
// quotes:curly hard-fail an ordinary titled link on a correct document.
|
||||
return maskTags(maskLinks(maskRefDef(b.replace(/`+[^`]*`+/g, ''))));
|
||||
});
|
||||
const joined = prose.join('\n');
|
||||
const words = (joined.match(/\b\w+\b/g) || []).length;
|
||||
const hard = [];
|
||||
const advisory = [];
|
||||
const heads = prose.map((l, i) => [i + 1, l.match(/^#{1,6}\s+(.*)$/)]).filter(([, h]) => h);
|
||||
|
||||
if (m.quotes === 'straight') {
|
||||
prose.forEach((l, i) => { if (/[“”‘’]/.test(l)) hard.push({ line: i + 1, rule: 'quotes-should-be-straight' }); });
|
||||
} else if (m.quotes === 'curly') {
|
||||
prose.forEach((l, i) => {
|
||||
const c = l.replace(/(\d)['"]/g, '$1'); // carve out feet/inch primes (5'11")
|
||||
if (/"/.test(c)) hard.push({ line: i + 1, rule: 'double-quote-should-be-curly' });
|
||||
if (/[A-Za-z]'[A-Za-z]|[A-Za-z]'(?!\w)|(^|\s)'/.test(c)) hard.push({ line: i + 1, rule: 'apostrophe-should-be-curly' });
|
||||
});
|
||||
}
|
||||
|
||||
// Heading case is ADVISORY: proper nouns make sentence vs title case ambiguous, so it
|
||||
// can't be verified deterministically without false positives on ordinary headings.
|
||||
if (m.headings === 'sentence') {
|
||||
heads.forEach(([ln, h]) => { if (isTitleCase(h[1])) advisory.push({ line: ln, rule: 'heading-may-need-sentence-case' }); });
|
||||
} else if (m.headings === 'title') {
|
||||
heads.forEach(([ln, h]) => { if (looksSentenceCase(h[1])) advisory.push({ line: ln, rule: 'heading-may-need-title-case' }); });
|
||||
}
|
||||
|
||||
if (m.latinAbbrev === 'parentheses' || m.latinAbbrev === 'never') {
|
||||
// Paren depth carries across lines, so a parenthetical that wraps keeps its carve-out,
|
||||
// but RESETS at a blank line: a prose parenthetical can't span a paragraph, and without
|
||||
// the reset one stray "(" (a ":(" smiley) would suppress every later finding in the
|
||||
// document. That trades an unbounded silent false negative for a paragraph-wide one.
|
||||
let depth = 0;
|
||||
prose.forEach((l, i) => {
|
||||
if (paraBreak[i]) { depth = 0; return; }
|
||||
const re = /\b(e\.g\.|i\.e\.)/gi;
|
||||
let mm;
|
||||
while ((mm = re.exec(l)) !== null) {
|
||||
if (m.latinAbbrev === 'never') { hard.push({ line: i + 1, rule: 'latin-abbrev-not-allowed' }); continue; }
|
||||
const before = l.slice(0, mm.index);
|
||||
const at = depth + (before.match(/\(/g) || []).length - (before.match(/\)/g) || []).length;
|
||||
if (at <= 0) hard.push({ line: i + 1, rule: 'latin-abbrev-outside-parens' });
|
||||
}
|
||||
depth = Math.max(0, depth + (l.match(/\(/g) || []).length - (l.match(/\)/g) || []).length);
|
||||
});
|
||||
}
|
||||
|
||||
if (m.emDash === 'sparing') {
|
||||
const em = (joined.match(/—/g) || []).length;
|
||||
if (em > Math.floor(words / 1000)) advisory.push({ rule: 'em-dash-rate', detail: `${em} in ${words} words` });
|
||||
}
|
||||
|
||||
if (typeof m.spellNumbersUpTo === 'number') {
|
||||
prose.forEach((l, i) => {
|
||||
if (/^\s*([-*]|\d+\.)\s/.test(l)) return;
|
||||
const nums = (l.match(/(?<![\w.$:])\d{1,3}(?![\w.%:])/g) || []).filter((n) => +n <= m.spellNumbersUpTo);
|
||||
if (nums.length) advisory.push({ line: i + 1, rule: 'number-may-need-spelling', detail: nums.join(', ') });
|
||||
});
|
||||
}
|
||||
|
||||
return { hard, advisory, warnings };
|
||||
}
|
||||
|
||||
module.exports = { check, resolveConfig };
|
||||
|
||||
if (require.main === module) {
|
||||
// Parse by position, not by value: the old `a !== cfgArg` filter made a file whose name
|
||||
// equalled the config value (e.g. a real file `technical` with `--config technical`)
|
||||
// unreachable, and silently ignored a second file argument.
|
||||
const argv = process.argv.slice(2);
|
||||
let cfgArg = null, json = false, file = null;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const a = argv[i];
|
||||
if (a === '--config') {
|
||||
const nx = argv[i + 1];
|
||||
if (nx !== undefined && !nx.startsWith('--')) { cfgArg = nx; i += 1; } // else: missing value
|
||||
} else if (a === '--json') { json = true; }
|
||||
else if (a.startsWith('--')) { console.error(`unknown flag: ${a}`); process.exit(2); }
|
||||
else if (file === null) { file = a; }
|
||||
else { console.error(`unexpected extra argument: ${a}`); process.exit(2); }
|
||||
}
|
||||
if (!file || !cfgArg) { console.error('usage: check-style.js <file> --config <config.json|name> [--json]'); process.exit(2); }
|
||||
const cfgPath = resolveConfig(cfgArg);
|
||||
if (!cfgPath) {
|
||||
console.error(`config not found: "${cfgArg}"\nPass a path to a JSON config, or a name matching a file in examples/ (for example, --config technical). See examples/README.md.`);
|
||||
process.exit(2);
|
||||
}
|
||||
let config;
|
||||
try { config = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
|
||||
catch (e) { console.error(`could not read config "${cfgPath}": ${e.message}`); process.exit(2); }
|
||||
if (!config || typeof config !== 'object' || typeof config.mechanics !== 'object'
|
||||
|| config.mechanics === null || Array.isArray(config.mechanics)) {
|
||||
console.error(`config "${cfgPath}" has no "mechanics" object`); process.exit(2);
|
||||
}
|
||||
let text;
|
||||
try { text = fs.readFileSync(file, 'utf8'); }
|
||||
catch (e) { console.error(`could not read file "${file}": ${e.message}`); process.exit(2); }
|
||||
const r = check(text, config.mechanics);
|
||||
if (json) {
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
} else {
|
||||
const w = r.warnings.length ? `, ${r.warnings.length} config warning(s)` : '';
|
||||
console.log(`${config.name || cfgPath}: ${r.hard.length} hard, ${r.advisory.length} advisory${w}`);
|
||||
r.warnings.forEach((x) => console.log(` ! ${x.rule}: ${x.detail}`));
|
||||
r.hard.forEach((x) => console.log(` L${x.line || '-'} ${x.rule}`));
|
||||
r.advisory.forEach((x) => console.log(` L${x.line || '-'} ${x.rule} (advisory)${x.detail ? `: ${x.detail}` : ''}`));
|
||||
}
|
||||
process.exit(r.hard.length > 0 ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env node
|
||||
/* Tests for scripts/check-style.js — run by `npm test`. */
|
||||
'use strict';
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { check, resolveConfig } = require('./check-style.js');
|
||||
|
||||
let passed = 0;
|
||||
const t = (name, fn) => { fn(); passed += 1; process.stdout.write(` ✓ ${name}\n`); };
|
||||
|
||||
// --- quotes (hard) ---
|
||||
t('quotes:straight flags curly marks; a clean doc passes', () => {
|
||||
assert.strictEqual(check('# ok\n\nplain "straight" text', { quotes: 'straight' }).hard.length, 0);
|
||||
assert.ok(check('# ok\n\nuse the “retry” option', { quotes: 'straight' }).hard.some((x) => x.rule === 'quotes-should-be-straight'));
|
||||
});
|
||||
t('quotes:curly flags straight quotes and apostrophes', () => {
|
||||
const r = check('She said "hi" and it\'s fine.', { quotes: 'curly' });
|
||||
assert.ok(r.hard.some((x) => x.rule === 'double-quote-should-be-curly'));
|
||||
assert.ok(r.hard.some((x) => x.rule === 'apostrophe-should-be-curly'));
|
||||
});
|
||||
t('quotes:curly carves out feet/inch primes (5\'11")', () => {
|
||||
assert.strictEqual(check('The wall is 5\'11" tall.', { quotes: 'curly' }).hard.length, 0);
|
||||
});
|
||||
|
||||
// --- latinAbbrev (hard): never (Google) vs parentheses (Chicago) ---
|
||||
t('latinAbbrev:never flags any e.g./i.e.', () => {
|
||||
assert.ok(check('Retry, e.g. 3 times.', { latinAbbrev: 'never' }).hard.some((x) => x.rule === 'latin-abbrev-not-allowed'));
|
||||
assert.strictEqual(check('Retry, for example 3 times.', { latinAbbrev: 'never' }).hard.length, 0);
|
||||
});
|
||||
t('latinAbbrev:parentheses flags outside parens, not inside (must-not-fire)', () => {
|
||||
assert.ok(check('Retry, e.g. 3 times.', { latinAbbrev: 'parentheses' }).hard.some((x) => x.rule === 'latin-abbrev-outside-parens'));
|
||||
assert.strictEqual(check('Retry a few times (e.g., 3).', { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||||
});
|
||||
|
||||
// --- heading case is ADVISORY now (proper nouns make it undecidable) ---
|
||||
t('a title-case heading is advisory under headings:sentence, never hard', () => {
|
||||
const r = check('# Configure Your Retries\n\ntext', { headings: 'sentence' });
|
||||
assert.strictEqual(r.hard.length, 0);
|
||||
assert.ok(r.advisory.some((x) => x.rule === 'heading-may-need-sentence-case'));
|
||||
});
|
||||
t('a proper-noun heading is NOT a hard violation', () => {
|
||||
// "# Configure Docker and Kubernetes" is correct sentence case; must not hard-fail.
|
||||
assert.strictEqual(check('# Configure Docker and Kubernetes\n\ntext', { headings: 'sentence' }).hard.length, 0);
|
||||
assert.strictEqual(check('# Deploying to Google Cloud\n\ntext', { headings: 'sentence' }).hard.length, 0);
|
||||
});
|
||||
t('headings:title flags a fully-lowercase heading as advisory', () => {
|
||||
assert.ok(check('# a plain heading\n\ntext', { headings: 'title' }).advisory.some((x) => x.rule === 'heading-may-need-title-case'));
|
||||
});
|
||||
|
||||
// --- config validation: unrecognized keys/values become warnings ---
|
||||
t('unknown key and unknown value are warned, not silently ignored', () => {
|
||||
const r = check('text', { quote: 'straight', headings: 'sentance' });
|
||||
assert.ok(r.warnings.some((x) => x.rule === 'unknown-key' && x.detail === 'quote'));
|
||||
assert.ok(r.warnings.some((x) => x.rule === 'unknown-value' && x.detail.startsWith('headings')));
|
||||
});
|
||||
t('a valid config produces no warnings', () => {
|
||||
assert.strictEqual(check('text', { quotes: 'straight', headings: 'sentence', serialComma: true }).warnings.length, 0);
|
||||
});
|
||||
|
||||
// --- structure/code protection ---
|
||||
t('frontmatter that closes is skipped; unterminated frontmatter does NOT swallow the doc', () => {
|
||||
assert.strictEqual(check('---\ntitle: "x"\n---\n# ok\n\nRun `git --force`.', { quotes: 'straight' }).hard.length, 0);
|
||||
// no closing --- : the body must still be checked, not silently blanked
|
||||
assert.ok(check('---\nx\n\nuse the “retry” option', { quotes: 'straight' }).hard.some((x) => x.rule === 'quotes-should-be-straight'));
|
||||
});
|
||||
t('empty/omitted mechanics check nothing and warn nothing', () => {
|
||||
const r = check('anything "here" it\'s fine', {});
|
||||
assert.strictEqual(r.hard.length, 0);
|
||||
assert.strictEqual(r.warnings.length, 0);
|
||||
});
|
||||
t('markdown link titles and reference definitions are syntax, not curly-quote violations', () => {
|
||||
// Link titles MUST use straight quotes; flagging them hard-fails a correct document.
|
||||
assert.strictEqual(check('See the [docs](https://x.example "The Title") for more.', { quotes: 'curly' }).hard.length, 0);
|
||||
assert.strictEqual(check('[1]: https://x.example "Ref Title"', { quotes: 'curly' }).hard.length, 0);
|
||||
// A real straight quote in prose on the same line is still caught.
|
||||
assert.ok(check('He said "hi" in [docs](https://x.example "T").', { quotes: 'curly' }).hard.length > 0);
|
||||
});
|
||||
t('a parenthetical that wraps across lines keeps its latinAbbrev carve-out', () => {
|
||||
const wrapped = 'A long aside (this parenthetical wraps,\ne.g. across two lines) ends here.';
|
||||
assert.strictEqual(check(wrapped, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||||
// Still fires once the parenthetical has closed.
|
||||
assert.ok(check('An aside (closed here).\ne.g. now outside.', { latinAbbrev: 'parentheses' }).hard.some((x) => x.rule === 'latin-abbrev-outside-parens'));
|
||||
});
|
||||
t('a stray "(" does not suppress later latinAbbrev findings past the paragraph', () => {
|
||||
// Regression: paren depth carried document-wide, so one ":(" silenced every later
|
||||
// finding. Depth must reset at a blank line.
|
||||
const stray = 'This made me sad :( honestly.\n\nLater we argue, e.g. this point, outside any parens.';
|
||||
assert.ok(check(stray, { latinAbbrev: 'parentheses' }).hard.some((x) => x.rule === 'latin-abbrev-outside-parens'));
|
||||
});
|
||||
t('a link whose URL contains parentheses does not corrupt the paren balance', () => {
|
||||
// Regression: [^)]* mis-terminated on a nested paren and left a stray ")" behind.
|
||||
const wiki = '(We cite [Foo](https://en.wikipedia.org/wiki/Foo_(bar)), e.g. this one.)';
|
||||
assert.strictEqual(check(wiki, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||||
// ...and its straight-quoted title is still masked for quotes:curly.
|
||||
assert.strictEqual(check('[Foo](https://en.wikipedia.org/wiki/Foo_(bar) "The Title")', { quotes: 'curly' }).hard.length, 0);
|
||||
});
|
||||
t('an unclosed "](" is left alone rather than swallowing the rest of the line', () => {
|
||||
assert.ok(check('weird ]( text with "quotes" here.', { quotes: 'curly' }).hard.length > 0);
|
||||
});
|
||||
t('prose that merely looks like a reference definition keeps its quotes checked', () => {
|
||||
assert.ok(check('[sic]: he said "hi" plainly.', { quotes: 'curly' }).hard.length > 0);
|
||||
});
|
||||
t('a link-led definition-list line is not swallowed as a reference definition', () => {
|
||||
// Regression: masking the destination left "[Docs]: ..." which re-parsed as a ref
|
||||
// definition, silently blanking the rest of an ordinary line.
|
||||
assert.ok(check('[Docs](https://d.example): "Note"', { quotes: 'curly' }).hard.length > 0);
|
||||
assert.ok(check('[Docs](https://d.example): e.g. outside.', { latinAbbrev: 'parentheses' }).hard.length > 0);
|
||||
});
|
||||
t('a reference-definition title may contain escaped quotes', () => {
|
||||
assert.strictEqual(check('[1]: https://x.example "say \\"hi\\""', { quotes: 'curly' }).hard.length, 0);
|
||||
});
|
||||
t('a parenthetical survives an intervening code block or code-only line', () => {
|
||||
// Regression: fenced/code-only lines mask to '' and were read as paragraph breaks,
|
||||
// resetting the paren depth mid-parenthetical.
|
||||
const fenced = '(See the config, for instance:\n```json\n{}\n```\nand, e.g., it closes here.)';
|
||||
assert.strictEqual(check(fenced, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||||
// A BLANK line inside the fence is not a paragraph break either.
|
||||
const gappy = '(See the config:\n```json\n{\n\n}\n```\nand, e.g., it closes here.)';
|
||||
assert.strictEqual(check(gappy, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||||
const inline = '(An aside about\n`some code`\ne.g. still inside the parens.)';
|
||||
assert.strictEqual(check(inline, { latinAbbrev: 'parentheses' }).hard.length, 0);
|
||||
});
|
||||
t('HTML attribute values are syntax, not curly-quote violations', () => {
|
||||
assert.strictEqual(check('<div class="callout">\n\nText.', { quotes: 'curly' }).hard.length, 0);
|
||||
assert.strictEqual(check('<img alt="a chart" />', { quotes: 'curly' }).hard.length, 0);
|
||||
});
|
||||
t('prose containing a comparison is not mistaken for an HTML tag', () => {
|
||||
// Regression: a loose "<letter ... >" mask swallowed everything between the brackets,
|
||||
// hiding real violations in ordinary technical prose.
|
||||
assert.ok(check('For n<N, the "tail" sum > epsilon.', { quotes: 'curly' }).hard.length > 0);
|
||||
assert.ok(check('if a<b it isn\'t > c.', { quotes: 'curly' }).hard.length > 0);
|
||||
assert.ok(check('When x<y (e.g. small) > z holds.', { latinAbbrev: 'never' }).hard.length > 0);
|
||||
});
|
||||
t('a UTF-8 BOM does not hide the frontmatter', () => {
|
||||
assert.strictEqual(check('---\ntitle: "x"\n---\nClean text.', { quotes: 'curly' }).hard.length, 0);
|
||||
});
|
||||
t('nested and mismatched fences do not leak code as prose (no false hard violation)', () => {
|
||||
// An inner ``` inside an outer ```` is code content; curly quotes there must NOT flag.
|
||||
const nested = '# ok\n\n````\nouter\n```\ninner “q”\n```\n````\n\nclean text';
|
||||
assert.strictEqual(check(nested, { quotes: 'straight' }).hard.length, 0);
|
||||
// A ~~~ line inside a ``` block does not close it either.
|
||||
const tilde = '# ok\n\n```\n~~~\ninner “q”\n~~~\n```\n\nclean text';
|
||||
assert.strictEqual(check(tilde, { quotes: 'straight' }).hard.length, 0);
|
||||
// Prose after a properly closed fence is still checked.
|
||||
assert.ok(check('# ok\n\n```\ncode\n```\n\nuse the “retry” option', { quotes: 'straight' }).hard.some((x) => x.rule === 'quotes-should-be-straight'));
|
||||
});
|
||||
|
||||
// --- resolution (filename / path / unknown / traversal); no guide aliases ---
|
||||
// path.join, not a '/' literal: on Windows these are examples\technical.json, so a
|
||||
// hardcoded forward slash fails the whole suite locally while ubuntu CI stays green.
|
||||
const EX = (f) => path.join(__dirname, '..', 'examples', f);
|
||||
t('resolveConfig: filename, path, unknown, traversal (no guide aliases)', () => {
|
||||
assert.strictEqual(resolveConfig('technical'), EX('technical.json'));
|
||||
assert.strictEqual(resolveConfig('prose'), EX('prose.json'));
|
||||
assert.strictEqual(resolveConfig(EX('technical.json')), EX('technical.json'));
|
||||
assert.strictEqual(resolveConfig('cmos'), null); // no guide alias resolution
|
||||
assert.strictEqual(resolveConfig('no-such-guide'), null);
|
||||
assert.strictEqual(resolveConfig('../package'), null); // bare names can't traverse
|
||||
});
|
||||
t('a bare name is not shadowed by a same-named file in the working directory', () => {
|
||||
// A file literally named "technical" in cwd must NOT hijack the bare-name lookup.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-shadow-'));
|
||||
fs.writeFileSync(path.join(dir, 'technical'), '# not a config');
|
||||
const cwd = process.cwd();
|
||||
process.chdir(dir);
|
||||
try { assert.strictEqual(resolveConfig('technical'), EX('technical.json')); }
|
||||
finally { process.chdir(cwd); fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
// --- shipped example configs are generic (no guide names/aliases), parse, and apply ---
|
||||
t('example configs are generic, parse with register + mechanics', () => {
|
||||
const tech = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'examples', 'technical.json'), 'utf8'));
|
||||
const prose = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'examples', 'prose.json'), 'utf8'));
|
||||
for (const cfg of [tech, prose]) {
|
||||
assert.ok(Array.isArray(cfg.register) && cfg.register.length);
|
||||
assert.ok(cfg.mechanics && typeof cfg.mechanics === 'object');
|
||||
assert.ok(cfg.aliases === undefined, 'no aliases shipped');
|
||||
assert.ok(!/google|chicago|cmos|apa/i.test(cfg.name || ''), 'name is guide-neutral');
|
||||
}
|
||||
assert.strictEqual(tech.mechanics.latinAbbrev, 'never');
|
||||
assert.strictEqual(prose.mechanics.latinAbbrev, 'parentheses');
|
||||
assert.strictEqual(check('# Configure retries\n\nSet the value.', tech.mechanics).hard.length, 0);
|
||||
});
|
||||
|
||||
// --- CLI exit codes: 0 clean, 1 hard, 2 tool error ---
|
||||
const cli = (mdText, cfgArg) => {
|
||||
const f = path.join(os.tmpdir(), `cs-${passed}-${Math.floor(process.hrtime()[1])}.md`);
|
||||
fs.writeFileSync(f, mdText);
|
||||
const r = spawnSync('node', [path.join(__dirname, 'check-style.js'), f, '--config', cfgArg], { encoding: 'utf8' });
|
||||
fs.unlinkSync(f);
|
||||
return r.status;
|
||||
};
|
||||
t('CLI exits 0 clean, 1 on a hard violation, 2 on a tool error', () => {
|
||||
assert.strictEqual(cli('# ok\n\nplain text', 'technical'), 0);
|
||||
assert.strictEqual(cli('# ok\n\nuse the “curly” quote', 'technical'), 1); // technical => quotes straight
|
||||
assert.strictEqual(cli('# ok\n\ntext', 'no-such-guide'), 2);
|
||||
});
|
||||
|
||||
// raw-argv runner: write {name: content} files into a temp dir, run the CLI from that
|
||||
// dir with the given argv, return exit status; cleans up.
|
||||
const cliRaw = (files, argv) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-cli-'));
|
||||
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
|
||||
const r = spawnSync('node', [path.join(__dirname, 'check-style.js'), ...argv], { cwd: dir, encoding: 'utf8' });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
return r.status;
|
||||
};
|
||||
t('CLI: a second file, an unknown flag, and a missing --config value each exit 2', () => {
|
||||
assert.strictEqual(cliRaw({ 'a.md': '# ok\n\ntext', 'b.md': '# ok\n\ntext' }, ['a.md', 'b.md', '--config', 'technical']), 2);
|
||||
assert.strictEqual(cliRaw({ 'a.md': '# ok\n\ntext' }, ['a.md', '--config', 'technical', '--bogus']), 2);
|
||||
assert.strictEqual(cliRaw({ 'a.md': '# ok\n\ntext' }, ['a.md', '--config']), 2);
|
||||
});
|
||||
t('CLI: a config whose mechanics is null or an array exits 2, not a false-green 0', () => {
|
||||
const withCfg = (cfgJson) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-mech-'));
|
||||
fs.writeFileSync(path.join(dir, 'a.md'), '# ok\n\ntext');
|
||||
fs.writeFileSync(path.join(dir, 'c.json'), cfgJson);
|
||||
const r = spawnSync('node', [path.join(__dirname, 'check-style.js'), 'a.md', '--config', 'c.json'], { cwd: dir, encoding: 'utf8' });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
return r.status;
|
||||
};
|
||||
assert.strictEqual(withCfg('{"mechanics": null}'), 2);
|
||||
assert.strictEqual(withCfg('{"mechanics": []}'), 2);
|
||||
assert.strictEqual(withCfg('{"mechanics": {"quotes": "straight"}}'), 0);
|
||||
});
|
||||
t('CLI: a file named like the config value is still reachable (not filtered by value)', () => {
|
||||
// `--config technical` resolves to examples/; the positional file also named `technical`
|
||||
// must be read and checked (clean => 0), not treated as unreachable.
|
||||
assert.strictEqual(cliRaw({ technical: '# ok\n\nplain text' }, ['technical', '--config', 'technical']), 0);
|
||||
});
|
||||
|
||||
// --- indented code blocks (masked) vs lazy continuation and list content (prose) ---
|
||||
t('4-space indented code after a blank line is masked (must-not-fire)', () => {
|
||||
assert.strictEqual(check('Para.\n\n code with "straight" quotes\n\nMore.', { quotes: 'curly' }).hard.length, 0);
|
||||
assert.strictEqual(check('Para.\n\n\tx = f(a, e.g., b)\n\nMore.', { latinAbbrev: 'never' }).hard.length, 0);
|
||||
});
|
||||
t('a 4-space line directly under a paragraph is lazy continuation and still checked', () => {
|
||||
assert.ok(check('Para starts\n still "prose" here.', { quotes: 'curly' }).hard.length > 0);
|
||||
});
|
||||
t('4-space content inside a list item is prose and still checked', () => {
|
||||
assert.ok(check('- item\n\n second "para" of the item', { quotes: 'curly' }).hard.length > 0);
|
||||
});
|
||||
t('a paragraph after a list resets list context, so indented code masks again', () => {
|
||||
assert.strictEqual(check('- item\n\nPlain paragraph.\n\n code "x"\n\nAfter.', { quotes: 'curly' }).hard.length, 0);
|
||||
});
|
||||
|
||||
// --- a leading thematic break is not frontmatter ---
|
||||
t('a document opening with --- then a blank line is not frontmatter (must-fire)', () => {
|
||||
assert.ok(check('---\n\nHe said "hi".\n\n---\n\nmore', { quotes: 'curly' }).hard.length > 0);
|
||||
});
|
||||
t('real frontmatter still masks, including a quoted value', () => {
|
||||
assert.strictEqual(check('---\ntitle: "Straight quotes are yaml"\n---\n\nplain text', { quotes: 'curly' }).hard.length, 0);
|
||||
});
|
||||
|
||||
// --- bare-name containment is by construction (no separator reaches that branch) ---
|
||||
t('dot-bearing bare names resolve inside examples/ only, and miss', () => {
|
||||
assert.strictEqual(resolveConfig('..'), null);
|
||||
assert.strictEqual(resolveConfig('foo..bar'), null);
|
||||
});
|
||||
|
||||
console.log(`\ncheck-style: ${passed} passed.`);
|
||||
@@ -48,6 +48,7 @@ const BUDGETS = {
|
||||
'CONTRIBUTING.md': 15,
|
||||
'detector/README.md': 15,
|
||||
'detector/CATEGORIES.md': 15,
|
||||
'examples/README.md': 10,
|
||||
// Higher than the rest on purpose. A changelog enumerates the pattern names
|
||||
// it added ("bustling, intricate, ever-evolving"), unquoted, which the
|
||||
// exemption cannot reach, and Keep-a-Changelog headings carry a
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
# Run this after editing SKILL.md. CI fails if the copy is out of sync.
|
||||
#
|
||||
# The rule is a copy-out artifact: users curl it into their own project's
|
||||
# .cursor/rules/, where nothing else from this repo exists. Three spans in
|
||||
# .cursor/rules/, where nothing else from this repo exists. Five spans in
|
||||
# SKILL.md point at files in this repo, so the generator rewrites them the
|
||||
# same way the claude-code-templates vendoring did (davila7/claude-code-templates#773):
|
||||
# 1. "this repo measures the ratios" -> passive form (no repo to measure)
|
||||
# 2. the detector/CATEGORIES.md citation -> "reverted upstream"
|
||||
# 3. the node detector/validate.js mechanical check -> a manual prose check
|
||||
# 4. the --style config path (scripts/check-style.js, examples/) -> apply, unverified
|
||||
# 5. --style resolution by bare name out of examples/ -> a path only
|
||||
# Each rewrite is anchored on the exact upstream text and FAILS LOUDLY if the
|
||||
# anchor stops matching exactly once — so an upstream edit to one of those
|
||||
# spans breaks CI here instead of silently shipping a wrong Cursor rule.
|
||||
@@ -73,6 +75,18 @@ It exits non-zero when a rewrite altered a fenced code block, YAML frontmatter,
|
||||
Confirm the rewrite did not alter a fenced code block, YAML frontmatter, a blockquote, a table cell, inline code, a URL, a file path, or the heading structure, and that it did not introduce more flagged patterns than it removed. Those are the promises made above. Rewording a heading to fix Title Case and stripping an AI tracking parameter from a URL are the two carve-outs, because this skill instructs both.""",
|
||||
"span 3 (validate.js mechanical check)",
|
||||
)
|
||||
body = replace_once(
|
||||
body,
|
||||
"""**Preferred: a config file.** `--style ./house.json` (or a bare name matching `examples/<name>.json`) applies a user-supplied JSON config and verifies the checkable subset of its mechanics with `node scripts/check-style.js <file> --config <path>` (exit 0 clean / 1 hard violation / 2 tool error). A config is JSON: **`register`** (voice directives you apply as written) plus **`mechanics`** (`quotes` and `latinAbbrev` hard-checkable; `headings`, `emDash`, `spellNumbersUpTo` advisory; `serialComma` model-applied). Schema and rationale: `examples/README.md`. Open the output by naming the resolved config (`Applying config examples/technical.json; checkable mechanics verified.`), the way the fallback below names its guide, so which mode ran is never ambiguous.""",
|
||||
"""**Preferred: a config file.** `--style ./house.json` applies a user-supplied JSON config: **`register`** (voice directives you apply as written) plus **`mechanics`** (`quotes`, `latinAbbrev`, `headings`, `emDash`, `spellNumbersUpTo`, `serialComma`). Apply the register and enforce the mechanics as written. The upstream repo ships a deterministic checker for the checkable ones; without it, treat the mechanics as applied but unverified. Open the output by naming the config you applied, the way the fallback below names its guide, so which mode ran is never ambiguous.""",
|
||||
"span 4 (check-style.js config path)",
|
||||
)
|
||||
body = replace_once(
|
||||
body,
|
||||
"""**Resolving `--style <arg>`.** A path, or a bare name matching `examples/<name>.json`, loads that config (apply and verify); anything else is the named-guide fallback above.""",
|
||||
"""**Resolving `--style <arg>`.** A path to a JSON config loads it, and you apply it as written; anything else is the named-guide fallback above.""",
|
||||
"span 5 (--style resolution)",
|
||||
)
|
||||
|
||||
cursor_fm = f"""---
|
||||
description: Audit and rewrite content to remove AI writing patterns ("AI-isms"). Activate whenever editing prose-heavy files (Markdown, documentation, blog posts, READMEs, release notes, emails). Cursor port of the avoid-ai-writing skill v{version}. See https://github.com/conorbronsdon/avoid-ai-writing.
|
||||
|
||||
Reference in New Issue
Block a user