Edit-time lint hook: sloplint on write via PostToolUse, advisory only

- scripts/lint-hook.mjs: reads the PostToolUse payload, lints .html/.css
  Hallmark artifacts on write, feeds FAILs back via additionalContext,
  never blocks (exit 0 always), no-ops on non-artifacts
- scripts/install-hook.mjs: idempotent settings merge (--global/--print/
  --remove), preserves other hooks and permissions
- SKILL.md Step 7 note + README section; Claude-Code-only, degrades to
  the Step 7 sweep everywhere else
This commit is contained in:
Youssef
2026-07-24 14:06:27 +01:00
parent 75bafe3188
commit 087dc859fd
4 changed files with 171 additions and 0 deletions
+12
View File
@@ -123,6 +123,18 @@ The `[1m]` suffix on GLM is load-bearing; dropping it silently shrinks the conte
---
## Edit-time linting <sup>NEW</sup>
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
```
+2
View File
@@ -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 <skill-dir>/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
+70
View File
@@ -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.`);
+87
View File
@@ -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 <style>/:root block). WARNs are
// excluded (only FAILs are worth interrupting for). Any internal error -> `{}`.
//
// Claude Code only; other harnesses simply never fire it (Step 7 still lints).
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const OK = (obj = {}) => { process.stdout.write(JSON.stringify(obj)); process.exit(0); };
function main() {
// --- read the hook payload from stdin ---
let raw = "";
try { raw = readFileSync(0, "utf8"); } catch { return OK(); }
let payload;
try { payload = JSON.parse(raw); } catch { return OK(); }
const filePath = payload?.tool_input?.file_path;
if (typeof filePath !== "string" || !filePath) return OK();
const lower = filePath.toLowerCase();
if (!lower.endsWith(".html") && !lower.endsWith(".css")) return OK();
// --- read the file from disk (already written; robust for Write/Edit/MultiEdit) ---
let src = "";
try { src = readFileSync(filePath, "utf8"); } catch { return OK(); }
if (src.length === 0) return OK();
// --- artifact sniff: only lint things Hallmark authored ---
const looksHallmark =
/\/\*\s*Hallmark/.test(src) || // the stamp
/:root\s*\{/.test(src) || // a token block
(lower.endsWith(".html") && /<style[\s>]/i.test(src)); // an inline-styled page
if (!looksHallmark) return OK();
// --- light genre / scope detection to cut false nags ---
const gm = src.match(/genre:\s*(editorial|modern-minimal|atmospheric|playful)/i);
const args = [filePath, "--json"];
if (gm) args.push("--genre", gm[1].toLowerCase());
if (/·\s*component:/i.test(src) || lower.endsWith(".preview.html")) args.push("--scope", "component");
// --- run sloplint (sibling script); swallow everything on failure ---
const sloplint = join(dirname(fileURLToPath(import.meta.url)), "sloplint.mjs");
let out = "";
try {
out = execFileSync(process.execPath, [sloplint, ...args], {
encoding: "utf8",
timeout: 4000,
stdio: ["ignore", "pipe", "ignore"],
});
} catch (e) {
// sloplint exits 1 when it finds a FAIL; its JSON is still on stdout.
out = e?.stdout?.toString?.() ?? "";
}
if (!out) return OK();
let report;
try { report = JSON.parse(out); } catch { return OK(); }
const fails = (report?.findings ?? []).filter((f) => f.grade === "FAIL");
if (fails.length === 0) return OK();
const name = filePath.split("/").pop();
const lines = fails.slice(0, 12).map((f) => `- gate ${f.gate} (line ${f.line}): ${f.evidence} -> ${f.fix}`);
const more = fails.length > 12 ? `\n (+${fails.length - 12} more)` : "";
const ctx =
`Hallmark lint on ${name}: ${fails.length} FAIL${fails.length === 1 ? "" : "s"} before this passes Step 7.\n` +
lines.join("\n") + more +
`\nFix these now while the context is small; run scripts/sloplint.mjs to re-check.`;
return OK({ additionalContext: ctx });
}
try { main(); } catch { OK(); }