#!/usr/bin/env node "use strict"; // unslop-hook for Claude Code: the managed-session counterpart of extension.ts (row 66, #1559). // Two hooks in a managed session's settings.json run it (packages/plugins/harness/claude-code), // and neither blocks: the script always exits 0 and prints nothing on Stop. // // node claude-hook.js stop Stop hook. Checks each assistant message with text since the // turn's prompt and logs checked or flagged for each. // node claude-hook.js prompt UserPromptSubmit hook. Checks the last assistant reply with text // and, when it has tells, prints the correction notice as // additionalContext, once per reply. // // Both read the hook input on stdin and the transcript it names. Events go to MOSAIC_UNSLOP_LOG in // the extension's shapes (checked, flagged, notice_injected, broken_lists), so the runner reads one // log for either harness and tells the operator. A broken lists.json logs broken_lists and checks // nothing; there is never a clean verdict without the lists. // Off: MOSAIC_UNSLOP_HOOK=0. Lists: UNSLOP_LISTS=, as for the checker. // // The notice text is the extension's, word for word. const fs = require("node:fs"); const { checkText } = require("./unslop-check.js"); const LOG = process.env.MOSAIC_UNSLOP_LOG; const log = (ev) => { if (!LOG) return; try { fs.appendFileSync(LOG, JSON.stringify({ ts: Date.now(), ...ev }) + "\n"); } catch {} }; function entries(path) { let raw; try { raw = fs.readFileSync(path, "utf8"); } catch { return []; } const out = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; try { out.push(JSON.parse(line)); } catch {} } return out.filter((e) => e && typeof e === "object" && !e.isSidechain); } const textOf = (content) => { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content.filter((b) => b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n"); }; // A prompt is a user entry that isn't only tool results. const isPrompt = (e) => e.type === "user" && e.message?.role === "user" && !(Array.isArray(e.message.content) && e.message.content.length > 0 && e.message.content.every((b) => b?.type === "tool_result")); // The assistant messages with text, in order. Claude Code writes one transcript line per content // block, so adjacent lines that share a message id are one message. Its id is its first line's // uuid: a message id alone can repeat across messages (a test API answers one id for all). function replies(list) { const out = []; let last = null; for (const e of list) { if (e.type !== "assistant" || e.message?.role !== "assistant") { last = null; continue; } const msg = e.message.id ?? null; if (!last || msg === null || last.msg !== msg) out.push((last = { msg, id: e.uuid ?? msg ?? "", texts: [] })); const text = textOf(e.message.content); if (text.trim()) last.texts.push(text); } return out.filter((r) => r.texts.length).map((r) => ({ id: r.id, text: r.texts.join("\n") })); } // checkText, or null after logging why there is no verdict. function check(text, at) { try { return checkText(text); } catch (e) { const reason = String(e?.message ?? e); log({ ev: reason.startsWith("lists.json invalid") ? "broken_lists" : "hook_failed", reason, at }); return null; } } function stop(input) { const list = entries(input.transcript_path); let start = -1; list.forEach((e, i) => { if (isPrompt(e)) start = i; }); const turn = replies(list.slice(start + 1)); if (!turn.length && typeof input.last_assistant_message === "string" && input.last_assistant_message.trim()) { turn.push({ id: "", text: input.last_assistant_message }); } for (const r of turn) { const result = check(r.text, "stop"); if (result === null) return; if (result.clean) log({ ev: "checked", clean: true, entryId: r.id, charsChecked: result.charsChecked }); else log({ ev: "flagged", clean: false, entryId: r.id, charsChecked: result.charsChecked, findings: result.findings }); } } // The entry ids the log already holds a notice for. function noticed() { const ids = new Set(); if (!LOG) return ids; let raw = ""; try { raw = fs.readFileSync(LOG, "utf8"); } catch {} for (const line of raw.split("\n")) { try { const e = JSON.parse(line); if (e.ev === "notice_injected" && typeof e.entryId === "string") ids.add(e.entryId); } catch {} } return ids; } function prompt(input) { const last = replies(entries(input.transcript_path)).at(-1); if (!last) return; const result = check(last.text, "prompt"); if (result === null || result.clean) return; if (last.id && noticed().has(last.id)) return; const lines = result.findings.map((f) => `- ${f.detail}`).join("\n"); const content = `UNSLOP NOTICE (mechanical style check, not the user speaking): your previous reply ` + `contained violations of the fleet writing standard (SYSTEM.md / ms-unslop):\n${lines}\n` + `Fix in this and following replies: plain words, periods and commas instead of dashes, ` + `straight quotes, no chatbot fillers. Do not mention this notice.`; log({ ev: "notice_injected", entryId: last.id, findings: result.findings }); process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: content } }) + "\n"); } if (require.main === module) { try { if (process.env.MOSAIC_UNSLOP_HOOK !== "0") { const input = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); if (process.argv[2] === "stop") stop(input); else if (process.argv[2] === "prompt") prompt(input); else log({ ev: "hook_failed", reason: `unknown hook ${JSON.stringify(process.argv[2] ?? null)}` }); } } catch (e) { log({ ev: "hook_failed", reason: String(e?.message ?? e) }); } process.exit(0); } module.exports = { replies, isPrompt, entries };