After a Write/Edit to public copy (globs in the repo's .claude/copy-lint.json), checks banned words/phrases and heading punctuation and hints the model; never…

Lints public-facing copy after each Write/Edit against the repo's own rules and hints the model; never blocks the write.
Write / Edit 成功之后,如果目标文件算「对外文案」,就对写入的文本查:
# 行,或 JSON 里 title / heading / headline / h1 / h2 开头的键)以指定标点结尾。命中时把提示作为 context 交给模型去改;从不拦写入。代码标识符里的命中由模型自行判断忽略。
规则全部来自仓库里的 .claude/copy-lint.json。从被写文件所在目录往上找,遇到 .git 停(所以 git worktree 里也生效)。没有配置文件、或 include 为空时,什么都不做。
| 键 | 含义 |
|---|---|
include | glob 列表,相对 .claude/ 所在目录;命中的文件算对外文案。支持 **、*、?、{a,b} |
exclude | 即使命中 include 也跳过的 glob(例如引用了禁用词的写作指南本身) |
banned | 数组;元素是字符串(字面词),或 { "word": "...", "reason": "..." },或 { "pattern": "<正则>", "flags": "i", "reason": "..." } |
headingNoTrailing | 标题不得以这些字符结尾,如 "。.";空串或省略则不查 |
guide | 可选,提示里让模型对照的写作指南路径 |
示例见 example.copy-lint.json,复制到你的仓库 .claude/copy-lint.json 再改。
claude plugin test .
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install copy-lint@claude-lead-harnesshooks/register.ts 41 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { isCopyPath, lint, parseConfig } from './lint'
4import type { Config } from './lint'
5
6const CONFIG = '.claude/copy-lint.json'
7
8// The nearest `.claude/copy-lint.json` above the file, stopping at the repo root (a `.git`).
9async function findConfig($: EngineInterface, file: string): Promise<{ base: string; config: Config } | null> {
10 let dir = file.replace(/\/[^/]*$/, '')
11 while (dir !== '') {
12 const path = `${dir}/${CONFIG}`
13 if (await $.fs.exists(path)) {
14 const text = await $.fs.read(path).then(t => t as string, () => null)
15 const config = parseConfig(text)
16 return config ? { base: dir, config } : null
17 }
18 if (await $.fs.exists(`${dir}/.git`)) return null
19 dir = dir.replace(/\/[^/]*$/, '')
20 }
21 return null
22}
23
24export const register: Register = on => {
25 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
26 const ran = await next(e)
27 if (ran.deny !== undefined || ran.isError === true || !e.file_path.startsWith('/')) return ran
28 const found = await findConfig($, e.file_path)
29 if (!found) return ran
30 const rel = e.file_path.slice(found.base.length + 1)
31 if (!isCopyPath(rel, found.config)) return ran
32 const text = e.tool === 'Write' ? e.content : e.new_string
33 const hits = lint(text, found.config)
34 if (hits.length === 0) return ran
35 const guide = found.config.guide ? `对照 ${found.config.guide} ` : ''
36 const note = `copy-lint(${e.file_path}):${hits.join(';')}。${guide}改掉;代码标识符里的不算。`
37 return { ...ran, context: [...(ran.context ?? []), note] }
38 // Only ever a hint: a fault here must not touch the write.
39 }).catch(($, e, next) => next(e))
40}
41hooks/lint.ts 122 lines1// Pure rules, kept apart so the tests can call them directly.
2// Everything product-specific comes from the repo's `.claude/copy-lint.json`.
3
4export type Banned = {
5 // A literal word or phrase (case-sensitive) ...
6 word?: string
7 // ... or a regular expression source, with `flags` (e.g. "i").
8 pattern?: string
9 flags?: string
10 // Why it is banned / what to write instead; shown to the model.
11 reason?: string
12}
13
14export type Config = {
15 // Globs, relative to the directory holding `.claude/`, of files that count as public copy.
16 include: string[]
17 // Globs excluded even when included (e.g. the style guide itself, which quotes the banned words).
18 exclude: string[]
19 banned: Banned[]
20 // Characters a heading must not end with, e.g. "。." ; empty turns the check off.
21 headingNoTrailing: string
22 // A doc the model should read before fixing, e.g. "docs/style-guide.md".
23 guide: string | null
24}
25
26const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
27
28// Null when the file is absent, broken or has nothing to check: the mod then does nothing.
29export function parseConfig(text: string | null): Config | null {
30 if (text === null) return null
31 let j: Record<string, unknown>
32 try {
33 j = JSON.parse(text) as Record<string, unknown>
34 } catch {
35 return null
36 }
37 if (typeof j !== 'object' || j === null) return null
38 const include = strings(j.include)
39 if (include.length === 0) return null
40 const banned = (Array.isArray(j.banned) ? j.banned : [])
41 .map((b): Banned | null => {
42 if (typeof b === 'string') return { word: b }
43 if (typeof b !== 'object' || b === null) return null
44 const o = b as Record<string, unknown>
45 const out: Banned = {}
46 if (typeof o.word === 'string' && o.word) out.word = o.word
47 if (typeof o.pattern === 'string' && o.pattern) out.pattern = o.pattern
48 if (typeof o.flags === 'string') out.flags = o.flags
49 if (typeof o.reason === 'string' && o.reason) out.reason = o.reason
50 return out.word || out.pattern ? out : null
51 })
52 .filter((b): b is Banned => b !== null)
53 return {
54 include,
55 exclude: strings(j.exclude),
56 banned,
57 headingNoTrailing: typeof j.headingNoTrailing === 'string' ? j.headingNoTrailing : '',
58 guide: typeof j.guide === 'string' && j.guide ? j.guide : null,
59 }
60}
61
62const escape = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
63
64// `**` any depth, `*` and `?` within one segment, `{a,b}` alternatives.
65export function globToRegExp(glob: string): RegExp {
66 let re = ''
67 for (let i = 0; i < glob.length; i++) {
68 const c = glob[i]!
69 if (c === '*') {
70 if (glob[i + 1] === '*') {
71 const slash = glob[i + 2] === '/'
72 re += slash ? '(?:.*/)?' : '.*'
73 i += slash ? 2 : 1
74 } else re += '[^/]*'
75 } else if (c === '?') re += '[^/]'
76 else if (c === '{') {
77 const end = glob.indexOf('}', i)
78 if (end < 0) re += '\\{'
79 else {
80 re += `(?:${glob.slice(i + 1, end).split(',').map(escape).join('|')})`
81 i = end
82 }
83 } else re += escape(c)
84 }
85 return new RegExp(`^${re}$`)
86}
87
88// `rel` is the path relative to the directory holding `.claude/`.
89export const isCopyPath = (rel: string, config: Config): boolean =>
90 config.include.some(g => globToRegExp(g).test(rel)) && !config.exclude.some(g => globToRegExp(g).test(rel))
91
92export function lint(text: string, config: Config): string[] {
93 const out: string[] = []
94 const found: string[] = []
95 for (const b of config.banned) {
96 let hit: string | null = null
97 if (b.word !== undefined) hit = text.includes(b.word) ? b.word : null
98 else if (b.pattern !== undefined) {
99 try {
100 const m = new RegExp(b.pattern, (b.flags ?? '').replace(/g/g, '')).exec(text)
101 hit = m ? m[0] : null
102 } catch {
103 hit = null
104 }
105 }
106 if (hit !== null) found.push(b.reason ? `「${hit}」(${b.reason})` : `「${hit}」`)
107 }
108 if (found.length > 0) out.push(`禁用词:${found.join('、')}`)
109
110 if (config.headingNoTrailing) {
111 const end = `[${escape(config.headingNoTrailing)}]`
112 const md = new RegExp(`^#{1,6}\\s.*${end}\\s*$`)
113 const json = new RegExp(`"(title|heading|headline|h1|h2)[^"]*"\\s*:\\s*"[^"]*${end}"`, 'i')
114 const headings = text
115 .split('\n')
116 .filter(l => md.test(l) || json.test(l))
117 .map(l => l.trim().slice(0, 40))
118 if (headings.length > 0) out.push(`标题以标点结尾:${headings.join(' | ')}`)
119 }
120 return out
121}
122