You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Line anchors in this issue were taken at 5ac991ce (main). Re-verify each anchor before editing.
Problem
.claude/hooks/block-prose-punctuation.sh gates its two pause-punctuation rules on a PROSE-CONTEXT heuristic that recognises four line shapes only:
Context
Pattern anchor
Repo-copy line (rule 2 / rule 3)
Comment line (// or a JSDoc *)
^[[:space:]]*(//|\*)[[:space:]]
L88 / L141
Markdown heading
^#{1,6}[[:space:]]
L94 / L145
Markdown blockquote
^>[[:space:]]
L100 / L149
HTML prose tag (p, li, td, h1..h6, strong, em, blockquote)
<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>
L106 / L153
A JSON string value matches none of those, so invariant 11 violations pass straight through in .json files. The same is true of a YAML front-matter value in a markdown file.
That is not a theoretical surface. packages/ui/package.jsondescription is the blurb npm renders on the registry page and in npm search results, so it is some of the most-read prose the project ships, and blog / article front-matter description becomes the page meta description and og:description (website/app/blog/[slug]/page.ts:32, :53, :56).
Measured against the hook as it stands today, per rule:
Rule
JSON string value
Same prose in markdown
1, em-dash (U+2014)
blocked
blocked
2, pause-hyphen
allowed
blocked
3, pause-semicolon
allowed
blocked
4a/4b/4c, code-LHS colon
n/a for a JSON value
blocked
5, lowercase brand
blocked
blocked
So the gap is exactly rules 2 and 3, the two that are prose-context-gated. Rules 1 and 5 are blunt (they match anywhere) and already cover JSON. Verified: a "title": "webjs config block" line exits 2 today, on rule 5.
Reproduce (verified at 5ac991ce)
printf'{"tool_name":"Write","tool_input":{"file_path":"/x/package.json","content":" \\"description\\": \\"A library - for things\\","}}' \
| bash .claude/hooks/block-prose-punctuation.sh;echo"exit=$?"# observed exit=0, ALLOWEDprintf'{"tool_name":"Write","tool_input":{"file_path":"/x/README.md","content":"# A library - for things"}}' \
| bash .claude/hooks/block-prose-punctuation.sh;echo"exit=$?"# observed exit=2, BLOCKED
Both exit codes above were observed on this checkout, not predicted.
Two live violations, both on main right now
Running the proposed pattern over every tracked file in the repo produces exactly two hits, and both are real:
The key counts were stale. Measured now over tracked files: description 62 quoted occurrences, title 5, displayName 1, name 114 (the earlier body said 59 / 5 / 1 / 127).
The invariant 11 anchor moved. It is AGENTS.md:484, not L473.
A second, larger defect found while verifying this one
The hook silently stops enforcing rules 1 through 4 on any payload larger than about 64 KB. Every one of those rules is written as if printf '%s' "$new_content" | grep -q ..., the script runs under set -euo pipefail, and grep -q exits the moment it matches. On a payload bigger than the pipe buffer that closes the pipe under printf, which takes SIGPIPE and exits 141, so under pipefail the pipeline status is 141, the if is false, and the rule is skipped even though grep matched.
Measured on this checkout, feeding # A library - for things followed by filler:
Payload size
Current hook blocked
~16 KB
8 of 8
~64 KB
1 of 8
~128 KB
0 of 8
~512 KB
0 of 8
Rule 5 is unaffected (verified 6 of 6 on a 256 KB payload) because it uses a command substitution with grep -n, which reads all of its input and never early-exits.
This is in scope because the fix requested here does not work without it. A package-lock.json is 240 KB in this repo, 29 tracked files exceed 64 KB, and a JSON rule that no-ops on exactly the biggest manifests is not a fix. Verified: a 256 KB payload carrying a "description" pause-hyphen blocks 0 of 6 times with the new pattern added over the pipe form, and 6 of 6 times once the pipelines are made SIGPIPE-safe.
Design / approach
Give rules 2 and 3 two additional prose-context patterns each, reusing each rule's existing character-class core byte for byte, and make every rule 1 through 4 pipeline SIGPIPE-safe so the new patterns actually fire.
The settled key list: description, title, displayName
The scope rule is the KEY, not the file and not the value. A line whose key is on this closed three-name list has its value scanned for the same pause cores the other four contexts already use. Every other key is left alone.
Candidate
Decision
Reasoning
description
INCLUDE
62 quoted JSON occurrences plus 65 column-0 front-matter lines. Covers the npm registry snippet (8 published manifests), the JSON Schema field docs in packages/server/webjs-config.schema.json, the VS Code snippet descriptions, the UI registry entries, the blog and article meta description, and the routing text at the top of every skill. The single most-read prose surface the project ships.
title
INCLUDE
5 quoted JSON occurrences plus 56 column-0 front-matter lines. All prose: the JSON Schema title, the VS Code command palette entries ("webjs: Run check"), a UI registry theme name ("Neutral"), and the blog and article SEO titles. Zero code-shaped values under this key anywhere in the tree.
displayName
INCLUDE
1 occurrence, the VS Code Marketplace listing name in packages/editors/vscode/package.json:3. Pure prose, and it costs nothing: it is one more branch in an alternation that is already there.
name
EXCLUDE
114 quoted occurrences, every one an identifier: package names, dependency names, component names, workflow names (name: CI), snippet ids, skill slugs. An identifier is precisely where a hyphen is structural, so this key is the single largest false-positive source in the tree.
summary
EXCLUDE
0 occurrences anywhere in the tracked tree. No surface emits it.
whenToUse
EXCLUDE
0 occurrences. Not a field any surface here emits.
detail
EXCLUDE
0 occurrences.
label
EXCLUDE
0 occurrences, and it is a UI-string key by nature. In an app it would hold arbitrary display copy, including range labels ("Rows 1 - 20") and flag help, which is the exact shape the rule bans.
text
EXCLUDE
0 occurrences, same UI-string reasoning as label, with a larger blast radius.
message
EXCLUDE
1 occurrence, at .claude/skills/use-railway/scripts/analyze-redis.py:497, inside a vendored third-party script. A message is a runtime diagnostic, not a published surface, and it routinely quotes a command, a diff, or a range verbatim.
YAML front-matter description: in a skill markdown file
INCLUDE, via a second line shape
Same root cause, same class of published prose. It is the meta description on the site and the routing text agents read. Covered by a column-0 pattern, described below.
An excluded key is a one-word edit the day a real surface appears. Including one with no backing surface is unbacked false-positive area today.
False-positive analysis, by concrete key in this repo
The controlling risk is a false positive: this is a PreToolUse gate, so exit 2 stops the write. Every code-shaped JSON value in this repo lives under a key that is not on the list. Named concretely, from package.json, package-lock.json, .claude/settings.json, packages/cli/templates/.vscode/settings.json, and .github/workflows/*:
Value class
The actual keys holding it here
Why it cannot match
Shell command
command (26 occurrences, the .claude/settings.json hook entries), and every scripts sub-key (prepare, pretest, dev, test:e2e, test:coverage, css:build, worktree:link, typecheck, publish:ovsx, print-chrome-path, ui:dev, prepack, postpack)
Key not on the list
Semver range
The dependency-name keys themselves (drizzle-orm, drizzle-kit, valibot, create-webjs, webjs-mcp), plus engines.node, engines.bun, and ignore ("^5.2.0")
Excluded twice: the key is not on the list, and npm's hyphen range ("1.2.3 - 2.3.4") is digit-bounded on both sides, so the shared [A-Za-z + backtick + (<] core cannot match it even if the key were listed
Key not on the list, AND these hold ARRAYS, whose elements have no key at all, so the "key": "value" line shape never appears
CLI flag string
scripts sub-keys, as above
Key not on the list
Raw SQL
default (15), for example the Drizzle snapshot "(cast((julianday('now') - 2440587.5)*86400000 as integer))"
The closest real space-hyphen-space in the whole tree. Excluded twice: the key is not on the list, and the character after the hyphen is a digit
TextMate scope / audit tag
injectionSelector (3), exempt (39)
Key not on the list
Empirical confirmation, run on this checkout: the pattern was applied over every tracked file (1708 files, not just .json, feeding each whole file through both the current and the patched hook and diffing the verdicts). Exactly two files change from allowed to blocked, and both are the real violations listed under Problem. Zero false positives.
File scope: none. The rule is line-shaped, not path-gated
The new patterns apply to any tool payload, with no file_path check, which automatically covers .json, .jsonc, .json5, package.json specifically, .claude/*.json, .vscode/settings.json, and a JSON fragment pasted into a markdown fenced block or a JS template literal. Three reasons:
The Bash payload has no file_path at all. The hook reads .tool_input.command for that path (L45), so a heredoc, a jq rewrite, or an npm pkg set writing a manifest walks straight through any file-gated rule.
MultiEdit and NotebookEdit carry no per-edit path either (.edits[]?.new_string, .new_source).
All four existing context patterns are content-shaped. A key-shaped fifth extends one mechanism instead of adding a second one alongside it.
The consequence of covering a fenced JSON example in a doc is intended: a documented description example should obey the invariant too.
YAML front matter: covered, column-0 anchored
A second line shape handles it: ^(description|title|displayName):[[:space:]], anchored at column 0 with no leading whitespace allowed.
The anchor is load-bearing. .github/workflows/release.yml:36 and :40 hold description: values that are INDENTED workflow-input docs, full of parentheses and prose, and every nested YAML mapping anywhere is indented, so the column-0 anchor confines the rule to document front matter. Measured: 65 column-0 description: lines and 56 column-0 title: lines in the tree, all in markdown, all front matter except one, and that one is a front-matter TEMPLATE inside a fenced block at .claude/skills/webjs-blog-write/SKILL.md:47 whose value is prose as well. Zero of the 121 lines match the pause pattern today, so the addition is a pure future guard with no cleanup attached.
Rejected alternatives
Scanning every JSON string value bluntly, the way rule 1 works. It meets semver ranges and script commands, and it abandons the trade the file's own header commits to, namely zero false positives in code-heavy diffs.
Gating on a file_path ending in .json, or on a file allowlist such as package.json plus *.schema.json plus manifest.json. The Bash path exposes only .tool_input.command, so every heredoc write escapes it, and it introduces a second mechanism alongside four content-shaped patterns.
Scanning any JSON key/value line regardless of key."name" alone would put 114 identifier values behind a blocking gate.
Adding speculative prose keys (summary, whenToUse, detail, label, text). Zero occurrences each, so each is unbacked false-positive surface.
Leaning on test/repo-health/published-package-descriptions.test.mjs instead of fixing the hook. It asserts the same invariant, but only over the non-private manifests under packages, packages/editors, and packages/wrappers, which is 8 packages, and only at CI time. Both live violations sit in files it skips: the repo root and a nested packages/ui/packages/* manifest.
Silencing the SIGPIPE flake by dropping pipefail. That would leave the rest of the script without its pipeline safety for one local problem. Removing the pipe is the narrower fix.
Adding a JSDoc carve-out for the @param {type} name - description separator. Only 3 such lines exist repo-wide, so the repo convention is already not to use it. Rewriting 3 lines is smaller than a permanent exception, and an exception would also swallow real prose in JSDoc.
Implementation plan
All line anchors are the repo copy at 5ac991ce. The two scaffold copies are the repo copy minus rule 5, so their anchors for rules 1 through 4c are the repo anchor MINUS 4 (their header omits the 4-line rule 5 summary at repo L17 through L20). Verified: diff of the two scaffold copies is empty, and diff of repo against scaffold shows only the rule 5 header block and the rule 5 body.
Step 1: make rules 1 through 4 SIGPIPE-safe
In .claude/hooks/block-prose-punctuation.sh, replace every one of the 15 printf ... | grep -q pipelines with a here-string. There are 15 of them, at L54, L88, L94, L100, L106, L141, L145, L149, L153, L182, L202, L223, and the three new ones added by steps 2 and 3.
Behaviour is preserved: <<< appends exactly one trailing newline, which is what printf '%s\n' did, and for the three printf '%s' sites grep treats a final incomplete line identically. A here-string is a temp file rather than a pipe, so there is no SIGPIPE and no pipefail interaction. The shebang is already #!/usr/bin/env bash, so the here-string is available.
Leave rule 5 (L267 through L295) alone. Its awk | sed and grep -n pipelines read all of their input and run inside command substitutions, and it was verified to block 6 of 6 times on a 256 KB payload.
Add a short comment above the first converted site recording why the pipe is gone, so nobody reintroduces it:
# Every match below reads from a here-string, never a pipe. `grep -q` exits on# the first match, which closes a pipe under `printf`, and with `set -o pipefail`# that SIGPIPE became the pipeline status, so the rule silently skipped on any# payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB).
Step 2: rule 2 gains two contexts
Same file. Rule 2 opens at the # --- 2. Pause-hyphen banner (L70), zeroes block_pause_hyphen at L82, and sets it from FOUR matches, at L88, L94, L100, and L106. Insert after the L106 block (that is, after its closing fi at L108, before the blank line preceding if [ "$block_pause_hyphen" = "1" ] at L110):
# JSON prose-value " - " pause: a string assignment whose KEY is one of the# three prose-bearing keys this project's JSON uses. Scoping to the key is what# keeps this off semver ranges, script commands, urls, paths and globs, every# one of which lives under a different key. Shape, not file path: the Bash# payload carries no file_path, so a heredoc writing a manifest is covered too.if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'<<<"$new_content";then
block_pause_hyphen=1
fi# YAML front-matter " - " pause, same three keys. Anchored at column 0 with no# leading whitespace, which is what confines it to document front matter: every# nested YAML mapping is indented, including the workflow-input `description:`# values in .github/workflows/release.yml.if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'<<<"$new_content";then
block_pause_hyphen=1
fi
The pause core is [A-Za-z + backtick + )>] before and [A-Za-z + backtick + (<] after, copied verbatim from L88. Reusing it is load-bearing, not cosmetic: the letter bound is what makes "1.2.3 - 2.3.4" unmatchable, so a loosened class silently admits every semver hyphen range in the tree.
Step 3: rule 3 gains the mirror pair
Same file. Rule 3 opens at L137, zeroes its flag at L139, and mirrors the same four patterns at L141, L145, L149, and L153. Insert after the L153 block (after its closing fi at L155):
if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'<<<"$new_content";then
block_pause_semicolon=1
fiif grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'<<<"$new_content";then
block_pause_semicolon=1
fi
The semicolon core is [A-Za-z + backtick + )] and [A-Za-z + backtick + (], with no angle brackets, matching L141 exactly. Note this rule bans only the space-SURROUNDED form, which is what invariant 11 says, so an ordinary English "Returns the envelope; the import becomes a stub." is untouched. Verified allowed.
Step 4: fix the block messages and the stale citations
Same file. Add one JSON example to each Bad/Good pair, in the pause-hyphen heredoc (L110 through L135) and the pause-semicolon heredoc (L157 through L175). For the hyphen message:
Bad: "description": "A library - for things"
Good: "description": "A library for things"
Then correct the citation footer. SEVEN lines cite item 10, which is the erasable-TypeScript invariant, while the prose rule is item 11 and rule 5's own footer at L319 already says so. Fix all seven: the header comment at L22 and the footers at L64, L131, L171, L193, L213, L234.
Step 5: state the new scope in the header comment
Same file, L8 through L12, which enumerates the prose contexts as "comment lines, markdown lines, headings, blockquotes". Add the JSON prose-key context and the front-matter context, and name the three keys, so the file states its own scope.
Step 6: mirror steps 1 through 5 into the two scaffold copies
packages/cli/templates/.claude/hooks/block-prose-punctuation.sh (236 lines), which webjs create copies verbatim into every scaffolded app (packages/cli/lib/create.js:588 lists it, :696 chmods it, and no generator string needs changing).
Those two are byte-identical to each other today and are the repo copy minus rule 5. Rules 1 through 4 must stay in step across all three. Nothing guards this, which is what the drift test in the Tests section then pins.
Step 7: fix the violations the change makes visible, in the same PR
The hook would otherwise block the next edit to each of these lines.
The two the JSON rule surfaces:
package.json:5, from "WebJs - AI-first, web-components-first framework." to "WebJs is an AI-first, web-components-first framework."
packages/ui/packages/registry/package.json:6, from "Source registry for @webjsdev/ui - component sources, themes, lib. ..." to "Source registry for @webjsdev/ui, holding component sources, themes, and lib. ..."
The three the SIGPIPE fix surfaces, all the JSDoc @param {type} name - description separator, which the existing L88 comment-line pattern already flags on a small edit today:
packages/server/src/actions.js:248, * @param {string} urlPath - a browser-visible URL path like ...
packages/server/src/check.js:193, * @param {string} relPath - path relative to appDir
packages/ui/packages/registry/themes/base-colors.js:168, * @param {string} neutralCss - verbatim contents of ...
Rewrite each without the separator, for example @param {string} relPath Path relative to appDir. These are the only three in the tree, so the repo convention is already not to use the separator.
Tests
All in test/hooks/block-prose-punctuation.test.mjs, which exists and holds 22 tests today, driven through runContent(), which spawns the hook with a real {tool_input:{content}} payload (no file_path, which exercises the path-agnostic design directly). The file currently has NO rule 2 or rule 3 coverage at all, only em-dash, brand, and the CLI drift guard, so the markdown "must stay blocked" assertions are new as well.
Follow the file's existing convention of assembling the brand and the em-dash at runtime (const B = 'web' + 'js', String.fromCharCode(0x2014)) so this test file never itself carries a literal the live hook would block.
Blocked cases (expect status === 2)
Case
Content
Assert
JSON description, pause-hyphen
"description": "A library - for things",
status 2, stderr matches /pause-hyphen/
JSON description, pause-semicolon
"description": "Forms work ; links work too.",
status 2, stderr matches /pause-semicolon/
JSON title
"title": "Neutral - the default palette",
status 2
JSON displayName
"displayName": "Editor - all in one",
status 2
Deep indentation
the same description line indented 12 spaces, as webjs-config.schema.json nests it
'# A library - for things\n' + 'x'.repeat(200_000)
status 2, run 5 times, all 5 blocked
Large payload, JSON rule
the description line plus 'x'.repeat(200_000)
status 2, run 5 times, all 5 blocked
Large payload, rule 1
an em-dash plus 'x'.repeat(200_000)
status 2, run 5 times, all 5 blocked
The three large-payload cases must loop, because the bug they cover is a race. A single run passes on the broken hook roughly one time in eight at 64 KB.
Allowed cases (expect status === 0), one assertion each
"description": "Returns the envelope; the import becomes a stub.",
Indented YAML (a workflow input, not front matter)
description: Republish every changelog - one time
Every one of these was measured as exit 0 against the patched hook on this checkout. The compound-word case is the one that proves the shared character-class core was reused rather than reinvented. The indented-YAML case is the one that proves the column-0 anchor is real.
Repo drift guard, same file
Walk every tracked *.json file plus every markdown front matter, feed each prose-key line through the hook, and assert exit 0. This is what stops the lines fixed in step 7 from coming back. It reds today against the unfixed tree, at package.json:5 and packages/ui/packages/registry/package.json:6.
Copy drift guard, same file
Assert that packages/cli/templates/.claude/hooks/block-prose-punctuation.sh and examples/blog/.claude/hooks/block-prose-punctuation.sh are byte-identical to each other, and that each contains all four new grep -qE patterns and zero remaining printf ... | grep -q pipelines. Without this, step 6 can be half-applied and every scaffolded app ships a hook missing the rule.
Counterfactuals
Three, each isolating one failure mode:
Delete only the four new grep -qE blocks from steps 2 and 3. Every new blocking test flips from 2 to 0, while every allowed test stays green (they were already green, which is the point of listing them) and every markdown test stays green.
Revert only step 1 (put the printf ... | pipes back). The three large-payload tests red while every small-payload test stays green. This is the one that proves the SIGPIPE fix is load-bearing rather than cosmetic.
Revert only step 6. The copy drift guard reds while every behaviour test stays green.
Commands
node --test test/hooks/block-prose-punctuation.test.mjs
npm test# the full node suite; scripts/run-node-tests.js walks test/ recursively
No browser, e2e, or Bun layer applies: this is a repo-tooling shell script with no runtime-sensitive surface, so the AGENTS.md Bun-parity requirement does not attach. Say so explicitly in the PR body so the omission reads as a decision.
Docs
AGENTS.md:484, invariant 11. The clause "Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS" reads as a blanket JSON exemption and is now wrong for the three prose keys. Narrow it so it says JSON SYNTAX is code while a description, title, or displayName VALUE is prose and is scanned, in a JSON file or in YAML front matter alike. Keep the sentence in the same place and the same register. Suggested replacement for that clause: "Plain hyphens stay fine in compound words, flags, filenames, ranges, and semicolons and colons stay fine inside code / TS / CSS and inside JSON SYNTAX. A JSON or front-matter description, title, or displayName VALUE is prose, not code, and is scanned like any other prose."
.claude/hooks/block-prose-punctuation.sh header comment, covered by step 5, since the file documents its own contexts at L5 through L20.
Nothing else. Checked and confirmed: framework-dev.md does not mention this hook. The scaffold and dogfood rule copies (packages/cli/templates/.agents/rules/workflow.md, examples/blog/.agents/rules/workflow.md, examples/blog/.cursorrules:93, examples/blog/.github/copilot-instructions.md:90) state the rule generically with no JSON exemption, so they are already correct. No docs site, marketing website, README, or .agents/skills/webjs/ surface applies: this is repo tooling, not framework behaviour, so the webjs-doc-sync skill has nothing to sync here.
Acceptance criteria
The first reproduce command in Problem now exits 2, and the second still exits 2
A pause-hyphen in a description, title, or displayName JSON value is blocked, and the same prose in a markdown heading, blockquote, and comment line stays blocked
A pause-semicolon in the same three JSON values is blocked
A pause-hyphen in a column-0 YAML front-matter description: or title: is blocked, and the same value INDENTED (a workflow input) is allowed
Every allowed case in the Tests table still exits 0, proven per candidate: an npm version range, an engines range, a scripts command, a command path, a name, a config-block leaf, a main path, raw SQL under default, and an ordinary English semicolon with no surrounding spaces
A compound word inside a scanned description ("An AI-first, web-components-first framework.") is still allowed
Rules 1 and 5 are behaviourally unchanged for small payloads, since both already cover JSON
Rules 1 through 4 block reliably on a 200 KB payload, asserted over 5 consecutive runs, where the current hook blocks 0 of 8 at 128 KB
Both live JSON violations and all three JSDoc separator lines are fixed in the same PR
Every tracked *.json file passes the hook, asserted by a test that reds against the unfixed tree
All three copies of the hook carry the four new patterns and no printf ... | grep -q pipelines, asserted by a test
The seven item 10 citations in the hook read item 11
AGENTS.md:484 no longer claims a blanket JSON exemption
The three counterfactuals each red exactly the tests named for them
node --test test/hooks/block-prose-punctuation.test.mjs passes, and npm test is green
Out of scope
Do NOT widen into any of these:
A fourth prose key.summary, whenToUse, detail, label, message, text, and short_name are all measured at zero or one occurrence and are deliberately excluded. Adding one is a one-word edit the day a real surface appears.
name, under any circumstances. 114 identifier values.
A file_path gate, or a per-file allowlist. Rejected above, with reasons.
Fenced-code-block stripping for rules 2 and 3. Rule 5 strips fenced blocks and inline spans, rules 1 through 4 do not, and equalising that is a separate behaviour change with its own false-negative cost.
The em-dashes in the vendored Railway skill..claude/skills/use-railway/** carries em-dashes in SKILL.md, six references/*.md, and four scripts/*.py. Those are vendored third-party files, and rewriting their prose creates drift against upstream. After the SIGPIPE fix a full-file rewrite of analyze-postgres.py (147 KB) or analyze-mysql.py (49 KB) will block on rule 1, which is correct behaviour on a file nobody rewrites wholesale. Leave them.
The pre-existing rule 5 over-match on packages/server/webjs-config.schema.json:4. Its "title": "webjs config block" line trips the brand rule today (verified exit 2), because config is not a CLI subcommand. That is a rule 5 problem, untouched by this change, and worth knowing before an unrelated edit to that file is blamed on this one.
A JSDoc @param carve-out. Rejected above. Fix the 3 lines instead.
Any change to rule 5, rules 4a, 4b, or 4c, other than the mechanical here-string conversion in step 1 and the item 10 to item 11 citation fix in step 4.
Landmines for the implementing agent
This hook gates YOUR OWN Write and Edit calls. A rule that is too broad blocks your legitimate edits and you discover it mid-task. TEST THE HOOK BY PIPING JSON INTO IT DIRECTLY. Do not test it by attempting an edit.
The hook also scans your Bash command string (L45 reads .tool_input.command). A test command containing the lowercase brand followed by a non-subcommand word will block your own shell call. This happened while writing this issue: a verification command containing the literal string for the schema title line was refused by rule 5. Assemble the brand in the shell (B='web'"js") when a fixture needs it, exactly as the existing test file does in JavaScript.
Build payloads with jq -Rs '{tool_input:{content:.}}'. A JSON value inside the payload is itself JSON-escaped by the time the hook sees it, so hand-writing an escaped payload is where a pattern looks right and does not fire in practice.
THREE copies of this hook exist, and the scaffold copy reaches every app webjs create generates. Edit all three. No drift guard exists until you write one.
This hook has already been narrowed once for false positives, in dogfood: prose hook false-positives on 'webjs <subcmd>' before a closing quote #956, where rule 5's trailing character class had to admit a closing quote so a package.json line stopped tripping it. Same file, same class of over-match. Read that fix before widening anything.
Prefer under-matching. Exit 2 is a hard block, not a warning.
Work in a dedicated worktree cut from origin/main, per AGENTS.md. The primary checkout is hook-blocked for tracked-file edits.
Problem
.claude/hooks/block-prose-punctuation.shgates its two pause-punctuation rules on a PROSE-CONTEXT heuristic that recognises four line shapes only://or a JSDoc*)^[[:space:]]*(//|\*)[[:space:]]^#{1,6}[[:space:]]^>[[:space:]]p,li,td,h1..h6,strong,em,blockquote)<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>A JSON string value matches none of those, so invariant 11 violations pass straight through in
.jsonfiles. The same is true of a YAML front-matter value in a markdown file.That is not a theoretical surface.
packages/ui/package.jsondescriptionis the blurb npm renders on the registry page and innpm searchresults, so it is some of the most-read prose the project ships, and blog / article front-matterdescriptionbecomes the page meta description andog:description(website/app/blog/[slug]/page.ts:32,:53,:56).Measured against the hook as it stands today, per rule:
So the gap is exactly rules 2 and 3, the two that are prose-context-gated. Rules 1 and 5 are blunt (they match anywhere) and already cover JSON. Verified: a
"title": "webjs config block"line exits 2 today, on rule 5.Reproduce (verified at
5ac991ce)Both exit codes above were observed on this checkout, not predicted.
Two live violations, both on main right now
Running the proposed pattern over every tracked file in the repo produces exactly two hits, and both are real:
package.json:5,"description": "WebJs - AI-first, web-components-first framework."packages/ui/packages/registry/package.json:6,"description": "Source registry for @webjsdev/ui - component sources, themes, lib. ..."Corrections to the previous statement of this issue
Three claims in the earlier body do not survive checking, and the corrected versions are better evidence, not worse:
git show b0bc2742 -- packages/ui/package.jsonshows feat!: scope @webjsdev/ui to WebJs apps, drop project detection #1235 REMOVED it. It was introduced by1458a7cd("chore: repo-wide punctuation cleanup"), which rewrote an em-dash into a space-hyphen inside a JSONdescription. The repo-wide punctuation cleanup itself swapped one banned glyph for another banned glyph in a JSON value, and nothing caught it, because the hook does not read JSON. That is a stronger statement of the bug than the original.description62 quoted occurrences,title5,displayName1,name114 (the earlier body said 59 / 5 / 1 / 127).AGENTS.md:484, not L473.A second, larger defect found while verifying this one
The hook silently stops enforcing rules 1 through 4 on any payload larger than about 64 KB. Every one of those rules is written as
if printf '%s' "$new_content" | grep -q ..., the script runs underset -euo pipefail, andgrep -qexits the moment it matches. On a payload bigger than the pipe buffer that closes the pipe underprintf, which takes SIGPIPE and exits 141, so underpipefailthe pipeline status is 141, theifis false, and the rule is skipped even though grep matched.Measured on this checkout, feeding
# A library - for thingsfollowed by filler:Rule 5 is unaffected (verified 6 of 6 on a 256 KB payload) because it uses a command substitution with
grep -n, which reads all of its input and never early-exits.This is in scope because the fix requested here does not work without it. A
package-lock.jsonis 240 KB in this repo, 29 tracked files exceed 64 KB, and a JSON rule that no-ops on exactly the biggest manifests is not a fix. Verified: a 256 KB payload carrying a"description"pause-hyphen blocks 0 of 6 times with the new pattern added over the pipe form, and 6 of 6 times once the pipelines are made SIGPIPE-safe.Design / approach
Give rules 2 and 3 two additional prose-context patterns each, reusing each rule's existing character-class core byte for byte, and make every rule 1 through 4 pipeline SIGPIPE-safe so the new patterns actually fire.
The settled key list:
description,title,displayNameThe scope rule is the KEY, not the file and not the value. A line whose key is on this closed three-name list has its value scanned for the same pause cores the other four contexts already use. Every other key is left alone.
descriptionpackages/server/webjs-config.schema.json, the VS Code snippet descriptions, the UI registry entries, the blog and article meta description, and the routing text at the top of every skill. The single most-read prose surface the project ships.titletitle, the VS Code command palette entries ("webjs: Run check"), a UI registry theme name ("Neutral"), and the blog and article SEO titles. Zero code-shaped values under this key anywhere in the tree.displayNamepackages/editors/vscode/package.json:3. Pure prose, and it costs nothing: it is one more branch in an alternation that is already there.namename: CI), snippet ids, skill slugs. An identifier is precisely where a hyphen is structural, so this key is the single largest false-positive source in the tree.summarywhenToUsedetaillabel"Rows 1 - 20") and flag help, which is the exact shape the rule bans.textlabel, with a larger blast radius.message.claude/skills/use-railway/scripts/analyze-redis.py:497, inside a vendored third-party script. A message is a runtime diagnostic, not a published surface, and it routinely quotes a command, a diff, or a range verbatim.description:in a skill markdown fileAn excluded key is a one-word edit the day a real surface appears. Including one with no backing surface is unbacked false-positive area today.
False-positive analysis, by concrete key in this repo
The controlling risk is a false positive: this is a
PreToolUsegate, so exit 2 stops the write. Every code-shaped JSON value in this repo lives under a key that is not on the list. Named concretely, frompackage.json,package-lock.json,.claude/settings.json,packages/cli/templates/.vscode/settings.json, and.github/workflows/*:command(26 occurrences, the.claude/settings.jsonhook entries), and everyscriptssub-key (prepare,pretest,dev,test:e2e,test:coverage,css:build,worktree:link,typecheck,publish:ovsx,print-chrome-path,ui:dev,prepack,postpack)drizzle-orm,drizzle-kit,valibot,create-webjs,webjs-mcp), plusengines.node,engines.bun, andignore("^5.2.0")"1.2.3 - 2.3.4") is digit-bounded on both sides, so the shared[A-Za-z+ backtick +(<]core cannot match it even if the key were listedresolved(490 occurrences),homepage,repository,url(11)integrity(489)main,bin,types,exports,path(10),source,demo(40),targetfiles,include,exclude,fileMatch"key": "value"line shape never appearsscriptssub-keys, as abovedefault(15), for example the Drizzle snapshot"(cast((julianday('now') - 2440587.5)*86400000 as integer))"injectionSelector(3),exempt(39)Empirical confirmation, run on this checkout: the pattern was applied over every tracked file (1708 files, not just
.json, feeding each whole file through both the current and the patched hook and diffing the verdicts). Exactly two files change from allowed to blocked, and both are the real violations listed under Problem. Zero false positives.File scope: none. The rule is line-shaped, not path-gated
The new patterns apply to any tool payload, with no
file_pathcheck, which automatically covers.json,.jsonc,.json5,package.jsonspecifically,.claude/*.json,.vscode/settings.json, and a JSON fragment pasted into a markdown fenced block or a JS template literal. Three reasons:Bashpayload has nofile_pathat all. The hook reads.tool_input.commandfor that path (L45), so a heredoc, ajqrewrite, or annpm pkg setwriting a manifest walks straight through any file-gated rule.MultiEditandNotebookEditcarry no per-edit path either (.edits[]?.new_string,.new_source).The consequence of covering a fenced JSON example in a doc is intended: a documented
descriptionexample should obey the invariant too.YAML front matter: covered, column-0 anchored
A second line shape handles it:
^(description|title|displayName):[[:space:]], anchored at column 0 with no leading whitespace allowed.The anchor is load-bearing.
.github/workflows/release.yml:36and:40holddescription:values that are INDENTED workflow-input docs, full of parentheses and prose, and every nested YAML mapping anywhere is indented, so the column-0 anchor confines the rule to document front matter. Measured: 65 column-0description:lines and 56 column-0title:lines in the tree, all in markdown, all front matter except one, and that one is a front-matter TEMPLATE inside a fenced block at.claude/skills/webjs-blog-write/SKILL.md:47whose value is prose as well. Zero of the 121 lines match the pause pattern today, so the addition is a pure future guard with no cleanup attached.Rejected alternatives
file_pathending in.json, or on a file allowlist such aspackage.jsonplus*.schema.jsonplusmanifest.json. TheBashpath exposes only.tool_input.command, so every heredoc write escapes it, and it introduces a second mechanism alongside four content-shaped patterns."name"alone would put 114 identifier values behind a blocking gate.summary,whenToUse,detail,label,text). Zero occurrences each, so each is unbacked false-positive surface.test/repo-health/published-package-descriptions.test.mjsinstead of fixing the hook. It asserts the same invariant, but only over the non-private manifests underpackages,packages/editors, andpackages/wrappers, which is 8 packages, and only at CI time. Both live violations sit in files it skips: the repo root and a nestedpackages/ui/packages/*manifest.pipefail. That would leave the rest of the script without its pipeline safety for one local problem. Removing the pipe is the narrower fix.@param {type} name - descriptionseparator. Only 3 such lines exist repo-wide, so the repo convention is already not to use it. Rewriting 3 lines is smaller than a permanent exception, and an exception would also swallow real prose in JSDoc.Implementation plan
All line anchors are the repo copy at
5ac991ce. The two scaffold copies are the repo copy minus rule 5, so their anchors for rules 1 through 4c are the repo anchor MINUS 4 (their header omits the 4-line rule 5 summary at repo L17 through L20). Verified:diffof the two scaffold copies is empty, anddiffof repo against scaffold shows only the rule 5 header block and the rule 5 body.Step 1: make rules 1 through 4 SIGPIPE-safe
In
.claude/hooks/block-prose-punctuation.sh, replace every one of the 15printf ... | grep -qpipelines with a here-string. There are 15 of them, at L54, L88, L94, L100, L106, L141, L145, L149, L153, L182, L202, L223, and the three new ones added by steps 2 and 3.The transformation is mechanical. Today, at L88:
becomes:
and at L54:
becomes:
Behaviour is preserved:
<<<appends exactly one trailing newline, which is whatprintf '%s\n'did, and for the threeprintf '%s'sites grep treats a final incomplete line identically. A here-string is a temp file rather than a pipe, so there is no SIGPIPE and nopipefailinteraction. The shebang is already#!/usr/bin/env bash, so the here-string is available.Leave rule 5 (L267 through L295) alone. Its
awk | sedandgrep -npipelines read all of their input and run inside command substitutions, and it was verified to block 6 of 6 times on a 256 KB payload.Add a short comment above the first converted site recording why the pipe is gone, so nobody reintroduces it:
Step 2: rule 2 gains two contexts
Same file. Rule 2 opens at the
# --- 2. Pause-hyphenbanner (L70), zeroesblock_pause_hyphenat L82, and sets it from FOUR matches, at L88, L94, L100, and L106. Insert after the L106 block (that is, after its closingfiat L108, before the blank line precedingif [ "$block_pause_hyphen" = "1" ]at L110):The pause core is
[A-Za-z+ backtick +)>]before and[A-Za-z+ backtick +(<]after, copied verbatim from L88. Reusing it is load-bearing, not cosmetic: the letter bound is what makes"1.2.3 - 2.3.4"unmatchable, so a loosened class silently admits every semver hyphen range in the tree.Step 3: rule 3 gains the mirror pair
Same file. Rule 3 opens at L137, zeroes its flag at L139, and mirrors the same four patterns at L141, L145, L149, and L153. Insert after the L153 block (after its closing
fiat L155):The semicolon core is
[A-Za-z+ backtick +)]and[A-Za-z+ backtick +(], with no angle brackets, matching L141 exactly. Note this rule bans only the space-SURROUNDED form, which is what invariant 11 says, so an ordinary English"Returns the envelope; the import becomes a stub."is untouched. Verified allowed.Step 4: fix the block messages and the stale citations
Same file. Add one JSON example to each Bad/Good pair, in the pause-hyphen heredoc (L110 through L135) and the pause-semicolon heredoc (L157 through L175). For the hyphen message:
Then correct the citation footer. SEVEN lines cite
item 10, which is the erasable-TypeScript invariant, while the prose rule is item 11 and rule 5's own footer at L319 already says so. Fix all seven: the header comment at L22 and the footers at L64, L131, L171, L193, L213, L234.Step 5: state the new scope in the header comment
Same file, L8 through L12, which enumerates the prose contexts as "comment lines, markdown lines, headings, blockquotes". Add the JSON prose-key context and the front-matter context, and name the three keys, so the file states its own scope.
Step 6: mirror steps 1 through 5 into the two scaffold copies
packages/cli/templates/.claude/hooks/block-prose-punctuation.sh(236 lines), whichwebjs createcopies verbatim into every scaffolded app (packages/cli/lib/create.js:588lists it,:696chmods it, and no generator string needs changing).examples/blog/.claude/hooks/block-prose-punctuation.sh(236 lines).Those two are byte-identical to each other today and are the repo copy minus rule 5. Rules 1 through 4 must stay in step across all three. Nothing guards this, which is what the drift test in the Tests section then pins.
Step 7: fix the violations the change makes visible, in the same PR
The hook would otherwise block the next edit to each of these lines.
The two the JSON rule surfaces:
package.json:5, from"WebJs - AI-first, web-components-first framework."to"WebJs is an AI-first, web-components-first framework."packages/ui/packages/registry/package.json:6, from"Source registry for @webjsdev/ui - component sources, themes, lib. ..."to"Source registry for @webjsdev/ui, holding component sources, themes, and lib. ..."The three the SIGPIPE fix surfaces, all the JSDoc
@param {type} name - descriptionseparator, which the existing L88 comment-line pattern already flags on a small edit today:packages/server/src/actions.js:248,* @param {string} urlPath - a browser-visible URL path like ...packages/server/src/check.js:193,* @param {string} relPath - path relative to appDirpackages/ui/packages/registry/themes/base-colors.js:168,* @param {string} neutralCss - verbatim contents of ...Rewrite each without the separator, for example
@param {string} relPath Path relative to appDir. These are the only three in the tree, so the repo convention is already not to use the separator.Tests
All in
test/hooks/block-prose-punctuation.test.mjs, which exists and holds 22 tests today, driven throughrunContent(), which spawns the hook with a real{tool_input:{content}}payload (nofile_path, which exercises the path-agnostic design directly). The file currently has NO rule 2 or rule 3 coverage at all, only em-dash, brand, and the CLI drift guard, so the markdown "must stay blocked" assertions are new as well.Follow the file's existing convention of assembling the brand and the em-dash at runtime (
const B = 'web' + 'js',String.fromCharCode(0x2014)) so this test file never itself carries a literal the live hook would block.Blocked cases (expect
status === 2)description, pause-hyphen"description": "A library - for things",/pause-hyphen/description, pause-semicolon"description": "Forms work ; links work too.",/pause-semicolon/title"title": "Neutral - the default palette",displayName"displayName": "Editor - all in one",descriptionline indented 12 spaces, aswebjs-config.schema.jsonnests it"description": "WebJs - AI-first, web-components-first framework.",description:description: A framework - for the web.title:title: Signals - the default state primitive# A library - for things> A library - for things// A library - for things'# A library - for things\n' + 'x'.repeat(200_000)descriptionline plus'x'.repeat(200_000)'x'.repeat(200_000)The three large-payload cases must loop, because the bug they cover is a race. A single run passes on the broken hook roughly one time in eight at 64 KB.
Allowed cases (expect
status === 0), one assertion each"drizzle-orm": "1.2.3 - 2.3.4",enginesrange"node": ">=24.0.0-alpha - 25",scripts"test:e2e": "node scripts/run.js --filter - --bail",command"command": ".claude/hooks/require-docs - src.sh"name"name": "@webjsdev/ui-registry","basePath": "/app - v2",main"main": "./src/index.js",default"default": "(cast((julianday(a) - 2440587.5)*86400 as integer))",description"description": "An AI-first, web-components-first framework.","description": "Returns the envelope; the import becomes a stub.",description: Republish every changelog - one timeEvery one of these was measured as
exit 0against the patched hook on this checkout. The compound-word case is the one that proves the shared character-class core was reused rather than reinvented. The indented-YAML case is the one that proves the column-0 anchor is real.Repo drift guard, same file
Walk every tracked
*.jsonfile plus every markdown front matter, feed each prose-key line through the hook, and assert exit 0. This is what stops the lines fixed in step 7 from coming back. It reds today against the unfixed tree, atpackage.json:5andpackages/ui/packages/registry/package.json:6.Copy drift guard, same file
Assert that
packages/cli/templates/.claude/hooks/block-prose-punctuation.shandexamples/blog/.claude/hooks/block-prose-punctuation.share byte-identical to each other, and that each contains all four newgrep -qEpatterns and zero remainingprintf ... | grep -qpipelines. Without this, step 6 can be half-applied and every scaffolded app ships a hook missing the rule.Counterfactuals
Three, each isolating one failure mode:
grep -qEblocks from steps 2 and 3. Every new blocking test flips from 2 to 0, while every allowed test stays green (they were already green, which is the point of listing them) and every markdown test stays green.printf ... |pipes back). The three large-payload tests red while every small-payload test stays green. This is the one that proves the SIGPIPE fix is load-bearing rather than cosmetic.Commands
No browser, e2e, or Bun layer applies: this is a repo-tooling shell script with no runtime-sensitive surface, so the AGENTS.md Bun-parity requirement does not attach. Say so explicitly in the PR body so the omission reads as a decision.
Docs
AGENTS.md:484, invariant 11. The clause "Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS" reads as a blanket JSON exemption and is now wrong for the three prose keys. Narrow it so it says JSON SYNTAX is code while adescription,title, ordisplayNameVALUE is prose and is scanned, in a JSON file or in YAML front matter alike. Keep the sentence in the same place and the same register. Suggested replacement for that clause: "Plain hyphens stay fine in compound words, flags, filenames, ranges, and semicolons and colons stay fine inside code / TS / CSS and inside JSON SYNTAX. A JSON or front-matterdescription,title, ordisplayNameVALUE is prose, not code, and is scanned like any other prose.".claude/hooks/block-prose-punctuation.shheader comment, covered by step 5, since the file documents its own contexts at L5 through L20.framework-dev.mddoes not mention this hook. The scaffold and dogfood rule copies (packages/cli/templates/.agents/rules/workflow.md,examples/blog/.agents/rules/workflow.md,examples/blog/.cursorrules:93,examples/blog/.github/copilot-instructions.md:90) state the rule generically with no JSON exemption, so they are already correct. No docs site, marketing website, README, or.agents/skills/webjs/surface applies: this is repo tooling, not framework behaviour, so thewebjs-doc-syncskill has nothing to sync here.Acceptance criteria
description,title, ordisplayNameJSON value is blocked, and the same prose in a markdown heading, blockquote, and comment line stays blockeddescription:ortitle:is blocked, and the same value INDENTED (a workflow input) is allowedenginesrange, ascriptscommand, acommandpath, aname, a config-block leaf, amainpath, raw SQL underdefault, and an ordinary English semicolon with no surrounding spacesdescription("An AI-first, web-components-first framework.") is still allowed*.jsonfile passes the hook, asserted by a test that reds against the unfixed treeprintf ... | grep -qpipelines, asserted by a testitem 10citations in the hook readitem 11AGENTS.md:484no longer claims a blanket JSON exemptionnode --test test/hooks/block-prose-punctuation.test.mjspasses, andnpm testis greenOut of scope
Do NOT widen into any of these:
summary,whenToUse,detail,label,message,text, andshort_nameare all measured at zero or one occurrence and are deliberately excluded. Adding one is a one-word edit the day a real surface appears.name, under any circumstances. 114 identifier values.file_pathgate, or a per-file allowlist. Rejected above, with reasons..claude/skills/use-railway/**carries em-dashes inSKILL.md, sixreferences/*.md, and fourscripts/*.py. Those are vendored third-party files, and rewriting their prose creates drift against upstream. After the SIGPIPE fix a full-file rewrite ofanalyze-postgres.py(147 KB) oranalyze-mysql.py(49 KB) will block on rule 1, which is correct behaviour on a file nobody rewrites wholesale. Leave them.packages/server/webjs-config.schema.json:4. Its"title": "webjs config block"line trips the brand rule today (verified exit 2), becauseconfigis not a CLI subcommand. That is a rule 5 problem, untouched by this change, and worth knowing before an unrelated edit to that file is blamed on this one.@paramcarve-out. Rejected above. Fix the 3 lines instead.item 10toitem 11citation fix in step 4.Landmines for the implementing agent
Bashcommand string (L45 reads.tool_input.command). A test command containing the lowercase brand followed by a non-subcommand word will block your own shell call. This happened while writing this issue: a verification command containing the literal string for the schema title line was refused by rule 5. Assemble the brand in the shell (B='web'"js") when a fixture needs it, exactly as the existing test file does in JavaScript.jq -Rs '{tool_input:{content:.}}'. A JSON value inside the payload is itself JSON-escaped by the time the hook sees it, so hand-writing an escaped payload is where a pattern looks right and does not fire in practice.webjs creategenerates. Edit all three. No drift guard exists until you write one.package.jsonline stopped tripping it. Same file, same class of over-match. Read that fix before widening anything.origin/main, perAGENTS.md. The primary checkout is hook-blocked for tracked-file edits.