mirror of
https://github.com/Nutlope/hallmark.git
synced 2026-08-14 12:35:33 +02:00
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:
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`);
|
||||
@@ -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(); }
|
||||
Reference in New Issue
Block a user