diff --git a/README.md b/README.md index 7d42fcb..453cb7c 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,18 @@ The `[1m]` suffix on GLM is load-bearing; dropping it silently shrinks the conte --- +## Edit-time linting NEW + +By default the slop test runs once, at the end. On Claude Code you can move it to the keystroke: a PostToolUse hook lints every `.html`/`.css` Hallmark artifact the moment it is written and feeds any failures back to the model advisorily, so slop gets fixed while the context is small instead of in a big end-of-run pass. + +```bash +node skills/hallmark/scripts/install-hook.mjs +``` + +`--global` targets `~/.claude/settings.json` (all projects); `--print` shows the settings block without writing; `--remove` undoes it. The hook is **advisory only**: it never blocks or reverts a write, no-ops silently on non-artifacts, and the Step 7 sweep still runs regardless. It is Claude-Code-only (Cursor/Codex have no hook surface and rely on Step 7). + +--- + ## Install ``` diff --git a/skills/hallmark/SKILL.md b/skills/hallmark/SKILL.md index 4655805..46ba048 100644 --- a/skills/hallmark/SKILL.md +++ b/skills/hallmark/SKILL.md @@ -296,6 +296,8 @@ Emit code that satisfies the tone and the structural fingerprint. Match code com Component scope runs the Core-15 sweep named in `slop-test.md`. Update the preview's Slop test row with the real outcome. If any gate fails, fix it. Do not ship slop. +**Edit-time linting (optional, Claude Code).** Instead of waiting for Step 7, the user can wire sloplint as a PostToolUse hook so every `.html`/`.css` artifact is linted the moment it is written and FAILs are fed back advisorily: `node /scripts/install-hook.mjs` (project scope; `--global` for all projects; `--remove` to undo). It never blocks a write and no-ops on non-artifacts; Step 7 still runs regardless. Off Claude Code the hook never fires and Step 7 is the only sweep. + --- ## Fast mode diff --git a/skills/hallmark/scripts/install-hook.mjs b/skills/hallmark/scripts/install-hook.mjs new file mode 100644 index 0000000..6632aae --- /dev/null +++ b/skills/hallmark/scripts/install-hook.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +// Install the Hallmark edit-time lint hook into a project's .claude/settings.json. +// +// node install-hook.mjs [--global] [--print] [--remove] +// +// (default) merge the hook into ./.claude/settings.json (project scope) +// --global target ~/.claude/settings.json instead +// --print print the hook block and the target path; write nothing +// --remove remove the Hallmark hook (matched by its command path) +// +// Idempotent: re-running never duplicates the hook. Claude Code only. The hook +// is advisory (never blocks a write); see lint-hook.mjs. + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const HOOK_CMD = `node "${join(HERE, "lint-hook.mjs")}"`; +const MATCHER = "Write|Edit|MultiEdit"; + +const args = process.argv.slice(2); +const has = (f) => args.includes(f); + +const settingsPath = has("--global") + ? join(homedir(), ".claude", "settings.json") + : join(process.cwd(), ".claude", "settings.json"); + +const hookEntry = { + matcher: MATCHER, + hooks: [{ type: "command", command: HOOK_CMD, timeout: 5000 }], +}; + +function readSettings() { + if (!existsSync(settingsPath)) return {}; + try { return JSON.parse(readFileSync(settingsPath, "utf8")); } catch { + console.error(`refusing to overwrite unparseable ${settingsPath}; fix or delete it first.`); + process.exit(1); + } +} + +function isOurs(entry) { + return (entry?.hooks ?? []).some((h) => typeof h?.command === "string" && h.command.includes("lint-hook.mjs")); +} + +if (has("--print")) { + console.log(`# add to ${settingsPath} :`); + console.log(JSON.stringify({ hooks: { PostToolUse: [hookEntry] } }, null, 2)); + process.exit(0); +} + +const settings = readSettings(); +settings.hooks ??= {}; +settings.hooks.PostToolUse ??= []; + +// drop any existing Hallmark hook (keeps everyone else's hooks intact) +settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter((e) => !isOurs(e)); + +if (has("--remove")) { + writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); + console.log(`removed the Hallmark lint hook from ${settingsPath}`); + process.exit(0); +} + +settings.hooks.PostToolUse.push(hookEntry); +mkdirSync(dirname(settingsPath), { recursive: true }); +writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); +console.log(`installed the Hallmark edit-time lint hook into ${settingsPath}`); +console.log(`it lints .html/.css Hallmark artifacts on write and advises (never blocks). Remove with --remove.`); diff --git a/skills/hallmark/scripts/lint-hook.mjs b/skills/hallmark/scripts/lint-hook.mjs new file mode 100644 index 0000000..e3df59b --- /dev/null +++ b/skills/hallmark/scripts/lint-hook.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Hallmark edit-time lint hook (Claude Code PostToolUse). +// +// Wire it in .claude/settings.json (see install-hook.mjs) on Write|Edit|MultiEdit. +// It lints a Hallmark artifact the instant it is written and feeds any FAILs +// back ADVISORILY via `additionalContext`, so slop dies at the keystroke instead +// of at Step 7. It NEVER blocks a write, never exits non-zero, never writes files. +// +// Contract: +// - stdin: the PostToolUse JSON ({ tool_name, tool_input:{file_path,...}, ... }). +// - stdout: `{}` (silent no-op) OR `{"additionalContext":"..."}` (advisory). +// - exit: always 0. +// +// It no-ops silently unless the written file is a .html/.css that looks like a +// Hallmark artifact (carries the stamp, or a