Skip documentation navigation
Documentation menu

Pattern packs

Most categories are one pack file. A pack whose category is a string joins that category, as closing-offer.json and backtick-branches.mjs join claude-tells. The built-in packs live under hooks/lib/patterns/: ai/ for writing patterns, git/ for commit, PR, and review checks, prose/ for punctuation. A project adds its own packs the same way.

A .mjs pack executes code from the checkout. The hook imports it, so its top-level code and its detect function run in the hook process with your permissions on every tool call. Read a .mjs pack before you add it. A .json pack holds data only.

Where packs load from, in order

  1. hooks/lib/patterns/ (the built-in packs).
  2. $XDG_CONFIG_HOME/concise/patterns/, else ~/.config/concise/patterns/ (the user packs, loaded for every project).
  3. <cwd>/.claude/concise/patterns/.
  4. <cwd>/.codex/concise/patterns/.
  5. Each entry in features.aiWriting.packs, in list order. BEC_LOAD_LIB_PATHS is appended to that list.

Each source is walked recursively, sorted by name, and only .json and .mjs files are read. presets.json is skipped. A later source with the same pack id replaces the earlier pack. Two files with the same id inside one source is an error, and so is a pack whose id does not match its file name. An invalid pack is skipped and reported once per session.

An entry in features.aiWriting.packs is a file or a directory, absolute or relative to the project directory.

Pack fields

FieldTypeRequiredWhat it does
idstringyesLowercase letters, digits, and hyphens. Must equal the file name without the extension.
featurestringyesaiWriting or emDash.
categorystring or objectyesA category id, or an object with id, label, description, and tags.
scopearraynoWhere the pack runs. Defaults to files, comments, gh, commit, reply.
presetsarraynoPreset names that turn the pack on.
tagsarraynoTags for tag:<tag> selection. Falls back to category.tags.
optionsobjectnoThresholds handed to detect as ctx.options.
notesstringnoProse printed under the category in the generated reference.
patternsarrayyes, unless detect is setPattern entries.
detectfunctionyes, unless patterns is set.mjs packs only.

Pattern entry fields:

FieldTypeRequiredWhat it does
phrasestringone of the 3A word-bounded phrase.
openingstringone of the 3A sentence-initial phrase.
regexstringone of the 3A raw regular expression.
flagsstringnoRegex flags for a regex entry. Defaults to gi.
fixstringyesThe replacement text printed in the deny message.
tier1 or 2noDefaults to 1.
showstringnoThe text the reference renderer prints instead of the expanded pattern.

A tier-2 pattern reports only when 2 distinct tier-2 patterns land in the same paragraph. A single tier-2 hit stays quiet.

A regex entry with a named group hit reports only that group as the flagged text.

The three pattern kinds

KindEntryCompiled form
phrase{"phrase": "delv(?:e|es|ed|ing)", "fix": "look at"}/\b(?:delv(?:e|es|ed|ing))\b/gi
opening{"opening": "ultimately", "fix": "cut"}/(?<=^|[.!?]\s+)(?:ultimately)/gim
regex{"regex": "\\bparadigm\\b(?! shift)", "fix": "model"}/\bparadigm\b(?! shift)/gi

Every ' inside a phrase or an opening compiles to ['’], so the straight and the curly apostrophe both match. The g flag is forced on for a regex entry.

scope values and the hook that feeds each

ScopeText scannedHook
filesA prose file, whole (md, mdx, markdown, txt, rst, adoc, asciidoc), and the plan ExitPlanMode showscheck-edit.mjs, check-shell-writes.mjs, check-tool-text.mjs
commentsComment runs in a code filecheck-edit.mjs, check-shell-writes.mjs, check-tool-text.mjs
codeA code file or a file of unknown type, whole, string literals includedcheck-edit.mjs, check-shell-writes.mjs, check-tool-text.mjs
ghA gh pr, gh issue, or gh release title or body, a gh api request body, and the text an MCP tool postscheck-bash.mjs, check-tool-text.mjs
commitA git commit, git tag, git merge, git notes, jj, or hg message, and the commit message of an MCP file pushcheck-bash.mjs, check-tool-text.mjs
commandThe full git or gh command linecheck-bash.mjs
replyThe agent’s final reply, task text, and questions to the usercheck-reply.mjs, check-tool-text.mjs

check-edit.mjs also reads a file that cat or tee writes from a heredoc, and a notebook cell: a markdown cell as a prose file, a code cell as a code file. check-shell-writes.mjs reads the lines a shell command added to files in a git work tree, after the command runs. check-tool-text.mjs reads a file an MCP tool writes the same way.

Leaving scope out gives the pack every scope except command and code.

presets semantics

A built-in pack lists the presets it belongs to. "presets": ["all"] restricts a pack to the all preset. The 8 preset names are default, ryan, technical, ste, minimal, git, statistical, and all, defined in hooks/lib/patterns/presets.json.

A pack of your own with no presets array is active under every preset. Give a pack "presets": ["all"] to keep it off until someone asks for it.

Setting features.aiWriting.categories replaces the preset’s category list, and only the listed category ids run.

Activation is per category. A pack that reuses a built-in category id joins that category, and the whole category then runs under every preset either one is active in. Give a pack its own category block to keep the built-in patterns out.

excludePacks and options

features.aiWriting.excludePacks drops a pack by id, built-in or not:

{
  "features": {
    "aiWriting": {
      "enabled": true,
      "excludePacks": ["ai-identity"],
      "options": { "passive-voice": { "minWords": 400, "maxRatio": 0.5 } }
    }
  }
}

features.aiWriting.options is keyed by pack id and merged over the pack’s own options. The merged object arrives as ctx.options.

features.aiWriting.disablePatterns takes a category id, a pack id, or tag:<tag> and drops the whole category. excludePacks does not reach prose/em-dash.json, because that pack belongs to the emDash feature. Turn features.emDash.enabled off instead.

A JSON pack

Save this as .claude/concise/patterns/team-words.json:

{
  "id": "team-words",
  "feature": "aiWriting",
  "category": {
    "id": "team-words",
    "label": "team words",
    "description": "Words this team replaced with a plain word.",
    "tags": ["phrase"]
  },
  "scope": ["files", "comments", "gh", "commit", "reply"],
  "presets": ["default", "ryan", "technical", "all"],
  "notes": "Added by the platform team.",
  "patterns": [
    { "phrase": "utili[sz](?:e|es|ed|ing)", "fix": "use" },
    { "opening": "as a reminder", "fix": "cut" },
    { "regex": "\\bsynerg(?:y|ies|istic)\\b", "fix": "name the shared part" }
  ]
}

An .mjs pack

Save this as .claude/concise/patterns/long-sentences.mjs:

export default {
  id: "long-sentences",
  feature: "aiWriting",
  category: {
    id: "long-sentences",
    label: "long sentences",
    description: "Flags a sentence past the word limit.",
    tags: ["statistical"],
  },
  scope: ["files", "gh", "reply"],
  presets: ["ryan", "statistical", "all"],
  options: { minWords: 100, maxSentenceWords: 40 },
  notes: "One finding per sentence over the limit.",
  detect(text, ctx) {
    const o = ctx.options;
    if (ctx.stats.words().length < o.minWords) return [];
    return ctx.stats
      .sentences()
      .filter((s) => s.text.split(/\s+/).filter(Boolean).length > o.maxSentenceWords)
      .map((s) => ({
        index: s.start,
        match: `sentence of ${s.text.split(/\s+/).filter(Boolean).length} words (max ${o.maxSentenceWords})`,
        fix: "split the sentence",
      }));
  },
};

detect(text, ctx) returns an array of { index, match, fix } findings. index is a character offset into text and has to be an integer. match is the flagged text printed in the deny message. fix defaults to rewrite. A finding with a missing index or match is dropped. A detect that throws is reported as a pack problem and the rest of the scan continues.

ctx holds:

KeyValue
ctx.pathThe file path, or null for a reply, commit message, gh body, or command.
ctx.scopeThe scope name the text came from.
ctx.optionsThe pack’s options, merged with features.aiWriting.options.<packId>.
ctx.rawThe text before code spans, fences, comments, and URLs are blanked. It has the same length as ctx.stats.text, so an index into one is an index into the other.
ctx.stats.textThe text being scanned.
ctx.stats.words(){ text, start, end } per word.
ctx.stats.sentences(){ text, start, end } per sentence.
ctx.stats.paragraphs(){ text, start, end } per paragraph.
ctx.stats.lines(){ text, start, end } per line.
ctx.stats.listBlocks(){ start, end, items } per list block, items holding { text, start, end, marker }.
ctx.stats.headings(){ text, start, end, level, title } per markdown heading.
ctx.stats.syllables(word)The syllable count for one word.

Each ctx.stats helper is memoized per scan.

Manage packs from the console

The concise-web console from the npm package can also switch, add, update, and remove packs. Its Rules page is described in Manage packs from the console in the repository README.

Validate and render

node plugins/concise/scripts/validate-packs.mjs
node plugins/concise/scripts/validate-packs.mjs .claude/concise/patterns

The validator checks the built-in packs plus any paths you pass, prints ok: <N> packs, <M> patterns, and exits 1 on a problem.

node plugins/concise/scripts/render-patterns.mjs
node plugins/concise/scripts/render-patterns.mjs --check

The renderer regenerates the reference tables under skills/concise-rules/references/ from the packs. --check exits 1 when the committed files are stale and prints the command to run.