Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .claude/hooks/nudge-uncommitted.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/bin/bash
#
# Claude Code PostToolUse hook.
#
# After each Edit, Write, MultiEdit, or NotebookEdit, counts
# uncommitted changes in the working tree. When the count
# crosses a threshold (default 4, override with the
# WEBJS_COMMIT_NUDGE_THRESHOLD env var), injects a reminder
# into the model's context via hookSpecificOutput.
#
# Soft nudge. Does NOT block the edit. The goal is to keep
# the agent honest about the "commit per logical unit" rule,
# not to interrupt valid work.
#
# Skipped on main/master and outside a git work tree.

set -e

THRESHOLD="${WEBJS_COMMIT_NUDGE_THRESHOLD:-4}"

if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
exit 0
fi

BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
exit 0
fi

# Read stdin so we don't break Claude Code's hook contract.
cat /dev/stdin >/dev/null 2>&1 || true

CHANGED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')

if [ -z "$CHANGED" ] || [ "$CHANGED" -lt "$THRESHOLD" ]; then
exit 0
fi

REASON="You have ${CHANGED} uncommitted changes on '${BRANCH}'. The webjs convention is small, focused commits per logical unit (one feature, one fix, one rename, one doc rewrite). Before continuing with more edits, group the current changes into a meaningful commit. See AGENTS.md \"Git workflow\" for the rule and the rationale. To raise the threshold for this hook in long-running tasks, set WEBJS_COMMIT_NUDGE_THRESHOLD."

jq -n --arg ctx "$REASON" '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: $ctx
}
}'
11 changes: 11 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/nudge-uncommitted.sh"
}
]
}
]
}
}
18 changes: 15 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,22 @@ bypass/autonomous mode).

1. **Create a feature branch first.** Before any code change:
`git checkout -b feature/<task-slug>`. Never edit directly on main.
2. **On the feature branch: commit and push freely.** No prompts and
no approval needed.
2. **Commit per logical unit, not at the end.** A logical unit is
one feature, one fix, one rename, one doc rewrite. As soon as a
unit is complete (tests pass, the change makes sense in
isolation), commit it. Do not pile multiple logical changes into
one commit. If you find yourself with 5+ unstaged files spanning
different concerns, you already waited too long. Push after each
commit so the remote stays in sync. Scaffolded apps ship hook
coverage for Claude Code (`PostToolUse`), Gemini CLI
(`AfterTool`), and Cursor 1.7+ (`afterFileEdit`), all firing at
threshold 4. Other agents (Windsurf, Copilot, OpenCode,
Antigravity) fall back to the text rules in this file and
`.cursorrules` / `.windsurfrules` / `copilot-instructions.md`.
The framework repo itself uses the Claude Code hook only.
3. **Meaningful commit messages.** Describe what changed and why. Imperative
mood, under 72 chars on the first line.
mood, under 72 chars on the first line. Body explains the reason,
not the diff (the diff is right there).
4. **No AI attribution in commits.** NEVER add `Co-Authored-By: Claude`,
`Generated by AI`, `AI-assisted`, or any similar trailer or prefix.
5. **Pull requests via the GitHub CLI, always.** Create a PR for every
Expand Down
19 changes: 17 additions & 2 deletions packages/cli/lib/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,15 @@ export async function scaffoldApp(name, cwd, opts = {}) {
// Claude Code config + hooks
'.claude.json',
'.claude/settings.json',
'.claude/hooks/guard-main-merge.sh',
'.claude/hooks/block-prose-punctuation.sh',
'.claude/hooks/guard-branch-context.sh',
'.claude/hooks/nudge-uncommitted.sh',
// Gemini CLI config + hooks
'.gemini/settings.json',
'.gemini/hooks/nudge-uncommitted.sh',
// Cursor config + hooks
'.cursor/hooks.json',
'.cursor/hooks/nudge-uncommitted.sh',
// Cross-agent config files
'.cursorrules',
'.windsurfrules',
Expand All @@ -287,10 +294,18 @@ export async function scaffoldApp(name, cwd, opts = {}) {

// Make hook scripts executable
const { chmod } = await import('node:fs/promises');
for (const hook of ['guard-main-merge.sh', 'guard-branch-context.sh']) {
for (const hook of ['block-prose-punctuation.sh', 'guard-branch-context.sh', 'nudge-uncommitted.sh']) {
const hookPath = join(appDir, '.claude', 'hooks', hook);
if (existsSync(hookPath)) await chmod(hookPath, 0o755);
}
for (const hook of ['nudge-uncommitted.sh']) {
const hookPath = join(appDir, '.gemini', 'hooks', hook);
if (existsSync(hookPath)) await chmod(hookPath, 0o755);
}
for (const hook of ['nudge-uncommitted.sh']) {
const hookPath = join(appDir, '.cursor', 'hooks', hook);
if (existsSync(hookPath)) await chmod(hookPath, 0o755);
}
// Make git pre-commit hook executable
const preCommitPath = join(appDir, '.hooks', 'pre-commit');
if (existsSync(preCommitPath)) await chmod(preCommitPath, 0o755);
Expand Down
236 changes: 236 additions & 0 deletions packages/cli/templates/.claude/hooks/block-prose-punctuation.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#!/usr/bin/env bash
#
# PreToolUse hook: block prose-punctuation patterns the webjs convention bans.
#
# Catches four classes of new content in tool calls:
#
# 1. U+2014 em-dash, anywhere.
# 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown
# lines, headings, blockquotes). Math expressions in code like
# `Math.abs(a - b)` or `arr.length - 1` are NOT flagged.
# 3. Space-semicolon-space " ; " in PROSE contexts. JS / CSS statement
# terminators (`;\n`) are NOT flagged.
# 4. Code-shaped left-hand side immediately followed by a colon and prose:
# - `<code>foo()</code>:` (markdown code-LHS in docs)
# - `<my-tag>:` (custom-element tag with hyphen)
# - Inline comment `// foo(): description`
#
# Why this exists: see AGENTS.md "Invariants", item 10. These patterns
# confuse AI agents that try to parse the prose as TypeScript / shorthand-
# method / object-literal syntax, and trip humans reading API docs.
#
# Covers two tool-call paths:
# * Write / Edit / MultiEdit / NotebookEdit. The hook inspects the NEW
# content fields of the tool payload. Existing glyphs in old_string
# are not flagged: you can still Edit a line that contains one to
# remove it.
# * Bash. The hook inspects the command string, which catches commit
# messages (`git commit -m "..."`), heredocs, echo / printf, and any
# other prose typed at the shell.

set -euo pipefail

payload=$(cat)

# Pull every field where prose might land. `// empty` keeps missing
# fields silent; `[]?` keeps array iteration safe when absent.
new_content=$(printf '%s' "$payload" | jq -r '
(.tool_input.content // empty),
(.tool_input.new_string // empty),
(.tool_input.new_source // empty),
(.tool_input.command // empty),
(.tool_input.edits[]?.new_string // empty)
' 2>/dev/null || true)

if [ -z "$new_content" ]; then
exit 0
fi

# --- 1. U+2014 em-dash --------------------------------------------------
if printf '%s' "$new_content" | grep -q $'\xe2\x80\x94'; then
cat >&2 <<'EOF'
BLOCKED: em-dash (U+2014) detected in this tool call.

webjs bans em-dashes repo-wide. Replace every U+2014 character with
a period, comma, colon (on a plain-noun LHS), parentheses, or
restructured sentence. Do NOT replace it with " - " or " ; " or a
trailing colon on code: those are also banned. See rule 2 / 3 / 4
below for the alternatives.

Rule: AGENTS.md, Invariants section, item 10.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
fi

# --- 2. Pause-hyphen " - " in PROSE contexts ----------------------------
# Only flag lines whose context is clearly prose:
# - Markdown lines starting with `#`, `>`, `*`, plain text outside code
# fences (heuristic: line has no `=`, `{`, or `(...)` math)
# - JSDoc / block comment lines starting with `*`
# - Single-line comments starting with `//`
#
# Math expressions like `Math.abs(a - b)` or `arr.length - 1` are NOT
# flagged because they appear in code lines (not comments) with code
# context. The hook trades some false negatives in prose for zero false
# positives in code-heavy diffs.

block_pause_hyphen=0

# Comment-line " - " pause: line starts with `//` or ` *` (JSDoc/block) or
# `*` (markdown bold-start would have a letter after, distinguishable),
# followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
# like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
block_pause_hyphen=1
fi

# Markdown heading " - " pause: line starts with `#` followed by prose
# and ` - ` pattern.
if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
block_pause_hyphen=1
fi

# Markdown blockquote " - " pause: line starts with `>` followed by prose
# and ` - ` pattern. (Single `>` blockquote, not table.)
if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
block_pause_hyphen=1
fi

# HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
# closing HTML tag from a prose context, then prose-style ` - `.
if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
block_pause_hyphen=1
fi

if [ "$block_pause_hyphen" = "1" ]; then
cat >&2 <<'EOF'
BLOCKED: pause-hyphen " - " detected in a prose context.

webjs bans plain hyphens used as pause-punctuation in prose. Rewrite
the sentence with a period, comma, colon (on a plain-noun LHS), or
restructured phrasing.

Bad: // Foo - bar
Good: // Foo, with bar
Good: // Foo. Bar.

Bad: <li>Foo - bar.</li>
Good: <li>Foo, with bar.</li>

Plain hyphens are still fine in compound words (`AI-first`), CLI
flags (`--http2`), filenames, ranges, and math expressions in code
(`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
` < word > - < word > ` pause-pattern in prose contexts (comments,
markdown headings, blockquotes, HTML prose tags).

Rule: AGENTS.md, Invariants section, item 10.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
fi

# --- 3. Pause-semicolon " ; " in PROSE contexts -------------------------
# Same prose-context guard as #2.
block_pause_semicolon=0

if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
block_pause_semicolon=1
fi

if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
block_pause_semicolon=1
fi

if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
block_pause_semicolon=1
fi

if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
block_pause_semicolon=1
fi

if [ "$block_pause_semicolon" = "1" ]; then
cat >&2 <<'EOF'
BLOCKED: pause-semicolon " ; " detected in a prose context.

webjs bans semicolons used as pause-punctuation in prose. Rewrite as
two sentences (period) or with a conjunction (", and", ", but", ", so").

Bad: // Forms work ; links work too.
Good: // Forms work. Links work too.
Good: // Forms work, and links work too.

Semicolons stay fine inside code (JS statement terminators, CSS
declarations) since those are not flagged.

Rule: AGENTS.md, Invariants section, item 10.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
fi

# --- 4a. <code>foo()</code>: prose ---------------------------------------
# Markdown / HTML definition list with code-call followed by colon and
# lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
# not code, AND the inner code ends in `()` so the colon visually parses
# as a return-type annotation.
if printf '%s' "$new_content" | grep -qE '\)</code>:[[:space:]][a-z]'; then
cat >&2 <<'EOF'
BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").

webjs bans `<code>foo()</code>: <prose>` because the colon visually
parses as a TypeScript return-type annotation. Rewrite verb-led.

Bad: <code>repeat()</code>: keyed list directive
Good: <code>repeat()</code> is the keyed list directive
Good: <code>startServer()</code> creates an HTTP(S) server

Rule: AGENTS.md, Invariants section, item 10.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
fi

# --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
# HTML reserves hyphenated tag names for custom elements (W3C spec), so
# `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
if printf '%s' "$new_content" | grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]'; then
cat >&2 <<'EOF'
BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").

webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.

Bad: // <ui-dialog>: owns open state, focus trap, escape, scroll lock.
Good: // <ui-dialog> owns open state, focus trap, escape, scroll lock.
Bad: // <ui-dialog-content>: the centered panel.
Good: // <ui-dialog-content> is the centered panel.

Rule: AGENTS.md, Invariants section, item 10.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
fi

# --- 4c. Inline / JSDoc comment "foo(): prose" --------------------------
# Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
# lowercase prose. Avoids TS return-type annotations because those never
# appear inside comment lines.
if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]'; then
cat >&2 <<'EOF'
BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").

webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.

Bad: // firstUpdated(): once, on the first render only
Good: // firstUpdated() runs once, on the first render only
Bad: // closest(): null if the click wasn't inside a frame
Good: // closest() returns null when the click wasn't inside a frame

Rule: AGENTS.md, Invariants section, item 10.
Hook: .claude/hooks/block-prose-punctuation.sh.
EOF
exit 2
fi

exit 0
Loading